Files
task_pilot/frontend/src/components/RunLogDrawer.tsx
T

502 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number | null>(
initialRunId ?? null,
)
const [historyStatus, setHistoryStatus] = useState<RunStatus | ''>('')
const [historyTrigger, setHistoryTrigger] = useState<RunTrigger | ''>('')
const [sseConnected, setSseConnected] = useState(false)
const [logEvents] = useState(() => new EventTarget())
const drawerRef = useRef<HTMLElement>(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<string>) => {
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<string>) => {
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 (
<div
className="modal-overlay drawer-overlay"
onMouseDown={(event) => {
if (event.currentTarget === event.target) {
onClose()
}
}}
>
<aside
ref={drawerRef}
className="run-drawer"
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="run-drawer-title"
>
<header className="modal-header">
<div>
<p className="eyebrow">RUN HISTORY</p>
<h2 id="run-drawer-title">{task.name}</h2>
<p className="drawer-subtitle mono">{task.id}</p>
</div>
<button
type="button"
className="icon-button"
onClick={onClose}
aria-label="关闭运行日志"
>
×
</button>
</header>
<div className="run-drawer-body">
<section className="run-history-panel">
<div className="section-heading">
<div>
<p className="section-label">RUNS</p>
<h3>最近运行</h3>
</div>
<button
type="button"
className="button button-secondary button-small"
onClick={() => void runsQuery.refetch()}
disabled={runsQuery.isFetching}
>
刷新
</button>
</div>
<div className="history-filters">
<select
className="field-input compact-select"
aria-label="运行状态筛选"
value={historyStatus}
onChange={(event) => {
setHistoryStatus(event.target.value as RunStatus | '')
setSelectedRunId(null)
}}
>
<option value="">全部状态</option>
<option value="queued">排队中</option>
<option value="running">运行中</option>
<option value="succeeded">成功</option>
<option value="failed">失败</option>
<option value="timed_out">已超时</option>
<option value="cancelled">已取消</option>
<option value="skipped">已跳过</option>
</select>
<select
className="field-input compact-select"
aria-label="触发方式筛选"
value={historyTrigger}
onChange={(event) => {
setHistoryTrigger(event.target.value as RunTrigger | '')
setSelectedRunId(null)
}}
>
<option value="">全部触发</option>
<option value="schedule">定时</option>
<option value="manual">手动</option>
<option value="retry">重试</option>
</select>
</div>
{runsQuery.isPending ? (
<p className="muted-copy">正在加载运行记录...</p>
) : runsQuery.isError ? (
<div className="inline-alert inline-alert-error">
{runsQuery.error.message}
</div>
) : runs.length === 0 ? (
<p className="empty-inline">该任务还没有运行记录。</p>
) : (
<div className="run-list">
{runs.map((run) => (
<button
type="button"
className={`run-list-item ${
run.id === effectiveRunId ? 'is-selected' : ''
}`}
key={run.id}
onClick={() => setSelectedRunId(run.id)}
>
<span className="run-list-main">
<span className="mono">#{run.id}</span>
<RunStatusBadge status={run.status} />
</span>
<span className="run-list-meta">
{formatDateTime(run.created_at)} · {run.trigger}
</span>
</button>
))}
</div>
)}
</section>
<section className="run-detail-panel">
<div className="section-heading">
<div>
<p className="section-label">DETAIL</p>
<h3>
{effectiveRunId ? `运行 #${effectiveRunId}` : '运行详情'}
</h3>
</div>
{selectedRun ? (
<div className="run-detail-actions">
<RunStatusBadge status={selectedRun.status} />
{!isTerminalRunStatus(selectedRun.status) ? (
<button
type="button"
className="button button-danger button-small"
disabled={
cancelMutation.isPending &&
cancelMutation.variables === selectedRun.id
}
onClick={() => cancelMutation.mutate(selectedRun.id)}
>
{cancelMutation.isPending ? '取消中...' : '取消运行'}
</button>
) : null}
<a
className="button button-secondary button-small"
href={runLogDownloadUrl(selectedRun.id)}
>
下载日志
</a>
</div>
) : null}
</div>
{selectedRunQuery.isError ? (
<div className="inline-alert inline-alert-error">
{selectedRunQuery.error.message}
</div>
) : null}
{selectedRun ? (
<RunMetadata run={selectedRun} />
) : (
<p className="empty-inline">选择一条运行记录查看日志。</p>
)}
{effectiveRunId ? (
<RunLogOutput
key={effectiveRunId}
runId={effectiveRunId}
logEvents={logEvents}
sseConnected={sseConnected}
runComplete={
selectedRun ? isTerminalRunStatus(selectedRun.status) : false
}
/>
) : null}
</section>
</div>
</aside>
</div>
)
}
function RunMetadata({ run }: { run: TaskRun }) {
return (
<dl className="run-metadata">
<div>
<dt>触发方式</dt>
<dd>{run.trigger}</dd>
</div>
<div>
<dt>开始时间</dt>
<dd>{formatDateTime(run.started_at)}</dd>
</div>
<div>
<dt>耗时</dt>
<dd>{formatDuration(run.duration_ms)}</dd>
</div>
<div>
<dt>退出码</dt>
<dd>{run.exit_code ?? '--'}</dd>
</div>
{run.error_message ? (
<div className="run-error-metadata">
<dt>结果说明</dt>
<dd>{run.error_message}</dd>
</div>
) : null}
</dl>
)
}
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<string | null>(null)
const [reloadKey, setReloadKey] = useState(0)
const [reconcileKey, setReconcileKey] = useState(0)
const offsetRef = useRef(0)
const outputRef = useRef<HTMLPreElement>(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<ExecutionEvent>).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 (
<div className="log-viewer-wrap">
<div className="log-viewer-header">
<span>运行日志</span>
<span className={complete ? 'log-complete' : 'log-streaming'}>
{error
? '读取失败'
: complete
? '已完成'
: sseConnected
? 'SSE 实时连接'
: '轮询读取中'}
</span>
</div>
{error ? (
<div className="inline-alert inline-alert-error">
{error}
<button
type="button"
className="text-button"
onClick={() => {
setContent('')
setPollComplete(false)
setError(null)
offsetRef.current = 0
setReloadKey((value) => value + 1)
}}
>
重试
</button>
</div>
) : null}
<pre className="log-viewer" ref={outputRef} aria-live="polite">
{content || '等待日志输出...'}
</pre>
</div>
)
}