mirror of
https://github.com/rustfs/console.git
synced 2026-10-30 19:33:40 +08:00
fix(status): separate cluster diagnostic states (#177)
This commit is contained in:
@@ -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<PerformanceServerSort>("attention")
|
||||
const [filterBy, setFilterBy] = React.useState<PerformanceServerFilter>("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[];
|
||||
<SelectValue>{sortLabels[sortBy]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(sortLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value} className="min-h-11">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectGroup>
|
||||
{Object.entries(sortLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value} className="min-h-11">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -247,8 +256,8 @@ export function PerformanceServerList({ servers, t }: { servers?: ServerInfo[];
|
||||
</div>
|
||||
) : visibleServers.length ? (
|
||||
<Accordion id="performance-server-list" className="space-y-2" aria-labelledby="server-list-title">
|
||||
{visibleServers.map(({ server, originalIndex }) => {
|
||||
const state = normalizeServerHealthState(server.state)
|
||||
{visibleServers.map(({ server, originalIndex, health }) => {
|
||||
const state = health.state
|
||||
return (
|
||||
<AccordionItem
|
||||
key={server.endpoint ?? `server-${originalIndex}`}
|
||||
@@ -256,11 +265,18 @@ export function PerformanceServerList({ servers, t }: { servers?: ServerInfo[];
|
||||
>
|
||||
<AccordionTrigger className="min-h-11 py-3">
|
||||
<div className="grid min-w-0 flex-1 gap-2 pe-3 text-start lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<Badge variant={getStateVariant(state)}>{getStateLabel(state, t)}</Badge>
|
||||
<span className="min-w-0 break-words font-semibold [overflow-wrap:anywhere]">
|
||||
{server.endpoint ?? t("Unknown")}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="break-words font-semibold [overflow-wrap:anywhere]">
|
||||
{server.endpoint ?? t("Unknown")}
|
||||
</span>
|
||||
{health.reason ? (
|
||||
<span className="break-words text-xs font-normal text-muted-foreground [overflow-wrap:anywhere]">
|
||||
{health.reason}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-muted-foreground sm:flex sm:flex-wrap">
|
||||
<span>
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid gap-2 py-4 sm:grid-cols-[minmax(0,12rem)_minmax(0,1fr)] sm:gap-6">
|
||||
<dt className="flex min-w-0 items-center justify-between gap-3 sm:flex-col sm:items-start sm:justify-start">
|
||||
<span className="font-medium text-foreground">{label}</span>
|
||||
<Badge variant={getStatusVariant(diagnostic.state)}>{getStatusLabel(diagnostic.state, t)}</Badge>
|
||||
</dt>
|
||||
<dd className="flex min-w-0 flex-col gap-2 text-muted-foreground">
|
||||
<p className="break-words text-foreground [overflow-wrap:anywhere]">
|
||||
{diagnostic.reason ?? getDefaultDescription(diagnostic.state, t)}
|
||||
</p>
|
||||
{diagnostic.lastSuccessfulUpdate || showLastSuccessfulUpdate ? (
|
||||
<p className="text-xs">
|
||||
{t("Last successful update")}:{" "}
|
||||
{diagnostic.lastSuccessfulUpdate ? formatTimestamp(diagnostic.lastSuccessfulUpdate, locale) : t("Unknown")}
|
||||
</p>
|
||||
) : null}
|
||||
{diagnostic.lastError ? (
|
||||
<p className="break-words text-xs [overflow-wrap:anywhere]">
|
||||
{t("Last error")}: {diagnostic.lastError}
|
||||
</p>
|
||||
) : null}
|
||||
{scopeParts.length ? (
|
||||
<p className="break-words text-xs [overflow-wrap:anywhere]">{scopeParts.join(" · ")}</p>
|
||||
) : null}
|
||||
{diagnostic.source ? (
|
||||
<p className="break-words text-xs [overflow-wrap:anywhere]">
|
||||
{t("Source")}: {diagnostic.source}
|
||||
</p>
|
||||
) : null}
|
||||
{diagnostic.historicalStallTimeouts !== undefined ? (
|
||||
<div className="space-y-1 text-xs">
|
||||
<p className="text-foreground">
|
||||
{t("Historical internode stall timeouts")}: {diagnostic.historicalStallTimeouts}
|
||||
</p>
|
||||
<p>
|
||||
{t("This lifetime counter has no sampling window and does not indicate current degradation by itself.")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{diagnostic.hint ? (
|
||||
<p className="break-words text-xs [overflow-wrap:anywhere]">
|
||||
{t("Backend guidance")}: {diagnostic.hint}
|
||||
</p>
|
||||
) : null}
|
||||
{troubleshooting ? (
|
||||
<p className="text-xs text-foreground">
|
||||
{t("Troubleshooting")}: {troubleshooting}
|
||||
</p>
|
||||
) : null}
|
||||
{troubleshootingHref ? (
|
||||
<a
|
||||
className="w-fit text-xs font-medium text-foreground underline underline-offset-4"
|
||||
href={troubleshootingHref}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t("Open the real multi-node metrics verification guide")}
|
||||
</a>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<h2 id="status-sources-title" className="text-base font-semibold">
|
||||
{t("Status Sources")}
|
||||
</h2>
|
||||
<CardDescription>
|
||||
{t("Review peer, storage, usage, listing, and workload admission health independently.")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl aria-labelledby="status-sources-title">
|
||||
{rows.map((row, index) => (
|
||||
<React.Fragment key={row.label}>
|
||||
{index ? <Separator /> : null}
|
||||
<DiagnosticRow
|
||||
label={row.label}
|
||||
diagnostic={row.diagnostic}
|
||||
troubleshooting={row.troubleshooting}
|
||||
troubleshootingHref={row.troubleshootingHref}
|
||||
showLastSuccessfulUpdate={row.showLastSuccessfulUpdate}
|
||||
t={t}
|
||||
locale={locale}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<PerformanceStatusSources
|
||||
diagnostics={diagnosticsInfo}
|
||||
usageFreshness={diagnosticsInfo ? usageFreshness : undefined}
|
||||
t={t}
|
||||
locale={i18n.resolvedLanguage}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<div className="order-1">
|
||||
<PerformanceInfrastructureCard
|
||||
@@ -267,7 +291,7 @@ export default function PerformancePage() {
|
||||
</div>
|
||||
|
||||
<div className="order-2 xl:order-3 xl:col-span-2">
|
||||
<PerformanceServerList servers={systemInfo.servers} t={t} />
|
||||
<PerformanceServerList servers={systemInfo.servers} diagnostics={diagnosticsInfo} t={t} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<Record<PerformanceDataSource, string>>
|
||||
|
||||
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<MetricsInfo>({})
|
||||
const [systemInfo, setSystemInfo] = useState<SystemInfo>({})
|
||||
const [datausageinfo, setDatausageinfo] = useState<DataUsageInfo>({})
|
||||
const [storageinfo, setStorageinfo] = useState<StorageInfo>({})
|
||||
const [diagnosticsInfo, setDiagnosticsInfo] = useState<ClusterDiagnostics | undefined>()
|
||||
const [hasLoaded, setHasLoaded] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [sourceErrors, setSourceErrors] = useState<PerformanceSourceErrors>({})
|
||||
const [diagnosticsError, setDiagnosticsError] = useState<string | null>(null)
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<Date | null>(null)
|
||||
const [metricsUpdatedAt, setMetricsUpdatedAt] = useState<Date | null>(null)
|
||||
const [usageUpdatedAt, setUsageUpdatedAt] = useState<Date | null>(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<AbortController | null>(null)
|
||||
const diagnosticsAbortControllerRef = React.useRef<AbortController | null>(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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
+27
-1
@@ -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."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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.": "分别检查节点、存储、用量、列表服务和工作负载准入状态。"
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+333
-2
@@ -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<OperationalStatus, number> = {
|
||||
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<unknown>(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<unknown>(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<unknown>(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<ServerHealthState, number> {
|
||||
export function summarizeServerStates(
|
||||
servers: ServerInfo[] | undefined,
|
||||
diagnostics?: ClusterDiagnostics,
|
||||
): Record<ServerHealthState, number> {
|
||||
const summary: Record<ServerHealthState, number> = {
|
||||
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
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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, /<Badge variant=\{getStateVariant\(state\)\}>\{getStateLabel\(state, t\)\}<\/Badge>/)
|
||||
assert.match(source, /health\.reason/)
|
||||
assert.match(source, /aria-pressed=\{selected\}/)
|
||||
assert.doesNotMatch(source, /!isOnlineServer\(server\)/)
|
||||
})
|
||||
|
||||
@@ -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, /<h2/)
|
||||
assert.match(backendSource, /Storage Configuration/)
|
||||
assert.match(statusSourcesSource, /<h2/)
|
||||
assert.match(statusSourcesSource, /Peer Health/)
|
||||
assert.match(statusSourcesSource, /Storage Readiness/)
|
||||
assert.match(statusSourcesSource, /Usage Freshness/)
|
||||
assert.match(statusSourcesSource, /Listing and Metacache/)
|
||||
assert.match(statusSourcesSource, /Workload Admission/)
|
||||
assert.match(statusSourcesSource, /Not reported/)
|
||||
assert.match(statusSourcesSource, /Historical internode stall timeouts/)
|
||||
assert.match(statusSourcesSource, /issuecomment-5040442761/)
|
||||
assert.match(statusSourcesSource, /<Badge/)
|
||||
assert.match(statusSourcesSource, /<Separator/)
|
||||
assert.match(statusSourcesSource, /Correlate time-windowed walk_dir metrics and metacache logs/)
|
||||
assert.doesNotMatch(pageSource, /\{diagnosticsInfo \? \(/)
|
||||
})
|
||||
|
||||
test("running status uses backend usable free capacity without synthesizing it", () => {
|
||||
|
||||
Reference in New Issue
Block a user