diff --git a/app/(dashboard)/_components/performance-server-list.tsx b/app/(dashboard)/_components/performance-server-list.tsx index 85da5e0..8de9868 100644 --- a/app/(dashboard)/_components/performance-server-list.tsx +++ b/app/(dashboard)/_components/performance-server-list.tsx @@ -6,9 +6,9 @@ import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card" import { Progress } from "@/components/ui/progress" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { niceBytes } from "@/lib/functions" -import { normalizeServerHealthState, type ServerHealthState } from "@/lib/performance-data" +import { resolveServerHealth, type ClusterDiagnostics, type ServerHealthState } from "@/lib/performance-data" import { cn } from "@/lib/utils" import type { ServerInfo } from "@/hooks/use-performance-data" @@ -114,7 +114,15 @@ function compareUptime(left: ServerInfo, right: ServerInfo, direction: "asc" | " const filterOrder: ServerHealthState[] = ["offline", "degraded", "initializing", "unknown", "online"] -export function PerformanceServerList({ servers, t }: { servers?: ServerInfo[]; t: Translate }) { +export function PerformanceServerList({ + servers, + diagnostics, + t, +}: { + servers?: ServerInfo[] + diagnostics?: ClusterDiagnostics + t: Translate +}) { const [sortBy, setSortBy] = React.useState("attention") const [filterBy, setFilterBy] = React.useState("all") const reportedServers = React.useMemo(() => servers ?? [], [servers]) @@ -127,14 +135,14 @@ export function PerformanceServerList({ servers, t }: { servers?: ServerInfo[]; initializing: 0, unknown: 0, } - for (const server of reportedServers) counts[normalizeServerHealthState(server.state)] += 1 + for (const server of reportedServers) counts[resolveServerHealth(server, diagnostics).state] += 1 return counts - }, [reportedServers]) + }, [diagnostics, reportedServers]) const visibleServers = React.useMemo(() => { const rows = reportedServers - .map((server, originalIndex) => ({ server, originalIndex })) - .filter(({ server }) => filterBy === "all" || normalizeServerHealthState(server.state) === filterBy) + .map((server, originalIndex) => ({ server, originalIndex, health: resolveServerHealth(server, diagnostics) })) + .filter(({ health }) => filterBy === "all" || health.state === filterBy) return rows.sort((left, right) => { switch (sortBy) { @@ -149,14 +157,13 @@ export function PerformanceServerList({ servers, t }: { servers?: ServerInfo[]; case "attention": default: return ( - getStatePriority(normalizeServerHealthState(left.server.state)) - - getStatePriority(normalizeServerHealthState(right.server.state)) || + getStatePriority(left.health.state) - getStatePriority(right.health.state) || compareEndpoint(left.server, right.server, "asc") || left.originalIndex - right.originalIndex ) } }) - }, [filterBy, reportedServers, sortBy]) + }, [diagnostics, filterBy, reportedServers, sortBy]) const filters: PerformanceServerFilter[] = [ "all", @@ -228,11 +235,13 @@ export function PerformanceServerList({ servers, t }: { servers?: ServerInfo[]; {sortLabels[sortBy]} - {Object.entries(sortLabels).map(([value, label]) => ( - - {label} - - ))} + + {Object.entries(sortLabels).map(([value, label]) => ( + + {label} + + ))} + @@ -247,8 +256,8 @@ export function PerformanceServerList({ servers, t }: { servers?: ServerInfo[]; ) : visibleServers.length ? ( - {visibleServers.map(({ server, originalIndex }) => { - const state = normalizeServerHealthState(server.state) + {visibleServers.map(({ server, originalIndex, health }) => { + const state = health.state return (
-
+
{getStateLabel(state, t)} - - {server.endpoint ?? t("Unknown")} - +
+ + {server.endpoint ?? t("Unknown")} + + {health.reason ? ( + + {health.reason} + + ) : null} +
diff --git a/app/(dashboard)/_components/performance-status-sources.tsx b/app/(dashboard)/_components/performance-status-sources.tsx new file mode 100644 index 0000000..5d41a44 --- /dev/null +++ b/app/(dashboard)/_components/performance-status-sources.tsx @@ -0,0 +1,192 @@ +"use client" + +import * as React from "react" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card" +import { Separator } from "@/components/ui/separator" +import type { ClusterDiagnostics, OperationalStatus, StatusDiagnostic } from "@/lib/performance-data" + +type Translate = (key: string) => string + +function getStatusLabel(state: OperationalStatus, t: Translate) { + if (state === "healthy") return t("Healthy") + if (state === "degraded") return t("Degraded") + if (state === "stale") return t("Stale") + if (state === "not_reported") return t("Not reported") + return t("Unknown") +} + +function getStatusVariant(state: OperationalStatus): "secondary" | "destructive" | "default" | "outline" | "ghost" { + if (state === "healthy") return "secondary" + if (state === "degraded") return "destructive" + if (state === "stale") return "default" + if (state === "not_reported") return "ghost" + return "outline" +} + +function getDefaultDescription(state: OperationalStatus, t: Translate) { + if (state === "healthy") return t("No issue was reported by this source.") + if (state === "degraded") return t("This status source requires attention.") + if (state === "stale") return t("Previously reported data may be out of date.") + if (state === "not_reported") return t("This status source was not reported by the server.") + return t("The server reported this status source with an unknown condition.") +} + +function formatTimestamp(value: string, locale: string | undefined) { + const timestamp = Date.parse(value) + if (!Number.isFinite(timestamp)) return value + return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "medium" }).format(timestamp) +} + +function DiagnosticRow({ + label, + diagnostic, + troubleshooting, + troubleshootingHref, + showLastSuccessfulUpdate, + t, + locale, +}: { + label: string + diagnostic: StatusDiagnostic + troubleshooting?: string + troubleshootingHref?: string + showLastSuccessfulUpdate?: boolean + t: Translate + locale?: string +}) { + const scope = diagnostic.scope + const scopeParts = [ + scope?.bucket ? `${t("Bucket")}: ${scope.bucket}` : undefined, + scope?.prefix ? `${t("Prefix")}: ${scope.prefix}` : undefined, + scope?.set ? `${t("Set")}: ${scope.set}` : undefined, + scope?.timeout ? `${t("Timeout")}: ${scope.timeout}` : undefined, + ].filter(Boolean) + + return ( +
+
+ {label} + {getStatusLabel(diagnostic.state, t)} +
+
+

+ {diagnostic.reason ?? getDefaultDescription(diagnostic.state, t)} +

+ {diagnostic.lastSuccessfulUpdate || showLastSuccessfulUpdate ? ( +

+ {t("Last successful update")}:{" "} + {diagnostic.lastSuccessfulUpdate ? formatTimestamp(diagnostic.lastSuccessfulUpdate, locale) : t("Unknown")} +

+ ) : null} + {diagnostic.lastError ? ( +

+ {t("Last error")}: {diagnostic.lastError} +

+ ) : null} + {scopeParts.length ? ( +

{scopeParts.join(" · ")}

+ ) : null} + {diagnostic.source ? ( +

+ {t("Source")}: {diagnostic.source} +

+ ) : null} + {diagnostic.historicalStallTimeouts !== undefined ? ( +
+

+ {t("Historical internode stall timeouts")}: {diagnostic.historicalStallTimeouts} +

+

+ {t("This lifetime counter has no sampling window and does not indicate current degradation by itself.")} +

+
+ ) : null} + {diagnostic.hint ? ( +

+ {t("Backend guidance")}: {diagnostic.hint} +

+ ) : null} + {troubleshooting ? ( +

+ {t("Troubleshooting")}: {troubleshooting} +

+ ) : null} + {troubleshootingHref ? ( + + {t("Open the real multi-node metrics verification guide")} + + ) : null} +
+
+ ) +} + +export function PerformanceStatusSources({ + diagnostics, + usageFreshness, + t, + locale, +}: { + diagnostics?: ClusterDiagnostics + usageFreshness?: StatusDiagnostic + t: Translate + locale?: string +}) { + const notReported: StatusDiagnostic = { state: "not_reported" } + const peerHealth = diagnostics?.peerHealth ?? notReported + const storageReadiness = diagnostics?.storageReadiness ?? notReported + const resolvedUsageFreshness = usageFreshness ?? diagnostics?.usageFreshness ?? notReported + const listingHealth = diagnostics?.listingHealth ?? notReported + const workloadAdmission = diagnostics?.workloadAdmission ?? notReported + const rows = [ + { label: t("Peer Health"), diagnostic: peerHealth }, + { label: t("Storage Readiness"), diagnostic: storageReadiness }, + { label: t("Usage Freshness"), diagnostic: resolvedUsageFreshness, showLastSuccessfulUpdate: true }, + { + label: t("Listing and Metacache"), + diagnostic: listingHealth, + troubleshooting: t( + "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.", + ), + troubleshootingHref: "https://github.com/rustfs/backlog/issues/1392#issuecomment-5040442761", + }, + { label: t("Workload Admission"), diagnostic: workloadAdmission }, + ] + + return ( + + +

+ {t("Status Sources")} +

+ + {t("Review peer, storage, usage, listing, and workload admission health independently.")} + +
+ +
+ {rows.map((row, index) => ( + + {index ? : null} + + + ))} +
+
+
+ ) +} diff --git a/app/(dashboard)/status/page.tsx b/app/(dashboard)/status/page.tsx index a838cf5..bca1dd6 100644 --- a/app/(dashboard)/status/page.tsx +++ b/app/(dashboard)/status/page.tsx @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button" import { Spinner } from "@/components/ui/spinner" import { usePerformanceData, type PerformanceDataSource } from "@/hooks/use-performance-data" import { usePermissions } from "@/hooks/use-permissions" -import { formatRelativeTime, summarizeServerStates } from "@/lib/performance-data" +import { formatRelativeTime, resolveUsageFreshness, summarizeServerStates } from "@/lib/performance-data" import { cn } from "@/lib/utils" import { RiArchiveDrawerFill, @@ -23,6 +23,7 @@ import { PerformanceBackendCard } from "../_components/performance-backend-card" import { PerformanceInfrastructureCard } from "../_components/performance-infrastructure-card" import { PerformanceServerList } from "../_components/performance-server-list" import { PerformanceSummaryCards } from "../_components/performance-summary-cards" +import { PerformanceStatusSources } from "../_components/performance-status-sources" import { PerformanceUsageCard } from "../_components/performance-usage-card" function formatDuration(seconds: number | undefined, t: (key: string) => string) { @@ -47,6 +48,7 @@ export default function PerformancePage() { metricsInfo, datausageinfo, storageinfo, + diagnosticsInfo, loading, refreshing, hasLoaded, @@ -54,6 +56,7 @@ export default function PerformancePage() { sourceErrors, lastUpdatedAt, metricsUpdatedAt, + usageUpdatedAt, refetch, } = usePerformanceData() const browserHref = canAccessPath("/browser") ? "/browser" : undefined @@ -128,8 +131,21 @@ export default function PerformancePage() { ) const serverSummary = useMemo( - () => (systemInfo.servers ? summarizeServerStates(systemInfo.servers) : undefined), - [systemInfo.servers], + () => (systemInfo.servers ? summarizeServerStates(systemInfo.servers, diagnosticsInfo) : undefined), + [diagnosticsInfo, systemInfo.servers], + ) + + const usageFreshness = useMemo( + () => + resolveUsageFreshness(diagnosticsInfo?.usageFreshness, { + hasData: + datausageinfo.total_capacity !== undefined || + datausageinfo.total_free_capacity !== undefined || + datausageinfo.total_used_capacity !== undefined, + error: sourceErrors.usage, + lastUpdatedAt: usageUpdatedAt, + }), + [datausageinfo, diagnosticsInfo?.usageFreshness, sourceErrors.usage, usageUpdatedAt], ) const backendInfo = useMemo( @@ -159,6 +175,7 @@ export default function PerformancePage() { usage: t("Storage Usage Statistics"), storage: t("Storage Configuration"), metrics: t("Scanner metrics"), + diagnostics: t("Cluster diagnostics"), } const refreshAction = ( @@ -240,6 +257,13 @@ export default function PerformancePage() {
) : null} + +
- +
diff --git a/hooks/use-performance-data.ts b/hooks/use-performance-data.ts index 35e44a4..a8de0c5 100644 --- a/hooks/use-performance-data.ts +++ b/hooks/use-performance-data.ts @@ -5,19 +5,28 @@ import { useCallback, useEffect, useState } from "react" import { useSystem } from "@/hooks/use-system" import { scheduleMicrotask } from "@/lib/schedule-microtask" import { + normalizeClusterDiagnostics, normalizeDataUsageInfo, normalizeMetricsInfo, normalizeStorageInfo, normalizeSystemInfo, + type ClusterDiagnostics, type DataUsageInfo, type MetricsInfo, type StorageInfo, type SystemInfo, } from "@/lib/performance-data" -export type { DataUsageInfo, MetricsInfo, ServerInfo, StorageInfo, SystemInfo } from "@/lib/performance-data" +export type { + ClusterDiagnostics, + DataUsageInfo, + MetricsInfo, + ServerInfo, + StorageInfo, + SystemInfo, +} from "@/lib/performance-data" -export type PerformanceDataSource = "system" | "usage" | "storage" | "metrics" +export type PerformanceDataSource = "system" | "usage" | "storage" | "metrics" | "diagnostics" export type PerformanceSourceErrors = Partial> function getErrorMessage(error: unknown, fallback: string) { @@ -45,23 +54,65 @@ function hasMetricsSnapshot(value: MetricsInfo) { } export function usePerformanceData() { - const { getSystemInfo, getDataUsageInfo, getStorageInfo, getSystemMetrics } = useSystem() + const { getSystemInfo, getDataUsageInfo, getStorageInfo, getSystemMetrics, getClusterSnapshot } = useSystem() const [metricsInfo, setMetricsInfo] = useState({}) const [systemInfo, setSystemInfo] = useState({}) const [datausageinfo, setDatausageinfo] = useState({}) const [storageinfo, setStorageinfo] = useState({}) + const [diagnosticsInfo, setDiagnosticsInfo] = useState() const [hasLoaded, setHasLoaded] = useState(false) const [refreshing, setRefreshing] = useState(true) const [error, setError] = useState(null) const [sourceErrors, setSourceErrors] = useState({}) + const [diagnosticsError, setDiagnosticsError] = useState(null) const [lastUpdatedAt, setLastUpdatedAt] = useState(null) const [metricsUpdatedAt, setMetricsUpdatedAt] = useState(null) + const [usageUpdatedAt, setUsageUpdatedAt] = useState(null) const mountedRef = React.useRef(false) const refetchingRef = React.useRef(false) const requestVersionRef = React.useRef(0) const hasSystemDataRef = React.useRef(false) const abortControllerRef = React.useRef(null) + const diagnosticsAbortControllerRef = React.useRef(null) + + const refreshDiagnostics = useCallback( + (path: string, requestVersion: number) => { + diagnosticsAbortControllerRef.current?.abort() + const controller = new AbortController() + let timedOut = false + const timeout = window.setTimeout(() => { + timedOut = true + controller.abort() + }, 5_000) + diagnosticsAbortControllerRef.current = controller + setDiagnosticsError(null) + + void (async () => { + try { + const diagnostics = normalizeClusterDiagnostics(await getClusterSnapshot(path, controller.signal)) + if (!mountedRef.current || requestVersion !== requestVersionRef.current) return + if (diagnostics) { + setDiagnosticsInfo(diagnostics) + setDiagnosticsError(null) + } else { + setDiagnosticsError("Cluster diagnostics are unavailable.") + } + } catch (diagnosticsRequestError) { + if (!mountedRef.current || requestVersion !== requestVersionRef.current) return + setDiagnosticsError( + timedOut ? "Cluster diagnostics timed out." : getErrorMessage(diagnosticsRequestError, "Get Data Failed"), + ) + } finally { + window.clearTimeout(timeout) + if (diagnosticsAbortControllerRef.current === controller) { + diagnosticsAbortControllerRef.current = null + } + } + })() + }, + [getClusterSnapshot], + ) const refetch = useCallback(async () => { if (!mountedRef.current || refetchingRef.current) return @@ -83,6 +134,7 @@ export function usePerformanceData() { const nextSourceErrors: PerformanceSourceErrors = {} let systemRefreshed = false let optionalSourceRefreshed = false + let diagnosticsPath: string | undefined if (systemResult.status === "fulfilled") { const normalized = normalizeSystemInfo(systemResult.value) @@ -90,6 +142,7 @@ export function usePerformanceData() { setSystemInfo(normalized) hasSystemDataRef.current = true systemRefreshed = true + diagnosticsPath = normalized.adminDiscovery?.clusterSnapshot } else { nextSourceErrors.system = "Get Data Failed" } @@ -101,6 +154,7 @@ export function usePerformanceData() { const normalized = normalizeDataUsageInfo(usageResult.value) if (hasUsageSnapshot(normalized)) { setDatausageinfo(normalized) + setUsageUpdatedAt(new Date()) optionalSourceRefreshed = true } else { nextSourceErrors.usage = "Get Data Failed" @@ -144,6 +198,14 @@ export function usePerformanceData() { : null, ) if (systemRefreshed) setLastUpdatedAt(new Date()) + if (systemRefreshed && diagnosticsPath) { + refreshDiagnostics(diagnosticsPath, requestVersion) + } else if (systemRefreshed) { + diagnosticsAbortControllerRef.current?.abort() + diagnosticsAbortControllerRef.current = null + setDiagnosticsInfo(undefined) + setDiagnosticsError(null) + } } finally { window.clearTimeout(timeout) if (abortControllerRef.current === controller) abortControllerRef.current = null @@ -155,7 +217,7 @@ export function usePerformanceData() { } } } - }, [getDataUsageInfo, getStorageInfo, getSystemInfo, getSystemMetrics]) + }, [getDataUsageInfo, getStorageInfo, getSystemInfo, getSystemMetrics, refreshDiagnostics]) useEffect(() => { let cancelled = false @@ -165,12 +227,15 @@ export function usePerformanceData() { setMetricsInfo({}) setDatausageinfo({}) setStorageinfo({}) + setDiagnosticsInfo(undefined) setHasLoaded(false) setRefreshing(true) setError(null) setSourceErrors({}) + setDiagnosticsError(null) setLastUpdatedAt(null) setMetricsUpdatedAt(null) + setUsageUpdatedAt(null) scheduleMicrotask(() => { if (!cancelled) void refetch() }) @@ -190,6 +255,8 @@ export function usePerformanceData() { requestVersionRef.current += 1 abortControllerRef.current?.abort() abortControllerRef.current = null + diagnosticsAbortControllerRef.current?.abort() + diagnosticsAbortControllerRef.current = null refetchingRef.current = false window.clearInterval(interval) document.removeEventListener("visibilitychange", handleVisibilityChange) @@ -201,13 +268,15 @@ export function usePerformanceData() { metricsInfo, datausageinfo, storageinfo, + diagnosticsInfo, loading: refreshing && !hasLoaded, refreshing, hasLoaded, error, - sourceErrors, + sourceErrors: diagnosticsError ? { ...sourceErrors, diagnostics: diagnosticsError } : sourceErrors, lastUpdatedAt, metricsUpdatedAt, + usageUpdatedAt, refetch, } } diff --git a/hooks/use-system.ts b/hooks/use-system.ts index 0f9e07d..505320a 100644 --- a/hooks/use-system.ts +++ b/hooks/use-system.ts @@ -60,6 +60,20 @@ export function useSystem() { [api], ) + const getClusterSnapshot = useCallback( + async (path: string, signal?: AbortSignal) => { + if (!path.startsWith("/") || path.startsWith("//")) { + throw new Error("Invalid cluster snapshot path") + } + return api.get(api.resolveUrl(path), { + suppress403Redirect: true, + signal, + dedupe: signal ? false : undefined, + }) + }, + [api], + ) + const getLicense = useCallback(async () => { return api.get("/license") }, [api]) @@ -69,6 +83,7 @@ export function useSystem() { getStorageInfo, getDataUsageInfo, getSystemMetrics, + getClusterSnapshot, getLicense, } } diff --git a/i18n/locales/ar-MA.json b/i18n/locales/ar-MA.json index 9426ae5..f7bcdaa 100644 --- a/i18n/locales/ar-MA.json +++ b/i18n/locales/ar-MA.json @@ -1519,5 +1519,31 @@ "Go to bottom": "Go to bottom", "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "يؤدي انتهاء صلاحية الإصدار الحالي إلى إنشاء علامة حذف. قم بتكوين انتهاء صلاحية الإصدارات غير الحالية لإزالة بيانات الكائن نهائيًا.", "2 lifecycle rules will be created.": "سيتم إنشاء قاعدتين لدورة الحياة.", - "Lifecycle configuration cannot contain more than 1000 rules.": "لا يمكن أن يحتوي تكوين دورة الحياة على أكثر من 1000 قاعدة." + "Lifecycle configuration cannot contain more than 1000 rules.": "لا يمكن أن يحتوي تكوين دورة الحياة على أكثر من 1000 قاعدة.", + "Cluster diagnostics": "Cluster diagnostics", + "Last error": "Last error", + "Last successful update": "Last successful update", + "Listing and Metacache": "Listing and Metacache", + "No issue was reported by this source.": "No issue was reported by this source.", + "Peer Health": "Peer Health", + "Previously reported data may be out of date.": "Previously reported data may be out of date.", + "Review peer, storage, usage, and listing health independently.": "Review peer, storage, usage, and listing health independently.", + "Set": "Set", + "Stale": "Stale", + "Status Sources": "Status Sources", + "Storage Readiness": "Storage Readiness", + "This status source requires attention.": "This status source requires attention.", + "This status source was not reported by the server.": "This status source was not reported by the server.", + "Usage Freshness": "Usage Freshness", + "Troubleshooting": "Troubleshooting", + "Check listing timeout and storage latency before treating this as a disk failure.": "Check listing timeout and storage latency before treating this as a disk failure.", + "Not reported": "Not reported", + "Workload Admission": "Workload Admission", + "The server reported this status source with an unknown condition.": "The server reported this status source with an unknown condition.", + "Historical internode stall timeouts": "Historical internode stall timeouts", + "This lifetime counter has no sampling window and does not indicate current degradation by itself.": "This lifetime counter has no sampling window and does not indicate current degradation by itself.", + "Backend guidance": "Backend guidance", + "Open the real multi-node metrics verification guide": "Open the real multi-node metrics verification guide", + "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.": "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.", + "Review peer, storage, usage, listing, and workload admission health independently.": "Review peer, storage, usage, listing, and workload admission health independently." } diff --git a/i18n/locales/de-DE.json b/i18n/locales/de-DE.json index 7c699ca..77d209f 100644 --- a/i18n/locales/de-DE.json +++ b/i18n/locales/de-DE.json @@ -1519,5 +1519,31 @@ "Go to bottom": "Go to bottom", "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "Beim Ablauf der aktuellen Version wird eine Löschmarkierung erstellt. Konfigurieren Sie den Ablauf nicht aktueller Versionen, um Objektdaten dauerhaft zu entfernen.", "2 lifecycle rules will be created.": "Es werden 2 Lebenszyklusregeln erstellt.", - "Lifecycle configuration cannot contain more than 1000 rules.": "Die Lebenszykluskonfiguration darf nicht mehr als 1000 Regeln enthalten." + "Lifecycle configuration cannot contain more than 1000 rules.": "Die Lebenszykluskonfiguration darf nicht mehr als 1000 Regeln enthalten.", + "Cluster diagnostics": "Cluster diagnostics", + "Last error": "Last error", + "Last successful update": "Last successful update", + "Listing and Metacache": "Listing and Metacache", + "No issue was reported by this source.": "No issue was reported by this source.", + "Peer Health": "Peer Health", + "Previously reported data may be out of date.": "Previously reported data may be out of date.", + "Review peer, storage, usage, and listing health independently.": "Review peer, storage, usage, and listing health independently.", + "Set": "Set", + "Stale": "Stale", + "Status Sources": "Status Sources", + "Storage Readiness": "Storage Readiness", + "This status source requires attention.": "This status source requires attention.", + "This status source was not reported by the server.": "This status source was not reported by the server.", + "Usage Freshness": "Usage Freshness", + "Troubleshooting": "Troubleshooting", + "Check listing timeout and storage latency before treating this as a disk failure.": "Check listing timeout and storage latency before treating this as a disk failure.", + "Not reported": "Not reported", + "Workload Admission": "Workload Admission", + "The server reported this status source with an unknown condition.": "The server reported this status source with an unknown condition.", + "Historical internode stall timeouts": "Historical internode stall timeouts", + "This lifetime counter has no sampling window and does not indicate current degradation by itself.": "This lifetime counter has no sampling window and does not indicate current degradation by itself.", + "Backend guidance": "Backend guidance", + "Open the real multi-node metrics verification guide": "Open the real multi-node metrics verification guide", + "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.": "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.", + "Review peer, storage, usage, listing, and workload admission health independently.": "Review peer, storage, usage, listing, and workload admission health independently." } diff --git a/i18n/locales/en-US.json b/i18n/locales/en-US.json index 9617f75..5a93f5d 100644 --- a/i18n/locales/en-US.json +++ b/i18n/locales/en-US.json @@ -1519,5 +1519,31 @@ "Go to bottom": "Go to bottom", "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.", "2 lifecycle rules will be created.": "2 lifecycle rules will be created.", - "Lifecycle configuration cannot contain more than 1000 rules.": "Lifecycle configuration cannot contain more than 1000 rules." + "Lifecycle configuration cannot contain more than 1000 rules.": "Lifecycle configuration cannot contain more than 1000 rules.", + "Cluster diagnostics": "Cluster diagnostics", + "Last error": "Last error", + "Last successful update": "Last successful update", + "Listing and Metacache": "Listing and Metacache", + "No issue was reported by this source.": "No issue was reported by this source.", + "Peer Health": "Peer Health", + "Previously reported data may be out of date.": "Previously reported data may be out of date.", + "Review peer, storage, usage, and listing health independently.": "Review peer, storage, usage, and listing health independently.", + "Set": "Set", + "Stale": "Stale", + "Status Sources": "Status Sources", + "Storage Readiness": "Storage Readiness", + "This status source requires attention.": "This status source requires attention.", + "This status source was not reported by the server.": "This status source was not reported by the server.", + "Usage Freshness": "Usage Freshness", + "Troubleshooting": "Troubleshooting", + "Check listing timeout and storage latency before treating this as a disk failure.": "Check listing timeout and storage latency before treating this as a disk failure.", + "Not reported": "Not reported", + "Workload Admission": "Workload Admission", + "The server reported this status source with an unknown condition.": "The server reported this status source with an unknown condition.", + "Historical internode stall timeouts": "Historical internode stall timeouts", + "This lifetime counter has no sampling window and does not indicate current degradation by itself.": "This lifetime counter has no sampling window and does not indicate current degradation by itself.", + "Backend guidance": "Backend guidance", + "Open the real multi-node metrics verification guide": "Open the real multi-node metrics verification guide", + "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.": "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.", + "Review peer, storage, usage, listing, and workload admission health independently.": "Review peer, storage, usage, listing, and workload admission health independently." } diff --git a/i18n/locales/es-ES.json b/i18n/locales/es-ES.json index 573c4ab..a0fa416 100644 --- a/i18n/locales/es-ES.json +++ b/i18n/locales/es-ES.json @@ -1519,5 +1519,31 @@ "Go to bottom": "Go to bottom", "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "La expiración de la versión actual crea un marcador de eliminación. Configura la expiración de versiones no actuales para eliminar permanentemente los datos del objeto.", "2 lifecycle rules will be created.": "Se crearán 2 reglas de ciclo de vida.", - "Lifecycle configuration cannot contain more than 1000 rules.": "La configuración del ciclo de vida no puede contener más de 1000 reglas." + "Lifecycle configuration cannot contain more than 1000 rules.": "La configuración del ciclo de vida no puede contener más de 1000 reglas.", + "Cluster diagnostics": "Cluster diagnostics", + "Last error": "Last error", + "Last successful update": "Last successful update", + "Listing and Metacache": "Listing and Metacache", + "No issue was reported by this source.": "No issue was reported by this source.", + "Peer Health": "Peer Health", + "Previously reported data may be out of date.": "Previously reported data may be out of date.", + "Review peer, storage, usage, and listing health independently.": "Review peer, storage, usage, and listing health independently.", + "Set": "Set", + "Stale": "Stale", + "Status Sources": "Status Sources", + "Storage Readiness": "Storage Readiness", + "This status source requires attention.": "This status source requires attention.", + "This status source was not reported by the server.": "This status source was not reported by the server.", + "Usage Freshness": "Usage Freshness", + "Troubleshooting": "Troubleshooting", + "Check listing timeout and storage latency before treating this as a disk failure.": "Check listing timeout and storage latency before treating this as a disk failure.", + "Not reported": "Not reported", + "Workload Admission": "Workload Admission", + "The server reported this status source with an unknown condition.": "The server reported this status source with an unknown condition.", + "Historical internode stall timeouts": "Historical internode stall timeouts", + "This lifetime counter has no sampling window and does not indicate current degradation by itself.": "This lifetime counter has no sampling window and does not indicate current degradation by itself.", + "Backend guidance": "Backend guidance", + "Open the real multi-node metrics verification guide": "Open the real multi-node metrics verification guide", + "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.": "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.", + "Review peer, storage, usage, listing, and workload admission health independently.": "Review peer, storage, usage, listing, and workload admission health independently." } diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index 9e1bd82..ed8b0e7 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -1519,5 +1519,31 @@ "Go to bottom": "Go to bottom", "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "L’expiration de la version actuelle crée un marqueur de suppression. Configurez l’expiration des versions non actuelles pour supprimer définitivement les données de l’objet.", "2 lifecycle rules will be created.": "2 règles de cycle de vie seront créées.", - "Lifecycle configuration cannot contain more than 1000 rules.": "La configuration du cycle de vie ne peut pas contenir plus de 1 000 règles." + "Lifecycle configuration cannot contain more than 1000 rules.": "La configuration du cycle de vie ne peut pas contenir plus de 1 000 règles.", + "Cluster diagnostics": "Cluster diagnostics", + "Last error": "Last error", + "Last successful update": "Last successful update", + "Listing and Metacache": "Listing and Metacache", + "No issue was reported by this source.": "No issue was reported by this source.", + "Peer Health": "Peer Health", + "Previously reported data may be out of date.": "Previously reported data may be out of date.", + "Review peer, storage, usage, and listing health independently.": "Review peer, storage, usage, and listing health independently.", + "Set": "Set", + "Stale": "Stale", + "Status Sources": "Status Sources", + "Storage Readiness": "Storage Readiness", + "This status source requires attention.": "This status source requires attention.", + "This status source was not reported by the server.": "This status source was not reported by the server.", + "Usage Freshness": "Usage Freshness", + "Troubleshooting": "Troubleshooting", + "Check listing timeout and storage latency before treating this as a disk failure.": "Check listing timeout and storage latency before treating this as a disk failure.", + "Not reported": "Not reported", + "Workload Admission": "Workload Admission", + "The server reported this status source with an unknown condition.": "The server reported this status source with an unknown condition.", + "Historical internode stall timeouts": "Historical internode stall timeouts", + "This lifetime counter has no sampling window and does not indicate current degradation by itself.": "This lifetime counter has no sampling window and does not indicate current degradation by itself.", + "Backend guidance": "Backend guidance", + "Open the real multi-node metrics verification guide": "Open the real multi-node metrics verification guide", + "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.": "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.", + "Review peer, storage, usage, listing, and workload admission health independently.": "Review peer, storage, usage, listing, and workload admission health independently." } diff --git a/i18n/locales/id-ID.json b/i18n/locales/id-ID.json index 35de5b5..6fbb6cc 100644 --- a/i18n/locales/id-ID.json +++ b/i18n/locales/id-ID.json @@ -1519,5 +1519,31 @@ "Go to bottom": "Go to bottom", "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "Kedaluwarsa versi saat ini membuat penanda penghapusan. Konfigurasikan kedaluwarsa versi nonaktif untuk menghapus data objek secara permanen.", "2 lifecycle rules will be created.": "2 aturan siklus hidup akan dibuat.", - "Lifecycle configuration cannot contain more than 1000 rules.": "Konfigurasi siklus hidup tidak boleh berisi lebih dari 1000 aturan." + "Lifecycle configuration cannot contain more than 1000 rules.": "Konfigurasi siklus hidup tidak boleh berisi lebih dari 1000 aturan.", + "Cluster diagnostics": "Cluster diagnostics", + "Last error": "Last error", + "Last successful update": "Last successful update", + "Listing and Metacache": "Listing and Metacache", + "No issue was reported by this source.": "No issue was reported by this source.", + "Peer Health": "Peer Health", + "Previously reported data may be out of date.": "Previously reported data may be out of date.", + "Review peer, storage, usage, and listing health independently.": "Review peer, storage, usage, and listing health independently.", + "Set": "Set", + "Stale": "Stale", + "Status Sources": "Status Sources", + "Storage Readiness": "Storage Readiness", + "This status source requires attention.": "This status source requires attention.", + "This status source was not reported by the server.": "This status source was not reported by the server.", + "Usage Freshness": "Usage Freshness", + "Troubleshooting": "Troubleshooting", + "Check listing timeout and storage latency before treating this as a disk failure.": "Check listing timeout and storage latency before treating this as a disk failure.", + "Not reported": "Not reported", + "Workload Admission": "Workload Admission", + "The server reported this status source with an unknown condition.": "The server reported this status source with an unknown condition.", + "Historical internode stall timeouts": "Historical internode stall timeouts", + "This lifetime counter has no sampling window and does not indicate current degradation by itself.": "This lifetime counter has no sampling window and does not indicate current degradation by itself.", + "Backend guidance": "Backend guidance", + "Open the real multi-node metrics verification guide": "Open the real multi-node metrics verification guide", + "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.": "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.", + "Review peer, storage, usage, listing, and workload admission health independently.": "Review peer, storage, usage, listing, and workload admission health independently." } diff --git a/i18n/locales/it-IT.json b/i18n/locales/it-IT.json index e82541a..2c68495 100644 --- a/i18n/locales/it-IT.json +++ b/i18n/locales/it-IT.json @@ -1519,5 +1519,31 @@ "Go to bottom": "Go to bottom", "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "La scadenza della versione corrente crea un indicatore di eliminazione. Configura la scadenza delle versioni non correnti per rimuovere definitivamente i dati dell’oggetto.", "2 lifecycle rules will be created.": "Verranno create 2 regole del ciclo di vita.", - "Lifecycle configuration cannot contain more than 1000 rules.": "La configurazione del ciclo di vita non può contenere più di 1000 regole." + "Lifecycle configuration cannot contain more than 1000 rules.": "La configurazione del ciclo di vita non può contenere più di 1000 regole.", + "Cluster diagnostics": "Cluster diagnostics", + "Last error": "Last error", + "Last successful update": "Last successful update", + "Listing and Metacache": "Listing and Metacache", + "No issue was reported by this source.": "No issue was reported by this source.", + "Peer Health": "Peer Health", + "Previously reported data may be out of date.": "Previously reported data may be out of date.", + "Review peer, storage, usage, and listing health independently.": "Review peer, storage, usage, and listing health independently.", + "Set": "Set", + "Stale": "Stale", + "Status Sources": "Status Sources", + "Storage Readiness": "Storage Readiness", + "This status source requires attention.": "This status source requires attention.", + "This status source was not reported by the server.": "This status source was not reported by the server.", + "Usage Freshness": "Usage Freshness", + "Troubleshooting": "Troubleshooting", + "Check listing timeout and storage latency before treating this as a disk failure.": "Check listing timeout and storage latency before treating this as a disk failure.", + "Not reported": "Not reported", + "Workload Admission": "Workload Admission", + "The server reported this status source with an unknown condition.": "The server reported this status source with an unknown condition.", + "Historical internode stall timeouts": "Historical internode stall timeouts", + "This lifetime counter has no sampling window and does not indicate current degradation by itself.": "This lifetime counter has no sampling window and does not indicate current degradation by itself.", + "Backend guidance": "Backend guidance", + "Open the real multi-node metrics verification guide": "Open the real multi-node metrics verification guide", + "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.": "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.", + "Review peer, storage, usage, listing, and workload admission health independently.": "Review peer, storage, usage, listing, and workload admission health independently." } diff --git a/i18n/locales/ja-JP.json b/i18n/locales/ja-JP.json index f205047..21377c5 100644 --- a/i18n/locales/ja-JP.json +++ b/i18n/locales/ja-JP.json @@ -1519,5 +1519,31 @@ "Go to bottom": "Go to bottom", "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "現行バージョンの有効期限切れでは削除マーカーが作成されます。オブジェクトデータを完全に削除するには、非現行バージョンの有効期限を設定してください。", "2 lifecycle rules will be created.": "2 件のライフサイクルルールが作成されます。", - "Lifecycle configuration cannot contain more than 1000 rules.": "ライフサイクル設定に含められるルールは 1000 件までです。" + "Lifecycle configuration cannot contain more than 1000 rules.": "ライフサイクル設定に含められるルールは 1000 件までです。", + "Cluster diagnostics": "Cluster diagnostics", + "Last error": "Last error", + "Last successful update": "Last successful update", + "Listing and Metacache": "Listing and Metacache", + "No issue was reported by this source.": "No issue was reported by this source.", + "Peer Health": "Peer Health", + "Previously reported data may be out of date.": "Previously reported data may be out of date.", + "Review peer, storage, usage, and listing health independently.": "Review peer, storage, usage, and listing health independently.", + "Set": "Set", + "Stale": "Stale", + "Status Sources": "Status Sources", + "Storage Readiness": "Storage Readiness", + "This status source requires attention.": "This status source requires attention.", + "This status source was not reported by the server.": "This status source was not reported by the server.", + "Usage Freshness": "Usage Freshness", + "Troubleshooting": "Troubleshooting", + "Check listing timeout and storage latency before treating this as a disk failure.": "Check listing timeout and storage latency before treating this as a disk failure.", + "Not reported": "Not reported", + "Workload Admission": "Workload Admission", + "The server reported this status source with an unknown condition.": "The server reported this status source with an unknown condition.", + "Historical internode stall timeouts": "Historical internode stall timeouts", + "This lifetime counter has no sampling window and does not indicate current degradation by itself.": "This lifetime counter has no sampling window and does not indicate current degradation by itself.", + "Backend guidance": "Backend guidance", + "Open the real multi-node metrics verification guide": "Open the real multi-node metrics verification guide", + "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.": "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.", + "Review peer, storage, usage, listing, and workload admission health independently.": "Review peer, storage, usage, listing, and workload admission health independently." } diff --git a/i18n/locales/ko-KR.json b/i18n/locales/ko-KR.json index 68da514..5c098fe 100644 --- a/i18n/locales/ko-KR.json +++ b/i18n/locales/ko-KR.json @@ -1519,5 +1519,31 @@ "Go to bottom": "Go to bottom", "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "현재 버전 만료 시 삭제 마커가 생성됩니다. 객체 데이터를 영구적으로 삭제하려면 비현재 버전 만료를 구성하세요.", "2 lifecycle rules will be created.": "수명 주기 규칙 2개가 생성됩니다.", - "Lifecycle configuration cannot contain more than 1000 rules.": "수명 주기 구성에는 1000개를 초과하는 규칙을 포함할 수 없습니다." + "Lifecycle configuration cannot contain more than 1000 rules.": "수명 주기 구성에는 1000개를 초과하는 규칙을 포함할 수 없습니다.", + "Cluster diagnostics": "Cluster diagnostics", + "Last error": "Last error", + "Last successful update": "Last successful update", + "Listing and Metacache": "Listing and Metacache", + "No issue was reported by this source.": "No issue was reported by this source.", + "Peer Health": "Peer Health", + "Previously reported data may be out of date.": "Previously reported data may be out of date.", + "Review peer, storage, usage, and listing health independently.": "Review peer, storage, usage, and listing health independently.", + "Set": "Set", + "Stale": "Stale", + "Status Sources": "Status Sources", + "Storage Readiness": "Storage Readiness", + "This status source requires attention.": "This status source requires attention.", + "This status source was not reported by the server.": "This status source was not reported by the server.", + "Usage Freshness": "Usage Freshness", + "Troubleshooting": "Troubleshooting", + "Check listing timeout and storage latency before treating this as a disk failure.": "Check listing timeout and storage latency before treating this as a disk failure.", + "Not reported": "Not reported", + "Workload Admission": "Workload Admission", + "The server reported this status source with an unknown condition.": "The server reported this status source with an unknown condition.", + "Historical internode stall timeouts": "Historical internode stall timeouts", + "This lifetime counter has no sampling window and does not indicate current degradation by itself.": "This lifetime counter has no sampling window and does not indicate current degradation by itself.", + "Backend guidance": "Backend guidance", + "Open the real multi-node metrics verification guide": "Open the real multi-node metrics verification guide", + "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.": "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.", + "Review peer, storage, usage, listing, and workload admission health independently.": "Review peer, storage, usage, listing, and workload admission health independently." } diff --git a/i18n/locales/pt-BR.json b/i18n/locales/pt-BR.json index 03c631f..8ed948e 100644 --- a/i18n/locales/pt-BR.json +++ b/i18n/locales/pt-BR.json @@ -1519,5 +1519,31 @@ "Go to bottom": "Go to bottom", "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "A expiração da versão atual cria um marcador de exclusão. Configure a expiração de versões não atuais para remover permanentemente os dados do objeto.", "2 lifecycle rules will be created.": "Serão criadas 2 regras de ciclo de vida.", - "Lifecycle configuration cannot contain more than 1000 rules.": "A configuração do ciclo de vida não pode conter mais de 1000 regras." + "Lifecycle configuration cannot contain more than 1000 rules.": "A configuração do ciclo de vida não pode conter mais de 1000 regras.", + "Cluster diagnostics": "Cluster diagnostics", + "Last error": "Last error", + "Last successful update": "Last successful update", + "Listing and Metacache": "Listing and Metacache", + "No issue was reported by this source.": "No issue was reported by this source.", + "Peer Health": "Peer Health", + "Previously reported data may be out of date.": "Previously reported data may be out of date.", + "Review peer, storage, usage, and listing health independently.": "Review peer, storage, usage, and listing health independently.", + "Set": "Set", + "Stale": "Stale", + "Status Sources": "Status Sources", + "Storage Readiness": "Storage Readiness", + "This status source requires attention.": "This status source requires attention.", + "This status source was not reported by the server.": "This status source was not reported by the server.", + "Usage Freshness": "Usage Freshness", + "Troubleshooting": "Troubleshooting", + "Check listing timeout and storage latency before treating this as a disk failure.": "Check listing timeout and storage latency before treating this as a disk failure.", + "Not reported": "Not reported", + "Workload Admission": "Workload Admission", + "The server reported this status source with an unknown condition.": "The server reported this status source with an unknown condition.", + "Historical internode stall timeouts": "Historical internode stall timeouts", + "This lifetime counter has no sampling window and does not indicate current degradation by itself.": "This lifetime counter has no sampling window and does not indicate current degradation by itself.", + "Backend guidance": "Backend guidance", + "Open the real multi-node metrics verification guide": "Open the real multi-node metrics verification guide", + "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.": "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.", + "Review peer, storage, usage, listing, and workload admission health independently.": "Review peer, storage, usage, listing, and workload admission health independently." } diff --git a/i18n/locales/ru-RU.json b/i18n/locales/ru-RU.json index 59026df..d8ea347 100644 --- a/i18n/locales/ru-RU.json +++ b/i18n/locales/ru-RU.json @@ -1519,5 +1519,31 @@ "Go to bottom": "Перейти вниз", "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "При истечении срока действия текущей версии создается маркер удаления. Настройте удаление неактуальных версий, чтобы окончательно удалить данные объекта.", "2 lifecycle rules will be created.": "Будут созданы 2 правила жизненного цикла.", - "Lifecycle configuration cannot contain more than 1000 rules.": "Конфигурация жизненного цикла не может содержать более 1000 правил." + "Lifecycle configuration cannot contain more than 1000 rules.": "Конфигурация жизненного цикла не может содержать более 1000 правил.", + "Cluster diagnostics": "Cluster diagnostics", + "Last error": "Last error", + "Last successful update": "Last successful update", + "Listing and Metacache": "Listing and Metacache", + "No issue was reported by this source.": "No issue was reported by this source.", + "Peer Health": "Peer Health", + "Previously reported data may be out of date.": "Previously reported data may be out of date.", + "Review peer, storage, usage, and listing health independently.": "Review peer, storage, usage, and listing health independently.", + "Set": "Set", + "Stale": "Stale", + "Status Sources": "Status Sources", + "Storage Readiness": "Storage Readiness", + "This status source requires attention.": "This status source requires attention.", + "This status source was not reported by the server.": "This status source was not reported by the server.", + "Usage Freshness": "Usage Freshness", + "Troubleshooting": "Troubleshooting", + "Check listing timeout and storage latency before treating this as a disk failure.": "Check listing timeout and storage latency before treating this as a disk failure.", + "Not reported": "Not reported", + "Workload Admission": "Workload Admission", + "The server reported this status source with an unknown condition.": "The server reported this status source with an unknown condition.", + "Historical internode stall timeouts": "Historical internode stall timeouts", + "This lifetime counter has no sampling window and does not indicate current degradation by itself.": "This lifetime counter has no sampling window and does not indicate current degradation by itself.", + "Backend guidance": "Backend guidance", + "Open the real multi-node metrics verification guide": "Open the real multi-node metrics verification guide", + "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.": "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.", + "Review peer, storage, usage, listing, and workload admission health independently.": "Review peer, storage, usage, listing, and workload admission health independently." } diff --git a/i18n/locales/tr-TR.json b/i18n/locales/tr-TR.json index f4d7a3c..ae4763c 100644 --- a/i18n/locales/tr-TR.json +++ b/i18n/locales/tr-TR.json @@ -1519,5 +1519,31 @@ "Go to bottom": "Go to bottom", "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "Geçerli sürümün süresi dolduğunda bir silme işareti oluşturulur. Nesne verilerini kalıcı olarak kaldırmak için geçerli olmayan sürümlerin süre sonunu yapılandırın.", "2 lifecycle rules will be created.": "2 yaşam döngüsü kuralı oluşturulacak.", - "Lifecycle configuration cannot contain more than 1000 rules.": "Yaşam döngüsü yapılandırması 1000'den fazla kural içeremez." + "Lifecycle configuration cannot contain more than 1000 rules.": "Yaşam döngüsü yapılandırması 1000'den fazla kural içeremez.", + "Cluster diagnostics": "Cluster diagnostics", + "Last error": "Last error", + "Last successful update": "Last successful update", + "Listing and Metacache": "Listing and Metacache", + "No issue was reported by this source.": "No issue was reported by this source.", + "Peer Health": "Peer Health", + "Previously reported data may be out of date.": "Previously reported data may be out of date.", + "Review peer, storage, usage, and listing health independently.": "Review peer, storage, usage, and listing health independently.", + "Set": "Set", + "Stale": "Stale", + "Status Sources": "Status Sources", + "Storage Readiness": "Storage Readiness", + "This status source requires attention.": "This status source requires attention.", + "This status source was not reported by the server.": "This status source was not reported by the server.", + "Usage Freshness": "Usage Freshness", + "Troubleshooting": "Troubleshooting", + "Check listing timeout and storage latency before treating this as a disk failure.": "Check listing timeout and storage latency before treating this as a disk failure.", + "Not reported": "Not reported", + "Workload Admission": "Workload Admission", + "The server reported this status source with an unknown condition.": "The server reported this status source with an unknown condition.", + "Historical internode stall timeouts": "Historical internode stall timeouts", + "This lifetime counter has no sampling window and does not indicate current degradation by itself.": "This lifetime counter has no sampling window and does not indicate current degradation by itself.", + "Backend guidance": "Backend guidance", + "Open the real multi-node metrics verification guide": "Open the real multi-node metrics verification guide", + "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.": "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.", + "Review peer, storage, usage, listing, and workload admission health independently.": "Review peer, storage, usage, listing, and workload admission health independently." } diff --git a/i18n/locales/vi-VN.json b/i18n/locales/vi-VN.json index 767d5d9..80afde0 100644 --- a/i18n/locales/vi-VN.json +++ b/i18n/locales/vi-VN.json @@ -1519,5 +1519,31 @@ "Go to bottom": "Go to bottom", "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "Việc hết hạn phiên bản hiện tại sẽ tạo dấu xóa. Hãy cấu hình hết hạn phiên bản không hiện tại để xóa vĩnh viễn dữ liệu đối tượng.", "2 lifecycle rules will be created.": "Sẽ tạo 2 quy tắc vòng đời.", - "Lifecycle configuration cannot contain more than 1000 rules.": "Cấu hình vòng đời không được chứa quá 1000 quy tắc." + "Lifecycle configuration cannot contain more than 1000 rules.": "Cấu hình vòng đời không được chứa quá 1000 quy tắc.", + "Cluster diagnostics": "Cluster diagnostics", + "Last error": "Last error", + "Last successful update": "Last successful update", + "Listing and Metacache": "Listing and Metacache", + "No issue was reported by this source.": "No issue was reported by this source.", + "Peer Health": "Peer Health", + "Previously reported data may be out of date.": "Previously reported data may be out of date.", + "Review peer, storage, usage, and listing health independently.": "Review peer, storage, usage, and listing health independently.", + "Set": "Set", + "Stale": "Stale", + "Status Sources": "Status Sources", + "Storage Readiness": "Storage Readiness", + "This status source requires attention.": "This status source requires attention.", + "This status source was not reported by the server.": "This status source was not reported by the server.", + "Usage Freshness": "Usage Freshness", + "Troubleshooting": "Troubleshooting", + "Check listing timeout and storage latency before treating this as a disk failure.": "Check listing timeout and storage latency before treating this as a disk failure.", + "Not reported": "Not reported", + "Workload Admission": "Workload Admission", + "The server reported this status source with an unknown condition.": "The server reported this status source with an unknown condition.", + "Historical internode stall timeouts": "Historical internode stall timeouts", + "This lifetime counter has no sampling window and does not indicate current degradation by itself.": "This lifetime counter has no sampling window and does not indicate current degradation by itself.", + "Backend guidance": "Backend guidance", + "Open the real multi-node metrics verification guide": "Open the real multi-node metrics verification guide", + "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.": "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.", + "Review peer, storage, usage, listing, and workload admission health independently.": "Review peer, storage, usage, listing, and workload admission health independently." } diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index 68fd59c..99b8d15 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -1519,5 +1519,31 @@ "Go to bottom": "直达底部", "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "当前版本过期时会创建删除标记。请配置非当前版本过期规则,以永久删除对象数据。", "2 lifecycle rules will be created.": "将创建 2 条生命周期规则。", - "Lifecycle configuration cannot contain more than 1000 rules.": "生命周期配置不能包含超过 1000 条规则。" + "Lifecycle configuration cannot contain more than 1000 rules.": "生命周期配置不能包含超过 1000 条规则。", + "Cluster diagnostics": "集群诊断", + "Last error": "最近错误", + "Last successful update": "最近成功更新时间", + "Listing and Metacache": "列表与元数据缓存", + "No issue was reported by this source.": "此状态来源未报告问题。", + "Peer Health": "节点健康", + "Previously reported data may be out of date.": "此前上报的数据可能已经过期。", + "Review peer, storage, usage, and listing health independently.": "分别检查节点、存储、用量和列表服务的健康状态。", + "Set": "纠删码组", + "Stale": "已过期", + "Status Sources": "状态来源", + "Storage Readiness": "存储就绪状态", + "This status source requires attention.": "此状态来源需要处理。", + "This status source was not reported by the server.": "服务器未上报此状态来源。", + "Usage Freshness": "用量数据新鲜度", + "Troubleshooting": "排查建议", + "Check listing timeout and storage latency before treating this as a disk failure.": "在判断为磁盘故障前,请先检查列表超时和存储延迟。", + "Not reported": "未报告", + "Workload Admission": "工作负载准入", + "The server reported this status source with an unknown condition.": "服务器报告了此状态来源,但状态条件未知。", + "Historical internode stall timeouts": "历史节点间停顿超时", + "This lifetime counter has no sampling window and does not indicate current degradation by itself.": "此生命周期累计计数没有采样窗口,不能单独说明当前处于降级状态。", + "Backend guidance": "后端提示", + "Open the real multi-node metrics verification guide": "打开真实多节点指标确认指南", + "Correlate time-windowed walk_dir metrics and metacache logs before treating listing symptoms as a disk failure.": "在将列表症状判断为磁盘故障前,请关联时间窗口内的 walk_dir 指标和元数据缓存日志。", + "Review peer, storage, usage, listing, and workload admission health independently.": "分别检查节点、存储、用量、列表服务和工作负载准入状态。" } diff --git a/lib/api-client.ts b/lib/api-client.ts index e08a76e..d80fac2 100644 --- a/lib/api-client.ts +++ b/lib/api-client.ts @@ -57,7 +57,7 @@ export class ApiClient { } async request(url: string, options: RequestOptions = {}, parseJson: boolean = true) { - url = this.config?.baseUrl ? joinURL(this.config?.baseUrl, url) : url + url = this.config?.baseUrl && !/^https?:\/\//i.test(url) ? joinURL(this.config.baseUrl, url) : url const { params, ...providedOptions } = options const requestOptions: RequestOptions = { ...providedOptions, diff --git a/lib/performance-data.ts b/lib/performance-data.ts index 2c8e310..434dd7e 100644 --- a/lib/performance-data.ts +++ b/lib/performance-data.ts @@ -1,4 +1,36 @@ export type ServerHealthState = "online" | "offline" | "degraded" | "initializing" | "unknown" +export type OperationalStatus = "healthy" | "degraded" | "stale" | "not_reported" | "unknown" + +export interface StatusDiagnostic { + state: OperationalStatus + reason?: string + source?: string + lastSuccessfulUpdate?: string + lastError?: string + historicalStallTimeouts?: number + hint?: string + scope?: { + bucket?: string + prefix?: string + set?: string + timeout?: string + } +} + +export interface PeerHealthDiagnostic extends StatusDiagnostic { + nodeId: string + isLocal?: boolean +} + +export interface ClusterDiagnostics { + peerHealth: StatusDiagnostic + storageReadiness: StatusDiagnostic + usageFreshness: StatusDiagnostic + listingHealth: StatusDiagnostic + workloadAdmission: StatusDiagnostic + peers: PeerHealthDiagnostic[] + membership: Array<{ nodeId: string; gridHost?: string }> +} export interface ServerInfo { endpoint?: string @@ -27,6 +59,9 @@ export interface SystemInfo { offlineDisks?: number unknownDisks?: number } + adminDiscovery?: { + clusterSnapshot: string + } } export interface DataUsageInfo { @@ -81,6 +116,10 @@ function asString(value: unknown): string | undefined { return typeof value === "string" && value.trim() !== "" ? value : undefined } +function asBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined +} + function asStringOrNumber(value: unknown): string | number | undefined { return typeof value === "string" || (typeof value === "number" && Number.isFinite(value)) ? value : undefined } @@ -90,6 +129,23 @@ function asTimestamp(value: unknown): string | undefined { return timestamp && !Number.isNaN(Date.parse(timestamp)) ? timestamp : undefined } +function asUnixTimestamp(value: unknown): string | undefined { + const seconds = asNonNegativeNumber(value) + if (seconds === undefined || seconds === 0) return undefined + const timestamp = new Date(seconds * 1000) + return Number.isNaN(timestamp.getTime()) ? undefined : timestamp.toISOString() +} + +function asSafeText(value: unknown): string | undefined { + const text = asString(value)?.trim() + return text ? text.slice(0, 500) : undefined +} + +function asSafeAdminPath(value: unknown): string | undefined { + const path = asString(value) + return path?.startsWith("/") && !path.startsWith("//") && !path.includes("\\") ? path : undefined +} + export function normalizeServerHealthState(value: unknown): ServerHealthState { const state = asString(value)?.toLowerCase() if (state === "online" || state === "offline" || state === "degraded" || state === "initializing") { @@ -151,8 +207,13 @@ function normalizeCountInfo(value: unknown): { count?: number } | undefined { } export function normalizeSystemInfo(value: unknown): SystemInfo { + const response = asRecord(value) const source = unwrapInfoRecord(value) const backend = asRecord(source.backend ?? source.Backend) + const discovery = asRecord( + response.admin_discovery ?? response.adminDiscovery ?? source.admin_discovery ?? source.adminDiscovery, + ) + const clusterSnapshotPath = asSafeAdminPath(discovery.clusterSnapshot ?? discovery.cluster_snapshot) const buckets = normalizeCountInfo(source.buckets ?? source.Buckets) const objects = normalizeCountInfo(source.objects ?? source.Objects) const rawServers = source.servers ?? source.Servers @@ -177,9 +238,276 @@ export function normalizeSystemInfo(value: unknown): SystemInfo { }, } : {}), + ...(clusterSnapshotPath ? { adminDiscovery: { clusterSnapshot: clusterSnapshotPath } } : {}), } } +function normalizeOperationalStatus(value: unknown): OperationalStatus { + const state = asString(value)?.trim().toLowerCase().replaceAll("-", "_").replaceAll(" ", "_") + if (["healthy", "ready", "online", "ok", "supported", "fresh", "available"].includes(state ?? "")) { + return "healthy" + } + if (["stale", "outdated", "expired"].includes(state ?? "")) return "stale" + if (["not_reported", "notreported", "unsupported", "disabled", "unavailable"].includes(state ?? "")) { + return "not_reported" + } + if ( + ["degraded", "unhealthy", "failed", "failure", "offline", "unreachable", "unresolved", "error", "timeout"].includes( + state ?? "", + ) + ) { + return "degraded" + } + return "unknown" +} + +function normalizeScope(value: unknown): StatusDiagnostic["scope"] { + const scope = asRecord(value) + const bucket = asSafeText(scope.bucket ?? scope.bucket_name ?? scope.bucketName) + const prefix = asSafeText(scope.prefix ?? scope.path) + const setValue = asStringOrNumber(scope.set ?? scope.set_index ?? scope.setIndex) + const timeoutValue = asStringOrNumber(scope.timeout ?? scope.timeout_ms ?? scope.timeoutMs) + const normalized = { + ...(bucket ? { bucket } : {}), + ...(prefix ? { prefix } : {}), + ...(setValue !== undefined ? { set: String(setValue) } : {}), + ...(timeoutValue !== undefined + ? { timeout: typeof timeoutValue === "number" ? `${timeoutValue} ms` : String(timeoutValue) } + : {}), + } + return Object.keys(normalized).length ? normalized : undefined +} + +function normalizeDiagnostic(value: unknown): StatusDiagnostic { + if (typeof value === "string") return { state: normalizeOperationalStatus(value) } + + const record = asRecord(value) + const nestedStatus = asRecord(record.status ?? record.Status) + const state = normalizeOperationalStatus( + record.condition ?? + record.Condition ?? + record.state ?? + record.State ?? + (typeof record.status === "string" ? record.status : undefined) ?? + nestedStatus.state ?? + nestedStatus.State, + ) + const reason = asSafeText(record.reason ?? record.Reason ?? nestedStatus.reason ?? nestedStatus.Reason) + const source = asSafeText(record.source ?? record.Source ?? nestedStatus.source ?? nestedStatus.Source) + const lastSuccessfulUpdate = + asTimestamp( + record.last_successful_update ?? + record.lastSuccessfulUpdate ?? + record.last_success ?? + record.lastSuccess ?? + nestedStatus.last_successful_update ?? + nestedStatus.lastSuccessfulUpdate, + ) ?? asUnixTimestamp(record.last_success_unix_secs ?? record.lastSuccessUnixSecs) + const lastError = asSafeText( + record.last_error ?? record.lastError ?? record.error ?? nestedStatus.last_error ?? nestedStatus.lastError, + ) + const scope = normalizeScope(record.scope ?? record.context ?? nestedStatus.scope ?? nestedStatus.context) + const historicalStallTimeouts = asNonNegativeNumber( + record.internode_stall_timeouts_total ?? record.internodeStallTimeoutsTotal, + ) + const hint = asSafeText(record.hint ?? record.Hint) + + return { + state, + ...(reason ? { reason } : {}), + ...(source ? { source } : {}), + ...(lastSuccessfulUpdate ? { lastSuccessfulUpdate } : {}), + ...(lastError ? { lastError } : {}), + ...(historicalStallTimeouts !== undefined ? { historicalStallTimeouts } : {}), + ...(hint ? { hint } : {}), + ...(scope ? { scope } : {}), + } +} + +function aggregateDiagnostics(diagnostics: StatusDiagnostic[], fallback?: StatusDiagnostic): StatusDiagnostic { + if (!diagnostics.length) return fallback ?? { state: "unknown" } + const priority: Record = { + degraded: 0, + stale: 1, + unknown: 2, + not_reported: 3, + healthy: 4, + } + return diagnostics.reduce((worst, current) => (priority[current.state] < priority[worst.state] ? current : worst)) +} + +function firstDiagnosticRecord(...values: unknown[]) { + return values.find((value) => typeof value === "string" || Object.keys(asRecord(value)).length > 0) +} + +export function normalizeClusterDiagnostics(value: unknown): ClusterDiagnostics | undefined { + const response = asRecord(value) + const wrappedSnapshot = response.snapshot ?? response.Snapshot + const snapshot = wrappedSnapshot === undefined ? response : asRecord(wrappedSnapshot) + if (!Object.keys(snapshot).length) return undefined + + const summary = asRecord(snapshot.summary ?? snapshot.Summary) + const components = asRecord( + snapshot.components ?? snapshot.Components ?? snapshot.component_status ?? snapshot.componentStatus, + ) + const membershipRecord = asRecord(snapshot.membership ?? snapshot.Membership) + const membership = asArray(membershipRecord.nodes ?? membershipRecord.Nodes).flatMap((value) => { + const node = asRecord(value) + const nodeId = asSafeText(node.node_id ?? node.nodeId ?? node.NodeId) + if (!nodeId) return [] + const gridHost = asSafeText(node.grid_host ?? node.gridHost ?? node.GridHost) + return [{ nodeId, ...(gridHost ? { gridHost } : {}) }] + }) + + const peerRecord = asRecord(snapshot.peer_health ?? snapshot.peerHealth ?? snapshot.PeerHealth) + const peers = asArray(peerRecord.peers ?? peerRecord.Peers).flatMap((value) => { + const peer = asRecord(value) + const nodeId = asSafeText(peer.node_id ?? peer.nodeId ?? peer.NodeId) + if (!nodeId) return [] + const diagnostic = normalizeDiagnostic(peer.status ?? peer.Status ?? peer) + const isLocal = asBoolean(peer.is_local ?? peer.isLocal ?? peer.IsLocal) + return [{ ...diagnostic, nodeId, ...(isLocal !== undefined ? { isLocal } : {}) }] + }) + const explicitPeerHealth = firstDiagnosticRecord( + components.peer_health, + components.peerHealth, + summary.peer_health, + summary.peerHealth, + ) + const peerHealth = explicitPeerHealth + ? normalizeDiagnostic(explicitPeerHealth) + : aggregateDiagnostics(peers, { state: "not_reported" }) + + const runtime = asRecord(snapshot.runtime_status ?? snapshot.runtimeStatus ?? snapshot.RuntimeStatus) + const explicitStorage = firstDiagnosticRecord( + snapshot.storage_readiness, + snapshot.storageReadiness, + components.storage_readiness, + components.storageReadiness, + components.storage, + ) + const storageReady = asBoolean(runtime.storage_ready ?? runtime.storageReady) + const degradedReasons = asArray(runtime.degraded_reasons ?? runtime.degradedReasons) + .flatMap((reason) => asSafeText(reason) ?? []) + .join(", ") + const storageReadiness = explicitStorage + ? normalizeDiagnostic(explicitStorage) + : storageReady === undefined + ? { state: "not_reported" as const } + : { + state: storageReady ? ("healthy" as const) : ("degraded" as const), + ...(!storageReady && degradedReasons ? { reason: degradedReasons } : {}), + } + + const explicitUsage = firstDiagnosticRecord( + snapshot.usage_freshness, + snapshot.usageFreshness, + snapshot.usage_cache, + snapshot.usageCache, + components.usage_freshness, + components.usageFreshness, + components.usage_cache, + components.usageCache, + components.usage, + ) + const usageFreshness = explicitUsage ? normalizeDiagnostic(explicitUsage) : { state: "not_reported" as const } + const explicitListing = firstDiagnosticRecord( + snapshot.listing, + snapshot.metacache, + snapshot.listing_metacache, + snapshot.listingMetacache, + components.listing, + components.metacache, + components.listing_metacache, + components.listingMetacache, + ) + const listingHealth = explicitListing ? normalizeDiagnostic(explicitListing) : { state: "not_reported" as const } + const explicitWorkloadAdmission = firstDiagnosticRecord( + snapshot.workload_admission_status, + snapshot.workloadAdmissionStatus, + components.workload_admission, + components.workloadAdmission, + ) + const workloadAdmission = explicitWorkloadAdmission + ? normalizeDiagnostic(explicitWorkloadAdmission) + : { state: "not_reported" as const } + + return { + peerHealth, + storageReadiness, + usageFreshness, + listingHealth, + workloadAdmission, + peers, + membership, + } +} + +function normalizeEndpointIdentity(value: string | undefined) { + if (!value) return undefined + const trimmed = value.trim().toLowerCase().replace(/\/$/, "") + try { + const url = new URL(trimmed.includes("://") ? trimmed : `http://${trimmed}`) + return url.host + } catch { + return trimmed.replace(/^https?:\/\//, "") + } +} + +function findPeerDiagnostic(server: ServerInfo, diagnostics: ClusterDiagnostics) { + const serverIdentity = normalizeEndpointIdentity(server.endpoint) + if (!serverIdentity) return undefined + return diagnostics.peers.find((peer) => { + const member = diagnostics.membership.find((item) => item.nodeId === peer.nodeId) + return [peer.nodeId, member?.gridHost].some((candidate) => normalizeEndpointIdentity(candidate) === serverIdentity) + }) +} + +function hasOnlyHealthyDrives(server: ServerInfo) { + return Boolean( + server.drives?.length && + server.drives.every((drive) => ["ok", "online"].includes(drive.state?.trim().toLowerCase() ?? "")), + ) +} + +export function resolveServerHealth( + server: ServerInfo, + diagnostics?: ClusterDiagnostics, +): { state: ServerHealthState; reason?: string; source: "legacy" | "peer" } { + const legacyState = normalizeServerHealthState(server.state) + if (!diagnostics) return { state: legacyState, source: "legacy" } + + const peer = findPeerDiagnostic(server, diagnostics) + if (!peer) return { state: legacyState, source: "legacy" } + if (peer.state === "degraded") { + return { state: "degraded", ...(peer.reason ? { reason: peer.reason } : {}), source: "peer" } + } + if ( + (peer.state === "unknown" || peer.state === "stale" || peer.state === "not_reported") && + legacyState === "degraded" && + hasOnlyHealthyDrives(server) + ) { + return { state: "unknown", ...(peer.reason ? { reason: peer.reason } : {}), source: "peer" } + } + return { state: legacyState, source: "legacy" } +} + +export function resolveUsageFreshness( + diagnostic: StatusDiagnostic | undefined, + context: { hasData: boolean; error?: string; lastUpdatedAt?: Date | null }, +): StatusDiagnostic { + if (diagnostic && diagnostic.state !== "unknown") return diagnostic + if (context.error && context.hasData) { + return { + state: "stale", + reason: context.error, + ...(context.lastUpdatedAt ? { lastSuccessfulUpdate: context.lastUpdatedAt.toISOString() } : {}), + } + } + if (context.error) return { state: "unknown", reason: context.error } + return diagnostic ?? { state: "unknown" } +} + export function normalizeStorageInfo(value: unknown): StorageInfo { const source = unwrapInfoRecord(value) const backend = asRecord(source.backend ?? source.Backend) @@ -237,7 +565,10 @@ export function normalizeMetricsInfo(value: unknown): MetricsInfo { } } -export function summarizeServerStates(servers: ServerInfo[] | undefined): Record { +export function summarizeServerStates( + servers: ServerInfo[] | undefined, + diagnostics?: ClusterDiagnostics, +): Record { const summary: Record = { online: 0, offline: 0, @@ -247,7 +578,7 @@ export function summarizeServerStates(servers: ServerInfo[] | undefined): Record } for (const server of servers ?? []) { - summary[normalizeServerHealthState(server.state)] += 1 + summary[resolveServerHealth(server, diagnostics).state] += 1 } return summary diff --git a/tests/lib/api-client.test.ts b/tests/lib/api-client.test.ts index 028fb10..46c74a5 100644 --- a/tests/lib/api-client.test.ts +++ b/tests/lib/api-client.test.ts @@ -55,6 +55,24 @@ test("ApiClient rejects 401 and 403 responses after invoking global handlers", a assert.deepEqual(handled, [401, 403]) }) +test("ApiClient preserves an explicitly resolved same-origin admin path", async () => { + const { ApiClient } = await loadApiClient() + const urls: string[] = [] + const client = new ApiClient( + { + fetch: async (input: string | Request) => { + urls.push(String(input)) + return Response.json({ ok: true }) + }, + }, + { baseUrl: "https://console.test/rustfs/admin/v3" }, + ) + + await client.get(client.resolveUrl("/rustfs/admin/v4/cluster/snapshot")) + + assert.deepEqual(urls, ["https://console.test/rustfs/admin/v4/cluster/snapshot"]) +}) + test("ApiClient redacts sensitive headers and request bodies from development logs", () => { const redacted = redactRequestOptionsForLog({ headers: { Authorization: "Bearer header-secret", Cookie: "session=cookie-secret" }, diff --git a/tests/lib/performance-data.test.js b/tests/lib/performance-data.test.js index e668ec4..96b8acf 100644 --- a/tests/lib/performance-data.test.js +++ b/tests/lib/performance-data.test.js @@ -3,10 +3,13 @@ import assert from "node:assert/strict" import { formatRelativeTime, + normalizeClusterDiagnostics, normalizeDataUsageInfo, normalizeMetricsInfo, normalizeStorageInfo, normalizeSystemInfo, + resolveServerHealth, + resolveUsageFreshness, summarizeServerStates, } from "../../lib/performance-data.ts" @@ -57,6 +60,25 @@ test("normalizeSystemInfo unwraps RustFS admin discovery info responses", () => assert.equal(info.backend?.onlineDisks, 12) assert.equal(info.backend?.offlineDisks, 0) assert.equal(info.servers?.length, 2) + assert.equal(info.adminDiscovery?.clusterSnapshot, "/rustfs/admin/v4/cluster/snapshot") +}) + +test("normalizeSystemInfo rejects unsafe cluster snapshot discovery paths", () => { + assert.equal( + normalizeSystemInfo({ info: { servers: [] }, admin_discovery: { clusterSnapshot: "https://example.com/snapshot" } }) + .adminDiscovery, + undefined, + ) + assert.equal( + normalizeSystemInfo({ info: { servers: [] }, admin_discovery: { clusterSnapshot: "//example.com/snapshot" } }) + .adminDiscovery, + undefined, + ) + assert.equal( + normalizeSystemInfo({ info: { servers: [] }, admin_discovery: { clusterSnapshot: "/\\evil.example/snapshot" } }) + .adminDiscovery, + undefined, + ) }) test("normalizeStorageInfo unwraps RustFS admin discovery storage responses", () => { @@ -194,3 +216,241 @@ test("formatRelativeTime follows the active locale and advances with the clock", assert.equal(formatRelativeTime(timestamp, "en-US", Date.parse("2026-07-10T11:00:00Z")), "3 hours ago") assert.equal(formatRelativeTime("invalid", "en-US"), undefined) }) + +test("normalizeClusterDiagnostics separates peer, storage, usage, and listing status", () => { + const diagnostics = normalizeClusterDiagnostics({ + snapshot: { + membership: { + nodes: [ + { node_id: "node-a", grid_host: "10.0.0.1:9000" }, + { node_id: "node-b", grid_host: "10.0.0.2:9000" }, + ], + }, + peer_health: { + peers: [ + { node_id: "node-a", status: { state: "supported" } }, + { node_id: "node-b", status: { state: "unknown", reason: "peer health not reported" } }, + ], + }, + runtime_status: { + state: "degraded", + storage_ready: true, + degraded_reasons: ["peer_health_unavailable"], + }, + usage_freshness: { + state: "stale", + reason: "refresh timed out", + last_successful_update: "2026-07-21T08:00:00Z", + }, + listing: { + state: "degraded", + reason: "metacache quorum timeout", + last_error: "timeout", + scope: { bucket: "archive", prefix: "2026/", set: 2, timeout_ms: 5000 }, + }, + }, + }) + + assert.equal(diagnostics?.peerHealth.state, "unknown") + assert.equal(diagnostics?.storageReadiness.state, "healthy") + assert.equal(diagnostics?.usageFreshness.state, "stale") + assert.equal(diagnostics?.usageFreshness.lastSuccessfulUpdate, "2026-07-21T08:00:00Z") + assert.equal(diagnostics?.listingHealth.state, "degraded") + assert.deepEqual(diagnostics?.listingHealth.scope, { + bucket: "archive", + prefix: "2026/", + set: "2", + timeout: "5000 ms", + }) +}) + +test("normalizeClusterDiagnostics follows the backend component condition contract", () => { + const diagnostics = normalizeClusterDiagnostics({ + snapshot: { + components: { + storage: { + source: "runtime", + condition: "degraded", + status: { state: "unknown", reason: "one storage set is unavailable" }, + }, + peer_health: { + source: "peer-health", + condition: "not_reported", + status: { state: "disabled", reason: "peer telemetry is disabled" }, + }, + listing: { + source: "metacache", + condition: "healthy", + status: { state: "supported", reason: "foreground read admission is open" }, + internode_stall_timeouts_total: 2, + hint: "inspect operation-labelled walk_dir metrics", + }, + usage: { + source: "usage-cache", + condition: "stale", + status: { state: "unknown", reason: "usage refresh is overdue" }, + last_success_unix_secs: 1_700_000_000, + last_error: "refresh timed out", + }, + workload_admission: { + source: "admission", + condition: "unknown", + status: { state: "unknown", reason: "admission telemetry unavailable" }, + }, + }, + }, + }) + + assert.equal(diagnostics?.storageReadiness.state, "degraded") + assert.equal(diagnostics?.peerHealth.state, "not_reported") + assert.equal(diagnostics?.listingHealth.state, "healthy") + assert.equal(diagnostics?.listingHealth.historicalStallTimeouts, 2) + assert.equal(diagnostics?.listingHealth.hint, "inspect operation-labelled walk_dir metrics") + assert.equal(diagnostics?.usageFreshness.state, "stale") + assert.equal(diagnostics?.usageFreshness.lastSuccessfulUpdate, "2023-11-14T22:13:20.000Z") + assert.equal(diagnostics?.usageFreshness.lastError, "refresh timed out") + assert.equal(diagnostics?.workloadAdmission.state, "unknown") +}) + +test("old snapshots report omitted diagnostic components as not reported", () => { + const diagnostics = normalizeClusterDiagnostics({ snapshot: { runtime_status: {} } }) + + assert.equal(diagnostics?.peerHealth.state, "not_reported") + assert.equal(diagnostics?.storageReadiness.state, "not_reported") + assert.equal(diagnostics?.usageFreshness.state, "not_reported") + assert.equal(diagnostics?.listingHealth.state, "not_reported") + assert.equal(diagnostics?.workloadAdmission.state, "not_reported") +}) + +test("normalizeClusterDiagnostics preserves a fully healthy component combination", () => { + const diagnostics = normalizeClusterDiagnostics({ + snapshot: { + peer_health: { peers: [{ node_id: "node-a", status: { state: "online" } }] }, + runtime_status: { storage_ready: true }, + usage_cache: { state: "fresh" }, + metacache: { state: "healthy" }, + }, + }) + + assert.equal(diagnostics?.peerHealth.state, "healthy") + assert.equal(diagnostics?.storageReadiness.state, "healthy") + assert.equal(diagnostics?.usageFreshness.state, "healthy") + assert.equal(diagnostics?.listingHealth.state, "healthy") +}) + +test("peer health unknown replaces only a false legacy degraded node", () => { + const diagnostics = normalizeClusterDiagnostics({ + snapshot: { + membership: { + nodes: [ + { node_id: "node-a", grid_host: "10.0.0.1:9000" }, + { node_id: "node-b", grid_host: "10.0.0.2:9000" }, + ], + }, + peer_health: { + peers: [ + { node_id: "node-a", status: { state: "unknown", reason: "not reported" } }, + { node_id: "node-b", status: { state: "unknown", reason: "not reported" } }, + ], + }, + runtime_status: { storage_ready: true }, + }, + }) + + const healthyLegacyNode = { + endpoint: "10.0.0.1:9000", + state: "online", + drives: [{ state: "ok" }], + } + const falseDegradedNode = { + endpoint: "http://10.0.0.2:9000", + state: "degraded", + drives: [{ state: "ok" }], + } + + assert.equal(resolveServerHealth(healthyLegacyNode, diagnostics).state, "online") + assert.equal(resolveServerHealth(falseDegradedNode, diagnostics).state, "unknown") + assert.equal(resolveServerHealth(falseDegradedNode, diagnostics).reason, "not reported") + assert.deepEqual(summarizeServerStates([healthyLegacyNode, falseDegradedNode], diagnostics), { + online: 1, + offline: 0, + degraded: 0, + initializing: 0, + unknown: 1, + }) +}) + +test("a real peer failure remains an exact degraded node while usage timeout stays separate", () => { + const diagnostics = normalizeClusterDiagnostics({ + snapshot: { + membership: { nodes: [{ node_id: "node-b", grid_host: "10.0.0.2:9000" }] }, + peer_health: { + peers: [{ node_id: "node-b", status: { state: "failed", reason: "peer unreachable" } }], + }, + runtime_status: { storage_ready: true }, + usage_cache: { state: "stale", reason: "refresh timeout" }, + }, + }) + + const node = { endpoint: "10.0.0.2:9000", state: "online", drives: [{ state: "ok" }] } + assert.deepEqual(resolveServerHealth(node, diagnostics), { + state: "degraded", + reason: "peer unreachable", + source: "peer", + }) + assert.equal(diagnostics?.usageFreshness.state, "stale") +}) + +test("usage refresh failures mark retained data stale without changing cluster diagnostics", () => { + const lastUpdatedAt = new Date("2026-07-21T08:00:00Z") + assert.deepEqual( + resolveUsageFreshness(undefined, { + hasData: true, + error: "request timeout", + lastUpdatedAt, + }), + { + state: "stale", + reason: "request timeout", + lastSuccessfulUpdate: "2026-07-21T08:00:00.000Z", + }, + ) + assert.equal(resolveUsageFreshness(undefined, { hasData: true }).state, "unknown") +}) + +test("the #5070 compatibility shape preserves 3 online servers and 48 online disks without a false degraded node", () => { + const system = normalizeSystemInfo({ + info: { + backend: { onlineDisks: 48, offlineDisks: 0 }, + servers: [ + { endpoint: "node-1:9000", state: "online", drives: [{ state: "ok" }] }, + { endpoint: "node-2:9000", state: "online", drives: [{ state: "ok" }] }, + { endpoint: "node-3:9000", state: "online", drives: [{ state: "ok" }] }, + { endpoint: "node-4:9000", state: "degraded", drives: [{ state: "ok" }] }, + ], + }, + }) + const diagnostics = normalizeClusterDiagnostics({ + snapshot: { + membership: { nodes: [{ node_id: "node-4", grid_host: "node-4:9000" }] }, + peer_health: { + peers: [{ node_id: "node-4", status: { state: "unknown", reason: "peer health not reported" } }], + }, + runtime_status: { storage_ready: true }, + }, + }) + + assert.equal(system.backend?.onlineDisks, 48) + assert.equal(system.backend?.offlineDisks, 0) + assert.deepEqual(summarizeServerStates(system.servers, diagnostics), { + online: 3, + offline: 0, + degraded: 0, + initializing: 0, + unknown: 1, + }) + assert.equal( + resolveUsageFreshness(diagnostics?.usageFreshness, { hasData: false, error: "usage unavailable" }).state, + "not_reported", + ) +}) diff --git a/tests/lib/performance-status-source.test.js b/tests/lib/performance-status-source.test.js index 52930b5..60be638 100644 --- a/tests/lib/performance-status-source.test.js +++ b/tests/lib/performance-status-source.test.js @@ -5,7 +5,7 @@ import fs from "node:fs" test("status page passes every normalized admin info state into infrastructure health", () => { const source = fs.readFileSync("app/(dashboard)/status/page.tsx", "utf8") - assert.match(source, /summarizeServerStates\(systemInfo\.servers\)/) + assert.match(source, /summarizeServerStates\(systemInfo\.servers, diagnosticsInfo\)/) assert.match(source, /unknownServers=\{serverSummary\?\.unknown\}/) assert.match(source, /degradedServers=\{serverSummary\?\.degraded\}/) assert.match(source, /initializingServers=\{serverSummary\?\.initializing\}/) @@ -33,9 +33,11 @@ test("performance server list treats every health state as a first-class filter" source, /const filterOrder: ServerHealthState\[\] = \["offline", "degraded", "initializing", "unknown", "online"\]/, ) - assert.match(source, /normalizeServerHealthState\(server\.state\) === filterBy/) - assert.match(source, /getStatePriority\(normalizeServerHealthState\(left\.server\.state\)\)/) + assert.match(source, /resolveServerHealth\(server, diagnostics\)/) + assert.match(source, /health\.state === filterBy/) + assert.match(source, /getStatePriority\(left\.health\.state\)/) assert.match(source, /\{getStateLabel\(state, t\)\}<\/Badge>/) + assert.match(source, /health\.reason/) assert.match(source, /aria-pressed=\{selected\}/) assert.doesNotMatch(source, /!isOnlineServer\(server\)/) }) diff --git a/tests/lib/running-status-safety.test.js b/tests/lib/running-status-safety.test.js index ff63e71..886ee88 100644 --- a/tests/lib/running-status-safety.test.js +++ b/tests/lib/running-status-safety.test.js @@ -10,6 +10,7 @@ const serverSource = fs.readFileSync("app/(dashboard)/_components/performance-se const usageSource = fs.readFileSync("app/(dashboard)/_components/performance-usage-card.tsx", "utf8") const healthSource = fs.readFileSync("app/(dashboard)/_components/performance-infrastructure-card.tsx", "utf8") const backendSource = fs.readFileSync("app/(dashboard)/_components/performance-backend-card.tsx", "utf8") +const statusSourcesSource = fs.readFileSync("app/(dashboard)/_components/performance-status-sources.tsx", "utf8") test("performance refresh keeps partial data and rejects stale responses", () => { assert.match(hookSource, /Promise\.allSettled/) @@ -17,6 +18,13 @@ test("performance refresh keeps partial data and rejects stale responses", () => assert.match(hookSource, /sourceErrors/) assert.match(hookSource, /lastUpdatedAt/) assert.match(hookSource, /metricsUpdatedAt/) + assert.match(hookSource, /diagnosticsInfo/) + assert.match(hookSource, /usageUpdatedAt/) + assert.match(hookSource, /getClusterSnapshot/) + assert.match(hookSource, /adminDiscovery\?\.clusterSnapshot/) + assert.match(hookSource, /diagnosticsError/) + assert.match(hookSource, /refreshDiagnostics\(diagnosticsPath, requestVersion\)/) + assert.match(hookSource, /Cluster diagnostics timed out\./) assert.match(hookSource, /setMetricsUpdatedAt\(new Date\(\)\)/) assert.match(hookSource, /refreshing/) assert.match(hookSource, /new AbortController/) @@ -27,6 +35,15 @@ test("performance refresh keeps partial data and rejects stale responses", () => assert.ok((systemSource.match(/suppress403Redirect: true/g) ?? []).length >= 3) }) +test("cluster diagnostics are optional and use only the discovered admin path", () => { + assert.match(systemSource, /getClusterSnapshot/) + assert.match(systemSource, /api\.get\(api\.resolveUrl\(path\)/) + assert.match(systemSource, /suppress403Redirect: true/) + assert.doesNotMatch(systemSource, /getClusterSnapshot[\s\S]*?https?:\/\//) + assert.doesNotMatch(hookSource, /Promise\.allSettled\(\[[\s\S]*getClusterSnapshot[\s\S]*\]\)/) + assert.doesNotMatch(hookSource, /InvalidAccessKeyId|BrokenPipe/) +}) + test("running status skips Strict Mode preview requests", () => { assert.match(hookSource, /scheduleMicrotask\(\(\) => \{\s*if \(!cancelled\) void refetch\(\)/) assert.match(hookSource, /return \(\) => \{\s*cancelled = true/) @@ -70,6 +87,19 @@ test("status sections expose semantic headings and named progress", () => { assert.match(healthSource, /unavailableLabel/) assert.match(backendSource, /

{