From e6a25b91e102d443f214796cde9ec824dbf8c6fc Mon Sep 17 00:00:00 2001 From: cxymds Date: Fri, 26 Jun 2026 11:30:01 +0800 Subject: [PATCH] fix: normalize performance status responses --- .../_components/performance-backend-card.tsx | 2 +- .../_components/performance-server-list.tsx | 4 +- hooks/use-performance-data.ts | 60 ++-------- lib/performance-data.ts | 113 ++++++++++++++++++ tests/lib/performance-data.test.js | 74 ++++++++++++ 5 files changed, 201 insertions(+), 52 deletions(-) create mode 100644 lib/performance-data.ts create mode 100644 tests/lib/performance-data.test.js diff --git a/app/(dashboard)/_components/performance-backend-card.tsx b/app/(dashboard)/_components/performance-backend-card.tsx index 6415dc1..0733dde 100644 --- a/app/(dashboard)/_components/performance-backend-card.tsx +++ b/app/(dashboard)/_components/performance-backend-card.tsx @@ -5,7 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com export interface BackendInfoItem { icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" | "false" }> title: string - value?: string + value?: string | number } export function PerformanceBackendCard({ items, t }: { items: BackendInfoItem[]; t: (key: string) => string }) { diff --git a/app/(dashboard)/_components/performance-server-list.tsx b/app/(dashboard)/_components/performance-server-list.tsx index af9ef8d..cbcca25 100644 --- a/app/(dashboard)/_components/performance-server-list.tsx +++ b/app/(dashboard)/_components/performance-server-list.tsx @@ -203,10 +203,10 @@ export function PerformanceServerList({ servers, t }: { servers: ServerInfo[]; t
{(server.drives || []).map((drive) => ( - + - {drive.drive_path ?? "--"} + {drive.drive_path ?? drive.path ?? "--"} {niceBytes(String(drive.usedspace ?? 0))} / {niceBytes(String(drive.totalspace ?? 0))} diff --git a/hooks/use-performance-data.ts b/hooks/use-performance-data.ts index 6e1bca9..3f5b08a 100644 --- a/hooks/use-performance-data.ts +++ b/hooks/use-performance-data.ts @@ -4,54 +4,16 @@ import * as React from "react" import { useCallback, useEffect, useState } from "react" import { useTranslation } from "react-i18next" import { useSystem } from "@/hooks/use-system" +import { + normalizeStorageInfo, + normalizeSystemInfo, + type DataUsageInfo, + type MetricsInfo, + type StorageInfo, + type SystemInfo, +} from "@/lib/performance-data" -export interface ServerInfo { - endpoint?: string - state?: string - version?: string - uptime?: number - drives?: Array<{ - uuid?: string - drive_path?: string - usedspace?: number - totalspace?: number - availspace?: number - state?: string - }> - network?: Record -} - -export interface SystemInfo { - buckets?: { count?: number } - objects?: { count?: number } - servers?: ServerInfo[] - backend?: { - backendType?: string - onlineDisks?: number - offlineDisks?: number - } -} - -export interface DataUsageInfo { - total_capacity?: number - total_used_capacity?: number -} - -export interface StorageInfo { - backend?: { - StandardSCParity?: string - RRSCParity?: string - } -} - -export interface MetricsInfo { - aggregated?: { - scanner?: { - current_started?: string - cycle_complete_times?: string[] - } - } -} +export type { DataUsageInfo, MetricsInfo, ServerInfo, StorageInfo, SystemInfo } from "@/lib/performance-data" export function usePerformanceData() { const { t } = useTranslation() @@ -78,9 +40,9 @@ export function usePerformanceData() { systemApi.getStorageInfo(), ]) if (!mountedRef.current) return - setSystemInfo((sysRes as SystemInfo) ?? {}) + setSystemInfo(normalizeSystemInfo(sysRes)) setDatausageinfo((usageRes as DataUsageInfo) ?? {}) - setStorageinfo((storageRes as StorageInfo) ?? {}) + setStorageinfo(normalizeStorageInfo(storageRes)) } catch (err) { if (!mountedRef.current) return console.error("Failed to load performance data:", err) diff --git a/lib/performance-data.ts b/lib/performance-data.ts new file mode 100644 index 0000000..55696e4 --- /dev/null +++ b/lib/performance-data.ts @@ -0,0 +1,113 @@ +export interface ServerInfo { + endpoint?: string + state?: string + version?: string + uptime?: number + drives?: Array<{ + uuid?: string + drive_path?: string + path?: string + usedspace?: number + totalspace?: number + availspace?: number + state?: string + }> + network?: Record +} + +export interface SystemInfo { + buckets?: { count?: number } + objects?: { count?: number } + servers?: ServerInfo[] + backend?: { + backendType?: string + onlineDisks?: number + offlineDisks?: number + } +} + +export interface DataUsageInfo { + total_capacity?: number + total_used_capacity?: number +} + +export interface StorageInfo { + backend?: { + BackendType?: string + StandardSCParity?: string | number + RRSCParity?: string | number + } +} + +export interface MetricsInfo { + aggregated?: { + scanner?: { + current_started?: string + cycle_complete_times?: string[] + } + } +} + +type JsonRecord = Record + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {} +} + +function asArray(value: unknown): T[] { + return Array.isArray(value) ? (value as T[]) : [] +} + +function asNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value) + if (Number.isFinite(parsed)) return parsed + } + return undefined +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() !== "" ? value : undefined +} + +function unwrapInfoRecord(value: unknown): JsonRecord { + const record = asRecord(value) + const wrapped = record.info ?? record.Info + return wrapped && typeof wrapped === "object" && !Array.isArray(wrapped) ? (wrapped as JsonRecord) : record +} + +function normalizeCountInfo(value: unknown): { count?: number } | undefined { + const record = asRecord(value) + const count = asNumber(record.count ?? record.Count) + return count === undefined ? undefined : { count } +} + +export function normalizeSystemInfo(value: unknown): SystemInfo { + const source = unwrapInfoRecord(value) + const backend = asRecord(source.backend ?? source.Backend) + + return { + buckets: normalizeCountInfo(source.buckets ?? source.Buckets), + objects: normalizeCountInfo(source.objects ?? source.Objects), + servers: asArray(source.servers ?? source.Servers), + backend: { + backendType: asString(backend.backendType ?? backend.BackendType), + onlineDisks: asNumber(backend.onlineDisks ?? backend.OnlineDisks), + offlineDisks: asNumber(backend.offlineDisks ?? backend.OfflineDisks), + }, + } +} + +export function normalizeStorageInfo(value: unknown): StorageInfo { + const source = unwrapInfoRecord(value) + const backend = asRecord(source.backend ?? source.Backend) + + return { + backend: { + BackendType: asString(backend.BackendType ?? backend.backendType), + StandardSCParity: (backend.StandardSCParity ?? backend.standardSCParity) as string | number | undefined, + RRSCParity: (backend.RRSCParity ?? backend.rrSCParity) as string | number | undefined, + }, + } +} diff --git a/tests/lib/performance-data.test.js b/tests/lib/performance-data.test.js new file mode 100644 index 0000000..26ef160 --- /dev/null +++ b/tests/lib/performance-data.test.js @@ -0,0 +1,74 @@ +import test from "node:test" +import assert from "node:assert/strict" + +import { normalizeStorageInfo, normalizeSystemInfo } from "../../lib/performance-data.ts" + +test("normalizeSystemInfo preserves legacy unwrapped info responses", () => { + const info = normalizeSystemInfo({ + buckets: { count: 2 }, + objects: { count: 40 }, + backend: { + backendType: "Erasure", + onlineDisks: 8, + offlineDisks: 1, + }, + servers: [{ endpoint: "node-a", state: "online" }], + }) + + assert.equal(info.buckets?.count, 2) + assert.equal(info.objects?.count, 40) + assert.equal(info.backend?.backendType, "Erasure") + assert.equal(info.backend?.onlineDisks, 8) + assert.equal(info.backend?.offlineDisks, 1) + assert.equal(info.servers?.[0]?.endpoint, "node-a") +}) + +test("normalizeSystemInfo unwraps RustFS admin discovery info responses", () => { + const info = normalizeSystemInfo({ + info: { + buckets: { count: 1, error: null }, + objects: { count: 2560, error: null }, + backend: { + backendType: "Erasure", + onlineDisks: 12, + offlineDisks: 0, + }, + servers: [ + { endpoint: "rustfs-node7", state: "online", drives: [{ state: "ok" }] }, + { endpoint: "10.0.0.9:19000", state: "offline", drives: [] }, + ], + }, + admin_discovery: { + runtimeCapabilities: "/rustfs/admin/v4/runtime/capabilities", + clusterSnapshot: "/rustfs/admin/v4/cluster/snapshot", + extensionsCatalog: "/rustfs/admin/v4/extensions/catalog", + }, + }) + + assert.equal(info.buckets?.count, 1) + assert.equal(info.objects?.count, 2560) + assert.equal(info.backend?.onlineDisks, 12) + assert.equal(info.backend?.offlineDisks, 0) + assert.equal(info.servers?.length, 2) +}) + +test("normalizeStorageInfo unwraps RustFS admin discovery storage responses", () => { + const storage = normalizeStorageInfo({ + info: { + backend: { + BackendType: "Erasure", + StandardSCParity: 4, + RRSCParity: 1, + }, + }, + admin_discovery: { + runtimeCapabilities: "/rustfs/admin/v4/runtime/capabilities", + clusterSnapshot: "/rustfs/admin/v4/cluster/snapshot", + extensionsCatalog: "/rustfs/admin/v4/extensions/catalog", + }, + }) + + assert.equal(storage.backend?.BackendType, "Erasure") + assert.equal(storage.backend?.StandardSCParity, 4) + assert.equal(storage.backend?.RRSCParity, 1) +})