feat: add provider-backed microservices

This commit is contained in:
Codex
2026-05-05 07:56:03 +00:00
parent ef70ca972b
commit abd40fa252
24 changed files with 1656 additions and 51 deletions
+118 -2
View File
@@ -401,6 +401,8 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; }
.status-badge.offline, .status-badge.failed { color: var(--danger); border-color: rgba(207, 106, 84, 0.45); }
.status-badge.running, .status-badge.dispatched, .status-badge.accepted, .status-badge.internal { color: var(--accent-2); border-color: rgba(78, 183, 168, 0.45); }
.status-badge.queued, .status-badge.warn { color: var(--warn); border-color: rgba(215, 161, 58, 0.45); }
.status-badge.private, .status-badge.p1, .status-badge.prioritized, .status-badge.verified { color: var(--accent-2); border-color: rgba(78, 183, 168, 0.45); }
.status-badge.stale, .status-badge.invalid, .status-badge.abandoned { color: var(--warn); border-color: rgba(215, 161, 58, 0.45); }
.docker-page {
display: grid;
@@ -837,6 +839,8 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; }
table { width: 100%; border-collapse: collapse; min-width: 760px; }
.node-list-table { min-width: 1180px; }
.task-history-table { min-width: 1080px; }
.microservice-table { min-width: 1320px; }
.findjob-job-table table { min-width: 1180px; }
th, td {
padding: 7px 9px;
border-bottom: 1px solid var(--line-soft);
@@ -961,6 +965,117 @@ 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 {
display: grid;
gap: 10px;
}
.microservice-actions, .inline-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-top: 8px;
}
.findjob-hero, .pipeline-hero {
display: grid;
grid-template-columns: minmax(260px, 1.4fr) minmax(260px, 0.9fr) minmax(220px, 0.7fr);
gap: 8px;
align-items: stretch;
}
.microservice-ref-card {
display: grid;
gap: 4px;
min-width: 0;
padding: 8px;
border: 1px solid var(--line-soft);
background: var(--panel-3);
}
.microservice-ref-card span {
color: var(--muted);
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.microservice-ref-card strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.findjob-grid {
display: grid;
grid-template-columns: minmax(360px, 0.9fr) minmax(560px, 1.35fr);
gap: 10px;
align-items: start;
}
.findjob-grid .panel:nth-child(3) { grid-column: 1 / -1; }
.pipeline-grid {
display: grid;
grid-template-columns: minmax(360px, 0.9fr) minmax(520px, 1.25fr);
gap: 10px;
align-items: start;
}
.pipeline-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(5) { grid-column: 1 / -1; }
.pipeline-node-table table { min-width: 1080px; }
.component-strata {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
gap: 7px;
}
.component-stratum, .pipeline-run-card {
display: grid;
gap: 6px;
padding: 9px;
border: 1px solid var(--line-soft);
background: var(--panel-3);
}
.component-stratum span {
color: var(--muted);
font-size: 10px;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.component-stratum strong {
color: var(--accent-2);
font-size: 18px;
}
.pipeline-component-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 9px;
}
.pipeline-run-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 8px;
}
.pipeline-log-list {
display: grid;
gap: 4px;
}
.pipeline-log-list code {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 5px 7px;
border: 1px solid var(--line-soft);
background: rgba(0,0,0,0.18);
color: #b9d5d8;
}
.draft-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 8px;
}
.draft-card {
display: grid;
gap: 6px;
padding: 9px;
border: 1px solid var(--line-soft);
background: var(--panel-3);
}
.endpoint-list article { grid-template-columns: 150px minmax(220px, 1fr) auto; }
.policy-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.policy-grid article { grid-template-columns: 1fr; align-items: start; }
@@ -1051,7 +1166,8 @@ 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 { grid-template-columns: 1fr; }
.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; }
.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; }
}
@@ -1149,6 +1265,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
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; }
.compact-row, .heartbeat-row, .log-row, .endpoint-list article, .volume-route { grid-template-columns: 1fr; align-items: start; }
.compact-row, .heartbeat-row, .log-row, .endpoint-list article, .volume-route, .findjob-hero, .pipeline-hero { grid-template-columns: 1fr; align-items: start; }
.docker-hero, .monitor-hero { flex-direction: column; }
}
+377 -4
View File
@@ -50,6 +50,11 @@ const MODULES = [
{ id: "history", label: "任务历史" },
{ id: "results", label: "执行结果" },
] },
{ id: "apps", label: "微服务", code: "APP", tabs: [
{ id: "catalog", label: "服务目录" },
{ id: "findjob", label: "FindJob" },
{ id: "pipeline", label: "Pipeline" },
] },
{ id: "config", label: "系统配置", code: "CFG", tabs: [
{ id: "topology", label: "连接拓扑" },
{ id: "auth", label: "认证策略" },
@@ -147,6 +152,14 @@ function summarizeValue(value: any): string {
return String(value);
}
function summarizeFieldValue(key: string, value: any): string {
if (key === "bodyText" && typeof value === "string") {
const kind = /^\s*[{[]/.test(value) ? "JSON" : "HTTP";
return `${kind} body ${value.length} chars`;
}
return summarizeValue(value);
}
function objectEntries(value: any): Array<[string, any]> {
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
return Object.entries(value);
@@ -260,6 +273,75 @@ function latestScheduledUpgradeTask(records: any[]): any | null {
return records.find((task) => taskUpgradeMode(task) === "schedule") || records[0] || null;
}
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 findjobSummaryMetric(summary: any, key: string): string {
const value = summary && typeof summary === "object" ? summary[key] : undefined;
return Number.isFinite(Number(value)) ? String(value) : "--";
}
function findjobJobRows(data: any): any[] {
const rows = Array.isArray(data?.jobs) ? data.jobs : [];
return rows.slice(0, 40);
}
function findjobDraftRows(data: any): any[] {
const rows = Array.isArray(data?.drafts) ? data.drafts : [];
return rows.slice(0, 12);
}
function pipelineSnapshotArrays(snapshot: any): { components: any[]; pipelines: any[]; runs: any[] } {
return {
components: Array.isArray(snapshot?.registry?.components) ? snapshot.registry.components : [],
pipelines: Array.isArray(snapshot?.pipelines) ? snapshot.pipelines : [],
runs: Array.isArray(snapshot?.runs) ? snapshot.runs : [],
};
}
function pipelineArrayCount(snapshot: any, path: string, fallback: number): number {
const meta = snapshot?._unidesk?.arrayLimits?.[path];
const original = Number(meta?.originalLength);
return Number.isFinite(original) ? original : fallback;
}
function pipelineComponentRef(value: any): string {
if (!value || typeof value !== "object" || Array.isArray(value)) return "--";
return `${value.componentClass || "--"}/${value.id || "--"}`;
}
function 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);
return String(node?.status || "pending");
}
function pipelineStatusCounts(runs: any[]): AnyRecord {
return runs.reduce((counts: AnyRecord, run: any) => {
const status = String(run?.status || "unknown").toLowerCase();
counts[status] = (counts[status] || 0) + 1;
return counts;
}, {});
}
function pipelineComponentClassCounts(components: any[]): Array<{ name: string; count: number }> {
const counts = components.reduce((memo: AnyRecord, component: any) => {
const name = String(component?.componentClass || "unknown");
memo[name] = (memo[name] || 0) + 1;
return memo;
}, {});
return Object.entries(counts).map(([name, count]) => ({ name, count: Number(count) })).sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
}
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");
@@ -387,7 +469,7 @@ function DataSummary({ data, empty = "无数据" }: AnyRecord) {
const entries = Object.entries(data).slice(0, 5);
if (entries.length === 0) return h("span", { className: "muted" }, empty);
return h("div", { className: "summary-grid" }, entries.map(([key, value]) =>
h("span", { key, className: "summary-item" }, h("b", null, key), h("span", null, summarizeValue(value))),
h("span", { key, className: "summary-item" }, h("b", null, key), h("span", null, summarizeFieldValue(key, value))),
));
}
@@ -1031,6 +1113,291 @@ function DockerSidePanel({ title, items, render, limit }: AnyRecord) {
);
}
function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord) {
const privateServices = microservices.filter((service: any) => microserviceBackend(service).public === false);
return h("div", { className: "microservice-page", "data-testid": "microservice-catalog-page" },
h(Panel, { title: "Microservice 目录", eyebrow: "Provider Mounted Backends" },
h("div", { className: "metric-grid" },
h(MetricCard, { label: "服务总数", value: microservices.length, hint: "config.json microservices" }),
h(MetricCard, { label: "私有后端", value: privateServices.length, hint: "不直接暴露公网", tone: "ok" }),
h(MetricCard, { label: "D601 服务", value: microservices.filter((service: any) => service.providerId === "D601").length, hint: "compute-node docker" }),
h(MetricCard, { label: "集成前端", value: microservices.filter((service: any) => service.frontend?.integrated).length, hint: "UniDesk React 页面" }),
),
),
h(Panel, { title: "服务映射", eyebrow: "Repo Reference + Runtime" },
microservices.length === 0 ? h(EmptyState, { title: "暂无 Microservice", text: "在 config.json 的 microservices 中登记 provider、仓库引用和后端映射" }) :
h("div", { className: "table-wrap" }, h("table", { className: "microservice-table" },
h("thead", null, h("tr", null, h("th", null, "服务"), h("th", null, "Provider"), h("th", null, "代码引用"), h("th", null, "Docker 引用"), h("th", null, "后端映射"), h("th", null, "开发入口"), h("th", null, "运行态"), h("th", null, "操作"))),
h("tbody", null, microservices.map((service: any) => {
const runtime = microserviceRuntime(service);
const repository = microserviceRepository(service);
const backend = microserviceBackend(service);
return h("tr", { key: service.id, "data-testid": `microservice-row-${safeId(service.id)}` },
h("td", null, h("strong", null, service.name), h("code", null, service.id)),
h("td", null, h("strong", null, runtime.providerName || service.providerId), h("code", null, service.providerId)),
h("td", null, h("span", null, repository.url || "--"), h("code", null, repository.commitId || "--")),
h("td", null, h("span", null, repository.composeFile || "--"), h("code", null, `${repository.composeService || "--"} / ${repository.containerName || "--"}`)),
h("td", null,
h(StatusBadge, { status: backend.public ? "warn" : "online" }, backend.public ? "public" : "private"),
h("code", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"} -> ${backend.proxyMode || "--"}`),
),
h("td", null, h("span", null, service.development?.sshPassthrough ? "SSH 透传" : "未配置"), h("code", null, service.development?.worktreePath || "--")),
h("td", null, h(StatusBadge, { status: runtime.providerStatus === "online" ? "online" : "warn" }, runtime.providerStatus || "unknown"), h(DataSummary, { data: runtime.container, empty: "容器快照未上报" })),
h("td", null,
h("div", { className: "microservice-actions" },
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,
h(RawButton, { title: `Microservice ${service.id}`, data: service, onOpen: onRaw }),
),
),
);
})),
)),
),
);
}
function FindJobPage({ microservices, onRaw }: AnyRecord) {
const service = microservices.find((item: any) => item.id === "findjob") || null;
const [state, setState] = useState({ loading: false, error: "", health: null, summary: null, jobs: null, drafts: null, refreshedAt: null });
async function load(): Promise<void> {
if (!service) return;
setState((prev: any) => ({ ...prev, loading: true, error: "" }));
try {
const [health, summary, jobs, drafts] = await Promise.all([
requestJson(`${cfg.apiBaseUrl}/microservices/findjob/health`),
requestJson(`${cfg.apiBaseUrl}/microservices/findjob/proxy/api/summary`),
requestJson(`${cfg.apiBaseUrl}/microservices/findjob/proxy/api/jobs?__unideskArrayLimit=jobs:40`),
requestJson(`${cfg.apiBaseUrl}/microservices/findjob/proxy/api/drafts`),
]);
setState({ loading: false, error: "", health, summary, jobs, drafts, refreshedAt: new Date() });
} catch (err) {
setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "FindJob 加载失败") }));
}
}
useEffect(() => {
load();
}, [service?.id, service?.runtime?.providerStatus]);
if (!service) return h(EmptyState, { title: "FindJob 未登记", text: "请在 config.json 的 microservices 中登记 id=findjob" });
const runtime = microserviceRuntime(service);
const repository = microserviceRepository(service);
const backend = microserviceBackend(service);
const summary = state.summary || {};
const jobs = findjobJobRows(state.jobs);
const drafts = findjobDraftRows(state.drafts);
const transform = state.jobs?._unidesk?.arrayLimits?.jobs;
return h("div", { className: "findjob-page", "data-testid": "findjob-page" },
h(Panel, {
title: "FindJob 工作台",
eyebrow: "D601 Backend Microservice",
actions: h("div", { className: "panel-actions" },
h("button", { type: "button", className: "ghost-btn", onClick: load, disabled: state.loading, "data-testid": "findjob-refresh-button" }, state.loading ? "刷新中" : "刷新"),
h(RawButton, { title: "FindJob Microservice", data: service, onOpen: onRaw, testId: "raw-findjob-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.composeService || "--"}`),
),
),
state.error ? h("div", { className: "form-error wide" }, state.error) : null,
),
h("div", { className: "findjob-grid" },
h(Panel, { title: "岗位指标", eyebrow: state.refreshedAt ? `Updated ${fmtClock(state.refreshedAt)}` : "Summary" },
h("div", { className: "metric-grid" },
h(MetricCard, { label: "岗位总量", value: findjobSummaryMetric(summary, "totalJobs"), hint: "tracked jobs", tone: "ok" }),
h(MetricCard, { label: "原始岗位", value: findjobSummaryMetric(summary, "rawJobs"), hint: "raw queue" }),
h(MetricCard, { label: "已验证", value: findjobSummaryMetric(summary, "verifiedJobs"), hint: "verified set" }),
h(MetricCard, { label: "优先处理", value: findjobSummaryMetric(summary, "prioritizedJobs"), hint: "prioritized" }),
h(MetricCard, { label: "过期", value: findjobSummaryMetric(summary, "staleJobs"), hint: "stale jobs", tone: "warn" }),
h(MetricCard, { label: "无效", value: findjobSummaryMetric(summary, "invalidJobs"), hint: "invalid jobs", tone: "warn" }),
h(MetricCard, { label: "上海", value: findjobSummaryMetric(summary, "shanghaiJobs"), hint: "city filter" }),
h(MetricCard, { label: "Health", value: state.health?.ok ? "OK" : "--", hint: "D601 /api/health" }),
),
h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "FindJob Summary", data: summary, onOpen: onRaw, testId: "raw-findjob-summary" })),
),
h(Panel, { title: "近期岗位", eyebrow: transform ? `${transform.returnedLength}/${transform.originalLength} Preview` : `${jobs.length} Preview` },
jobs.length === 0 ? h(EmptyState, { title: "暂无岗位预览", text: "等待 D601 findjob backend 返回 /api/jobs" }) :
h("div", { className: "table-wrap findjob-job-table" }, h("table", null,
h("thead", null, h("tr", null, h("th", null, "优先级"), h("th", null, "状态"), h("th", null, "单位"), h("th", null, "职位"), h("th", null, "城市"), h("th", null, "阶段"), h("th", null, "截止"), h("th", null, "证据"))),
h("tbody", null, jobs.map((job: any) => h("tr", { key: job.id },
h("td", null, h(StatusBadge, { status: String(job.priority || "").toLowerCase() || "unknown" }, job.priority || "--")),
h("td", null, h(StatusBadge, { status: String(job.status || "").toLowerCase() || "unknown" }, job.status || "--")),
h("td", null, job.organization_name || "--", h("code", null, job.id || "--")),
h("td", null, job.display_title || job.title || "--"),
h("td", null, job.display_city || job.city || "--"),
h("td", null, job.workflow_stage || "--"),
h("td", null, job.deadline || "--"),
h("td", null, job.evidence_url ? h("a", { href: job.evidence_url, target: "_blank", rel: "noreferrer" }, "打开") : h("span", { className: "muted" }, "无")),
))),
)),
h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "FindJob Jobs Preview", data: state.jobs, onOpen: onRaw, testId: "raw-findjob-jobs" })),
),
h(Panel, { title: "草稿与报告", eyebrow: `${drafts.length} Drafts` },
drafts.length === 0 ? h(EmptyState, { title: "暂无草稿", text: "D601 findjob backend 未返回 drafts" }) :
h("div", { className: "draft-list" }, drafts.map((draft: any) => h("article", { key: draft.id, className: "draft-card" },
h("div", { className: "node-card-head" }, h("strong", null, draft.id), h(StatusBadge, { status: draft.status }, draft.status || "--")),
h("div", { className: "docker-meta compact" },
h("span", null, draft.workflow_stage || "--"),
h("span", null, `jobs ${draft.counts?.jobs ?? 0}`),
h("span", null, `reports ${draft.counts?.reports ?? 0}`),
),
h("span", null, draft.latestReportPath || "暂无报告"),
h("code", null, fmtDate(draft.updated_at || draft.updatedAt)),
))),
h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "FindJob Drafts", data: state.drafts, onOpen: onRaw, testId: "raw-findjob-drafts" })),
),
),
);
}
function PipelinePage({ microservices, onRaw }: AnyRecord) {
const service = microservices.find((item: any) => item.id === "pipeline") || null;
const [state, setState] = useState({ loading: false, error: "", health: null, snapshot: null, refreshedAt: null });
async function load(): Promise<void> {
if (!service) return;
setState((prev: any) => ({ ...prev, loading: true, error: "" }));
try {
const [health, snapshot] = await Promise.all([
requestJson(`${cfg.apiBaseUrl}/microservices/pipeline/health`),
requestJson(`${cfg.apiBaseUrl}/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3`),
]);
setState({ loading: false, error: "", health, snapshot, refreshedAt: new Date() });
} catch (err) {
setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "Pipeline 加载失败") }));
}
}
useEffect(() => {
load();
}, [service?.id, service?.runtime?.providerStatus]);
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);
const snapshot = state.snapshot || {};
const { components, pipelines, runs } = pipelineSnapshotArrays(snapshot);
const activePipeline = pipelines[0] || {};
const pipelineNodes = Array.isArray(activePipeline.nodes) ? activePipeline.nodes : [];
const pipelineEdges = Array.isArray(activePipeline.edges) ? activePipeline.edges : [];
const latestRun = runs[0] || null;
const 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);
return h("div", { className: "pipeline-page", "data-testid": "pipeline-page" },
h(Panel, {
title: "Pipeline v2 工作台",
eyebrow: "D601 Snapshot Microservice",
actions: h("div", { className: "panel-actions" },
h("button", { type: "button", className: "ghost-btn", onClick: load, disabled: state.loading, "data-testid": "pipeline-refresh-button" }, state.loading ? "刷新中" : "刷新"),
h(RawButton, { title: "Pipeline Microservice", data: service, onOpen: onRaw, testId: "raw-pipeline-service" }),
),
},
h("div", { className: "pipeline-hero" },
h("div", null,
h("div", { className: "node-version-line" },
h(StatusBadge, { status: runtime.providerStatus === "online" ? "online" : "warn" }, runtime.providerStatus || "unknown"),
h("span", null, service.providerId),
h("span", null, backend.public ? "公网暴露" : "仅 UniDesk frontend 代理访问"),
),
h("p", { className: "muted paragraph" }, service.description),
),
h("div", { className: "microservice-ref-card" },
h("span", null, "Repo"),
h("strong", null, repository.url || "--"),
h("code", null, repository.commitId || "--"),
),
h("div", { className: "microservice-ref-card" },
h("span", null, "D601 Docker"),
h("strong", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"}`),
h("code", null, `${repository.composeFile || "--"} / ${repository.composeService || "--"}`),
),
),
state.error ? h("div", { className: "form-error wide" }, state.error) : null,
),
h("div", { className: "pipeline-grid" },
h(Panel, { title: "观测指标", eyebrow: state.refreshedAt ? `Updated ${fmtClock(state.refreshedAt)}` : "Snapshot" },
h("div", { className: "metric-grid" },
h(MetricCard, { label: "Health", value: state.health?.ok ? "OK" : "--", hint: state.health?.service || "D601 /health", tone: state.health?.ok ? "ok" : "warn" }),
h(MetricCard, { label: "组件", value: componentCount, hint: "components registry", tone: snapshot?.registry?.ok === false ? "warn" : "ok" }),
h(MetricCard, { label: "Pipeline", value: pipelines.length, hint: `${pipelineNodes.length} nodes / ${pipelineEdges.length} edges` }),
h(MetricCard, { label: "运行记录", value: runCount, hint: `${statusCounts.succeeded || 0} succeeded / ${statusCounts.running || 0} running` }),
h(MetricCard, { label: "OA 记录", value: Array.isArray(latestRun?.submissions) ? latestRun.submissions.length : 0, hint: latestRun?.runId || "latest run" }),
h(MetricCard, { label: "Procedure", value: Array.isArray(latestRun?.procedureRuns) ? latestRun.procedureRuns.length : 0, hint: latestRun?.status || "no run" }),
),
h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "Pipeline Snapshot", data: snapshot, onOpen: onRaw, testId: "raw-pipeline-snapshot" })),
),
h(Panel, { title: "组件矩阵", eyebrow: `${componentClasses.length} classes` },
componentClasses.length === 0 ? h(EmptyState, { title: "暂无组件", text: "等待 D601 pipeline backend 返回 registry.components" }) :
h("div", { className: "component-strata" }, componentClasses.map((item) => h("article", { key: item.name, className: "component-stratum" },
h("span", null, item.name),
h("strong", null, item.count),
))),
h("div", { className: "pipeline-component-list" },
components.slice(0, 12).map((component: any) => h("span", { key: component.key, className: "data-chip" }, h("b", null, component.componentClass || "--"), h("span", null, component.id || component.key || "--"))),
),
),
h(Panel, { title: "控制图", eyebrow: `${activePipeline.id || "pipeline"} / latest run ${latestRun?.status || "--"}` },
pipelineNodes.length === 0 ? h(EmptyState, { title: "暂无控制图", text: "等待 D601 pipeline backend 返回 pipeline.nodes" }) :
h("div", { className: "table-wrap pipeline-node-table" }, h("table", null,
h("thead", null, h("tr", null, h("th", null, "节点"), h("th", null, "状态"), h("th", null, "类型"), h("th", null, "组件引用"), h("th", null, "输入摘要"))),
h("tbody", null, pipelineNodes.slice(0, 32).map((node: any) => h("tr", { key: node.id },
h("td", null, h("strong", null, node.id || "--")),
h("td", null, h(StatusBadge, { status: pipelineRunNodeStatus(latestRun, node.id) }, pipelineRunNodeStatus(latestRun, node.id))),
h("td", null, node.kind || "--"),
h("td", null, h("code", null, pipelineComponentRef(node.componentRef))),
h("td", null, h(DataSummary, { data: node.instanceInputs, empty: "无实例输入" })),
))),
)),
),
h(Panel, { title: "最近运行", eyebrow: `${runs.length}/${runCount} preview` },
runs.length === 0 ? h(EmptyState, { title: "暂无运行记录", text: "Pipeline .state/pipeline-runs 还没有可展示状态" }) :
h("div", { className: "pipeline-run-list" }, runs.map((run: any) => h("article", { key: run.runId, className: "pipeline-run-card" },
h("div", { className: "node-card-head" }, h("strong", null, run.runId || "--"), h(StatusBadge, { status: run.status }, run.status || "--")),
h("div", { className: "docker-meta compact" },
h("span", null, run.pipelineId || "--"),
h("span", null, `nodes ${Array.isArray(run.nodes) ? run.nodes.length : 0}`),
h("span", null, `oa ${Array.isArray(run.submissions) ? run.submissions.length : 0}`),
h("span", null, `procedures ${Array.isArray(run.procedureRuns) ? run.procedureRuns.length : 0}`),
),
h("p", { className: "muted paragraph" }, summarizeValue(run.task)),
h("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("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)),
),
h("div", { className: "panel-actions inline-actions" }, latestRun ? h(RawButton, { title: `Pipeline Run ${latestRun.runId}`, data: latestRun, onOpen: onRaw, testId: "raw-pipeline-run" }) : null),
),
),
);
}
function DispatchPage({ nodes, onDispatched, onRaw }: AnyRecord) {
const onlineNodes = nodes.filter((node: any) => node.status === "online");
const [providerId, setProviderId] = useState(onlineNodes[0]?.providerId || nodes[0]?.providerId || "");
@@ -1085,6 +1452,7 @@ function DispatchPage({ nodes, onDispatched, onRaw }: AnyRecord) {
h("label", null, "Command", h("select", { value: command, onChange: (event: any) => setCommand(event.target.value) },
h("option", { value: "docker.ps" }, "docker.ps"),
h("option", { value: "host.ssh" }, "host.ssh"),
h("option", { value: "microservice.http" }, "microservice.http"),
h("option", { value: "echo" }, "echo"),
)),
h("label", null, "来源", h("input", { value: source, onChange: (event: any) => setSource(event.target.value) })),
@@ -1275,6 +1643,9 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa
if (activeModule === "tasks" && activeTab === "pending") return h(TaskPendingPage, { tasks: data.pendingTasks, onRaw });
if (activeModule === "tasks" && activeTab === "history") return h(TaskHistoryPage, { tasks: data.tasks, onRaw });
if (activeModule === "tasks" && activeTab === "results") return h(TaskResultsPage, { tasks: data.tasks, onRaw });
if (activeModule === "apps" && activeTab === "catalog") return h(MicroserviceCatalogPage, { microservices: data.microservices, onRaw, onNavigate });
if (activeModule === "apps" && activeTab === "findjob") return h(FindJobPage, { microservices: data.microservices, onRaw });
if (activeModule === "apps" && activeTab === "pipeline") return h(PipelinePage, { microservices: data.microservices, onRaw });
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);
@@ -1283,8 +1654,8 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa
function Shell({ session, onLogout }: AnyRecord) {
const [activeModule, setActiveModule] = useState("ops");
const [activeTabs, setActiveTabs] = useState({ ops: "status", nodes: "list", tasks: "dispatch", config: "topology" });
const [data, setData] = useState({ overview: null, nodes: [], systemStatuses: [], dockerStatuses: [], events: [], tasks: [], pendingTasks: [], logs: [] });
const [activeTabs, setActiveTabs] = useState({ ops: "status", nodes: "list", tasks: "dispatch", apps: "catalog", config: "topology" });
const [data, setData] = useState({ overview: null, nodes: [], systemStatuses: [], dockerStatuses: [], microservices: [], events: [], tasks: [], pendingTasks: [], logs: [] });
const [connection, setConnection] = useState({ ok: false, text: "连接中" });
const [lastRefresh, setLastRefresh] = useState(null);
const [clock, setClock] = useState(new Date());
@@ -1295,11 +1666,12 @@ function Shell({ session, onLogout }: AnyRecord) {
async function refresh(): Promise<void> {
try {
const [overview, nodes, systemStatuses, dockerStatuses, events, tasks, pendingTasks, logs] = await Promise.all([
const [overview, nodes, systemStatuses, dockerStatuses, microservices, events, tasks, pendingTasks, logs] = await Promise.all([
requestJson(`${cfg.apiBaseUrl}/overview`),
requestJson(`${cfg.apiBaseUrl}/nodes`),
requestJson(`${cfg.apiBaseUrl}/nodes/system-status?limit=120`),
requestJson(`${cfg.apiBaseUrl}/nodes/docker-status`),
requestJson(`${cfg.apiBaseUrl}/microservices`),
requestJson(`${cfg.apiBaseUrl}/events?limit=100`),
requestJson(`${cfg.apiBaseUrl}/tasks?limit=300`),
requestJson(`${cfg.apiBaseUrl}/tasks?status=pending&limit=100`),
@@ -1310,6 +1682,7 @@ function Shell({ session, onLogout }: AnyRecord) {
nodes: nodes.nodes || [],
systemStatuses: systemStatuses.systemStatuses || [],
dockerStatuses: dockerStatuses.dockerStatuses || [],
microservices: microservices.microservices || [],
events: events.events || [],
tasks: tasks.tasks || [],
pendingTasks: pendingTasks.tasks || [],