From 5041dc03c34829e9b124050c1e083e6fb7e6d7e8 Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 4 Aug 2026 01:51:35 +0800 Subject: [PATCH] feat: add admin state and diagnostics (TASK-WP6-05) --- apps/api/src/admin-state.ts | 194 ++++++++++ apps/api/src/app.ts | 63 ++++ apps/api/src/main.ts | 16 + apps/web/src/admin-models.tsx | 15 +- apps/web/src/admin-services-storage.css | 37 ++ apps/web/src/admin-services-storage.tsx | 136 +++++++ apps/web/src/generated/api/sdk.gen.ts | 16 +- apps/web/src/generated/api/types.gen.ts | 38 ++ apps/web/src/main.tsx | 3 +- openapi/openapi.json | 482 ++++++++++++++++++++++++ package.json | 5 +- packages/shared-contracts/src/admin.ts | 69 ++++ tests/api/wp6-05-state.test.ts | 179 +++++++++ tests/e2e/wp6-05-state.spec.ts | 95 +++++ tests/fixtures/wp6-05-state.ts | 34 ++ 15 files changed, 1374 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/admin-state.ts create mode 100644 apps/web/src/admin-services-storage.css create mode 100644 apps/web/src/admin-services-storage.tsx create mode 100644 tests/api/wp6-05-state.test.ts create mode 100644 tests/e2e/wp6-05-state.spec.ts create mode 100644 tests/fixtures/wp6-05-state.ts diff --git a/apps/api/src/admin-state.ts b/apps/api/src/admin-state.ts new file mode 100644 index 0000000..e3a1f92 --- /dev/null +++ b/apps/api/src/admin-state.ts @@ -0,0 +1,194 @@ +import type BetterSqlite3 from "better-sqlite3"; + +import type { AdminDiagnosticsResponse, AdminServicesStorageResponse } from "@dada/shared-contracts"; + +import type { BrowserSupportRelease } from "./browser-support.js"; +import type { ManagedStorage } from "./managed-storage.js"; +import type { ModelConfigurationService } from "./model-configuration.js"; + +type AdminService = AdminServicesStorageResponse["services"][number]; +const adminServiceIds = ["resend", "amap", "ai_gateway", "worker", "api", "asset_root"] as const; +const forbiddenDiagnosticPatterns = [ + /\b(?:api[_ -]?key|secret|password|credential|authorization|bearer|session[_ -]?token|cookie|prompt|email|token)\b/i, + /[A-Z]:[\\/](?:Users|Documents|ProgramData|Windows)[\\/]/i, + /\\\\[^\\\s]+\\[^\s]+/, + /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i, + /https?:\/\//i, +]; +const safePauseReasons = new Set([ + "asset_root_state_missing", "balance_insufficient", "configured_disabled", "contract_blocked", + "contract_unverified", "gateway_balance_insufficient", "gateway_paused", "health_check_failed", + "model_disabled", "provider_unavailable", "quota_exhausted", "service_state_missing", "unknown", + "worker_degraded", "worker_state_missing", "worker_stopped", +]); + +function iso(value: number | string | null | undefined) { + if (value === null || value === undefined) return null; + return typeof value === "number" ? new Date(value).toISOString() : value; +} + +function tableExists(database: BetterSqlite3.Database, table: string) { + return Boolean(database.prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?").get(table)); +} + +function safeService( + service_id: AdminService["service_id"], + status: AdminService["status"], + impact_scope: AdminService["impact_scope"], + configured: boolean, + checked_at: string | null, + pause_reason: string | null = null, +): AdminService { + return { checked_at, configured, impact_scope, pause_reason, service_id, status }; +} + +function safeReason(value: unknown) { + return typeof value === "string" && safePauseReasons.has(value) ? value : null; +} + +function serviceUsage(database: BetterSqlite3.Database, serviceName: string) { + if (!tableExists(database, "external_service_usage")) return undefined; + const columns = new Set((database.prepare("PRAGMA table_info(external_service_usage)").all() as Array<{ name: string }>).map((column) => column.name)); + const serviceColumn = columns.has("service_name") ? "service_name" : columns.has("service_id") ? "service_id" : undefined; + if (!serviceColumn || !columns.has("service_status")) return undefined; + const row = database.prepare(`SELECT service_status, ${columns.has("checked_at") ? "checked_at" : "NULL AS checked_at"}, ${columns.has("pause_reason") ? "pause_reason" : "NULL AS pause_reason"} FROM external_service_usage WHERE ${serviceColumn} = ? ORDER BY rowid DESC LIMIT 1`).get(serviceName) as { service_status: string; checked_at: number | string | null; pause_reason: string | null } | undefined; + if (!row) return undefined; + const status = new Set(["active", "paused_quota", "paused_provider", "disabled"]).has(row.service_status as AdminService["status"]) + ? row.service_status as AdminService["status"] + : "degraded"; + return { status, checked_at: iso(row.checked_at), pause_reason: safeReason(row.pause_reason) }; +} + +function workerStatus(database: BetterSqlite3.Database) { + if (!tableExists(database, "worker_runtime_state")) return { status: "unavailable" as const, checked_at: null, pause_reason: "worker_state_missing" }; + const row = database.prepare("SELECT status, reason, updated_at FROM worker_runtime_state WHERE singleton = 1").get() as { status: string; reason: string | null; updated_at: number | string | null } | undefined; + if (!row) return { status: "unavailable" as const, checked_at: null, pause_reason: "worker_state_missing" }; + return { + status: row.status === "ready" ? "active" as const : row.status === "degraded" ? "degraded" as const : "unavailable" as const, + checked_at: iso(row.updated_at), + pause_reason: safeReason(row.reason), + }; +} + +export function createAdminServicesStorageProvider(input: { + database: BetterSqlite3.Database; + models?: ModelConfigurationService; + storage?: ManagedStorage; + assetRoot?: Pick; + clock?: () => number; +}): () => AdminServicesStorageResponse { + const clock = input.clock ?? Date.now; + return () => { + const generatedAt = new Date(clock()).toISOString(); + const resend = serviceUsage(input.database, "resend"); + const amap = serviceUsage(input.database, "amap"); + const worker = workerStatus(input.database); + const modelRuntime = input.models?.read().models ?? []; + const unavailableModels = modelRuntime.filter((model) => !model.runtime_availability.available_for_new_jobs); + const gatewayReason = unavailableModels[0]?.runtime_availability.reason ?? null; + const storageState = input.storage?.getState(); + const cleanupPendingCount = tableExists(input.database, "file_cleanup_queue") + ? (input.database.prepare("SELECT COUNT(*) AS count FROM file_cleanup_queue WHERE status IN ('pending', 'failed')").get() as { count: number }).count + : 0; + const services: AdminService[] = [ + safeService("resend", resend?.status ?? "unavailable", "authentication", Boolean(resend), resend?.checked_at ?? null, resend?.pause_reason ?? "service_state_missing"), + safeService("amap", amap?.status ?? "unavailable", "location", Boolean(amap), amap?.checked_at ?? null, amap?.pause_reason ?? "service_state_missing"), + safeService("ai_gateway", unavailableModels.length > 0 ? "degraded" : modelRuntime.length > 0 ? "active" : "unavailable", "generation", modelRuntime.length > 0, generatedAt, safeReason(gatewayReason)), + safeService("worker", worker.status, "generation", worker.status !== "unavailable", worker.checked_at, worker.pause_reason), + safeService("api", "active", "api", true, generatedAt), + safeService( + "asset_root", + input.assetRoot?.status ?? "unavailable", + "storage", + input.assetRoot?.configured ?? false, + input.assetRoot?.checked_at ?? null, + input.assetRoot?.pause_reason ?? "asset_root_state_missing", + ), + ]; + return assertSafeAdminServicesStorage({ + generated_at: generatedAt, + services, + storage: { + capacity_notice_level: storageState?.capacity_notice_level ?? "normal", + cleanup_pending_count: cleanupPendingCount, + data_root_ref: "configured_local_data_root", + hard_limit_bytes: storageState?.hard_limit_bytes ?? 5_368_709_120, + last_measured_at: storageState?.measured_at ?? null, + managed_content_bytes: storageState?.managed_content_bytes ?? 0, + remeasurement_required: storageState?.storage_status === "unavailable", + status: storageState?.storage_status ?? "unavailable", + storage_backend: "local_filesystem", + }, + }); + }; +} + +function diagnosticText(input: AdminServicesStorageResponse, system: AdminDiagnosticsResponse["system"]) { + const lines = [ + "Dada P0-A diagnostics", + `app_version=${system.app_version}`, + `api_status=${system.api_status}`, + `worker_status=${system.worker_status}`, + `storage_status=${input.storage.status}`, + `capacity_notice_level=${input.storage.capacity_notice_level}`, + `managed_content_bytes=${input.storage.managed_content_bytes}`, + `hard_limit_bytes=${input.storage.hard_limit_bytes}`, + `cleanup_pending_count=${input.storage.cleanup_pending_count}`, + ]; + for (const service of input.services) lines.push(`service.${service.service_id}=${service.status}`); + return lines.join("\n"); +} + +export function createAdminDiagnosticsProvider(input: { + servicesStorage: () => AdminServicesStorageResponse; + browserSupportRelease?: BrowserSupportRelease; + appVersion?: string; + clock?: () => number; +}): () => AdminDiagnosticsResponse { + const clock = input.clock ?? Date.now; + return () => { + const services = input.servicesStorage(); + const system: AdminDiagnosticsResponse["system"] = { + api_status: "ready", + app_version: input.appVersion ?? input.browserSupportRelease?.appVersion ?? "0.0.0", + browser_support: (input.browserSupportRelease?.browsers ?? []).map((browser) => ({ + brand: browser.brand, + major: Number.parseInt(browser.fullVersion.split(".")[0] ?? "0", 10), + })).filter((browser) => Number.isSafeInteger(browser.major) && browser.major > 0), + worker_status: services.services.find((service) => service.service_id === "worker")?.status === "active" + ? "ready" + : services.services.find((service) => service.service_id === "worker")?.status === "unavailable" + ? "unavailable" + : "degraded", + }; + return assertSafeAdminDiagnostics({ + generated_at: new Date(clock()).toISOString(), + diagnostic_text: diagnosticText(services, system), + services, + system, + }); + }; +} + +export function assertSafeAdminServicesStorage(input: AdminServicesStorageResponse) { + const ids = input.services.map((service) => service.service_id); + if (ids.length !== adminServiceIds.length || new Set(ids).size !== adminServiceIds.length + || adminServiceIds.some((serviceId) => !ids.includes(serviceId))) { + throw new Error("admin_service_state_incomplete"); + } + if (input.services.some((service) => service.pause_reason !== null && !safePauseReasons.has(service.pause_reason))) { + throw new Error("admin_services_redaction_failed"); + } + if (forbiddenDiagnosticPatterns.some((pattern) => pattern.test(JSON.stringify(input)))) { + throw new Error("admin_services_redaction_failed"); + } + return input; +} + +export function assertSafeAdminDiagnostics(input: AdminDiagnosticsResponse) { + assertSafeAdminServicesStorage(input.services); + if (forbiddenDiagnosticPatterns.some((pattern) => pattern.test(input.diagnostic_text))) { + throw new Error("admin_diagnostics_redaction_failed"); + } + return input; +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 705d190..32646a3 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -10,7 +10,9 @@ import { AccountProfileUpdateResponseSchema, AccountSettingsResponseSchema, AdminAuthenticatedUserSchema, + AdminDiagnosticsResponseSchema, AdminOverviewResponseSchema, + AdminServicesStorageResponseSchema, AdminCreditParamsSchema, AdminLoginCompleteRequestSchema, AdminLoginCompleteResponseSchema, @@ -112,6 +114,8 @@ import { type AdminLoginCompleteRequest, type AdminLoginSendRequest, type AdminOverviewResponse, + type AdminDiagnosticsResponse, + type AdminServicesStorageResponse, type AccountDeletionCompleteRequest, type AccountProfileUpdateRequest, type AdminCreditParams, @@ -180,6 +184,7 @@ import type { RecentAssetService } from "./recent-assets.js"; import type { AmapAdapter } from "./amap-adapter.js"; import { ModelConfigurationError } from "./model-configuration.js"; import type { ModelConfigurationService } from "./model-configuration.js"; +import { assertSafeAdminDiagnostics, assertSafeAdminServicesStorage } from "./admin-state.js"; const defaultBootstrap: BootstrapResponse = { app_version: "0.0.0", @@ -194,7 +199,9 @@ const defaultBootstrap: BootstrapResponse = { }; export interface CreateAppOptions { + adminDiagnostics?: () => AdminDiagnosticsResponse | Promise; adminOverview?: () => AdminOverviewResponse | Promise; + adminServicesStorage?: () => AdminServicesStorageResponse | Promise; amap?: AmapAdapter; assetReleases?: AssetReleaseReader; bootstrap?: () => BootstrapResponse | Promise; @@ -692,6 +699,8 @@ export async function createApp(options: CreateAppOptions = {}) { AdminLoginCompleteResponseSchema, AdminSessionResponseSchema, AdminOverviewResponseSchema, + AdminServicesStorageResponseSchema, + AdminDiagnosticsResponseSchema, CreditSummarySchema, CreditEntryTypeSchema, CreditEntryStatusSchema, @@ -1236,6 +1245,60 @@ export async function createApp(options: CreateAppOptions = {}) { }, ); + app.get( + "/api/v1/admin/services-storage", + { + schema: { + operationId: "getAdminServicesStorage", + response: { + 200: Type.Ref(AdminServicesStorageResponseSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Null(), + }, + tags: ["Admin Operations"], + }, + }, + async (request, reply) => { + if (!options.registration) return reply.code(503).send(null); + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const session = token ? options.registration.readAdminSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + if (!options.adminServicesStorage) return reply.code(503).send(null); + try { + return assertSafeAdminServicesStorage(await options.adminServicesStorage()); + } catch { + return reply.code(503).send(null); + } + }, + ); + + app.get( + "/api/v1/admin/diagnostics", + { + schema: { + operationId: "getAdminDiagnostics", + response: { + 200: Type.Ref(AdminDiagnosticsResponseSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Null(), + }, + tags: ["Admin Operations"], + }, + }, + async (request, reply) => { + if (!options.registration) return reply.code(503).send(null); + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const session = token ? options.registration.readAdminSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + if (!options.adminDiagnostics) return reply.code(503).send(null); + try { + return assertSafeAdminDiagnostics(await options.adminDiagnostics()); + } catch { + return reply.code(503).send(null); + } + }, + ); + app.post( "/api/v1/auth/login/send", { diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 1629249..7f95c33 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -18,6 +18,7 @@ import { StructuredJsonlLogger } from "./structured-log.js"; import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js"; import { ModelConfigurationService } from "./model-configuration.js"; import { MockAmapAdapter } from "./amap-adapter.js"; +import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js"; const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin"); let registration: RegistrationService | undefined; @@ -70,7 +71,22 @@ if (credentialChannelEnabled) { } const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json")); +const adminServicesStorage = registration + ? createAdminServicesStorageProvider({ + database: registration.database, + ...(models ? { models } : {}), + ...(storage ? { storage } : {}), + }) + : undefined; +const adminDiagnostics = adminServicesStorage + ? createAdminDiagnosticsProvider({ + ...(browserSupportRelease ? { browserSupportRelease, appVersion: browserSupportRelease.appVersion } : {}), + servicesStorage: adminServicesStorage, + }) + : undefined; const app = await createApp({ + ...(adminServicesStorage ? { adminServicesStorage } : {}), + ...(adminDiagnostics ? { adminDiagnostics } : {}), amap: new MockAmapAdapter(), ...(browserSupportRelease ? { browserSupportRelease } : {}), ...(credits ? { credits } : {}), diff --git a/apps/web/src/admin-models.tsx b/apps/web/src/admin-models.tsx index 655a19f..022e055 100644 --- a/apps/web/src/admin-models.tsx +++ b/apps/web/src/admin-models.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import "./admin-models.css"; @@ -64,7 +64,7 @@ export function AdminModelsPage() { const priorityRefs = useRef>({}); const defaultRefs = useRef>({}); - async function load() { + const load = useCallback(async () => { setLoadingFailed(false); setConflicted(false); try { @@ -80,9 +80,16 @@ export function AdminModelsPage() { } catch { setLoadingFailed(true); } - } + }, []); - useEffect(() => { void load(); }, []); + useEffect(() => { void load(); }, [load]); + + useEffect(() => { + if (typeof EventSource === "undefined") return undefined; + const source = new EventSource("/api/v1/events"); + source.onmessage = () => { void load(); }; + return () => source.close(); + }, [load]); const validation = useMemo(() => { if (!draft) return { valid: false, message: "" }; diff --git a/apps/web/src/admin-services-storage.css b/apps/web/src/admin-services-storage.css new file mode 100644 index 0000000..c96631f --- /dev/null +++ b/apps/web/src/admin-services-storage.css @@ -0,0 +1,37 @@ +.admin-services-storage { max-width: 1180px; } +.admin-services-heading { align-items: end; } +.admin-services-heading-actions { align-items: center; display: flex; gap: 16px; } +.admin-services-heading-actions button, .admin-diagnostics-section button { background: #111827; border: 0; color: #fff; cursor: pointer; font: inherit; padding: 10px 14px; } +.admin-health-section { border-top: 1px solid #d9dde5; margin-top: 26px; padding-top: 22px; } +.admin-health-section > header { align-items: center; display: flex; justify-content: space-between; margin-bottom: 18px; } +.admin-health-section h3 { margin: 4px 0 0; } +.admin-health-section header p { color: #7b8493; font-size: 11px; letter-spacing: .12em; margin: 0; } +.admin-safe-note { color: #667085; font-size: 13px; } +.admin-service-grid { display: grid; gap: 12px; grid-template-columns: repeat(3, minmax(0, 1fr)); } +.admin-service-card { background: #fff; border: 1px solid #e1e5ea; min-height: 160px; padding: 18px; } +.admin-service-card.is-degraded, .admin-service-card.is-paused_quota, .admin-service-card.is-paused_provider, .admin-service-card.is-unavailable { border-color: #e5b6b6; } +.admin-service-card-heading { align-items: center; display: flex; justify-content: space-between; } +.admin-service-card-heading span, .admin-storage-state { color: #147a50; font-size: 13px; } +.admin-service-card.is-degraded .admin-service-card-heading span, .admin-service-card.is-paused_quota .admin-service-card-heading span, .admin-service-card.is-paused_provider .admin-service-card-heading span, .admin-service-card.is-unavailable .admin-service-card-heading span { color: #b42318; } +.admin-service-card dl, .admin-storage-details { display: grid; gap: 10px; margin: 18px 0 0; } +.admin-service-card dl div, .admin-storage-details div { align-items: baseline; display: flex; justify-content: space-between; } +.admin-service-card dt, .admin-storage-details dt { color: #667085; font-size: 12px; } +.admin-service-card dd, .admin-storage-details dd { margin: 0; text-align: right; } +.admin-storage-state.is-full, .admin-storage-state.is-unavailable { color: #b42318; } +.admin-storage-metrics { display: grid; gap: 16px; grid-template-columns: repeat(4, minmax(0, 1fr)); } +.admin-storage-metrics div { background: #f7f8fa; padding: 14px 16px; } +.admin-storage-metrics span { color: #667085; display: block; font-size: 12px; } +.admin-storage-metrics strong { display: block; font-size: 20px; margin-top: 6px; } +.admin-storage-progress { background: #e5e7eb; height: 8px; margin-top: 18px; overflow: hidden; } +.admin-storage-progress span { background: #147a50; display: block; height: 100%; } +.admin-storage-description { color: #667085; font-size: 13px; line-height: 1.7; max-width: 780px; } +.admin-storage-description code { color: #344054; } +.admin-diagnostics-section > header button:disabled { background: #98a2b3; cursor: not-allowed; } +.admin-diagnostics-section > p { color: #667085; font-size: 13px; } +.admin-diagnostics-section pre { background: #111827; color: #d1fadf; font: 12px/1.65 ui-monospace, SFMono-Regular, Consolas, monospace; margin: 16px 0 0; max-height: 280px; overflow: auto; padding: 16px; white-space: pre-wrap; } +.admin-services-loading { display: grid; gap: 12px; grid-template-columns: repeat(3, 1fr); } +.admin-services-loading span, .admin-diagnostics-placeholder { background: #eef1f4; display: block; height: 160px; } +.admin-services-failure { align-items: center; background: #fff4f2; color: #b42318; display: flex; gap: 16px; justify-content: space-between; padding: 14px 16px; } +.admin-services-failure button { background: transparent; border: 1px solid #b42318; color: #b42318; cursor: pointer; padding: 6px 12px; } +@media (max-width: 900px) { .admin-service-grid, .admin-storage-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 620px) { .admin-service-grid, .admin-storage-metrics { grid-template-columns: 1fr; } .admin-services-heading-actions { align-items: flex-end; flex-direction: column; gap: 8px; } } diff --git a/apps/web/src/admin-services-storage.tsx b/apps/web/src/admin-services-storage.tsx new file mode 100644 index 0000000..b464246 --- /dev/null +++ b/apps/web/src/admin-services-storage.tsx @@ -0,0 +1,136 @@ +import { useCallback, useEffect, useState } from "react"; + +import type { AdminDiagnosticsResponse, AdminServicesStorageResponse } from "@dada/shared-contracts"; + +import "./admin-services-storage.css"; + +const serviceLabels: Record = { + ai_gateway: "AI 网关", + amap: "高德", + api: "API", + asset_root: "素材根", + resend: "Resend", + worker: "Worker", +}; + +const statusLabels: Record = { + active: "正常", + degraded: "有异常", + disabled: "已停用", + paused_provider: "供应商暂停", + paused_quota: "额度暂停", + unavailable: "不可用", +}; + +const impactLabels: Record = { + account: "账户模型", + api: "后台接口", + authentication: "认证", + generation: "生成", + location: "定位", + model: "单模型", + none: "无", + storage: "存储", + unknown: "未知范围", +}; + +function formatTime(value: string | null) { + if (!value) return "未记录"; + return new Intl.DateTimeFormat("zh-CN", { dateStyle: "short", timeStyle: "short" }).format(new Date(value)); +} + +async function getJson(url: string) { + const response = await fetch(url, { credentials: "same-origin" }); + if (response.status === 401) window.dispatchEvent(new Event("dada:session-invalid")); + if (!response.ok) throw new Error("admin_state_unavailable"); + return await response.json() as T; +} + +export function AdminServicesStoragePage() { + const [state, setState] = useState(); + const [diagnostics, setDiagnostics] = useState(); + const [loading, setLoading] = useState(true); + const [failed, setFailed] = useState(false); + const [copied, setCopied] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setFailed(false); + try { + const [nextState, nextDiagnostics] = await Promise.all([ + getJson("/api/v1/admin/services-storage"), + getJson("/api/v1/admin/diagnostics"), + ]); + setState(nextState); + setDiagnostics(nextDiagnostics); + } catch { + setFailed(true); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { void load(); }, [load]); + + async function copyDiagnostics() { + if (!diagnostics) return; + try { + await navigator.clipboard.writeText(diagnostics.diagnostic_text); + setCopied(true); + window.setTimeout(() => setCopied(false), 1800); + } catch { + setCopied(false); + } + } + + return ( +
+
+

OPERATIONS / HEALTH

服务与存储

+
+ {state ? : null} + +
+
+ {loading && !state ?
: null} + {failed ?
状态暂时无法读取{state ? `,保留 ${formatTime(state.generated_at)} 的结果` : ""}。
: null} + {state ? ( + <> +
+

SERVICE STATUS

外部服务与本机组件

仅显示脱敏状态
+
+ {state.services.map((service) => ( +
+
{serviceLabels[service.service_id]}{statusLabels[service.status]}
+
+
配置状态
{service.configured ? "已配置" : "未配置"}
+
影响范围
{impactLabels[service.impact_scope]}
+
最近检查
{formatTime(service.checked_at)}
+ {service.pause_reason ?
安全原因
{service.pause_reason}
: null} +
+
+ ))} +
+
+
+

LOCAL DATA ROOT

本机内容容量

{state.storage.status === "active" ? "可写" : state.storage.status === "full" ? "已满" : "不可用"}
+
+
已用内容{(state.storage.managed_content_bytes / 1024 / 1024 / 1024).toFixed(2)} GB
+
固定上限{(state.storage.hard_limit_bytes / 1024 / 1024 / 1024).toFixed(2)} GB
+
容量提醒{state.storage.capacity_notice_level}
+
清理队列{state.storage.cleanup_pending_count}
+
+
+

测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。当前 Windows 用户的 Dada 本机数据目录引用:{state.storage.data_root_ref}。只读规范素材库不计入 5 GB 内容额度。

+
最后计量
{formatTime(state.storage.last_measured_at)}
重新计量
{state.storage.remeasurement_required ? "需要完成" : "无需等待"}
+
+
+

DIAGNOSTICS

脱敏诊断

+

诊断内容只包含固定版本、组件状态、逻辑位置和容量计量,不包含密钥、邮箱、绝对路径、提示词、图片或供应商原文。

+ {diagnostics ?
{diagnostics.diagnostic_text}
:
} +
+ + ) : null} +
+ ); +} diff --git a/apps/web/src/generated/api/sdk.gen.ts b/apps/web/src/generated/api/sdk.gen.ts index 0cc508a..af9929c 100644 --- a/apps/web/src/generated/api/sdk.gen.ts +++ b/apps/web/src/generated/api/sdk.gen.ts @@ -1,6 +1,6 @@ // Generated from openapi/openapi.json. Do not edit by hand. -import type { CreditAdjustmentResponse, CreditAdjustmentRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminOverviewResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, ReverseGeocodeResponse, ReverseGeocodeRequest, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js"; +import type { CreditAdjustmentResponse, CreditAdjustmentRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminDiagnosticsResponse, AdminOverviewResponse, AdminServicesStorageResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, ReverseGeocodeResponse, ReverseGeocodeRequest, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js"; export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; } @@ -87,6 +87,13 @@ export async function getAccountSettings(options: ClientOptions = {}): Promise; } +export async function getAdminDiagnostics(options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/diagnostics`, { method: "GET", headers: options.headers ?? {} }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function getAdminOverview(options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/overview`, { method: "GET", headers: options.headers ?? {} }); @@ -94,6 +101,13 @@ export async function getAdminOverview(options: ClientOptions = {}): Promise; } +export async function getAdminServicesStorage(options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/services-storage`, { method: "GET", headers: options.headers ?? {} }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function getAdminSession(options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/session`, { method: "GET", headers: options.headers ?? {} }); diff --git a/apps/web/src/generated/api/types.gen.ts b/apps/web/src/generated/api/types.gen.ts index 4387041..5c7b2d9 100644 --- a/apps/web/src/generated/api/types.gen.ts +++ b/apps/web/src/generated/api/types.gen.ts @@ -60,6 +60,21 @@ export type AdminCreditParams = { "userId": string; }; +export type AdminDiagnosticsResponse = { + "diagnostic_text": string; + "generated_at": string; + "services": AdminServicesStorageResponse; + "system": { + "api_status": "ready" | "degraded" | "unavailable"; + "app_version": string; + "browser_support": Array<{ + "brand": "Google Chrome" | "Microsoft Edge"; + "major": number; +}>; + "worker_status": "ready" | "degraded" | "unavailable"; +}; +}; + export type AdminLoginCompleteRequest = { "registration_id": string; "verification_code": string; @@ -117,6 +132,29 @@ export type AdminOverviewResponse = { }; }; +export type AdminServicesStorageResponse = { + "generated_at": string; + "services": Array<{ + "checked_at": string | null; + "configured": boolean; + "impact_scope": "none" | "authentication" | "location" | "generation" | "storage" | "api" | "model" | "account" | "unknown"; + "pause_reason": string | null; + "service_id": "resend" | "amap" | "ai_gateway" | "worker" | "api" | "asset_root"; + "status": "active" | "paused_quota" | "paused_provider" | "disabled" | "degraded" | "unavailable"; +}>; + "storage": { + "capacity_notice_level": "normal" | "warning" | "critical"; + "cleanup_pending_count": number; + "data_root_ref": "configured_local_data_root"; + "hard_limit_bytes": number; + "last_measured_at": string | null; + "managed_content_bytes": number; + "remeasurement_required": boolean; + "status": "active" | "full" | "unavailable"; + "storage_backend": "local_filesystem"; +}; +}; + export type AdminSessionResponse = { "acknowledged_private_content_notice_version": string | null; "admin": AdminAuthenticatedUser; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 544b206..1002475 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -7,6 +7,7 @@ import { UserAuthPage } from "./user-auth.js"; import { AccountSettingsPage } from "./account-settings.js"; import { AdminUsersPage } from "./admin-users.js"; import { AdminModelsPage } from "./admin-models.js"; +import { AdminServicesStoragePage } from "./admin-services-storage.js"; import { CreditsPage } from "./credits-page.js"; import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js"; import { EditorPage } from "./editor-page.js"; @@ -45,7 +46,7 @@ function renderAuthenticationEntry() { "/admin/invites": { content: , title: "邀请码" }, "/admin/models": { content: , title: "模型" }, "/admin/preview": { content: , title: "内部预览" }, - "/admin/services-storage": { content: , title: "服务与存储" }, + "/admin/services-storage": { content: , title: "服务与存储" }, "/admin/users": { content: , title: "用户与点数" }, }; const page = adminPages[window.location.pathname] ?? adminPages["/admin"]!; diff --git a/openapi/openapi.json b/openapi/openapi.json index 39835dc..5e3986e 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -300,6 +300,125 @@ ], "type": "object" }, + "AdminDiagnosticsResponse": { + "additionalProperties": false, + "properties": { + "diagnostic_text": { + "maxLength": 12000, + "minLength": 1, + "type": "string" + }, + "generated_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$", + "type": "string" + }, + "services": { + "$ref": "#/components/schemas/AdminServicesStorageResponse" + }, + "system": { + "additionalProperties": false, + "properties": { + "api_status": { + "anyOf": [ + { + "enum": [ + "ready" + ], + "type": "string" + }, + { + "enum": [ + "degraded" + ], + "type": "string" + }, + { + "enum": [ + "unavailable" + ], + "type": "string" + } + ] + }, + "app_version": { + "maxLength": 80, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$", + "type": "string" + }, + "browser_support": { + "items": { + "additionalProperties": false, + "properties": { + "brand": { + "anyOf": [ + { + "enum": [ + "Google Chrome" + ], + "type": "string" + }, + { + "enum": [ + "Microsoft Edge" + ], + "type": "string" + } + ] + }, + "major": { + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "brand", + "major" + ], + "type": "object" + }, + "maxItems": 2, + "type": "array" + }, + "worker_status": { + "anyOf": [ + { + "enum": [ + "ready" + ], + "type": "string" + }, + { + "enum": [ + "degraded" + ], + "type": "string" + }, + { + "enum": [ + "unavailable" + ], + "type": "string" + } + ] + } + }, + "required": [ + "api_status", + "app_version", + "browser_support", + "worker_status" + ], + "type": "object" + } + }, + "required": [ + "generated_at", + "diagnostic_text", + "services", + "system" + ], + "type": "object" + }, "AdminLoginCompleteRequest": { "additionalProperties": false, "properties": { @@ -696,6 +815,303 @@ ], "type": "object" }, + "AdminServicesStorageResponse": { + "additionalProperties": false, + "properties": { + "generated_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$", + "type": "string" + }, + "services": { + "items": { + "additionalProperties": false, + "properties": { + "checked_at": { + "anyOf": [ + { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "configured": { + "type": "boolean" + }, + "impact_scope": { + "anyOf": [ + { + "enum": [ + "none" + ], + "type": "string" + }, + { + "enum": [ + "authentication" + ], + "type": "string" + }, + { + "enum": [ + "location" + ], + "type": "string" + }, + { + "enum": [ + "generation" + ], + "type": "string" + }, + { + "enum": [ + "storage" + ], + "type": "string" + }, + { + "enum": [ + "api" + ], + "type": "string" + }, + { + "enum": [ + "model" + ], + "type": "string" + }, + { + "enum": [ + "account" + ], + "type": "string" + }, + { + "enum": [ + "unknown" + ], + "type": "string" + } + ] + }, + "pause_reason": { + "anyOf": [ + { + "maxLength": 80, + "pattern": "^[a-z][a-z0-9_]*$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "service_id": { + "anyOf": [ + { + "enum": [ + "resend" + ], + "type": "string" + }, + { + "enum": [ + "amap" + ], + "type": "string" + }, + { + "enum": [ + "ai_gateway" + ], + "type": "string" + }, + { + "enum": [ + "worker" + ], + "type": "string" + }, + { + "enum": [ + "api" + ], + "type": "string" + }, + { + "enum": [ + "asset_root" + ], + "type": "string" + } + ] + }, + "status": { + "anyOf": [ + { + "enum": [ + "active" + ], + "type": "string" + }, + { + "enum": [ + "paused_quota" + ], + "type": "string" + }, + { + "enum": [ + "paused_provider" + ], + "type": "string" + }, + { + "enum": [ + "disabled" + ], + "type": "string" + }, + { + "enum": [ + "degraded" + ], + "type": "string" + }, + { + "enum": [ + "unavailable" + ], + "type": "string" + } + ] + } + }, + "required": [ + "checked_at", + "configured", + "impact_scope", + "pause_reason", + "service_id", + "status" + ], + "type": "object" + }, + "maxItems": 6, + "minItems": 6, + "type": "array" + }, + "storage": { + "additionalProperties": false, + "properties": { + "capacity_notice_level": { + "anyOf": [ + { + "enum": [ + "normal" + ], + "type": "string" + }, + { + "enum": [ + "warning" + ], + "type": "string" + }, + { + "enum": [ + "critical" + ], + "type": "string" + } + ] + }, + "cleanup_pending_count": { + "minimum": 0, + "type": "integer" + }, + "data_root_ref": { + "enum": [ + "configured_local_data_root" + ], + "type": "string" + }, + "hard_limit_bytes": { + "minimum": 1, + "type": "integer" + }, + "last_measured_at": { + "anyOf": [ + { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "managed_content_bytes": { + "minimum": 0, + "type": "integer" + }, + "remeasurement_required": { + "type": "boolean" + }, + "status": { + "anyOf": [ + { + "enum": [ + "active" + ], + "type": "string" + }, + { + "enum": [ + "full" + ], + "type": "string" + }, + { + "enum": [ + "unavailable" + ], + "type": "string" + } + ] + }, + "storage_backend": { + "enum": [ + "local_filesystem" + ], + "type": "string" + } + }, + "required": [ + "capacity_notice_level", + "cleanup_pending_count", + "data_root_ref", + "hard_limit_bytes", + "last_measured_at", + "managed_content_bytes", + "remeasurement_required", + "status", + "storage_backend" + ], + "type": "object" + } + }, + "required": [ + "generated_at", + "services", + "storage" + ], + "type": "object" + }, "AdminSessionResponse": { "additionalProperties": false, "properties": { @@ -5287,6 +5703,39 @@ ] } }, + "/api/v1/admin/diagnostics": { + "get": { + "operationId": "getAdminDiagnostics", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminDiagnosticsResponse" + } + } + }, + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "503": { + "description": "Default Response" + } + }, + "tags": [ + "Admin Operations" + ] + } + }, "/api/v1/admin/models/configuration": { "put": { "operationId": "replaceModelConfiguration", @@ -8331,6 +8780,39 @@ ] } }, + "/api/v1/admin/services-storage": { + "get": { + "operationId": "getAdminServicesStorage", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminServicesStorageResponse" + } + } + }, + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "503": { + "description": "Default Response" + } + }, + "tags": [ + "Admin Operations" + ] + } + }, "/api/v1/admin/users/{userId}/credit-adjustments": { "post": { "operationId": "adjustAdminUserCredits", diff --git a/package.json b/package.json index 3b32534..8a5e436 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test:integration": "vitest run tests/integration", "test:api": "pnpm check:openapi && vitest run tests/api", "test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker", - "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts tests/e2e/wp4-05-export.spec.ts tests/e2e/wp4-06-accessibility.spec.ts tests/e2e/wp5-02-static-sticker-catalog.spec.ts tests/e2e/wp5-03-template-registry.spec.ts tests/e2e/wp5-04-resource-isolation.spec.ts tests/e2e/wp6-01-admin-shell.spec.ts --config playwright.config.ts", + "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts tests/e2e/wp4-05-export.spec.ts tests/e2e/wp4-06-accessibility.spec.ts tests/e2e/wp5-02-static-sticker-catalog.spec.ts tests/e2e/wp5-03-template-registry.spec.ts tests/e2e/wp5-04-resource-isolation.spec.ts tests/e2e/wp6-01-admin-shell.spec.ts tests/e2e/wp6-05-state.spec.ts --config playwright.config.ts", "test:visual": "node scripts/validate-layer-scope.mjs VISUAL", "test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE", "test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs", @@ -96,7 +96,8 @@ "test:wp5-04": "node scripts/run-wp5-04-validation.mjs", "test:wp5-04:red": "node scripts/run-wp5-04-validation.mjs --phase red", "test:wp6-01": "node scripts/run-wp6-01-validation.mjs --phase scaffold", - "test:wp6-01:red": "node scripts/run-wp6-01-validation.mjs --phase red" + "test:wp6-01:red": "node scripts/run-wp6-01-validation.mjs --phase red", + "test:wp6-05": "pnpm exec vitest run tests/api/wp6-05-state.test.ts && pnpm exec playwright test tests/e2e/wp6-05-state.spec.ts --config playwright.config.ts" }, "devDependencies": { "@playwright/test": "1.62.0", diff --git a/packages/shared-contracts/src/admin.ts b/packages/shared-contracts/src/admin.ts index 0f73546..e9d3625 100644 --- a/packages/shared-contracts/src/admin.ts +++ b/packages/shared-contracts/src/admin.ts @@ -92,3 +92,72 @@ export const AdminOverviewResponseSchema = Type.Object( ); export type AdminOverviewResponse = Static; + +const adminServiceStatusSchema = Type.Union([ + Type.Literal("active"), + Type.Literal("paused_quota"), + Type.Literal("paused_provider"), + Type.Literal("disabled"), + Type.Literal("degraded"), + Type.Literal("unavailable"), +]); + +const adminServiceIdSchema = Type.Union([ + Type.Literal("resend"), + Type.Literal("amap"), + Type.Literal("ai_gateway"), + Type.Literal("worker"), + Type.Literal("api"), + Type.Literal("asset_root"), +]); + +export const AdminServicesStorageResponseSchema = Type.Object({ + generated_at: Type.String({ pattern: isoTimestampPattern }), + services: Type.Array(Type.Object({ + checked_at: Type.Union([Type.String({ pattern: isoTimestampPattern }), Type.Null()]), + configured: Type.Boolean(), + impact_scope: Type.Union([ + Type.Literal("none"), + Type.Literal("authentication"), + Type.Literal("location"), + Type.Literal("generation"), + Type.Literal("storage"), + Type.Literal("api"), + Type.Literal("model"), + Type.Literal("account"), + Type.Literal("unknown"), + ]), + pause_reason: Type.Union([Type.String({ maxLength: 80, pattern: "^[a-z][a-z0-9_]*$" }), Type.Null()]), + service_id: adminServiceIdSchema, + status: adminServiceStatusSchema, + }, { additionalProperties: false }), { minItems: 6, maxItems: 6 }), + storage: Type.Object({ + capacity_notice_level: Type.Union([Type.Literal("normal"), Type.Literal("warning"), Type.Literal("critical")]), + cleanup_pending_count: Type.Integer({ minimum: 0 }), + data_root_ref: Type.Literal("configured_local_data_root"), + hard_limit_bytes: Type.Integer({ minimum: 1 }), + last_measured_at: Type.Union([Type.String({ pattern: isoTimestampPattern }), Type.Null()]), + managed_content_bytes: Type.Integer({ minimum: 0 }), + remeasurement_required: Type.Boolean(), + status: Type.Union([Type.Literal("active"), Type.Literal("full"), Type.Literal("unavailable")]), + storage_backend: Type.Literal("local_filesystem"), + }, { additionalProperties: false }), +}, { additionalProperties: false, $id: "AdminServicesStorageResponse" }); + +export const AdminDiagnosticsResponseSchema = Type.Object({ + generated_at: Type.String({ pattern: isoTimestampPattern }), + diagnostic_text: Type.String({ minLength: 1, maxLength: 12_000 }), + services: Type.Ref(AdminServicesStorageResponseSchema), + system: Type.Object({ + api_status: Type.Union([Type.Literal("ready"), Type.Literal("degraded"), Type.Literal("unavailable")]), + app_version: Type.String({ maxLength: 80, pattern: "^[A-Za-z0-9][A-Za-z0-9._-]*$" }), + browser_support: Type.Array(Type.Object({ + brand: Type.Union([Type.Literal("Google Chrome"), Type.Literal("Microsoft Edge")]), + major: Type.Integer({ minimum: 1 }), + }, { additionalProperties: false }), { maxItems: 2 }), + worker_status: Type.Union([Type.Literal("ready"), Type.Literal("degraded"), Type.Literal("unavailable")]), + }, { additionalProperties: false }), +}, { additionalProperties: false, $id: "AdminDiagnosticsResponse" }); + +export type AdminServicesStorageResponse = Static; +export type AdminDiagnosticsResponse = Static; diff --git a/tests/api/wp6-05-state.test.ts b/tests/api/wp6-05-state.test.ts new file mode 100644 index 0000000..b07c770 --- /dev/null +++ b/tests/api/wp6-05-state.test.ts @@ -0,0 +1,179 @@ +import { randomUUID } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { createApp } from "../../apps/api/src/app.js"; +import { createAdminServicesStorageProvider } from "../../apps/api/src/admin-state.js"; +import { ManagedStorage } from "../../apps/api/src/managed-storage.js"; +import { ModelConfigurationService } from "../../apps/api/src/model-configuration.js"; +import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js"; +import { RegistrationService } from "../../apps/api/src/registration.js"; +import type { AdminDiagnosticsResponse, AdminServicesStorageResponse } from "@dada/shared-contracts"; + +const now = "2026-08-04T09:30:00.000Z"; +const headers = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" }; +const roots: string[] = []; +const registrations: RegistrationService[] = []; +const storages: ManagedStorage[] = []; + +const servicesFixture: AdminServicesStorageResponse = { + generated_at: now, + services: [ + { checked_at: now, configured: true, impact_scope: "authentication", pause_reason: null, service_id: "resend", status: "active" }, + { checked_at: now, configured: true, impact_scope: "location", pause_reason: "quota_exhausted", service_id: "amap", status: "paused_quota" }, + { checked_at: now, configured: true, impact_scope: "generation", pause_reason: "balance_insufficient", service_id: "ai_gateway", status: "degraded" }, + { checked_at: now, configured: true, impact_scope: "generation", pause_reason: "worker_stopped", service_id: "worker", status: "degraded" }, + { checked_at: now, configured: true, impact_scope: "api", pause_reason: null, service_id: "api", status: "active" }, + { checked_at: now, configured: true, impact_scope: "storage", pause_reason: null, service_id: "asset_root", status: "active" }, + ], + storage: { + capacity_notice_level: "warning", + cleanup_pending_count: 2, + data_root_ref: "configured_local_data_root", + hard_limit_bytes: 5_368_709_120, + last_measured_at: now, + managed_content_bytes: 4_563_402_752, + remeasurement_required: false, + status: "active", + storage_backend: "local_filesystem", + }, +}; + +const diagnosticsFixture: AdminDiagnosticsResponse = { + generated_at: now, + diagnostic_text: "Dada P0-A\napp_version=0.0.0\napi_status=ready\nworker_status=ready\nstorage_status=active", + services: servicesFixture, + system: { + api_status: "ready", + app_version: "0.0.0", + browser_support: [{ brand: "Google Chrome", major: 128 }, { brand: "Microsoft Edge", major: 128 }], + worker_status: "ready", + }, +}; + +function createRegistration() { + const root = mkdtempSync(join(tmpdir(), "dada-wp6-05-api-")); + roots.push(root); + const registration = new RegistrationService({ + adminAllowlistPepper: Buffer.alloc(32, 0xe1), + challengePepper: Buffer.alloc(32, 0xe2), + clock: () => Date.parse(now), + currentPrivacyNoticeVersion: "p0a-registration-notice-v1", + databasePath: join(root, "dada.sqlite3"), + invitePepper: Buffer.alloc(32, 0xe3), + resend: new MockResendAdapter(), + sessionPepper: Buffer.alloc(32, 0xe4), + }); + registrations.push(registration); + return registration; +} + +function seedAdmin(registration: RegistrationService) { + const userId = randomUUID(); + registration.database.prepare(` + INSERT INTO users (user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at) + VALUES (?, ?, 'super_admin', 'active', 0, ?, ?) + `).run(userId, `${userId}@example.invalid`, randomUUID(), Date.parse(now)); + registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(userId); + return registration.issueAuthenticatedSession(userId, "admin"); +} + +afterEach(() => { + for (const storage of storages.splice(0)) storage.close(); + for (const registration of registrations.splice(0)) registration.close(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("admin status aggregation", () => { + it("reads storage and runtime truth without fabricating provider readiness", () => { + const registration = createRegistration(); + const root = roots[roots.length - 1]!; + const storage = new ManagedStorage({ dataRoot: root, databasePath: join(root, "dada.sqlite3") }); + storages.push(storage); + const models = new ModelConfigurationService({ database: registration.database, clock: () => Date.parse(now) }); + const state = createAdminServicesStorageProvider({ database: registration.database, models, storage, clock: () => Date.parse(now) })(); + expect(new Set(state.services.map((service) => service.service_id)).size).toBe(6); + expect(state.services.find((service) => service.service_id === "worker")?.status).toBe("unavailable"); + expect(state.services.find((service) => service.service_id === "ai_gateway")?.status).toBe("degraded"); + expect(state.storage.storage_backend).toBe("local_filesystem"); + expect(JSON.stringify(state)).not.toContain("http"); + expect(JSON.stringify(state)).not.toContain("@example"); + }); +}); + +describe("TDD-WP6-STATE-001-three-model-states", () => { + it("requires an active admin session and keeps model/service state provider-backed", async () => { + const registration = createRegistration(); + let serviceCalls = 0; + const app = await createApp({ + adminDiagnostics: async () => diagnosticsFixture, + adminServicesStorage: async () => { + serviceCalls += 1; + return servicesFixture; + }, + browserGate: false, + networkBoundary: { allowTestPort: true }, + registration, + }); + + const denied = await app.inject({ headers, method: "GET", url: "/api/v1/admin/services-storage" }); + expect(denied.statusCode).toBe(401); + expect(serviceCalls).toBe(0); + + const session = seedAdmin(registration); + const authorizedHeaders = { ...headers, cookie: `dada_admin_session=${session.sessionToken}` }; + const state = await app.inject({ headers: authorizedHeaders, method: "GET", url: "/api/v1/admin/services-storage" }); + expect(state.statusCode).toBe(200); + expect(state.json()).toEqual(servicesFixture); + expect(serviceCalls).toBe(1); + expect(JSON.stringify(state.json()).toLowerCase()).not.toMatch(/secret|password|email|absolute/); + + const diagnostic = await app.inject({ headers: authorizedHeaders, method: "GET", url: "/api/v1/admin/diagnostics" }); + expect(diagnostic.statusCode).toBe(200); + expect(diagnostic.json()).toEqual(diagnosticsFixture); + await app.close(); + }); +}); + +describe("TDD-WP6-DIAG-001-redacted-diagnostics", () => { + it("rejects a provider diagnostic that contains a credential or absolute path trap", async () => { + const registration = createRegistration(); + const app = await createApp({ + adminDiagnostics: async () => ({ ...diagnosticsFixture, diagnostic_text: "api_key=trap C:\\Users\\dada\\secret.txt" }), + browserGate: false, + networkBoundary: { allowTestPort: true }, + registration, + }); + const session = seedAdmin(registration); + const response = await app.inject({ + headers: { ...headers, cookie: `dada_admin_session=${session.sessionToken}` }, + method: "GET", + url: "/api/v1/admin/diagnostics", + }); + expect(response.statusCode).toBe(503); + await app.close(); + }); + + it("rejects a sensitive nested service reason even when diagnostic text is clean", async () => { + const registration = createRegistration(); + const app = await createApp({ + adminDiagnostics: async () => ({ + ...diagnosticsFixture, + services: { + ...servicesFixture, + services: servicesFixture.services.map((service) => service.service_id === "resend" ? { ...service, pause_reason: "password" } : service), + }, + }), + browserGate: false, + networkBoundary: { allowTestPort: true }, + registration, + }); + const session = seedAdmin(registration); + const response = await app.inject({ headers: { ...headers, cookie: `dada_admin_session=${session.sessionToken}` }, method: "GET", url: "/api/v1/admin/diagnostics" }); + expect(response.statusCode).toBe(503); + await app.close(); + }); +}); diff --git a/tests/e2e/wp6-05-state.spec.ts b/tests/e2e/wp6-05-state.spec.ts new file mode 100644 index 0000000..b40cc66 --- /dev/null +++ b/tests/e2e/wp6-05-state.spec.ts @@ -0,0 +1,95 @@ +import { expect, test } from "@playwright/test"; +import { createServer, type ViteDevServer } from "vite"; +import { resolve } from "node:path"; + +import { adminDiagnosticsFixture, adminServicesStorageFixture } from "../fixtures/wp6-05-state.js"; + +let vite: ViteDevServer; +let webUrl: string; + +const adminSession = { + admin: { role: "super_admin", status: "active", user_id: "00000000-0000-4000-8000-000000000605" }, + audience: "admin", + authenticated: true, + expires_at: "2026-09-02T09:30:00.000Z", +}; + +test.beforeAll(async () => { + vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } }); + await vite.listen(); + const address = vite.httpServer?.address(); + if (!address || typeof address === "string") throw new Error("Vite did not expose a test port."); + webUrl = `http://127.0.0.1:${address.port}`; +}); + +test.afterAll(async () => vite.close()); + +test("TDD-WP6-DIAG-001-redacted-diagnostics renders state and diagnostics without sensitive fields", async ({ page }) => { + await page.route("**/api/v1/admin-auth/session", (route) => route.fulfill({ body: JSON.stringify(adminSession), contentType: "application/json", status: 200 })); + await page.route("**/api/v1/admin/services-storage", (route) => route.fulfill({ body: JSON.stringify(adminServicesStorageFixture), contentType: "application/json", status: 200 })); + await page.route("**/api/v1/admin/diagnostics", (route) => route.fulfill({ body: JSON.stringify(adminDiagnosticsFixture), contentType: "application/json", status: 200 })); + await page.goto(`${webUrl}/admin/services-storage`); + + await expect(page.getByRole("heading", { level: 2, name: "服务与存储" })).toBeVisible(); + await expect(page.getByText("Resend", { exact: true })).toBeVisible(); + await expect(page.getByText("额度暂停", { exact: true })).toBeVisible(); + await expect(page.getByText("4.25 GB", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "复制诊断" })).toBeEnabled(); + await expect(page.locator("body")).not.toContainText(/secret|password|example\.invalid|C:\\Users/i); +}); + +test("TDD-WP6-STATE-001-three-model-states refetches REST truth after a runtime event", async ({ page }) => { + await page.addInitScript(() => { + class FakeEventSource { + static instance: FakeEventSource | undefined; + onmessage: ((event: MessageEvent) => void) | null = null; + constructor() { FakeEventSource.instance = this; } + close() { if (FakeEventSource.instance === this) FakeEventSource.instance = undefined; } + } + Object.defineProperty(window, "EventSource", { configurable: true, value: FakeEventSource }); + (window as unknown as { emitDadaEvent: () => void }).emitDadaEvent = () => FakeEventSource.instance?.onmessage?.({ data: "{}" } as MessageEvent); + }); + await page.route("**/api/v1/admin-auth/session", (route) => route.fulfill({ body: JSON.stringify({ ...adminSession, csrf_token: "csrf-admin-model-fixture-000000000000000000000000000000000" }), contentType: "application/json", status: 200 })); + let runtimeAvailable = false; + let modelCalls = 0; + const configuration = () => { + const ids = ["gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview", "gpt-image-2"]; + return { + config_set_version: 1, + configured_default_model_id: ids[0], + recommended_model_id: runtimeAvailable ? ids[1] : null, + models: ids.map((modelId, index) => ({ + config_version: 1, + contract_evidence_ref: null, + contract_validation_status: "verified", + credit_cost: 1, + display_name: ["Gemini 3.1 Flash Image Preview", "Gemini 3 Pro Image Preview", "GPT Image 2"][index], + enabled: true, + error_mapping_profile: { timeout: "upstream_timeout" }, + gateway_account_ref: "mock-gateway", + is_default: index === 0, + model_id: modelId, + prompt_max_length: 1_000, + recommendation_priority: index + 1, + reference_limits: { max_file_bytes: 1_024, max_files: 2, max_total_bytes: 2_048 }, + route_profile: { endpoint: "https://mock.invalid/v1/images" }, + runtime_availability: { available_for_new_jobs: runtimeAvailable, checked_at: "2026-08-02T15:00:00.000Z", reason: runtimeAvailable ? "available" : "gateway_balance_insufficient" }, + safety_source: "provider", + supported_ratios: ["3:4", "1:1", "4:3", "9:16"], + })), + }; + }; + await page.route("**/api/v1/models", (route) => { + modelCalls += 1; + return route.fulfill({ body: JSON.stringify(configuration()), contentType: "application/json", status: 200 }); + }); + + await page.goto(`${webUrl}/admin/models`); + await expect(page.getByText("当前推荐").locator("..").getByText("无", { exact: true })).toBeVisible(); + expect(modelCalls).toBeGreaterThanOrEqual(1); + runtimeAvailable = true; + await page.evaluate(() => (window as unknown as { emitDadaEvent: () => void }).emitDadaEvent()); + await expect(page.getByText("当前推荐").locator("..").getByText("gemini-3-pro-image-preview", { exact: true })).toBeVisible(); + await expect(page.getByText("配置默认").locator("..").getByText("gemini-3.1-flash-image-preview", { exact: true })).toBeVisible(); + expect(modelCalls).toBeGreaterThanOrEqual(2); +}); diff --git a/tests/fixtures/wp6-05-state.ts b/tests/fixtures/wp6-05-state.ts new file mode 100644 index 0000000..a97b064 --- /dev/null +++ b/tests/fixtures/wp6-05-state.ts @@ -0,0 +1,34 @@ +export const adminServicesStorageFixture = { + generated_at: "2026-08-04T09:30:00.000Z", + services: [ + { checked_at: "2026-08-04T09:30:00.000Z", configured: true, impact_scope: "authentication" as const, pause_reason: null, service_id: "resend" as const, status: "active" as const }, + { checked_at: "2026-08-04T09:30:00.000Z", configured: true, impact_scope: "location" as const, pause_reason: "quota_exhausted", service_id: "amap" as const, status: "paused_quota" as const }, + { checked_at: "2026-08-04T09:30:00.000Z", configured: true, impact_scope: "generation" as const, pause_reason: "balance_insufficient", service_id: "ai_gateway" as const, status: "degraded" as const }, + { checked_at: "2026-08-04T09:30:00.000Z", configured: true, impact_scope: "generation" as const, pause_reason: "worker_stopped", service_id: "worker" as const, status: "degraded" as const }, + { checked_at: "2026-08-04T09:30:00.000Z", configured: true, impact_scope: "api" as const, pause_reason: null, service_id: "api" as const, status: "active" as const }, + { checked_at: "2026-08-04T09:30:00.000Z", configured: true, impact_scope: "storage" as const, pause_reason: null, service_id: "asset_root" as const, status: "active" as const }, + ], + storage: { + capacity_notice_level: "warning" as const, + cleanup_pending_count: 2, + data_root_ref: "configured_local_data_root" as const, + hard_limit_bytes: 5_368_709_120, + last_measured_at: "2026-08-04T09:30:00.000Z", + managed_content_bytes: 4_563_402_752, + remeasurement_required: false, + status: "active" as const, + storage_backend: "local_filesystem" as const, + }, +}; + +export const adminDiagnosticsFixture = { + generated_at: "2026-08-04T09:30:00.000Z", + diagnostic_text: "Dada P0-A diagnostics\napp_version=0.0.0\napi_status=ready\nworker_status=ready\nstorage_status=active", + services: adminServicesStorageFixture, + system: { + api_status: "ready" as const, + app_version: "0.0.0", + browser_support: [{ brand: "Google Chrome" as const, major: 128 }], + worker_status: "ready" as const, + }, +};