feat: improve met project tree details

This commit is contained in:
Codex
2026-05-06 02:50:49 +00:00
parent 17fb816d33
commit cd784f5e4b
10 changed files with 980 additions and 78 deletions
+319 -4
View File
@@ -195,6 +195,19 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; }
}
.ghost-btn:hover { color: var(--text); border-color: var(--accent); }
.ghost-btn.danger:hover { border-color: var(--danger); color: #ffd7cf; }
.primary-btn, .danger-btn {
min-height: 28px;
padding: 4px 9px;
border: 1px solid rgba(78, 183, 168, 0.62);
background: linear-gradient(180deg, rgba(78, 183, 168, 0.22), rgba(78, 183, 168, 0.08));
color: var(--text);
}
.danger-btn {
border-color: rgba(207, 106, 84, 0.62);
background: linear-gradient(180deg, rgba(207, 106, 84, 0.2), rgba(207, 106, 84, 0.08));
color: #ffd7cf;
}
.ghost-btn.compact, .primary-btn.compact, .danger-btn.compact { width: max-content; }
.page-grid {
display: grid;
@@ -1125,7 +1138,189 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
min-width: 0;
border: 1px solid var(--line-soft);
}
.met-project-table { max-height: 520px; overflow: auto; }
.met-project-table {
max-height: 560px;
overflow: auto;
font-size: 12px;
}
.met-tree-header, .met-tree-row {
display: grid;
grid-template-columns: minmax(280px, 1fr) 90px 72px 90px 116px;
gap: 8px;
align-items: center;
}
.met-tree-header {
position: sticky;
top: 0;
z-index: 1;
padding: 6px 8px;
border-bottom: 1px solid var(--line-soft);
background: var(--panel);
color: var(--muted);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.met-tree-row {
min-height: 30px;
padding: 4px 8px;
border-bottom: 1px solid rgba(255,255,255,0.045);
}
.met-tree-row.project {
cursor: pointer;
}
.met-tree-row.project:hover, .met-tree-row.project.active, .met-click-row:hover, .met-click-row.active {
background: rgba(78, 183, 168, 0.08);
}
.met-tree-row.project.selected {
box-shadow: inset 3px 0 0 var(--accent);
}
.met-tree-row.folder {
grid-template-columns: auto auto 1fr;
color: var(--text);
background: rgba(255,255,255,0.025);
}
.met-tree-name {
display: flex;
gap: 7px;
align-items: center;
min-width: 0;
}
.met-tree-name input {
width: 14px;
height: 14px;
margin: 0;
}
.met-tree-toggle {
width: 20px;
min-height: 20px;
padding: 0;
border: 1px solid var(--line-soft);
background: var(--panel-3);
color: var(--text);
}
.met-tree-count {
color: var(--muted);
font-size: 11px;
}
.met-inline-link {
min-width: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
}
.met-inline-link:hover {
color: var(--accent-2);
}
.met-inline-link.project-path {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.met-detail-panel {
grid-column: 1 / -1;
display: grid;
gap: 10px;
margin-top: 10px;
padding: 10px;
border: 1px solid var(--line-soft);
background:
linear-gradient(135deg, rgba(78, 183, 168, 0.07), transparent 38%),
var(--panel-2);
}
.met-detail-panel.muted {
background: var(--panel-3);
}
.met-detail-panel .panel-head {
padding: 0 0 8px;
border-bottom: 1px solid var(--line-soft);
}
.met-detail-panel code {
display: block;
margin-top: 3px;
white-space: normal;
}
.met-detail-section {
display: grid;
gap: 7px;
}
.met-detail-section h3 {
margin: 0;
color: var(--text);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.met-detail-kv {
display: grid;
grid-template-columns: repeat(5, minmax(130px, 1fr));
gap: 6px;
}
.met-detail-kv-item {
min-width: 0;
padding: 7px;
border: 1px solid var(--line-soft);
background: rgba(255,255,255,0.025);
}
.met-detail-kv-item span {
display: block;
overflow: hidden;
color: var(--muted);
font-size: 10px;
letter-spacing: 0.08em;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.met-detail-kv-item strong {
display: block;
margin-top: 3px;
overflow: hidden;
color: var(--text);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.met-detail-kv-item small {
display: block;
margin-top: 2px;
color: var(--muted);
}
.met-layer-table {
max-height: 260px;
overflow: auto;
}
.met-file-chip-grid {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.met-file-chip-grid span {
padding: 3px 7px;
border: 1px solid var(--line-soft);
background: var(--panel-3);
color: var(--muted);
font-size: 11px;
}
.met-log-lines {
display: grid;
gap: 4px;
max-height: 240px;
overflow: auto;
}
.met-log-lines div {
padding: 5px 7px;
border-left: 2px solid var(--accent-2);
background: rgba(0,0,0,0.18);
color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
white-space: pre-wrap;
}
.ghost-btn.mini {
min-height: 22px;
padding: 2px 6px;
@@ -1143,6 +1338,12 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
min-height: 28px;
padding: 4px 8px;
}
.pipeline-control-shell {
display: grid;
grid-template-columns: minmax(620px, 1fr) minmax(320px, 0.38fr);
gap: 10px;
align-items: stretch;
}
.pipeline-flow-frame {
height: min(68vh, 720px);
min-height: 520px;
@@ -1236,6 +1437,10 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
.pipeline-flow-node.failed { border-color: rgba(207, 106, 84, 0.78); }
.pipeline-flow-node.quality-gate { background: linear-gradient(180deg, rgba(22, 38, 34, 0.98), rgba(10, 19, 17, 0.98)); }
.pipeline-flow-node.control-block { background: linear-gradient(180deg, rgba(36, 31, 20, 0.98), rgba(17, 15, 11, 0.98)); }
.pipeline-flow-node.selected-control-node {
outline: 2px solid rgba(215, 161, 58, 0.78);
box-shadow: 0 0 0 4px rgba(215, 161, 58, 0.16), 0 20px 38px rgba(0,0,0,0.42);
}
.flow-node-label {
display: grid;
gap: 4px;
@@ -1273,6 +1478,95 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
border: 1px solid var(--line-soft);
background: rgba(255,255,255,0.03);
}
.pipeline-node-control {
min-width: 0;
max-height: min(68vh, 720px);
min-height: 520px;
overflow: auto;
border: 1px solid rgba(215, 161, 58, 0.28);
background:
linear-gradient(135deg, rgba(215, 161, 58, 0.11), transparent 36%),
linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.014)),
#0a1218;
box-shadow: inset 3px 0 0 rgba(215, 161, 58, 0.28);
padding: 10px;
}
.pipeline-node-control-head {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: start;
padding-bottom: 8px;
border-bottom: 1px solid var(--line-soft);
}
.pipeline-node-control-head h3 {
margin: 0;
font-size: 15px;
letter-spacing: 0.04em;
overflow-wrap: anywhere;
}
.pipeline-control-runbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 6px;
align-items: end;
margin: 10px 0;
}
.pipeline-control-runbar label,
.pipeline-control-actions label {
display: grid;
gap: 5px;
color: var(--muted);
}
.pipeline-control-runbar select,
.pipeline-control-actions textarea {
width: 100%;
min-width: 0;
border: 1px solid var(--line);
background: rgba(4, 9, 13, 0.64);
color: var(--text);
}
.pipeline-control-runbar select { min-height: 28px; padding: 4px 7px; }
.pipeline-control-actions textarea {
resize: vertical;
padding: 7px;
line-height: 1.45;
}
.pipeline-control-meta,
.pipeline-control-evidence-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
margin-bottom: 10px;
}
.pipeline-control-meta span,
.pipeline-control-evidence-grid span {
min-width: 0;
padding: 6px 7px;
border: 1px solid var(--line-soft);
background: rgba(255,255,255,0.03);
color: var(--muted);
overflow-wrap: anywhere;
}
.pipeline-control-meta b {
display: block;
color: var(--accent);
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.pipeline-control-actions {
display: grid;
gap: 10px;
margin: 10px 0;
}
.pipeline-control-evidence {
display: grid;
gap: 7px;
padding-top: 8px;
border-top: 1px solid var(--line-soft);
}
.compact-log code { font-size: 11px; }
.component-strata {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
@@ -1384,6 +1678,12 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
color: #ffd7cf;
background: rgba(207,106,84,0.1);
}
.form-success {
padding: 7px 8px;
border: 1px solid rgba(78,183,168,0.5);
color: #d8fff6;
background: rgba(78,183,168,0.1);
}
.modal-backdrop {
position: fixed;
@@ -1423,14 +1723,24 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
.metric-grid, .policy-grid, .security-board, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dispatch-form { grid-template-columns: 1fr 1fr; }
.dispatch-actions { align-items: center; }
.page-grid, .docker-layout, .monitor-layout, .findjob-grid, .findjob-hero, .pipeline-grid, .pipeline-hero { grid-template-columns: 1fr; }
.findjob-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(5) { grid-column: 1; }
.page-grid, .docker-layout, .monitor-layout, .findjob-grid, .findjob-hero, .pipeline-grid, .pipeline-hero, .met-grid, .met-form-grid { grid-template-columns: 1fr; }
.pipeline-control-shell { grid-template-columns: 1fr; }
.pipeline-node-control { max-height: none; min-height: 0; }
.findjob-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(5), .met-grid .panel:nth-child(3), .met-grid .panel:nth-child(5), .met-detail-panel { grid-column: 1; }
.gateway-record-grid { grid-template-columns: 1fr; }
.overview-grid .panel:nth-child(n+3), .dispatch-grid .panel:first-child, .topology-grid .panel:nth-child(3) { grid-column: 1; }
}
@media (max-width: 760px) {
body { font-size: 12px; }
.pipeline-control-runbar,
.pipeline-control-meta,
.pipeline-control-evidence-grid {
grid-template-columns: 1fr;
}
.pipeline-flow-frame {
min-height: 430px;
}
.shell {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
@@ -1521,8 +1831,13 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
padding: 4px 9px;
white-space: nowrap;
}
.metric-grid, .policy-grid, .security-board, .dispatch-form, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid, .gateway-record-grid { grid-template-columns: 1fr; }
.metric-grid, .policy-grid, .security-board, .dispatch-form, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid, .gateway-record-grid, .met-detail-kv { grid-template-columns: 1fr; }
.compact-row, .heartbeat-row, .log-row, .endpoint-list article, .volume-route, .findjob-hero, .pipeline-hero { grid-template-columns: 1fr; align-items: start; }
.met-tree-header, .met-tree-row {
grid-template-columns: minmax(220px, 1fr) 72px 62px 76px 96px;
min-width: 560px;
}
.met-project-table { overflow-x: auto; }
.docker-hero, .monitor-hero { flex-direction: column; }
}
+344 -22
View File
@@ -35,6 +35,39 @@ function fmtDuration(seconds: any): string {
return `${Math.floor(value / 3600)}h ${Math.floor((value % 3600) / 60)}m`;
}
function fmtNumber(value: any, digits = 2): string {
const number = Number(value);
if (!Number.isFinite(number)) return value === false ? "false" : value === true ? "true" : "--";
const abs = Math.abs(number);
if (Number.isInteger(number) || abs >= 1000) return number.toLocaleString("zh-CN", { maximumFractionDigits: 0 });
if (abs >= 1) return number.toLocaleString("zh-CN", { maximumFractionDigits: digits });
return number.toLocaleString("zh-CN", { maximumFractionDigits: Math.max(digits, 6) });
}
function fmtValue(value: any): string {
if (value === null || value === undefined || value === "") return "--";
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "number") return fmtNumber(value, 4);
if (Array.isArray(value)) return value.map((item) => fmtValue(item)).join(" x ");
if (typeof value === "object") return "已上报";
return String(value);
}
function fmtEpochSpeed(value: any): string {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) return "--";
const digits = number >= 100 ? 0 : number >= 10 ? 1 : 2;
return `${number.toLocaleString("zh-CN", { maximumFractionDigits: digits })} epoch/h`;
}
function safeDomId(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]/g, "-");
}
function objectValue(value: any): AnyRecord {
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
}
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");
@@ -84,7 +117,10 @@ function RawButton({ title, data, onOpen, testId }: AnyRecord) {
type: "button",
className: "ghost-btn",
"data-testid": testId,
onClick: () => onOpen(title, data),
onClick: (event: any) => {
event?.stopPropagation?.();
onOpen(title, data);
},
}, "查看原始JSON");
}
@@ -113,7 +149,11 @@ function jobRows(queue: any): any[] {
}
function projectRows(projects: any): any[] {
return Array.isArray(projects?.projects) ? projects.projects.slice(0, 160) : [];
return Array.isArray(projects?.projects) ? projects.projects.slice(0, 1000) : [];
}
function allProjectRows(projects: any): any[] {
return Array.isArray(projects?.projects) ? projects.projects : [];
}
function gpuRows(health: any, queue: any): any[] {
@@ -145,6 +185,18 @@ function jobEtaSeconds(job: any): number | null {
return Math.max(0, (elapsedSeconds / currentEpoch) * (epochTarget - currentEpoch));
}
function jobEpochPerHour(job: any): number | null {
const progress = job.progress || {};
const reported = Number(progress.epochPerHour);
if (Number.isFinite(reported) && reported > 0) return reported;
const startedAt = Date.parse(job.startedAt || "");
const finishedAt = ["succeeded", "failed", "canceled"].includes(job.status) ? Date.parse(job.finishedAt || "") : Date.now();
if (!Number.isFinite(startedAt) || !Number.isFinite(finishedAt) || finishedAt <= startedAt) return null;
const epochs = Number(progress.currentEpoch ?? job.epochTarget);
if (!Number.isFinite(epochs) || epochs <= 0) return null;
return epochs / ((finishedAt - startedAt) / 3600000);
}
function statusLabel(status: string): string {
if (status === "staged") return "待启动";
if (status === "queued") return "排队中";
@@ -155,12 +207,69 @@ function statusLabel(status: string): string {
return status || "unknown";
}
type ProjectTreeNode = {
name: string;
path: string;
depth: number;
count: number;
children: ProjectTreeNode[];
project: any | null;
};
function createProjectTreeNode(name: string, path: string, depth: number): ProjectTreeNode {
return { name, path, depth, count: 0, children: [], project: null };
}
function buildProjectTree(projects: any[]): ProjectTreeNode {
const root = createProjectTreeNode("", "", -1);
for (const project of projects) {
const projectPath = String(project?.projectPath || "").replace(/\\/g, "/");
const parts = projectPath.split("/").filter(Boolean);
if (parts.length === 0) continue;
let node = root;
const cursor: string[] = [];
for (const [index, part] of parts.entries()) {
cursor.push(part);
const path = cursor.join("/");
let child = node.children.find((item) => item.path === path);
if (!child) {
child = createProjectTreeNode(part, path, index);
node.children.push(child);
}
if (index === parts.length - 1) child.project = project;
node = child;
}
}
const fillCount = (node: ProjectTreeNode): number => {
const childCount = node.children.reduce((sum, child) => sum + fillCount(child), 0);
node.count = (node.project ? 1 : 0) + childCount;
node.children.sort((a, b) => {
if (Boolean(a.project) !== Boolean(b.project)) return a.project ? 1 : -1;
return a.name.localeCompare(b.name, "zh-CN", { numeric: true, sensitivity: "base" });
});
return node.count;
};
fillCount(root);
return root;
}
function detailPayload(detail: AnyRecord): AnyRecord {
const data = objectValue(detail.data);
return objectValue(data.project).projectPath ? objectValue(data.project) : data;
}
function detailJob(detail: AnyRecord): AnyRecord {
return objectValue(objectValue(detail.data).job);
}
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 [detail, setDetail] = useState({ loading: false, error: "", kind: "", key: "", title: "", data: null });
const [ui, setUi] = useState(() => ({
activeTab: "projects",
selectedProjects: {},
expandedProjectDirs: {},
sourceProject: "",
forkCount: 1,
forkEpochs: 200,
@@ -178,14 +287,23 @@ export function MetNonlinearPage({ microservices, onRaw, apiBaseUrl = "/api" }:
if (!service) return;
setState((prev: any) => ({ ...prev, loading: true, error: "" }));
try {
const [health, summary, queue, projects, history, images] = await Promise.all([
const [health, summary, queue, projectsRoot, exProjectsRoot, history, images] = await Promise.all([
requestJson(`${apiBaseUrl}/microservices/met-nonlinear/health`),
requestJson(metApi(apiBaseUrl, "/api/summary")),
requestJson(metApi(apiBaseUrl, "/api/queue")),
requestJson(metApi(apiBaseUrl, "/api/projects?root=projects&limit=160")),
requestJson(metApi(apiBaseUrl, "/api/projects?root=projects&limit=500")),
requestJson(metApi(apiBaseUrl, "/api/projects?root=ex_projects&limit=500")),
requestJson(metApi(apiBaseUrl, "/api/history")),
requestJson(metApi(apiBaseUrl, "/api/images")),
]);
const projects = {
ok: projectsRoot?.ok !== false && exProjectsRoot?.ok !== false,
roots: [
{ root: "projects", count: allProjectRows(projectsRoot).length },
{ root: "ex_projects", count: allProjectRows(exProjectsRoot).length },
],
projects: [...allProjectRows(projectsRoot), ...allProjectRows(exProjectsRoot)],
};
setState({ loading: false, actionBusy: false, error: "", health, summary, queue, projects, history, images, refreshedAt: new Date() });
} catch (err) {
setState((prev: any) => ({ ...prev, loading: false, actionBusy: false, error: errorMessage(err, "MET Nonlinear 加载失败") }));
@@ -265,6 +383,30 @@ export function MetNonlinearPage({ microservices, onRaw, apiBaseUrl = "/api" }:
});
}
async function openProjectDetail(project: any): Promise<void> {
const projectPath = String(project?.projectPath || "");
if (!projectPath) return;
setDetail({ loading: true, error: "", kind: "project", key: projectPath, title: projectPath, data: null });
try {
const data = await requestJson(metApi(apiBaseUrl, `/api/projects/config?path=${encodeURIComponent(projectPath)}`));
setDetail({ loading: false, error: "", kind: "project", key: projectPath, title: projectPath, data });
} catch (err) {
setDetail({ loading: false, error: errorMessage(err, "Project 详情加载失败"), kind: "project", key: projectPath, title: projectPath, data: null });
}
}
async function openJobDetail(job: any): Promise<void> {
const jobId = String(job?.id || "");
if (!jobId) return;
setDetail({ loading: true, error: "", kind: "job", key: jobId, title: job.projectPath || jobId, data: null });
try {
const data = await requestJson(metApi(apiBaseUrl, `/api/jobs/${encodeURIComponent(jobId)}`));
setDetail({ loading: false, error: "", kind: "job", key: jobId, title: data?.job?.projectPath || job.projectPath || jobId, data });
} catch (err) {
setDetail({ loading: false, error: errorMessage(err, "Job 详情加载失败"), kind: "job", key: jobId, title: job.projectPath || jobId, data: null });
}
}
useEffect(() => {
load();
}, [service?.id, service?.runtime?.providerStatus]);
@@ -280,6 +422,7 @@ 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 projectTree = buildProjectTree(projects);
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");
@@ -296,23 +439,25 @@ export function MetNonlinearPage({ microservices, onRaw, apiBaseUrl = "/api" }:
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("thead", null, h("tr", null, h("th", null, "状态"), h("th", null, "Project"), h("th", null, "Epoch"), h("th", null, "速度"), 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 },
const isActive = detail.kind === "job" && detail.key === job.id;
return h("tr", { key: job.id, className: `met-click-row ${isActive ? "active" : ""}`, onClick: () => openJobDetail(job), "data-testid": `met-job-row-${safeDomId(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("button", { type: "button", className: "met-inline-link", onClick: (event: any) => { event.stopPropagation(); openJobDetail(job); } }, 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, h("strong", null, fmtEpochSpeed(jobEpochPerHour(job)))),
h("td", null, job.status === "succeeded" || job.status === "failed" || job.status === "canceled" ? jobDuration(job) : (job.status === "running" ? `ETA ${fmtDuration(jobEtaSeconds(job))}` : "--")),
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,
canCancel ? h("button", { type: "button", className: "ghost-btn mini", onClick: (event: any) => { event.stopPropagation(); cancelJob(job); }, disabled: state.actionBusy }, "取消") : null,
h(RawButton, { title: `MET Job ${job.id}`, data: job, onOpen: onRaw, testId: `raw-met-job-${job.id}` }),
),
);
@@ -330,6 +475,188 @@ export function MetNonlinearPage({ microservices, onRaw, apiBaseUrl = "/api" }:
);
}
function projectFolderExpanded(path: string, depth: number): boolean {
const explicit = ui.expandedProjectDirs[path];
return explicit === undefined ? depth < 2 : Boolean(explicit);
}
function toggleProjectFolder(path: string, depth: number): void {
const expanded = projectFolderExpanded(path, depth);
patchUi({ expandedProjectDirs: { ...ui.expandedProjectDirs, [path]: !expanded } });
}
function renderProjectTreeNode(node: ProjectTreeNode): any {
const paddingLeft = 8 + Math.max(0, node.depth) * 16;
const isProject = Boolean(node.project);
if (isProject) {
const project = node.project;
const selected = Boolean(ui.selectedProjects[project.projectPath]);
const active = detail.kind === "project" && detail.key === project.projectPath;
return h("div", {
key: node.path,
className: `met-tree-row project ${selected ? "selected" : ""} ${active ? "active" : ""}`,
style: { paddingLeft },
onClick: () => openProjectDetail(project),
"data-testid": `met-project-node-${safeDomId(project.projectPath)}`,
},
h("div", { className: "met-tree-name" },
h("input", {
type: "checkbox",
checked: selected,
onClick: (event: any) => event.stopPropagation(),
onChange: (event: any) => patchUi({ selectedProjects: { ...ui.selectedProjects, [project.projectPath]: event.target.checked } }),
"data-testid": `met-project-checkbox-${safeDomId(project.projectPath)}`,
}),
h("button", { type: "button", className: "met-inline-link project-path", onClick: (event: any) => { event.stopPropagation(); openProjectDetail(project); } }, node.name),
),
h("span", null, project.useModel || "--"),
h("span", null, project.epochTrain ?? "--"),
h("span", null, fmtPercent(project.progress?.progressPercent)),
h("span", null, fmtEpochSpeed(project.progress?.epochPerHour)),
);
}
const expanded = projectFolderExpanded(node.path, node.depth);
return h(React.Fragment, { key: node.path },
h("div", { className: "met-tree-row folder", style: { paddingLeft }, "data-testid": `met-project-folder-${safeDomId(node.path)}` },
h("button", { type: "button", className: "met-tree-toggle", onClick: () => toggleProjectFolder(node.path, node.depth), "aria-label": expanded ? `折叠 ${node.path}` : `展开 ${node.path}` }, expanded ? "-" : "+"),
h("strong", null, node.name),
h("span", { className: "met-tree-count" }, `${node.count} projects`),
),
expanded ? node.children.map((child) => renderProjectTreeNode(child)) : null,
);
}
function renderKvGrid(items: Array<{ label: string; value: any; hint?: string }>) {
return h("div", { className: "met-detail-kv" }, items.map((item) => h("div", { key: item.label, className: "met-detail-kv-item" },
h("span", null, item.label),
h("strong", null, fmtValue(item.value)),
item.hint ? h("small", null, item.hint) : null,
)));
}
function renderMetricList(title: string, items: Array<{ label: string; value: any; hint?: string }>) {
return h("div", { className: "met-detail-section" },
h("h3", null, title),
renderKvGrid(items),
);
}
function renderLayerTable(layers: any[]) {
if (!Array.isArray(layers) || layers.length === 0) return h(EmptyState, { title: "模型层未上报", text: "等待 data/model_info.json 或 compute_analysis.json 生成。" });
return h("div", { className: "table-wrap met-layer-table" }, h("table", null,
h("thead", null, h("tr", null, h("th", null, "Layer"), h("th", null, "Type"), h("th", null, "Params"), h("th", null, "Trainable"), h("th", null, "Compute"))),
h("tbody", null, layers.slice(0, 18).map((layer: any, index: number) => h("tr", { key: `${layer.name || "layer"}-${index}` },
h("td", null, layer.name || `#${index + 1}`),
h("td", null, layer.type || "--"),
h("td", null, fmtNumber(layer.num_params)),
h("td", null, layer.trainable === undefined ? "--" : String(Boolean(layer.trainable))),
h("td", null, fmtNumber(layer.compute?.total ?? layer.estimated_cost?.weighted_units?.total)),
))),
));
}
function renderDataFiles(files: any[]) {
const list = Array.isArray(files) ? files : [];
if (list.length === 0) return h(EmptyState, { title: "data/ 暂无文件", text: "训练或评估完成后会生成 training_state、metrics、model_info 等文件。" });
return h("div", { className: "met-file-chip-grid" }, list.slice(0, 48).map((file: string) => h("span", { key: file }, file)), list.length > 48 ? h("span", null, `+${list.length - 48}`) : null);
}
function renderLogTail(text: any) {
const lines = String(text || "").replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(-12);
if (lines.length === 0) return h(EmptyState, { title: "暂无日志尾部", text: "该任务未上报 logTail 或日志已轮转。" });
return h("div", { className: "met-log-lines" }, lines.map((line, index) => h("div", { key: `${index}-${line.slice(0, 16)}` }, line)));
}
function renderDetailPanel() {
if (detail.loading) return h("section", { className: "met-detail-panel", "data-testid": "met-detail-panel" }, h(EmptyState, { title: "详情加载中", text: detail.title || "正在读取 D601 data/ 和 config.json" }));
if (detail.error) return h("section", { className: "met-detail-panel", "data-testid": "met-detail-panel" }, h("div", { className: "form-error wide" }, detail.error));
if (!detail.data) return h("section", { className: "met-detail-panel muted", "data-testid": "met-detail-panel" }, h(EmptyState, { title: "选择一个项目或任务查看详情", text: "项目库、当前队列、已完成和失败诊断中的行都可以点击;默认只展示结构化字段,原始 JSON 需显式点击按钮。" }));
const payload = detailPayload(detail);
const job = detailJob(detail);
const config = objectValue(payload.config);
const progress = objectValue(payload.progress || job.progress);
const data = objectValue(payload.data);
const metrics = objectValue(payload.metrics || data.metrics || progress.trainingInfo?.evaluation_metrics);
const trainingInfo = objectValue(data.trainingInfo || progress.trainingInfo);
const trainingState = objectValue(data.trainingState);
const model = objectValue(payload.model || data.model);
const layers = Array.isArray(model.modelSummary) && model.modelSummary.length > 0 ? model.modelSummary : model.computeLayers;
const evalMetrics = objectValue(trainingInfo.evaluation_metrics);
const title = detail.kind === "job" ? "训练任务详情" : "Project 详情";
return h("section", { className: "met-detail-panel", "data-testid": "met-detail-panel" },
h("div", { className: "panel-head compact" },
h("div", null,
h("p", { className: "panel-eyebrow" }, detail.kind === "job" ? "Job + Project Detail" : "Project Library Detail"),
h("h2", null, title),
h("code", null, payload.projectPath || job.projectPath || detail.title),
),
h("div", { className: "panel-actions" }, h(RawButton, { title: `MET ${title}`, data: detail.data, onOpen: onRaw, testId: "raw-met-detail" })),
),
detail.kind === "job" ? renderMetricList("任务状态", [
{ label: "Job ID", value: job.id },
{ label: "状态", value: statusLabel(job.status) },
{ label: "GPU", value: job.gpuName },
{ label: "Exit Code", value: job.exitCode },
{ label: "耗时", value: jobDuration(job) },
{ label: "训练速度", value: fmtEpochSpeed(jobEpochPerHour({ ...job, progress })) },
]) : null,
renderMetricList("config.json", [
{ label: "use_model", value: config.use_model },
{ label: "epoch_train", value: config.epoch_train },
{ label: "step_per_epoch", value: config.step_per_epoch },
{ label: "learning_rate", value: config.learning_rate },
{ label: "using_gpu", value: config.using_gpu },
{ label: "use_points", value: config.use_points },
{ label: "sample_rate", value: config.sample_rate },
{ label: "time_clipped_s", value: config.time_clipped_s },
{ label: "H_UNITS", value: config.H_UNITS },
{ label: "INNER_KAN_UNITS", value: config.INNER_KAN_UNITS },
{ label: "INNER_KAN_LAYERS", value: config.INNER_KAN_LAYERS },
{ label: "GRID_SIZE", value: config.GRID_SIZE },
{ label: "SPLINE_ORDER", value: config.SPLINE_ORDER },
{ label: "USE_FAST_MODEL", value: config.USE_FAST_MODEL },
{ label: "IIR_TRAINABLE", value: config.IIR_TRAINABLE },
]),
renderMetricList("data/ 训练状态", [
{ label: "Epoch", value: `${progress.currentEpoch ?? trainingState.current_epoch ?? trainingState.completed_epoch ?? "--"} / ${progress.epochTarget ?? config.epoch_train ?? "--"}` },
{ label: "Progress", value: fmtPercent(progress.progressPercent) },
{ label: "Last Loss", value: progress.lastLoss ?? trainingState.loss },
{ label: "Last Val Loss", value: progress.lastValLoss ?? trainingState.val_loss },
{ label: "Min Loss", value: trainingInfo.min_loss ?? trainingState.min_loss },
{ label: "Min Val Loss", value: trainingInfo.min_val_loss ?? trainingState.min_val_loss },
{ label: "Log Lines", value: progress.logLineCount },
{ label: "ETA", value: fmtDuration(progress.etaSeconds ?? trainingState.remaining_time) },
{ label: "训练速度", value: fmtEpochSpeed(progress.epochPerHour ?? trainingState.smoothed_speed) },
{ label: "Training Alive", value: trainingState.training_alive },
]),
renderMetricList("模型参数", [
{ label: "Model Type", value: model.modelType ?? config.use_model },
{ label: "Total Params", value: model.totalParams, hint: model.totalParams === null || model.totalParams === undefined ? "未上报" : "data/model_info.json" },
{ label: "Trainable", value: model.trainableParams },
{ label: "Non-trainable", value: model.nonTrainableParams },
{ label: "Compute Cost", value: model.computeCost },
{ label: "Estimate Status", value: model.estimateStatus },
{ label: "Unsupported Layers", value: model.unsupportedLayerCount },
]),
renderMetricList("指标", [
{ label: "train_loss", value: metrics.train_loss ?? evalMetrics.train_loss },
{ label: "val_loss", value: metrics.val_loss ?? evalMetrics.val_loss },
{ label: "train_mae", value: metrics.train_mae ?? evalMetrics.train_mae },
{ label: "val_mae", value: metrics.val_mae ?? evalMetrics.val_mae },
{ label: "train_afmae", value: metrics.train_afmae ?? evalMetrics.train_afmae },
{ label: "val_afmae", value: metrics.val_afmae ?? evalMetrics.val_afmae },
{ label: "freq_drift_hz", value: metrics.freq_drift_hz },
{ label: "sens_drift_percent", value: metrics.sens_drift_percent },
{ label: "linearity_percent", value: metrics.linearity_percent },
{ label: "weights_source", value: metrics.weights_source ?? evalMetrics.weights_source },
{ label: "lr min/mean/max", value: `${fmtValue(trainingInfo.learning_rate_min)} / ${fmtValue(trainingInfo.learning_rate_mean)} / ${fmtValue(trainingInfo.learning_rate_max)}` },
]),
h("div", { className: "met-detail-section" }, h("h3", null, "模型层"), renderLayerTable(layers)),
h("div", { className: "met-detail-section" }, h("h3", null, "data/ 文件"), renderDataFiles(data.files)),
detail.kind === "job" ? h("div", { className: "met-detail-section" }, h("h3", null, "日志尾部"), renderLogTail(objectValue(detail.data).logTail)) : null,
);
}
return h("div", { className: "met-page", "data-testid": "met-nonlinear-page" },
h(Panel, {
title: "MET Nonlinear 训练编排",
@@ -404,23 +731,18 @@ export function MetNonlinearPage({ microservices, onRaw, apiBaseUrl = "/api" }:
h("p", { className: "muted paragraph" }, "Fork 只创建新 Project 并自动勾选,不会直接训练;需要在右侧确认后加入待启动队列。"),
),
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})`)),
h("div", { className: "panel-head compact" }, h("div", null, h("p", { className: "panel-eyebrow" }, `Existing Projects · ${(state.projects?.roots || []).map((root: any) => `${root.root} ${root.count}`).join(" / ")}`), 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)),
))),
)),
h("div", { className: "met-project-table", "data-testid": "met-project-tree" },
h("div", { className: "met-tree-header" }, h("span", null, "文件树 Project"), h("span", null, "Model"), h("span", null, "Epochs"), h("span", null, "Progress"), h("span", null, "速度")),
projectTree.children.map((node) => renderProjectTreeNode(node)),
),
),
renderDetailPanel(),
) : 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 === "current" ? h("div", { "data-testid": "met-current-pane" }, currentQueueSummary(), queueTable(currentJobs, "current"), renderDetailPanel(), 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"), renderDetailPanel()) : 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"), renderDetailPanel(), 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,
+260 -31
View File
@@ -356,11 +356,15 @@ function pipelineNodeComponent(node: any, componentByRef: Map<string, AnyRecord>
}
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);
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();
@@ -789,10 +793,129 @@ function pipelineLatestRun(runs: any[], pipelineId: string): any {
return runs.find((run) => String(run?.pipelineId || "") === pipelineId) || null;
}
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 details = control.details || {};
const procedureRuns = Array.isArray(details.procedureRuns) ? details.procedureRuns : [];
const latestProcedure = procedureRuns.at(-1) || {};
const attempts = Array.isArray(latestProcedure.attempts) ? latestProcedure.attempts : [];
const latestAttempt = attempts.at(-1) || {};
const workerLogTail = Array.isArray(latestProcedure.workerLogTail) ? latestProcedure.workerLogTail : [];
const controlEventsTail = Array.isArray(latestAttempt.controlEventsTail) ? latestAttempt.controlEventsTail : [];
const disabled = !runId || !selectedNodeId || control.loading || Boolean(control.actionLoading);
const updateText = (field: string) => (event: any) => onControlChange({ [field]: event.target.value, error: "", message: "" });
const runOptions = pipelineRuns.length > 0 ? pipelineRuns : (activeRun ? [activeRun] : []);
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, "执行证据"),
control.details ? h("div", { className: "pipeline-control-evidence-grid" },
h("span", null, `${procedureRuns.length} procedure runs`),
h("span", null, `${attempts.length} attempts`),
h("span", null, `messages ${summarizeValue(latestAttempt.opencodeMessages?.messageCount)}`),
h("span", null, control.fetchedAt ? `fetched ${fmtClock(control.fetchedAt)}` : "not fetched"),
) : h("span", { className: "muted" }, "点击“抓取过程”读取 node 的 request/artifact/worker log/control prompt tail。"),
workerLogTail.length > 0 ? h("div", { className: "pipeline-log-list compact-log" }, workerLogTail.slice(-8).map((line: string, index: number) => h("code", { key: `${index}-${line.slice(0, 18)}` }, line))) : null,
controlEventsTail.length > 0 ? h("div", { className: "pipeline-log-list compact-log" }, controlEventsTail.slice(-6).map((line: string, index: number) => h("code", { key: `event-${index}-${line.slice(0, 18)}` }, line))) : null,
),
);
}
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());
async function load(): Promise<void> {
if (!service) return;
@@ -812,8 +935,6 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
load();
}, [service?.id, service?.runtime?.providerStatus]);
if (!service) return h(EmptyState, { title: "Pipeline 未登记", text: "请在 config.json 的 microservices 中登记 id=pipeline" });
const runtime = microserviceRuntime(service);
const repository = microserviceRepository(service);
const backend = microserviceBackend(service);
@@ -824,11 +945,20 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
const pipelineNodes = pipelineConfigNodes(activePipeline);
const pipelineEdges = pipelineConfigEdges(activePipeline);
const latestRun = pipelineLatestRun(runs, activePipelineId);
const pipelineRuns = runs.filter((run: any) => String(run?.pipelineId || "") === activePipelineId);
const 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 flow = pipelineFlowElements(activePipeline, latestRun, components);
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);
@@ -837,6 +967,73 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
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());
}, [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 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 工作台",
@@ -892,19 +1089,24 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
),
h(Panel, {
title: "控制图",
eyebrow: `${activePipeline.id || "pipeline"} / latest run ${latestRun?.status || "--"}`,
eyebrow: `${activePipeline.id || "pipeline"} / run ${activeRun?.status || "--"}`,
actions: h("div", { className: "pipeline-toolbar" },
h("select", {
value: activePipelineId,
disabled: pipelines.length === 0,
onChange: (event: any) => setSelectedPipelineId(event.target.value),
onChange: (event: any) => {
setSelectedPipelineId(event.target.value);
setSelectedRunId("");
setSelectedNodeId("");
setNodeControl(pipelineNodeControlState());
},
"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"}`),
onClick: () => exportPipelineGraph(flow, `${activePipeline.id || "pipeline"}-${activeRun?.runId || "snapshot"}`),
"data-testid": "pipeline-export-graph",
}, "导出渲染图"),
h("button", {
@@ -917,32 +1119,59 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
),
},
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,
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 },
},
h(Background, { gap: 22, size: 1, color: "rgba(215, 161, 58, 0.24)" }),
h(MiniMap, { pannable: true, zoomable: true, className: "pipeline-minimap" }),
h(Controls, { showInteractive: false }),
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(MiniMap, { pannable: true, zoomable: true, className: "pipeline-minimap" }),
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, `latest ${latestRun?.runId || "--"}`),
h("span", null, `run ${activeRun?.runId || "--"}`),
h("span", null, selectedNodeId ? `selected ${selectedNodeId}` : "click node to control"),
),
),
h(Panel, { title: "最近运行", eyebrow: `${runs.length}/${runCount} preview` },
@@ -959,12 +1188,12 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
h("code", null, fmtDate(run.updatedAt)),
))),
),
h(Panel, { title: "证据日志", eyebrow: latestRun?.runId || "latest worker tail" },
!latestRun ? h(EmptyState, { title: "暂无证据", text: "没有 Pipeline run 时不会展示 worker log tail" }) :
h(Panel, { title: "证据日志", eyebrow: activeRun?.runId || "selected worker tail" },
!activeRun ? h(EmptyState, { title: "暂无证据", text: "没有 Pipeline run 时不会展示 worker log tail" }) :
h("div", { className: "pipeline-log-list" },
(Array.isArray(latestRun.workerLogTail) && latestRun.workerLogTail.length > 0 ? latestRun.workerLogTail.slice(-12) : [`${latestRun.runId} ${latestRun.status || "--"} / ${fmtDate(latestRun.updatedAt)}`]).map((line: string, index: number) => h("code", { key: `${index}-${line.slice(0, 24)}` }, line)),
(Array.isArray(activeRun.workerLogTail) && activeRun.workerLogTail.length > 0 ? activeRun.workerLogTail.slice(-12) : [`${activeRun.runId} ${activeRun.status || "--"} / ${fmtDate(activeRun.updatedAt)}`]).map((line: string, index: number) => h("code", { key: `${index}-${line.slice(0, 24)}` }, line)),
),
h("div", { className: "panel-actions inline-actions" }, latestRun ? h(RawButton, { title: `Pipeline Run ${latestRun.runId}`, data: latestRun, onOpen: onRaw, testId: "raw-pipeline-run" }) : null),
h("div", { className: "panel-actions inline-actions" }, activeRun ? h(RawButton, { title: `Pipeline Run ${activeRun.runId}`, data: activeRun, onOpen: onRaw, testId: "raw-pipeline-run" }) : null),
),
),
);