Files
gyxx-flow/workbench/plugin/client/src/Panel.jsx
T
wangyunlong 01218b2907 feat: add deepseek-harness workbench plugin and console diagnosis API
- 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)
2026-09-04 11:10:16 +08:00

356 lines
12 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 工作流面板:侧边抽屉,列出全部工作流,选中后可提问 / 诊断 / 修复 / 启停。 */
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>
)
}