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)
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
/** 工作流面板:侧边抽屉,列出全部工作流,选中后可提问 / 诊断 / 修复 / 启停。 */
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/** 失败告警浮层:轮询桥接告警,新失败弹出 toast,可一键发起诊断会话。 */
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { getState, subscribe, update, dismissToast, pushToast } from './store.js'
|
||||
import { api } from './api.js'
|
||||
|
||||
function useWorkbench() {
|
||||
return useSyncExternalStore(subscribe, getState)
|
||||
}
|
||||
|
||||
export function Toasts() {
|
||||
const s = useWorkbench()
|
||||
|
||||
useEffect(() => {
|
||||
let stopped = false
|
||||
let timer = null
|
||||
const poll = async () => {
|
||||
try {
|
||||
const payload = await api.alerts()
|
||||
const fresh = (payload.alerts ?? []).filter(
|
||||
(alert) => !alert.seen && !getState().seenAlertIds[alert.id],
|
||||
)
|
||||
if (fresh.length) {
|
||||
const seen = { ...getState().seenAlertIds }
|
||||
for (const alert of fresh) {
|
||||
seen[alert.id] = true
|
||||
pushToast({ kind: 'failed', text: `工作流「${alert.name}」运行失败`, alert })
|
||||
}
|
||||
update({ seenAlertIds: seen })
|
||||
}
|
||||
} catch {
|
||||
// 桥接离线时静默
|
||||
}
|
||||
if (!stopped) timer = setTimeout(poll, 30_000)
|
||||
}
|
||||
timer = setTimeout(poll, 8_000)
|
||||
return () => {
|
||||
stopped = true
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!s.toasts.length) return null
|
||||
return (
|
||||
<div className="gyxxwb-toasts">
|
||||
{s.toasts.map((toast) => (
|
||||
<div key={toast.id} className={`gyxxwb-toast gyxxwb-toast-${toast.kind}`}>
|
||||
<span className="gyxxwb-toast-text">{toast.text}</span>
|
||||
{toast.alert && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.ask({ workflow_id: toast.alert.workflow_id, action: 'diagnose' })
|
||||
await api.markAlertSeen(toast.alert.id).catch(() => {})
|
||||
pushToast({ kind: 'ok', text: '已创建诊断会话,请在左侧会话列表查看' })
|
||||
} catch (error) {
|
||||
pushToast({ kind: 'error', text: `创建会话失败:${error.message}` })
|
||||
}
|
||||
dismissToast(toast.id)
|
||||
}}
|
||||
>
|
||||
立即诊断
|
||||
</button>
|
||||
)}
|
||||
<button type="button" title="关闭" onClick={() => dismissToast(toast.id)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/** 工作台桥接服务客户端:所有数据经由 gyxx-workbench 宿主插件的本机回环桥接。 */
|
||||
|
||||
export function bridgeBase() {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (window.__GYXX_WB_BRIDGE__) return window.__GYXX_WB_BRIDGE__
|
||||
try {
|
||||
const saved = window.localStorage?.getItem('gyxx.wb.bridge')
|
||||
if (saved) return saved
|
||||
} catch {
|
||||
// localStorage 不可用时使用默认值
|
||||
}
|
||||
}
|
||||
return 'http://127.0.0.1:8790'
|
||||
}
|
||||
|
||||
async function request(method, path, body) {
|
||||
const response = await fetch(`${bridgeBase()}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
...(body !== undefined
|
||||
? { 'content-type': 'application/json', 'x-gyxx-workbench': '1' }
|
||||
: {}),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
let payload
|
||||
try {
|
||||
payload = await response.json()
|
||||
} catch {
|
||||
payload = { ok: false, error: `桥接服务返回了非 JSON 内容(HTTP ${response.status})` }
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error ?? `桥接服务错误(HTTP ${response.status})`)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listWorkflows: () => request('GET', '/bridge/workflows'),
|
||||
workflowDetail: (workflowId) =>
|
||||
request('GET', `/bridge/workflows/${encodeURIComponent(workflowId)}`),
|
||||
workflowRuns: (workflowId, limit = 6) =>
|
||||
request('GET', `/bridge/workflows/${encodeURIComponent(workflowId)}/runs?limit=${limit}`),
|
||||
diagnosis: (workflowId, runId) =>
|
||||
request(
|
||||
'GET',
|
||||
`/bridge/workflows/${encodeURIComponent(workflowId)}/diagnosis${runId ? `?run_id=${encodeURIComponent(runId)}` : ''}`,
|
||||
),
|
||||
alerts: () => request('GET', '/bridge/alerts'),
|
||||
markAlertSeen: (id) => request('POST', '/bridge/alerts/seen', { id }),
|
||||
trigger: (payload) => request('POST', '/bridge/trigger', payload),
|
||||
cancel: (payload) => request('POST', '/bridge/cancel', payload),
|
||||
ask: (payload) => request('POST', '/bridge/ask', payload),
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* GYXX 工作台客户端插件入口。
|
||||
* 由 esbuild 打包为 CJS 并通过 window.__ModuleLoader__ 注册(见 scripts/build-client.mjs)。
|
||||
* 运行时只依赖 dsh 平台共享模块表中的 react。
|
||||
*/
|
||||
|
||||
import { createElement, useSyncExternalStore } from 'react'
|
||||
import { WorkflowPanel, refreshWorkflows } from './Panel.jsx'
|
||||
import { Toasts } from './Toasts.jsx'
|
||||
import { getState, subscribe, update } from './store.js'
|
||||
import stylesText from './styles.css'
|
||||
|
||||
export const inject = ['slots']
|
||||
|
||||
function useWorkbench() {
|
||||
return useSyncExternalStore(subscribe, getState)
|
||||
}
|
||||
|
||||
function injectStyles() {
|
||||
if (typeof document === 'undefined') return
|
||||
if (document.getElementById('gyxxwb-styles')) return
|
||||
const style = document.createElement('style')
|
||||
style.id = 'gyxxwb-styles'
|
||||
style.textContent = stylesText
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
/** 侧边栏底部动作:工作流面板开关 + 聚合状态点。 */
|
||||
function WorkbenchFooterAction() {
|
||||
const s = useWorkbench()
|
||||
const failed = s.workflows.filter((w) => !w.active_run && w.last_run?.status === 'failed').length
|
||||
const running = s.workflows.filter((w) => w.active_run).length
|
||||
const tone = failed > 0 ? 'failed' : running > 0 ? 'running' : 'success'
|
||||
return createElement(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
className: `gyxxwb-footer-btn${s.open ? ' gyxxwb-footer-btn-active' : ''}`,
|
||||
title: 'GYXX 工作流工作台',
|
||||
onClick: () => {
|
||||
const next = !getState().open
|
||||
update({ open: next })
|
||||
if (next) refreshWorkflows()
|
||||
},
|
||||
},
|
||||
createElement('span', { className: `gyxxwb-dot gyxxwb-dot-${tone}` }),
|
||||
createElement('span', null, '工作流'),
|
||||
)
|
||||
}
|
||||
|
||||
/** 覆盖层根:左侧抽屉面板 + 失败 toast。 */
|
||||
function WorkbenchOverlay() {
|
||||
return createElement(
|
||||
'div',
|
||||
{ className: 'gyxxwb-overlay' },
|
||||
createElement(WorkflowPanel, null),
|
||||
createElement(Toasts, null),
|
||||
)
|
||||
}
|
||||
|
||||
export function apply(ctx) {
|
||||
injectStyles()
|
||||
ctx.effect(
|
||||
() => ctx.slots.register({ name: 'sidebar.footer.action' }, WorkbenchFooterAction),
|
||||
'gyxx-workbench: footer action',
|
||||
)
|
||||
ctx.effect(
|
||||
() => ctx.slots.register({ name: 'shell.overlay' }, WorkbenchOverlay),
|
||||
'gyxx-workbench: overlay',
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* GYXX 工作台面板 —— 极简外部存储(避免向客户端包引入额外依赖)。
|
||||
* React 18 的 useSyncExternalStore 直接订阅。
|
||||
*/
|
||||
|
||||
const listeners = new Set()
|
||||
|
||||
let state = {
|
||||
open: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
generatedAt: null,
|
||||
summary: null,
|
||||
workflows: [],
|
||||
selectedId: null,
|
||||
detail: null,
|
||||
detailLoading: false,
|
||||
runs: [],
|
||||
diagnosis: null,
|
||||
diagnosisLoading: false,
|
||||
question: '',
|
||||
businessDate: defaultBusinessDate(),
|
||||
alerts: [],
|
||||
seenAlertIds: {},
|
||||
toasts: [],
|
||||
}
|
||||
|
||||
export function defaultBusinessDate() {
|
||||
const now = new Date()
|
||||
now.setDate(now.getDate() - 1)
|
||||
const pad = (value) => String(value).padStart(2, '0')
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||
}
|
||||
|
||||
export function getState() {
|
||||
return state
|
||||
}
|
||||
|
||||
export function update(patch) {
|
||||
state = { ...state, ...patch }
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
|
||||
export function subscribe(listener) {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
export function pushToast(toast) {
|
||||
const id = `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
|
||||
update({ toasts: [...state.toasts, { id, ...toast }] })
|
||||
return id
|
||||
}
|
||||
|
||||
export function dismissToast(id) {
|
||||
update({ toasts: state.toasts.filter((toast) => toast.id !== id) })
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
/* GYXX 工作台面板样式 —— 以文本形式内联进客户端包,运行时注入 <style>。 */
|
||||
|
||||
.gyxxwb-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 70;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
||||
.gyxxwb-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 264px;
|
||||
width: 380px;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e4e4e7;
|
||||
box-shadow: 8px 0 24px rgba(0, 0, 0, 0.08);
|
||||
pointer-events: auto;
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
.gyxxwb-panel-open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.gyxxwb-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #e4e4e7;
|
||||
}
|
||||
|
||||
.gyxxwb-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.gyxxwb-pills {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gyxxwb-pill {
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: #f4f4f5;
|
||||
color: #52525b;
|
||||
}
|
||||
|
||||
.gyxxwb-pill-failed {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.gyxxwb-pill-running {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.gyxxwb-icon-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #71717a;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.gyxxwb-icon-btn:hover {
|
||||
background: #f4f4f5;
|
||||
}
|
||||
|
||||
.gyxxwb-error {
|
||||
margin: 10px 12px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.gyxxwb-panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gyxxwb-group-label {
|
||||
padding: 10px 12px 4px;
|
||||
font-size: 12px;
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
.gyxxwb-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.gyxxwb-row:hover {
|
||||
background: #f4f4f5;
|
||||
}
|
||||
|
||||
.gyxxwb-row-selected {
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.gyxxwb-row-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gyxxwb-row-name {
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.gyxxwb-row-id {
|
||||
font-size: 11px;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
.gyxxwb-row-meta {
|
||||
font-size: 11px;
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
.gyxxwb-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #d4d4d8;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.gyxxwb-dot-success {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.gyxxwb-dot-failed {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.gyxxwb-dot-running {
|
||||
background: #2563eb;
|
||||
animation: gyxxwb-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.gyxxwb-dot-cancelled,
|
||||
.gyxxwb-dot-none {
|
||||
background: #a1a1aa;
|
||||
}
|
||||
|
||||
@keyframes gyxxwb-pulse {
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.gyxxwb-detail {
|
||||
border-top: 1px solid #e4e4e7;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.gyxxwb-detail-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gyxxwb-status {
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: #f4f4f5;
|
||||
}
|
||||
|
||||
.gyxxwb-status-failed {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.gyxxwb-status-running {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.gyxxwb-status-success {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.gyxxwb-detail-sub {
|
||||
font-size: 11px;
|
||||
color: #71717a;
|
||||
margin: 4px 0 10px;
|
||||
}
|
||||
|
||||
.gyxxwb-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gyxxwb-actions button,
|
||||
.gyxxwb-ask button {
|
||||
border: 1px solid #e4e4e7;
|
||||
background: #ffffff;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gyxxwb-actions button:hover,
|
||||
.gyxxwb-ask button:hover {
|
||||
background: #f4f4f5;
|
||||
}
|
||||
|
||||
.gyxxwb-actions .gyxxwb-danger {
|
||||
border-color: #fecaca;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.gyxxwb-ask {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.gyxxwb-ask input {
|
||||
flex: 1;
|
||||
border: 1px solid #e4e4e7;
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.gyxxwb-date-row {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: #71717a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gyxxwb-date-row input {
|
||||
border: 1px solid #e4e4e7;
|
||||
border-radius: 6px;
|
||||
padding: 3px 6px;
|
||||
font-size: 11px;
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.gyxxwb-section-label {
|
||||
margin: 12px 0 4px;
|
||||
font-size: 12px;
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
.gyxxwb-run {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.gyxxwb-run:hover {
|
||||
background: #f4f4f5;
|
||||
}
|
||||
|
||||
.gyxxwb-run-mode {
|
||||
color: #a1a1aa;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.gyxxwb-run-error {
|
||||
color: #b91c1c;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.gyxxwb-diagnosis {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.gyxxwb-diagnosis-head {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.gyxxwb-step-fail {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.gyxxwb-step-id {
|
||||
font-size: 12px;
|
||||
color: #b91c1c;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.gyxxwb-log {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.gyxxwb-log-path {
|
||||
font-size: 11px;
|
||||
color: #71717a;
|
||||
margin-bottom: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.gyxxwb-pre {
|
||||
font-size: 11px;
|
||||
background: #18181b;
|
||||
color: #e4e4e7;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.gyxxwb-empty {
|
||||
padding: 16px 12px;
|
||||
font-size: 12px;
|
||||
color: #a1a1aa;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gyxxwb-toasts {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: auto;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.gyxxwb-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #18181b;
|
||||
color: #fafafa;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.gyxxwb-toast button {
|
||||
border: 1px solid #3f3f46;
|
||||
background: transparent;
|
||||
color: #fafafa;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gyxxwb-toast-ok {
|
||||
background: #14532d;
|
||||
}
|
||||
|
||||
.gyxxwb-toast-error,
|
||||
.gyxxwb-toast-failed {
|
||||
background: #7f1d1d;
|
||||
}
|
||||
|
||||
.gyxxwb-footer-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.gyxxwb-footer-btn:hover,
|
||||
.gyxxwb-footer-btn-active {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.gyxxwb-panel {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.gyxxwb-panel {
|
||||
background: #18181b;
|
||||
color: #fafafa;
|
||||
border-right-color: #3f3f46;
|
||||
}
|
||||
.gyxxwb-row:hover {
|
||||
background: #27272a;
|
||||
}
|
||||
.gyxxwb-row-selected {
|
||||
background: #312e81;
|
||||
}
|
||||
.gyxxwb-detail {
|
||||
background: #1f1f22;
|
||||
border-top-color: #3f3f46;
|
||||
}
|
||||
.gyxxwb-actions button,
|
||||
.gyxxwb-ask button {
|
||||
background: #27272a;
|
||||
border-color: #3f3f46;
|
||||
color: #fafafa;
|
||||
}
|
||||
.gyxxwb-ask input,
|
||||
.gyxxwb-date-row input {
|
||||
background: #27272a;
|
||||
border-color: #3f3f46;
|
||||
color: #fafafa;
|
||||
}
|
||||
.gyxxwb-pill {
|
||||
background: #27272a;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
.gyxxwb-footer-btn:hover,
|
||||
.gyxxwb-footer-btn-active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>GYXX 智能工作台</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
background: #f4f4f5; color: #18181b;
|
||||
}
|
||||
header {
|
||||
display: flex; align-items: center; gap: 10px; padding: 12px 16px;
|
||||
background: #fff; border-bottom: 1px solid #e4e4e7; position: sticky; top: 0; z-index: 2;
|
||||
}
|
||||
h1 { font-size: 15px; margin: 0; }
|
||||
.pill { font-size: 11px; padding: 1px 8px; border-radius: 999px; background: #f4f4f5; color: #52525b; border: 1px solid #e4e4e7; }
|
||||
.pill-failed { background: #fef2f2; color: #b91c1c; border-color: #fecaca; }
|
||||
.pill-running { background: #eff6ff; color: #1d4ed8; border-color: #bfdbfe; }
|
||||
main { display: flex; gap: 0; min-height: calc(100vh - 49px); }
|
||||
#list { width: 320px; background: #fff; border-right: 1px solid #e4e4e7; overflow-y: auto; }
|
||||
#detail { flex: 1; padding: 16px; overflow-y: auto; }
|
||||
.group-label { padding: 10px 12px 4px; font-size: 12px; color: #71717a; }
|
||||
.row { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 12px; border: none; background: none; cursor: pointer; text-align: left; }
|
||||
.row:hover { background: #f4f4f5; }
|
||||
.row.selected { background: #eef2ff; }
|
||||
.row .name { font-size: 13px; display: block; }
|
||||
.row .id { font-size: 11px; color: #a1a1aa; display: block; }
|
||||
.row .meta { font-size: 11px; color: #71717a; margin-left: auto; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: #a1a1aa; flex: none; }
|
||||
.dot-success { background: #16a34a; } .dot-failed { background: #dc2626; }
|
||||
.dot-running { background: #2563eb; animation: pulse 1.2s infinite; }
|
||||
@keyframes pulse { 50% { opacity: .35; } }
|
||||
button.act { border: 1px solid #e4e4e7; background: #fff; border-radius: 6px; font-size: 12px; padding: 5px 12px; cursor: pointer; margin-right: 6px; }
|
||||
button.act:hover { background: #f4f4f5; }
|
||||
button.danger { border-color: #fecaca; color: #b91c1c; }
|
||||
input { border: 1px solid #e4e4e7; border-radius: 6px; padding: 6px 8px; font-size: 12px; }
|
||||
pre { background: #18181b; color: #e4e4e7; border-radius: 8px; padding: 10px; font-size: 11px; max-height: 260px; overflow: auto; white-space: pre-wrap; word-break: break-all; }
|
||||
.muted { color: #71717a; font-size: 12px; }
|
||||
.err { color: #b91c1c; font-size: 12px; }
|
||||
.card { background: #fff; border: 1px solid #e4e4e7; border-radius: 10px; padding: 14px; margin-bottom: 12px; }
|
||||
#toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 9; }
|
||||
.toast { background: #18181b; color: #fafafa; border-radius: 10px; padding: 10px 12px; font-size: 12px; max-width: 340px; }
|
||||
.toast.ok { background: #14532d; } .toast.err { background: #7f1d1d; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #09090b; color: #fafafa; }
|
||||
header, #list, .card { background: #18181b; border-color: #3f3f46; }
|
||||
.row:hover { background: #27272a; } .row.selected { background: #312e81; }
|
||||
button.act, input { background: #27272a; border-color: #3f3f46; color: #fafafa; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>GYXX 智能工作台</h1>
|
||||
<span id="pills"></span>
|
||||
<span class="muted" style="margin-left:auto" id="generated"></span>
|
||||
<button class="act" onclick="loadWorkflows()">刷新</button>
|
||||
</header>
|
||||
<main>
|
||||
<div id="list"></div>
|
||||
<div id="detail"><p class="muted" style="padding:16px">在左侧选择一个工作流,可提问、诊断、修复或启动。</p></div>
|
||||
</main>
|
||||
<div id="toasts"></div>
|
||||
<script>
|
||||
const $ = (sel) => document.querySelector(sel)
|
||||
const state = { workflows: [], selected: null, detail: null, runs: [], seenAlerts: {} }
|
||||
|
||||
function toast(text, kind) {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'toast ' + (kind || '')
|
||||
el.textContent = text
|
||||
$('#toasts').appendChild(el)
|
||||
setTimeout(() => el.remove(), 8000)
|
||||
}
|
||||
|
||||
async function req(method, path, body) {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: body !== undefined ? { 'content-type': 'application/json', 'x-gyxx-workbench': '1' } : {},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
const payload = await res.json().catch(() => ({}))
|
||||
if (!res.ok) throw new Error(payload.error || ('HTTP ' + res.status))
|
||||
return payload
|
||||
}
|
||||
|
||||
function statusOf(w) { return w.active_run ? 'running' : (w.last_run && w.last_run.status) || 'none' }
|
||||
function fmtTime(v) {
|
||||
if (!v) return '—'
|
||||
const d = new Date(v); if (isNaN(d)) return v
|
||||
const p = (n) => String(n).padStart(2, '0')
|
||||
return `${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
||||
}
|
||||
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({'&':'&','<':'<','>':'>','"':'"'}[c])) }
|
||||
|
||||
async function loadWorkflows() {
|
||||
try {
|
||||
const payload = await req('GET', '/bridge/workflows')
|
||||
state.workflows = payload.workflows || []
|
||||
$('#generated').textContent = payload.generated_at ? ('更新于 ' + fmtTime(payload.generated_at)) : ''
|
||||
renderList()
|
||||
} catch (e) {
|
||||
$('#list').innerHTML = '<p class="err" style="padding:12px">无法连接桥接服务:' + esc(e.message) + '<br>请确认 gyxx console 与 dsh workbench 插件已启动。</p>'
|
||||
}
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const failed = state.workflows.filter((w) => statusOf(w) === 'failed').length
|
||||
const running = state.workflows.filter((w) => w.active_run).length
|
||||
$('#pills').innerHTML =
|
||||
`<span class="pill">共 ${state.workflows.length}</span>` +
|
||||
(running ? `<span class="pill pill-running">运行中 ${running}</span>` : '') +
|
||||
(failed ? `<span class="pill pill-failed">失败 ${failed}</span>` : '')
|
||||
const byModule = new Map()
|
||||
for (const w of state.workflows) {
|
||||
const key = w.module_label || w.module
|
||||
if (!byModule.has(key)) byModule.set(key, [])
|
||||
byModule.get(key).push(w)
|
||||
}
|
||||
let html = ''
|
||||
for (const [label, items] of byModule) {
|
||||
html += `<div class="group-label">${esc(label)}</div>`
|
||||
for (const w of items) {
|
||||
const st = statusOf(w)
|
||||
html += `<button class="row ${state.selected === w.id ? 'selected' : ''}" onclick="select('${esc(w.id)}')">
|
||||
<span class="dot dot-${st}"></span>
|
||||
<span><span class="name">${esc(w.name)}</span><span class="id">${esc(w.id)}</span></span>
|
||||
<span class="meta">${w.last_run ? fmtTime(w.last_run.ended_at || w.last_run.started_at) : '—'}</span>
|
||||
</button>`
|
||||
}
|
||||
}
|
||||
$('#list').innerHTML = html || '<p class="muted" style="padding:12px">未发现工作流</p>'
|
||||
}
|
||||
|
||||
async function select(id) {
|
||||
state.selected = id
|
||||
renderList()
|
||||
$('#detail').innerHTML = '<p class="muted" style="padding:16px">加载中…</p>'
|
||||
try {
|
||||
const [detail, runs] = await Promise.all([
|
||||
req('GET', '/bridge/workflows/' + encodeURIComponent(id)).catch(() => null),
|
||||
req('GET', '/bridge/workflows/' + encodeURIComponent(id) + '/runs?limit=6').catch(() => ({ runs: [] })),
|
||||
])
|
||||
state.detail = detail
|
||||
state.runs = runs.runs || []
|
||||
renderDetail()
|
||||
} catch (e) {
|
||||
$('#detail').innerHTML = '<p class="err" style="padding:16px">' + esc(e.message) + '</p>'
|
||||
}
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
const w = state.workflows.find((x) => x.id === state.selected)
|
||||
if (!w) return
|
||||
const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1)
|
||||
const p = (n) => String(n).padStart(2, '0')
|
||||
const defaultDate = `${yesterday.getFullYear()}-${p(yesterday.getMonth()+1)}-${p(yesterday.getDate())}`
|
||||
const sched = w.schedule
|
||||
let html = `<div class="card">
|
||||
<h2 style="font-size:15px;margin:0 0 4px">${esc(w.name)}</h2>
|
||||
<div class="muted">${esc(w.id)} · 调度 ${sched ? (sched.enabled ? '启用' : '停用') + ' ' + (Array.isArray(sched.at) ? sched.at.join('/') : sched.at || '') : '无'}</div>
|
||||
<div style="margin:10px 0">
|
||||
<button class="act" onclick="ask('diagnose')">诊断</button>
|
||||
<button class="act" onclick="ask('repair')">修复</button>
|
||||
<button class="act" onclick="triggerRun(false)">试运行</button>
|
||||
<button class="act danger" onclick="triggerRun(true)">正式运行</button>
|
||||
${w.active_run ? '<button class="act danger" onclick="cancelRun()">停止</button>' : ''}
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-bottom:8px">
|
||||
<input id="q" style="flex:1" placeholder="就这个工作流提问,例如:昨天为什么失败?" />
|
||||
<button class="act" onclick="ask('ask')">提问</button>
|
||||
</div>
|
||||
<div class="muted">业务日期 <input id="bd" value="${defaultDate}" style="width:110px" /></div>
|
||||
</div>
|
||||
<div class="card"><div class="muted" style="margin-bottom:6px">最近运行(点击查看诊断)</div>`
|
||||
if (!state.runs.length) html += '<div class="muted">暂无运行记录</div>'
|
||||
for (const r of state.runs) {
|
||||
html += `<button class="row" onclick="diagnose('${esc(r.run_id)}')">
|
||||
<span class="dot dot-${r.status}"></span><span>${esc(r.business_date)}</span>
|
||||
<span class="muted">${r.mode === 'execute' ? '正式' : '试运行'}</span>
|
||||
${r.error ? `<span class="err">${esc(r.error.slice(0, 80))}</span>` : ''}
|
||||
</button>`
|
||||
}
|
||||
html += '</div><div id="diag"></div>'
|
||||
$('#detail').innerHTML = html
|
||||
}
|
||||
|
||||
async function diagnose(runId) {
|
||||
$('#diag').innerHTML = '<div class="card muted">加载诊断数据…</div>'
|
||||
try {
|
||||
const d = await req('GET', '/bridge/workflows/' + encodeURIComponent(state.selected) + '/diagnosis?run_id=' + encodeURIComponent(runId))
|
||||
let html = '<div class="card"><div class="muted">诊断:' + esc(runId) + '</div>'
|
||||
const steps = (d.run && d.run.steps) || []
|
||||
for (const s of steps.filter((x) => x.status === 'failed')) {
|
||||
html += `<div class="err">✗ ${esc(s.id)}(退出码 ${s.exit_code == null ? '—' : s.exit_code})</div>${s.error ? '<pre>' + esc(s.error) + '</pre>' : ''}`
|
||||
}
|
||||
for (const log of d.logs || []) {
|
||||
html += `<div class="muted" style="margin-top:8px">${esc(log.path)}</div><pre>${esc(log.tail)}</pre>`
|
||||
}
|
||||
if (!steps.some((x) => x.status === 'failed') && !(d.logs || []).length) html += '<div class="muted">该运行没有失败步骤或日志</div>'
|
||||
$('#diag').innerHTML = html + '</div>'
|
||||
} catch (e) {
|
||||
$('#diag').innerHTML = '<div class="card err">' + esc(e.message) + '</div>'
|
||||
}
|
||||
}
|
||||
|
||||
async function ask(action) {
|
||||
const question = ($('#q') && $('#q').value) || ''
|
||||
try {
|
||||
const payload = await req('POST', '/bridge/ask', { workflow_id: state.selected, action, question })
|
||||
toast('已创建会话(' + String(payload.session_id).slice(0, 24) + '…),请在 dsh 侧边栏会话列表中查看', 'ok')
|
||||
} catch (e) { toast('创建会话失败:' + e.message, 'err') }
|
||||
}
|
||||
|
||||
async function triggerRun(execute) {
|
||||
const businessDate = ($('#bd') && $('#bd').value) || ''
|
||||
if (execute && !confirm(`正式执行 ${state.selected}(业务日期 ${businessDate})会产生真实外部写入。确认继续?`)) return
|
||||
try {
|
||||
await req('POST', '/bridge/trigger', { workflow_id: state.selected, business_date: businessDate, execute, confirmed: execute })
|
||||
toast(execute ? '已发起正式执行' : '已发起试运行(无外部副作用)', 'ok')
|
||||
setTimeout(loadWorkflows, 1500)
|
||||
} catch (e) { toast('启动失败:' + e.message, 'err') }
|
||||
}
|
||||
|
||||
async function cancelRun() {
|
||||
const w = state.workflows.find((x) => x.id === state.selected)
|
||||
const body = w && w.active_run && w.active_run.operation_id
|
||||
? { workflow_id: state.selected, operation_id: w.active_run.operation_id }
|
||||
: { workflow_id: state.selected, scheduled: true }
|
||||
try {
|
||||
await req('POST', '/bridge/cancel', body)
|
||||
toast('已发送停止指令', 'ok')
|
||||
setTimeout(loadWorkflows, 1500)
|
||||
} catch (e) { toast('停止失败:' + e.message, 'err') }
|
||||
}
|
||||
|
||||
async function pollAlerts() {
|
||||
try {
|
||||
const payload = await req('GET', '/bridge/alerts')
|
||||
for (const alert of payload.alerts || []) {
|
||||
if (alert.seen || state.seenAlerts[alert.id]) continue
|
||||
state.seenAlerts[alert.id] = true
|
||||
toast('工作流「' + alert.name + '」运行失败,可在 dsh 中发起诊断', 'err')
|
||||
req('POST', '/bridge/alerts/seen', { id: alert.id }).catch(() => {})
|
||||
}
|
||||
} catch (e) { /* 桥接离线时静默 */ }
|
||||
}
|
||||
|
||||
loadWorkflows()
|
||||
setInterval(loadWorkflows, 15000)
|
||||
setInterval(pollAlerts, 30000)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user