-
-
Notifications
You must be signed in to change notification settings - Fork 330
feat(public-status): add redis-backed public status runtime #1078
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2359afa
bf2fd53
8b607ec
833c6fd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -87,6 +87,8 @@ | |||||
| "saveFailed": "Ошибка сохранения", | ||||||
| "saveSettings": "Сохранить настройки", | ||||||
| "saveSuccess": "Сохранено успешно", | ||||||
| "publicStatusProjectionWarning": "Системные настройки сохранены, но Redis-проекция public status не была обновлена.", | ||||||
| "publicStatusBackgroundRefreshPending": "Системные настройки сохранены, но публичная статус-страница может временно показывать устаревшие данные, пока фоновое обновление не завершится.", | ||||||
|
Comment on lines
+90
to
+91
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Check file size and content around the mentioned lines
wc -l messages/ru/settings/config.jsonRepository: ding113/claude-code-hub Length of output: 103 🏁 Script executed: # Read the file content with line numbers to verify the duplicate keys
cat -n messages/ru/settings/config.jsonRepository: ding113/claude-code-hub Length of output: 13762 删除重复的 JSON 键定义。 行 39-40 和行 90-91 中的 建议删除的重复部分- "publicStatusProjectionWarning": "Системные настройки сохранены, но Redis-проекция public status не была обновлена.",
- "publicStatusBackgroundRefreshPending": "Системные настройки сохранены, но публичная статус-страница может временно показывать устаревшие данные, пока фоновое обновление не завершится.",📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| "siteTitle": "Название сайта", | ||||||
| "siteTitleDesc": "Используется для установки заголовка вкладки браузера и имени системы по умолчанию.", | ||||||
| "siteTitlePlaceholder": "например: Claude Code Hub", | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -87,6 +87,8 @@ | |||||
| "saveFailed": "儲存失敗", | ||||||
| "saveSettings": "儲存設定", | ||||||
| "saveSuccess": "儲存成功", | ||||||
| "publicStatusProjectionWarning": "系統設定已儲存,但 public status Redis 投影尚未刷新。", | ||||||
| "publicStatusBackgroundRefreshPending": "系統設定已儲存,但公開狀態頁可能會在背景刷新成功前暫時維持舊資料。", | ||||||
|
Comment on lines
+90
to
+91
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cat -n messages/zh-TW/settings/config.json | head -100Repository: ding113/claude-code-hub Length of output: 6192 删除
建议删除的重复定义- "publicStatusProjectionWarning": "系統設定已儲存,但 public status Redis 投影尚未刷新。",
- "publicStatusBackgroundRefreshPending": "系統設定已儲存,但公開狀態頁可能會在背景刷新成功前暫時維持舊資料。",📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| "siteTitle": "站台標題", | ||||||
| "siteTitleDesc": "用於設定瀏覽器分頁標題以及系統預設顯示名稱。", | ||||||
| "siteTitlePlaceholder": "例:Claude Code Hub", | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,22 +1,25 @@ | ||
| import { getTranslations } from "next-intl/server"; | ||
| import { readPublicSiteMeta } from "@/lib/public-site-meta"; | ||
| import { readCurrentPublicStatusConfigSnapshot } from "@/lib/public-status/config-snapshot"; | ||
| import { | ||
| readCurrentPublicStatusConfigSnapshot, | ||
| readPublicStatusSiteMetadata, | ||
| } from "@/lib/public-status/config-snapshot"; | ||
| import { readPublicStatusPayload } from "@/lib/public-status/read-store"; | ||
| import { schedulePublicStatusRebuild } from "@/lib/public-status/rebuild-hints"; | ||
| import { resolveSiteTitle } from "@/lib/site-title"; | ||
| import { PublicStatusView } from "./_components/public-status-view"; | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
|
|
||
| const FALLBACK_SITE_TITLE = "Claude Code Hub"; | ||
|
|
||
| export default async function PublicStatusPage({ | ||
| params, | ||
| }: { | ||
| params: Promise<{ locale: string }>; | ||
| }) { | ||
| const { locale } = await params; | ||
| const t = await getTranslations("settings"); | ||
| const t = await getTranslations({ locale, namespace: "settings.statusPage.public" }); | ||
| const configSnapshot = await readCurrentPublicStatusConfigSnapshot(); | ||
| const siteMeta = await readPublicSiteMeta(); | ||
| const siteMetadata = await readPublicStatusSiteMetadata(); | ||
| const intervalMinutes = configSnapshot?.defaultIntervalMinutes ?? 5; | ||
| const rangeHours = configSnapshot?.defaultRangeHours ?? 24; | ||
| const followServerDefaults = !configSnapshot; | ||
|
|
@@ -27,13 +30,11 @@ export default async function PublicStatusPage({ | |
| hasConfiguredGroups: configSnapshot ? configSnapshot.groups.length > 0 : undefined, | ||
| nowIso: new Date().toISOString(), | ||
| triggerRebuildHint: async (reason) => { | ||
| if (followServerDefaults) { | ||
| await schedulePublicStatusRebuild({ | ||
| intervalMinutes, | ||
| rangeHours, | ||
| reason, | ||
| }); | ||
| } | ||
| await schedulePublicStatusRebuild({ | ||
| intervalMinutes, | ||
| rangeHours, | ||
| reason, | ||
| }); | ||
|
Comment on lines
32
to
+37
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| }, | ||
| }); | ||
|
|
||
|
|
@@ -44,51 +45,53 @@ export default async function PublicStatusPage({ | |
| rangeHours={rangeHours} | ||
| followServerDefaults={followServerDefaults} | ||
| locale={locale} | ||
| siteTitle={resolveSiteTitle(configSnapshot?.siteTitle, siteMeta.siteTitle)} | ||
| siteTitle={ | ||
| siteMetadata?.siteTitle?.trim() || configSnapshot?.siteTitle?.trim() || FALLBACK_SITE_TITLE | ||
| } | ||
| timeZone={configSnapshot?.timeZone ?? "UTC"} | ||
| labels={{ | ||
| systemStatus: t("statusPage.public.systemStatus"), | ||
| heroPrimary: t("statusPage.public.heroPrimary"), | ||
| heroSecondary: t("statusPage.public.heroSecondary"), | ||
| generatedAt: t("statusPage.public.generatedAt"), | ||
| history: t("statusPage.public.history"), | ||
| availability: t("statusPage.public.availability"), | ||
| ttfb: t("statusPage.public.ttfb"), | ||
| freshnessWindow: t("statusPage.public.freshnessWindow"), | ||
| fresh: t("statusPage.public.fresh"), | ||
| stale: t("statusPage.public.stale"), | ||
| staleDetail: t("statusPage.public.staleDetail"), | ||
| rebuilding: t("statusPage.public.rebuilding"), | ||
| noData: t("statusPage.public.noData"), | ||
| emptyDescription: t("statusPage.public.emptyDescription"), | ||
| systemStatus: t("systemStatus"), | ||
| heroPrimary: t("heroPrimary"), | ||
| heroSecondary: t("heroSecondary"), | ||
| generatedAt: t("generatedAt"), | ||
| history: t("history"), | ||
| availability: t("availability"), | ||
| ttfb: t("ttfb"), | ||
| freshnessWindow: t("freshnessWindow"), | ||
| fresh: t("fresh"), | ||
| stale: t("stale"), | ||
| staleDetail: t("staleDetail"), | ||
| rebuilding: t("rebuilding"), | ||
| noData: t("noData"), | ||
| emptyDescription: t("emptyDescription"), | ||
| requestTypes: { | ||
| openaiCompatible: t("statusPage.public.requestTypes.openaiCompatible"), | ||
| codex: t("statusPage.public.requestTypes.codex"), | ||
| anthropic: t("statusPage.public.requestTypes.anthropic"), | ||
| gemini: t("statusPage.public.requestTypes.gemini"), | ||
| openaiCompatible: t("requestTypes.openaiCompatible"), | ||
| codex: t("requestTypes.codex"), | ||
| anthropic: t("requestTypes.anthropic"), | ||
| gemini: t("requestTypes.gemini"), | ||
| }, | ||
| statusBadge: { | ||
| operational: t("statusPage.public.statusBadge.operational"), | ||
| degraded: t("statusPage.public.statusBadge.degraded"), | ||
| failed: t("statusPage.public.statusBadge.failed"), | ||
| noData: t("statusPage.public.statusBadge.noData"), | ||
| operational: t("statusBadge.operational"), | ||
| degraded: t("statusBadge.degraded"), | ||
| failed: t("statusBadge.failed"), | ||
| noData: t("statusBadge.noData"), | ||
| }, | ||
| tooltip: { | ||
| availability: t("statusPage.public.tooltip.availability"), | ||
| ttfb: t("statusPage.public.tooltip.ttfb"), | ||
| tps: t("statusPage.public.tooltip.tps"), | ||
| historyAriaLabel: t("statusPage.public.tooltip.historyAriaLabel"), | ||
| availability: t("tooltip.availability"), | ||
| ttfb: t("tooltip.ttfb"), | ||
| tps: t("tooltip.tps"), | ||
| historyAriaLabel: t("tooltip.historyAriaLabel"), | ||
| }, | ||
| searchPlaceholder: t("statusPage.public.searchPlaceholder"), | ||
| customSort: t("statusPage.public.customSort"), | ||
| resetSort: t("statusPage.public.resetSort"), | ||
| emptyByFilter: t("statusPage.public.emptyByFilter"), | ||
| modelsLabel: t("statusPage.public.modelsLabel"), | ||
| issuesLabel: t("statusPage.public.issuesLabel"), | ||
| clearSearch: t("statusPage.public.clearSearch"), | ||
| dragHandle: t("statusPage.public.dragHandle"), | ||
| toggleGroup: t("statusPage.public.toggleGroup"), | ||
| openGroupPage: t("statusPage.public.openGroupPage"), | ||
| searchPlaceholder: t("searchPlaceholder"), | ||
| customSort: t("customSort"), | ||
| resetSort: t("resetSort"), | ||
| emptyByFilter: t("emptyByFilter"), | ||
| modelsLabel: t("modelsLabel"), | ||
| issuesLabel: t("issuesLabel"), | ||
| clearSearch: t("clearSearch"), | ||
| dragHandle: t("dragHandle"), | ||
| toggleGroup: t("toggleGroup"), | ||
| openGroupPage: t("openGroupPage"), | ||
| }} | ||
| /> | ||
| ); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,7 +26,6 @@ export async function GET(request: Request): Promise<Response> { | |
| const defaultRange = configSnapshot?.defaultRangeHours ?? 24; | ||
| const intervalMinutes = clampInterval(url.searchParams.get("interval"), defaultInterval); | ||
| const rangeHours = clampRange(url.searchParams.get("rangeHours"), defaultRange); | ||
| const canTriggerRebuild = intervalMinutes === defaultInterval && rangeHours === defaultRange; | ||
|
|
||
| const payload = await readPublicStatusPayload({ | ||
| intervalMinutes, | ||
|
|
@@ -35,9 +34,6 @@ export async function GET(request: Request): Promise<Response> { | |
| hasConfiguredGroups: configSnapshot ? configSnapshot.groups.length > 0 : undefined, | ||
| nowIso: new Date().toISOString(), | ||
| triggerRebuildHint: async (reason) => { | ||
| if (!canTriggerRebuild) { | ||
| return; | ||
| } | ||
| await schedulePublicStatusRebuild({ | ||
| intervalMinutes, | ||
| rangeHours, | ||
|
Comment on lines
37
to
39
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The Useful? React with 👍 / 👎. |
||
|
|
@@ -47,6 +43,5 @@ export async function GET(request: Request): Promise<Response> { | |
| }); | ||
|
|
||
| const status = payload.rebuildState === "rebuilding" && !payload.generatedAt ? 503 : 200; | ||
|
|
||
| return NextResponse.json(payload, { status }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -57,6 +57,18 @@ const CONFIG_CACHE_TTL_MS = 60 * 1000; | |
| let cachedConfiguredGroups: EnabledPublicStatusGroup[] | null = null; | ||
| let cachedConfiguredGroupsAt = 0; | ||
|
|
||
| export class DuplicatePublicStatusGroupSlugError extends Error { | ||
| constructor( | ||
| public readonly publicGroupSlug: string, | ||
| groupNames: string[] | ||
| ) { | ||
| super( | ||
| `Duplicate normalized publicGroupSlug "${publicGroupSlug}" for groups: ${groupNames.join(", ")}` | ||
| ); | ||
| this.name = "DuplicatePublicStatusGroupSlugError"; | ||
| } | ||
| } | ||
|
|
||
| function sanitizeString(value: unknown): string | undefined { | ||
| if (typeof value !== "string") { | ||
| return undefined; | ||
|
|
@@ -136,6 +148,11 @@ export function slugifyPublicGroup(input: string): string { | |
| .slice(0, 64); | ||
| } | ||
|
|
||
| function normalizePublicGroupSlug(groupName: string, publicGroupSlug?: string): string { | ||
| const normalized = slugifyPublicGroup(publicGroupSlug?.trim() || groupName); | ||
| return normalized || slugifyPublicGroup(groupName); | ||
| } | ||
|
Comment on lines
+151
to
+154
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 避免返回空的
建议修正方向 function normalizePublicGroupSlug(groupName: string, publicGroupSlug?: string): string {
const normalized = slugifyPublicGroup(publicGroupSlug?.trim() || groupName);
- return normalized || slugifyPublicGroup(groupName);
+ const fallback = normalized || slugifyPublicGroup(groupName);
+ if (!fallback) {
+ throw new Error(`Public status group "${groupName}" must define a URL-safe publicGroupSlug`);
+ }
+ return fallback;
}🤖 Prompt for AI Agents |
||
|
|
||
| export function parsePublicStatusDescription( | ||
| description: string | null | undefined | ||
| ): ParsedPublicStatusDescription { | ||
|
|
@@ -252,15 +269,30 @@ export function serializePublicStatusDescription( | |
| export function collectEnabledPublicStatusGroups( | ||
| groups: PublicStatusConfiguredGroupInput[] | ||
| ): EnabledPublicStatusGroup[] { | ||
| const seenGroupNamesBySlug = new Map<string, string>(); | ||
|
|
||
| return groups | ||
| .map((group) => { | ||
| const publicModels = sanitizePublicModels(group.publicStatus?.publicModels); | ||
| const publicGroupSlug = normalizePublicGroupSlug( | ||
| group.groupName, | ||
| group.publicStatus?.publicGroupSlug | ||
| ); | ||
|
|
||
| const existingGroupName = seenGroupNamesBySlug.get(publicGroupSlug); | ||
| if (existingGroupName) { | ||
| throw new DuplicatePublicStatusGroupSlugError(publicGroupSlug, [ | ||
|
Comment on lines
+282
to
+284
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Duplicate slug detection is executed before empty/invalid groups are removed ( Useful? React with 👍 / 👎. |
||
| existingGroupName, | ||
| group.groupName, | ||
| ]); | ||
| } | ||
|
|
||
| seenGroupNamesBySlug.set(publicGroupSlug, group.groupName); | ||
|
|
||
| return { | ||
| groupName: group.groupName, | ||
| displayName: group.publicStatus?.displayName?.trim() || group.groupName, | ||
| publicGroupSlug: | ||
| group.publicStatus?.publicGroupSlug?.trim() || slugifyPublicGroup(group.groupName), | ||
| publicGroupSlug, | ||
| explanatoryCopy: group.publicStatus?.explanatoryCopy?.trim() || null, | ||
| sortOrder: group.publicStatus?.sortOrder ?? 0, | ||
| publicModels, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: ding113/claude-code-hub
Length of output: 5258
🏁 Script executed:
head -50 messages/ja/settings/config.json | cat -nRepository: ding113/claude-code-hub
Length of output: 2862
🏁 Script executed:
tail -20 messages/ja/settings/config.json | cat -nRepository: ding113/claude-code-hub
Length of output: 1161
删除重复的 JSON 键。
这两个键已在第 39-40 行定义,第 90-91 行的重复定义会导致后面的值覆盖前面的值,造成 JSON 验证和 linting 错误。
建议的修改
📝 Committable suggestion
🤖 Prompt for AI Agents