Dashboard: Cluster Health replaces Server Memory in a cluster #17

Merged
jcoffey-dev merged 1 commits from feature/cluster-health into main 2026-09-25 08:08:21 +00:00
4 changed files with 305 additions and 13 deletions

No files matched your search

@@ -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();
});
});
+106
View File
@@ -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<ClusterNodeRow[] | null> {
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<string, unknown>).list as Record<string, unknown>[] | 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<ClusterHealth | null>(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;
}
@@ -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 = (
<Card
className={cn(
'h-full transition-all hover:shadow-md',
!allWell && 'border-highlight/50',
href &&
'group-hover:-translate-y-0.5 group-hover:border-primary/50 group-focus-visible:ring-2 group-focus-visible:ring-ring',
)}
>
<CardContent className="p-5">
<div className="flex items-center gap-2">
<IconTile name="network" size="sm" />
<span className="text-sm font-medium text-muted-foreground">{t('cluster.health', 'Cluster Health')}</span>
{href && (
<ArrowUpRight className="ml-auto h-4 w-4 shrink-0 text-muted-foreground/0 transition-colors group-hover:text-primary" />
)}
</div>
<div className="mt-3 font-display text-3xl font-semibold tracking-tight tabular-nums">
{health.healthy}
<span className="text-muted-foreground"> / {total}</span>
</div>
<div className="mt-1 text-sm text-muted-foreground">
{t('cluster.healthy', {
count: health.healthy,
defaultValue_one: '{{count}} healthy node',
defaultValue_other: '{{count}} healthy nodes',
})}
{' · '}
<span className={cn(!allWell && 'font-medium text-highlight')}>
{t('cluster.unhealthy', {
count: health.unhealthy,
defaultValue_one: '{{count}} unhealthy',
defaultValue_other: '{{count}} unhealthy',
})}
</span>
</div>
{health.silent.length > 0 && (
<ul className="mt-2 space-y-0.5 text-xs text-muted-foreground">
{health.silent.map((n) => (
<li key={n.hostname} className="truncate">
<span className="font-mono text-foreground">{n.hostname}</span> {quietFor(n.lastRenewal)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
return href ? (
<Link
to={href}
className="group block focus-visible:outline-none"
aria-label={t('cluster.healthLink', 'Cluster Health: see the nodes')}
>
{body}
</Link>
) : (
body
);
}
@@ -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<string, keyof ServerFacts> = {
@@ -36,6 +38,9 @@ const FALLBACKS: Record<string, keyof ServerFacts> = {
'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<Dashboard[]>(() => 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 && (
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(220px,1fr))]">
{dashboard.cards.map((card, i) => (
<StatCard
key={`${card.title}-${i}`}
card={card}
historySamples={historySamples}
historyWindow={historyWindow}
fallback={
card.metrics.length === 1 && FALLBACKS[card.metrics[0]]
? (facts?.[FALLBACKS[card.metrics[0]]] as number | undefined)
: undefined
}
/>
))}
{dashboard.cards.map((card, i) =>
clusterHealth && replacedInCluster(card.metrics) ? (
<ClusterHealthCard key={`cluster-health-${i}`} health={clusterHealth} />
) : (
<StatCard
key={`${card.title}-${i}`}
card={card}
historySamples={historySamples}
historyWindow={historyWindow}
fallback={
card.metrics.length === 1 && FALLBACKS[card.metrics[0]]
? (facts?.[FALLBACKS[card.metrics[0]]] as number | undefined)
: undefined
}
/>
),
)}
</div>
)}