From e708aea07da1312c7eb1444d4de1810037d9560c Mon Sep 17 00:00:00 2001 From: ding113 Date: Tue, 21 Apr 2026 10:10:39 +0000 Subject: [PATCH 01/25] test(public-status): lock redis-only worktree guardrails --- .../public-status.integration.config.ts | 17 ++++ tests/helpers/public-status-test-helpers.ts | 58 +++++++++++ .../public-status/config-publish.test.ts | 24 +++++ .../public-status/rebuild-lifecycle.test.ts | 30 ++++++ .../public-status/route-redis-only.test.ts | 20 ++++ .../public-status/config-snapshot.test.ts | 96 +++++++++++++++++++ .../public-status/no-db-import-guard.test.ts | 43 +++++++++ tests/unit/public-status/read-store.test.ts | 65 +++++++++++++ .../unit/public-status/rebuild-worker.test.ts | 53 ++++++++++ .../unit/public-status/redis-contract.test.ts | 50 ++++++++++ 10 files changed, 456 insertions(+) create mode 100644 tests/configs/public-status.integration.config.ts create mode 100644 tests/helpers/public-status-test-helpers.ts create mode 100644 tests/integration/public-status/config-publish.test.ts create mode 100644 tests/integration/public-status/rebuild-lifecycle.test.ts create mode 100644 tests/integration/public-status/route-redis-only.test.ts create mode 100644 tests/unit/public-status/config-snapshot.test.ts create mode 100644 tests/unit/public-status/no-db-import-guard.test.ts create mode 100644 tests/unit/public-status/read-store.test.ts create mode 100644 tests/unit/public-status/rebuild-worker.test.ts create mode 100644 tests/unit/public-status/redis-contract.test.ts diff --git a/tests/configs/public-status.integration.config.ts b/tests/configs/public-status.integration.config.ts new file mode 100644 index 000000000..e5ad584dd --- /dev/null +++ b/tests/configs/public-status.integration.config.ts @@ -0,0 +1,17 @@ +import { createTestRunnerConfig } from "../vitest.base"; + +export default createTestRunnerConfig({ + environment: "node", + testTimeout: 20000, + hookTimeout: 20000, + testFiles: [ + "tests/integration/public-status/route-redis-only.test.ts", + "tests/integration/public-status/config-publish.test.ts", + "tests/integration/public-status/rebuild-lifecycle.test.ts", + ], + api: { + host: process.env.VITEST_API_HOST || "127.0.0.1", + port: Number(process.env.VITEST_API_PORT || 51204), + strictPort: false, + }, +}); diff --git a/tests/helpers/public-status-test-helpers.ts b/tests/helpers/public-status-test-helpers.ts new file mode 100644 index 000000000..d4677ead7 --- /dev/null +++ b/tests/helpers/public-status-test-helpers.ts @@ -0,0 +1,58 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { vi } from "vitest"; + +export function repoPath(...segments: string[]): string { + return path.join(process.cwd(), ...segments); +} + +export async function readRepoFile(relativePath: string): Promise { + const absolutePath = repoPath(relativePath); + + try { + return await readFile(absolutePath, "utf8"); + } catch (error) { + throw new Error( + `Missing implementation file ${relativePath}: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +export async function importPublicStatusModule(modulePath: string): Promise { + try { + return (await import(/* @vite-ignore */ modulePath)) as T; + } catch (error) { + throw new Error( + `Missing or unfinished public-status implementation for ${modulePath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } +} + +export function listStaticImports(source: string): string[] { + const fromMatches = [...source.matchAll(/from\\s+["']([^"']+)["']/g)].map((match) => match[1]); + const sideEffectMatches = [...source.matchAll(/import\\s+["']([^"']+)["']/g)].map( + (match) => match[1] + ); + return Array.from(new Set([...fromMatches, ...sideEffectMatches])); +} + +export function createForbiddenCallSpy(label: string) { + return vi.fn(() => { + throw new Error(`Forbidden dependency invoked: ${label}`); + }); +} + +export function createRedisClientSpy(overrides: Record = {}) { + return { + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), + mget: vi.fn(), + expire: vi.fn(), + eval: vi.fn(), + setnx: vi.fn(), + ...overrides, + }; +} diff --git a/tests/integration/public-status/config-publish.test.ts b/tests/integration/public-status/config-publish.test.ts new file mode 100644 index 000000000..3d6050e32 --- /dev/null +++ b/tests/integration/public-status/config-publish.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { importPublicStatusModule } from "../../helpers/public-status-test-helpers"; + +interface ConfigPublisherModule { + publishPublicStatusConfigSnapshot(input: { + reason: string; + }): Promise<{ + configVersion: string; + }>; +} + +describe("public-status config publish integration", () => { + it("requires a control-plane publisher for public-safe config snapshots", async () => { + const mod = await importPublicStatusModule( + "@/lib/public-status/config-snapshot" + ); + + const result = await mod.publishPublicStatusConfigSnapshot({ + reason: "task-1-red-test", + }); + + expect(result.configVersion).toBeTruthy(); + }); +}); diff --git a/tests/integration/public-status/rebuild-lifecycle.test.ts b/tests/integration/public-status/rebuild-lifecycle.test.ts new file mode 100644 index 000000000..36315d95f --- /dev/null +++ b/tests/integration/public-status/rebuild-lifecycle.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { importPublicStatusModule } from "../../helpers/public-status-test-helpers"; + +interface RebuildLifecycleModule { + schedulePublicStatusRebuild(input: { + intervalMinutes: number; + rangeHours: number; + reason: string; + }): Promise<{ + accepted: boolean; + rebuildState: string; + }>; +} + +describe("public-status rebuild lifecycle", () => { + it("reserves an async rebuild lifecycle for widened ranges and cold starts", async () => { + const mod = await importPublicStatusModule( + "@/lib/public-status/rebuild-worker" + ); + + const result = await mod.schedulePublicStatusRebuild({ + intervalMinutes: 5, + rangeHours: 24, + reason: "task-1-red-test", + }); + + expect(result.accepted).toBe(true); + expect(result.rebuildState).toBeTruthy(); + }); +}); diff --git a/tests/integration/public-status/route-redis-only.test.ts b/tests/integration/public-status/route-redis-only.test.ts new file mode 100644 index 000000000..070a753d7 --- /dev/null +++ b/tests/integration/public-status/route-redis-only.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { importPublicStatusModule } from "../../helpers/public-status-test-helpers"; + +interface PublicStatusRouteModule { + GET(request: Request): Promise; +} + +describe("GET /api/public-status", () => { + it("reserves a Redis-only public route contract", async () => { + const mod = await importPublicStatusModule( + "@/app/api/public-status/route" + ); + + const response = await mod.GET( + new Request("http://localhost/api/public-status?locale=en&interval=5m&rangeHours=24") + ); + + expect(response).toBeInstanceOf(Response); + }); +}); diff --git a/tests/unit/public-status/config-snapshot.test.ts b/tests/unit/public-status/config-snapshot.test.ts new file mode 100644 index 000000000..06fa9a9c4 --- /dev/null +++ b/tests/unit/public-status/config-snapshot.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { importPublicStatusModule } from "../../helpers/public-status-test-helpers"; + +interface ConfigSnapshotModule { + buildPublicStatusConfigSnapshot(input: { + configVersion: string; + siteTitle: string; + siteDescription: string; + defaultIntervalMinutes: number; + defaultRangeHours: number; + groups: Array<{ + slug: string; + displayName: string; + sortOrder: number; + description: string | null; + models: Array<{ + publicModelKey: string; + label: string; + vendorIconKey: string; + requestTypeBadge: string; + internalProviderName?: string; + endpointUrl?: string; + }>; + }>; + }): { + configVersion: string; + siteTitle: string; + siteDescription: string; + groups: Array<{ + slug: string; + displayName: string; + models: Array<{ + publicModelKey: string; + label: string; + vendorIconKey: string; + requestTypeBadge: string; + }>; + }>; + }; +} + +describe("public-status config snapshot", () => { + it("publishes a public-safe snapshot with resolved model metadata", async () => { + const mod = await importPublicStatusModule( + "@/lib/public-status/config-snapshot" + ); + + const snapshot = mod.buildPublicStatusConfigSnapshot({ + configVersion: "cfg-2", + siteTitle: "Claude Code Hub Status", + siteDescription: "Request-derived public status", + defaultIntervalMinutes: 5, + defaultRangeHours: 24, + groups: [ + { + slug: "openai", + displayName: "OpenAI", + sortOrder: 10, + description: "Primary public models", + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "chat", + internalProviderName: "openai-prod-primary", + endpointUrl: "https://internal.example/v1", + }, + ], + }, + ], + }); + + expect(snapshot).toMatchObject({ + configVersion: "cfg-2", + siteTitle: "Claude Code Hub Status", + siteDescription: "Request-derived public status", + groups: [ + { + slug: "openai", + displayName: "OpenAI", + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "chat", + }, + ], + }, + ], + }); + expect(JSON.stringify(snapshot)).not.toContain("internalProviderName"); + expect(JSON.stringify(snapshot)).not.toContain("endpointUrl"); + }); +}); diff --git a/tests/unit/public-status/no-db-import-guard.test.ts b/tests/unit/public-status/no-db-import-guard.test.ts new file mode 100644 index 000000000..2f852ba36 --- /dev/null +++ b/tests/unit/public-status/no-db-import-guard.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { + listStaticImports, + readRepoFile, + repoPath, +} from "../../helpers/public-status-test-helpers"; + +const guardedFiles = [ + "src/app/api/public-status/route.ts", + "src/lib/public-status/read-store.ts", + "src/app/[locale]/status/page.tsx", +]; + +const bannedImports = [ + "@/drizzle/db", + "@/repository/system-config", + "@/repository/model-price", + "@/lib/availability/availability-service", + "@/lib/proxy-status-tracker", +]; + +const bannedTokens = ["findLatestPriceByModel", "getSystemSettings", "queryProviderAvailability"]; + +describe("public-status no-db import guard", () => { + it("keeps public request-path files away from DB-backed modules", async () => { + for (const relativePath of guardedFiles) { + const source = await readRepoFile(relativePath); + const imports = listStaticImports(source); + + for (const bannedImport of bannedImports) { + expect(imports, `${repoPath(relativePath)} must not import ${bannedImport}`).not.toContain( + bannedImport + ); + } + + for (const bannedToken of bannedTokens) { + expect(source, `${repoPath(relativePath)} must not reference ${bannedToken}`).not.toContain( + bannedToken + ); + } + } + }); +}); diff --git a/tests/unit/public-status/read-store.test.ts b/tests/unit/public-status/read-store.test.ts new file mode 100644 index 000000000..322694751 --- /dev/null +++ b/tests/unit/public-status/read-store.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createForbiddenCallSpy, + createRedisClientSpy, + importPublicStatusModule, +} from "../../helpers/public-status-test-helpers"; + +interface ReadStoreModule { + readPublicStatusPayload(input: { + intervalMinutes: number; + rangeHours: number; + nowIso: string; + redis: ReturnType; + triggerRebuildHint: (reason: string) => Promise | void; + }): Promise<{ + rebuildState: string; + sourceGeneration: string; + }>; +} + +describe("public-status read store", () => { + it("serves stale data and requests a background rebuild without DB reads", async () => { + const forbiddenDbRead = createForbiddenCallSpy("db-read"); + const forbiddenPriceLookup = createForbiddenCallSpy("findLatestPriceByModel"); + const triggerRebuildHint = vi.fn(); + + const mod = await importPublicStatusModule("@/lib/public-status/read-store"); + + const redis = createRedisClientSpy({ + get: vi + .fn() + .mockResolvedValueOnce( + JSON.stringify({ + generation: "gen-1", + freshUntil: "2026-04-21T10:00:00.000Z", + lastCompleteGeneration: "gen-1", + rebuildState: "idle", + }) + ) + .mockResolvedValueOnce( + JSON.stringify({ + sourceGeneration: "gen-1", + generatedAt: "2026-04-21T09:55:00.000Z", + freshUntil: "2026-04-21T10:00:00.000Z", + }) + ), + dbRead: forbiddenDbRead, + priceLookup: forbiddenPriceLookup, + }); + + const result = await mod.readPublicStatusPayload({ + intervalMinutes: 5, + rangeHours: 24, + nowIso: "2026-04-21T10:05:00.000Z", + redis, + triggerRebuildHint, + }); + + expect(result.rebuildState).toBe("stale"); + expect(result.sourceGeneration).toBe("gen-1"); + expect(triggerRebuildHint).toHaveBeenCalledTimes(1); + expect(forbiddenDbRead).not.toHaveBeenCalled(); + expect(forbiddenPriceLookup).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/public-status/rebuild-worker.test.ts b/tests/unit/public-status/rebuild-worker.test.ts new file mode 100644 index 000000000..6f811072b --- /dev/null +++ b/tests/unit/public-status/rebuild-worker.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; +import { importPublicStatusModule } from "../../helpers/public-status-test-helpers"; + +interface RebuildWorkerModule { + runPublicStatusRebuild(input: { + flightKey: string; + computeGeneration: () => Promise<{ sourceGeneration: string }>; + }): Promise<{ sourceGeneration: string }>; +} + +describe("public-status rebuild worker", () => { + it("collapses concurrent rebuild requests into a single in-flight computation", async () => { + const mod = await importPublicStatusModule( + "@/lib/public-status/rebuild-worker" + ); + + let releaseCompute: (() => void) | undefined; + const computeGate = new Promise((resolve) => { + releaseCompute = resolve; + }); + const computeGeneration = vi.fn(async () => { + await computeGate; + return { sourceGeneration: "generation-1" }; + }); + + const first = mod.runPublicStatusRebuild({ + flightKey: "cfg-1:5m:24h", + computeGeneration, + }); + const second = mod.runPublicStatusRebuild({ + flightKey: "cfg-1:5m:24h", + computeGeneration, + }); + const third = mod.runPublicStatusRebuild({ + flightKey: "cfg-1:5m:24h", + computeGeneration, + }); + + await Promise.resolve(); + expect(computeGeneration).toHaveBeenCalledTimes(1); + + releaseCompute?.(); + + const results = await Promise.all([first, second, third]); + + expect(computeGeneration).toHaveBeenCalledTimes(1); + expect(results).toEqual([ + { sourceGeneration: "generation-1" }, + { sourceGeneration: "generation-1" }, + { sourceGeneration: "generation-1" }, + ]); + }); +}); diff --git a/tests/unit/public-status/redis-contract.test.ts b/tests/unit/public-status/redis-contract.test.ts new file mode 100644 index 000000000..5eb872d50 --- /dev/null +++ b/tests/unit/public-status/redis-contract.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { importPublicStatusModule } from "../../helpers/public-status-test-helpers"; + +interface RedisContractModule { + buildGenerationFingerprint(input: { + configVersion: string; + intervalMinutes: number; + coveredFromIso: string; + coveredToIso: string; + }): string; + alignBucketStartUtc(isoTimestamp: string, intervalMinutes: number): string; +} + +describe("public-status redis contract", () => { + it("changes generation fingerprint when interval changes", async () => { + const mod = await importPublicStatusModule( + "@/lib/public-status/redis-contract" + ); + + const input = { + configVersion: "cfg-2026-04-21", + coveredFromIso: "2026-04-20T00:00:00.000Z", + coveredToIso: "2026-04-21T00:00:00.000Z", + }; + + const fiveMinute = mod.buildGenerationFingerprint({ + ...input, + intervalMinutes: 5, + }); + const fifteenMinute = mod.buildGenerationFingerprint({ + ...input, + intervalMinutes: 15, + }); + + expect(fiveMinute).not.toBe(fifteenMinute); + }); + + it("aligns buckets on UTC interval boundaries", async () => { + const mod = await importPublicStatusModule( + "@/lib/public-status/redis-contract" + ); + + expect(mod.alignBucketStartUtc("2026-04-21T10:07:31.000Z", 5)).toBe( + "2026-04-21T10:05:00.000Z" + ); + expect(mod.alignBucketStartUtc("2026-04-21T10:07:31.000Z", 15)).toBe( + "2026-04-21T10:00:00.000Z" + ); + }); +}); From 920a2e2f9324ca563e3d3080a40ae4ec0db581d2 Mon Sep 17 00:00:00 2001 From: ding113 Date: Tue, 21 Apr 2026 11:21:55 +0000 Subject: [PATCH 02/25] feat(public-status): add redis projection scaffolding --- drizzle/0091_daily_carnage.sql | 2 + messages/en/settings/index.ts | 2 + messages/en/settings/nav.json | 1 + messages/en/settings/statusPage.json | 49 ++++ messages/ja/settings/index.ts | 2 + messages/ja/settings/nav.json | 1 + messages/ja/settings/statusPage.json | 49 ++++ messages/ru/settings/index.ts | 2 + messages/ru/settings/nav.json | 1 + messages/ru/settings/statusPage.json | 49 ++++ messages/zh-CN/settings/index.ts | 2 + messages/zh-CN/settings/nav.json | 1 + messages/zh-CN/settings/statusPage.json | 49 ++++ messages/zh-TW/settings/index.ts | 2 + messages/zh-TW/settings/nav.json | 1 + messages/zh-TW/settings/statusPage.json | 49 ++++ src/actions/public-status.ts | 198 ++++++++++++++ src/actions/system-config.ts | 4 + .../settings/_components/settings-nav.tsx | 2 + .../_components/settings-page-header.tsx | 3 + src/app/[locale]/settings/_lib/nav-items.ts | 7 + .../public-status-settings-form.tsx | 250 ++++++++++++++++++ .../[locale]/settings/status-page/page.tsx | 46 ++++ .../_components/public-status-timeline.tsx | 55 ++++ src/app/[locale]/status/page.tsx | 184 +++++++++++++ src/app/api/public-status/route.ts | 28 ++ src/components/section.tsx | 3 + src/drizzle/schema.ts | 5 + src/lib/config/system-settings-cache.ts | 2 + src/lib/public-status/config-snapshot.ts | 116 ++++++++ src/lib/public-status/config.ts | 201 ++++++++++++++ src/lib/public-status/payload.ts | 40 +++ src/lib/public-status/read-store.ts | 87 ++++++ src/lib/public-status/rebuild-worker.ts | 36 +++ src/lib/public-status/redis-contract.ts | 165 ++++++++++++ src/lib/validation/schemas.ts | 12 + src/proxy.ts | 38 ++- src/repository/_shared/transformers.ts | 2 + src/repository/system-config.ts | 23 ++ src/types/system-config.ts | 6 + .../public-status/config-publish.test.ts | 140 ++++++++-- tests/unit/public-status/public-path.test.ts | 10 + 42 files changed, 1909 insertions(+), 16 deletions(-) create mode 100644 drizzle/0091_daily_carnage.sql create mode 100644 messages/en/settings/statusPage.json create mode 100644 messages/ja/settings/statusPage.json create mode 100644 messages/ru/settings/statusPage.json create mode 100644 messages/zh-CN/settings/statusPage.json create mode 100644 messages/zh-TW/settings/statusPage.json create mode 100644 src/actions/public-status.ts create mode 100644 src/app/[locale]/settings/status-page/_components/public-status-settings-form.tsx create mode 100644 src/app/[locale]/settings/status-page/page.tsx create mode 100644 src/app/[locale]/status/_components/public-status-timeline.tsx create mode 100644 src/app/[locale]/status/page.tsx create mode 100644 src/app/api/public-status/route.ts create mode 100644 src/lib/public-status/config-snapshot.ts create mode 100644 src/lib/public-status/config.ts create mode 100644 src/lib/public-status/payload.ts create mode 100644 src/lib/public-status/read-store.ts create mode 100644 src/lib/public-status/rebuild-worker.ts create mode 100644 src/lib/public-status/redis-contract.ts create mode 100644 tests/unit/public-status/public-path.test.ts diff --git a/drizzle/0091_daily_carnage.sql b/drizzle/0091_daily_carnage.sql new file mode 100644 index 000000000..101d7ac82 --- /dev/null +++ b/drizzle/0091_daily_carnage.sql @@ -0,0 +1,2 @@ +ALTER TABLE "system_settings" ADD COLUMN "public_status_window_hours" integer DEFAULT 24 NOT NULL;--> statement-breakpoint +ALTER TABLE "system_settings" ADD COLUMN "public_status_aggregation_interval_minutes" integer DEFAULT 5 NOT NULL; \ No newline at end of file diff --git a/messages/en/settings/index.ts b/messages/en/settings/index.ts index b89bfb600..56357e8ad 100644 --- a/messages/en/settings/index.ts +++ b/messages/en/settings/index.ts @@ -10,6 +10,7 @@ import notifications from "./notifications.json"; import prices from "./prices.json"; import requestFilters from "./requestFilters.json"; import sensitiveWords from "./sensitiveWords.json"; +import statusPage from "./statusPage.json"; import strings from "./strings.json"; import providersAutoSort from "./providers/autoSort.json"; @@ -110,6 +111,7 @@ export default { data, clientVersions, notifications, + statusPage, errors, errorRules, ...strings, diff --git a/messages/en/settings/nav.json b/messages/en/settings/nav.json index 5a3646860..f3b77757f 100644 --- a/messages/en/settings/nav.json +++ b/messages/en/settings/nav.json @@ -2,6 +2,7 @@ "apiDocs": "API Docs", "clientVersions": "Updates", "config": "Config", + "statusPage": "Status Page", "data": "Data", "docs": "Documentation", "errorRules": "Errors", diff --git a/messages/en/settings/statusPage.json b/messages/en/settings/statusPage.json new file mode 100644 index 000000000..48b5742a6 --- /dev/null +++ b/messages/en/settings/statusPage.json @@ -0,0 +1,49 @@ +{ + "title": "Public Status Page", + "description": "Configure the public status window, aggregation interval, and which groups/models should be exposed.", + "form": { + "windowHours": "Display Window (hours)", + "windowHoursDesc": "Controls how much history the public page aggregates. Default: 24 hours.", + "aggregationIntervalMinutes": "Aggregation Interval (minutes)", + "aggregationIntervalMinutesDesc": "How often the background job refreshes the public snapshot. It only runs after at least one group declares public models.", + "groupsTitle": "Public Groups and Models", + "groupsDesc": "Only groups with the toggle enabled and a non-empty model list will activate public status aggregation.", + "groupName": "Group", + "enabled": "Expose this group", + "displayName": "Public display name", + "displayNamePlaceholder": "Defaults to the group name", + "modelIds": "Public model IDs", + "modelIdsPlaceholder": "One model ID per line, e.g. gpt-4.1", + "modelIdsDesc": "Only enter model IDs to expose. The public page resolves display name and icon from the local price table.", + "helper": "The feature is off by default. Background aggregation starts only when at least one group has a non-empty public model list.", + "empty": "No provider groups available", + "save": "Save public status settings", + "saveSuccess": "Public status settings saved", + "saveFailed": "Failed to save public status settings" + }, + "public": { + "systemStatus": "System Status", + "heroPrimary": "AI SERVICES", + "heroSecondary": "INTELLIGENCE MONITOR", + "generatedAt": "Updated", + "ttfb": "TTFB", + "tps": "TPS", + "history": "History", + "availability": "Availability", + "noData": "No data", + "operational": "Operational", + "failed": "Failed", + "fresh": "Fresh", + "stale": "Stale", + "rebuilding": "Rebuilding", + "emptyDescription": "We are preparing the first public snapshot for this page.", + "past": "Past", + "now": "Now", + "requestTypes": { + "openaiCompatible": "OpenAI Compatible", + "codex": "Codex", + "anthropic": "Anthropic", + "gemini": "Gemini" + } + } +} diff --git a/messages/ja/settings/index.ts b/messages/ja/settings/index.ts index b89bfb600..56357e8ad 100644 --- a/messages/ja/settings/index.ts +++ b/messages/ja/settings/index.ts @@ -10,6 +10,7 @@ import notifications from "./notifications.json"; import prices from "./prices.json"; import requestFilters from "./requestFilters.json"; import sensitiveWords from "./sensitiveWords.json"; +import statusPage from "./statusPage.json"; import strings from "./strings.json"; import providersAutoSort from "./providers/autoSort.json"; @@ -110,6 +111,7 @@ export default { data, clientVersions, notifications, + statusPage, errors, errorRules, ...strings, diff --git a/messages/ja/settings/nav.json b/messages/ja/settings/nav.json index 52e634fc2..2506d5997 100644 --- a/messages/ja/settings/nav.json +++ b/messages/ja/settings/nav.json @@ -2,6 +2,7 @@ "apiDocs": "API文書", "clientVersions": "更新通知", "config": "設定", + "statusPage": "ステータスページ", "data": "データ", "docs": "ドキュメント", "errorRules": "エラー", diff --git a/messages/ja/settings/statusPage.json b/messages/ja/settings/statusPage.json new file mode 100644 index 000000000..7a6b49cc0 --- /dev/null +++ b/messages/ja/settings/statusPage.json @@ -0,0 +1,49 @@ +{ + "title": "公開ステータスページ", + "description": "公開ステータスページの集計期間、集計間隔、および公開するグループ/モデルを設定します。", + "form": { + "windowHours": "表示期間(時間)", + "windowHoursDesc": "公開ページが集計する履歴期間を指定します。既定値は 24 時間です。", + "aggregationIntervalMinutes": "集計間隔(分)", + "aggregationIntervalMinutesDesc": "バックグラウンド集計がスナップショットを更新する周期です。公開グループとモデルが設定された場合のみ有効になります。", + "groupsTitle": "公開グループとモデル", + "groupsDesc": "公開を有効にし、かつモデル ID が入力されたグループだけが公開ステータス集計を有効化します。", + "groupName": "グループ", + "enabled": "このグループを公開", + "displayName": "公開表示名", + "displayNamePlaceholder": "未入力時はグループ名を使用", + "modelIds": "公開モデル ID", + "modelIdsPlaceholder": "1 行に 1 つのモデル ID(例: gpt-4.1)", + "modelIdsDesc": "公開するモデル ID のみ入力してください。表示名とアイコンはローカル価格表から解決されます。", + "helper": "機能は既定で無効です。少なくとも 1 つのグループに空でないモデル一覧が設定された場合のみ、定期集計が開始されます。", + "empty": "設定可能なグループがありません", + "save": "公開ステータス設定を保存", + "saveSuccess": "公開ステータス設定を保存しました", + "saveFailed": "公開ステータス設定の保存に失敗しました" + }, + "public": { + "systemStatus": "システム状態", + "heroPrimary": "AI SERVICES", + "heroSecondary": "INTELLIGENCE MONITOR", + "generatedAt": "更新", + "ttfb": "TTFB", + "tps": "TPS", + "history": "履歴", + "availability": "可用率", + "noData": "データなし", + "operational": "正常", + "failed": "失敗", + "fresh": "最新", + "stale": "古い", + "rebuilding": "再構築中", + "emptyDescription": "このページの最初の公開スナップショットを準備しています。", + "past": "過去", + "now": "現在", + "requestTypes": { + "openaiCompatible": "OpenAI 互換", + "codex": "Codex", + "anthropic": "Anthropic", + "gemini": "Gemini" + } + } +} diff --git a/messages/ru/settings/index.ts b/messages/ru/settings/index.ts index b89bfb600..56357e8ad 100644 --- a/messages/ru/settings/index.ts +++ b/messages/ru/settings/index.ts @@ -10,6 +10,7 @@ import notifications from "./notifications.json"; import prices from "./prices.json"; import requestFilters from "./requestFilters.json"; import sensitiveWords from "./sensitiveWords.json"; +import statusPage from "./statusPage.json"; import strings from "./strings.json"; import providersAutoSort from "./providers/autoSort.json"; @@ -110,6 +111,7 @@ export default { data, clientVersions, notifications, + statusPage, errors, errorRules, ...strings, diff --git a/messages/ru/settings/nav.json b/messages/ru/settings/nav.json index 0e57e3b28..d90e990e9 100644 --- a/messages/ru/settings/nav.json +++ b/messages/ru/settings/nav.json @@ -2,6 +2,7 @@ "apiDocs": "API док.", "clientVersions": "Обновления", "config": "Конфиг", + "statusPage": "Статус", "data": "Данные", "docs": "Документация", "errorRules": "Ошибки", diff --git a/messages/ru/settings/statusPage.json b/messages/ru/settings/statusPage.json new file mode 100644 index 000000000..229819dd1 --- /dev/null +++ b/messages/ru/settings/statusPage.json @@ -0,0 +1,49 @@ +{ + "title": "Публичная страница статуса", + "description": "Настройте окно агрегации, интервал агрегации и группы/модели, которые будут показаны публично.", + "form": { + "windowHours": "Окно отображения (часы)", + "windowHoursDesc": "Определяет глубину истории для публичной страницы. По умолчанию: 24 часа.", + "aggregationIntervalMinutes": "Интервал агрегации (минуты)", + "aggregationIntervalMinutesDesc": "Как часто фоновая задача обновляет публичный снимок. Запускается только после настройки хотя бы одной группы с моделями.", + "groupsTitle": "Публичные группы и модели", + "groupsDesc": "Публичная агрегация включается только для групп с включенным переключателем и непустым списком моделей.", + "groupName": "Группа", + "enabled": "Показывать эту группу", + "displayName": "Публичное имя", + "displayNamePlaceholder": "По умолчанию используется имя группы", + "modelIds": "Публичные ID моделей", + "modelIdsPlaceholder": "Один ID модели на строку, например: gpt-4.1", + "modelIdsDesc": "Укажите только те ID моделей, которые нужно показать. Имя и иконка будут определены из локальной таблицы цен.", + "helper": "По умолчанию функция отключена. Фоновая агрегация запускается только если хотя бы у одной группы есть непустой список моделей.", + "empty": "Нет доступных групп", + "save": "Сохранить настройки публичного статуса", + "saveSuccess": "Настройки публичного статуса сохранены", + "saveFailed": "Не удалось сохранить настройки публичного статуса" + }, + "public": { + "systemStatus": "Статус системы", + "heroPrimary": "AI SERVICES", + "heroSecondary": "INTELLIGENCE MONITOR", + "generatedAt": "Обновлено", + "ttfb": "TTFB", + "tps": "TPS", + "history": "История", + "availability": "Доступность", + "noData": "Нет данных", + "operational": "Доступно", + "failed": "Сбой", + "fresh": "Актуально", + "stale": "Устарело", + "rebuilding": "Перестраивается", + "emptyDescription": "Мы готовим первый публичный снимок для этой страницы.", + "past": "Прошлое", + "now": "Сейчас", + "requestTypes": { + "openaiCompatible": "Совместимый с OpenAI", + "codex": "Codex", + "anthropic": "Anthropic", + "gemini": "Gemini" + } + } +} diff --git a/messages/zh-CN/settings/index.ts b/messages/zh-CN/settings/index.ts index b89bfb600..56357e8ad 100644 --- a/messages/zh-CN/settings/index.ts +++ b/messages/zh-CN/settings/index.ts @@ -10,6 +10,7 @@ import notifications from "./notifications.json"; import prices from "./prices.json"; import requestFilters from "./requestFilters.json"; import sensitiveWords from "./sensitiveWords.json"; +import statusPage from "./statusPage.json"; import strings from "./strings.json"; import providersAutoSort from "./providers/autoSort.json"; @@ -110,6 +111,7 @@ export default { data, clientVersions, notifications, + statusPage, errors, errorRules, ...strings, diff --git a/messages/zh-CN/settings/nav.json b/messages/zh-CN/settings/nav.json index e3294525f..74dd37815 100644 --- a/messages/zh-CN/settings/nav.json +++ b/messages/zh-CN/settings/nav.json @@ -1,5 +1,6 @@ { "config": "配置", + "statusPage": "状态页", "prices": "价格表", "providers": "供应商", "sensitiveWords": "敏感词", diff --git a/messages/zh-CN/settings/statusPage.json b/messages/zh-CN/settings/statusPage.json new file mode 100644 index 000000000..66afe2f0a --- /dev/null +++ b/messages/zh-CN/settings/statusPage.json @@ -0,0 +1,49 @@ +{ + "title": "公开状态页面", + "description": "配置公开状态页面的聚合窗口、聚合间隔,以及需要对外展示的分组和模型。", + "form": { + "windowHours": "展示时间长度(小时)", + "windowHoursDesc": "控制公开页面聚合覆盖的时间窗口。默认 24 小时。", + "aggregationIntervalMinutes": "聚合间隔(分钟)", + "aggregationIntervalMinutesDesc": "定时聚合写快照的周期。只有配置了公开分组和模型后才会生效。", + "groupsTitle": "公开分组与模型", + "groupsDesc": "只有勾选并填写模型 ID 的分组,才会启用公开状态聚合。", + "groupName": "分组", + "enabled": "公开此分组", + "displayName": "对外显示名", + "displayNamePlaceholder": "默认使用分组名", + "modelIds": "公开模型 ID", + "modelIdsPlaceholder": "每行一个模型 ID,例如:gpt-4.1", + "modelIdsDesc": "只需填写需要公开展示的模型 ID;公开页展示名和图标将从本地价格表解析。", + "helper": "功能默认关闭;只有至少一个分组配置了非空模型列表时,后台才会启动定时聚合。", + "empty": "暂无可配置分组", + "save": "保存公开状态配置", + "saveSuccess": "公开状态配置已保存", + "saveFailed": "保存公开状态配置失败" + }, + "public": { + "systemStatus": "系统状态", + "heroPrimary": "AI 服务", + "heroSecondary": "智能状态面板", + "generatedAt": "更新于", + "ttfb": "TTFB", + "tps": "TPS", + "history": "历史", + "availability": "在线率", + "noData": "暂无数据", + "operational": "可用", + "failed": "失败", + "fresh": "最新", + "stale": "陈旧", + "rebuilding": "重建中", + "emptyDescription": "我们正在为此页面准备第一份公开快照。", + "past": "过去", + "now": "现在", + "requestTypes": { + "openaiCompatible": "OpenAI 兼容", + "codex": "Codex", + "anthropic": "Anthropic", + "gemini": "Gemini" + } + } +} diff --git a/messages/zh-TW/settings/index.ts b/messages/zh-TW/settings/index.ts index b89bfb600..56357e8ad 100644 --- a/messages/zh-TW/settings/index.ts +++ b/messages/zh-TW/settings/index.ts @@ -10,6 +10,7 @@ import notifications from "./notifications.json"; import prices from "./prices.json"; import requestFilters from "./requestFilters.json"; import sensitiveWords from "./sensitiveWords.json"; +import statusPage from "./statusPage.json"; import strings from "./strings.json"; import providersAutoSort from "./providers/autoSort.json"; @@ -110,6 +111,7 @@ export default { data, clientVersions, notifications, + statusPage, errors, errorRules, ...strings, diff --git a/messages/zh-TW/settings/nav.json b/messages/zh-TW/settings/nav.json index 36d33bde4..9bf38b42a 100644 --- a/messages/zh-TW/settings/nav.json +++ b/messages/zh-TW/settings/nav.json @@ -2,6 +2,7 @@ "apiDocs": "API 文檔", "clientVersions": "用戶端升級提醒", "config": "設定", + "statusPage": "狀態頁", "data": "資料管理", "docs": "使用文檔", "errorRules": "錯誤規則", diff --git a/messages/zh-TW/settings/statusPage.json b/messages/zh-TW/settings/statusPage.json new file mode 100644 index 000000000..1b50f88d7 --- /dev/null +++ b/messages/zh-TW/settings/statusPage.json @@ -0,0 +1,49 @@ +{ + "title": "公開狀態頁面", + "description": "設定公開狀態頁面的聚合視窗、聚合間隔,以及需要對外展示的分組和模型。", + "form": { + "windowHours": "展示時間長度(小時)", + "windowHoursDesc": "控制公開頁面聚合覆蓋的時間視窗。預設 24 小時。", + "aggregationIntervalMinutes": "聚合間隔(分鐘)", + "aggregationIntervalMinutesDesc": "定時聚合寫入快照的週期。只有在設定了公開分組和模型後才會生效。", + "groupsTitle": "公開分組與模型", + "groupsDesc": "只有勾選並填寫模型 ID 的分組,才會啟用公開狀態聚合。", + "groupName": "分組", + "enabled": "公開此分組", + "displayName": "對外顯示名稱", + "displayNamePlaceholder": "預設使用分組名", + "modelIds": "公開模型 ID", + "modelIdsPlaceholder": "每行一個模型 ID,例如:gpt-4.1", + "modelIdsDesc": "只需填寫需要公開展示的模型 ID;公開頁的展示名與圖示會從本地價格表解析。", + "helper": "功能預設關閉;只有至少一個分組設定了非空模型列表時,後台才會啟動定時聚合。", + "empty": "暫無可設定分組", + "save": "儲存公開狀態設定", + "saveSuccess": "公開狀態設定已儲存", + "saveFailed": "儲存公開狀態設定失敗" + }, + "public": { + "systemStatus": "系統狀態", + "heroPrimary": "AI 服務", + "heroSecondary": "智慧狀態面板", + "generatedAt": "更新於", + "ttfb": "TTFB", + "tps": "TPS", + "history": "歷史", + "availability": "在線率", + "noData": "暫無資料", + "operational": "可用", + "failed": "失敗", + "fresh": "最新", + "stale": "陳舊", + "rebuilding": "重建中", + "emptyDescription": "我們正在為此頁面準備第一份公開快照。", + "past": "過去", + "now": "現在", + "requestTypes": { + "openaiCompatible": "OpenAI 相容", + "codex": "Codex", + "anthropic": "Anthropic", + "gemini": "Gemini" + } + } +} diff --git a/src/actions/public-status.ts b/src/actions/public-status.ts new file mode 100644 index 000000000..f2111b188 --- /dev/null +++ b/src/actions/public-status.ts @@ -0,0 +1,198 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { locales } from "@/i18n/config"; +import { getSession } from "@/lib/auth"; +import { invalidateSystemSettingsCache } from "@/lib/config"; +import { logger } from "@/lib/logger"; +import { + collectEnabledPublicStatusGroups, + type EnabledPublicStatusGroup, + invalidateConfiguredPublicStatusGroupsCache, + parsePublicStatusDescription, + serializePublicStatusDescription, +} from "@/lib/public-status/config"; +import { + buildPublicStatusConfigSnapshot, + publishPublicStatusConfigSnapshot, +} from "@/lib/public-status/config-snapshot"; +import { schedulePublicStatusRebuild } from "@/lib/public-status/rebuild-worker"; +import { UpdateSystemSettingsSchema } from "@/lib/validation/schemas"; +import { findLatestPricesByModels } from "@/repository/model-price"; +import { findAllProviderGroups, updateProviderGroup } from "@/repository/provider-groups"; +import { updateSystemSettings } from "@/repository/system-config"; +import type { ActionResult } from "./types"; + +export interface SavePublicStatusSettingsInput { + publicStatusWindowHours: number; + publicStatusAggregationIntervalMinutes: number; + groups: Array<{ + groupName: string; + displayName?: string; + publicGroupSlug?: string; + explanatoryCopy?: string | null; + sortOrder?: number; + publicModelKeys: string[]; + }>; +} + +function resolveRequestTypeBadge(modelName: string): string { + const normalized = modelName.toLowerCase(); + if (normalized.includes("codex")) { + return "codex"; + } + if (normalized.includes("claude")) { + return "anthropic"; + } + if (normalized.includes("gemini")) { + return "gemini"; + } + return "openaiCompatible"; +} + +function normalizeEnabledGroups( + groups: SavePublicStatusSettingsInput["groups"] +): EnabledPublicStatusGroup[] { + return collectEnabledPublicStatusGroups( + groups.map((group) => ({ + groupName: group.groupName, + note: null, + publicStatus: { + displayName: group.displayName, + publicGroupSlug: group.publicGroupSlug, + explanatoryCopy: group.explanatoryCopy, + sortOrder: group.sortOrder, + publicModelKeys: group.publicModelKeys, + }, + })) + ); +} + +export async function savePublicStatusSettings( + input: SavePublicStatusSettingsInput +): Promise> { + try { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "无权限执行此操作" }; + } + + const validatedSettings = UpdateSystemSettingsSchema.parse({ + publicStatusWindowHours: input.publicStatusWindowHours, + publicStatusAggregationIntervalMinutes: input.publicStatusAggregationIntervalMinutes, + }); + const normalizedEnabledGroups = normalizeEnabledGroups(input.groups); + + const allGroups = await findAllProviderGroups(); + const enabledByName = new Map( + normalizedEnabledGroups.map((group) => [group.groupName, group] as const) + ); + const groupUpdates: Array<{ id: number; description: string | null }> = []; + + for (const group of allGroups) { + const existing = parsePublicStatusDescription(group.description); + const configured = enabledByName.get(group.name); + const nextDescription = serializePublicStatusDescription({ + note: existing.note, + publicStatus: configured + ? { + displayName: configured.displayName, + publicGroupSlug: configured.publicGroupSlug, + explanatoryCopy: configured.explanatoryCopy, + sortOrder: configured.sortOrder, + publicModelKeys: configured.publicModelKeys, + } + : null, + }); + + if (nextDescription && nextDescription.length > 500) { + return { + ok: false, + error: "公开状态配置超过 provider_groups.description 的 500 字符限制", + }; + } + + if ((group.description ?? null) !== nextDescription) { + groupUpdates.push({ + id: group.id, + description: nextDescription, + }); + } + } + + const settings = await updateSystemSettings({ + publicStatusWindowHours: validatedSettings.publicStatusWindowHours, + publicStatusAggregationIntervalMinutes: + validatedSettings.publicStatusAggregationIntervalMinutes, + }); + + for (const groupUpdate of groupUpdates) { + await updateProviderGroup(groupUpdate.id, { + description: groupUpdate.description, + }); + } + + const latestPrices = await findLatestPricesByModels( + normalizedEnabledGroups.flatMap((group) => group.publicModelKeys) + ); + const configVersion = `cfg-${Date.now()}`; + + const snapshot = buildPublicStatusConfigSnapshot({ + configVersion, + siteTitle: settings.siteTitle, + siteDescription: settings.siteTitle, + defaultIntervalMinutes: settings.publicStatusAggregationIntervalMinutes, + defaultRangeHours: settings.publicStatusWindowHours, + groups: normalizedEnabledGroups.map((group) => ({ + slug: group.publicGroupSlug, + displayName: group.displayName, + sortOrder: group.sortOrder, + description: group.explanatoryCopy, + models: group.publicModelKeys.map((modelName) => { + const price = latestPrices.get(modelName); + return { + publicModelKey: modelName, + label: price?.priceData.display_name?.trim() || modelName, + vendorIconKey: + price?.priceData.litellm_provider?.trim() || resolveRequestTypeBadge(modelName), + requestTypeBadge: resolveRequestTypeBadge(modelName), + }; + }), + })), + }); + + await publishPublicStatusConfigSnapshot({ + reason: "save-public-status-settings", + snapshot, + }); + await schedulePublicStatusRebuild({ + intervalMinutes: settings.publicStatusAggregationIntervalMinutes, + rangeHours: settings.publicStatusWindowHours, + reason: "config-updated", + }); + + invalidateSystemSettingsCache(); + invalidateConfiguredPublicStatusGroupsCache(); + + for (const locale of locales) { + revalidatePath(`/${locale}/settings/config`); + revalidatePath(`/${locale}/settings/providers`); + revalidatePath(`/${locale}/status`); + } + revalidatePath("/", "layout"); + + return { + ok: true, + data: { + updatedGroupCount: groupUpdates.length, + configVersion, + }, + }; + } catch (error) { + logger.error("[PublicStatus] savePublicStatusSettings failed", error); + return { + ok: false, + error: error instanceof Error ? error.message : "保存公开状态设置失败", + }; + } +} diff --git a/src/actions/system-config.ts b/src/actions/system-config.ts index a4117249d..7817250cc 100644 --- a/src/actions/system-config.ts +++ b/src/actions/system-config.ts @@ -79,6 +79,8 @@ export async function saveSystemSettings(formData: { quotaLeasePercentWeekly?: number; quotaLeasePercentMonthly?: number; quotaLeaseCapUsd?: number | null; + publicStatusWindowHours?: number; + publicStatusAggregationIntervalMinutes?: number; // IP 提取 / 归属地查询 ipExtractionConfig?: IpExtractionConfig | null; ipGeoLookupEnabled?: boolean; @@ -122,6 +124,8 @@ export async function saveSystemSettings(formData: { quotaLeasePercentWeekly: validated.quotaLeasePercentWeekly, quotaLeasePercentMonthly: validated.quotaLeasePercentMonthly, quotaLeaseCapUsd: validated.quotaLeaseCapUsd, + publicStatusWindowHours: validated.publicStatusWindowHours, + publicStatusAggregationIntervalMinutes: validated.publicStatusAggregationIntervalMinutes, ipExtractionConfig: validated.ipExtractionConfig, ipGeoLookupEnabled: validated.ipGeoLookupEnabled, }); diff --git a/src/app/[locale]/settings/_components/settings-nav.tsx b/src/app/[locale]/settings/_components/settings-nav.tsx index 98c38c4c8..6e21ece2a 100644 --- a/src/app/[locale]/settings/_components/settings-nav.tsx +++ b/src/app/[locale]/settings/_components/settings-nav.tsx @@ -2,6 +2,7 @@ import { motion } from "framer-motion"; import { + Activity, AlertTriangle, Bell, BookOpen, @@ -27,6 +28,7 @@ import type { SettingsNavIconName, SettingsNavItem } from "../_lib/nav-items"; // Map icon names to actual icon components (client-side only) const ICON_MAP: Record = { settings: Settings, + activity: Activity, "dollar-sign": DollarSign, server: Server, "shield-alert": ShieldAlert, diff --git a/src/app/[locale]/settings/_components/settings-page-header.tsx b/src/app/[locale]/settings/_components/settings-page-header.tsx index 03fb1e5bd..24e1d86d4 100644 --- a/src/app/[locale]/settings/_components/settings-page-header.tsx +++ b/src/app/[locale]/settings/_components/settings-page-header.tsx @@ -1,4 +1,5 @@ import { + Activity, AlertTriangle, Bell, Database, @@ -15,6 +16,7 @@ import { cn } from "@/lib/utils"; // Icon name type for serialization across server/client boundary export type PageHeaderIconName = | "settings" + | "activity" | "database" | "file-text" | "bell" @@ -27,6 +29,7 @@ export type PageHeaderIconName = // Map icon names to components const HEADER_ICON_MAP: Record = { settings: Settings, + activity: Activity, database: Database, "file-text": FileText, bell: Bell, diff --git a/src/app/[locale]/settings/_lib/nav-items.ts b/src/app/[locale]/settings/_lib/nav-items.ts index 8972ebd31..974bb2b39 100644 --- a/src/app/[locale]/settings/_lib/nav-items.ts +++ b/src/app/[locale]/settings/_lib/nav-items.ts @@ -2,6 +2,7 @@ import { getTranslations } from "next-intl/server"; export type SettingsNavIconName = | "settings" + | "activity" | "dollar-sign" | "server" | "shield-alert" @@ -32,6 +33,12 @@ export const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [ label: "Configuration", iconName: "settings", }, + { + href: "/settings/status-page", + labelKey: "nav.statusPage", + label: "Status Page", + iconName: "activity", + }, { href: "/settings/prices", labelKey: "nav.prices", label: "Prices", iconName: "dollar-sign" }, { href: "/settings/providers", diff --git a/src/app/[locale]/settings/status-page/_components/public-status-settings-form.tsx b/src/app/[locale]/settings/status-page/_components/public-status-settings-form.tsx new file mode 100644 index 000000000..2c8812685 --- /dev/null +++ b/src/app/[locale]/settings/status-page/_components/public-status-settings-form.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { Activity, Save } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useMemo, useState, useTransition } from "react"; +import { toast } from "sonner"; +import { + type SavePublicStatusSettingsInput, + savePublicStatusSettings, +} from "@/actions/public-status"; +import { Section } from "@/components/section"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; + +export interface PublicStatusSettingsFormGroup { + groupName: string; + enabled: boolean; + displayName: string; + publicGroupSlug: string; + explanatoryCopy: string; + sortOrder: number; + modelIdsText: string; +} + +interface PublicStatusSettingsFormProps { + initialWindowHours: number; + initialAggregationIntervalMinutes: number; + initialGroups: PublicStatusSettingsFormGroup[]; +} + +function normalizeModelKeys(value: string): string[] { + return Array.from( + new Set( + value + .split(/\r?\n/) + .map((item) => item.trim()) + .filter(Boolean) + ) + ); +} + +export function PublicStatusSettingsForm({ + initialWindowHours, + initialAggregationIntervalMinutes, + initialGroups, +}: PublicStatusSettingsFormProps) { + const router = useRouter(); + const t = useTranslations("settings"); + const [windowHours, setWindowHours] = useState(String(initialWindowHours)); + const [aggregationIntervalMinutes, setAggregationIntervalMinutes] = useState( + String(initialAggregationIntervalMinutes) + ); + const [groups, setGroups] = useState(initialGroups); + const [isPending, startTransition] = useTransition(); + + const enabledGroupCount = useMemo( + () => + groups.filter((group) => group.enabled && normalizeModelKeys(group.modelIdsText).length > 0) + .length, + [groups] + ); + + const updateGroup = (index: number, patch: Partial) => { + setGroups((current) => + current.map((group, groupIndex) => (groupIndex === index ? { ...group, ...patch } : group)) + ); + }; + + const handleSave = () => { + const payload: SavePublicStatusSettingsInput = { + publicStatusWindowHours: Number(windowHours), + publicStatusAggregationIntervalMinutes: Number(aggregationIntervalMinutes), + groups: groups + .filter((group) => group.enabled) + .map((group) => ({ + groupName: group.groupName, + displayName: group.displayName.trim() || undefined, + publicGroupSlug: group.publicGroupSlug.trim() || undefined, + explanatoryCopy: group.explanatoryCopy.trim() || null, + sortOrder: group.sortOrder, + publicModelKeys: normalizeModelKeys(group.modelIdsText), + })), + }; + + startTransition(async () => { + const result = await savePublicStatusSettings(payload); + if (!result.ok) { + toast.error(result.error || t("statusPage.form.saveFailed")); + return; + } + + toast.success(t("statusPage.form.saveSuccess")); + router.refresh(); + }); + }; + + return ( +
+
+
+
+ + setWindowHours(event.target.value)} + disabled={isPending} + /> +

{t("statusPage.form.windowHoursDesc")}

+
+ +
+ + setAggregationIntervalMinutes(event.target.value)} + disabled={isPending} + /> +

+ {t("statusPage.form.aggregationIntervalMinutesDesc")} +

+
+
+ +
+ {enabledGroupCount} + {t("statusPage.form.helper")} +
+
+ +
+
+ {groups.map((group, index) => ( + + +
+ + + {group.groupName} + +
+
+ updateGroup(index, { enabled: checked === true })} + disabled={isPending} + /> + + {t("statusPage.form.enabled")} + +
+
+ +
+ + updateGroup(index, { displayName: event.target.value })} + placeholder={t("statusPage.form.displayNamePlaceholder")} + disabled={isPending} + /> +
+ +
+ + + updateGroup(index, { publicGroupSlug: event.target.value }) + } + placeholder={group.groupName.toLowerCase()} + disabled={isPending} + /> +
+ +
+ +