Files
pikasTech-unidesk/src/components/frontend/src/pipeline.tsx
T

1927 lines
96 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
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 fmtDurationMs(value: any): string {
const ms = Number(value);
if (!Number.isFinite(ms) || ms < 0) return "--";
const seconds = Math.round(ms / 1000);
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
}
function isRecord(value: any): value is AnyRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function asArray(value: any): any[] {
return Array.isArray(value) ? value : [];
}
function timeMs(value: any): number | null {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.getTime();
}
function isoFromMs(value: number | null | undefined): string {
return Number.isFinite(Number(value)) ? new Date(Number(value)).toISOString() : "";
}
function firstIso(...values: any[]): string {
for (const value of values) {
const ms = timeMs(value);
if (ms !== null) return new Date(ms).toISOString();
}
return "";
}
function latestIso(...values: any[]): string {
const candidates = values.map(timeMs).filter((value): value is number => value !== null);
return candidates.length > 0 ? new Date(Math.max(...candidates)).toISOString() : "";
}
function terminalStatus(status: any): boolean {
return ["succeeded", "failed", "skipped", "cancelled", "canceled", "completed"].includes(String(status || "").toLowerCase());
}
function statusCounts(items: any[], key = "status"): AnyRecord {
return items.reduce((counts: AnyRecord, item: any) => {
const status = String(item?.[key] || "unknown").toLowerCase();
counts[status] = (counts[status] || 0) + 1;
return counts;
}, {});
}
function parseJsonLine(value: any): AnyRecord | null {
if (!value || typeof value !== "string") return null;
try {
const parsed = JSON.parse(value) as unknown;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function tailSummary(lines: any[]): AnyRecord {
const records = lines.map(parseJsonLine).filter((item): item is AnyRecord => Boolean(item));
const timestamps = records.flatMap((record) => [record.timestamp, record.createdAt, record.updatedAt]).filter(Boolean);
const lastAt = latestIso(...timestamps);
const eventKinds = Array.from(new Set(records.map((record) => String(record.event || record.action || record.type || "")).filter(Boolean))).slice(0, 3);
return {
total: lines.length,
parsed: records.length,
lastAt,
eventKinds,
};
}
function summarizeValue(value: any): string {
if (value === null || value === undefined) return "--";
if (typeof value === "boolean") return value ? "是" : "否";
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 ? `duration ${fmtDurationMs(part.durationMs)}` : "",
part?.outputSize !== undefined ? `output ${part.outputSize} chars` : "",
].filter(Boolean);
}
function PipelineFieldList({ fields }: AnyRecord) {
const safeFields = asArray(fields).filter((field: any) => field?.key);
if (safeFields.length === 0) return null;
return h("dl", { className: "pipeline-field-list" }, safeFields.flatMap((field: AnyRecord) => [
h("dt", { key: `${field.key}-k` }, field.key),
h("dd", { key: `${field.key}-v` }, previewText(field.value, 260)),
]));
}
function PipelineOpenCodePart({ part }: AnyRecord) {
const title = String(part?.title || part?.tool || part?.type || "part");
return h("details", { className: "pipeline-opencode-part" },
h("summary", null,
h("strong", null, title),
h(PipelineChipRow, { items: opencodePartFacts(part) }),
),
h("div", { className: "pipeline-opencode-part-body" },
part?.textPreview ? h("p", { className: "pipeline-text-preview" }, part.textPreview) : null,
asArray(part?.inputFields).length > 0 ? h("div", null, h("b", null, "输入字段"), h(PipelineFieldList, { fields: part.inputFields })) : null,
part?.outputPreview && part.outputPreview !== "--" ? h("div", null, h("b", null, "输出摘要"), h("p", { className: "pipeline-text-preview" }, part.outputPreview)) : null,
asArray(part?.metadataFields).length > 0 ? h("div", null, h("b", null, "元数据"), h(PipelineFieldList, { fields: part.metadataFields })) : null,
tokenFacts(part?.tokens).length > 0 ? h(PipelineChipRow, { items: tokenFacts(part.tokens) }) : null,
),
);
}
function PipelineOpenCodeStep({ step }: AnyRecord) {
const tools = asArray(step?.tools).map((item) => String(item || "")).filter(Boolean);
const partTypes = isRecord(step?.partTypes) ? Object.entries(step.partTypes).map(([type, count]) => `${type} ${count}`) : [];
const facts = [
`role ${step?.role || "unknown"}`,
step?.finish ? `finish ${step.finish}` : "",
step?.durationMs !== undefined ? `duration ${fmtDurationMs(step.durationMs)}` : "",
`parts ${step?.partCount ?? asArray(step?.parts).length}`,
...tools.slice(0, 4).map((tool) => `tool ${tool}`),
].filter(Boolean);
return h("details", { className: "pipeline-opencode-step", "data-testid": "pipeline-opencode-step" },
h("summary", null,
h("span", { className: "pipeline-step-index" }, `Step ${step?.index ?? "?"}`),
h("strong", null, `${step?.agent || "agent"} / ${step?.model || "model"}`),
h("small", null, `${fmtDate(step?.createdAt)} -> ${fmtDate(step?.completedAt)}`),
h(PipelineChipRow, { items: facts }),
),
h("div", { className: "pipeline-opencode-step-body" },
h(PipelineKvGrid, { items: [
{ label: "message", value: step?.messageId || "--" },
{ label: "session", value: step?.sessionId || "--" },
{ label: "provider", value: step?.provider || "--" },
{ label: "cost", value: step?.cost ?? "--" },
] }),
tokenFacts(step?.tokens).length > 0 ? h(PipelineChipRow, { items: tokenFacts(step.tokens) }) : null,
partTypes.length > 0 ? h(PipelineChipRow, { items: partTypes }) : null,
step?.textPreview ? h("p", { className: "pipeline-text-preview" }, step.textPreview) : null,
asArray(step?.parts).length > 0 ? h("div", { className: "pipeline-opencode-part-list" }, asArray(step.parts).map((part: any, index: number) => h(PipelineOpenCodePart, { key: part?.id || index, part }))) : null,
),
);
}
function PipelineGanttDetailPanel({ selection, onRaw }: AnyRecord) {
const interval = selection?.interval;
if (!interval) {
return h("aside", { className: "pipeline-gantt-detail-panel empty", "data-testid": "pipeline-gantt-detail-panel" },
h(EmptyState, { title: "选择一条执行线", text: "点击甘特图中的 node 执行线,在这里查看结构化过程和 OpenCode step。" }),
);
}
const details = selection?.details;
const procedure = findProcedureRun(details, interval) || interval.raw || {};
const attempts = asArray(procedure?.attempts);
const status = statusValue(procedure?.status || interval.status);
return h("aside", { className: "pipeline-gantt-detail-panel", "data-testid": "pipeline-gantt-detail-panel" },
h("div", { className: "pipeline-gantt-detail-head" },
h("div", null,
h("span", { className: "panel-eyebrow" }, "Gantt Line Detail"),
h("h3", null, interval.nodeId || "node"),
),
h(StatusBadge, { status }, status),
),
h(PipelineKvGrid, { items: [
{ label: "epoch", value: interval.runId || "--" },
{ label: "procedure", value: interval.procedureRunId || procedureRunIdOf(procedure) || "--" },
{ label: "started", value: fmtDate(interval.startedAt || procedure?.startedAt) },
{ label: "finished", value: fmtDate(interval.finishedAt || procedure?.finishedAt) },
{ label: "duration", value: fmtDurationMs(interval.durationMs || procedure?.durationMs) },
{ label: "fetched", value: selection?.fetchedAt ? fmtClock(selection.fetchedAt) : "--" },
] }),
selection?.loading ? h("div", { className: "form-success" }, "正在抓取 node 执行过程...") : null,
selection?.error ? h("div", { className: "form-error" }, selection.error) : null,
h("div", { className: "pipeline-gantt-detail-actions" },
h(RawButton, { title: `Procedure ${interval.procedureRunId || interval.nodeId}`, data: procedure, onOpen: onRaw, testId: "raw-pipeline-gantt-procedure" }),
details ? h(RawButton, { title: `Node execution ${interval.nodeId}`, data: details, onOpen: onRaw, testId: "raw-pipeline-gantt-node-details" }) : null,
),
attempts.length === 0 && !selection?.loading ? h(EmptyState, { title: "暂无 attempt 详情", text: "后端还未返回该 procedure 的 attempt / opencodeMessages。" }) : null,
attempts.map((attempt: any, attemptIndex: number) => {
const messages = attempt?.opencodeMessages || {};
const steps = opencodeSteps(attempt);
const sessionIds = asArray(messages.sessionIds).map((item) => String(item)).filter(Boolean);
return h("article", { key: attempt?.attempt || attemptIndex, className: "pipeline-attempt-card" },
h("div", { className: "pipeline-attempt-head" },
h("strong", null, attempt?.attempt || `attempt-${attemptIndex + 1}`),
h("span", null, messages.source || "opencode"),
),
h(PipelineKvGrid, { items: [
{ label: "messages", value: messages.messageCount ?? "--" },
{ label: "steps", value: messages.stepCount ?? steps.length },
{ label: "tools", value: messages.toolCallCount ?? "--" },
{ label: "updated", value: fmtDate(messages.updatedAt) },
{ label: "sessions", value: sessionIds.join(", ") || "--" },
] }),
steps.length === 0 ? h("p", { className: "muted paragraph" }, "当前 attempt 尚未返回 OpenCode step 摘要;请确认 D601 pipeline-webui 已重建并重新抓取。") :
h("div", { className: "pipeline-opencode-flow" }, steps.map((step: any, stepIndex: number) => h(PipelineOpenCodeStep, { key: step?.messageId || stepIndex, step }))),
);
}),
);
}
function GanttHeaderLabel({ value }: AnyRecord) {
const text = String(value || "--");
const pieces = text.split(/([_-])/u);
return h(React.Fragment, null, pieces.map((piece, index) => (
piece === "-" || piece === "_" ? h(React.Fragment, { key: index }, piece, h("wbr", null)) : h(React.Fragment, { key: index }, piece)
)));
}
async function requestJson(path: string, options: AnyRecord = {}): Promise<any> {
const headers = new Headers(options.headers || {});
if (options.body && !headers.has("content-type")) headers.set("content-type", "application/json");
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 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 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 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 columns = pipelineGraphColumns(pipeline, pipelineNodes, pipelineEdges);
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 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 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 = String(title || "pipeline").replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-|-$/g, "") || "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 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 || procedure?.artifact?.status || procedure?.status?.status || "").toLowerCase();
const artifact = isRecord(procedure?.artifact) ? procedure.artifact : {};
const terminal = terminalStatus(status);
return firstIso(
procedure?.finishedAt,
artifact.finishedAt,
procedure?.completedAt,
terminal ? procedure?.updatedAt : undefined,
terminal ? artifact.updatedAt : undefined,
terminal ? run?.updatedAt : undefined,
);
}
function pipelineRunIntervals(run: any, pipelineNodes: AnyRecord[]): AnyRecord[] {
const runId = String(run?.runId || "");
const knownNodeIds = new Set(pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean));
return asArray(run?.procedureRuns).flatMap((procedure: any) => {
const nodeId = inferProcedureNodeId(procedure, runId);
if (!nodeId) return [];
const status = String(procedure?.status || procedure?.artifact?.status || "unknown").toLowerCase();
const startIso = procedureStartIso(procedure, run);
const startMs = timeMs(startIso);
if (startMs === null) return [];
const explicitEndIso = procedureEndIso(procedure, run);
const endMs = timeMs(explicitEndIso) ?? (terminalStatus(status) ? (timeMs(procedure?.updatedAt) ?? startMs + 1000) : Date.now());
const safeEndMs = Math.max(startMs + 1000, endMs);
return [{
nodeId,
knownNode: knownNodeIds.has(nodeId),
procedureRunId: procedureRunIdOf(procedure),
status,
startMs,
endMs: safeEndMs,
startedAt: isoFromMs(startMs),
finishedAt: isoFromMs(safeEndMs),
durationMs: safeEndMs - startMs,
runId,
raw: procedure,
}];
}).sort((left: AnyRecord, right: AnyRecord) => left.startMs - right.startMs || left.endMs - right.endMs || left.nodeId.localeCompare(right.nodeId));
}
function pipelineRunTimeBounds(run: any, intervals: AnyRecord[]): AnyRecord {
const starts = intervals.map((item) => Number(item.startMs)).filter(Number.isFinite);
const ends = intervals.map((item) => Number(item.endMs)).filter(Number.isFinite);
const runStart = timeMs(run?.startedAt) ?? timeMs(run?.artifact?.startedAt) ?? timeMs(run?.request?.createdAt);
const runEnd = timeMs(run?.finishedAt) ?? timeMs(run?.artifact?.finishedAt) ?? timeMs(run?.updatedAt);
if (runStart !== null) starts.push(runStart);
if (runEnd !== null) ends.push(runEnd);
const now = Date.now();
const startMs = starts.length > 0 ? Math.min(...starts) : now - 60_000;
const endMs = Math.max(startMs + 60_000, ends.length > 0 ? Math.max(...ends) : now);
return { startMs, endMs, durationMs: endMs - startMs };
}
function pipelineGanttHeight(bounds: AnyRecord): number {
const minutes = Math.max(1, Number(bounds.durationMs || 0) / 60_000);
return Math.round(Math.max(440, Math.min(1800, minutes * 48)));
}
function pipelineGanttTicks(bounds: AnyRecord, count = 7): AnyRecord[] {
const duration = Math.max(1, Number(bounds.endMs || 0) - Number(bounds.startMs || 0));
return Array.from({ length: count }, (_item, index) => {
const ratio = count === 1 ? 0 : index / (count - 1);
const ms = Number(bounds.startMs) + duration * ratio;
return { ms, percent: ratio * 100 };
});
}
function intervalOverlaps(interval: AnyRecord, range: AnyRecord): boolean {
return Number(interval.startMs) <= Number(range.endMs) && Number(interval.endMs) >= Number(range.startMs);
}
function PipelineNodeExecutionIndex({ details, selectedNodeId, selectedNodeRuntime, control, onRaw }: AnyRecord) {
if (!details) {
return h("span", { className: "muted" }, "点击“抓取过程”读取 node 运行材料;主界面只显示结构化摘要,完整内容需点开原始 JSON。");
}
const procedureRuns = asArray(details.procedureRuns);
const latestProcedure = procedureRuns.at(-1) || {};
const attempts = asArray(latestProcedure.attempts);
const latestAttempt = attempts.at(-1) || {};
const workerLogTail = asArray(latestProcedure.workerLogTail);
const controlEventsTail = asArray(latestAttempt.controlEventsTail);
const controlPromptsTail = asArray(latestAttempt.controlPromptsTail);
const monitorPromptsTail = asArray(latestAttempt.monitorPromptsTail);
const eventTail = tailSummary(controlEventsTail);
const controlPromptTail = tailSummary(controlPromptsTail);
const monitorPromptTail = tailSummary(monitorPromptsTail);
const latestMessage = latestAttempt.opencodeMessages || {};
return h("div", { className: "pipeline-evidence-list compact" },
h(EvidenceIndexRow, {
title: "Node runtime",
subtitle: selectedNodeId || "--",
facts: [
`status ${selectedNodeRuntime?.status || "pending"}`,
`attempts ${selectedNodeRuntime?.attempts ?? attempts.length}`,
`procedure ${selectedNodeRuntime?.currentProcedureRunId || procedureRunIdOf(latestProcedure) || "--"}`,
control.fetchedAt ? `fetched ${fmtClock(control.fetchedAt)}` : "not fetched",
],
data: details.node || details,
onRaw,
testId: "raw-pipeline-node-runtime",
}),
h(EvidenceIndexRow, {
title: "Procedure runs",
subtitle: `${procedureRuns.length} groups`,
facts: [
`latest ${latestProcedure.status?.status || latestProcedure.status || "--"}`,
`steps ${asArray(latestProcedure.recentSteps).length}`,
`duration ${fmtDurationMs(timeMs(latestProcedure.finishedAt) && timeMs(latestProcedure.startedAt) ? Number(timeMs(latestProcedure.finishedAt)) - Number(timeMs(latestProcedure.startedAt)) : latestProcedure.durationMs)}`,
],
data: procedureRuns,
onRaw,
testId: "raw-pipeline-node-procedures",
}),
h(EvidenceIndexRow, {
title: "OpenCode messages",
subtitle: String(latestMessage.exists ? "available" : "not indexed"),
facts: [
`messages ${summarizeValue(latestMessage.messageCount)}`,
`size ${summarizeValue(latestMessage.size)}`,
`updated ${fmtDate(latestMessage.updatedAt)}`,
],
data: latestMessage,
onRaw,
testId: "raw-pipeline-node-messages",
}),
h(EvidenceIndexRow, {
title: "Control prompts",
subtitle: "manual / monitor append queues",
facts: [
`manual tail ${controlPromptTail.total}`,
`monitor tail ${monitorPromptTail.total}`,
`last ${fmtDate(latestIso(controlPromptTail.lastAt, monitorPromptTail.lastAt))}`,
],
data: { controlPromptsTail, monitorPromptsTail },
onRaw,
testId: "raw-pipeline-node-prompts",
}),
h(EvidenceIndexRow, {
title: "Control events",
subtitle: eventTail.eventKinds.length > 0 ? eventTail.eventKinds.join(", ") : "event tail",
facts: [
`tail ${eventTail.total}`,
`parsed ${eventTail.parsed}`,
`last ${fmtDate(eventTail.lastAt)}`,
],
data: controlEventsTail,
onRaw,
testId: "raw-pipeline-node-events",
}),
h(EvidenceIndexRow, {
title: "Worker log",
subtitle: "tail is hidden on main canvas",
facts: [`tail ${workerLogTail.length} lines`, `raw only via button`, `procedure ${procedureRunIdOf(latestProcedure) || "--"}`],
data: workerLogTail,
onRaw,
testId: "raw-pipeline-node-worker-log",
}),
);
}
function PipelineRunMaterialIndex({ activeRun, onRaw }: AnyRecord) {
if (!activeRun) return h(EmptyState, { title: "暂无运行材料", text: "没有 Pipeline epoch 时不会展示运行材料索引。" });
const nodes = asArray(activeRun.nodes);
const procedures = asArray(activeRun.procedureRuns);
const submissions = asArray(activeRun.submissions);
const workerLogTail = asArray(activeRun.workerLogTail);
const nodeCounts = statusCounts(nodes);
const procedureCounts = statusCounts(procedures);
const failedProcedures = procedures.filter((procedure: any) => String(procedure?.status || "").toLowerCase() === "failed");
const latestProcedureAt = latestIso(...procedures.flatMap((procedure: any) => [procedure.updatedAt, procedure.finishedAt, procedure.startedAt]));
return h("div", { className: "pipeline-evidence-list" },
h(EvidenceIndexRow, {
title: "Epoch overview",
subtitle: activeRun.runId || "--",
facts: [
`pipeline ${activeRun.pipelineId || "--"}`,
`status ${activeRun.status || "--"}`,
`started ${fmtDate(activeRun.startedAt)}`,
`updated ${fmtDate(activeRun.updatedAt)}`,
],
data: activeRun,
onRaw,
testId: "raw-pipeline-run",
}),
h(EvidenceIndexRow, {
title: "Node states",
subtitle: `${nodes.length} nodes`,
facts: [
`running ${nodeCounts.running || 0}`,
`succeeded ${nodeCounts.succeeded || 0}`,
`failed ${nodeCounts.failed || 0}`,
`pending ${nodeCounts.pending || 0}`,
],
data: nodes,
onRaw,
testId: "raw-pipeline-run-nodes",
}),
h(EvidenceIndexRow, {
title: "Procedure run index",
subtitle: `${procedures.length} procedure records`,
facts: [
`succeeded ${procedureCounts.succeeded || 0}`,
`failed ${procedureCounts.failed || 0}`,
`latest ${fmtDate(latestProcedureAt)}`,
`errors ${failedProcedures.length}`,
],
data: procedures,
onRaw,
testId: "raw-pipeline-run-procedures",
}),
h(EvidenceIndexRow, {
title: "OA submissions",
subtitle: `${submissions.length} submission files`,
facts: [`records ${submissions.length}`, `task ${summarizeValue(activeRun.task)}`, `raw grouped by run`],
data: submissions,
onRaw,
testId: "raw-pipeline-run-submissions",
}),
h(EvidenceIndexRow, {
title: "Worker log tail",
subtitle: "hidden from main interface",
facts: [`tail ${workerLogTail.length} lines`, `display raw only after click`, `updated ${fmtDate(activeRun.updatedAt)}`],
data: workerLogTail,
onRaw,
testId: "raw-pipeline-run-worker-log",
}),
);
}
function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, onRunChange, onIntervalSelect, selection, onRaw }: AnyRecord) {
const [autoHideIdle, setAutoHideIdle] = useState(true);
const [visibleRange, setVisibleRange] = useState({ startMs: 0, endMs: 0 });
const viewportRef = useRef(null);
const activeRunId = String(activeRun?.runId || "");
const intervals = pipelineRunIntervals(activeRun, pipelineNodes);
const bounds = pipelineRunTimeBounds(activeRun, intervals);
const chartHeight = pipelineGanttHeight(bounds);
const configuredNodeIds = pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean);
const intervalNodeIds = intervals.map((interval: AnyRecord) => String(interval.nodeId || "")).filter(Boolean);
const allNodeIds = Array.from(new Set([...configuredNodeIds, ...intervalNodeIds]));
const safeRange = visibleRange.startMs > 0 ? visibleRange : bounds;
const activeNodeIds = new Set(allNodeIds.filter((nodeId) => intervals.some((interval: AnyRecord) => interval.nodeId === nodeId && intervalOverlaps(interval, safeRange))));
const visibleNodeIds = autoHideIdle ? allNodeIds.filter((nodeId) => activeNodeIds.has(nodeId)) : allNodeIds;
const gridTemplateColumns = `96px ${visibleNodeIds.length > 0 ? visibleNodeIds.map(() => "72px").join(" ") : "minmax(160px, 1fr)"}`;
const ticks = pipelineGanttTicks(bounds);
const selectedIntervalKey = String(selection?.interval?.procedureRunId || "");
const updateVisibleRange = () => {
const element = viewportRef.current as HTMLElement | null;
if (!element) {
setVisibleRange({ startMs: Number(bounds.startMs), endMs: Number(bounds.endMs) });
return;
}
const headerHeight = 64;
const topPx = Math.max(0, element.scrollTop - headerHeight);
const visiblePx = Math.max(120, element.clientHeight - headerHeight);
const bottomPx = Math.min(chartHeight, topPx + visiblePx);
const startRatio = Math.max(0, Math.min(1, topPx / chartHeight));
const endRatio = Math.max(startRatio, Math.min(1, bottomPx / chartHeight));
const duration = Math.max(1, Number(bounds.endMs) - Number(bounds.startMs));
setVisibleRange({
startMs: Number(bounds.startMs) + duration * startRatio,
endMs: Number(bounds.startMs) + duration * endRatio,
});
};
useEffect(() => {
const element = viewportRef.current as HTMLElement | null;
const timer = window.setTimeout(updateVisibleRange, 0);
element?.addEventListener("scroll", updateVisibleRange);
window.addEventListener("resize", updateVisibleRange);
return () => {
window.clearTimeout(timer);
element?.removeEventListener("scroll", updateVisibleRange);
window.removeEventListener("resize", updateVisibleRange);
};
}, [activeRunId, bounds.startMs, bounds.endMs, chartHeight]);
const hiddenCount = Math.max(0, allNodeIds.length - visibleNodeIds.length);
return h(Panel, {
title: "Epoch 甘特图",
eyebrow: `${activePipeline?.id || "pipeline"} / ${epochs.length} epochs`,
className: "pipeline-wide-panel",
actions: h("div", { className: "pipeline-gantt-actions" },
h("select", {
value: activeRunId,
disabled: epochs.length === 0,
onChange: (event: any) => onRunChange(event.target.value),
"data-testid": "pipeline-epoch-select",
}, epochs.map((run: any) => h("option", { key: run.runId, value: run.runId }, pipelineEpochLabel(epochs, run)))),
h("label", { className: "pipeline-gantt-toggle" },
h("input", {
type: "checkbox",
checked: autoHideIdle,
onChange: (event: any) => {
setAutoHideIdle(Boolean(event.target.checked));
window.setTimeout(updateVisibleRange, 0);
},
}),
h("span", null, "自动隐藏空闲列"),
),
activeRun ? h(RawButton, { title: `Pipeline Epoch ${activeRun.runId}`, data: activeRun, onOpen: onRaw, testId: "raw-pipeline-epoch-gantt" }) : null,
),
},
!activeRun ? h(EmptyState, { title: "暂无 Epoch", text: "当前 pipeline 还没有完整运行记录。" }) :
intervals.length === 0 ? h(EmptyState, { title: "暂无时间区间", text: "等待 D601 Pipeline backend 在 procedure summary 中返回 startedAt / finishedAt。" }) :
h("div", { className: "pipeline-gantt-wrap" },
h("div", { className: "pipeline-gantt-detail-layout" },
h("div", { className: "pipeline-gantt-main" },
h("div", { className: "pipeline-gantt-meta" },
h("span", null, `time ${fmtDate(bounds.startMs)} -> ${fmtDate(bounds.endMs)}`),
h("span", null, `duration ${fmtDurationMs(bounds.durationMs)}`),
h("span", null, `visible ${visibleNodeIds.length}/${allNodeIds.length} nodes`),
autoHideIdle && hiddenCount > 0 ? h("span", null, `hidden idle ${hiddenCount}`) : null,
),
h("div", { className: "pipeline-gantt-viewport", ref: viewportRef, "data-testid": "pipeline-epoch-gantt" },
h("div", { className: "pipeline-gantt-board", style: { gridTemplateColumns, minWidth: `${96 + Math.max(1, visibleNodeIds.length) * 72}px` } },
h("div", { className: "pipeline-gantt-head time" }, "Time"),
visibleNodeIds.length === 0 ? h("div", { className: "pipeline-gantt-head empty" }, "当前时间窗无工作节点") :
visibleNodeIds.map((nodeId) => h("div", { key: `head-${nodeId}`, className: "pipeline-gantt-head node", title: nodeId }, h(GanttHeaderLabel, { value: nodeId }))),
h("div", { className: "pipeline-gantt-time-axis", style: { height: `${chartHeight}px` } },
ticks.map((tick: AnyRecord) => h("div", { key: `tick-${tick.ms}`, className: "pipeline-gantt-tick", style: { top: `${tick.percent}%` } },
h("b", null, fmtDate(tick.ms)),
h("span", null, `+${fmtDurationMs(tick.ms - bounds.startMs)}`),
)),
),
visibleNodeIds.length === 0 ? h("div", { className: "pipeline-gantt-empty-col", style: { height: `${chartHeight}px` } }, "滚动到有活动的时间段后,相关 node 列会自动出现。") :
visibleNodeIds.map((nodeId) => {
const nodeIntervals = intervals.filter((interval: AnyRecord) => interval.nodeId === nodeId);
return h("div", { key: `col-${nodeId}`, className: "pipeline-gantt-node-col", style: { height: `${chartHeight}px` } },
nodeIntervals.map((interval: AnyRecord) => {
const duration = Math.max(1, Number(bounds.endMs) - Number(bounds.startMs));
const top = Math.max(0, Math.min(100, ((Number(interval.startMs) - Number(bounds.startMs)) / duration) * 100));
const bottom = Math.max(top, Math.min(100, ((Number(interval.endMs) - Number(bounds.startMs)) / duration) * 100));
const height = Math.max(10, ((bottom - top) / 100) * chartHeight);
const intervalKey = String(interval.procedureRunId || `${nodeId}-${interval.startMs}`);
return h("button", {
key: intervalKey,
type: "button",
className: `pipeline-gantt-bar ${interval.status} ${selectedIntervalKey === intervalKey ? "selected" : ""}`,
style: { top: `${top}%`, height: `${height}px` },
title: `${nodeId} ${interval.status} ${fmtDate(interval.startedAt)} -> ${fmtDate(interval.finishedAt)}`,
onClick: () => onIntervalSelect(interval),
"data-testid": "pipeline-gantt-line",
},
h("strong", null, interval.status || "working"),
h("span", null, fmtDurationMs(interval.durationMs)),
);
}),
);
}),
),
),
),
h(PipelineGanttDetailPanel, { selection, onRaw }),
),
),
);
}
function pipelineNodeControlState(): AnyRecord {
return {
loading: false,
actionLoading: "",
error: "",
message: "",
details: null,
fetchedAt: null,
appendPrompt: "",
guidePrompt: "",
redoReason: "",
};
}
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, "重做 / 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, refreshedAt: null });
const [selectedPipelineId, setSelectedPipelineId] = useState("");
const [selectedRunId, setSelectedRunId] = useState("");
const [selectedNodeId, setSelectedNodeId] = useState("");
const [nodeControl, setNodeControl] = useState(pipelineNodeControlState());
const [ganttSelection, setGanttSelection] = useState({ interval: null, loading: false, error: "", details: null, fetchedAt: null });
async function load(): Promise<void> {
if (!service) return;
setState((prev: any) => ({ ...prev, loading: true, error: "" }));
try {
const [health, snapshot] = await Promise.all([
requestJson(`${apiBaseUrl}/microservices/pipeline/health`),
requestJson(`${apiBaseUrl}/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:80,runs:3`),
]);
setState({ loading: false, error: "", health, snapshot, refreshedAt: new Date() });
} catch (err) {
setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "Pipeline 加载失败") }));
}
}
useEffect(() => {
load();
}, [service?.id, service?.runtime?.providerStatus]);
const runtime = microserviceRuntime(service);
const repository = microserviceRepository(service);
const backend = microserviceBackend(service);
const snapshot = state.snapshot || {};
const { components, pipelines, runs } = pipelineSnapshotArrays(snapshot);
const activePipeline = pipelines.find((pipeline: any) => String(pipeline.id || "") === selectedPipelineId) || pipelines[0] || {};
const activePipelineId = String(activePipeline.id || "");
const pipelineNodes = pipelineConfigNodes(activePipeline);
const pipelineEdges = pipelineConfigEdges(activePipeline);
const latestRun = pipelineLatestRun(runs, activePipelineId);
const pipelineRuns = pipelineEpochRuns(runs, activePipelineId);
const activeRun = pipelineRuns.find((run: any) => String(run?.runId || "") === selectedRunId) || latestRun;
const 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({ interval: null, loading: false, error: "", details: null, fetchedAt: null });
}, [selectedNodeId, pipelineNodeIds]);
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({ interval, loading: true, error: "", details: null, fetchedAt: null });
if (!runId || !nodeId) {
setGanttSelection({ interval, loading: false, error: "缺少 runId 或 nodeId,无法抓取执行过程", details: null, fetchedAt: null });
return;
}
if (runId !== activeRunId) setSelectedRunId(runId);
setSelectedNodeId(nodeId);
setNodeControl(pipelineNodeControlState());
try {
const details = await requestJson(pipelineProxyPath(apiBaseUrl, `/api/node-control/runs/${encodeURIComponent(runId)}/nodes/${encodeURIComponent(nodeId)}?tail=220`));
setGanttSelection({ interval, loading: false, error: "", details, fetchedAt: new Date() });
setNodeControl((prev: AnyRecord) => ({ ...prev, details, fetchedAt: new Date(), error: "" }));
} catch (err) {
setGanttSelection({ interval, loading: false, error: errorMessage(err, "抓取线条执行过程失败"), details: null, fetchedAt: null });
}
}
async function postNodeAction(action: "append" | "guide" | "redo"): Promise<void> {
if (!activeRunId || !selectedNodeId) {
setNodeControl((prev: AnyRecord) => ({ ...prev, error: "请先选择 run 和 node", message: "" }));
return;
}
const endpoint = action === "append" ? "prompts" : action;
const text = action === "append" ? nodeControl.appendPrompt : action === "guide" ? nodeControl.guidePrompt : 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"
? { reason: text, source: "unidesk-frontend" }
: { prompt: text, source: "unidesk-frontend" };
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,
redoReason: action === "redo" ? "" : prev.redoReason,
message: action === "append" ? "已追加到运行中 node" : action === "guide" ? "已下发 guide,等待 runner 处理" : "已排队重做命令",
}));
await fetchNodeDetails(activeRunId, selectedNodeId);
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("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "Pipeline Snapshot", data: snapshot, onOpen: onRaw, testId: "raw-pipeline-snapshot" })),
),
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({ interval: null, loading: false, error: "", details: null, fetchedAt: null });
},
"data-testid": "pipeline-select",
}, pipelines.map((pipeline: any) => h("option", { key: pipeline.id, value: pipeline.id }, pipeline.id || pipeline.key))),
h("select", {
value: activeRunId,
disabled: pipelineRuns.length === 0,
onChange: (event: any) => {
setSelectedRunId(event.target.value);
setNodeControl(pipelineNodeControlState());
if (selectedNodeId) void fetchNodeDetails(event.target.value, selectedNodeId);
},
"data-testid": "pipeline-run-select",
}, pipelineRuns.map((run: any) => h("option", { key: run.runId, value: run.runId }, pipelineEpochLabel(pipelineRuns, run)))),
h("button", {
type: "button",
className: "ghost-btn",
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());
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, selectedNodeId ? `selected ${selectedNodeId}` : "click node to control"),
),
),
h(PipelineEpochGantt, {
epochs: pipelineRuns,
activeRun,
activePipeline,
pipelineNodes,
selection: ganttSelection,
onIntervalSelect: selectGanttInterval,
onRunChange: (runId: string) => {
setSelectedRunId(runId);
setNodeControl(pipelineNodeControlState());
setGanttSelection({ interval: null, loading: false, error: "", details: null, fetchedAt: null });
if (selectedNodeId) void fetchNodeDetails(runId, selectedNodeId);
},
onRaw,
}),
h(Panel, { title: "Epoch 列表", eyebrow: `${pipelineRuns.length}/${runCount} preview` },
pipelineRuns.length === 0 ? h(EmptyState, { title: "暂无运行记录", text: "当前 pipeline 在 .state/pipeline-runs 中还没有 epoch。" }) :
h("div", { className: "pipeline-run-list" }, pipelineRuns.map((run: any) => h("article", {
key: run.runId,
className: `pipeline-run-card ${String(run.runId || "") === activeRunId ? "active" : ""}`,
role: "button",
tabIndex: 0,
onClick: () => setSelectedRunId(String(run.runId || "")),
onKeyDown: (event: any) => {
if (event.key === "Enter" || event.key === " ") setSelectedRunId(String(run.runId || ""));
},
},
h("div", { className: "node-card-head" }, h("strong", null, pipelineEpochLabel(pipelineRuns, run)), h(StatusBadge, { status: run.status }, run.status || "--")),
h("div", { className: "docker-meta compact" },
h("span", null, run.pipelineId || "--"),
h("span", null, `nodes ${Array.isArray(run.nodes) ? run.nodes.length : 0}`),
h("span", null, `oa ${Array.isArray(run.submissions) ? run.submissions.length : 0}`),
h("span", null, `procedures ${Array.isArray(run.procedureRuns) ? run.procedureRuns.length : 0}`),
),
h("p", { className: "muted paragraph" }, summarizeValue(run.task)),
h("span", { className: "pipeline-run-time" }, fmtDate(run.updatedAt)),
))),
),
h(Panel, { title: "运行材料索引", eyebrow: activeRun?.runId || "selected epoch", className: "pipeline-wide-panel" },
h(PipelineRunMaterialIndex, { activeRun, onRaw }),
),
),
);
}