fix(status): detect incomplete cluster topology (#178)

This commit is contained in:
cxymds
2026-07-23 10:25:31 +08:00
committed by GitHub
parent 64809a3d0c
commit b0fc482905
20 changed files with 490 additions and 26 deletions
@@ -2,6 +2,7 @@
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card"
import type { RunningStatusTopology } from "@/lib/performance-data"
function HealthMetric({ label, value, unavailableLabel }: { label: string; value?: number; unavailableLabel: string }) {
return (
@@ -21,6 +22,7 @@ export function PerformanceInfrastructureCard({
onlineDisks,
offlineDisks,
unknownDisks,
topology,
t,
}: {
onlineServers?: number
@@ -31,6 +33,7 @@ export function PerformanceInfrastructureCard({
onlineDisks?: number
offlineDisks?: number
unknownDisks?: number
topology: RunningStatusTopology
t: (key: string) => string
}) {
const needsAttention = Boolean((offlineServers ?? 0) + (degradedServers ?? 0) + (offlineDisks ?? 0))
@@ -42,16 +45,19 @@ export function PerformanceInfrastructureCard({
diskCounts.some((value) => value === undefined) ||
serverCounts.reduce<number>((total, value) => total + (value ?? 0), 0) === 0 ||
diskCounts.reduce<number>((total, value) => total + (value ?? 0), 0) === 0
const hasIncompleteTopology = topology.incomplete
const status = needsAttention
? t("Needs attention")
: hasUnknownState || dataUnavailable
: hasUnknownState || dataUnavailable || hasIncompleteTopology
? t("Unknown")
: t("Healthy")
const description = needsAttention
? t("Offline or degraded resources require attention.")
: hasUnknownState || dataUnavailable
? t("Some resource health data is unavailable or still initializing.")
: t("All reported servers and disks are online.")
: hasIncompleteTopology
? t("The reported health rows do not cover the full cluster topology.")
: hasUnknownState || dataUnavailable
? t("Some resource health data is unavailable or still initializing.")
: t("All reported servers and disks are online.")
return (
<Card className="h-full shadow-none">
@@ -64,7 +70,13 @@ export function PerformanceInfrastructureCard({
<CardDescription>{description}</CardDescription>
</div>
<Badge
variant={needsAttention ? "destructive" : hasUnknownState || dataUnavailable ? "outline" : "secondary"}
variant={
needsAttention
? "destructive"
: hasUnknownState || dataUnavailable || hasIncompleteTopology
? "outline"
: "secondary"
}
aria-live="polite"
>
{status}
@@ -94,6 +106,12 @@ export function PerformanceInfrastructureCard({
<HealthMetric label={t("Unknown")} value={unknownServers} unavailableLabel={t("Unavailable")} />
) : null}
</dl>
{topology.expectedServers !== undefined ? (
<p className="mt-3 text-xs text-muted-foreground tabular-nums">
{t("Reported")}: {topology.reportedServers ?? t("Unavailable")} · {t("Expected")}:{" "}
{topology.expectedServers}
</p>
) : null}
</section>
<section aria-labelledby="disk-health-title">
<h3 id="disk-health-title" className="text-sm font-medium text-muted-foreground">
@@ -105,7 +123,20 @@ export function PerformanceInfrastructureCard({
{unknownDisks ? (
<HealthMetric label={t("Unknown")} value={unknownDisks} unavailableLabel={t("Unavailable")} />
) : null}
{topology.unreportedDrives ? (
<HealthMetric
label={t("Unreported")}
value={topology.unreportedDrives}
unavailableLabel={t("Unavailable")}
/>
) : null}
</dl>
{topology.expectedDrives !== undefined ? (
<p className="mt-3 text-xs text-muted-foreground tabular-nums">
{t("Reported")}: {topology.reportedDrives ?? t("Unavailable")} · {t("Expected")}:{" "}
{topology.expectedDrives}
</p>
) : null}
</section>
</div>
</CardContent>
@@ -8,7 +8,12 @@ import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/
import { Progress } from "@/components/ui/progress"
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { niceBytes } from "@/lib/functions"
import { resolveServerHealth, type ClusterDiagnostics, type ServerHealthState } from "@/lib/performance-data"
import {
resolveServerHealth,
type ClusterDiagnostics,
type RunningStatusTopology,
type ServerHealthState,
} from "@/lib/performance-data"
import { cn } from "@/lib/utils"
import type { ServerInfo } from "@/hooks/use-performance-data"
@@ -117,10 +122,12 @@ const filterOrder: ServerHealthState[] = ["offline", "degraded", "initializing",
export function PerformanceServerList({
servers,
diagnostics,
topology,
t,
}: {
servers?: ServerInfo[]
diagnostics?: ClusterDiagnostics
topology: RunningStatusTopology
t: Translate
}) {
const [sortBy, setSortBy] = React.useState<PerformanceServerSort>("attention")
@@ -191,7 +198,20 @@ export function PerformanceServerList({
</div>
{servers !== undefined ? (
<span className="text-sm text-muted-foreground" role="status" aria-live="polite">
{t("Total")}: {visibleServers.length}/{reportedServers.length}
{topology.expectedServers !== undefined ? (
<>
{t("Total")}: {visibleServers.length}/{topology.expectedServers} · {t("Reported")}:{" "}
{topology.reportedServers ?? t("Unavailable")}
</>
) : topology.incomplete ? (
<>
{t("Reported")}: {topology.reportedServers ?? reportedServers.length}
</>
) : (
<>
{t("Total")}: {visibleServers.length}/{reportedServers.length}
</>
)}
</span>
) : null}
</div>
+12 -5
View File
@@ -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, resolveUsageFreshness, summarizeServerStates } from "@/lib/performance-data"
import { buildRunningStatusView, formatRelativeTime, resolveUsageFreshness } from "@/lib/performance-data"
import { cn } from "@/lib/utils"
import {
RiArchiveDrawerFill,
@@ -130,10 +130,11 @@ export default function PerformancePage() {
[i18n.resolvedLanguage, metricsUpdatedAt, scannerCompletedAt, scannerDuration, scannerStatus, t],
)
const serverSummary = useMemo(
() => (systemInfo.servers ? summarizeServerStates(systemInfo.servers, diagnosticsInfo) : undefined),
[diagnosticsInfo, systemInfo.servers],
const runningStatus = useMemo(
() => buildRunningStatusView(systemInfo, diagnosticsInfo),
[diagnosticsInfo, systemInfo],
)
const serverSummary = runningStatus.serverSummary
const usageFreshness = useMemo(
() =>
@@ -275,6 +276,7 @@ export default function PerformancePage() {
onlineDisks={systemInfo.backend?.onlineDisks}
offlineDisks={systemInfo.backend?.offlineDisks}
unknownDisks={systemInfo.backend?.unknownDisks}
topology={runningStatus.topology}
t={t}
/>
</div>
@@ -291,7 +293,12 @@ export default function PerformancePage() {
</div>
<div className="order-2 xl:order-3 xl:col-span-2">
<PerformanceServerList servers={systemInfo.servers} diagnostics={diagnosticsInfo} t={t} />
<PerformanceServerList
servers={runningStatus.servers}
diagnostics={diagnosticsInfo}
topology={runningStatus.topology}
t={t}
/>
</div>
</div>
+4
View File
@@ -1371,6 +1371,10 @@
"Clear Decommission": "Clear Decommission",
"All": "الكل",
"All reported servers and disks are online.": "جميع الخوادم والأقراص المبلّغ عنها متصلة.",
"Expected": "المتوقع",
"Reported": "المبلّغ عنه",
"Unreported": "غير مبلّغ عنه",
"The reported health rows do not cover the full cluster topology.": "لا تغطي حالات الصحة المبلّغ عنها مخطط المجموعة بالكامل.",
"Check your connection, then refresh the status page.": "تحقق من اتصالك، ثم حدّث صفحة الحالة.",
"Cluster Health": "صحة المجموعة",
"Cluster information": "معلومات المجموعة",
+4
View File
@@ -1376,6 +1376,10 @@
"Clear Decommission": "Clear Decommission",
"All": "Alle",
"All reported servers and disks are online.": "Alle gemeldeten Server und Laufwerke sind online.",
"Expected": "Erwartet",
"Reported": "Gemeldet",
"Unreported": "Nicht gemeldet",
"The reported health rows do not cover the full cluster topology.": "Die gemeldeten Integritätsdaten decken nicht die vollständige Clustertopologie ab.",
"Check your connection, then refresh the status page.": "Prüfen Sie Ihre Verbindung und aktualisieren Sie dann die Statusseite.",
"Cluster Health": "Clusterzustand",
"Cluster information": "Clusterinformationen",
+4
View File
@@ -1376,6 +1376,10 @@
"Clear Decommission": "Clear Decommission",
"All": "All",
"All reported servers and disks are online.": "All reported servers and disks are online.",
"Expected": "Expected",
"Reported": "Reported",
"Unreported": "Unreported",
"The reported health rows do not cover the full cluster topology.": "The reported health rows do not cover the full cluster topology.",
"Check your connection, then refresh the status page.": "Check your connection, then refresh the status page.",
"Cluster Health": "Cluster Health",
"Cluster information": "Cluster information",
+4
View File
@@ -1376,6 +1376,10 @@
"Clear Decommission": "Clear Decommission",
"All": "Todos",
"All reported servers and disks are online.": "Todos los servidores y discos informados están en línea.",
"Expected": "Esperado",
"Reported": "Informado",
"Unreported": "No informado",
"The reported health rows do not cover the full cluster topology.": "Los estados de salud informados no cubren toda la topología del clúster.",
"Check your connection, then refresh the status page.": "Comprueba la conexión y actualiza la página de estado.",
"Cluster Health": "Estado del clúster",
"Cluster information": "Información del clúster",
+4
View File
@@ -1376,6 +1376,10 @@
"Clear Decommission": "Clear Decommission",
"All": "Tous",
"All reported servers and disks are online.": "Tous les serveurs et disques signalés sont en ligne.",
"Expected": "Attendu",
"Reported": "Signalé",
"Unreported": "Non signalé",
"The reported health rows do not cover the full cluster topology.": "Les états de santé signalés ne couvrent pas toute la topologie du cluster.",
"Check your connection, then refresh the status page.": "Vérifiez votre connexion, puis actualisez la page d’état.",
"Cluster Health": "État du cluster",
"Cluster information": "Informations sur le cluster",
+4
View File
@@ -1376,6 +1376,10 @@
"Clear Decommission": "Clear Decommission",
"All": "Semua",
"All reported servers and disks are online.": "Semua server dan disk yang dilaporkan sedang online.",
"Expected": "Diharapkan",
"Reported": "Dilaporkan",
"Unreported": "Tidak dilaporkan",
"The reported health rows do not cover the full cluster topology.": "Baris kesehatan yang dilaporkan tidak mencakup seluruh topologi klaster.",
"Check your connection, then refresh the status page.": "Periksa koneksi, lalu muat ulang halaman status.",
"Cluster Health": "Kesehatan klaster",
"Cluster information": "Informasi klaster",
+4
View File
@@ -1376,6 +1376,10 @@
"Clear Decommission": "Clear Decommission",
"All": "Tutti",
"All reported servers and disks are online.": "Tutti i server e le unità segnalati sono online.",
"Expected": "Previsto",
"Reported": "Segnalato",
"Unreported": "Non segnalato",
"The reported health rows do not cover the full cluster topology.": "Gli stati di integrità segnalati non coprono l'intera topologia del cluster.",
"Check your connection, then refresh the status page.": "Controlla la connessione, quindi aggiorna la pagina di stato.",
"Cluster Health": "Stato del cluster",
"Cluster information": "Informazioni sul cluster",
+4
View File
@@ -1371,6 +1371,10 @@
"Clear Decommission": "Clear Decommission",
"All": "すべて",
"All reported servers and disks are online.": "報告されたすべてのサーバーとディスクはオンラインです。",
"Expected": "想定",
"Reported": "報告済み",
"Unreported": "未報告",
"The reported health rows do not cover the full cluster topology.": "報告されたヘルス情報はクラスター全体のトポロジーをカバーしていません。",
"Check your connection, then refresh the status page.": "接続を確認して、ステータスページを更新してください。",
"Cluster Health": "クラスターの状態",
"Cluster information": "クラスター情報",
+4
View File
@@ -1371,6 +1371,10 @@
"Clear Decommission": "Clear Decommission",
"All": "전체",
"All reported servers and disks are online.": "보고된 모든 서버와 디스크가 온라인 상태입니다.",
"Expected": "예상",
"Reported": "보고됨",
"Unreported": "보고되지 않음",
"The reported health rows do not cover the full cluster topology.": "보고된 상태 정보가 전체 클러스터 토폴로지를 포함하지 않습니다.",
"Check your connection, then refresh the status page.": "연결을 확인한 후 상태 페이지를 새로고침하세요.",
"Cluster Health": "클러스터 상태",
"Cluster information": "클러스터 정보",
+4
View File
@@ -1376,6 +1376,10 @@
"Clear Decommission": "Clear Decommission",
"All": "Todos",
"All reported servers and disks are online.": "Todos os servidores e discos reportados estão online.",
"Expected": "Esperado",
"Reported": "Reportado",
"Unreported": "Não reportado",
"The reported health rows do not cover the full cluster topology.": "Os estados de integridade reportados não cobrem toda a topologia do cluster.",
"Check your connection, then refresh the status page.": "Verifique sua conexão e atualize a página de status.",
"Cluster Health": "Integridade do cluster",
"Cluster information": "Informações do cluster",
+4
View File
@@ -1376,6 +1376,10 @@
"Clear Decommission": "Очистить декомиссию",
"All": "Все",
"All reported servers and disks are online.": "Все указанные серверы и диски доступны.",
"Expected": "Ожидается",
"Reported": "Сообщено",
"Unreported": "Не сообщено",
"The reported health rows do not cover the full cluster topology.": "Полученные данные о состоянии не охватывают полную топологию кластера.",
"Check your connection, then refresh the status page.": "Проверьте подключение и обновите страницу состояния.",
"Cluster Health": "Состояние кластера",
"Cluster information": "Сведения о кластере",
+4
View File
@@ -1376,6 +1376,10 @@
"Clear Decommission": "Clear Decommission",
"All": "Tümü",
"All reported servers and disks are online.": "Bildirilen tüm sunucu ve diskler çevrimiçi.",
"Expected": "Beklenen",
"Reported": "Bildirilen",
"Unreported": "Bildirilmemiş",
"The reported health rows do not cover the full cluster topology.": "Bildirilen sağlık satırları küme topolojisinin tamamını kapsamıyor.",
"Check your connection, then refresh the status page.": "Bağlantınızı kontrol edip durum sayfasını yenileyin.",
"Cluster Health": "Küme durumu",
"Cluster information": "Küme bilgileri",
+4
View File
@@ -1376,6 +1376,10 @@
"Clear Decommission": "Clear Decommission",
"All": "Tất cả",
"All reported servers and disks are online.": "Tất cả máy chủ và ổ đĩa được báo cáo đều đang trực tuyến.",
"Expected": "Dự kiến",
"Reported": "Đã báo cáo",
"Unreported": "Chưa báo cáo",
"The reported health rows do not cover the full cluster topology.": "Các trạng thái sức khỏe được báo cáo chưa bao phủ toàn bộ cấu trúc liên kết cụm.",
"Check your connection, then refresh the status page.": "Kiểm tra kết nối, sau đó làm mới trang trạng thái.",
"Cluster Health": "Tình trạng cụm",
"Cluster information": "Thông tin cụm",
+4
View File
@@ -1371,6 +1371,10 @@
"Clear Decommission": "清理退役",
"All": "全部",
"All reported servers and disks are online.": "已上报的服务器和磁盘均在线。",
"Expected": "预期",
"Reported": "已上报",
"Unreported": "未上报",
"The reported health rows do not cover the full cluster topology.": "已上报的健康状态未覆盖完整的集群拓扑。",
"Check your connection, then refresh the status page.": "请检查连接,然后刷新状态页。",
"Cluster Health": "集群健康状态",
"Cluster information": "集群信息",
+229 -13
View File
@@ -29,7 +29,18 @@ export interface ClusterDiagnostics {
listingHealth: StatusDiagnostic
workloadAdmission: StatusDiagnostic
peers: PeerHealthDiagnostic[]
membership: Array<{ nodeId: string; gridHost?: string }>
membership: Array<{ nodeId: string; gridHost?: string; isLocal?: boolean }>
topologyDrives: Array<{
nodeId: string
poolIndex?: number
setIndex?: number
diskIndex?: number
}>
pools: Array<{
poolIndex?: number
drivesPerSet?: number
endpointCount?: number
}>
}
export interface ServerInfo {
@@ -58,12 +69,30 @@ export interface SystemInfo {
onlineDisks?: number
offlineDisks?: number
unknownDisks?: number
totalDrivesPerSet?: number[]
}
adminDiscovery?: {
clusterSnapshot: string
}
}
export interface RunningStatusTopology {
source: "v4" | "v3" | "reported"
expectedServers?: number
reportedServers?: number
expectedDrives?: number
reportedDrives?: number
unreportedServers?: number
unreportedDrives?: number
incomplete: boolean
}
export interface RunningStatusView {
servers?: ServerInfo[]
serverSummary?: Record<ServerHealthState, number>
topology: RunningStatusTopology
}
export interface DataUsageInfo {
total_capacity?: number
total_free_capacity?: number
@@ -223,6 +252,12 @@ export function normalizeSystemInfo(value: unknown): SystemInfo {
return normalized ? [normalized] : []
})
: undefined
const totalDrivesPerSet = asArray<unknown>(backend.totalDrivesPerSet ?? backend.TotalDrivesPerSet).flatMap(
(value) => {
const count = asNonNegativeNumber(value)
return count === undefined ? [] : [count]
},
)
return {
...(buckets ? { buckets } : {}),
@@ -235,6 +270,7 @@ export function normalizeSystemInfo(value: unknown): SystemInfo {
onlineDisks: asNonNegativeNumber(backend.onlineDisks ?? backend.OnlineDisks),
offlineDisks: asNonNegativeNumber(backend.offlineDisks ?? backend.OfflineDisks),
unknownDisks: asNonNegativeNumber(backend.unknownDisks ?? backend.UnknownDisks),
...(totalDrivesPerSet.length ? { totalDrivesPerSet } : {}),
},
}
: {}),
@@ -356,7 +392,39 @@ export function normalizeClusterDiagnostics(value: unknown): ClusterDiagnostics
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 isLocal = asBoolean(node.is_local ?? node.isLocal ?? node.IsLocal)
return [{ nodeId, ...(gridHost ? { gridHost } : {}), ...(isLocal !== undefined ? { isLocal } : {}) }]
})
const topologyDrives = asArray<unknown>(membershipRecord.drives ?? membershipRecord.Drives).flatMap((value) => {
const drive = asRecord(value)
const nodeId = asSafeText(drive.node_id ?? drive.nodeId ?? drive.NodeId)
if (!nodeId) return []
const poolIndex = asNonNegativeNumber(drive.pool_index ?? drive.poolIndex ?? drive.PoolIndex)
const setIndex = asNonNegativeNumber(drive.set_index ?? drive.setIndex ?? drive.SetIndex)
const diskIndex = asNonNegativeNumber(drive.disk_index ?? drive.diskIndex ?? drive.DiskIndex)
return [
{
nodeId,
...(poolIndex !== undefined ? { poolIndex } : {}),
...(setIndex !== undefined ? { setIndex } : {}),
...(diskIndex !== undefined ? { diskIndex } : {}),
},
]
})
const poolState = asRecord(snapshot.pool_state ?? snapshot.poolState ?? snapshot.PoolState)
const pools = asArray<unknown>(poolState.pools ?? poolState.Pools).flatMap((value) => {
const pool = asRecord(value)
if (!Object.keys(pool).length) return []
const poolIndex = asNonNegativeNumber(pool.pool_index ?? pool.poolIndex ?? pool.PoolIndex)
const drivesPerSet = asNonNegativeNumber(pool.drives_per_set ?? pool.drivesPerSet ?? pool.DrivesPerSet)
const endpointCount = asNonNegativeNumber(pool.endpoint_count ?? pool.endpointCount ?? pool.EndpointCount)
return [
{
...(poolIndex !== undefined ? { poolIndex } : {}),
...(drivesPerSet !== undefined ? { drivesPerSet } : {}),
...(endpointCount !== undefined ? { endpointCount } : {}),
},
]
})
const peerRecord = asRecord(snapshot.peer_health ?? snapshot.peerHealth ?? snapshot.PeerHealth)
@@ -440,27 +508,46 @@ export function normalizeClusterDiagnostics(value: unknown): ClusterDiagnostics
workloadAdmission,
peers,
membership,
topologyDrives,
pools,
}
}
function normalizeEndpointIdentity(value: string | undefined) {
function getEndpointIdentity(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
return { host: url.host, hostname: url.hostname }
} catch {
return trimmed.replace(/^https?:\/\//, "")
const host = trimmed.replace(/^https?:\/\//, "")
return { host, hostname: host.split(":")[0] }
}
}
function findPeerDiagnostic(server: ServerInfo, diagnostics: ClusterDiagnostics) {
const serverIdentity = normalizeEndpointIdentity(server.endpoint)
function getMemberIdentities(member: ClusterDiagnostics["membership"][number]) {
return [getEndpointIdentity(member.nodeId), getEndpointIdentity(member.gridHost)].filter(
(identity): identity is NonNullable<ReturnType<typeof getEndpointIdentity>> => Boolean(identity),
)
}
function findMatchingMember(server: ServerInfo, diagnostics: ClusterDiagnostics) {
const serverIdentity = getEndpointIdentity(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)
})
const exactMatches = diagnostics.membership.filter((member) =>
getMemberIdentities(member).some((identity) => identity.host === serverIdentity.host),
)
if (exactMatches.length === 1) return exactMatches[0]
const hostnameMatches = diagnostics.membership.filter((member) =>
getMemberIdentities(member).some((identity) => identity.hostname === serverIdentity.hostname),
)
return hostnameMatches.length === 1 ? hostnameMatches[0] : undefined
}
function findPeerDiagnostic(server: ServerInfo, diagnostics: ClusterDiagnostics) {
const member = findMatchingMember(server, diagnostics)
return member ? diagnostics.peers.find((peer) => peer.nodeId === member.nodeId) : undefined
}
function hasOnlyHealthyDrives(server: ServerInfo) {
@@ -484,8 +571,7 @@ export function resolveServerHealth(
}
if (
(peer.state === "unknown" || peer.state === "stale" || peer.state === "not_reported") &&
legacyState === "degraded" &&
hasOnlyHealthyDrives(server)
(legacyState === "unknown" || (legacyState === "degraded" && hasOnlyHealthyDrives(server)))
) {
return { state: "unknown", ...(peer.reason ? { reason: peer.reason } : {}), source: "peer" }
}
@@ -584,6 +670,136 @@ export function summarizeServerStates(
return summary
}
function getUniqueMembership(diagnostics: ClusterDiagnostics | undefined) {
const members = diagnostics?.membership ?? []
return Array.from(new Map(members.map((member) => [member.nodeId, member])).values())
}
function findServerIndexForMember(
member: ClusterDiagnostics["membership"][number],
members: ClusterDiagnostics["membership"],
servers: ServerInfo[],
usedIndexes: Set<number>,
) {
const memberIdentities = getMemberIdentities(member)
const available = servers.flatMap((server, index) => {
if (usedIndexes.has(index)) return []
const identity = getEndpointIdentity(server.endpoint)
return identity ? [{ identity, index }] : []
})
const exactMatches = available.filter(({ identity }) =>
memberIdentities.some((memberIdentity) => memberIdentity.host === identity.host),
)
if (exactMatches.length) return exactMatches[0].index
const memberHostnames = new Set(memberIdentities.map((identity) => identity.hostname))
const hostnameMatches = available.filter(({ identity }) => memberHostnames.has(identity.hostname))
if (hostnameMatches.length !== 1) return undefined
const sharedMemberHostname = members.some(
(candidate) =>
candidate !== member && getMemberIdentities(candidate).some((identity) => memberHostnames.has(identity.hostname)),
)
const sharedServerHostname = servers.some((server, index) => {
if (index === hostnameMatches[0].index) return false
const identity = getEndpointIdentity(server.endpoint)
return identity ? memberHostnames.has(identity.hostname) : false
})
return sharedMemberHostname || sharedServerHostname ? undefined : hostnameMatches[0].index
}
function reconcileTopologyServers(system: SystemInfo, diagnostics: ClusterDiagnostics | undefined) {
const reportedServers = system.servers ?? []
const membership = getUniqueMembership(diagnostics)
if (!membership.length) {
return {
servers: system.servers,
reportedServers: system.servers?.length,
expectedServers: undefined,
}
}
const usedIndexes = new Set<number>()
let matchedServers = 0
const servers = membership.map((member): ServerInfo => {
const serverIndex = findServerIndexForMember(member, membership, reportedServers, usedIndexes)
if (serverIndex !== undefined) {
usedIndexes.add(serverIndex)
matchedServers += 1
return reportedServers[serverIndex]
}
return {
endpoint: member.gridHost ?? member.nodeId,
state: "unknown",
drives: [],
network: {},
}
})
reportedServers.forEach((server, index) => {
if (!usedIndexes.has(index)) servers.push(server)
})
return {
servers,
reportedServers: matchedServers,
expectedServers: membership.length,
}
}
function sumDefined(values: Array<number | undefined>) {
return values.some((value) => value !== undefined)
? values.reduce<number>((total, value) => total + (value ?? 0), 0)
: undefined
}
export function buildRunningStatusView(system: SystemInfo, diagnostics?: ClusterDiagnostics): RunningStatusView {
const reconciled = reconcileTopologyServers(system, diagnostics)
const v4ExpectedDrives = diagnostics?.topologyDrives.length
? new Set(
diagnostics.topologyDrives.map(
(drive) => `${drive.poolIndex ?? ""}:${drive.setIndex ?? ""}:${drive.diskIndex ?? ""}:${drive.nodeId}`,
),
).size
: sumDefined(diagnostics?.pools.map((pool) => pool.endpointCount) ?? [])
const v3ExpectedDrives = system.backend?.totalDrivesPerSet?.reduce((total, count) => total + count, 0)
const reportedDrives = sumDefined([
system.backend?.onlineDisks,
system.backend?.offlineDisks,
system.backend?.unknownDisks,
])
const hasV4Topology = reconciled.expectedServers !== undefined || v4ExpectedDrives !== undefined
const expectedDrives = v4ExpectedDrives ?? v3ExpectedDrives
const source = hasV4Topology ? "v4" : v3ExpectedDrives !== undefined ? "v3" : "reported"
const unreportedServers =
reconciled.expectedServers !== undefined && reconciled.reportedServers !== undefined
? Math.max(0, reconciled.expectedServers - reconciled.reportedServers)
: undefined
const unreportedDrives =
expectedDrives !== undefined && reportedDrives !== undefined
? Math.max(0, expectedDrives - reportedDrives)
: undefined
const incomplete = Boolean(
(reconciled.expectedServers !== undefined && reconciled.reportedServers !== reconciled.expectedServers) ||
(expectedDrives !== undefined && reportedDrives !== expectedDrives),
)
return {
servers: reconciled.servers,
serverSummary: reconciled.servers ? summarizeServerStates(reconciled.servers, diagnostics) : undefined,
topology: {
source,
...(reconciled.expectedServers !== undefined ? { expectedServers: reconciled.expectedServers } : {}),
...(reconciled.reportedServers !== undefined ? { reportedServers: reconciled.reportedServers } : {}),
...(expectedDrives !== undefined ? { expectedDrives } : {}),
...(reportedDrives !== undefined ? { reportedDrives } : {}),
...(unreportedServers !== undefined ? { unreportedServers } : {}),
...(unreportedDrives !== undefined ? { unreportedDrives } : {}),
incomplete,
},
}
}
export function formatRelativeTime(timestamp: string | Date, locale: string | undefined, now = Date.now()) {
const time = timestamp instanceof Date ? timestamp.getTime() : Date.parse(timestamp)
if (!Number.isFinite(time)) return undefined
+130
View File
@@ -2,6 +2,7 @@ import test from "node:test"
import assert from "node:assert/strict"
import {
buildRunningStatusView,
formatRelativeTime,
normalizeClusterDiagnostics,
normalizeDataUsageInfo,
@@ -13,6 +14,52 @@ import {
summarizeServerStates,
} from "../../lib/performance-data.ts"
const reporterV3Payload = {
info: {
backend: {
backendType: "Erasure",
onlineDisks: 3,
offlineDisks: 0,
unknownDisks: 0,
totalDrivesPerSet: [4],
},
servers: [1, 2, 3].map((index) => ({
endpoint: `rustfs-${index}.storage.swarm.private`,
state: "online",
drives: [{ state: "ok" }],
})),
},
admin_discovery: {
clusterSnapshot: "/rustfs/admin/v4/cluster/snapshot",
},
}
const reporterV4Payload = {
snapshot: {
membership: {
nodes: [1, 2, 3, 4].map((index) => ({
node_id: `rustfs-${index}.storage.swarm.private:9000`,
grid_host: `http://rustfs-${index}.storage.swarm.private:9000`,
})),
drives: [1, 2, 3, 4].map((index) => ({
pool_index: 0,
set_index: 0,
disk_index: index - 1,
node_id: `rustfs-${index}.storage.swarm.private:9000`,
})),
},
pool_state: {
pools: [{ pool_index: 0, set_count: 1, drives_per_set: 4, endpoint_count: 4 }],
},
peer_health: {
peers: [1, 2, 3, 4].map((index) => ({
node_id: `rustfs-${index}.storage.swarm.private:9000`,
status: { state: "unknown", reason: "peer health not reported by endpoints" },
})),
},
},
}
test("normalizeSystemInfo preserves legacy unwrapped info responses", () => {
const info = normalizeSystemInfo({
buckets: { count: 2 },
@@ -454,3 +501,86 @@ test("the #5070 compatibility shape preserves 3 online servers and 48 online dis
"not_reported",
)
})
test("the #1429 reporter payload uses v4 membership without duplicating v3 rows", () => {
const system = normalizeSystemInfo(reporterV3Payload)
const diagnostics = normalizeClusterDiagnostics(reporterV4Payload)
const view = buildRunningStatusView(system, diagnostics)
assert.equal(system.backend?.totalDrivesPerSet?.[0], 4)
assert.equal(diagnostics?.topologyDrives.length, 4)
assert.equal(view.servers?.length, 4)
assert.deepEqual(
view.servers?.map((server) => server.endpoint),
[
"rustfs-1.storage.swarm.private",
"rustfs-2.storage.swarm.private",
"rustfs-3.storage.swarm.private",
"http://rustfs-4.storage.swarm.private:9000",
],
)
assert.deepEqual(view.serverSummary, {
online: 3,
offline: 0,
degraded: 0,
initializing: 0,
unknown: 1,
})
assert.deepEqual(view.topology, {
source: "v4",
expectedServers: 4,
reportedServers: 3,
expectedDrives: 4,
reportedDrives: 3,
unreportedServers: 1,
unreportedDrives: 1,
incomplete: true,
})
})
test("a v3-only incomplete drive denominator is unknown without inventing a server denominator", () => {
const view = buildRunningStatusView(normalizeSystemInfo(reporterV3Payload))
assert.equal(view.servers?.length, 3)
assert.equal(view.topology.source, "v3")
assert.equal(view.topology.expectedServers, undefined)
assert.equal(view.topology.reportedServers, 3)
assert.equal(view.topology.expectedDrives, 4)
assert.equal(view.topology.reportedDrives, 3)
assert.equal(view.topology.unreportedDrives, 1)
assert.equal(view.topology.incomplete, true)
})
test("a fixed v3 unknown row remains visible without uptime, version, drives, or network", () => {
const payload = structuredClone(reporterV3Payload)
payload.info.servers.push({ endpoint: "rustfs-4.storage.swarm.private", state: "unknown" })
payload.info.backend.unknownDisks = 1
const view = buildRunningStatusView(normalizeSystemInfo(payload))
assert.equal(view.servers?.length, 4)
assert.deepEqual(view.serverSummary, {
online: 3,
offline: 0,
degraded: 0,
initializing: 0,
unknown: 1,
})
assert.equal(view.topology.reportedDrives, 4)
assert.equal(view.topology.unreportedDrives, 0)
assert.equal(view.topology.incomplete, false)
})
test("older v3 responses without topology or unknown disks keep an indeterminate denominator", () => {
const view = buildRunningStatusView(
normalizeSystemInfo({
backend: { onlineDisks: 2, offlineDisks: 0 },
servers: [{ endpoint: "node-a", state: "online" }],
}),
)
assert.equal(view.topology.source, "reported")
assert.equal(view.topology.expectedDrives, undefined)
assert.equal(view.topology.reportedDrives, 2)
assert.equal(view.topology.incomplete, false)
})
+5 -1
View File
@@ -5,11 +5,12 @@ 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, diagnosticsInfo\)/)
assert.match(source, /buildRunningStatusView\(systemInfo, diagnosticsInfo\)/)
assert.match(source, /unknownServers=\{serverSummary\?\.unknown\}/)
assert.match(source, /degradedServers=\{serverSummary\?\.degraded\}/)
assert.match(source, /initializingServers=\{serverSummary\?\.initializing\}/)
assert.match(source, /unknownDisks=\{systemInfo\.backend\?\.unknownDisks\}/)
assert.match(source, /topology=\{runningStatus\.topology\}/)
assert.doesNotMatch(source, /unknownDisks=.*\?\? 0/)
})
@@ -20,6 +21,9 @@ test("performance infrastructure card renders unknown, degraded, and initializin
assert.match(source, /degradedServers\?: number/)
assert.match(source, /initializingServers\?: number/)
assert.match(source, /unknownDisks\?: number/)
assert.match(source, /topology: RunningStatusTopology/)
assert.match(source, /hasIncompleteTopology/)
assert.match(source, /t\("Unreported"\)/)
assert.match(source, /\{t\("Unknown"\)\}/)
assert.match(source, /\{t\("Degraded"\)\}/)
assert.match(source, /\{t\("Initializing"\)\}/)