Files
pikasTech-HWLAB/internal/cloud/server-dashboard-http.ts
2026-07-13 05:08:40 +02:00

433 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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<string, unknown> | 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<string, { ok: boolean; body?: Record<string, unknown>; 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<Record<string, unknown> | 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<string, unknown> } | undefined): number | null {
if (!result?.ok || !isRecord(result.body?.page)) return null;
return nullableNumber(result.body.page.total);
}
function newestObservedAt(values: Array<Record<string, unknown> | 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<string, unknown> | 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<string>();
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<string, any> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function text(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : "";
}