mirror of
https://github.com/rustfs/console.git
synced 2026-08-29 03:52:28 +08:00
819 lines
30 KiB
TypeScript
819 lines
30 KiB
TypeScript
export type ServerHealthState = "online" | "offline" | "degraded" | "initializing" | "unknown"
|
|
export type OperationalStatus = "healthy" | "degraded" | "stale" | "not_reported" | "unknown"
|
|
|
|
export interface StatusDiagnostic {
|
|
state: OperationalStatus
|
|
reason?: string
|
|
source?: string
|
|
lastSuccessfulUpdate?: string
|
|
lastError?: string
|
|
historicalStallTimeouts?: number
|
|
hint?: string
|
|
scope?: {
|
|
bucket?: string
|
|
prefix?: string
|
|
set?: string
|
|
timeout?: string
|
|
}
|
|
}
|
|
|
|
export interface PeerHealthDiagnostic extends StatusDiagnostic {
|
|
nodeId: string
|
|
isLocal?: boolean
|
|
}
|
|
|
|
export interface ClusterDiagnostics {
|
|
peerHealth: StatusDiagnostic
|
|
storageReadiness: StatusDiagnostic
|
|
usageFreshness: StatusDiagnostic
|
|
listingHealth: StatusDiagnostic
|
|
workloadAdmission: StatusDiagnostic
|
|
peers: PeerHealthDiagnostic[]
|
|
membership: Array<{ nodeId: string; gridHost?: string; isLocal?: boolean }>
|
|
topologyDrives: Array<{
|
|
nodeId: string
|
|
poolIndex?: number
|
|
setIndex?: number
|
|
diskIndex?: number
|
|
}>
|
|
pools: Array<{
|
|
poolIndex?: number
|
|
drivesPerSet?: number
|
|
endpointCount?: number
|
|
}>
|
|
}
|
|
|
|
export interface ServerInfo {
|
|
endpoint?: string
|
|
state?: ServerHealthState
|
|
version?: string
|
|
uptime?: number
|
|
drives?: Array<{
|
|
uuid?: string
|
|
drive_path?: string
|
|
path?: string
|
|
usedspace?: number
|
|
totalspace?: number
|
|
availspace?: number
|
|
state?: string
|
|
}>
|
|
network?: Record<string, string>
|
|
}
|
|
|
|
export interface SystemInfo {
|
|
buckets?: { count?: number }
|
|
objects?: { count?: number }
|
|
servers?: ServerInfo[]
|
|
backend?: {
|
|
backendType?: string
|
|
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
|
|
total_used_capacity?: number
|
|
}
|
|
|
|
export interface StorageInfo {
|
|
backend?: {
|
|
BackendType?: string
|
|
StandardSCParity?: string | number
|
|
RRSCParity?: string | number
|
|
}
|
|
}
|
|
|
|
export interface MetricsInfo {
|
|
aggregated?: {
|
|
scanner?: {
|
|
current_cycle?: number
|
|
current_started?: string
|
|
cycle_complete_times?: string[]
|
|
}
|
|
}
|
|
}
|
|
|
|
type JsonRecord = Record<string, unknown>
|
|
|
|
function asRecord(value: unknown): JsonRecord {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}
|
|
}
|
|
|
|
function asArray<T>(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 asNonNegativeNumber(value: unknown): number | undefined {
|
|
const number = asNumber(value)
|
|
return number !== undefined && number >= 0 ? number : undefined
|
|
}
|
|
|
|
function asString(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.trim() !== "" ? value : undefined
|
|
}
|
|
|
|
function asBoolean(value: unknown): boolean | undefined {
|
|
return typeof value === "boolean" ? value : undefined
|
|
}
|
|
|
|
function asStringOrNumber(value: unknown): string | number | undefined {
|
|
return typeof value === "string" || (typeof value === "number" && Number.isFinite(value)) ? value : undefined
|
|
}
|
|
|
|
function asTimestamp(value: unknown): string | undefined {
|
|
const timestamp = asString(value)
|
|
return timestamp && !Number.isNaN(Date.parse(timestamp)) ? timestamp : undefined
|
|
}
|
|
|
|
function asUnixTimestamp(value: unknown): string | undefined {
|
|
const seconds = asNonNegativeNumber(value)
|
|
if (seconds === undefined || seconds === 0) return undefined
|
|
const timestamp = new Date(seconds * 1000)
|
|
return Number.isNaN(timestamp.getTime()) ? undefined : timestamp.toISOString()
|
|
}
|
|
|
|
function asSafeText(value: unknown): string | undefined {
|
|
const text = asString(value)?.trim()
|
|
return text ? text.slice(0, 500) : undefined
|
|
}
|
|
|
|
function asSafeAdminPath(value: unknown): string | undefined {
|
|
const path = asString(value)
|
|
return path?.startsWith("/") && !path.startsWith("//") && !path.includes("\\") ? path : undefined
|
|
}
|
|
|
|
export function normalizeServerHealthState(value: unknown): ServerHealthState {
|
|
const state = asString(value)?.toLowerCase()
|
|
if (state === "online" || state === "offline" || state === "degraded" || state === "initializing") {
|
|
return state
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
function normalizeDrive(value: unknown): NonNullable<ServerInfo["drives"]>[number] | undefined {
|
|
const source = asRecord(value)
|
|
if (!Object.keys(source).length) return undefined
|
|
|
|
return {
|
|
uuid: asString(source.uuid ?? source.UUID),
|
|
drive_path: asString(source.drive_path ?? source.drivePath ?? source.DrivePath),
|
|
path: asString(source.path ?? source.Path),
|
|
usedspace: asNonNegativeNumber(source.usedspace ?? source.usedSpace ?? source.UsedSpace),
|
|
totalspace: asNonNegativeNumber(source.totalspace ?? source.totalSpace ?? source.TotalSpace),
|
|
availspace: asNonNegativeNumber(source.availspace ?? source.availableSpace ?? source.AvailableSpace),
|
|
state: asString(source.state ?? source.State)?.toLowerCase(),
|
|
}
|
|
}
|
|
|
|
function normalizeServer(value: unknown): ServerInfo | undefined {
|
|
const source = asRecord(value)
|
|
if (!Object.keys(source).length) return undefined
|
|
|
|
const network = Object.fromEntries(
|
|
Object.entries(asRecord(source.network ?? source.Network)).flatMap(([key, state]) => {
|
|
const normalizedState = asString(state)?.toLowerCase()
|
|
return normalizedState ? [[key, normalizedState]] : []
|
|
}),
|
|
)
|
|
|
|
return {
|
|
endpoint: asString(source.endpoint ?? source.Endpoint),
|
|
state: normalizeServerHealthState(source.state ?? source.State),
|
|
version: asString(source.version ?? source.Version),
|
|
uptime: asNonNegativeNumber(source.uptime ?? source.Uptime),
|
|
drives: asArray<unknown>(source.drives ?? source.Drives).flatMap((drive) => {
|
|
const normalized = normalizeDrive(drive)
|
|
return normalized ? [normalized] : []
|
|
}),
|
|
network,
|
|
}
|
|
}
|
|
|
|
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)
|
|
if (record.error ?? record.Error) return undefined
|
|
const count = asNonNegativeNumber(record.count ?? record.Count)
|
|
return count === undefined ? undefined : { count }
|
|
}
|
|
|
|
export function normalizeSystemInfo(value: unknown): SystemInfo {
|
|
const response = asRecord(value)
|
|
const source = unwrapInfoRecord(value)
|
|
const backend = asRecord(source.backend ?? source.Backend)
|
|
const discovery = asRecord(
|
|
response.admin_discovery ?? response.adminDiscovery ?? source.admin_discovery ?? source.adminDiscovery,
|
|
)
|
|
const clusterSnapshotPath = asSafeAdminPath(discovery.clusterSnapshot ?? discovery.cluster_snapshot)
|
|
const buckets = normalizeCountInfo(source.buckets ?? source.Buckets)
|
|
const objects = normalizeCountInfo(source.objects ?? source.Objects)
|
|
const rawServers = source.servers ?? source.Servers
|
|
const servers = Array.isArray(rawServers)
|
|
? rawServers.flatMap((server) => {
|
|
const normalized = normalizeServer(server)
|
|
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 } : {}),
|
|
...(objects ? { objects } : {}),
|
|
...(servers ? { servers } : {}),
|
|
...(Object.keys(backend).length
|
|
? {
|
|
backend: {
|
|
backendType: asString(backend.backendType ?? backend.BackendType),
|
|
onlineDisks: asNonNegativeNumber(backend.onlineDisks ?? backend.OnlineDisks),
|
|
offlineDisks: asNonNegativeNumber(backend.offlineDisks ?? backend.OfflineDisks),
|
|
unknownDisks: asNonNegativeNumber(backend.unknownDisks ?? backend.UnknownDisks),
|
|
...(totalDrivesPerSet.length ? { totalDrivesPerSet } : {}),
|
|
},
|
|
}
|
|
: {}),
|
|
...(clusterSnapshotPath ? { adminDiscovery: { clusterSnapshot: clusterSnapshotPath } } : {}),
|
|
}
|
|
}
|
|
|
|
function normalizeOperationalStatus(value: unknown): OperationalStatus {
|
|
const state = asString(value)?.trim().toLowerCase().replaceAll("-", "_").replaceAll(" ", "_")
|
|
if (["healthy", "ready", "online", "ok", "supported", "fresh", "available"].includes(state ?? "")) {
|
|
return "healthy"
|
|
}
|
|
if (["stale", "outdated", "expired"].includes(state ?? "")) return "stale"
|
|
if (["not_reported", "notreported", "unsupported", "disabled", "unavailable"].includes(state ?? "")) {
|
|
return "not_reported"
|
|
}
|
|
if (
|
|
["degraded", "unhealthy", "failed", "failure", "offline", "unreachable", "unresolved", "error", "timeout"].includes(
|
|
state ?? "",
|
|
)
|
|
) {
|
|
return "degraded"
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
function normalizeScope(value: unknown): StatusDiagnostic["scope"] {
|
|
const scope = asRecord(value)
|
|
const bucket = asSafeText(scope.bucket ?? scope.bucket_name ?? scope.bucketName)
|
|
const prefix = asSafeText(scope.prefix ?? scope.path)
|
|
const setValue = asStringOrNumber(scope.set ?? scope.set_index ?? scope.setIndex)
|
|
const timeoutValue = asStringOrNumber(scope.timeout ?? scope.timeout_ms ?? scope.timeoutMs)
|
|
const normalized = {
|
|
...(bucket ? { bucket } : {}),
|
|
...(prefix ? { prefix } : {}),
|
|
...(setValue !== undefined ? { set: String(setValue) } : {}),
|
|
...(timeoutValue !== undefined
|
|
? { timeout: typeof timeoutValue === "number" ? `${timeoutValue} ms` : String(timeoutValue) }
|
|
: {}),
|
|
}
|
|
return Object.keys(normalized).length ? normalized : undefined
|
|
}
|
|
|
|
function normalizeDiagnostic(value: unknown): StatusDiagnostic {
|
|
if (typeof value === "string") return { state: normalizeOperationalStatus(value) }
|
|
|
|
const record = asRecord(value)
|
|
const nestedStatus = asRecord(record.status ?? record.Status)
|
|
const state = normalizeOperationalStatus(
|
|
record.condition ??
|
|
record.Condition ??
|
|
record.state ??
|
|
record.State ??
|
|
(typeof record.status === "string" ? record.status : undefined) ??
|
|
nestedStatus.state ??
|
|
nestedStatus.State,
|
|
)
|
|
const reason = asSafeText(record.reason ?? record.Reason ?? nestedStatus.reason ?? nestedStatus.Reason)
|
|
const source = asSafeText(record.source ?? record.Source ?? nestedStatus.source ?? nestedStatus.Source)
|
|
const lastSuccessfulUpdate =
|
|
asTimestamp(
|
|
record.last_successful_update ??
|
|
record.lastSuccessfulUpdate ??
|
|
record.last_success ??
|
|
record.lastSuccess ??
|
|
nestedStatus.last_successful_update ??
|
|
nestedStatus.lastSuccessfulUpdate,
|
|
) ?? asUnixTimestamp(record.last_success_unix_secs ?? record.lastSuccessUnixSecs)
|
|
const lastError = asSafeText(
|
|
record.last_error ?? record.lastError ?? record.error ?? nestedStatus.last_error ?? nestedStatus.lastError,
|
|
)
|
|
const scope = normalizeScope(record.scope ?? record.context ?? nestedStatus.scope ?? nestedStatus.context)
|
|
const historicalStallTimeouts = asNonNegativeNumber(
|
|
record.internode_stall_timeouts_total ?? record.internodeStallTimeoutsTotal,
|
|
)
|
|
const hint = asSafeText(record.hint ?? record.Hint)
|
|
|
|
return {
|
|
state,
|
|
...(reason ? { reason } : {}),
|
|
...(source ? { source } : {}),
|
|
...(lastSuccessfulUpdate ? { lastSuccessfulUpdate } : {}),
|
|
...(lastError ? { lastError } : {}),
|
|
...(historicalStallTimeouts !== undefined ? { historicalStallTimeouts } : {}),
|
|
...(hint ? { hint } : {}),
|
|
...(scope ? { scope } : {}),
|
|
}
|
|
}
|
|
|
|
function aggregateDiagnostics(diagnostics: StatusDiagnostic[], fallback?: StatusDiagnostic): StatusDiagnostic {
|
|
if (!diagnostics.length) return fallback ?? { state: "unknown" }
|
|
const priority: Record<OperationalStatus, number> = {
|
|
degraded: 0,
|
|
stale: 1,
|
|
unknown: 2,
|
|
not_reported: 3,
|
|
healthy: 4,
|
|
}
|
|
return diagnostics.reduce((worst, current) => (priority[current.state] < priority[worst.state] ? current : worst))
|
|
}
|
|
|
|
function firstDiagnosticRecord(...values: unknown[]) {
|
|
return values.find((value) => typeof value === "string" || Object.keys(asRecord(value)).length > 0)
|
|
}
|
|
|
|
export function normalizeClusterDiagnostics(value: unknown): ClusterDiagnostics | undefined {
|
|
const response = asRecord(value)
|
|
const wrappedSnapshot = response.snapshot ?? response.Snapshot
|
|
const snapshot = wrappedSnapshot === undefined ? response : asRecord(wrappedSnapshot)
|
|
if (!Object.keys(snapshot).length) return undefined
|
|
|
|
const summary = asRecord(snapshot.summary ?? snapshot.Summary)
|
|
const components = asRecord(
|
|
snapshot.components ?? snapshot.Components ?? snapshot.component_status ?? snapshot.componentStatus,
|
|
)
|
|
const membershipRecord = asRecord(snapshot.membership ?? snapshot.Membership)
|
|
const membership = asArray<unknown>(membershipRecord.nodes ?? membershipRecord.Nodes).flatMap((value) => {
|
|
const node = asRecord(value)
|
|
const nodeId = asSafeText(node.node_id ?? node.nodeId ?? node.NodeId)
|
|
if (!nodeId) return []
|
|
const gridHost = asSafeText(node.grid_host ?? node.gridHost ?? node.GridHost)
|
|
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)
|
|
const peers = asArray<unknown>(peerRecord.peers ?? peerRecord.Peers).flatMap((value) => {
|
|
const peer = asRecord(value)
|
|
const nodeId = asSafeText(peer.node_id ?? peer.nodeId ?? peer.NodeId)
|
|
if (!nodeId) return []
|
|
const diagnostic = normalizeDiagnostic(peer.status ?? peer.Status ?? peer)
|
|
const isLocal = asBoolean(peer.is_local ?? peer.isLocal ?? peer.IsLocal)
|
|
return [{ ...diagnostic, nodeId, ...(isLocal !== undefined ? { isLocal } : {}) }]
|
|
})
|
|
const explicitPeerHealth = firstDiagnosticRecord(
|
|
components.peer_health,
|
|
components.peerHealth,
|
|
summary.peer_health,
|
|
summary.peerHealth,
|
|
)
|
|
const peerHealth = explicitPeerHealth
|
|
? normalizeDiagnostic(explicitPeerHealth)
|
|
: aggregateDiagnostics(peers, { state: "not_reported" })
|
|
|
|
const runtime = asRecord(snapshot.runtime_status ?? snapshot.runtimeStatus ?? snapshot.RuntimeStatus)
|
|
const explicitStorage = firstDiagnosticRecord(
|
|
snapshot.storage_readiness,
|
|
snapshot.storageReadiness,
|
|
components.storage_readiness,
|
|
components.storageReadiness,
|
|
components.storage,
|
|
)
|
|
const storageReady = asBoolean(runtime.storage_ready ?? runtime.storageReady)
|
|
const degradedReasons = asArray<unknown>(runtime.degraded_reasons ?? runtime.degradedReasons)
|
|
.flatMap((reason) => asSafeText(reason) ?? [])
|
|
.join(", ")
|
|
const storageReadiness = explicitStorage
|
|
? normalizeDiagnostic(explicitStorage)
|
|
: storageReady === undefined
|
|
? { state: "not_reported" as const }
|
|
: {
|
|
state: storageReady ? ("healthy" as const) : ("degraded" as const),
|
|
...(!storageReady && degradedReasons ? { reason: degradedReasons } : {}),
|
|
}
|
|
|
|
const explicitUsage = firstDiagnosticRecord(
|
|
snapshot.usage_freshness,
|
|
snapshot.usageFreshness,
|
|
snapshot.usage_cache,
|
|
snapshot.usageCache,
|
|
components.usage_freshness,
|
|
components.usageFreshness,
|
|
components.usage_cache,
|
|
components.usageCache,
|
|
components.usage,
|
|
)
|
|
const usageFreshness = explicitUsage ? normalizeDiagnostic(explicitUsage) : { state: "not_reported" as const }
|
|
const explicitListing = firstDiagnosticRecord(
|
|
snapshot.listing,
|
|
snapshot.metacache,
|
|
snapshot.listing_metacache,
|
|
snapshot.listingMetacache,
|
|
components.listing,
|
|
components.metacache,
|
|
components.listing_metacache,
|
|
components.listingMetacache,
|
|
)
|
|
const listingHealth = explicitListing ? normalizeDiagnostic(explicitListing) : { state: "not_reported" as const }
|
|
const explicitWorkloadAdmission = firstDiagnosticRecord(
|
|
snapshot.workload_admission_status,
|
|
snapshot.workloadAdmissionStatus,
|
|
components.workload_admission,
|
|
components.workloadAdmission,
|
|
)
|
|
const workloadAdmission = explicitWorkloadAdmission
|
|
? normalizeDiagnostic(explicitWorkloadAdmission)
|
|
: { state: "not_reported" as const }
|
|
|
|
return {
|
|
peerHealth,
|
|
storageReadiness,
|
|
usageFreshness,
|
|
listingHealth,
|
|
workloadAdmission,
|
|
peers,
|
|
membership,
|
|
topologyDrives,
|
|
pools,
|
|
}
|
|
}
|
|
|
|
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 { host: url.host, hostname: url.hostname }
|
|
} catch {
|
|
const host = trimmed.replace(/^https?:\/\//, "")
|
|
return { host, hostname: host.split(":")[0] }
|
|
}
|
|
}
|
|
|
|
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
|
|
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) {
|
|
return Boolean(
|
|
server.drives?.length &&
|
|
server.drives.every((drive) => ["ok", "online"].includes(drive.state?.trim().toLowerCase() ?? "")),
|
|
)
|
|
}
|
|
|
|
export function resolveServerHealth(
|
|
server: ServerInfo,
|
|
diagnostics?: ClusterDiagnostics,
|
|
): { state: ServerHealthState; reason?: string; source: "legacy" | "peer" } {
|
|
const legacyState = normalizeServerHealthState(server.state)
|
|
if (!diagnostics) return { state: legacyState, source: "legacy" }
|
|
|
|
const peer = findPeerDiagnostic(server, diagnostics)
|
|
if (!peer) return { state: legacyState, source: "legacy" }
|
|
if (peer.state === "degraded") {
|
|
return { state: "degraded", ...(peer.reason ? { reason: peer.reason } : {}), source: "peer" }
|
|
}
|
|
if (
|
|
(peer.state === "unknown" || peer.state === "stale" || peer.state === "not_reported") &&
|
|
(legacyState === "unknown" || (legacyState === "degraded" && hasOnlyHealthyDrives(server)))
|
|
) {
|
|
return { state: "unknown", ...(peer.reason ? { reason: peer.reason } : {}), source: "peer" }
|
|
}
|
|
return { state: legacyState, source: "legacy" }
|
|
}
|
|
|
|
export function resolveUsageFreshness(
|
|
diagnostic: StatusDiagnostic | undefined,
|
|
context: { hasData: boolean; error?: string; lastUpdatedAt?: Date | null },
|
|
): StatusDiagnostic {
|
|
if (diagnostic && diagnostic.state !== "unknown") return diagnostic
|
|
if (context.error && context.hasData) {
|
|
return {
|
|
state: "stale",
|
|
reason: context.error,
|
|
...(context.lastUpdatedAt ? { lastSuccessfulUpdate: context.lastUpdatedAt.toISOString() } : {}),
|
|
}
|
|
}
|
|
if (context.error) return { state: "unknown", reason: context.error }
|
|
return diagnostic ?? { state: "unknown" }
|
|
}
|
|
|
|
export function normalizeStorageInfo(value: unknown): StorageInfo {
|
|
const source = unwrapInfoRecord(value)
|
|
const backend = asRecord(source.backend ?? source.Backend)
|
|
if (!Object.keys(backend).length) return {}
|
|
|
|
return {
|
|
backend: {
|
|
BackendType: asString(backend.BackendType ?? backend.backendType),
|
|
StandardSCParity: asStringOrNumber(backend.StandardSCParity ?? backend.standardSCParity),
|
|
RRSCParity: asStringOrNumber(backend.RRSCParity ?? backend.rrSCParity),
|
|
},
|
|
}
|
|
}
|
|
|
|
export function normalizeDataUsageInfo(value: unknown): DataUsageInfo {
|
|
const source = unwrapInfoRecord(value)
|
|
const totalCapacity = asNonNegativeNumber(source.total_capacity ?? source.totalCapacity ?? source.TotalCapacity)
|
|
const totalFreeCapacity = asNonNegativeNumber(
|
|
source.total_free_capacity ?? source.totalFreeCapacity ?? source.TotalFreeCapacity,
|
|
)
|
|
const totalUsedCapacity = asNonNegativeNumber(
|
|
source.total_used_capacity ?? source.totalUsedCapacity ?? source.TotalUsedCapacity,
|
|
)
|
|
|
|
return {
|
|
...(totalCapacity !== undefined ? { total_capacity: totalCapacity } : {}),
|
|
...(totalFreeCapacity !== undefined ? { total_free_capacity: totalFreeCapacity } : {}),
|
|
...(totalUsedCapacity !== undefined ? { total_used_capacity: totalUsedCapacity } : {}),
|
|
}
|
|
}
|
|
|
|
export function normalizeMetricsInfo(value: unknown): MetricsInfo {
|
|
const source = unwrapInfoRecord(value)
|
|
const aggregated = asRecord(source.aggregated ?? source.Aggregated)
|
|
const scanner = asRecord(aggregated.scanner ?? aggregated.Scanner)
|
|
const currentCycle = asNonNegativeNumber(scanner.current_cycle ?? scanner.currentCycle)
|
|
const currentStarted = asTimestamp(scanner.current_started ?? scanner.currentStarted)
|
|
const cycleCompleteTimes = asArray<unknown>(scanner.cycle_complete_times ?? scanner.cycleCompleteTimes).flatMap(
|
|
(item) => {
|
|
const timestamp = asTimestamp(item)
|
|
return timestamp ? [timestamp] : []
|
|
},
|
|
)
|
|
|
|
if (currentCycle === undefined && !currentStarted && !cycleCompleteTimes.length) return {}
|
|
|
|
return {
|
|
aggregated: {
|
|
scanner: {
|
|
...(currentCycle !== undefined ? { current_cycle: currentCycle } : {}),
|
|
...(currentStarted ? { current_started: currentStarted } : {}),
|
|
...(cycleCompleteTimes.length ? { cycle_complete_times: cycleCompleteTimes } : {}),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
export function summarizeServerStates(
|
|
servers: ServerInfo[] | undefined,
|
|
diagnostics?: ClusterDiagnostics,
|
|
): Record<ServerHealthState, number> {
|
|
const summary: Record<ServerHealthState, number> = {
|
|
online: 0,
|
|
offline: 0,
|
|
degraded: 0,
|
|
initializing: 0,
|
|
unknown: 0,
|
|
}
|
|
|
|
for (const server of servers ?? []) {
|
|
summary[resolveServerHealth(server, diagnostics).state] += 1
|
|
}
|
|
|
|
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
|
|
|
|
const difference = time - now
|
|
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
|
|
["year", 31_536_000_000],
|
|
["month", 2_592_000_000],
|
|
["day", 86_400_000],
|
|
["hour", 3_600_000],
|
|
["minute", 60_000],
|
|
["second", 1_000],
|
|
]
|
|
const [unit, size] = units.find(([, unitSize]) => Math.abs(difference) >= unitSize) ?? units.at(-1)!
|
|
return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(Math.round(difference / size), unit)
|
|
}
|