feat: integrate MET Nonlinear microservice

This commit is contained in:
Codex
2026-05-05 15:59:34 +00:00
parent 1d0046dc50
commit b1c717b834
11 changed files with 455 additions and 18 deletions
+23 -1
View File
@@ -965,7 +965,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
.result-card dd { margin: 0; }
.result-grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
.microservice-page, .findjob-page, .pipeline-page {
.microservice-page, .findjob-page, .pipeline-page, .met-page {
display: grid;
gap: 10px;
}
@@ -1015,6 +1015,28 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
align-items: start;
}
.pipeline-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(5) { grid-column: 1 / -1; }
.met-grid {
display: grid;
grid-template-columns: minmax(420px, 0.9fr) minmax(560px, 1.25fr);
gap: 10px;
align-items: start;
}
.met-grid .panel:nth-child(3), .met-grid .panel:nth-child(5) { grid-column: 1 / -1; }
.met-progress {
height: 6px;
margin-top: 5px;
overflow: hidden;
border: 1px solid var(--line-soft);
background: var(--panel-3);
}
.met-progress span {
display: block;
height: 100%;
min-width: 2px;
max-width: 100%;
background: linear-gradient(90deg, var(--accent-2), var(--accent));
}
.met-job-table { max-height: 460px; overflow: auto; }
.pipeline-flow-frame {
height: min(68vh, 720px);
min-height: 520px;
+4
View File
@@ -1,6 +1,7 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { FindJobPage } from "./findjob";
import { MetNonlinearPage } from "./met-nonlinear";
import { PipelinePage } from "./pipeline";
import { TodoNotePage } from "./todo-note";
@@ -51,6 +52,7 @@ const MODULES = [
{ id: "todo-note", label: "Todo Note" },
{ id: "findjob", label: "FindJob" },
{ id: "pipeline", label: "Pipeline" },
{ id: "met-nonlinear", label: "MET Nonlinear" },
] },
{ id: "config", label: "系统配置", code: "CFG", tabs: [
{ id: "topology", label: "连接拓扑" },
@@ -1096,6 +1098,7 @@ function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord
service.id === "findjob" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "findjob"), "data-testid": "open-findjob-button" }, "打开") : null,
service.id === "pipeline" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "pipeline"), "data-testid": "open-pipeline-button" }, "打开") : null,
service.id === "todo-note" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "todo-note"), "data-testid": "open-todo-note-button" }, "打开") : null,
service.id === "met-nonlinear" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "met-nonlinear"), "data-testid": "open-met-nonlinear-button" }, "打开") : null,
h(RawButton, { title: `Microservice ${service.id}`, data: service, onOpen: onRaw }),
),
),
@@ -1355,6 +1358,7 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa
if (activeModule === "apps" && activeTab === "todo-note") return h(TodoNotePage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "findjob") return h(FindJobPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "pipeline") return h(PipelinePage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "met-nonlinear") return h(MetNonlinearPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "config" && activeTab === "topology") return h(TopologyPage, { data });
if (activeModule === "config" && activeTab === "auth") return h(AuthPage, { session });
if (activeModule === "config" && activeTab === "security") return h(SecurityPage);
@@ -0,0 +1,306 @@
import React from "react";
type AnyRecord = Record<string, any>;
const h = React.createElement;
const { useEffect } = React;
const useState: any = React.useState;
function errorMessage(error: unknown, fallback = "操作失败"): string {
return error instanceof Error ? error.message : String(error || fallback);
}
function fmtDate(value: any): string {
if (!value) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--";
return date.toLocaleString("zh-CN", { hour12: false });
}
function fmtClock(value: Date): string {
return value.toLocaleTimeString("zh-CN", { hour12: false });
}
function fmtPercent(value: any): string {
const number = Number(value);
return Number.isFinite(number) ? `${Math.max(0, Math.min(100, number)).toFixed(1)}%` : "--";
}
function fmtBytes(value: any): string {
const bytes = Number(value);
if (!Number.isFinite(bytes) || bytes <= 0) return "--";
const units = ["B", "KB", "MB", "GB", "TB"];
let current = bytes;
let index = 0;
while (current >= 1024 && index < units.length - 1) {
current /= 1024;
index += 1;
}
return `${current.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
function fmtDuration(seconds: any): string {
const value = Number(seconds);
if (!Number.isFinite(value)) return "--";
if (value < 60) return `${Math.max(0, Math.round(value))}s`;
if (value < 3600) return `${Math.floor(value / 60)}m ${Math.round(value % 60)}s`;
return `${Math.floor(value / 3600)}h ${Math.floor((value % 3600) / 60)}m`;
}
async function requestJson(path: string, options: AnyRecord = {}): Promise<any> {
const headers = new Headers(options.headers || {});
if (options.body && !headers.has("content-type")) headers.set("content-type", "application/json");
const response = await fetch(path, { credentials: "same-origin", ...options, headers });
const text = await response.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = { text };
}
if (!response.ok || body?.ok === false) {
const message = body?.error?.message || body?.error || `HTTP ${response.status}`;
throw new Error(message);
}
return body;
}
function StatusBadge({ status, children }: AnyRecord) {
const normalized = String(status || "unknown").toLowerCase();
return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown");
}
function MetricCard({ label, value, hint, tone }: AnyRecord) {
return h("article", { className: `metric-card ${tone || ""}` },
h("div", { className: "metric-label" }, label),
h("div", { className: "metric-value" }, value),
h("div", { className: "metric-hint" }, hint),
);
}
function Panel({ title, eyebrow, actions, children, className }: AnyRecord) {
return h("section", { className: `panel ${className || ""}` },
h("div", { className: "panel-head" },
h("div", null,
eyebrow ? h("p", { className: "panel-eyebrow" }, eyebrow) : null,
h("h2", null, title),
),
actions ? h("div", { className: "panel-actions" }, actions) : null,
),
h("div", { className: "panel-body" }, children),
);
}
function RawButton({ title, data, onOpen, testId }: AnyRecord) {
return h("button", {
type: "button",
className: "ghost-btn",
"data-testid": testId,
onClick: () => onOpen(title, data),
}, "查看原始JSON");
}
function EmptyState({ title, text }: AnyRecord) {
return h("div", { className: "empty-state" }, h("strong", null, title), h("span", null, text));
}
function microserviceRuntime(service: any): AnyRecord {
return service?.runtime && typeof service.runtime === "object" && !Array.isArray(service.runtime) ? service.runtime : {};
}
function microserviceBackend(service: any): AnyRecord {
return service?.backend && typeof service.backend === "object" && !Array.isArray(service.backend) ? service.backend : {};
}
function microserviceRepository(service: any): AnyRecord {
return service?.repository && typeof service.repository === "object" && !Array.isArray(service.repository) ? service.repository : {};
}
function queueCounts(queue: any): AnyRecord {
return queue?.counts && typeof queue.counts === "object" && !Array.isArray(queue.counts) ? queue.counts : {};
}
function jobRows(queue: any): any[] {
return Array.isArray(queue?.jobs) ? queue.jobs.slice(0, 80) : [];
}
function projectRows(projects: any): any[] {
return Array.isArray(projects?.projects) ? projects.projects.slice(0, 40) : [];
}
function gpuRows(health: any, queue: any): any[] {
if (Array.isArray(health?.gpu)) return health.gpu;
if (Array.isArray(queue?.gpu)) return queue.gpu;
return [];
}
function metApi(apiBaseUrl: string, path: string): string {
return `${apiBaseUrl}/microservices/met-nonlinear/proxy${path}`;
}
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 });
async function load(): Promise<void> {
if (!service) return;
setState((prev: any) => ({ ...prev, loading: true, error: "" }));
try {
const [health, summary, queue, projects, 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=40")),
requestJson(metApi(apiBaseUrl, "/api/history")),
requestJson(metApi(apiBaseUrl, "/api/images")),
]);
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 加载失败") }));
}
}
async function enqueueServerTest(): Promise<void> {
setState((prev: any) => ({ ...prev, actionBusy: true, error: "" }));
try {
await requestJson(metApi(apiBaseUrl, "/api/queue/server-test"), {
method: "POST",
body: JSON.stringify({ sourceProject: "projects/FRIKANh6u6l4", count: 10, epochs: 10, maxConcurrency: 2 }),
});
await load();
} catch (err) {
setState((prev: any) => ({ ...prev, actionBusy: false, error: errorMessage(err, "Server test 入队失败") }));
}
}
useEffect(() => {
load();
}, [service?.id, service?.runtime?.providerStatus]);
if (!service) return h(EmptyState, { title: "MET Nonlinear 未登记", text: "请在 config.json 的 microservices 中登记 id=met-nonlinear" });
const runtime = microserviceRuntime(service);
const repository = microserviceRepository(service);
const backend = microserviceBackend(service);
const counts = queueCounts(state.queue?.queue || state.summary?.queue);
const gpus = gpuRows(state.health, state.queue);
const targetGpu = state.health?.targetGpu || state.summary?.targetGpu || gpus.find((gpu: any) => String(gpu.name || "").includes("2080"));
const image = state.images?.mlImage || state.health?.image || {};
const jobs = jobRows(state.queue);
const projects = projectRows(state.projects);
const history = Array.isArray(state.history?.jobs) ? state.history.jobs.slice(0, 20) : [];
return h("div", { className: "met-page", "data-testid": "met-nonlinear-page" },
h(Panel, {
title: "MET Nonlinear 训练编排",
eyebrow: "D601 GPU Microservice",
actions: h("div", { className: "panel-actions" },
h("button", { type: "button", className: "ghost-btn", onClick: load, disabled: state.loading, "data-testid": "met-refresh-button" }, state.loading ? "刷新中" : "刷新"),
h("button", { type: "button", className: "primary-btn", onClick: enqueueServerTest, disabled: state.actionBusy, "data-testid": "met-server-test-button" }, state.actionBusy ? "入队中" : "创建10个10轮任务"),
h(RawButton, { title: "MET Nonlinear Microservice", data: service, onOpen: onRaw, testId: "raw-met-service" }),
),
},
h("div", { className: "findjob-hero" },
h("div", null,
h("div", { className: "node-version-line" },
h(StatusBadge, { status: runtime.providerStatus === "online" ? "online" : "warn" }, runtime.providerStatus || "unknown"),
h("span", null, service.providerId),
h("span", null, backend.public ? "公网暴露" : "仅 UniDesk frontend 代理访问"),
),
h("p", { className: "muted paragraph" }, service.description),
),
h("div", { className: "microservice-ref-card" },
h("span", null, "Repo"),
h("strong", null, repository.url || "--"),
h("code", null, repository.commitId || "--"),
),
h("div", { className: "microservice-ref-card" },
h("span", null, "D601 Docker"),
h("strong", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"}`),
h("code", null, `${repository.composeFile || "--"} / ${repository.containerName || "--"}`),
),
),
state.error ? h("div", { className: "form-error wide" }, state.error) : null,
),
h("div", { className: "met-grid" },
h(Panel, { title: "核心状态", eyebrow: state.refreshedAt ? `Updated ${fmtClock(state.refreshedAt)}` : "Queue + GPU" },
h("div", { className: "metric-grid" },
h(MetricCard, { label: "Queued", value: counts.queued ?? 0, hint: "等待训练", tone: Number(counts.queued || 0) > 0 ? "warn" : "" }),
h(MetricCard, { label: "Running", value: counts.running ?? 0, hint: `max ${state.summary?.queue?.maxConcurrency ?? state.queue?.queue?.maxConcurrency ?? "--"}`, tone: Number(counts.running || 0) > 0 ? "ok" : "" }),
h(MetricCard, { label: "Succeeded", value: counts.succeeded ?? 0, hint: "历史成功" }),
h(MetricCard, { label: "Failed", value: counts.failed ?? 0, hint: "需要诊断", tone: Number(counts.failed || 0) > 0 ? "warn" : "" }),
h(MetricCard, { label: "2080Ti Free", value: targetGpu ? fmtPercent(Number(targetGpu.freeRatio) * 100) : "--", hint: targetGpu ? `${targetGpu.memoryFreeMiB}/${targetGpu.memoryTotalMiB} MiB` : "等待 GPU 上报" }),
h(MetricCard, { label: "ML Image", value: image.present ? "READY" : "MISSING", hint: image.image || "met-nonlinear-ml:tf26", tone: image.present ? "ok" : "warn" }),
h(MetricCard, { label: "Projects", value: projects.length, hint: "config preview" }),
h(MetricCard, { label: "Health", value: state.health?.ok ? "OK" : "--", hint: "D601 /health" }),
),
h("div", { className: "panel-actions inline-actions" },
h(RawButton, { title: "MET Summary", data: state.summary, onOpen: onRaw, testId: "raw-met-summary" }),
h(RawButton, { title: "MET Images", data: state.images, onOpen: onRaw, testId: "raw-met-images" }),
),
),
h(Panel, { title: "GPU 与镜像", eyebrow: `${gpus.length} GPU` },
gpus.length === 0 ? h(EmptyState, { title: "暂无 GPU 上报", text: "等待 D601 met-nonlinear-ts 或 ML image 提供 nvidia-smi 数据" }) :
h("div", { className: "table-wrap" }, h("table", null,
h("thead", null, h("tr", null, h("th", null, "Index"), h("th", null, "Name"), h("th", null, "Free"), h("th", null, "Policy"))),
h("tbody", null, gpus.map((gpu: any) => h("tr", { key: gpu.index },
h("td", null, gpu.index),
h("td", null, gpu.name),
h("td", null, `${gpu.memoryFreeMiB} / ${gpu.memoryTotalMiB} MiB`, h("div", { className: "met-progress" }, h("span", { style: { width: fmtPercent(Number(gpu.freeRatio) * 100) } }))),
h("td", null, String(gpu.name || "").includes("2080") ? "target 2080Ti, <20% 限制并发" : "non-target"),
))),
)),
),
h(Panel, { title: "任务队列", eyebrow: `${jobs.length} Jobs` },
jobs.length === 0 ? h(EmptyState, { title: "队列为空", text: "可创建 server_test 训练任务或通过 API 添加已有 projects" }) :
h("div", { className: "table-wrap met-job-table" }, h("table", null,
h("thead", null, h("tr", null, h("th", null, "状态"), h("th", null, "Project"), h("th", null, "Epoch"), h("th", null, "ETA"), h("th", null, "GPU"), h("th", null, "Exit"), h("th", null, "更新时间"))),
h("tbody", null, jobs.map((job: any) => {
const progress = job.progress || {};
return h("tr", { key: job.id },
h("td", null, h(StatusBadge, { status: job.status }, job.status)),
h("td", null, h("strong", null, job.projectPath), h("code", null, job.id)),
h("td", null,
h("span", null, `${progress.currentEpoch ?? "--"} / ${progress.epochTarget ?? job.epochTarget ?? "--"}`),
h("div", { className: "met-progress" }, h("span", { style: { width: fmtPercent(progress.progressPercent) } })),
),
h("td", null, fmtDuration(progress.etaSeconds)),
h("td", null, job.gpuName || "--"),
h("td", null, job.exitCode ?? "--"),
h("td", null, fmtDate(job.updatedAt)),
);
})),
)),
h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "MET Queue", data: state.queue, onOpen: onRaw, testId: "raw-met-queue" })),
),
h(Panel, { title: "Project Config 预览", eyebrow: "projects/" },
projects.length === 0 ? h(EmptyState, { title: "暂无 project", text: "等待 D601 返回 /api/projects" }) :
h("div", { className: "table-wrap" }, h("table", null,
h("thead", null, h("tr", null, h("th", null, "Project"), h("th", null, "Model"), h("th", null, "Epochs"), h("th", null, "Step/Epoch"), h("th", null, "GPU"), h("th", null, "Progress"))),
h("tbody", null, projects.map((project: any) => h("tr", { key: project.projectPath },
h("td", null, project.projectPath),
h("td", null, project.useModel || "--"),
h("td", null, project.epochTrain ?? "--"),
h("td", null, project.stepPerEpoch ?? "--"),
h("td", null, project.usingGpu ? "true" : "false"),
h("td", null, fmtPercent(project.progress?.progressPercent)),
))),
)),
h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "MET Projects", data: state.projects, onOpen: onRaw, testId: "raw-met-projects" })),
),
h(Panel, { title: "历史训练记录", eyebrow: `${history.length} Terminal Jobs` },
history.length === 0 ? h(EmptyState, { title: "暂无历史", text: "完成或失败的训练任务会显示耗时、exit code 和失败诊断" }) :
h("div", { className: "history-list" }, history.map((job: any) => h("article", { key: job.id, className: "result-card" },
h("div", { className: "node-card-head" }, h("strong", null, job.projectPath), h(StatusBadge, { status: job.status }, job.status)),
h("div", { className: "docker-meta compact" },
h("span", null, `耗时 ${job.startedAt && job.finishedAt ? fmtDuration((Date.parse(job.finishedAt) - Date.parse(job.startedAt)) / 1000) : "--"}`),
h("span", null, `exit ${job.exitCode ?? "--"}`),
h("span", null, job.gpuName || "--"),
),
job.error ? h("p", { className: "form-error" }, job.error.slice(0, 260)) : h("p", { className: "muted" }, "无失败原因"),
h("code", null, job.id),
))),
h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "MET History", data: state.history, onOpen: onRaw, testId: "raw-met-history" })),
),
),
);
}