Skip to content

Commit 64809a3

Browse files
authored
fix(status): separate cluster diagnostic states (#177)
1 parent 3a6fa37 commit 64809a3

25 files changed

Lines changed: 1371 additions & 50 deletions

app/(dashboard)/_components/performance-server-list.tsx

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import { Badge } from "@/components/ui/badge"
66
import { Button } from "@/components/ui/button"
77
import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card"
88
import { Progress } from "@/components/ui/progress"
9-
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
9+
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
1010
import { niceBytes } from "@/lib/functions"
11-
import { normalizeServerHealthState, type ServerHealthState } from "@/lib/performance-data"
11+
import { resolveServerHealth, type ClusterDiagnostics, type ServerHealthState } from "@/lib/performance-data"
1212
import { cn } from "@/lib/utils"
1313
import type { ServerInfo } from "@/hooks/use-performance-data"
1414

@@ -114,7 +114,15 @@ function compareUptime(left: ServerInfo, right: ServerInfo, direction: "asc" | "
114114

115115
const filterOrder: ServerHealthState[] = ["offline", "degraded", "initializing", "unknown", "online"]
116116

117-
export function PerformanceServerList({ servers, t }: { servers?: ServerInfo[]; t: Translate }) {
117+
export function PerformanceServerList({
118+
servers,
119+
diagnostics,
120+
t,
121+
}: {
122+
servers?: ServerInfo[]
123+
diagnostics?: ClusterDiagnostics
124+
t: Translate
125+
}) {
118126
const [sortBy, setSortBy] = React.useState<PerformanceServerSort>("attention")
119127
const [filterBy, setFilterBy] = React.useState<PerformanceServerFilter>("all")
120128
const reportedServers = React.useMemo(() => servers ?? [], [servers])
@@ -127,14 +135,14 @@ export function PerformanceServerList({ servers, t }: { servers?: ServerInfo[];
127135
initializing: 0,
128136
unknown: 0,
129137
}
130-
for (const server of reportedServers) counts[normalizeServerHealthState(server.state)] += 1
138+
for (const server of reportedServers) counts[resolveServerHealth(server, diagnostics).state] += 1
131139
return counts
132-
}, [reportedServers])
140+
}, [diagnostics, reportedServers])
133141

134142
const visibleServers = React.useMemo(() => {
135143
const rows = reportedServers
136-
.map((server, originalIndex) => ({ server, originalIndex }))
137-
.filter(({ server }) => filterBy === "all" || normalizeServerHealthState(server.state) === filterBy)
144+
.map((server, originalIndex) => ({ server, originalIndex, health: resolveServerHealth(server, diagnostics) }))
145+
.filter(({ health }) => filterBy === "all" || health.state === filterBy)
138146

139147
return rows.sort((left, right) => {
140148
switch (sortBy) {
@@ -149,14 +157,13 @@ export function PerformanceServerList({ servers, t }: { servers?: ServerInfo[];
149157
case "attention":
150158
default:
151159
return (
152-
getStatePriority(normalizeServerHealthState(left.server.state)) -
153-
getStatePriority(normalizeServerHealthState(right.server.state)) ||
160+
getStatePriority(left.health.state) - getStatePriority(right.health.state) ||
154161
compareEndpoint(left.server, right.server, "asc") ||
155162
left.originalIndex - right.originalIndex
156163
)
157164
}
158165
})
159-
}, [filterBy, reportedServers, sortBy])
166+
}, [diagnostics, filterBy, reportedServers, sortBy])
160167

161168
const filters: PerformanceServerFilter[] = [
162169
"all",
@@ -228,11 +235,13 @@ export function PerformanceServerList({ servers, t }: { servers?: ServerInfo[];
228235
<SelectValue>{sortLabels[sortBy]}</SelectValue>
229236
</SelectTrigger>
230237
<SelectContent>
231-
{Object.entries(sortLabels).map(([value, label]) => (
232-
<SelectItem key={value} value={value} className="min-h-11">
233-
{label}
234-
</SelectItem>
235-
))}
238+
<SelectGroup>
239+
{Object.entries(sortLabels).map(([value, label]) => (
240+
<SelectItem key={value} value={value} className="min-h-11">
241+
{label}
242+
</SelectItem>
243+
))}
244+
</SelectGroup>
236245
</SelectContent>
237246
</Select>
238247
</div>
@@ -247,20 +256,27 @@ export function PerformanceServerList({ servers, t }: { servers?: ServerInfo[];
247256
</div>
248257
) : visibleServers.length ? (
249258
<Accordion id="performance-server-list" className="space-y-2" aria-labelledby="server-list-title">
250-
{visibleServers.map(({ server, originalIndex }) => {
251-
const state = normalizeServerHealthState(server.state)
259+
{visibleServers.map(({ server, originalIndex, health }) => {
260+
const state = health.state
252261
return (
253262
<AccordionItem
254263
key={server.endpoint ?? `server-${originalIndex}`}
255264
value={server.endpoint ?? `server-${originalIndex}`}
256265
>
257266
<AccordionTrigger className="min-h-11 py-3">
258267
<div className="grid min-w-0 flex-1 gap-2 pe-3 text-start lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
259-
<div className="flex min-w-0 items-center gap-2">
268+
<div className="flex min-w-0 items-start gap-2">
260269
<Badge variant={getStateVariant(state)}>{getStateLabel(state, t)}</Badge>
261-
<span className="min-w-0 break-words font-semibold [overflow-wrap:anywhere]">
262-
{server.endpoint ?? t("Unknown")}
263-
</span>
270+
<div className="flex min-w-0 flex-col gap-1">
271+
<span className="break-words font-semibold [overflow-wrap:anywhere]">
272+
{server.endpoint ?? t("Unknown")}
273+
</span>
274+
{health.reason ? (
275+
<span className="break-words text-xs font-normal text-muted-foreground [overflow-wrap:anywhere]">
276+
{health.reason}
277+
</span>
278+
) : null}
279+
</div>
264280
</div>
265281
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-muted-foreground sm:flex sm:flex-wrap">
266282
<span>
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"use client"
2+
3+
import * as React from "react"
4+
import { Badge } from "@/components/ui/badge"
5+
import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card"
6+
import { Separator } from "@/components/ui/separator"
7+
import type { ClusterDiagnostics, OperationalStatus, StatusDiagnostic } from "@/lib/performance-data"
8+
9+
type Translate = (key: string) => string
10+
11+
function getStatusLabel(state: OperationalStatus, t: Translate) {
12+
if (state === "healthy") return t("Healthy")
13+
if (state === "degraded") return t("Degraded")
14+
if (state === "stale") return t("Stale")
15+
if (state === "not_reported") return t("Not reported")
16+
return t("Unknown")
17+
}
18+
19+
function getStatusVariant(state: OperationalStatus): "secondary" | "destructive" | "default" | "outline" | "ghost" {
20+
if (state === "healthy") return "secondary"
21+
if (state === "degraded") return "destructive"
22+
if (state === "stale") return "default"
23+
if (state === "not_reported") return "ghost"
24+
return "outline"
25+
}
26+
27+
function getDefaultDescription(state: OperationalStatus, t: Translate) {
28+
if (state === "healthy") return t("No issue was reported by this source.")
29+
if (state === "degraded") return t("This status source requires attention.")
30+
if (state === "stale") return t("Previously reported data may be out of date.")
31+
if (state === "not_reported") return t("This status source was not reported by the server.")
32+
return t("The server reported this status source with an unknown condition.")
33+
}
34+
35+
function formatTimestamp(value: string, locale: string | undefined) {
36+
const timestamp = Date.parse(value)
37+
if (!Number.isFinite(timestamp)) return value
38+
return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "medium" }).format(timestamp)
39+
}
40+
41+
function DiagnosticRow({
42+
label,
43+
diagnostic,
44+
troubleshooting,
45+
troubleshootingHref,
46+
showLastSuccessfulUpdate,
47+
t,
48+
locale,
49+
}: {
50+
label: string
51+
diagnostic: StatusDiagnostic
52+
troubleshooting?: string
53+
troubleshootingHref?: string
54+
showLastSuccessfulUpdate?: boolean
55+
t: Translate
56+
locale?: string
57+
}) {
58+
const scope = diagnostic.scope
59+
const scopeParts = [
60+
scope?.bucket ? `${t("Bucket")}: ${scope.bucket}` : undefined,
61+
scope?.prefix ? `${t("Prefix")}: ${scope.prefix}` : undefined,
62+
scope?.set ? `${t("Set")}: ${scope.set}` : undefined,
63+
scope?.timeout ? `${t("Timeout")}: ${scope.timeout}` : undefined,
64+
].filter(Boolean)
65+
66+
return (
67+
<div className="grid gap-2 py-4 sm:grid-cols-[minmax(0,12rem)_minmax(0,1fr)] sm:gap-6">
68+
<dt className="flex min-w-0 items-center justify-between gap-3 sm:flex-col sm:items-start sm:justify-start">
69+
<span className="font-medium text-foreground">{label}</span>
70+
<Badge variant={getStatusVariant(diagnostic.state)}>{getStatusLabel(diagnostic.state, t)}</Badge>
71+
</dt>
72+
<dd className="flex min-w-0 flex-col gap-2 text-muted-foreground">
73+
<p className="break-words text-foreground [overflow-wrap:anywhere]">
74+
{diagnostic.reason ?? getDefaultDescription(diagnostic.state, t)}
75+
</p>
76+
{diagnostic.lastSuccessfulUpdate || showLastSuccessfulUpdate ? (
77+
<p className="text-xs">
78+
{t("Last successful update")}:{" "}
79+
{diagnostic.lastSuccessfulUpdate ? formatTimestamp(diagnostic.lastSuccessfulUpdate, locale) : t("Unknown")}
80+
</p>
81+
) : null}
82+
{diagnostic.lastError ? (
83+
<p className="break-words text-xs [overflow-wrap:anywhere]">
84+
{t("Last error")}: {diagnostic.lastError}
85+
</p>
86+
) : null}
87+
{scopeParts.length ? (
88+
<p className="break-words text-xs [overflow-wrap:anywhere]">{scopeParts.join(" · ")}</p>
89+
) : null}
90+
{diagnostic.source ? (
91+
<p className="break-words text-xs [overflow-wrap:anywhere]">
92+
{t("Source")}: {diagnostic.source}
93+
</p>
94+
) : null}
95+
{diagnostic.historicalStallTimeouts !== undefined ? (
96+
<div className="space-y-1 text-xs">
97+
<p className="text-foreground">
98+
{t("Historical internode stall timeouts")}: {diagnostic.historicalStallTimeouts}
99+
</p>
100+
<p>
101+
{t("This lifetime counter has no sampling window and does not indicate current degradation by itself.")}
102+
</p>
103+
</div>
104+
) : null}
105+
{diagnostic.hint ? (
106+
<p className="break-words text-xs [overflow-wrap:anywhere]">
107+
{t("Backend guidance")}: {diagnostic.hint}
108+
</p>
109+
) : null}
110+
{troubleshooting ? (
111+
<p className="text-xs text-foreground">
112+
{t("Troubleshooting")}: {troubleshooting}
113+
</p>
114+
) : null}
115+
{troubleshootingHref ? (
116+
<a
117+
className="w-fit text-xs font-medium text-foreground underline underline-offset-4"
118+
href={troubleshootingHref}
119+
target="_blank"
120+
rel="noreferrer"
121+
>
122+
{t("Open the real multi-node metrics verification guide")}
123+
</a>
124+
) : null}
125+
</dd>
126+
</div>
127+
)
128+
}
129+
130+
export function PerformanceStatusSources({
131+
diagnostics,
132+
usageFreshness,
133+
t,
134+
locale,
135+
}: {
136+
diagnostics?: ClusterDiagnostics
137+
usageFreshness?: StatusDiagnostic
138+
t: Translate
139+
locale?: string
140+
}) {
141+
const notReported: StatusDiagnostic = { state: "not_reported" }
142+
const peerHealth = diagnostics?.peerHealth ?? notReported
143+
const storageReadiness = diagnostics?.storageReadiness ?? notReported
144+
const resolvedUsageFreshness = usageFreshness ?? diagnostics?.usageFreshness ?? notReported
145+
const listingHealth = diagnostics?.listingHealth ?? notReported
146+
const workloadAdmission = diagnostics?.workloadAdmission ?? notReported
147+
const rows = [
148+
{ label: t("Peer Health"), diagnostic: peerHealth },
149+
{ label: t("Storage Readiness"), diagnostic: storageReadiness },
150+
{ label: t("Usage Freshness"), diagnostic: resolvedUsageFreshness, showLastSuccessfulUpdate: true },
151+
{
152+
label: t("Listing and Metacache"),
153+
diagnostic: listingHealth,
154+
troubleshooting: t(
155+
"Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.",
156+
),
157+
troubleshootingHref: "https://github.com/rustfs/backlog/issues/1392#issuecomment-5040442761",
158+
},
159+
{ label: t("Workload Admission"), diagnostic: workloadAdmission },
160+
]
161+
162+
return (
163+
<Card className="shadow-none">
164+
<CardHeader>
165+
<h2 id="status-sources-title" className="text-base font-semibold">
166+
{t("Status Sources")}
167+
</h2>
168+
<CardDescription>
169+
{t("Review peer, storage, usage, listing, and workload admission health independently.")}
170+
</CardDescription>
171+
</CardHeader>
172+
<CardContent>
173+
<dl aria-labelledby="status-sources-title">
174+
{rows.map((row, index) => (
175+
<React.Fragment key={row.label}>
176+
{index ? <Separator /> : null}
177+
<DiagnosticRow
178+
label={row.label}
179+
diagnostic={row.diagnostic}
180+
troubleshooting={row.troubleshooting}
181+
troubleshootingHref={row.troubleshootingHref}
182+
showLastSuccessfulUpdate={row.showLastSuccessfulUpdate}
183+
t={t}
184+
locale={locale}
185+
/>
186+
</React.Fragment>
187+
))}
188+
</dl>
189+
</CardContent>
190+
</Card>
191+
)
192+
}

app/(dashboard)/status/page.tsx

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"
66
import { Spinner } from "@/components/ui/spinner"
77
import { usePerformanceData, type PerformanceDataSource } from "@/hooks/use-performance-data"
88
import { usePermissions } from "@/hooks/use-permissions"
9-
import { formatRelativeTime, summarizeServerStates } from "@/lib/performance-data"
9+
import { formatRelativeTime, resolveUsageFreshness, summarizeServerStates } from "@/lib/performance-data"
1010
import { cn } from "@/lib/utils"
1111
import {
1212
RiArchiveDrawerFill,
@@ -23,6 +23,7 @@ import { PerformanceBackendCard } from "../_components/performance-backend-card"
2323
import { PerformanceInfrastructureCard } from "../_components/performance-infrastructure-card"
2424
import { PerformanceServerList } from "../_components/performance-server-list"
2525
import { PerformanceSummaryCards } from "../_components/performance-summary-cards"
26+
import { PerformanceStatusSources } from "../_components/performance-status-sources"
2627
import { PerformanceUsageCard } from "../_components/performance-usage-card"
2728

2829
function formatDuration(seconds: number | undefined, t: (key: string) => string) {
@@ -47,13 +48,15 @@ export default function PerformancePage() {
4748
metricsInfo,
4849
datausageinfo,
4950
storageinfo,
51+
diagnosticsInfo,
5052
loading,
5153
refreshing,
5254
hasLoaded,
5355
error,
5456
sourceErrors,
5557
lastUpdatedAt,
5658
metricsUpdatedAt,
59+
usageUpdatedAt,
5760
refetch,
5861
} = usePerformanceData()
5962
const browserHref = canAccessPath("/browser") ? "/browser" : undefined
@@ -128,8 +131,21 @@ export default function PerformancePage() {
128131
)
129132

130133
const serverSummary = useMemo(
131-
() => (systemInfo.servers ? summarizeServerStates(systemInfo.servers) : undefined),
132-
[systemInfo.servers],
134+
() => (systemInfo.servers ? summarizeServerStates(systemInfo.servers, diagnosticsInfo) : undefined),
135+
[diagnosticsInfo, systemInfo.servers],
136+
)
137+
138+
const usageFreshness = useMemo(
139+
() =>
140+
resolveUsageFreshness(diagnosticsInfo?.usageFreshness, {
141+
hasData:
142+
datausageinfo.total_capacity !== undefined ||
143+
datausageinfo.total_free_capacity !== undefined ||
144+
datausageinfo.total_used_capacity !== undefined,
145+
error: sourceErrors.usage,
146+
lastUpdatedAt: usageUpdatedAt,
147+
}),
148+
[datausageinfo, diagnosticsInfo?.usageFreshness, sourceErrors.usage, usageUpdatedAt],
133149
)
134150

135151
const backendInfo = useMemo(
@@ -159,6 +175,7 @@ export default function PerformancePage() {
159175
usage: t("Storage Usage Statistics"),
160176
storage: t("Storage Configuration"),
161177
metrics: t("Scanner metrics"),
178+
diagnostics: t("Cluster diagnostics"),
162179
}
163180

164181
const refreshAction = (
@@ -240,6 +257,13 @@ export default function PerformancePage() {
240257
</div>
241258
) : null}
242259

260+
<PerformanceStatusSources
261+
diagnostics={diagnosticsInfo}
262+
usageFreshness={diagnosticsInfo ? usageFreshness : undefined}
263+
t={t}
264+
locale={i18n.resolvedLanguage}
265+
/>
266+
243267
<div className="grid gap-4 xl:grid-cols-2">
244268
<div className="order-1">
245269
<PerformanceInfrastructureCard
@@ -267,7 +291,7 @@ export default function PerformancePage() {
267291
</div>
268292

269293
<div className="order-2 xl:order-3 xl:col-span-2">
270-
<PerformanceServerList servers={systemInfo.servers} t={t} />
294+
<PerformanceServerList servers={systemInfo.servers} diagnostics={diagnosticsInfo} t={t} />
271295
</div>
272296
</div>
273297

0 commit comments

Comments
 (0)