feat: implement full-stack TaskPilot scheduler

This commit is contained in:
2026-10-18 22:05:45 +08:00
parent d88c21753d
commit 8ce02af13a
72 changed files with 17812 additions and 0 deletions
+501
View File
@@ -0,0 +1,501 @@
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>
)
}