feat(microservices): manage code queue through v3s

This commit is contained in:
Codex
2026-05-15 11:54:41 +00:00
parent c334c4f082
commit 00add260e3
42 changed files with 2010 additions and 354 deletions
File diff suppressed because one or more lines are too long
+200
View File
@@ -6290,3 +6290,203 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
.notification-banner-dismiss:hover {
background: rgba(255,255,255,0.3);
}
.v3s-page {
display: grid;
gap: 14px;
}
.v3s-hero-panel {
position: relative;
overflow: hidden;
border-color: rgba(215, 161, 58, 0.34);
background:
radial-gradient(circle at 88% 18%, rgba(215, 161, 58, 0.16), transparent 30%),
radial-gradient(circle at 12% 84%, rgba(78, 183, 168, 0.13), transparent 34%),
linear-gradient(135deg, rgba(255,255,255,0.045), rgba(255,255,255,0.012));
}
.v3s-hero {
display: grid;
grid-template-columns: 128px minmax(0, 1fr);
align-items: center;
gap: 18px;
margin-bottom: 16px;
}
.v3s-orb {
position: relative;
display: grid;
place-items: center;
width: 118px;
height: 118px;
border: 1px solid rgba(215, 161, 58, 0.5);
border-radius: 999px;
background:
repeating-conic-gradient(from 12deg, rgba(215,161,58,0.22) 0 8deg, transparent 8deg 19deg),
radial-gradient(circle, rgba(78,183,168,0.22), rgba(0,0,0,0.18) 62%);
box-shadow: 0 0 34px rgba(78,183,168,0.18), inset 0 0 24px rgba(0,0,0,0.32);
text-transform: uppercase;
letter-spacing: 0.22em;
color: var(--accent);
font-weight: 800;
}
.v3s-orb::before,
.v3s-orb::after {
content: "";
position: absolute;
inset: 12px;
border: 1px dashed rgba(78,183,168,0.38);
border-radius: inherit;
}
.v3s-orb::after {
inset: 28px;
border-style: solid;
border-color: rgba(255,255,255,0.12);
}
.v3s-hero-copy h2 {
max-width: 860px;
margin: 0 0 8px;
color: var(--text);
font-size: 22px;
line-height: 1.2;
letter-spacing: 0.08em;
text-transform: none;
}
.v3s-route-strip {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.v3s-route-strip span {
padding: 5px 8px;
border: 1px solid rgba(215,161,58,0.5);
border-radius: 999px;
background: rgba(215,161,58,0.12);
color: var(--accent);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.16em;
}
.v3s-route-strip code {
max-width: 100%;
padding: 5px 8px;
border: 1px solid var(--line-soft);
border-radius: 999px;
background: rgba(0,0,0,0.24);
color: var(--text);
overflow-wrap: anywhere;
white-space: normal;
}
.v3s-control-plane-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 12px;
}
.v3s-control-plane-card {
min-height: 96px;
padding: 12px;
border: 1px solid rgba(255,255,255,0.1);
background: linear-gradient(135deg, rgba(0,0,0,0.22), rgba(78,183,168,0.07));
}
.v3s-control-plane-card span {
color: var(--muted);
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.v3s-control-plane-card strong {
display: block;
margin-top: 8px;
color: var(--text);
font-size: 17px;
letter-spacing: 0.08em;
}
.v3s-control-plane-card p {
margin: 8px 0 0;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.v3s-service-panel {
border-color: rgba(78,183,168,0.25);
}
.v3s-service-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.v3s-service-summary > div,
.v3s-instance-card {
border: 1px solid var(--line-soft);
background: rgba(0,0,0,0.18);
}
.v3s-service-summary > div {
display: grid;
gap: 6px;
min-height: 66px;
padding: 10px;
}
.v3s-service-summary span,
.v3s-kv dt {
color: var(--muted);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.v3s-service-summary strong {
color: var(--text);
font-size: 18px;
}
.v3s-instance-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 10px;
}
.v3s-instance-card {
padding: 12px;
border-left: 3px solid rgba(78,183,168,0.7);
}
.v3s-instance-role {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 8px 0 10px;
color: var(--accent);
font-size: 11px;
letter-spacing: 0.15em;
}
.v3s-kv {
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
gap: 6px 10px;
margin: 0;
}
.v3s-kv dd {
min-width: 0;
margin: 0;
color: var(--text);
overflow-wrap: anywhere;
}
.v3s-kv code {
white-space: normal;
}
@media (max-width: 780px) {
.v3s-hero {
grid-template-columns: 1fr;
}
.v3s-orb {
width: 96px;
height: 96px;
}
.v3s-service-summary {
grid-template-columns: 1fr 1fr;
}
.v3s-control-plane-grid {
grid-template-columns: 1fr;
}
}
+14 -4
View File
@@ -13,6 +13,7 @@ import { PipelinePage } from "./pipeline";
import { ProjectManagerPage } from "./project-manager";
import { TodoNotePage } from "./todo-note";
import { TopStatusBar } from "./top-status";
import { V3sCtlPage } from "./v3sctl";
import { LoadingTitle } from "./loading-indicator";
import { errorMessage, requestJson } from "./unidesk-error";
import { UniDeskErrorBanner } from "./unidesk-error-banner";
@@ -44,14 +45,21 @@ const fastCodeQueueService = {
name: "Code Queue",
providerId: "D601",
description: "Code Queue",
repository: { containerName: "code-queue-backend" },
repository: { containerName: "v3s:code-queue" },
backend: {
nodeBaseUrl: "http://host.docker.internal:4222",
nodeBindHost: "127.0.0.1",
nodeBaseUrl: "v3s://code-queue",
nodeBindHost: "v3s://unidesk/code-queue",
nodePort: 4222,
proxyMode: "v3sctl-adapter-http",
public: false,
},
deployment: {
mode: "v3sctl-managed",
adapterServiceId: "v3sctl-adapter",
v3sServiceId: "code-queue",
},
runtime: {
orchestrator: "v3sctl",
providerStatus: "loading",
providerName: "D601",
},
@@ -1640,6 +1648,7 @@ function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord
service.id === "claudeqq" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "claudeqq"), "data-testid": "open-claudeqq-button" }, "打开") : null,
service.id === "baidu-netdisk" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "baidu-netdisk"), "data-testid": "open-baidu-netdisk-button" }, "打开") : null,
service.id === "oa-event-flow" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "oa-event-flow"), "data-testid": "open-oa-event-flow-button" }, "打开") : null,
service.id === "v3sctl-adapter" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "v3sctl"), "data-testid": "open-v3sctl-button" }, "打开") : null,
service.id === "code-queue" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "code-queue"), "data-testid": "open-code-queue-button" }, "打开") : null,
service.id === "project-manager" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "project-manager"), "data-testid": "open-project-manager-button" }, "打开") : null,
h(RawButton, { title: `用户服务 ${service.id}`, data: service, onOpen: onRaw }),
@@ -2146,6 +2155,7 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa
if (activeModule === "apps" && activeTab === "baidu-netdisk") return h(BaiduNetdiskPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "filebrowser") return h(FileBrowserPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "oa-event-flow") return h(OaEventFlowPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "v3sctl") return h(V3sCtlPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl, onNavigate });
if (activeModule === "apps" && activeTab === "code-queue") return h(CodeQueuePage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl, initialTasksData: initialCodeQueueOverview });
if (activeModule === "apps" && activeTab === "project-manager") return h(ProjectManagerPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "config" && activeTab === "topology") return h(TopologyPage, { data });
@@ -2175,7 +2185,7 @@ function Shell({ session, onLogout }: AnyRecord) {
: microservices;
const effectiveData = effectiveMicroservices === microservices ? data : { ...data, microservices: effectiveMicroservices };
const activeService = activeModule === "apps"
? effectiveMicroservices.find((service: any) => String(service?.id || "") === activeTab)
? effectiveMicroservices.find((service: any) => String(service?.id || "") === (activeTab === "v3sctl" ? "v3sctl-adapter" : activeTab))
: null;
const activeServiceRuntime = activeService ? microserviceRuntime(activeService) : {};
const activeTabTitle = module.tabs.find((tab: any) => tab.id === activeTab)?.label || activeTab;
@@ -30,15 +30,22 @@ const standaloneCodeQueueService = {
id: "code-queue",
name: "Code Queue",
providerId: "D601",
description: "Code Queue 独立入口,使用 summary 首屏和按需全量加载保持信息完整。",
repository: { containerName: "code-queue-backend" },
description: "Code Queue 独立入口,使用 v3sctl-adapter 单一路径访问 active service。",
repository: { containerName: "v3s:code-queue" },
backend: {
nodeBaseUrl: "http://host.docker.internal:4222",
nodeBindHost: "127.0.0.1",
nodeBaseUrl: "v3s://code-queue",
nodeBindHost: "v3s://unidesk/code-queue",
nodePort: 4222,
proxyMode: "v3sctl-adapter-http",
public: false,
},
deployment: {
mode: "v3sctl-managed",
adapterServiceId: "v3sctl-adapter",
v3sServiceId: "code-queue",
},
runtime: {
orchestrator: "v3sctl",
providerStatus: "online",
providerName: "D601",
},
+1 -1
View File
@@ -148,7 +148,7 @@ function microserviceBackend(service: any): AnyRecord {
}
function codexApi(apiBaseUrl: string, path: string): string {
return `${apiBaseUrl}/code-queue-direct${path}`;
return `${apiBaseUrl}/microservices/code-queue/proxy${path}`;
}
function oaCodeQueueEventsApi(apiBaseUrl: string): string {
-161
View File
@@ -66,58 +66,6 @@ const baiduNetdiskDocsFallbackLinks = [
{ href: "/docs/reference/deployment.md", section: "配置与文档 DEPLOY", label: "UniDesk 部署与重建流程" },
{ href: "/docs/reference/cli.md", section: "配置与文档 CLI", label: "UniDesk CLI 验证命令" },
];
const codeQueueOverviewCache = new Map<string, { at: number; payload: JsonValue; text: string }>();
const codeQueueOverviewRefreshes = new Map<string, Promise<JsonValue | null>>();
const codeQueueOverviewCacheTtlMs = 10_000;
const defaultCodeQueueOverviewPath = "/api/tasks/overview?limit=24&transcriptLimit=3&compact=1&afterSeq=0&preferId=";
function codeQueueOverviewCacheKey(pathWithQuery: string): string {
return pathWithQuery;
}
function cachedCodeQueueOverview(pathWithQuery: string, maxAgeMs = codeQueueOverviewCacheTtlMs): { payload: JsonValue; text: string } | null {
const cached = codeQueueOverviewCache.get(codeQueueOverviewCacheKey(pathWithQuery));
if (!cached || Date.now() - cached.at > maxAgeMs) return null;
return { payload: cached.payload, text: cached.text };
}
function invalidateCodeQueueOverviewCache(): void {
codeQueueOverviewCache.clear();
}
async function refreshCodeQueueOverview(pathWithQuery: string, timeoutMs = 800): Promise<JsonValue | null> {
const existing = codeQueueOverviewRefreshes.get(pathWithQuery);
if (existing !== undefined) return existing;
const refresh = refreshCodeQueueOverviewUncached(pathWithQuery, timeoutMs)
.finally(() => {
codeQueueOverviewRefreshes.delete(pathWithQuery);
});
codeQueueOverviewRefreshes.set(pathWithQuery, refresh);
return refresh;
}
async function refreshCodeQueueOverviewUncached(pathWithQuery: string, timeoutMs = 800): Promise<JsonValue | null> {
const started = performance.now();
try {
const upstreamUrl = new URL(`/api/microservices/code-queue/proxy${pathWithQuery}`, config.coreInternalUrl);
const response = await fetch(upstreamUrl, {
signal: AbortSignal.timeout(timeoutMs),
headers: { accept: "application/json" },
});
const text = await response.text();
const payload = text ? JSON.parse(text) as unknown : null;
const ok = response.ok && typeof payload === "object" && payload !== null;
recordOperationPerformance("frontend", "code_queue_initial_overview", performance.now() - started, ok, pathWithQuery);
if (!ok) return null;
codeQueueOverviewCache.set(codeQueueOverviewCacheKey(pathWithQuery), { at: Date.now(), payload: payload as JsonValue, text });
return payload as JsonValue;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
recordOperationPerformance("frontend", "code_queue_initial_overview", performance.now() - started, false, message);
return null;
}
}
function renderIndexHtml(extraRootAttributes = ""): string {
const docsFallback = `<nav hidden data-page="baidu-netdisk" data-section="配置与文档">${baiduNetdiskDocsFallbackLinks.map((link) => {
const href = new URL(link.href, config.frontendPublicUrl).toString();
@@ -143,7 +91,6 @@ async function spaShellHtml(req: Request, pathname: string): Promise<string> {
return renderIndexHtml();
}
refreshCodeQueueOverview(defaultCodeQueueOverviewPath, 2_000).catch(() => undefined);
const requestPerformanceSamples: RequestPerformanceSample[] = [];
const operationPerformanceSamples: OperationPerformanceSample[] = [];
const maxPerformanceSamples = 3000;
@@ -697,113 +644,6 @@ function sessionResponse(req: Request): Response {
return jsonResponse({ ok: true, authenticated: true, user: { username: session.username }, expiresAt: new Date(session.expiresAt).toISOString() });
}
async function proxyCodeQueueDirect(req: Request, url: URL): Promise<Response> {
if (sessionFromRequest(req) === null) {
return jsonResponse({ ok: false, error: "authentication required" }, 401);
}
const prefix = "/api/code-queue-direct";
const suffix = url.pathname === prefix ? "/" : `/${url.pathname.slice(prefix.length).replace(/^\/+/u, "")}`;
if (!(suffix === "/health" || suffix.startsWith("/api/"))) {
return jsonResponse({ ok: false, error: "code-queue direct path is not allowed", path: suffix }, 403);
}
const overviewCacheKey = `${suffix}${url.search}`;
const canUseOverviewCache = req.method === "GET" && suffix === "/api/tasks/overview";
const cacheControl = req.headers.get("cache-control") || "";
const bypassOverviewCache = /\bno-cache\b|\bno-store\b/iu.test(cacheControl) || req.headers.get("x-unidesk-no-cache") === "1";
const expectsJsonResponse = suffix === "/health" || suffix.startsWith("/api/");
if (req.method !== "GET" && req.method !== "HEAD") invalidateCodeQueueOverviewCache();
if (canUseOverviewCache && !bypassOverviewCache) {
const cached = cachedCodeQueueOverview(overviewCacheKey);
if (cached !== null) {
recordOperationPerformance("frontend", "code_queue_direct_proxy_cache", 0, true, overviewCacheKey);
return new Response(cached.text, { headers: { "content-type": "application/json; charset=utf-8" } });
}
}
const upstreamUrl = new URL(`/api/microservices/code-queue/proxy${suffix}${url.search}`, config.coreInternalUrl);
const headers = new Headers(req.headers);
headers.delete("host");
headers.delete("connection");
headers.delete("content-length");
headers.delete("cookie");
const init: RequestInit = { method: req.method, headers, redirect: "manual", signal: req.signal };
if (req.method !== "GET" && req.method !== "HEAD") {
init.body = await req.arrayBuffer();
}
const started = performance.now();
try {
const upstream = await fetch(upstreamUrl, init);
recordOperationPerformance("frontend", "code_queue_direct_proxy", performance.now() - started, upstream.ok, `${req.method} ${suffix}`);
const responseHeaders = new Headers();
const upstreamContentType = upstream.headers.get("content-type");
if (upstreamContentType !== null) responseHeaders.set("content-type", upstreamContentType);
const upstreamBody = await upstream.arrayBuffer();
const isJsonResponse = (upstreamContentType ?? "").toLowerCase().includes("json");
let parsedJson: unknown = null;
let jsonText = "";
if (expectsJsonResponse) {
jsonText = new TextDecoder().decode(upstreamBody);
if (!isJsonResponse) {
logger("warn", "code_queue_direct_proxy_non_json", {
path: suffix,
upstreamUrl: upstreamUrl.toString(),
status: upstream.status,
contentType: upstreamContentType,
bodyBytes: upstreamBody.byteLength,
preview: safePreview(jsonText),
});
return jsonResponse({
ok: false,
error: {
message: "code-queue upstream returned non-JSON response",
status: upstream.status,
contentType: upstreamContentType,
bodyBytes: upstreamBody.byteLength,
preview: safePreview(jsonText),
},
}, 502);
}
try {
parsedJson = jsonText ? JSON.parse(jsonText) as unknown : null;
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger("warn", "code_queue_direct_proxy_invalid_json", {
path: suffix,
upstreamUrl: upstreamUrl.toString(),
status: upstream.status,
bodyBytes: upstreamBody.byteLength,
error: detail,
preview: safePreview(jsonText),
});
return jsonResponse({
ok: false,
error: {
message: "code-queue upstream returned invalid JSON",
detail,
status: upstream.status,
bodyBytes: upstreamBody.byteLength,
contentType: upstreamContentType,
preview: safePreview(jsonText),
},
}, 502);
}
}
if (expectsJsonResponse) {
const text = jsonText;
if (canUseOverviewCache && !bypassOverviewCache && upstream.ok && typeof parsedJson === "object" && parsedJson !== null) {
codeQueueOverviewCache.set(codeQueueOverviewCacheKey(overviewCacheKey), { at: Date.now(), payload: parsedJson as JsonValue, text });
}
return new Response(text, { status: upstream.status, headers: responseHeaders });
}
responseHeaders.set("content-length", String(upstreamBody.byteLength));
return new Response(upstreamBody, { status: upstream.status, headers: responseHeaders });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
recordOperationPerformance("frontend", "code_queue_direct_proxy", performance.now() - started, false, message);
logger("warn", "code_queue_direct_proxy_failed", { path: suffix, upstreamUrl: upstreamUrl.toString(), error: message });
return jsonResponse({ ok: false, error: { message: "code-queue direct proxy failed", detail: message } }, 502);
}
}
async function proxyApi(req: Request, url: URL): Promise<Response> {
if (sessionFromRequest(req) === null) {
return jsonResponse({ ok: false, error: "authentication required" }, 401);
@@ -877,7 +717,6 @@ async function handleRequest(req: Request): Promise<Response> {
if (url.pathname === "/logout" && req.method === "POST") return logout();
if (url.pathname === "/api/session") return sessionResponse(req);
if (url.pathname === "/api/frontend-performance") return frontendPerformanceResponse(req);
if (url.pathname === "/api/code-queue-direct" || url.pathname.startsWith("/api/code-queue-direct/")) return proxyCodeQueueDirect(req, url);
if (url.pathname.startsWith("/api/") || url.pathname === "/logs") return proxyApi(req, url);
if (url.pathname === "/docs" || url.pathname.startsWith("/docs/")) return docsResponse(req, url);
if (url.pathname === "/" || url.pathname === "/index.html") {
@@ -70,6 +70,7 @@ export const MODULES: UniDeskModuleDefinition[] = [
{ id: "baidu-netdisk", label: "Baidu Netdisk" },
{ id: "filebrowser", label: "File Browser" },
{ id: "oa-event-flow", label: "OA Event Flow" },
{ id: "v3sctl", label: "V3S Control" },
{ id: "code-queue", label: "Code Queue" },
{ id: "project-manager", label: "Project Manager" },
] },
+216
View File
@@ -0,0 +1,216 @@
import React from "react";
import { fmtClock, fmtDate } from "./time";
import { LoadingTitle } from "./loading-indicator";
import { errorMessage, requestJson } from "./unidesk-error";
import { UniDeskErrorBanner } from "./unidesk-error-banner";
type AnyRecord = Record<string, any>;
const h = React.createElement;
const { useEffect, useMemo } = React;
const useState: any = React.useState;
function StatusBadge({ status, children, title }: AnyRecord) {
const normalized = String(status || "unknown").toLowerCase();
return h("span", { className: `status-badge ${normalized}`, title }, 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, loading }: AnyRecord) {
return h("section", { className: `panel ${className || ""}` },
h("div", { className: "panel-head" },
h("div", null,
eyebrow ? h("p", { className: "panel-eyebrow" }, eyebrow) : null,
h(LoadingTitle, { title, loading }),
),
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 asArray(value: any): any[] {
return Array.isArray(value) ? value : [];
}
function objectRecord(value: any): AnyRecord {
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
}
function microserviceRuntime(service: any): AnyRecord {
return service?.runtime && typeof service.runtime === "object" && !Array.isArray(service.runtime) ? service.runtime : {};
}
function proxy(apiBaseUrl: string, path: string): string {
return `${apiBaseUrl}/microservices/v3sctl-adapter/proxy${path}`;
}
function findAdapter(microservices: any[]): any | null {
return microservices.find((service) => String(service?.id || "") === "v3sctl-adapter") || null;
}
function instanceTone(instance: any): string {
if (instance?.healthy === true) return "online";
if (String(instance?.role || "") === "standby") return "warn";
return "failed";
}
function serviceTone(service: any): string {
return service?.healthy === true ? "online" : "failed";
}
function fmtBool(value: any): string {
if (value === true) return "YES";
if (value === false) return "NO";
return "--";
}
function clusterNodes(services: any[]): string[] {
return Array.from(new Set(services.flatMap((service) => asArray(service?.expectedNodeIds).map((node) => String(node))))).filter(Boolean).sort();
}
function primaryInstance(services: any[]): string {
const codeQueue = services.find((service) => service?.id === "code-queue") || services[0];
return String(codeQueue?.activeInstanceId || "--");
}
function renderInstanceCard(instance: any) {
return h("article", { key: instance?.id || instance?.nodeId, className: "v3s-instance-card" },
h("div", { className: "node-card-head" },
h("strong", null, instance?.nodeId || instance?.id || "--"),
h(StatusBadge, { status: instanceTone(instance) }, instance?.healthy ? "HEALTHY" : "DEGRADED"),
),
h("div", { className: "v3s-instance-role" },
h("span", null, String(instance?.role || "worker").toUpperCase()),
h("code", null, instance?.id || "--"),
),
h("dl", { className: "v3s-kv" },
h("dt", null, "Base URL"), h("dd", null, h("code", null, instance?.baseUrl || "--")),
h("dt", null, "Proxy"), h("dd", null, instance?.proxyMode || "--"),
h("dt", null, "Health"), h("dd", null, `${instance?.upstreamStatus ?? "--"} / ${instance?.status || "unknown"}`),
h("dt", null, "Checked"), h("dd", null, fmtDate(instance?.checkedAt)),
),
);
}
function renderManagedService(service: any, onRaw: any) {
const instances = asArray(service?.instances);
const active = objectRecord(service?.active);
return h(Panel, {
key: service?.id || "service",
title: service?.id || "managed-service",
eyebrow: `${service?.namespace || "unidesk"} / v3s managed service`,
className: "v3s-service-panel",
actions: h(RawButton, { title: `v3s service ${service?.id || ""}`, data: service, onOpen: onRaw, testId: `raw-v3s-service-${service?.id || "unknown"}` }),
},
h("div", { className: "v3s-service-summary" },
h("div", null, h("span", null, "状态"), h(StatusBadge, { status: serviceTone(service) }, service?.status || "unknown")),
h("div", null, h("span", null, "Active"), h("strong", null, service?.activeInstanceId || "--")),
h("div", null, h("span", null, "Single Writer"), h("strong", null, fmtBool(service?.singleWriter))),
h("div", null, h("span", null, "Active Health"), h("strong", null, active?.upstreamStatus ?? "--")),
),
instances.length === 0
? h(EmptyState, { title: "暂无 v3s 实例", text: "adapter 没有返回该服务的 endpoint 列表" })
: h("div", { className: "v3s-instance-grid" }, instances.map(renderInstanceCard)),
);
}
export function V3sCtlPage({ microservices, onRaw, apiBaseUrl, onNavigate }: AnyRecord) {
const adapter = findAdapter(Array.isArray(microservices) ? microservices : []);
const runtime = microserviceRuntime(adapter);
const [state, setState] = useState({ loading: false, error: "", data: null as any, refreshedAt: null as Date | null });
async function load(): Promise<void> {
setState((previous: any) => ({ ...previous, loading: true, error: "" }));
try {
const data = await requestJson(proxy(apiBaseUrl, "/api/control-plane"));
setState({ loading: false, error: "", data, refreshedAt: new Date() });
} catch (err) {
setState((previous: any) => ({ ...previous, loading: false, error: errorMessage(err, "加载 v3s 控制平面失败") }));
}
}
useEffect(() => { void load(); }, [apiBaseUrl]);
const services = useMemo(() => asArray(state.data?.services), [state.data]);
const nodes = clusterNodes(services);
const healthyServices = services.filter((service) => service?.healthy === true).length;
const totalInstances = services.reduce((sum, service) => sum + asArray(service?.instances).length, 0);
const healthyInstances = services.reduce((sum, service) => sum + asArray(service?.instances).filter((instance) => instance?.healthy === true).length, 0);
const primary = primaryInstance(services);
const kubectl = objectRecord(state.data?.kubectl);
const kubeProxy = objectRecord(state.data?.kubeApiProxy);
const manifestPaths = asArray(state.data?.manifestPaths).map((item) => String(item));
if (!adapter) return h(EmptyState, { title: "v3sctl-adapter 未登记", text: "请在 config.json 的 microservices 中登记 id=v3sctl-adapter,并通过该微服务连接 v3s 控制平面。" });
return h("div", { className: "v3s-page", "data-testid": "v3sctl-page" },
h(Panel, {
title: "V3S Control Plane",
eyebrow: "Managed by v3sctl-adapter",
className: "v3s-hero-panel",
loading: state.loading,
actions: h(React.Fragment, null,
h("button", { type: "button", className: "ghost-btn", onClick: load, disabled: state.loading, "data-testid": "v3s-refresh-button" }, state.loading ? "刷新中" : "刷新"),
onNavigate ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "code-queue"), "data-testid": "v3s-open-code-queue" }, "打开 Code Queue") : null,
h(RawButton, { title: "v3sctl-adapter microservice", data: adapter, onOpen: onRaw, testId: "raw-v3s-adapter" }),
),
},
h("div", { className: "v3s-hero" },
h("div", { className: "v3s-orb", "aria-hidden": "true" }, h("span", null, "v3s")),
h("div", { className: "v3s-hero-copy" },
h("p", { className: "eyebrow" }, "D601 control plane / D518 managed node"),
h("h2", null, "UniDesk 只管理 adapter;业务微服务交给 v3s 标准服务路由"),
h("p", { className: "muted paragraph" }, "Code Queue 的前端/API 请求进入 v3sctl-adapter,再由 adapter 转发到 v3s active service。provider-gateway 只用于维护 adapter 和节点诊断,不再直接管理 Code Queue 容器。"),
h("div", { className: "v3s-route-strip" },
h("span", null, "NO FALLBACK"),
h("code", null, state.data?.runtimePath || "frontend -> backend-core -> v3sctl-adapter"),
),
),
),
h("div", { className: "metric-grid" },
h(MetricCard, { label: "控制面", value: state.data?.clusterId || "D601", hint: `adapter ${runtime.providerStatus || "unknown"}`, tone: runtime.providerStatus === "online" ? "ok" : "warn" }),
h(MetricCard, { label: "代管服务", value: services.length, hint: `${healthyServices}/${services.length || 0} healthy`, tone: healthyServices === services.length && services.length > 0 ? "ok" : "warn" }),
h(MetricCard, { label: "节点", value: nodes.join(" / ") || "--", hint: "expected v3s nodes" }),
h(MetricCard, { label: "实例", value: `${healthyInstances}/${totalInstances}`, hint: `active ${primary}`, tone: healthyInstances === totalInstances && totalInstances > 0 ? "ok" : "warn" }),
),
h("div", { className: "v3s-control-plane-grid" },
h("article", { className: "v3s-control-plane-card" },
h("span", null, "service proxy"),
h("strong", null, kubeProxy.configured === true ? "K8S API PROXY" : "PROXY DEGRADED"),
h("p", null, kubeProxy.configured === true ? `${kubeProxy.mode || "kubernetes-api-service-proxy"} via ${kubeProxy.connectHost || "--"}` : "adapter 必须通过 k8s API service proxy 访问业务服务,不回退到业务容器直连。"),
),
h("article", { className: "v3s-control-plane-card" },
h("span", null, "manifests"),
h("strong", null, manifestPaths.length || "--"),
h("p", null, manifestPaths.join(" / ") || "未配置 manifest"),
),
h("article", { className: "v3s-control-plane-card" },
h("span", null, "cluster snapshot"),
h("strong", null, kubectl.enabled === true ? (kubectl.ok === true ? "KUBECTL OK" : "KUBECTL DEGRADED") : "API ONLY"),
h("p", null, kubectl.enabled === true ? `nodes ${kubectl.nodeCount ?? "--"}` : "控制面页面以 adapter 返回的 k8s service proxy 状态为准;kubectl 只作为可选快照。"),
),
),
state.error ? h(UniDeskErrorBanner, { error: state.error }) : null,
state.refreshedAt ? h("p", { className: "muted paragraph" }, `最近刷新 ${fmtClock(state.refreshedAt)}`) : null,
),
services.length === 0
? h(Panel, { title: "代管服务", eyebrow: "v3s services", loading: state.loading }, h(EmptyState, { title: "暂无 v3s 服务", text: "等待 v3sctl-adapter 返回 /api/servicesCode Queue 切换后这里应显示 D601 和 D518 两个实例。" }))
: services.map((service) => renderManagedService(service, onRaw)),
);
}