/** 工作流面板:侧边抽屉,列出全部工作流,选中后可提问 / 诊断 / 修复 / 启停。 */ import { useSyncExternalStore, useEffect, useCallback } from 'react' import { getState, subscribe, update, pushToast } from './store.js' import { api } from './api.js' function useWorkbench() { return useSyncExternalStore(subscribe, getState) } const STATUS_LABELS = { success: '成功', failed: '失败', running: '运行中', cancelled: '已停止', none: '未运行', } function statusOf(workflow) { if (workflow.active_run) return 'running' return workflow.last_run?.status ?? 'none' } function formatTime(value) { if (!value) return '—' const date = new Date(value) if (Number.isNaN(date.getTime())) return String(value) const pad = (n) => String(n).padStart(2, '0') return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` } function formatDuration(seconds) { if (seconds == null) return '' if (seconds < 60) return `${seconds}s` return `${Math.floor(seconds / 60)}m${seconds % 60 ? `${seconds % 60}s` : ''}` } export async function refreshWorkflows() { update({ loading: true, error: null }) try { const payload = await api.listWorkflows() update({ loading: false, workflows: payload.workflows ?? [], summary: payload.summary ?? null, generatedAt: payload.generated_at ?? null, }) } catch (error) { update({ loading: false, error: error.message }) } } async function selectWorkflow(workflowId) { update({ selectedId: workflowId, detailLoading: true, diagnosis: null, runs: [] }) try { const [detail, runs] = await Promise.all([ api.workflowDetail(workflowId).catch(() => null), api.workflowRuns(workflowId, 6).catch(() => ({ runs: [] })), ]) update({ detailLoading: false, detail, runs: runs.runs ?? [] }) } catch (error) { update({ detailLoading: false, error: error.message }) } } async function loadDiagnosis(workflowId, runId) { update({ diagnosisLoading: true }) try { const payload = await api.diagnosis(workflowId, runId) update({ diagnosisLoading: false, diagnosis: payload }) } catch (error) { update({ diagnosisLoading: false }) pushToast({ kind: 'error', text: `诊断数据加载失败:${error.message}` }) } } async function doAsk(action) { const s = getState() if (!s.selectedId) return const question = s.question.trim() try { const payload = await api.ask({ workflow_id: s.selectedId, action, question, }) const label = { ask: '提问', diagnose: '诊断', repair: '修复' }[action] ?? '会话' pushToast({ kind: 'ok', text: `已创建${label}会话(${String(payload.session_id).slice(0, 18)}…),请在左侧会话列表中查看`, }) update({ question: '' }) } catch (error) { pushToast({ kind: 'error', text: `创建会话失败:${error.message}` }) } } async function doTrigger(execute) { const s = getState() if (!s.selectedId) return if (execute) { const ok = window.confirm( `正式执行 ${s.selectedId}(业务日期 ${s.businessDate})会对外部系统产生真实写入。确认继续?`, ) if (!ok) return } try { await api.trigger({ workflow_id: s.selectedId, business_date: s.businessDate, execute, confirmed: execute, }) pushToast({ kind: 'ok', text: execute ? '已发起正式执行' : '已发起试运行(无外部副作用)' }) setTimeout(refreshWorkflows, 1500) } catch (error) { pushToast({ kind: 'error', text: `启动失败:${error.message}` }) } } async function doCancel() { const s = getState() const active = s.detail?.workflow?.active_run if (!s.selectedId) return try { if (active?.operation_id) { await api.cancel({ workflow_id: s.selectedId, operation_id: active.operation_id }) } else { await api.cancel({ workflow_id: s.selectedId, scheduled: true }) } pushToast({ kind: 'ok', text: '已发送停止指令' }) setTimeout(refreshWorkflows, 1500) } catch (error) { pushToast({ kind: 'error', text: `停止失败:${error.message}` }) } } function SummaryPills({ summary, workflows }) { const failed = workflows.filter((w) => statusOf(w) === 'failed').length const running = workflows.filter((w) => w.active_run).length return (
共 {summary?.total ?? workflows.length} {running > 0 && 运行中 {running}} {failed > 0 && 失败 {failed}}
) } function WorkflowRow({ workflow, selected, onSelect }) { const status = statusOf(workflow) return ( ) } function RunList({ runs, onDiagnose }) { if (!runs.length) return
暂无运行记录
return (
{runs.map((run) => ( ))}
) } function DiagnosisView({ diagnosis, loading }) { if (loading) return
正在加载诊断数据…
if (!diagnosis) return null if (diagnosis.message) return
{diagnosis.message}
const run = diagnosis.run ?? {} const steps = Array.isArray(run.steps) ? run.steps : [] const logs = Array.isArray(diagnosis.logs) ? diagnosis.logs : [] return (
诊断:{run.run_id}({run.status})
{steps .filter((step) => step.status === 'failed') .map((step) => (
✗ {step.id}(退出码 {step.exit_code ?? '—'})
{step.error &&
{step.error}
}
))} {logs.map((log) => (
{log.path}
{log.tail}
))} {!steps.some((s) => s.status === 'failed') && !logs.length && (
该运行没有失败步骤或日志
)}
) } export function WorkflowPanel() { const s = useWorkbench() useEffect(() => { if (!s.open) return undefined refreshWorkflows() const timer = setInterval(refreshWorkflows, 10_000) return () => clearInterval(timer) }, [s.open]) const onSelect = useCallback((workflowId) => { selectWorkflow(workflowId) }, []) const groups = [] const byModule = new Map() for (const workflow of s.workflows) { const key = workflow.module_label ?? workflow.module if (!byModule.has(key)) byModule.set(key, []) byModule.get(key).push(workflow) } for (const [label, items] of byModule) groups.push({ label, items }) const selected = s.workflows.find((w) => w.id === s.selectedId) ?? null return (
GYXX 工作流
{s.error && (
无法连接工作台桥接服务:{s.error}
请确认 dsh 已通过 workbench/cordis.yml 启动。
)}
{groups.map((group) => (
{group.label}
{group.items.map((workflow) => ( ))}
))} {!s.loading && !s.workflows.length && !s.error && (
未发现工作流
)}
{selected && (
{selected.name} {STATUS_LABELS[statusOf(selected)]}
{selected.id} · 调度{' '} {selected.schedule ? `${selected.schedule.enabled ? '启用' : '停用'} ${ Array.isArray(selected.schedule.at) ? selected.schedule.at.join('/') : (selected.schedule.at ?? '') }` : '无'} {selected.next_run_at ? ` · 下次 ${formatTime(selected.next_run_at)}` : ''}
{selected.active_run && ( )}
update({ question: event.target.value })} onKeyDown={(event) => { if (event.key === 'Enter') doAsk('ask') }} />
业务日期{' '} update({ businessDate: event.target.value })} pattern="\d{4}-\d{2}-\d{2}" />
最近运行
loadDiagnosis(selected.id, runId)} />
)}
) }