fix(status): separate cluster diagnostic states (#177)

This commit is contained in:
cxymds
2026-07-22 09:25:42 +08:00
committed by GitHub
parent 3a6fa37e80
commit 64809a3d0c
25 changed files with 1371 additions and 50 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ export class ApiClient {
}
async request(url: string, options: RequestOptions = {}, parseJson: boolean = true) {
url = this.config?.baseUrl ? joinURL(this.config?.baseUrl, url) : url
url = this.config?.baseUrl && !/^https?:\/\//i.test(url) ? joinURL(this.config.baseUrl, url) : url
const { params, ...providedOptions } = options
const requestOptions: RequestOptions = {
...providedOptions,
+333 -2
View File
@@ -1,4 +1,36 @@
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 }>
}
export interface ServerInfo {
endpoint?: string
@@ -27,6 +59,9 @@ export interface SystemInfo {
offlineDisks?: number
unknownDisks?: number
}
adminDiscovery?: {
clusterSnapshot: string
}
}
export interface DataUsageInfo {
@@ -81,6 +116,10 @@ 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
}
@@ -90,6 +129,23 @@ function asTimestamp(value: unknown): string | undefined {
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") {
@@ -151,8 +207,13 @@ function normalizeCountInfo(value: unknown): { count?: number } | undefined {
}
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
@@ -177,9 +238,276 @@ export function normalizeSystemInfo(value: unknown): SystemInfo {
},
}
: {}),
...(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)
return [{ nodeId, ...(gridHost ? { gridHost } : {}) }]
})
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,
}
}
function normalizeEndpointIdentity(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
} catch {
return trimmed.replace(/^https?:\/\//, "")
}
}
function findPeerDiagnostic(server: ServerInfo, diagnostics: ClusterDiagnostics) {
const serverIdentity = normalizeEndpointIdentity(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)
})
}
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 === "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)
@@ -237,7 +565,10 @@ export function normalizeMetricsInfo(value: unknown): MetricsInfo {
}
}
export function summarizeServerStates(servers: ServerInfo[] | undefined): Record<ServerHealthState, number> {
export function summarizeServerStates(
servers: ServerInfo[] | undefined,
diagnostics?: ClusterDiagnostics,
): Record<ServerHealthState, number> {
const summary: Record<ServerHealthState, number> = {
online: 0,
offline: 0,
@@ -247,7 +578,7 @@ export function summarizeServerStates(servers: ServerInfo[] | undefined): Record
}
for (const server of servers ?? []) {
summary[normalizeServerHealthState(server.state)] += 1
summary[resolveServerHealth(server, diagnostics).state] += 1
}
return summary