diff --git a/workbench/plugin/client/src/index.jsx b/workbench/plugin/client/src/index.jsx
index e6b14a7..42a9e52 100644
--- a/workbench/plugin/client/src/index.jsx
+++ b/workbench/plugin/client/src/index.jsx
@@ -60,12 +60,26 @@ function WorkbenchOverlay() {
export function apply(ctx) {
injectStyles()
+ // 插槽须等宿主声明后才能注册:slots.inject 会等待座位声明(顺序安全),
+ // 直接 register 在我们先于 ui-sidebar/ui-layout 加载时会报“slot is not declared”。
ctx.effect(
- () => ctx.slots.register({ name: 'sidebar.footer.action' }, WorkbenchFooterAction),
+ () =>
+ ctx.slots.inject('sidebar.footer.action', () =>
+ ctx.slots.register(
+ { name: 'sidebar.footer.action', id: 'gyxx-workbench.footer' },
+ WorkbenchFooterAction,
+ ),
+ ),
'gyxx-workbench: footer action',
)
ctx.effect(
- () => ctx.slots.register({ name: 'shell.overlay' }, WorkbenchOverlay),
+ () =>
+ ctx.slots.inject('shell.overlay', () =>
+ ctx.slots.register(
+ { name: 'shell.overlay', id: 'gyxx-workbench.overlay' },
+ WorkbenchOverlay,
+ ),
+ ),
'gyxx-workbench: overlay',
)
}
diff --git a/workbench/plugin/lib/client.js b/workbench/plugin/lib/client.js
index 6000fa6..ab80fa2 100644
--- a/workbench/plugin/lib/client.js
+++ b/workbench/plugin/lib/client.js
@@ -556,11 +556,23 @@ function WorkbenchOverlay() {
function apply(ctx) {
injectStyles();
ctx.effect(
- () => ctx.slots.register({ name: "sidebar.footer.action" }, WorkbenchFooterAction),
+ () => ctx.slots.inject(
+ "sidebar.footer.action",
+ () => ctx.slots.register(
+ { name: "sidebar.footer.action", id: "gyxx-workbench.footer" },
+ WorkbenchFooterAction
+ )
+ ),
"gyxx-workbench: footer action"
);
ctx.effect(
- () => ctx.slots.register({ name: "shell.overlay" }, WorkbenchOverlay),
+ () => ctx.slots.inject(
+ "shell.overlay",
+ () => ctx.slots.register(
+ { name: "shell.overlay", id: "gyxx-workbench.overlay" },
+ WorkbenchOverlay
+ )
+ ),
"gyxx-workbench: overlay"
);
}
diff --git a/workbench/plugin/lib/client.js.map b/workbench/plugin/lib/client.js.map
index 254c15e..2dbb036 100644
--- a/workbench/plugin/lib/client.js.map
+++ b/workbench/plugin/lib/client.js.map
@@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../client/src/index.jsx", "../client/src/Panel.jsx", "../client/src/store.js", "../client/src/api.js", "../client/src/Toasts.jsx", "../client/src/styles.css"],
- "sourcesContent": ["/**\n * GYXX \u5DE5\u4F5C\u53F0\u5BA2\u6237\u7AEF\u63D2\u4EF6\u5165\u53E3\u3002\n * \u7531 esbuild \u6253\u5305\u4E3A CJS \u5E76\u901A\u8FC7 window.__ModuleLoader__ \u6CE8\u518C\uFF08\u89C1 scripts/build-client.mjs\uFF09\u3002\n * \u8FD0\u884C\u65F6\u53EA\u4F9D\u8D56 dsh \u5E73\u53F0\u5171\u4EAB\u6A21\u5757\u8868\u4E2D\u7684 react\u3002\n */\n\nimport { createElement, useSyncExternalStore } from 'react'\nimport { WorkflowPanel, refreshWorkflows } from './Panel.jsx'\nimport { Toasts } from './Toasts.jsx'\nimport { getState, subscribe, update } from './store.js'\nimport stylesText from './styles.css'\n\nexport const inject = ['slots']\n\nfunction useWorkbench() {\n return useSyncExternalStore(subscribe, getState)\n}\n\nfunction injectStyles() {\n if (typeof document === 'undefined') return\n if (document.getElementById('gyxxwb-styles')) return\n const style = document.createElement('style')\n style.id = 'gyxxwb-styles'\n style.textContent = stylesText\n document.head.appendChild(style)\n}\n\n/** \u4FA7\u8FB9\u680F\u5E95\u90E8\u52A8\u4F5C\uFF1A\u5DE5\u4F5C\u6D41\u9762\u677F\u5F00\u5173 + \u805A\u5408\u72B6\u6001\u70B9\u3002 */\nfunction WorkbenchFooterAction() {\n const s = useWorkbench()\n const failed = s.workflows.filter((w) => !w.active_run && w.last_run?.status === 'failed').length\n const running = s.workflows.filter((w) => w.active_run).length\n const tone = failed > 0 ? 'failed' : running > 0 ? 'running' : 'success'\n return createElement(\n 'button',\n {\n type: 'button',\n className: `gyxxwb-footer-btn${s.open ? ' gyxxwb-footer-btn-active' : ''}`,\n title: 'GYXX \u5DE5\u4F5C\u6D41\u5DE5\u4F5C\u53F0',\n onClick: () => {\n const next = !getState().open\n update({ open: next })\n if (next) refreshWorkflows()\n },\n },\n createElement('span', { className: `gyxxwb-dot gyxxwb-dot-${tone}` }),\n createElement('span', null, '\u5DE5\u4F5C\u6D41'),\n )\n}\n\n/** \u8986\u76D6\u5C42\u6839\uFF1A\u5DE6\u4FA7\u62BD\u5C49\u9762\u677F + \u5931\u8D25 toast\u3002 */\nfunction WorkbenchOverlay() {\n return createElement(\n 'div',\n { className: 'gyxxwb-overlay' },\n createElement(WorkflowPanel, null),\n createElement(Toasts, null),\n )\n}\n\nexport function apply(ctx) {\n injectStyles()\n ctx.effect(\n () => ctx.slots.register({ name: 'sidebar.footer.action' }, WorkbenchFooterAction),\n 'gyxx-workbench: footer action',\n )\n ctx.effect(\n () => ctx.slots.register({ name: 'shell.overlay' }, WorkbenchOverlay),\n 'gyxx-workbench: overlay',\n )\n}\n", "/** \u5DE5\u4F5C\u6D41\u9762\u677F\uFF1A\u4FA7\u8FB9\u62BD\u5C49\uFF0C\u5217\u51FA\u5168\u90E8\u5DE5\u4F5C\u6D41\uFF0C\u9009\u4E2D\u540E\u53EF\u63D0\u95EE / \u8BCA\u65AD / \u4FEE\u590D / \u542F\u505C\u3002 */\n\nimport { useSyncExternalStore, useEffect, useCallback } from 'react'\nimport { getState, subscribe, update, pushToast } from './store.js'\nimport { api } from './api.js'\n\nfunction useWorkbench() {\n return useSyncExternalStore(subscribe, getState)\n}\n\nconst STATUS_LABELS = {\n success: '\u6210\u529F',\n failed: '\u5931\u8D25',\n running: '\u8FD0\u884C\u4E2D',\n cancelled: '\u5DF2\u505C\u6B62',\n none: '\u672A\u8FD0\u884C',\n}\n\nfunction statusOf(workflow) {\n if (workflow.active_run) return 'running'\n return workflow.last_run?.status ?? 'none'\n}\n\nfunction formatTime(value) {\n if (!value) return '\u2014'\n const date = new Date(value)\n if (Number.isNaN(date.getTime())) return String(value)\n const pad = (n) => String(n).padStart(2, '0')\n return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`\n}\n\nfunction formatDuration(seconds) {\n if (seconds == null) return ''\n if (seconds < 60) return `${seconds}s`\n return `${Math.floor(seconds / 60)}m${seconds % 60 ? `${seconds % 60}s` : ''}`\n}\n\nexport async function refreshWorkflows() {\n update({ loading: true, error: null })\n try {\n const payload = await api.listWorkflows()\n update({\n loading: false,\n workflows: payload.workflows ?? [],\n summary: payload.summary ?? null,\n generatedAt: payload.generated_at ?? null,\n })\n } catch (error) {\n update({ loading: false, error: error.message })\n }\n}\n\nasync function selectWorkflow(workflowId) {\n update({ selectedId: workflowId, detailLoading: true, diagnosis: null, runs: [] })\n try {\n const [detail, runs] = await Promise.all([\n api.workflowDetail(workflowId).catch(() => null),\n api.workflowRuns(workflowId, 6).catch(() => ({ runs: [] })),\n ])\n update({ detailLoading: false, detail, runs: runs.runs ?? [] })\n } catch (error) {\n update({ detailLoading: false, error: error.message })\n }\n}\n\nasync function loadDiagnosis(workflowId, runId) {\n update({ diagnosisLoading: true })\n try {\n const payload = await api.diagnosis(workflowId, runId)\n update({ diagnosisLoading: false, diagnosis: payload })\n } catch (error) {\n update({ diagnosisLoading: false })\n pushToast({ kind: 'error', text: `\u8BCA\u65AD\u6570\u636E\u52A0\u8F7D\u5931\u8D25\uFF1A${error.message}` })\n }\n}\n\nasync function doAsk(action) {\n const s = getState()\n if (!s.selectedId) return\n const question = s.question.trim()\n try {\n const payload = await api.ask({\n workflow_id: s.selectedId,\n action,\n question,\n })\n const label = { ask: '\u63D0\u95EE', diagnose: '\u8BCA\u65AD', repair: '\u4FEE\u590D' }[action] ?? '\u4F1A\u8BDD'\n pushToast({\n kind: 'ok',\n text: `\u5DF2\u521B\u5EFA${label}\u4F1A\u8BDD\uFF08${String(payload.session_id).slice(0, 18)}\u2026\uFF09\uFF0C\u8BF7\u5728\u5DE6\u4FA7\u4F1A\u8BDD\u5217\u8868\u4E2D\u67E5\u770B`,\n })\n update({ question: '' })\n } catch (error) {\n pushToast({ kind: 'error', text: `\u521B\u5EFA\u4F1A\u8BDD\u5931\u8D25\uFF1A${error.message}` })\n }\n}\n\nasync function doTrigger(execute) {\n const s = getState()\n if (!s.selectedId) return\n if (execute) {\n const ok = window.confirm(\n `\u6B63\u5F0F\u6267\u884C ${s.selectedId}\uFF08\u4E1A\u52A1\u65E5\u671F ${s.businessDate}\uFF09\u4F1A\u5BF9\u5916\u90E8\u7CFB\u7EDF\u4EA7\u751F\u771F\u5B9E\u5199\u5165\u3002\u786E\u8BA4\u7EE7\u7EED\uFF1F`,\n )\n if (!ok) return\n }\n try {\n await api.trigger({\n workflow_id: s.selectedId,\n business_date: s.businessDate,\n execute,\n confirmed: execute,\n })\n pushToast({ kind: 'ok', text: execute ? '\u5DF2\u53D1\u8D77\u6B63\u5F0F\u6267\u884C' : '\u5DF2\u53D1\u8D77\u8BD5\u8FD0\u884C\uFF08\u65E0\u5916\u90E8\u526F\u4F5C\u7528\uFF09' })\n setTimeout(refreshWorkflows, 1500)\n } catch (error) {\n pushToast({ kind: 'error', text: `\u542F\u52A8\u5931\u8D25\uFF1A${error.message}` })\n }\n}\n\nasync function doCancel() {\n const s = getState()\n const active = s.detail?.workflow?.active_run\n if (!s.selectedId) return\n try {\n if (active?.operation_id) {\n await api.cancel({ workflow_id: s.selectedId, operation_id: active.operation_id })\n } else {\n await api.cancel({ workflow_id: s.selectedId, scheduled: true })\n }\n pushToast({ kind: 'ok', text: '\u5DF2\u53D1\u9001\u505C\u6B62\u6307\u4EE4' })\n setTimeout(refreshWorkflows, 1500)\n } catch (error) {\n pushToast({ kind: 'error', text: `\u505C\u6B62\u5931\u8D25\uFF1A${error.message}` })\n }\n}\n\nfunction SummaryPills({ summary, workflows }) {\n const failed = workflows.filter((w) => statusOf(w) === 'failed').length\n const running = workflows.filter((w) => w.active_run).length\n return (\n
\n \u5171 {summary?.total ?? workflows.length}\n {running > 0 && \u8FD0\u884C\u4E2D {running}}\n {failed > 0 && \u5931\u8D25 {failed}}\n
\n )\n}\n\nfunction WorkflowRow({ workflow, selected, onSelect }) {\n const status = statusOf(workflow)\n return (\n \n )\n}\n\nfunction RunList({ runs, onDiagnose }) {\n if (!runs.length) return \u6682\u65E0\u8FD0\u884C\u8BB0\u5F55
\n return (\n \n {runs.map((run) => (\n \n ))}\n
\n )\n}\n\nfunction DiagnosisView({ diagnosis, loading }) {\n if (loading) return \u6B63\u5728\u52A0\u8F7D\u8BCA\u65AD\u6570\u636E\u2026
\n if (!diagnosis) return null\n if (diagnosis.message) return {diagnosis.message}
\n const run = diagnosis.run ?? {}\n const steps = Array.isArray(run.steps) ? run.steps : []\n const logs = Array.isArray(diagnosis.logs) ? diagnosis.logs : []\n return (\n \n
\n \u8BCA\u65AD\uFF1A{run.run_id}\uFF08{run.status}\uFF09\n
\n {steps\n .filter((step) => step.status === 'failed')\n .map((step) => (\n
\n
\n \u2717 {step.id}\uFF08\u9000\u51FA\u7801 {step.exit_code ?? '\u2014'}\uFF09\n
\n {step.error &&
{step.error}}\n
\n ))}\n {logs.map((log) => (\n
\n
{log.path}
\n
{log.tail}\n
\n ))}\n {!steps.some((s) => s.status === 'failed') && !logs.length && (\n
\u8BE5\u8FD0\u884C\u6CA1\u6709\u5931\u8D25\u6B65\u9AA4\u6216\u65E5\u5FD7
\n )}\n
\n )\n}\n\nexport function WorkflowPanel() {\n const s = useWorkbench()\n\n useEffect(() => {\n if (!s.open) return undefined\n refreshWorkflows()\n const timer = setInterval(refreshWorkflows, 10_000)\n return () => clearInterval(timer)\n }, [s.open])\n\n const onSelect = useCallback((workflowId) => {\n selectWorkflow(workflowId)\n }, [])\n\n const groups = []\n const byModule = new Map()\n for (const workflow of s.workflows) {\n const key = workflow.module_label ?? workflow.module\n if (!byModule.has(key)) byModule.set(key, [])\n byModule.get(key).push(workflow)\n }\n for (const [label, items] of byModule) groups.push({ label, items })\n\n const selected = s.workflows.find((w) => w.id === s.selectedId) ?? null\n\n return (\n \n
\n GYXX \u5DE5\u4F5C\u6D41\n \n \n \n
\n {s.error && (\n
\n \u65E0\u6CD5\u8FDE\u63A5\u5DE5\u4F5C\u53F0\u6865\u63A5\u670D\u52A1\uFF1A{s.error}\n
\n \u8BF7\u786E\u8BA4 dsh \u5DF2\u901A\u8FC7 workbench/cordis.yml \u542F\u52A8\u3002\n
\n )}\n
\n
\n {groups.map((group) => (\n
\n
{group.label}
\n {group.items.map((workflow) => (\n
\n ))}\n
\n ))}\n {!s.loading && !s.workflows.length && !s.error && (\n
\u672A\u53D1\u73B0\u5DE5\u4F5C\u6D41
\n )}\n
\n {selected && (\n
\n
\n {selected.name}\n \n {STATUS_LABELS[statusOf(selected)]}\n \n
\n
\n {selected.id} \u00B7 \u8C03\u5EA6{' '}\n {selected.schedule\n ? `${selected.schedule.enabled ? '\u542F\u7528' : '\u505C\u7528'} ${\n Array.isArray(selected.schedule.at)\n ? selected.schedule.at.join('/')\n : (selected.schedule.at ?? '')\n }`\n : '\u65E0'}\n {selected.next_run_at ? ` \u00B7 \u4E0B\u6B21 ${formatTime(selected.next_run_at)}` : ''}\n
\n
\n \n \n \n \n {selected.active_run && (\n \n )}\n
\n
\n update({ question: event.target.value })}\n onKeyDown={(event) => {\n if (event.key === 'Enter') doAsk('ask')\n }}\n />\n \n
\n
\n \u4E1A\u52A1\u65E5\u671F{' '}\n update({ businessDate: event.target.value })}\n pattern=\"\\d{4}-\\d{2}-\\d{2}\"\n />\n
\n
\u6700\u8FD1\u8FD0\u884C
\n
loadDiagnosis(selected.id, runId)} />\n \n \n )}\n
\n
\n )\n}\n", "/**\n * GYXX \u5DE5\u4F5C\u53F0\u9762\u677F \u2014\u2014 \u6781\u7B80\u5916\u90E8\u5B58\u50A8\uFF08\u907F\u514D\u5411\u5BA2\u6237\u7AEF\u5305\u5F15\u5165\u989D\u5916\u4F9D\u8D56\uFF09\u3002\n * React 18 \u7684 useSyncExternalStore \u76F4\u63A5\u8BA2\u9605\u3002\n */\n\nconst listeners = new Set()\n\nlet state = {\n open: false,\n loading: false,\n error: null,\n generatedAt: null,\n summary: null,\n workflows: [],\n selectedId: null,\n detail: null,\n detailLoading: false,\n runs: [],\n diagnosis: null,\n diagnosisLoading: false,\n question: '',\n businessDate: defaultBusinessDate(),\n alerts: [],\n seenAlertIds: {},\n toasts: [],\n}\n\nexport function defaultBusinessDate() {\n const now = new Date()\n now.setDate(now.getDate() - 1)\n const pad = (value) => String(value).padStart(2, '0')\n return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`\n}\n\nexport function getState() {\n return state\n}\n\nexport function update(patch) {\n state = { ...state, ...patch }\n for (const listener of listeners) listener()\n}\n\nexport function subscribe(listener) {\n listeners.add(listener)\n return () => listeners.delete(listener)\n}\n\nexport function pushToast(toast) {\n const id = `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`\n update({ toasts: [...state.toasts, { id, ...toast }] })\n return id\n}\n\nexport function dismissToast(id) {\n update({ toasts: state.toasts.filter((toast) => toast.id !== id) })\n}\n", "/** \u5DE5\u4F5C\u53F0\u6865\u63A5\u670D\u52A1\u5BA2\u6237\u7AEF\uFF1A\u6240\u6709\u6570\u636E\u7ECF\u7531 gyxx-workbench \u5BBF\u4E3B\u63D2\u4EF6\u7684\u672C\u673A\u56DE\u73AF\u6865\u63A5\u3002 */\n\nexport function bridgeBase() {\n if (typeof window !== 'undefined') {\n if (window.__GYXX_WB_BRIDGE__) return window.__GYXX_WB_BRIDGE__\n try {\n const saved = window.localStorage?.getItem('gyxx.wb.bridge')\n if (saved) return saved\n } catch {\n // localStorage \u4E0D\u53EF\u7528\u65F6\u4F7F\u7528\u9ED8\u8BA4\u503C\n }\n }\n return 'http://127.0.0.1:8790'\n}\n\nasync function request(method, path, body) {\n const response = await fetch(`${bridgeBase()}${path}`, {\n method,\n headers: {\n accept: 'application/json',\n ...(body !== undefined\n ? { 'content-type': 'application/json', 'x-gyxx-workbench': '1' }\n : {}),\n },\n body: body === undefined ? undefined : JSON.stringify(body),\n })\n let payload\n try {\n payload = await response.json()\n } catch {\n payload = { ok: false, error: `\u6865\u63A5\u670D\u52A1\u8FD4\u56DE\u4E86\u975E JSON \u5185\u5BB9\uFF08HTTP ${response.status}\uFF09` }\n }\n if (!response.ok) {\n throw new Error(payload?.error ?? `\u6865\u63A5\u670D\u52A1\u9519\u8BEF\uFF08HTTP ${response.status}\uFF09`)\n }\n return payload\n}\n\nexport const api = {\n listWorkflows: () => request('GET', '/bridge/workflows'),\n workflowDetail: (workflowId) =>\n request('GET', `/bridge/workflows/${encodeURIComponent(workflowId)}`),\n workflowRuns: (workflowId, limit = 6) =>\n request('GET', `/bridge/workflows/${encodeURIComponent(workflowId)}/runs?limit=${limit}`),\n diagnosis: (workflowId, runId) =>\n request(\n 'GET',\n `/bridge/workflows/${encodeURIComponent(workflowId)}/diagnosis${runId ? `?run_id=${encodeURIComponent(runId)}` : ''}`,\n ),\n alerts: () => request('GET', '/bridge/alerts'),\n markAlertSeen: (id) => request('POST', '/bridge/alerts/seen', { id }),\n trigger: (payload) => request('POST', '/bridge/trigger', payload),\n cancel: (payload) => request('POST', '/bridge/cancel', payload),\n ask: (payload) => request('POST', '/bridge/ask', payload),\n}\n", "/** \u5931\u8D25\u544A\u8B66\u6D6E\u5C42\uFF1A\u8F6E\u8BE2\u6865\u63A5\u544A\u8B66\uFF0C\u65B0\u5931\u8D25\u5F39\u51FA toast\uFF0C\u53EF\u4E00\u952E\u53D1\u8D77\u8BCA\u65AD\u4F1A\u8BDD\u3002 */\n\nimport { useEffect } from 'react'\nimport { useSyncExternalStore } from 'react'\nimport { getState, subscribe, update, dismissToast, pushToast } from './store.js'\nimport { api } from './api.js'\n\nfunction useWorkbench() {\n return useSyncExternalStore(subscribe, getState)\n}\n\nexport function Toasts() {\n const s = useWorkbench()\n\n useEffect(() => {\n let stopped = false\n let timer = null\n const poll = async () => {\n try {\n const payload = await api.alerts()\n const fresh = (payload.alerts ?? []).filter(\n (alert) => !alert.seen && !getState().seenAlertIds[alert.id],\n )\n if (fresh.length) {\n const seen = { ...getState().seenAlertIds }\n for (const alert of fresh) {\n seen[alert.id] = true\n pushToast({ kind: 'failed', text: `\u5DE5\u4F5C\u6D41\u300C${alert.name}\u300D\u8FD0\u884C\u5931\u8D25`, alert })\n }\n update({ seenAlertIds: seen })\n }\n } catch {\n // \u6865\u63A5\u79BB\u7EBF\u65F6\u9759\u9ED8\n }\n if (!stopped) timer = setTimeout(poll, 30_000)\n }\n timer = setTimeout(poll, 8_000)\n return () => {\n stopped = true\n if (timer) clearTimeout(timer)\n }\n }, [])\n\n if (!s.toasts.length) return null\n return (\n \n {s.toasts.map((toast) => (\n
\n {toast.text}\n {toast.alert && (\n \n )}\n \n
\n ))}\n
\n )\n}\n", "/* GYXX \u5DE5\u4F5C\u53F0\u9762\u677F\u6837\u5F0F \u2014\u2014 \u4EE5\u6587\u672C\u5F62\u5F0F\u5185\u8054\u8FDB\u5BA2\u6237\u7AEF\u5305\uFF0C\u8FD0\u884C\u65F6\u6CE8\u5165