fix: harden provider gateway upgrades

This commit is contained in:
Codex
2026-05-06 00:24:17 +00:00
parent af42002f02
commit 2dc46ce056
16 changed files with 1059 additions and 228 deletions
+4
View File
@@ -1302,6 +1302,10 @@ const providerServer = Bun.serve<WsData>({
const providerId = ws.data.providerId;
logger("warn", "provider_socket_close", { providerId: providerId ?? null });
if (providerId !== undefined) {
if (activeProviders.get(providerId) !== ws) {
logger("info", "provider_socket_close_ignored_replaced", { providerId });
return;
}
markProviderOffline(providerId).catch((error) => logger("error", "provider_offline_mark_failed", { providerId, error: errorToJson(error) }));
}
},
+117 -5
View File
@@ -398,9 +398,9 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; }
font-size: 11px;
}
.status-badge.online, .status-badge.succeeded, .status-badge.public { color: var(--ok); border-color: rgba(113, 191, 120, 0.45); }
.status-badge.offline, .status-badge.failed { color: var(--danger); border-color: rgba(207, 106, 84, 0.45); }
.status-badge.offline, .status-badge.failed, .status-badge.canceled { color: var(--danger); border-color: rgba(207, 106, 84, 0.45); }
.status-badge.running, .status-badge.dispatched, .status-badge.accepted, .status-badge.internal { color: var(--accent-2); border-color: rgba(78, 183, 168, 0.45); }
.status-badge.queued, .status-badge.warn { color: var(--warn); border-color: rgba(215, 161, 58, 0.45); }
.status-badge.queued, .status-badge.staged, .status-badge.warn { color: var(--warn); border-color: rgba(215, 161, 58, 0.45); }
.status-badge.private, .status-badge.p1, .status-badge.prioritized, .status-badge.verified { color: var(--accent-2); border-color: rgba(78, 183, 168, 0.45); }
.status-badge.stale, .status-badge.invalid, .status-badge.abandoned { color: var(--warn); border-color: rgba(215, 161, 58, 0.45); }
@@ -725,6 +725,17 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; }
color: var(--muted);
}
.upgrade-target-line {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
padding: 6px 7px;
border: 1px solid var(--line-soft);
background: rgba(78, 183, 168, 0.07);
color: var(--muted);
}
.upgrade-actions {
display: flex;
gap: 6px;
@@ -1037,6 +1048,101 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
background: linear-gradient(90deg, var(--accent-2), var(--accent));
}
.met-job-table { max-height: 460px; overflow: auto; }
.met-queue-summary {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin: 0 0 8px;
color: var(--muted);
font-size: 12px;
}
.met-queue-summary > span:not(.status-badge) {
padding: 4px 8px;
border: 1px solid var(--line-soft);
background: var(--panel-3);
}
.met-action-log {
margin-top: 8px;
padding: 7px 9px;
border: 1px solid var(--line-soft);
background: rgba(78, 183, 168, 0.08);
color: var(--accent-2);
font-size: 12px;
}
.met-control-strip, .met-tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.met-control-strip label, .met-fork-card label {
display: grid;
gap: 4px;
color: var(--muted);
font-size: 11px;
letter-spacing: 0.04em;
}
.met-control-strip input, .met-fork-card input, .met-fork-card select {
min-height: 28px;
padding: 4px 8px;
}
.met-control-strip input { width: 130px; }
.met-tabs {
padding: 8px;
border-bottom: 1px solid var(--line-soft);
}
.met-tabs button {
min-height: 28px;
padding: 4px 10px;
border: 1px solid var(--line-soft);
background: var(--panel-3);
color: var(--text);
}
.met-tabs button.active {
border-color: var(--accent);
background: rgba(215, 161, 58, 0.14);
}
.met-workspace { grid-column: 1 / -1; }
.met-form-grid {
display: grid;
grid-template-columns: minmax(280px, 340px) minmax(560px, 1fr);
gap: 10px;
align-items: start;
}
.met-fork-card {
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid var(--line-soft);
background: var(--panel-3);
}
.met-fork-card h3 {
margin: 0 0 2px;
font-size: 13px;
}
.met-project-list {
min-width: 0;
border: 1px solid var(--line-soft);
}
.met-project-table { max-height: 520px; overflow: auto; }
.ghost-btn.mini {
min-height: 22px;
padding: 2px 6px;
font-size: 11px;
}
.pipeline-toolbar {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
align-items: center;
}
.pipeline-toolbar select {
width: min(280px, 42vw);
min-height: 28px;
padding: 4px 8px;
}
.pipeline-flow-frame {
height: min(68vh, 720px);
min-height: 520px;
@@ -1062,10 +1168,10 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
background: rgba(8, 13, 18, 0.88);
}
.pipeline-flow-frame .react-flow__edges {
z-index: 5;
z-index: 30 !important;
}
.pipeline-flow-frame .react-flow__nodes {
z-index: 4;
z-index: 20 !important;
}
.pipeline-flow-frame .react-flow__edge-path {
stroke: rgba(129, 147, 159, 0.72);
@@ -1076,7 +1182,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
}
.pipeline-flow-frame .react-flow__edge {
opacity: 0.82;
z-index: 12;
z-index: 30 !important;
}
.pipeline-flow-frame .react-flow__edge:hover,
.pipeline-flow-frame .react-flow__edge.selected {
@@ -1089,6 +1195,12 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
.pipeline-flow-frame .react-flow__edge.succeeded .react-flow__edge-path { stroke: var(--accent-2); }
.pipeline-flow-frame .react-flow__edge.running .react-flow__edge-path { stroke: var(--accent); }
.pipeline-flow-frame .react-flow__edge.failed .react-flow__edge-path { stroke: var(--danger); }
.pipeline-flow-frame .react-flow__edge.feedback .react-flow__edge-path {
stroke-dasharray: 9 7;
}
.pipeline-flow-frame .react-flow__edge.overlap-colored .react-flow__edge-path {
filter: drop-shadow(0 0 6px rgba(215, 161, 58, 0.16));
}
.pipeline-flow-frame .pipeline-flow-handle {
width: 9px;
height: 18px;
+19
View File
@@ -251,6 +251,16 @@ function taskUpgradePolicy(task: any): string {
return typeof policy === "string" && policy.length > 0 ? policy : "--";
}
function taskUpgradeVersion(task: any): string {
const result = taskResult(task);
const plan = result.plan && typeof result.plan === "object" && !Array.isArray(result.plan) ? result.plan as AnyRecord : {};
const value = result.targetProviderGatewayVersion
?? result.providerGatewayVersion
?? plan.targetProviderGatewayVersion
?? plan.providerGatewayVersion;
return typeof value === "string" && value.length > 0 ? fmtGatewayVersion(value) : "版本未知";
}
function taskUpgradeOutcome(task: any): string {
const status = String(task?.status || "").toLowerCase();
if (status === "failed") return taskFailureReason(task);
@@ -768,6 +778,11 @@ function UpgradeControl({ provider, refresh, onRaw }: AnyRecord) {
return h(Panel, { title: "Provider Gateway 升级", eyebrow: "Remote Control" },
h("div", { className: "upgrade-control", "data-testid": "provider-upgrade-control" },
h("p", null, "通过 UniDesk WebSocket 向当前计算节点下发 provider.upgrade;预检只生成升级计划,执行升级会调度节点本地 updater 容器。"),
h("div", { className: "upgrade-target-line" },
h("span", null, "指定 Provider"),
h("code", null, provider.providerId),
h(GatewayVersionBadge, { node: provider }),
),
h("div", { className: "upgrade-actions" },
h("button", { type: "button", className: "ghost-btn", disabled: Boolean(busyMode), onClick: () => run("plan"), "data-testid": "upgrade-plan-button" }, busyMode === "plan" ? "预检中" : "预检升级"),
h("button", { type: "button", className: "ghost-btn danger", disabled: Boolean(busyMode), onClick: () => run("schedule"), "data-testid": "upgrade-schedule-button" }, busyMode === "schedule" ? "调度中" : "执行升级"),
@@ -776,6 +791,7 @@ function UpgradeControl({ provider, refresh, onRaw }: AnyRecord) {
result ? h("div", { className: "upgrade-result" },
h(StatusBadge, { status: result.status || "queued" }, result.status || "queued"),
h("span", null, `${result.mode === "schedule" ? "执行升级" : "预检升级"} 已下发`),
h("span", null, `指定版本 ${fmtGatewayVersion(nodeGatewayVersion(provider))}`),
h("code", null, result.taskId || "--"),
h(RawButton, { title: "Provider Upgrade Dispatch", data: result, onOpen: onRaw }),
) : h("span", { className: "muted" }, "升级任务结果会进入任务历史;执行升级可能导致 provider 短暂重连。"),
@@ -795,6 +811,7 @@ function UpgradeRecordsTable({ records, onRaw, compact = false }: AnyRecord) {
h("th", null, "来源"),
h("th", null, "耗时"),
h("th", null, "策略"),
h("th", null, "Gateway 版本"),
h("th", null, "结果记录"),
h("th", null, "更新时间"),
h("th", null, "操作"),
@@ -806,6 +823,7 @@ function UpgradeRecordsTable({ records, onRaw, compact = false }: AnyRecord) {
h("td", null, taskUpgradeSource(task)),
h("td", null, h(TaskDurationCell, { task })),
h("td", null, taskUpgradePolicy(task)),
h("td", null, h("span", { className: "version-chip" }, taskUpgradeVersion(task))),
h("td", null, h("span", { className: `upgrade-outcome ${String(task.status || "").toLowerCase()}` }, taskUpgradeOutcome(task))),
h("td", null, fmtDate(task.updatedAt)),
h("td", null, h(RawButton, { title: `Provider Upgrade Task ${task.id}`, data: task, onOpen: onRaw })),
@@ -863,6 +881,7 @@ function GatewayVersionPage({ nodes, tasks, onRaw }: AnyRecord) {
h("td", null, row.latest ? h("div", { className: "latest-upgrade-cell" },
h(StatusBadge, { status: row.latest.status }),
h("span", null, `${taskUpgradeMode(row.latest) === "schedule" ? "执行升级" : "预检"} / ${fmtDate(row.latest.updatedAt)}`),
h("small", null, `Gateway ${taskUpgradeVersion(row.latest)}`),
h("small", null, taskUpgradeOutcome(row.latest)),
) : h("span", { className: "muted" }, "暂无记录")),
h("td", null, h(RawButton, { title: `Provider ${row.node.providerId}`, data: row.node, onOpen: onRaw })),
+211 -92
View File
@@ -26,19 +26,6 @@ function fmtPercent(value: any): string {
return Number.isFinite(number) ? `${Math.max(0, Math.min(100, number)).toFixed(1)}%` : "--";
}
function fmtBytes(value: any): string {
const bytes = Number(value);
if (!Number.isFinite(bytes) || bytes <= 0) return "--";
const units = ["B", "KB", "MB", "GB", "TB"];
let current = bytes;
let index = 0;
while (current >= 1024 && index < units.length - 1) {
current /= 1024;
index += 1;
}
return `${current.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
function fmtDuration(seconds: any): string {
const value = Number(seconds);
if (!Number.isFinite(value)) return "--";
@@ -121,16 +108,16 @@ function queueCounts(queue: any): AnyRecord {
}
function jobRows(queue: any): any[] {
return Array.isArray(queue?.jobs) ? queue.jobs.slice(0, 80) : [];
return Array.isArray(queue?.jobs) ? queue.jobs.slice(0, 240) : [];
}
function projectRows(projects: any): any[] {
return Array.isArray(projects?.projects) ? projects.projects.slice(0, 40) : [];
return Array.isArray(projects?.projects) ? projects.projects.slice(0, 160) : [];
}
function gpuRows(health: any, queue: any): any[] {
if (Array.isArray(health?.gpu)) return health.gpu;
if (Array.isArray(queue?.gpu)) return queue.gpu;
if (Array.isArray(health?.gpu)) return health.gpu;
return [];
}
@@ -138,9 +125,38 @@ function metApi(apiBaseUrl: string, path: string): string {
return `${apiBaseUrl}/microservices/met-nonlinear/proxy${path}`;
}
function jobDuration(job: any): string {
return job.startedAt && job.finishedAt ? fmtDuration((Date.parse(job.finishedAt) - Date.parse(job.startedAt)) / 1000) : "--";
}
function statusLabel(status: string): string {
if (status === "staged") return "待启动";
if (status === "queued") return "排队中";
if (status === "running") return "训练中";
if (status === "succeeded") return "已完成";
if (status === "failed") return "失败";
if (status === "canceled") return "已取消";
return status || "unknown";
}
export function MetNonlinearPage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) {
const service = microservices.find((item: any) => item.id === "met-nonlinear") || null;
const [state, setState] = useState({ loading: false, actionBusy: false, error: "", health: null, summary: null, queue: null, projects: null, history: null, images: null, refreshedAt: null });
const [ui, setUi] = useState(() => ({
activeTab: "projects",
selectedProjects: {},
sourceProject: "",
forkCount: 1,
forkEpochs: 200,
forkPrefix: `ui_fork_${Date.now()}`,
maxConcurrency: 3,
targetGpuName: "2080 Ti",
actionMessage: "",
}));
function patchUi(patch: AnyRecord): void {
setUi((prev: any) => ({ ...prev, ...patch }));
}
async function load(): Promise<void> {
if (!service) return;
@@ -150,7 +166,7 @@ export function MetNonlinearPage({ microservices, onRaw, apiBaseUrl = "/api" }:
requestJson(`${apiBaseUrl}/microservices/met-nonlinear/health`),
requestJson(metApi(apiBaseUrl, "/api/summary")),
requestJson(metApi(apiBaseUrl, "/api/queue")),
requestJson(metApi(apiBaseUrl, "/api/projects?root=projects&limit=40")),
requestJson(metApi(apiBaseUrl, "/api/projects?root=projects&limit=160")),
requestJson(metApi(apiBaseUrl, "/api/history")),
requestJson(metApi(apiBaseUrl, "/api/images")),
]);
@@ -160,19 +176,79 @@ export function MetNonlinearPage({ microservices, onRaw, apiBaseUrl = "/api" }:
}
}
async function enqueueServerTest(): Promise<void> {
async function runAction(label: string, fn: () => Promise<string | void>): Promise<void> {
setState((prev: any) => ({ ...prev, actionBusy: true, error: "" }));
patchUi({ actionMessage: `${label}...` });
try {
await requestJson(metApi(apiBaseUrl, "/api/queue/server-test"), {
method: "POST",
body: JSON.stringify({ sourceProject: "projects/FRIKANh6u6l4", count: 10, epochs: 10, maxConcurrency: 2 }),
});
const message = await fn();
patchUi({ actionMessage: message || `${label}完成` });
await load();
} catch (err) {
setState((prev: any) => ({ ...prev, actionBusy: false, error: errorMessage(err, "Server test 入队失败") }));
setState((prev: any) => ({ ...prev, actionBusy: false, error: errorMessage(err, `${label}失败`) }));
}
}
async function saveQueueSettings(): Promise<void> {
await runAction("保存并发设置", async () => {
await requestJson(metApi(apiBaseUrl, "/api/queue/settings"), {
method: "PUT",
body: JSON.stringify({ maxConcurrency: Number(ui.maxConcurrency), targetGpuName: ui.targetGpuName }),
});
});
}
function selectedProjectPaths(): string[] {
return Object.entries(ui.selectedProjects).filter(([, selected]) => selected).map(([path]) => path);
}
async function stageSelectedProjects(): Promise<void> {
const paths = selectedProjectPaths();
if (paths.length === 0) throw new Error("请先选择至少一个 project");
await runAction("加入待启动队列", async () => {
await requestJson(metApi(apiBaseUrl, "/api/queue"), {
method: "POST",
body: JSON.stringify({ projectPaths: paths, maxConcurrency: Number(ui.maxConcurrency), targetGpuName: ui.targetGpuName, start: false }),
});
patchUi({ activeTab: "current", selectedProjects: {} });
});
}
async function forkProjects(): Promise<void> {
const sourceProject = ui.sourceProject || projects[0]?.projectPath;
if (!sourceProject) throw new Error("请先选择源 project");
await runAction("Fork Project", async () => {
const forked = await requestJson(metApi(apiBaseUrl, "/api/projects/fork"), {
method: "POST",
body: JSON.stringify({ sourceProject, count: Number(ui.forkCount), epochs: Number(ui.forkEpochs), prefix: ui.forkPrefix }),
});
const projectPaths = Array.isArray(forked.projectPaths) ? forked.projectPaths : [];
const selectedProjects = projectPaths.reduce((acc: AnyRecord, projectPath: string) => {
acc[projectPath] = true;
return acc;
}, { ...ui.selectedProjects });
patchUi({
selectedProjects,
});
return `已 fork ${projectPaths.length} 个 project,并已自动勾选;请确认后点击加入待启动队列。`;
});
}
async function startQueue(): Promise<void> {
await runAction("启动队列", async () => {
await requestJson(metApi(apiBaseUrl, "/api/queue/start"), {
method: "POST",
body: JSON.stringify({ maxConcurrency: Number(ui.maxConcurrency), targetGpuName: ui.targetGpuName }),
});
patchUi({ activeTab: "current" });
});
}
async function cancelJob(job: any): Promise<void> {
await runAction("取消任务", async () => {
await requestJson(metApi(apiBaseUrl, `/api/jobs/${encodeURIComponent(job.id)}/cancel`), { method: "POST", body: JSON.stringify({}) });
});
}
useEffect(() => {
load();
}, [service?.id, service?.runtime?.providerStatus]);
@@ -188,14 +264,62 @@ export function MetNonlinearPage({ microservices, onRaw, apiBaseUrl = "/api" }:
const image = state.images?.mlImage || state.health?.image || {};
const jobs = jobRows(state.queue);
const projects = projectRows(state.projects);
const history = Array.isArray(state.history?.jobs) ? state.history.jobs.slice(0, 20) : [];
const sourceProject = ui.sourceProject || projects[0]?.projectPath || "";
const currentJobs = jobs.filter((job) => ["staged", "queued", "running"].includes(job.status));
const completedJobs = jobs.filter((job) => job.status === "succeeded");
const failedJobs = jobs.filter((job) => ["failed", "canceled"].includes(job.status));
const terminalHistory = Array.isArray(state.history?.jobs) ? state.history.jobs.slice(0, 120) : [];
const tabItems = [
{ id: "projects", label: "项目库", count: projects.length },
{ id: "current", label: "当前队列", count: currentJobs.length },
{ id: "completed", label: "已完成", count: completedJobs.length || Number(counts.succeeded || 0) },
{ id: "failed", label: "失败诊断", count: failedJobs.length || Number(counts.failed || 0) + Number(counts.canceled || 0) },
{ id: "gpu", label: "GPU/镜像", count: gpus.length },
];
function queueTable(rows: any[], mode: string) {
if (rows.length === 0) return h(EmptyState, { title: mode === "current" ? "当前队列为空" : "暂无记录", text: mode === "current" ? "从项目库选择或 fork project 后先加入待启动队列,再启动队列。" : "终态任务会显示耗时、exit code 和失败诊断。" });
return h("div", { className: "table-wrap met-job-table" }, h("table", null,
h("thead", null, h("tr", null, h("th", null, "状态"), h("th", null, "Project"), h("th", null, "Epoch"), h("th", null, "ETA/耗时"), h("th", null, "GPU"), h("th", null, "Exit"), h("th", null, "更新时间"), h("th", null, "操作"))),
h("tbody", null, rows.map((job: any) => {
const progress = job.progress || {};
const canCancel = ["staged", "queued", "running"].includes(job.status);
return h("tr", { key: job.id },
h("td", null, h(StatusBadge, { status: job.status }, statusLabel(job.status))),
h("td", null, h("strong", null, job.projectPath), h("code", null, job.id)),
h("td", null,
h("span", null, `${progress.currentEpoch ?? "--"} / ${progress.epochTarget ?? job.epochTarget ?? "--"}`),
h("div", { className: "met-progress" }, h("span", { style: { width: fmtPercent(progress.progressPercent) } })),
),
h("td", null, job.status === "succeeded" || job.status === "failed" || job.status === "canceled" ? jobDuration(job) : fmtDuration(progress.etaSeconds)),
h("td", null, job.gpuName || "--"),
h("td", null, job.exitCode ?? "--"),
h("td", null, fmtDate(job.updatedAt)),
h("td", null,
canCancel ? h("button", { type: "button", className: "ghost-btn mini", onClick: () => cancelJob(job), disabled: state.actionBusy }, "取消") : null,
h(RawButton, { title: `MET Job ${job.id}`, data: job, onOpen: onRaw, testId: `raw-met-job-${job.id}` }),
),
);
})),
));
}
function currentQueueSummary() {
return h("div", { className: "met-queue-summary", "data-testid": "met-current-summary" },
h(StatusBadge, { status: "staged" }, `待启动 ${counts.staged ?? 0}`),
h(StatusBadge, { status: "queued" }, `排队中 ${counts.queued ?? 0}`),
h(StatusBadge, { status: "running" }, `训练中 ${counts.running ?? 0}`),
h("span", null, `最大并发 ${state.summary?.queue?.maxConcurrency ?? state.queue?.queue?.maxConcurrency ?? ui.maxConcurrency}`),
h("span", null, `目标 GPU ${state.summary?.queue?.targetGpuName ?? state.queue?.queue?.targetGpuName ?? ui.targetGpuName}`),
);
}
return h("div", { className: "met-page", "data-testid": "met-nonlinear-page" },
h(Panel, {
title: "MET Nonlinear 训练编排",
eyebrow: "D601 GPU Microservice",
actions: h("div", { className: "panel-actions" },
h("button", { type: "button", className: "ghost-btn", onClick: load, disabled: state.loading, "data-testid": "met-refresh-button" }, state.loading ? "刷新中" : "刷新"),
h("button", { type: "button", className: "primary-btn", onClick: enqueueServerTest, disabled: state.actionBusy, "data-testid": "met-server-test-button" }, state.actionBusy ? "入队中" : "创建10个10轮任务"),
h(RawButton, { title: "MET Nonlinear Microservice", data: service, onOpen: onRaw, testId: "raw-met-service" }),
),
},
@@ -220,86 +344,81 @@ export function MetNonlinearPage({ microservices, onRaw, apiBaseUrl = "/api" }:
),
),
state.error ? h("div", { className: "form-error wide" }, state.error) : null,
ui.actionMessage ? h("div", { className: "met-action-log", "data-testid": "met-action-message" }, ui.actionMessage) : null,
),
h("div", { className: "met-grid" },
h(Panel, { title: "核心状态", eyebrow: state.refreshedAt ? `Updated ${fmtClock(state.refreshedAt)}` : "Queue + GPU" },
h("div", { className: "metric-grid" },
h(MetricCard, { label: "Queued", value: counts.queued ?? 0, hint: "等待训练", tone: Number(counts.queued || 0) > 0 ? "warn" : "" }),
h(MetricCard, { label: "Staged", value: counts.staged ?? 0, hint: "加入队列未开始", tone: Number(counts.staged || 0) > 0 ? "warn" : "" }),
h(MetricCard, { label: "Queued", value: counts.queued ?? 0, hint: "排队等待调度", tone: Number(counts.queued || 0) > 0 ? "warn" : "" }),
h(MetricCard, { label: "Running", value: counts.running ?? 0, hint: `max ${state.summary?.queue?.maxConcurrency ?? state.queue?.queue?.maxConcurrency ?? "--"}`, tone: Number(counts.running || 0) > 0 ? "ok" : "" }),
h(MetricCard, { label: "Succeeded", value: counts.succeeded ?? 0, hint: "历史成功" }),
h(MetricCard, { label: "Succeeded", value: counts.succeeded ?? 0, hint: "已完成" }),
h(MetricCard, { label: "Failed", value: counts.failed ?? 0, hint: "需要诊断", tone: Number(counts.failed || 0) > 0 ? "warn" : "" }),
h(MetricCard, { label: "2080Ti Free", value: targetGpu ? fmtPercent(Number(targetGpu.freeRatio) * 100) : "--", hint: targetGpu ? `${targetGpu.memoryFreeMiB}/${targetGpu.memoryTotalMiB} MiB` : "等待 GPU 上报" }),
h(MetricCard, { label: "ML Image", value: image.present ? "READY" : "MISSING", hint: image.image || "met-nonlinear-ml:tf26", tone: image.present ? "ok" : "warn" }),
h(MetricCard, { label: "Projects", value: projects.length, hint: "config preview" }),
h(MetricCard, { label: "Health", value: state.health?.ok ? "OK" : "--", hint: "D601 /health" }),
),
h("div", { className: "panel-actions inline-actions" },
h(RawButton, { title: "MET Summary", data: state.summary, onOpen: onRaw, testId: "raw-met-summary" }),
h(RawButton, { title: "MET Images", data: state.images, onOpen: onRaw, testId: "raw-met-images" }),
),
h(Panel, { title: "队列控制", eyebrow: "Downloader-like staging" },
h("div", { className: "met-control-strip" },
h("label", null, "最大并发", h("input", { type: "number", min: 1, max: 16, value: ui.maxConcurrency, "data-testid": "met-max-concurrency-input", onChange: (event: any) => patchUi({ maxConcurrency: event.target.value }) })),
h("label", null, "目标 GPU", h("input", { value: ui.targetGpuName, "data-testid": "met-target-gpu-input", onChange: (event: any) => patchUi({ targetGpuName: event.target.value }) })),
h("button", { type: "button", className: "ghost-btn", onClick: saveQueueSettings, disabled: state.actionBusy, "data-testid": "met-save-settings-button" }, "保存设置"),
h("button", { type: "button", className: "primary-btn", onClick: startQueue, disabled: state.actionBusy || Number(counts.staged || 0) === 0, "data-testid": "met-start-queue-button" }, "启动队列"),
),
h("p", { className: "muted paragraph" }, "Project 先进入待启动队列,不会立即训练;点击启动队列后才切换为排队中,并由 D601 scheduler 按最大并发和 2080Ti 显存策略调度。"),
),
h(Panel, { title: "GPU 与镜像", eyebrow: `${gpus.length} GPU` },
gpus.length === 0 ? h(EmptyState, { title: "暂无 GPU 上报", text: "等待 D601 met-nonlinear-ts 或 ML image 提供 nvidia-smi 数据" }) :
h("div", { className: "table-wrap" }, h("table", null,
h("thead", null, h("tr", null, h("th", null, "Index"), h("th", null, "Name"), h("th", null, "Free"), h("th", null, "Policy"))),
h("tbody", null, gpus.map((gpu: any) => h("tr", { key: gpu.index },
h("td", null, gpu.index),
h("td", null, gpu.name),
h("td", null, `${gpu.memoryFreeMiB} / ${gpu.memoryTotalMiB} MiB`, h("div", { className: "met-progress" }, h("span", { style: { width: fmtPercent(Number(gpu.freeRatio) * 100) } }))),
h("td", null, String(gpu.name || "").includes("2080") ? "target 2080Ti, <20% 限制并发" : "non-target"),
))),
)),
),
h(Panel, { title: "任务队列", eyebrow: `${jobs.length} Jobs` },
jobs.length === 0 ? h(EmptyState, { title: "队列为空", text: "可创建 server_test 训练任务或通过 API 添加已有 projects" }) :
h("div", { className: "table-wrap met-job-table" }, h("table", null,
h("thead", null, h("tr", null, h("th", null, "状态"), h("th", null, "Project"), h("th", null, "Epoch"), h("th", null, "ETA"), h("th", null, "GPU"), h("th", null, "Exit"), h("th", null, "更新时间"))),
h("tbody", null, jobs.map((job: any) => {
const progress = job.progress || {};
return h("tr", { key: job.id },
h("td", null, h(StatusBadge, { status: job.status }, job.status)),
h("td", null, h("strong", null, job.projectPath), h("code", null, job.id)),
h("td", null,
h("span", null, `${progress.currentEpoch ?? "--"} / ${progress.epochTarget ?? job.epochTarget ?? "--"}`),
h("div", { className: "met-progress" }, h("span", { style: { width: fmtPercent(progress.progressPercent) } })),
),
h("td", null, fmtDuration(progress.etaSeconds)),
h("td", null, job.gpuName || "--"),
h("td", null, job.exitCode ?? "--"),
h("td", null, fmtDate(job.updatedAt)),
);
})),
)),
h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "MET Queue", data: state.queue, onOpen: onRaw, testId: "raw-met-queue" })),
),
h(Panel, { title: "Project Config 预览", eyebrow: "projects/" },
projects.length === 0 ? h(EmptyState, { title: "暂无 project", text: "等待 D601 返回 /api/projects" }) :
h("div", { className: "table-wrap" }, h("table", null,
h("thead", null, h("tr", null, h("th", null, "Project"), h("th", null, "Model"), h("th", null, "Epochs"), h("th", null, "Step/Epoch"), h("th", null, "GPU"), h("th", null, "Progress"))),
h("tbody", null, projects.map((project: any) => h("tr", { key: project.projectPath },
h("td", null, project.projectPath),
h("td", null, project.useModel || "--"),
h("td", null, project.epochTrain ?? "--"),
h("td", null, project.stepPerEpoch ?? "--"),
h("td", null, project.usingGpu ? "true" : "false"),
h("td", null, fmtPercent(project.progress?.progressPercent)),
))),
)),
h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "MET Projects", data: state.projects, onOpen: onRaw, testId: "raw-met-projects" })),
),
h(Panel, { title: "历史训练记录", eyebrow: `${history.length} Terminal Jobs` },
history.length === 0 ? h(EmptyState, { title: "暂无历史", text: "完成或失败的训练任务会显示耗时、exit code 和失败诊断" }) :
h("div", { className: "history-list" }, history.map((job: any) => h("article", { key: job.id, className: "result-card" },
h("div", { className: "node-card-head" }, h("strong", null, job.projectPath), h(StatusBadge, { status: job.status }, job.status)),
h("div", { className: "docker-meta compact" },
h("span", null, `耗时 ${job.startedAt && job.finishedAt ? fmtDuration((Date.parse(job.finishedAt) - Date.parse(job.startedAt)) / 1000) : "--"}`),
h("span", null, `exit ${job.exitCode ?? "--"}`),
h("span", null, job.gpuName || "--"),
h("section", { className: "panel met-workspace" },
h("div", { className: "met-tabs", role: "tablist" }, tabItems.map((tab) => h("button", {
key: tab.id,
type: "button",
className: ui.activeTab === tab.id ? "active" : "",
onClick: () => patchUi({ activeTab: tab.id }),
"data-testid": `met-tab-${tab.id}`,
}, `${tab.label} ${tab.count}`))),
h("div", { className: "panel-body" },
ui.activeTab === "projects" ? h("div", { className: "met-form-grid", "data-testid": "met-projects-pane" },
h("div", { className: "met-fork-card" },
h("h3", null, "Fork Project"),
h("label", null, "源 Project", h("select", { value: sourceProject, "data-testid": "met-source-project-select", onChange: (event: any) => patchUi({ sourceProject: event.target.value }) }, projects.map((project: any) => h("option", { key: project.projectPath, value: project.projectPath }, `${project.projectPath} · ${project.useModel || "model?"}`)))),
h("label", null, "Fork 数量", h("input", { type: "number", min: 1, max: 100, value: ui.forkCount, "data-testid": "met-fork-count-input", onChange: (event: any) => patchUi({ forkCount: event.target.value }) })),
h("label", null, "训练轮数", h("input", { type: "number", min: 1, max: 100000, value: ui.forkEpochs, "data-testid": "met-fork-epochs-input", onChange: (event: any) => patchUi({ forkEpochs: event.target.value }) })),
h("label", null, "目标前缀", h("input", { value: ui.forkPrefix, "data-testid": "met-fork-prefix-input", onChange: (event: any) => patchUi({ forkPrefix: event.target.value }) })),
h("button", { type: "button", className: "primary-btn", onClick: forkProjects, disabled: state.actionBusy || !sourceProject, "data-testid": "met-fork-button" }, "Fork Project"),
h("p", { className: "muted paragraph" }, "Fork 只创建新 Project 并自动勾选,不会直接训练;需要在右侧确认后加入待启动队列。"),
),
job.error ? h("p", { className: "form-error" }, job.error.slice(0, 260)) : h("p", { className: "muted" }, "无失败原因"),
h("code", null, job.id),
))),
h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "MET History", data: state.history, onOpen: onRaw, testId: "raw-met-history" })),
h("div", { className: "met-project-list" },
h("div", { className: "panel-head compact" }, h("div", null, h("p", { className: "panel-eyebrow" }, "Existing Projects"), h("h2", null, "选择已有 Project")), h("button", { type: "button", className: "ghost-btn", onClick: stageSelectedProjects, disabled: state.actionBusy || selectedProjectPaths().length === 0, "data-testid": "met-stage-selected-button" }, `加入待启动队列 (${selectedProjectPaths().length})`)),
projects.length === 0 ? h(EmptyState, { title: "暂无 project", text: "等待 D601 返回 /api/projects" }) :
h("div", { className: "table-wrap met-project-table" }, h("table", null,
h("thead", null, h("tr", null, h("th", null, "选择"), h("th", null, "Project"), h("th", null, "Model"), h("th", null, "Epochs"), h("th", null, "Progress"))),
h("tbody", null, projects.map((project: any) => h("tr", { key: project.projectPath },
h("td", null, h("input", { type: "checkbox", checked: Boolean(ui.selectedProjects[project.projectPath]), onChange: (event: any) => patchUi({ selectedProjects: { ...ui.selectedProjects, [project.projectPath]: event.target.checked } }), "data-testid": `met-project-checkbox-${project.projectPath.replace(/[^a-zA-Z0-9_-]/g, "-")}` })),
h("td", null, h("strong", null, project.projectPath)),
h("td", null, project.useModel || "--"),
h("td", null, project.epochTrain ?? "--"),
h("td", null, fmtPercent(project.progress?.progressPercent)),
))),
)),
),
) : null,
ui.activeTab === "current" ? h("div", { "data-testid": "met-current-pane" }, currentQueueSummary(), queueTable(currentJobs, "current"), h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "MET Queue", data: state.queue, onOpen: onRaw, testId: "raw-met-queue" }))) : null,
ui.activeTab === "completed" ? h("div", { "data-testid": "met-completed-pane" }, queueTable(completedJobs.length > 0 ? completedJobs : terminalHistory.filter((job: any) => job.status === "succeeded"), "completed")) : null,
ui.activeTab === "failed" ? h("div", { "data-testid": "met-failed-pane" }, queueTable(failedJobs.length > 0 ? failedJobs : terminalHistory.filter((job: any) => ["failed", "canceled"].includes(job.status)), "failed"), h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "MET History", data: state.history, onOpen: onRaw, testId: "raw-met-history" }))) : null,
ui.activeTab === "gpu" ? h("div", { className: "met-gpu-pane", "data-testid": "met-gpu-pane" },
gpus.length === 0 ? h(EmptyState, { title: "暂无 GPU 上报", text: "等待 D601 met-nonlinear-ts 或 ML image 提供 nvidia-smi 数据" }) :
h("div", { className: "table-wrap" }, h("table", null,
h("thead", null, h("tr", null, h("th", null, "Index"), h("th", null, "Name"), h("th", null, "Free"), h("th", null, "Policy"))),
h("tbody", null, gpus.map((gpu: any) => h("tr", { key: gpu.index },
h("td", null, gpu.index),
h("td", null, gpu.name),
h("td", null, `${gpu.memoryFreeMiB} / ${gpu.memoryTotalMiB} MiB`, h("div", { className: "met-progress" }, h("span", { style: { width: fmtPercent(Number(gpu.freeRatio) * 100) } }))),
h("td", null, String(gpu.name || "").includes("2080") ? "target 2080Ti, <20% 限制并发" : "non-target"),
))),
)),
h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "MET Images", data: state.images, onOpen: onRaw, testId: "raw-met-images" })),
) : null,
),
),
),
);
+505 -89
View File
@@ -8,23 +8,143 @@ const { useEffect } = React;
const useState: any = React.useState;
const pipelineInputPorts: AnyRecord[] = [
{ id: "in-left-top", side: "left", position: Position.Left, style: { top: "24%" } },
{ id: "in-left-mid", side: "left", position: Position.Left, style: { top: "50%" } },
{ id: "in-left-bottom", side: "left", position: Position.Left, style: { top: "76%" } },
{ id: "in-top-left", side: "top", position: Position.Top, style: { left: "28%" } },
{ id: "in-top-mid", side: "top", position: Position.Top, style: { left: "50%" } },
{ id: "in-top-right", side: "top", position: Position.Top, style: { left: "72%" } },
{ id: "in-bottom-left", side: "bottom", position: Position.Bottom, style: { left: "28%" } },
{ id: "in-bottom-mid", side: "bottom", position: Position.Bottom, style: { left: "50%" } },
{ id: "in-bottom-right", side: "bottom", position: Position.Bottom, style: { left: "72%" } },
{ 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-top", position: Position.Right, style: { top: "24%" } },
{ id: "out-right-mid", position: Position.Right, style: { top: "50%" } },
{ id: "out-right-bottom", position: Position.Right, style: { top: "76%" } },
{ 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, {
@@ -33,7 +153,7 @@ function PipelineFlowNode({ data }: AnyRecord) {
type: "target",
position: port.position,
isConnectable: false,
className: `pipeline-flow-handle input ${port.side}`,
className: `pipeline-flow-handle input ${port.side} slot-${port.slot || "mid"}`,
style: port.style,
})),
pipelineOutputPorts.map((port) => h(Handle, {
@@ -51,47 +171,7 @@ function PipelineFlowNode({ data }: AnyRecord) {
function PipelineCurveEdge({ id, sourceX, sourceY, targetX, targetY, targetPosition, markerEnd, markerStart, style, data }: AnyRecord) {
const laneOffset = Number(data?.laneOffset || 0);
const forward = targetX >= sourceX;
const distance = Math.max(1, Math.abs(targetX - sourceX));
const handleOffset = Math.max(8, Math.min(92, distance * 0.24));
let path = "";
if (targetPosition === Position.Top || targetPosition === Position.Bottom) {
const sourceRailX = sourceX + handleOffset;
const verticalSign = targetPosition === Position.Top ? -1 : 1;
const railY = targetY + verticalSign * (46 + Math.min(86, Math.abs(laneOffset) * 0.35));
path = [
`M ${sourceX},${sourceY}`,
`C ${sourceRailX},${sourceY} ${sourceRailX},${sourceY} ${sourceRailX},${sourceY}`,
`L ${sourceRailX},${railY}`,
`L ${targetX},${railY}`,
`C ${targetX},${railY} ${targetX},${targetY - verticalSign * 18} ${targetX},${targetY}`,
].join(" ");
} else if (forward && Math.abs(laneOffset) < 4) {
path = [
`M ${sourceX},${sourceY}`,
`C ${sourceX + handleOffset},${sourceY} ${targetX - handleOffset},${targetY} ${targetX},${targetY}`,
].join(" ");
} else if (forward) {
const sourceRailX = sourceX + handleOffset;
const targetRailX = targetX - handleOffset;
const railY = (sourceY + targetY) / 2 + laneOffset;
path = [
`M ${sourceX},${sourceY}`,
`C ${sourceRailX},${sourceY} ${sourceRailX},${sourceY} ${sourceRailX},${sourceY}`,
`L ${sourceRailX},${railY}`,
`L ${targetRailX},${railY}`,
`C ${targetRailX},${railY} ${targetRailX},${targetY} ${targetRailX},${targetY}`,
`C ${targetRailX},${targetY} ${targetRailX},${targetY} ${targetX},${targetY}`,
].join(" ");
} else {
const detourX = Math.max(sourceX, targetX) + 92 + Math.min(130, Math.abs(laneOffset));
const railY = (sourceY + targetY) / 2 + laneOffset;
path = [
`M ${sourceX},${sourceY}`,
`C ${detourX},${sourceY} ${detourX},${railY} ${detourX},${railY}`,
`C ${detourX},${targetY} ${targetX - handleOffset},${targetY} ${targetX},${targetY}`,
].join(" ");
}
const path = pipelineCurvePath(sourceX, sourceY, targetX, targetY, targetPosition, laneOffset, String(data?.routeMode || ""));
return h(BaseEdge, { id, path, markerEnd, markerStart, style, interactionWidth: 28 });
}
@@ -213,6 +293,68 @@ function pipelineComponentRef(value: any): string {
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 nodes = Array.isArray(run?.nodes) ? run.nodes : [];
const node = nodes.find((item: any) => item?.nodeId === nodeId || item?.id === nodeId);
@@ -243,18 +385,16 @@ function pipelineRawNodeIds(value: any): string[] {
return [];
}
function pipelineGraphColumns(pipeline: any): string[][] {
const rawBatches = Array.isArray(pipeline?.topologicalBatches) ? pipeline.topologicalBatches : [];
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 nodes = Array.isArray(pipeline?.nodes) ? pipeline.nodes : [];
const ids: string[] = nodes.map((node: any) => String(node?.id || "")).filter(Boolean);
const ids: string[] = pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean);
const idSet = new Set(ids);
const rawEdges = Array.isArray(pipeline?.edges) ? pipeline.edges : [];
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 rawEdges) {
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;
@@ -282,10 +422,12 @@ function pipelineFlowEdgeKey(edge: AnyRecord): string {
return `${edge.source}->${edge.target}-${edge.index}`;
}
function pipelineFlowElements(pipeline: any, latestRun: any): { nodes: Node[]; edges: Edge[] } {
const pipelineNodes = Array.isArray(pipeline?.nodes) ? pipeline.nodes : [];
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);
const columns = pipelineGraphColumns(pipeline, pipelineNodes, pipelineEdges);
const flowNodes: Node[] = [];
const nodeLayout: Map<string, { column: number; row: number; y: number }> = new Map();
const columnGap = 330;
@@ -294,8 +436,12 @@ function pipelineFlowElements(pipeline: any, latestRun: any): { nodes: Node[]; e
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 || "node").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({
@@ -306,10 +452,18 @@ function pipelineFlowElements(pipeline: any, latestRun: any): { nodes: Node[]; e
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", null, pipelineComponentRef(node.componentRef)),
h("code", { title: componentDescription || componentRef }, componentVersion ? `${componentRef}@${componentVersion}` : componentRef),
h(StatusBadge, { status }, status),
),
},
@@ -317,11 +471,11 @@ function pipelineFlowElements(pipeline: any, latestRun: any): { nodes: Node[]; e
});
});
});
const graphEdges = (Array.isArray(pipeline?.edges) ? pipeline.edges : []).flatMap((edge: any, index: number) => {
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 }];
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>());
@@ -334,6 +488,32 @@ function pipelineFlowElements(pipeline: any, latestRun: any): { nodes: Node[]; e
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);
@@ -346,16 +526,31 @@ function pipelineFlowElements(pipeline: any, latestRun: any): { nodes: Node[]; e
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 span = Math.max(0, targetLayout.column - sourceLayout.column);
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 loadDiff = (loads.get(left.id) || 0) - (loads.get(right.id) || 0);
if (loadDiff !== 0) return loadDiff;
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;
@@ -363,12 +558,18 @@ function pipelineFlowElements(pipeline: any, latestRun: any): { nodes: Node[]; e
if (span > 1 && side !== "left") value -= 10;
return value;
};
return score(left) - score(right);
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 edges: Edge[] = graphEdges.map((edge: AnyRecord) => {
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;
@@ -382,16 +583,35 @@ function pipelineFlowElements(pipeline: any, latestRun: any): { nodes: Node[]; e
const pairLane = pairSlot - ((pairTotals.get(pairKey) || 1) - 1) / 2;
const sourceLayout = nodeLayout.get(edge.source);
const targetLayout = nodeLayout.get(edge.target);
const span = Math.max(1, (targetLayout?.column || 0) - (sourceLayout?.column || 0));
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 lane = pairLane * 2 + sourceLane + targetLane * 0.45;
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 shouldScatter = span > 1 || verticalDelta > 96 || Math.abs(lane) > 0.2 || targetPort.side !== "left";
const laneOffset = shouldScatter ? Math.max(-220, Math.min(220, lane * 34 + railDirection * Math.min(92, 22 + span * 16))) : 0;
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 edgeColor = targetStatus === "succeeded" ? "var(--accent-2)" : targetStatus === "running" ? "var(--accent)" : targetStatus === "failed" ? "var(--danger)" : "rgba(129, 147, 159, 0.78)";
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,
@@ -401,18 +621,178 @@ function pipelineFlowElements(pipeline: any, latestRun: any): { nodes: Node[]; e
type: "pipelineCurve",
zIndex: 12,
animated: targetStatus === "running",
data: { laneOffset, targetSide: targetPort.side },
style: { stroke: edgeColor },
markerEnd: { type: MarkerType.ArrowClosed, color: edgeColor },
className: `pipeline-flow-edge ${targetStatus}`,
data: { baseEdgeColor, laneOffset, routeMode: fanOutMeta && targetPort.side === "left" ? "direct-forward-left" : "", targetSide: targetPort.side, isFeedback: backward, overlapGroup },
targetStatus,
};
});
const overlapGroupCounts = edgeDrafts.reduce((memo: Map<string, number>, edge: AnyRecord) => {
const key = String(edge.data?.overlapGroup || "");
return key ? memo.set(key, (memo.get(key) || 0) + 1) : memo;
}, new Map<string, number>());
const overlapGroupSeen = new Map<string, number>();
const edges: Edge[] = edgeDrafts.map((edge: AnyRecord) => {
const draftTargetStatus = String(edge.targetStatus || "pending");
const flowEdge: AnyRecord = { ...edge };
delete flowEdge.targetStatus;
const groupKey = String(edge.data?.overlapGroup || "");
const groupCount = groupKey ? overlapGroupCounts.get(groupKey) || 0 : 0;
const overlapSlot = groupCount > 1 ? overlapGroupSeen.get(groupKey) || 0 : -1;
if (groupCount > 1) overlapGroupSeen.set(groupKey, overlapSlot + 1);
const edgeColor = overlapSlot >= 0 ? pipelineOverlapEdgePalette[overlapSlot % pipelineOverlapEdgePalette.length] : String(edge.data.baseEdgeColor);
const style: AnyRecord = { stroke: edgeColor };
if (edge.data.isFeedback) style.strokeDasharray = "9 7";
return {
...flowEdge,
data: { ...edge.data, edgeColor, overlapSlot, overlapCount: groupCount },
style,
markerEnd: { type: MarkerType.ArrowClosed, color: edgeColor },
className: `pipeline-flow-edge ${draftTargetStatus} ${edge.data.isFeedback ? "feedback" : ""} ${overlapSlot >= 0 ? "overlap-colored" : ""}`,
} as Edge;
});
return { nodes: flowNodes, edges };
}
function escapeSvg(value: any): string {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function exportColor(value: any): string {
const text = String(value || "");
if (text.includes("--accent-2")) return "#4eb7a8";
if (text.includes("--accent")) return "#d7a13a";
if (text.includes("--danger")) return "#cf6a54";
return text.startsWith("#") ? text : "#81939f";
}
function exportMarkerId(color: string): string {
return `arrow-${color.replace(/[^a-zA-Z0-9_-]+/g, "")}`;
}
function targetPortPosition(node: Node, handle: string): { x: number; y: number; position: Position } {
const x = node.position.x;
const y = node.position.y;
const port = pipelineInputPorts.find((item) => item.id === handle);
if (port?.side === "top") return { x: x + pipelineNodeWidth * pipelinePercent(port.style?.left, 0.5), y, position: Position.Top };
if (port?.side === "bottom") return { x: x + pipelineNodeWidth * pipelinePercent(port.style?.left, 0.5), y: y + pipelineNodeHeight, position: Position.Bottom };
return { x, y: y + pipelineNodeHeight / 2, position: Position.Left };
}
function sourcePortPosition(node: Node): { x: number; y: number } {
return { x: node.position.x + pipelineNodeWidth, y: node.position.y + pipelineNodeHeight / 2 };
}
function pipelineGraphSvg(flow: { nodes: Node[]; edges: Edge[] }, title: string): { svg: string; width: number; height: number } {
const minX = Math.min(...flow.nodes.map((node) => node.position.x), 0) - 220;
const minY = Math.min(...flow.nodes.map((node) => node.position.y), 0) - 220;
const maxX = Math.max(...flow.nodes.map((node) => node.position.x + pipelineNodeWidth), 1) + 220;
const maxY = Math.max(...flow.nodes.map((node) => node.position.y + pipelineNodeHeight), 1) + 220;
const width = Math.ceil(maxX - minX);
const height = Math.ceil(maxY - minY);
const nodeById = new Map(flow.nodes.map((node) => [node.id, node]));
const edgeColors = flow.edges.map((edge) => exportColor((edge.data as AnyRecord)?.edgeColor || (edge.style as AnyRecord)?.stroke));
const markerColors = Array.from(new Set(["#4eb7a8", "#d7a13a", "#cf6a54", "#81939f", ...edgeColors]));
const markers = markerColors.map((color) =>
`<marker id="${exportMarkerId(color)}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="${color}"/></marker>`,
).join("");
const edgeSvg = flow.edges.flatMap((edge) => {
const source = nodeById.get(edge.source);
const target = nodeById.get(edge.target);
if (!source || !target) return [];
const sourcePoint = sourcePortPosition(source);
const targetPoint = targetPortPosition(target, String(edge.targetHandle || "in-left"));
const path = pipelineCurvePath(sourcePoint.x, sourcePoint.y, targetPoint.x, targetPoint.y, targetPoint.position, Number((edge.data as AnyRecord)?.laneOffset || 0), String((edge.data as AnyRecord)?.routeMode || ""));
const color = exportColor((edge.data as AnyRecord)?.edgeColor || (edge.style as AnyRecord)?.stroke);
const dash = (edge.data as AnyRecord)?.isFeedback ? ` stroke-dasharray="9 7"` : "";
return `<path d="${escapeSvg(path)}" fill="none" stroke="${color}" stroke-width="2.35" stroke-linecap="round" stroke-linejoin="round" opacity="0.82"${dash} marker-end="url(#${exportMarkerId(color)})"/>`;
}).join("\n");
const nodeSvg = flow.nodes.map((node) => {
const label = ((node.data as AnyRecord)?.exportLabel || {}) as AnyRecord;
const status = String(label.status || "pending").toLowerCase();
const stroke = status === "succeeded" ? "#4eb7a8" : status === "running" ? "#d7a13a" : status === "failed" ? "#cf6a54" : "#81939f";
const x = node.position.x;
const y = node.position.y;
const inputHandles = pipelineInputPorts.map((port) => {
const point = targetPortPosition(node, port.id);
if (port.side === "top" || port.side === "bottom") return `<rect x="${point.x - 9}" y="${point.y - 5}" width="18" height="10" rx="2" fill="#071016" stroke="#4eb7a8"/>`;
return `<rect x="${point.x - 5}" y="${point.y - 9}" width="10" height="18" rx="2" fill="#071016" stroke="#4eb7a8"/>`;
}).join("\n");
return `<g>
<rect x="${x}" y="${y}" width="${pipelineNodeWidth}" height="${pipelineNodeHeight}" rx="2" fill="#0d171f" stroke="${stroke}" stroke-width="1.2"/>
${inputHandles}
<rect x="${x + pipelineNodeWidth - 5}" y="${y + pipelineNodeHeight / 2 - 9}" width="10" height="18" rx="2" fill="#071016" stroke="#d7a13a"/>
<text x="${x + 12}" y="${y + 22}" fill="#e5edf2" font-size="12" font-family="monospace" font-weight="700">${escapeSvg(label.id || node.id)}</text>
<text x="${x + 12}" y="${y + 42}" fill="#81939f" font-size="10" font-family="monospace">${escapeSvg(label.kind || "node")}</text>
<text x="${x + 12}" y="${y + 60}" fill="#d7a13a" font-size="10" font-family="monospace">${escapeSvg(label.componentRef || "--")}</text>
<text x="${x + 12}" y="${y + 78}" fill="${stroke}" font-size="10" font-family="monospace">${escapeSvg(status)}</text>
</g>`;
}).join("\n");
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<defs>${markers}<pattern id="grid" width="22" height="22" patternUnits="userSpaceOnUse"><path d="M 22 0 L 0 0 0 22" fill="none" stroke="#18303a" stroke-width="0.6"/></pattern></defs>
<rect width="100%" height="100%" fill="#081118"/>
<rect width="100%" height="100%" fill="url(#grid)" opacity="0.55"/>
<text x="24" y="34" fill="#d7a13a" font-size="13" font-family="monospace" letter-spacing="2">${escapeSvg(title)}</text>
<g transform="translate(${-minX} ${-minY})">${nodeSvg}<g>${edgeSvg}</g></g>
</svg>`;
return { svg, width, height };
}
function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
async function exportPipelineGraph(flow: { nodes: Node[]; edges: Edge[] }, title: string): Promise<void> {
const safeTitle = String(title || "pipeline").replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-|-$/g, "") || "pipeline";
const { svg, width, height } = pipelineGraphSvg(flow, title);
const svgBlob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(svgBlob);
try {
const image = new Image();
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () => reject(new Error("svg image load failed"));
image.src = url;
});
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("canvas unavailable");
ctx.drawImage(image, 0, 0);
const pngBlob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
if (!pngBlob) throw new Error("png export failed");
downloadBlob(pngBlob, `${safeTitle}.png`);
} catch {
downloadBlob(svgBlob, `${safeTitle}.svg`);
} finally {
URL.revokeObjectURL(url);
}
}
async function exportPipelineGraphs(items: Array<{ flow: { nodes: Node[]; edges: Edge[] }; title: string }>): Promise<void> {
for (const item of items) {
if (item.flow.nodes.length === 0) continue;
await exportPipelineGraph(item.flow, item.title);
await new Promise((resolve) => setTimeout(resolve, 750));
}
}
function pipelineLatestRun(runs: any[], pipelineId: string): any {
return runs.find((run) => String(run?.pipelineId || "") === pipelineId) || null;
}
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("");
async function load(): Promise<void> {
if (!service) return;
@@ -420,7 +800,7 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
try {
const [health, snapshot] = await Promise.all([
requestJson(`${apiBaseUrl}/microservices/pipeline/health`),
requestJson(`${apiBaseUrl}/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3`),
requestJson(`${apiBaseUrl}/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:80,runs:3`),
]);
setState({ loading: false, error: "", health, snapshot, refreshedAt: new Date() });
} catch (err) {
@@ -439,15 +819,24 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
const backend = microserviceBackend(service);
const snapshot = state.snapshot || {};
const { components, pipelines, runs } = pipelineSnapshotArrays(snapshot);
const activePipeline = pipelines[0] || {};
const pipelineNodes = Array.isArray(activePipeline.nodes) ? activePipeline.nodes : [];
const pipelineEdges = Array.isArray(activePipeline.edges) ? activePipeline.edges : [];
const latestRun = runs[0] || null;
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 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 flow = pipelineFlowElements(activePipeline, latestRun);
const flow = pipelineFlowElements(activePipeline, latestRun, components);
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),
};
});
return h("div", { className: "pipeline-page", "data-testid": "pipeline-page" },
h(Panel, {
title: "Pipeline v2 工作台",
@@ -501,8 +890,33 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
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"} / latest run ${latestRun?.status || "--"}` },
pipelineNodes.length === 0 ? h(EmptyState, { title: "暂无控制图", text: "等待 D601 pipeline backend 返回 pipeline.nodes" }) :
h(Panel, {
title: "控制图",
eyebrow: `${activePipeline.id || "pipeline"} / latest run ${latestRun?.status || "--"}`,
actions: h("div", { className: "pipeline-toolbar" },
h("select", {
value: activePipelineId,
disabled: pipelines.length === 0,
onChange: (event: any) => setSelectedPipelineId(event.target.value),
"data-testid": "pipeline-select",
}, pipelines.map((pipeline: any) => h("option", { key: pipeline.id, value: pipeline.id }, pipeline.id || pipeline.key))),
h("button", {
type: "button",
className: "ghost-btn",
disabled: flow.nodes.length === 0,
onClick: () => exportPipelineGraph(flow, `${activePipeline.id || "pipeline"}-${latestRun?.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-flow-frame", "data-testid": "pipeline-react-flow" },
h(ReactFlow, {
nodes: flow.nodes,
@@ -526,6 +940,8 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
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, `latest ${latestRun?.runId || "--"}`),
),
),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@unidesk/provider-gateway",
"version": "0.2.1",
"version": "0.2.5",
"private": true,
"type": "module",
"scripts": {
+157 -17
View File
@@ -58,6 +58,8 @@ let systemStatusRunning = false;
let previousCpuSample: { idle: number; total: number } | null = null;
let reconnectAttempt = 0;
let stopping = false;
let upgradeSleepUntil = 0;
let upgradeSleepTimer: ReturnType<typeof setTimeout> | null = null;
interface HostSshSession {
proc: ReturnType<typeof Bun.spawn>;
@@ -72,19 +74,29 @@ interface HostSshStdin {
const hostSshSessions = new Map<string, HostSshSession>();
const gatewayMetadata = readGatewayMetadata();
function readGatewayMetadata(): { name: string; version: string } {
function readGatewayMetadataFile(path: string): { name: string; version: string } | null {
try {
const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
const raw = readFileSync(path, "utf8");
const parsed = JSON.parse(raw) as { name?: unknown; version?: unknown };
return {
name: typeof parsed.name === "string" && parsed.name.length > 0 ? parsed.name : "@unidesk/provider-gateway",
version: typeof parsed.version === "string" && parsed.version.length > 0 ? parsed.version : "0.0.0-unknown",
};
} catch {
return { name: "@unidesk/provider-gateway", version: "0.0.0-unknown" };
return null;
}
}
function readGatewayMetadata(): { name: string; version: string } {
return readGatewayMetadataFile(new URL("../package.json", import.meta.url).pathname)
?? { name: "@unidesk/provider-gateway", version: "0.0.0-unknown" };
}
function readTargetGatewayMetadata(workspace: string): { name: string; version: string } {
const root = workspace.replace(/\/+$/, "");
return readGatewayMetadataFile(`${root}/src/components/provider-gateway/package.json`) ?? gatewayMetadata;
}
function requiredEnv(name: string): string {
const value = process.env[name];
if (value === undefined || value.length === 0) {
@@ -885,8 +897,14 @@ function safeDockerName(value: string): string {
return value.replace(/[^a-zA-Z0-9_.-]/g, "-").slice(0, 80);
}
function candidateGatewayName(taskId: string): string {
return `unidesk-provider-gateway-${safeDockerName(config.providerId)}-candidate-${safeDockerName(taskId)}`.slice(0, 120);
}
function upgradePlan(taskId: string): Record<string, JsonValue> {
const workspace = config.upgradeWorkspacePath;
const sleepMs = 300_000;
const validationTimeoutMs = 180_000;
const composeBaseCommand = [
"docker",
"compose",
@@ -910,6 +928,8 @@ function upgradePlan(taskId: string): Record<string, JsonValue> {
`label=com.docker.compose.project=${config.upgradeComposeProject}`,
"--filter",
`label=com.docker.compose.service=${config.upgradeService}`,
"--filter",
"label=com.docker.compose.oneoff=False",
];
const composeUpCommand = [
...composeBaseCommand,
@@ -920,22 +940,73 @@ function upgradePlan(taskId: string): Record<string, JsonValue> {
config.upgradeService,
];
const updaterName = `unidesk-provider-upgrader-${safeDockerName(taskId)}`;
const candidateName = candidateGatewayName(taskId);
const validationNeedleOpen = `"message":"connect_open"`;
const validationNeedleAck = `"requestId":"register"`;
const validationNeedleOk = `"ok":true`;
const validationAttempts = Math.max(1, Math.ceil(validationTimeoutMs / 2000));
const targetGatewayMetadata = readTargetGatewayMetadata(workspace);
const script = [
"set -eu",
"sleep 2",
`cd ${shellQuote(workspace)}`,
`old_ids=$(${listServiceContainersCommand.map(shellQuote).join(" ")})`,
`first_old=""`,
`for old_id in $old_ids; do first_old="$old_id"; break; done`,
`old_name=""`,
`mount_args=""`,
`extra_host_args=""`,
`candidate_env_file=${shellQuote(`/tmp/${candidateName}.env`)}`,
`network_names=""`,
`first_network=""`,
`network_arg=""`,
`if [ -n "$old_ids" ]; then docker update --restart always $old_ids >/dev/null 2>&1 || true; fi`,
`if [ -z "$first_old" ]; then echo "no existing provider-gateway compose container found; cannot perform safe in-place upgrade" >&2; exit 1; fi`,
`old_name=$(docker inspect --format '{{.Name}}' "$first_old")`,
`old_name="\${old_name#/}"`,
`docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$first_old" > "$candidate_env_file"`,
`mount_args=$(docker inspect --format '{{range .Mounts}}{{printf "-v %s:%s" .Source .Destination}}{{if not .RW}}{{printf ":ro"}}{{end}}{{printf "\\n"}}{{end}}' "$first_old")`,
`extra_host_args=$(docker inspect --format '{{range .HostConfig.ExtraHosts}}{{printf "--add-host %s\\n" .}}{{end}}' "$first_old")`,
`network_names=$(docker inspect --format '{{range $name, $_ := .NetworkSettings.Networks}}{{printf "%s\\n" $name}}{{end}}' "$first_old")`,
`for net in $network_names; do first_network="$net"; break; done`,
`if [ -n "$first_network" ]; then network_arg="--network $first_network"; fi`,
composeBuildCommand.map(shellQuote).join(" "),
`ids=$(${listServiceContainersCommand.map(shellQuote).join(" ")})`,
`if [ -n "$ids" ]; then docker rm -f $ids; fi`,
composeUpCommand.map(shellQuote).join(" "),
].join("; ");
`docker rm -f ${shellQuote(candidateName)} >/dev/null 2>&1 || true`,
[
"docker run -d",
`--name ${shellQuote(candidateName)}`,
"--restart no",
"$network_arg",
`--env-file "$candidate_env_file"`,
`--label ${shellQuote(`com.docker.compose.project=${config.upgradeComposeProject}`)}`,
`--label ${shellQuote(`com.docker.compose.service=${config.upgradeService}`)}`,
`--label ${shellQuote("com.docker.compose.oneoff=False")}`,
"$mount_args",
"$extra_host_args",
shellQuote(config.upgradeRunnerImage),
].join(" "),
`if [ -n "$network_names" ]; then for net in $network_names; do if [ "$net" != "$first_network" ]; then docker network connect "$net" ${shellQuote(candidateName)} >/dev/null 2>&1 || true; fi; done; fi`,
`attempt=0`,
`validated=0`,
`while [ "$attempt" -lt "${validationAttempts}" ]; do logs=$(docker logs ${shellQuote(candidateName)} 2>&1 || true); has_open=0; has_ack=0; has_ok=0; case "$logs" in *${shellQuote(validationNeedleOpen)}*) has_open=1;; esac; case "$logs" in *${shellQuote(validationNeedleAck)}*) has_ack=1;; esac; case "$logs" in *${shellQuote(validationNeedleOk)}*) has_ok=1;; esac; if [ "$has_open" = "1" ] && [ "$has_ack" = "1" ] && [ "$has_ok" = "1" ]; then validated=1; break; fi; candidate_running=$(docker inspect --format '{{.State.Running}}' ${shellQuote(candidateName)} 2>/dev/null || true); if [ "$candidate_running" != "true" ]; then break; fi; attempt=$((attempt + 1)); sleep 2; done`,
`if [ "$validated" != "1" ]; then echo "candidate validation failed; old gateway will leave upgrade sleep automatically" >&2; docker logs ${shellQuote(candidateName)} >&2 || true; docker rm -f ${shellQuote(candidateName)} >/dev/null 2>&1 || true; rm -f "$candidate_env_file"; exit 1; fi`,
`docker update --restart always ${shellQuote(candidateName)} >/dev/null`,
`if [ -n "$old_ids" ]; then docker rm -f $old_ids; fi`,
`if [ -n "$old_name" ] && [ "$old_name" != ${shellQuote(candidateName)} ]; then docker rename ${shellQuote(candidateName)} "$old_name" || true; fi`,
`rm -f "$candidate_env_file"`,
`echo "candidate provider-gateway validated and promoted"`,
].join("\n");
const dockerRunCommand = [
"docker",
"run",
"-d",
"--rm",
"--name",
updaterName,
"--restart",
"no",
"--label",
"com.docker.compose.oneoff=True",
"--label",
`unidesk.upgrade.updater=${taskId}`,
"-v",
"/var/run/docker.sock:/var/run/docker.sock",
"-v",
@@ -950,7 +1021,14 @@ function upgradePlan(taskId: string): Record<string, JsonValue> {
return {
policy: "always-enabled",
taskId,
providerId: config.providerId,
providerName: config.providerName,
providerGatewayName: gatewayMetadata.name,
providerGatewayVersion: gatewayMetadata.version,
targetProviderGatewayName: targetGatewayMetadata.name,
targetProviderGatewayVersion: targetGatewayMetadata.version,
updaterName,
candidateName,
runnerImage: config.upgradeRunnerImage,
hostProjectRoot: config.upgradeHostProjectRoot,
workspace,
@@ -965,7 +1043,16 @@ function upgradePlan(taskId: string): Record<string, JsonValue> {
listServiceContainersCommand,
composeUpCommand,
replacementStrategy: {
buildBeforeRemove: true,
buildBeforeCandidate: true,
oldGatewaySleepMs: sleepMs,
validationTimeoutMs,
oldGatewayRestartPolicyBeforeSleep: "always",
promoteOnlyAfterCandidateValidation: true,
candidateRestartPolicyAfterPromotion: "always",
candidateUsesOldContainerMounts: true,
candidateUsesOldContainerNetworks: true,
candidateUsesOldContainerExtraHosts: true,
candidateUsesOldContainerEnvironment: true,
removeScope: {
projectLabel: config.upgradeComposeProject,
serviceLabel: config.upgradeService,
@@ -986,9 +1073,15 @@ async function runProviderUpgrade(taskId: string, payload: Record<string, JsonVa
if (!result.ok) {
throw new Error(`provider upgrade scheduler failed with exit ${result.exitCode}: ${result.stderr}`);
}
const sleepMs = Number((plan.replacementStrategy as Record<string, JsonValue>).oldGatewaySleepMs ?? 300_000);
setTimeout(() => enterUpgradeSleep(sleepMs), 250).unref?.();
return {
mode,
message: "provider gateway upgrade scheduled by detached updater container",
message: "provider gateway upgrade scheduled with sleep-and-validate rollback guard",
providerId: config.providerId,
providerName: config.providerName,
providerGatewayVersion: gatewayMetadata.version,
targetProviderGatewayVersion: (plan.targetProviderGatewayVersion as string | undefined) ?? gatewayMetadata.version,
updaterContainerId: result.stdout.trim(),
plan,
stderr: result.stderr.slice(0, 500),
@@ -1197,13 +1290,65 @@ function handleMessage(raw: MessageEvent<string>): void {
function scheduleReconnect(): void {
if (stopping) return;
const sleepRemainingMs = upgradeSleepUntil - Date.now();
if (sleepRemainingMs > 0) {
logger("warn", "reconnect_deferred_for_upgrade_sleep", { sleepRemainingMs });
if (upgradeSleepTimer === null) {
upgradeSleepTimer = setTimeout(() => {
upgradeSleepTimer = null;
logger("warn", "upgrade_sleep_expired_reconnecting", { providerId: config.providerId });
connect();
}, sleepRemainingMs);
}
return;
}
reconnectAttempt += 1;
const delayMs = Math.min(config.reconnectMaxMs, config.reconnectBaseMs * 2 ** Math.min(reconnectAttempt, 8));
logger("warn", "reconnect_scheduled", { reconnectAttempt, delayMs });
setTimeout(connect, delayMs);
}
function clearConnectionTimers(): void {
if (heartbeatTimer !== null) clearInterval(heartbeatTimer);
if (systemStatusTimer !== null) clearInterval(systemStatusTimer);
if (dockerStatusTimer !== null) clearInterval(dockerStatusTimer);
heartbeatTimer = null;
systemStatusTimer = null;
dockerStatusTimer = null;
}
function enterUpgradeSleep(durationMs: number): void {
const sleepMs = Math.max(30_000, Math.min(300_000, Math.floor(durationMs)));
upgradeSleepUntil = Date.now() + sleepMs;
logger("warn", "upgrade_sleep_enter", { providerId: config.providerId, sleepMs, wakeAt: new Date(upgradeSleepUntil).toISOString() });
clearConnectionTimers();
try {
socket?.close(4000, "provider upgrade sleep");
} catch (error) {
logger("warn", "upgrade_sleep_socket_close_failed", { error: String(error) });
}
socket = null;
if (upgradeSleepTimer !== null) clearTimeout(upgradeSleepTimer);
upgradeSleepTimer = setTimeout(() => {
upgradeSleepTimer = null;
logger("warn", "upgrade_sleep_expired_reconnecting", { providerId: config.providerId });
connect();
}, sleepMs);
}
function connect(): void {
if (stopping) return;
const sleepRemainingMs = upgradeSleepUntil - Date.now();
if (sleepRemainingMs > 0) {
logger("warn", "connect_deferred_for_upgrade_sleep", { sleepRemainingMs });
if (upgradeSleepTimer === null) {
upgradeSleepTimer = setTimeout(() => {
upgradeSleepTimer = null;
connect();
}, sleepRemainingMs);
}
return;
}
const url = withToken(config.serverUrl, config.token);
logger("info", "connect_start", { serverUrl: config.serverUrl, providerId: config.providerId });
socket = new WebSocket(url);
@@ -1228,12 +1373,7 @@ function connect(): void {
socket.addEventListener("message", (event) => handleMessage(event as MessageEvent<string>));
socket.addEventListener("close", (event) => {
logger("warn", "connect_close", { code: event.code, reason: event.reason });
if (heartbeatTimer !== null) clearInterval(heartbeatTimer);
if (systemStatusTimer !== null) clearInterval(systemStatusTimer);
if (dockerStatusTimer !== null) clearInterval(dockerStatusTimer);
heartbeatTimer = null;
systemStatusTimer = null;
dockerStatusTimer = null;
clearConnectionTimers();
scheduleReconnect();
});
socket.addEventListener("error", () => {