feat: implement full-stack TaskPilot scheduler
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user