feat: improve met project tree details
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user