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
+291 -16
View File
@@ -32,9 +32,45 @@ interface RuntimeConfig {
providerToken: string;
heartbeatTimeoutMs: number;
taskPendingTimeoutMs: number;
microservices: MicroserviceConfig[];
logFile: string;
}
interface MicroserviceConfig {
id: string;
name: string;
providerId: string;
description: string;
repository: {
url: string;
commitId: string;
dockerfile: string;
composeFile: string;
composeService: string;
containerName: string;
};
backend: {
nodeBaseUrl: string;
nodeBindHost: string;
nodePort: number;
proxyMode: string;
frontendOnly: boolean;
public: boolean;
allowedPathPrefixes: string[];
healthPath: string;
timeoutMs: number;
};
development: {
providerId: string;
sshPassthrough: boolean;
worktreePath: string;
};
frontend: {
route: string;
integrated: boolean;
};
}
interface WsData {
providerId?: string;
channel?: "provider" | "ssh";
@@ -86,6 +122,88 @@ function readOptionalNumberEnv(name: string, fallback: number): number {
return parsed;
}
function asRecord(value: unknown, name: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${name} must be an object`);
return value as Record<string, unknown>;
}
function stringFromRecord(value: Record<string, unknown>, key: string, path: string): string {
const field = value[key];
if (typeof field !== "string" || field.length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
return field;
}
function numberFromRecord(value: Record<string, unknown>, key: string, path: string): number {
const field = value[key];
if (typeof field !== "number" || !Number.isFinite(field) || field <= 0) throw new Error(`${path}.${key} must be a positive number`);
return field;
}
function booleanFromRecord(value: Record<string, unknown>, key: string, path: string): boolean {
const field = value[key];
if (typeof field !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
return field;
}
function stringArrayFromRecord(value: Record<string, unknown>, key: string, path: string): string[] {
const field = value[key];
if (!Array.isArray(field) || field.some((item) => typeof item !== "string" || item.length === 0)) {
throw new Error(`${path}.${key} must be an array of non-empty strings`);
}
return field as string[];
}
function parseMicroserviceConfig(value: unknown, index: number): MicroserviceConfig {
const path = `MICROSERVICES_JSON[${index}]`;
const item = asRecord(value, path);
const repository = asRecord(item.repository, `${path}.repository`);
const backend = asRecord(item.backend, `${path}.backend`);
const development = asRecord(item.development, `${path}.development`);
const frontend = asRecord(item.frontend, `${path}.frontend`);
return {
id: stringFromRecord(item, "id", path),
name: stringFromRecord(item, "name", path),
providerId: stringFromRecord(item, "providerId", path),
description: stringFromRecord(item, "description", path),
repository: {
url: stringFromRecord(repository, "url", `${path}.repository`),
commitId: stringFromRecord(repository, "commitId", `${path}.repository`),
dockerfile: stringFromRecord(repository, "dockerfile", `${path}.repository`),
composeFile: stringFromRecord(repository, "composeFile", `${path}.repository`),
composeService: stringFromRecord(repository, "composeService", `${path}.repository`),
containerName: stringFromRecord(repository, "containerName", `${path}.repository`),
},
backend: {
nodeBaseUrl: stringFromRecord(backend, "nodeBaseUrl", `${path}.backend`),
nodeBindHost: stringFromRecord(backend, "nodeBindHost", `${path}.backend`),
nodePort: numberFromRecord(backend, "nodePort", `${path}.backend`),
proxyMode: stringFromRecord(backend, "proxyMode", `${path}.backend`),
frontendOnly: booleanFromRecord(backend, "frontendOnly", `${path}.backend`),
public: booleanFromRecord(backend, "public", `${path}.backend`),
allowedPathPrefixes: stringArrayFromRecord(backend, "allowedPathPrefixes", `${path}.backend`),
healthPath: stringFromRecord(backend, "healthPath", `${path}.backend`),
timeoutMs: numberFromRecord(backend, "timeoutMs", `${path}.backend`),
},
development: {
providerId: stringFromRecord(development, "providerId", `${path}.development`),
sshPassthrough: booleanFromRecord(development, "sshPassthrough", `${path}.development`),
worktreePath: stringFromRecord(development, "worktreePath", `${path}.development`),
},
frontend: {
route: stringFromRecord(frontend, "route", `${path}.frontend`),
integrated: booleanFromRecord(frontend, "integrated", `${path}.frontend`),
},
};
}
function readMicroservicesEnv(): MicroserviceConfig[] {
const raw = process.env.MICROSERVICES_JSON;
if (raw === undefined || raw.length === 0) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) throw new Error("MICROSERVICES_JSON must be an array");
return parsed.map(parseMicroserviceConfig);
}
function readConfig(): RuntimeConfig {
return {
port: readNumberEnv("PORT"),
@@ -94,6 +212,7 @@ function readConfig(): RuntimeConfig {
providerToken: requiredEnv("PROVIDER_TOKEN"),
heartbeatTimeoutMs: readNumberEnv("HEARTBEAT_TIMEOUT_MS"),
taskPendingTimeoutMs: readOptionalNumberEnv("TASK_PENDING_TIMEOUT_MS", 10 * 60 * 1000),
microservices: readMicroservicesEnv(),
logFile: requiredEnv("LOG_FILE"),
};
}
@@ -735,20 +854,54 @@ async function getOverview(): Promise<JsonValue> {
};
}
async function dispatchTask(req: Request): Promise<Response> {
const body = (await req.json()) as { providerId?: unknown; command?: unknown; payload?: unknown };
const providerId = typeof body.providerId === "string" ? body.providerId : "";
const command = isProviderDispatchCommand(body.command) ? body.command : null;
const payload = typeof body.payload === "object" && body.payload !== null ? (body.payload as Record<string, JsonValue>) : {};
if (!providerId) {
return jsonResponse({ ok: false, error: "providerId is required" }, 400);
}
if (command === null) {
return jsonResponse({ ok: false, error: "command must be one of docker.ps, provider.upgrade, host.ssh, echo" }, 400);
}
if (command === "host.ssh" && !(await providerSupports(providerId, "host.ssh"))) {
return jsonResponse({ ok: false, error: `provider does not declare host.ssh capability: ${providerId}` }, 409);
}
function microserviceById(id: string): MicroserviceConfig | null {
return config.microservices.find((service) => service.id === id) ?? null;
}
function dockerStatusRecord(status: JsonValue | null): Record<string, unknown> {
return typeof status === "object" && status !== null && !Array.isArray(status) ? status as Record<string, unknown> : {};
}
function findContainer(status: JsonValue | null, containerName: string): JsonValue | null {
const containers = dockerStatusRecord(status).containers;
if (!Array.isArray(containers)) return null;
const container = containers.find((item) => {
if (typeof item !== "object" || item === null || Array.isArray(item)) return false;
return (item as Record<string, unknown>).name === containerName;
});
return container === undefined ? null : container as JsonValue;
}
async function getMicroservices(): Promise<JsonValue[]> {
const nodes = await getNodes();
const dockerStatuses = await getNodeDockerStatuses();
return config.microservices.map((service) => {
const node = nodes.find((item) => item.providerId === service.providerId) ?? null;
const docker = dockerStatuses.find((item) => item.providerId === service.providerId) ?? null;
const container = findContainer(docker?.dockerStatus ?? null, service.repository.containerName);
return {
...service,
runtime: {
providerStatus: node?.status ?? "missing",
providerName: node?.name ?? service.providerId,
providerLastHeartbeat: node?.lastHeartbeat ?? null,
container,
backendPortMapping: {
providerId: service.providerId,
node: `${service.backend.nodeBindHost}:${service.backend.nodePort}`,
mainServerAccess: "frontend-only backend proxy",
public: service.backend.public,
},
},
} satisfies JsonValue;
});
}
async function createAndSendTask(
providerId: string,
command: CoreDispatchMessage["command"],
payload: Record<string, JsonValue>,
): Promise<{ taskId: string; providerOnline: boolean }> {
const taskId = `task_${Date.now()}_${Math.random().toString(16).slice(2)}`;
await sql`
INSERT INTO unidesk_tasks (id, provider_id, command, status, payload, result)
@@ -757,7 +910,7 @@ async function dispatchTask(req: Request): Promise<Response> {
const socket = activeProviders.get(providerId);
if (!socket) {
await recordEvent("task_queued_provider_offline", providerId, { taskId, providerId, command });
return jsonResponse({ ok: true, taskId, status: "queued", providerOnline: false });
return { taskId, providerOnline: false };
}
const dispatch: CoreDispatchMessage = { type: "dispatch", taskId, command, payload };
socket.send(JSON.stringify(dispatch));
@@ -767,7 +920,127 @@ async function dispatchTask(req: Request): Promise<Response> {
WHERE id = ${taskId} AND status = 'queued'
`;
await recordEvent("task_dispatched", providerId, { taskId, providerId, command });
return jsonResponse({ ok: true, taskId, status: "dispatched", providerOnline: true });
return { taskId, providerOnline: true };
}
async function rawTask(taskId: string): Promise<{ id: string; provider_id: string; command: string; status: string; payload: JsonValue; result: JsonValue | null; updated_at: Date | string } | null> {
const rows = await sql<Array<{ id: string; provider_id: string; command: string; status: string; payload: JsonValue; result: JsonValue | null; updated_at: Date | string }>>`
SELECT id, provider_id, command, status, payload, result, updated_at
FROM unidesk_tasks
WHERE id = ${taskId}
LIMIT 1
`;
return rows[0] ?? null;
}
async function waitForTaskTerminal(taskId: string, timeoutMs: number): Promise<ReturnType<typeof rawTask> extends Promise<infer T> ? T : never> {
const started = Date.now();
let latest = await rawTask(taskId);
while (Date.now() - started < timeoutMs) {
latest = await rawTask(taskId);
if (latest !== null && (latest.status === "succeeded" || latest.status === "failed")) return latest;
await Bun.sleep(200);
}
return latest;
}
function isMicroservicePathAllowed(service: MicroserviceConfig, path: string): boolean {
return service.backend.allowedPathPrefixes.some((prefix) => path === prefix || path.startsWith(prefix));
}
function readMicroserviceArrayLimits(url: URL): { query: string; jsonArrayLimits: Record<string, JsonValue> } {
const params = new URLSearchParams(url.searchParams);
const jsonArrayLimits: Record<string, JsonValue> = {};
const rawLimits = params.getAll("__unideskArrayLimit");
params.delete("__unideskArrayLimit");
for (const raw of rawLimits) {
for (const entry of raw.split(",")) {
const [path = "", limitText = ""] = entry.split(":");
if (!/^[A-Za-z0-9_.-]+$/.test(path)) continue;
const limit = Number(limitText);
if (Number.isInteger(limit) && limit > 0 && limit <= 500) jsonArrayLimits[path] = limit;
}
}
const search = params.toString();
return { query: search.length > 0 ? `?${search}` : "", jsonArrayLimits };
}
function responseFromMicroserviceResult(task: Awaited<ReturnType<typeof rawTask>>): Response {
if (task === null) return jsonResponse({ ok: false, error: "microservice proxy task missing" }, 502);
if (task.status !== "succeeded") return jsonResponse({ ok: false, error: "microservice proxy task failed", task }, 502);
const result = dockerStatusRecord(task.result);
const status = Number(result.status);
const contentType = typeof result.contentType === "string" ? result.contentType : "application/json; charset=utf-8";
const bodyText = typeof result.bodyText === "string" ? result.bodyText : "";
if (!Number.isInteger(status) || status < 100 || status > 599) {
return jsonResponse({ ok: false, error: "microservice proxy returned invalid upstream status", task }, 502);
}
return new Response(bodyText, {
status,
headers: { "content-type": contentType },
});
}
async function microserviceRoute(req: Request, url: URL): Promise<Response> {
const rest = url.pathname.slice("/api/microservices/".length);
const slashIndex = rest.indexOf("/");
const serviceId = decodeURIComponent(slashIndex === -1 ? rest : rest.slice(0, slashIndex));
const suffix = slashIndex === -1 ? "" : rest.slice(slashIndex + 1);
const service = microserviceById(serviceId);
if (service === null) return jsonResponse({ ok: false, error: `microservice not found: ${serviceId}` }, 404);
if (suffix === "" || suffix === "status") return jsonResponse({ ok: true, microservice: (await getMicroservices()).find((item) => recordValue(item, "id") === serviceId) ?? service });
if (req.method !== "GET" && req.method !== "HEAD") return jsonResponse({ ok: false, error: "microservice frontend proxy only supports GET/HEAD" }, 405);
const proxyPrefix = "proxy";
const targetPath = suffix === "health"
? service.backend.healthPath
: suffix === proxyPrefix
? "/"
: suffix.startsWith(`${proxyPrefix}/`)
? `/${suffix.slice(proxyPrefix.length + 1)}`
: "";
if (targetPath.length === 0) return jsonResponse({ ok: false, error: "microservice route must be /status, /health, or /proxy/<path>" }, 404);
if (!isMicroservicePathAllowed(service, targetPath)) {
return jsonResponse({ ok: false, error: "microservice path is not allowed", serviceId, targetPath }, 403);
}
if (!(await providerSupports(service.providerId, "microservice.http"))) {
return jsonResponse({ ok: false, error: `provider does not declare microservice.http capability: ${service.providerId}` }, 409);
}
const proxyOptions = readMicroserviceArrayLimits(url);
const { taskId, providerOnline } = await createAndSendTask(service.providerId, "microservice.http", {
source: "microservice-frontend-proxy",
serviceId: service.id,
method: req.method,
targetBaseUrl: service.backend.nodeBaseUrl,
path: targetPath,
query: proxyOptions.query,
jsonArrayLimits: proxyOptions.jsonArrayLimits,
timeoutMs: service.backend.timeoutMs,
});
if (!providerOnline) return jsonResponse({ ok: false, error: `provider is offline: ${service.providerId}`, taskId }, 503);
const task = await waitForTaskTerminal(taskId, service.backend.timeoutMs + 3000);
return responseFromMicroserviceResult(task);
}
async function dispatchTask(req: Request): Promise<Response> {
const body = (await req.json()) as { providerId?: unknown; command?: unknown; payload?: unknown };
const providerId = typeof body.providerId === "string" ? body.providerId : "";
const command = isProviderDispatchCommand(body.command) ? body.command : null;
const payload = typeof body.payload === "object" && body.payload !== null ? (body.payload as Record<string, JsonValue>) : {};
if (!providerId) {
return jsonResponse({ ok: false, error: "providerId is required" }, 400);
}
if (command === null) {
return jsonResponse({ ok: false, error: "command must be one of docker.ps, provider.upgrade, host.ssh, microservice.http, echo" }, 400);
}
if (command === "host.ssh" && !(await providerSupports(providerId, "host.ssh"))) {
return jsonResponse({ ok: false, error: `provider does not declare host.ssh capability: ${providerId}` }, 409);
}
if (command === "microservice.http" && !(await providerSupports(providerId, "microservice.http"))) {
return jsonResponse({ ok: false, error: `provider does not declare microservice.http capability: ${providerId}` }, 409);
}
const { taskId, providerOnline } = await createAndSendTask(providerId, command, payload);
return jsonResponse({ ok: true, taskId, status: providerOnline ? "dispatched" : "queued", providerOnline });
}
function numberFromUnknown(value: unknown, fallback: number, min: number, max: number): number {
@@ -889,6 +1162,8 @@ async function route(req: Request, server: Server<WsData>): Promise<Response | u
if (url.pathname === "/api/nodes/docker-status") return jsonResponse({ ok: true, dockerStatuses: await getNodeDockerStatuses() });
if (url.pathname === "/api/events") return jsonResponse({ ok: true, events: await getEvents(readLimit(url, 100)) });
if (url.pathname === "/api/tasks") return jsonResponse({ ok: true, tasks: await getTasks(readLimit(url, 100), url.searchParams.get("status") ?? "all") });
if (url.pathname === "/api/microservices") return jsonResponse({ ok: true, microservices: await getMicroservices() });
if (url.pathname.startsWith("/api/microservices/")) return microserviceRoute(req, url);
if (url.pathname === "/api/dispatch" && req.method === "POST") return dispatchTask(req);
if (url.pathname === "/logs") return jsonResponse({ ok: true, logs: recentLogs.slice(-readLimit(url, 100)) });
if (url.pathname === "/favicon.ico") return textResponse("", 204);
+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 || [],
+204 -5
View File
@@ -193,7 +193,7 @@ function sendJson(value: unknown): void {
}
function sendRegister(): void {
const capabilities = ["heartbeat", "system.status", "docker.status", "docker.ps", "provider.upgrade", "echo"];
const capabilities = ["heartbeat", "system.status", "docker.status", "docker.ps", "provider.upgrade", "microservice.http", "echo"];
if (isHostSshConfigured()) capabilities.push("host.ssh");
sendJson({
type: "register",
@@ -609,6 +609,45 @@ function defaultHostSshProbeCommand(): string {
return "printf 'UNIDESK_SSH_TEST user=%s host=%s bridge=%s cwd=%s\\n' \"$(whoami)\" \"$(hostname)\" \"${UNIDESK_BRIDGE:-}\" \"$(pwd)\"";
}
function normalizeShellCommand(command: string): string {
return command.replace(/\\\r?\n/g, " ").replace(/\s+/g, " ").trim().toLowerCase();
}
function hostSshSelfMutationReason(command: string, cwd: string | null): string | null {
const normalized = normalizeShellCommand(command);
const currentContainerName = `unidesk-provider-gateway-${safeDockerName(config.providerId)}`.toLowerCase();
const composeProject = config.upgradeComposeProject.toLowerCase();
const composeService = config.upgradeService.toLowerCase();
const composeFile = config.upgradeComposeFile.toLowerCase();
const composeEnvFile = config.upgradeEnvFile.toLowerCase();
const hostProjectRoot = config.upgradeHostProjectRoot.toLowerCase();
const currentCwd = (cwd ?? "").toLowerCase();
const mutatesCompose = /\bdocker\s+compose\b|\bdocker-compose\b/.test(normalized)
&& /\b(up|build|restart|stop|rm|down|kill)\b/.test(normalized);
const mentionsCurrentProvider = normalized.includes(currentContainerName)
|| normalized.includes(composeProject)
|| normalized.includes(composeService)
|| normalized.includes(composeFile)
|| normalized.includes(composeEnvFile)
|| normalized.includes(hostProjectRoot)
|| (currentCwd.length > 0 && currentCwd.startsWith(hostProjectRoot));
if (mutatesCompose && mentionsCurrentProvider) {
return "docker compose mutation targets the current provider-gateway deployment";
}
const mutatesContainer = /\bdocker\s+(container\s+)?(rm|stop|restart|kill)\b/.test(normalized);
if (mutatesContainer && normalized.includes(currentContainerName)) {
return "docker container mutation targets the current provider-gateway container";
}
return null;
}
function assertHostSshCommandAllowed(command: string, cwd: string | null): void {
const reason = hostSshSelfMutationReason(command, cwd);
if (reason !== null) {
throw new Error(`blocked unsafe host.ssh self-mutation: ${reason}; use provider.upgrade mode=schedule or a detached local node shell instead`);
}
}
async function runHostSsh(payload: Record<string, JsonValue>): Promise<JsonValue> {
if (!isHostSshConfigured()) {
throw new Error(`host SSH bridge is not configured; missing ${missingHostSshFields().join(", ")}`);
@@ -629,6 +668,7 @@ async function runHostSsh(payload: Record<string, JsonValue>): Promise<JsonValue
throw new Error(`host SSH command is too long: ${command.length} bytes`);
}
const cwd = payloadString(payload, "cwd") ?? config.hostRemoteCwd;
if (mode === "exec") assertHostSshCommandAllowed(command, cwd);
const scriptParts = [
"set -e",
cwd === null ? null : `cd ${shellQuote(cwd)}`,
@@ -682,6 +722,7 @@ async function runHostSsh(payload: Record<string, JsonValue>): Promise<JsonValue
function hostSshRemoteScript(command: string | null, cwd: string | null, cols?: number, rows?: number): string {
const fallbackCwd = config.hostRemoteCwd ?? `/home/${config.hostSshUser ?? "root"}`;
const requestedCwd = cwd ?? fallbackCwd;
if (command !== null && command.length > 0) assertHostSshCommandAllowed(command, requestedCwd);
const loginShell = config.hostLoginShell ?? "/bin/bash";
const resize = Number.isFinite(cols) && Number.isFinite(rows)
? `stty rows ${Math.max(8, Math.min(120, Math.floor(rows ?? 30)))} cols ${Math.max(20, Math.min(300, Math.floor(cols ?? 100)))} 2>/dev/null || true`
@@ -846,7 +887,7 @@ function safeDockerName(value: string): string {
function upgradePlan(taskId: string): Record<string, JsonValue> {
const workspace = config.upgradeWorkspacePath;
const composeCommand = [
const composeBaseCommand = [
"docker",
"compose",
"--env-file",
@@ -855,14 +896,39 @@ function upgradePlan(taskId: string): Record<string, JsonValue> {
`${workspace}/${config.upgradeComposeFile}`,
"-p",
config.upgradeComposeProject,
];
const composeBuildCommand = [
...composeBaseCommand,
"build",
config.upgradeService,
];
const listServiceContainersCommand = [
"docker",
"ps",
"-aq",
"--filter",
`label=com.docker.compose.project=${config.upgradeComposeProject}`,
"--filter",
`label=com.docker.compose.service=${config.upgradeService}`,
];
const composeUpCommand = [
...composeBaseCommand,
"up",
"-d",
"--no-deps",
"--build",
"--force-recreate",
config.upgradeService,
];
const updaterName = `unidesk-provider-upgrader-${safeDockerName(taskId)}`;
const script = `set -eu; sleep 2; cd ${shellQuote(workspace)}; ${composeCommand.map(shellQuote).join(" ")}`;
const script = [
"set -eu",
"sleep 2",
`cd ${shellQuote(workspace)}`,
composeBuildCommand.map(shellQuote).join(" "),
`ids=$(${listServiceContainersCommand.map(shellQuote).join(" ")})`,
`if [ -n "$ids" ]; then docker rm -f $ids; fi`,
composeUpCommand.map(shellQuote).join(" "),
].join("; ");
const dockerRunCommand = [
"docker",
"run",
@@ -894,7 +960,19 @@ function upgradePlan(taskId: string): Record<string, JsonValue> {
composeFile: config.upgradeComposeFile,
envFile: config.upgradeEnvFile,
},
composeCommand,
composeCommand: composeUpCommand,
composeBuildCommand,
listServiceContainersCommand,
composeUpCommand,
replacementStrategy: {
buildBeforeRemove: true,
removeScope: {
projectLabel: config.upgradeComposeProject,
serviceLabel: config.upgradeService,
},
noDeps: true,
namedVolumesPreserved: true,
},
dockerRunCommand,
};
}
@@ -917,6 +995,118 @@ async function runProviderUpgrade(taskId: string, payload: Record<string, JsonVa
};
}
function payloadNumber(payload: Record<string, JsonValue>, key: string, fallback: number): number {
const raw = payload[key];
const value = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : fallback;
if (!Number.isFinite(value) || value <= 0) return fallback;
return Math.floor(value);
}
function payloadJsonArrayLimits(payload: Record<string, JsonValue>): Record<string, number> {
const raw = payload.jsonArrayLimits;
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
const limits: Record<string, number> = {};
for (const [path, value] of Object.entries(raw)) {
if (!/^[A-Za-z0-9_.-]+$/.test(path)) continue;
const limit = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
if (Number.isInteger(limit) && limit > 0 && limit <= 500) limits[path] = limit;
}
return limits;
}
function assertAllowedMicroserviceBase(rawBaseUrl: string): URL {
const baseUrl = new URL(rawBaseUrl);
if (baseUrl.protocol !== "http:") throw new Error(`microservice backend only supports http URLs, got ${baseUrl.protocol}`);
const host = baseUrl.hostname.toLowerCase();
const allowedHosts = new Set(["127.0.0.1", "localhost", "host.docker.internal"]);
if (!allowedHosts.has(host)) throw new Error(`microservice backend host is not allowed: ${baseUrl.hostname}`);
return baseUrl;
}
function arrayAtPath(value: unknown, path: string): JsonValue[] | null {
let current: unknown = value;
for (const part of path.split(".")) {
if (typeof current !== "object" || current === null || Array.isArray(current)) return null;
current = (current as Record<string, unknown>)[part];
}
return Array.isArray(current) ? current as JsonValue[] : null;
}
function applyJsonArrayLimits(bodyText: string, contentType: string, limits: Record<string, number>): { bodyText: string; transform: JsonValue } {
const entries = Object.entries(limits);
if (entries.length === 0) return { bodyText, transform: { applied: false } };
if (!contentType.toLowerCase().includes("json")) {
return { bodyText, transform: { applied: false, reason: "content-type is not json" } };
}
try {
const parsed = JSON.parse(bodyText) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return { bodyText, transform: { applied: false, reason: "json root is not an object" } };
}
const root = parsed as Record<string, unknown>;
const applied: Record<string, JsonValue> = {};
for (const [path, limit] of entries) {
const array = arrayAtPath(root, path);
if (array === null) continue;
const originalLength = array.length;
if (array.length > limit) array.splice(limit);
applied[path] = { limit, originalLength, returnedLength: array.length };
}
root._unidesk = { arrayLimits: applied };
return { bodyText: JSON.stringify(parsed), transform: { applied: Object.keys(applied).length > 0, arrayLimits: applied } };
} catch (error) {
return { bodyText, transform: { applied: false, error: error instanceof Error ? error.message : String(error) } };
}
}
async function runMicroserviceHttp(payload: Record<string, JsonValue>): Promise<JsonValue> {
const method = payload.method === "HEAD" ? "HEAD" : "GET";
const targetBaseUrl = payloadString(payload, "targetBaseUrl");
if (targetBaseUrl === null) throw new Error("microservice.http requires targetBaseUrl");
const path = payloadString(payload, "path") ?? "/";
const query = payloadString(payload, "query") ?? "";
if (!path.startsWith("/")) throw new Error("microservice.http path must start with /");
if (query.length > 0 && !query.startsWith("?")) throw new Error("microservice.http query must start with ?");
const baseUrl = assertAllowedMicroserviceBase(targetBaseUrl);
const url = new URL(path, baseUrl);
url.search = query;
const timeoutMs = Math.max(1000, Math.min(30_000, payloadNumber(payload, "timeoutMs", 10_000)));
const jsonArrayLimits = payloadJsonArrayLimits(payload);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { method, signal: controller.signal });
const rawBodyText = await response.text();
const contentType = response.headers.get("content-type") ?? "text/plain; charset=utf-8";
const transformed = applyJsonArrayLimits(rawBodyText, contentType, jsonArrayLimits);
return {
ok: true,
serviceId: payloadString(payload, "serviceId") ?? "unknown",
method,
url: url.toString(),
status: response.status,
upstreamOk: response.ok,
contentType,
bodyText: truncateText(transformed.bodyText, 1024 * 1024),
upstreamBodyBytes: rawBodyText.length,
returnedBodyBytes: Math.min(transformed.bodyText.length, 1024 * 1024),
truncated: transformed.bodyText.length > 1024 * 1024,
transform: transformed.transform,
};
} catch (error) {
return {
ok: false,
serviceId: payloadString(payload, "serviceId") ?? "unknown",
method,
url: url.toString(),
timeoutMs,
error: error instanceof Error ? error.message : String(error),
};
} finally {
clearTimeout(timer);
}
}
async function handleDispatch(message: CoreDispatchMessage): Promise<void> {
logger("info", "dispatch_received", { taskId: message.taskId, command: message.command, payload: message.payload });
await sendTaskStatus(message.taskId, "accepted", "provider accepted task");
@@ -941,6 +1131,15 @@ async function handleDispatch(message: CoreDispatchMessage): Promise<void> {
await sendTaskStatus(message.taskId, "succeeded", "host SSH command completed", result);
return;
}
if (message.command === "microservice.http") {
const result = await runMicroserviceHttp(message.payload);
if ((result as { ok?: unknown }).ok !== true) {
await sendTaskStatus(message.taskId, "failed", "microservice HTTP proxy failed", result);
return;
}
await sendTaskStatus(message.taskId, "succeeded", "microservice HTTP proxy completed", result);
return;
}
await sendTaskStatus(message.taskId, "succeeded", "echo completed", { echo: message.payload });
} catch (error) {
const text = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
+2 -2
View File
@@ -106,7 +106,7 @@ export interface ProviderTaskStatusMessage {
result?: JsonValue;
}
export type ProviderDispatchCommand = "docker.ps" | "provider.upgrade" | "host.ssh" | "echo";
export type ProviderDispatchCommand = "docker.ps" | "provider.upgrade" | "host.ssh" | "microservice.http" | "echo";
export interface CoreDispatchMessage {
type: "dispatch";
@@ -271,7 +271,7 @@ export function parseJsonObject(value: string, name: string): Record<string, Jso
}
export function isProviderDispatchCommand(value: unknown): value is ProviderDispatchCommand {
return value === "docker.ps" || value === "provider.upgrade" || value === "host.ssh" || value === "echo";
return value === "docker.ps" || value === "provider.upgrade" || value === "host.ssh" || value === "microservice.http" || value === "echo";
}
export function isProviderToCoreMessage(value: unknown): value is ProviderToCoreMessage {