feat: 拆分 HWPOD Temporal 服务与独立 Web
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
import { createHwpodHttpApp } from "../../internal/hwpod/http.ts";
|
||||||
|
import { hwpodRuntime } from "../../internal/hwpod/runtime.ts";
|
||||||
|
|
||||||
|
const runtime = hwpodRuntime();
|
||||||
|
const app = createHwpodHttpApp({ runtimeApiUrl: runtime.runtimeApiUrl, runtimeApiAuthorization: runtime.runtimeApiAuthorization, temporal: runtime.temporal });
|
||||||
|
const host = process.env.HWPOD_API_HOST || "0.0.0.0";
|
||||||
|
const port = positivePort(process.env.HWPOD_API_PORT, 6681);
|
||||||
|
const server = Bun.serve({ hostname: host, port, fetch: request => app.fetch(request) });
|
||||||
|
process.stdout.write(`${JSON.stringify({ serviceId: "hwlab-hwpod-api", status: "listening", host, port: server.port, temporalNamespace: runtime.namespace, taskQueue: runtime.taskQueue, valuesPrinted: false })}\n`);
|
||||||
|
for (const signal of ["SIGINT", "SIGTERM"] as const) process.once(signal, async () => { server.stop(true); await app.close(); process.exit(0); });
|
||||||
|
function positivePort(value: string | undefined, fallback: number) { const parsed = Number.parseInt(String(value ?? fallback), 10); if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) throw new Error("HWPOD_API_PORT must be a valid port"); return parsed; }
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
import path from "node:path";
|
||||||
|
import { NativeConnection, Worker } from "@temporalio/worker";
|
||||||
|
import { executeHwpodOperation } from "../../internal/hwpod/activities.ts";
|
||||||
|
import { hwpodRuntime } from "../../internal/hwpod/runtime.ts";
|
||||||
|
|
||||||
|
const runtime = hwpodRuntime();
|
||||||
|
const connection = await NativeConnection.connect({ address: runtime.address });
|
||||||
|
const worker = await Worker.create({ connection, namespace: runtime.namespace, taskQueue: runtime.taskQueue, workflowsPath: path.resolve(import.meta.dir, "../../internal/hwpod/workflows.ts"), activities: { executeHwpodOperation } });
|
||||||
|
const healthPort = positivePort(process.env.HWPOD_WORKER_HEALTH_PORT, 6682);
|
||||||
|
const health = Bun.serve({ hostname: process.env.HWPOD_WORKER_HEALTH_HOST || "0.0.0.0", port: healthPort, fetch(request) { return ["/healthz", "/readyz", "/health/live", "/health/ready"].includes(new URL(request.url).pathname) ? Response.json({ ok: true, serviceId: "hwlab-hwpod-worker", namespace: runtime.namespace, taskQueue: runtime.taskQueue, valuesPrinted: false }) : new Response("not found", { status: 404 }); } });
|
||||||
|
process.stdout.write(`${JSON.stringify({ serviceId: "hwlab-hwpod-worker", status: "started", namespace: runtime.namespace, taskQueue: runtime.taskQueue, healthPort: health.port, valuesPrinted: false })}\n`);
|
||||||
|
for (const signal of ["SIGINT", "SIGTERM"] as const) process.once(signal, () => worker.shutdown());
|
||||||
|
try { await worker.run(); } finally { health.stop(true); await connection.close(); await runtime.temporal.close(); }
|
||||||
|
function positivePort(value: string | undefined, fallback: number) { const parsed = Number.parseInt(String(value ?? fallback), 10); if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) throw new Error("HWPOD_WORKER_HEALTH_PORT must be a valid port"); return parsed; }
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
* SPEC: PJ2026-010103 HWPOD 服务。
|
||||||
|
* 实现引用: draft-2026-07-20-hwpod-temporal-split。
|
||||||
|
* 责任: 把独立 HWPOD workflow 接到迁移期 Cloud API authority。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { validateHwpodOperationInput } from "./contracts.ts";
|
||||||
|
|
||||||
|
export async function executeHwpodOperation(raw: unknown) {
|
||||||
|
const input = validateHwpodOperationInput(raw);
|
||||||
|
const response = await fetch(`${input.runtimeApiUrl}/v1/hwpod-node-ops`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", ...(input.authorization ? { authorization: input.authorization } : {}) },
|
||||||
|
body: JSON.stringify(input.plan),
|
||||||
|
signal: AbortSignal.timeout(900_000)
|
||||||
|
});
|
||||||
|
const body = await response.json().catch(() => null);
|
||||||
|
if (!body || typeof body !== "object") return { ok: false, status: "failed", operationId: input.operationId, httpStatus: response.status, blocker: { code: "hwpod_runtime_response_invalid" } };
|
||||||
|
return { ok: response.ok && (body as any).ok !== false, status: (body as any).status ?? (response.ok ? "completed" : "failed"), operationId: input.operationId, response: body };
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* SPEC: PJ2026-010103 HWPOD 服务。
|
||||||
|
* 实现引用: draft-2026-07-20-hwpod-temporal-split。
|
||||||
|
* 责任: 独立 HWPOD API、worker 和 Web 共用的稳定输入输出合同。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type HwpodOperationInput = {
|
||||||
|
operationId: string;
|
||||||
|
plan: Record<string, unknown>;
|
||||||
|
runtimeApiUrl: string;
|
||||||
|
authorization?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HwpodOperationResult = {
|
||||||
|
ok: boolean;
|
||||||
|
status: string;
|
||||||
|
operationId: string;
|
||||||
|
workflowId: string;
|
||||||
|
workflowRunId: string | null;
|
||||||
|
result?: Record<string, unknown> | null;
|
||||||
|
error?: { code: string; message: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function validateHwpodOperationInput(value: unknown): HwpodOperationInput {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw codedError("hwpod_operation_input_invalid", "HWPOD operation input must be an object");
|
||||||
|
const input = value as Record<string, unknown>;
|
||||||
|
const operationId = text(input.operationId);
|
||||||
|
const runtimeApiUrl = text(input.runtimeApiUrl);
|
||||||
|
const plan = input.plan;
|
||||||
|
if (!operationId) throw codedError("hwpod_operation_id_required", "operationId is required");
|
||||||
|
if (!runtimeApiUrl) throw codedError("hwpod_runtime_api_url_required", "runtimeApiUrl is required");
|
||||||
|
if (!plan || typeof plan !== "object" || Array.isArray(plan)) throw codedError("hwpod_operation_plan_required", "plan must be an object");
|
||||||
|
return { operationId, runtimeApiUrl: runtimeApiUrl.replace(/\/+$/u, ""), plan: plan as Record<string, unknown>, authorization: text(input.authorization) || undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown) { return typeof value === "string" ? value.trim() : ""; }
|
||||||
|
function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); }
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* SPEC: PJ2026-010103 HWPOD 服务。
|
||||||
|
* 实现引用: draft-2026-07-20-hwpod-temporal-split。
|
||||||
|
* 责任: 独立 HWPOD API HTTP 合同。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { validateHwpodOperationInput } from "./contracts.ts";
|
||||||
|
|
||||||
|
export function createHwpodHttpApp(options: { runtimeApiUrl: string; runtimeApiAuthorization: string; temporal: any }) {
|
||||||
|
return {
|
||||||
|
async fetch(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
try {
|
||||||
|
if (url.pathname === "/health/live") return json(200, { ok: true, serviceId: "hwlab-hwpod-api", status: "live", valuesPrinted: false });
|
||||||
|
if (url.pathname === "/health/ready") return readiness(options);
|
||||||
|
if (url.pathname === "/v1/hwpod" && request.method === "GET") return json(200, { ok: true, serviceId: "hwlab-hwpod-api", executionAuthority: "temporal", runtimeApiUrl: options.runtimeApiUrl, valuesPrinted: false });
|
||||||
|
if (url.pathname === "/v1/hwpod/specs" && request.method === "GET") return proxyGet(options.runtimeApiUrl, `/v1/hwpod/specs${url.search}`, options.runtimeApiAuthorization);
|
||||||
|
if (url.pathname === "/v1/hwpod/topology" && request.method === "GET") return proxyGet(options.runtimeApiUrl, `/v1/hwpod/topology${url.search}`, options.runtimeApiAuthorization);
|
||||||
|
if (url.pathname === "/v1/hwpod-node-ops" && request.method === "GET") return proxyGet(options.runtimeApiUrl, `/v1/hwpod-node-ops${url.search}`, options.runtimeApiAuthorization);
|
||||||
|
if (url.pathname === "/v1/hwpod/operations" && request.method === "POST") {
|
||||||
|
const body = await request.json();
|
||||||
|
const input = validateHwpodOperationInput({ ...(body as any), runtimeApiUrl: options.runtimeApiUrl, authorization: options.runtimeApiAuthorization });
|
||||||
|
const started = await options.temporal.start(input);
|
||||||
|
return json(202, { ok: true, status: "accepted", operationId: input.operationId, ...started, authority: "hwpod-temporal", valuesPrinted: false });
|
||||||
|
}
|
||||||
|
const match = /^\/v1\/hwpod\/operations\/([^/]+)$/u.exec(url.pathname);
|
||||||
|
if (match && request.method === "GET") {
|
||||||
|
const operationId = decodeURIComponent(match[1]);
|
||||||
|
const workflowId = `hwpod-operation-${operationId}`;
|
||||||
|
const description = await options.temporal.describe(workflowId);
|
||||||
|
const status = String((description as any)?.status?.name ?? (description as any)?.status ?? "RUNNING").toLowerCase();
|
||||||
|
const result = /completed|failed|canceled/u.test(status) ? await options.temporal.result(workflowId).catch((error: any) => ({ ok: false, error: { code: error?.code ?? "hwpod_workflow_result_failed", message: error?.message ?? String(error) } })) : null;
|
||||||
|
return json(200, { ok: true, operationId, workflowId, workflowRunId: (description as any)?.runId ?? null, status, result, valuesPrinted: false });
|
||||||
|
}
|
||||||
|
return json(404, { ok: false, error: { code: "hwpod_route_not_found", message: "HWPOD API route was not found" } });
|
||||||
|
} catch (error: any) {
|
||||||
|
const code = error?.code ?? "hwpod_http_error";
|
||||||
|
return json(code.includes("not_found") ? 404 : 400, { ok: false, error: { code, message: error?.message ?? String(error) }, valuesPrinted: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close: async () => options.temporal.close()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readiness(options: { runtimeApiUrl: string; runtimeApiAuthorization: string; temporal: any }) {
|
||||||
|
try { const response = await fetch(`${options.runtimeApiUrl}/v1/hwpod/topology?resource=node&limit=1`, { headers: { authorization: options.runtimeApiAuthorization }, signal: AbortSignal.timeout(5000) }); return json(response.ok ? 200 : 503, { ok: response.ok, serviceId: "hwlab-hwpod-api", temporal: "configured", runtimeApi: response.ok ? "reachable" : "unreachable", valuesPrinted: false }); }
|
||||||
|
catch { return json(503, { ok: false, serviceId: "hwlab-hwpod-api", temporal: "configured", runtimeApi: "unreachable", valuesPrinted: false }); }
|
||||||
|
}
|
||||||
|
async function proxyGet(base: string, route: string, authorization: string) { const response = await fetch(`${base}${route}`, { headers: { authorization }, signal: AbortSignal.timeout(10_000) }); return new Response(await response.text(), { status: response.status, headers: { "content-type": response.headers.get("content-type") ?? "application/json", "cache-control": "no-store" } }); }
|
||||||
|
function json(status: number, body: unknown) { return Response.json(body, { status, headers: { "cache-control": "no-store" } }); }
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* SPEC: PJ2026-010103 HWPOD 服务。
|
||||||
|
* 实现引用: draft-2026-07-20-hwpod-temporal-split。
|
||||||
|
* 责任: HWPOD L0 合同与 activity 转发回归。
|
||||||
|
*/
|
||||||
|
import { afterEach, test, expect } from "bun:test";
|
||||||
|
import { executeHwpodOperation } from "./activities.ts";
|
||||||
|
import { validateHwpodOperationInput } from "./contracts.ts";
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
afterEach(() => { globalThis.fetch = originalFetch; });
|
||||||
|
|
||||||
|
test("validates stable HWPOD operation identity", () => {
|
||||||
|
const input = validateHwpodOperationInput({ operationId: "op-1", runtimeApiUrl: "http://runtime/", plan: { planId: "plan-1" } });
|
||||||
|
expect(input.operationId).toBe("op-1");
|
||||||
|
expect(input.runtimeApiUrl).toBe("http://runtime");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("forwards an HWPOD activity without changing the plan", async () => {
|
||||||
|
let received: any;
|
||||||
|
globalThis.fetch = (async (_url: any, init: any) => { received = JSON.parse(String(init.body)); return Response.json({ ok: true, status: "completed", planId: received.planId }); }) as any;
|
||||||
|
const result = await executeHwpodOperation({ operationId: "op-2", runtimeApiUrl: "http://runtime", plan: { planId: "plan-2", nodeId: "node-1", hwpodId: "pod-1", ops: [{ op: "node.health" }] } });
|
||||||
|
expect(received.planId).toBe("plan-2");
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.operationId).toBe("op-2");
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
* SPEC: PJ2026-010103 HWPOD 服务。
|
||||||
|
* 实现引用: draft-2026-07-20-hwpod-temporal-split。
|
||||||
|
* 责任: HWPOD API 的 Temporal 与迁移期 Cloud API 配置解析。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHwpodTemporalGateway } from "./temporal.ts";
|
||||||
|
|
||||||
|
export function hwpodRuntime(env: Record<string, string | undefined> = process.env) {
|
||||||
|
const address = String(env.HWPOD_TEMPORAL_ADDRESS ?? env.TEMPORAL_ADDRESS ?? "").trim();
|
||||||
|
const namespace = String(env.HWPOD_TEMPORAL_NAMESPACE ?? "unidesk").trim();
|
||||||
|
const taskQueue = String(env.HWPOD_TEMPORAL_TASK_QUEUE ?? "hwlab-v03-hwpod").trim();
|
||||||
|
const runtimeApiUrl = String(env.HWPOD_RUNTIME_API_URL ?? "").trim().replace(/\/+$/u, "");
|
||||||
|
const runtimeApiAuthorization = String(env.HWPOD_RUNTIME_API_AUTHORIZATION ?? "").trim();
|
||||||
|
if (!runtimeApiUrl) throw codedError("hwpod_runtime_api_url_required", "HWPOD_RUNTIME_API_URL is required");
|
||||||
|
if (!runtimeApiAuthorization) throw codedError("hwpod_runtime_api_authorization_required", "HWPOD_RUNTIME_API_AUTHORIZATION is required");
|
||||||
|
return { address, namespace, taskQueue, runtimeApiUrl, runtimeApiAuthorization, temporal: createHwpodTemporalGateway({ address, namespace, taskQueue }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); }
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/*
|
||||||
|
* SPEC: PJ2026-010103 HWPOD 服务。
|
||||||
|
* 实现引用: draft-2026-07-20-hwpod-temporal-split。
|
||||||
|
* 责任: HWPOD API 到共享 Temporal namespace 的最小 gateway。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Client, Connection } from "@temporalio/client";
|
||||||
|
|
||||||
|
export function createHwpodTemporalGateway(options: { address: string; namespace: string; taskQueue: string }) {
|
||||||
|
if (!options.address) throw codedError("hwpod_temporal_address_required", "HWPOD_TEMPORAL_ADDRESS is required");
|
||||||
|
let connection: Connection | undefined;
|
||||||
|
let client: Client | undefined;
|
||||||
|
const getClient = async () => {
|
||||||
|
connection ??= await Connection.connect({ address: options.address });
|
||||||
|
client ??= new Client({ connection, namespace: options.namespace });
|
||||||
|
return client;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
async start(input: unknown) {
|
||||||
|
const workflowId = `hwpod-operation-${String((input as any)?.operationId ?? "unknown")}`;
|
||||||
|
try {
|
||||||
|
const handle = await (await getClient()).workflow.start("hwpodOperationWorkflow", { taskQueue: options.taskQueue, workflowId, args: [input] });
|
||||||
|
return { workflowId, workflowRunId: handle.firstExecutionRunId, reused: false };
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.name !== "WorkflowExecutionAlreadyStartedError") throw error;
|
||||||
|
const description = await (await getClient()).workflow.getHandle(workflowId).describe();
|
||||||
|
return { workflowId, workflowRunId: description.runId, reused: true };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async describe(workflowId: string) { return (await getClient()).workflow.getHandle(workflowId).describe(); },
|
||||||
|
async result(workflowId: string) { return (await getClient()).workflow.getHandle(workflowId).result(); },
|
||||||
|
async close() { await connection?.close(); connection = undefined; client = undefined; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); }
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
* SPEC: PJ2026-010103 HWPOD 服务。
|
||||||
|
* 实现引用: draft-2026-07-20-hwpod-temporal-split。
|
||||||
|
* 责任: HWPOD operation durable workflow。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { proxyActivities } from "@temporalio/workflow";
|
||||||
|
import type * as activities from "./activities.ts";
|
||||||
|
|
||||||
|
const { executeHwpodOperation } = proxyActivities<typeof activities>({ startToCloseTimeout: "15 minutes", retry: { maximumAttempts: 1 } });
|
||||||
|
|
||||||
|
export async function hwpodOperationWorkflow(input: any) {
|
||||||
|
return executeHwpodOperation(input);
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@
|
|||||||
"workbench:worker:dev": "bun --watch cmd/hwlab-workbench-worker/main.ts",
|
"workbench:worker:dev": "bun --watch cmd/hwlab-workbench-worker/main.ts",
|
||||||
"workbench:web:dev": "cd web/hwlab-cloud-web && WORKBENCH_VITE_USE_POLLING=1 bun run dev",
|
"workbench:web:dev": "cd web/hwlab-cloud-web && WORKBENCH_VITE_USE_POLLING=1 bun run dev",
|
||||||
"workbench:native:smoke": "bun test internal/workbench/workbench.test.ts && bun run scripts/workbench-native-smoke.mjs",
|
"workbench:native:smoke": "bun test internal/workbench/workbench.test.ts && bun run scripts/workbench-native-smoke.mjs",
|
||||||
|
"hwpod:api:dev": "bun --watch cmd/hwlab-hwpod-api/main.ts",
|
||||||
|
"hwpod:worker:dev": "bun --watch cmd/hwlab-hwpod-worker/main.ts",
|
||||||
|
"hwpod:native:smoke": "bun test internal/hwpod/hwpod.test.ts",
|
||||||
"workbench:p1:inventory": "node scripts/workbench-long-reliability-p1-inventory.mjs",
|
"workbench:p1:inventory": "node scripts/workbench-long-reliability-p1-inventory.mjs",
|
||||||
"dev-runtime-base:build": "node scripts/dev-runtime-base-image.mjs",
|
"dev-runtime-base:build": "node scripts/dev-runtime-base-image.mjs",
|
||||||
"cloud-api:smoke": "node scripts/run-bun.mjs scripts/cloud-api-runtime-smoke.mjs",
|
"cloud-api:smoke": "node scripts/run-bun.mjs scripts/cloud-api-runtime-smoke.mjs",
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ if (argv[0] === "tasktree") {
|
|||||||
console.log(JSON.stringify({ ok: false, operation: "caserun", error: { code: error?.code ?? "caserun_cli_error", message: error?.message ?? String(error) } }, null, 2));
|
console.log(JSON.stringify({ ok: false, operation: "caserun", error: { code: error?.code ?? "caserun_cli_error", message: error?.message ?? String(error) } }, null, 2));
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
}
|
}
|
||||||
|
} else if (argv[0] === "hwpod" && argv[1] === "service") {
|
||||||
|
const { hwpodNativeServiceCommand } = await import("../../src/hwpod-native-service.ts");
|
||||||
|
try {
|
||||||
|
const result = await hwpodNativeServiceCommand({ service: argv[2], action: argv[3] ?? "status", cwd: process.cwd(), env: process.env });
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
process.exitCode = result?.ok === false ? 1 : 0;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log(JSON.stringify({ ok: false, operation: "hwpod", error: { code: error?.code ?? "hwpod_cli_error", message: error?.message ?? String(error) } }, null, 2));
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
} else if (argv[0] === "workbench") {
|
} else if (argv[0] === "workbench") {
|
||||||
const { runWorkbenchCli } = await import("../../src/workbench-cli.ts");
|
const { runWorkbenchCli } = await import("../../src/workbench-cli.ts");
|
||||||
try {
|
try {
|
||||||
|
|||||||
+1
-1
@@ -872,9 +872,9 @@ class WebSocketNodeClient:
|
|||||||
port = parsed.port or (443 if parsed.scheme == "wss" else 80)
|
port = parsed.port or (443 if parsed.scheme == "wss" else 80)
|
||||||
host = parsed.hostname or ""
|
host = parsed.hostname or ""
|
||||||
raw = socket.create_connection((host, port), timeout=10)
|
raw = socket.create_connection((host, port), timeout=10)
|
||||||
raw.settimeout(1.0)
|
|
||||||
self.sock = ssl.create_default_context().wrap_socket(raw, server_hostname=host) if parsed.scheme == "wss" else raw
|
self.sock = ssl.create_default_context().wrap_socket(raw, server_hostname=host) if parsed.scheme == "wss" else raw
|
||||||
self._handshake(parsed, host, port)
|
self._handshake(parsed, host, port)
|
||||||
|
self.sock.settimeout(1.0)
|
||||||
self.bus.emit("log", f"WebSocket 已打开: {ws_url}")
|
self.bus.emit("log", f"WebSocket 已打开: {ws_url}")
|
||||||
self._send_json({
|
self._send_json({
|
||||||
"type": "register",
|
"type": "register",
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
* SPEC: PJ2026-010103 HWPOD 服务。
|
||||||
|
* 实现引用: draft-2026-07-20-hwpod-temporal-split。
|
||||||
|
* 责任: 通过一个受控入口独立管理 HWPOD L1 API、worker 和 Web。
|
||||||
|
*/
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { closeSync, openSync } from "node:fs";
|
||||||
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
type ServiceName = "api" | "worker" | "web";
|
||||||
|
|
||||||
|
export async function hwpodNativeServiceCommand(input: { service: string; action: string; cwd: string; env: Record<string, string | undefined> }) {
|
||||||
|
const service = validService(input.service);
|
||||||
|
const stateDir = path.resolve(input.cwd, requiredEnv(input.env, "HWPOD_NATIVE_SERVICE_STATE_DIR"));
|
||||||
|
const stateFile = path.join(stateDir, `${service}.json`);
|
||||||
|
const logFile = path.join(stateDir, `${service}.log`);
|
||||||
|
if (input.action === "status") return status(service, stateFile, logFile);
|
||||||
|
if (input.action === "logs") return logs(service, stateFile, logFile);
|
||||||
|
if (input.action === "stop") return stop(service, stateFile, logFile);
|
||||||
|
if (input.action === "restart") { await stop(service, stateFile, logFile); return start(service, stateDir, stateFile, logFile, input.cwd, input.env); }
|
||||||
|
if (input.action === "start") return start(service, stateDir, stateFile, logFile, input.cwd, input.env);
|
||||||
|
throw codedError("unsupported_service_action", `unsupported HWPOD service action: ${input.action}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(service: ServiceName, stateDir: string, stateFile: string, logFile: string, cwd: string, env: Record<string, string | undefined>) {
|
||||||
|
const current = await readState(stateFile);
|
||||||
|
if (current?.pid && alive(current.pid)) throw codedError("service_already_running", `${service} is already running`, { pid: current.pid, correctionCommand: `hwlab-cli hwpod service ${service} stop` });
|
||||||
|
await mkdir(stateDir, { recursive: true });
|
||||||
|
const endpoints = serviceEndpoints(service, env);
|
||||||
|
const command = serviceCommand(service);
|
||||||
|
const logFd = openSync(logFile, "w");
|
||||||
|
const child = spawn(command[0], command.slice(1), { cwd: service === "web" ? path.join(cwd, "web/hwlab-cloud-web") : cwd, env: serviceEnv(service, env), detached: true, stdio: ["ignore", logFd, logFd] });
|
||||||
|
closeSync(logFd);
|
||||||
|
child.unref();
|
||||||
|
const state = { service, pid: child.pid, status: "starting", command, cwd, logFile, endpoints, healthUrl: endpoints.probe.url, startedAt: new Date().toISOString() };
|
||||||
|
await writeFile(stateFile, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||||
|
return { ok: true, operation: `hwpod.service.${service}.start`, ...state, nextCommand: `hwlab-cli hwpod service ${service} status` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop(service: ServiceName, stateFile: string, logFile: string) {
|
||||||
|
const state = await readState(stateFile);
|
||||||
|
if (!state?.pid || !alive(state.pid)) { await rm(stateFile, { force: true }); return { ok: true, operation: `hwpod.service.${service}.stop`, service, status: "stopped", alreadyStopped: true, logFile }; }
|
||||||
|
try { process.kill(-state.pid, "SIGTERM"); } catch (error: any) { if (error?.code !== "ESRCH") throw error; }
|
||||||
|
await rm(stateFile, { force: true });
|
||||||
|
return { ok: true, operation: `hwpod.service.${service}.stop`, service, status: "stopped", pid: state.pid, logFile };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function status(service: ServiceName, stateFile: string, logFile: string) {
|
||||||
|
const state = await readState(stateFile);
|
||||||
|
const processRunning = Boolean(state?.pid && alive(state.pid));
|
||||||
|
const health = processRunning && state?.healthUrl ? await probeHealth(state.healthUrl) : { ok: false, status: null, error: processRunning ? "health-url-not-declared" : "process-not-running" };
|
||||||
|
const running = processRunning && health.ok;
|
||||||
|
return { ok: running, operation: `hwpod.service.${service}.status`, service, status: running ? "running" : processRunning ? "degraded" : "stopped", pid: processRunning ? state.pid : null, endpoints: state?.endpoints ?? null, health, stateFile, logFile, nextCommand: `hwlab-cli hwpod service ${service} logs` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logs(service: ServiceName, stateFile: string, logFile: string) {
|
||||||
|
const current = await status(service, stateFile, logFile);
|
||||||
|
const lines = (await readFile(logFile, "utf8").catch(() => "")).split("\n").filter(Boolean).slice(-80);
|
||||||
|
return { ...current, operation: `hwpod.service.${service}.logs`, lines, truncated: lines.length === 80 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceCommand(service: ServiceName) {
|
||||||
|
if (service === "api") return ["bun", "cmd/hwlab-hwpod-api/main.ts"];
|
||||||
|
if (service === "worker") return ["bun", "cmd/hwlab-hwpod-worker/main.ts"];
|
||||||
|
return ["bun", "run", "dev:native-hwpod"];
|
||||||
|
}
|
||||||
|
function serviceEnv(service: ServiceName, env: Record<string, string | undefined>) {
|
||||||
|
if (service === "api") return { ...process.env, ...env, HWPOD_API_HOST: requiredEnv(env, "HWPOD_API_BIND_HOST"), HWPOD_API_PORT: requiredEnv(env, "HWPOD_API_PORT") };
|
||||||
|
if (service === "worker") return { ...process.env, ...env, HWPOD_WORKER_HEALTH_HOST: requiredEnv(env, "HWPOD_WORKER_BIND_HOST"), HWPOD_WORKER_HEALTH_PORT: requiredEnv(env, "HWPOD_WORKER_HEALTH_PORT") };
|
||||||
|
return { ...process.env, ...env, HWPOD_WEB_HOST: requiredEnv(env, "HWPOD_WEB_BIND_HOST"), HWPOD_WEB_PORT: requiredEnv(env, "HWPOD_WEB_PORT"), HWPOD_NATIVE_API_URL: requiredEnv(env, "HWPOD_NATIVE_API_URL") };
|
||||||
|
}
|
||||||
|
function serviceEndpoints(service: ServiceName, env: Record<string, string | undefined>) {
|
||||||
|
const prefix = service === "worker" ? "HWPOD_WORKER" : service === "api" ? "HWPOD_API" : "HWPOD_WEB";
|
||||||
|
const bindHost = requiredEnv(env, `${prefix}_BIND_HOST`);
|
||||||
|
const probeHost = requiredEnv(env, `${prefix}_PROBE_HOST`);
|
||||||
|
const port = requiredEnv(env, service === "worker" ? "HWPOD_WORKER_HEALTH_PORT" : `${prefix}_PORT`);
|
||||||
|
const publicBaseUrl = fixedHttpsOrigin(requiredEnv(env, "HWPOD_PUBLIC_BASE_URL"), "HWPOD_PUBLIC_BASE_URL");
|
||||||
|
const healthPath = service === "web" ? "/health/live" : "/health/ready";
|
||||||
|
return { bind: { host: bindHost, port: validPort(port) }, probe: endpoint(probeHost, port, healthPath), public: service === "worker" ? null : { url: publicBaseUrl } };
|
||||||
|
}
|
||||||
|
function endpoint(host: string, port: string, pathname: string) { const numericPort = validPort(port); return { host, port: numericPort, url: `http://${host}:${numericPort}${pathname}` }; }
|
||||||
|
function validPort(value: string) { const port = Number(value); if (!Number.isInteger(port) || port < 1 || port > 65535) throw codedError("invalid_service_port", `invalid HWPOD service port: ${value}`); return port; }
|
||||||
|
function validService(value: string): ServiceName { if (value === "api" || value === "worker" || value === "web") return value; throw codedError("invalid_service", "HWPOD service must be api, worker, or web"); }
|
||||||
|
function requiredEnv(env: Record<string, string | undefined>, key: string) { const value = String(env[key] ?? "").trim(); if (!value) throw codedError("native_exposure_config_required", `${key} is required from YAML-first HWPOD native development config`); return value; }
|
||||||
|
function fixedHttpsOrigin(value: string, key: string) { try { const parsed = new URL(value); if (parsed.protocol === "https:" && parsed.pathname === "/" && !parsed.search && !parsed.hash && !parsed.port) return parsed.origin; } catch {} throw codedError("native_exposure_config_invalid", `${key} must be a pathless HTTPS origin`); }
|
||||||
|
async function readState(file: string) { return JSON.parse(await readFile(file, "utf8").catch(() => "null")); }
|
||||||
|
function alive(pid: number) { try { process.kill(pid, 0); return true; } catch { return false; } }
|
||||||
|
async function probeHealth(url: string) { try { const response = await fetch(url, { signal: AbortSignal.timeout(1000) }); return { ok: response.ok, status: response.status, url }; } catch (error) { return { ok: false, status: null, url, error: error instanceof Error ? error.message : String(error) }; } }
|
||||||
|
function codedError(code: string, message: string, details?: unknown) { return Object.assign(new Error(message), { code, details }); }
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
const state = { resources: [], selected: null };
|
||||||
|
const resourcesEl = document.querySelector("[data-resources]");
|
||||||
|
const detailEl = document.querySelector("[data-detail]");
|
||||||
|
const healthEl = document.querySelector("[data-health]");
|
||||||
|
document.querySelector("[data-refresh]").addEventListener("click", refresh);
|
||||||
|
void refresh();
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
healthEl.dataset.health = "loading";
|
||||||
|
document.querySelector("[data-health-label]").textContent = "同步中";
|
||||||
|
try {
|
||||||
|
const [health, nodes, devices] = await Promise.all([getJson("/health/ready"), getJson("/v1/hwpod/topology?resource=node&limit=100"), getJson("/v1/hwpod/topology?resource=device&limit=100")]);
|
||||||
|
const nodeItems = Array.isArray(nodes.items) ? nodes.items : [];
|
||||||
|
const deviceItems = Array.isArray(devices.items) ? devices.items : [];
|
||||||
|
state.resources = [...nodeItems, ...deviceItems];
|
||||||
|
updateStats(nodes.summary || {}, devices.summary || {});
|
||||||
|
renderResources();
|
||||||
|
if (state.selected) selectResource(state.resources.find(item => resourceKey(item) === state.selected) || null);
|
||||||
|
healthEl.dataset.health = health.ok ? "ready" : "blocked";
|
||||||
|
document.querySelector("[data-health-label]").textContent = health.ok ? "服务就绪" : "依赖阻塞";
|
||||||
|
document.querySelector("[data-observed]").textContent = displayTime(devices.observedAt || nodes.observedAt);
|
||||||
|
} catch (error) {
|
||||||
|
healthEl.dataset.health = "blocked";
|
||||||
|
document.querySelector("[data-health-label]").textContent = "连接失败";
|
||||||
|
resourcesEl.innerHTML = `<div class="empty">${escapeHtml(error.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResources() {
|
||||||
|
if (!state.resources.length) { resourcesEl.innerHTML = '<div class="empty">未发现 HWPOD 节点或设备。</div>'; return; }
|
||||||
|
resourcesEl.innerHTML = state.resources.map(item => {
|
||||||
|
const key = resourceKey(item);
|
||||||
|
const title = item.resourceType === "hwpod" ? item.name || item.hwpodId : item.name || item.nodeId;
|
||||||
|
const subtitle = item.resourceType === "hwpod" ? `${item.nodeId || "未绑定节点"} · ${item.elements?.workspace?.label || "未声明工作区"}` : `${item.platform || "unknown"} · ${item.deviceCount || 0} 个 HWPOD`;
|
||||||
|
return `<button class="resource" type="button" data-key="${escapeHtml(key)}" data-status="${escapeHtml(item.status || "unknown")}" aria-current="${state.selected === key}"><span class="rail"></span><span class="resource-copy"><strong>${escapeHtml(title)}</strong><span>${escapeHtml(subtitle)}</span></span><span class="badge">${escapeHtml(item.status || "unknown")}</span></button>`;
|
||||||
|
}).join("");
|
||||||
|
resourcesEl.querySelectorAll("[data-key]").forEach(button => button.addEventListener("click", () => selectResource(state.resources.find(item => resourceKey(item) === button.dataset.key))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectResource(item) {
|
||||||
|
state.selected = item ? resourceKey(item) : null;
|
||||||
|
renderResources();
|
||||||
|
if (!item) { detailEl.innerHTML = '<div class="detail-empty"><strong>选择一个资源</strong><span>查看能力、占用状态和 blocker。</span></div>'; return; }
|
||||||
|
const identity = item.resourceType === "hwpod" ? { 类型: "HWPOD", ID: item.hwpodId, 节点: item.nodeId, 状态: item.status } : { 类型: "HWPOD Node", ID: item.nodeId, 平台: item.platform, 状态: item.status };
|
||||||
|
const capabilities = item.resourceType === "hwpod" ? item.declaredCapabilities : item.actualCapabilities;
|
||||||
|
detailEl.innerHTML = `${detailBlock("IDENTITY", kv(identity))}${detailBlock("CAPABILITIES", chips(capabilities))}${blockers(item.blockers)}${detailBlock("RAW STATUS", kv({ 在线: String(Boolean(item.online)), 占用: String(Boolean(item.busy || item.inFlight?.busy)), 最近观测: displayTime(item.observedAt) }))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats(nodes, devices) {
|
||||||
|
setStat("nodes", nodes.nodeCount || 0); setStat("online", nodes.onlineNodeCount || 0); setStat("devices", devices.deviceCount || 0); setStat("available", devices.availableDeviceCount || 0); setStat("blocked", (devices.deviceCount || 0) - (devices.availableDeviceCount || 0));
|
||||||
|
}
|
||||||
|
function setStat(name, value) { document.querySelector(`[data-stat="${name}"]`).textContent = String(value); }
|
||||||
|
function detailBlock(title, body) { return `<section class="detail-block"><h3>${title}</h3>${body}</section>`; }
|
||||||
|
function kv(values) { return `<dl class="kv">${Object.entries(values).map(([key, value]) => `<dt>${escapeHtml(key)}</dt><dd>${escapeHtml(value ?? "-")}</dd>`).join("")}</dl>`; }
|
||||||
|
function chips(values) { const items = Array.isArray(values) ? values : []; return items.length ? `<div class="chips">${items.map(value => `<span class="chip">${escapeHtml(value)}</span>`).join("")}</div>` : '<span class="empty">未声明能力</span>'; }
|
||||||
|
function blockers(values) { const items = Array.isArray(values) ? values : []; return items.length ? detailBlock("BLOCKERS", items.map(value => `<div class="blocker"><strong>${escapeHtml(value.code || "blocked")}</strong><br>${escapeHtml(value.summary || value.message || "资源不可用")}</div>`).join("")) : ""; }
|
||||||
|
function resourceKey(item) { return `${item.resourceType}:${item.hwpodId || item.nodeId}`; }
|
||||||
|
function displayTime(value) { if (!value) return "尚未同步"; const date = new Date(value); return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("zh-CN", { hour12: false }); }
|
||||||
|
async function getJson(url) { const response = await fetch(url, { headers: { accept: "application/json" } }); const body = await response.json().catch(() => null); if (!response.ok || !body) throw new Error(body?.error?.message || `${url} 返回 HTTP ${response.status}`); return body; }
|
||||||
|
function escapeHtml(value) { return String(value ?? "").replace(/[&<>"']/gu, char => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char])); }
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#111410" />
|
||||||
|
<title>HWPOD 控制台</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
<script type="module" src="/app.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell" data-testid="hwpod-console">
|
||||||
|
<header class="command-bar">
|
||||||
|
<div class="identity">
|
||||||
|
<span class="mark" aria-hidden="true">HP</span>
|
||||||
|
<div><strong>HWPOD</strong><small>硬件资源控制台</small></div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<span class="connection" data-health="loading"><i></i><span data-health-label>连接中</span></span>
|
||||||
|
<button type="button" data-refresh>刷新</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="status-strip" aria-label="HWPOD 状态摘要">
|
||||||
|
<div><span>节点</span><strong data-stat="nodes">-</strong></div>
|
||||||
|
<div><span>在线</span><strong data-stat="online">-</strong></div>
|
||||||
|
<div><span>设备</span><strong data-stat="devices">-</strong></div>
|
||||||
|
<div><span>可用</span><strong data-stat="available">-</strong></div>
|
||||||
|
<div><span>阻塞</span><strong data-stat="blocked">-</strong></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main class="workspace">
|
||||||
|
<section class="resource-pane" aria-labelledby="resource-title">
|
||||||
|
<div class="pane-heading"><div><p>RESOURCE MAP</p><h1 id="resource-title">节点与设备</h1></div><time data-observed>尚未同步</time></div>
|
||||||
|
<div class="resource-list" data-resources><div class="empty">正在读取 HWPOD topology...</div></div>
|
||||||
|
</section>
|
||||||
|
<aside class="detail-pane" aria-labelledby="detail-title">
|
||||||
|
<div class="pane-heading"><div><p>INSPECTOR</p><h2 id="detail-title">资源详情</h2></div></div>
|
||||||
|
<div class="detail" data-detail>
|
||||||
|
<div class="detail-empty"><strong>选择一个资源</strong><span>查看能力、占用状态和 blocker。</span></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
:root { color-scheme: dark; font-family: "IBM Plex Sans", "Noto Sans SC", sans-serif; background: #111410; color: #eef2e8; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { width: 100%; min-height: 100%; margin: 0; }
|
||||||
|
body { background: #111410; letter-spacing: 0; overflow: hidden; }
|
||||||
|
button { font: inherit; letter-spacing: 0; }
|
||||||
|
.shell { min-height: 100vh; display: grid; grid-template-rows: 58px 54px minmax(0, 1fr); background-image: linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px); background-size: 28px 28px; }
|
||||||
|
.command-bar { display: flex; align-items: center; justify-content: space-between; padding: 0 22px; border-bottom: 1px solid #30372e; background: rgba(17,20,16,.94); }
|
||||||
|
.identity, .actions, .connection { display: flex; align-items: center; }
|
||||||
|
.identity { gap: 12px; }
|
||||||
|
.identity .mark { display: grid; place-items: center; width: 32px; height: 32px; border: 1px solid #a7ff3f; color: #a7ff3f; font: 700 12px/1 "IBM Plex Mono", monospace; }
|
||||||
|
.identity div { display: grid; gap: 2px; }
|
||||||
|
.identity strong { font-size: 15px; }
|
||||||
|
.identity small { color: #8d9788; font-size: 11px; }
|
||||||
|
.actions { gap: 16px; }
|
||||||
|
.connection { gap: 8px; color: #aeb6a8; font-size: 12px; }
|
||||||
|
.connection i { width: 7px; height: 7px; border-radius: 50%; background: #e9bd54; box-shadow: 0 0 12px #e9bd54; }
|
||||||
|
.connection[data-health="ready"] i { background: #a7ff3f; box-shadow: 0 0 12px #a7ff3f; }
|
||||||
|
.connection[data-health="blocked"] i { background: #ff6b57; box-shadow: 0 0 12px #ff6b57; }
|
||||||
|
.actions button { min-width: 66px; height: 30px; border: 1px solid #495044; background: #191d18; color: #eef2e8; cursor: pointer; }
|
||||||
|
.actions button:hover { border-color: #a7ff3f; color: #a7ff3f; }
|
||||||
|
.status-strip { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); border-bottom: 1px solid #30372e; background: #181c17; }
|
||||||
|
.status-strip div { min-width: 0; display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 10px 18px; border-right: 1px solid #30372e; }
|
||||||
|
.status-strip span { color: #8d9788; font-size: 11px; }
|
||||||
|
.status-strip strong { font: 600 20px/1 "IBM Plex Mono", monospace; color: #f4f7f0; }
|
||||||
|
.workspace { min-height: 0; display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(320px, .75fr); }
|
||||||
|
.resource-pane, .detail-pane { min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); }
|
||||||
|
.resource-pane { border-right: 1px solid #30372e; }
|
||||||
|
.pane-heading { min-height: 68px; display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-bottom: 1px solid #30372e; background: rgba(17,20,16,.84); }
|
||||||
|
.pane-heading p { margin: 0 0 4px; color: #a7ff3f; font: 600 10px/1 "IBM Plex Mono", monospace; }
|
||||||
|
.pane-heading h1, .pane-heading h2 { margin: 0; font-size: 17px; font-weight: 600; }
|
||||||
|
.pane-heading time { color: #717b6d; font: 11px/1 "IBM Plex Mono", monospace; }
|
||||||
|
.resource-list, .detail { min-height: 0; overflow: auto; padding: 14px; }
|
||||||
|
.resource-list { display: grid; align-content: start; gap: 8px; }
|
||||||
|
.resource { width: 100%; min-height: 76px; display: grid; grid-template-columns: 12px minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 12px 14px; border: 1px solid #333a30; background: #171b16; color: inherit; text-align: left; cursor: pointer; }
|
||||||
|
.resource:hover, .resource[aria-current="true"] { border-color: #829e65; background: #1c211a; }
|
||||||
|
.resource .rail { width: 3px; height: 44px; background: #596255; }
|
||||||
|
.resource[data-status="online"] .rail, .resource[data-status="available"] .rail { background: #a7ff3f; }
|
||||||
|
.resource[data-status="busy"] .rail { background: #e9bd54; }
|
||||||
|
.resource[data-status="offline"] .rail, .resource[data-status="blocked"] .rail { background: #ff6b57; }
|
||||||
|
.resource-copy { min-width: 0; display: grid; gap: 5px; }
|
||||||
|
.resource-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
|
||||||
|
.resource-copy span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #8d9788; font: 11px/1.35 "IBM Plex Mono", monospace; }
|
||||||
|
.badge { border: 1px solid #41483d; padding: 5px 8px; color: #b6beb1; font: 10px/1 "IBM Plex Mono", monospace; text-transform: uppercase; }
|
||||||
|
.detail { display: grid; align-content: start; gap: 14px; }
|
||||||
|
.detail-empty, .empty { min-height: 180px; display: grid; place-content: center; gap: 7px; color: #747d70; text-align: center; }
|
||||||
|
.detail-empty strong { color: #c8cec3; }
|
||||||
|
.detail-block { border-top: 1px solid #353c32; padding-top: 14px; }
|
||||||
|
.detail-block h3 { margin: 0 0 10px; color: #8d9788; font: 600 10px/1 "IBM Plex Mono", monospace; }
|
||||||
|
.kv { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 8px 14px; font-size: 12px; }
|
||||||
|
.kv dt { color: #7f897a; }
|
||||||
|
.kv dd { margin: 0; overflow-wrap: anywhere; color: #e5e9df; font-family: "IBM Plex Mono", monospace; }
|
||||||
|
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.chip { border: 1px solid #3f493b; padding: 5px 7px; color: #c8d0c1; font: 10px/1 "IBM Plex Mono", monospace; }
|
||||||
|
.blocker { border-left: 3px solid #ff6b57; padding: 10px 12px; background: #271a17; color: #ffc1b8; font-size: 12px; }
|
||||||
|
@media (max-width: 760px) { body { overflow: auto; } .shell { min-height: 100svh; grid-template-rows: 58px auto auto; } .status-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); } .status-strip div:nth-child(n+4) { border-top: 1px solid #30372e; } .workspace { grid-template-columns: 1fr; } .resource-pane { border-right: 0; } .detail-pane { border-top: 1px solid #30372e; min-height: 420px; } .resource-list, .detail { overflow: visible; } }
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
"deps": "node ../../scripts/worktree-deps.mjs --web",
|
"deps": "node ../../scripts/worktree-deps.mjs --web",
|
||||||
"dev": "bun run deps --quiet && vite",
|
"dev": "bun run deps --quiet && vite",
|
||||||
"dev:native-caserun": "CHOKIDAR_USEPOLLING=1 HWLAB_CASERUN_NATIVE_TEST=1 bun run dev",
|
"dev:native-caserun": "CHOKIDAR_USEPOLLING=1 HWLAB_CASERUN_NATIVE_TEST=1 bun run dev",
|
||||||
|
"dev:native-hwpod": "bun run deps --quiet && vite --config scripts/hwpod-native-vite.config.ts",
|
||||||
"caserun:native": "bun scripts/caserun-native-supervisor.ts",
|
"caserun:native": "bun scripts/caserun-native-supervisor.ts",
|
||||||
"caserun:native:start": "bun run scripts/caserun-native-service.ts start",
|
"caserun:native:start": "bun run scripts/caserun-native-service.ts start",
|
||||||
"caserun:native:stop": "bun run scripts/caserun-native-service.ts stop",
|
"caserun:native:stop": "bun run scripts/caserun-native-service.ts stop",
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
* SPEC: PJ2026-010103 HWPOD 服务。
|
||||||
|
* 实现引用: draft-2026-07-20-hwpod-temporal-split。
|
||||||
|
* 责任: 独立 HWPOD L1 Web 的固定端口、健康检查和 API proxy。
|
||||||
|
*/
|
||||||
|
import path from "node:path";
|
||||||
|
import { defineConfig, type Plugin } from "vite";
|
||||||
|
|
||||||
|
const apiUrl = process.env.HWPOD_NATIVE_API_URL;
|
||||||
|
if (!apiUrl) throw new Error("HWPOD_NATIVE_API_URL is required");
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: path.resolve(import.meta.dirname, "../hwpod"),
|
||||||
|
server: {
|
||||||
|
host: process.env.HWPOD_WEB_HOST || "0.0.0.0",
|
||||||
|
port: requiredPort(process.env.HWPOD_WEB_PORT),
|
||||||
|
strictPort: true,
|
||||||
|
proxy: { "/v1": { target: apiUrl, changeOrigin: false }, "/health/ready": { target: apiUrl, changeOrigin: false } }
|
||||||
|
},
|
||||||
|
plugins: [healthPlugin()]
|
||||||
|
});
|
||||||
|
|
||||||
|
function healthPlugin(): Plugin { return { name: "hwpod-native-health", configureServer(server) { server.middlewares.use((request, response, next) => { if (request.url !== "/health/live") return next(); response.statusCode = 200; response.setHeader("content-type", "application/json"); response.end(JSON.stringify({ ok: true, serviceId: "hwlab-hwpod-web", status: "live", valuesPrinted: false })); }); } }; }
|
||||||
|
function requiredPort(value: string | undefined) { const port = Number.parseInt(String(value ?? ""), 10); if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("HWPOD_WEB_PORT is required and must be valid"); return port; }
|
||||||
Reference in New Issue
Block a user