From 8a41fef327797bf38978cd2d63e128dd7f21c6de Mon Sep 17 00:00:00 2001 From: John Coffey Date: Fri, 25 Sep 2026 01:05:16 -0700 Subject: [PATCH] Dashboard: Cluster Health replaces Server Memory in a cluster When more than one node holds a lease, the overview's Server Memory card gives way to Cluster Health: healthy nodes out of the total, the number unhealthy, and each silent node named with how long it has been quiet. It reads x:ClusterNode every 30 seconds and links to the node list when the viewer may open it. Healthy means Active. A server with the node heartbeat marks a node Stale after three minutes without a lease renewal; older servers take an hour. Inactive leases (a day silent) are left out. On a single server, or without permission to list nodes, the dashboard is unchanged. --- src/features/dashboard/clusterHealth.test.ts | 62 ++++++++++ src/features/dashboard/clusterHealth.ts | 106 ++++++++++++++++ .../components/ClusterHealthCard.tsx | 114 ++++++++++++++++++ .../dashboard/components/DashboardView.tsx | 36 ++++-- 4 files changed, 305 insertions(+), 13 deletions(-) create mode 100644 src/features/dashboard/clusterHealth.test.ts create mode 100644 src/features/dashboard/clusterHealth.ts create mode 100644 src/features/dashboard/components/ClusterHealthCard.tsx diff --git a/src/features/dashboard/clusterHealth.test.ts b/src/features/dashboard/clusterHealth.test.ts new file mode 100644 index 0000000..4e79da6 --- /dev/null +++ b/src/features/dashboard/clusterHealth.test.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, it } from 'vitest'; +import { minutesSilent, summarizeNodes, type ClusterNodeRow } from './clusterHealth'; + +const node = (hostname: string, status: ClusterNodeRow['status'], lastRenewal = '2026-09-25T10:00:00Z') => ({ + hostname, + status, + lastRenewal, +}); + +describe('summarizeNodes', () => { + it('is not a cluster with one node', () => { + expect(summarizeNodes([node('mail.example.org', 'active')])).toBeNull(); + expect(summarizeNodes([])).toBeNull(); + }); + + it('counts active nodes as healthy', () => { + expect( + summarizeNodes([ + node('mail.example.org', 'active'), + node('mx2.example.org', 'active'), + node('mx3.example.org', 'active'), + ]), + ).toEqual({ healthy: 3, unhealthy: 0, silent: [] }); + }); + + it('names the silent nodes, longest quiet first', () => { + const health = summarizeNodes([ + node('mail.example.org', 'active'), + node('mx2.example.org', 'stale', '2026-09-25T09:55:00Z'), + node('mx3.example.org', 'stale', '2026-09-25T09:10:00Z'), + ]); + expect(health).toMatchObject({ healthy: 1, unhealthy: 2 }); + expect(health?.silent.map((n) => n.hostname)).toEqual(['mx3.example.org', 'mx2.example.org']); + }); + + it('leaves out old leases', () => { + // A node gone for a day is history, not a member: one live node is no cluster. + expect(summarizeNodes([node('mail.example.org', 'active'), node('old.example.org', 'inactive')])).toBeNull(); + expect( + summarizeNodes([ + node('mail.example.org', 'active'), + node('mx2.example.org', 'stale'), + node('old.example.org', 'inactive'), + ]), + ).toMatchObject({ healthy: 1, unhealthy: 1 }); + }); +}); + +describe('minutesSilent', () => { + it('counts whole minutes since the last renewal', () => { + const now = Date.parse('2026-09-25T10:07:30Z'); + expect(minutesSilent('2026-09-25T10:00:00Z', now)).toBe(7); + expect(minutesSilent('2026-09-25T10:08:00Z', now)).toBe(0); + expect(minutesSilent('', now)).toBeNull(); + }); +}); diff --git a/src/features/dashboard/clusterHealth.ts b/src/features/dashboard/clusterHealth.ts new file mode 100644 index 0000000..71d7bc7 --- /dev/null +++ b/src/features/dashboard/clusterHealth.ts @@ -0,0 +1,106 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * How the cluster's nodes are doing, from the server's own node leases + * (x:ClusterNode). Every node renews its lease once a minute; the server + * reports a node it hasn't heard from in three minutes as stale, and one + * silent for a day as inactive. Healthy means active. Inactive nodes are + * left out altogether: they're old leases, not members. + * + * A server counts as a cluster when more than one node holds a lease. On a + * single server, or when the viewer can't list nodes, there's nothing here + * and the dashboard keeps its Server Memory card. + */ +import { useCallback, useEffect, useState } from 'react'; +import { getAccountId, jmapRequest } from '@/services/jmap/client'; + +export type NodeStatus = 'active' | 'stale' | 'inactive'; + +export interface ClusterNodeRow { + hostname: string; + status: NodeStatus; + /** When the node last renewed its lease. */ + lastRenewal: string; +} + +export interface ClusterHealth { + healthy: number; + unhealthy: number; + /** Nodes that stopped renewing, longest silent first. */ + silent: { hostname: string; lastRenewal: string }[]; +} + +export function summarizeNodes(nodes: ClusterNodeRow[]): ClusterHealth | null { + const members = nodes.filter((n) => n.status !== 'inactive'); + if (members.length < 2) return null; + const silent = members + .filter((n) => n.status !== 'active') + .sort((a, b) => a.lastRenewal.localeCompare(b.lastRenewal)) + .map(({ hostname, lastRenewal }) => ({ hostname, lastRenewal })); + return { healthy: members.length - silent.length, unhealthy: silent.length, silent }; +} + +/** Whole minutes since a node last renewed its lease, or null if the time can't be read. */ +export function minutesSilent(lastRenewal: string, now: number = Date.now()): number | null { + const t = Date.parse(lastRenewal); + return Number.isNaN(t) ? null : Math.max(0, Math.floor((now - t) / 60_000)); +} + +async function fetchNodes(): Promise { + let accountId: string; + try { + accountId = getAccountId('x:ClusterNode'); + } catch { + return null; + } + const responses = await jmapRequest([ + ['x:ClusterNode/query', { accountId }, 'q'], + [ + 'x:ClusterNode/get', + { + accountId, + '#ids': { resultOf: 'q', name: 'x:ClusterNode/query', path: '/ids' }, + properties: ['hostname', 'status', 'lastRenewal'], + }, + 'g', + ], + ]); + const got = responses.find(([name, , tag]) => tag === 'g' && name !== 'error'); + if (!got) return null; + const list = ((got[1] as Record).list as Record[] | undefined) ?? []; + return list.map((n) => ({ + hostname: String(n.hostname ?? n.id), + status: String(n.status ?? 'active').toLowerCase() as NodeStatus, + lastRenewal: String(n.lastRenewal ?? ''), + })); +} + +const REFRESH_MS = 30_000; + +/** The cluster's health, refreshed every 30 seconds; null when this isn't a cluster. */ +export function useClusterHealth() { + const [health, setHealth] = useState(null); + + const refresh = useCallback(() => { + fetchNodes() + .then((nodes) => setHealth(nodes ? summarizeNodes(nodes) : null)) + // A failed refresh keeps the last answer rather than flipping the card back. + .catch(() => {}); + }, []); + + useEffect(() => { + // Fetching syncs with the server; state lands from the promise, not here. + const first = setTimeout(refresh, 0); + const timer = setInterval(refresh, REFRESH_MS); + return () => { + clearTimeout(first); + clearInterval(timer); + }; + }, [refresh]); + + return health; +} diff --git a/src/features/dashboard/components/ClusterHealthCard.tsx b/src/features/dashboard/components/ClusterHealthCard.tsx new file mode 100644 index 0000000..5ef65a4 --- /dev/null +++ b/src/features/dashboard/components/ClusterHealthCard.tsx @@ -0,0 +1,114 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useTranslation } from 'react-i18next'; +import { ArrowUpRight } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { IconTile } from '@/components/common/IconTile'; +import { Card, CardContent } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import { usePermissions } from '@/hooks/usePermissions'; +import { useSchemaStore } from '@/stores/schemaStore'; +import { minutesSilent, type ClusterHealth } from '../clusterHealth'; + +const NODES_VIEW = 'x:ClusterNode'; + +/** + * Stands in for Server Memory when the server is one node of a cluster: a + * single node's memory says little about the whole, and which nodes are up + * says a lot. Healthy of total, with every silent node named and how long + * it has been quiet. Links to the node list when the viewer may open it. + */ +export function ClusterHealthCard({ health }: { health: ClusterHealth }) { + const { t } = useTranslation(); + const { canViewObject } = usePermissions(); + const section = useSchemaStore((s) => s.viewToSection[NODES_VIEW]); + const href = section && canViewObject(NODES_VIEW) ? `/${section}/${NODES_VIEW}` : null; + + const total = health.healthy + health.unhealthy; + const allWell = health.unhealthy === 0; + + const quietFor = (lastRenewal: string) => { + const m = minutesSilent(lastRenewal); + if (m === null) return t('cluster.quietUnknown', 'not heard from'); + if (m < 60) + return t('cluster.quietMinutes', { + count: m, + defaultValue_one: 'quiet for {{count}} minute', + defaultValue_other: 'quiet for {{count}} minutes', + }); + const h = Math.floor(m / 60); + return t('cluster.quietHours', { + count: h, + defaultValue_one: 'quiet for {{count}} hour', + defaultValue_other: 'quiet for {{count}} hours', + }); + }; + + const body = ( + + +
+ + {t('cluster.health', 'Cluster Health')} + {href && ( + + )} +
+ +
+ {health.healthy} + / {total} +
+ +
+ {t('cluster.healthy', { + count: health.healthy, + defaultValue_one: '{{count}} healthy node', + defaultValue_other: '{{count}} healthy nodes', + })} + {' ยท '} + + {t('cluster.unhealthy', { + count: health.unhealthy, + defaultValue_one: '{{count}} unhealthy', + defaultValue_other: '{{count}} unhealthy', + })} + +
+ + {health.silent.length > 0 && ( +
    + {health.silent.map((n) => ( +
  • + {n.hostname} {quietFor(n.lastRenewal)} +
  • + ))} +
+ )} +
+
+ ); + + return href ? ( + + {body} + + ) : ( + body + ); +} diff --git a/src/features/dashboard/components/DashboardView.tsx b/src/features/dashboard/components/DashboardView.tsx index d3bef86..17b6601 100644 --- a/src/features/dashboard/components/DashboardView.tsx +++ b/src/features/dashboard/components/DashboardView.tsx @@ -28,6 +28,8 @@ import { StorageTreemap } from './StorageTreemap'; import { QueueWaiting } from './QueueWaiting'; import { WeeklyHeatmap } from './WeeklyHeatmap'; import { useServerFacts, type ServerFacts } from '../serverFacts'; +import { useClusterHealth } from '../clusterHealth'; +import { ClusterHealthCard } from './ClusterHealthCard'; /** INBUXA: live-metric cards the server's own objects can stand in for. */ const FALLBACKS: Record = { @@ -36,6 +38,9 @@ const FALLBACKS: Record = { 'queue.count': 'queued', }; +/** INBUXA: in a cluster, this card gives way to Cluster Health. */ +const replacedInCluster = (metrics: string[]) => metrics.length === 1 && metrics[0] === 'server.memory'; + interface DashboardViewProps { dashboardId: string; section: string; @@ -55,6 +60,7 @@ export function DashboardView({ dashboardId, section }: DashboardViewProps) { const liveStatus = useLiveMetricsStore((s) => s.status); const liveError = useLiveMetricsStore((s) => s.error); const { facts } = useServerFacts(); + const clusterHealth = useClusterHealth(); const dashboards = useMemo(() => schema?.dashboards ?? [], [schema]); const dashboard = dashboards.find((d) => d.id === dashboardId); @@ -158,19 +164,23 @@ export function DashboardView({ dashboardId, section }: DashboardViewProps) { {dashboard.cards && dashboard.cards.length > 0 && (
- {dashboard.cards.map((card, i) => ( - - ))} + {dashboard.cards.map((card, i) => + clusterHealth && replacedInCluster(card.metrics) ? ( + + ) : ( + + ), + )}
)} -- 2.54.0