79166574e8
- add Codex Queue microservice/frontend integration and related deployment docs - document 100% Pipeline OA event-flow requirements and E2E gates - harden Pipeline frontend Gantt/timeline E2E assertions and rendering
4124 lines
207 KiB
TypeScript
4124 lines
207 KiB
TypeScript
import React from "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%" } },
|
||
{ id: "in-top-left", side: "top", slot: "left", slotIndex: -1, position: Position.Top, style: { left: "28%" } },
|
||
{ id: "in-top-mid", side: "top", slot: "mid", slotIndex: 0, position: Position.Top, style: { left: "50%" } },
|
||
{ id: "in-top-right", side: "top", slot: "right", slotIndex: 1, position: Position.Top, style: { left: "72%" } },
|
||
{ id: "in-bottom-left", side: "bottom", slot: "left", slotIndex: -1, position: Position.Bottom, style: { left: "28%" } },
|
||
{ id: "in-bottom-mid", side: "bottom", slot: "mid", slotIndex: 0, position: Position.Bottom, style: { left: "50%" } },
|
||
{ id: "in-bottom-right", side: "bottom", slot: "right", slotIndex: 1, position: Position.Bottom, style: { left: "72%" } },
|
||
];
|
||
|
||
const pipelineOutputPorts: AnyRecord[] = [
|
||
{ id: "out-right", position: Position.Right, style: { top: "50%" } },
|
||
];
|
||
|
||
const pipelineOverlapEdgePalette = ["#4eb7a8", "#d7a13a", "#69aee8", "#e0835f", "#b7d86b", "#d98bd2", "#5fc6bf"];
|
||
const pipelineNodeWidth = 236;
|
||
const pipelineNodeHeight = 88;
|
||
const pipelineAutoRefreshMs = 15000;
|
||
const pipelineSnapshotRunLimit = 10;
|
||
const pipelineGanttTimeAxisWidth = 96;
|
||
const pipelineGanttNodeColumnWidth = 72;
|
||
const pipelineGanttHeaderHeight = 64;
|
||
const pipelineGanttArrowTipInsetPx = 12;
|
||
|
||
function pipelinePercent(value: any, fallback: number): number {
|
||
const number = Number.parseFloat(String(value || ""));
|
||
return Number.isFinite(number) ? number / 100 : fallback;
|
||
}
|
||
|
||
function pipelineEndpointScore(port: AnyRecord, loads: Map<string, number>, laneHint: number): number {
|
||
const side = String(port.side || "");
|
||
if (side !== "top" && side !== "bottom") return 0;
|
||
const slotIndex = Number(port.slotIndex || 0);
|
||
const midId = side === "top" ? "in-top-mid" : "in-bottom-mid";
|
||
const load = loads.get(port.id) || 0;
|
||
const midLoad = loads.get(midId) || 0;
|
||
if (slotIndex === 0) return midLoad === 0 ? -26 : 28 + load * 74;
|
||
const lanePreference = laneHint === 0 ? Math.abs(slotIndex) * 2 : (Math.sign(laneHint) === Math.sign(slotIndex) ? -3 : 3);
|
||
if (midLoad > 0 && load === 0) return -14 + lanePreference;
|
||
return 8 + load * 74 + lanePreference;
|
||
}
|
||
|
||
function pipelineSmoothPath(points: Array<{ x: number; y: number }>): string {
|
||
const compact = points.filter((point, index) => {
|
||
const previous = points[index - 1];
|
||
return !previous || Math.abs(previous.x - point.x) > 0.5 || Math.abs(previous.y - point.y) > 0.5;
|
||
});
|
||
if (compact.length < 2) return "";
|
||
let path = `M ${compact[0].x},${compact[0].y}`;
|
||
let last = compact[0];
|
||
for (let index = 1; index < compact.length - 1; index += 1) {
|
||
const previous = compact[index - 1];
|
||
const current = compact[index];
|
||
const next = compact[index + 1];
|
||
const previousDistance = Math.hypot(current.x - previous.x, current.y - previous.y);
|
||
const nextDistance = Math.hypot(next.x - current.x, next.y - current.y);
|
||
const radius = Math.min(64, previousDistance * 0.42, nextDistance * 0.42);
|
||
if (radius < 2) {
|
||
path += ` L ${current.x},${current.y}`;
|
||
last = current;
|
||
continue;
|
||
}
|
||
const entry = {
|
||
x: current.x + ((previous.x - current.x) / previousDistance) * radius,
|
||
y: current.y + ((previous.y - current.y) / previousDistance) * radius,
|
||
};
|
||
const exit = {
|
||
x: current.x + ((next.x - current.x) / nextDistance) * radius,
|
||
y: current.y + ((next.y - current.y) / nextDistance) * radius,
|
||
};
|
||
if (Math.abs(entry.x - last.x) > 0.5 || Math.abs(entry.y - last.y) > 0.5) path += ` L ${entry.x},${entry.y}`;
|
||
path += ` Q ${current.x},${current.y} ${exit.x},${exit.y}`;
|
||
last = exit;
|
||
}
|
||
const end = compact[compact.length - 1];
|
||
return `${path} L ${end.x},${end.y}`;
|
||
}
|
||
|
||
function pipelineCurvePath(sourceX: number, sourceY: number, targetX: number, targetY: number, targetPosition: Position, laneOffset: number, routeMode = ""): string {
|
||
const forward = targetX >= sourceX;
|
||
const horizontalDistance = Math.max(1, Math.abs(targetX - sourceX));
|
||
const verticalDistance = Math.abs(targetY - sourceY);
|
||
const handleOffset = Math.max(34, Math.min(118, horizontalDistance * 0.26));
|
||
const scatter = Math.min(280, Math.abs(laneOffset));
|
||
const directShortLine = forward && targetPosition === Position.Left && scatter < 4 && verticalDistance < 28 && horizontalDistance < 420;
|
||
if (directShortLine) return `M ${sourceX},${sourceY} C ${sourceX + handleOffset},${sourceY} ${targetX - handleOffset},${targetY} ${targetX},${targetY}`;
|
||
const directForwardLeft = forward && targetPosition === Position.Left && (routeMode === "direct-forward-left" || (horizontalDistance <= 260 && verticalDistance <= 210));
|
||
if (directForwardLeft) {
|
||
const bend = Math.max(42, Math.min(140, horizontalDistance * 0.48));
|
||
const fanoutLift = Math.max(-28, Math.min(28, laneOffset * 0.18));
|
||
return `M ${sourceX},${sourceY} C ${sourceX + bend},${sourceY + fanoutLift} ${targetX - bend},${targetY} ${targetX},${targetY}`;
|
||
}
|
||
|
||
if (forward) {
|
||
const sourceRailX = sourceX + handleOffset;
|
||
if (targetPosition === Position.Top || targetPosition === Position.Bottom) {
|
||
const verticalSign = targetPosition === Position.Top ? -1 : 1;
|
||
const railY = targetY + verticalSign * (54 + scatter * 0.42);
|
||
return pipelineSmoothPath([
|
||
{ x: sourceX, y: sourceY },
|
||
{ x: sourceRailX, y: sourceY },
|
||
{ x: sourceRailX + Math.min(120, horizontalDistance * 0.18), y: railY },
|
||
{ x: targetX, y: railY },
|
||
{ x: targetX, y: targetY + verticalSign * 34 },
|
||
{ x: targetX, y: targetY },
|
||
]);
|
||
}
|
||
const targetRailX = targetX - handleOffset;
|
||
const railY = (sourceY + targetY) / 2 + laneOffset;
|
||
return pipelineSmoothPath([
|
||
{ x: sourceX, y: sourceY },
|
||
{ x: sourceRailX, y: sourceY },
|
||
{ x: sourceRailX + Math.min(110, horizontalDistance * 0.16), y: railY },
|
||
{ x: targetRailX - Math.min(90, horizontalDistance * 0.12), y: railY },
|
||
{ x: targetRailX, y: targetY },
|
||
{ x: targetX, y: targetY },
|
||
]);
|
||
}
|
||
|
||
const targetSign = targetPosition === Position.Bottom ? 1 : targetPosition === Position.Top ? -1 : (laneOffset >= 0 ? 1 : -1);
|
||
const detourX = Math.max(sourceX, targetX) + 92 + Math.min(180, scatter * 0.52);
|
||
const railY = targetSign < 0
|
||
? Math.min(sourceY, targetY) - 84 - scatter * 0.62
|
||
: Math.max(sourceY, targetY) + 84 + scatter * 0.62;
|
||
if (targetPosition === Position.Top || targetPosition === Position.Bottom) {
|
||
return pipelineSmoothPath([
|
||
{ x: sourceX, y: sourceY },
|
||
{ x: sourceX + handleOffset, y: sourceY },
|
||
{ x: detourX, y: railY },
|
||
{ x: targetX, y: railY },
|
||
{ x: targetX, y: targetY + targetSign * 38 },
|
||
{ x: targetX, y: targetY },
|
||
]);
|
||
}
|
||
return pipelineSmoothPath([
|
||
{ x: sourceX, y: sourceY },
|
||
{ x: sourceX + handleOffset, y: sourceY },
|
||
{ x: detourX, y: railY },
|
||
{ x: targetX - handleOffset, y: railY },
|
||
{ x: targetX - handleOffset, y: targetY },
|
||
{ x: targetX, y: targetY },
|
||
]);
|
||
}
|
||
|
||
function PipelineFlowNode({ data }: AnyRecord) {
|
||
return h("div", { className: "pipeline-flow-node-body" },
|
||
pipelineInputPorts.map((port) => h(Handle, {
|
||
key: port.id,
|
||
id: port.id,
|
||
type: "target",
|
||
position: port.position,
|
||
isConnectable: false,
|
||
className: `pipeline-flow-handle input ${port.side} slot-${port.slot || "mid"}`,
|
||
style: port.style,
|
||
})),
|
||
pipelineOutputPorts.map((port) => h(Handle, {
|
||
key: port.id,
|
||
id: port.id,
|
||
type: "source",
|
||
position: port.position,
|
||
isConnectable: false,
|
||
className: "pipeline-flow-handle output right",
|
||
style: port.style,
|
||
})),
|
||
data?.label,
|
||
);
|
||
}
|
||
|
||
function PipelineCurveEdge({ id, sourceX, sourceY, targetX, targetY, targetPosition, markerEnd, markerStart, style, data }: AnyRecord) {
|
||
const laneOffset = Number(data?.laneOffset || 0);
|
||
const path = pipelineCurvePath(sourceX, sourceY, targetX, targetY, targetPosition, laneOffset, String(data?.routeMode || ""));
|
||
return h(BaseEdge, { id, path, markerEnd, markerStart, style, interactionWidth: 28 });
|
||
}
|
||
|
||
const pipelineEdgeTypes: any = { pipelineCurve: PipelineCurveEdge };
|
||
const pipelineNodeTypes: any = { pipelineNode: PipelineFlowNode };
|
||
|
||
function errorMessage(error: unknown, fallback = "操作失败"): string {
|
||
return error instanceof Error ? error.message : String(error || fallback);
|
||
}
|
||
|
||
function fmtDate(value: any): string {
|
||
if (!value) return "--";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return "--";
|
||
return date.toLocaleString("zh-CN", { hour12: false });
|
||
}
|
||
|
||
function fmtClock(value: Date): string {
|
||
return value.toLocaleTimeString("zh-CN", { hour12: false });
|
||
}
|
||
|
||
function fmtClockValue(value: any): string {
|
||
if (!value) return "--";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return "--";
|
||
return fmtClock(date);
|
||
}
|
||
|
||
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 runningStatus(status: any): boolean {
|
||
const value = statusValue(status).toLowerCase();
|
||
return ["running", "active", "in-progress", "in_progress"].includes(value);
|
||
}
|
||
|
||
function statusCounts(items: any[], key = "status"): AnyRecord {
|
||
return items.reduce((counts: AnyRecord, item: any) => {
|
||
const status = String(item?.[key] || "unknown").toLowerCase();
|
||
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 ? "是" : "否";
|
||
if (typeof value === "number") return String(value);
|
||
if (typeof value === "string") return value.length > 80 ? `${value.slice(0, 77)}...` : value;
|
||
if (Array.isArray(value)) return `${value.length} 项`;
|
||
if (typeof value === "object") return `${Object.keys(value).length} 字段`;
|
||
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 && part?.durationMs !== null ? `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 firstFieldValue(fields: any, keys: string[]): string {
|
||
const normalized = new Set(keys.map((key) => key.toLowerCase()));
|
||
for (const field of asArray(fields)) {
|
||
const key = String(field?.key || "").toLowerCase();
|
||
if (normalized.has(key)) return String(field?.value || "");
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function stepRoleLabel(role: any): string {
|
||
const value = String(role || "unknown").toLowerCase();
|
||
if (value === "user") return "用户";
|
||
if (value === "assistant") return "助手";
|
||
if (value === "system") return "系统";
|
||
return value || "其他";
|
||
}
|
||
|
||
function stepRoleTone(role: any): string {
|
||
const value = String(role || "unknown").toLowerCase();
|
||
return ["user", "assistant", "system"].includes(value) ? value : "unknown";
|
||
}
|
||
|
||
function pipelineNoisePartType(type: any): boolean {
|
||
const normalized = String(type || "").toLowerCase();
|
||
return normalized === "step-start" || normalized === "step-finish";
|
||
}
|
||
|
||
function partKindLabel(part: any): string {
|
||
const type = String(part?.type || "unknown").toLowerCase();
|
||
if (type === "text") return "正文";
|
||
if (type === "reasoning") return "思考";
|
||
if (type === "tool") return "工具";
|
||
if (type === "step-start") return "开始";
|
||
if (type === "step-finish") return "结束";
|
||
return type || "part";
|
||
}
|
||
|
||
function partStatusTone(part: any): string {
|
||
const status = String(part?.status || "").toLowerCase();
|
||
if (["error", "failed", "failure"].includes(status)) return "failed";
|
||
if (["completed", "succeeded", "success"].includes(status)) return "succeeded";
|
||
if (["running", "started", "in_progress"].includes(status)) return "running";
|
||
return "unknown";
|
||
}
|
||
|
||
function stepStatusTone(step: any): string {
|
||
const tools = asArray(step?.parts).filter((part: any) => String(part?.type || "").toLowerCase() === "tool");
|
||
if (tools.some((part: any) => partStatusTone(part) === "failed")) return "failed";
|
||
if (!step?.completedAt && String(step?.role || "").toLowerCase() === "assistant") return "running";
|
||
return "succeeded";
|
||
}
|
||
|
||
function stepTokenLine(tokens: any): string {
|
||
const facts = tokenFacts(tokens);
|
||
return facts.length > 0 ? facts.join(" / ") : "tokens --";
|
||
}
|
||
|
||
function pipelineStructuredSummary(value: any): AnyRecord {
|
||
if (Array.isArray(value)) {
|
||
const first = value[0];
|
||
const keys = isRecord(first) ? Object.keys(first).slice(0, 4) : [];
|
||
return {
|
||
shape: "array",
|
||
facts: [`${value.length} items`, ...keys.map((key) => `key ${key}`)],
|
||
kv: [],
|
||
};
|
||
}
|
||
if (isRecord(value)) {
|
||
const entries = Object.entries(value);
|
||
const primitiveEntries = entries.filter(([, item]) => item === null || ["string", "number", "boolean"].includes(typeof item)).slice(0, 6);
|
||
return {
|
||
shape: "object",
|
||
facts: [`${entries.length} fields`, ...entries.slice(0, 4).map(([key]) => `key ${key}`)],
|
||
kv: primitiveEntries.map(([key, item]) => ({ label: key, value: summarizeValue(item) })),
|
||
};
|
||
}
|
||
return {
|
||
shape: typeof value,
|
||
facts: [summarizeValue(value)],
|
||
kv: [],
|
||
};
|
||
}
|
||
|
||
function readBalancedStructuredBlock(text: string, startIndex: number): AnyRecord | null {
|
||
const opening = text[startIndex];
|
||
if (opening !== "{" && opening !== "[") return null;
|
||
const pairs: AnyRecord = { "{": "}", "[": "]" };
|
||
const stack = [opening];
|
||
let inString = false;
|
||
let escaped = false;
|
||
for (let index = startIndex + 1; index < text.length; index += 1) {
|
||
const char = text[index];
|
||
if (inString) {
|
||
if (escaped) {
|
||
escaped = false;
|
||
continue;
|
||
}
|
||
if (char === "\\") {
|
||
escaped = true;
|
||
continue;
|
||
}
|
||
if (char === "\"") inString = false;
|
||
continue;
|
||
}
|
||
if (char === "\"") {
|
||
inString = true;
|
||
continue;
|
||
}
|
||
if (char === "{" || char === "[") {
|
||
stack.push(char);
|
||
continue;
|
||
}
|
||
if (char === "}" || char === "]") {
|
||
const expected = pairs[stack[stack.length - 1]];
|
||
if (char !== expected) return null;
|
||
stack.pop();
|
||
if (stack.length === 0) return { raw: text.slice(startIndex, index + 1), end: index + 1 };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function appendPipelineStructuredAwareText(blocks: AnyRecord[], text: string, defaultLabel = "structured payload"): void {
|
||
const source = String(text || "").trim();
|
||
if (!source) return;
|
||
const standalonePattern = /(^|\n)(?=[\[{])/gu;
|
||
let cursor = 0;
|
||
while (cursor < source.length) {
|
||
standalonePattern.lastIndex = cursor;
|
||
const match = standalonePattern.exec(source);
|
||
if (!match) {
|
||
const remainder = source.slice(cursor).trim();
|
||
if (remainder) blocks.push({ type: "text", text: remainder });
|
||
return;
|
||
}
|
||
const start = match.index + match[1].length;
|
||
const before = source.slice(cursor, start).trim();
|
||
if (before) blocks.push({ type: "text", text: before });
|
||
const parsedBlock = readBalancedStructuredBlock(source, start);
|
||
if (!parsedBlock) {
|
||
blocks.push({ type: "text", text: `${defaultLabel}: structured payload preview unavailable` });
|
||
const nextBoundary = source.indexOf("\n\n", start);
|
||
cursor = nextBoundary >= 0 ? nextBoundary + 2 : source.length;
|
||
continue;
|
||
}
|
||
try {
|
||
blocks.push({ type: "structured", label: defaultLabel, value: JSON.parse(parsedBlock.raw) });
|
||
} catch {
|
||
blocks.push({ type: "text", text: `${defaultLabel}: structured payload` });
|
||
}
|
||
cursor = parsedBlock.end;
|
||
while (cursor < source.length && /\s/u.test(source[cursor])) cursor += 1;
|
||
}
|
||
}
|
||
|
||
function pipelineStructuredTextBlocks(text: any): AnyRecord[] {
|
||
const source = typeof text === "string" ? text.replace(/\r\n/gu, "\n").trim() : "";
|
||
if (!source) return [];
|
||
const blocks: AnyRecord[] = [];
|
||
let cursor = 0;
|
||
const headingPattern = /(^|\n)([^\n]{1,120}):\n(?=[\[{])/gu;
|
||
while (cursor < source.length) {
|
||
headingPattern.lastIndex = cursor;
|
||
const match = headingPattern.exec(source);
|
||
if (!match) {
|
||
appendPipelineStructuredAwareText(blocks, source.slice(cursor));
|
||
break;
|
||
}
|
||
const matchStart = match.index + match[1].length;
|
||
appendPipelineStructuredAwareText(blocks, source.slice(cursor, matchStart));
|
||
const label = String(match[2] || "").trim();
|
||
const jsonStart = headingPattern.lastIndex;
|
||
const parsedBlock = readBalancedStructuredBlock(source, jsonStart);
|
||
if (!parsedBlock) {
|
||
blocks.push({ type: "text", text: `${label}: structured payload preview unavailable` });
|
||
const nextBoundary = source.indexOf("\n\n", jsonStart);
|
||
cursor = nextBoundary >= 0 ? nextBoundary + 2 : source.length;
|
||
continue;
|
||
}
|
||
try {
|
||
const parsed = JSON.parse(parsedBlock.raw);
|
||
blocks.push({ type: "structured", label, value: parsed });
|
||
cursor = parsedBlock.end;
|
||
while (cursor < source.length && /\s/u.test(source[cursor])) cursor += 1;
|
||
continue;
|
||
} catch {
|
||
const fallbackChunk = `${label}: structured payload`;
|
||
blocks.push({ type: "text", text: fallbackChunk });
|
||
cursor = parsedBlock.end;
|
||
}
|
||
}
|
||
return blocks;
|
||
}
|
||
|
||
function PipelineStructuredPayloadBlock({ label, value }: AnyRecord) {
|
||
const summary = pipelineStructuredSummary(value);
|
||
return h("section", { className: "pipeline-structured-payload" },
|
||
h("div", { className: "pipeline-structured-payload-head" },
|
||
h("b", null, label || "structured payload"),
|
||
h("span", null, summary.shape),
|
||
),
|
||
h(PipelineChipRow, { items: summary.facts }),
|
||
summary.kv.length > 0 ? h(PipelineKvGrid, { items: summary.kv }) : null,
|
||
);
|
||
}
|
||
|
||
function PipelineStepTextBlock({ label, text, tone = "", previewLimit = 760, maxBlocks = Number.POSITIVE_INFINITY, compactText = false }: AnyRecord) {
|
||
const safePreviewLimit = Number.isFinite(Number(previewLimit)) ? Number(previewLimit) : 760;
|
||
const safeMaxBlocks = Number.isFinite(Number(maxBlocks)) ? Math.max(1, Number(maxBlocks)) : Number.POSITIVE_INFINITY;
|
||
const blocks = pipelineStructuredTextBlocks(text);
|
||
if (blocks.length === 0) return null;
|
||
const visibleBlocks = blocks.slice(0, safeMaxBlocks);
|
||
const hiddenCount = Math.max(0, blocks.length - visibleBlocks.length);
|
||
return h("div", { className: "pipeline-step-text-stack" },
|
||
visibleBlocks.map((block: AnyRecord, index: number) => {
|
||
if (block.type === "structured") {
|
||
return h(PipelineStructuredPayloadBlock, { key: `structured-${index}-${block.label || "payload"}`, label: block.label, value: block.value });
|
||
}
|
||
const sourceText = compactText ? String(block.text || "").replace(/\s+/gu, " ").trim() : block.text;
|
||
const preview = previewText(sourceText, safePreviewLimit);
|
||
if (!preview) return null;
|
||
return h("section", { key: `text-${index}`, className: `pipeline-step-text-block ${tone}` },
|
||
h("b", null, label),
|
||
h("p", null, preview),
|
||
);
|
||
}),
|
||
hiddenCount > 0 ? h("div", { className: "pipeline-step-text-overflow" }, `已折叠 ${hiddenCount} 段`) : null,
|
||
);
|
||
}
|
||
|
||
function normalizedPipelinePreview(value: any): string {
|
||
return previewText(value, 3200).replace(/\s+/gu, " ").trim().toLowerCase();
|
||
}
|
||
|
||
function uniquePipelinePartPreviews(parts: any[], blockedValues: Set<string> = new Set()): string[] {
|
||
const seen = new Set<string>();
|
||
const values: string[] = [];
|
||
for (const part of asArray(parts)) {
|
||
const text = previewText(part?.textPreview, 3200);
|
||
const normalized = normalizedPipelinePreview(text);
|
||
if (!normalized || seen.has(normalized) || blockedValues.has(normalized)) continue;
|
||
seen.add(normalized);
|
||
values.push(text);
|
||
}
|
||
return values;
|
||
}
|
||
|
||
function pipelineSummaryBlockProps(step: any, textParts: any[], reasoningParts: any[]): AnyRecord | null {
|
||
const role = String(step?.role || "").toLowerCase();
|
||
const reasoningValues = uniquePipelinePartPreviews(reasoningParts);
|
||
const reasoningSet = new Set(reasoningValues.map((item) => normalizedPipelinePreview(item)).filter(Boolean));
|
||
const textValues = uniquePipelinePartPreviews(textParts, role === "assistant" ? reasoningSet : new Set());
|
||
const textSummary = textValues.join("\n\n");
|
||
const stepPreview = previewText(step?.textPreview, 3200);
|
||
const normalizedStepPreview = normalizedPipelinePreview(stepPreview);
|
||
const hasIndependentStepPreview = Boolean(
|
||
normalizedStepPreview
|
||
&& !reasoningSet.has(normalizedStepPreview)
|
||
&& !Array.from(reasoningSet).some((item) => item && normalizedStepPreview.includes(item)),
|
||
);
|
||
|
||
if (role === "assistant") {
|
||
if (textSummary) {
|
||
return { label: "消息摘要", text: textSummary, tone: "assistant", previewLimit: 180, maxBlocks: 2, compactText: true };
|
||
}
|
||
if (hasIndependentStepPreview) {
|
||
return { label: "消息摘要", text: stepPreview, tone: "assistant", previewLimit: 180, maxBlocks: 2, compactText: true };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const summaryText = textSummary || stepPreview;
|
||
if (!summaryText) return null;
|
||
return {
|
||
label: role === "user" ? "用户输入" : role === "system" ? "系统输入" : "消息摘要",
|
||
text: summaryText,
|
||
tone: role,
|
||
previewLimit: role === "user" ? 200 : 180,
|
||
maxBlocks: 2,
|
||
compactText: true,
|
||
};
|
||
}
|
||
|
||
function pipelineReasoningSummaryText(reasoningParts: any[]): string {
|
||
return uniquePipelinePartPreviews(reasoningParts).join("\n\n");
|
||
}
|
||
|
||
function pipelineMessageCardRows(step: any, textParts: any[], reasoningParts: any[]): AnyRecord[] {
|
||
const role = String(step?.role || "").toLowerCase();
|
||
const roleTone = stepRoleTone(role);
|
||
const messageSummary = pipelineSummaryBlockProps(step, textParts, reasoningParts);
|
||
const reasoningSummary = role === "assistant" ? pipelineReasoningSummaryText(reasoningParts) : "";
|
||
const normalizedReasoning = normalizedPipelinePreview(reasoningSummary);
|
||
const normalizedMessage = normalizedPipelinePreview(messageSummary?.text || "");
|
||
const duplicateSummary = Boolean(
|
||
normalizedReasoning
|
||
&& normalizedMessage
|
||
&& (
|
||
normalizedReasoning === normalizedMessage
|
||
|| normalizedReasoning.includes(normalizedMessage)
|
||
|| normalizedMessage.includes(normalizedReasoning)
|
||
),
|
||
);
|
||
const rows: AnyRecord[] = [];
|
||
if (reasoningSummary) rows.push({ key: "reasoning", label: "思考", text: reasoningSummary, tone: "reasoning", previewLimit: 104 });
|
||
if (messageSummary?.text && !(role === "assistant" && duplicateSummary)) rows.push({
|
||
key: "message",
|
||
label: role === "assistant" ? "消息" : messageSummary.label,
|
||
text: messageSummary.text,
|
||
tone: messageSummary.tone || roleTone,
|
||
previewLimit: role === "user" ? 124 : 110,
|
||
});
|
||
if (rows.length === 0 && role === "assistant") rows.push({ key: "message", label: "消息", text: "无正文输出,仅工具调用。", tone: roleTone, previewLimit: 120 });
|
||
return rows;
|
||
}
|
||
|
||
function pipelineToolPrimarySummary(part: any): AnyRecord {
|
||
const inputFields = asArray(part?.inputFields);
|
||
const outputPreview = previewText(part?.outputPreview && part.outputPreview !== "--" ? part.outputPreview : "", 620);
|
||
const tool = String(part?.tool || part?.type || "tool").toUpperCase();
|
||
const description = firstFieldValue(inputFields, ["description", "justification", "purpose", "summary"]);
|
||
const query = firstFieldValue(inputFields, ["q", "query"]);
|
||
const command = firstFieldValue(inputFields, ["command", "cmd"]);
|
||
const filePath = firstFieldValue(inputFields, ["filePath", "filepath", "path"]);
|
||
const primaryText = description
|
||
|| query
|
||
|| (tool === "BASH" ? command || filePath : filePath || command)
|
||
|| previewText(part?.textPreview || outputPreview || part?.title || tool, 220);
|
||
return {
|
||
tool,
|
||
text: previewText(String(primaryText || "").replace(/\s+/gu, " ").trim(), 150),
|
||
};
|
||
}
|
||
|
||
function pipelineToolSummaryItems(parts: any[]): AnyRecord[] {
|
||
const seen = new Set<string>();
|
||
const items: AnyRecord[] = [];
|
||
for (const part of asArray(parts)) {
|
||
const summary = pipelineToolPrimarySummary(part);
|
||
const key = `${summary.tool}:${normalizedPipelinePreview(summary.text)}`;
|
||
if (!summary.text || seen.has(key)) continue;
|
||
seen.add(key);
|
||
items.push(summary);
|
||
}
|
||
return items;
|
||
}
|
||
|
||
function pipelineSessionFacts(steps: any[], sessionIds: string[]): string[] {
|
||
const agents = uniqueStrings(steps.map((step: any) => step?.agent)).slice(0, 3);
|
||
const models = uniqueStrings(steps.map((step: any) => step?.model)).slice(0, 3);
|
||
const sessions = sessionIds.length <= 2
|
||
? sessionIds.map((sessionId) => `session ${sessionId}`)
|
||
: [`sessions ${sessionIds.length}`, ...sessionIds.slice(0, 2).map((sessionId) => `session ${sessionId}`)];
|
||
return [
|
||
...agents.map((agent) => `agent ${agent}`),
|
||
...models.map((model) => `model ${model}`),
|
||
...sessions,
|
||
];
|
||
}
|
||
|
||
function PipelineStepMessageCard({ rows, compact = false, role = "", matched = false }: AnyRecord) {
|
||
const safeRows = asArray(rows).filter((row: any) => row?.text);
|
||
if (safeRows.length === 0) return null;
|
||
if (compact) {
|
||
return h("section", { className: `pipeline-step-message-card compact ${role}` },
|
||
h("div", { className: "pipeline-step-message-rows" },
|
||
safeRows.map((row: AnyRecord, index: number) => {
|
||
const text = previewText(String(row.text || "").replace(/\s+/gu, " ").trim(), row.previewLimit || (index === 0 ? 116 : 124));
|
||
return h("section", { key: row.key || `${row.label}-${index}`, className: `pipeline-step-message-row compact ${row.tone || ""}` },
|
||
h("p", null,
|
||
h("b", null, row.label),
|
||
h("span", null, text || "--"),
|
||
),
|
||
);
|
||
}),
|
||
),
|
||
);
|
||
}
|
||
return h("section", { className: `pipeline-step-message-card ${compact ? "compact" : "expanded"} ${role}` },
|
||
h("div", { className: "pipeline-step-message-card-head" },
|
||
h("span", { className: `pipeline-step-role ${role}` }, stepRoleLabel(role)),
|
||
matched ? h("span", { className: "pipeline-step-match-badge" }, "匹配点") : null,
|
||
),
|
||
h("div", { className: "pipeline-step-message-rows" },
|
||
safeRows.map((row: AnyRecord, index: number) => {
|
||
return h("div", { key: row.key || `${row.label}-${index}`, className: `pipeline-step-message-row expanded ${row.tone || ""}` },
|
||
h(PipelineStepTextBlock, {
|
||
label: row.label,
|
||
text: row.text,
|
||
tone: row.tone || role,
|
||
previewLimit: row.previewLimitFull || 420,
|
||
maxBlocks: 3,
|
||
compactText: false,
|
||
}),
|
||
);
|
||
}),
|
||
),
|
||
);
|
||
}
|
||
|
||
function PipelineStepTimeSummary({ step, role = "", matched = false }: AnyRecord) {
|
||
const durationText = step?.durationMs !== undefined && step?.durationMs !== null ? fmtDurationMs(step.durationMs) : "";
|
||
const meta = [
|
||
`Step ${step?.index ?? "?"}`,
|
||
durationText,
|
||
step?.finish ? String(step.finish) : "",
|
||
].filter(Boolean).join(" / ");
|
||
return h("section", { className: "pipeline-step-time-card" },
|
||
h("b", null, "时间"),
|
||
h("strong", null, `${fmtClockValue(step?.createdAt)} -> ${fmtClockValue(step?.completedAt)}`),
|
||
h("div", { className: "pipeline-step-time-meta" },
|
||
meta ? h("span", null, meta) : null,
|
||
role ? h("span", { className: `pipeline-step-role ${role}` }, stepRoleLabel(role)) : null,
|
||
matched ? h("span", { className: "pipeline-step-match-badge" }, "匹配点") : null,
|
||
),
|
||
);
|
||
}
|
||
|
||
function PipelineStepToolSummary({ tools }: AnyRecord) {
|
||
const items = pipelineToolSummaryItems(tools);
|
||
return h("section", { className: `pipeline-step-tool-summary ${items.length > 0 ? "has-items" : "empty"}` },
|
||
h("b", null, "工具调用"),
|
||
items.length === 0
|
||
? h("p", null, "无")
|
||
: h("div", { className: "pipeline-step-tool-summary-list" },
|
||
items.slice(0, 2).map((item: AnyRecord, index: number) => h("div", { key: `${item.tool}-${index}`, className: "pipeline-step-tool-summary-item" },
|
||
h("span", null, item.tool),
|
||
h("p", null, item.text),
|
||
)),
|
||
items.length > 2 ? h("small", null, `+${items.length - 2} more`) : null,
|
||
),
|
||
);
|
||
}
|
||
|
||
function PipelineOpenCodePart({ part }: AnyRecord) {
|
||
const type = String(part?.type || "unknown").toLowerCase();
|
||
if (pipelineNoisePartType(type)) return null;
|
||
const title = String(part?.title || part?.tool || partKindLabel(part));
|
||
const facts = opencodePartFacts(part);
|
||
const tone = partStatusTone(part);
|
||
const inputFields = asArray(part?.inputFields);
|
||
const metadataFields = asArray(part?.metadataFields);
|
||
const outputPreview = previewText(part?.outputPreview && part.outputPreview !== "--" ? part.outputPreview : "", 620);
|
||
const command = firstFieldValue(inputFields, ["command", "cmd"]);
|
||
const filePath = firstFieldValue(inputFields, ["filePath", "filepath", "path"]);
|
||
const compactSummary = command || filePath || previewText(part?.textPreview || outputPreview || title, 120);
|
||
|
||
if (type === "text") {
|
||
return h(PipelineStepTextBlock, { label: "用户输入 / 上下文", text: part?.textPreview, tone: "user-text" });
|
||
}
|
||
|
||
if (type === "reasoning") {
|
||
return h("details", { className: "pipeline-opencode-part reasoning" },
|
||
h("summary", null,
|
||
h("span", { className: "pipeline-part-kind" }, "思考"),
|
||
h("strong", null, previewText(part?.textPreview || "reasoning", 96)),
|
||
h(PipelineChipRow, { items: facts }),
|
||
),
|
||
h("div", { className: "pipeline-opencode-part-body" },
|
||
h(PipelineStepTextBlock, { label: "reasoning preview", text: part?.textPreview, tone: "reasoning" }),
|
||
metadataFields.length > 0 ? h("div", null, h("b", null, "元数据"), h(PipelineFieldList, { fields: metadataFields })) : null,
|
||
),
|
||
);
|
||
}
|
||
|
||
if (type === "tool") {
|
||
return h("details", { className: `pipeline-opencode-part tool ${tone}` },
|
||
h("summary", null,
|
||
h("span", { className: `pipeline-tool-badge ${tone}` }, part?.tool || "tool"),
|
||
h("strong", null, compactSummary || title),
|
||
),
|
||
h("div", { className: "pipeline-opencode-part-body" },
|
||
facts.length > 0 ? h(PipelineChipRow, { items: facts }) : null,
|
||
inputFields.length > 0 ? h("div", null, h("b", null, "输入字段"), h(PipelineFieldList, { fields: inputFields })) : null,
|
||
outputPreview ? h(PipelineStepTextBlock, { label: "输出摘要", text: outputPreview, tone: tone === "failed" ? "failed" : "tool-output" }) : null,
|
||
metadataFields.length > 0 ? h("div", null, h("b", null, "元数据"), h(PipelineFieldList, { fields: metadataFields })) : null,
|
||
),
|
||
);
|
||
}
|
||
|
||
return h("details", { className: `pipeline-opencode-part ${tone}` },
|
||
h("summary", null,
|
||
h("span", { className: "pipeline-part-kind" }, partKindLabel(part)),
|
||
h("strong", null, title),
|
||
h(PipelineChipRow, { items: facts }),
|
||
),
|
||
h("div", { className: "pipeline-opencode-part-body" },
|
||
part?.textPreview ? h(PipelineStepTextBlock, { label: partKindLabel(part), text: part.textPreview }) : null,
|
||
inputFields.length > 0 ? h("div", null, h("b", null, "输入字段"), h(PipelineFieldList, { fields: inputFields })) : null,
|
||
outputPreview ? h(PipelineStepTextBlock, { label: "输出摘要", text: outputPreview }) : null,
|
||
metadataFields.length > 0 ? h("div", null, h("b", null, "元数据"), h(PipelineFieldList, { fields: metadataFields })) : null,
|
||
),
|
||
);
|
||
}
|
||
|
||
function opencodeStepKey(step: any, fallbackIndex = 0): string {
|
||
const key = String(step?.messageId || step?.index || "");
|
||
return key || `step-${fallbackIndex}`;
|
||
}
|
||
|
||
function PipelineOpenCodeStep({ step, matched = false }: AnyRecord) {
|
||
const parts = asArray(step?.parts);
|
||
const visibleParts = parts.filter((part: any) => !pipelineNoisePartType(part?.type));
|
||
const tools = visibleParts.filter((part: any) => String(part?.type || "").toLowerCase() === "tool");
|
||
const textParts = visibleParts.filter((part: any) => String(part?.type || "").toLowerCase() === "text");
|
||
const reasoningParts = visibleParts.filter((part: any) => String(part?.type || "").toLowerCase() === "reasoning");
|
||
const otherParts = visibleParts.filter((part: any) => !["tool", "text", "reasoning"].includes(String(part?.type || "").toLowerCase()));
|
||
const partTypes = isRecord(step?.partTypes)
|
||
? Object.entries(step.partTypes)
|
||
.filter(([type, count]) => !pipelineNoisePartType(type) && Number(count) > 0)
|
||
.map(([type, count]) => `${type} ${count}`)
|
||
: [];
|
||
const roleTone = stepRoleTone(step?.role);
|
||
const statusTone = stepStatusTone(step);
|
||
const stepStartMs = timeMs(step?.createdAt) ?? timeMs(step?.completedAt);
|
||
const stepEndMs = timeMs(step?.completedAt) ?? stepStartMs;
|
||
const toolNames = uniqueStrings([...asArray(step?.tools), ...tools.map((part: any) => part?.tool)]).slice(0, 5);
|
||
const facts = [
|
||
step?.finish ? `finish ${step.finish}` : "",
|
||
step?.durationMs !== undefined && step?.durationMs !== null ? `duration ${fmtDurationMs(step.durationMs)}` : "",
|
||
`parts ${visibleParts.length}`,
|
||
tools.length > 0 ? `tools ${tools.length}` : "",
|
||
...toolNames.map((tool) => `tool ${tool}`),
|
||
].filter(Boolean);
|
||
const messageRows = pipelineMessageCardRows(step, textParts, reasoningParts);
|
||
|
||
return h("details", {
|
||
className: `pipeline-opencode-step ${roleTone} ${statusTone} ${matched ? "matched" : ""}`,
|
||
open: matched ? true : undefined,
|
||
"data-testid": "pipeline-opencode-step",
|
||
"data-step-start-ms": stepStartMs !== null ? String(stepStartMs) : "",
|
||
"data-step-end-ms": stepEndMs !== null ? String(stepEndMs) : "",
|
||
},
|
||
h("summary", { className: "pipeline-opencode-step-summary", "data-testid": "pipeline-opencode-step-summary" },
|
||
h(PipelineStepTimeSummary, { step, role: roleTone, matched }),
|
||
h(PipelineStepMessageCard, { rows: messageRows, compact: true, role: roleTone, matched }),
|
||
h(PipelineStepToolSummary, { tools }),
|
||
),
|
||
h("div", { className: "pipeline-step-body", "data-testid": "pipeline-opencode-step-body" },
|
||
h("div", { className: "pipeline-step-factbar" },
|
||
h(StatusBadge, { status: statusTone }, statusTone),
|
||
h("span", { className: "pipeline-step-token-line" }, stepTokenLine(step?.tokens)),
|
||
h(PipelineChipRow, { items: facts }),
|
||
partTypes.length > 0 ? h(PipelineChipRow, { items: partTypes }) : null,
|
||
),
|
||
h(PipelineStepMessageCard, { rows: messageRows, role: roleTone, matched }),
|
||
tools.length > 0 ? h("div", { className: "pipeline-tool-call-strip" },
|
||
h("div", { className: "pipeline-tool-call-title" }, h("b", null, "工具调用"), h("span", null, `${tools.length} calls`)),
|
||
h("div", { className: "pipeline-opencode-part-list" }, tools.map((part: any, index: number) => h(PipelineOpenCodePart, { key: part?.id || `tool-${index}`, part }))),
|
||
) : null,
|
||
otherParts.length > 0 ? h("div", { className: "pipeline-opencode-part-list meta-list" }, otherParts.map((part: any, index: number) => h(PipelineOpenCodePart, { key: part?.id || `part-${index}`, part }))) : null,
|
||
),
|
||
);
|
||
}
|
||
|
||
function structuredEventRecords(value: any): AnyRecord[] {
|
||
return asArray(value).flatMap((item: any) => {
|
||
if (isRecord(item)) return [item];
|
||
const parsed = parseJsonLine(item);
|
||
return parsed ? [parsed] : [];
|
||
});
|
||
}
|
||
|
||
function eventKind(record: any): string {
|
||
return String(record?.event || record?.action || record?.requestedAction || record?.type || "").toLowerCase();
|
||
}
|
||
|
||
function eventTimestampIso(record: any): string {
|
||
return firstIso(record?.timestamp, record?.createdAt, record?.updatedAt, record?.startedAt, record?.finishedAt);
|
||
}
|
||
|
||
function eventTimestampMs(record: any): number | null {
|
||
return timeMs(eventTimestampIso(record));
|
||
}
|
||
|
||
function attemptLabel(attempt: any): string {
|
||
return String(attempt?.attempt || attempt?.id || "");
|
||
}
|
||
|
||
function uniqueStrings(values: any[]): string[] {
|
||
const seen = new Set<string>();
|
||
const ordered: string[] = [];
|
||
for (const value of values) {
|
||
const text = String(value || "");
|
||
if (!text || seen.has(text)) continue;
|
||
seen.add(text);
|
||
ordered.push(text);
|
||
}
|
||
return ordered;
|
||
}
|
||
|
||
function sourceKindLabel(value: any): string {
|
||
switch (String(value || "").toLowerCase()) {
|
||
case "monitor": return "monitor";
|
||
case "webui": return "webui";
|
||
case "cli": return "cli";
|
||
case "system": return "runner";
|
||
default: return String(value || "--");
|
||
}
|
||
}
|
||
|
||
function controlActionValue(record: any): string {
|
||
return String(record?.requestedAction || record?.action || "").toLowerCase();
|
||
}
|
||
|
||
function controlActionLabel(record: any): string {
|
||
switch (controlActionValue(record)) {
|
||
case "guide": return "引导";
|
||
case "modify": return "修改";
|
||
case "approve": return "审核通过";
|
||
case "restart": return "重启";
|
||
case "redo": return "重做";
|
||
default: return String(record?.requestedAction || record?.action || "控制");
|
||
}
|
||
}
|
||
|
||
function eventLabel(record: any): string {
|
||
switch (eventKind(record)) {
|
||
case "initial-prompt-delivered": return "初始 prompt";
|
||
case "append-prompt-delivered": return "追加 prompt";
|
||
case "append-prompt-queued": return "追加 prompt 已排队";
|
||
case "monitor-prompt-delivered": return "Monitor prompt";
|
||
case "node-long-running-observation": return "长任务观察";
|
||
case "node-finished": return "节点完成";
|
||
case "oa-policy-downstream-evaluated": return "OA 下游策略";
|
||
case "control-command-queued": return `${controlActionLabel(record)} 已发起`;
|
||
case "control-command-applied": return `${controlActionLabel(record)} 已生效`;
|
||
case "control-command-ignored": return `${controlActionLabel(record)} 已忽略`;
|
||
default: return String(record?.event || record?.action || record?.requestedAction || "event");
|
||
}
|
||
}
|
||
|
||
function eventPreview(record: any): string {
|
||
return previewText(record?.promptPreview || record?.reasonPreview || record?.prompt || record?.reason || "", 240);
|
||
}
|
||
|
||
function eventTextBlocks(record: any): AnyRecord[] {
|
||
const prompt = String(record?.prompt || "");
|
||
const reason = String(record?.reason || record?.restartReason || "");
|
||
const promptPreview = prompt ? "" : String(record?.promptPreview || "");
|
||
const reasonPreview = reason ? "" : String(record?.reasonPreview || "");
|
||
return [
|
||
prompt || promptPreview ? { label: prompt ? "prompt" : "prompt preview", value: prompt || promptPreview } : null,
|
||
reason || reasonPreview ? { label: reason ? "reason" : "reason preview", value: reason || reasonPreview } : null,
|
||
asArray(record?.resetNodeIds).length > 0 ? { label: "reset nodes", value: asArray(record.resetNodeIds).join(", ") } : null,
|
||
asArray(record?.runningResetNodeIds).length > 0 ? { label: "interrupted running nodes", value: asArray(record.runningResetNodeIds).join(", ") } : null,
|
||
asArray(record?.interruptedProcedureRunIds).length > 0 ? { label: "interrupted procedures", value: asArray(record.interruptedProcedureRunIds).join(", ") } : null,
|
||
record?.interruptedProcedureRunId ? { label: "interrupted procedure", value: String(record.interruptedProcedureRunId) } : null,
|
||
].filter(Boolean) as AnyRecord[];
|
||
}
|
||
|
||
function attemptWindow(attempt: any): AnyRecord {
|
||
const steps = opencodeSteps(attempt);
|
||
const started = steps.map((step: any) => timeMs(step?.createdAt)).filter((value): value is number => value !== null);
|
||
const finished = steps.map((step: any) => timeMs(step?.completedAt) ?? timeMs(step?.createdAt)).filter((value): value is number => value !== null);
|
||
const eventTimes = structuredEventRecords(attempt?.controlEventRecords).map((record) => eventTimestampMs(record)).filter((value): value is number => value !== null);
|
||
const outputTimes = asArray(attempt?.assistantOutputs).map((item: any) => timeMs(item?.updatedAt)).filter((value): value is number => value !== null);
|
||
const startMs = started[0] ?? eventTimes[0] ?? outputTimes[0] ?? null;
|
||
const endMs = finished.at(-1) ?? eventTimes.at(-1) ?? outputTimes.at(-1) ?? startMs;
|
||
return { startMs, endMs };
|
||
}
|
||
|
||
function nearestProcedureForNode(details: any, runId: string, nodeId: string, markerMs: number | null, procedureRunId = ""): AnyRecord | null {
|
||
const procedures = asArray(details?.procedureRuns).filter((procedure: any) => inferProcedureNodeId(procedure, runId) === nodeId);
|
||
if (procedures.length === 0) return null;
|
||
if (procedureRunId) {
|
||
const direct = procedures.find((procedure: any) => procedureRunIdOf(procedure) === procedureRunId);
|
||
if (direct) return direct;
|
||
}
|
||
if (markerMs === null) return procedures.at(-1) || null;
|
||
const containing = procedures.find((procedure: any) => {
|
||
const startMs = timeMs(procedureStartIso(procedure, details));
|
||
const endMs = timeMs(procedureEndIso(procedure, details)) ?? startMs;
|
||
return startMs !== null && endMs !== null && markerMs >= startMs - 1000 && markerMs <= endMs + 1000;
|
||
});
|
||
if (containing) return containing;
|
||
return procedures.slice().sort((left: any, right: any) => {
|
||
const leftStart = timeMs(procedureStartIso(left, details)) ?? markerMs;
|
||
const leftEnd = timeMs(procedureEndIso(left, details)) ?? leftStart;
|
||
const rightStart = timeMs(procedureStartIso(right, details)) ?? markerMs;
|
||
const rightEnd = timeMs(procedureEndIso(right, details)) ?? rightStart;
|
||
const leftDistance = Math.min(Math.abs(leftStart - markerMs), Math.abs(leftEnd - markerMs));
|
||
const rightDistance = Math.min(Math.abs(rightStart - markerMs), Math.abs(rightEnd - markerMs));
|
||
return leftDistance - rightDistance;
|
||
})[0] || null;
|
||
}
|
||
|
||
function nearestAttemptForMarker(procedure: any, marker: any): AnyRecord | null {
|
||
const attempts = asArray(procedure?.attempts).filter(isRecord);
|
||
if (attempts.length === 0) return null;
|
||
const direct = String(marker?.attempt || "");
|
||
if (direct) {
|
||
const matched = attempts.find((attempt: any) => attemptLabel(attempt) === direct);
|
||
if (matched) return matched;
|
||
}
|
||
const markerMs = Number.isFinite(Number(marker?.ms)) ? Number(marker.ms) : null;
|
||
if (markerMs === null) return attempts.at(-1) || null;
|
||
const containing = attempts.find((attempt: any) => {
|
||
const window = attemptWindow(attempt);
|
||
return Number.isFinite(window.startMs) && Number.isFinite(window.endMs) && markerMs >= Number(window.startMs) - 1000 && markerMs <= Number(window.endMs) + 1000;
|
||
});
|
||
if (containing) return containing;
|
||
return attempts.slice().sort((left: any, right: any) => {
|
||
const leftWindow = attemptWindow(left);
|
||
const rightWindow = attemptWindow(right);
|
||
const leftDistance = Math.min(Math.abs(Number(leftWindow.startMs ?? markerMs) - markerMs), Math.abs(Number(leftWindow.endMs ?? markerMs) - markerMs));
|
||
const rightDistance = Math.min(Math.abs(Number(rightWindow.startMs ?? markerMs) - markerMs), Math.abs(Number(rightWindow.endMs ?? markerMs) - markerMs));
|
||
return leftDistance - rightDistance;
|
||
})[0] || attempts.at(-1) || null;
|
||
}
|
||
|
||
function nearestStepForMarker(attempt: any, markerMs: number | null): AnyRecord {
|
||
const steps = opencodeSteps(attempt);
|
||
if (steps.length === 0) return { step: null, stepIndex: -1, stepKey: "" };
|
||
if (markerMs === null) {
|
||
const step = steps[0];
|
||
return { step, stepIndex: 0, stepKey: opencodeStepKey(step, 0) };
|
||
}
|
||
for (let index = 0; index < steps.length; index += 1) {
|
||
const step = steps[index];
|
||
const startMs = timeMs(step?.createdAt) ?? timeMs(step?.completedAt);
|
||
const endMs = timeMs(step?.completedAt) ?? startMs;
|
||
if (startMs !== null && endMs !== null && markerMs >= startMs - 1000 && markerMs <= endMs + 1000) {
|
||
return { step, stepIndex: index, stepKey: opencodeStepKey(step, index) };
|
||
}
|
||
}
|
||
const afterIndex = steps.findIndex((step: any) => {
|
||
const startMs = timeMs(step?.createdAt) ?? timeMs(step?.completedAt);
|
||
return startMs !== null && startMs >= markerMs;
|
||
});
|
||
if (afterIndex >= 0) {
|
||
const step = steps[afterIndex];
|
||
return { step, stepIndex: afterIndex, stepKey: opencodeStepKey(step, afterIndex) };
|
||
}
|
||
const fallbackIndex = Math.max(0, steps.length - 1);
|
||
return { step: steps[fallbackIndex], stepIndex: fallbackIndex, stepKey: opencodeStepKey(steps[fallbackIndex], fallbackIndex) };
|
||
}
|
||
|
||
function pipelineSelectionContext(runDetails: any, selection: any): AnyRecord {
|
||
const runId = String(selection?.runId || runDetails?.runId || "");
|
||
if (String(selection?.mode || "") === "interval") {
|
||
const interval = selection?.interval || {};
|
||
const procedure = findProcedureRun(runDetails, interval) || interval.raw || {};
|
||
return {
|
||
mode: "interval",
|
||
runId,
|
||
interval,
|
||
marker: null,
|
||
nodeId: String(interval?.nodeId || inferProcedureNodeId(procedure, runId) || ""),
|
||
procedure,
|
||
attempt: null,
|
||
matchedStep: null,
|
||
matchedStepIndex: -1,
|
||
matchedStepKey: "",
|
||
};
|
||
}
|
||
const marker = isRecord(selection?.marker) ? selection.marker : {};
|
||
const markerMs = Number.isFinite(Number(marker?.ms)) ? Number(marker.ms) : null;
|
||
const nodeId = String(marker?.nodeId || "");
|
||
const procedure = nodeId ? nearestProcedureForNode(runDetails, runId, nodeId, markerMs, String(marker?.procedureRunId || "")) : null;
|
||
const attempt = procedure ? nearestAttemptForMarker(procedure, marker) : null;
|
||
const matched = attempt ? nearestStepForMarker(attempt, markerMs) : { step: null, stepIndex: -1, stepKey: "" };
|
||
return {
|
||
mode: "event",
|
||
runId,
|
||
interval: null,
|
||
marker,
|
||
nodeId,
|
||
procedure,
|
||
attempt,
|
||
matchedStep: matched.step,
|
||
matchedStepIndex: matched.stepIndex,
|
||
matchedStepKey: matched.stepKey,
|
||
};
|
||
}
|
||
|
||
function PipelineProcedureAttemptList({ procedure, matchedStepKey = "", matchedAttemptId = "" }: AnyRecord) {
|
||
const attempts = asArray(procedure?.attempts);
|
||
if (attempts.length === 0) return h(EmptyState, { title: "暂无 attempt 详情", text: "后端还未返回该 procedure 的 attempt / opencodeMessages。" });
|
||
return 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);
|
||
const sessionFacts = pipelineSessionFacts(steps, sessionIds);
|
||
const currentAttemptId = attemptLabel(attempt) || `attempt-${attemptIndex + 1}`;
|
||
const failedToolCount = steps.reduce((sum, step: any) => sum + asArray(step?.parts).filter((part: any) =>
|
||
String(part?.type || "").toLowerCase() === "tool" && partStatusTone(part) === "failed").length, 0);
|
||
return h("article", { key: currentAttemptId, className: `pipeline-attempt-card ${matchedAttemptId === currentAttemptId ? "matched" : ""}` },
|
||
h("div", { className: "pipeline-attempt-head" },
|
||
h("div", null,
|
||
h("strong", null, currentAttemptId),
|
||
h("span", null, messages.source || "opencode"),
|
||
),
|
||
h("div", { className: "pipeline-attempt-badges" },
|
||
h("span", null, `${steps.length} steps`),
|
||
h("span", null, `${messages.toolCallCount ?? "--"} tools`),
|
||
failedToolCount > 0 ? h("span", { className: "danger" }, `${failedToolCount} failed`) : null,
|
||
),
|
||
),
|
||
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-control 已重建并重新抓取。") :
|
||
h("section", { className: "pipeline-opencode-timeline", "data-testid": "pipeline-step-timeline" },
|
||
h("div", { className: "pipeline-opencode-timeline-head" },
|
||
h("div", null,
|
||
h("b", null, "OpenCode Step Timeline"),
|
||
h("span", null, "step 外层只保留时间 / 消息 / 工具调用;agent 与 model 聚合到 session 头部,统计信息默认折叠。"),
|
||
),
|
||
h("div", { className: "pipeline-opencode-session-head", "data-testid": "pipeline-step-timeline-session" },
|
||
h("span", null, `${steps.length} steps / ${sessionIds.length} sessions`),
|
||
sessionFacts.length > 0 ? h(PipelineChipRow, { items: sessionFacts }) : null,
|
||
),
|
||
),
|
||
h("div", { className: "pipeline-opencode-flow" }, steps.map((step: any, stepIndex: number) => {
|
||
const stepKey = opencodeStepKey(step, stepIndex);
|
||
return h(PipelineOpenCodeStep, { key: stepKey, step, matched: matchedStepKey === stepKey });
|
||
})),
|
||
),
|
||
);
|
||
});
|
||
}
|
||
|
||
function pipelineGanttDetailPayload(baseDetails: any, nodeDetails: any, runId: string, nodeId: string): any {
|
||
if (!isRecord(nodeDetails) || String(nodeDetails.runId || "") !== runId || String(nodeDetails.nodeId || "") !== nodeId) return baseDetails;
|
||
const nodeProcedures = asArray(nodeDetails.procedureRuns);
|
||
const base = isRecord(baseDetails) ? baseDetails : {};
|
||
return {
|
||
...base,
|
||
...nodeDetails,
|
||
// Keep run-level evidence unless the node endpoint has a more focused copy.
|
||
controlCommands: asArray(nodeDetails.controlCommands).length > 0 ? nodeDetails.controlCommands : base.controlCommands,
|
||
controlEvents: asArray(nodeDetails.controlEvents).length > 0 ? nodeDetails.controlEvents : base.controlEvents,
|
||
procedureRuns: nodeProcedures.length > 0 ? nodeProcedures : base.procedureRuns,
|
||
};
|
||
}
|
||
|
||
function PipelineGanttDetailPanel({ selection, runDetails, nodeDetails, onRaw }: AnyRecord) {
|
||
if (!selection?.mode) {
|
||
return h("aside", { className: "pipeline-gantt-detail-panel empty", "data-testid": "pipeline-gantt-detail-panel" },
|
||
h(EmptyState, { title: "选择一条执行线或一个控制点", text: "点击甘特图中的 node 执行线、prompt 点或控制点,在这里查看结构化过程和 OpenCode step。" }),
|
||
);
|
||
}
|
||
const runId = String(selection?.runId || "");
|
||
const selectedNodeId = String(selection?.interval?.nodeId || selection?.marker?.nodeId || "");
|
||
const baseDetails = runDetails?.runId === runId ? runDetails.details : null;
|
||
const details = pipelineGanttDetailPayload(baseDetails, nodeDetails, runId, selectedNodeId);
|
||
const loading = (String(runDetails?.runId || "") !== runId || Boolean(runDetails?.loading)) && !details;
|
||
const error = String(runDetails?.runId || "") === runId ? String(runDetails?.error || "") : "";
|
||
const context = details ? pipelineSelectionContext(details, selection) : null;
|
||
const interval = context?.interval || selection?.interval || null;
|
||
const marker = context?.marker || selection?.marker || null;
|
||
const procedure = context?.procedure || (details ? findProcedureRun(details, interval) : null) || interval?.raw || {};
|
||
const matchedAttemptId = attemptLabel(context?.attempt);
|
||
const matchedStepKey = String(context?.matchedStepKey || "");
|
||
const status = statusValue(procedure?.status || interval?.status || marker?.status || marker?.event);
|
||
const detailTitle = selection?.mode === "event" ? (marker?.label || eventLabel(marker?.raw || marker) || "event") : (context?.nodeId || interval?.nodeId || "node");
|
||
const markerBlocks = marker ? eventTextBlocks(marker?.raw || marker) : [];
|
||
const eventFacts = marker ? [
|
||
eventKind(marker?.raw || marker) ? `event ${eventKind(marker?.raw || marker)}` : "",
|
||
marker?.promptEvent ? `prompt ${marker.promptEvent}` : "",
|
||
marker?.action ? `action ${marker.action}` : "",
|
||
marker?.sourceKind ? `source ${sourceKindLabel(marker.sourceKind)}` : "",
|
||
marker?.sourceNodeId ? `from ${marker.sourceNodeId}` : "",
|
||
marker?.targetNodeId ? `to ${marker.targetNodeId}` : "",
|
||
marker?.snapReason ? `draw ${marker.snapReason}` : "",
|
||
].filter(Boolean) : [];
|
||
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" }, selection?.mode === "event" ? "Gantt Event Detail" : "Gantt Line Detail"),
|
||
h("h3", null, detailTitle),
|
||
),
|
||
h(StatusBadge, { status }, status),
|
||
),
|
||
marker ? h("article", { className: "pipeline-event-card" },
|
||
h("div", { className: "pipeline-event-card-head" },
|
||
h("strong", null, marker?.label || eventLabel(marker?.raw || marker)),
|
||
h(PipelineChipRow, { items: eventFacts }),
|
||
),
|
||
h(PipelineKvGrid, { items: [
|
||
{ label: "event time", value: fmtDate(marker?.timestampIso || marker?.timestamp || "--") },
|
||
marker?.snapped ? { label: "drawn time", value: fmtDate(marker?.renderedTimestampIso || marker?.ms) } : null,
|
||
{ label: "node", value: marker?.nodeId || "--" },
|
||
{ label: "procedure", value: marker?.procedureRunId || procedureRunIdOf(procedure) || "--" },
|
||
{ label: "attempt", value: marker?.attempt || matchedAttemptId || "--" },
|
||
{ label: "source kind", value: marker?.sourceKind ? sourceKindLabel(marker.sourceKind) : "--" },
|
||
{ label: "source node", value: marker?.sourceNodeId || "--" },
|
||
{ label: "target node", value: marker?.targetNodeId || "--" },
|
||
{ label: "command", value: marker?.commandId || marker?.eventId || "--" },
|
||
marker?.snapReason ? { label: "placement", value: marker.snapReason } : null,
|
||
] }),
|
||
markerBlocks.length > 0 ? h("div", { className: "pipeline-event-blocks" },
|
||
markerBlocks.map((block: AnyRecord, index: number) => h("section", { key: `${block.label}-${index}`, className: "pipeline-event-text-block" },
|
||
h("b", null, block.label),
|
||
h("p", null, block.value),
|
||
)),
|
||
) : null,
|
||
eventPreview(marker?.raw || marker) ? h("p", { className: "pipeline-text-preview" }, eventPreview(marker?.raw || marker)) : null,
|
||
) : null,
|
||
h(PipelineKvGrid, { items: [
|
||
{ label: "epoch", value: runId || interval?.runId || "--" },
|
||
{ label: "node", value: context?.nodeId || interval?.nodeId || marker?.nodeId || "--" },
|
||
{ label: "procedure", value: interval?.procedureRunId || marker?.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: runDetails?.fetchedAt ? fmtClock(runDetails.fetchedAt) : "--" },
|
||
context?.matchedStep ? { label: "matched step", value: `Step ${context.matchedStep.index ?? context.matchedStepIndex + 1}` } : null,
|
||
] }),
|
||
loading ? h("div", { className: "form-success" }, "正在抓取 epoch 执行过程..." ) : null,
|
||
error ? h("div", { className: "form-error" }, error) : null,
|
||
h("div", { className: "pipeline-gantt-detail-actions" },
|
||
h(RawButton, { title: `Procedure ${interval?.procedureRunId || marker?.procedureRunId || context?.nodeId || "node"}`, data: procedure, onOpen: onRaw, testId: "raw-pipeline-gantt-procedure" }),
|
||
marker ? h(RawButton, { title: `Pipeline event ${marker?.id || marker?.commandId || marker?.eventId || context?.nodeId || "event"}`, data: marker?.raw || marker, onOpen: onRaw, testId: "raw-pipeline-gantt-event" }) : null,
|
||
details ? h(RawButton, { title: `Pipeline run ${runId || "--"}`, data: details, onOpen: onRaw, testId: "raw-pipeline-gantt-node-details" }) : null,
|
||
),
|
||
!loading && !procedureRunIdOf(procedure) && !marker ? h(EmptyState, { title: "暂无过程详情", text: "当前选择还没有可匹配的 procedure 运行记录。" }) : null,
|
||
!loading && procedureRunIdOf(procedure) ? h(PipelineProcedureAttemptList, { procedure, matchedStepKey, matchedAttemptId }) : null,
|
||
);
|
||
}
|
||
|
||
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");
|
||
const response = await fetch(path, { credentials: "same-origin", ...options, headers });
|
||
const text = await response.text();
|
||
let body = null;
|
||
try {
|
||
body = text ? JSON.parse(text) : null;
|
||
} catch {
|
||
body = { text };
|
||
}
|
||
if (!response.ok || body?.ok === false) {
|
||
const message = body?.error?.message || body?.error || `HTTP ${response.status}`;
|
||
const error = new Error(message);
|
||
(error as Error & { status?: number }).status = response.status;
|
||
throw error;
|
||
}
|
||
return body;
|
||
}
|
||
|
||
function StatusBadge({ status, children }: AnyRecord) {
|
||
const normalized = String(status || "unknown").toLowerCase();
|
||
return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown");
|
||
}
|
||
|
||
function MetricCard({ label, value, hint, tone }: AnyRecord) {
|
||
return h("article", { className: `metric-card ${tone || ""}` },
|
||
h("div", { className: "metric-label" }, label),
|
||
h("div", { className: "metric-value" }, value),
|
||
h("div", { className: "metric-hint" }, hint),
|
||
);
|
||
}
|
||
|
||
function Panel({ title, eyebrow, actions, children, className }: AnyRecord) {
|
||
return h("section", { className: `panel ${className || ""}` },
|
||
h("div", { className: "panel-head" },
|
||
h("div", null,
|
||
eyebrow ? h("p", { className: "panel-eyebrow" }, eyebrow) : null,
|
||
h("h2", null, title),
|
||
),
|
||
actions ? h("div", { className: "panel-actions" }, actions) : null,
|
||
),
|
||
h("div", { className: "panel-body" }, children),
|
||
);
|
||
}
|
||
|
||
function RawButton({ title, data, onOpen, testId }: AnyRecord) {
|
||
return h("button", {
|
||
type: "button",
|
||
className: "ghost-btn",
|
||
"data-testid": testId,
|
||
onClick: () => onOpen(title, data),
|
||
}, "查看原始JSON");
|
||
}
|
||
|
||
function 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));
|
||
}
|
||
|
||
function microserviceRuntime(service: any): AnyRecord {
|
||
return service?.runtime && typeof service.runtime === "object" && !Array.isArray(service.runtime) ? service.runtime : {};
|
||
}
|
||
|
||
function microserviceBackend(service: any): AnyRecord {
|
||
return service?.backend && typeof service.backend === "object" && !Array.isArray(service.backend) ? service.backend : {};
|
||
}
|
||
|
||
function microserviceRepository(service: any): AnyRecord {
|
||
return service?.repository && typeof service.repository === "object" && !Array.isArray(service.repository) ? service.repository : {};
|
||
}
|
||
|
||
function pipelineSnapshotArrays(snapshot: any): { components: any[]; pipelines: any[]; runs: any[] } {
|
||
return {
|
||
components: Array.isArray(snapshot?.registry?.components) ? snapshot.registry.components : [],
|
||
pipelines: Array.isArray(snapshot?.pipelines) ? snapshot.pipelines : [],
|
||
runs: Array.isArray(snapshot?.runs) ? snapshot.runs : [],
|
||
};
|
||
}
|
||
|
||
function pipelineArrayCount(snapshot: any, path: string, fallback: number): number {
|
||
const meta = snapshot?._unidesk?.arrayLimits?.[path];
|
||
const original = Number(meta?.originalLength);
|
||
return Number.isFinite(original) ? original : fallback;
|
||
}
|
||
|
||
function pipelineComponentRef(value: any): string {
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) return "--";
|
||
return `${value.componentClass || "--"}/${value.id || "--"}`;
|
||
}
|
||
|
||
function pipelineComponentRefKey(value: any): string {
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
|
||
const componentClass = String(value.componentClass || "").trim();
|
||
const id = String(value.id || "").trim();
|
||
return componentClass && id ? `${componentClass}/${id}` : "";
|
||
}
|
||
|
||
function pipelineConfigObject(pipeline: any): AnyRecord {
|
||
return pipeline?.config && typeof pipeline.config === "object" && !Array.isArray(pipeline.config) ? pipeline.config : {};
|
||
}
|
||
|
||
function pipelineConfigNodes(pipeline: any): AnyRecord[] {
|
||
const config = pipelineConfigObject(pipeline);
|
||
const rawNodes = Array.isArray(config.nodes) ? config.nodes : Array.isArray(pipeline?.nodes) ? pipeline.nodes : [];
|
||
const nodeById: Map<string, AnyRecord> = new Map();
|
||
for (const node of rawNodes) {
|
||
const id = String(node?.id || node?.nodeId || "");
|
||
if (id) nodeById.set(id, { ...node, id });
|
||
}
|
||
const edges = pipelineConfigEdges(pipeline);
|
||
const addMissing = (id: string) => {
|
||
if (id && !nodeById.has(id)) nodeById.set(id, { id });
|
||
};
|
||
for (const batch of pipelineGraphBatches(pipeline)) pipelineRawNodeIds(batch).forEach(addMissing);
|
||
for (const edge of edges) {
|
||
addMissing(String(edge?.from || edge?.source || ""));
|
||
addMissing(String(edge?.to || edge?.target || ""));
|
||
}
|
||
return Array.from(nodeById.values());
|
||
}
|
||
|
||
function pipelineConfigEdges(pipeline: any): AnyRecord[] {
|
||
const config = pipelineConfigObject(pipeline);
|
||
return Array.isArray(config.edges) ? config.edges : Array.isArray(pipeline?.edges) ? pipeline.edges : [];
|
||
}
|
||
|
||
function pipelineGraphBatches(pipeline: any): any[] {
|
||
const config = pipelineConfigObject(pipeline);
|
||
return Array.isArray(config.topologicalBatches) ? config.topologicalBatches : Array.isArray(pipeline?.topologicalBatches) ? pipeline.topologicalBatches : [];
|
||
}
|
||
|
||
function pipelineComponentLookup(components: any[]): Map<string, AnyRecord> {
|
||
const byRef: Map<string, AnyRecord> = new Map();
|
||
for (const component of components) {
|
||
const directKey = pipelineComponentRefKey(component);
|
||
if (directKey) byRef.set(directKey, component);
|
||
const refs = Array.isArray(component?.refs) ? component.refs : [];
|
||
for (const ref of refs) {
|
||
const refKey = pipelineComponentRefKey(ref);
|
||
if (refKey) byRef.set(refKey, component);
|
||
}
|
||
}
|
||
return byRef;
|
||
}
|
||
|
||
function pipelineNodeComponent(node: any, componentByRef: Map<string, AnyRecord>): AnyRecord | null {
|
||
const explicit = componentByRef.get(pipelineComponentRefKey(node?.componentRef));
|
||
if (explicit) return explicit;
|
||
const fallbackKey = pipelineComponentRefKey({ componentClass: node?.kind, id: node?.id });
|
||
return fallbackKey ? componentByRef.get(fallbackKey) || null : null;
|
||
}
|
||
|
||
function pipelineRunNodeStatus(run: any, nodeId: string): string {
|
||
const node = pipelineRunNodeRecord(run, nodeId);
|
||
return String(node?.status || "pending");
|
||
}
|
||
|
||
function pipelineRunNodeRecord(run: any, nodeId: string): AnyRecord | null {
|
||
const nodes = Array.isArray(run?.nodes) ? run.nodes : [];
|
||
return nodes.find((item: any) => item?.nodeId === nodeId || item?.id === nodeId) || null;
|
||
}
|
||
|
||
function pipelineStatusCounts(runs: any[]): AnyRecord {
|
||
return runs.reduce((counts: AnyRecord, run: any) => {
|
||
const status = String(run?.status || "unknown").toLowerCase();
|
||
counts[status] = (counts[status] || 0) + 1;
|
||
return counts;
|
||
}, {});
|
||
}
|
||
|
||
function pipelineRunScorers(run: any): AnyRecord[] {
|
||
if (Array.isArray(run?.scorers)) return run.scorers.filter(isRecord);
|
||
if (Array.isArray(run?.summary?.scorers)) return run.summary.scorers.filter(isRecord);
|
||
if (Array.isArray(run?.artifact?.summary?.scorers)) return run.artifact.summary.scorers.filter(isRecord);
|
||
return [];
|
||
}
|
||
|
||
function pipelineDetailedRun(details: any): AnyRecord | null {
|
||
if (isRecord(details?.run)) return details.run;
|
||
if (isRecord(details?.runSummary)) return details.runSummary;
|
||
return null;
|
||
}
|
||
|
||
function mergePipelineRunSummary(base: any, detail: any): AnyRecord | null {
|
||
if (!isRecord(base) && !isRecord(detail)) return null;
|
||
if (!isRecord(base)) return detail;
|
||
if (!isRecord(detail)) return base;
|
||
return {
|
||
...base,
|
||
...detail,
|
||
request: isRecord(base.request) || isRecord(detail.request) ? { ...(isRecord(base.request) ? base.request : {}), ...(isRecord(detail.request) ? detail.request : {}) } : detail.request ?? base.request,
|
||
artifact: isRecord(base.artifact) || isRecord(detail.artifact) ? { ...(isRecord(base.artifact) ? base.artifact : {}), ...(isRecord(detail.artifact) ? detail.artifact : {}) } : detail.artifact ?? base.artifact,
|
||
summary: isRecord(base.summary) || isRecord(detail.summary) ? { ...(isRecord(base.summary) ? base.summary : {}), ...(isRecord(detail.summary) ? detail.summary : {}) } : detail.summary ?? base.summary,
|
||
};
|
||
}
|
||
|
||
function pipelineScoreSummary(run: any): AnyRecord {
|
||
const scorers = pipelineRunScorers(run);
|
||
const primary = scorers.find((scorer) => isRecord(scorer?.score)) || scorers[0] || null;
|
||
const score = isRecord(primary?.score) ? primary.score : {};
|
||
const passed = Number(score.passed);
|
||
const total = Number(score.total);
|
||
const ratio = Number(score.ratio);
|
||
const computedRatio = Number.isFinite(ratio)
|
||
? ratio
|
||
: Number.isFinite(passed) && Number.isFinite(total) && total > 0 ? passed / total : null;
|
||
const percent = computedRatio === null ? null : Math.round(Math.max(0, Math.min(100, computedRatio <= 1 ? computedRatio * 100 : computedRatio)));
|
||
const text = String(score.text || (Number.isFinite(passed) && Number.isFinite(total) ? `${passed}/${total}` : ""));
|
||
return {
|
||
scorer: primary,
|
||
scorers,
|
||
score,
|
||
passed: Number.isFinite(passed) ? passed : null,
|
||
total: Number.isFinite(total) ? total : null,
|
||
percent,
|
||
text,
|
||
};
|
||
}
|
||
|
||
function pipelineScoreText(run: any): string {
|
||
const summary = pipelineScoreSummary(run);
|
||
return summary.text || (summary.scorers.length > 0 ? String(summary.scorer?.status || "pending") : "--");
|
||
}
|
||
|
||
function pipelineScoreTone(run: any): string {
|
||
const summary = pipelineScoreSummary(run);
|
||
if (summary.total > 0 && summary.passed === summary.total) return "succeeded";
|
||
if (summary.total > 0 && summary.passed > 0) return "running";
|
||
if (summary.scorers.length > 0) return "failed";
|
||
return "pending";
|
||
}
|
||
|
||
function pipelineScorerItems(scorer: any): AnyRecord[] {
|
||
return Array.isArray(scorer?.items) ? scorer.items.filter(isRecord) : [];
|
||
}
|
||
|
||
function PipelineScoreBadge({ run }: AnyRecord) {
|
||
const text = pipelineScoreText(run);
|
||
return h("span", { className: `pipeline-score-badge ${pipelineScoreTone(run)}` }, `score ${text}`);
|
||
}
|
||
|
||
function PipelineScoreBoard({ run, onRaw }: AnyRecord) {
|
||
const summary = pipelineScoreSummary(run);
|
||
const scorers = summary.scorers;
|
||
if (!run) return h(EmptyState, { title: "暂无评分", text: "选择一个 epoch 后会显示 scorer 结果。" });
|
||
if (scorers.length === 0) return h("div", { className: "pipeline-score-empty" },
|
||
h("strong", null, "评分器等待中"),
|
||
h("span", null, "DAG 完成后,Pipeline control backend 会把 scorer summary 追加到 run artifact,并通过 UniDesk 显示。"),
|
||
);
|
||
return h("div", { className: "pipeline-score-board", "data-testid": "pipeline-score-board" },
|
||
scorers.map((scorer: AnyRecord, index: number) => {
|
||
const localSummary = pipelineScoreSummary({ scorers: [scorer] });
|
||
const items = pipelineScorerItems(scorer);
|
||
const percent = localSummary.percent ?? 0;
|
||
return h("article", { key: `${scorer.scorerId || scorer.component || index}`, className: `pipeline-score-card ${pipelineScoreTone({ scorers: [scorer] })}` },
|
||
h("div", { className: "pipeline-score-head" },
|
||
h("div", null,
|
||
h("span", null, scorer.scorerId || scorer.component || "scorer"),
|
||
h("strong", null, localSummary.text || scorer.status || "--"),
|
||
),
|
||
h(StatusBadge, { status: scorer.status || "unknown" }, scorer.status || "unknown"),
|
||
),
|
||
h("div", { className: "pipeline-score-meter", "aria-label": `score ${percent}%` }, h("span", { style: { width: `${percent}%` } })),
|
||
h("div", { className: "pipeline-score-facts" },
|
||
h("span", null, `${percent}%`),
|
||
h("span", null, scorer.component || "--"),
|
||
h("span", null, scorer.applicationCheckoutRef || "--"),
|
||
),
|
||
items.length > 0 ? h("div", { className: "pipeline-score-items" }, items.map((item: AnyRecord) => h("span", {
|
||
key: `${item.id || item.filter}`,
|
||
className: `pipeline-score-item ${String(item.status || "").toLowerCase()}`,
|
||
title: `${item.filter || "--"} / ran=${item.ran ?? "?"}`,
|
||
}, h("b", null, item.id || "--"), h("small", null, item.status || "--")))) : h("p", { className: "muted paragraph" }, "当前 scorer 尚未返回 item 级结果。"),
|
||
scorer.error ? h("p", { className: "pipeline-score-error" }, previewText(scorer.error, 360)) : null,
|
||
h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: `Scorer ${scorer.scorerId || index}`, data: scorer, onOpen: onRaw, testId: "raw-pipeline-score" })),
|
||
);
|
||
}),
|
||
);
|
||
}
|
||
|
||
function pipelineComponentClassCounts(components: any[]): Array<{ name: string; count: number }> {
|
||
const counts = components.reduce((memo: AnyRecord, component: any) => {
|
||
const name = String(component?.componentClass || "unknown");
|
||
memo[name] = (memo[name] || 0) + 1;
|
||
return memo;
|
||
}, {});
|
||
return Object.entries(counts).map(([name, count]) => ({ name, count: Number(count) })).sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
|
||
}
|
||
|
||
function pipelineRawNodeIds(value: any): string[] {
|
||
if (Array.isArray(value)) return value.map((item) => typeof item === "string" ? item : String(item?.id || item?.nodeId || "")).filter(Boolean);
|
||
if (Array.isArray(value?.nodes)) return pipelineRawNodeIds(value.nodes);
|
||
if (Array.isArray(value?.nodeIds)) return pipelineRawNodeIds(value.nodeIds);
|
||
return [];
|
||
}
|
||
|
||
function pipelineMonitorInputs(node: any): AnyRecord {
|
||
return isRecord(node?.instanceInputs?.monitor) ? node.instanceInputs.monitor : {};
|
||
}
|
||
|
||
function pipelineNodeIsMonitor(node: any, component?: any): boolean {
|
||
if (String(node?.kind || "").toLowerCase() !== "procedure") return false;
|
||
const monitorInputs = pipelineMonitorInputs(node);
|
||
if (node?.instanceInputs?.monitorMode === true || monitorInputs.enabled === true) return true;
|
||
const componentRef = pipelineComponentRef(node?.componentRef);
|
||
return String(component?.id || component?.config?.id || componentRef || "").toLowerCase().includes("monitor");
|
||
}
|
||
|
||
function pipelineMonitorNodeIds(pipelineNodes: AnyRecord[]): string[] {
|
||
return pipelineNodes
|
||
.filter((node: any) => pipelineNodeIsMonitor(node))
|
||
.map((node: any) => String(node?.id || ""))
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function pipelineOrderWithLeadingMonitors(nodeIds: string[], monitorNodeIds: string[]): string[] {
|
||
if (monitorNodeIds.length === 0) return nodeIds;
|
||
const monitorSet = new Set(monitorNodeIds);
|
||
const leading = monitorNodeIds.filter((nodeId) => nodeIds.includes(nodeId));
|
||
if (leading.length === 0) return nodeIds;
|
||
return [...leading, ...nodeIds.filter((nodeId) => !monitorSet.has(nodeId))];
|
||
}
|
||
|
||
function pipelineColumnsWithLeadingMonitors(columns: string[][], monitorNodeIds: string[]): string[][] {
|
||
if (monitorNodeIds.length === 0) return columns;
|
||
const monitorSet = new Set(monitorNodeIds);
|
||
const leading = monitorNodeIds.filter((nodeId) => columns.some((column) => column.includes(nodeId)));
|
||
if (leading.length === 0) return columns;
|
||
const remaining = columns
|
||
.map((column) => column.filter((nodeId) => !monitorSet.has(nodeId)))
|
||
.filter((column) => column.length > 0);
|
||
return [leading, ...remaining];
|
||
}
|
||
|
||
function pipelineGraphColumns(pipeline: any, pipelineNodes: AnyRecord[], pipelineEdges: AnyRecord[]): string[][] {
|
||
const rawBatches = pipelineGraphBatches(pipeline);
|
||
const explicit = rawBatches.map(pipelineRawNodeIds).filter((batch: string[]) => batch.length > 0);
|
||
if (explicit.length > 0) return explicit;
|
||
|
||
const ids: string[] = pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean);
|
||
const idSet = new Set(ids);
|
||
const incoming: Map<string, number> = new Map(ids.map((id) => [id, 0]));
|
||
const outgoing: Map<string, string[]> = new Map(ids.map((id) => [id, []]));
|
||
for (const edge of pipelineEdges) {
|
||
const from = String(edge?.from || edge?.source || "");
|
||
const to = String(edge?.to || edge?.target || "");
|
||
if (!idSet.has(from) || !idSet.has(to)) continue;
|
||
outgoing.get(from)?.push(to);
|
||
incoming.set(to, (incoming.get(to) || 0) + 1);
|
||
}
|
||
const levels = new Map<string, number>();
|
||
const queue = ids.filter((id) => (incoming.get(id) || 0) === 0);
|
||
for (const id of queue) levels.set(id, 0);
|
||
while (queue.length > 0) {
|
||
const current = queue.shift()!;
|
||
const nextLevel = (levels.get(current) || 0) + 1;
|
||
for (const next of outgoing.get(current) || []) {
|
||
incoming.set(next, Math.max(0, (incoming.get(next) || 0) - 1));
|
||
levels.set(next, Math.max(levels.get(next) || 0, nextLevel));
|
||
if ((incoming.get(next) || 0) === 0) queue.push(next);
|
||
}
|
||
}
|
||
ids.forEach((id) => { if (!levels.has(id)) levels.set(id, 0); });
|
||
const maxLevel = Math.max(0, ...Array.from(levels.values()));
|
||
return Array.from({ length: maxLevel + 1 }, (_, level) => ids.filter((id) => levels.get(id) === level)).filter((batch) => batch.length > 0);
|
||
}
|
||
|
||
function pipelineGraphNodeOrder(pipeline: any, pipelineNodes: AnyRecord[], pipelineEdges: AnyRecord[]): string[] {
|
||
const rawBatches = pipelineGraphBatches(pipeline);
|
||
const explicit = rawBatches.map(pipelineRawNodeIds).filter((batch: string[]) => batch.length > 0);
|
||
const ordered = explicit.length > 0 ? explicit.flatMap((batch) => batch) : (() => {
|
||
const ids = pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean);
|
||
const idSet = new Set(ids);
|
||
const forwardEdges = pipelineEdges.filter((edge: AnyRecord) => String(edge?.edgeType || "").toLowerCase() !== "rework");
|
||
const incoming = new Map(ids.map((id) => [id, 0]));
|
||
const outgoing = new Map(ids.map((id) => [id, [] as string[]]));
|
||
for (const edge of forwardEdges) {
|
||
const from = String(edge?.from || edge?.source || "");
|
||
const to = String(edge?.to || edge?.target || "");
|
||
if (!idSet.has(from) || !idSet.has(to)) continue;
|
||
outgoing.get(from)?.push(to);
|
||
incoming.set(to, (incoming.get(to) || 0) + 1);
|
||
}
|
||
const levels = new Map<string, number>();
|
||
const queue = ids.filter((id) => (incoming.get(id) || 0) === 0);
|
||
for (const id of queue) levels.set(id, 0);
|
||
while (queue.length > 0) {
|
||
const current = queue.shift()!;
|
||
const nextLevel = (levels.get(current) || 0) + 1;
|
||
for (const next of outgoing.get(current) || []) {
|
||
incoming.set(next, Math.max(0, (incoming.get(next) || 0) - 1));
|
||
levels.set(next, Math.max(levels.get(next) || 0, nextLevel));
|
||
if ((incoming.get(next) || 0) === 0) queue.push(next);
|
||
}
|
||
}
|
||
ids.forEach((id) => { if (!levels.has(id)) levels.set(id, 0); });
|
||
const maxLevel = Math.max(0, ...Array.from(levels.values()));
|
||
return Array.from({ length: maxLevel + 1 }, (_, level) => ids.filter((id) => levels.get(id) === level)).flatMap((batch) => batch);
|
||
})();
|
||
const seen = new Set(ordered);
|
||
for (const node of pipelineNodes) {
|
||
const nodeId = String(node?.id || "");
|
||
if (!nodeId || seen.has(nodeId)) continue;
|
||
ordered.push(nodeId);
|
||
seen.add(nodeId);
|
||
}
|
||
return pipelineOrderWithLeadingMonitors(ordered, pipelineMonitorNodeIds(pipelineNodes));
|
||
}
|
||
|
||
function pipelineFlowEdgeKey(edge: AnyRecord): string {
|
||
return `${edge.source}->${edge.target}-${edge.index}`;
|
||
}
|
||
|
||
function pipelineFlowElements(pipeline: any, latestRun: any, components: any[]): { nodes: Node[]; edges: Edge[] } {
|
||
const pipelineNodes = pipelineConfigNodes(pipeline);
|
||
const pipelineEdges = pipelineConfigEdges(pipeline);
|
||
const componentByRef = pipelineComponentLookup(components);
|
||
const nodeById: Map<string, AnyRecord> = new Map(pipelineNodes.map((node: any) => [String(node?.id || ""), node]));
|
||
const monitorNodeIds = pipelineNodes
|
||
.filter((node: any) => pipelineNodeIsMonitor(node, pipelineNodeComponent(node, componentByRef)))
|
||
.map((node: any) => String(node?.id || ""))
|
||
.filter(Boolean);
|
||
const columns = pipelineColumnsWithLeadingMonitors(pipelineGraphColumns(pipeline, pipelineNodes, pipelineEdges), monitorNodeIds);
|
||
const flowNodes: Node[] = [];
|
||
const nodeLayout: Map<string, { column: number; row: number; y: number }> = new Map();
|
||
const columnGap = 330;
|
||
const rowGap = 122;
|
||
columns.forEach((batch, columnIndex) => {
|
||
const columnHeight = batch.length * rowGap;
|
||
batch.forEach((nodeId, rowIndex) => {
|
||
const node = nodeById.get(nodeId) || { id: nodeId };
|
||
const component = pipelineNodeComponent(node, componentByRef);
|
||
const status = pipelineRunNodeStatus(latestRun, nodeId).toLowerCase();
|
||
const kind = String(node.kind || component?.componentClass || "node").toLowerCase();
|
||
const componentRef = pipelineComponentRef(node.componentRef || component);
|
||
const componentVersion = String(component?.config?.version || component?.version || "");
|
||
const componentDescription = String(component?.config?.description || component?.description || "");
|
||
const y = rowIndex * rowGap - Math.floor(columnHeight / 2);
|
||
nodeLayout.set(nodeId, { column: columnIndex, row: rowIndex, y });
|
||
flowNodes.push({
|
||
id: nodeId,
|
||
type: "pipelineNode",
|
||
position: {
|
||
x: columnIndex * columnGap,
|
||
y,
|
||
},
|
||
data: {
|
||
exportLabel: {
|
||
id: nodeId,
|
||
kind,
|
||
componentRef,
|
||
componentVersion,
|
||
componentDescription,
|
||
status,
|
||
},
|
||
label: h("div", { className: "flow-node-label" },
|
||
h("strong", null, nodeId),
|
||
h("span", null, kind),
|
||
h("code", { title: componentDescription || componentRef }, componentVersion ? `${componentRef}@${componentVersion}` : componentRef),
|
||
h(StatusBadge, { status }, status),
|
||
),
|
||
},
|
||
className: `pipeline-flow-node ${kind} ${status}`,
|
||
});
|
||
});
|
||
});
|
||
const graphEdges = pipelineEdges.flatMap((edge: any, index: number) => {
|
||
const source = String(edge?.from || edge?.source || "");
|
||
const target = String(edge?.to || edge?.target || "");
|
||
if (!nodeById.has(source) || !nodeById.has(target)) return [];
|
||
return [{ source, target, index, condition: edge?.condition, edgeType: edge?.edgeType }];
|
||
});
|
||
const sourceTotals = graphEdges.reduce((memo: Map<string, number>, edge: AnyRecord) => memo.set(edge.source, (memo.get(edge.source) || 0) + 1), new Map<string, number>());
|
||
const targetTotals = graphEdges.reduce((memo: Map<string, number>, edge: AnyRecord) => memo.set(edge.target, (memo.get(edge.target) || 0) + 1), new Map<string, number>());
|
||
const pairTotals = graphEdges.reduce((memo: Map<string, number>, edge: AnyRecord) => {
|
||
const key = `${edge.source}->${edge.target}`;
|
||
return memo.set(key, (memo.get(key) || 0) + 1);
|
||
}, new Map<string, number>());
|
||
const sourceSeen = new Map<string, number>();
|
||
const targetSeen = new Map<string, number>();
|
||
const pairSeen = new Map<string, number>();
|
||
const targetHandleByEdge = new Map<string, AnyRecord>();
|
||
const targetPortLoads: Map<string, Map<string, number>> = new Map();
|
||
const adjacentForwardFanOutByEdge = new Map<string, { slot: number; count: number }>();
|
||
const adjacentForwardClusters = graphEdges.reduce((memo: Map<string, AnyRecord[]>, edge: AnyRecord) => {
|
||
const sourceLayout = nodeLayout.get(edge.source);
|
||
const targetLayout = nodeLayout.get(edge.target);
|
||
const rawSpan = (targetLayout?.column || 0) - (sourceLayout?.column || 0);
|
||
const backward = rawSpan <= 0 || String(edge.edgeType || "").toLowerCase() === "rework";
|
||
if (backward || rawSpan !== 1) return memo;
|
||
const key = `${edge.source}->column:${targetLayout?.column ?? ""}`;
|
||
const list = memo.get(key) || [];
|
||
list.push(edge);
|
||
memo.set(key, list);
|
||
return memo;
|
||
}, new Map<string, AnyRecord[]>());
|
||
for (const cluster of adjacentForwardClusters.values()) {
|
||
if (cluster.length < 2) continue;
|
||
cluster
|
||
.slice()
|
||
.sort((left: AnyRecord, right: AnyRecord) => {
|
||
const leftTarget = nodeLayout.get(left.target);
|
||
const rightTarget = nodeLayout.get(right.target);
|
||
return (leftTarget?.y || 0) - (rightTarget?.y || 0) || left.index - right.index;
|
||
})
|
||
.forEach((edge: AnyRecord, index: number, ordered: AnyRecord[]) => {
|
||
adjacentForwardFanOutByEdge.set(pipelineFlowEdgeKey(edge), { slot: index - (ordered.length - 1) / 2, count: ordered.length });
|
||
});
|
||
}
|
||
const sortedIncomingEdges = [...graphEdges].sort((left: AnyRecord, right: AnyRecord) => {
|
||
const leftSource = nodeLayout.get(left.source);
|
||
const leftTarget = nodeLayout.get(left.target);
|
||
const rightSource = nodeLayout.get(right.source);
|
||
const rightTarget = nodeLayout.get(right.target);
|
||
const leftScore = Math.abs((leftTarget?.column || 0) - (leftSource?.column || 0)) * columnGap + Math.abs((leftTarget?.y || 0) - (leftSource?.y || 0));
|
||
const rightScore = Math.abs((rightTarget?.column || 0) - (rightSource?.column || 0)) * columnGap + Math.abs((rightTarget?.y || 0) - (rightSource?.y || 0));
|
||
return leftScore - rightScore || left.index - right.index;
|
||
});
|
||
sortedIncomingEdges.forEach((edge: AnyRecord) => {
|
||
const sourceLayout = nodeLayout.get(edge.source) || { column: 0, row: 0, y: 0 };
|
||
const targetLayout = nodeLayout.get(edge.target) || { column: 0, row: 0, y: 0 };
|
||
const rawSpan = targetLayout.column - sourceLayout.column;
|
||
const span = Math.max(0, rawSpan);
|
||
const backward = rawSpan <= 0 || String(edge.edgeType || "").toLowerCase() === "rework";
|
||
const verticalDelta = sourceLayout.y - targetLayout.y;
|
||
const targetIncomingCount = targetTotals.get(edge.target) || 1;
|
||
const adjacentFanOut = adjacentForwardFanOutByEdge.has(pipelineFlowEdgeKey(edge));
|
||
const primaryForwardInput = !backward && span <= 1 && (adjacentFanOut || targetIncomingCount === 1);
|
||
const loads = targetPortLoads.get(edge.target) || new Map<string, number>();
|
||
targetPortLoads.set(edge.target, loads);
|
||
const port = pipelineInputPorts.slice().sort((left, right) => {
|
||
const score = (item: AnyRecord): number => {
|
||
const side = String(item.side);
|
||
let value = 0;
|
||
if (backward) {
|
||
if (side === "left") value += 86;
|
||
if (side === "top") value += targetLayout.y <= 0 ? -22 : 12;
|
||
if (side === "bottom") value += targetLayout.y >= 0 ? -22 : 12;
|
||
if (Math.abs(targetLayout.y) < 12 && side !== "left") value += edge.index % 2 === 0 ? (side === "top" ? -6 : 6) : (side === "bottom" ? -6 : 6);
|
||
return value;
|
||
}
|
||
if (primaryForwardInput) {
|
||
if (side === "left") value -= adjacentFanOut ? 72 : 44;
|
||
if (side !== "left") value += adjacentFanOut ? 72 : 44;
|
||
return value + Math.abs(verticalDelta) * 0.02;
|
||
}
|
||
if (side === "left") value += span <= 1 ? 0 : 24;
|
||
if (side === "top") value += verticalDelta < -36 ? -18 : 42;
|
||
if (side === "bottom") value += verticalDelta > 36 ? -18 : 42;
|
||
if (span <= 1 && Math.abs(verticalDelta) <= 82 && side !== "left") value += 38;
|
||
if (span > 1 && side !== "left") value -= 10;
|
||
return value;
|
||
};
|
||
const rawLaneHint = sourceLayout.y - targetLayout.y;
|
||
const laneHint = rawLaneHint !== 0 ? rawLaneHint : (edge.index % 2 === 0 ? -1 : 1);
|
||
const totalScore = (item: AnyRecord): number => {
|
||
const load = loads.get(item.id) || 0;
|
||
return score(item) + load * 64 + pipelineEndpointScore(item, loads, laneHint);
|
||
};
|
||
return totalScore(left) - totalScore(right) || String(left.id).localeCompare(String(right.id));
|
||
})[0];
|
||
loads.set(port.id, (loads.get(port.id) || 0) + 1);
|
||
targetHandleByEdge.set(pipelineFlowEdgeKey(edge), port);
|
||
});
|
||
const edgeDrafts: AnyRecord[] = graphEdges.map((edge: AnyRecord) => {
|
||
const targetStatus = pipelineRunNodeStatus(latestRun, edge.target).toLowerCase();
|
||
const pairKey = `${edge.source}->${edge.target}`;
|
||
const sourceSlot = sourceSeen.get(edge.source) || 0;
|
||
const targetSlot = targetSeen.get(edge.target) || 0;
|
||
const pairSlot = pairSeen.get(pairKey) || 0;
|
||
sourceSeen.set(edge.source, sourceSlot + 1);
|
||
targetSeen.set(edge.target, targetSlot + 1);
|
||
pairSeen.set(pairKey, pairSlot + 1);
|
||
const sourceLane = sourceSlot - ((sourceTotals.get(edge.source) || 1) - 1) / 2;
|
||
const targetLane = targetSlot - ((targetTotals.get(edge.target) || 1) - 1) / 2;
|
||
const pairLane = pairSlot - ((pairTotals.get(pairKey) || 1) - 1) / 2;
|
||
const sourceLayout = nodeLayout.get(edge.source);
|
||
const targetLayout = nodeLayout.get(edge.target);
|
||
const rawSpan = (targetLayout?.column || 0) - (sourceLayout?.column || 0);
|
||
const span = Math.max(1, Math.abs(rawSpan));
|
||
const backward = rawSpan <= 0 || String(edge.edgeType || "").toLowerCase() === "rework";
|
||
const verticalDelta = Math.abs((targetLayout?.y || 0) - (sourceLayout?.y || 0));
|
||
const fanOutMeta = adjacentForwardFanOutByEdge.get(pipelineFlowEdgeKey(edge));
|
||
const adjacentFanIn = !backward && rawSpan === 1 && (targetTotals.get(edge.target) || 0) > 1;
|
||
const lane = fanOutMeta ? fanOutMeta.slot : pairLane * 2 + sourceLane + targetLane * 0.45;
|
||
const railDirection = lane === 0 ? (edge.index % 2 === 0 ? -1 : 1) : Math.sign(lane);
|
||
const targetPort = targetHandleByEdge.get(pipelineFlowEdgeKey(edge)) || pipelineInputPorts[1];
|
||
const portDirection = targetPort.side === "top" ? -1 : targetPort.side === "bottom" ? 1 : railDirection;
|
||
const shouldScatter = backward || span > 1 || verticalDelta > 96 || Math.abs(lane) > 0.2 || targetPort.side !== "left";
|
||
const baseScatter = backward ? 118 + span * 18 : 22 + span * 16;
|
||
const sideScatter = targetPort.side === "left" ? 0 : 28;
|
||
const laneOffset = shouldScatter ? Math.max(-280, Math.min(280, portDirection * Math.min(180, baseScatter + sideScatter + verticalDelta * 0.22) + lane * 28)) : 0;
|
||
const sourceHandleIndex = Math.max(0, Math.min(pipelineOutputPorts.length - 1, Math.round(sourceLane + (pipelineOutputPorts.length - 1) / 2)));
|
||
const sourcePort = pipelineOutputPorts[sourceHandleIndex] || pipelineOutputPorts[1];
|
||
const baseEdgeColor = targetStatus === "succeeded" ? "var(--accent-2)" : targetStatus === "running" ? "var(--accent)" : targetStatus === "failed" ? "var(--danger)" : "rgba(129, 147, 159, 0.78)";
|
||
const sourceColumn = sourceLayout?.column || 0;
|
||
const targetColumn = targetLayout?.column || 0;
|
||
const railSign = laneOffset === 0 ? 0 : Math.sign(laneOffset);
|
||
const overlapGroup = backward
|
||
? `feedback:${sourceColumn}->${targetColumn}:${railSign}`
|
||
: fanOutMeta
|
||
? `fanout:${sourceColumn}->${targetColumn}:${edge.source}`
|
||
: adjacentFanIn
|
||
? `fanin:${sourceColumn}->${targetColumn}:${edge.target}`
|
||
: targetPort.side !== "left" || span > 1
|
||
? `corridor:${sourceColumn}->${targetColumn}:${targetPort.side}:${railSign}:${Math.round(Math.abs(laneOffset) / 56)}`
|
||
: "";
|
||
return {
|
||
id: `${edge.source}->${edge.target}-${edge.index}`,
|
||
source: edge.source,
|
||
target: edge.target,
|
||
sourceHandle: sourcePort.id,
|
||
targetHandle: targetPort.id,
|
||
type: "pipelineCurve",
|
||
zIndex: 12,
|
||
animated: targetStatus === "running",
|
||
data: { baseEdgeColor, laneOffset, routeMode: fanOutMeta && targetPort.side === "left" ? "direct-forward-left" : "", targetSide: targetPort.side, isFeedback: backward, overlapGroup },
|
||
targetStatus,
|
||
};
|
||
});
|
||
const overlapGroupCounts = edgeDrafts.reduce((memo: Map<string, number>, edge: AnyRecord) => {
|
||
const key = String(edge.data?.overlapGroup || "");
|
||
return key ? memo.set(key, (memo.get(key) || 0) + 1) : memo;
|
||
}, new Map<string, number>());
|
||
const overlapGroupSeen = new Map<string, number>();
|
||
const edges: Edge[] = edgeDrafts.map((edge: AnyRecord) => {
|
||
const draftTargetStatus = String(edge.targetStatus || "pending");
|
||
const flowEdge: AnyRecord = { ...edge };
|
||
delete flowEdge.targetStatus;
|
||
const groupKey = String(edge.data?.overlapGroup || "");
|
||
const groupCount = groupKey ? overlapGroupCounts.get(groupKey) || 0 : 0;
|
||
const overlapSlot = groupCount > 1 ? overlapGroupSeen.get(groupKey) || 0 : -1;
|
||
if (groupCount > 1) overlapGroupSeen.set(groupKey, overlapSlot + 1);
|
||
const edgeColor = overlapSlot >= 0 ? pipelineOverlapEdgePalette[overlapSlot % pipelineOverlapEdgePalette.length] : String(edge.data.baseEdgeColor);
|
||
const style: AnyRecord = { stroke: edgeColor };
|
||
if (edge.data.isFeedback) style.strokeDasharray = "9 7";
|
||
return {
|
||
...flowEdge,
|
||
data: { ...edge.data, edgeColor, overlapSlot, overlapCount: groupCount },
|
||
style,
|
||
markerEnd: { type: MarkerType.ArrowClosed, color: edgeColor },
|
||
className: `pipeline-flow-edge ${draftTargetStatus} ${edge.data.isFeedback ? "feedback" : ""} ${overlapSlot >= 0 ? "overlap-colored" : ""}`,
|
||
} as Edge;
|
||
});
|
||
return { nodes: flowNodes, edges };
|
||
}
|
||
|
||
function escapeSvg(value: any): string {
|
||
return String(value ?? "")
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
function exportColor(value: any): string {
|
||
const text = String(value || "");
|
||
if (text.includes("--accent-2")) return "#4eb7a8";
|
||
if (text.includes("--accent")) return "#d7a13a";
|
||
if (text.includes("--danger")) return "#cf6a54";
|
||
return text.startsWith("#") ? text : "#81939f";
|
||
}
|
||
|
||
function exportMarkerId(color: string): string {
|
||
return `arrow-${color.replace(/[^a-zA-Z0-9_-]+/g, "")}`;
|
||
}
|
||
|
||
function safeExportFileTitle(title: string, fallback = "pipeline"): string {
|
||
return String(title || fallback).replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-|-$/g, "") || fallback;
|
||
}
|
||
|
||
function targetPortPosition(node: Node, handle: string): { x: number; y: number; position: Position } {
|
||
const x = node.position.x;
|
||
const y = node.position.y;
|
||
const port = pipelineInputPorts.find((item) => item.id === handle);
|
||
if (port?.side === "top") return { x: x + pipelineNodeWidth * pipelinePercent(port.style?.left, 0.5), y, position: Position.Top };
|
||
if (port?.side === "bottom") return { x: x + pipelineNodeWidth * pipelinePercent(port.style?.left, 0.5), y: y + pipelineNodeHeight, position: Position.Bottom };
|
||
return { x, y: y + pipelineNodeHeight / 2, position: Position.Left };
|
||
}
|
||
|
||
function sourcePortPosition(node: Node): { x: number; y: number } {
|
||
return { x: node.position.x + pipelineNodeWidth, y: node.position.y + pipelineNodeHeight / 2 };
|
||
}
|
||
|
||
function pipelineGraphSvg(flow: { nodes: Node[]; edges: Edge[] }, title: string): { svg: string; width: number; height: number } {
|
||
const minX = Math.min(...flow.nodes.map((node) => node.position.x), 0) - 220;
|
||
const minY = Math.min(...flow.nodes.map((node) => node.position.y), 0) - 220;
|
||
const maxX = Math.max(...flow.nodes.map((node) => node.position.x + pipelineNodeWidth), 1) + 220;
|
||
const maxY = Math.max(...flow.nodes.map((node) => node.position.y + pipelineNodeHeight), 1) + 220;
|
||
const width = Math.ceil(maxX - minX);
|
||
const height = Math.ceil(maxY - minY);
|
||
const nodeById = new Map(flow.nodes.map((node) => [node.id, node]));
|
||
const edgeColors = flow.edges.map((edge) => exportColor((edge.data as AnyRecord)?.edgeColor || (edge.style as AnyRecord)?.stroke));
|
||
const markerColors = Array.from(new Set(["#4eb7a8", "#d7a13a", "#cf6a54", "#81939f", ...edgeColors]));
|
||
const markers = markerColors.map((color) =>
|
||
`<marker id="${exportMarkerId(color)}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="${color}"/></marker>`,
|
||
).join("");
|
||
const edgeSvg = flow.edges.flatMap((edge) => {
|
||
const source = nodeById.get(edge.source);
|
||
const target = nodeById.get(edge.target);
|
||
if (!source || !target) return [];
|
||
const sourcePoint = sourcePortPosition(source);
|
||
const targetPoint = targetPortPosition(target, String(edge.targetHandle || "in-left"));
|
||
const path = pipelineCurvePath(sourcePoint.x, sourcePoint.y, targetPoint.x, targetPoint.y, targetPoint.position, Number((edge.data as AnyRecord)?.laneOffset || 0), String((edge.data as AnyRecord)?.routeMode || ""));
|
||
const color = exportColor((edge.data as AnyRecord)?.edgeColor || (edge.style as AnyRecord)?.stroke);
|
||
const dash = (edge.data as AnyRecord)?.isFeedback ? ` stroke-dasharray="9 7"` : "";
|
||
return `<path d="${escapeSvg(path)}" fill="none" stroke="${color}" stroke-width="2.35" stroke-linecap="round" stroke-linejoin="round" opacity="0.82"${dash} marker-end="url(#${exportMarkerId(color)})"/>`;
|
||
}).join("\n");
|
||
const nodeSvg = flow.nodes.map((node) => {
|
||
const label = ((node.data as AnyRecord)?.exportLabel || {}) as AnyRecord;
|
||
const status = String(label.status || "pending").toLowerCase();
|
||
const stroke = status === "succeeded" ? "#4eb7a8" : status === "running" ? "#d7a13a" : status === "failed" ? "#cf6a54" : "#81939f";
|
||
const x = node.position.x;
|
||
const y = node.position.y;
|
||
const inputHandles = pipelineInputPorts.map((port) => {
|
||
const point = targetPortPosition(node, port.id);
|
||
if (port.side === "top" || port.side === "bottom") return `<rect x="${point.x - 9}" y="${point.y - 5}" width="18" height="10" rx="2" fill="#071016" stroke="#4eb7a8"/>`;
|
||
return `<rect x="${point.x - 5}" y="${point.y - 9}" width="10" height="18" rx="2" fill="#071016" stroke="#4eb7a8"/>`;
|
||
}).join("\n");
|
||
return `<g>
|
||
<rect x="${x}" y="${y}" width="${pipelineNodeWidth}" height="${pipelineNodeHeight}" rx="2" fill="#0d171f" stroke="${stroke}" stroke-width="1.2"/>
|
||
${inputHandles}
|
||
<rect x="${x + pipelineNodeWidth - 5}" y="${y + pipelineNodeHeight / 2 - 9}" width="10" height="18" rx="2" fill="#071016" stroke="#d7a13a"/>
|
||
<text x="${x + 12}" y="${y + 22}" fill="#e5edf2" font-size="12" font-family="monospace" font-weight="700">${escapeSvg(label.id || node.id)}</text>
|
||
<text x="${x + 12}" y="${y + 42}" fill="#81939f" font-size="10" font-family="monospace">${escapeSvg(label.kind || "node")}</text>
|
||
<text x="${x + 12}" y="${y + 60}" fill="#d7a13a" font-size="10" font-family="monospace">${escapeSvg(label.componentRef || "--")}</text>
|
||
<text x="${x + 12}" y="${y + 78}" fill="${stroke}" font-size="10" font-family="monospace">${escapeSvg(status)}</text>
|
||
</g>`;
|
||
}).join("\n");
|
||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||
<defs>${markers}<pattern id="grid" width="22" height="22" patternUnits="userSpaceOnUse"><path d="M 22 0 L 0 0 0 22" fill="none" stroke="#18303a" stroke-width="0.6"/></pattern></defs>
|
||
<rect width="100%" height="100%" fill="#081118"/>
|
||
<rect width="100%" height="100%" fill="url(#grid)" opacity="0.55"/>
|
||
<text x="24" y="34" fill="#d7a13a" font-size="13" font-family="monospace" letter-spacing="2">${escapeSvg(title)}</text>
|
||
<g transform="translate(${-minX} ${-minY})">${nodeSvg}<g>${edgeSvg}</g></g>
|
||
</svg>`;
|
||
return { svg, width, height };
|
||
}
|
||
|
||
function pipelineGanttExportStatusColor(status: any): string {
|
||
const value = String(status || "").toLowerCase();
|
||
if (value === "succeeded" || value === "completed") return "#4eb7a8";
|
||
if (value === "failed") return "#cf6a54";
|
||
if (runningStatus(value)) return "#69aee8";
|
||
return "#d7a13a";
|
||
}
|
||
|
||
function pipelineGanttExportMarkerColor(marker: AnyRecord): string {
|
||
const kind = String(marker?.kind || "");
|
||
const tone = String(marker?.tone || marker?.status || "").toLowerCase();
|
||
if (kind === "prompt" && tone === "initial") return "#d7a13a";
|
||
if (kind === "prompt" && tone === "monitor") return "#69aee8";
|
||
if (kind === "prompt") return "#4eb7a8";
|
||
if (tone === "modify") return "#e0b95a";
|
||
if (tone === "approve" || tone === "guide" || tone === "monitor") return "#4eb7a8";
|
||
if (tone === "restart" || tone === "redo") return "#d7a13a";
|
||
if (tone === "ignored") return "#81939f";
|
||
if (tone === "webui") return "#69aee8";
|
||
if (tone === "cli") return "#d7a13a";
|
||
return "#a7bac5";
|
||
}
|
||
|
||
function pipelineGanttExportArrowColor(arrow: AnyRecord): string {
|
||
const sourceKind = String(arrow?.sourceKind || "").toLowerCase();
|
||
const action = String(arrow?.action || "").toLowerCase();
|
||
const status = String(arrow?.status || "").toLowerCase();
|
||
if (action === "observe" || status === "observation" || sourceKind === "monitor") return "#4eb7a8";
|
||
if (sourceKind === "webui") return "#69aee8";
|
||
if (sourceKind === "cli") return "#d7a13a";
|
||
if (status.includes("ignored")) return "#81939f";
|
||
return "#8aa0ad";
|
||
}
|
||
|
||
function pipelineGanttExportMarkerSvg(marker: AnyRecord, x: number, y: number): string {
|
||
const color = pipelineGanttExportMarkerColor(marker);
|
||
const kind = String(marker?.kind || "");
|
||
if (kind === "control-source") {
|
||
return `<rect x="${x - 5.5}" y="${y - 5.5}" width="11" height="11" rx="2" transform="rotate(45 ${x} ${y})" fill="${color}" stroke="rgba(255,255,255,0.26)" stroke-width="1"/>`;
|
||
}
|
||
if (kind === "control-target") {
|
||
const fill = String(marker?.tone || "").toLowerCase() === "approve" ? "rgba(78,183,168,0.22)" : "#081118";
|
||
return `<circle cx="${x}" cy="${y}" r="5.5" fill="${fill}" stroke="${color}" stroke-width="2"/>`;
|
||
}
|
||
const radius = kind === "prompt" ? 4.5 : 5.5;
|
||
return `<circle cx="${x}" cy="${y}" r="${radius}" fill="${color}" stroke="rgba(255,255,255,0.24)" stroke-width="1"/>`;
|
||
}
|
||
|
||
function pipelineGanttSvg(input: AnyRecord): { svg: string; width: number; height: number } {
|
||
const nodeIds = asArray(input.visibleNodeIds).map((nodeId) => String(nodeId || "")).filter(Boolean);
|
||
const intervals = asArray(input.intervals).filter(isRecord);
|
||
const markers = asArray(input.markers).filter(isRecord);
|
||
const arrows = asArray(input.arrows).filter(isRecord);
|
||
const ticks = asArray(input.ticks).filter(isRecord);
|
||
const bounds = isRecord(input.bounds) ? input.bounds : {};
|
||
const backendLayout = isRecord(input.backendLayout) ? input.backendLayout : null;
|
||
const chartHeight = Math.max(240, Math.round(Number(input.chartHeight || 360)));
|
||
const exportColumnWidth = Math.max(pipelineGanttNodeColumnWidth, 108);
|
||
const timeAxisWidth = 128;
|
||
const padding = 24;
|
||
const titleHeight = 58;
|
||
const headerHeight = 56;
|
||
const boardWidth = timeAxisWidth + Math.max(1, nodeIds.length) * exportColumnWidth;
|
||
const width = Math.max(760, boardWidth + padding * 2);
|
||
const height = titleHeight + headerHeight + chartHeight + padding;
|
||
const boardX = padding;
|
||
const boardY = titleHeight;
|
||
const chartY = boardY + headerHeight;
|
||
const nodeX = (index: number) => boardX + timeAxisWidth + index * exportColumnWidth;
|
||
const nodeCenterX = (index: number) => nodeX(index) + exportColumnWidth / 2;
|
||
const meta = asArray(input.meta).map((item) => String(item || "")).filter(Boolean).slice(0, 4).join(" · ");
|
||
const markerById = new Map(markers.map((marker) => [String(marker.id || ""), marker]));
|
||
const markerColors = Array.from(new Set(["#4eb7a8", "#69aee8", "#d7a13a", "#cf6a54", "#8aa0ad", ...arrows.map(pipelineGanttExportArrowColor)]));
|
||
const defs = markerColors.map((color) =>
|
||
`<marker id="${exportMarkerId(color)}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="${color}"/></marker>`,
|
||
).join("");
|
||
const tickSvg = ticks.map((tick) => {
|
||
const y = chartY + pipelineGanttTickY(tick, bounds, chartHeight, backendLayout);
|
||
return `<g>
|
||
<line x1="${boardX}" y1="${y}" x2="${boardX + boardWidth}" y2="${y}" stroke="rgba(215,161,58,0.18)" stroke-width="1"/>
|
||
<text x="${boardX + 8}" y="${y - 4}" fill="#dce6ec" font-size="10" font-family="monospace">${escapeSvg(fmtDate(tick.ms))}</text>
|
||
<text x="${boardX + 8}" y="${y + 10}" fill="#81939f" font-size="9" font-family="monospace">+${escapeSvg(fmtDurationMs(Number(tick.offsetMs ?? Number(tick.ms) - Number(bounds.startMs))))}</text>
|
||
</g>`;
|
||
}).join("\n");
|
||
const headerSvg = [
|
||
`<rect x="${boardX}" y="${boardY}" width="${timeAxisWidth}" height="${headerHeight}" fill="#101d25" stroke="rgba(255,255,255,0.08)"/>`,
|
||
`<text x="${boardX + 10}" y="${boardY + 20}" fill="#d7a13a" font-size="10" font-family="monospace" letter-spacing="1.2">TIME</text>`,
|
||
...nodeIds.map((nodeId, index) => {
|
||
const x = nodeX(index);
|
||
const label = nodeId.length > 18 ? `${nodeId.slice(0, 16)}…` : nodeId;
|
||
return `<g>
|
||
<rect x="${x}" y="${boardY}" width="${exportColumnWidth}" height="${headerHeight}" fill="#101d25" stroke="rgba(255,255,255,0.08)"/>
|
||
<text x="${x + exportColumnWidth / 2}" y="${boardY + 21}" fill="#dce6ec" font-size="10" font-family="monospace" text-anchor="middle">${escapeSvg(label)}</text>
|
||
<text x="${x + exportColumnWidth / 2}" y="${boardY + 39}" fill="#81939f" font-size="9" font-family="monospace" text-anchor="middle">node ${index + 1}</text>
|
||
</g>`;
|
||
}),
|
||
].join("\n");
|
||
const columnSvg = nodeIds.map((_nodeId, index) => {
|
||
const x = nodeX(index);
|
||
return `<rect x="${x}" y="${chartY}" width="${exportColumnWidth}" height="${chartHeight}" fill="${index % 2 === 0 ? "rgba(255,255,255,0.018)" : "rgba(255,255,255,0.008)"}" stroke="rgba(255,255,255,0.045)"/>`;
|
||
}).join("\n");
|
||
const intervalSvg = intervals.map((interval) => {
|
||
const index = nodeIds.indexOf(String(interval.nodeId || ""));
|
||
if (index < 0) return "";
|
||
const top = chartY + pipelineGanttIntervalTop(interval, bounds, chartHeight, backendLayout);
|
||
const barHeight = Math.max(2, pipelineGanttIntervalHeight(interval, bounds, chartHeight, backendLayout));
|
||
const color = pipelineGanttExportStatusColor(interval.status);
|
||
const x = nodeCenterX(index) - 3.5;
|
||
const liveOverlay = interval.live ? `<rect x="${x - 1}" y="${top}" width="9" height="${barHeight}" rx="5" fill="none" stroke="rgba(255,255,255,0.54)" stroke-width="1" stroke-dasharray="4 5"/>` : "";
|
||
const label = barHeight >= 28
|
||
? `<text x="${x + 12}" y="${top + 12}" fill="${color}" font-size="9" font-family="monospace">${escapeSvg(String(interval.status || "working"))}</text>
|
||
<text x="${x + 12}" y="${top + 24}" fill="#81939f" font-size="8" font-family="monospace">${escapeSvg(fmtDurationMs(interval.durationMs))}</text>`
|
||
: "";
|
||
return `<g>
|
||
<rect x="${x}" y="${top}" width="7" height="${barHeight}" rx="4" fill="${color}" stroke="rgba(0,0,0,0.42)" stroke-width="1"/>
|
||
${liveOverlay}
|
||
${label}
|
||
</g>`;
|
||
}).join("\n");
|
||
const markerSvg = markers.map((marker) => {
|
||
const index = nodeIds.indexOf(String(marker.nodeId || ""));
|
||
if (index < 0) return "";
|
||
const y = chartY + pipelineGanttMarkerY(marker, bounds, chartHeight, backendLayout);
|
||
return pipelineGanttExportMarkerSvg(marker, nodeCenterX(index), y);
|
||
}).join("\n");
|
||
const arrowSvg = arrows.map((arrow) => {
|
||
const targetMarker = markerById.get(String(arrow.targetMarkerId || ""));
|
||
if (!targetMarker) return "";
|
||
const sourceMarker = markerById.get(String(arrow.sourceMarkerId || ""));
|
||
const sourceNodeId = String(sourceMarker?.nodeId || arrow.sourceNodeId || "");
|
||
const targetNodeId = String(targetMarker.nodeId || arrow.targetNodeId || "");
|
||
const sourceIndex = nodeIds.indexOf(sourceNodeId);
|
||
const targetIndex = nodeIds.indexOf(targetNodeId);
|
||
if (sourceIndex < 0 || targetIndex < 0) return "";
|
||
const sourceX = nodeCenterX(sourceIndex) - boardX - timeAxisWidth;
|
||
const targetX = nodeCenterX(targetIndex) - boardX - timeAxisWidth;
|
||
const sourceY = pipelineGanttNumber(arrow.sourceY ?? arrow.y1)
|
||
?? (sourceMarker ? pipelineGanttMarkerY(sourceMarker, bounds, chartHeight, backendLayout) : pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout));
|
||
const targetY = pipelineGanttNumber(arrow.targetY ?? arrow.y2) ?? pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout);
|
||
const color = pipelineGanttExportArrowColor(arrow);
|
||
const dash = String(arrow.action || "").toLowerCase() === "observe" ? "3 4" : "6 5";
|
||
const path = escapeSvg(pipelineControlArrowPath(sourceX, sourceY, targetX, targetY));
|
||
return `<path d="${path}" fill="none" stroke="rgba(8,17,24,0.92)" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||
<path d="${path}" fill="none" stroke="${color}" stroke-width="2.45" stroke-dasharray="${dash}" stroke-linecap="round" stroke-linejoin="round" opacity="0.96" marker-end="url(#${exportMarkerId(color)})"/>`;
|
||
}).join("\n");
|
||
const emptySvg = nodeIds.length === 0 ? `<text x="${boardX + timeAxisWidth + 24}" y="${chartY + 42}" fill="#81939f" font-size="12" font-family="monospace">No visible Gantt nodes</text>` : "";
|
||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||
<defs>${defs}<pattern id="ganttGrid" width="32" height="32" patternUnits="userSpaceOnUse"><path d="M 32 0 L 0 0 0 32" fill="none" stroke="#18303a" stroke-width="0.55"/></pattern></defs>
|
||
<rect width="100%" height="100%" fill="#081118"/>
|
||
<rect width="100%" height="100%" fill="url(#ganttGrid)" opacity="0.55"/>
|
||
<rect x="${boardX}" y="${chartY}" width="${boardWidth}" height="${chartHeight}" fill="rgba(255,255,255,0.012)" stroke="rgba(78,183,168,0.24)"/>
|
||
<text x="${padding}" y="26" fill="#d7a13a" font-size="13" font-family="monospace" font-weight="700" letter-spacing="1.4">${escapeSvg(input.title || "Pipeline Epoch Gantt")}</text>
|
||
<text x="${padding}" y="45" fill="#81939f" font-size="10" font-family="monospace">${escapeSvg(meta)}</text>
|
||
${headerSvg}
|
||
<rect x="${boardX}" y="${chartY}" width="${timeAxisWidth}" height="${chartHeight}" fill="rgba(215,161,58,0.055)" stroke="rgba(215,161,58,0.2)"/>
|
||
${columnSvg}
|
||
${tickSvg}
|
||
${intervalSvg}
|
||
<g transform="translate(${boardX + timeAxisWidth} ${chartY})">${arrowSvg}</g>
|
||
${markerSvg}
|
||
${emptySvg}
|
||
</svg>`;
|
||
return { svg, width, height };
|
||
}
|
||
|
||
function downloadBlob(blob: Blob, filename: string): void {
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = filename;
|
||
link.click();
|
||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||
}
|
||
|
||
async function exportPipelineGraph(flow: { nodes: Node[]; edges: Edge[] }, title: string): Promise<void> {
|
||
const safeTitle = safeExportFileTitle(title, "pipeline");
|
||
const { svg, width, height } = pipelineGraphSvg(flow, title);
|
||
const svgBlob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
|
||
const url = URL.createObjectURL(svgBlob);
|
||
try {
|
||
const image = new Image();
|
||
await new Promise<void>((resolve, reject) => {
|
||
image.onload = () => resolve();
|
||
image.onerror = () => reject(new Error("svg image load failed"));
|
||
image.src = url;
|
||
});
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) throw new Error("canvas unavailable");
|
||
ctx.drawImage(image, 0, 0);
|
||
const pngBlob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
|
||
if (!pngBlob) throw new Error("png export failed");
|
||
downloadBlob(pngBlob, `${safeTitle}.png`);
|
||
} catch {
|
||
downloadBlob(svgBlob, `${safeTitle}.svg`);
|
||
} finally {
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
}
|
||
|
||
async function exportPipelineGantt(input: AnyRecord): Promise<void> {
|
||
const safeTitle = safeExportFileTitle(String(input?.title || "pipeline-gantt"), "pipeline-gantt");
|
||
const { svg, width, height } = pipelineGanttSvg(input);
|
||
const svgBlob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
|
||
const url = URL.createObjectURL(svgBlob);
|
||
try {
|
||
const image = new Image();
|
||
await new Promise<void>((resolve, reject) => {
|
||
image.onload = () => resolve();
|
||
image.onerror = () => reject(new Error("gantt svg image load failed"));
|
||
image.src = url;
|
||
});
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) throw new Error("canvas unavailable");
|
||
ctx.drawImage(image, 0, 0);
|
||
const pngBlob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
|
||
if (!pngBlob) throw new Error("gantt png export failed");
|
||
downloadBlob(pngBlob, `${safeTitle}.png`);
|
||
} catch {
|
||
downloadBlob(svgBlob, `${safeTitle}.svg`);
|
||
} finally {
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
}
|
||
|
||
async function exportPipelineGraphs(items: Array<{ flow: { nodes: Node[]; edges: Edge[] }; title: string }>): Promise<void> {
|
||
for (const item of items) {
|
||
if (item.flow.nodes.length === 0) continue;
|
||
await exportPipelineGraph(item.flow, item.title);
|
||
await new Promise((resolve) => setTimeout(resolve, 750));
|
||
}
|
||
}
|
||
|
||
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?.status || procedure?.artifact?.status || procedure?.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[], nowMs = Date.now()): AnyRecord[] {
|
||
const runId = String(run?.runId || "");
|
||
const knownNodeIds = new Set(pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean));
|
||
return asArray(run?.procedureRuns).flatMap((procedure: any) => {
|
||
const nodeId = inferProcedureNodeId(procedure, runId);
|
||
if (!nodeId) return [];
|
||
const status = String(procedure?.status?.status || procedure?.artifact?.status || procedure?.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) : nowMs);
|
||
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 };
|
||
}
|
||
|
||
const pipelineGanttScaleBasePxPerMinute = 12;
|
||
const pipelineGanttScaleExponentBase = 20;
|
||
const pipelineDefaultGanttPxPerMinute = 100;
|
||
const pipelineDefaultGanttAutoHideIdle = false;
|
||
|
||
function clampPipelineGanttScale(value: any): number {
|
||
const number = Number(value);
|
||
if (!Number.isFinite(number)) return 0;
|
||
return Math.max(0, Math.min(100, Math.round(number * 100) / 100));
|
||
}
|
||
|
||
function pipelineScaleValueForPxPerMinute(pxPerMinute: number): number {
|
||
const safePxPerMinute = Math.max(pipelineGanttScaleBasePxPerMinute, Number(pxPerMinute || pipelineGanttScaleBasePxPerMinute));
|
||
const normalized = Math.log(safePxPerMinute / pipelineGanttScaleBasePxPerMinute) / Math.log(pipelineGanttScaleExponentBase);
|
||
return clampPipelineGanttScale(normalized * 100);
|
||
}
|
||
|
||
const pipelineDefaultGanttScale = pipelineScaleValueForPxPerMinute(pipelineDefaultGanttPxPerMinute);
|
||
|
||
function pipelineGanttScaleConfig(value: number): AnyRecord {
|
||
const normalized = clampPipelineGanttScale(value) / 100;
|
||
const pxPerMinute = pipelineGanttScaleBasePxPerMinute * Math.pow(pipelineGanttScaleExponentBase, normalized);
|
||
const label = normalized < 0.24 ? "全局" : normalized < 0.64 ? "均衡" : "细节";
|
||
return { value: clampPipelineGanttScale(normalized * 100), pxPerMinute, label };
|
||
}
|
||
|
||
function pipelineDisplayPxPerMinute(value: any): number {
|
||
const rounded = Math.round(Number(value));
|
||
return Math.abs(rounded - pipelineDefaultGanttPxPerMinute) <= 1 ? pipelineDefaultGanttPxPerMinute : rounded;
|
||
}
|
||
|
||
function pipelineGanttHeight(bounds: AnyRecord, scaleValue = pipelineDefaultGanttScale): number {
|
||
const minutes = Math.max(1, Number(bounds.durationMs || 0) / 60_000);
|
||
const scale = pipelineGanttScaleConfig(scaleValue);
|
||
return Math.round(Math.max(360, Math.min(7200, minutes * Number(scale.pxPerMinute || 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 markerPercent(ms: number, bounds: AnyRecord): number {
|
||
const duration = Math.max(1, Number(bounds.endMs) - Number(bounds.startMs));
|
||
return Math.max(0, Math.min(100, ((ms - Number(bounds.startMs)) / duration) * 100));
|
||
}
|
||
|
||
function pipelineGanttBackendLayout(gantt: any): AnyRecord | null {
|
||
const layout = isRecord(gantt?.layout) ? gantt.layout : null;
|
||
return layout && Number.isFinite(Number(layout.chartHeight)) && Number.isFinite(Number(layout.startMs)) && Number.isFinite(Number(layout.endMs)) ? layout : null;
|
||
}
|
||
|
||
function pipelineGanttNumber(value: any): number | null {
|
||
const number = Number(value);
|
||
return Number.isFinite(number) ? number : null;
|
||
}
|
||
|
||
function pipelineGanttIntervalIsRunning(interval: AnyRecord): boolean {
|
||
return runningStatus(interval?.status) && !terminalStatus(interval?.status);
|
||
}
|
||
|
||
function pipelineGanttMsToY(ms: number, layout: AnyRecord | null): number | null {
|
||
if (!layout) return null;
|
||
const startMs = pipelineGanttNumber(layout?.startMs);
|
||
const endMs = pipelineGanttNumber(layout?.endMs);
|
||
const chartHeight = pipelineGanttNumber(layout?.chartHeight);
|
||
if (startMs === null || endMs === null || chartHeight === null) return null;
|
||
const durationMs = Math.max(1, endMs - startMs);
|
||
return Math.max(0, ((ms - startMs) / durationMs) * chartHeight);
|
||
}
|
||
|
||
function pipelineGanttLiveEndMs(interval: AnyRecord, nowMs: number): number {
|
||
const startMs = pipelineGanttNumber(interval?.rawStartMs ?? interval?.startMs) ?? pipelineGanttNumber(interval?.startMs) ?? nowMs;
|
||
const currentEndMs = pipelineGanttNumber(interval?.endMs) ?? startMs + 1000;
|
||
if (!pipelineGanttIntervalIsRunning(interval)) return Math.max(startMs + 1000, currentEndMs);
|
||
return Math.max(startMs + 1000, currentEndMs, nowMs);
|
||
}
|
||
|
||
function pipelineGanttLiveBackendLayout(layout: AnyRecord | null, intervals: AnyRecord[], nowMs: number): AnyRecord | null {
|
||
if (!layout) return null;
|
||
const startMs = pipelineGanttNumber(layout?.startMs);
|
||
const endMs = pipelineGanttNumber(layout?.endMs);
|
||
const chartHeight = pipelineGanttNumber(layout?.chartHeight);
|
||
if (startMs === null || endMs === null || chartHeight === null) return layout;
|
||
const liveEndMs = intervals.reduce((maxMs, interval) => Math.max(maxMs, pipelineGanttLiveEndMs(interval, nowMs)), endMs);
|
||
if (liveEndMs <= endMs) return layout;
|
||
const pxPerMs = chartHeight / Math.max(1, endMs - startMs);
|
||
return {
|
||
...layout,
|
||
endMs: liveEndMs,
|
||
durationMs: Math.max(1, liveEndMs - startMs),
|
||
chartHeight: Math.max(chartHeight, Math.round((liveEndMs - startMs) * pxPerMs)),
|
||
liveExtended: true,
|
||
};
|
||
}
|
||
|
||
function pipelineGanttNormalizeLiveInterval(interval: AnyRecord, layout: AnyRecord | null, nowMs: number): AnyRecord {
|
||
if (!pipelineGanttIntervalIsRunning(interval)) return interval;
|
||
const startMs = pipelineGanttNumber(interval?.rawStartMs ?? interval?.startMs) ?? pipelineGanttNumber(interval?.startMs) ?? nowMs;
|
||
const endMs = pipelineGanttLiveEndMs(interval, nowMs);
|
||
const y1 = pipelineGanttMsToY(startMs, layout);
|
||
const y2 = pipelineGanttMsToY(endMs, layout);
|
||
const safeY1 = pipelineGanttNumber(y1 ?? interval?.y1 ?? interval?.startY) ?? 0;
|
||
const safeY2 = pipelineGanttNumber(y2 ?? interval?.y2 ?? interval?.endY) ?? safeY1 + 10;
|
||
const height = Math.max(24, safeY2 - safeY1);
|
||
return {
|
||
...interval,
|
||
live: true,
|
||
startMs,
|
||
endMs,
|
||
durationMs: Math.max(1000, endMs - startMs),
|
||
finishedAt: isoFromMs(endMs),
|
||
y1: safeY1,
|
||
y2: safeY2,
|
||
startY: safeY1,
|
||
endY: safeY2,
|
||
height,
|
||
};
|
||
}
|
||
|
||
function pipelineGanttFallbackY(ms: number, bounds: AnyRecord, chartHeight: number): number {
|
||
return (markerPercent(ms, bounds) / 100) * chartHeight;
|
||
}
|
||
|
||
function pipelineGanttItemY(item: AnyRecord, bounds: AnyRecord, chartHeight: number, backendLayout: AnyRecord | null, fields: string[]): number {
|
||
if (backendLayout) {
|
||
for (const field of fields) {
|
||
const y = pipelineGanttNumber(item?.[field]);
|
||
if (y !== null) return y;
|
||
}
|
||
}
|
||
const ms = pipelineGanttNumber(item?.ms ?? item?.eventMs ?? item?.startMs);
|
||
return pipelineGanttFallbackY(ms ?? Number(bounds.startMs), bounds, chartHeight);
|
||
}
|
||
|
||
function pipelineGanttIntervalTop(interval: AnyRecord, bounds: AnyRecord, chartHeight: number, backendLayout: AnyRecord | null): number {
|
||
return pipelineGanttItemY(interval, bounds, chartHeight, backendLayout, ["y1", "startY"]);
|
||
}
|
||
|
||
function pipelineGanttIntervalBottom(interval: AnyRecord, bounds: AnyRecord, chartHeight: number, backendLayout: AnyRecord | null): number {
|
||
if (backendLayout) {
|
||
const endY = pipelineGanttNumber(interval?.y2 ?? interval?.endY);
|
||
if (endY !== null) return endY;
|
||
}
|
||
const endMs = pipelineGanttNumber(interval?.endMs) ?? Number(bounds.endMs);
|
||
return pipelineGanttFallbackY(endMs, bounds, chartHeight);
|
||
}
|
||
|
||
function pipelineGanttIntervalHeight(interval: AnyRecord, bounds: AnyRecord, chartHeight: number, backendLayout: AnyRecord | null): number {
|
||
if (backendLayout) {
|
||
const height = pipelineGanttNumber(interval?.height);
|
||
if (height !== null) return Math.max(1, height);
|
||
}
|
||
return Math.max(10, pipelineGanttIntervalBottom(interval, bounds, chartHeight, backendLayout) - pipelineGanttIntervalTop(interval, bounds, chartHeight, backendLayout));
|
||
}
|
||
|
||
function pipelineGanttMarkerY(marker: AnyRecord, bounds: AnyRecord, chartHeight: number, backendLayout: AnyRecord | null): number {
|
||
return pipelineGanttItemY(marker, bounds, chartHeight, backendLayout, ["y", "timeAxisY"]);
|
||
}
|
||
|
||
function pipelineGanttTickY(tick: AnyRecord, bounds: AnyRecord, chartHeight: number, backendLayout: AnyRecord | null): number {
|
||
if (backendLayout) {
|
||
const y = pipelineGanttNumber(tick?.y);
|
||
if (y !== null) return y;
|
||
}
|
||
const percent = pipelineGanttNumber(tick?.percent);
|
||
if (percent !== null) return (percent / 100) * chartHeight;
|
||
const ms = pipelineGanttNumber(tick?.ms) ?? Number(bounds.startMs);
|
||
return pipelineGanttFallbackY(ms, bounds, chartHeight);
|
||
}
|
||
|
||
function pipelineGanttNormalizeBackendMarker(marker: AnyRecord): AnyRecord {
|
||
const kind = String(marker?.kind || "event");
|
||
const event = String(marker?.event || "");
|
||
const timestampIso = String(marker?.timestampIso || marker?.timestamp || isoFromMs(pipelineGanttNumber(marker?.eventMs ?? marker?.ms)));
|
||
const actionRecord = { ...marker, event, action: marker?.action || marker?.requestedAction };
|
||
const label = marker?.label || (kind === "prompt"
|
||
? pipelinePromptMarkerLabel(actionRecord, event)
|
||
: kind === "control-source"
|
||
? `${controlActionLabel(actionRecord)} 发起`
|
||
: kind === "control-target"
|
||
? controlTargetLabel(actionRecord, String(marker?.nodeId || marker?.targetNodeId || ""))
|
||
: eventLabel(actionRecord));
|
||
const tone = marker?.tone || (kind === "prompt"
|
||
? pipelinePromptMarkerTone(actionRecord, event)
|
||
: kind === "control-source"
|
||
? controlSourceTone(actionRecord)
|
||
: controlTargetTone(actionRecord));
|
||
const status = marker?.status || (event.startsWith("control-command-") ? event.replace(/^control-command-/u, "") : "");
|
||
return {
|
||
...marker,
|
||
kind,
|
||
tone,
|
||
status,
|
||
label,
|
||
timestampIso,
|
||
renderedTimestampIso: marker?.renderedTimestampIso || isoFromMs(pipelineGanttNumber(marker?.renderedMs ?? marker?.ms)),
|
||
snapped: Number(marker?.ms || 0) !== Number(marker?.eventMs ?? marker?.ms ?? 0),
|
||
raw: marker?.raw || marker,
|
||
};
|
||
}
|
||
|
||
function pipelineObservedPromptSourceNodeId(marker: AnyRecord): string {
|
||
const promptEvent = String(marker?.promptEvent || marker?.raw?.promptEvent || marker?.event || "").toLowerCase();
|
||
if (!["node-long-running-observation", "node-finished"].includes(promptEvent)) return "";
|
||
const sourceNodeId = String(marker?.sourceNodeId || marker?.raw?.sourceNodeId || marker?.raw?.detail?.nodeId || "");
|
||
const monitorNodeId = String(marker?.nodeId || marker?.targetNodeId || "");
|
||
return sourceNodeId && sourceNodeId !== monitorNodeId ? sourceNodeId : "";
|
||
}
|
||
|
||
function pipelineGanttAddObservationArrows(markers: AnyRecord[], arrows: AnyRecord[]): AnyRecord {
|
||
const arrowKeys = new Set(arrows.map((arrow) => [
|
||
String(arrow.sourceNodeId || ""),
|
||
String(arrow.targetNodeId || ""),
|
||
String(arrow.targetMarkerId || ""),
|
||
String(arrow.action || ""),
|
||
].join(":")));
|
||
const nextArrows = [...arrows];
|
||
for (const marker of markers) {
|
||
const observedNodeId = pipelineObservedPromptSourceNodeId(marker);
|
||
const monitorNodeId = String(marker?.nodeId || "");
|
||
const targetMarkerId = String(marker?.id || "");
|
||
if (!observedNodeId || !monitorNodeId || !targetMarkerId) continue;
|
||
const arrowKey = [observedNodeId, monitorNodeId, targetMarkerId, "observe"].join(":");
|
||
if (arrowKeys.has(arrowKey)) continue;
|
||
arrowKeys.add(arrowKey);
|
||
nextArrows.push({
|
||
id: `observation-arrow:${targetMarkerId}:${observedNodeId}:${monitorNodeId}`,
|
||
commandId: String(marker?.commandId || marker?.eventId || targetMarkerId),
|
||
sourceNodeId: observedNodeId,
|
||
targetNodeId: monitorNodeId,
|
||
sourceMarkerId: "",
|
||
targetMarkerId,
|
||
sourceKind: "monitor",
|
||
action: "observe",
|
||
status: "observation",
|
||
});
|
||
}
|
||
return { markers, arrows: nextArrows };
|
||
}
|
||
|
||
function pipelineBackendGanttSignals(details: any): AnyRecord {
|
||
const gantt = isRecord(details?.gantt) ? details.gantt : {};
|
||
const rawMarkers = asArray(gantt.markers).filter(isRecord).map(pipelineGanttNormalizeBackendMarker);
|
||
const arrows = asArray(gantt.arrows).filter(isRecord);
|
||
return pipelineGanttAddObservationArrows(rawMarkers, arrows);
|
||
}
|
||
|
||
function pipelinePromptMarkerTone(record: any, fallbackKind = ""): string {
|
||
const kind = eventKind(record) || fallbackKind;
|
||
const promptEvent = String(record?.promptEvent || "");
|
||
if (kind === "initial-prompt-delivered") return "initial";
|
||
if (promptEvent === "node-finished" || promptEvent === "node-long-running-observation" || promptEvent.startsWith("monitor-")) return "monitor";
|
||
if (kind === "monitor-prompt-delivered" || String(record?.sourceKind || "").toLowerCase() === "monitor" || fallbackKind === "monitor-prompt-queued") return "monitor";
|
||
return "append";
|
||
}
|
||
|
||
function pipelineRecordTags(record: any): string[] {
|
||
return asArray(record?.tags || record?.raw?.tags).map((tag) => String(tag || "")).filter(Boolean);
|
||
}
|
||
|
||
function pipelinePromptMarkerLabel(record: any, fallbackKind = ""): string {
|
||
const kind = eventKind(record) || fallbackKind;
|
||
const promptEvent = String(record?.promptEvent || "");
|
||
if (kind === "initial-prompt-delivered") return "初始 prompt";
|
||
if (promptEvent === "node-long-running-observation") return "长任务观察";
|
||
if (promptEvent === "node-finished") return pipelineRecordTags(record).includes("monitor.audit") ? "节点完成 / OA 审核" : "节点完成";
|
||
if (promptEvent === "monitor-interval") return "旧版轮询";
|
||
if (promptEvent === "monitor-start") return "Monitor start";
|
||
if (promptEvent === "monitor-stop") return "Monitor stop";
|
||
if (kind === "monitor-prompt-delivered" || fallbackKind === "monitor-prompt-queued") return "Monitor prompt";
|
||
if (kind === "append-prompt-queued") return "追加 prompt 已排队";
|
||
return "追加 prompt";
|
||
}
|
||
|
||
function controlEventPriority(record: any): number {
|
||
const kind = eventKind(record);
|
||
if (kind === "control-command-applied") return 3;
|
||
if (kind === "control-command-ignored") return 2;
|
||
if (kind === "control-command-queued") return 1;
|
||
return 0;
|
||
}
|
||
|
||
function controlRecordGroupKey(record: any, index: number): string {
|
||
const commandId = String(record?.commandId || "");
|
||
if (commandId) return `command:${commandId}`;
|
||
const timestamp = eventTimestampIso(record) || firstIso(record?.createdAt, record?.timestamp) || `index-${index}`;
|
||
return [
|
||
"fallback",
|
||
timestamp,
|
||
String(record?.sourceKind || ""),
|
||
String(record?.sourceNodeId || ""),
|
||
String(record?.targetNodeId || ""),
|
||
controlActionValue(record),
|
||
].join(":");
|
||
}
|
||
|
||
function controlTargetNodeIds(record: any): string[] {
|
||
return uniqueStrings([
|
||
record?.targetNodeId,
|
||
...asArray(record?.resetNodeIds),
|
||
]);
|
||
}
|
||
|
||
function controlTargetLabel(record: any, targetNodeId: string): string {
|
||
const action = controlActionLabel(record);
|
||
const kind = eventKind(record);
|
||
const primaryTarget = String(record?.targetNodeId || "");
|
||
const isResetSideEffect = Boolean(primaryTarget) && targetNodeId !== primaryTarget;
|
||
if (kind === "control-command-applied") return isResetSideEffect ? `${action} 波及` : `${action} 生效`;
|
||
if (kind === "control-command-ignored") return `${action} 忽略`;
|
||
if (kind === "control-command-queued") return `${action} 已发起`;
|
||
return isResetSideEffect ? `${action} 波及` : action;
|
||
}
|
||
|
||
function controlTargetTone(record: any): string {
|
||
const kind = eventKind(record);
|
||
if (kind === "control-command-ignored") return "ignored";
|
||
const action = controlActionValue(record);
|
||
if (action === "restart" || action === "redo") return "restart";
|
||
if (action === "modify") return "modify";
|
||
if (action === "approve") return "approve";
|
||
if (action === "guide") return "guide";
|
||
return "pending";
|
||
}
|
||
|
||
function controlSourceTone(record: any): string {
|
||
const sourceKind = String(record?.sourceKind || "").toLowerCase();
|
||
if (sourceKind === "monitor") return "monitor";
|
||
if (sourceKind === "webui") return "webui";
|
||
if (sourceKind === "cli") return "cli";
|
||
return "system";
|
||
}
|
||
|
||
function controlTargetMarkerPlacement(intervals: AnyRecord[], nodeId: string, eventMs: number, record: any): AnyRecord {
|
||
const nodeIntervals = intervals
|
||
.filter((interval: AnyRecord) => String(interval.nodeId || "") === nodeId)
|
||
.sort((left: AnyRecord, right: AnyRecord) => Number(left.startMs) - Number(right.startMs));
|
||
const containing = nodeIntervals.find((interval: AnyRecord) => eventMs >= Number(interval.startMs) - 1000 && eventMs <= Number(interval.endMs) + 1000);
|
||
if (containing) {
|
||
return { ms: eventMs, onInterval: true, snapReason: "inside-interval", procedureRunId: String(containing.procedureRunId || "") };
|
||
}
|
||
const action = controlActionValue(record);
|
||
const previous = nodeIntervals.slice().reverse().find((interval: AnyRecord) => Number(interval.endMs) <= eventMs + 1000);
|
||
if (previous && action === "approve") {
|
||
return { ms: Number(previous.endMs), onInterval: true, snapReason: "previous-interval-end", procedureRunId: String(previous.procedureRunId || "") };
|
||
}
|
||
const next = nodeIntervals.find((interval: AnyRecord) => Number(interval.startMs) >= eventMs - 1000);
|
||
if (next && ["guide", "modify", "restart", "redo"].includes(action)) {
|
||
return { ms: Number(next.startMs), onInterval: true, snapReason: "next-interval-start", procedureRunId: String(next.procedureRunId || "") };
|
||
}
|
||
return { ms: eventMs, onInterval: false, snapReason: "event-time", procedureRunId: String(record?.procedureRunId || "") };
|
||
}
|
||
|
||
function pipelineControlArrowPath(sourceX: number, sourceY: number, targetX: number, targetY: number): string {
|
||
const distance = Math.hypot(targetX - sourceX, targetY - sourceY);
|
||
const inset = distance > pipelineGanttArrowTipInsetPx ? pipelineGanttArrowTipInsetPx : 0;
|
||
const endX = inset > 0 ? targetX - ((targetX - sourceX) / distance) * inset : targetX;
|
||
const endY = inset > 0 ? targetY - ((targetY - sourceY) / distance) * inset : targetY;
|
||
const deltaX = endX - sourceX;
|
||
const bend = Math.max(16, Math.min(42, Math.abs(deltaX) * 0.45 + 12));
|
||
const sign = deltaX === 0 ? 1 : Math.sign(deltaX);
|
||
return `M ${sourceX},${sourceY} C ${sourceX + sign * bend},${sourceY} ${endX - sign * bend},${endY} ${endX},${endY}`;
|
||
}
|
||
|
||
function pipelineRunGanttSignals(details: any, activeRun: any): AnyRecord {
|
||
const runId = String(details?.runId || activeRun?.runId || "");
|
||
const intervals = pipelineRunIntervals({ ...(isRecord(activeRun) ? activeRun : {}), ...(isRecord(details) ? details : {}), runId, procedureRuns: asArray(details?.procedureRuns).length > 0 ? details.procedureRuns : activeRun?.procedureRuns }, []);
|
||
const promptMarkers: AnyRecord[] = [];
|
||
const controlMarkers: AnyRecord[] = [];
|
||
const controlArrows: AnyRecord[] = [];
|
||
const markerIds = new Set<string>();
|
||
const sourceMarkerByCommand = new Map<string, string>();
|
||
|
||
const addMarker = (marker: AnyRecord, bucket: AnyRecord[]) => {
|
||
if (!marker.nodeId || !Number.isFinite(Number(marker.ms))) return;
|
||
if (markerIds.has(marker.id)) return;
|
||
markerIds.add(marker.id);
|
||
bucket.push(marker);
|
||
};
|
||
|
||
for (const procedure of asArray(details?.procedureRuns)) {
|
||
const nodeId = inferProcedureNodeId(procedure, runId);
|
||
const procedureRunId = procedureRunIdOf(procedure);
|
||
if (!nodeId) continue;
|
||
for (const attempt of asArray(procedure?.attempts)) {
|
||
const attemptId = attemptLabel(attempt);
|
||
const deliveredIds = new Set<string>();
|
||
const deliveredFallbackKeys = new Set<string>();
|
||
for (const record of structuredEventRecords(attempt?.controlEventRecords)) {
|
||
const kind = eventKind(record);
|
||
if (!["initial-prompt-delivered", "append-prompt-delivered", "monitor-prompt-delivered"].includes(kind)) continue;
|
||
const timestampIso = eventTimestampIso(record);
|
||
const ms = timeMs(timestampIso);
|
||
if (ms === null) continue;
|
||
const eventId = String(record?.eventId || "");
|
||
if (eventId) deliveredIds.add(eventId);
|
||
deliveredFallbackKeys.add(`${kind}:${timestampIso}:${String(record?.sourceKind || "")}:${String(record?.promptPreview || "")}`);
|
||
addMarker({
|
||
id: `prompt:${eventId || `${procedureRunId}:${attemptId}:${kind}:${ms}`}`,
|
||
runId,
|
||
nodeId,
|
||
procedureRunId,
|
||
attempt: attemptId,
|
||
kind: "prompt",
|
||
tone: pipelinePromptMarkerTone(record, kind),
|
||
status: "delivered",
|
||
label: pipelinePromptMarkerLabel(record, kind),
|
||
ms,
|
||
timestampIso,
|
||
sourceKind: String(record?.sourceKind || ""),
|
||
sourceNodeId: String(record?.sourceNodeId || ""),
|
||
targetNodeId: nodeId,
|
||
action: "",
|
||
eventId,
|
||
commandId: String(record?.commandId || ""),
|
||
raw: record,
|
||
}, promptMarkers);
|
||
}
|
||
const fallbackSources = [
|
||
{ records: structuredEventRecords(attempt?.controlPromptRecords), fallbackKind: "append-prompt-queued" },
|
||
{ records: structuredEventRecords(attempt?.monitorPromptRecords), fallbackKind: "monitor-prompt-queued" },
|
||
];
|
||
for (const source of fallbackSources) {
|
||
for (const record of source.records) {
|
||
const timestampIso = eventTimestampIso(record);
|
||
const ms = timeMs(timestampIso);
|
||
if (ms === null) continue;
|
||
const eventId = String(record?.eventId || "");
|
||
if (eventId && deliveredIds.has(eventId)) continue;
|
||
const deliveredKind = source.fallbackKind === "monitor-prompt-queued" ? "monitor-prompt-delivered" : "append-prompt-delivered";
|
||
const fallbackKey = `${deliveredKind}:${timestampIso}:${String(record?.sourceKind || "")}:${String(record?.promptPreview || "")}`;
|
||
if (deliveredFallbackKeys.has(fallbackKey)) continue;
|
||
addMarker({
|
||
id: `prompt-fallback:${eventId || `${procedureRunId}:${attemptId}:${source.fallbackKind}:${ms}`}`,
|
||
runId,
|
||
nodeId,
|
||
procedureRunId,
|
||
attempt: attemptId,
|
||
kind: "prompt",
|
||
tone: pipelinePromptMarkerTone(record, source.fallbackKind),
|
||
status: "queued",
|
||
label: pipelinePromptMarkerLabel(record, source.fallbackKind),
|
||
ms,
|
||
timestampIso,
|
||
sourceKind: String(record?.sourceKind || ""),
|
||
sourceNodeId: String(record?.sourceNodeId || ""),
|
||
targetNodeId: nodeId,
|
||
action: "",
|
||
eventId,
|
||
commandId: String(record?.commandId || ""),
|
||
raw: record,
|
||
}, promptMarkers);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const grouped = new Map<string, AnyRecord>();
|
||
structuredEventRecords(details?.controlEvents).forEach((record: AnyRecord, index: number) => {
|
||
const key = controlRecordGroupKey(record, index);
|
||
const group = grouped.get(key) || { key, events: [], commands: [] };
|
||
group.events.push(record);
|
||
grouped.set(key, group);
|
||
});
|
||
asArray(details?.controlCommands).filter(isRecord).forEach((record: AnyRecord, index: number) => {
|
||
const key = controlRecordGroupKey(record, index);
|
||
const group = grouped.get(key) || { key, events: [], commands: [] };
|
||
group.commands.push(record);
|
||
grouped.set(key, group);
|
||
});
|
||
|
||
for (const group of grouped.values()) {
|
||
const events = asArray(group.events).slice().sort((left: any, right: any) => controlEventPriority(right) - controlEventPriority(left));
|
||
const commands = asArray(group.commands);
|
||
const queuedRecord = asArray(group.events).find((record: any) => eventKind(record) === "control-command-queued") || commands[0] || null;
|
||
const outcomeRecord = events[0] || commands[0] || queuedRecord;
|
||
if (!queuedRecord && !outcomeRecord) continue;
|
||
const sourceNodeId = String(queuedRecord?.sourceNodeId || outcomeRecord?.sourceNodeId || "");
|
||
const sourceKind = String(queuedRecord?.sourceKind || outcomeRecord?.sourceKind || "");
|
||
const sourceTimestampIso = eventTimestampIso(queuedRecord) || eventTimestampIso(outcomeRecord) || firstIso(queuedRecord?.createdAt, outcomeRecord?.createdAt);
|
||
const sourceMs = timeMs(sourceTimestampIso);
|
||
const commandId = String(outcomeRecord?.commandId || queuedRecord?.commandId || group.key);
|
||
const outcomeStatus = (eventKind(outcomeRecord) || "control-command-queued").replace(/^control-command-/u, "");
|
||
let sourceMarkerId = "";
|
||
if (sourceNodeId && sourceMs !== null) {
|
||
sourceMarkerId = `control-source:${commandId}:${sourceNodeId}`;
|
||
sourceMarkerByCommand.set(commandId, sourceMarkerId);
|
||
addMarker({
|
||
id: sourceMarkerId,
|
||
runId,
|
||
nodeId: sourceNodeId,
|
||
procedureRunId: String(queuedRecord?.procedureRunId || outcomeRecord?.procedureRunId || ""),
|
||
attempt: "",
|
||
kind: "control-source",
|
||
tone: controlSourceTone(queuedRecord || outcomeRecord),
|
||
status: outcomeStatus,
|
||
label: `${controlActionLabel(queuedRecord || outcomeRecord)} 发起`,
|
||
ms: sourceMs,
|
||
timestampIso: sourceTimestampIso,
|
||
action: controlActionValue(queuedRecord || outcomeRecord),
|
||
sourceKind,
|
||
sourceNodeId,
|
||
targetNodeId: String(outcomeRecord?.targetNodeId || queuedRecord?.targetNodeId || ""),
|
||
commandId,
|
||
raw: queuedRecord || outcomeRecord,
|
||
}, controlMarkers);
|
||
}
|
||
const targetRecord = outcomeRecord || queuedRecord;
|
||
const targetTimestampIso = eventTimestampIso(targetRecord) || sourceTimestampIso;
|
||
const targetMs = timeMs(targetTimestampIso);
|
||
if (targetMs === null) continue;
|
||
const targetIds = controlTargetNodeIds(targetRecord);
|
||
for (const targetNodeId of targetIds) {
|
||
const placement = controlTargetMarkerPlacement(intervals, targetNodeId, targetMs, targetRecord);
|
||
const targetMarkerId = `control-target:${commandId}:${targetNodeId}`;
|
||
addMarker({
|
||
id: targetMarkerId,
|
||
runId,
|
||
nodeId: targetNodeId,
|
||
procedureRunId: placement.procedureRunId,
|
||
attempt: "",
|
||
kind: "control-target",
|
||
tone: controlTargetTone(targetRecord),
|
||
status: outcomeStatus,
|
||
label: controlTargetLabel(targetRecord, targetNodeId),
|
||
ms: placement.ms,
|
||
eventMs: targetMs,
|
||
onInterval: placement.onInterval,
|
||
snapReason: placement.snapReason,
|
||
snapped: Number(placement.ms) !== targetMs,
|
||
timestampIso: targetTimestampIso,
|
||
renderedTimestampIso: isoFromMs(Number(placement.ms)),
|
||
action: controlActionValue(targetRecord),
|
||
sourceKind,
|
||
sourceNodeId,
|
||
targetNodeId,
|
||
commandId,
|
||
raw: targetRecord,
|
||
}, controlMarkers);
|
||
if (sourceMarkerId && sourceNodeId && sourceNodeId !== targetNodeId) {
|
||
controlArrows.push({
|
||
id: `control-arrow:${commandId}:${sourceNodeId}:${targetNodeId}`,
|
||
commandId,
|
||
sourceNodeId,
|
||
targetNodeId,
|
||
sourceMarkerId,
|
||
targetMarkerId,
|
||
sourceKind,
|
||
action: controlActionValue(targetRecord),
|
||
status: outcomeStatus,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
const markers = [...promptMarkers, ...controlMarkers].sort((left: AnyRecord, right: AnyRecord) =>
|
||
Number(left.ms) - Number(right.ms)
|
||
|| String(left.nodeId).localeCompare(String(right.nodeId))
|
||
|| String(left.id).localeCompare(String(right.id)));
|
||
return { ...pipelineGanttAddObservationArrows(markers, controlArrows), sourceMarkerByCommand };
|
||
}
|
||
|
||
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 PipelineOaEventFlowPanel({ diagnostics, onRaw }: AnyRecord) {
|
||
const runs = asArray(diagnostics?.runs).filter(isRecord);
|
||
const forbiddenResiduals = asArray(diagnostics?.forbiddenResiduals);
|
||
const guarantees = isRecord(diagnostics?.guarantees) ? diagnostics.guarantees : {};
|
||
const evidenceOk = diagnostics?.hasNeutralNodeFinishedEvidence === true
|
||
&& diagnostics?.hasNoAuditPolicyEvidence === true
|
||
&& diagnostics?.hasAuditPolicyEvidence === true;
|
||
const ok = diagnostics?.ok === true && evidenceOk && forbiddenResiduals.length === 0;
|
||
const latestRun = runs[0] || null;
|
||
const guaranteeItems = [
|
||
{ label: "中性完成事实", ok: guarantees.neutralNodeFinished === true, hint: "node-finished 不携带流程策略" },
|
||
{ label: "Config 策略判定", ok: guarantees.auditPolicyFromConfig === true, hint: "OA backend 读取当前 epoch 配置" },
|
||
{ label: "控制命令来自 OA", ok: guarantees.runnerConsumesControlCommandsFromOaEvents === true, hint: "runner 只消费 OA control.command" },
|
||
{ label: "无独立审核事件", ok: guarantees.noIndependentAuditRequestEvent === true, hint: "审核由 node-finished + policy 派生" },
|
||
{ label: "无批次门禁", ok: guarantees.noBatchFinishedControlGate === true, hint: "下游启动由每个 node 完成驱动" },
|
||
];
|
||
return h("div", { className: "pipeline-oa-panel", "data-testid": "pipeline-oa-event-flow-panel" },
|
||
h("div", { className: "metric-grid compact" },
|
||
h(MetricCard, { label: "OA Flow", value: ok ? "100%" : "--", hint: String(diagnostics?.mode || "waiting diagnostics"), tone: ok ? "ok" : "warn" }),
|
||
h(MetricCard, { label: "禁止残留", value: forbiddenResiduals.length, hint: forbiddenResiduals.length === 0 ? "source scan clean" : "needs cleanup", tone: forbiddenResiduals.length === 0 ? "ok" : "warn" }),
|
||
h(MetricCard, { label: "No-audit", value: diagnostics?.hasNoAuditPolicyEvidence ? "OK" : "--", hint: "OA 下游策略证据", tone: diagnostics?.hasNoAuditPolicyEvidence ? "ok" : "warn" }),
|
||
h(MetricCard, { label: "Monitor 审核", value: diagnostics?.hasAuditPolicyEvidence ? "OK" : "--", hint: "OA 控制事件闭环", tone: diagnostics?.hasAuditPolicyEvidence ? "ok" : "warn" }),
|
||
),
|
||
h("div", { className: "pipeline-oa-guarantees" },
|
||
guaranteeItems.map((item) => h("article", { key: item.label, className: `pipeline-oa-guarantee ${item.ok ? "ok" : "warn"}` },
|
||
h(StatusBadge, { status: item.ok ? "online" : "warn" }, item.ok ? "OK" : "MISS"),
|
||
h("div", null, h("strong", null, item.label), h("span", null, item.hint)),
|
||
)),
|
||
),
|
||
h("div", { className: "pipeline-evidence-list compact" },
|
||
runs.slice(0, 6).map((run: AnyRecord) => h(EvidenceIndexRow, {
|
||
key: run.runId,
|
||
title: String(run.runId || "--"),
|
||
subtitle: [
|
||
Number(run.monitorAuditNodeFinishedCount || 0) > 0 ? "monitor audit" : "",
|
||
Number(run.noAuditPolicyCount || 0) > 0 ? "no-audit policy" : "",
|
||
].filter(Boolean).join(" / ") || "event evidence",
|
||
facts: [
|
||
`events ${run.eventCount || 0}`,
|
||
`node-finished ${run.nodeFinishedCount || 0}`,
|
||
`policy-in-detail ${run.nodeFinishedWithPolicyCount || 0}`,
|
||
`queued ${run.controlQueuedCount || 0}`,
|
||
`applied ${run.controlAppliedCount || 0}`,
|
||
],
|
||
data: run,
|
||
onRaw,
|
||
testId: `raw-pipeline-oa-run-${String(run.runId || "run").replace(/[^a-zA-Z0-9_.-]+/g, "-")}`,
|
||
})),
|
||
),
|
||
latestRun ? h("p", { className: "muted paragraph" }, `最新证据 ${latestRun.runId}: ${latestRun.nodeFinishedCount || 0} 个 node-finished,${latestRun.controlAppliedCount || 0} 个控制结果。`) : h(EmptyState, { title: "暂无 OA 事件流证据", text: "等待 Pipeline backend 暴露 diagnostics。" }),
|
||
diagnostics ? h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "Pipeline OA Event Flow Diagnostics", data: diagnostics, onOpen: onRaw, testId: "raw-pipeline-oa-event-flow" })) : null,
|
||
);
|
||
}
|
||
|
||
function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, pipelineEdges, runDetails, nodeDetails, ganttScale = pipelineDefaultGanttScale, onGanttScaleChange, onRunChange, onIntervalSelect, onMarkerSelect, selection, onRaw }: AnyRecord) {
|
||
const [autoHideIdle, setAutoHideIdle] = useState(pipelineDefaultGanttAutoHideIdle);
|
||
const [visibleRange, setVisibleRange] = useState({ startY: 0, endY: 0, startMs: 0, endMs: 0 });
|
||
const [liveNowMs, setLiveNowMs] = useState(Date.now());
|
||
const viewportRef = useRef(null);
|
||
const activeRunId = String(activeRun?.runId || "");
|
||
const timeScale = clampPipelineGanttScale(ganttScale ?? pipelineDefaultGanttScale);
|
||
const fallbackIntervals = pipelineRunIntervals(activeRun, pipelineNodes, liveNowMs);
|
||
const runDetailPayload = String(runDetails?.runId || "") === activeRunId ? runDetails?.details : null;
|
||
const backendGantt = isRecord(runDetailPayload?.gantt) ? runDetailPayload.gantt : null;
|
||
const baseBackendLayout = pipelineGanttBackendLayout(backendGantt);
|
||
const hasBackendLayout = Boolean(baseBackendLayout);
|
||
const rawIntervals = hasBackendLayout
|
||
? asArray(backendGantt?.intervals).filter(isRecord).map((interval: AnyRecord) => ({ ...interval, runId: activeRunId }))
|
||
: fallbackIntervals;
|
||
const hasLiveRunning = rawIntervals.some(pipelineGanttIntervalIsRunning);
|
||
useEffect(() => {
|
||
if (!activeRunId || !hasLiveRunning) return undefined;
|
||
const timer = window.setInterval(() => setLiveNowMs(Date.now()), 1000);
|
||
return () => window.clearInterval(timer);
|
||
}, [activeRunId, hasLiveRunning]);
|
||
const backendLayout = hasBackendLayout ? pipelineGanttLiveBackendLayout(baseBackendLayout, rawIntervals, liveNowMs) : baseBackendLayout;
|
||
const intervals = hasBackendLayout
|
||
? rawIntervals.map((interval: AnyRecord) => pipelineGanttNormalizeLiveInterval(interval, backendLayout, liveNowMs))
|
||
: rawIntervals;
|
||
const bounds = hasBackendLayout ? {
|
||
startMs: Number(backendLayout?.startMs),
|
||
endMs: Number(backendLayout?.endMs),
|
||
durationMs: Math.max(1, Number(backendLayout?.durationMs ?? Number(backendLayout?.endMs) - Number(backendLayout?.startMs))),
|
||
} : pipelineRunTimeBounds(activeRun, intervals);
|
||
const backendScaleValue = hasBackendLayout ? Number(backendLayout?.scale ?? timeScale) : timeScale;
|
||
const fallbackScaleConfig = pipelineGanttScaleConfig(backendScaleValue);
|
||
const timeScaleConfig = hasBackendLayout ? {
|
||
...fallbackScaleConfig,
|
||
pxPerMinute: Number(backendLayout?.pxPerMinute ?? fallbackScaleConfig.pxPerMinute),
|
||
} : pipelineGanttScaleConfig(timeScale);
|
||
const chartHeight = hasBackendLayout ? Math.round(Number(backendLayout?.chartHeight || 360)) : pipelineGanttHeight(bounds, timeScale);
|
||
const ganttSignals = hasBackendLayout ? pipelineBackendGanttSignals(runDetailPayload) : runDetailPayload ? pipelineRunGanttSignals(runDetailPayload, activeRun) : { markers: [], arrows: [] };
|
||
const allMarkers = asArray(ganttSignals.markers);
|
||
const graphNodeOrder = pipelineGraphNodeOrder(activePipeline, pipelineNodes, Array.isArray(pipelineEdges) ? pipelineEdges : []);
|
||
const configuredNodeIds = pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean);
|
||
const layoutNodeIds = hasBackendLayout ? asArray(backendLayout?.nodeOrder).map((nodeId: any) => String(nodeId || "")).filter(Boolean) : [];
|
||
const intervalNodeIds = intervals.map((interval: AnyRecord) => String(interval.nodeId || "")).filter(Boolean);
|
||
const markerNodeIds = allMarkers.map((marker: AnyRecord) => String(marker.nodeId || "")).filter(Boolean);
|
||
const allNodeIds = Array.from(new Set([
|
||
...graphNodeOrder,
|
||
...layoutNodeIds,
|
||
...configuredNodeIds,
|
||
...intervalNodeIds,
|
||
...markerNodeIds,
|
||
]));
|
||
const defaultVisibleRange = { startY: 0, endY: chartHeight, startMs: Number(bounds.startMs), endMs: Number(bounds.endMs) };
|
||
const safeRange = Number(visibleRange?.endY || 0) > 0 ? visibleRange : defaultVisibleRange;
|
||
const intervalIsVisible = (interval: AnyRecord): boolean => {
|
||
if (hasBackendLayout) {
|
||
return pipelineGanttIntervalTop(interval, bounds, chartHeight, backendLayout) <= Number(safeRange.endY)
|
||
&& pipelineGanttIntervalBottom(interval, bounds, chartHeight, backendLayout) >= Number(safeRange.startY);
|
||
}
|
||
return intervalOverlaps(interval, safeRange);
|
||
};
|
||
const markerIsVisible = (marker: AnyRecord): boolean => {
|
||
if (hasBackendLayout) {
|
||
const y = pipelineGanttMarkerY(marker, bounds, chartHeight, backendLayout);
|
||
return y >= Number(safeRange.startY) && y <= Number(safeRange.endY);
|
||
}
|
||
return Number(marker.ms) >= Number(safeRange.startMs) && Number(marker.ms) <= Number(safeRange.endMs);
|
||
};
|
||
const activeNodeIds = new Set(allNodeIds.filter((nodeId) =>
|
||
intervals.some((interval: AnyRecord) => interval.nodeId === nodeId && intervalIsVisible(interval))
|
||
|| allMarkers.some((marker: AnyRecord) => marker.nodeId === nodeId && markerIsVisible(marker))));
|
||
const visibleNodeIds = autoHideIdle ? allNodeIds.filter((nodeId) => activeNodeIds.has(nodeId)) : allNodeIds;
|
||
const gridTemplateColumns = `${pipelineGanttTimeAxisWidth}px ${visibleNodeIds.length > 0 ? visibleNodeIds.map(() => `${pipelineGanttNodeColumnWidth}px`).join(" ") : "minmax(160px, 1fr)"}`;
|
||
const backendTicks = hasBackendLayout ? asArray(backendLayout?.ticks).filter(isRecord) : [];
|
||
const ticks = backendTicks.length > 0 ? backendTicks : pipelineGanttTicks(bounds, Math.max(5, Math.min(18, Math.round(chartHeight / 150))));
|
||
const selectedIntervalKey = String(selection?.mode === "interval" ? selection?.interval?.procedureRunId || "" : "");
|
||
const selectedMarkerKey = String(selection?.mode === "event" ? selection?.marker?.id || "" : "");
|
||
const updateVisibleRange = () => {
|
||
const element = viewportRef.current as HTMLElement | null;
|
||
if (!element) {
|
||
setVisibleRange(defaultVisibleRange);
|
||
return;
|
||
}
|
||
const topY = Math.max(0, element.scrollTop - pipelineGanttHeaderHeight);
|
||
const visiblePx = Math.max(120, element.clientHeight - pipelineGanttHeaderHeight);
|
||
const bottomY = Math.min(chartHeight, topY + visiblePx);
|
||
const nextRange: AnyRecord = { startY: topY, endY: bottomY, startMs: Number(bounds.startMs), endMs: Number(bounds.endMs) };
|
||
if (!hasBackendLayout) {
|
||
const startRatio = Math.max(0, Math.min(1, topY / chartHeight));
|
||
const endRatio = Math.max(startRatio, Math.min(1, bottomY / chartHeight));
|
||
const duration = Math.max(1, Number(bounds.endMs) - Number(bounds.startMs));
|
||
nextRange.startMs = Number(bounds.startMs) + duration * startRatio;
|
||
nextRange.endMs = Number(bounds.startMs) + duration * endRatio;
|
||
}
|
||
setVisibleRange(nextRange);
|
||
};
|
||
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, hasBackendLayout]);
|
||
const hiddenCount = Math.max(0, allNodeIds.length - visibleNodeIds.length);
|
||
const visibleMarkerIds = new Set(allMarkers.filter((marker: AnyRecord) =>
|
||
visibleNodeIds.includes(String(marker.nodeId || "")) && markerIsVisible(marker)).map((marker: AnyRecord) => String(marker.id)));
|
||
const markerById = new Map(allMarkers.map((marker: AnyRecord) => [String(marker.id), marker]));
|
||
const visibleArrows = asArray(ganttSignals.arrows).filter((arrow: AnyRecord) => {
|
||
const targetVisible = visibleMarkerIds.has(String(arrow.targetMarkerId || ""));
|
||
if (!targetVisible) return false;
|
||
if (String(arrow.action || "") === "observe") return visibleNodeIds.includes(String(arrow.sourceNodeId || ""));
|
||
return visibleMarkerIds.has(String(arrow.sourceMarkerId || ""));
|
||
});
|
||
const boardMinWidth = pipelineGanttTimeAxisWidth + Math.max(1, visibleNodeIds.length) * pipelineGanttNodeColumnWidth;
|
||
const setScaleFromSlider = (event: any) => {
|
||
const nextScale = clampPipelineGanttScale(event.target.value);
|
||
if (typeof onGanttScaleChange === "function") onGanttScaleChange(nextScale);
|
||
window.setTimeout(updateVisibleRange, 0);
|
||
};
|
||
const exportCurrentGantt = () => exportPipelineGantt({
|
||
title: `${activePipeline?.id || "pipeline"}-${activeRunId || "epoch"}-gantt`,
|
||
meta: [
|
||
`run ${activeRunId || "--"}`,
|
||
`${fmtDate(bounds.startMs)} -> ${fmtDate(bounds.endMs)}`,
|
||
`duration ${fmtDurationMs(bounds.durationMs)}`,
|
||
`${timeScaleConfig.label} / ${pipelineDisplayPxPerMinute(timeScaleConfig.pxPerMinute)} px/min`,
|
||
`${visibleNodeIds.length}/${allNodeIds.length} nodes`,
|
||
`${allMarkers.length} markers`,
|
||
],
|
||
visibleNodeIds,
|
||
intervals,
|
||
markers: allMarkers.filter((marker: AnyRecord) => visibleNodeIds.includes(String(marker.nodeId || ""))),
|
||
arrows: visibleArrows,
|
||
ticks,
|
||
bounds,
|
||
chartHeight,
|
||
backendLayout,
|
||
});
|
||
const diagnostics = isRecord(backendGantt?.diagnostics) ? backendGantt.diagnostics : null;
|
||
return h(Panel, {
|
||
title: "Epoch 甘特图",
|
||
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",
|
||
"data-testid": "pipeline-gantt-auto-hide-idle",
|
||
checked: autoHideIdle,
|
||
onChange: (event: any) => {
|
||
setAutoHideIdle(Boolean(event.target.checked));
|
||
window.setTimeout(updateVisibleRange, 0);
|
||
},
|
||
}),
|
||
h("span", null, "自动隐藏空闲列"),
|
||
),
|
||
h("label", { className: "pipeline-gantt-scale" },
|
||
h("span", null,
|
||
h("b", null, "时间尺度"),
|
||
h("em", { "data-testid": "pipeline-gantt-scale-label" }, `${timeScaleConfig.label} · ${pipelineDisplayPxPerMinute(timeScaleConfig.pxPerMinute)} px/min`),
|
||
),
|
||
h("input", {
|
||
type: "range",
|
||
min: 0,
|
||
max: 100,
|
||
step: 0.01,
|
||
value: timeScale,
|
||
onChange: setScaleFromSlider,
|
||
"aria-label": "调整甘特图时间尺度",
|
||
"data-testid": "pipeline-gantt-time-scale",
|
||
}),
|
||
h("small", null, h("span", null, "全局"), h("span", null, "细节")),
|
||
),
|
||
activeRun ? h("button", {
|
||
type: "button",
|
||
className: "ghost-btn",
|
||
onClick: exportCurrentGantt,
|
||
disabled: visibleNodeIds.length === 0,
|
||
"data-testid": "pipeline-export-gantt",
|
||
}, "导出甘特图") : null,
|
||
activeRun ? h(RawButton, { title: `Pipeline Epoch ${activeRun.runId}`, data: activeRun, onOpen: onRaw, testId: "raw-pipeline-epoch-gantt" }) : null,
|
||
),
|
||
},
|
||
!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, `scale ${timeScaleConfig.label} / ${pipelineDisplayPxPerMinute(timeScaleConfig.pxPerMinute)} px/min`),
|
||
h("span", null, `layout ${hasBackendLayout ? "backend-y" : "fallback"}`),
|
||
diagnostics ? h("span", null, `align ${diagnostics.timeAxisAlignmentOk === false ? "check" : "ok"}`) : null,
|
||
h("span", null, `visible ${visibleNodeIds.length}/${allNodeIds.length} nodes`),
|
||
runDetailPayload ? h("span", null, `markers ${allMarkers.length}`) : null,
|
||
autoHideIdle && hiddenCount > 0 ? h("span", null, `hidden idle ${hiddenCount}`) : null,
|
||
),
|
||
h("div", { className: "pipeline-gantt-viewport", ref: viewportRef, "data-testid": "pipeline-epoch-gantt", "data-pipeline-id": activePipeline?.id || "", "data-run-id": activeRunId },
|
||
h("div", { className: "pipeline-gantt-board", style: { gridTemplateColumns, minWidth: `${boardMinWidth}px` } },
|
||
h("div", { className: "pipeline-gantt-head time" }, "Time"),
|
||
visibleNodeIds.length === 0 ? h("div", { className: "pipeline-gantt-head empty" }, "当前时间窗无工作节点") :
|
||
visibleNodeIds.map((nodeId) => h("div", { key: `head-${nodeId}`, className: "pipeline-gantt-head node", title: nodeId, "data-testid": "pipeline-gantt-head-node", "data-node-id": nodeId }, h(GanttHeaderLabel, { value: nodeId }))),
|
||
h("div", { className: "pipeline-gantt-time-axis", style: { height: `${chartHeight}px` } },
|
||
ticks.map((tick: AnyRecord) => {
|
||
const tickY = pipelineGanttTickY(tick, bounds, chartHeight, backendLayout);
|
||
return h("div", { key: `tick-${tick.ms}-${tickY}`, className: "pipeline-gantt-tick", style: { top: `${tickY}px` } },
|
||
h("b", null, fmtDate(tick.ms)),
|
||
h("span", null, `+${fmtDurationMs(Number(tick.offsetMs ?? Number(tick.ms) - Number(bounds.startMs)))}`),
|
||
);
|
||
}),
|
||
),
|
||
visibleNodeIds.length > 0 ? h("svg", {
|
||
className: "pipeline-gantt-arrow-layer",
|
||
width: visibleNodeIds.length * pipelineGanttNodeColumnWidth,
|
||
height: chartHeight,
|
||
viewBox: `0 0 ${visibleNodeIds.length * pipelineGanttNodeColumnWidth} ${chartHeight}`,
|
||
style: {
|
||
left: `${pipelineGanttTimeAxisWidth}px`,
|
||
top: `${pipelineGanttHeaderHeight}px`,
|
||
width: `${visibleNodeIds.length * pipelineGanttNodeColumnWidth}px`,
|
||
height: `${chartHeight}px`,
|
||
},
|
||
"aria-hidden": "true",
|
||
},
|
||
h("defs", null,
|
||
h("marker", {
|
||
id: "pipeline-gantt-arrowhead",
|
||
viewBox: "0 0 10 10",
|
||
refX: 9,
|
||
refY: 5,
|
||
markerWidth: 6,
|
||
markerHeight: 6,
|
||
orient: "auto-start-reverse",
|
||
},
|
||
h("path", { d: "M 0 0 L 10 5 L 0 10 z", fill: "context-stroke" }),
|
||
),
|
||
),
|
||
visibleArrows.map((arrow: AnyRecord) => {
|
||
const targetMarker = markerById.get(String(arrow.targetMarkerId || ""));
|
||
if (!targetMarker) return null;
|
||
const sourceMarker = markerById.get(String(arrow.sourceMarkerId || ""));
|
||
const sourceNodeId = String(sourceMarker?.nodeId || arrow.sourceNodeId || "");
|
||
const sourceIndex = visibleNodeIds.indexOf(sourceNodeId);
|
||
const targetIndex = visibleNodeIds.indexOf(String(targetMarker.nodeId || ""));
|
||
if (sourceIndex < 0 || targetIndex < 0) return null;
|
||
const sourceX = sourceIndex * pipelineGanttNodeColumnWidth + pipelineGanttNodeColumnWidth / 2;
|
||
const targetX = targetIndex * pipelineGanttNodeColumnWidth + pipelineGanttNodeColumnWidth / 2;
|
||
const sourceY = pipelineGanttNumber(arrow.sourceY ?? arrow.y1)
|
||
?? (sourceMarker ? pipelineGanttMarkerY(sourceMarker, bounds, chartHeight, backendLayout) : pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout));
|
||
const targetY = pipelineGanttNumber(arrow.targetY ?? arrow.y2) ?? pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout);
|
||
return h("path", {
|
||
key: arrow.id,
|
||
className: `pipeline-gantt-arrow ${String(arrow.sourceKind || "").toLowerCase()} ${String(arrow.status || "").toLowerCase()} ${String(arrow.action || "").toLowerCase()}`,
|
||
d: pipelineControlArrowPath(sourceX, sourceY, targetX, targetY),
|
||
markerEnd: "url(#pipeline-gantt-arrowhead)",
|
||
"data-testid": String(arrow.action || "") === "observe" ? "pipeline-gantt-observation-arrow" : "pipeline-gantt-arrow",
|
||
"data-source-node-id": String(arrow.sourceNodeId || ""),
|
||
"data-target-node-id": String(arrow.targetNodeId || ""),
|
||
"data-target-marker-id": String(arrow.targetMarkerId || ""),
|
||
"data-action": String(arrow.action || ""),
|
||
});
|
||
}),
|
||
) : null,
|
||
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);
|
||
const nodeMarkers = allMarkers.filter((marker: AnyRecord) => String(marker.nodeId || "") === nodeId);
|
||
return h("div", { key: `col-${nodeId}`, className: "pipeline-gantt-node-col", style: { height: `${chartHeight}px` } },
|
||
nodeIntervals.map((interval: AnyRecord) => {
|
||
const top = pipelineGanttIntervalTop(interval, bounds, chartHeight, backendLayout);
|
||
const height = pipelineGanttIntervalHeight(interval, bounds, chartHeight, backendLayout);
|
||
const intervalKey = String(interval.procedureRunId || `${nodeId}-${interval.startMs}`);
|
||
return h("button", {
|
||
key: intervalKey,
|
||
type: "button",
|
||
className: `pipeline-gantt-bar ${interval.status} ${interval.live ? "live" : ""} ${selectedIntervalKey === intervalKey ? "selected" : ""}`,
|
||
style: { top: `${top}px`, height: `${height}px` },
|
||
title: `${nodeId} ${interval.status} ${fmtDate(interval.startedAt || interval.startMs)} -> ${fmtDate(interval.finishedAt || interval.endMs)}`,
|
||
onClick: () => onIntervalSelect(interval),
|
||
"data-testid": "pipeline-gantt-line",
|
||
"data-node-id": nodeId,
|
||
"data-procedure-run-id": String(interval.procedureRunId || ""),
|
||
"data-status": String(interval.status || ""),
|
||
"data-live": interval.live ? "true" : "false",
|
||
},
|
||
h("strong", null, interval.status || "working"),
|
||
h("span", null, fmtDurationMs(interval.durationMs)),
|
||
);
|
||
}),
|
||
nodeMarkers.map((marker: AnyRecord) => h("button", {
|
||
key: marker.id,
|
||
type: "button",
|
||
className: `pipeline-gantt-marker ${marker.kind} ${marker.tone || ""} ${marker.status || ""} ${selectedMarkerKey === String(marker.id) ? "selected" : ""}`,
|
||
style: { top: `${pipelineGanttMarkerY(marker, bounds, chartHeight, backendLayout)}px` },
|
||
title: `${marker.label || "event"} / ${fmtDate(marker.timestampIso || marker.timestamp || marker.ms)}`,
|
||
onClick: () => onMarkerSelect(marker),
|
||
"data-testid": marker.kind === "prompt" ? "pipeline-gantt-prompt-marker" : "pipeline-gantt-control-marker",
|
||
"data-marker-id": String(marker.id || ""),
|
||
})),
|
||
);
|
||
}),
|
||
),
|
||
),
|
||
),
|
||
h(PipelineGanttDetailPanel, { selection, runDetails, nodeDetails, onRaw }),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
function pipelineNodeControlState(): AnyRecord {
|
||
return {
|
||
loading: false,
|
||
actionLoading: "",
|
||
error: "",
|
||
message: "",
|
||
details: null,
|
||
fetchedAt: null,
|
||
appendPrompt: "",
|
||
guidePrompt: "",
|
||
modifyPrompt: "",
|
||
approveReason: "",
|
||
redoReason: "",
|
||
};
|
||
}
|
||
|
||
function pipelineGanttSelectionState(): AnyRecord {
|
||
return {
|
||
mode: "",
|
||
runId: "",
|
||
interval: null,
|
||
marker: null,
|
||
};
|
||
}
|
||
|
||
function pipelineRunDetailsState(): AnyRecord {
|
||
return {
|
||
runId: "",
|
||
loading: false,
|
||
error: "",
|
||
details: null,
|
||
fetchedAt: null,
|
||
};
|
||
}
|
||
|
||
function pipelineProxyPath(apiBaseUrl: string, path: string): string {
|
||
return `${apiBaseUrl}/microservices/pipeline/proxy${path}`;
|
||
}
|
||
|
||
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 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] : []);
|
||
return h("aside", { className: "pipeline-node-control", "data-testid": "pipeline-node-control" },
|
||
h("div", { className: "pipeline-node-control-head" },
|
||
h("div", null,
|
||
h("p", { className: "panel-eyebrow" }, "Manual Node Control"),
|
||
h("h3", null, selectedNodeId || "点击控制图中的 node"),
|
||
),
|
||
selectedNodeId ? h(StatusBadge, { status }, status) : h(StatusBadge, { status: "pending" }, "idle"),
|
||
),
|
||
h("div", { className: "pipeline-control-runbar" },
|
||
h("label", null,
|
||
h("span", null, "目标 run"),
|
||
h("select", {
|
||
value: runId || selectedRunId,
|
||
disabled: runOptions.length === 0,
|
||
onChange: (event: any) => onRunChange(event.target.value),
|
||
"data-testid": "pipeline-node-run-select",
|
||
}, runOptions.map((run: any) => h("option", { key: run.runId, value: run.runId }, `${run.runId || "--"} / ${run.status || "--"}`))),
|
||
),
|
||
h("button", {
|
||
type: "button",
|
||
className: "ghost-btn",
|
||
disabled,
|
||
onClick: onFetch,
|
||
"data-testid": "pipeline-node-fetch",
|
||
}, control.loading ? "抓取中" : "抓取过程"),
|
||
control.details ? h(RawButton, { title: `Pipeline Node ${selectedNodeId}`, data: control.details, onOpen: onRaw, testId: "raw-pipeline-node-control" }) : null,
|
||
),
|
||
h("div", { className: "pipeline-control-meta" },
|
||
h("span", null, h("b", null, "kind"), String(selectedNodeConfig?.kind || "--")),
|
||
h("span", null, h("b", null, "procedure"), String(selectedNodeRuntime?.currentProcedureRunId || "--")),
|
||
h("span", null, h("b", null, "attempts"), String(selectedNodeRuntime?.attempts ?? "--")),
|
||
h("span", null, h("b", null, "updated"), fmtDate(activeRun?.updatedAt)),
|
||
),
|
||
!selectedNodeId ? h(EmptyState, { title: "未选择 node", text: "点击 React Flow 控制图中的任意 node 后,可抓取执行过程、追加 prompt、下发引导、增量修改、审核通过或重做。" }) : null,
|
||
control.error ? h("div", { className: "form-error wide" }, control.error) : null,
|
||
control.message ? h("div", { className: "form-success wide" }, control.message) : null,
|
||
h("div", { className: "pipeline-control-actions" },
|
||
h("label", null,
|
||
h("span", null, "实时追加 prompt(仅 running node)"),
|
||
h("textarea", { value: control.appendPrompt, onChange: updateText("appendPrompt"), placeholder: "让当前执行中的 agent 继续、补充检查或调整当前步骤...", rows: 4, disabled: !selectedNodeId, "data-testid": "pipeline-node-append-input" }),
|
||
h("button", {
|
||
type: "button",
|
||
className: "primary-btn compact",
|
||
disabled: disabled || !String(control.appendPrompt || "").trim(),
|
||
onClick: () => onAction("append"),
|
||
"data-testid": "pipeline-node-append-button",
|
||
}, control.actionLoading === "append" ? "追加中" : "追加到运行中 node"),
|
||
),
|
||
h("label", null,
|
||
h("span", null, "下次尝试引导 prompt"),
|
||
h("textarea", { value: control.guidePrompt, onChange: updateText("guidePrompt"), placeholder: "给该 node 下一次 attempt 的执行提示;不会立即打断当前 session。", rows: 4, disabled: !selectedNodeId, "data-testid": "pipeline-node-guide-input" }),
|
||
h("button", {
|
||
type: "button",
|
||
className: "ghost-btn compact",
|
||
disabled: disabled || !String(control.guidePrompt || "").trim(),
|
||
onClick: () => onAction("guide"),
|
||
"data-testid": "pipeline-node-guide-button",
|
||
}, control.actionLoading === "guide" ? "下发中" : "下发 guide"),
|
||
),
|
||
h("label", null,
|
||
h("span", null, "完成后增量修改 prompt"),
|
||
h("textarea", { value: control.modifyPrompt, onChange: updateText("modifyPrompt"), placeholder: "在该 node 已完成结果基础上追加修改要求;runner 会重跑目标 node,并保留同 node 既有 OA 输出作为上下文。", rows: 4, disabled: !selectedNodeId, "data-testid": "pipeline-node-modify-input" }),
|
||
h("button", {
|
||
type: "button",
|
||
className: "ghost-btn compact",
|
||
disabled: disabled || !String(control.modifyPrompt || "").trim(),
|
||
onClick: () => onAction("modify"),
|
||
"data-testid": "pipeline-node-modify-button",
|
||
}, control.actionLoading === "modify" ? "排队中" : "增量修改 node"),
|
||
),
|
||
h("label", null,
|
||
h("span", null, "Monitor 审核通过原因"),
|
||
h("textarea", { value: control.approveReason, onChange: updateText("approveReason"), placeholder: "当流程配置开启 monitor 审核时,记录审核通过原因并释放后续 node。", rows: 3, disabled: !selectedNodeId, "data-testid": "pipeline-node-approve-input" }),
|
||
h("button", {
|
||
type: "button",
|
||
className: "primary-btn compact",
|
||
disabled: disabled || !String(control.approveReason || "").trim(),
|
||
onClick: () => onAction("approve"),
|
||
"data-testid": "pipeline-node-approve-button",
|
||
}, control.actionLoading === "approve" ? "提交中" : "审核通过"),
|
||
),
|
||
h("label", null,
|
||
h("span", null, "重做 / restart 原因"),
|
||
h("textarea", { value: control.redoReason, onChange: updateText("redoReason"), placeholder: "说明为什么需要重做;runner 会重置目标 node 以及非 rework 下游 node。", rows: 4, disabled: !selectedNodeId, "data-testid": "pipeline-node-redo-input" }),
|
||
h("button", {
|
||
type: "button",
|
||
className: "danger-btn compact",
|
||
disabled: disabled || !String(control.redoReason || "").trim(),
|
||
onClick: () => onAction("redo"),
|
||
"data-testid": "pipeline-node-redo-button",
|
||
}, control.actionLoading === "redo" ? "排队中" : "重做 node"),
|
||
),
|
||
),
|
||
h("div", { className: "pipeline-control-evidence" },
|
||
h("strong", null, "Node 过程索引"),
|
||
h(PipelineNodeExecutionIndex, {
|
||
details: control.details,
|
||
selectedNodeId,
|
||
selectedNodeRuntime,
|
||
control,
|
||
onRaw,
|
||
}),
|
||
),
|
||
);
|
||
}
|
||
|
||
export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) {
|
||
const service = microservices.find((item: any) => item.id === "pipeline") || null;
|
||
const [state, setState] = useState({ loading: false, error: "", health: null, snapshot: null, oaDiagnostics: null, refreshedAt: null });
|
||
const [selectedPipelineId, setSelectedPipelineId] = useState("");
|
||
const [selectedRunId, setSelectedRunId] = useState("");
|
||
const [selectedNodeId, setSelectedNodeId] = useState("");
|
||
const [nodeControl, setNodeControl] = useState(pipelineNodeControlState());
|
||
const [ganttSelection, setGanttSelection] = useState(pipelineGanttSelectionState());
|
||
const [runDetails, setRunDetails] = useState(pipelineRunDetailsState());
|
||
const [ganttScale, setGanttScale] = useState(pipelineDefaultGanttScale);
|
||
const loadRequestRef = useRef(0);
|
||
const loadInFlightRef = useRef(false);
|
||
const runDetailsRequestRef = useRef(0);
|
||
const runDetailsInFlightRef = useRef("");
|
||
|
||
async function load(options: AnyRecord = {}): Promise<void> {
|
||
const silent = options.silent === true;
|
||
if (!service) return;
|
||
if (loadInFlightRef.current) return;
|
||
loadInFlightRef.current = true;
|
||
const requestId = loadRequestRef.current + 1;
|
||
loadRequestRef.current = requestId;
|
||
if (!silent) setState((prev: any) => ({ ...prev, loading: true, error: "" }));
|
||
try {
|
||
const snapshotQuery = `__unideskArrayLimit=registry.components:80,runs:${pipelineSnapshotRunLimit}&_=${Date.now()}`;
|
||
const [snapshot, oaDiagnostics] = await Promise.all([
|
||
requestJson(`${apiBaseUrl}/microservices/pipeline/proxy/api/snapshot?${snapshotQuery}`, { cache: "no-store" }),
|
||
requestJson(`${apiBaseUrl}/microservices/pipeline/proxy/api/oa-event-flow/diagnostics?_=${Date.now()}`, { cache: "no-store" })
|
||
.catch((error: unknown) => ({ ok: false, error: errorMessage(error, "OA event flow diagnostics failed") })),
|
||
]);
|
||
if (requestId !== loadRequestRef.current) return;
|
||
const health = { ok: snapshot?.ok !== false, service: "pipeline-v2-control snapshot" };
|
||
setState({ loading: false, error: "", health, snapshot, oaDiagnostics, refreshedAt: new Date() });
|
||
} catch (err) {
|
||
if (requestId !== loadRequestRef.current) return;
|
||
setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "Pipeline 加载失败") }));
|
||
} finally {
|
||
loadInFlightRef.current = false;
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
load();
|
||
if (!service) return undefined;
|
||
const timer = window.setInterval(() => {
|
||
load({ silent: true });
|
||
}, pipelineAutoRefreshMs);
|
||
return () => window.clearInterval(timer);
|
||
}, [service?.id, service?.runtime?.providerStatus, apiBaseUrl]);
|
||
|
||
const runtime = microserviceRuntime(service);
|
||
const repository = microserviceRepository(service);
|
||
const backend = microserviceBackend(service);
|
||
const snapshot = state.snapshot || {};
|
||
const oaDiagnostics = state.oaDiagnostics || null;
|
||
const { components, pipelines, runs } = pipelineSnapshotArrays(snapshot);
|
||
const latestPipelineId = String(runs[0]?.pipelineId || "");
|
||
const defaultPipeline = (latestPipelineId ? pipelines.find((pipeline: any) => String(pipeline.id || "") === latestPipelineId) : null) || pipelines[0] || {};
|
||
const activePipeline = pipelines.find((pipeline: any) => String(pipeline.id || "") === selectedPipelineId) || defaultPipeline;
|
||
const activePipelineId = String(activePipeline.id || "");
|
||
const pipelineNodes = pipelineConfigNodes(activePipeline);
|
||
const pipelineEdges = pipelineConfigEdges(activePipeline);
|
||
const latestRun = pipelineLatestRun(runs, activePipelineId);
|
||
const pipelineRuns = pipelineEpochRuns(runs, activePipelineId);
|
||
const activeRunBase = pipelineRuns.find((run: any) => String(run?.runId || "") === selectedRunId) || latestRun;
|
||
const activeRunDetail = String(runDetails.runId || "") === String(activeRunBase?.runId || "") ? pipelineDetailedRun(runDetails.details) : null;
|
||
const activeRun = mergePipelineRunSummary(activeRunBase, activeRunDetail);
|
||
const activeRunId = String(activeRun?.runId || "");
|
||
const selectedNodeConfig = pipelineNodes.find((node: any) => String(node?.id || "") === selectedNodeId) || null;
|
||
const selectedNodeRuntime = selectedNodeId ? pipelineRunNodeRecord(activeRun, selectedNodeId) : null;
|
||
const statusCounts = pipelineStatusCounts(runs);
|
||
const componentClasses = pipelineComponentClassCounts(components);
|
||
const componentCount = Number(state.health?.components) || pipelineArrayCount(snapshot, "registry.components", components.length);
|
||
const runCount = pipelineArrayCount(snapshot, "runs", runs.length);
|
||
const baseFlow = pipelineFlowElements(activePipeline, activeRun, components);
|
||
const flow = {
|
||
nodes: baseFlow.nodes.map((node) => node.id === selectedNodeId ? { ...node, selected: true, className: `${node.className || ""} selected-control-node` } : node),
|
||
edges: baseFlow.edges,
|
||
};
|
||
const exportItems = pipelines.map((pipeline: any) => {
|
||
const pipelineId = String(pipeline.id || "pipeline");
|
||
const run = pipelineLatestRun(runs, pipelineId);
|
||
return {
|
||
title: `${pipelineId}-${run?.runId || "snapshot"}`,
|
||
flow: pipelineFlowElements(pipeline, run, components),
|
||
};
|
||
});
|
||
const pipelineRunIds = pipelineRuns.map((run: any) => String(run?.runId || "")).filter(Boolean).join("|");
|
||
const pipelineNodeIds = pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean).join("|");
|
||
|
||
useEffect(() => {
|
||
if (!selectedRunId || pipelineRunIds.split("|").includes(selectedRunId)) return;
|
||
setSelectedRunId("");
|
||
}, [selectedRunId, pipelineRunIds]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedNodeId || pipelineNodeIds.split("|").includes(selectedNodeId)) return;
|
||
setSelectedNodeId("");
|
||
setNodeControl(pipelineNodeControlState());
|
||
setGanttSelection(pipelineGanttSelectionState());
|
||
}, [selectedNodeId, pipelineNodeIds]);
|
||
|
||
async function fetchRunDetails(runId = activeRunId, options: AnyRecord = {}): Promise<void> {
|
||
if (!runId) {
|
||
setRunDetails(pipelineRunDetailsState());
|
||
return;
|
||
}
|
||
const scale = clampPipelineGanttScale(options.scale ?? ganttScale ?? pipelineDefaultGanttScale);
|
||
const requestKey = `${runId}:${scale}`;
|
||
if (runDetailsInFlightRef.current === requestKey) return;
|
||
runDetailsInFlightRef.current = requestKey;
|
||
const silent = options.silent === true;
|
||
const requestId = runDetailsRequestRef.current + 1;
|
||
runDetailsRequestRef.current = requestId;
|
||
setRunDetails((prev: AnyRecord) => ({
|
||
runId,
|
||
scale,
|
||
loading: !silent || String(prev.runId || "") !== runId || !prev.details,
|
||
error: "",
|
||
details: silent && prev.runId === runId ? prev.details : prev.runId === runId ? prev.details : null,
|
||
fetchedAt: prev.runId === runId ? prev.fetchedAt : null,
|
||
}));
|
||
try {
|
||
const [details, runSummary] = await Promise.all([
|
||
requestJson(`${pipelineProxyPath(apiBaseUrl, `/api/node-control/runs/${encodeURIComponent(runId)}?tail=160&view=gantt&scale=${scale}`)}&_=${Date.now()}`, { cache: "no-store" }),
|
||
requestJson(`${pipelineProxyPath(apiBaseUrl, `/api/runs/${encodeURIComponent(runId)}`)}?_=${Date.now()}`, { cache: "no-store" }).catch((error: unknown) => ({ ok: false, runSummaryError: errorMessage(error, "抓取评分失败") })),
|
||
]);
|
||
if (requestId !== runDetailsRequestRef.current) return;
|
||
setRunDetails({ runId, scale, loading: false, error: "", details: { ...details, run: isRecord(runSummary?.run) ? runSummary.run : undefined, runSummaryError: runSummary?.runSummaryError }, fetchedAt: new Date() });
|
||
} catch (err) {
|
||
if (requestId !== runDetailsRequestRef.current) return;
|
||
setRunDetails((prev: AnyRecord) => ({
|
||
runId,
|
||
scale,
|
||
loading: false,
|
||
error: errorMessage(err, "抓取 epoch 执行过程失败"),
|
||
details: prev.runId === runId ? prev.details : null,
|
||
fetchedAt: prev.runId === runId ? prev.fetchedAt : null,
|
||
}));
|
||
} finally {
|
||
if (runDetailsInFlightRef.current === requestKey) runDetailsInFlightRef.current = "";
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!activeRunId) {
|
||
setRunDetails(pipelineRunDetailsState());
|
||
return undefined;
|
||
}
|
||
void fetchRunDetails(activeRunId);
|
||
const timer = window.setInterval(() => {
|
||
void fetchRunDetails(activeRunId, { silent: true });
|
||
}, pipelineAutoRefreshMs);
|
||
return () => window.clearInterval(timer);
|
||
}, [activeRunId, apiBaseUrl, ganttScale]);
|
||
|
||
async function fetchNodeDetails(runId = activeRunId, nodeId = selectedNodeId): Promise<void> {
|
||
if (!runId || !nodeId) {
|
||
setNodeControl((prev: AnyRecord) => ({ ...prev, error: "请先选择 run 和 node", message: "" }));
|
||
return;
|
||
}
|
||
setNodeControl((prev: AnyRecord) => ({ ...prev, loading: true, error: "", message: "" }));
|
||
try {
|
||
const details = await requestJson(pipelineProxyPath(apiBaseUrl, `/api/node-control/runs/${encodeURIComponent(runId)}/nodes/${encodeURIComponent(nodeId)}?tail=160`));
|
||
setNodeControl((prev: AnyRecord) => ({ ...prev, loading: false, details, fetchedAt: new Date(), error: "" }));
|
||
} catch (err) {
|
||
setNodeControl((prev: AnyRecord) => ({ ...prev, loading: false, error: errorMessage(err, "抓取 node 执行过程失败") }));
|
||
}
|
||
}
|
||
|
||
async function selectGanttInterval(interval: AnyRecord): Promise<void> {
|
||
const runId = String(interval?.runId || activeRunId || "");
|
||
const nodeId = String(interval?.nodeId || "");
|
||
setGanttSelection({ mode: "interval", runId, interval, marker: null });
|
||
if (!runId || !nodeId) return;
|
||
if (runId !== activeRunId) setSelectedRunId(runId);
|
||
setSelectedNodeId(nodeId);
|
||
setNodeControl(pipelineNodeControlState());
|
||
void fetchRunDetails(runId, { silent: true });
|
||
await fetchNodeDetails(runId, nodeId);
|
||
}
|
||
|
||
async function selectGanttMarker(marker: AnyRecord): Promise<void> {
|
||
const runId = String(marker?.runId || activeRunId || "");
|
||
const nodeId = String(marker?.nodeId || "");
|
||
setGanttSelection({ mode: "event", runId, interval: null, marker });
|
||
if (!runId) return;
|
||
if (runId !== activeRunId) setSelectedRunId(runId);
|
||
void fetchRunDetails(runId, { silent: true });
|
||
if (!nodeId) return;
|
||
setSelectedNodeId(nodeId);
|
||
setNodeControl(pipelineNodeControlState());
|
||
await fetchNodeDetails(runId, nodeId);
|
||
}
|
||
|
||
async function postNodeAction(action: "append" | "guide" | "modify" | "approve" | "redo"): Promise<void> {
|
||
if (!activeRunId || !selectedNodeId) {
|
||
setNodeControl((prev: AnyRecord) => ({ ...prev, error: "请先选择 run 和 node", message: "" }));
|
||
return;
|
||
}
|
||
const endpoint = action === "append" ? "prompts" : action;
|
||
const text =
|
||
action === "append" ? nodeControl.appendPrompt :
|
||
action === "guide" ? nodeControl.guidePrompt :
|
||
action === "modify" ? nodeControl.modifyPrompt :
|
||
action === "approve" ? nodeControl.approveReason :
|
||
nodeControl.redoReason;
|
||
if (!String(text || "").trim()) {
|
||
setNodeControl((prev: AnyRecord) => ({ ...prev, error: "操作内容不能为空", message: "" }));
|
||
return;
|
||
}
|
||
setNodeControl((prev: AnyRecord) => ({ ...prev, actionLoading: action, error: "", message: "" }));
|
||
try {
|
||
const body = action === "redo" || action === "approve"
|
||
? { reason: text, source: "unidesk-frontend", sourceKind: "webui" }
|
||
: { prompt: text, source: "unidesk-frontend", sourceKind: "webui" };
|
||
const result = await requestJson(pipelineProxyPath(apiBaseUrl, `/api/node-control/runs/${encodeURIComponent(activeRunId)}/nodes/${encodeURIComponent(selectedNodeId)}/${endpoint}`), {
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
});
|
||
setNodeControl((prev: AnyRecord) => ({
|
||
...prev,
|
||
actionLoading: "",
|
||
details: result,
|
||
fetchedAt: new Date(),
|
||
appendPrompt: action === "append" ? "" : prev.appendPrompt,
|
||
guidePrompt: action === "guide" ? "" : prev.guidePrompt,
|
||
modifyPrompt: action === "modify" ? "" : prev.modifyPrompt,
|
||
approveReason: action === "approve" ? "" : prev.approveReason,
|
||
redoReason: action === "redo" ? "" : prev.redoReason,
|
||
message:
|
||
action === "append" ? "已追加到运行中 node" :
|
||
action === "guide" ? "已下发 guide,等待 runner 处理" :
|
||
action === "modify" ? "已排队增量修改命令" :
|
||
action === "approve" ? "已提交审核通过决策" :
|
||
"已排队重做命令",
|
||
}));
|
||
await fetchNodeDetails(activeRunId, selectedNodeId);
|
||
await fetchRunDetails(activeRunId, { silent: true });
|
||
if (action !== "append") await load();
|
||
} catch (err) {
|
||
setNodeControl((prev: AnyRecord) => ({ ...prev, actionLoading: "", error: errorMessage(err, "node 控制操作失败") }));
|
||
}
|
||
}
|
||
|
||
if (!service) return h(EmptyState, { title: "Pipeline 未登记", text: "请在 config.json 的 microservices 中登记 id=pipeline" });
|
||
|
||
return h("div", { className: "pipeline-page", "data-testid": "pipeline-page" },
|
||
h(Panel, {
|
||
title: "Pipeline v2 工作台",
|
||
eyebrow: "D601 Snapshot Microservice",
|
||
actions: h("div", { className: "panel-actions" },
|
||
h("button", { type: "button", className: "ghost-btn", onClick: load, disabled: state.loading, "data-testid": "pipeline-refresh-button" }, state.loading ? "刷新中" : "刷新"),
|
||
h(RawButton, { title: "Pipeline Microservice", data: service, onOpen: onRaw, testId: "raw-pipeline-service" }),
|
||
),
|
||
},
|
||
h("div", { className: "pipeline-hero" },
|
||
h("div", null,
|
||
h("div", { className: "node-version-line" },
|
||
h(StatusBadge, { status: runtime.providerStatus === "online" ? "online" : "warn" }, runtime.providerStatus || "unknown"),
|
||
h("span", null, service.providerId),
|
||
h("span", null, backend.public ? "公网暴露" : "仅 UniDesk frontend 代理访问"),
|
||
),
|
||
h("p", { className: "muted paragraph" }, service.description),
|
||
),
|
||
h("div", { className: "microservice-ref-card" },
|
||
h("span", null, "Repo"),
|
||
h("strong", null, repository.url || "--"),
|
||
h("code", null, repository.commitId || "--"),
|
||
),
|
||
h("div", { className: "microservice-ref-card" },
|
||
h("span", null, "D601 Docker"),
|
||
h("strong", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"}`),
|
||
h("code", null, `${repository.composeFile || "--"} / ${repository.composeService || "--"}`),
|
||
),
|
||
),
|
||
state.error ? h("div", { className: "form-error wide" }, state.error) : null,
|
||
),
|
||
h("div", { className: "pipeline-grid" },
|
||
h(Panel, { title: "观测指标", eyebrow: state.refreshedAt ? `Updated ${fmtClock(state.refreshedAt)}` : "Snapshot" },
|
||
h("div", { className: "metric-grid" },
|
||
h(MetricCard, { label: "Health", value: state.health?.ok ? "OK" : "--", hint: state.health?.service || "D601 /health", tone: state.health?.ok ? "ok" : "warn" }),
|
||
h(MetricCard, { label: "组件", value: componentCount, hint: "components registry", tone: snapshot?.registry?.ok === false ? "warn" : "ok" }),
|
||
h(MetricCard, { label: "Pipeline", value: pipelines.length, hint: `${pipelineNodes.length} nodes / ${pipelineEdges.length} edges` }),
|
||
h(MetricCard, { label: "运行记录", value: runCount, hint: `${statusCounts.succeeded || 0} succeeded / ${statusCounts.running || 0} running` }),
|
||
h(MetricCard, { label: "OA 记录", value: Array.isArray(latestRun?.submissions) ? latestRun.submissions.length : 0, hint: latestRun?.runId || "latest run" }),
|
||
h(MetricCard, { label: "Procedure", value: Array.isArray(latestRun?.procedureRuns) ? latestRun.procedureRuns.length : 0, hint: latestRun?.status || "no run" }),
|
||
h(MetricCard, { label: "Score", value: pipelineScoreText(activeRun), hint: activeRun?.runId || "selected epoch", tone: pipelineScoreTone(activeRun) }),
|
||
),
|
||
h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "Pipeline Snapshot", data: snapshot, onOpen: onRaw, testId: "raw-pipeline-snapshot" })),
|
||
),
|
||
h(Panel, { title: "评分器", eyebrow: activeRun?.runId || "selected epoch" },
|
||
h(PipelineScoreBoard, { run: activeRun, onRaw }),
|
||
),
|
||
h(Panel, { title: "OA 事件流", eyebrow: "100% event-driven diagnostics", className: "pipeline-wide-panel" },
|
||
h(PipelineOaEventFlowPanel, { diagnostics: oaDiagnostics, onRaw }),
|
||
),
|
||
h(Panel, { title: "组件矩阵", eyebrow: `${componentClasses.length} classes` },
|
||
componentClasses.length === 0 ? h(EmptyState, { title: "暂无组件", text: "等待 D601 pipeline backend 返回 registry.components" }) :
|
||
h("div", { className: "component-strata" }, componentClasses.map((item) => h("article", { key: item.name, className: "component-stratum" },
|
||
h("span", null, item.name),
|
||
h("strong", null, item.count),
|
||
))),
|
||
h("div", { className: "pipeline-component-list" },
|
||
components.slice(0, 12).map((component: any) => h("span", { key: component.key, className: "data-chip" }, h("b", null, component.componentClass || "--"), h("span", null, component.id || component.key || "--"))),
|
||
),
|
||
),
|
||
h(Panel, {
|
||
title: "控制图",
|
||
eyebrow: `${activePipeline.id || "pipeline"} / run ${activeRun?.status || "--"}`,
|
||
className: "pipeline-wide-panel",
|
||
actions: h("div", { className: "pipeline-toolbar" },
|
||
h("select", {
|
||
value: activePipelineId,
|
||
disabled: pipelines.length === 0,
|
||
onChange: (event: any) => {
|
||
setSelectedPipelineId(event.target.value);
|
||
setSelectedRunId("");
|
||
setSelectedNodeId("");
|
||
setNodeControl(pipelineNodeControlState());
|
||
setGanttSelection(pipelineGanttSelectionState());
|
||
},
|
||
"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());
|
||
setGanttSelection(pipelineGanttSelectionState());
|
||
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",
|
||
disabled: flow.nodes.length === 0,
|
||
onClick: () => exportPipelineGraph(flow, `${activePipeline.id || "pipeline"}-${activeRun?.runId || "snapshot"}`),
|
||
"data-testid": "pipeline-export-graph",
|
||
}, "导出渲染图"),
|
||
h("button", {
|
||
type: "button",
|
||
className: "ghost-btn",
|
||
disabled: exportItems.every((item) => item.flow.nodes.length === 0),
|
||
onClick: () => exportPipelineGraphs(exportItems),
|
||
"data-testid": "pipeline-export-all-graphs",
|
||
}, "批量导出"),
|
||
),
|
||
},
|
||
pipelineNodes.length === 0 ? h(EmptyState, { title: "暂无控制图", text: "等待 D601 pipeline backend 返回 config.nodes / config.edges" }) :
|
||
h("div", { className: "pipeline-control-shell" },
|
||
h("div", { className: "pipeline-flow-frame", "data-testid": "pipeline-react-flow" },
|
||
h(ReactFlow, {
|
||
nodes: flow.nodes,
|
||
edges: flow.edges,
|
||
nodeTypes: pipelineNodeTypes,
|
||
edgeTypes: pipelineEdgeTypes,
|
||
fitView: true,
|
||
fitViewOptions: { padding: 0.18 },
|
||
nodesDraggable: false,
|
||
nodesConnectable: false,
|
||
elementsSelectable: true,
|
||
minZoom: 0.25,
|
||
maxZoom: 1.4,
|
||
proOptions: { hideAttribution: true },
|
||
onNodeClick: (_event: any, node: Node) => {
|
||
const nodeId = String(node.id);
|
||
setSelectedNodeId(nodeId);
|
||
setNodeControl(pipelineNodeControlState());
|
||
if (activeRunId) void fetchNodeDetails(activeRunId, nodeId);
|
||
},
|
||
},
|
||
h(Background, { gap: 22, size: 1, color: "rgba(215, 161, 58, 0.24)" }),
|
||
h(Controls, { showInteractive: false }),
|
||
),
|
||
),
|
||
h(PipelineNodeControlPanel, {
|
||
activeRun,
|
||
pipelineRuns,
|
||
selectedRunId,
|
||
onRunChange: (runId: string) => {
|
||
setSelectedRunId(runId);
|
||
setNodeControl(pipelineNodeControlState());
|
||
setGanttSelection(pipelineGanttSelectionState());
|
||
if (selectedNodeId) void fetchNodeDetails(runId, selectedNodeId);
|
||
},
|
||
selectedNodeId,
|
||
selectedNodeConfig,
|
||
selectedNodeRuntime,
|
||
control: nodeControl,
|
||
onControlChange: (patch: AnyRecord) => setNodeControl((prev: AnyRecord) => ({ ...prev, ...patch })),
|
||
onFetch: () => fetchNodeDetails(),
|
||
onAction: postNodeAction,
|
||
onRaw,
|
||
}),
|
||
),
|
||
h("div", { className: "pipeline-flow-summary" },
|
||
h("span", null, `${flow.nodes.length} nodes`),
|
||
h("span", null, `${flow.edges.length} edges`),
|
||
h("span", null, `${pipelines.length} pipelines`),
|
||
h("span", null, `source config+components(${components.length})`),
|
||
h("span", null, `run ${activeRun?.runId || "--"}`),
|
||
h("span", null, `score ${pipelineScoreText(activeRun)}`),
|
||
h("span", null, selectedNodeId ? `selected ${selectedNodeId}` : "click node to control"),
|
||
),
|
||
),
|
||
h(PipelineEpochGantt, {
|
||
epochs: pipelineRuns,
|
||
activeRun,
|
||
activePipeline,
|
||
pipelineNodes,
|
||
pipelineEdges,
|
||
selection: ganttSelection,
|
||
runDetails,
|
||
nodeDetails: nodeControl.details,
|
||
ganttScale,
|
||
onGanttScaleChange: setGanttScale,
|
||
onIntervalSelect: selectGanttInterval,
|
||
onMarkerSelect: selectGanttMarker,
|
||
onRunChange: (runId: string) => {
|
||
setSelectedRunId(runId);
|
||
setNodeControl(pipelineNodeControlState());
|
||
setGanttSelection(pipelineGanttSelectionState());
|
||
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) => {
|
||
const cardRun = String(run?.runId || "") === activeRunId ? activeRun : run;
|
||
return h("article", {
|
||
key: run.runId,
|
||
className: `pipeline-run-card ${String(run.runId || "") === activeRunId ? "active" : ""}`,
|
||
role: "button",
|
||
tabIndex: 0,
|
||
onClick: () => {
|
||
setSelectedRunId(String(run.runId || ""));
|
||
setGanttSelection(pipelineGanttSelectionState());
|
||
},
|
||
onKeyDown: (event: any) => {
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
setSelectedRunId(String(run.runId || ""));
|
||
setGanttSelection(pipelineGanttSelectionState());
|
||
}
|
||
},
|
||
},
|
||
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, cardRun?.pipelineId || "--"),
|
||
h("span", null, `nodes ${Array.isArray(cardRun?.nodes) ? cardRun.nodes.length : 0}`),
|
||
h("span", null, `oa ${Array.isArray(cardRun?.submissions) ? cardRun.submissions.length : 0}`),
|
||
h("span", null, `procedures ${Array.isArray(cardRun?.procedureRuns) ? cardRun.procedureRuns.length : 0}`),
|
||
h(PipelineScoreBadge, { run: cardRun }),
|
||
),
|
||
h("p", { className: "muted paragraph" }, summarizeValue(cardRun?.task)),
|
||
h("span", { className: "pipeline-run-time" }, fmtDate(cardRun?.updatedAt)),
|
||
);
|
||
})),
|
||
),
|
||
h(Panel, { title: "运行材料索引", eyebrow: activeRun?.runId || "selected epoch", className: "pipeline-wide-panel" },
|
||
h(PipelineRunMaterialIndex, { activeRun, onRaw }),
|
||
),
|
||
),
|
||
);
|
||
}
|