diff --git a/messages/en/settings/index.ts b/messages/en/settings/index.ts
index 56357e8ad..cfa249145 100644
--- a/messages/en/settings/index.ts
+++ b/messages/en/settings/index.ts
@@ -104,6 +104,7 @@ export default {
common,
config,
providers,
+ providerTypes: providersFormProviderTypes,
prices,
sensitiveWords,
requestFilters,
diff --git a/messages/en/settings/statusPage.json b/messages/en/settings/statusPage.json
index 5835a65fb..b96824c79 100644
--- a/messages/en/settings/statusPage.json
+++ b/messages/en/settings/statusPage.json
@@ -1,16 +1,18 @@
{
"title": "Public Status Page",
- "description": "Configure the public status window, aggregation interval, and which groups/models should be exposed.",
+ "description": "Configure the public status page's stats window, chart bucket size, 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.",
+ "windowHours": "Stats Window (hours)",
+ "windowHoursDesc": "Window length used to compute TTFB and availability, and the total span the chart covers. Default: 24 hours.",
+ "aggregationIntervalMinutes": "Chart Bucket (minutes)",
+ "aggregationIntervalMinutesDesc": "Length of each bucket on the chart timeline; controls chart granularity. Choose 5 / 15 / 30 / 60 minutes.",
"aggregationIntervalMinutesInvalid": "Public status aggregation interval must be one of 5, 15, 30, or 60 minutes.",
"intervalOption": "{minutes} min",
"slug": "Slug",
+ "slugTooltip": "URL identifier for this group's dedicated status page. Leave empty to auto-generate from the public display name. Lowercase letters, digits, and hyphens only.",
"copy": "Copy",
"sortOrder": "Sort Order",
+ "sortOrderTooltip": "Display order on the public status page. Lower values appear first (same convention as provider priority). Equal values fall back to group name.",
"descriptionTooLong": "Public status configuration exceeds the 16 KB UTF-8 limit of provider_groups.description.",
"projectionPublishFailed": "Public status Redis projection publish failed.",
"groupsTitle": "Public Groups and Models",
@@ -41,7 +43,7 @@
"public": {
"systemStatus": "System Status",
"heroPrimary": "AI SERVICES",
- "heroSecondary": "INTELLIGENCE MONITOR",
+ "heroSecondary": "SERVICE STATUS DASHBOARD",
"generatedAt": "Updated",
"ttfb": "TTFB",
"tps": "TPS",
@@ -85,6 +87,8 @@
"modelsLabel": "models",
"issuesLabel": "issues",
"clearSearch": "Clear search",
- "dragHandle": "Drag to reorder"
+ "dragHandle": "Drag to reorder",
+ "toggleGroup": "Toggle group",
+ "openGroupPage": "Open group status page"
}
}
diff --git a/messages/ja/settings/index.ts b/messages/ja/settings/index.ts
index 56357e8ad..cfa249145 100644
--- a/messages/ja/settings/index.ts
+++ b/messages/ja/settings/index.ts
@@ -104,6 +104,7 @@ export default {
common,
config,
providers,
+ providerTypes: providersFormProviderTypes,
prices,
sensitiveWords,
requestFilters,
diff --git a/messages/ja/settings/statusPage.json b/messages/ja/settings/statusPage.json
index f7a89c1e6..69c527a8d 100644
--- a/messages/ja/settings/statusPage.json
+++ b/messages/ja/settings/statusPage.json
@@ -1,16 +1,18 @@
{
"title": "公開ステータスページ",
- "description": "公開ステータスページの集計期間、集計間隔、および公開するグループ/モデルを設定します。",
+ "description": "公開ステータスページの統計ウィンドウ、チャートのバケットサイズ、および公開するグループ/モデルを設定します。",
"form": {
- "windowHours": "表示期間(時間)",
- "windowHoursDesc": "公開ページが集計する履歴期間を指定します。既定値は 24 時間です。",
- "aggregationIntervalMinutes": "集計間隔(分)",
- "aggregationIntervalMinutesDesc": "バックグラウンド集計がスナップショットを更新する周期です。公開グループとモデルが設定された場合のみ有効になります。",
+ "windowHours": "統計ウィンドウ(時間)",
+ "windowHoursDesc": "TTFB と可用率の算出に使う統計期間であり、チャートがカバーする総期間でもあります。既定値は 24 時間です。",
+ "aggregationIntervalMinutes": "チャートのバケット(分)",
+ "aggregationIntervalMinutesDesc": "チャート時間軸の各バケットの長さで、チャートの粒度を決定します。5 / 15 / 30 / 60 分から選択できます。",
"aggregationIntervalMinutesInvalid": "公開ステータスの集計間隔は 5、15、30、60 分のいずれかである必要があります。",
"intervalOption": "{minutes} 分",
"slug": "Slug",
+ "slugTooltip": "グループ専用ステータスページの URL 識別子。空欄の場合は公開表示名から自動生成されます。小文字、数字、ハイフンのみ使用可能です。",
"copy": "説明文",
"sortOrder": "並び順",
+ "sortOrderTooltip": "公開ステータスページでの表示順。値が小さいほど先頭に表示されます(プロバイダー優先度と同じ規則)。同値の場合はグループ名順となります。",
"descriptionTooLong": "公開ステータス設定が provider_groups.description の 16 KB UTF-8 制限を超えています。",
"projectionPublishFailed": "公開ステータスの Redis 投影の公開に失敗しました。",
"groupsTitle": "公開グループとモデル",
@@ -41,7 +43,7 @@
"public": {
"systemStatus": "システム状態",
"heroPrimary": "AI SERVICES",
- "heroSecondary": "INTELLIGENCE MONITOR",
+ "heroSecondary": "SERVICE STATUS DASHBOARD",
"generatedAt": "更新",
"ttfb": "TTFB",
"tps": "TPS",
@@ -85,6 +87,8 @@
"modelsLabel": "モデル",
"issuesLabel": "異常",
"clearSearch": "検索をクリア",
- "dragHandle": "ドラッグして並べ替え"
+ "dragHandle": "ドラッグして並べ替え",
+ "toggleGroup": "グループを展開または折りたたむ",
+ "openGroupPage": "グループ専用ステータスページを開く"
}
}
diff --git a/messages/ru/settings/index.ts b/messages/ru/settings/index.ts
index 56357e8ad..cfa249145 100644
--- a/messages/ru/settings/index.ts
+++ b/messages/ru/settings/index.ts
@@ -104,6 +104,7 @@ export default {
common,
config,
providers,
+ providerTypes: providersFormProviderTypes,
prices,
sensitiveWords,
requestFilters,
diff --git a/messages/ru/settings/statusPage.json b/messages/ru/settings/statusPage.json
index 3b0344676..3e6e36f0f 100644
--- a/messages/ru/settings/statusPage.json
+++ b/messages/ru/settings/statusPage.json
@@ -1,16 +1,18 @@
{
"title": "Публичная страница статуса",
- "description": "Настройте окно агрегации, интервал агрегации и группы/модели, которые будут показаны публично.",
+ "description": "Настройте окно статистики, размер бакета графика и группы/модели, которые будут показаны публично.",
"form": {
- "windowHours": "Окно отображения (часы)",
- "windowHoursDesc": "Определяет глубину истории для публичной страницы. По умолчанию: 24 часа.",
- "aggregationIntervalMinutes": "Интервал агрегации (минуты)",
- "aggregationIntervalMinutesDesc": "Как часто фоновая задача обновляет публичный снимок. Запускается только после настройки хотя бы одной группы с моделями.",
+ "windowHours": "Окно статистики (часы)",
+ "windowHoursDesc": "Длина окна для расчёта TTFB и доступности, а также общий период, покрываемый графиком. По умолчанию: 24 часа.",
+ "aggregationIntervalMinutes": "Бакет графика (минуты)",
+ "aggregationIntervalMinutesDesc": "Длина каждого бакета на оси времени графика; определяет детализацию графика. Допустимые значения: 5 / 15 / 30 / 60 минут.",
"aggregationIntervalMinutesInvalid": "Интервал агрегации публичного статуса должен быть одним из 5, 15, 30 или 60 минут.",
"intervalOption": "{minutes} мин",
"slug": "Slug",
+ "slugTooltip": "Идентификатор URL для выделенной страницы статуса этой группы. Если оставить пустым, будет создан автоматически из публичного имени. Разрешены только строчные буквы, цифры и дефисы.",
"copy": "Пояснение",
"sortOrder": "Порядок",
+ "sortOrderTooltip": "Порядок отображения на публичной странице статуса. Меньшие значения — выше (как у приоритета провайдеров). При равных значениях порядок определяется по имени группы.",
"descriptionTooLong": "Конфигурация публичного статуса превышает лимит 16 КБ UTF-8 для provider_groups.description.",
"projectionPublishFailed": "Не удалось опубликовать Redis-проекцию публичного статуса.",
"groupsTitle": "Публичные группы и модели",
@@ -41,7 +43,7 @@
"public": {
"systemStatus": "Статус системы",
"heroPrimary": "AI SERVICES",
- "heroSecondary": "INTELLIGENCE MONITOR",
+ "heroSecondary": "SERVICE STATUS DASHBOARD",
"generatedAt": "Обновлено",
"ttfb": "TTFB",
"tps": "TPS",
@@ -85,6 +87,8 @@
"modelsLabel": "моделей",
"issuesLabel": "проблем",
"clearSearch": "Очистить поиск",
- "dragHandle": "Перетащить"
+ "dragHandle": "Перетащить",
+ "toggleGroup": "Развернуть или свернуть группу",
+ "openGroupPage": "Открыть страницу статуса группы"
}
}
diff --git a/messages/zh-CN/settings/index.ts b/messages/zh-CN/settings/index.ts
index 56357e8ad..cfa249145 100644
--- a/messages/zh-CN/settings/index.ts
+++ b/messages/zh-CN/settings/index.ts
@@ -104,6 +104,7 @@ export default {
common,
config,
providers,
+ providerTypes: providersFormProviderTypes,
prices,
sensitiveWords,
requestFilters,
diff --git a/messages/zh-CN/settings/statusPage.json b/messages/zh-CN/settings/statusPage.json
index 45ae12355..cc13ba669 100644
--- a/messages/zh-CN/settings/statusPage.json
+++ b/messages/zh-CN/settings/statusPage.json
@@ -1,16 +1,18 @@
{
"title": "公开状态页面",
- "description": "配置公开状态页面的聚合窗口、聚合间隔,以及需要对外展示的分组和模型。",
+ "description": "配置公开状态页面的统计窗口、图表分桶,以及需要对外展示的分组和模型。",
"form": {
- "windowHours": "展示时间长度(小时)",
- "windowHoursDesc": "控制公开页面聚合覆盖的时间窗口。默认 24 小时。",
- "aggregationIntervalMinutes": "聚合间隔(分钟)",
- "aggregationIntervalMinutesDesc": "定时聚合写快照的周期。只有配置了公开分组和模型后才会生效。",
+ "windowHours": "统计窗口(小时)",
+ "windowHoursDesc": "用于计算 TTFB 与在线率的统计窗口长度,也是图表覆盖的总时间跨度。默认 24 小时。",
+ "aggregationIntervalMinutes": "图表分桶(分钟)",
+ "aggregationIntervalMinutesDesc": "图表时间线每个分桶的时长,决定图表粒度。可选 5 / 15 / 30 / 60 分钟。",
"aggregationIntervalMinutesInvalid": "公开状态聚合间隔只能是 5、15、30、60 分钟之一。",
"intervalOption": "{minutes} 分钟",
"slug": "Slug",
+ "slugTooltip": "用于分组独立状态页的 URL 标识。留空将基于对外显示名自动生成。只能包含小写字母、数字和连字符。",
"copy": "说明文案",
"sortOrder": "排序",
+ "sortOrderTooltip": "控制公开状态页中分组的显示顺序。数值越小越靠前(与供应商优先级一致)。相同数值按分组名排序。",
"descriptionTooLong": "公开状态配置超过 provider_groups.description 的 16 KB UTF-8 限制。",
"projectionPublishFailed": "公开状态 Redis 投影发布失败。",
"groupsTitle": "公开分组与模型",
@@ -41,7 +43,7 @@
"public": {
"systemStatus": "系统状态",
"heroPrimary": "AI 服务",
- "heroSecondary": "智能状态面板",
+ "heroSecondary": "服务状态面板",
"generatedAt": "更新于",
"ttfb": "TTFB",
"tps": "TPS",
@@ -85,6 +87,8 @@
"modelsLabel": "模型",
"issuesLabel": "异常",
"clearSearch": "清除搜索",
- "dragHandle": "拖动重排"
+ "dragHandle": "拖动重排",
+ "toggleGroup": "展开或折叠分组",
+ "openGroupPage": "打开分组独立状态页"
}
}
diff --git a/messages/zh-TW/settings/index.ts b/messages/zh-TW/settings/index.ts
index 56357e8ad..cfa249145 100644
--- a/messages/zh-TW/settings/index.ts
+++ b/messages/zh-TW/settings/index.ts
@@ -104,6 +104,7 @@ export default {
common,
config,
providers,
+ providerTypes: providersFormProviderTypes,
prices,
sensitiveWords,
requestFilters,
diff --git a/messages/zh-TW/settings/statusPage.json b/messages/zh-TW/settings/statusPage.json
index e06fe719d..9280487d1 100644
--- a/messages/zh-TW/settings/statusPage.json
+++ b/messages/zh-TW/settings/statusPage.json
@@ -1,16 +1,18 @@
{
"title": "公開狀態頁面",
- "description": "設定公開狀態頁面的聚合視窗、聚合間隔,以及需要對外展示的分組和模型。",
+ "description": "設定公開狀態頁面的統計視窗、圖表分桶,以及需要對外展示的分組和模型。",
"form": {
- "windowHours": "展示時間長度(小時)",
- "windowHoursDesc": "控制公開頁面聚合覆蓋的時間視窗。預設 24 小時。",
- "aggregationIntervalMinutes": "聚合間隔(分鐘)",
- "aggregationIntervalMinutesDesc": "定時聚合寫入快照的週期。只有在設定了公開分組和模型後才會生效。",
+ "windowHours": "統計視窗(小時)",
+ "windowHoursDesc": "用於計算 TTFB 與在線率的統計視窗長度,也是圖表覆蓋的總時間跨度。預設 24 小時。",
+ "aggregationIntervalMinutes": "圖表分桶(分鐘)",
+ "aggregationIntervalMinutesDesc": "圖表時間軸每個分桶的時長,決定圖表粒度。可選 5 / 15 / 30 / 60 分鐘。",
"aggregationIntervalMinutesInvalid": "公開狀態聚合間隔只能是 5、15、30、60 分鐘之一。",
"intervalOption": "{minutes} 分鐘",
"slug": "Slug",
+ "slugTooltip": "用於分組獨立狀態頁的 URL 識別碼。留空將根據對外顯示名自動產生。只能包含小寫字母、數字與連字號。",
"copy": "說明文案",
"sortOrder": "排序",
+ "sortOrderTooltip": "控制公開狀態頁中分組的顯示順序。數值越小越靠前(與供應商優先級一致)。相同數值按分組名排序。",
"descriptionTooLong": "公開狀態設定超過 provider_groups.description 的 16 KB UTF-8 限制。",
"projectionPublishFailed": "公開狀態 Redis 投影發布失敗。",
"groupsTitle": "公開分組與模型",
@@ -41,7 +43,7 @@
"public": {
"systemStatus": "系統狀態",
"heroPrimary": "AI 服務",
- "heroSecondary": "智慧狀態面板",
+ "heroSecondary": "服務狀態面板",
"generatedAt": "更新於",
"ttfb": "TTFB",
"tps": "TPS",
@@ -85,6 +87,8 @@
"modelsLabel": "模型",
"issuesLabel": "異常",
"clearSearch": "清除搜尋",
- "dragHandle": "拖曳重排"
+ "dragHandle": "拖曳重排",
+ "toggleGroup": "展開或收合分組",
+ "openGroupPage": "開啟分組獨立狀態頁"
}
}
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
index 8df95944b..4118dad2f 100644
--- 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
@@ -1,6 +1,6 @@
"use client";
-import { ChevronDown, ChevronRight, ExternalLink, Save } from "lucide-react";
+import { ChevronDown, ChevronRight, ExternalLink, Info, Save } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useMemo, useState, useTransition } from "react";
@@ -25,6 +25,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Link } from "@/i18n/routing";
import {
getProviderTypeTranslationKey,
@@ -58,6 +59,37 @@ function getPublishableGroupCount(groups: PublicStatusSettingsFormGroup[]): numb
return groups.filter((group) => group.enabled && group.publicModels.length > 0).length;
}
+function slugifyGroupName(input: string): string {
+ const trimmed = (input || "").trim().toLowerCase();
+ if (!trimmed) return "";
+ return trimmed
+ .replace(/[^a-z0-9\s-]+/g, "")
+ .replace(/\s+/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/^-+|-+$/g, "");
+}
+
+function InfoTip({ text }: { text: string }) {
+ return (
+
{t("statusPage.form.windowHoursDesc")}
- {t("statusPage.form.aggregationIntervalMinutesDesc")} -
- {labels.inferredFromNeighbors} -
- ) : null} ); diff --git a/src/app/[locale]/status/_components/public-status-view.tsx b/src/app/[locale]/status/_components/public-status-view.tsx index 47d328a71..3936b3962 100644 --- a/src/app/[locale]/status/_components/public-status-view.tsx +++ b/src/app/[locale]/status/_components/public-status-view.tsx @@ -15,6 +15,7 @@ import { sortableKeyboardCoordinates, verticalListSortingStrategy, } from "@dnd-kit/sortable"; +import { Activity } from "lucide-react"; import { startTransition, useEffect, useMemo, useState } from "react"; import { Badge } from "@/components/ui/badge"; import type { PublicStatusPayload } from "@/lib/public-status/payload"; @@ -22,6 +23,7 @@ import { getPublicStatusVendorIconComponent } from "@/lib/public-status/vendor-i import { cn } from "@/lib/utils"; import { type DisplayState, deriveLatestModelState } from "../_lib/derive-display-state"; import { fillDisplayTimeline } from "../_lib/fill-display-timeline"; +import { formatTtfb } from "../_lib/format-ttfb"; import { clearGroupOrder, loadCollapsedSet, @@ -47,6 +49,7 @@ interface PublicStatusViewProps { intervalMinutes: number; rangeHours: number; followServerDefaults?: boolean; + filterSlug?: string; locale: string; siteTitle: string; timeZone: string; @@ -76,8 +79,6 @@ interface PublicStatusViewProps { availability: string; ttfb: string; tps: string; - samples: string; - inferredFromNeighbors: string; historyAriaLabel: string; }; searchPlaceholder: string; @@ -88,6 +89,8 @@ interface PublicStatusViewProps { issuesLabel: string; clearSearch: string; dragHandle: string; + toggleGroup: string; + openGroupPage: string; }; } @@ -119,11 +122,13 @@ function badgeVariant(state: DisplayState): { } } -function aggregateOverallState(states: DisplayState[]): DisplayState { - if (states.some((s) => s === "failed")) return "failed"; - if (states.some((s) => s === "degraded")) return "degraded"; - if (states.some((s) => s === "operational")) return "operational"; - return "no_data"; +function aggregateByFailed(states: DisplayState[]): DisplayState { + const effective = states.filter((s) => s !== "no_data"); + if (effective.length === 0) return "no_data"; + const failedCount = effective.filter((s) => s === "failed").length; + if (failedCount === effective.length) return "failed"; + if (failedCount >= 1) return "degraded"; + return "operational"; } export function PublicStatusView({ @@ -131,6 +136,7 @@ export function PublicStatusView({ intervalMinutes, rangeHours, followServerDefaults = false, + filterSlug, locale, siteTitle, timeZone, @@ -154,7 +160,11 @@ export function PublicStatusView({ ); if (!response.ok) return; const next = (await response.json()) as PublicStatusPayload; - startTransition(() => setPayload(next)); + // 单分组页面(/status/[slug]):每次轮询后仍只保留目标分组,避免刷新后展开成全站视图 + const scoped = filterSlug + ? { ...next, groups: next.groups.filter((g) => g.publicGroupSlug === filterSlug) } + : next; + startTransition(() => setPayload(scoped)); } catch { // keep last payload until next tick } @@ -164,7 +174,7 @@ export function PublicStatusView({ } const pollId = window.setInterval(() => void refresh(), 30_000); return () => window.clearInterval(pollId); - }, [followServerDefaults, initialPayload.rebuildState, intervalMinutes, rangeHours]); + }, [followServerDefaults, initialPayload.rebuildState, intervalMinutes, rangeHours, filterSlug]); useEffect(() => { setGroupOrder(loadGroupOrder()); @@ -172,20 +182,7 @@ export function PublicStatusView({ setOrderHydrated(true); }, []); - const baseGroups = useMemo( - () => - payload.groups.length > 0 - ? payload.groups - : [ - { - publicGroupSlug: "bootstrap", - displayName: labels.systemStatus, - explanatoryCopy: labels.emptyDescription, - models: [], - }, - ], - [payload.groups, labels.systemStatus, labels.emptyDescription] - ); + const baseGroups = useMemo(() => payload.groups, [payload.groups]); const derivedGroups = useMemo(() => { return baseGroups.map((group) => { @@ -197,10 +194,9 @@ export function PublicStatusView({ const latest = deriveLatestModelState(model); return { model, chartCells, uptime24h, ttfb24h, latest }; }); - const issueCount = derivedModels.filter( - (d) => d.latest === "failed" || d.latest === "degraded" - ).length; - return { group, derivedModels, issueCount }; + const issueCount = derivedModels.filter((d) => d.latest === "failed").length; + const groupState = aggregateByFailed(derivedModels.map((d) => d.latest)); + return { group, derivedModels, issueCount, groupState }; }); }, [baseGroups]); @@ -235,8 +231,7 @@ export function PublicStatusView({ }, [orderedGroups, searchQuery, isFiltering]); const overallState: DisplayState = useMemo(() => { - const states = derivedGroups.flatMap((g) => g.derivedModels.map((d) => d.latest)); - return aggregateOverallState(states); + return aggregateByFailed(derivedGroups.map((g) => g.groupState)); }, [derivedGroups]); const overallLabel = badgeVariant(overallState).label(labels); @@ -287,12 +282,18 @@ export function PublicStatusView({ availability: labels.tooltip.availability, ttfb: labels.tooltip.ttfb, tps: labels.tooltip.tps, - samples: labels.tooltip.samples, - inferredFromNeighbors: labels.tooltip.inferredFromNeighbors, noData: labels.noData, historyAriaLabel: labels.tooltip.historyAriaLabel, }; + if (payload.groups.length === 0) { + return ( +{explanatoryCopy}
+ ) : null}