fix: support admin info degraded states (#155)

This commit is contained in:
cxymds
2026-07-09 14:09:22 +08:00
committed by GitHub
parent 25944866d5
commit 7f9bdae7aa
20 changed files with 197 additions and 45 deletions
@@ -5,14 +5,20 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
export function PerformanceInfrastructureCard({
onlineServers,
offlineServers,
unknownServers,
degradedServers,
onlineDisks,
offlineDisks,
unknownDisks,
t,
}: {
onlineServers: number
offlineServers: number
unknownServers: number
degradedServers: number
onlineDisks: number
offlineDisks: number
unknownDisks: number
t: (key: string) => string
}) {
return (
@@ -25,7 +31,7 @@ export function PerformanceInfrastructureCard({
<div className="grid gap-4 lg:grid-cols-2">
<div className="border bg-muted/40 p-4">
<p className="text-sm font-medium text-muted-foreground">{t("Servers")}</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="border bg-background p-3">
<p className="text-xs text-muted-foreground">{t("Online")}</p>
<p className="mt-1 text-xl font-semibold text-foreground">{onlineServers}</p>
@@ -34,11 +40,19 @@ export function PerformanceInfrastructureCard({
<p className="text-xs text-muted-foreground">{t("Offline")}</p>
<p className="mt-1 text-xl font-semibold text-foreground">{offlineServers}</p>
</div>
<div className="border bg-background p-3">
<p className="text-xs text-muted-foreground">{t("Unknown")}</p>
<p className="mt-1 text-xl font-semibold text-foreground">{unknownServers}</p>
</div>
<div className="border bg-background p-3">
<p className="text-xs text-muted-foreground">{t("Degraded")}</p>
<p className="mt-1 text-xl font-semibold text-foreground">{degradedServers}</p>
</div>
</div>
</div>
<div className="border bg-muted/40 p-4">
<p className="text-sm font-medium text-muted-foreground">{t("Disks")}</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<div className="border bg-background p-3">
<p className="text-xs text-muted-foreground">{t("Online")}</p>
<p className="mt-1 text-xl font-semibold text-foreground">{onlineDisks}</p>
@@ -47,6 +61,10 @@ export function PerformanceInfrastructureCard({
<p className="text-xs text-muted-foreground">{t("Offline")}</p>
<p className="mt-1 text-xl font-semibold text-foreground">{offlineDisks}</p>
</div>
<div className="border bg-background p-3">
<p className="text-xs text-muted-foreground">{t("Unknown")}</p>
<p className="mt-1 text-xl font-semibold text-foreground">{unknownDisks}</p>
</div>
</div>
</div>
</div>
@@ -8,6 +8,7 @@ import { Progress } from "@/components/ui/progress"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { niceBytes } from "@/lib/functions"
import { normalizeServerHealthState, type ServerHealthState } from "@/lib/performance-data"
import { cn } from "@/lib/utils"
import dayjs from "dayjs"
import relativeTime from "dayjs/plugin/relativeTime"
@@ -29,13 +30,39 @@ type PerformanceServerSort =
| "endpoint-desc"
| "status-online"
| "status-offline"
| "status-unknown"
| "status-degraded"
| "uptime-desc"
| "uptime-asc"
type PerformanceServerFilter = "all" | "online" | "offline"
type PerformanceServerFilter = "all" | ServerHealthState
function isOnlineServer(server: ServerInfo) {
return (server.state ?? "").toLowerCase() === "online"
const serverStates: ServerHealthState[] = ["online", "offline", "unknown", "degraded"]
function getServerState(server: ServerInfo) {
return normalizeServerHealthState(server.state)
}
function getServerStateLabel(state: ServerHealthState, t: (key: string) => string) {
if (state === "online") return t("Online")
if (state === "offline") return t("Offline")
if (state === "degraded") return t("Degraded")
return t("Unknown")
}
function getServerStateTone(state: ServerHealthState) {
if (state === "online") return "bg-primary"
if (state === "offline") return "bg-destructive"
if (state === "degraded") return "bg-amber-500"
return "bg-muted-foreground"
}
function getServerStateButtonClass(state: ServerHealthState, active: boolean) {
if (!active) return "shadow-none"
if (state === "offline") return "border-destructive/40 text-destructive shadow-none"
if (state === "degraded") return "border-amber-500/50 text-amber-700 shadow-none dark:text-amber-300"
if (state === "unknown") return "border-muted-foreground/40 text-muted-foreground shadow-none"
return "shadow-none"
}
function compareEndpoint(left: ServerInfo, right: ServerInfo, direction: "asc" | "desc") {
@@ -68,7 +95,17 @@ function compareUptime(left: ServerInfo, right: ServerInfo, direction: "asc" | "
export function PerformanceServerList({ servers, t }: { servers: ServerInfo[]; t: (key: string) => string }) {
const [sortBy, setSortBy] = React.useState<PerformanceServerSort>("default")
const [filterBy, setFilterBy] = React.useState<PerformanceServerFilter>("all")
const onlineCount = servers.filter((server) => isOnlineServer(server)).length
const stateCounts = React.useMemo(
() =>
servers.reduce(
(counts, server) => {
counts[getServerState(server)] += 1
return counts
},
{ online: 0, offline: 0, unknown: 0, degraded: 0 } satisfies Record<ServerHealthState, number>,
),
[servers],
)
const visibleServers = React.useMemo(() => {
const rows = servers
@@ -77,8 +114,7 @@ export function PerformanceServerList({ servers, t }: { servers: ServerInfo[]; t
originalIndex,
}))
.filter(({ server }) => {
if (filterBy === "online") return isOnlineServer(server)
if (filterBy === "offline") return !isOnlineServer(server)
if (filterBy !== "all") return getServerState(server) === filterBy
return true
})
@@ -90,13 +126,25 @@ export function PerformanceServerList({ servers, t }: { servers: ServerInfo[]; t
return compareEndpoint(left.server, right.server, "desc") || left.originalIndex - right.originalIndex
case "status-online":
return (
Number(isOnlineServer(right.server)) - Number(isOnlineServer(left.server)) ||
Number(getServerState(right.server) === "online") - Number(getServerState(left.server) === "online") ||
compareEndpoint(left.server, right.server, "asc") ||
left.originalIndex - right.originalIndex
)
case "status-offline":
return (
Number(isOnlineServer(left.server)) - Number(isOnlineServer(right.server)) ||
Number(getServerState(right.server) === "offline") - Number(getServerState(left.server) === "offline") ||
compareEndpoint(left.server, right.server, "asc") ||
left.originalIndex - right.originalIndex
)
case "status-unknown":
return (
Number(getServerState(right.server) === "unknown") - Number(getServerState(left.server) === "unknown") ||
compareEndpoint(left.server, right.server, "asc") ||
left.originalIndex - right.originalIndex
)
case "status-degraded":
return (
Number(getServerState(right.server) === "degraded") - Number(getServerState(left.server) === "degraded") ||
compareEndpoint(left.server, right.server, "asc") ||
left.originalIndex - right.originalIndex
)
@@ -125,24 +173,18 @@ export function PerformanceServerList({ servers, t }: { servers: ServerInfo[]; t
{t("Total")}: {visibleServers.length}/{servers.length}
</span>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
size="sm"
variant={filterBy === "online" ? "secondary" : "outline"}
className="shadow-none"
onClick={() => setFilterBy((current) => (current === "online" ? "all" : "online"))}
>
{t("Online")} ({onlineCount})
</Button>
<Button
type="button"
size="sm"
variant={filterBy === "offline" ? "secondary" : "outline"}
className={cn("shadow-none", filterBy === "offline" ? "border-destructive/40 text-destructive" : "")}
onClick={() => setFilterBy((current) => (current === "offline" ? "all" : "offline"))}
>
{t("Offline")} ({servers.length - onlineCount})
</Button>
{serverStates.map((state) => (
<Button
key={state}
type="button"
size="sm"
variant={filterBy === state ? "secondary" : "outline"}
className={getServerStateButtonClass(state, filterBy === state)}
onClick={() => setFilterBy((current) => (current === state ? "all" : state))}
>
{getServerStateLabel(state, t)} ({stateCounts[state]})
</Button>
))}
</div>
<div className="flex items-center gap-2 self-start sm:self-auto">
<span className="text-sm text-muted-foreground">{t("Sort by")}</span>
@@ -156,6 +198,8 @@ export function PerformanceServerList({ servers, t }: { servers: ServerInfo[]; t
<SelectItem value="endpoint-desc">{`${t("Endpoint")} ↓`}</SelectItem>
<SelectItem value="status-online">{`${t("Status")} · ${t("Online")}`}</SelectItem>
<SelectItem value="status-offline">{`${t("Status")} · ${t("Offline")}`}</SelectItem>
<SelectItem value="status-unknown">{`${t("Status")} · ${t("Unknown")}`}</SelectItem>
<SelectItem value="status-degraded">{`${t("Status")} · ${t("Degraded")}`}</SelectItem>
<SelectItem value="uptime-desc">{`${t("Uptime")} ↓`}</SelectItem>
<SelectItem value="uptime-asc">{`${t("Uptime")} ↑`}</SelectItem>
</SelectContent>
@@ -174,12 +218,12 @@ export function PerformanceServerList({ servers, t }: { servers: ServerInfo[]; t
<div className="flex flex-col gap-2 text-start sm:flex-row sm:items-center sm:gap-4">
<div className="flex items-center gap-2">
<span
className={cn(
"inline-flex h-2 w-2 rounded-full",
server.state === "online" ? "bg-primary" : "bg-destructive",
)}
className={cn("inline-flex h-2 w-2 rounded-full", getServerStateTone(getServerState(server)))}
/>
<span className="font-semibold">{server.endpoint ?? "--"}</span>
<span className="text-xs text-muted-foreground">
{getServerStateLabel(getServerState(server), t)}
</span>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span>
+19 -10
View File
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { usePerformanceData } from "@/hooks/use-performance-data"
import { niceBytes } from "@/lib/functions"
import { normalizeServerHealthState, type ServerHealthState } from "@/lib/performance-data"
import {
RiArchiveDrawerFill,
RiArchiveLine,
@@ -115,15 +116,20 @@ export default function PerformancePage() {
[t, fromLastStartTime, fromLastScanTime, lastScanTime],
)
const onlineServers = useMemo(
() => (systemInfo?.servers || []).filter((s) => s.state === "online").length,
[systemInfo],
)
const serverCounts = useMemo(() => {
const counts: Record<ServerHealthState, number> = {
online: 0,
offline: 0,
unknown: 0,
degraded: 0,
}
const offlineServers = useMemo(
() => (systemInfo?.servers || []).filter((s) => s.state === "offline").length,
[systemInfo],
)
for (const server of systemInfo?.servers ?? []) {
counts[normalizeServerHealthState(server.state)] += 1
}
return counts
}, [systemInfo])
const backendInfo = useMemo(
() => [
@@ -204,10 +210,13 @@ export default function PerformancePage() {
/>
<PerformanceInfrastructureCard
onlineServers={onlineServers}
offlineServers={offlineServers}
onlineServers={serverCounts.online}
offlineServers={serverCounts.offline}
unknownServers={serverCounts.unknown}
degradedServers={serverCounts.degraded}
onlineDisks={systemInfo?.backend?.onlineDisks ?? 0}
offlineDisks={systemInfo?.backend?.offlineDisks ?? 0}
unknownDisks={systemInfo?.backend?.unknownDisks ?? 0}
t={t}
/>
+1
View File
@@ -1182,6 +1182,7 @@
"Understanding Key Types": "فهم أنواع المفاتيح",
"Unhealthy": "غير سليم",
"Unknown": "غير معروف",
"Degraded": "متدهور",
"Unknown Folder": "مجلد غير معروف",
"Unlimited": "غير محدود",
"Unnamed Site": "موقع بدون اسم",
+1
View File
@@ -1182,6 +1182,7 @@
"Understanding Key Types": "Schlüsseltypen verstehen",
"Unhealthy": "Fehlerhaft",
"Unknown": "Unbekannt",
"Degraded": "Beeinträchtigt",
"Unknown Folder": "Unbekannter Ordner",
"Unlimited": "Unbegrenzt",
"Unnamed Site": "Unbenannte Site",
+1
View File
@@ -1182,6 +1182,7 @@
"Understanding Key Types": "Understanding Key Types",
"Unhealthy": "Unhealthy",
"Unknown": "Unknown",
"Degraded": "Degraded",
"Unknown Folder": "Unknown Folder",
"Unlimited": "Unlimited",
"Unnamed Site": "Unnamed Site",
+1
View File
@@ -1182,6 +1182,7 @@
"Understanding Key Types": "Comprensión de Tipos de Clave",
"Unhealthy": "Con problemas",
"Unknown": "Desconocido",
"Degraded": "Degradado",
"Unknown Folder": "Carpeta Desconocida",
"Unlimited": "Ilimitado",
"Unnamed Site": "Sitio sin nombre",
+1
View File
@@ -1182,6 +1182,7 @@
"Understanding Key Types": "Comprendre les types de clés",
"Unhealthy": "Défaillant",
"Unknown": "Inconnu",
"Degraded": "Dégradé",
"Unknown Folder": "Dossier inconnu",
"Unlimited": "Illimité",
"Unnamed Site": "Site sans nom",
+1
View File
@@ -1182,6 +1182,7 @@
"Understanding Key Types": "Memahami Tipe Kunci",
"Unhealthy": "Tidak sehat",
"Unknown": "Tidak Diketahui",
"Degraded": "Terdegradasi",
"Unknown Folder": "Folder Tidak Diketahui",
"Unlimited": "Tidak Terbatas",
"Unnamed Site": "Situs tanpa nama",
+1
View File
@@ -1182,6 +1182,7 @@
"Understanding Key Types": "Comprensione tipi chiave",
"Unhealthy": "Non integro",
"Unknown": "Sconosciuto",
"Degraded": "Degradato",
"Unknown Folder": "Cartella sconosciuta",
"Unlimited": "Illimitato",
"Unnamed Site": "Sito senza nome",
+1
View File
@@ -1182,6 +1182,7 @@
"Understanding Key Types": "キータイプの理解",
"Unhealthy": "異常",
"Unknown": "不明",
"Degraded": "劣化",
"Unknown Folder": "不明なフォルダ",
"Unlimited": "無制限",
"Unnamed Site": "無名のサイト",
+1
View File
@@ -1182,6 +1182,7 @@
"Understanding Key Types": "키 유형 이해",
"Unhealthy": "비정상",
"Unknown": "알 수 없음",
"Degraded": "성능 저하",
"Unknown Folder": "알 수 없는 폴더",
"Unlimited": "무제한",
"Unnamed Site": "이름 없는 사이트",
+1
View File
@@ -1182,6 +1182,7 @@
"Understanding Key Types": "Entendendo Tipos de Chave",
"Unhealthy": "Com problemas",
"Unknown": "Desconhecido",
"Degraded": "Degradado",
"Unknown Folder": "Pasta Desconhecida",
"Unlimited": "Ilimitado",
"Unnamed Site": "Site sem nome",
+1
View File
@@ -1182,6 +1182,7 @@
"Understanding Key Types": "Понимание типов ключей",
"Unhealthy": "Неисправен",
"Unknown": "Неизвестно",
"Degraded": "Деградировано",
"Unknown Folder": "Неизвестная папка",
"Unlimited": "Неограниченно",
"Unnamed Site": "Сайт без названия",
+1
View File
@@ -1182,6 +1182,7 @@
"Understanding Key Types": "Anahtar Türlerini Anlamak",
"Unhealthy": "Sağlıksız",
"Unknown": "Bilinmeyen",
"Degraded": "Bozulmuş",
"Unknown Folder": "Bilinmeyen Klasör",
"Unlimited": "Sınırsız",
"Unnamed Site": "Adsız Site",
+1
View File
@@ -1182,6 +1182,7 @@
"Understanding Key Types": "Tìm hiểu các loại khóa",
"Unhealthy": "Không khỏe mạnh",
"Unknown": "Không xác định",
"Degraded": "Suy giảm",
"Unknown Folder": "Thư mục không xác định",
"Unlimited": "Không giới hạn",
"Unnamed Site": "Site chưa đặt tên",
+1
View File
@@ -1182,6 +1182,7 @@
"Understanding Key Types": "了解密钥类型",
"Unhealthy": "异常",
"Unknown": "未知",
"Degraded": "降级",
"Unknown Folder": "未知文件夹",
"Unlimited": "无限制",
"Unnamed Site": "未命名站点",
+12
View File
@@ -23,6 +23,7 @@ export interface SystemInfo {
backendType?: string
onlineDisks?: number
offlineDisks?: number
unknownDisks?: number
}
}
@@ -50,6 +51,8 @@ export interface MetricsInfo {
type JsonRecord = Record<string, unknown>
export type ServerHealthState = "online" | "offline" | "unknown" | "degraded"
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}
}
@@ -71,6 +74,14 @@ function asString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() !== "" ? value : undefined
}
export function normalizeServerHealthState(state?: string | null): ServerHealthState {
const normalized = state?.trim().toLowerCase()
if (normalized === "online" || normalized === "offline" || normalized === "unknown" || normalized === "degraded") {
return normalized
}
return "unknown"
}
function unwrapInfoRecord(value: unknown): JsonRecord {
const record = asRecord(value)
const wrapped = record.info ?? record.Info
@@ -95,6 +106,7 @@ export function normalizeSystemInfo(value: unknown): SystemInfo {
backendType: asString(backend.backendType ?? backend.BackendType),
onlineDisks: asNumber(backend.onlineDisks ?? backend.OnlineDisks),
offlineDisks: asNumber(backend.offlineDisks ?? backend.OfflineDisks),
unknownDisks: asNumber(backend.unknownDisks ?? backend.UnknownDisks),
},
}
}
+25 -3
View File
@@ -1,7 +1,17 @@
import test from "node:test"
import assert from "node:assert/strict"
import { normalizeStorageInfo, normalizeSystemInfo } from "../../lib/performance-data.ts"
import { normalizeServerHealthState, normalizeStorageInfo, normalizeSystemInfo } from "../../lib/performance-data.ts"
test("normalizeServerHealthState preserves backend admin info states", () => {
assert.equal(normalizeServerHealthState("online"), "online")
assert.equal(normalizeServerHealthState("offline"), "offline")
assert.equal(normalizeServerHealthState("unknown"), "unknown")
assert.equal(normalizeServerHealthState("degraded"), "degraded")
assert.equal(normalizeServerHealthState(" Degraded "), "degraded")
assert.equal(normalizeServerHealthState("initializing"), "unknown")
assert.equal(normalizeServerHealthState(undefined), "unknown")
})
test("normalizeSystemInfo preserves legacy unwrapped info responses", () => {
const info = normalizeSystemInfo({
@@ -11,8 +21,13 @@ test("normalizeSystemInfo preserves legacy unwrapped info responses", () => {
backendType: "Erasure",
onlineDisks: 8,
offlineDisks: 1,
unknownDisks: 1,
},
servers: [{ endpoint: "node-a", state: "online" }],
servers: [
{ endpoint: "node-a", state: "online" },
{ endpoint: "node-b", state: "unknown" },
{ endpoint: "node-c", state: "degraded" },
],
})
assert.equal(info.buckets?.count, 2)
@@ -20,7 +35,10 @@ test("normalizeSystemInfo preserves legacy unwrapped info responses", () => {
assert.equal(info.backend?.backendType, "Erasure")
assert.equal(info.backend?.onlineDisks, 8)
assert.equal(info.backend?.offlineDisks, 1)
assert.equal(info.backend?.unknownDisks, 1)
assert.equal(info.servers?.[0]?.endpoint, "node-a")
assert.equal(info.servers?.[1]?.state, "unknown")
assert.equal(info.servers?.[2]?.state, "degraded")
})
test("normalizeSystemInfo unwraps RustFS admin discovery info responses", () => {
@@ -32,10 +50,12 @@ test("normalizeSystemInfo unwraps RustFS admin discovery info responses", () =>
backendType: "Erasure",
onlineDisks: 12,
offlineDisks: 0,
unknownDisks: 1,
},
servers: [
{ endpoint: "rustfs-node7", state: "online", drives: [{ state: "ok" }] },
{ endpoint: "10.0.0.9:19000", state: "offline", drives: [] },
{ endpoint: "rustfs-node8", state: "degraded", drives: [{ state: "ok" }] },
],
},
admin_discovery: {
@@ -49,7 +69,9 @@ test("normalizeSystemInfo unwraps RustFS admin discovery info responses", () =>
assert.equal(info.objects?.count, 2560)
assert.equal(info.backend?.onlineDisks, 12)
assert.equal(info.backend?.offlineDisks, 0)
assert.equal(info.servers?.length, 2)
assert.equal(info.backend?.unknownDisks, 1)
assert.equal(info.servers?.length, 3)
assert.equal(info.servers?.[2]?.state, "degraded")
})
test("normalizeStorageInfo unwraps RustFS admin discovery storage responses", () => {
@@ -0,0 +1,33 @@
import test from "node:test"
import assert from "node:assert/strict"
import fs from "node:fs"
test("status page passes unknown and degraded admin info states into infrastructure health", () => {
const source = fs.readFileSync("app/(dashboard)/status/page.tsx", "utf8")
assert.match(source, /normalizeServerHealthState\(server\.state\)/)
assert.match(source, /unknownServers=\{serverCounts\.unknown\}/)
assert.match(source, /degradedServers=\{serverCounts\.degraded\}/)
assert.match(source, /unknownDisks=\{systemInfo\?\.backend\?\.unknownDisks \?\? 0\}/)
})
test("performance infrastructure card renders unknown and degraded buckets", () => {
const source = fs.readFileSync("app/(dashboard)/_components/performance-infrastructure-card.tsx", "utf8")
assert.match(source, /unknownServers: number/)
assert.match(source, /degradedServers: number/)
assert.match(source, /unknownDisks: number/)
assert.match(source, /\{t\("Unknown"\)\}/)
assert.match(source, /\{t\("Degraded"\)\}/)
})
test("performance server list treats unknown and degraded as first-class filters", () => {
const source = fs.readFileSync("app/(dashboard)/_components/performance-server-list.tsx", "utf8")
assert.match(source, /const serverStates: ServerHealthState\[\] = \["online", "offline", "unknown", "degraded"\]/)
assert.match(source, /getServerState\(server\) === filterBy/)
assert.match(source, /status-unknown/)
assert.match(source, /status-degraded/)
assert.match(source, /getServerStateTone\(getServerState\(server\)\)/)
assert.doesNotMatch(source, /!isOnlineServer\(server\)/)
})