1927 lines
96 KiB
TypeScript
1927 lines
96 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;
|
||
|
||
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, "&")
|
||
.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 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 }),
|
||
),
|
||
),
|
||
);
|
||
}
|