From dafa6ed1d2cdf7b542769754dca296c2bc7bea25 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 13 Jul 2026 05:08:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(cloud):=20=E6=8F=90=E4=BE=9B=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=8F=B0=E8=81=9A=E5=90=88=E6=91=98=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cloud/server-dashboard-http.test.ts | 198 +++++++++ internal/cloud/server-dashboard-http.ts | 432 +++++++++++++++++++ internal/cloud/server.ts | 7 + 3 files changed, 637 insertions(+) create mode 100644 internal/cloud/server-dashboard-http.test.ts create mode 100644 internal/cloud/server-dashboard-http.ts diff --git a/internal/cloud/server-dashboard-http.test.ts b/internal/cloud/server-dashboard-http.test.ts new file mode 100644 index 00000000..4d89d2c9 --- /dev/null +++ b/internal/cloud/server-dashboard-http.test.ts @@ -0,0 +1,198 @@ +import assert from "node:assert/strict"; +import { test } from "bun:test"; + +import { createCloudApiServer } from "./server.ts"; +import { DASHBOARD_SUMMARY_CONTRACT_VERSION } from "./server-dashboard-http.ts"; + +test("dashboard summary returns one typed bounded read model from real domain contracts", async () => { + const sessionToken = "hws_dashboard_contract_secret"; + const requestedNavIds: string[] = []; + const auth = { + ok: true, + actor: { id: "usr_dashboard", username: "operator", displayName: "控制台操作员", role: "admin" }, + authMethod: "user-billing-session", + session: { id: "ses_dashboard" }, + userBilling: { active: true, token: sessionToken, valuesRedacted: true } + }; + const userBillingClient = { + configured: true, + async billingSummary(token: string, options: { limit: number }) { + assert.equal(token, sessionToken); + assert.deepEqual(options, { limit: 5 }); + return { + ok: true, + status: 200, + body: { + contractVersion: "user-billing-summary-v1", + status: "ready", + planId: "plan_team", + plan: { id: "plan_team", displayName: "Team", status: "active" }, + credits: { balance: 240, reserved: 20, available: 220 }, + entitlements: [{ resourceType: "code-agent" }, { resourceType: "hwpod" }], + reservations: { activeCount: 1 } + } + }; + } + }; + const server = createCloudApiServer({ + env: { HWLAB_PROJECT_MANAGEMENT_URL: "http://project-management.test", HWLAB_WORKBENCH_EMPTY_SESSION_GC_ENABLED: "0" }, + accessController: dashboardAccessController(auth, userBillingClient, requestedNavIds), + userBillingClient, + hwpodNodeWsRegistry: dashboardHwpodRegistry(), + dashboardDiscoverHwpodSpecs: async () => [dashboardHwpodSpec()], + dashboardNow: () => "2026-07-13T12:00:00.000Z", + fetchImpl: async (input: URL | RequestInfo) => projectManagementResponse(new URL(String(input))), + kafkaEventBridge: disabledKafkaBridge() + }); + await listen(server); + + try { + const response = await fetch(`http://127.0.0.1:${(server.address() as any).port}/v1/dashboard/summary`); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.contractVersion, DASHBOARD_SUMMARY_CONTRACT_VERSION); + assert.equal(body.route, "/v1/dashboard/summary"); + assert.equal(body.status, "ready"); + assert.equal(body.actor.id, "usr_dashboard"); + assert.deepEqual(body.sources.map((source: any) => [source.id, source.state]), [["work", "ready"], ["hwpod", "ready"], ["billing", "ready"]]); + assert.equal(body.work.projectCount, 2); + assert.deepEqual(body.work.taskCounts, { open: 2, inProgress: 1, blocked: 1 }); + assert.deepEqual(body.work.continuations.map((task: any) => task.taskId), ["R2", "R1"]); + assert.equal(body.work.continuations[0].href, "/projects/mdtodo/sources/source-main/files/file-main/tasks/R2"); + assert.equal(body.hwpod.nodeCount, 1); + assert.equal(body.hwpod.onlineNodeCount, 1); + assert.equal(body.hwpod.availableDeviceCount, 1); + assert.deepEqual(body.billing.plan, { id: "plan_team", displayName: "Team", status: "active" }); + assert.deepEqual(body.billing.credits, { balance: 240, reserved: 20, available: 220 }); + assert.equal(body.billing.entitlementCount, 2); + assert.equal(body.billing.activeReservationCount, 1); + assert.equal(body.issues.some((issue: any) => issue.code === "mdtodo_tasks_blocked" && issue.count === 1), true); + assert.equal(JSON.stringify(body).includes(sessionToken), false); + assert.equal(body.valuesRedacted, true); + assert.equal(requestedNavIds.includes("user.dashboard"), true); + } finally { + await close(server); + } +}); + +test("dashboard summary keeps successful sections when upstreams are partial, stale, or restricted", async () => { + const auth = { + ok: true, + actor: { id: "usr_dashboard_partial", username: "member", role: "user" }, + authMethod: "user-billing-session", + session: { id: "ses_dashboard_partial" }, + userBilling: { active: true, token: "hws_dashboard_partial", valuesRedacted: true } + }; + const userBillingClient = { + configured: true, + async billingSummary() { + return { ok: true, status: 200, body: { status: "stale", observedAt: "2026-07-12T12:00:00.000Z", planId: "default", credits: { available: 8 } } }; + } + }; + const requestedNavIds: string[] = []; + const accessController = dashboardAccessController(auth, userBillingClient, requestedNavIds, (navId) => navId !== "admin.hwpodGroups"); + const server = createCloudApiServer({ + env: { HWLAB_PROJECT_MANAGEMENT_URL: "http://project-management.test", HWLAB_WORKBENCH_EMPTY_SESSION_GC_ENABLED: "0" }, + accessController, + userBillingClient, + hwpodNodeWsRegistry: dashboardHwpodRegistry(), + dashboardDiscoverHwpodSpecs: async () => { throw new Error("restricted HWPOD discovery must not run"); }, + dashboardNow: () => "2026-07-13T12:00:00.000Z", + fetchImpl: async (input: URL | RequestInfo) => { + const url = new URL(String(input)); + if (url.searchParams.get("status") === "in_progress") return jsonResponse(503, { ok: false, error: { code: "projection_refreshing" } }); + return projectManagementResponse(url); + }, + kafkaEventBridge: disabledKafkaBridge() + }); + await listen(server); + + try { + const response = await fetch(`http://127.0.0.1:${(server.address() as any).port}/v1/dashboard/summary`); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.status, "partial"); + assert.equal(body.work.state, "partial"); + assert.equal(body.work.projectCount, 2); + assert.equal(body.work.taskCounts.inProgress, null); + assert.equal(body.hwpod.state, "restricted"); + assert.equal(body.hwpod.nodeCount, null); + assert.equal(body.billing.state, "stale"); + assert.equal(body.billing.credits.available, 8); + assert.equal(body.issues.some((issue: any) => issue.code === "work_summary_partial"), true); + assert.equal(body.issues.some((issue: any) => issue.code === "billing_summary_stale"), true); + assert.equal(body.issues.some((issue: any) => issue.domain === "hwpod"), false); + assert.equal(body.sources.find((source: any) => source.id === "hwpod").reason, "当前身份无 HWPOD topology 访问权限"); + assert.equal(requestedNavIds.includes("user.dashboard"), true); + } finally { + await close(server); + } +}); + +function dashboardAccessController(auth: any, userBilling: any, requestedNavIds: string[], allow: (navId: string) => boolean = () => true) { + return { + required: true, + userBilling, + async requireNavAccess(_request: unknown, _response: unknown, navId: string) { + requestedNavIds.push(navId); + return allow(navId) ? auth : null; + }, + async authenticate() { return auth; }, + navAccessAllowed(_actor: unknown, navId: string) { return allow(navId); } + }; +} + +function projectManagementResponse(url: URL) { + if (url.pathname === "/v1/project-management/projects") { + return jsonResponse(200, { ok: true, contractVersion: "project-management-v1", projects: [{ projectId: "project_alpha", name: "Alpha", status: "active" }, { projectId: "project_beta", name: "Beta", status: "ready" }] }); + } + if (url.pathname === "/v1/project-management/mdtodo/tasks") { + const status = url.searchParams.get("status"); + const task = (taskId: string, taskStatus: string) => ({ taskRef: `mdtodo:source-main:file-main:${taskId}`, taskId, title: `Task ${taskId}`, status: taskStatus, projectId: "project_alpha", sourceId: "source-main", fileRef: "file-main", updatedAt: "2026-07-13T10:00:00.000Z" }); + if (status === "in_progress") return jsonResponse(200, { ok: true, tasks: [task("R2", status)], page: { offset: 0, limit: 6, total: 1, hasMore: false } }); + if (status === "open") return jsonResponse(200, { ok: true, tasks: [task("R1", status)], page: { offset: 0, limit: 6, total: 2, hasMore: true } }); + if (status === "blocked") return jsonResponse(200, { ok: true, tasks: [task("R3", status)], page: { offset: 0, limit: 6, total: 1, hasMore: false } }); + } + return jsonResponse(404, { ok: false, error: { code: "not_found" } }); +} + +function dashboardHwpodSpec() { + return { + ok: true, + hwpodId: "hwpod-demo", + name: "hwpod-demo", + nodeId: "node-demo", + authority: "test-registry", + specPath: "/redacted/hwpod-spec.yaml", + availability: { ok: true, status: "available", checkedAt: "2026-07-13T11:59:00.000Z" }, + document: { spec: {} } + }; +} + +function dashboardHwpodRegistry() { + return { + describe() { + return { + mode: "hwpod-node-outbound-native-ws", + pendingCount: 0, + nodes: [{ nodeId: "node-demo", name: "Demo Node", status: "online", capabilities: ["node.health", "node.version", "node.inventory", "node.diagnostics"], inFlightCount: 0, maxInFlight: 1 }] + }; + } + }; +} + +function disabledKafkaBridge() { + return { ready: Promise.resolve(), async stop() {}, describe() { return { status: "disabled" }; } }; +} + +function jsonResponse(status: number, body: unknown) { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +async function listen(server: any) { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +} + +async function close(server: any) { + await new Promise((resolve, reject) => server.close((error: Error | null) => error ? reject(error) : resolve())); +} diff --git a/internal/cloud/server-dashboard-http.ts b/internal/cloud/server-dashboard-http.ts new file mode 100644 index 00000000..791058fb --- /dev/null +++ b/internal/cloud/server-dashboard-http.ts @@ -0,0 +1,432 @@ +/* + * SPEC: PJ2026-010405 云端控制台。 + * Implementation reference: draft-2026-07-13-p0-cloud-console。 + * Responsibility: 聚合当前用户可见的项目、HWPOD 与账务事实,输出有界 typed Dashboard read model。 + */ +import { discoverHwpodSpecs } from "./hwpod-spec-discovery.ts"; +import { buildHwpodTopologyReadModel } from "./hwpod-topology-read-model.ts"; +import { getHeader, sendJson } from "./server-http-utils.ts"; + +export const DASHBOARD_SUMMARY_CONTRACT_VERSION = "cloud-dashboard-summary-v1"; + +export type DashboardSourceState = "ready" | "partial" | "stale" | "unavailable" | "restricted"; + +export interface DashboardSourceStatus { + id: "work" | "hwpod" | "billing"; + label: string; + authority: string; + state: DashboardSourceState; + observedAt: string | null; + reason: string | null; + href: string; +} + +export interface DashboardIssue { + id: string; + domain: DashboardSourceStatus["id"]; + severity: "warning" | "error"; + code: string; + title: string; + summary: string; + count: number | null; + action: { label: string; href: string }; +} + +export interface DashboardTaskContinuation { + taskRef: string; + taskId: string; + title: string; + status: string; + projectId: string | null; + sourceId: string; + fileRef: string; + updatedAt: string | null; + href: string; +} + +export interface DashboardSummaryResponse { + ok: true; + status: "ready" | "partial" | "stale"; + contractVersion: typeof DASHBOARD_SUMMARY_CONTRACT_VERSION; + route: "/v1/dashboard/summary"; + observedAt: string; + actor: { id: string; displayName: string; role: string }; + sources: DashboardSourceStatus[]; + work: { + state: DashboardSourceState; + authority: "hwlab-project-management"; + observedAt: string | null; + projectCount: number | null; + projects: Array<{ projectId: string; title: string; status: string; href: string }>; + taskCounts: { open: number | null; inProgress: number | null; blocked: number | null }; + continuations: DashboardTaskContinuation[]; + }; + hwpod: { + state: DashboardSourceState; + authority: "cloud-api-hwpod-domain"; + observedAt: string | null; + nodeCount: number | null; + onlineNodeCount: number | null; + offlineNodeCount: number | null; + deviceCount: number | null; + availableDeviceCount: number | null; + busyDeviceCount: number | null; + capabilityMismatchCount: number | null; + invalidSpecCount: number | null; + }; + billing: { + state: DashboardSourceState; + authority: "hwlab-user-billing"; + observedAt: string | null; + plan: { id: string | null; displayName: string | null; status: string | null } | null; + credits: { balance: number | null; reserved: number | null; available: number | null } | null; + entitlementCount: number | null; + activeReservationCount: number | null; + }; + issues: DashboardIssue[]; + valuesRedacted: true; +} + +interface DashboardAuth { + actor?: Record | null; + authMethod?: string | null; + userBilling?: { active?: boolean; token?: string } | null; + apiKey?: unknown; + session?: unknown; +} + +export async function handleDashboardSummaryHttp(request: any, response: any, options: any = {}) { + if (request.method !== "GET") { + sendJson(response, 405, { + ok: false, + contractVersion: DASHBOARD_SUMMARY_CONTRACT_VERSION, + error: { code: "method_not_allowed", message: "Dashboard summary only supports GET" }, + valuesRedacted: true + }); + return; + } + + const auth = await options.accessController.authenticate(request, { required: true }); + if (!auth.ok) { + sendJson(response, auth.status, auth); + return; + } + + const observedAt = dashboardNow(options); + const [work, hwpod, billing] = await Promise.all([ + loadWorkSummary(request, auth, options, observedAt), + loadHwpodSummary(auth, options, observedAt), + loadBillingSummary(auth, options, observedAt) + ]); + const sources = [sourceStatus("work", "项目与任务", "hwlab-project-management", work, "/projects"), sourceStatus("hwpod", "HWPOD", "cloud-api-hwpod-domain", hwpod, "/hwpods/devices"), sourceStatus("billing", "额度与 Plan", "hwlab-user-billing", billing, "/usage")]; + const payload: DashboardSummaryResponse = { + ok: true, + status: aggregateStatus(sources), + contractVersion: DASHBOARD_SUMMARY_CONTRACT_VERSION, + route: "/v1/dashboard/summary", + observedAt, + actor: publicActor(auth.actor), + sources, + work: publicWorkSummary(work), + hwpod: publicHwpodSummary(hwpod), + billing: publicBillingSummary(billing), + issues: dashboardIssues(work, hwpod, billing), + valuesRedacted: true + }; + sendJson(response, 200, payload); +} + +async function loadWorkSummary(request: any, auth: DashboardAuth, options: any, observedAt: string) { + if (!navAllowed(options.accessController, auth.actor, "project.mdtodo")) return restrictedSection(observedAt, "当前身份无项目管理访问权限"); + const baseUrl = text(options.env?.HWLAB_PROJECT_MANAGEMENT_URL); + if (!baseUrl) return unavailableSection(observedAt, "project_management_service_unconfigured"); + const fetchImpl = options.fetchImpl ?? fetch; + const requests = [ + ["projects", "/v1/project-management/projects"], + ["open", "/v1/project-management/mdtodo/tasks?status=open&limit=6&offset=0"], + ["inProgress", "/v1/project-management/mdtodo/tasks?status=in_progress&limit=6&offset=0"], + ["blocked", "/v1/project-management/mdtodo/tasks?status=blocked&limit=6&offset=0"] + ] as const; + const settled = await Promise.all(requests.map(async ([id, pathname]) => { + try { + return [id, { ok: true, body: await projectManagementJson(request, auth, fetchImpl, baseUrl, pathname, options) }] as const; + } catch (error) { + return [id, { ok: false, reason: safeReason(error) }] as const; + } + })); + const results = Object.fromEntries(settled) as Record; reason?: string }>; + const failures = requests.map(([id]) => results[id]).filter((item) => !item?.ok); + const projectsPayload = results.projects?.body; + const projects = array(projectsPayload?.projects).map(projectSummary).filter(Boolean).slice(0, 6); + const inProgressTasks = array(results.inProgress?.body?.tasks); + const openTasks = array(results.open?.body?.tasks); + const state = failures.length > 0 ? "partial" : strongestPayloadState(settled.map(([, result]) => result.body)); + return { + state, + observedAt: newestObservedAt(settled.map(([, result]) => result.body), observedAt), + reason: failures.length ? `${failures.length} 个 Project Management 查询未完成:${failures.map((item) => item.reason).filter(Boolean).join(";")}` : null, + projectCount: results.projects?.ok ? array(projectsPayload?.projects).length : null, + projects, + taskCounts: { + open: taskTotal(results.open), + inProgress: taskTotal(results.inProgress), + blocked: taskTotal(results.blocked) + }, + continuations: [...inProgressTasks, ...openTasks].map(taskContinuation).filter(Boolean).slice(0, 6) + }; +} + +async function projectManagementJson(request: any, auth: DashboardAuth, fetchImpl: typeof fetch, baseUrl: string, pathname: string, options: any) { + const url = new URL(pathname, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`); + const headers = new Headers({ accept: "application/json", "x-hwlab-bridge-service": "hwlab-cloud-api" }); + for (const name of ["x-request-id", "traceparent", "tracestate"]) { + const value = getHeader(request, name); + if (value) headers.set(name, value); + } + if (auth.actor?.id) headers.set("x-hwlab-actor-id", text(auth.actor.id)); + if (auth.actor?.role) headers.set("x-hwlab-actor-role", text(auth.actor.role)); + headers.set("x-hwlab-auth-method", text(auth.authMethod) || (auth.apiKey ? "api-key" : auth.session ? "web-session" : "unknown")); + const timeoutMs = positiveInteger(options.env?.HWLAB_PROJECT_MANAGEMENT_PROXY_TIMEOUT_MS, 30000); + const upstream = await fetchImpl(url, { method: "GET", headers, signal: AbortSignal.timeout(timeoutMs) }); + const body = await upstream.json().catch(() => null); + if (!upstream.ok || !isRecord(body) || body.ok === false) { + const error = isRecord(body?.error) ? body.error : {}; + throw new Error(text(error.code) || text(error.message) || `project_management_http_${upstream.status}`); + } + return body; +} + +async function loadHwpodSummary(auth: DashboardAuth, options: any, observedAt: string) { + if (!navAllowed(options.accessController, auth.actor, "admin.hwpodGroups")) return restrictedSection(observedAt, "当前身份无 HWPOD topology 访问权限"); + try { + const discover = options.dashboardDiscoverHwpodSpecs ?? discoverHwpodSpecs; + const specs = await discover(options); + const registrySnapshot = options.hwpodNodeWsRegistry?.describe?.() ?? {}; + const topology = buildHwpodTopologyReadModel(specs, registrySnapshot, { observedAt, resource: "node", limit: 1 }); + return { + state: payloadState(topology.status), + observedAt: text(topology.observedAt) || observedAt, + reason: topology.status === "partial" ? `${number(topology.summary?.invalidSpecCount)} 个 HWPOD spec 无法解析` : null, + ...topology.summary + }; + } catch (error) { + return unavailableSection(observedAt, safeReason(error)); + } +} + +async function loadBillingSummary(auth: DashboardAuth, options: any, observedAt: string) { + if (!navAllowed(options.accessController, auth.actor, "user.usage")) return restrictedSection(observedAt, "当前身份无用户账务访问权限"); + const client = options.userBillingClient ?? options.accessController?.userBilling ?? null; + const token = text(auth.userBilling?.token); + if (auth.userBilling?.active !== true || !token) return unavailableSection(observedAt, "user_billing_auth_unavailable"); + if (!client?.configured || typeof client.billingSummary !== "function") return unavailableSection(observedAt, "user_billing_not_configured"); + try { + const result = await client.billingSummary(token, { limit: 5 }); + if (!result?.ok || !isRecord(result.body)) return unavailableSection(observedAt, text(result?.error?.code) || `user_billing_http_${number(result?.status) || 502}`); + const body = result.body; + const plan = isRecord(body.plan) ? body.plan : null; + const credits = isRecord(body.credits) ? body.credits : null; + const reservations = isRecord(body.reservations) ? body.reservations : null; + return { + state: payloadState(body.status), + observedAt: text(body.observedAt) || observedAt, + reason: null, + plan: plan ? { id: nullableText(plan.id ?? body.planId), displayName: nullableText(plan.displayName), status: nullableText(plan.status) } : body.planId ? { id: nullableText(body.planId), displayName: null, status: null } : null, + credits: credits ? { balance: nullableNumber(credits.balance), reserved: nullableNumber(credits.reserved), available: nullableNumber(credits.available) } : null, + entitlementCount: Array.isArray(body.entitlements) ? body.entitlements.length : null, + activeReservationCount: reservations ? nullableNumber(reservations.activeCount) : null + }; + } catch (error) { + return unavailableSection(observedAt, safeReason(error)); + } +} + +function publicWorkSummary(value: any): DashboardSummaryResponse["work"] { + return { + state: value.state, + authority: "hwlab-project-management", + observedAt: value.observedAt ?? null, + projectCount: value.projectCount ?? null, + projects: value.projects ?? [], + taskCounts: value.taskCounts ?? { open: null, inProgress: null, blocked: null }, + continuations: value.continuations ?? [] + }; +} + +function publicHwpodSummary(value: any): DashboardSummaryResponse["hwpod"] { + return { + state: value.state, + authority: "cloud-api-hwpod-domain", + observedAt: value.observedAt ?? null, + nodeCount: nullableNumber(value.nodeCount), + onlineNodeCount: nullableNumber(value.onlineNodeCount), + offlineNodeCount: nullableNumber(value.offlineNodeCount), + deviceCount: nullableNumber(value.deviceCount), + availableDeviceCount: nullableNumber(value.availableDeviceCount), + busyDeviceCount: nullableNumber(value.busyDeviceCount), + capabilityMismatchCount: nullableNumber(value.capabilityMismatchCount), + invalidSpecCount: nullableNumber(value.invalidSpecCount) + }; +} + +function publicBillingSummary(value: any): DashboardSummaryResponse["billing"] { + return { + state: value.state, + authority: "hwlab-user-billing", + observedAt: value.observedAt ?? null, + plan: value.plan ?? null, + credits: value.credits ?? null, + entitlementCount: value.entitlementCount ?? null, + activeReservationCount: value.activeReservationCount ?? null + }; +} + +function dashboardIssues(work: any, hwpod: any, billing: any): DashboardIssue[] { + const issues: DashboardIssue[] = []; + dependencyIssue(issues, "work", work, "项目与任务", "/projects"); + dependencyIssue(issues, "hwpod", hwpod, "HWPOD", "/hwpods/devices"); + dependencyIssue(issues, "billing", billing, "额度与 Plan", "/usage"); + if (number(work.taskCounts?.blocked) > 0) issues.push(issue("work-blocked-tasks", "work", "warning", "mdtodo_tasks_blocked", "存在阻塞任务", `${number(work.taskCounts.blocked)} 个 MDTODO 任务由 Project Management 标记为 blocked。`, number(work.taskCounts.blocked), "查看阻塞任务", "/projects/mdtodo?filter=blocked")); + if (number(hwpod.offlineNodeCount) > 0) issues.push(issue("hwpod-offline-nodes", "hwpod", "error", "hwpod_node_offline", "HWPOD Node 离线", `${number(hwpod.offlineNodeCount)} 个已声明 Node 当前未连接。`, number(hwpod.offlineNodeCount), "查看 Node", "/hwpods/nodes?status=offline")); + if (number(hwpod.capabilityMismatchCount) > 0) issues.push(issue("hwpod-capability-mismatch", "hwpod", "error", "hwpod_capability_mismatch", "HWPOD 能力不匹配", `${number(hwpod.capabilityMismatchCount)} 个设备缺少声明能力。`, number(hwpod.capabilityMismatchCount), "查看设备", "/hwpods/devices?status=capability-mismatch")); + if (number(hwpod.invalidSpecCount) > 0) issues.push(issue("hwpod-invalid-specs", "hwpod", "error", "invalid_hwpod_spec", "HWPOD spec 无效", `${number(hwpod.invalidSpecCount)} 个 spec 无法进入 topology。`, number(hwpod.invalidSpecCount), "查看设备", "/hwpods/devices")); + const planStatus = text(billing.plan?.status).toLowerCase(); + if (["blocked", "disabled", "inactive", "past_due", "suspended"].includes(planStatus)) issues.push(issue("billing-plan-status", "billing", "error", "billing_plan_not_active", "Plan 状态异常", `user-billing 返回 plan status=${planStatus}。`, null, "查看账务", "/billing")); + return dedupeIssues(issues).slice(0, 10); +} + +function dependencyIssue(target: DashboardIssue[], domain: DashboardIssue["domain"], value: any, label: string, href: string) { + if (value.state === "unavailable") target.push(issue(`${domain}-unavailable`, domain, "error", `${domain}_summary_unavailable`, `${label}摘要不可用`, value.reason || `${label} authority 未返回摘要。`, null, `打开${label}`, href)); + if (value.state === "stale") target.push(issue(`${domain}-stale`, domain, "warning", `${domain}_summary_stale`, `${label}摘要已过期`, value.reason || `${label} authority 明确返回 stale。`, null, `打开${label}`, href)); + if (value.state === "partial" && value.reason) target.push(issue(`${domain}-partial`, domain, "warning", `${domain}_summary_partial`, `${label}摘要不完整`, value.reason, null, `打开${label}`, href)); +} + +function issue(id: string, domain: DashboardIssue["domain"], severity: DashboardIssue["severity"], code: string, title: string, summary: string, count: number | null, label: string, href: string): DashboardIssue { + return { id, domain, severity, code, title, summary, count, action: { label, href } }; +} + +function sourceStatus(id: DashboardSourceStatus["id"], label: string, authority: string, value: any, href: string): DashboardSourceStatus { + return { id, label, authority, state: value.state, observedAt: value.observedAt ?? null, reason: value.reason ?? null, href }; +} + +function aggregateStatus(sources: DashboardSourceStatus[]): DashboardSummaryResponse["status"] { + const states = sources.filter((source) => source.state !== "restricted").map((source) => source.state); + if (states.some((state) => state === "partial" || state === "unavailable")) return "partial"; + if (states.some((state) => state === "stale")) return "stale"; + return "ready"; +} + +function strongestPayloadState(values: Array | undefined>) { + const states = values.filter(Boolean).map((value) => payloadState(value?.status)); + if (states.includes("partial")) return "partial"; + if (states.includes("stale")) return "stale"; + return "ready"; +} + +function payloadState(value: unknown): DashboardSourceState { + const state = text(value).toLowerCase(); + if (state === "stale") return "stale"; + if (["partial", "degraded"].includes(state)) return "partial"; + return "ready"; +} + +function projectSummary(value: unknown): { projectId: string; title: string; status: string; href: string } | null { + if (!isRecord(value)) return null; + const projectId = text(value.projectId); + if (!projectId) return null; + return { projectId, title: text(value.name ?? value.title) || projectId, status: text(value.status) || "unknown", href: `/projects/${encodeURIComponent(projectId)}` }; +} + +function taskContinuation(value: unknown): DashboardTaskContinuation | null { + if (!isRecord(value)) return null; + const taskRef = text(value.taskRef); + const taskId = text(value.taskId); + const sourceId = text(value.sourceId); + const fileRef = text(value.fileRef); + if (!taskRef || !taskId || !sourceId || !fileRef) return null; + return { + taskRef, + taskId, + title: text(value.title) || taskId, + status: text(value.status) || "unknown", + projectId: nullableText(value.projectId), + sourceId, + fileRef, + updatedAt: nullableText(value.updatedAt), + href: `/projects/mdtodo/sources/${encodeURIComponent(sourceId)}/files/${encodeURIComponent(fileRef)}/tasks/${encodeURIComponent(taskId)}` + }; +} + +function taskTotal(result: { ok: boolean; body?: Record } | undefined): number | null { + if (!result?.ok || !isRecord(result.body?.page)) return null; + return nullableNumber(result.body.page.total); +} + +function newestObservedAt(values: Array | undefined>, fallback: string) { + const timestamps = values.map((value) => text(value?.observedAt)).filter(Boolean).sort(); + return timestamps.at(-1) || fallback; +} + +function restrictedSection(observedAt: string, reason: string) { + return { state: "restricted" as const, observedAt, reason }; +} + +function unavailableSection(observedAt: string, reason: string) { + return { state: "unavailable" as const, observedAt, reason }; +} + +function publicActor(actor: Record | null | undefined) { + return { id: text(actor?.id), displayName: text(actor?.displayName ?? actor?.username ?? actor?.name), role: text(actor?.role) || "member" }; +} + +function navAllowed(accessController: any, actor: unknown, navId: string) { + return typeof accessController?.navAccessAllowed !== "function" || accessController.navAccessAllowed(actor, navId) === true; +} + +function dashboardNow(options: any) { + const value = typeof options.dashboardNow === "function" ? options.dashboardNow() : new Date().toISOString(); + return text(value) || new Date().toISOString(); +} + +function safeReason(error: unknown) { + return text((error as any)?.code ?? (error as any)?.message ?? error) + .replace(/\b(Bearer|Basic)\s+\S+/giu, "$1 [REDACTED]") + .replace(/\b(api[_-]?key|token|secret|password|authorization)\s*[:=]\s*\S+/giu, "$1=[REDACTED]") + .slice(0, 240) || "dependency_request_failed"; +} + +function dedupeIssues(values: DashboardIssue[]) { + const seen = new Set(); + return values.filter((value) => !seen.has(value.id) && Boolean(seen.add(value.id))); +} + +function nullableText(value: unknown) { + return text(value) || null; +} + +function nullableNumber(value: unknown): number | null { + if (value === null || value === undefined || value === "") return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function number(value: unknown) { + return nullableNumber(value) ?? 0; +} + +function positiveInteger(value: unknown, fallback: number) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function array(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function text(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : ""; +} diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 769f1d52..0965f300 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -94,6 +94,7 @@ import { import { HWPOD_TOPOLOGY_CONTRACT_VERSION } from "./hwpod-topology-read-model.ts"; import { handleCaseRunHttp } from "./server-caserun-http.ts"; import { handleProjectManagementProxyHttp } from "./project-management-proxy.ts"; +import { handleDashboardSummaryHttp } from "./server-dashboard-http.ts"; import { HWPOD_NODE_OPS, HWPOD_NODE_OPS_CONTRACT_VERSION } from "../../tools/src/hwpod-node-ops-contract.ts"; const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024; @@ -529,6 +530,11 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (url.pathname === "/v1/dashboard/summary") { + await handleDashboardSummaryHttp(request, response, options); + return; + } + if ((url.pathname === "/v1/usage/summary" || url.pathname === "/v1/billing/summary") && request.method === "GET") { await handleUsageSummaryHttp(request, response, url, options); return; @@ -996,6 +1002,7 @@ async function handleRestAdapter(request, response, url, options) { function navIdForRestPath(pathname, method = "GET") { const verb = String(method || "GET").toUpperCase(); + if (pathname === "/v1/dashboard/summary") return "user.dashboard"; if (pathname === "/v1/usage/summary") return "user.usage"; if (pathname === "/v1/billing/summary" || pathname === "/v1/redeem" || pathname === "/v1/subscription/summary" || pathname === "/v1/payments/summary") return "user.billing"; if (pathname.startsWith("/v1/admin/billing/")) return "admin.billing";