feat: improve pipeline ui and remote update labels

This commit is contained in:
Codex
2026-05-06 06:50:16 +00:00
parent a9b5756dfe
commit 0aa5c1e021
11 changed files with 1202 additions and 62 deletions
+414 -5
View File
@@ -1159,6 +1159,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
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;
grid-template-columns: minmax(420px, 0.9fr) minmax(560px, 1.25fr);
@@ -1484,10 +1485,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
color: var(--text);
}
.pipeline-flow-frame .react-flow__controls-button svg { fill: currentColor; }
.pipeline-flow-frame .react-flow__minimap {
border: 1px solid var(--line);
background: rgba(8, 13, 18, 0.88);
}
.pipeline-flow-frame .react-flow__edges {
z-index: 30 !important;
}
@@ -1598,6 +1595,347 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
border: 1px solid var(--line-soft);
background: rgba(255,255,255,0.03);
}
.pipeline-gantt-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 7px;
}
.pipeline-gantt-actions select {
width: min(420px, 48vw);
min-height: 28px;
padding: 4px 8px;
}
.pipeline-gantt-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 28px;
padding: 4px 8px;
border: 1px solid var(--line-soft);
color: var(--muted);
background: rgba(255,255,255,0.03);
}
.pipeline-gantt-wrap {
display: grid;
gap: 8px;
}
.pipeline-gantt-detail-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(340px, 0.36fr);
gap: 10px;
align-items: start;
}
.pipeline-gantt-main {
min-width: 0;
}
.pipeline-gantt-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
color: var(--muted);
}
.pipeline-gantt-meta span {
padding: 4px 8px;
border: 1px solid rgba(78, 183, 168, 0.18);
background: rgba(78, 183, 168, 0.04);
}
.pipeline-gantt-viewport {
max-height: min(78vh, 820px);
overflow: auto;
border: 1px solid rgba(78, 183, 168, 0.26);
background:
linear-gradient(90deg, rgba(215, 161, 58, 0.08), transparent 18%),
repeating-linear-gradient(0deg, rgba(255,255,255,0.026) 0, rgba(255,255,255,0.026) 1px, transparent 1px, transparent 32px),
#081118;
}
.pipeline-gantt-board {
display: grid;
align-items: start;
}
.pipeline-gantt-head {
position: sticky;
top: 0;
z-index: 6;
min-height: 64px;
padding: 8px 5px;
border-bottom: 1px solid var(--line);
border-right: 1px solid var(--line-soft);
background: linear-gradient(180deg, rgba(18, 31, 39, 0.98), rgba(8, 17, 24, 0.96));
color: var(--accent);
font-size: 10px;
letter-spacing: 0.07em;
text-transform: uppercase;
overflow: visible;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.15;
}
.pipeline-gantt-head.time {
left: 0;
z-index: 8;
}
.pipeline-gantt-head.node {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
text-transform: none;
letter-spacing: 0.02em;
}
.pipeline-gantt-head.empty {
color: var(--muted);
text-transform: none;
letter-spacing: 0.04em;
}
.pipeline-gantt-time-axis {
position: sticky;
left: 0;
z-index: 4;
border-right: 1px solid rgba(215, 161, 58, 0.22);
background:
linear-gradient(90deg, rgba(215, 161, 58, 0.08), rgba(8, 17, 24, 0.92)),
#081118;
}
.pipeline-gantt-tick {
position: absolute;
left: 0;
right: 0;
transform: translateY(-50%);
display: grid;
gap: 1px;
padding: 0 7px;
border-top: 1px solid rgba(215, 161, 58, 0.18);
color: var(--muted);
font-size: 10px;
}
.pipeline-gantt-tick:first-child {
transform: translateY(0);
padding-top: 3px;
}
.pipeline-gantt-tick:last-child {
transform: translateY(-100%);
}
.pipeline-gantt-tick b {
color: var(--text);
font-weight: 700;
}
.pipeline-gantt-tick span {
color: var(--faint);
}
.pipeline-gantt-node-col,
.pipeline-gantt-empty-col {
position: relative;
border-right: 1px solid rgba(255,255,255,0.045);
background:
linear-gradient(180deg, rgba(255,255,255,0.018), transparent),
repeating-linear-gradient(0deg, rgba(255,255,255,0.032) 0, rgba(255,255,255,0.032) 1px, transparent 1px, transparent 96px);
}
.pipeline-gantt-empty-col {
display: grid;
place-items: start center;
padding-top: 28px;
color: var(--muted);
}
.pipeline-gantt-bar {
position: absolute;
left: 50%;
width: 6px;
min-height: 10px;
border: 1px solid rgba(215, 161, 58, 0.92);
border-radius: 999px;
background:
linear-gradient(180deg, rgba(246, 197, 91, 0.98), rgba(171, 107, 27, 0.92)),
#20170b;
color: #f7e8c5;
box-shadow: 0 0 0 1px rgba(0,0,0,0.32), 0 0 12px rgba(215, 161, 58, 0.28);
overflow: hidden;
padding: 0;
text-indent: -999px;
transform: translateX(-50%);
}
.pipeline-gantt-bar strong {
display: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.pipeline-gantt-bar span {
display: none;
color: rgba(255,255,255,0.72);
font-size: 10px;
}
.pipeline-gantt-bar.succeeded {
border-color: rgba(78, 183, 168, 0.76);
background: linear-gradient(180deg, rgba(78, 183, 168, 0.88), rgba(31, 111, 103, 0.72));
color: #dffaf4;
}
.pipeline-gantt-bar.failed {
border-color: rgba(207, 106, 84, 0.85);
background: linear-gradient(180deg, rgba(207, 106, 84, 0.92), rgba(110, 42, 32, 0.74));
color: #ffe3db;
}
.pipeline-gantt-bar.running {
animation: ganttPulse 1.8s ease-in-out infinite;
}
.pipeline-gantt-bar.selected {
width: 10px;
border-color: rgba(255, 240, 198, 0.98);
box-shadow: 0 0 0 2px rgba(8, 17, 24, 0.88), 0 0 22px rgba(246, 197, 91, 0.55);
}
.pipeline-gantt-detail-panel {
position: sticky;
top: 8px;
display: grid;
gap: 9px;
max-height: min(78vh, 820px);
min-height: 420px;
overflow: auto;
padding: 10px;
border: 1px solid rgba(215, 161, 58, 0.28);
background:
linear-gradient(135deg, rgba(215, 161, 58, 0.11), transparent 36%),
linear-gradient(180deg, rgba(255,255,255,0.045), rgba(255,255,255,0.014)),
#091219;
box-shadow: inset 3px 0 0 rgba(215, 161, 58, 0.36);
}
.pipeline-gantt-detail-panel.empty {
place-items: center;
}
.pipeline-gantt-detail-head,
.pipeline-attempt-head {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: start;
}
.pipeline-gantt-detail-head h3 {
margin: 0;
overflow-wrap: anywhere;
}
.pipeline-gantt-detail-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pipeline-kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.pipeline-kv-grid > span {
min-width: 0;
padding: 6px 7px;
border: 1px solid var(--line-soft);
background: rgba(255,255,255,0.028);
color: var(--muted);
overflow-wrap: anywhere;
}
.pipeline-kv-grid b {
display: block;
color: var(--accent);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.pipeline-chip-row {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.pipeline-chip-row span {
padding: 3px 6px;
border: 1px solid rgba(78, 183, 168, 0.18);
background: rgba(78, 183, 168, 0.055);
color: var(--muted);
font-size: 10px;
}
.pipeline-attempt-card {
display: grid;
gap: 8px;
padding: 9px;
border: 1px solid var(--line-soft);
background: rgba(255,255,255,0.026);
}
.pipeline-attempt-head span {
color: var(--muted);
font-size: 11px;
}
.pipeline-opencode-flow {
display: grid;
gap: 7px;
}
.pipeline-opencode-step,
.pipeline-opencode-part {
border: 1px solid rgba(255,255,255,0.08);
background: rgba(0,0,0,0.15);
}
.pipeline-opencode-step > summary,
.pipeline-opencode-part > summary {
display: grid;
gap: 5px;
padding: 8px;
cursor: pointer;
}
.pipeline-opencode-step > summary::-webkit-details-marker,
.pipeline-opencode-part > summary::-webkit-details-marker {
display: none;
}
.pipeline-step-index {
color: var(--accent-2);
font-size: 10px;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.pipeline-opencode-step summary small {
color: var(--muted);
}
.pipeline-opencode-step-body,
.pipeline-opencode-part-body {
display: grid;
gap: 8px;
padding: 8px;
border-top: 1px solid var(--line-soft);
}
.pipeline-text-preview {
margin: 0;
padding: 7px 8px;
border: 1px solid rgba(255,255,255,0.07);
background: rgba(4, 9, 13, 0.5);
color: #bfd3d4;
white-space: pre-wrap;
overflow-wrap: anywhere;
line-height: 1.45;
}
.pipeline-field-list {
display: grid;
grid-template-columns: minmax(82px, 0.38fr) minmax(0, 1fr);
gap: 4px 7px;
margin: 5px 0 0;
}
.pipeline-field-list dt {
color: var(--accent-2);
font-size: 10px;
overflow-wrap: anywhere;
}
.pipeline-field-list dd {
margin: 0;
color: var(--muted);
overflow-wrap: anywhere;
}
.pipeline-opencode-part-list {
display: grid;
gap: 6px;
}
@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); }
}
.pipeline-node-control {
min-width: 0;
max-height: min(68vh, 720px);
@@ -1686,6 +2024,53 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
padding-top: 8px;
border-top: 1px solid var(--line-soft);
}
.pipeline-evidence-list {
display: grid;
gap: 7px;
}
.pipeline-evidence-list.compact {
gap: 6px;
}
.pipeline-evidence-row {
display: grid;
grid-template-columns: minmax(160px, 0.8fr) minmax(220px, 1.4fr) auto;
gap: 8px;
align-items: center;
padding: 8px;
border: 1px solid var(--line-soft);
background:
linear-gradient(90deg, rgba(78, 183, 168, 0.05), transparent 38%),
rgba(255,255,255,0.026);
}
.pipeline-evidence-main {
display: grid;
gap: 2px;
min-width: 0;
}
.pipeline-evidence-main strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
}
.pipeline-evidence-main span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted);
font-size: 11px;
}
.pipeline-evidence-facts {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.pipeline-evidence-facts span {
padding: 3px 6px;
border: 1px solid rgba(255,255,255,0.07);
background: rgba(0,0,0,0.16);
color: var(--muted);
}
.compact-log code { font-size: 11px; }
.component-strata {
display: grid;
@@ -1699,6 +2084,21 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
border: 1px solid var(--line-soft);
background: var(--panel-3);
}
.pipeline-run-card {
cursor: pointer;
}
.pipeline-run-card.active {
border-color: rgba(215, 161, 58, 0.68);
box-shadow: inset 3px 0 0 rgba(215, 161, 58, 0.62);
}
.pipeline-run-card:focus {
outline: 2px solid rgba(78, 183, 168, 0.58);
outline-offset: 2px;
}
.pipeline-run-time {
color: var(--muted);
font-size: 12px;
}
.component-stratum span {
color: var(--muted);
font-size: 10px;
@@ -1855,9 +2255,18 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
body { font-size: 12px; }
.pipeline-control-runbar,
.pipeline-control-meta,
.pipeline-control-evidence-grid {
.pipeline-control-evidence-grid,
.pipeline-evidence-row,
.pipeline-gantt-detail-layout,
.pipeline-kv-grid,
.pipeline-field-list {
grid-template-columns: 1fr;
}
.pipeline-gantt-actions { justify-content: stretch; }
.pipeline-gantt-actions select { width: 100%; }
.pipeline-gantt-bar { left: 50%; width: 5px; padding: 0; }
.pipeline-gantt-bar.selected { width: 9px; }
.pipeline-gantt-detail-panel { position: static; max-height: none; }
.pipeline-flow-frame {
min-height: 430px;
}
+5 -5
View File
@@ -934,7 +934,7 @@ function UpgradeControl({ provider, refresh, onRaw }: AnyRecord) {
function UpgradeRecordsTable({ records, onRaw, compact = false }: AnyRecord) {
if (records.length === 0) {
return h(EmptyState, { title: "暂无自动更新记录", text: "该节点还没有 provider.upgrade 任务;执行预检或升级后会在这里形成结构化记录" });
return h(EmptyState, { title: "暂无远程更新记录", text: "该节点还没有 provider.upgrade 任务;执行预检或升级后会在这里形成结构化记录" });
}
return h("div", { className: `upgrade-record-table-wrap table-wrap ${compact ? "compact" : ""}` }, h("table", { className: "upgrade-record-table" },
h("thead", null, h("tr", null,
@@ -967,7 +967,7 @@ function UpgradeRecordsTable({ records, onRaw, compact = false }: AnyRecord) {
function ProviderUpgradeRecordsPanel({ provider, tasks, onRaw, limit = 5 }: AnyRecord) {
const records = providerUpgradeTasks(tasks, provider.providerId).slice(0, limit);
return h(Panel, {
title: "自动更新记录",
title: "远程更新记录",
eyebrow: provider.providerId,
actions: h(GatewayVersionBadge, { node: provider }),
className: "provider-upgrade-records-panel",
@@ -997,7 +997,7 @@ function GatewayVersionPage({ nodes, tasks, onRaw }: AnyRecord) {
h("th", null, "运维可用性"),
h("th", null, "运行时间"),
h("th", null, "能力"),
h("th", null, "最近自动更新"),
h("th", null, "最近远程更新"),
h("th", null, "操作"),
)),
h("tbody", null, rows.map((row: any) => h("tr", { key: row.node.providerId },
@@ -1021,8 +1021,8 @@ function GatewayVersionPage({ nodes, tasks, onRaw }: AnyRecord) {
))),
)),
),
h(Panel, { title: "自动更新记录", eyebrow: "Structured provider.upgrade records" },
nodes.length === 0 ? h(EmptyState, { title: "暂无记录", text: "没有 provider 节点时不会生成自动更新记录" }) :
h(Panel, { title: "远程更新记录", eyebrow: "Structured provider.upgrade records" },
nodes.length === 0 ? h(EmptyState, { title: "暂无记录", text: "没有 provider 节点时不会生成远程更新记录" }) :
h("div", { className: "gateway-record-grid" }, rows.map((row: any) => h("article", {
key: row.node.providerId,
className: "gateway-record-card",
+756 -30
View File
@@ -1,11 +1,12 @@
import React from "react";
import { Background, BaseEdge, Controls, Handle, MarkerType, MiniMap, Position, ReactFlow, type Edge, type Node } from "@xyflow/react";
import { Background, BaseEdge, Controls, Handle, MarkerType, Position, ReactFlow, type Edge, type Node } from "@xyflow/react";
type AnyRecord = Record<string, any>;
const h = React.createElement;
const { useEffect } = React;
const useState: any = React.useState;
const useRef: any = React.useRef;
const pipelineInputPorts: AnyRecord[] = [
{ id: "in-left", side: "left", position: Position.Left, style: { top: "50%" } },
@@ -193,6 +194,81 @@ function fmtClock(value: Date): string {
return value.toLocaleTimeString("zh-CN", { hour12: false });
}
function fmtDurationMs(value: any): string {
const ms = Number(value);
if (!Number.isFinite(ms) || ms < 0) return "--";
const seconds = Math.round(ms / 1000);
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
}
function isRecord(value: any): value is AnyRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function asArray(value: any): any[] {
return Array.isArray(value) ? value : [];
}
function timeMs(value: any): number | null {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.getTime();
}
function isoFromMs(value: number | null | undefined): string {
return Number.isFinite(Number(value)) ? new Date(Number(value)).toISOString() : "";
}
function firstIso(...values: any[]): string {
for (const value of values) {
const ms = timeMs(value);
if (ms !== null) return new Date(ms).toISOString();
}
return "";
}
function latestIso(...values: any[]): string {
const candidates = values.map(timeMs).filter((value): value is number => value !== null);
return candidates.length > 0 ? new Date(Math.max(...candidates)).toISOString() : "";
}
function terminalStatus(status: any): boolean {
return ["succeeded", "failed", "skipped", "cancelled", "canceled", "completed"].includes(String(status || "").toLowerCase());
}
function statusCounts(items: any[], key = "status"): AnyRecord {
return items.reduce((counts: AnyRecord, item: any) => {
const status = String(item?.[key] || "unknown").toLowerCase();
counts[status] = (counts[status] || 0) + 1;
return counts;
}, {});
}
function parseJsonLine(value: any): AnyRecord | null {
if (!value || typeof value !== "string") return null;
try {
const parsed = JSON.parse(value) as unknown;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function tailSummary(lines: any[]): AnyRecord {
const records = lines.map(parseJsonLine).filter((item): item is AnyRecord => Boolean(item));
const timestamps = records.flatMap((record) => [record.timestamp, record.createdAt, record.updatedAt]).filter(Boolean);
const lastAt = latestIso(...timestamps);
const eventKinds = Array.from(new Set(records.map((record) => String(record.event || record.action || record.type || "")).filter(Boolean))).slice(0, 3);
return {
total: lines.length,
parsed: records.length,
lastAt,
eventKinds,
};
}
function summarizeValue(value: any): string {
if (value === null || value === undefined) return "--";
if (typeof value === "boolean") return value ? "是" : "否";
@@ -203,6 +279,192 @@ function summarizeValue(value: any): string {
return String(value);
}
function previewText(value: any, maxLength = 280): string {
if (value === null || value === undefined) return "";
const text = typeof value === "string" ? value : String(value);
const normalized = text.replace(/\r\n/gu, "\n").trim();
return normalized.length > maxLength ? `${normalized.slice(0, Math.max(0, maxLength - 1))}...` : normalized;
}
function statusValue(value: any): string {
if (typeof value === "string") return value;
if (isRecord(value)) return String(value.status || value.state || value.phase || "unknown");
return "unknown";
}
function tokenFacts(tokens: any): string[] {
if (!isRecord(tokens)) return [];
return [
typeof tokens.total === "number" ? `tokens ${tokens.total}` : "",
typeof tokens.input === "number" ? `in ${tokens.input}` : "",
typeof tokens.output === "number" ? `out ${tokens.output}` : "",
typeof tokens.reasoning === "number" ? `reason ${tokens.reasoning}` : "",
typeof tokens.cacheRead === "number" ? `cache read ${tokens.cacheRead}` : "",
typeof tokens.cacheWrite === "number" ? `cache write ${tokens.cacheWrite}` : "",
].filter(Boolean);
}
function kvItems(items: any[]): any[] {
return items.filter((item) => item && item.value !== undefined && item.value !== null && String(item.value) !== "");
}
function PipelineKvGrid({ items }: AnyRecord) {
const safeItems = kvItems(asArray(items));
return h("div", { className: "pipeline-kv-grid" }, safeItems.map((item: AnyRecord) => h("span", { key: item.label },
h("b", null, item.label),
h("span", null, item.value),
)));
}
function PipelineChipRow({ items }: AnyRecord) {
const safeItems = asArray(items).map((item) => String(item || "")).filter(Boolean);
if (safeItems.length === 0) return null;
return h("div", { className: "pipeline-chip-row" }, safeItems.map((item, index) => h("span", { key: `${index}-${item}` }, item)));
}
function findProcedureRun(details: any, interval: any): AnyRecord | null {
const procedureRunId = String(interval?.procedureRunId || "");
const procedures = asArray(details?.procedureRuns);
return procedures.find((procedure: any) => String(procedureRunIdOf(procedure)) === procedureRunId) || procedures.at(-1) || null;
}
function opencodeSteps(attempt: any): AnyRecord[] {
return asArray(attempt?.opencodeMessages?.steps).filter(isRecord);
}
function opencodePartFacts(part: any): string[] {
return [
`type ${part?.type || "unknown"}`,
part?.tool ? `tool ${part.tool}` : "",
part?.status ? `status ${part.status}` : "",
part?.durationMs !== undefined ? `duration ${fmtDurationMs(part.durationMs)}` : "",
part?.outputSize !== undefined ? `output ${part.outputSize} chars` : "",
].filter(Boolean);
}
function PipelineFieldList({ fields }: AnyRecord) {
const safeFields = asArray(fields).filter((field: any) => field?.key);
if (safeFields.length === 0) return null;
return h("dl", { className: "pipeline-field-list" }, safeFields.flatMap((field: AnyRecord) => [
h("dt", { key: `${field.key}-k` }, field.key),
h("dd", { key: `${field.key}-v` }, previewText(field.value, 260)),
]));
}
function PipelineOpenCodePart({ part }: AnyRecord) {
const title = String(part?.title || part?.tool || part?.type || "part");
return h("details", { className: "pipeline-opencode-part" },
h("summary", null,
h("strong", null, title),
h(PipelineChipRow, { items: opencodePartFacts(part) }),
),
h("div", { className: "pipeline-opencode-part-body" },
part?.textPreview ? h("p", { className: "pipeline-text-preview" }, part.textPreview) : null,
asArray(part?.inputFields).length > 0 ? h("div", null, h("b", null, "输入字段"), h(PipelineFieldList, { fields: part.inputFields })) : null,
part?.outputPreview && part.outputPreview !== "--" ? h("div", null, h("b", null, "输出摘要"), h("p", { className: "pipeline-text-preview" }, part.outputPreview)) : null,
asArray(part?.metadataFields).length > 0 ? h("div", null, h("b", null, "元数据"), h(PipelineFieldList, { fields: part.metadataFields })) : null,
tokenFacts(part?.tokens).length > 0 ? h(PipelineChipRow, { items: tokenFacts(part.tokens) }) : null,
),
);
}
function PipelineOpenCodeStep({ step }: AnyRecord) {
const tools = asArray(step?.tools).map((item) => String(item || "")).filter(Boolean);
const partTypes = isRecord(step?.partTypes) ? Object.entries(step.partTypes).map(([type, count]) => `${type} ${count}`) : [];
const facts = [
`role ${step?.role || "unknown"}`,
step?.finish ? `finish ${step.finish}` : "",
step?.durationMs !== undefined ? `duration ${fmtDurationMs(step.durationMs)}` : "",
`parts ${step?.partCount ?? asArray(step?.parts).length}`,
...tools.slice(0, 4).map((tool) => `tool ${tool}`),
].filter(Boolean);
return h("details", { className: "pipeline-opencode-step", "data-testid": "pipeline-opencode-step" },
h("summary", null,
h("span", { className: "pipeline-step-index" }, `Step ${step?.index ?? "?"}`),
h("strong", null, `${step?.agent || "agent"} / ${step?.model || "model"}`),
h("small", null, `${fmtDate(step?.createdAt)} -> ${fmtDate(step?.completedAt)}`),
h(PipelineChipRow, { items: facts }),
),
h("div", { className: "pipeline-opencode-step-body" },
h(PipelineKvGrid, { items: [
{ label: "message", value: step?.messageId || "--" },
{ label: "session", value: step?.sessionId || "--" },
{ label: "provider", value: step?.provider || "--" },
{ label: "cost", value: step?.cost ?? "--" },
] }),
tokenFacts(step?.tokens).length > 0 ? h(PipelineChipRow, { items: tokenFacts(step.tokens) }) : null,
partTypes.length > 0 ? h(PipelineChipRow, { items: partTypes }) : null,
step?.textPreview ? h("p", { className: "pipeline-text-preview" }, step.textPreview) : null,
asArray(step?.parts).length > 0 ? h("div", { className: "pipeline-opencode-part-list" }, asArray(step.parts).map((part: any, index: number) => h(PipelineOpenCodePart, { key: part?.id || index, part }))) : null,
),
);
}
function PipelineGanttDetailPanel({ selection, onRaw }: AnyRecord) {
const interval = selection?.interval;
if (!interval) {
return h("aside", { className: "pipeline-gantt-detail-panel empty", "data-testid": "pipeline-gantt-detail-panel" },
h(EmptyState, { title: "选择一条执行线", text: "点击甘特图中的 node 执行线,在这里查看结构化过程和 OpenCode step。" }),
);
}
const details = selection?.details;
const procedure = findProcedureRun(details, interval) || interval.raw || {};
const attempts = asArray(procedure?.attempts);
const status = statusValue(procedure?.status || interval.status);
return h("aside", { className: "pipeline-gantt-detail-panel", "data-testid": "pipeline-gantt-detail-panel" },
h("div", { className: "pipeline-gantt-detail-head" },
h("div", null,
h("span", { className: "panel-eyebrow" }, "Gantt Line Detail"),
h("h3", null, interval.nodeId || "node"),
),
h(StatusBadge, { status }, status),
),
h(PipelineKvGrid, { items: [
{ label: "epoch", value: interval.runId || "--" },
{ label: "procedure", value: interval.procedureRunId || procedureRunIdOf(procedure) || "--" },
{ label: "started", value: fmtDate(interval.startedAt || procedure?.startedAt) },
{ label: "finished", value: fmtDate(interval.finishedAt || procedure?.finishedAt) },
{ label: "duration", value: fmtDurationMs(interval.durationMs || procedure?.durationMs) },
{ label: "fetched", value: selection?.fetchedAt ? fmtClock(selection.fetchedAt) : "--" },
] }),
selection?.loading ? h("div", { className: "form-success" }, "正在抓取 node 执行过程...") : null,
selection?.error ? h("div", { className: "form-error" }, selection.error) : null,
h("div", { className: "pipeline-gantt-detail-actions" },
h(RawButton, { title: `Procedure ${interval.procedureRunId || interval.nodeId}`, data: procedure, onOpen: onRaw, testId: "raw-pipeline-gantt-procedure" }),
details ? h(RawButton, { title: `Node execution ${interval.nodeId}`, data: details, onOpen: onRaw, testId: "raw-pipeline-gantt-node-details" }) : null,
),
attempts.length === 0 && !selection?.loading ? h(EmptyState, { title: "暂无 attempt 详情", text: "后端还未返回该 procedure 的 attempt / opencodeMessages。" }) : null,
attempts.map((attempt: any, attemptIndex: number) => {
const messages = attempt?.opencodeMessages || {};
const steps = opencodeSteps(attempt);
const sessionIds = asArray(messages.sessionIds).map((item) => String(item)).filter(Boolean);
return h("article", { key: attempt?.attempt || attemptIndex, className: "pipeline-attempt-card" },
h("div", { className: "pipeline-attempt-head" },
h("strong", null, attempt?.attempt || `attempt-${attemptIndex + 1}`),
h("span", null, messages.source || "opencode"),
),
h(PipelineKvGrid, { items: [
{ label: "messages", value: messages.messageCount ?? "--" },
{ label: "steps", value: messages.stepCount ?? steps.length },
{ label: "tools", value: messages.toolCallCount ?? "--" },
{ 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 已重建并重新抓取。") :
h("div", { className: "pipeline-opencode-flow" }, steps.map((step: any, stepIndex: number) => h(PipelineOpenCodeStep, { key: step?.messageId || stepIndex, step }))),
);
}),
);
}
function GanttHeaderLabel({ value }: AnyRecord) {
const text = String(value || "--");
const pieces = text.split(/([_-])/u);
return h(React.Fragment, null, pieces.map((piece, index) => (
piece === "-" || piece === "_" ? h(React.Fragment, { key: index }, piece, h("wbr", null)) : h(React.Fragment, { key: index }, piece)
)));
}
async function requestJson(path: string, options: AnyRecord = {}): Promise<any> {
const headers = new Headers(options.headers || {});
if (options.body && !headers.has("content-type")) headers.set("content-type", "application/json");
@@ -258,6 +520,18 @@ function RawButton({ title, data, onOpen, testId }: AnyRecord) {
}, "查看原始JSON");
}
function EvidenceIndexRow({ title, subtitle, facts, data, onRaw, testId }: AnyRecord) {
const safeFacts = asArray(facts).map((item) => String(item || "")).filter(Boolean);
return h("article", { className: "pipeline-evidence-row" },
h("div", { className: "pipeline-evidence-main" },
h("strong", null, title),
subtitle ? h("span", null, subtitle) : null,
),
h("div", { className: "pipeline-evidence-facts" }, safeFacts.map((fact, index) => h("span", { key: `${index}-${fact.slice(0, 16)}` }, fact))),
data !== undefined ? h(RawButton, { title, data, onOpen: onRaw, testId }) : null,
);
}
function EmptyState({ title, text }: AnyRecord) {
return h("div", { className: "empty-state" }, h("strong", null, title), h("span", null, text));
}
@@ -793,6 +1067,413 @@ function pipelineLatestRun(runs: any[], pipelineId: string): any {
return runs.find((run) => String(run?.pipelineId || "") === pipelineId) || null;
}
function pipelineRunStartMs(run: any): number {
return timeMs(run?.startedAt) ?? timeMs(run?.artifact?.startedAt) ?? timeMs(run?.request?.createdAt) ?? timeMs(run?.updatedAt) ?? 0;
}
function pipelineEpochRuns(runs: any[], pipelineId: string): any[] {
return runs
.filter((run: any) => String(run?.pipelineId || "") === pipelineId)
.slice()
.sort((left: any, right: any) => pipelineRunStartMs(left) - pipelineRunStartMs(right) || String(left?.runId || "").localeCompare(String(right?.runId || "")));
}
function pipelineEpochLabel(epochs: any[], run: any): string {
const runId = String(run?.runId || "");
const index = epochs.findIndex((item: any) => String(item?.runId || "") === runId);
const number = index >= 0 ? index + 1 : epochs.length;
const status = String(run?.status || "--");
return `Epoch ${number} / ${runId || "--"} / ${status}`;
}
function procedureRunIdOf(procedure: any): string {
return String(procedure?.procedureRunId || procedure?.runId || "");
}
function inferProcedureNodeId(procedure: any, parentRunId: string): string {
const direct = String(procedure?.nodeId || procedure?.request?.nodeId || "");
if (direct) return direct;
const procedureRunId = procedureRunIdOf(procedure);
const prefix = `${parentRunId}__`;
if (procedureRunId.startsWith(prefix)) return procedureRunId.slice(prefix.length).replace(/__\d+$/u, "");
return "";
}
function procedureStartIso(procedure: any, run: any): string {
const artifact = isRecord(procedure?.artifact) ? procedure.artifact : {};
const request = isRecord(procedure?.request) ? procedure.request : {};
return firstIso(
procedure?.startedAt,
artifact.startedAt,
request.createdAt,
request.startedAt,
procedure?.createdAt,
procedure?.updatedAt,
run?.startedAt,
run?.request?.createdAt,
);
}
function procedureEndIso(procedure: any, run: any): string {
const status = String(procedure?.status || procedure?.artifact?.status || procedure?.status?.status || "").toLowerCase();
const artifact = isRecord(procedure?.artifact) ? procedure.artifact : {};
const terminal = terminalStatus(status);
return firstIso(
procedure?.finishedAt,
artifact.finishedAt,
procedure?.completedAt,
terminal ? procedure?.updatedAt : undefined,
terminal ? artifact.updatedAt : undefined,
terminal ? run?.updatedAt : undefined,
);
}
function pipelineRunIntervals(run: any, pipelineNodes: AnyRecord[]): 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) => {
const nodeId = inferProcedureNodeId(procedure, runId);
if (!nodeId) return [];
const status = String(procedure?.status || procedure?.artifact?.status || "unknown").toLowerCase();
const startIso = procedureStartIso(procedure, run);
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 safeEndMs = Math.max(startMs + 1000, endMs);
return [{
nodeId,
knownNode: knownNodeIds.has(nodeId),
procedureRunId: procedureRunIdOf(procedure),
status,
startMs,
endMs: safeEndMs,
startedAt: isoFromMs(startMs),
finishedAt: isoFromMs(safeEndMs),
durationMs: safeEndMs - startMs,
runId,
raw: procedure,
}];
}).sort((left: AnyRecord, right: AnyRecord) => left.startMs - right.startMs || left.endMs - right.endMs || left.nodeId.localeCompare(right.nodeId));
}
function pipelineRunTimeBounds(run: any, intervals: AnyRecord[]): AnyRecord {
const starts = intervals.map((item) => Number(item.startMs)).filter(Number.isFinite);
const ends = intervals.map((item) => Number(item.endMs)).filter(Number.isFinite);
const runStart = timeMs(run?.startedAt) ?? timeMs(run?.artifact?.startedAt) ?? timeMs(run?.request?.createdAt);
const runEnd = timeMs(run?.finishedAt) ?? timeMs(run?.artifact?.finishedAt) ?? timeMs(run?.updatedAt);
if (runStart !== null) starts.push(runStart);
if (runEnd !== null) ends.push(runEnd);
const now = Date.now();
const startMs = starts.length > 0 ? Math.min(...starts) : now - 60_000;
const endMs = Math.max(startMs + 60_000, ends.length > 0 ? Math.max(...ends) : now);
return { startMs, endMs, durationMs: endMs - startMs };
}
function pipelineGanttHeight(bounds: AnyRecord): number {
const minutes = Math.max(1, Number(bounds.durationMs || 0) / 60_000);
return Math.round(Math.max(440, Math.min(1800, minutes * 48)));
}
function pipelineGanttTicks(bounds: AnyRecord, count = 7): AnyRecord[] {
const duration = Math.max(1, Number(bounds.endMs || 0) - Number(bounds.startMs || 0));
return Array.from({ length: count }, (_item, index) => {
const ratio = count === 1 ? 0 : index / (count - 1);
const ms = Number(bounds.startMs) + duration * ratio;
return { ms, percent: ratio * 100 };
});
}
function intervalOverlaps(interval: AnyRecord, range: AnyRecord): boolean {
return Number(interval.startMs) <= Number(range.endMs) && Number(interval.endMs) >= Number(range.startMs);
}
function PipelineNodeExecutionIndex({ details, selectedNodeId, selectedNodeRuntime, control, onRaw }: AnyRecord) {
if (!details) {
return h("span", { className: "muted" }, "点击“抓取过程”读取 node 运行材料;主界面只显示结构化摘要,完整内容需点开原始 JSON。");
}
const procedureRuns = asArray(details.procedureRuns);
const latestProcedure = procedureRuns.at(-1) || {};
const attempts = asArray(latestProcedure.attempts);
const latestAttempt = attempts.at(-1) || {};
const workerLogTail = asArray(latestProcedure.workerLogTail);
const controlEventsTail = asArray(latestAttempt.controlEventsTail);
const controlPromptsTail = asArray(latestAttempt.controlPromptsTail);
const monitorPromptsTail = asArray(latestAttempt.monitorPromptsTail);
const eventTail = tailSummary(controlEventsTail);
const controlPromptTail = tailSummary(controlPromptsTail);
const monitorPromptTail = tailSummary(monitorPromptsTail);
const latestMessage = latestAttempt.opencodeMessages || {};
return h("div", { className: "pipeline-evidence-list compact" },
h(EvidenceIndexRow, {
title: "Node runtime",
subtitle: selectedNodeId || "--",
facts: [
`status ${selectedNodeRuntime?.status || "pending"}`,
`attempts ${selectedNodeRuntime?.attempts ?? attempts.length}`,
`procedure ${selectedNodeRuntime?.currentProcedureRunId || procedureRunIdOf(latestProcedure) || "--"}`,
control.fetchedAt ? `fetched ${fmtClock(control.fetchedAt)}` : "not fetched",
],
data: details.node || details,
onRaw,
testId: "raw-pipeline-node-runtime",
}),
h(EvidenceIndexRow, {
title: "Procedure runs",
subtitle: `${procedureRuns.length} groups`,
facts: [
`latest ${latestProcedure.status?.status || latestProcedure.status || "--"}`,
`steps ${asArray(latestProcedure.recentSteps).length}`,
`duration ${fmtDurationMs(timeMs(latestProcedure.finishedAt) && timeMs(latestProcedure.startedAt) ? Number(timeMs(latestProcedure.finishedAt)) - Number(timeMs(latestProcedure.startedAt)) : latestProcedure.durationMs)}`,
],
data: procedureRuns,
onRaw,
testId: "raw-pipeline-node-procedures",
}),
h(EvidenceIndexRow, {
title: "OpenCode messages",
subtitle: String(latestMessage.exists ? "available" : "not indexed"),
facts: [
`messages ${summarizeValue(latestMessage.messageCount)}`,
`size ${summarizeValue(latestMessage.size)}`,
`updated ${fmtDate(latestMessage.updatedAt)}`,
],
data: latestMessage,
onRaw,
testId: "raw-pipeline-node-messages",
}),
h(EvidenceIndexRow, {
title: "Control prompts",
subtitle: "manual / monitor append queues",
facts: [
`manual tail ${controlPromptTail.total}`,
`monitor tail ${monitorPromptTail.total}`,
`last ${fmtDate(latestIso(controlPromptTail.lastAt, monitorPromptTail.lastAt))}`,
],
data: { controlPromptsTail, monitorPromptsTail },
onRaw,
testId: "raw-pipeline-node-prompts",
}),
h(EvidenceIndexRow, {
title: "Control events",
subtitle: eventTail.eventKinds.length > 0 ? eventTail.eventKinds.join(", ") : "event tail",
facts: [
`tail ${eventTail.total}`,
`parsed ${eventTail.parsed}`,
`last ${fmtDate(eventTail.lastAt)}`,
],
data: controlEventsTail,
onRaw,
testId: "raw-pipeline-node-events",
}),
h(EvidenceIndexRow, {
title: "Worker log",
subtitle: "tail is hidden on main canvas",
facts: [`tail ${workerLogTail.length} lines`, `raw only via button`, `procedure ${procedureRunIdOf(latestProcedure) || "--"}`],
data: workerLogTail,
onRaw,
testId: "raw-pipeline-node-worker-log",
}),
);
}
function PipelineRunMaterialIndex({ activeRun, onRaw }: AnyRecord) {
if (!activeRun) return h(EmptyState, { title: "暂无运行材料", text: "没有 Pipeline epoch 时不会展示运行材料索引。" });
const nodes = asArray(activeRun.nodes);
const procedures = asArray(activeRun.procedureRuns);
const submissions = asArray(activeRun.submissions);
const workerLogTail = asArray(activeRun.workerLogTail);
const nodeCounts = statusCounts(nodes);
const procedureCounts = statusCounts(procedures);
const failedProcedures = procedures.filter((procedure: any) => String(procedure?.status || "").toLowerCase() === "failed");
const latestProcedureAt = latestIso(...procedures.flatMap((procedure: any) => [procedure.updatedAt, procedure.finishedAt, procedure.startedAt]));
return h("div", { className: "pipeline-evidence-list" },
h(EvidenceIndexRow, {
title: "Epoch overview",
subtitle: activeRun.runId || "--",
facts: [
`pipeline ${activeRun.pipelineId || "--"}`,
`status ${activeRun.status || "--"}`,
`started ${fmtDate(activeRun.startedAt)}`,
`updated ${fmtDate(activeRun.updatedAt)}`,
],
data: activeRun,
onRaw,
testId: "raw-pipeline-run",
}),
h(EvidenceIndexRow, {
title: "Node states",
subtitle: `${nodes.length} nodes`,
facts: [
`running ${nodeCounts.running || 0}`,
`succeeded ${nodeCounts.succeeded || 0}`,
`failed ${nodeCounts.failed || 0}`,
`pending ${nodeCounts.pending || 0}`,
],
data: nodes,
onRaw,
testId: "raw-pipeline-run-nodes",
}),
h(EvidenceIndexRow, {
title: "Procedure run index",
subtitle: `${procedures.length} procedure records`,
facts: [
`succeeded ${procedureCounts.succeeded || 0}`,
`failed ${procedureCounts.failed || 0}`,
`latest ${fmtDate(latestProcedureAt)}`,
`errors ${failedProcedures.length}`,
],
data: procedures,
onRaw,
testId: "raw-pipeline-run-procedures",
}),
h(EvidenceIndexRow, {
title: "OA submissions",
subtitle: `${submissions.length} submission files`,
facts: [`records ${submissions.length}`, `task ${summarizeValue(activeRun.task)}`, `raw grouped by run`],
data: submissions,
onRaw,
testId: "raw-pipeline-run-submissions",
}),
h(EvidenceIndexRow, {
title: "Worker log tail",
subtitle: "hidden from main interface",
facts: [`tail ${workerLogTail.length} lines`, `display raw only after click`, `updated ${fmtDate(activeRun.updatedAt)}`],
data: workerLogTail,
onRaw,
testId: "raw-pipeline-run-worker-log",
}),
);
}
function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, onRunChange, onIntervalSelect, selection, onRaw }: AnyRecord) {
const [autoHideIdle, setAutoHideIdle] = useState(true);
const [visibleRange, setVisibleRange] = useState({ startMs: 0, endMs: 0 });
const viewportRef = useRef(null);
const activeRunId = String(activeRun?.runId || "");
const intervals = pipelineRunIntervals(activeRun, pipelineNodes);
const bounds = pipelineRunTimeBounds(activeRun, intervals);
const chartHeight = pipelineGanttHeight(bounds);
const configuredNodeIds = pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean);
const intervalNodeIds = intervals.map((interval: AnyRecord) => String(interval.nodeId || "")).filter(Boolean);
const allNodeIds = Array.from(new Set([...configuredNodeIds, ...intervalNodeIds]));
const safeRange = visibleRange.startMs > 0 ? visibleRange : bounds;
const activeNodeIds = new Set(allNodeIds.filter((nodeId) => intervals.some((interval: AnyRecord) => interval.nodeId === nodeId && intervalOverlaps(interval, safeRange))));
const visibleNodeIds = autoHideIdle ? allNodeIds.filter((nodeId) => activeNodeIds.has(nodeId)) : allNodeIds;
const gridTemplateColumns = `96px ${visibleNodeIds.length > 0 ? visibleNodeIds.map(() => "72px").join(" ") : "minmax(160px, 1fr)"}`;
const ticks = pipelineGanttTicks(bounds);
const selectedIntervalKey = String(selection?.interval?.procedureRunId || "");
const updateVisibleRange = () => {
const element = viewportRef.current as HTMLElement | null;
if (!element) {
setVisibleRange({ startMs: Number(bounds.startMs), endMs: Number(bounds.endMs) });
return;
}
const headerHeight = 64;
const topPx = Math.max(0, element.scrollTop - headerHeight);
const visiblePx = Math.max(120, element.clientHeight - headerHeight);
const bottomPx = Math.min(chartHeight, topPx + visiblePx);
const startRatio = Math.max(0, Math.min(1, topPx / chartHeight));
const endRatio = Math.max(startRatio, Math.min(1, bottomPx / chartHeight));
const duration = Math.max(1, Number(bounds.endMs) - Number(bounds.startMs));
setVisibleRange({
startMs: Number(bounds.startMs) + duration * startRatio,
endMs: Number(bounds.startMs) + duration * endRatio,
});
};
useEffect(() => {
const element = viewportRef.current as HTMLElement | null;
const timer = window.setTimeout(updateVisibleRange, 0);
element?.addEventListener("scroll", updateVisibleRange);
window.addEventListener("resize", updateVisibleRange);
return () => {
window.clearTimeout(timer);
element?.removeEventListener("scroll", updateVisibleRange);
window.removeEventListener("resize", updateVisibleRange);
};
}, [activeRunId, bounds.startMs, bounds.endMs, chartHeight]);
const hiddenCount = Math.max(0, allNodeIds.length - visibleNodeIds.length);
return h(Panel, {
title: "Epoch 甘特图",
eyebrow: `${activePipeline?.id || "pipeline"} / ${epochs.length} epochs`,
className: "pipeline-wide-panel",
actions: h("div", { className: "pipeline-gantt-actions" },
h("select", {
value: activeRunId,
disabled: epochs.length === 0,
onChange: (event: any) => onRunChange(event.target.value),
"data-testid": "pipeline-epoch-select",
}, epochs.map((run: any) => h("option", { key: run.runId, value: run.runId }, pipelineEpochLabel(epochs, run)))),
h("label", { className: "pipeline-gantt-toggle" },
h("input", {
type: "checkbox",
checked: autoHideIdle,
onChange: (event: any) => {
setAutoHideIdle(Boolean(event.target.checked));
window.setTimeout(updateVisibleRange, 0);
},
}),
h("span", null, "自动隐藏空闲列"),
),
activeRun ? h(RawButton, { title: `Pipeline Epoch ${activeRun.runId}`, data: activeRun, onOpen: onRaw, testId: "raw-pipeline-epoch-gantt" }) : null,
),
},
!activeRun ? h(EmptyState, { title: "暂无 Epoch", text: "当前 pipeline 还没有完整运行记录。" }) :
intervals.length === 0 ? h(EmptyState, { title: "暂无时间区间", text: "等待 D601 Pipeline backend 在 procedure summary 中返回 startedAt / finishedAt。" }) :
h("div", { className: "pipeline-gantt-wrap" },
h("div", { className: "pipeline-gantt-detail-layout" },
h("div", { className: "pipeline-gantt-main" },
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, `visible ${visibleNodeIds.length}/${allNodeIds.length} nodes`),
autoHideIdle && hiddenCount > 0 ? h("span", null, `hidden idle ${hiddenCount}`) : null,
),
h("div", { className: "pipeline-gantt-viewport", ref: viewportRef, "data-testid": "pipeline-epoch-gantt" },
h("div", { className: "pipeline-gantt-board", style: { gridTemplateColumns, minWidth: `${96 + Math.max(1, visibleNodeIds.length) * 72}px` } },
h("div", { className: "pipeline-gantt-head time" }, "Time"),
visibleNodeIds.length === 0 ? h("div", { className: "pipeline-gantt-head empty" }, "当前时间窗无工作节点") :
visibleNodeIds.map((nodeId) => h("div", { key: `head-${nodeId}`, className: "pipeline-gantt-head node", title: nodeId }, h(GanttHeaderLabel, { value: nodeId }))),
h("div", { className: "pipeline-gantt-time-axis", style: { height: `${chartHeight}px` } },
ticks.map((tick: AnyRecord) => h("div", { key: `tick-${tick.ms}`, className: "pipeline-gantt-tick", style: { top: `${tick.percent}%` } },
h("b", null, fmtDate(tick.ms)),
h("span", null, `+${fmtDurationMs(tick.ms - bounds.startMs)}`),
)),
),
visibleNodeIds.length === 0 ? h("div", { className: "pipeline-gantt-empty-col", style: { height: `${chartHeight}px` } }, "滚动到有活动的时间段后,相关 node 列会自动出现。") :
visibleNodeIds.map((nodeId) => {
const nodeIntervals = intervals.filter((interval: AnyRecord) => interval.nodeId === nodeId);
return h("div", { key: `col-${nodeId}`, className: "pipeline-gantt-node-col", style: { height: `${chartHeight}px` } },
nodeIntervals.map((interval: AnyRecord) => {
const duration = Math.max(1, Number(bounds.endMs) - Number(bounds.startMs));
const top = Math.max(0, Math.min(100, ((Number(interval.startMs) - Number(bounds.startMs)) / duration) * 100));
const bottom = Math.max(top, Math.min(100, ((Number(interval.endMs) - Number(bounds.startMs)) / duration) * 100));
const height = Math.max(10, ((bottom - top) / 100) * chartHeight);
const intervalKey = String(interval.procedureRunId || `${nodeId}-${interval.startMs}`);
return h("button", {
key: intervalKey,
type: "button",
className: `pipeline-gantt-bar ${interval.status} ${selectedIntervalKey === intervalKey ? "selected" : ""}`,
style: { top: `${top}%`, height: `${height}px` },
title: `${nodeId} ${interval.status} ${fmtDate(interval.startedAt)} -> ${fmtDate(interval.finishedAt)}`,
onClick: () => onIntervalSelect(interval),
"data-testid": "pipeline-gantt-line",
},
h("strong", null, interval.status || "working"),
h("span", null, fmtDurationMs(interval.durationMs)),
);
}),
);
}),
),
),
),
h(PipelineGanttDetailPanel, { selection, onRaw }),
),
),
);
}
function pipelineNodeControlState(): AnyRecord {
return {
loading: false,
@@ -814,13 +1495,6 @@ function pipelineProxyPath(apiBaseUrl: string, path: string): string {
function PipelineNodeControlPanel({ activeRun, pipelineRuns, selectedRunId, onRunChange, selectedNodeId, selectedNodeConfig, selectedNodeRuntime, control, onControlChange, onFetch, onAction, onRaw }: AnyRecord) {
const runId = String(activeRun?.runId || "");
const status = String(selectedNodeRuntime?.status || "pending");
const details = control.details || {};
const procedureRuns = Array.isArray(details.procedureRuns) ? details.procedureRuns : [];
const latestProcedure = procedureRuns.at(-1) || {};
const attempts = Array.isArray(latestProcedure.attempts) ? latestProcedure.attempts : [];
const latestAttempt = attempts.at(-1) || {};
const workerLogTail = Array.isArray(latestProcedure.workerLogTail) ? latestProcedure.workerLogTail : [];
const controlEventsTail = Array.isArray(latestAttempt.controlEventsTail) ? latestAttempt.controlEventsTail : [];
const disabled = !runId || !selectedNodeId || control.loading || Boolean(control.actionLoading);
const updateText = (field: string) => (event: any) => onControlChange({ [field]: event.target.value, error: "", message: "" });
const runOptions = pipelineRuns.length > 0 ? pipelineRuns : (activeRun ? [activeRun] : []);
@@ -896,15 +1570,14 @@ function PipelineNodeControlPanel({ activeRun, pipelineRuns, selectedRunId, onRu
),
),
h("div", { className: "pipeline-control-evidence" },
h("strong", null, "执行证据"),
control.details ? h("div", { className: "pipeline-control-evidence-grid" },
h("span", null, `${procedureRuns.length} procedure runs`),
h("span", null, `${attempts.length} attempts`),
h("span", null, `messages ${summarizeValue(latestAttempt.opencodeMessages?.messageCount)}`),
h("span", null, control.fetchedAt ? `fetched ${fmtClock(control.fetchedAt)}` : "not fetched"),
) : h("span", { className: "muted" }, "点击“抓取过程”读取 node 的 request/artifact/worker log/control prompt tail。"),
workerLogTail.length > 0 ? h("div", { className: "pipeline-log-list compact-log" }, workerLogTail.slice(-8).map((line: string, index: number) => h("code", { key: `${index}-${line.slice(0, 18)}` }, line))) : null,
controlEventsTail.length > 0 ? h("div", { className: "pipeline-log-list compact-log" }, controlEventsTail.slice(-6).map((line: string, index: number) => h("code", { key: `event-${index}-${line.slice(0, 18)}` }, line))) : null,
h("strong", null, "Node 过程索引"),
h(PipelineNodeExecutionIndex, {
details: control.details,
selectedNodeId,
selectedNodeRuntime,
control,
onRaw,
}),
),
);
}
@@ -916,6 +1589,7 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
const [selectedRunId, setSelectedRunId] = useState("");
const [selectedNodeId, setSelectedNodeId] = useState("");
const [nodeControl, setNodeControl] = useState(pipelineNodeControlState());
const [ganttSelection, setGanttSelection] = useState({ interval: null, loading: false, error: "", details: null, fetchedAt: null });
async function load(): Promise<void> {
if (!service) return;
@@ -945,7 +1619,7 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
const pipelineNodes = pipelineConfigNodes(activePipeline);
const pipelineEdges = pipelineConfigEdges(activePipeline);
const latestRun = pipelineLatestRun(runs, activePipelineId);
const pipelineRuns = runs.filter((run: any) => String(run?.pipelineId || "") === activePipelineId);
const pipelineRuns = pipelineEpochRuns(runs, activePipelineId);
const activeRun = pipelineRuns.find((run: any) => String(run?.runId || "") === selectedRunId) || latestRun;
const activeRunId = String(activeRun?.runId || "");
const selectedNodeConfig = pipelineNodes.find((node: any) => String(node?.id || "") === selectedNodeId) || null;
@@ -979,6 +1653,7 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
if (!selectedNodeId || pipelineNodeIds.split("|").includes(selectedNodeId)) return;
setSelectedNodeId("");
setNodeControl(pipelineNodeControlState());
setGanttSelection({ interval: null, loading: false, error: "", details: null, fetchedAt: null });
}, [selectedNodeId, pipelineNodeIds]);
async function fetchNodeDetails(runId = activeRunId, nodeId = selectedNodeId): Promise<void> {
@@ -995,6 +1670,26 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
}
}
async function selectGanttInterval(interval: AnyRecord): Promise<void> {
const runId = String(interval?.runId || activeRunId || "");
const nodeId = String(interval?.nodeId || "");
setGanttSelection({ interval, loading: true, error: "", details: null, fetchedAt: null });
if (!runId || !nodeId) {
setGanttSelection({ interval, loading: false, error: "缺少 runId 或 nodeId,无法抓取执行过程", details: null, fetchedAt: null });
return;
}
if (runId !== activeRunId) setSelectedRunId(runId);
setSelectedNodeId(nodeId);
setNodeControl(pipelineNodeControlState());
try {
const details = await requestJson(pipelineProxyPath(apiBaseUrl, `/api/node-control/runs/${encodeURIComponent(runId)}/nodes/${encodeURIComponent(nodeId)}?tail=220`));
setGanttSelection({ interval, loading: false, error: "", details, fetchedAt: new Date() });
setNodeControl((prev: AnyRecord) => ({ ...prev, details, fetchedAt: new Date(), error: "" }));
} catch (err) {
setGanttSelection({ interval, loading: false, error: errorMessage(err, "抓取线条执行过程失败"), details: null, fetchedAt: null });
}
}
async function postNodeAction(action: "append" | "guide" | "redo"): Promise<void> {
if (!activeRunId || !selectedNodeId) {
setNodeControl((prev: AnyRecord) => ({ ...prev, error: "请先选择 run 和 node", message: "" }));
@@ -1090,6 +1785,7 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
h(Panel, {
title: "控制图",
eyebrow: `${activePipeline.id || "pipeline"} / run ${activeRun?.status || "--"}`,
className: "pipeline-wide-panel",
actions: h("div", { className: "pipeline-toolbar" },
h("select", {
value: activePipelineId,
@@ -1099,9 +1795,20 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
setSelectedRunId("");
setSelectedNodeId("");
setNodeControl(pipelineNodeControlState());
setGanttSelection({ interval: null, loading: false, error: "", details: null, fetchedAt: null });
},
"data-testid": "pipeline-select",
}, pipelines.map((pipeline: any) => h("option", { key: pipeline.id, value: pipeline.id }, pipeline.id || pipeline.key))),
h("select", {
value: activeRunId,
disabled: pipelineRuns.length === 0,
onChange: (event: any) => {
setSelectedRunId(event.target.value);
setNodeControl(pipelineNodeControlState());
if (selectedNodeId) void fetchNodeDetails(event.target.value, selectedNodeId);
},
"data-testid": "pipeline-run-select",
}, pipelineRuns.map((run: any) => h("option", { key: run.runId, value: run.runId }, pipelineEpochLabel(pipelineRuns, run)))),
h("button", {
type: "button",
className: "ghost-btn",
@@ -1142,7 +1849,6 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
},
},
h(Background, { gap: 22, size: 1, color: "rgba(215, 161, 58, 0.24)" }),
h(MiniMap, { pannable: true, zoomable: true, className: "pipeline-minimap" }),
h(Controls, { showInteractive: false }),
),
),
@@ -1174,10 +1880,34 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
h("span", null, selectedNodeId ? `selected ${selectedNodeId}` : "click node to control"),
),
),
h(Panel, { title: "最近运行", eyebrow: `${runs.length}/${runCount} preview` },
runs.length === 0 ? h(EmptyState, { title: "暂无运行记录", text: "Pipeline .state/pipeline-runs 还没有可展示状态" }) :
h("div", { className: "pipeline-run-list" }, runs.map((run: any) => h("article", { key: run.runId, className: "pipeline-run-card" },
h("div", { className: "node-card-head" }, h("strong", null, run.runId || "--"), h(StatusBadge, { status: run.status }, run.status || "--")),
h(PipelineEpochGantt, {
epochs: pipelineRuns,
activeRun,
activePipeline,
pipelineNodes,
selection: ganttSelection,
onIntervalSelect: selectGanttInterval,
onRunChange: (runId: string) => {
setSelectedRunId(runId);
setNodeControl(pipelineNodeControlState());
setGanttSelection({ interval: null, loading: false, error: "", details: null, fetchedAt: null });
if (selectedNodeId) void fetchNodeDetails(runId, selectedNodeId);
},
onRaw,
}),
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", {
key: run.runId,
className: `pipeline-run-card ${String(run.runId || "") === activeRunId ? "active" : ""}`,
role: "button",
tabIndex: 0,
onClick: () => setSelectedRunId(String(run.runId || "")),
onKeyDown: (event: any) => {
if (event.key === "Enter" || event.key === " ") setSelectedRunId(String(run.runId || ""));
},
},
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}`),
@@ -1185,15 +1915,11 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
h("span", null, `procedures ${Array.isArray(run.procedureRuns) ? run.procedureRuns.length : 0}`),
),
h("p", { className: "muted paragraph" }, summarizeValue(run.task)),
h("code", null, fmtDate(run.updatedAt)),
h("span", { className: "pipeline-run-time" }, fmtDate(run.updatedAt)),
))),
),
h(Panel, { title: "证据日志", eyebrow: activeRun?.runId || "selected worker tail" },
!activeRun ? h(EmptyState, { title: "暂无证据", text: "没有 Pipeline run 时不会展示 worker log tail" }) :
h("div", { className: "pipeline-log-list" },
(Array.isArray(activeRun.workerLogTail) && activeRun.workerLogTail.length > 0 ? activeRun.workerLogTail.slice(-12) : [`${activeRun.runId} ${activeRun.status || "--"} / ${fmtDate(activeRun.updatedAt)}`]).map((line: string, index: number) => h("code", { key: `${index}-${line.slice(0, 24)}` }, line)),
),
h("div", { className: "panel-actions inline-actions" }, activeRun ? h(RawButton, { title: `Pipeline Run ${activeRun.runId}`, data: activeRun, onOpen: onRaw, testId: "raw-pipeline-run" }) : null),
h(Panel, { title: "运行材料索引", eyebrow: activeRun?.runId || "selected epoch", className: "pipeline-wide-panel" },
h(PipelineRunMaterialIndex, { activeRun, onRaw }),
),
),
);