01218b2907
- console: GET /api/workflows/{id}, /runs/{run_id}, /runs/{run_id}/diagnosis
(journal trace + sanitized bounded log tails, workflow/run pairing enforced)
- workbench/: dsh 宿主插件(7 个工作流工具、中文系统提示词、失败监控器、
本机回环桥接服务)+ 侧边栏面板客户端包(sidebar.footer.action 与
shell.overlay 追加插槽)+ 降级独立面板 + 一键启动脚本
- adapters/browser: 收敛 looks_like_login_url 到共享层,修复
jd_main_image_collector 对 gyxx_flow.accounts 的越层导入
- tests: 新端点覆盖;replay_policy 断言对齐已迁移的 catalog(repeatable)
356 lines
12 KiB
React
356 lines
12 KiB
React
/** 工作流面板:侧边抽屉,列出全部工作流,选中后可提问 / 诊断 / 修复 / 启停。 */
|
||
|
||
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 (
|
||
<div className="gyxxwb-pills">
|
||
<span className="gyxxwb-pill">共 {summary?.total ?? workflows.length}</span>
|
||
{running > 0 && <span className="gyxxwb-pill gyxxwb-pill-running">运行中 {running}</span>}
|
||
{failed > 0 && <span className="gyxxwb-pill gyxxwb-pill-failed">失败 {failed}</span>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function WorkflowRow({ workflow, selected, onSelect }) {
|
||
const status = statusOf(workflow)
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={`gyxxwb-row ${selected ? 'gyxxwb-row-selected' : ''}`}
|
||
onClick={() => onSelect(workflow.id)}
|
||
>
|
||
<span className={`gyxxwb-dot gyxxwb-dot-${status}`} />
|
||
<span className="gyxxwb-row-main">
|
||
<span className="gyxxwb-row-name">{workflow.name}</span>
|
||
<span className="gyxxwb-row-id">{workflow.id}</span>
|
||
</span>
|
||
<span className="gyxxwb-row-meta">
|
||
{workflow.last_run ? formatTime(workflow.last_run.ended_at ?? workflow.last_run.started_at) : '—'}
|
||
</span>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function RunList({ runs, onDiagnose }) {
|
||
if (!runs.length) return <div className="gyxxwb-empty">暂无运行记录</div>
|
||
return (
|
||
<div className="gyxxwb-runs">
|
||
{runs.map((run) => (
|
||
<button
|
||
type="button"
|
||
key={run.run_id}
|
||
className="gyxxwb-run"
|
||
onClick={() => onDiagnose(run.run_id)}
|
||
title="点击查看诊断详情"
|
||
>
|
||
<span className={`gyxxwb-dot gyxxwb-dot-${run.status}`} />
|
||
<span>{run.business_date}</span>
|
||
<span className="gyxxwb-run-mode">{run.mode === 'execute' ? '正式' : '试运行'}</span>
|
||
<span>{formatDuration(run.duration_seconds)}</span>
|
||
{run.error && <span className="gyxxwb-run-error">{run.error.slice(0, 60)}</span>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function DiagnosisView({ diagnosis, loading }) {
|
||
if (loading) return <div className="gyxxwb-empty">正在加载诊断数据…</div>
|
||
if (!diagnosis) return null
|
||
if (diagnosis.message) return <div className="gyxxwb-empty">{diagnosis.message}</div>
|
||
const run = diagnosis.run ?? {}
|
||
const steps = Array.isArray(run.steps) ? run.steps : []
|
||
const logs = Array.isArray(diagnosis.logs) ? diagnosis.logs : []
|
||
return (
|
||
<div className="gyxxwb-diagnosis">
|
||
<div className="gyxxwb-diagnosis-head">
|
||
诊断:{run.run_id}({run.status})
|
||
</div>
|
||
{steps
|
||
.filter((step) => step.status === 'failed')
|
||
.map((step) => (
|
||
<div key={step.id} className="gyxxwb-step-fail">
|
||
<div className="gyxxwb-step-id">
|
||
✗ {step.id}(退出码 {step.exit_code ?? '—'})
|
||
</div>
|
||
{step.error && <pre className="gyxxwb-pre">{step.error}</pre>}
|
||
</div>
|
||
))}
|
||
{logs.map((log) => (
|
||
<div key={log.path} className="gyxxwb-log">
|
||
<div className="gyxxwb-log-path">{log.path}</div>
|
||
<pre className="gyxxwb-pre">{log.tail}</pre>
|
||
</div>
|
||
))}
|
||
{!steps.some((s) => s.status === 'failed') && !logs.length && (
|
||
<div className="gyxxwb-empty">该运行没有失败步骤或日志</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div className={`gyxxwb-panel ${s.open ? 'gyxxwb-panel-open' : ''}`}>
|
||
<div className="gyxxwb-panel-head">
|
||
<span className="gyxxwb-title">GYXX 工作流</span>
|
||
<SummaryPills summary={s.summary} workflows={s.workflows} />
|
||
<button type="button" className="gyxxwb-icon-btn" title="刷新" onClick={refreshWorkflows}>
|
||
⟳
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="gyxxwb-icon-btn"
|
||
title="关闭"
|
||
onClick={() => update({ open: false })}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
{s.error && (
|
||
<div className="gyxxwb-error">
|
||
无法连接工作台桥接服务:{s.error}
|
||
<br />
|
||
请确认 dsh 已通过 workbench/cordis.yml 启动。
|
||
</div>
|
||
)}
|
||
<div className="gyxxwb-panel-body">
|
||
<div className="gyxxwb-list">
|
||
{groups.map((group) => (
|
||
<div key={group.label} className="gyxxwb-group">
|
||
<div className="gyxxwb-group-label">{group.label}</div>
|
||
{group.items.map((workflow) => (
|
||
<WorkflowRow
|
||
key={workflow.id}
|
||
workflow={workflow}
|
||
selected={workflow.id === s.selectedId}
|
||
onSelect={onSelect}
|
||
/>
|
||
))}
|
||
</div>
|
||
))}
|
||
{!s.loading && !s.workflows.length && !s.error && (
|
||
<div className="gyxxwb-empty">未发现工作流</div>
|
||
)}
|
||
</div>
|
||
{selected && (
|
||
<div className="gyxxwb-detail">
|
||
<div className="gyxxwb-detail-title">
|
||
{selected.name}
|
||
<span className={`gyxxwb-status gyxxwb-status-${statusOf(selected)}`}>
|
||
{STATUS_LABELS[statusOf(selected)]}
|
||
</span>
|
||
</div>
|
||
<div className="gyxxwb-detail-sub">
|
||
{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)}` : ''}
|
||
</div>
|
||
<div className="gyxxwb-actions">
|
||
<button type="button" onClick={() => doAsk('diagnose')}>诊断</button>
|
||
<button type="button" onClick={() => doAsk('repair')}>修复</button>
|
||
<button type="button" onClick={() => doTrigger(false)}>试运行</button>
|
||
<button type="button" className="gyxxwb-danger" onClick={() => doTrigger(true)}>
|
||
正式运行
|
||
</button>
|
||
{selected.active_run && (
|
||
<button type="button" className="gyxxwb-danger" onClick={doCancel}>
|
||
停止
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="gyxxwb-ask">
|
||
<input
|
||
value={s.question}
|
||
placeholder="就这个工作流提问,例如:昨天为什么失败?"
|
||
onChange={(event) => update({ question: event.target.value })}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') doAsk('ask')
|
||
}}
|
||
/>
|
||
<button type="button" onClick={() => doAsk('ask')}>提问</button>
|
||
</div>
|
||
<div className="gyxxwb-date-row">
|
||
业务日期{' '}
|
||
<input
|
||
value={s.businessDate}
|
||
onChange={(event) => update({ businessDate: event.target.value })}
|
||
pattern="\d{4}-\d{2}-\d{2}"
|
||
/>
|
||
</div>
|
||
<div className="gyxxwb-section-label">最近运行</div>
|
||
<RunList runs={s.runs} onDiagnose={(runId) => loadDiagnosis(selected.id, runId)} />
|
||
<DiagnosisView diagnosis={s.diagnosis} loading={s.diagnosisLoading} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|