Compare commits

..
Author SHA1 Message Date
suyx 5041dc03c3 feat: add admin state and diagnostics (TASK-WP6-05)
Dada P0-A isolated Windows CI / validate-and-package (push) Successful in 12m4s
2026-08-04 01:51:35 +08:00
20 changed files with 1302 additions and 1343 deletions
+194
View File
@@ -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<AdminService["status"]>(["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<AdminService, "configured" | "status" | "checked_at" | "pause_reason">;
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;
}
+66 -195
View File
@@ -10,9 +10,9 @@ import {
AccountProfileUpdateResponseSchema,
AccountSettingsResponseSchema,
AdminAuthenticatedUserSchema,
AdminGenerationRecordSchema,
AdminGenerationListResponseSchema,
AdminDiagnosticsResponseSchema,
AdminOverviewResponseSchema,
AdminServicesStorageResponseSchema,
AdminCreditParamsSchema,
AdminLoginCompleteRequestSchema,
AdminLoginCompleteResponseSchema,
@@ -63,10 +63,6 @@ import {
ModelConfigUpdateRequestSchema,
ModelParamsSchema,
ModelConfigUpdateHeadersSchema,
PrivateContentGenerationParamsSchema,
PrivateContentNoticeAckRequestSchema,
PrivateContentNoticeAckResponseSchema,
PrivateContentPromptResponseSchema,
FailedEmptyTrashRequestSchema,
FailedEmptyTrashResponseSchema,
ExportFormatSchema,
@@ -118,6 +114,8 @@ import {
type AdminLoginCompleteRequest,
type AdminLoginSendRequest,
type AdminOverviewResponse,
type AdminDiagnosticsResponse,
type AdminServicesStorageResponse,
type AccountDeletionCompleteRequest,
type AccountProfileUpdateRequest,
type AdminCreditParams,
@@ -186,7 +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 { PrivateContentError, PrivateContentService } from "./private-content.js";
import { assertSafeAdminDiagnostics, assertSafeAdminServicesStorage } from "./admin-state.js";
const defaultBootstrap: BootstrapResponse = {
app_version: "0.0.0",
@@ -201,7 +199,9 @@ const defaultBootstrap: BootstrapResponse = {
};
export interface CreateAppOptions {
adminDiagnostics?: () => AdminDiagnosticsResponse | Promise<AdminDiagnosticsResponse>;
adminOverview?: () => AdminOverviewResponse | Promise<AdminOverviewResponse>;
adminServicesStorage?: () => AdminServicesStorageResponse | Promise<AdminServicesStorageResponse>;
amap?: AmapAdapter;
assetReleases?: AssetReleaseReader;
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
@@ -228,7 +228,6 @@ export interface CreateAppOptions {
releaseVersion: string;
resourceId: string;
}) => boolean | Promise<boolean>;
privateContent?: PrivateContentService;
registration?: RegistrationService;
}
@@ -648,13 +647,6 @@ function sendBrowserUnsupported(
export async function createApp(options: CreateAppOptions = {}) {
const eventHub = options.eventHub ?? new EventHub();
const bootstrap = options.bootstrap ?? (() => defaultBootstrap);
const privateContent = options.privateContent ?? (options.registration
? new PrivateContentService(
options.registration.database,
options.registration.options.currentPrivacyNoticeVersion,
options.registration.options.clock,
)
: undefined);
const browserGate = options.browserGate ?? true;
const browserSupportSecret = options.browserSupportSecret ?? randomBytes(32);
const browserSupportRelease = options.browserSupportRelease;
@@ -706,13 +698,9 @@ export async function createApp(options: CreateAppOptions = {}) {
AdminLoginCompleteRequestSchema,
AdminLoginCompleteResponseSchema,
AdminSessionResponseSchema,
AdminGenerationRecordSchema,
AdminGenerationListResponseSchema,
PrivateContentNoticeAckRequestSchema,
PrivateContentNoticeAckResponseSchema,
PrivateContentPromptResponseSchema,
PrivateContentGenerationParamsSchema,
AdminOverviewResponseSchema,
AdminServicesStorageResponseSchema,
AdminDiagnosticsResponseSchema,
CreditSummarySchema,
CreditEntryTypeSchema,
CreditEntryStatusSchema,
@@ -854,167 +842,6 @@ export async function createApp(options: CreateAppOptions = {}) {
status: "ready",
}));
const readAdminRequestSession = (request: { headers: Record<string, string | string[] | undefined> }) => {
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
return token && options.registration ? options.registration.readAdminSession(token) : undefined;
};
const privateContentNoticeRequired = (reply: FastifyReply, correlationId: string) => {
const notice = privateContent?.currentNotice();
return reply.code(428).send(createErrorEnvelope({
code: "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED",
correlationId,
details: { latest_version: notice?.version ?? "" },
}));
};
app.post(
"/api/v1/admin/private-content-notice/ack",
{
attachValidation: true,
schema: {
body: Type.Ref(PrivateContentNoticeAckRequestSchema),
headers: Type.Intersect([Type.Ref(CsrfHeadersSchema), Type.Ref(RegistrationCompleteHeadersSchema)]),
operationId: "ackPrivateContentNotice",
response: {
200: Type.Ref(PrivateContentNoticeAckResponseSchema),
400: Type.Ref(ErrorEnvelopeSchema),
401: Type.Ref(ErrorEnvelopeSchema),
403: Type.Ref(ErrorEnvelopeSchema),
428: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Admin Private Content"],
},
},
async (request, reply) => {
if (request.validationError || !headerValue(request.headers["idempotency-key"])) {
return reply.code(400).send(createErrorEnvelope({
code: "REGISTRATION_REQUEST_INVALID",
correlationId: request.id,
details: { field_errors: [{ field: "headers", message_key: "request.headers.invalid" }] },
}));
}
if (!privateContent || !options.registration) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const admin = options.registration.authorizeAdminMutation({
csrfToken: headerValue(request.headers["x-csrf-token"]) ?? "",
sessionToken: token,
});
const result = privateContent.acknowledge(admin.userId, (request.body as { expected_notice_version: string }).expected_notice_version);
return { acknowledged_at: result.acknowledgedAt, notice_version: result.noticeVersion, status: "acknowledged" as const };
} catch (error) {
if (error instanceof RegistrationError) return registrationFailure(reply, request.id, error);
if (error instanceof PrivateContentError && error.code === "notice_version_conflict") return privateContentNoticeRequired(reply, request.id);
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
},
);
app.get(
"/api/v1/admin/generations",
{
schema: {
operationId: "listAdminGenerations",
response: {
200: Type.Ref(AdminGenerationListResponseSchema),
401: Type.Ref(ErrorEnvelopeSchema),
428: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Admin Private Content"],
},
},
async (request, reply) => {
if (!privateContent || !options.registration) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
const session = readAdminRequestSession(request);
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
privateContent.requireAcknowledgement(session.user_id);
return privateContent.listGenerations();
} catch (error) {
if (error instanceof PrivateContentError && error.code === "notice_required") return privateContentNoticeRequired(reply, request.id);
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
},
);
app.get(
"/api/v1/admin/private-content/generations/:generationId/prompt",
{
attachValidation: true,
schema: {
params: Type.Ref(PrivateContentGenerationParamsSchema),
operationId: "openAdminGenerationPrompt",
response: {
200: Type.Ref(PrivateContentPromptResponseSchema),
401: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
428: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Admin Private Content"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(404).send(null);
if (!privateContent || !options.registration) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
const session = readAdminRequestSession(request);
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const generationId = (request.params as { generationId: string }).generationId;
const value = privateContent.readPrompt(session.user_id, generationId);
reply.header("Cache-Control", "private, no-store");
return { content_type: "prompt" as const, generation_id: value.generationId, prompt: value.prompt };
} catch (error) {
if (error instanceof PrivateContentError && error.code === "notice_required") return privateContentNoticeRequired(reply, request.id);
if (error instanceof PrivateContentError && error.code === "not_found") return reply.code(404).send(null);
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
},
);
app.get(
"/api/v1/admin/private-content/generations/:generationId/image",
{
attachValidation: true,
schema: {
params: Type.Ref(PrivateContentGenerationParamsSchema),
operationId: "openAdminGenerationImage",
produces: ["application/octet-stream"],
response: {
200: Type.String({ format: "binary" }),
401: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
428: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Admin Private Content"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(404).send(null);
if (!privateContent || !options.registration || !options.latestExports) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
const session = readAdminRequestSession(request);
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const generationId = (request.params as { generationId: string }).generationId;
const target = privateContent.readImageTarget(session.user_id, generationId);
const item = options.latestExports.getOriginal(target.ownerId, target.projectId, target.imageId);
const extension = item.mime_type === "image/jpeg" ? "jpg" : item.mime_type === "image/webp" ? "webp" : "png";
reply.header("Cache-Control", "private, no-store");
reply.header("Content-Disposition", `inline; filename="dada-generation.${extension}"`);
reply.type(item.mime_type);
return reply.send(createReadStream(item.path));
} catch (error) {
if (error instanceof PrivateContentError && error.code === "notice_required") return privateContentNoticeRequired(reply, request.id);
if (error instanceof PrivateContentError && error.code === "not_found") return reply.code(404).send(null);
return latestExportFailure(reply, request.id, error);
}
},
);
app.get(
"/api/v1/assets/public/:resourceVersion/manifest",
{ schema: { hide: true } },
@@ -1143,13 +970,6 @@ export async function createApp(options: CreateAppOptions = {}) {
})
: false;
if (userSession?.userId !== resource.ownerId && !controlledAdmin) return reply.code(404).send();
if (adminSession && controlledAdmin && privateContent) {
try {
privateContent.recordPrivateAssetAccess(adminSession.user_id, resource.ownerId, resource.resourceId);
} catch {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
}
reply.type(resource.mimeType);
reply.header("Cache-Control", "private, no-store");
reply.header("Content-Disposition", "inline");
@@ -1383,18 +1203,15 @@ export async function createApp(options: CreateAppOptions = {}) {
if (!session) {
return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
}
const acknowledgement = privateContent?.readAcknowledgement(session.user_id);
const notice = privateContent?.currentNotice();
return {
acknowledged_private_content_notice_version: acknowledgement?.version ?? null,
acknowledged_private_content_notice_version: null,
admin: { role: "super_admin" as const, status: "active" as const, user_id: session.user_id },
audience: "admin" as const,
authenticated: true as const,
csrf_token: options.registration.issueAdminCsrfToken(token!),
...(notice ? { current_private_content_notice_message_key: notice.messageKey } : {}),
current_private_content_notice_version: notice?.version ?? null,
current_private_content_notice_version: null,
expires_at: new Date(session.expires_at).toISOString(),
notice_acknowledged: notice ? acknowledgement?.version === notice.version : false,
notice_acknowledged: false,
};
},
);
@@ -1428,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",
{
+16
View File
@@ -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 } : {}),
-186
View File
@@ -1,186 +0,0 @@
import { randomUUID } from "node:crypto";
import type BetterSqlite3 from "better-sqlite3";
import { auditRetentionMilliseconds } from "./audit-policy.js";
type GenerationStatus = "queued" | "running" | "succeeded" | "failed" | "rejected";
export class PrivateContentError extends Error {
constructor(readonly code: "notice_required" | "notice_version_conflict" | "not_found") {
super(code);
this.name = "PrivateContentError";
}
}
function iso(value: number) {
return new Date(value).toISOString();
}
function isGenerationTablePresent(database: BetterSqlite3.Database) {
return Boolean(database.prepare(
"SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'generation_jobs'",
).get());
}
export class PrivateContentService {
constructor(
readonly database: BetterSqlite3.Database,
readonly currentNoticeVersion: string,
private readonly clock: () => number = Date.now,
) {}
currentNotice() {
return {
version: this.currentNoticeVersion,
messageKey: "admin.private_content.notice",
} as const;
}
readAcknowledgement(adminUserId: string) {
const row = this.database.prepare(`
SELECT private_content_notice_version, private_content_notice_acknowledged_at
FROM user_profiles WHERE user_id = ?
`).get(adminUserId) as { private_content_notice_version: string | null; private_content_notice_acknowledged_at: number | null } | undefined;
return {
version: row?.private_content_notice_version ?? null,
acknowledgedAt: row?.private_content_notice_acknowledged_at === null || row?.private_content_notice_acknowledged_at === undefined
? null : iso(row.private_content_notice_acknowledged_at),
};
}
isAcknowledged(adminUserId: string) {
return this.readAcknowledgement(adminUserId).version === this.currentNoticeVersion;
}
requireAcknowledgement(adminUserId: string) {
if (!this.isAcknowledged(adminUserId)) throw new PrivateContentError("notice_required");
}
acknowledge(adminUserId: string, expectedNoticeVersion: string) {
const now = this.clock();
return this.database.transaction(() => {
if (expectedNoticeVersion !== this.currentNoticeVersion) {
throw new PrivateContentError("notice_version_conflict");
}
this.database.prepare(`
INSERT INTO user_profiles (
user_id, creator_name, social_id, private_content_notice_version,
private_content_notice_acknowledged_at
) VALUES (?, '', '', ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
private_content_notice_version = excluded.private_content_notice_version,
private_content_notice_acknowledged_at =
CASE WHEN user_profiles.private_content_notice_version = excluded.private_content_notice_version
THEN user_profiles.private_content_notice_acknowledged_at ELSE excluded.private_content_notice_acknowledged_at END
`).run(adminUserId, this.currentNoticeVersion, now);
const acknowledged = this.readAcknowledgement(adminUserId);
return {
noticeVersion: this.currentNoticeVersion,
acknowledgedAt: acknowledged.acknowledgedAt ?? iso(now),
};
})();
}
listGenerations() {
const generatedAt = iso(this.clock());
if (!isGenerationTablePresent(this.database)) return { generated_at: generatedAt, items: [] };
const rows = this.database.prepare(`
SELECT generation_id, owner_id, project_id, model_id, ratio, status,
confirmed_credit_cost, reserved_credits, final_credit_state,
error_category, created_at, updated_at
FROM generation_jobs
WHERE submission_ready = 1
ORDER BY created_at DESC, generation_id DESC
LIMIT 100
`).all() as Array<{
generation_id: string;
owner_id: string;
project_id: string;
model_id: string;
ratio: "3:4" | "1:1" | "4:3" | "9:16";
status: GenerationStatus;
confirmed_credit_cost: number;
reserved_credits: number;
final_credit_state: "committed" | "released" | null;
error_category: string | null;
created_at: number;
updated_at: number;
}>;
return {
generated_at: generatedAt,
items: rows.map((row) => {
const terminal = row.status === "succeeded" || row.status === "failed" || row.status === "rejected";
return {
generation_id: row.generation_id,
owner_ref: row.owner_id,
project_id: row.project_id,
model_id: row.model_id,
ratio: row.ratio,
status: row.status,
created_at: iso(row.created_at),
completed_at: terminal ? iso(row.updated_at) : null,
duration_ms: terminal ? Math.max(0, row.updated_at - row.created_at) : null,
confirmed_credit_cost: row.confirmed_credit_cost,
reserved_credits: row.reserved_credits,
final_credit_state: row.final_credit_state,
error_category: row.error_category,
};
}),
};
}
private generation(generationId: string) {
if (!isGenerationTablePresent(this.database)) throw new PrivateContentError("not_found");
const row = this.database.prepare(`
SELECT generation_id, owner_id, project_id
FROM generation_jobs WHERE generation_id = ? AND submission_ready = 1
`).get(generationId) as { generation_id: string; owner_id: string; project_id: string } | undefined;
if (!row) throw new PrivateContentError("not_found");
return row;
}
private recordAccess(input: { adminUserId: string; ownerId: string; generationId: string; contentType: "image" | "prompt" }) {
const now = this.clock();
// The insert is committed before the caller reads the private value. A failed
// constraint therefore cannot accidentally release a private response.
this.database.transaction(() => {
this.database.prepare(`
INSERT INTO private_content_access_logs (
log_id, actor_ref, subject_ref, target_ref, content_type, occurred_at, expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
randomUUID(), input.adminUserId, input.ownerId, input.generationId,
input.contentType, now, now + auditRetentionMilliseconds,
);
})();
}
recordPrivateAssetAccess(adminUserId: string, ownerId: string, resourceId: string) {
this.recordAccess({ adminUserId, ownerId, generationId: resourceId, contentType: "image" });
}
readPrompt(adminUserId: string, generationId: string) {
this.requireAcknowledgement(adminUserId);
const row = this.generation(generationId);
this.recordAccess({ adminUserId, ownerId: row.owner_id, generationId: row.generation_id, contentType: "prompt" });
const content = this.database.prepare(
"SELECT prompt FROM generation_jobs WHERE generation_id = ? AND submission_ready = 1",
).get(row.generation_id) as { prompt: string } | undefined;
if (!content) throw new PrivateContentError("not_found");
return { generationId: row.generation_id, prompt: content.prompt };
}
readImageTarget(adminUserId: string, generationId: string) {
this.requireAcknowledgement(adminUserId);
const row = this.database.prepare(`
SELECT g.generation_id, g.owner_id, g.project_id, pi.image_id
FROM generation_jobs g
JOIN project_images pi ON pi.project_id = g.project_id AND pi.generation_id = g.generation_id
WHERE g.generation_id = ? AND g.status = 'succeeded'
ORDER BY pi.created_at DESC LIMIT 1
`).get(generationId) as { generation_id: string; owner_id: string; project_id: string; image_id: string } | undefined;
if (!row) throw new PrivateContentError("not_found");
this.recordAccess({ adminUserId, ownerId: row.owner_id, generationId: row.generation_id, contentType: "image" });
return { projectId: row.project_id, imageId: row.image_id, ownerId: row.owner_id };
}
}
-24
View File
@@ -1,24 +0,0 @@
.admin-generations { display: grid; gap: 20px; }
.admin-generations-refresh { align-self: start; }
.admin-generations-notice-panel { display: grid; gap: 14px; max-width: 760px; padding: 24px; border: 1px solid #d5b36a; background: #fffaf0; }
.admin-generations-notice-panel p { margin: 0; }
.admin-generations-notice-panel button { justify-self: start; }
.admin-generations-error, .admin-generations-notice { padding: 12px 16px; border: 1px solid #d46a6a; background: #fff4f4; }
.admin-generations-error button { margin-left: 12px; }
.admin-generations-table-wrap { overflow-x: auto; border: 1px solid #d9dde5; background: #fff; }
.admin-generations-table-wrap table { width: 100%; min-width: 1050px; border-collapse: collapse; }
.admin-generations-table-wrap th, .admin-generations-table-wrap td { padding: 12px 14px; border-bottom: 1px solid #e9ebef; text-align: left; vertical-align: top; }
.admin-generations-table-wrap th { background: #f5f6f8; color: #4d5664; font-size: 12px; }
.admin-generations-table-wrap small { color: #6c7481; }
.admin-generations-status { display: inline-block; padding: 3px 7px; border-radius: 4px; background: #edf0f4; }
.admin-generations-status.is-succeeded { color: #23623d; background: #e6f4ea; }
.admin-generations-status.is-failed, .admin-generations-status.is-rejected { color: #8b2b2b; background: #fff0f0; }
.admin-generations-status.is-running { color: #7a5a10; background: #fff5d8; }
.admin-generations-actions { display: grid; gap: 8px; min-width: 190px; }
.admin-generations-actions button { white-space: normal; }
.admin-generations-empty { margin: 0; padding: 28px; color: #6c7481; }
.admin-generations-opened { display: grid; gap: 10px; padding: 18px; border: 1px solid #cbd2dd; background: #fff; }
.admin-generations-opened header { display: flex; align-items: center; justify-content: space-between; }
.admin-generations-opened h3 { margin: 0; }
.admin-generations-opened pre { max-height: 360px; overflow: auto; margin: 0; padding: 14px; white-space: pre-wrap; background: #f6f7f9; }
.admin-generations-opened img { max-width: 100%; max-height: 620px; object-fit: contain; }
-156
View File
@@ -1,156 +0,0 @@
import { useEffect, useState } from "react";
import "./admin-generations.css";
interface AdminSession {
acknowledged_private_content_notice_version: string | null;
current_private_content_notice_version: string | null;
csrf_token: string;
notice_acknowledged: boolean;
}
interface GenerationRecord {
generation_id: string;
owner_ref: string;
project_id: string;
model_id: string;
ratio: string;
status: "queued" | "running" | "succeeded" | "failed" | "rejected";
created_at: string;
completed_at: string | null;
duration_ms: number | null;
confirmed_credit_cost: number;
reserved_credits: number;
final_credit_state: "committed" | "released" | null;
error_category: string | null;
}
interface GenerationResponse { generated_at: string; items: GenerationRecord[] }
interface OpenedPrompt { generation_id: string; prompt: string }
function idempotencyKey() {
return `${crypto.randomUUID().replaceAll("-", "")}${crypto.randomUUID().replaceAll("-", "")}`;
}
function compactId(value: string) { return `${value.slice(0, 8)}...${value.slice(-4)}`; }
function formatTime(value: string | null) { return value ? new Intl.DateTimeFormat("zh-CN", { dateStyle: "short", timeStyle: "medium" }).format(new Date(value)) : "未完成"; }
function statusLabel(value: GenerationRecord["status"]) { return { queued: "排队", running: "运行中", succeeded: "成功", failed: "失败", rejected: "已拒绝" }[value]; }
export function AdminGenerationsPage() {
const [session, setSession] = useState<AdminSession>();
const [records, setRecords] = useState<GenerationRecord[]>([]);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [acknowledging, setAcknowledging] = useState(false);
const [notice, setNotice] = useState("");
const [openedPrompt, setOpenedPrompt] = useState<OpenedPrompt>();
const [openedImage, setOpenedImage] = useState<{ generationId: string; url: string }>();
async function load() {
setLoading(true);
setFailed(false);
try {
const sessionResponse = await fetch("/api/v1/admin-auth/session", { credentials: "same-origin" });
if (sessionResponse.status === 401) throw new Error("session_invalid");
if (!sessionResponse.ok) throw new Error("session_unavailable");
const current = await sessionResponse.json() as AdminSession;
setSession(current);
setNotice("");
if (!current.notice_acknowledged) {
setRecords([]);
return;
}
const listResponse = await fetch("/api/v1/admin/generations", { credentials: "same-origin" });
if (!listResponse.ok) throw new Error("generation_list_unavailable");
setRecords((await listResponse.json() as GenerationResponse).items);
} catch {
setFailed(true);
} finally {
setLoading(false);
}
}
useEffect(() => { void load(); }, []);
useEffect(() => () => { if (openedImage) URL.revokeObjectURL(openedImage.url); }, [openedImage]);
async function acknowledge() {
if (!session?.current_private_content_notice_version || acknowledging) return;
setAcknowledging(true);
setNotice("");
try {
const response = await fetch("/api/v1/admin/private-content-notice/ack", {
body: JSON.stringify({ expected_notice_version: session.current_private_content_notice_version }),
credentials: "same-origin",
headers: { "Content-Type": "application/json", "Idempotency-Key": idempotencyKey(), "X-CSRF-Token": session.csrf_token },
method: "POST",
});
if (!response.ok) throw new Error("notice_ack_failed");
await load();
} catch {
setNotice("告知版本已变化或确认未完成,请重新读取。 ");
} finally {
setAcknowledging(false);
}
}
async function openPrompt(generationId: string) {
setNotice("");
try {
const response = await fetch(`/api/v1/admin/private-content/generations/${generationId}/prompt`, { credentials: "same-origin" });
if (!response.ok) throw new Error("prompt_unavailable");
setOpenedPrompt(await response.json() as OpenedPrompt);
} catch {
setNotice("内容读取未完成,访问审计未成功时不会返回内容。 ");
}
}
async function openImage(generationId: string) {
setNotice("");
try {
const response = await fetch(`/api/v1/admin/private-content/generations/${generationId}/image`, { credentials: "same-origin" });
if (!response.ok) throw new Error("image_unavailable");
const url = URL.createObjectURL(await response.blob());
setOpenedImage((previous) => {
if (previous) URL.revokeObjectURL(previous.url);
return { generationId, url };
});
} catch {
setNotice("内容读取未完成,访问审计未成功时不会返回内容。 ");
}
}
return (
<main className="admin-generations" id="admin-main">
<header className="admin-page-heading"><div><p>OPERATIONS / GENERATION RECORDS</p><h2></h2></div><button className="admin-generations-refresh" onClick={() => void load()} type="button"></button></header>
{loading ? <p aria-live="polite"></p> : null}
{failed ? <div className="admin-generations-error" role="alert"><button onClick={() => void load()} type="button"></button></div> : null}
{notice ? <p className="admin-generations-notice" role="alert">{notice}</p> : null}
{session && !session.notice_acknowledged ? (
<section aria-labelledby="private-content-notice-title" className="admin-generations-notice-panel">
<p>PRIVATE CONTENT ACCESS</p>
<h3 id="private-content-notice-title"></h3>
<p>访</p>
<button disabled={acknowledging} onClick={() => void acknowledge()} type="button">{acknowledging ? "确认中" : "确认并进入记录"}</button>
</section>
) : null}
{session?.notice_acknowledged ? (
<section aria-label="生成记录元数据" className="admin-generations-table-wrap">
<table><thead><tr><th></th><th></th><th> / </th><th></th><th> / </th><th></th><th></th></tr></thead><tbody>
{records.map((record) => <tr key={record.generation_id}>
<td><code>{compactId(record.generation_id)}</code></td>
<td><code>{compactId(record.owner_ref)}</code></td>
<td>{record.model_id}<br /><small>{record.ratio}</small></td>
<td><span className={`admin-generations-status is-${record.status}`}>{statusLabel(record.status)}</span>{record.error_category ? <small>{record.error_category}</small> : null}</td>
<td><time dateTime={record.created_at}>{formatTime(record.created_at)}</time><br /><small>{formatTime(record.completed_at)}</small></td>
<td>{record.confirmed_credit_cost} / {record.final_credit_state ?? "冻结"}</td>
<td className="admin-generations-actions"><button onClick={() => void openPrompt(record.generation_id)} type="button"></button><button disabled={record.status !== "succeeded"} onClick={() => void openImage(record.generation_id)} type="button"></button></td>
</tr>)}
</tbody></table>
{!records.length && !loading ? <p className="admin-generations-empty"></p> : null}
</section>
) : null}
{openedPrompt ? <section aria-label="已审计的完整提示词" className="admin-generations-opened"><header><h3></h3><button onClick={() => setOpenedPrompt(undefined)} type="button"></button></header><p><code>{compactId(openedPrompt.generation_id)}</code></p><pre>{openedPrompt.prompt}</pre></section> : null}
{openedImage ? <section aria-label="已审计的生成图片" className="admin-generations-opened"><header><h3></h3><button onClick={() => { URL.revokeObjectURL(openedImage.url); setOpenedImage(undefined); }} type="button"></button></header><img alt="已记录审计的生成图片" src={openedImage.url} /></section> : null}
</main>
);
}
+11 -4
View File
@@ -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<Record<string, HTMLInputElement | null>>({});
const defaultRefs = useRef<Record<string, HTMLInputElement | null>>({});
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: "" };
+37
View File
@@ -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; } }
+136
View File
@@ -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<AdminServicesStorageResponse["services"][number]["service_id"], string> = {
ai_gateway: "AI 网关",
amap: "高德",
api: "API",
asset_root: "素材根",
resend: "Resend",
worker: "Worker",
};
const statusLabels: Record<AdminServicesStorageResponse["services"][number]["status"], string> = {
active: "正常",
degraded: "有异常",
disabled: "已停用",
paused_provider: "供应商暂停",
paused_quota: "额度暂停",
unavailable: "不可用",
};
const impactLabels: Record<AdminServicesStorageResponse["services"][number]["impact_scope"], string> = {
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<T>(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<AdminServicesStorageResponse>();
const [diagnostics, setDiagnostics] = useState<AdminDiagnosticsResponse>();
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<AdminServicesStorageResponse>("/api/v1/admin/services-storage"),
getJson<AdminDiagnosticsResponse>("/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 (
<main className="admin-services-storage" id="admin-main">
<header className="admin-page-heading admin-services-heading">
<div><p>OPERATIONS / HEALTH</p><h2></h2></div>
<div className="admin-services-heading-actions">
{state ? <time dateTime={state.generated_at}> {formatTime(state.generated_at)}</time> : null}
<button aria-label="重新读取服务与存储状态" onClick={() => void load()} type="button"></button>
</div>
</header>
{loading && !state ? <div aria-label="服务与存储状态加载中" className="admin-services-loading"><span /><span /><span /><span /></div> : null}
{failed ? <div className="admin-services-failure" role="alert"><span>{state ? `,保留 ${formatTime(state.generated_at)} 的结果` : ""}</span><button onClick={() => void load()} type="button"></button></div> : null}
{state ? (
<>
<section aria-labelledby="admin-services-list-heading" className="admin-health-section">
<header><div><p>SERVICE STATUS</p><h3 id="admin-services-list-heading"></h3></div><span className="admin-safe-note"></span></header>
<div className="admin-service-grid">
{state.services.map((service) => (
<article className={`admin-service-card is-${service.status}`} key={service.service_id}>
<div className="admin-service-card-heading"><strong>{serviceLabels[service.service_id]}</strong><span>{statusLabels[service.status]}</span></div>
<dl>
<div><dt></dt><dd>{service.configured ? "已配置" : "未配置"}</dd></div>
<div><dt></dt><dd>{impactLabels[service.impact_scope]}</dd></div>
<div><dt></dt><dd>{formatTime(service.checked_at)}</dd></div>
{service.pause_reason ? <div><dt></dt><dd>{service.pause_reason}</dd></div> : null}
</dl>
</article>
))}
</div>
</section>
<section aria-labelledby="admin-storage-heading" className="admin-health-section">
<header><div><p>LOCAL DATA ROOT</p><h3 id="admin-storage-heading"></h3></div><span className={`admin-storage-state is-${state.storage.status}`}>{state.storage.status === "active" ? "可写" : state.storage.status === "full" ? "已满" : "不可用"}</span></header>
<div className="admin-storage-metrics">
<div><span></span><strong>{(state.storage.managed_content_bytes / 1024 / 1024 / 1024).toFixed(2)} GB</strong></div>
<div><span></span><strong>{(state.storage.hard_limit_bytes / 1024 / 1024 / 1024).toFixed(2)} GB</strong></div>
<div><span></span><strong>{state.storage.capacity_notice_level}</strong></div>
<div><span></span><strong>{state.storage.cleanup_pending_count}</strong></div>
</div>
<div className="admin-storage-progress" aria-label={`本机内容容量 ${(state.storage.managed_content_bytes / state.storage.hard_limit_bytes * 100).toFixed(1)}%`}><span style={{ width: `${Math.min(100, state.storage.managed_content_bytes / state.storage.hard_limit_bytes * 100)}%` }} /></div>
<p className="admin-storage-description"> Windows Dada <code>{state.storage.data_root_ref}</code> 5 GB </p>
<dl className="admin-storage-details"><div><dt></dt><dd>{formatTime(state.storage.last_measured_at)}</dd></div><div><dt></dt><dd>{state.storage.remeasurement_required ? "需要完成" : "无需等待"}</dd></div></dl>
</section>
<section aria-labelledby="admin-diagnostics-heading" className="admin-health-section admin-diagnostics-section">
<header><div><p>DIAGNOSTICS</p><h3 id="admin-diagnostics-heading"></h3></div><button disabled={!diagnostics} onClick={() => void copyDiagnostics()} type="button">{copied ? "已复制" : "复制诊断"}</button></header>
<p></p>
{diagnostics ? <pre aria-label="脱敏诊断内容">{diagnostics.diagnostic_text}</pre> : <div aria-label="诊断加载中" className="admin-diagnostics-placeholder" />}
</section>
</>
) : null}
</main>
);
}
+15 -31
View File
@@ -1,18 +1,9 @@
// Generated from openapi/openapi.json. Do not edit by hand.
import type { PrivateContentNoticeAckResponse, PrivateContentNoticeAckRequest, 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, AdminGenerationListResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, PrivateContentPromptResponse, 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; }
export async function ackPrivateContentNotice(body: PrivateContentNoticeAckRequest, options: ClientOptions = {}): Promise<PrivateContentNoticeAckResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/private-content-notice/ack`, { body: JSON.stringify(body), method: "POST", headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<PrivateContentNoticeAckResponse>;
}
export async function adjustAdminUserCredits(body: CreditAdjustmentRequest, options: ClientOptions = {}): Promise<CreditAdjustmentResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
@@ -96,6 +87,13 @@ export async function getAccountSettings(options: ClientOptions = {}): Promise<A
return response.json() as Promise<AccountSettingsResponse>;
}
export async function getAdminDiagnostics(options: ClientOptions = {}): Promise<AdminDiagnosticsResponse> {
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<AdminDiagnosticsResponse>;
}
export async function getAdminOverview(options: ClientOptions = {}): Promise<AdminOverviewResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/overview`, { method: "GET", headers: options.headers ?? {} });
@@ -103,6 +101,13 @@ export async function getAdminOverview(options: ClientOptions = {}): Promise<Adm
return response.json() as Promise<AdminOverviewResponse>;
}
export async function getAdminServicesStorage(options: ClientOptions = {}): Promise<AdminServicesStorageResponse> {
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<AdminServicesStorageResponse>;
}
export async function getAdminSession(options: ClientOptions = {}): Promise<AdminSessionResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/session`, { method: "GET", headers: options.headers ?? {} });
@@ -184,13 +189,6 @@ export async function getUserSession(options: ClientOptions = {}): Promise<UserS
return response.json() as Promise<UserSessionResponse>;
}
export async function listAdminGenerations(options: ClientOptions = {}): Promise<AdminGenerationListResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/generations`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AdminGenerationListResponse>;
}
export async function listProjects(options: ClientOptions = {}): Promise<ProjectListResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects`, { method: "GET", headers: options.headers ?? {} });
@@ -212,20 +210,6 @@ export async function logoutUser(options: ClientOptions = {}): Promise<LogoutRes
return response.json() as Promise<LogoutResponse>;
}
export async function openAdminGenerationImage(options: ClientOptions = {}): Promise<Blob> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/private-content/generations/{generationId}/image`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.blob() as Promise<Blob>;
}
export async function openAdminGenerationPrompt(options: ClientOptions = {}): Promise<PrivateContentPromptResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/private-content/generations/{generationId}/prompt`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<PrivateContentPromptResponse>;
}
export async function purgeProject(options: ClientOptions = {}): Promise<ProjectPurgeResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/purge`, { method: "POST", headers: options.headers ?? {} });
+34 -38
View File
@@ -60,25 +60,19 @@ export type AdminCreditParams = {
"userId": string;
};
export type AdminGenerationListResponse = {
export type AdminDiagnosticsResponse = {
"diagnostic_text": string;
"generated_at": string;
"items": Array<AdminGenerationRecord>;
"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 AdminGenerationRecord = {
"completed_at": string | null;
"confirmed_credit_cost": number;
"created_at": string;
"duration_ms": number | null;
"error_category": "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled" | "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid" | "unknown_retryable" | "unknown_non_retryable" | null;
"final_credit_state": "committed" | "released" | null;
"generation_id": string;
"model_id": string;
"owner_ref": string;
"project_id": string;
"ratio": "3:4" | "1:1" | "4:3" | "9:16";
"reserved_credits": number;
"status": "queued" | "running" | "succeeded" | "failed" | "rejected";
};
export type AdminLoginCompleteRequest = {
@@ -138,13 +132,35 @@ 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;
"audience": "admin";
"authenticated": true;
"csrf_token": string;
"current_private_content_notice_message_key"?: string;
"current_private_content_notice_version": string | null;
"expires_at": string;
"notice_acknowledged": boolean;
@@ -604,26 +620,6 @@ export type ModelRuntimeSseEvent = {
"runtime_availability_version": number;
};
export type PrivateContentGenerationParams = {
"generationId": string;
};
export type PrivateContentNoticeAckRequest = {
"expected_notice_version": string;
};
export type PrivateContentNoticeAckResponse = {
"acknowledged_at": string;
"notice_version": string;
"status": "acknowledged";
};
export type PrivateContentPromptResponse = {
"content_type": "prompt";
"generation_id": string;
"prompt": string;
};
export type ProjectDetailResponse = {
"canvas_state": CanvasState;
"created_at": string;
+3 -3
View File
@@ -7,7 +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 { AdminGenerationsPage } from "./admin-generations.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";
@@ -42,11 +42,11 @@ function renderAuthenticationEntry() {
"/admin": { content: <AdminOverviewPage />, title: "运营总览" },
"/admin/assets": { content: <AdminPlaceholderPage title="素材" />, title: "素材" },
"/admin/audit": { content: <AdminPlaceholderPage title="审计" />, title: "审计" },
"/admin/generations": { content: <AdminGenerationsPage />, title: "生成记录" },
"/admin/generations": { content: <AdminPlaceholderPage title="生成记录" />, title: "生成记录" },
"/admin/invites": { content: <AdminPlaceholderPage title="邀请码" />, title: "邀请码" },
"/admin/models": { content: <AdminModelsPage />, title: "模型" },
"/admin/preview": { content: <AdminPlaceholderPage title="内部预览" />, title: "内部预览" },
"/admin/services-storage": { content: <AdminPlaceholderPage title="服务与存储" />, title: "服务与存储" },
"/admin/services-storage": { content: <AdminServicesStoragePage />, title: "服务与存储" },
"/admin/users": { content: <AdminUsersPage />, title: "用户与点数" },
};
const page = adminPages[window.location.pathname] ?? adminPages["/admin"]!;
+410 -532
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -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",
+69 -66
View File
@@ -3,72 +3,6 @@ import { Type, type Static } from "@sinclair/typebox";
const isoTimestampPattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$";
const modelIdPattern = "^[a-z0-9][a-z0-9.-]+$";
const safeReferencePattern = "^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$";
const uuidPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$";
export const AdminGenerationRecordSchema = Type.Object(
{
generation_id: Type.String({ pattern: uuidPattern }),
owner_ref: Type.String({ pattern: uuidPattern }),
project_id: Type.String({ pattern: uuidPattern }),
model_id: Type.String({ maxLength: 80, pattern: modelIdPattern }),
ratio: Type.Union([Type.Literal("3:4"), Type.Literal("1:1"), Type.Literal("4:3"), Type.Literal("9:16")]),
status: Type.Union([Type.Literal("queued"), Type.Literal("running"), Type.Literal("succeeded"), Type.Literal("failed"), Type.Literal("rejected")]),
created_at: Type.String({ pattern: isoTimestampPattern }),
completed_at: Type.Union([Type.String({ pattern: isoTimestampPattern }), Type.Null()]),
duration_ms: Type.Union([Type.Integer({ minimum: 0 }), Type.Null()]),
confirmed_credit_cost: Type.Integer({ minimum: 0 }),
reserved_credits: Type.Integer({ minimum: 0 }),
final_credit_state: Type.Union([Type.Literal("committed"), Type.Literal("released"), Type.Null()]),
error_category: Type.Union([
Type.Literal("upstream_timeout"), Type.Literal("upstream_failed"), Type.Literal("safety_rejected"),
Type.Literal("model_disabled"), Type.Literal("gateway_balance_insufficient"), Type.Literal("gateway_contract_invalid"),
Type.Literal("reference_invalid"), Type.Literal("unknown_retryable"), Type.Literal("unknown_non_retryable"), Type.Null(),
]),
},
{ additionalProperties: false, $id: "AdminGenerationRecord" },
);
export const AdminGenerationListResponseSchema = Type.Object(
{
generated_at: Type.String({ pattern: isoTimestampPattern }),
items: Type.Array(Type.Ref(AdminGenerationRecordSchema), { maxItems: 100 }),
},
{ additionalProperties: false, $id: "AdminGenerationListResponse" },
);
export const PrivateContentNoticeAckRequestSchema = Type.Object(
{ expected_notice_version: Type.String({ minLength: 1, maxLength: 80, pattern: "^[A-Za-z0-9_.:-]+$" }) },
{ additionalProperties: false, $id: "PrivateContentNoticeAckRequest" },
);
export const PrivateContentNoticeAckResponseSchema = Type.Object(
{
notice_version: Type.String({ minLength: 1, maxLength: 80, pattern: "^[A-Za-z0-9_.:-]+$" }),
acknowledged_at: Type.String({ pattern: isoTimestampPattern }),
status: Type.Literal("acknowledged"),
},
{ additionalProperties: false, $id: "PrivateContentNoticeAckResponse" },
);
export const PrivateContentPromptResponseSchema = Type.Object(
{
generation_id: Type.String({ pattern: uuidPattern }),
content_type: Type.Literal("prompt"),
prompt: Type.String({ minLength: 1, maxLength: 4000 }),
},
{ additionalProperties: false, $id: "PrivateContentPromptResponse" },
);
export const PrivateContentGenerationParamsSchema = Type.Object(
{ generationId: Type.String({ pattern: uuidPattern }) },
{ additionalProperties: false, $id: "PrivateContentGenerationParams" },
);
export type AdminGenerationRecord = Static<typeof AdminGenerationRecordSchema>;
export type AdminGenerationListResponse = Static<typeof AdminGenerationListResponseSchema>;
export type PrivateContentNoticeAckRequest = Static<typeof PrivateContentNoticeAckRequestSchema>;
export type PrivateContentNoticeAckResponse = Static<typeof PrivateContentNoticeAckResponseSchema>;
export type PrivateContentPromptResponse = Static<typeof PrivateContentPromptResponseSchema>;
export const AdminOverviewResponseSchema = Type.Object(
{
@@ -158,3 +92,72 @@ export const AdminOverviewResponseSchema = Type.Object(
);
export type AdminOverviewResponse = Static<typeof AdminOverviewResponseSchema>;
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<typeof AdminServicesStorageResponseSchema>;
export type AdminDiagnosticsResponse = Static<typeof AdminDiagnosticsResponseSchema>;
-1
View File
@@ -149,7 +149,6 @@ export const AdminSessionResponseSchema = Type.Object(
audience: Type.Literal("admin"),
authenticated: Type.Literal(true),
csrf_token: Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }),
current_private_content_notice_message_key: Type.Optional(Type.String({ maxLength: 120, pattern: "^[A-Za-z0-9_.-]+$" })),
current_private_content_notice_version: Type.Union([Type.String(), Type.Null()]),
expires_at: Type.String({ pattern: isoTimestampPattern }),
notice_acknowledged: Type.Boolean(),
-105
View File
@@ -1,105 +0,0 @@
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 { RegistrationService } from "../../apps/api/src/registration.js";
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
const roots: string[] = [];
const services: RegistrationService[] = [];
const now = Date.parse("2026-08-04T09:30:00.000Z");
const headers = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
function harness() {
const root = mkdtempSync(join(tmpdir(), "dada-wp6-02-api-"));
roots.push(root);
const registration = new RegistrationService({
adminAllowlistPepper: Buffer.alloc(32, 0xc1),
challengePepper: Buffer.alloc(32, 0xc2),
clock: () => now,
currentPrivacyNoticeVersion: "p0a-private-content-v1",
databasePath: join(root, "dada.sqlite3"),
invitePepper: Buffer.alloc(32, 0xc3),
resend: new MockResendAdapter(),
sessionPepper: Buffer.alloc(32, 0xc4),
});
services.push(registration);
const adminId = randomUUID();
const ownerId = randomUUID();
const projectId = randomUUID();
const generationId = 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, ?, ?), (?, ?, 'user', 'active', 1, ?, ?)
`).run(adminId, `admin-${adminId}@example.invalid`, randomUUID(), now, ownerId, `user-${ownerId}@example.invalid`, randomUUID(), now);
registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(adminId);
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Admin', '@admin')").run(adminId);
registration.database.exec(`
CREATE TABLE generation_jobs (
generation_id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, project_id TEXT NOT NULL,
prompt TEXT NOT NULL, ratio TEXT NOT NULL, status TEXT NOT NULL, model_id TEXT NOT NULL,
model_config_version INTEGER NOT NULL, confirmed_credit_cost INTEGER NOT NULL,
reserved_credits INTEGER NOT NULL, final_credit_state TEXT, error_category TEXT,
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, submission_ready INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE project_images (image_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, generation_id TEXT NOT NULL, created_at INTEGER NOT NULL);
`);
registration.database.prepare(`
INSERT INTO generation_jobs (
generation_id, owner_id, project_id, prompt, ratio, status, model_id, model_config_version,
confirmed_credit_cost, reserved_credits, final_credit_state, error_category, created_at, updated_at, submission_ready
) VALUES (?, ?, ?, ?, '1:1', 'succeeded', 'demo.model', 1, 2, 0, 'committed', NULL, ?, ?, 1)
`).run(generationId, ownerId, projectId, "secret prompt must never be listed", now - 1000, now);
return { registration, adminId, generationId, adminSession: registration.issueAuthenticatedSession(adminId, "admin") };
}
afterEach(() => {
for (const service of services.splice(0)) service.close();
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
});
describe("TDD-WP6-PRIV-001/TDD-WP6-PRIV-002", () => {
it("persists notice acknowledgement, returns metadata only, and audits every prompt open", async () => {
const fixture = harness();
const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration: fixture.registration });
const cookie = `dada_admin_session=${fixture.adminSession.sessionToken}`;
const denied = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/admin/generations" });
expect(denied.statusCode).toBe(428);
expect(denied.json()).toMatchObject({ error: { code: "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED", details: { latest_version: "p0a-private-content-v1" } } });
expect(JSON.stringify(denied.json())).not.toContain("secret prompt");
const csrf = fixture.registration.issueAdminCsrfToken(fixture.adminSession.sessionToken);
const ack = await app.inject({
headers: { ...headers, cookie, "x-csrf-token": csrf, "idempotency-key": "wp6-02-ack-000000000000000000000000000000" },
method: "POST", payload: { expected_notice_version: "p0a-private-content-v1" }, url: "/api/v1/admin/private-content-notice/ack",
});
expect(ack.statusCode).toBe(200);
expect(ack.json()).toMatchObject({ notice_version: "p0a-private-content-v1", status: "acknowledged" });
const stale = await app.inject({
headers: { ...headers, cookie, "x-csrf-token": fixture.registration.issueAdminCsrfToken(fixture.adminSession.sessionToken), "idempotency-key": "wp6-02-ack-stale-000000000000000000000000000" },
method: "POST", payload: { expected_notice_version: "old-private-content-v0" }, url: "/api/v1/admin/private-content-notice/ack",
});
expect(stale.statusCode).toBe(428);
expect(stale.json()).toMatchObject({ error: { code: "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED", details: { latest_version: "p0a-private-content-v1" } } });
const list = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/admin/generations" });
expect(list.statusCode).toBe(200);
expect(list.json().items[0]).toMatchObject({ generation_id: fixture.generationId, owner_ref: expect.any(String), status: "succeeded" });
expect(JSON.stringify(list.json())).not.toContain("secret prompt");
const opened = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: `/api/v1/admin/private-content/generations/${fixture.generationId}/prompt` });
expect(opened.statusCode).toBe(200);
expect(opened.json()).toEqual({ content_type: "prompt", generation_id: fixture.generationId, prompt: "secret prompt must never be listed" });
expect((fixture.registration.database.prepare("SELECT COUNT(*) AS count FROM private_content_access_logs").get() as { count: number }).count).toBe(1);
const reopened = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: `/api/v1/admin/private-content/generations/${fixture.generationId}/prompt` });
expect(reopened.statusCode).toBe(200);
expect((fixture.registration.database.prepare("SELECT COUNT(*) AS count FROM private_content_access_logs").get() as { count: number }).count).toBe(2);
await app.close();
});
});
+179
View File
@@ -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();
});
});
+95
View File
@@ -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);
});
+34
View File
@@ -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,
},
};