import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useRef, useState } from 'react' import { cancelRun, getRun, getRunLogs, listTaskRuns, runLogDownloadUrl, type ExecutionEvent, type RunStatus, type RunTrigger, type Task, type TaskRun, } from '../api/client' import { formatDateTime, formatDuration, isTerminalRunStatus, } from '../lib/format' import { useFocusTrap } from '../lib/useFocusTrap' import { RunStatusBadge } from './StatusBadge' interface RunLogDrawerProps { open: boolean task: Task | null initialRunId?: number | null onClose: () => void } export function RunLogDrawer({ open, task, initialRunId, onClose, }: RunLogDrawerProps) { const queryClient = useQueryClient() const [selectedRunId, setSelectedRunId] = useState( initialRunId ?? null, ) const [historyStatus, setHistoryStatus] = useState('') const [historyTrigger, setHistoryTrigger] = useState('') const [sseConnected, setSseConnected] = useState(false) const [logEvents] = useState(() => new EventTarget()) const drawerRef = useRef(null) useFocusTrap(open, drawerRef) const runsQuery = useQuery({ queryKey: [ 'task-runs', task?.id, historyStatus, historyTrigger, ], queryFn: ({ signal }) => listTaskRuns( task!.id, { page: 1, page_size: 50, status: historyStatus || undefined, trigger: historyTrigger || undefined, }, signal, ), enabled: open && Boolean(task), refetchInterval: (query) => query.state.data?.items.some((run) => !isTerminalRunStatus(run.status)) ? 1_000 : 5_000, }) const runs = runsQuery.data?.items ?? [] const effectiveRunId = selectedRunId ?? runs[0]?.id ?? null const selectedRunQuery = useQuery({ queryKey: ['run', effectiveRunId], queryFn: ({ signal }) => getRun(effectiveRunId!, signal), enabled: effectiveRunId !== null, refetchInterval: (query) => query.state.data && !isTerminalRunStatus(query.state.data.status) ? 800 : false, }) const selectedRun = selectedRunQuery.data ?? runs.find((run) => run.id === effectiveRunId) ?? null const cancelMutation = useMutation({ mutationFn: (runId: number) => cancelRun(runId), onSuccess: (run) => { queryClient.setQueryData(['run', run.id], run) void queryClient.invalidateQueries({ queryKey: ['task-runs', run.task_id], }) }, }) useEffect(() => { if (!open || !task) { return } const source = new EventSource('/api/v1/events') source.onopen = () => setSseConnected(true) source.onerror = () => setSseConnected(false) const onRun = (event: MessageEvent) => { try { const payload = JSON.parse(event.data) as ExecutionEvent if (payload.event !== 'run' || payload.data.run.task_id !== task.id) { return } queryClient.setQueryData(['run', payload.data.run.id], payload.data.run) void queryClient.invalidateQueries({ queryKey: ['task-runs', task.id], }) } catch { setSseConnected(false) } } const onLog = (event: MessageEvent) => { try { const payload = JSON.parse(event.data) as ExecutionEvent if (payload.event === 'log') { logEvents.dispatchEvent( new CustomEvent('log', { detail: payload }), ) } } catch { setSseConnected(false) } } const onLagged = () => { void queryClient.invalidateQueries({ queryKey: ['task-runs', task.id], }) } source.addEventListener('run', onRun as EventListener) source.addEventListener('log', onLog as EventListener) source.addEventListener('lagged', onLagged) return () => { source.removeEventListener('run', onRun as EventListener) source.removeEventListener('log', onLog as EventListener) source.removeEventListener('lagged', onLagged) source.close() setSseConnected(false) } }, [logEvents, open, queryClient, task]) useEffect(() => { if (!open) { return } const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { onClose() } } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [onClose, open]) if (!open || !task) { return null } return (
{ if (event.currentTarget === event.target) { onClose() } }} >
) } function RunMetadata({ run }: { run: TaskRun }) { return (
触发方式
{run.trigger}
开始时间
{formatDateTime(run.started_at)}
耗时
{formatDuration(run.duration_ms)}
退出码
{run.exit_code ?? '--'}
{run.error_message ? (
结果说明
{run.error_message}
) : null}
) } interface RunLogOutputProps { runId: number logEvents: EventTarget sseConnected: boolean runComplete: boolean } function RunLogOutput({ runId, logEvents, sseConnected, runComplete, }: RunLogOutputProps) { const [content, setContent] = useState('') const [pollComplete, setPollComplete] = useState(runComplete) const [error, setError] = useState(null) const [reloadKey, setReloadKey] = useState(0) const [reconcileKey, setReconcileKey] = useState(0) const offsetRef = useRef(0) const outputRef = useRef(null) const complete = runComplete || pollComplete useEffect(() => { let cancelled = false const controller = new AbortController() const poll = async () => { while (!cancelled) { try { const startOffset = offsetRef.current const chunk = await getRunLogs(runId, startOffset, controller.signal) if (cancelled) { return } if (offsetRef.current !== startOffset) { continue } if (chunk.content) { setContent((current) => current + chunk.content) } offsetRef.current = chunk.next_offset if (chunk.complete) { setPollComplete(true) return } await new Promise((resolve) => window.setTimeout(resolve, 600)) } catch (requestError) { if (!cancelled) { setError( requestError instanceof Error ? requestError.message : '读取日志失败', ) } return } } } void poll() return () => { cancelled = true controller.abort() } }, [reconcileKey, reloadKey, runId]) useEffect(() => { const onLog = (event: Event) => { const payload = (event as CustomEvent).detail if ( payload.event !== 'log' || payload.data.run_id !== runId ) { return } if (payload.data.offset === offsetRef.current) { if (payload.data.content) { setContent((current) => current + payload.data.content) } offsetRef.current = payload.data.next_offset } else if (payload.data.offset > offsetRef.current) { setReconcileKey((value) => value + 1) } } logEvents.addEventListener('log', onLog) return () => logEvents.removeEventListener('log', onLog) }, [logEvents, runId]) useEffect(() => { if (outputRef.current) { outputRef.current.scrollTop = outputRef.current.scrollHeight } }, [content]) return (
运行日志 {error ? '读取失败' : complete ? '已完成' : sseConnected ? 'SSE 实时连接' : '轮询读取中'}
{error ? (
{error}
) : null}
        {content || '等待日志输出...'}
      
) }