From 18e44fe68fc03e9d032a5049ffecd62dea21505c Mon Sep 17 00:00:00 2001 From: cxymds Date: Thu, 23 Jul 2026 10:21:25 +0800 Subject: [PATCH] fix(status): detect incomplete cluster topology --- .../performance-infrastructure-card.tsx | 41 ++- .../_components/performance-server-list.tsx | 24 +- app/(dashboard)/status/page.tsx | 17 +- i18n/locales/ar-MA.json | 4 + i18n/locales/de-DE.json | 4 + i18n/locales/en-US.json | 4 + i18n/locales/es-ES.json | 4 + i18n/locales/fr-FR.json | 4 + i18n/locales/id-ID.json | 4 + i18n/locales/it-IT.json | 4 + i18n/locales/ja-JP.json | 4 + i18n/locales/ko-KR.json | 4 + i18n/locales/pt-BR.json | 4 + i18n/locales/ru-RU.json | 4 + i18n/locales/tr-TR.json | 4 + i18n/locales/vi-VN.json | 4 + i18n/locales/zh-CN.json | 4 + lib/performance-data.ts | 242 +++++++++++++++++- tests/lib/performance-data.test.js | 130 ++++++++++ tests/lib/performance-status-source.test.js | 6 +- 20 files changed, 490 insertions(+), 26 deletions(-) diff --git a/app/(dashboard)/_components/performance-infrastructure-card.tsx b/app/(dashboard)/_components/performance-infrastructure-card.tsx index b5d46ca..1c85a7f 100644 --- a/app/(dashboard)/_components/performance-infrastructure-card.tsx +++ b/app/(dashboard)/_components/performance-infrastructure-card.tsx @@ -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((total, value) => total + (value ?? 0), 0) === 0 || diskCounts.reduce((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 ( @@ -64,7 +70,13 @@ export function PerformanceInfrastructureCard({ {description} {status} @@ -94,6 +106,12 @@ export function PerformanceInfrastructureCard({ ) : null} + {topology.expectedServers !== undefined ? ( +

+ {t("Reported")}: {topology.reportedServers ?? t("Unavailable")} · {t("Expected")}:{" "} + {topology.expectedServers} +

+ ) : null}

@@ -105,7 +123,20 @@ export function PerformanceInfrastructureCard({ {unknownDisks ? ( ) : null} + {topology.unreportedDrives ? ( + + ) : null} + {topology.expectedDrives !== undefined ? ( +

+ {t("Reported")}: {topology.reportedDrives ?? t("Unavailable")} · {t("Expected")}:{" "} + {topology.expectedDrives} +

+ ) : null}

diff --git a/app/(dashboard)/_components/performance-server-list.tsx b/app/(dashboard)/_components/performance-server-list.tsx index 8de9868..6252bcc 100644 --- a/app/(dashboard)/_components/performance-server-list.tsx +++ b/app/(dashboard)/_components/performance-server-list.tsx @@ -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("attention") @@ -191,7 +198,20 @@ export function PerformanceServerList({ {servers !== undefined ? ( - {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} + + )} ) : null} diff --git a/app/(dashboard)/status/page.tsx b/app/(dashboard)/status/page.tsx index bca1dd6..1226280 100644 --- a/app/(dashboard)/status/page.tsx +++ b/app/(dashboard)/status/page.tsx @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button" import { Spinner } from "@/components/ui/spinner" import { usePerformanceData, type PerformanceDataSource } from "@/hooks/use-performance-data" import { usePermissions } from "@/hooks/use-permissions" -import { formatRelativeTime, 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} /> @@ -291,7 +293,12 @@ export default function PerformancePage() {
- +
diff --git a/i18n/locales/ar-MA.json b/i18n/locales/ar-MA.json index f7bcdaa..a90f46b 100644 --- a/i18n/locales/ar-MA.json +++ b/i18n/locales/ar-MA.json @@ -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": "معلومات المجموعة", diff --git a/i18n/locales/de-DE.json b/i18n/locales/de-DE.json index 77d209f..cd8bdf1 100644 --- a/i18n/locales/de-DE.json +++ b/i18n/locales/de-DE.json @@ -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", diff --git a/i18n/locales/en-US.json b/i18n/locales/en-US.json index 5a93f5d..e86e82f 100644 --- a/i18n/locales/en-US.json +++ b/i18n/locales/en-US.json @@ -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", diff --git a/i18n/locales/es-ES.json b/i18n/locales/es-ES.json index a0fa416..b5224fd 100644 --- a/i18n/locales/es-ES.json +++ b/i18n/locales/es-ES.json @@ -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", diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index ed8b0e7..36861be 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -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", diff --git a/i18n/locales/id-ID.json b/i18n/locales/id-ID.json index 6fbb6cc..c7563c4 100644 --- a/i18n/locales/id-ID.json +++ b/i18n/locales/id-ID.json @@ -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", diff --git a/i18n/locales/it-IT.json b/i18n/locales/it-IT.json index 2c68495..7c63eda 100644 --- a/i18n/locales/it-IT.json +++ b/i18n/locales/it-IT.json @@ -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", diff --git a/i18n/locales/ja-JP.json b/i18n/locales/ja-JP.json index 21377c5..ffa1119 100644 --- a/i18n/locales/ja-JP.json +++ b/i18n/locales/ja-JP.json @@ -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": "クラスター情報", diff --git a/i18n/locales/ko-KR.json b/i18n/locales/ko-KR.json index 5c098fe..3eef108 100644 --- a/i18n/locales/ko-KR.json +++ b/i18n/locales/ko-KR.json @@ -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": "클러스터 정보", diff --git a/i18n/locales/pt-BR.json b/i18n/locales/pt-BR.json index 8ed948e..ae9f542 100644 --- a/i18n/locales/pt-BR.json +++ b/i18n/locales/pt-BR.json @@ -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", diff --git a/i18n/locales/ru-RU.json b/i18n/locales/ru-RU.json index d8ea347..c332788 100644 --- a/i18n/locales/ru-RU.json +++ b/i18n/locales/ru-RU.json @@ -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": "Сведения о кластере", diff --git a/i18n/locales/tr-TR.json b/i18n/locales/tr-TR.json index ae4763c..46f68ab 100644 --- a/i18n/locales/tr-TR.json +++ b/i18n/locales/tr-TR.json @@ -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", diff --git a/i18n/locales/vi-VN.json b/i18n/locales/vi-VN.json index 80afde0..73a83b1 100644 --- a/i18n/locales/vi-VN.json +++ b/i18n/locales/vi-VN.json @@ -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", diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index 99b8d15..65376f6 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -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": "集群信息", diff --git a/lib/performance-data.ts b/lib/performance-data.ts index 434dd7e..2abf68b 100644 --- a/lib/performance-data.ts +++ b/lib/performance-data.ts @@ -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 + 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(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(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(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> => 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, +) { + 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() + 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) { + return values.some((value) => value !== undefined) + ? values.reduce((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 diff --git a/tests/lib/performance-data.test.js b/tests/lib/performance-data.test.js index 96b8acf..9208ae4 100644 --- a/tests/lib/performance-data.test.js +++ b/tests/lib/performance-data.test.js @@ -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) +}) diff --git a/tests/lib/performance-status-source.test.js b/tests/lib/performance-status-source.test.js index 60be638..327f385 100644 --- a/tests/lib/performance-status-source.test.js +++ b/tests/lib/performance-status-source.test.js @@ -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"\)\}/)