Dashboard: Cluster Health replaces Server Memory in a cluster #17
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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in new issue
Block a user