mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
fix(workflow): isolate run records while preserving selected tabs (#43263)
This commit is contained in:
2 files changed
+305
-44
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import type { WorkflowRunDetailResponse } from '@/models/log'
|
||||
import type { NodeTracing, NodeTracingListResponse } from '@/types/workflow'
|
||||
import { screen, waitFor, within } from '@testing-library/react'
|
||||
import { act, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import { BlockEnum, NodeRunningStatus } from '../../types'
|
||||
@@ -246,4 +246,198 @@ describe('RunPanel', () => {
|
||||
expect(mockFetchRunDetail).toHaveBeenCalledTimes(activeTab === 'RESULT' ? 2 : 1)
|
||||
},
|
||||
)
|
||||
it('keeps the new record and selected tab when an old detail response arrives late', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleResult = vi.fn()
|
||||
let resolveOldResponse: (detail: WorkflowRunDetailResponse) => void = () => {}
|
||||
mockFetchRunDetail
|
||||
.mockReturnValueOnce(
|
||||
new Promise<WorkflowRunDetailResponse>((resolve) => {
|
||||
resolveOldResponse = resolve
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(createRunDetail({ id: 'new-run', outputs: 'New result' }))
|
||||
const { rerender } = renderWorkflowComponent(
|
||||
<RunPanel
|
||||
runDetailUrl="/runs/old-run"
|
||||
tracingListUrl="/runs/old-run/tracing"
|
||||
getResultCallback={handleResult}
|
||||
/>,
|
||||
)
|
||||
await user.click(screen.getByRole('tab', { name: 'runLog.tracing' }))
|
||||
rerender(
|
||||
<RunPanel
|
||||
runDetailUrl="/runs/new-run"
|
||||
tracingListUrl="/runs/new-run/tracing"
|
||||
getResultCallback={handleResult}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('tab', { name: 'runLog.tracing' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
)
|
||||
await screen.findByText('Trace Node')
|
||||
await act(async () => {
|
||||
resolveOldResponse(createRunDetail({ id: 'old-run', outputs: 'Old result' }))
|
||||
})
|
||||
expect(handleResult).toHaveBeenCalledTimes(1)
|
||||
expect(handleResult).toHaveBeenCalledWith(expect.objectContaining({ id: 'new-run' }))
|
||||
await user.click(screen.getByRole('tab', { name: 'runLog.result' }))
|
||||
expect(((await screen.findByTestId('monaco-editor')) as HTMLTextAreaElement).value).toContain(
|
||||
'New result',
|
||||
)
|
||||
})
|
||||
|
||||
it('clears the previous result while the destination record is loading and ignores old failures', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = renderWorkflowComponent(
|
||||
<RunPanel runDetailUrl="/runs/first" tracingListUrl="/runs/first/tracing" />,
|
||||
)
|
||||
await screen.findByTestId('monaco-editor')
|
||||
let rejectRefresh: (error: Error) => void = () => {}
|
||||
mockFetchRunDetail
|
||||
.mockReturnValueOnce(
|
||||
new Promise<WorkflowRunDetailResponse>((_, reject) => {
|
||||
rejectRefresh = reject
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(createRunDetail({ id: 'next', outputs: 'Next result' }))
|
||||
await user.click(screen.getByRole('tab', { name: 'runLog.result' }))
|
||||
rerender(<RunPanel runDetailUrl="/runs/next" tracingListUrl="/runs/next/tracing" />)
|
||||
expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument()
|
||||
await screen.findByTestId('monaco-editor')
|
||||
await act(async () => {
|
||||
rejectRefresh(new Error('Old refresh failed'))
|
||||
})
|
||||
expect(mockToastError).not.toHaveBeenCalled()
|
||||
expect((screen.getByTestId('monaco-editor') as HTMLTextAreaElement).value).toContain(
|
||||
'Next result',
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores tracing that completes after its record has been replaced', async () => {
|
||||
let resolveOldTrace: (value: NodeTracingListResponse) => void = () => {}
|
||||
mockFetchTracingList
|
||||
.mockReturnValueOnce(
|
||||
new Promise<NodeTracingListResponse>((resolve) => {
|
||||
resolveOldTrace = resolve
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue({ data: [createTracingNode({ title: 'New trace' })] })
|
||||
const { rerender } = renderWorkflowComponent(
|
||||
<RunPanel activeTab="TRACING" runDetailUrl="/runs/old" tracingListUrl="/runs/old/tracing" />,
|
||||
)
|
||||
await waitFor(() => expect(mockFetchTracingList).toHaveBeenCalledTimes(1))
|
||||
rerender(
|
||||
<RunPanel activeTab="TRACING" runDetailUrl="/runs/new" tracingListUrl="/runs/new/tracing" />,
|
||||
)
|
||||
await screen.findByText('New trace')
|
||||
await act(async () => {
|
||||
resolveOldTrace({ data: [createTracingNode({ title: 'Old trace' })] })
|
||||
})
|
||||
expect(screen.getByText('New trace')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Old trace')).not.toBeInTheDocument()
|
||||
})
|
||||
it('does not restart the record request when its result callback changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const firstCallback = vi.fn()
|
||||
const nextCallback = vi.fn()
|
||||
const { rerender } = renderWorkflowComponent(
|
||||
<RunPanel
|
||||
runDetailUrl="/runs/record"
|
||||
tracingListUrl="/runs/record/tracing"
|
||||
getResultCallback={firstCallback}
|
||||
/>,
|
||||
)
|
||||
await screen.findByTestId('monaco-editor')
|
||||
rerender(
|
||||
<RunPanel
|
||||
runDetailUrl="/runs/record"
|
||||
tracingListUrl="/runs/record/tracing"
|
||||
getResultCallback={nextCallback}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
|
||||
expect(mockFetchRunDetail).toHaveBeenCalledTimes(1)
|
||||
await user.click(screen.getByRole('tab', { name: 'runLog.result' }))
|
||||
await waitFor(() => expect(nextCallback).toHaveBeenCalledTimes(1))
|
||||
expect(firstCallback).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('keeps the latest refresh when requests for the same record resolve out of order', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWorkflowComponent(
|
||||
<RunPanel runDetailUrl="/runs/current" tracingListUrl="/runs/current/tracing" />,
|
||||
)
|
||||
await screen.findByTestId('monaco-editor')
|
||||
let resolveEarlierRefresh: (detail: WorkflowRunDetailResponse) => void = () => {}
|
||||
mockFetchRunDetail
|
||||
.mockReturnValueOnce(
|
||||
new Promise<WorkflowRunDetailResponse>((resolve) => {
|
||||
resolveEarlierRefresh = resolve
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(createRunDetail({ outputs: 'Latest result' }))
|
||||
await user.click(screen.getByRole('tab', { name: 'runLog.result' }))
|
||||
await user.click(screen.getByRole('tab', { name: 'runLog.result' }))
|
||||
await waitFor(() =>
|
||||
expect((screen.getByTestId('monaco-editor') as HTMLTextAreaElement).value).toContain(
|
||||
'Latest result',
|
||||
),
|
||||
)
|
||||
await act(async () => {
|
||||
resolveEarlierRefresh(createRunDetail({ outputs: 'Outdated result' }))
|
||||
})
|
||||
expect((screen.getByTestId('monaco-editor') as HTMLTextAreaElement).value).toContain(
|
||||
'Latest result',
|
||||
)
|
||||
})
|
||||
it('keeps the latest tracing refresh and suppresses an older failed detail refresh', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWorkflowComponent(
|
||||
<RunPanel runDetailUrl="/runs/current" tracingListUrl="/runs/current/tracing" />,
|
||||
)
|
||||
await screen.findByTestId('monaco-editor')
|
||||
let rejectEarlierDetail: (error: Error) => void = () => {}
|
||||
let resolveEarlierTrace: (value: NodeTracingListResponse) => void = () => {}
|
||||
mockFetchRunDetail.mockReturnValueOnce(
|
||||
new Promise<WorkflowRunDetailResponse>((_, reject) => {
|
||||
rejectEarlierDetail = reject
|
||||
}),
|
||||
)
|
||||
mockFetchTracingList
|
||||
.mockReturnValueOnce(
|
||||
new Promise<NodeTracingListResponse>((resolve) => {
|
||||
resolveEarlierTrace = resolve
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue({ data: [createTracingNode({ title: 'Latest trace' })] })
|
||||
await user.click(screen.getByRole('tab', { name: 'runLog.result' }))
|
||||
await user.click(screen.getByRole('tab', { name: 'runLog.result' }))
|
||||
await waitFor(() => expect(mockFetchRunDetail).toHaveBeenCalledTimes(3))
|
||||
await act(async () => {
|
||||
rejectEarlierDetail(new Error('Outdated failure'))
|
||||
resolveEarlierTrace({ data: [createTracingNode({ title: 'Outdated trace' })] })
|
||||
})
|
||||
mockFetchTracingList.mockReturnValue(new Promise(() => {}))
|
||||
await user.click(screen.getByRole('tab', { name: 'runLog.tracing' }))
|
||||
expect(screen.getByText('Latest trace')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Outdated trace')).not.toBeInTheDocument()
|
||||
expect(mockToastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('finishes loading from a newer refresh without waiting for the initial request', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockFetchRunDetail
|
||||
.mockReturnValueOnce(new Promise(() => {}))
|
||||
.mockResolvedValue(createRunDetail({ outputs: 'Refreshed result' }))
|
||||
renderWorkflowComponent(
|
||||
<RunPanel runDetailUrl="/runs/current" tracingListUrl="/runs/current/tracing" />,
|
||||
)
|
||||
expect(screen.getByRole('progressbar')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('tab', { name: 'runLog.result' }))
|
||||
expect(((await screen.findByTestId('monaco-editor')) as HTMLTextAreaElement).value).toContain(
|
||||
'Refreshed result',
|
||||
)
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import type { FC } from 'react'
|
||||
import type { WorkflowRunDetailResponse } from '@/models/log'
|
||||
import type { NodeTracing } from '@/types/workflow'
|
||||
import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
@@ -23,19 +23,63 @@ type RunProps = {
|
||||
tracingListUrl: string
|
||||
}
|
||||
|
||||
type RunTab = NonNullable<RunProps['activeTab']>
|
||||
type RequestLifetime = {
|
||||
active: boolean
|
||||
detailRequest: number
|
||||
tracingRequest: number
|
||||
detailSettled: boolean
|
||||
tracingSettled: boolean
|
||||
}
|
||||
|
||||
const RunPanel: FC<RunProps> = ({
|
||||
hideResult,
|
||||
activeTab = 'RESULT',
|
||||
hideResult,
|
||||
getResultCallback,
|
||||
runDetailUrl,
|
||||
tracingListUrl,
|
||||
}) => {
|
||||
const [currentTab, setCurrentTab] = useState<RunTab>(activeTab)
|
||||
const isListening = useStore((s) => s.isListening)
|
||||
|
||||
useEffect(() => {
|
||||
if (isListening) setCurrentTab('DETAIL')
|
||||
}, [isListening])
|
||||
|
||||
return (
|
||||
<RunSession
|
||||
key={JSON.stringify([runDetailUrl, tracingListUrl])}
|
||||
hideResult={hideResult}
|
||||
getResultCallback={getResultCallback}
|
||||
runDetailUrl={runDetailUrl}
|
||||
tracingListUrl={tracingListUrl}
|
||||
currentTab={currentTab}
|
||||
onTabChange={setCurrentTab}
|
||||
isListening={isListening}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type RunSessionProps = Omit<RunProps, 'activeTab'> & {
|
||||
currentTab: RunTab
|
||||
onTabChange: (tab: RunTab) => void
|
||||
isListening: boolean
|
||||
}
|
||||
|
||||
function RunSession({
|
||||
hideResult,
|
||||
getResultCallback,
|
||||
runDetailUrl,
|
||||
tracingListUrl,
|
||||
currentTab,
|
||||
onTabChange,
|
||||
isListening,
|
||||
}: RunSessionProps) {
|
||||
const { t } = useTranslation(['runLog'])
|
||||
const [currentTab, setCurrentTab] = useState<string>(activeTab)
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [runDetail, setRunDetail] = useState<WorkflowRunDetailResponse>()
|
||||
const [list, setList] = useState<NodeTracing[]>([])
|
||||
const isListening = useStore((s) => s.isListening)
|
||||
const requestLifetimeRef = useRef<RequestLifetime | null>(null)
|
||||
|
||||
const executor = useMemo(() => {
|
||||
if (runDetail?.created_by_role === 'account') return runDetail.created_by_account?.name || ''
|
||||
@@ -44,49 +88,72 @@ const RunPanel: FC<RunProps> = ({
|
||||
return 'N/A'
|
||||
}, [runDetail])
|
||||
|
||||
const getResult = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetchRunDetail(runDetailUrl)
|
||||
setRunDetail(res)
|
||||
if (getResultCallback) getResultCallback(res)
|
||||
} catch (err) {
|
||||
toast.error(`${err}`)
|
||||
}
|
||||
}, [getResultCallback, runDetailUrl])
|
||||
const getResult = useCallback(
|
||||
async (lifetime: RequestLifetime) => {
|
||||
const request = ++lifetime.detailRequest
|
||||
try {
|
||||
const res = await fetchRunDetail(runDetailUrl)
|
||||
if (!lifetime.active || request !== lifetime.detailRequest) return
|
||||
setRunDetail(res)
|
||||
if (getResultCallback) getResultCallback(res)
|
||||
} catch (err) {
|
||||
if (lifetime.active && request === lifetime.detailRequest) toast.error(`${err}`)
|
||||
} finally {
|
||||
if (lifetime.active && request === lifetime.detailRequest) {
|
||||
lifetime.detailSettled = true
|
||||
if (lifetime.tracingSettled) setLoading(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[getResultCallback, runDetailUrl],
|
||||
)
|
||||
|
||||
const getTracingList = useCallback(async () => {
|
||||
try {
|
||||
const { data: nodeList } = await fetchTracingList({
|
||||
url: tracingListUrl,
|
||||
})
|
||||
setList(nodeList)
|
||||
} catch (err) {
|
||||
toast.error(`${err}`)
|
||||
}
|
||||
}, [tracingListUrl])
|
||||
const getTracingList = useCallback(
|
||||
async (lifetime: RequestLifetime) => {
|
||||
const request = ++lifetime.tracingRequest
|
||||
try {
|
||||
const { data: nodeList } = await fetchTracingList({
|
||||
url: tracingListUrl,
|
||||
})
|
||||
if (lifetime.active && request === lifetime.tracingRequest) setList(nodeList)
|
||||
} catch (err) {
|
||||
if (lifetime.active && request === lifetime.tracingRequest) toast.error(`${err}`)
|
||||
} finally {
|
||||
if (lifetime.active && request === lifetime.tracingRequest) {
|
||||
lifetime.tracingSettled = true
|
||||
if (lifetime.detailSettled) setLoading(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[tracingListUrl],
|
||||
)
|
||||
|
||||
const getData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
await getResult()
|
||||
await getTracingList()
|
||||
setLoading(false)
|
||||
}, [getResult, getTracingList])
|
||||
|
||||
const switchTab = async (tab: string) => {
|
||||
setCurrentTab(tab)
|
||||
if (tab === 'RESULT') {
|
||||
if (runDetailUrl) await getResult()
|
||||
}
|
||||
if (tracingListUrl) await getTracingList()
|
||||
const switchTab = (tab: RunTab) => {
|
||||
onTabChange(tab)
|
||||
const lifetime = requestLifetimeRef.current
|
||||
if (!lifetime?.active) return
|
||||
if (tab === 'RESULT' && runDetailUrl) void getResult(lifetime)
|
||||
if (tracingListUrl) void getTracingList(lifetime)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isListening) setCurrentTab('DETAIL')
|
||||
}, [isListening])
|
||||
const loadRecord = useEffectEvent((lifetime: RequestLifetime) => {
|
||||
void getResult(lifetime)
|
||||
void getTracingList(lifetime)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// fetch data
|
||||
if (runDetailUrl && tracingListUrl) getData()
|
||||
const lifetime: RequestLifetime = {
|
||||
active: true,
|
||||
detailRequest: 0,
|
||||
tracingRequest: 0,
|
||||
detailSettled: false,
|
||||
tracingSettled: false,
|
||||
}
|
||||
requestLifetimeRef.current = lifetime
|
||||
if (runDetailUrl && tracingListUrl) loadRecord(lifetime)
|
||||
return () => {
|
||||
lifetime.active = false
|
||||
}
|
||||
}, [runDetailUrl, tracingListUrl])
|
||||
|
||||
const [height, setHeight] = useState(0)
|
||||
@@ -104,7 +171,7 @@ const RunPanel: FC<RunProps> = ({
|
||||
<Tabs
|
||||
className="relative flex grow flex-col"
|
||||
value={currentTab}
|
||||
onValueChange={(value) => switchTab(value as string)}
|
||||
onValueChange={(value) => switchTab(value as RunTab)}
|
||||
>
|
||||
{/* tab */}
|
||||
<TabsList className="shrink-0 items-center gap-6 border-b-[0.5px] border-divider-subtle px-4">
|
||||
|
||||
Reference in new issue
Block a user