502 lines
15 KiB
TypeScript
502 lines
15 KiB
TypeScript
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>
|
||
)
|
||
}
|