feat(microservices): manage code queue through v3s
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" },
|
||||
] },
|
||||
|
||||
@@ -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/services;Code Queue 切换后这里应显示 D601 和 D518 两个实例。" }))
|
||||
: services.map((service) => renderManagedService(service, onRaw)),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user