mirror of
https://github.com/rustfs/console.git
synced 2026-08-29 03:52:28 +08:00
fix(status): detect incomplete cluster topology (#178)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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": "معلومات المجموعة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "クラスター情報",
|
||||
|
||||
@@ -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": "클러스터 정보",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Сведения о кластере",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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"\)\}/)
|
||||
|
||||
Reference in New Issue
Block a user