feat: integrate codex queue and pipeline oa flow
- add Codex Queue microservice/frontend integration and related deployment docs - document 100% Pipeline OA event-flow requirements and E2E gates - harden Pipeline frontend Gantt/timeline E2E assertions and rendering
This commit is contained in:
@@ -1110,7 +1110,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
.result-card dd { margin: 0; }
|
||||
.result-grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
|
||||
|
||||
.microservice-page, .findjob-page, .pipeline-page, .met-page {
|
||||
.microservice-page, .findjob-page, .pipeline-page, .met-page, .codex-queue-page {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -1159,7 +1159,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
.pipeline-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(5) { grid-column: 1 / -1; }
|
||||
.pipeline-grid .pipeline-wide-panel { grid-column: 1 / -1; }
|
||||
.met-grid {
|
||||
display: grid;
|
||||
@@ -1205,6 +1204,173 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
color: var(--accent-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
.codex-queue-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 1.35fr) minmax(260px, 0.8fr) minmax(220px, 0.65fr);
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.codex-queue-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.codex-queue-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 0.52fr) minmax(680px, 1.58fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
.codex-left-rail,
|
||||
.codex-main-stage,
|
||||
.codex-detail-grid,
|
||||
.codex-task-form,
|
||||
.codex-steer-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.codex-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 0.8fr) minmax(180px, 1fr) 92px;
|
||||
gap: 8px;
|
||||
}
|
||||
.codex-task-list {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
max-height: calc(100vh - 460px);
|
||||
min-height: 180px;
|
||||
overflow: auto;
|
||||
align-content: start;
|
||||
}
|
||||
.codex-task-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 9px;
|
||||
border: 1px solid var(--line-soft);
|
||||
color: var(--muted);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(78, 183, 168, 0.08), transparent 38%),
|
||||
rgba(10, 16, 21, 0.74);
|
||||
text-align: left;
|
||||
}
|
||||
.codex-task-card:hover,
|
||||
.codex-task-card.selected {
|
||||
color: var(--text);
|
||||
border-color: var(--accent-2);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(78, 183, 168, 0.16), transparent 42%),
|
||||
rgba(12, 24, 28, 0.9);
|
||||
}
|
||||
.codex-task-card-head,
|
||||
.codex-task-meta,
|
||||
.codex-judge-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.codex-task-card strong {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text);
|
||||
}
|
||||
.codex-task-meta,
|
||||
.codex-judge-line {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.codex-output-panel .panel-body {
|
||||
padding: 0;
|
||||
}
|
||||
.codex-transcript {
|
||||
min-height: 520px;
|
||||
max-height: calc(100vh - 300px);
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
background:
|
||||
linear-gradient(rgba(78, 183, 168, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(78, 183, 168, 0.026) 1px, transparent 1px),
|
||||
radial-gradient(circle at top right, rgba(215, 161, 58, 0.10), transparent 34%),
|
||||
#060a0d;
|
||||
background-size: 24px 24px, 24px 24px, auto, auto;
|
||||
}
|
||||
.codex-output-empty {
|
||||
padding: 24px;
|
||||
border: 1px dashed var(--line);
|
||||
color: var(--muted);
|
||||
background: rgba(255,255,255,0.025);
|
||||
}
|
||||
.codex-output-line {
|
||||
display: grid;
|
||||
grid-template-columns: 130px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.045);
|
||||
}
|
||||
.codex-output-meta {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
.codex-output-meta code {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.codex-output-channel {
|
||||
width: max-content;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid rgba(78, 183, 168, 0.42);
|
||||
color: var(--accent-2);
|
||||
background: rgba(78, 183, 168, 0.08);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.codex-output-line.user .codex-output-channel { color: var(--accent); border-color: rgba(215, 161, 58, 0.48); background: rgba(215, 161, 58, 0.08); }
|
||||
.codex-output-line.error .codex-output-channel { color: var(--danger); border-color: rgba(207, 106, 84, 0.52); background: rgba(207, 106, 84, 0.08); }
|
||||
.codex-output-line.command .codex-output-channel { color: #8fc7ee; border-color: rgba(105, 174, 232, 0.46); background: rgba(105, 174, 232, 0.08); }
|
||||
.codex-output-line.diff .codex-output-channel { color: #b6da89; border-color: rgba(182, 218, 137, 0.42); background: rgba(182, 218, 137, 0.07); }
|
||||
.codex-output-line pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
color: #d9e8e7;
|
||||
font-size: 12px;
|
||||
line-height: 1.48;
|
||||
}
|
||||
.codex-output-line.reasoning pre { color: #9fb5b8; font-style: italic; }
|
||||
.codex-detail-grid {
|
||||
grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr);
|
||||
}
|
||||
.codex-judge-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: var(--panel-3);
|
||||
}
|
||||
.codex-judge-card p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.codex-attempt-table {
|
||||
max-height: 260px;
|
||||
}
|
||||
.inline-check {
|
||||
display: inline-flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
.inline-check input {
|
||||
width: auto;
|
||||
}
|
||||
.mono-text {
|
||||
font-family: "SFMono-Regular", "Consolas", monospace;
|
||||
color: var(--muted);
|
||||
}
|
||||
.met-control-strip, .met-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1797,9 +1963,15 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
opacity: 0.82;
|
||||
}
|
||||
.pipeline-gantt-arrow.monitor,
|
||||
.pipeline-gantt-arrow.guide {
|
||||
.pipeline-gantt-arrow.guide,
|
||||
.pipeline-gantt-arrow.observe {
|
||||
stroke: var(--accent-2);
|
||||
}
|
||||
.pipeline-gantt-arrow.observation {
|
||||
opacity: 0.92;
|
||||
stroke-width: 1.9;
|
||||
stroke-dasharray: 3 4;
|
||||
}
|
||||
.pipeline-gantt-arrow.webui {
|
||||
stroke: #69aee8;
|
||||
}
|
||||
@@ -1859,7 +2031,20 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
color: #ffe3db;
|
||||
}
|
||||
.pipeline-gantt-bar.running {
|
||||
animation: ganttPulse 1.8s ease-in-out infinite;
|
||||
border-color: rgba(105, 174, 232, 0.95);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(105, 174, 232, 0.95), rgba(35, 94, 133, 0.86)),
|
||||
#07131d;
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.32), 0 0 18px rgba(105, 174, 232, 0.38);
|
||||
animation: ganttPulse 1.25s ease-in-out infinite;
|
||||
}
|
||||
.pipeline-gantt-bar.running.live::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -18px 0;
|
||||
background: linear-gradient(180deg, transparent, rgba(255,255,255,0.82), transparent);
|
||||
opacity: 0.7;
|
||||
animation: ganttLiveSweep 1.4s linear infinite;
|
||||
}
|
||||
.pipeline-gantt-bar.selected {
|
||||
width: 10px;
|
||||
@@ -2054,6 +2239,46 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.pipeline-oa-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.pipeline-oa-guarantees {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
}
|
||||
.pipeline-oa-guarantee {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 7px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: rgba(255,255,255,0.026);
|
||||
}
|
||||
.pipeline-oa-guarantee.ok {
|
||||
border-color: rgba(78, 183, 168, 0.22);
|
||||
}
|
||||
.pipeline-oa-guarantee.warn {
|
||||
border-color: rgba(215, 161, 58, 0.28);
|
||||
}
|
||||
.pipeline-oa-guarantee strong,
|
||||
.pipeline-oa-guarantee span {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pipeline-oa-guarantee strong {
|
||||
color: var(--text);
|
||||
}
|
||||
.pipeline-oa-guarantee span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.pipeline-attempt-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -2150,12 +2375,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
}
|
||||
.pipeline-opencode-flow::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 6px;
|
||||
bottom: 8px;
|
||||
width: 1px;
|
||||
background: linear-gradient(180deg, rgba(78, 183, 168, 0.56), rgba(215, 161, 58, 0.34), rgba(105, 174, 232, 0.18));
|
||||
display: none;
|
||||
}
|
||||
.pipeline-opencode-step {
|
||||
position: relative;
|
||||
@@ -2607,8 +2827,12 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
}
|
||||
}
|
||||
@keyframes ganttPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 1px rgba(0,0,0,0.32), 0 0 12px rgba(215, 161, 58, 0.28); }
|
||||
50% { box-shadow: 0 0 0 1px rgba(215, 161, 58, 0.52), 0 0 20px rgba(215, 161, 58, 0.48); }
|
||||
0%, 100% { box-shadow: 0 0 0 1px rgba(0,0,0,0.32), 0 0 12px rgba(105, 174, 232, 0.28); }
|
||||
50% { box-shadow: 0 0 0 1px rgba(105, 174, 232, 0.62), 0 0 24px rgba(105, 174, 232, 0.58); }
|
||||
}
|
||||
@keyframes ganttLiveSweep {
|
||||
0% { transform: translateY(-48px); }
|
||||
100% { transform: translateY(48px); }
|
||||
}
|
||||
.pipeline-node-control {
|
||||
min-width: 0;
|
||||
@@ -2773,6 +2997,114 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.pipeline-score-board {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.pipeline-score-card, .pipeline-score-empty {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(78, 183, 168, 0.08), transparent 36%),
|
||||
var(--panel-3);
|
||||
}
|
||||
.pipeline-score-empty strong {
|
||||
color: var(--accent);
|
||||
}
|
||||
.pipeline-score-empty span {
|
||||
color: var(--muted);
|
||||
}
|
||||
.pipeline-score-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.pipeline-score-head div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.pipeline-score-head span {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pipeline-score-head strong {
|
||||
color: var(--accent);
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
}
|
||||
.pipeline-score-meter {
|
||||
height: 7px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
background: rgba(0,0,0,0.24);
|
||||
}
|
||||
.pipeline-score-meter span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-width: 2px;
|
||||
max-width: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-2), var(--accent));
|
||||
}
|
||||
.pipeline-score-card.failed .pipeline-score-meter span { background: linear-gradient(90deg, var(--danger), var(--accent)); }
|
||||
.pipeline-score-card.running .pipeline-score-meter span { background: linear-gradient(90deg, var(--accent), var(--accent-2)); }
|
||||
.pipeline-score-facts, .pipeline-score-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
.pipeline-score-facts span, .pipeline-score-badge, .pipeline-score-item {
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: rgba(255,255,255,0.03);
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.pipeline-score-badge {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.pipeline-score-badge.succeeded, .pipeline-score-item.passed {
|
||||
border-color: rgba(78, 183, 168, 0.55);
|
||||
color: var(--accent-2);
|
||||
}
|
||||
.pipeline-score-badge.failed, .pipeline-score-item.failed {
|
||||
border-color: rgba(207, 106, 84, 0.58);
|
||||
color: var(--danger);
|
||||
}
|
||||
.pipeline-score-badge.running {
|
||||
border-color: rgba(215, 161, 58, 0.58);
|
||||
color: var(--accent);
|
||||
}
|
||||
.pipeline-score-item {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
min-width: 62px;
|
||||
}
|
||||
.pipeline-score-item b {
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
.pipeline-score-item small {
|
||||
color: currentColor;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.pipeline-score-error {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.component-stratum span {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
@@ -2915,9 +3247,10 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.metric-grid, .policy-grid, .security-board, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.pipeline-oa-guarantees { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.dispatch-form { grid-template-columns: 1fr 1fr; }
|
||||
.dispatch-actions { align-items: center; }
|
||||
.page-grid, .docker-layout, .monitor-layout, .findjob-grid, .findjob-hero, .pipeline-grid, .pipeline-hero, .met-grid, .met-form-grid { grid-template-columns: 1fr; }
|
||||
.page-grid, .docker-layout, .monitor-layout, .findjob-grid, .findjob-hero, .pipeline-grid, .pipeline-hero, .met-grid, .met-form-grid, .codex-queue-layout, .codex-queue-hero, .codex-detail-grid { grid-template-columns: 1fr; }
|
||||
.pipeline-control-shell { grid-template-columns: 1fr; }
|
||||
.pipeline-node-control { max-height: none; min-height: 0; }
|
||||
.findjob-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(5), .met-grid .panel:nth-child(3), .met-grid .panel:nth-child(5), .met-detail-panel { grid-column: 1; }
|
||||
@@ -2932,6 +3265,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
.pipeline-control-evidence-grid,
|
||||
.pipeline-evidence-row,
|
||||
.pipeline-gantt-detail-layout,
|
||||
.pipeline-oa-guarantees,
|
||||
.pipeline-kv-grid,
|
||||
.pipeline-field-list {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -3034,8 +3368,10 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
padding: 4px 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.metric-grid, .policy-grid, .security-board, .dispatch-form, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid, .gateway-record-grid, .met-detail-kv { grid-template-columns: 1fr; }
|
||||
.compact-row, .heartbeat-row, .log-row, .endpoint-list article, .volume-route, .findjob-hero, .pipeline-hero { grid-template-columns: 1fr; align-items: start; }
|
||||
.metric-grid, .policy-grid, .security-board, .dispatch-form, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid, .gateway-record-grid, .met-detail-kv, .codex-queue-metrics, .codex-form-grid { grid-template-columns: 1fr; }
|
||||
.compact-row, .heartbeat-row, .log-row, .endpoint-list article, .volume-route, .findjob-hero, .pipeline-hero, .codex-queue-hero { grid-template-columns: 1fr; align-items: start; }
|
||||
.codex-output-line { grid-template-columns: 1fr; }
|
||||
.codex-transcript { min-height: 360px; }
|
||||
.process-resource-head {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { CodexQueuePage } from "./codex-queue";
|
||||
import { FindJobPage } from "./findjob";
|
||||
import { MetNonlinearPage } from "./met-nonlinear";
|
||||
import { canonicalizeKnownRoute, createRouteRegistry, DEFAULT_ACTIVE_TABS, MODULES, pathForTarget, resolveRouteTarget } from "./navigation";
|
||||
@@ -1221,6 +1222,7 @@ function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord
|
||||
service.id === "pipeline" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "pipeline"), "data-testid": "open-pipeline-button" }, "打开") : null,
|
||||
service.id === "todo-note" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "todo-note"), "data-testid": "open-todo-note-button" }, "打开") : null,
|
||||
service.id === "met-nonlinear" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "met-nonlinear"), "data-testid": "open-met-nonlinear-button" }, "打开") : null,
|
||||
service.id === "codex-queue" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "codex-queue"), "data-testid": "open-codex-queue-button" }, "打开") : null,
|
||||
h(RawButton, { title: `Microservice ${service.id}`, data: service, onOpen: onRaw }),
|
||||
),
|
||||
),
|
||||
@@ -1481,6 +1483,7 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa
|
||||
if (activeModule === "apps" && activeTab === "findjob") return h(FindJobPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
|
||||
if (activeModule === "apps" && activeTab === "pipeline") return h(PipelinePage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
|
||||
if (activeModule === "apps" && activeTab === "met-nonlinear") return h(MetNonlinearPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
|
||||
if (activeModule === "apps" && activeTab === "codex-queue") return h(CodexQueuePage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
|
||||
if (activeModule === "config" && activeTab === "topology") return h(TopologyPage, { data });
|
||||
if (activeModule === "config" && activeTab === "auth") return h(AuthPage, { session });
|
||||
if (activeModule === "config" && activeTab === "security") return h(SecurityPage);
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
import React from "react";
|
||||
|
||||
type AnyRecord = Record<string, any>;
|
||||
|
||||
const h = React.createElement;
|
||||
const { useEffect, useMemo, useRef } = React;
|
||||
const useState: any = React.useState;
|
||||
|
||||
function errorMessage(error: unknown, fallback = "操作失败"): string {
|
||||
return error instanceof Error ? error.message : String(error || fallback);
|
||||
}
|
||||
|
||||
function fmtDate(value: any): string {
|
||||
if (!value) return "--";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "--";
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function fmtClock(value: Date): string {
|
||||
return value.toLocaleTimeString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function shortText(value: any, max = 180): string {
|
||||
const text = String(value || "").replace(/\s+/gu, " ").trim();
|
||||
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
||||
}
|
||||
|
||||
async function requestJson(path: string, options: AnyRecord = {}): Promise<any> {
|
||||
const headers = new Headers(options.headers || {});
|
||||
const body = options.body && typeof options.body !== "string" ? JSON.stringify(options.body) : options.body;
|
||||
if (body && !headers.has("content-type")) headers.set("content-type", "application/json");
|
||||
const response = await fetch(path, { credentials: "same-origin", ...options, body, headers });
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = { text };
|
||||
}
|
||||
if (!response.ok || payload?.ok === false) {
|
||||
const message = payload?.error?.message || payload?.error || `HTTP ${response.status}`;
|
||||
const error = new Error(message);
|
||||
(error as Error & { status?: number }).status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function StatusBadge({ status, children }: AnyRecord) {
|
||||
const normalized = String(status || "unknown").toLowerCase();
|
||||
return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown");
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, hint, tone }: AnyRecord) {
|
||||
return h("article", { className: `metric-card ${tone || ""}` },
|
||||
h("div", { className: "metric-label" }, label),
|
||||
h("div", { className: "metric-value" }, value),
|
||||
h("div", { className: "metric-hint" }, hint),
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, eyebrow, actions, children, className }: AnyRecord) {
|
||||
return h("section", { className: `panel ${className || ""}` },
|
||||
h("div", { className: "panel-head" },
|
||||
h("div", null,
|
||||
eyebrow ? h("p", { className: "panel-eyebrow" }, eyebrow) : null,
|
||||
h("h2", null, title),
|
||||
),
|
||||
actions ? h("div", { className: "panel-actions" }, actions) : null,
|
||||
),
|
||||
h("div", { className: "panel-body" }, children),
|
||||
);
|
||||
}
|
||||
|
||||
function RawButton({ title, data, onOpen, testId }: AnyRecord) {
|
||||
return h("button", {
|
||||
type: "button",
|
||||
className: "ghost-btn",
|
||||
"data-testid": testId,
|
||||
onClick: () => onOpen(title, data),
|
||||
}, "查看原始JSON");
|
||||
}
|
||||
|
||||
function EmptyState({ title, text }: AnyRecord) {
|
||||
return h("div", { className: "empty-state" }, h("strong", null, title), h("span", null, text));
|
||||
}
|
||||
|
||||
function microserviceRuntime(service: any): AnyRecord {
|
||||
return service?.runtime && typeof service.runtime === "object" && !Array.isArray(service.runtime) ? service.runtime : {};
|
||||
}
|
||||
|
||||
function microserviceBackend(service: any): AnyRecord {
|
||||
return service?.backend && typeof service.backend === "object" && !Array.isArray(service.backend) ? service.backend : {};
|
||||
}
|
||||
|
||||
function microserviceRepository(service: any): AnyRecord {
|
||||
return service?.repository && typeof service.repository === "object" && !Array.isArray(service.repository) ? service.repository : {};
|
||||
}
|
||||
|
||||
function codexApi(apiBaseUrl: string, path: string): string {
|
||||
return `${apiBaseUrl}/microservices/codex-queue/proxy${path}`;
|
||||
}
|
||||
|
||||
function taskRows(data: any): any[] {
|
||||
return Array.isArray(data?.tasks) ? data.tasks : [];
|
||||
}
|
||||
|
||||
function taskOutput(task: any): any[] {
|
||||
return Array.isArray(task?.output) ? task.output : [];
|
||||
}
|
||||
|
||||
function taskAttempts(task: any): any[] {
|
||||
return Array.isArray(task?.attempts) ? task.attempts : [];
|
||||
}
|
||||
|
||||
function queueCounts(queue: any): AnyRecord {
|
||||
return queue?.counts && typeof queue.counts === "object" && !Array.isArray(queue.counts) ? queue.counts : {};
|
||||
}
|
||||
|
||||
function splitPromptTasks(prompt: string): string[] {
|
||||
return prompt
|
||||
.split(/^\s*---+\s*$/gmu)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function channelLabel(channel: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
system: "SYS",
|
||||
user: "YOU",
|
||||
assistant: "GPT",
|
||||
reasoning: "THINK",
|
||||
command: "CMD",
|
||||
diff: "DIFF",
|
||||
tool: "TOOL",
|
||||
error: "ERR",
|
||||
};
|
||||
return labels[channel] || channel.toUpperCase();
|
||||
}
|
||||
|
||||
function taskIsActive(task: any): boolean {
|
||||
return ["running", "judging", "retry_wait"].includes(String(task?.status || ""));
|
||||
}
|
||||
|
||||
function countValue(counts: AnyRecord, key: string): string {
|
||||
const value = Number(counts[key] ?? 0);
|
||||
return Number.isFinite(value) ? String(value) : "0";
|
||||
}
|
||||
|
||||
function TaskCard({ task, selected, onSelect }: AnyRecord) {
|
||||
const judge = task?.lastJudge || {};
|
||||
return h("button", {
|
||||
type: "button",
|
||||
className: `codex-task-card ${selected ? "selected" : ""}`,
|
||||
onClick: onSelect,
|
||||
"data-testid": `codex-task-${task?.id || "unknown"}`,
|
||||
},
|
||||
h("div", { className: "codex-task-card-head" },
|
||||
h(StatusBadge, { status: task?.status }, task?.status || "unknown"),
|
||||
h("span", { className: "mono-text" }, `${task?.currentAttempt || 0}/${task?.maxAttempts || 0}`),
|
||||
),
|
||||
h("strong", null, shortText(task?.prompt, 120) || "空任务"),
|
||||
h("div", { className: "codex-task-meta" },
|
||||
h("span", null, task?.model || "--"),
|
||||
h("span", null, fmtDate(task?.updatedAt)),
|
||||
),
|
||||
judge?.decision ? h("div", { className: "codex-judge-line" }, `judge=${judge.decision} ${Math.round(Number(judge.confidence || 0) * 100)}%`) : null,
|
||||
);
|
||||
}
|
||||
|
||||
function Transcript({ task, autoScroll }: AnyRecord) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const output = taskOutput(task);
|
||||
useEffect(() => {
|
||||
if (autoScroll && ref.current) ref.current.scrollTop = ref.current.scrollHeight;
|
||||
}, [autoScroll, output.length, task?.id]);
|
||||
if (!task) return h(EmptyState, { title: "未选择任务", text: "从左侧队列选择任务,或提交新 Codex 任务。" });
|
||||
return h("div", { className: "codex-transcript", ref, "data-testid": "codex-output" },
|
||||
output.length === 0 ? h("div", { className: "codex-output-empty" }, "等待 Codex 输出...") : output.map((item: any) => h("article", { key: `${item.seq}-${item.channel}`, className: `codex-output-line ${item.channel || "system"}` },
|
||||
h("div", { className: "codex-output-meta" },
|
||||
h("span", { className: "codex-output-channel" }, channelLabel(String(item.channel || "system"))),
|
||||
h("span", null, fmtDate(item.at)),
|
||||
item.method ? h("code", null, item.method) : null,
|
||||
),
|
||||
h("pre", null, String(item.text || "")),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
function AttemptTable({ task }: AnyRecord) {
|
||||
const attempts = taskAttempts(task).slice().reverse();
|
||||
if (attempts.length === 0) return h(EmptyState, { title: "尚无 attempt", text: "任务开始运行后,这里会记录 Codex 终态、传输中断和 stderr tail。" });
|
||||
return h("div", { className: "table-wrap codex-attempt-table" },
|
||||
h("table", null,
|
||||
h("thead", null, h("tr", null,
|
||||
h("th", null, "#"),
|
||||
h("th", null, "模式"),
|
||||
h("th", null, "终态"),
|
||||
h("th", null, "传输"),
|
||||
h("th", null, "退出"),
|
||||
h("th", null, "完成时间"),
|
||||
)),
|
||||
h("tbody", null, attempts.map((attempt: any) => h("tr", { key: `${attempt.index}-${attempt.startedAt}` },
|
||||
h("td", null, attempt.index),
|
||||
h("td", null, attempt.mode),
|
||||
h("td", null, h(StatusBadge, { status: attempt.terminalStatus || "unknown" }, attempt.terminalStatus || "unknown")),
|
||||
h("td", null, attempt.transportClosedBeforeTerminal ? h(StatusBadge, { status: "failed" }, "closed-before-terminal") : h(StatusBadge, { status: "succeeded" }, "normal")),
|
||||
h("td", null, `code=${attempt.appServerExitCode ?? "--"} signal=${attempt.appServerSignal ?? "--"}`),
|
||||
h("td", null, fmtDate(attempt.finishedAt)),
|
||||
))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) {
|
||||
const service = microservices.find((item: any) => item.id === "codex-queue") || null;
|
||||
const [health, setHealth] = useState(null);
|
||||
const [tasksData, setTasksData] = useState(null);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [selectedTask, setSelectedTask] = useState(null);
|
||||
const [prompt, setPrompt] = useState("请在 UniDesk 工作区中完成一个很小的验证任务:读取 package.json 并总结项目名称,不要修改文件。");
|
||||
const [model, setModel] = useState("gpt-5.4-mini");
|
||||
const [cwd, setCwd] = useState("/workspace");
|
||||
const [maxAttempts, setMaxAttempts] = useState(3);
|
||||
const [steerPrompt, setSteerPrompt] = useState("");
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [refreshedAt, setRefreshedAt] = useState(null);
|
||||
|
||||
const tasks = taskRows(tasksData);
|
||||
const queue = tasksData?.queue || health?.body?.queue || health?.queue || {};
|
||||
const counts = queueCounts(queue);
|
||||
const activeTaskId = queue?.activeTaskId || tasks.find((task: any) => taskIsActive(task))?.id || "";
|
||||
const runtime = service ? microserviceRuntime(service) : {};
|
||||
const repository = service ? microserviceRepository(service) : {};
|
||||
const backend = service ? microserviceBackend(service) : {};
|
||||
const promptParts = useMemo(() => splitPromptTasks(prompt), [prompt]);
|
||||
const selectedCanSteer = selectedTask?.id && selectedTask?.activeTurnId && String(selectedTask?.status) === "running";
|
||||
const selectedCanInterrupt = selectedTask?.id && !["succeeded", "failed", "canceled"].includes(String(selectedTask?.status || ""));
|
||||
const selectedCanRetry = selectedTask?.id && ["succeeded", "failed", "canceled"].includes(String(selectedTask?.status || ""));
|
||||
|
||||
async function load(preferId = selectedId): Promise<void> {
|
||||
if (!service) return;
|
||||
const [healthResult, tasksResult] = await Promise.all([
|
||||
requestJson(`${apiBaseUrl}/microservices/codex-queue/health`),
|
||||
requestJson(codexApi(apiBaseUrl, "/api/tasks?limit=80")),
|
||||
]);
|
||||
setHealth(healthResult);
|
||||
setTasksData(tasksResult);
|
||||
const rows = taskRows(tasksResult);
|
||||
const nextId = preferId && rows.some((task: any) => task.id === preferId)
|
||||
? preferId
|
||||
: (tasksResult?.queue?.activeTaskId || rows.find((task: any) => taskIsActive(task))?.id || rows[0]?.id || "");
|
||||
setSelectedId(nextId);
|
||||
if (nextId) {
|
||||
const detail = await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(nextId)}`));
|
||||
setSelectedTask(detail?.task || null);
|
||||
} else {
|
||||
setSelectedTask(null);
|
||||
}
|
||||
setRefreshedAt(new Date());
|
||||
}
|
||||
|
||||
async function guarded(action: () => Promise<void>, message: string): Promise<void> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await action();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, message));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueue(event: any): Promise<void> {
|
||||
event.preventDefault();
|
||||
await guarded(async () => {
|
||||
if (promptParts.length === 0) throw new Error("prompt 不能为空");
|
||||
const body = promptParts.length === 1
|
||||
? { prompt: promptParts[0], model, cwd, maxAttempts: Number(maxAttempts) }
|
||||
: { tasks: promptParts.map((text) => ({ prompt: text, model, cwd, maxAttempts: Number(maxAttempts) })) };
|
||||
const result = await requestJson(codexApi(apiBaseUrl, promptParts.length === 1 ? "/api/tasks" : "/api/tasks/batch"), { method: "POST", body });
|
||||
const firstId = result?.tasks?.[0]?.id || "";
|
||||
await load(firstId);
|
||||
}, "Codex 任务入队失败");
|
||||
}
|
||||
|
||||
async function steer(event: any): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (!selectedTask?.id) return;
|
||||
await guarded(async () => {
|
||||
await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(selectedTask.id)}/steer`), { method: "POST", body: { prompt: steerPrompt } });
|
||||
setSteerPrompt("");
|
||||
await load(selectedTask.id);
|
||||
}, "追加 prompt 失败");
|
||||
}
|
||||
|
||||
async function interrupt(): Promise<void> {
|
||||
if (!selectedTask?.id) return;
|
||||
await guarded(async () => {
|
||||
await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(selectedTask.id)}/interrupt`), { method: "POST", body: {} });
|
||||
await load(selectedTask.id);
|
||||
}, "打断 Codex session 失败");
|
||||
}
|
||||
|
||||
async function retry(): Promise<void> {
|
||||
if (!selectedTask?.id) return;
|
||||
await guarded(async () => {
|
||||
await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(selectedTask.id)}/retry`), { method: "POST", body: {} });
|
||||
await load(selectedTask.id);
|
||||
}, "重新入队失败");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void guarded(() => load(), "Codex Queue 加载失败");
|
||||
}, [service?.id, service?.runtime?.providerStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!service) return undefined;
|
||||
const timer = window.setInterval(() => {
|
||||
void load(selectedId).catch((err) => setError(errorMessage(err, "Codex Queue 轮询失败")));
|
||||
}, 1500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [service?.id, selectedId]);
|
||||
|
||||
if (!service) return h(EmptyState, { title: "Codex Queue 未登记", text: "请在 config.json 的 microservices 中登记 id=codex-queue" });
|
||||
|
||||
return h("div", { className: "codex-queue-page", "data-testid": "codex-queue-page" },
|
||||
h(Panel, {
|
||||
title: "Codex Queue 控制台",
|
||||
eyebrow: "App-Server Task Deck",
|
||||
actions: h("div", { className: "panel-actions" },
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => void guarded(() => load(selectedId), "刷新失败"), disabled: busy, "data-testid": "codex-refresh-button" }, busy ? "同步中" : "刷新"),
|
||||
h(RawButton, { title: "Codex Queue Microservice", data: service, onOpen: onRaw, testId: "raw-codex-queue-service" }),
|
||||
),
|
||||
},
|
||||
h("div", { className: "codex-queue-hero" },
|
||||
h("div", null,
|
||||
h("div", { className: "node-version-line" },
|
||||
h(StatusBadge, { status: runtime.providerStatus === "online" ? "online" : "warn" }, runtime.providerStatus || "unknown"),
|
||||
h("span", null, service.providerId),
|
||||
h("span", null, backend.public ? "公网暴露" : "仅 UniDesk frontend 代理访问"),
|
||||
h("span", null, queue?.judgeConfigured ? `MiniMax ${queue?.minimaxModel || "M2.7"}` : "Fallback judge"),
|
||||
),
|
||||
h("p", { className: "muted paragraph" }, service.description),
|
||||
),
|
||||
h("div", { className: "microservice-ref-card" },
|
||||
h("span", null, "Codex"),
|
||||
h("strong", null, queue?.defaultModel || "gpt-5.4-mini"),
|
||||
h("code", null, "codex app-server --listen stdio://"),
|
||||
),
|
||||
h("div", { className: "microservice-ref-card" },
|
||||
h("span", null, "Backend"),
|
||||
h("strong", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"}`),
|
||||
h("code", null, repository.containerName || "codex-queue-backend"),
|
||||
),
|
||||
),
|
||||
error ? h("div", { className: "form-error wide" }, error) : null,
|
||||
),
|
||||
h("div", { className: "codex-queue-metrics" },
|
||||
h(MetricCard, { label: "排队", value: countValue(counts, "queued"), hint: "waiting turns" }),
|
||||
h(MetricCard, { label: "运行", value: countValue(counts, "running"), hint: activeTaskId ? `active ${String(activeTaskId).slice(0, 16)}` : "idle", tone: activeTaskId ? "warn" : "ok" }),
|
||||
h(MetricCard, { label: "成功", value: countValue(counts, "succeeded"), hint: "completed tasks", tone: "ok" }),
|
||||
h(MetricCard, { label: "异常/取消", value: String(Number(counts.failed || 0) + Number(counts.canceled || 0)), hint: "terminal non-success", tone: Number(counts.failed || 0) > 0 ? "fail" : "" }),
|
||||
h(MetricCard, { label: "最近刷新", value: refreshedAt ? fmtClock(refreshedAt) : "--", hint: "1.5s polling" }),
|
||||
),
|
||||
h("div", { className: "codex-queue-layout" },
|
||||
h("div", { className: "codex-left-rail" },
|
||||
h(Panel, { title: "提交任务", eyebrow: promptParts.length > 1 ? `${promptParts.length} tasks` : "Single or Batch", className: "codex-compose-panel" },
|
||||
h("form", { className: "codex-task-form", onSubmit: enqueue, "data-testid": "codex-queue-task-form" },
|
||||
h("label", null, "Prompt / 多任务用单独一行 --- 分隔",
|
||||
h("textarea", { value: prompt, rows: 8, onChange: (event: any) => setPrompt(event.target.value), placeholder: "写入 Codex 任务;多个任务之间用 --- 分隔。" }),
|
||||
),
|
||||
h("div", { className: "codex-form-grid" },
|
||||
h("label", null, "模型", h("input", { value: model, onChange: (event: any) => setModel(event.target.value), placeholder: "gpt-5.4-mini" })),
|
||||
h("label", null, "工作目录", h("input", { value: cwd, onChange: (event: any) => setCwd(event.target.value), placeholder: "/workspace" })),
|
||||
h("label", null, "最大尝试", h("input", { type: "number", min: 1, max: 10, value: maxAttempts, onChange: (event: any) => setMaxAttempts(Number(event.target.value)) })),
|
||||
),
|
||||
h("button", { type: "submit", className: "primary-btn", disabled: busy || promptParts.length === 0 }, promptParts.length > 1 ? `批量入队 ${promptParts.length} 个任务` : "入队并运行"),
|
||||
),
|
||||
),
|
||||
h(Panel, { title: "队列", eyebrow: `${tasks.length} visible` },
|
||||
h("div", { className: "codex-task-list" },
|
||||
tasks.length === 0 ? h(EmptyState, { title: "队列为空", text: "提交一个任务后,Codex 会串行执行并保存输出。" }) : tasks.map((task: any) => h(TaskCard, {
|
||||
key: task.id,
|
||||
task,
|
||||
selected: selectedId === task.id,
|
||||
onSelect: () => {
|
||||
setSelectedId(task.id);
|
||||
void load(task.id);
|
||||
},
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
h("div", { className: "codex-main-stage" },
|
||||
h(Panel, {
|
||||
title: selectedTask ? `Session ${String(selectedTask.id).slice(0, 22)}` : "Session 输出",
|
||||
eyebrow: selectedTask ? `${selectedTask.status} / ${selectedTask.model}` : "Codex CLI-like stream",
|
||||
actions: h("div", { className: "panel-actions" },
|
||||
h("label", { className: "inline-check" }, h("input", { type: "checkbox", checked: autoScroll, onChange: (event: any) => setAutoScroll(Boolean(event.target.checked)) }), "自动滚动"),
|
||||
h("button", { type: "button", className: "ghost-btn", disabled: !selectedCanInterrupt || busy, onClick: () => void interrupt(), "data-testid": "codex-interrupt-button" }, "打断"),
|
||||
h("button", { type: "button", className: "ghost-btn", disabled: !selectedCanRetry || busy, onClick: () => void retry() }, "重试"),
|
||||
selectedTask ? h(RawButton, { title: "Codex Task", data: selectedTask, onOpen: onRaw, testId: "raw-codex-task" }) : null,
|
||||
),
|
||||
className: "codex-output-panel",
|
||||
},
|
||||
h(Transcript, { task: selectedTask, autoScroll }),
|
||||
),
|
||||
h("div", { className: "codex-detail-grid" },
|
||||
h(Panel, { title: "运行控制", eyebrow: selectedCanSteer ? "Active turn steer" : "Steer when running" },
|
||||
h("form", { className: "codex-steer-form", onSubmit: steer },
|
||||
h("label", null, "追加 prompt",
|
||||
h("textarea", { value: steerPrompt, rows: 4, onChange: (event: any) => setSteerPrompt(event.target.value), placeholder: "给正在运行的 Codex session 推入新的指令或纠偏。", disabled: !selectedCanSteer }),
|
||||
),
|
||||
h("button", { type: "submit", className: "primary-btn", disabled: !selectedCanSteer || busy || steerPrompt.trim().length === 0, "data-testid": "codex-steer-button" }, "推入运行中 session"),
|
||||
),
|
||||
),
|
||||
h(Panel, { title: "完成判定", eyebrow: selectedTask?.lastJudge ? selectedTask.lastJudge.source : "judge" },
|
||||
selectedTask?.lastJudge ? h("div", { className: "codex-judge-card" },
|
||||
h(StatusBadge, { status: selectedTask.lastJudge.decision }, selectedTask.lastJudge.decision),
|
||||
h("strong", null, `${Math.round(Number(selectedTask.lastJudge.confidence || 0) * 100)}% confidence`),
|
||||
h("p", null, selectedTask.lastJudge.reason || "--"),
|
||||
selectedTask.lastJudge.continuePrompt ? h("code", null, shortText(selectedTask.lastJudge.continuePrompt, 220)) : null,
|
||||
) : h(EmptyState, { title: "尚未判定", text: "Codex turn 结束后会由 MiniMax M2.7 或 fallback judge 判定 complete/retry/fail;retry 会在已有 thread 追加继续执行 prompt。" }),
|
||||
),
|
||||
),
|
||||
h(Panel, { title: "Attempts", eyebrow: "terminal vs interruption" }, h(AttemptTable, { task: selectedTask })),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,7 @@ export const MODULES: UniDeskModuleDefinition[] = [
|
||||
{ id: "findjob", label: "FindJob" },
|
||||
{ id: "pipeline", label: "Pipeline" },
|
||||
{ id: "met-nonlinear", label: "MET Nonlinear" },
|
||||
{ id: "codex-queue", label: "Codex Queue" },
|
||||
] },
|
||||
{ id: "config", label: "系统配置", code: "CFG", tabs: [
|
||||
{ id: "topology", label: "连接拓扑" },
|
||||
|
||||
@@ -30,6 +30,7 @@ const pipelineSnapshotRunLimit = 10;
|
||||
const pipelineGanttTimeAxisWidth = 96;
|
||||
const pipelineGanttNodeColumnWidth = 72;
|
||||
const pipelineGanttHeaderHeight = 64;
|
||||
const pipelineGanttArrowTipInsetPx = 12;
|
||||
|
||||
function pipelinePercent(value: any, fallback: number): number {
|
||||
const number = Number.parseFloat(String(value || ""));
|
||||
@@ -250,6 +251,11 @@ function terminalStatus(status: any): boolean {
|
||||
return ["succeeded", "failed", "skipped", "cancelled", "canceled", "completed"].includes(String(status || "").toLowerCase());
|
||||
}
|
||||
|
||||
function runningStatus(status: any): boolean {
|
||||
const value = statusValue(status).toLowerCase();
|
||||
return ["running", "active", "in-progress", "in_progress"].includes(value);
|
||||
}
|
||||
|
||||
function statusCounts(items: any[], key = "status"): AnyRecord {
|
||||
return items.reduce((counts: AnyRecord, item: any) => {
|
||||
const status = String(item?.[key] || "unknown").toLowerCase();
|
||||
@@ -877,6 +883,8 @@ function PipelineOpenCodeStep({ step, matched = false }: AnyRecord) {
|
||||
: [];
|
||||
const roleTone = stepRoleTone(step?.role);
|
||||
const statusTone = stepStatusTone(step);
|
||||
const stepStartMs = timeMs(step?.createdAt) ?? timeMs(step?.completedAt);
|
||||
const stepEndMs = timeMs(step?.completedAt) ?? stepStartMs;
|
||||
const toolNames = uniqueStrings([...asArray(step?.tools), ...tools.map((part: any) => part?.tool)]).slice(0, 5);
|
||||
const facts = [
|
||||
step?.finish ? `finish ${step.finish}` : "",
|
||||
@@ -887,7 +895,13 @@ function PipelineOpenCodeStep({ step, matched = false }: AnyRecord) {
|
||||
].filter(Boolean);
|
||||
const messageRows = pipelineMessageCardRows(step, textParts, reasoningParts);
|
||||
|
||||
return h("details", { className: `pipeline-opencode-step ${roleTone} ${statusTone} ${matched ? "matched" : ""}`, open: matched ? true : undefined, "data-testid": "pipeline-opencode-step" },
|
||||
return h("details", {
|
||||
className: `pipeline-opencode-step ${roleTone} ${statusTone} ${matched ? "matched" : ""}`,
|
||||
open: matched ? true : undefined,
|
||||
"data-testid": "pipeline-opencode-step",
|
||||
"data-step-start-ms": stepStartMs !== null ? String(stepStartMs) : "",
|
||||
"data-step-end-ms": stepEndMs !== null ? String(stepEndMs) : "",
|
||||
},
|
||||
h("summary", { className: "pipeline-opencode-step-summary", "data-testid": "pipeline-opencode-step-summary" },
|
||||
h(PipelineStepTimeSummary, { step, role: roleTone, matched }),
|
||||
h(PipelineStepMessageCard, { rows: messageRows, compact: true, role: roleTone, matched }),
|
||||
@@ -962,7 +976,6 @@ function controlActionValue(record: any): string {
|
||||
|
||||
function controlActionLabel(record: any): string {
|
||||
switch (controlActionValue(record)) {
|
||||
case "audit-request": return "待审核";
|
||||
case "guide": return "引导";
|
||||
case "modify": return "修改";
|
||||
case "approve": return "审核通过";
|
||||
@@ -978,9 +991,9 @@ function eventLabel(record: any): string {
|
||||
case "append-prompt-delivered": return "追加 prompt";
|
||||
case "append-prompt-queued": return "追加 prompt 已排队";
|
||||
case "monitor-prompt-delivered": return "Monitor prompt";
|
||||
case "monitor-audit-requested": return "待审核";
|
||||
case "monitor-audit-approved": return "审核通过";
|
||||
case "monitor-audit-intervened": return "审核被打断";
|
||||
case "node-long-running-observation": return "长任务观察";
|
||||
case "node-finished": return "节点完成";
|
||||
case "oa-policy-downstream-evaluated": return "OA 下游策略";
|
||||
case "control-command-queued": return `${controlActionLabel(record)} 已发起`;
|
||||
case "control-command-applied": return `${controlActionLabel(record)} 已生效`;
|
||||
case "control-command-ignored": return `${controlActionLabel(record)} 已忽略`;
|
||||
@@ -1162,7 +1175,7 @@ function PipelineProcedureAttemptList({ procedure, matchedStepKey = "", matchedA
|
||||
{ label: "updated", value: fmtDate(messages.updatedAt) },
|
||||
{ label: "sessions", value: sessionIds.join(", ") || "--" },
|
||||
] }),
|
||||
steps.length === 0 ? h("p", { className: "muted paragraph" }, "当前 attempt 尚未返回 OpenCode step 摘要;请确认 D601 pipeline-webui 已重建并重新抓取。") :
|
||||
steps.length === 0 ? h("p", { className: "muted paragraph" }, "当前 attempt 尚未返回 OpenCode step 摘要;请确认 D601 pipeline-control 已重建并重新抓取。") :
|
||||
h("section", { className: "pipeline-opencode-timeline", "data-testid": "pipeline-step-timeline" },
|
||||
h("div", { className: "pipeline-opencode-timeline-head" },
|
||||
h("div", null,
|
||||
@@ -1472,6 +1485,116 @@ function pipelineStatusCounts(runs: any[]): AnyRecord {
|
||||
}, {});
|
||||
}
|
||||
|
||||
function pipelineRunScorers(run: any): AnyRecord[] {
|
||||
if (Array.isArray(run?.scorers)) return run.scorers.filter(isRecord);
|
||||
if (Array.isArray(run?.summary?.scorers)) return run.summary.scorers.filter(isRecord);
|
||||
if (Array.isArray(run?.artifact?.summary?.scorers)) return run.artifact.summary.scorers.filter(isRecord);
|
||||
return [];
|
||||
}
|
||||
|
||||
function pipelineDetailedRun(details: any): AnyRecord | null {
|
||||
if (isRecord(details?.run)) return details.run;
|
||||
if (isRecord(details?.runSummary)) return details.runSummary;
|
||||
return null;
|
||||
}
|
||||
|
||||
function mergePipelineRunSummary(base: any, detail: any): AnyRecord | null {
|
||||
if (!isRecord(base) && !isRecord(detail)) return null;
|
||||
if (!isRecord(base)) return detail;
|
||||
if (!isRecord(detail)) return base;
|
||||
return {
|
||||
...base,
|
||||
...detail,
|
||||
request: isRecord(base.request) || isRecord(detail.request) ? { ...(isRecord(base.request) ? base.request : {}), ...(isRecord(detail.request) ? detail.request : {}) } : detail.request ?? base.request,
|
||||
artifact: isRecord(base.artifact) || isRecord(detail.artifact) ? { ...(isRecord(base.artifact) ? base.artifact : {}), ...(isRecord(detail.artifact) ? detail.artifact : {}) } : detail.artifact ?? base.artifact,
|
||||
summary: isRecord(base.summary) || isRecord(detail.summary) ? { ...(isRecord(base.summary) ? base.summary : {}), ...(isRecord(detail.summary) ? detail.summary : {}) } : detail.summary ?? base.summary,
|
||||
};
|
||||
}
|
||||
|
||||
function pipelineScoreSummary(run: any): AnyRecord {
|
||||
const scorers = pipelineRunScorers(run);
|
||||
const primary = scorers.find((scorer) => isRecord(scorer?.score)) || scorers[0] || null;
|
||||
const score = isRecord(primary?.score) ? primary.score : {};
|
||||
const passed = Number(score.passed);
|
||||
const total = Number(score.total);
|
||||
const ratio = Number(score.ratio);
|
||||
const computedRatio = Number.isFinite(ratio)
|
||||
? ratio
|
||||
: Number.isFinite(passed) && Number.isFinite(total) && total > 0 ? passed / total : null;
|
||||
const percent = computedRatio === null ? null : Math.round(Math.max(0, Math.min(100, computedRatio <= 1 ? computedRatio * 100 : computedRatio)));
|
||||
const text = String(score.text || (Number.isFinite(passed) && Number.isFinite(total) ? `${passed}/${total}` : ""));
|
||||
return {
|
||||
scorer: primary,
|
||||
scorers,
|
||||
score,
|
||||
passed: Number.isFinite(passed) ? passed : null,
|
||||
total: Number.isFinite(total) ? total : null,
|
||||
percent,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
function pipelineScoreText(run: any): string {
|
||||
const summary = pipelineScoreSummary(run);
|
||||
return summary.text || (summary.scorers.length > 0 ? String(summary.scorer?.status || "pending") : "--");
|
||||
}
|
||||
|
||||
function pipelineScoreTone(run: any): string {
|
||||
const summary = pipelineScoreSummary(run);
|
||||
if (summary.total > 0 && summary.passed === summary.total) return "succeeded";
|
||||
if (summary.total > 0 && summary.passed > 0) return "running";
|
||||
if (summary.scorers.length > 0) return "failed";
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function pipelineScorerItems(scorer: any): AnyRecord[] {
|
||||
return Array.isArray(scorer?.items) ? scorer.items.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
function PipelineScoreBadge({ run }: AnyRecord) {
|
||||
const text = pipelineScoreText(run);
|
||||
return h("span", { className: `pipeline-score-badge ${pipelineScoreTone(run)}` }, `score ${text}`);
|
||||
}
|
||||
|
||||
function PipelineScoreBoard({ run, onRaw }: AnyRecord) {
|
||||
const summary = pipelineScoreSummary(run);
|
||||
const scorers = summary.scorers;
|
||||
if (!run) return h(EmptyState, { title: "暂无评分", text: "选择一个 epoch 后会显示 scorer 结果。" });
|
||||
if (scorers.length === 0) return h("div", { className: "pipeline-score-empty" },
|
||||
h("strong", null, "评分器等待中"),
|
||||
h("span", null, "DAG 完成后,Pipeline control backend 会把 scorer summary 追加到 run artifact,并通过 UniDesk 显示。"),
|
||||
);
|
||||
return h("div", { className: "pipeline-score-board", "data-testid": "pipeline-score-board" },
|
||||
scorers.map((scorer: AnyRecord, index: number) => {
|
||||
const localSummary = pipelineScoreSummary({ scorers: [scorer] });
|
||||
const items = pipelineScorerItems(scorer);
|
||||
const percent = localSummary.percent ?? 0;
|
||||
return h("article", { key: `${scorer.scorerId || scorer.component || index}`, className: `pipeline-score-card ${pipelineScoreTone({ scorers: [scorer] })}` },
|
||||
h("div", { className: "pipeline-score-head" },
|
||||
h("div", null,
|
||||
h("span", null, scorer.scorerId || scorer.component || "scorer"),
|
||||
h("strong", null, localSummary.text || scorer.status || "--"),
|
||||
),
|
||||
h(StatusBadge, { status: scorer.status || "unknown" }, scorer.status || "unknown"),
|
||||
),
|
||||
h("div", { className: "pipeline-score-meter", "aria-label": `score ${percent}%` }, h("span", { style: { width: `${percent}%` } })),
|
||||
h("div", { className: "pipeline-score-facts" },
|
||||
h("span", null, `${percent}%`),
|
||||
h("span", null, scorer.component || "--"),
|
||||
h("span", null, scorer.applicationCheckoutRef || "--"),
|
||||
),
|
||||
items.length > 0 ? h("div", { className: "pipeline-score-items" }, items.map((item: AnyRecord) => h("span", {
|
||||
key: `${item.id || item.filter}`,
|
||||
className: `pipeline-score-item ${String(item.status || "").toLowerCase()}`,
|
||||
title: `${item.filter || "--"} / ran=${item.ran ?? "?"}`,
|
||||
}, h("b", null, item.id || "--"), h("small", null, item.status || "--")))) : h("p", { className: "muted paragraph" }, "当前 scorer 尚未返回 item 级结果。"),
|
||||
scorer.error ? h("p", { className: "pipeline-score-error" }, previewText(scorer.error, 360)) : null,
|
||||
h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: `Scorer ${scorer.scorerId || index}`, data: scorer, onOpen: onRaw, testId: "raw-pipeline-score" })),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function pipelineComponentClassCounts(components: any[]): Array<{ name: string; count: number }> {
|
||||
const counts = components.reduce((memo: AnyRecord, component: any) => {
|
||||
const name = String(component?.componentClass || "unknown");
|
||||
@@ -1496,7 +1619,23 @@ function pipelineNodeIsMonitor(node: any, component?: any): boolean {
|
||||
if (String(node?.kind || "").toLowerCase() !== "procedure") return false;
|
||||
const monitorInputs = pipelineMonitorInputs(node);
|
||||
if (node?.instanceInputs?.monitorMode === true || monitorInputs.enabled === true) return true;
|
||||
return String(component?.id || component?.config?.id || "").toLowerCase().includes("monitor");
|
||||
const componentRef = pipelineComponentRef(node?.componentRef);
|
||||
return String(component?.id || component?.config?.id || componentRef || "").toLowerCase().includes("monitor");
|
||||
}
|
||||
|
||||
function pipelineMonitorNodeIds(pipelineNodes: AnyRecord[]): string[] {
|
||||
return pipelineNodes
|
||||
.filter((node: any) => pipelineNodeIsMonitor(node))
|
||||
.map((node: any) => String(node?.id || ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function pipelineOrderWithLeadingMonitors(nodeIds: string[], monitorNodeIds: string[]): string[] {
|
||||
if (monitorNodeIds.length === 0) return nodeIds;
|
||||
const monitorSet = new Set(monitorNodeIds);
|
||||
const leading = monitorNodeIds.filter((nodeId) => nodeIds.includes(nodeId));
|
||||
if (leading.length === 0) return nodeIds;
|
||||
return [...leading, ...nodeIds.filter((nodeId) => !monitorSet.has(nodeId))];
|
||||
}
|
||||
|
||||
function pipelineColumnsWithLeadingMonitors(columns: string[][], monitorNodeIds: string[]): string[][] {
|
||||
@@ -1582,7 +1721,7 @@ function pipelineGraphNodeOrder(pipeline: any, pipelineNodes: AnyRecord[], pipel
|
||||
ordered.push(nodeId);
|
||||
seen.add(nodeId);
|
||||
}
|
||||
return ordered;
|
||||
return pipelineOrderWithLeadingMonitors(ordered, pipelineMonitorNodeIds(pipelineNodes));
|
||||
}
|
||||
|
||||
function pipelineFlowEdgeKey(edge: AnyRecord): string {
|
||||
@@ -1843,6 +1982,10 @@ function exportMarkerId(color: string): string {
|
||||
return `arrow-${color.replace(/[^a-zA-Z0-9_-]+/g, "")}`;
|
||||
}
|
||||
|
||||
function safeExportFileTitle(title: string, fallback = "pipeline"): string {
|
||||
return String(title || fallback).replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-|-$/g, "") || fallback;
|
||||
}
|
||||
|
||||
function targetPortPosition(node: Node, handle: string): { x: number; y: number; position: Position } {
|
||||
const x = node.position.x;
|
||||
const y = node.position.y;
|
||||
@@ -1911,6 +2054,171 @@ function pipelineGraphSvg(flow: { nodes: Node[]; edges: Edge[] }, title: string)
|
||||
return { svg, width, height };
|
||||
}
|
||||
|
||||
function pipelineGanttExportStatusColor(status: any): string {
|
||||
const value = String(status || "").toLowerCase();
|
||||
if (value === "succeeded" || value === "completed") return "#4eb7a8";
|
||||
if (value === "failed") return "#cf6a54";
|
||||
if (runningStatus(value)) return "#69aee8";
|
||||
return "#d7a13a";
|
||||
}
|
||||
|
||||
function pipelineGanttExportMarkerColor(marker: AnyRecord): string {
|
||||
const kind = String(marker?.kind || "");
|
||||
const tone = String(marker?.tone || marker?.status || "").toLowerCase();
|
||||
if (kind === "prompt" && tone === "initial") return "#d7a13a";
|
||||
if (kind === "prompt" && tone === "monitor") return "#69aee8";
|
||||
if (kind === "prompt") return "#4eb7a8";
|
||||
if (tone === "modify") return "#e0b95a";
|
||||
if (tone === "approve" || tone === "guide" || tone === "monitor") return "#4eb7a8";
|
||||
if (tone === "restart" || tone === "redo") return "#d7a13a";
|
||||
if (tone === "ignored") return "#81939f";
|
||||
if (tone === "webui") return "#69aee8";
|
||||
if (tone === "cli") return "#d7a13a";
|
||||
return "#a7bac5";
|
||||
}
|
||||
|
||||
function pipelineGanttExportArrowColor(arrow: AnyRecord): string {
|
||||
const sourceKind = String(arrow?.sourceKind || "").toLowerCase();
|
||||
const action = String(arrow?.action || "").toLowerCase();
|
||||
const status = String(arrow?.status || "").toLowerCase();
|
||||
if (action === "observe" || status === "observation" || sourceKind === "monitor") return "#4eb7a8";
|
||||
if (sourceKind === "webui") return "#69aee8";
|
||||
if (sourceKind === "cli") return "#d7a13a";
|
||||
if (status.includes("ignored")) return "#81939f";
|
||||
return "#8aa0ad";
|
||||
}
|
||||
|
||||
function pipelineGanttExportMarkerSvg(marker: AnyRecord, x: number, y: number): string {
|
||||
const color = pipelineGanttExportMarkerColor(marker);
|
||||
const kind = String(marker?.kind || "");
|
||||
if (kind === "control-source") {
|
||||
return `<rect x="${x - 5.5}" y="${y - 5.5}" width="11" height="11" rx="2" transform="rotate(45 ${x} ${y})" fill="${color}" stroke="rgba(255,255,255,0.26)" stroke-width="1"/>`;
|
||||
}
|
||||
if (kind === "control-target") {
|
||||
const fill = String(marker?.tone || "").toLowerCase() === "approve" ? "rgba(78,183,168,0.22)" : "#081118";
|
||||
return `<circle cx="${x}" cy="${y}" r="5.5" fill="${fill}" stroke="${color}" stroke-width="2"/>`;
|
||||
}
|
||||
const radius = kind === "prompt" ? 4.5 : 5.5;
|
||||
return `<circle cx="${x}" cy="${y}" r="${radius}" fill="${color}" stroke="rgba(255,255,255,0.24)" stroke-width="1"/>`;
|
||||
}
|
||||
|
||||
function pipelineGanttSvg(input: AnyRecord): { svg: string; width: number; height: number } {
|
||||
const nodeIds = asArray(input.visibleNodeIds).map((nodeId) => String(nodeId || "")).filter(Boolean);
|
||||
const intervals = asArray(input.intervals).filter(isRecord);
|
||||
const markers = asArray(input.markers).filter(isRecord);
|
||||
const arrows = asArray(input.arrows).filter(isRecord);
|
||||
const ticks = asArray(input.ticks).filter(isRecord);
|
||||
const bounds = isRecord(input.bounds) ? input.bounds : {};
|
||||
const backendLayout = isRecord(input.backendLayout) ? input.backendLayout : null;
|
||||
const chartHeight = Math.max(240, Math.round(Number(input.chartHeight || 360)));
|
||||
const exportColumnWidth = Math.max(pipelineGanttNodeColumnWidth, 108);
|
||||
const timeAxisWidth = 128;
|
||||
const padding = 24;
|
||||
const titleHeight = 58;
|
||||
const headerHeight = 56;
|
||||
const boardWidth = timeAxisWidth + Math.max(1, nodeIds.length) * exportColumnWidth;
|
||||
const width = Math.max(760, boardWidth + padding * 2);
|
||||
const height = titleHeight + headerHeight + chartHeight + padding;
|
||||
const boardX = padding;
|
||||
const boardY = titleHeight;
|
||||
const chartY = boardY + headerHeight;
|
||||
const nodeX = (index: number) => boardX + timeAxisWidth + index * exportColumnWidth;
|
||||
const nodeCenterX = (index: number) => nodeX(index) + exportColumnWidth / 2;
|
||||
const meta = asArray(input.meta).map((item) => String(item || "")).filter(Boolean).slice(0, 4).join(" · ");
|
||||
const markerById = new Map(markers.map((marker) => [String(marker.id || ""), marker]));
|
||||
const markerColors = Array.from(new Set(["#4eb7a8", "#69aee8", "#d7a13a", "#cf6a54", "#8aa0ad", ...arrows.map(pipelineGanttExportArrowColor)]));
|
||||
const defs = markerColors.map((color) =>
|
||||
`<marker id="${exportMarkerId(color)}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="${color}"/></marker>`,
|
||||
).join("");
|
||||
const tickSvg = ticks.map((tick) => {
|
||||
const y = chartY + pipelineGanttTickY(tick, bounds, chartHeight, backendLayout);
|
||||
return `<g>
|
||||
<line x1="${boardX}" y1="${y}" x2="${boardX + boardWidth}" y2="${y}" stroke="rgba(215,161,58,0.18)" stroke-width="1"/>
|
||||
<text x="${boardX + 8}" y="${y - 4}" fill="#dce6ec" font-size="10" font-family="monospace">${escapeSvg(fmtDate(tick.ms))}</text>
|
||||
<text x="${boardX + 8}" y="${y + 10}" fill="#81939f" font-size="9" font-family="monospace">+${escapeSvg(fmtDurationMs(Number(tick.offsetMs ?? Number(tick.ms) - Number(bounds.startMs))))}</text>
|
||||
</g>`;
|
||||
}).join("\n");
|
||||
const headerSvg = [
|
||||
`<rect x="${boardX}" y="${boardY}" width="${timeAxisWidth}" height="${headerHeight}" fill="#101d25" stroke="rgba(255,255,255,0.08)"/>`,
|
||||
`<text x="${boardX + 10}" y="${boardY + 20}" fill="#d7a13a" font-size="10" font-family="monospace" letter-spacing="1.2">TIME</text>`,
|
||||
...nodeIds.map((nodeId, index) => {
|
||||
const x = nodeX(index);
|
||||
const label = nodeId.length > 18 ? `${nodeId.slice(0, 16)}…` : nodeId;
|
||||
return `<g>
|
||||
<rect x="${x}" y="${boardY}" width="${exportColumnWidth}" height="${headerHeight}" fill="#101d25" stroke="rgba(255,255,255,0.08)"/>
|
||||
<text x="${x + exportColumnWidth / 2}" y="${boardY + 21}" fill="#dce6ec" font-size="10" font-family="monospace" text-anchor="middle">${escapeSvg(label)}</text>
|
||||
<text x="${x + exportColumnWidth / 2}" y="${boardY + 39}" fill="#81939f" font-size="9" font-family="monospace" text-anchor="middle">node ${index + 1}</text>
|
||||
</g>`;
|
||||
}),
|
||||
].join("\n");
|
||||
const columnSvg = nodeIds.map((_nodeId, index) => {
|
||||
const x = nodeX(index);
|
||||
return `<rect x="${x}" y="${chartY}" width="${exportColumnWidth}" height="${chartHeight}" fill="${index % 2 === 0 ? "rgba(255,255,255,0.018)" : "rgba(255,255,255,0.008)"}" stroke="rgba(255,255,255,0.045)"/>`;
|
||||
}).join("\n");
|
||||
const intervalSvg = intervals.map((interval) => {
|
||||
const index = nodeIds.indexOf(String(interval.nodeId || ""));
|
||||
if (index < 0) return "";
|
||||
const top = chartY + pipelineGanttIntervalTop(interval, bounds, chartHeight, backendLayout);
|
||||
const barHeight = Math.max(2, pipelineGanttIntervalHeight(interval, bounds, chartHeight, backendLayout));
|
||||
const color = pipelineGanttExportStatusColor(interval.status);
|
||||
const x = nodeCenterX(index) - 3.5;
|
||||
const liveOverlay = interval.live ? `<rect x="${x - 1}" y="${top}" width="9" height="${barHeight}" rx="5" fill="none" stroke="rgba(255,255,255,0.54)" stroke-width="1" stroke-dasharray="4 5"/>` : "";
|
||||
const label = barHeight >= 28
|
||||
? `<text x="${x + 12}" y="${top + 12}" fill="${color}" font-size="9" font-family="monospace">${escapeSvg(String(interval.status || "working"))}</text>
|
||||
<text x="${x + 12}" y="${top + 24}" fill="#81939f" font-size="8" font-family="monospace">${escapeSvg(fmtDurationMs(interval.durationMs))}</text>`
|
||||
: "";
|
||||
return `<g>
|
||||
<rect x="${x}" y="${top}" width="7" height="${barHeight}" rx="4" fill="${color}" stroke="rgba(0,0,0,0.42)" stroke-width="1"/>
|
||||
${liveOverlay}
|
||||
${label}
|
||||
</g>`;
|
||||
}).join("\n");
|
||||
const markerSvg = markers.map((marker) => {
|
||||
const index = nodeIds.indexOf(String(marker.nodeId || ""));
|
||||
if (index < 0) return "";
|
||||
const y = chartY + pipelineGanttMarkerY(marker, bounds, chartHeight, backendLayout);
|
||||
return pipelineGanttExportMarkerSvg(marker, nodeCenterX(index), y);
|
||||
}).join("\n");
|
||||
const arrowSvg = arrows.map((arrow) => {
|
||||
const targetMarker = markerById.get(String(arrow.targetMarkerId || ""));
|
||||
if (!targetMarker) return "";
|
||||
const sourceMarker = markerById.get(String(arrow.sourceMarkerId || ""));
|
||||
const sourceNodeId = String(sourceMarker?.nodeId || arrow.sourceNodeId || "");
|
||||
const targetNodeId = String(targetMarker.nodeId || arrow.targetNodeId || "");
|
||||
const sourceIndex = nodeIds.indexOf(sourceNodeId);
|
||||
const targetIndex = nodeIds.indexOf(targetNodeId);
|
||||
if (sourceIndex < 0 || targetIndex < 0) return "";
|
||||
const sourceX = nodeCenterX(sourceIndex) - boardX - timeAxisWidth;
|
||||
const targetX = nodeCenterX(targetIndex) - boardX - timeAxisWidth;
|
||||
const sourceY = pipelineGanttNumber(arrow.sourceY ?? arrow.y1)
|
||||
?? (sourceMarker ? pipelineGanttMarkerY(sourceMarker, bounds, chartHeight, backendLayout) : pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout));
|
||||
const targetY = pipelineGanttNumber(arrow.targetY ?? arrow.y2) ?? pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout);
|
||||
const color = pipelineGanttExportArrowColor(arrow);
|
||||
const dash = String(arrow.action || "").toLowerCase() === "observe" ? "3 4" : "6 5";
|
||||
const path = escapeSvg(pipelineControlArrowPath(sourceX, sourceY, targetX, targetY));
|
||||
return `<path d="${path}" fill="none" stroke="rgba(8,17,24,0.92)" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="${path}" fill="none" stroke="${color}" stroke-width="2.45" stroke-dasharray="${dash}" stroke-linecap="round" stroke-linejoin="round" opacity="0.96" marker-end="url(#${exportMarkerId(color)})"/>`;
|
||||
}).join("\n");
|
||||
const emptySvg = nodeIds.length === 0 ? `<text x="${boardX + timeAxisWidth + 24}" y="${chartY + 42}" fill="#81939f" font-size="12" font-family="monospace">No visible Gantt nodes</text>` : "";
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||
<defs>${defs}<pattern id="ganttGrid" width="32" height="32" patternUnits="userSpaceOnUse"><path d="M 32 0 L 0 0 0 32" fill="none" stroke="#18303a" stroke-width="0.55"/></pattern></defs>
|
||||
<rect width="100%" height="100%" fill="#081118"/>
|
||||
<rect width="100%" height="100%" fill="url(#ganttGrid)" opacity="0.55"/>
|
||||
<rect x="${boardX}" y="${chartY}" width="${boardWidth}" height="${chartHeight}" fill="rgba(255,255,255,0.012)" stroke="rgba(78,183,168,0.24)"/>
|
||||
<text x="${padding}" y="26" fill="#d7a13a" font-size="13" font-family="monospace" font-weight="700" letter-spacing="1.4">${escapeSvg(input.title || "Pipeline Epoch Gantt")}</text>
|
||||
<text x="${padding}" y="45" fill="#81939f" font-size="10" font-family="monospace">${escapeSvg(meta)}</text>
|
||||
${headerSvg}
|
||||
<rect x="${boardX}" y="${chartY}" width="${timeAxisWidth}" height="${chartHeight}" fill="rgba(215,161,58,0.055)" stroke="rgba(215,161,58,0.2)"/>
|
||||
${columnSvg}
|
||||
${tickSvg}
|
||||
${intervalSvg}
|
||||
<g transform="translate(${boardX + timeAxisWidth} ${chartY})">${arrowSvg}</g>
|
||||
${markerSvg}
|
||||
${emptySvg}
|
||||
</svg>`;
|
||||
return { svg, width, height };
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
@@ -1921,7 +2229,7 @@ function downloadBlob(blob: Blob, filename: string): void {
|
||||
}
|
||||
|
||||
async function exportPipelineGraph(flow: { nodes: Node[]; edges: Edge[] }, title: string): Promise<void> {
|
||||
const safeTitle = String(title || "pipeline").replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-|-$/g, "") || "pipeline";
|
||||
const safeTitle = safeExportFileTitle(title, "pipeline");
|
||||
const { svg, width, height } = pipelineGraphSvg(flow, title);
|
||||
const svgBlob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
@@ -1948,6 +2256,34 @@ async function exportPipelineGraph(flow: { nodes: Node[]; edges: Edge[] }, title
|
||||
}
|
||||
}
|
||||
|
||||
async function exportPipelineGantt(input: AnyRecord): Promise<void> {
|
||||
const safeTitle = safeExportFileTitle(String(input?.title || "pipeline-gantt"), "pipeline-gantt");
|
||||
const { svg, width, height } = pipelineGanttSvg(input);
|
||||
const svgBlob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
try {
|
||||
const image = new Image();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
image.onload = () => resolve();
|
||||
image.onerror = () => reject(new Error("gantt svg image load failed"));
|
||||
image.src = url;
|
||||
});
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("canvas unavailable");
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const pngBlob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||
if (!pngBlob) throw new Error("gantt png export failed");
|
||||
downloadBlob(pngBlob, `${safeTitle}.png`);
|
||||
} catch {
|
||||
downloadBlob(svgBlob, `${safeTitle}.svg`);
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportPipelineGraphs(items: Array<{ flow: { nodes: Node[]; edges: Edge[] }; title: string }>): Promise<void> {
|
||||
for (const item of items) {
|
||||
if (item.flow.nodes.length === 0) continue;
|
||||
@@ -2021,7 +2357,7 @@ function procedureEndIso(procedure: any, run: any): string {
|
||||
);
|
||||
}
|
||||
|
||||
function pipelineRunIntervals(run: any, pipelineNodes: AnyRecord[]): AnyRecord[] {
|
||||
function pipelineRunIntervals(run: any, pipelineNodes: AnyRecord[], nowMs = Date.now()): AnyRecord[] {
|
||||
const runId = String(run?.runId || "");
|
||||
const knownNodeIds = new Set(pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean));
|
||||
return asArray(run?.procedureRuns).flatMap((procedure: any) => {
|
||||
@@ -2032,7 +2368,7 @@ function pipelineRunIntervals(run: any, pipelineNodes: AnyRecord[]): AnyRecord[]
|
||||
const startMs = timeMs(startIso);
|
||||
if (startMs === null) return [];
|
||||
const explicitEndIso = procedureEndIso(procedure, run);
|
||||
const endMs = timeMs(explicitEndIso) ?? (terminalStatus(status) ? (timeMs(procedure?.updatedAt) ?? startMs + 1000) : Date.now());
|
||||
const endMs = timeMs(explicitEndIso) ?? (terminalStatus(status) ? (timeMs(procedure?.updatedAt) ?? startMs + 1000) : nowMs);
|
||||
const safeEndMs = Math.max(startMs + 1000, endMs);
|
||||
return [{
|
||||
nodeId,
|
||||
@@ -2089,6 +2425,11 @@ function pipelineGanttScaleConfig(value: number): AnyRecord {
|
||||
return { value: clampPipelineGanttScale(normalized * 100), pxPerMinute, label };
|
||||
}
|
||||
|
||||
function pipelineDisplayPxPerMinute(value: any): number {
|
||||
const rounded = Math.round(Number(value));
|
||||
return Math.abs(rounded - pipelineDefaultGanttPxPerMinute) <= 1 ? pipelineDefaultGanttPxPerMinute : rounded;
|
||||
}
|
||||
|
||||
function pipelineGanttHeight(bounds: AnyRecord, scaleValue = pipelineDefaultGanttScale): number {
|
||||
const minutes = Math.max(1, Number(bounds.durationMs || 0) / 60_000);
|
||||
const scale = pipelineGanttScaleConfig(scaleValue);
|
||||
@@ -2123,6 +2464,69 @@ function pipelineGanttNumber(value: any): number | null {
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function pipelineGanttIntervalIsRunning(interval: AnyRecord): boolean {
|
||||
return runningStatus(interval?.status) && !terminalStatus(interval?.status);
|
||||
}
|
||||
|
||||
function pipelineGanttMsToY(ms: number, layout: AnyRecord | null): number | null {
|
||||
if (!layout) return null;
|
||||
const startMs = pipelineGanttNumber(layout?.startMs);
|
||||
const endMs = pipelineGanttNumber(layout?.endMs);
|
||||
const chartHeight = pipelineGanttNumber(layout?.chartHeight);
|
||||
if (startMs === null || endMs === null || chartHeight === null) return null;
|
||||
const durationMs = Math.max(1, endMs - startMs);
|
||||
return Math.max(0, ((ms - startMs) / durationMs) * chartHeight);
|
||||
}
|
||||
|
||||
function pipelineGanttLiveEndMs(interval: AnyRecord, nowMs: number): number {
|
||||
const startMs = pipelineGanttNumber(interval?.rawStartMs ?? interval?.startMs) ?? pipelineGanttNumber(interval?.startMs) ?? nowMs;
|
||||
const currentEndMs = pipelineGanttNumber(interval?.endMs) ?? startMs + 1000;
|
||||
if (!pipelineGanttIntervalIsRunning(interval)) return Math.max(startMs + 1000, currentEndMs);
|
||||
return Math.max(startMs + 1000, currentEndMs, nowMs);
|
||||
}
|
||||
|
||||
function pipelineGanttLiveBackendLayout(layout: AnyRecord | null, intervals: AnyRecord[], nowMs: number): AnyRecord | null {
|
||||
if (!layout) return null;
|
||||
const startMs = pipelineGanttNumber(layout?.startMs);
|
||||
const endMs = pipelineGanttNumber(layout?.endMs);
|
||||
const chartHeight = pipelineGanttNumber(layout?.chartHeight);
|
||||
if (startMs === null || endMs === null || chartHeight === null) return layout;
|
||||
const liveEndMs = intervals.reduce((maxMs, interval) => Math.max(maxMs, pipelineGanttLiveEndMs(interval, nowMs)), endMs);
|
||||
if (liveEndMs <= endMs) return layout;
|
||||
const pxPerMs = chartHeight / Math.max(1, endMs - startMs);
|
||||
return {
|
||||
...layout,
|
||||
endMs: liveEndMs,
|
||||
durationMs: Math.max(1, liveEndMs - startMs),
|
||||
chartHeight: Math.max(chartHeight, Math.round((liveEndMs - startMs) * pxPerMs)),
|
||||
liveExtended: true,
|
||||
};
|
||||
}
|
||||
|
||||
function pipelineGanttNormalizeLiveInterval(interval: AnyRecord, layout: AnyRecord | null, nowMs: number): AnyRecord {
|
||||
if (!pipelineGanttIntervalIsRunning(interval)) return interval;
|
||||
const startMs = pipelineGanttNumber(interval?.rawStartMs ?? interval?.startMs) ?? pipelineGanttNumber(interval?.startMs) ?? nowMs;
|
||||
const endMs = pipelineGanttLiveEndMs(interval, nowMs);
|
||||
const y1 = pipelineGanttMsToY(startMs, layout);
|
||||
const y2 = pipelineGanttMsToY(endMs, layout);
|
||||
const safeY1 = pipelineGanttNumber(y1 ?? interval?.y1 ?? interval?.startY) ?? 0;
|
||||
const safeY2 = pipelineGanttNumber(y2 ?? interval?.y2 ?? interval?.endY) ?? safeY1 + 10;
|
||||
const height = Math.max(24, safeY2 - safeY1);
|
||||
return {
|
||||
...interval,
|
||||
live: true,
|
||||
startMs,
|
||||
endMs,
|
||||
durationMs: Math.max(1000, endMs - startMs),
|
||||
finishedAt: isoFromMs(endMs),
|
||||
y1: safeY1,
|
||||
y2: safeY2,
|
||||
startY: safeY1,
|
||||
endY: safeY2,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
function pipelineGanttFallbackY(ms: number, bounds: AnyRecord, chartHeight: number): number {
|
||||
return (markerPercent(ms, bounds) / 100) * chartHeight;
|
||||
}
|
||||
@@ -2205,30 +2609,72 @@ function pipelineGanttNormalizeBackendMarker(marker: AnyRecord): AnyRecord {
|
||||
};
|
||||
}
|
||||
|
||||
function pipelineObservedPromptSourceNodeId(marker: AnyRecord): string {
|
||||
const promptEvent = String(marker?.promptEvent || marker?.raw?.promptEvent || marker?.event || "").toLowerCase();
|
||||
if (!["node-long-running-observation", "node-finished"].includes(promptEvent)) return "";
|
||||
const sourceNodeId = String(marker?.sourceNodeId || marker?.raw?.sourceNodeId || marker?.raw?.detail?.nodeId || "");
|
||||
const monitorNodeId = String(marker?.nodeId || marker?.targetNodeId || "");
|
||||
return sourceNodeId && sourceNodeId !== monitorNodeId ? sourceNodeId : "";
|
||||
}
|
||||
|
||||
function pipelineGanttAddObservationArrows(markers: AnyRecord[], arrows: AnyRecord[]): AnyRecord {
|
||||
const arrowKeys = new Set(arrows.map((arrow) => [
|
||||
String(arrow.sourceNodeId || ""),
|
||||
String(arrow.targetNodeId || ""),
|
||||
String(arrow.targetMarkerId || ""),
|
||||
String(arrow.action || ""),
|
||||
].join(":")));
|
||||
const nextArrows = [...arrows];
|
||||
for (const marker of markers) {
|
||||
const observedNodeId = pipelineObservedPromptSourceNodeId(marker);
|
||||
const monitorNodeId = String(marker?.nodeId || "");
|
||||
const targetMarkerId = String(marker?.id || "");
|
||||
if (!observedNodeId || !monitorNodeId || !targetMarkerId) continue;
|
||||
const arrowKey = [observedNodeId, monitorNodeId, targetMarkerId, "observe"].join(":");
|
||||
if (arrowKeys.has(arrowKey)) continue;
|
||||
arrowKeys.add(arrowKey);
|
||||
nextArrows.push({
|
||||
id: `observation-arrow:${targetMarkerId}:${observedNodeId}:${monitorNodeId}`,
|
||||
commandId: String(marker?.commandId || marker?.eventId || targetMarkerId),
|
||||
sourceNodeId: observedNodeId,
|
||||
targetNodeId: monitorNodeId,
|
||||
sourceMarkerId: "",
|
||||
targetMarkerId,
|
||||
sourceKind: "monitor",
|
||||
action: "observe",
|
||||
status: "observation",
|
||||
});
|
||||
}
|
||||
return { markers, arrows: nextArrows };
|
||||
}
|
||||
|
||||
function pipelineBackendGanttSignals(details: any): AnyRecord {
|
||||
const gantt = isRecord(details?.gantt) ? details.gantt : {};
|
||||
return {
|
||||
markers: asArray(gantt.markers).filter(isRecord).map(pipelineGanttNormalizeBackendMarker),
|
||||
arrows: asArray(gantt.arrows).filter(isRecord),
|
||||
};
|
||||
const rawMarkers = asArray(gantt.markers).filter(isRecord).map(pipelineGanttNormalizeBackendMarker);
|
||||
const arrows = asArray(gantt.arrows).filter(isRecord);
|
||||
return pipelineGanttAddObservationArrows(rawMarkers, arrows);
|
||||
}
|
||||
|
||||
function pipelinePromptMarkerTone(record: any, fallbackKind = ""): string {
|
||||
const kind = eventKind(record) || fallbackKind;
|
||||
const promptEvent = String(record?.promptEvent || "");
|
||||
if (kind === "initial-prompt-delivered") return "initial";
|
||||
if (promptEvent === "node-audit-required" || promptEvent.startsWith("monitor-")) return "monitor";
|
||||
if (promptEvent === "node-finished" || promptEvent === "node-long-running-observation" || promptEvent.startsWith("monitor-")) return "monitor";
|
||||
if (kind === "monitor-prompt-delivered" || String(record?.sourceKind || "").toLowerCase() === "monitor" || fallbackKind === "monitor-prompt-queued") return "monitor";
|
||||
return "append";
|
||||
}
|
||||
|
||||
function pipelineRecordTags(record: any): string[] {
|
||||
return asArray(record?.tags || record?.raw?.tags).map((tag) => String(tag || "")).filter(Boolean);
|
||||
}
|
||||
|
||||
function pipelinePromptMarkerLabel(record: any, fallbackKind = ""): string {
|
||||
const kind = eventKind(record) || fallbackKind;
|
||||
const promptEvent = String(record?.promptEvent || "");
|
||||
if (kind === "initial-prompt-delivered") return "初始 prompt";
|
||||
if (promptEvent === "node-audit-required") return "审核请求";
|
||||
if (promptEvent === "monitor-interval") return "Monitor interval";
|
||||
if (promptEvent === "batch-finished") return "批次完成";
|
||||
if (promptEvent === "node-long-running-observation") return "长任务观察";
|
||||
if (promptEvent === "node-finished") return pipelineRecordTags(record).includes("monitor.audit") ? "节点完成 / OA 审核" : "节点完成";
|
||||
if (promptEvent === "monitor-interval") return "旧版轮询";
|
||||
if (promptEvent === "monitor-start") return "Monitor start";
|
||||
if (promptEvent === "monitor-stop") return "Monitor stop";
|
||||
if (kind === "monitor-prompt-delivered" || fallbackKind === "monitor-prompt-queued") return "Monitor prompt";
|
||||
@@ -2316,10 +2762,14 @@ function controlTargetMarkerPlacement(intervals: AnyRecord[], nodeId: string, ev
|
||||
}
|
||||
|
||||
function pipelineControlArrowPath(sourceX: number, sourceY: number, targetX: number, targetY: number): string {
|
||||
const deltaX = targetX - sourceX;
|
||||
const distance = Math.hypot(targetX - sourceX, targetY - sourceY);
|
||||
const inset = distance > pipelineGanttArrowTipInsetPx ? pipelineGanttArrowTipInsetPx : 0;
|
||||
const endX = inset > 0 ? targetX - ((targetX - sourceX) / distance) * inset : targetX;
|
||||
const endY = inset > 0 ? targetY - ((targetY - sourceY) / distance) * inset : targetY;
|
||||
const deltaX = endX - sourceX;
|
||||
const bend = Math.max(16, Math.min(42, Math.abs(deltaX) * 0.45 + 12));
|
||||
const sign = deltaX === 0 ? 1 : Math.sign(deltaX);
|
||||
return `M ${sourceX},${sourceY} C ${sourceX + sign * bend},${sourceY} ${targetX - sign * bend},${targetY} ${targetX},${targetY}`;
|
||||
return `M ${sourceX},${sourceY} C ${sourceX + sign * bend},${sourceY} ${endX - sign * bend},${endY} ${endX},${endY}`;
|
||||
}
|
||||
|
||||
function pipelineRunGanttSignals(details: any, activeRun: any): AnyRecord {
|
||||
@@ -2517,7 +2967,7 @@ function pipelineRunGanttSignals(details: any, activeRun: any): AnyRecord {
|
||||
Number(left.ms) - Number(right.ms)
|
||||
|| String(left.nodeId).localeCompare(String(right.nodeId))
|
||||
|| String(left.id).localeCompare(String(right.id)));
|
||||
return { markers, arrows: controlArrows, sourceMarkerByCommand };
|
||||
return { ...pipelineGanttAddObservationArrows(markers, controlArrows), sourceMarkerByCommand };
|
||||
}
|
||||
|
||||
function PipelineNodeExecutionIndex({ details, selectedNodeId, selectedNodeRuntime, control, onRaw }: AnyRecord) {
|
||||
@@ -2678,20 +3128,85 @@ function PipelineRunMaterialIndex({ activeRun, onRaw }: AnyRecord) {
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineOaEventFlowPanel({ diagnostics, onRaw }: AnyRecord) {
|
||||
const runs = asArray(diagnostics?.runs).filter(isRecord);
|
||||
const forbiddenResiduals = asArray(diagnostics?.forbiddenResiduals);
|
||||
const guarantees = isRecord(diagnostics?.guarantees) ? diagnostics.guarantees : {};
|
||||
const evidenceOk = diagnostics?.hasNeutralNodeFinishedEvidence === true
|
||||
&& diagnostics?.hasNoAuditPolicyEvidence === true
|
||||
&& diagnostics?.hasAuditPolicyEvidence === true;
|
||||
const ok = diagnostics?.ok === true && evidenceOk && forbiddenResiduals.length === 0;
|
||||
const latestRun = runs[0] || null;
|
||||
const guaranteeItems = [
|
||||
{ label: "中性完成事实", ok: guarantees.neutralNodeFinished === true, hint: "node-finished 不携带流程策略" },
|
||||
{ label: "Config 策略判定", ok: guarantees.auditPolicyFromConfig === true, hint: "OA backend 读取当前 epoch 配置" },
|
||||
{ label: "控制命令来自 OA", ok: guarantees.runnerConsumesControlCommandsFromOaEvents === true, hint: "runner 只消费 OA control.command" },
|
||||
{ label: "无独立审核事件", ok: guarantees.noIndependentAuditRequestEvent === true, hint: "审核由 node-finished + policy 派生" },
|
||||
{ label: "无批次门禁", ok: guarantees.noBatchFinishedControlGate === true, hint: "下游启动由每个 node 完成驱动" },
|
||||
];
|
||||
return h("div", { className: "pipeline-oa-panel", "data-testid": "pipeline-oa-event-flow-panel" },
|
||||
h("div", { className: "metric-grid compact" },
|
||||
h(MetricCard, { label: "OA Flow", value: ok ? "100%" : "--", hint: String(diagnostics?.mode || "waiting diagnostics"), tone: ok ? "ok" : "warn" }),
|
||||
h(MetricCard, { label: "禁止残留", value: forbiddenResiduals.length, hint: forbiddenResiduals.length === 0 ? "source scan clean" : "needs cleanup", tone: forbiddenResiduals.length === 0 ? "ok" : "warn" }),
|
||||
h(MetricCard, { label: "No-audit", value: diagnostics?.hasNoAuditPolicyEvidence ? "OK" : "--", hint: "OA 下游策略证据", tone: diagnostics?.hasNoAuditPolicyEvidence ? "ok" : "warn" }),
|
||||
h(MetricCard, { label: "Monitor 审核", value: diagnostics?.hasAuditPolicyEvidence ? "OK" : "--", hint: "OA 控制事件闭环", tone: diagnostics?.hasAuditPolicyEvidence ? "ok" : "warn" }),
|
||||
),
|
||||
h("div", { className: "pipeline-oa-guarantees" },
|
||||
guaranteeItems.map((item) => h("article", { key: item.label, className: `pipeline-oa-guarantee ${item.ok ? "ok" : "warn"}` },
|
||||
h(StatusBadge, { status: item.ok ? "online" : "warn" }, item.ok ? "OK" : "MISS"),
|
||||
h("div", null, h("strong", null, item.label), h("span", null, item.hint)),
|
||||
)),
|
||||
),
|
||||
h("div", { className: "pipeline-evidence-list compact" },
|
||||
runs.slice(0, 6).map((run: AnyRecord) => h(EvidenceIndexRow, {
|
||||
key: run.runId,
|
||||
title: String(run.runId || "--"),
|
||||
subtitle: [
|
||||
Number(run.monitorAuditNodeFinishedCount || 0) > 0 ? "monitor audit" : "",
|
||||
Number(run.noAuditPolicyCount || 0) > 0 ? "no-audit policy" : "",
|
||||
].filter(Boolean).join(" / ") || "event evidence",
|
||||
facts: [
|
||||
`events ${run.eventCount || 0}`,
|
||||
`node-finished ${run.nodeFinishedCount || 0}`,
|
||||
`policy-in-detail ${run.nodeFinishedWithPolicyCount || 0}`,
|
||||
`queued ${run.controlQueuedCount || 0}`,
|
||||
`applied ${run.controlAppliedCount || 0}`,
|
||||
],
|
||||
data: run,
|
||||
onRaw,
|
||||
testId: `raw-pipeline-oa-run-${String(run.runId || "run").replace(/[^a-zA-Z0-9_.-]+/g, "-")}`,
|
||||
})),
|
||||
),
|
||||
latestRun ? h("p", { className: "muted paragraph" }, `最新证据 ${latestRun.runId}: ${latestRun.nodeFinishedCount || 0} 个 node-finished,${latestRun.controlAppliedCount || 0} 个控制结果。`) : h(EmptyState, { title: "暂无 OA 事件流证据", text: "等待 Pipeline backend 暴露 diagnostics。" }),
|
||||
diagnostics ? h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "Pipeline OA Event Flow Diagnostics", data: diagnostics, onOpen: onRaw, testId: "raw-pipeline-oa-event-flow" })) : null,
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, pipelineEdges, runDetails, nodeDetails, ganttScale = pipelineDefaultGanttScale, onGanttScaleChange, onRunChange, onIntervalSelect, onMarkerSelect, selection, onRaw }: AnyRecord) {
|
||||
const [autoHideIdle, setAutoHideIdle] = useState(pipelineDefaultGanttAutoHideIdle);
|
||||
const [visibleRange, setVisibleRange] = useState({ startY: 0, endY: 0, startMs: 0, endMs: 0 });
|
||||
const [liveNowMs, setLiveNowMs] = useState(Date.now());
|
||||
const viewportRef = useRef(null);
|
||||
const activeRunId = String(activeRun?.runId || "");
|
||||
const timeScale = clampPipelineGanttScale(ganttScale ?? pipelineDefaultGanttScale);
|
||||
const fallbackIntervals = pipelineRunIntervals(activeRun, pipelineNodes);
|
||||
const fallbackIntervals = pipelineRunIntervals(activeRun, pipelineNodes, liveNowMs);
|
||||
const runDetailPayload = String(runDetails?.runId || "") === activeRunId ? runDetails?.details : null;
|
||||
const backendGantt = isRecord(runDetailPayload?.gantt) ? runDetailPayload.gantt : null;
|
||||
const backendLayout = pipelineGanttBackendLayout(backendGantt);
|
||||
const hasBackendLayout = Boolean(backendLayout);
|
||||
const intervals = hasBackendLayout
|
||||
const baseBackendLayout = pipelineGanttBackendLayout(backendGantt);
|
||||
const hasBackendLayout = Boolean(baseBackendLayout);
|
||||
const rawIntervals = hasBackendLayout
|
||||
? asArray(backendGantt?.intervals).filter(isRecord).map((interval: AnyRecord) => ({ ...interval, runId: activeRunId }))
|
||||
: fallbackIntervals;
|
||||
const hasLiveRunning = rawIntervals.some(pipelineGanttIntervalIsRunning);
|
||||
useEffect(() => {
|
||||
if (!activeRunId || !hasLiveRunning) return undefined;
|
||||
const timer = window.setInterval(() => setLiveNowMs(Date.now()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [activeRunId, hasLiveRunning]);
|
||||
const backendLayout = hasBackendLayout ? pipelineGanttLiveBackendLayout(baseBackendLayout, rawIntervals, liveNowMs) : baseBackendLayout;
|
||||
const intervals = hasBackendLayout
|
||||
? rawIntervals.map((interval: AnyRecord) => pipelineGanttNormalizeLiveInterval(interval, backendLayout, liveNowMs))
|
||||
: rawIntervals;
|
||||
const bounds = hasBackendLayout ? {
|
||||
startMs: Number(backendLayout?.startMs),
|
||||
endMs: Number(backendLayout?.endMs),
|
||||
@@ -2777,14 +3292,37 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes,
|
||||
const visibleMarkerIds = new Set(allMarkers.filter((marker: AnyRecord) =>
|
||||
visibleNodeIds.includes(String(marker.nodeId || "")) && markerIsVisible(marker)).map((marker: AnyRecord) => String(marker.id)));
|
||||
const markerById = new Map(allMarkers.map((marker: AnyRecord) => [String(marker.id), marker]));
|
||||
const visibleArrows = asArray(ganttSignals.arrows).filter((arrow: AnyRecord) =>
|
||||
visibleMarkerIds.has(String(arrow.sourceMarkerId || "")) && visibleMarkerIds.has(String(arrow.targetMarkerId || "")));
|
||||
const visibleArrows = asArray(ganttSignals.arrows).filter((arrow: AnyRecord) => {
|
||||
const targetVisible = visibleMarkerIds.has(String(arrow.targetMarkerId || ""));
|
||||
if (!targetVisible) return false;
|
||||
if (String(arrow.action || "") === "observe") return visibleNodeIds.includes(String(arrow.sourceNodeId || ""));
|
||||
return visibleMarkerIds.has(String(arrow.sourceMarkerId || ""));
|
||||
});
|
||||
const boardMinWidth = pipelineGanttTimeAxisWidth + Math.max(1, visibleNodeIds.length) * pipelineGanttNodeColumnWidth;
|
||||
const setScaleFromSlider = (event: any) => {
|
||||
const nextScale = clampPipelineGanttScale(event.target.value);
|
||||
if (typeof onGanttScaleChange === "function") onGanttScaleChange(nextScale);
|
||||
window.setTimeout(updateVisibleRange, 0);
|
||||
};
|
||||
const exportCurrentGantt = () => exportPipelineGantt({
|
||||
title: `${activePipeline?.id || "pipeline"}-${activeRunId || "epoch"}-gantt`,
|
||||
meta: [
|
||||
`run ${activeRunId || "--"}`,
|
||||
`${fmtDate(bounds.startMs)} -> ${fmtDate(bounds.endMs)}`,
|
||||
`duration ${fmtDurationMs(bounds.durationMs)}`,
|
||||
`${timeScaleConfig.label} / ${pipelineDisplayPxPerMinute(timeScaleConfig.pxPerMinute)} px/min`,
|
||||
`${visibleNodeIds.length}/${allNodeIds.length} nodes`,
|
||||
`${allMarkers.length} markers`,
|
||||
],
|
||||
visibleNodeIds,
|
||||
intervals,
|
||||
markers: allMarkers.filter((marker: AnyRecord) => visibleNodeIds.includes(String(marker.nodeId || ""))),
|
||||
arrows: visibleArrows,
|
||||
ticks,
|
||||
bounds,
|
||||
chartHeight,
|
||||
backendLayout,
|
||||
});
|
||||
const diagnostics = isRecord(backendGantt?.diagnostics) ? backendGantt.diagnostics : null;
|
||||
return h(Panel, {
|
||||
title: "Epoch 甘特图",
|
||||
@@ -2812,7 +3350,7 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes,
|
||||
h("label", { className: "pipeline-gantt-scale" },
|
||||
h("span", null,
|
||||
h("b", null, "时间尺度"),
|
||||
h("em", { "data-testid": "pipeline-gantt-scale-label" }, `${timeScaleConfig.label} · ${Math.round(Number(timeScaleConfig.pxPerMinute))} px/min`),
|
||||
h("em", { "data-testid": "pipeline-gantt-scale-label" }, `${timeScaleConfig.label} · ${pipelineDisplayPxPerMinute(timeScaleConfig.pxPerMinute)} px/min`),
|
||||
),
|
||||
h("input", {
|
||||
type: "range",
|
||||
@@ -2826,6 +3364,13 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes,
|
||||
}),
|
||||
h("small", null, h("span", null, "全局"), h("span", null, "细节")),
|
||||
),
|
||||
activeRun ? h("button", {
|
||||
type: "button",
|
||||
className: "ghost-btn",
|
||||
onClick: exportCurrentGantt,
|
||||
disabled: visibleNodeIds.length === 0,
|
||||
"data-testid": "pipeline-export-gantt",
|
||||
}, "导出甘特图") : null,
|
||||
activeRun ? h(RawButton, { title: `Pipeline Epoch ${activeRun.runId}`, data: activeRun, onOpen: onRaw, testId: "raw-pipeline-epoch-gantt" }) : null,
|
||||
),
|
||||
},
|
||||
@@ -2837,14 +3382,14 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes,
|
||||
h("div", { className: "pipeline-gantt-meta" },
|
||||
h("span", null, `time ${fmtDate(bounds.startMs)} -> ${fmtDate(bounds.endMs)}`),
|
||||
h("span", null, `duration ${fmtDurationMs(bounds.durationMs)}`),
|
||||
h("span", null, `scale ${timeScaleConfig.label} / ${Math.round(Number(timeScaleConfig.pxPerMinute))} px/min`),
|
||||
h("span", null, `scale ${timeScaleConfig.label} / ${pipelineDisplayPxPerMinute(timeScaleConfig.pxPerMinute)} px/min`),
|
||||
h("span", null, `layout ${hasBackendLayout ? "backend-y" : "fallback"}`),
|
||||
diagnostics ? h("span", null, `align ${diagnostics.timeAxisAlignmentOk === false ? "check" : "ok"}`) : null,
|
||||
h("span", null, `visible ${visibleNodeIds.length}/${allNodeIds.length} nodes`),
|
||||
runDetailPayload ? h("span", null, `markers ${allMarkers.length}`) : null,
|
||||
autoHideIdle && hiddenCount > 0 ? h("span", null, `hidden idle ${hiddenCount}`) : null,
|
||||
),
|
||||
h("div", { className: "pipeline-gantt-viewport", ref: viewportRef, "data-testid": "pipeline-epoch-gantt", "data-pipeline-id": activePipeline?.id || "" },
|
||||
h("div", { className: "pipeline-gantt-viewport", ref: viewportRef, "data-testid": "pipeline-epoch-gantt", "data-pipeline-id": activePipeline?.id || "", "data-run-id": activeRunId },
|
||||
h("div", { className: "pipeline-gantt-board", style: { gridTemplateColumns, minWidth: `${boardMinWidth}px` } },
|
||||
h("div", { className: "pipeline-gantt-head time" }, "Time"),
|
||||
visibleNodeIds.length === 0 ? h("div", { className: "pipeline-gantt-head empty" }, "当前时间窗无工作节点") :
|
||||
@@ -2885,21 +3430,28 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes,
|
||||
),
|
||||
),
|
||||
visibleArrows.map((arrow: AnyRecord) => {
|
||||
const sourceMarker = markerById.get(String(arrow.sourceMarkerId || ""));
|
||||
const targetMarker = markerById.get(String(arrow.targetMarkerId || ""));
|
||||
if (!sourceMarker || !targetMarker) return null;
|
||||
const sourceIndex = visibleNodeIds.indexOf(String(sourceMarker.nodeId || ""));
|
||||
if (!targetMarker) return null;
|
||||
const sourceMarker = markerById.get(String(arrow.sourceMarkerId || ""));
|
||||
const sourceNodeId = String(sourceMarker?.nodeId || arrow.sourceNodeId || "");
|
||||
const sourceIndex = visibleNodeIds.indexOf(sourceNodeId);
|
||||
const targetIndex = visibleNodeIds.indexOf(String(targetMarker.nodeId || ""));
|
||||
if (sourceIndex < 0 || targetIndex < 0) return null;
|
||||
const sourceX = sourceIndex * pipelineGanttNodeColumnWidth + pipelineGanttNodeColumnWidth / 2;
|
||||
const targetX = targetIndex * pipelineGanttNodeColumnWidth + pipelineGanttNodeColumnWidth / 2;
|
||||
const sourceY = pipelineGanttNumber(arrow.sourceY ?? arrow.y1) ?? pipelineGanttMarkerY(sourceMarker, bounds, chartHeight, backendLayout);
|
||||
const sourceY = pipelineGanttNumber(arrow.sourceY ?? arrow.y1)
|
||||
?? (sourceMarker ? pipelineGanttMarkerY(sourceMarker, bounds, chartHeight, backendLayout) : pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout));
|
||||
const targetY = pipelineGanttNumber(arrow.targetY ?? arrow.y2) ?? pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout);
|
||||
return h("path", {
|
||||
key: arrow.id,
|
||||
className: `pipeline-gantt-arrow ${String(arrow.sourceKind || "").toLowerCase()} ${String(arrow.status || "").toLowerCase()} ${String(arrow.action || "").toLowerCase()}`,
|
||||
d: pipelineControlArrowPath(sourceX, sourceY, targetX, targetY),
|
||||
markerEnd: "url(#pipeline-gantt-arrowhead)",
|
||||
"data-testid": String(arrow.action || "") === "observe" ? "pipeline-gantt-observation-arrow" : "pipeline-gantt-arrow",
|
||||
"data-source-node-id": String(arrow.sourceNodeId || ""),
|
||||
"data-target-node-id": String(arrow.targetNodeId || ""),
|
||||
"data-target-marker-id": String(arrow.targetMarkerId || ""),
|
||||
"data-action": String(arrow.action || ""),
|
||||
});
|
||||
}),
|
||||
) : null,
|
||||
@@ -2915,11 +3467,15 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes,
|
||||
return h("button", {
|
||||
key: intervalKey,
|
||||
type: "button",
|
||||
className: `pipeline-gantt-bar ${interval.status} ${selectedIntervalKey === intervalKey ? "selected" : ""}`,
|
||||
className: `pipeline-gantt-bar ${interval.status} ${interval.live ? "live" : ""} ${selectedIntervalKey === intervalKey ? "selected" : ""}`,
|
||||
style: { top: `${top}px`, height: `${height}px` },
|
||||
title: `${nodeId} ${interval.status} ${fmtDate(interval.startedAt || interval.startMs)} -> ${fmtDate(interval.finishedAt || interval.endMs)}`,
|
||||
onClick: () => onIntervalSelect(interval),
|
||||
"data-testid": "pipeline-gantt-line",
|
||||
"data-node-id": nodeId,
|
||||
"data-procedure-run-id": String(interval.procedureRunId || ""),
|
||||
"data-status": String(interval.status || ""),
|
||||
"data-live": interval.live ? "true" : "false",
|
||||
},
|
||||
h("strong", null, interval.status || "working"),
|
||||
h("span", null, fmtDurationMs(interval.durationMs)),
|
||||
@@ -2933,6 +3489,7 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes,
|
||||
title: `${marker.label || "event"} / ${fmtDate(marker.timestampIso || marker.timestamp || marker.ms)}`,
|
||||
onClick: () => onMarkerSelect(marker),
|
||||
"data-testid": marker.kind === "prompt" ? "pipeline-gantt-prompt-marker" : "pipeline-gantt-control-marker",
|
||||
"data-marker-id": String(marker.id || ""),
|
||||
})),
|
||||
);
|
||||
}),
|
||||
@@ -3098,7 +3655,7 @@ function PipelineNodeControlPanel({ activeRun, pipelineRuns, selectedRunId, onRu
|
||||
|
||||
export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) {
|
||||
const service = microservices.find((item: any) => item.id === "pipeline") || null;
|
||||
const [state, setState] = useState({ loading: false, error: "", health: null, snapshot: null, refreshedAt: null });
|
||||
const [state, setState] = useState({ loading: false, error: "", health: null, snapshot: null, oaDiagnostics: null, refreshedAt: null });
|
||||
const [selectedPipelineId, setSelectedPipelineId] = useState("");
|
||||
const [selectedRunId, setSelectedRunId] = useState("");
|
||||
const [selectedNodeId, setSelectedNodeId] = useState("");
|
||||
@@ -3121,10 +3678,14 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
|
||||
if (!silent) setState((prev: any) => ({ ...prev, loading: true, error: "" }));
|
||||
try {
|
||||
const snapshotQuery = `__unideskArrayLimit=registry.components:80,runs:${pipelineSnapshotRunLimit}&_=${Date.now()}`;
|
||||
const snapshot = await requestJson(`${apiBaseUrl}/microservices/pipeline/proxy/api/snapshot?${snapshotQuery}`, { cache: "no-store" });
|
||||
const [snapshot, oaDiagnostics] = await Promise.all([
|
||||
requestJson(`${apiBaseUrl}/microservices/pipeline/proxy/api/snapshot?${snapshotQuery}`, { cache: "no-store" }),
|
||||
requestJson(`${apiBaseUrl}/microservices/pipeline/proxy/api/oa-event-flow/diagnostics?_=${Date.now()}`, { cache: "no-store" })
|
||||
.catch((error: unknown) => ({ ok: false, error: errorMessage(error, "OA event flow diagnostics failed") })),
|
||||
]);
|
||||
if (requestId !== loadRequestRef.current) return;
|
||||
const health = { ok: snapshot?.ok !== false, service: "pipeline-v2-webui snapshot" };
|
||||
setState({ loading: false, error: "", health, snapshot, refreshedAt: new Date() });
|
||||
const health = { ok: snapshot?.ok !== false, service: "pipeline-v2-control snapshot" };
|
||||
setState({ loading: false, error: "", health, snapshot, oaDiagnostics, refreshedAt: new Date() });
|
||||
} catch (err) {
|
||||
if (requestId !== loadRequestRef.current) return;
|
||||
setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "Pipeline 加载失败") }));
|
||||
@@ -3146,14 +3707,19 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
|
||||
const repository = microserviceRepository(service);
|
||||
const backend = microserviceBackend(service);
|
||||
const snapshot = state.snapshot || {};
|
||||
const oaDiagnostics = state.oaDiagnostics || null;
|
||||
const { components, pipelines, runs } = pipelineSnapshotArrays(snapshot);
|
||||
const activePipeline = pipelines.find((pipeline: any) => String(pipeline.id || "") === selectedPipelineId) || pipelines[0] || {};
|
||||
const latestPipelineId = String(runs[0]?.pipelineId || "");
|
||||
const defaultPipeline = (latestPipelineId ? pipelines.find((pipeline: any) => String(pipeline.id || "") === latestPipelineId) : null) || pipelines[0] || {};
|
||||
const activePipeline = pipelines.find((pipeline: any) => String(pipeline.id || "") === selectedPipelineId) || defaultPipeline;
|
||||
const activePipelineId = String(activePipeline.id || "");
|
||||
const pipelineNodes = pipelineConfigNodes(activePipeline);
|
||||
const pipelineEdges = pipelineConfigEdges(activePipeline);
|
||||
const latestRun = pipelineLatestRun(runs, activePipelineId);
|
||||
const pipelineRuns = pipelineEpochRuns(runs, activePipelineId);
|
||||
const activeRun = pipelineRuns.find((run: any) => String(run?.runId || "") === selectedRunId) || latestRun;
|
||||
const activeRunBase = pipelineRuns.find((run: any) => String(run?.runId || "") === selectedRunId) || latestRun;
|
||||
const activeRunDetail = String(runDetails.runId || "") === String(activeRunBase?.runId || "") ? pipelineDetailedRun(runDetails.details) : null;
|
||||
const activeRun = mergePipelineRunSummary(activeRunBase, activeRunDetail);
|
||||
const activeRunId = String(activeRun?.runId || "");
|
||||
const selectedNodeConfig = pipelineNodes.find((node: any) => String(node?.id || "") === selectedNodeId) || null;
|
||||
const selectedNodeRuntime = selectedNodeId ? pipelineRunNodeRecord(activeRun, selectedNodeId) : null;
|
||||
@@ -3210,9 +3776,12 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
|
||||
fetchedAt: prev.runId === runId ? prev.fetchedAt : null,
|
||||
}));
|
||||
try {
|
||||
const details = await requestJson(`${pipelineProxyPath(apiBaseUrl, `/api/node-control/runs/${encodeURIComponent(runId)}?tail=160&view=gantt&scale=${scale}`)}&_=${Date.now()}`, { cache: "no-store" });
|
||||
const [details, runSummary] = await Promise.all([
|
||||
requestJson(`${pipelineProxyPath(apiBaseUrl, `/api/node-control/runs/${encodeURIComponent(runId)}?tail=160&view=gantt&scale=${scale}`)}&_=${Date.now()}`, { cache: "no-store" }),
|
||||
requestJson(`${pipelineProxyPath(apiBaseUrl, `/api/runs/${encodeURIComponent(runId)}`)}?_=${Date.now()}`, { cache: "no-store" }).catch((error: unknown) => ({ ok: false, runSummaryError: errorMessage(error, "抓取评分失败") })),
|
||||
]);
|
||||
if (requestId !== runDetailsRequestRef.current) return;
|
||||
setRunDetails({ runId, scale, loading: false, error: "", details, fetchedAt: new Date() });
|
||||
setRunDetails({ runId, scale, loading: false, error: "", details: { ...details, run: isRecord(runSummary?.run) ? runSummary.run : undefined, runSummaryError: runSummary?.runSummaryError }, fetchedAt: new Date() });
|
||||
} catch (err) {
|
||||
if (requestId !== runDetailsRequestRef.current) return;
|
||||
setRunDetails((prev: AnyRecord) => ({
|
||||
@@ -3371,9 +3940,16 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
|
||||
h(MetricCard, { label: "运行记录", value: runCount, hint: `${statusCounts.succeeded || 0} succeeded / ${statusCounts.running || 0} running` }),
|
||||
h(MetricCard, { label: "OA 记录", value: Array.isArray(latestRun?.submissions) ? latestRun.submissions.length : 0, hint: latestRun?.runId || "latest run" }),
|
||||
h(MetricCard, { label: "Procedure", value: Array.isArray(latestRun?.procedureRuns) ? latestRun.procedureRuns.length : 0, hint: latestRun?.status || "no run" }),
|
||||
h(MetricCard, { label: "Score", value: pipelineScoreText(activeRun), hint: activeRun?.runId || "selected epoch", tone: pipelineScoreTone(activeRun) }),
|
||||
),
|
||||
h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "Pipeline Snapshot", data: snapshot, onOpen: onRaw, testId: "raw-pipeline-snapshot" })),
|
||||
),
|
||||
h(Panel, { title: "评分器", eyebrow: activeRun?.runId || "selected epoch" },
|
||||
h(PipelineScoreBoard, { run: activeRun, onRaw }),
|
||||
),
|
||||
h(Panel, { title: "OA 事件流", eyebrow: "100% event-driven diagnostics", className: "pipeline-wide-panel" },
|
||||
h(PipelineOaEventFlowPanel, { diagnostics: oaDiagnostics, onRaw }),
|
||||
),
|
||||
h(Panel, { title: "组件矩阵", eyebrow: `${componentClasses.length} classes` },
|
||||
componentClasses.length === 0 ? h(EmptyState, { title: "暂无组件", text: "等待 D601 pipeline backend 返回 registry.components" }) :
|
||||
h("div", { className: "component-strata" }, componentClasses.map((item) => h("article", { key: item.name, className: "component-stratum" },
|
||||
@@ -3481,6 +4057,7 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
|
||||
h("span", null, `${pipelines.length} pipelines`),
|
||||
h("span", null, `source config+components(${components.length})`),
|
||||
h("span", null, `run ${activeRun?.runId || "--"}`),
|
||||
h("span", null, `score ${pipelineScoreText(activeRun)}`),
|
||||
h("span", null, selectedNodeId ? `selected ${selectedNodeId}` : "click node to control"),
|
||||
),
|
||||
),
|
||||
@@ -3507,7 +4084,9 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
|
||||
}),
|
||||
h(Panel, { title: "Epoch 列表", eyebrow: `${pipelineRuns.length}/${runCount} preview` },
|
||||
pipelineRuns.length === 0 ? h(EmptyState, { title: "暂无运行记录", text: "当前 pipeline 在 .state/pipeline-runs 中还没有 epoch。" }) :
|
||||
h("div", { className: "pipeline-run-list" }, pipelineRuns.map((run: any) => h("article", {
|
||||
h("div", { className: "pipeline-run-list" }, pipelineRuns.map((run: any) => {
|
||||
const cardRun = String(run?.runId || "") === activeRunId ? activeRun : run;
|
||||
return h("article", {
|
||||
key: run.runId,
|
||||
className: `pipeline-run-card ${String(run.runId || "") === activeRunId ? "active" : ""}`,
|
||||
role: "button",
|
||||
@@ -3525,14 +4104,16 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
|
||||
},
|
||||
h("div", { className: "node-card-head" }, h("strong", null, pipelineEpochLabel(pipelineRuns, run)), h(StatusBadge, { status: run.status }, run.status || "--")),
|
||||
h("div", { className: "docker-meta compact" },
|
||||
h("span", null, run.pipelineId || "--"),
|
||||
h("span", null, `nodes ${Array.isArray(run.nodes) ? run.nodes.length : 0}`),
|
||||
h("span", null, `oa ${Array.isArray(run.submissions) ? run.submissions.length : 0}`),
|
||||
h("span", null, `procedures ${Array.isArray(run.procedureRuns) ? run.procedureRuns.length : 0}`),
|
||||
h("span", null, cardRun?.pipelineId || "--"),
|
||||
h("span", null, `nodes ${Array.isArray(cardRun?.nodes) ? cardRun.nodes.length : 0}`),
|
||||
h("span", null, `oa ${Array.isArray(cardRun?.submissions) ? cardRun.submissions.length : 0}`),
|
||||
h("span", null, `procedures ${Array.isArray(cardRun?.procedureRuns) ? cardRun.procedureRuns.length : 0}`),
|
||||
h(PipelineScoreBadge, { run: cardRun }),
|
||||
),
|
||||
h("p", { className: "muted paragraph" }, summarizeValue(run.task)),
|
||||
h("span", { className: "pipeline-run-time" }, fmtDate(run.updatedAt)),
|
||||
))),
|
||||
h("p", { className: "muted paragraph" }, summarizeValue(cardRun?.task)),
|
||||
h("span", { className: "pipeline-run-time" }, fmtDate(cardRun?.updatedAt)),
|
||||
);
|
||||
})),
|
||||
),
|
||||
h(Panel, { title: "运行材料索引", eyebrow: activeRun?.runId || "selected epoch", className: "pipeline-wide-panel" },
|
||||
h(PipelineRunMaterialIndex, { activeRun, onRaw }),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
FROM oven/bun:1-debian
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl git bash ripgrep procps python3 make g++ bubblewrap docker.io npm \
|
||||
&& npm install -g @openai/codex@0.128.0 \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY src/components/microservices/codex-queue/package.json ./package.json
|
||||
COPY src/components/microservices/codex-queue/tsconfig.json ./tsconfig.json
|
||||
COPY src/components/microservices/codex-queue/src ./src
|
||||
|
||||
EXPOSE 4222
|
||||
CMD ["bun", "run", "src/index.ts"]
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@unidesk/codex-queue",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "bun run src/index.ts",
|
||||
"check": "tsc -p tsconfig.json --noEmit"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["bun", "node"],
|
||||
"strict": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "dist",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unidesk/provider-gateway",
|
||||
"version": "0.2.8",
|
||||
"version": "0.2.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1350,7 +1350,7 @@ function assertAllowedMicroserviceBase(rawBaseUrl: string): URL {
|
||||
const baseUrl = new URL(rawBaseUrl);
|
||||
if (baseUrl.protocol !== "http:") throw new Error(`microservice backend only supports http URLs, got ${baseUrl.protocol}`);
|
||||
const host = baseUrl.hostname.toLowerCase();
|
||||
const allowedHosts = new Set(["127.0.0.1", "localhost", "host.docker.internal", "todo-note"]);
|
||||
const allowedHosts = new Set(["127.0.0.1", "localhost", "host.docker.internal", "todo-note", "codex-queue"]);
|
||||
if (!allowedHosts.has(host)) throw new Error(`microservice backend host is not allowed: ${baseUrl.hostname}`);
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user