feat: improve pipeline ui and remote update labels
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user