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 ( + + + + + + + {text} + + + + ); +} + export function PublicStatusSettingsForm({ initialWindowHours, initialAggregationIntervalMinutes, @@ -148,7 +180,10 @@ export function PublicStatusSettingsForm({ >
- +
+ + +
setWindowHours(event.target.value)} disabled={isPending} /> -

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

- +
+ + +
-

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

@@ -279,7 +313,10 @@ export function PublicStatusSettingsForm({
- +
+ + +
@@ -287,7 +324,7 @@ export function PublicStatusSettingsForm({ publicGroupSlug: event.target.value, }) } - placeholder={group.groupName.toLowerCase()} + placeholder={slugifyGroupName(group.displayName || group.groupName)} disabled={isPending} />
@@ -307,7 +344,10 @@ export function PublicStatusSettingsForm({
- +
+ + +
0 : undefined, + nowIso: new Date().toISOString(), + triggerRebuildHint: async (reason) => { + if (followServerDefaults) { + await schedulePublicStatusRebuild({ + intervalMinutes, + rangeHours, + reason, + }); + } + }, + }); + const targetGroup = payload.groups.find((group) => group.publicGroupSlug === slug); + return { configSnapshot, siteMeta, intervalMinutes, rangeHours, payload, targetGroup }; +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await params; + const { configSnapshot, siteMeta, targetGroup } = await loadGroupContext(slug); + const siteTitle = resolveSiteTitle(configSnapshot?.siteTitle, siteMeta.siteTitle); + if (!targetGroup) { + return { title: siteTitle }; + } + return { + title: `${targetGroup.displayName} · ${siteTitle}`, + description: targetGroup.explanatoryCopy ?? undefined, + }; +} + +export default async function PublicStatusGroupPage({ + params, +}: { + params: Promise<{ locale: string; slug: string }>; +}) { + const { locale, slug } = await params; + const t = await getTranslations("settings"); + const { configSnapshot, siteMeta, intervalMinutes, rangeHours, payload, targetGroup } = + await loadGroupContext(slug); + if (!targetGroup) { + notFound(); + } + + const followServerDefaults = !configSnapshot; + const filteredPayload = { ...payload, groups: [targetGroup] }; + + return ( + + ); +} diff --git a/src/app/[locale]/status/_components/public-status-timeline.tsx b/src/app/[locale]/status/_components/public-status-timeline.tsx index 3ea0fa2c4..e31dca979 100644 --- a/src/app/[locale]/status/_components/public-status-timeline.tsx +++ b/src/app/[locale]/status/_components/public-status-timeline.tsx @@ -3,13 +3,12 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import type { FilledTimelineCell } from "../_lib/fill-display-timeline"; +import { formatTtfb } from "../_lib/format-ttfb"; export interface PublicStatusTimelineLabels { availability: string; ttfb: string; tps: string; - samples: string; - inferredFromNeighbors: string; noData: string; historyAriaLabel: string; } @@ -21,17 +20,28 @@ interface PublicStatusTimelineProps { labels: PublicStatusTimelineLabels; } -function cellColor(state: FilledTimelineCell["displayState"], inferred: boolean): string { - switch (state) { - case "operational": - return inferred ? "bg-emerald-500/60" : "bg-emerald-500"; - case "degraded": - return inferred ? "bg-amber-500/60" : "bg-amber-500"; - case "failed": - return inferred ? "bg-rose-500/60" : "bg-rose-500"; - default: - return "bg-muted/40"; +function cellColor(cell: FilledTimelineCell): string { + const { displayState, inferred, bucket } = cell; + if (displayState === "no_data") { + return "bg-muted/40"; } + if (displayState === "failed" || bucket.state === "failed") { + return inferred ? "bg-rose-500/60" : "bg-rose-500"; + } + const pct = bucket.availabilityPct; + if (pct === null) { + return displayState === "degraded" + ? inferred + ? "bg-amber-500/60" + : "bg-amber-500" + : inferred + ? "bg-emerald-500/60" + : "bg-emerald-500"; + } + if (pct >= 90) return inferred ? "bg-emerald-500/60" : "bg-emerald-500"; + if (pct >= 80) return inferred ? "bg-amber-400/60" : "bg-amber-400"; + if (pct >= 60) return inferred ? "bg-orange-500/60" : "bg-orange-500"; + return inferred ? "bg-rose-500/60" : "bg-rose-500"; } function formatRange(start: string, end: string, locale: string, timeZone: string): string { @@ -72,7 +82,7 @@ export function PublicStatusTimeline({ aria-label={labels.historyAriaLabel} > {cells.map((cell, index) => { - const { bucket, displayState, inferred } = cell; + const { bucket } = cell; const isPlaceholder = bucket.bucketStart.startsWith("empty-"); return ( @@ -83,7 +93,7 @@ export function PublicStatusTimeline({ aria-label={`${labels.availability}: ${bucket.availabilityPct ?? "—"}`} className={cn( "h-6 flex-1 rounded-[2px] outline-none transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-ring", - cellColor(displayState, inferred) + cellColor(cell) )} /> @@ -104,21 +114,12 @@ export function PublicStatusTimeline({ : `${bucket.availabilityPct.toFixed(2)}%`} {labels.ttfb} - - {bucket.ttfbMs === null ? "—" : `${bucket.ttfbMs} ms`} - + {formatTtfb(bucket.ttfbMs)} {labels.tps} {bucket.tps === null ? "—" : bucket.tps.toFixed(1)} - {labels.samples} - {bucket.sampleCount}
- {inferred && !isPlaceholder ? ( -

- {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 ( +
+
+ ); + } + return (
@@ -342,6 +343,7 @@ export function PublicStatusView({ key={group.publicGroupSlug} slug={group.publicGroupSlug} displayName={group.displayName} + explanatoryCopy={group.explanatoryCopy} modelCount={entry.derivedModels.length} issueCount={entry.issueCount} open={open} @@ -350,6 +352,9 @@ export function PublicStatusView({ modelBadgeLabel={labels.modelsLabel} issueBadgeLabel={labels.issuesLabel} dragHandleLabel={labels.dragHandle} + toggleLabel={labels.toggleGroup} + groupHref={filterSlug ? undefined : `/status/${group.publicGroupSlug}`} + groupLinkLabel={labels.openGroupPage} >
{entry.derivedModels.map( @@ -394,7 +399,10 @@ export function PublicStatusView({
- {labels.availability} + {labels.availability}{" "} + + ({rangeHours}H) +
{uptime24h === null ? "—" : `${uptime24h.toFixed(2)}%`} @@ -402,10 +410,13 @@ export function PublicStatusView({
- {labels.ttfb} + {labels.ttfb}{" "} + + ({rangeHours}H) +
- {ttfb24h === null ? "—" : `${ttfb24h} ms`} + {formatTtfb(ttfb24h)}
diff --git a/src/app/[locale]/status/_components/sortable-group-panel.tsx b/src/app/[locale]/status/_components/sortable-group-panel.tsx index 13cfb088a..d1a27665a 100644 --- a/src/app/[locale]/status/_components/sortable-group-panel.tsx +++ b/src/app/[locale]/status/_components/sortable-group-panel.tsx @@ -2,14 +2,16 @@ import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { ChevronDown, GripVertical } from "lucide-react"; +import { ChevronDown, ExternalLink, GripVertical } from "lucide-react"; import type { ReactNode } from "react"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; +import { Link } from "@/i18n/routing"; import { cn } from "@/lib/utils"; interface SortableGroupPanelProps { slug: string; displayName: string; + explanatoryCopy?: string | null; modelCount: number; issueCount: number; open: boolean; @@ -19,11 +21,15 @@ interface SortableGroupPanelProps { issueBadgeLabel?: string; modelBadgeLabel?: string; dragHandleLabel?: string; + toggleLabel?: string; + groupHref?: string; + groupLinkLabel?: string; } export function SortableGroupPanel({ slug, displayName, + explanatoryCopy, modelCount, issueCount, open, @@ -33,6 +39,9 @@ export function SortableGroupPanel({ issueBadgeLabel, modelBadgeLabel, dragHandleLabel, + toggleLabel, + groupHref, + groupLinkLabel, }: SortableGroupPanelProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: slug, @@ -67,30 +76,57 @@ export function SortableGroupPanel({ ) : null} - + + {groupHref ? ( + + {displayName} + + ) : ( +

{displayName}

- - {modelBadgeLabel ? ( - - {modelCount} {modelBadgeLabel} - - ) : null} - {issueCount > 0 && issueBadgeLabel ? ( - - {issueCount} {issueBadgeLabel} - - ) : null} - -
+ )} + + {modelBadgeLabel ? ( + + {modelCount} {modelBadgeLabel} + + ) : null} + {issueCount > 0 && issueBadgeLabel ? ( + + {issueCount} {issueBadgeLabel} + + ) : null} + {groupHref ? ( + + + + ) : null} +
+ {explanatoryCopy ? ( +

{explanatoryCopy}

+ ) : null} {children} diff --git a/src/app/[locale]/status/_lib/derive-display-state.ts b/src/app/[locale]/status/_lib/derive-display-state.ts index 6d02f63f8..30fd8191d 100644 --- a/src/app/[locale]/status/_lib/derive-display-state.ts +++ b/src/app/[locale]/status/_lib/derive-display-state.ts @@ -19,13 +19,10 @@ export function deriveDisplayState(bucket: PublicStatusTimelineBucket): DisplayS if (pct === null) { return bucket.state === "degraded" ? "degraded" : "operational"; } - if (pct >= 100) { - return "operational"; - } if (pct < DEGRADED_THRESHOLD) { - return "failed"; + return "degraded"; } - return "degraded"; + return "operational"; } export function deriveLatestModelState( diff --git a/src/app/[locale]/status/_lib/format-ttfb.ts b/src/app/[locale]/status/_lib/format-ttfb.ts new file mode 100644 index 000000000..aec03c853 --- /dev/null +++ b/src/app/[locale]/status/_lib/format-ttfb.ts @@ -0,0 +1,7 @@ +export function formatTtfb(ms: number | null): string { + if (ms === null) return "—"; + if (ms >= 10000) { + return `${(ms / 1000).toFixed(2)} s`; + } + return `${ms} ms`; +} diff --git a/src/app/[locale]/status/page.tsx b/src/app/[locale]/status/page.tsx index 94a5e4079..ac2f45069 100644 --- a/src/app/[locale]/status/page.tsx +++ b/src/app/[locale]/status/page.tsx @@ -77,8 +77,6 @@ export default async function PublicStatusPage({ availability: t("statusPage.public.tooltip.availability"), ttfb: t("statusPage.public.tooltip.ttfb"), tps: t("statusPage.public.tooltip.tps"), - samples: t("statusPage.public.tooltip.samples"), - inferredFromNeighbors: t("statusPage.public.tooltip.inferredFromNeighbors"), historyAriaLabel: t("statusPage.public.tooltip.historyAriaLabel"), }, searchPlaceholder: t("statusPage.public.searchPlaceholder"), @@ -89,6 +87,8 @@ export default async function PublicStatusPage({ 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"), }} /> ); diff --git a/tests/unit/app/status/derive-display-state.test.ts b/tests/unit/app/status/derive-display-state.test.ts index f7cc57894..54d6ddac8 100644 --- a/tests/unit/app/status/derive-display-state.test.ts +++ b/tests/unit/app/status/derive-display-state.test.ts @@ -40,16 +40,20 @@ describe("deriveDisplayState", () => { expect(deriveDisplayState(makeBucket({ availabilityPct: 100 }))).toBe("operational"); }); - it("returns degraded when availabilityPct between threshold and 100", () => { - expect(deriveDisplayState(makeBucket({ availabilityPct: 80 }))).toBe("degraded"); + it("returns degraded when availabilityPct below threshold", () => { + expect(deriveDisplayState(makeBucket({ availabilityPct: 30 }))).toBe("degraded"); + expect(deriveDisplayState(makeBucket({ availabilityPct: 0 }))).toBe("degraded"); + }); + + it("returns operational when availabilityPct between threshold and 100", () => { + expect(deriveDisplayState(makeBucket({ availabilityPct: 80 }))).toBe("operational"); expect(deriveDisplayState(makeBucket({ availabilityPct: DEGRADED_THRESHOLD }))).toBe( - "degraded" + "operational" ); }); - it("collapses to failed when availabilityPct below threshold", () => { - expect(deriveDisplayState(makeBucket({ availabilityPct: 30 }))).toBe("failed"); - expect(deriveDisplayState(makeBucket({ availabilityPct: 0 }))).toBe("failed"); + it("returns operational when availabilityPct is below 100 but not below threshold", () => { + expect(deriveDisplayState(makeBucket({ availabilityPct: 99 }))).toBe("operational"); }); }); @@ -97,11 +101,11 @@ describe("deriveLatestModelState", () => { expect(result).toBe("degraded"); }); - it("returns degraded when last known availability is partial", () => { + it("returns operational when last known availability is partial but >= threshold", () => { const result = deriveLatestModelState({ latestState: "operational", timeline: [makeBucket({ state: "operational", availabilityPct: 75 })], }); - expect(result).toBe("degraded"); + expect(result).toBe("operational"); }); }); diff --git a/tests/unit/app/status/fill-display-timeline.test.ts b/tests/unit/app/status/fill-display-timeline.test.ts index 74a7585ce..559c1ddbd 100644 --- a/tests/unit/app/status/fill-display-timeline.test.ts +++ b/tests/unit/app/status/fill-display-timeline.test.ts @@ -99,11 +99,11 @@ describe("fillDisplayTimeline", () => { expect(JSON.stringify(original)).toBe(snapshot); }); - it("derives degraded for partial availability when filling", () => { + it("derives degraded for partial availability below threshold when filling", () => { const result = fillDisplayTimeline([ - bucket("operational", 80, 0), + bucket("operational", 30, 0), bucket("no_data", null, 1), - bucket("operational", 80, 2), + bucket("operational", 30, 2), ]); expect(result.map((c) => c.displayState)).toEqual(["degraded", "degraded", "degraded"]); });