Dashboard: a command center, with trend pages to match #63
No files matched your search
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* What needs a look, gathered from everything the dashboards know: the
|
||||
* server's objects, the cluster's leases, the security to-do list and the
|
||||
* period's metrics. Each matter carries where you'd deal with it. The list
|
||||
* is empty when all is well, and then nothing is shown at all.
|
||||
*/
|
||||
import type { TFunction } from 'i18next';
|
||||
import {
|
||||
CircleX,
|
||||
FileWarning,
|
||||
HardDrive,
|
||||
ListX,
|
||||
RadioTower,
|
||||
RotateCw,
|
||||
ServerCrash,
|
||||
ShieldAlert,
|
||||
Timer,
|
||||
} from 'lucide-react';
|
||||
import { LEGACY_PROTOCOLS_VIEW } from '@/features/hardening/LegacyProtocolsBanner';
|
||||
import type { Metric } from './types/metrics';
|
||||
import type { ServerFacts } from './serverFacts';
|
||||
import type { ClusterHealth } from './clusterHealth';
|
||||
import type { Attention } from './components/AttentionRail';
|
||||
import { DMARC_WARNINGS, LATENCIES, TLS_WARNINGS, mean, nearQuota, total, zoneOf } from './commandCenter';
|
||||
|
||||
const QUEUE = 'x:QueuedMessage';
|
||||
|
||||
export interface AttentionInput {
|
||||
t: TFunction;
|
||||
/** Whether the viewer may open a view; matters they can't act on are left out. */
|
||||
may: (view: string) => boolean;
|
||||
facts: ServerFacts | null;
|
||||
health: ClusterHealth | null;
|
||||
critical: number | null;
|
||||
samples: Metric[];
|
||||
liveStatus: string;
|
||||
nodesHref: string | null;
|
||||
/** The Performance page, where the slow stage shows. */
|
||||
slowHref: string | null;
|
||||
linkOf: (metrics: string[]) => string | null;
|
||||
}
|
||||
|
||||
export function buildAttention({
|
||||
t,
|
||||
may,
|
||||
facts,
|
||||
health,
|
||||
critical,
|
||||
samples,
|
||||
liveStatus,
|
||||
nodesHref,
|
||||
slowHref,
|
||||
linkOf,
|
||||
}: AttentionInput): Attention[] {
|
||||
const retrying = facts?.retrying ?? 0;
|
||||
const bounced = facts?.waiting?.reduce((s, w) => s + w.failed, 0) ?? 0;
|
||||
const attention: Attention[] = [];
|
||||
if (critical)
|
||||
attention.push({
|
||||
id: 'security',
|
||||
severity: 'crit',
|
||||
icon: ShieldAlert,
|
||||
count: critical,
|
||||
title: t('cc.a.security', {
|
||||
count: critical,
|
||||
defaultValue_one: 'critical security item',
|
||||
defaultValue_other: 'critical security items',
|
||||
}),
|
||||
detail: t('cc.a.securityDetail', 'On the Security to-do list and not yet accepted.'),
|
||||
href: `/Settings/${LEGACY_PROTOCOLS_VIEW}`,
|
||||
});
|
||||
if (health && health.unhealthy)
|
||||
attention.push({
|
||||
id: 'nodes',
|
||||
severity: 'crit',
|
||||
icon: ServerCrash,
|
||||
count: health.unhealthy,
|
||||
title: t('cc.a.nodes', {
|
||||
count: health.unhealthy,
|
||||
defaultValue_one: 'node not responding',
|
||||
defaultValue_other: 'nodes not responding',
|
||||
}),
|
||||
detail: health.silent.map((n) => n.hostname).join(', '),
|
||||
href: nodesHref,
|
||||
});
|
||||
if (bounced && may(QUEUE))
|
||||
attention.push({
|
||||
id: 'bounced',
|
||||
severity: 'crit',
|
||||
icon: CircleX,
|
||||
count: bounced,
|
||||
title: t('cc.a.bounced', {
|
||||
count: bounced,
|
||||
defaultValue_one: 'recipient given up on',
|
||||
defaultValue_other: 'recipients given up on',
|
||||
}),
|
||||
detail: t('cc.a.bouncedDetail', 'Delivery failed for good; the sender gets a bounce.'),
|
||||
href: `/Management/${QUEUE}`,
|
||||
});
|
||||
if (facts?.failedTasks && may('x:Task/TaskFailed'))
|
||||
attention.push({
|
||||
id: 'tasks',
|
||||
severity: 'warn',
|
||||
icon: ListX,
|
||||
count: facts.failedTasks,
|
||||
title: t('cc.a.tasks', {
|
||||
count: facts.failedTasks,
|
||||
defaultValue_one: 'failed task',
|
||||
defaultValue_other: 'failed tasks',
|
||||
}),
|
||||
detail: t('cc.a.tasksDetail', 'Background work that stopped with an error.'),
|
||||
href: '/Management/x:Task/TaskFailed',
|
||||
});
|
||||
if (retrying && may(QUEUE))
|
||||
attention.push({
|
||||
id: 'retrying',
|
||||
severity: 'warn',
|
||||
icon: RotateCw,
|
||||
count: retrying,
|
||||
title: t('cc.a.retrying', {
|
||||
count: retrying,
|
||||
defaultValue_one: 'message retrying',
|
||||
defaultValue_other: 'messages retrying',
|
||||
}),
|
||||
detail: t('cc.a.retryingDetail', 'A receiving server turned it away for now.'),
|
||||
href: `/Management/${QUEUE}`,
|
||||
});
|
||||
const full = nearQuota(facts?.storage);
|
||||
if (full && may('x:Account/User'))
|
||||
attention.push({
|
||||
id: 'quota',
|
||||
severity: 'warn',
|
||||
icon: HardDrive,
|
||||
count: full,
|
||||
title: t('cc.a.quota', {
|
||||
count: full,
|
||||
defaultValue_one: 'account near its quota',
|
||||
defaultValue_other: 'accounts near their quota',
|
||||
}),
|
||||
detail: t('cc.a.quotaDetail', 'At 90% or more of their storage; mail may soon be refused.'),
|
||||
href: '/Management/x:Account/User',
|
||||
});
|
||||
const slow = LATENCIES.filter((l) => {
|
||||
const ms = mean(samples, [l.metric]);
|
||||
return ms !== null && zoneOf(ms, l) === 'slow';
|
||||
}).length;
|
||||
if (slow)
|
||||
attention.push({
|
||||
id: 'slow',
|
||||
severity: 'warn',
|
||||
icon: Timer,
|
||||
count: slow,
|
||||
title: t('cc.a.slow', {
|
||||
count: slow,
|
||||
defaultValue_one: 'stage running slow',
|
||||
defaultValue_other: 'stages running slow',
|
||||
}),
|
||||
detail: t('cc.a.slowDetailPage', 'Performance shows which one.'),
|
||||
href: slowHref,
|
||||
});
|
||||
const dmarc = total(samples, DMARC_WARNINGS);
|
||||
const tls = total(samples, TLS_WARNINGS);
|
||||
if (dmarc + tls > 0)
|
||||
attention.push({
|
||||
id: 'reports',
|
||||
severity: 'warn',
|
||||
icon: FileWarning,
|
||||
count: dmarc + tls,
|
||||
title: t('cc.a.reports', {
|
||||
count: dmarc + tls,
|
||||
defaultValue_one: 'report with warnings',
|
||||
defaultValue_other: 'reports with warnings',
|
||||
}),
|
||||
detail: t('cc.a.reportsDetail', 'Other providers flagged how your mail arrived ({{dmarc}} DMARC, {{tls}} TLS).', {
|
||||
dmarc,
|
||||
tls,
|
||||
}),
|
||||
href: linkOf(dmarc ? DMARC_WARNINGS : TLS_WARNINGS),
|
||||
});
|
||||
if (liveStatus === 'error')
|
||||
attention.push({
|
||||
id: 'feed',
|
||||
severity: 'warn',
|
||||
icon: RadioTower,
|
||||
title: t('cc.a.feed', 'Live feed unavailable'),
|
||||
detail: t('cc.a.feedDetail', 'Counts come from the server’s records; connection meters are dark.'),
|
||||
});
|
||||
|
||||
return attention;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { startAutoRefresh } from './autoRefresh';
|
||||
|
||||
describe('startAutoRefresh', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('refreshes once per chosen interval', () => {
|
||||
const refresh = vi.fn();
|
||||
const stop = startAutoRefresh(5, refresh, () => false);
|
||||
vi.advanceTimersByTime(4 * 60_000 + 59_000);
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
vi.advanceTimersByTime(10 * 60_000);
|
||||
expect(refresh).toHaveBeenCalledTimes(3);
|
||||
stop();
|
||||
vi.advanceTimersByTime(15 * 60_000);
|
||||
expect(refresh).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('does nothing when off', () => {
|
||||
const refresh = vi.fn();
|
||||
startAutoRefresh(0, refresh, () => false);
|
||||
vi.advanceTimersByTime(60 * 60_000);
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips beats while the page is hidden', () => {
|
||||
const refresh = vi.fn();
|
||||
let hidden = true;
|
||||
startAutoRefresh(1, refresh, () => hidden);
|
||||
vi.advanceTimersByTime(3 * 60_000);
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
hidden = false;
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* Calls `refresh` every `minutes`, skipping a beat while the page is out of
|
||||
* sight (nobody is watching, and the page catches up on the next one).
|
||||
* Returns the way to stop. Zero minutes is off.
|
||||
*/
|
||||
export function startAutoRefresh(
|
||||
minutes: number,
|
||||
refresh: () => void,
|
||||
hidden: () => boolean = () => document.hidden,
|
||||
): () => void {
|
||||
if (!minutes) return () => {};
|
||||
const timer = setInterval(() => {
|
||||
if (!hidden()) refresh();
|
||||
}, minutes * 60_000);
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export type NodeStatus = 'active' | 'stale' | 'inactive';
|
||||
|
||||
export interface ClusterNodeRow {
|
||||
hostname: string;
|
||||
nodeId?: string;
|
||||
status: NodeStatus;
|
||||
/** When the node last renewed its lease. */
|
||||
lastRenewal: string;
|
||||
@@ -64,7 +65,7 @@ async function fetchNodes(): Promise<ClusterNodeRow[] | null> {
|
||||
{
|
||||
accountId,
|
||||
'#ids': { resultOf: 'q', name: 'x:ClusterNode/query', path: '/ids' },
|
||||
properties: ['hostname', 'status', 'lastRenewal'],
|
||||
properties: ['hostname', 'nodeId', 'status', 'lastRenewal'],
|
||||
},
|
||||
'g',
|
||||
],
|
||||
@@ -74,6 +75,7 @@ async function fetchNodes(): Promise<ClusterNodeRow[] | 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),
|
||||
nodeId: n.nodeId === undefined || n.nodeId === null ? undefined : String(n.nodeId),
|
||||
status: String(n.status ?? 'active').toLowerCase() as NodeStatus,
|
||||
lastRenewal: String(n.lastRenewal ?? ''),
|
||||
}));
|
||||
@@ -81,15 +83,28 @@ async function fetchNodes(): Promise<ClusterNodeRow[] | null> {
|
||||
|
||||
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);
|
||||
export interface ClusterState {
|
||||
/** Every lease, inactive ones included, for the node roster. */
|
||||
nodes: ClusterNodeRow[];
|
||||
health: ClusterHealth;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cluster's nodes and their health, refreshed every 30 seconds and
|
||||
* whenever `tick` moves. undefined until the first answer; null when this
|
||||
* server isn't one node of several, or the viewer can't list nodes.
|
||||
*/
|
||||
export function useClusterState(tick = 0) {
|
||||
const [state, setState] = useState<ClusterState | null | undefined>(undefined);
|
||||
|
||||
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(() => {});
|
||||
.then((nodes) => {
|
||||
const health = nodes ? summarizeNodes(nodes) : null;
|
||||
setState(nodes && health ? { nodes, health } : null);
|
||||
})
|
||||
// A failed refresh keeps the last answer rather than flipping the page back.
|
||||
.catch(() => setState((s) => (s === undefined ? null : s)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -100,7 +115,7 @@ export function useClusterHealth() {
|
||||
clearTimeout(first);
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [refresh]);
|
||||
}, [refresh, tick]);
|
||||
|
||||
return health;
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Metric } from './types/metrics';
|
||||
import {
|
||||
cleanShare,
|
||||
latest,
|
||||
load,
|
||||
mean,
|
||||
nearQuota,
|
||||
queueHealth,
|
||||
series,
|
||||
summarize,
|
||||
total,
|
||||
zoneOf,
|
||||
} from './commandCenter';
|
||||
|
||||
const at = (iso: string, metric: string, count: number): Metric => ({
|
||||
'@type': 'Counter',
|
||||
metric,
|
||||
count,
|
||||
timestamp: iso,
|
||||
});
|
||||
const timing = (metric: string, count: number, sum: number): Metric => ({
|
||||
'@type': 'Histogram',
|
||||
metric,
|
||||
count,
|
||||
sum,
|
||||
timestamp: '2026-09-28T10:00:00Z',
|
||||
});
|
||||
|
||||
describe('total', () => {
|
||||
it('adds up only the metrics asked for', () => {
|
||||
const s = [
|
||||
at('2026-09-28T10:00:00Z', 'a', 3),
|
||||
at('2026-09-28T11:00:00Z', 'b', 4),
|
||||
at('2026-09-28T12:00:00Z', 'a', 1),
|
||||
];
|
||||
expect(total(s, ['a'])).toBe(4);
|
||||
expect(total(s, ['a', 'b'])).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mean', () => {
|
||||
it('weights each sample by how many events it holds', () => {
|
||||
// 1 event at 100 ms and 3 at 300 ms: 250 ms, not the 200 of a plain average.
|
||||
expect(mean([timing('t', 1, 100), timing('t', 3, 900)], ['t'])).toBe(250);
|
||||
});
|
||||
it('is null when nothing was timed', () => {
|
||||
expect(mean([], ['t'])).toBeNull();
|
||||
expect(mean([at('2026-09-28T10:00:00Z', 't', 5)], ['t'])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('series', () => {
|
||||
it('drops each sample in its slice of the period', () => {
|
||||
const from = new Date('2026-09-28T00:00:00Z');
|
||||
const to = new Date('2026-09-28T04:00:00Z');
|
||||
const s = [
|
||||
at('2026-09-28T00:30:00Z', 'a', 2),
|
||||
at('2026-09-28T03:30:00Z', 'a', 5),
|
||||
at('2026-09-28T03:40:00Z', 'b', 9),
|
||||
];
|
||||
expect(series(s, ['a'], from, to, 4)).toEqual([2, 0, 0, 5]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('load', () => {
|
||||
it('reads now from the last complete slice while the newest is still filling', () => {
|
||||
expect(load([10, 40, 20, 3])).toEqual({ now: 20, peak: 40, ratio: 0.5 });
|
||||
});
|
||||
it('takes the newest slice once it has overtaken the one before', () => {
|
||||
expect(load([10, 40, 20, 30]).now).toBe(30);
|
||||
});
|
||||
it('is zero without traffic', () => {
|
||||
expect(load([0, 0])).toEqual({ now: 0, peak: 0, ratio: 0 });
|
||||
expect(load([])).toEqual({ now: 0, peak: 0, ratio: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('zoneOf', () => {
|
||||
const l = { good: 100, bad: 1000 };
|
||||
it('marks the bounds inclusive of the better zone', () => {
|
||||
expect(zoneOf(100, l)).toBe('good');
|
||||
expect(zoneOf(101, l)).toBe('fair');
|
||||
expect(zoneOf(1000, l)).toBe('fair');
|
||||
expect(zoneOf(1001, l)).toBe('slow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('queueHealth', () => {
|
||||
it('is the share of recipients simply waiting their turn', () => {
|
||||
expect(
|
||||
queueHealth([
|
||||
{ scheduled: 6, retrying: 1, failed: 1 },
|
||||
{ scheduled: 2, retrying: 0, failed: 0 },
|
||||
]),
|
||||
).toBe(0.8);
|
||||
});
|
||||
it('is whole when the queue is empty or unknown', () => {
|
||||
expect(queueHealth([])).toBe(1);
|
||||
expect(queueHealth(undefined)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nearQuota', () => {
|
||||
it('counts accounts at 90% of a quota, ignoring those without one', () => {
|
||||
const s = [
|
||||
{ used: 90, quota: 100 },
|
||||
{ used: 89, quota: 100 },
|
||||
{ used: 5000, quota: null },
|
||||
{ used: 120, quota: 100 },
|
||||
];
|
||||
expect(nearQuota(s)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarize', () => {
|
||||
const level = (iso: string, count: number): Metric => ({ '@type': 'Gauge', metric: 'q', count, timestamp: iso });
|
||||
it('reads a level by its latest value, not by adding readings up', () => {
|
||||
const s = [level('2026-09-28T10:00:00Z', 30), level('2026-09-28T12:00:00Z', 7), level('2026-09-28T11:00:00Z', 50)];
|
||||
expect(latest(s, ['q'])).toBe(7);
|
||||
expect(summarize(s, ['q'], false)).toBe(7);
|
||||
});
|
||||
it('totals counts and averages timings', () => {
|
||||
expect(summarize([at('2026-09-28T10:00:00Z', 'c', 2), at('2026-09-28T11:00:00Z', 'c', 3)], ['c'], false)).toBe(5);
|
||||
expect(summarize([timing('t', 2, 100)], ['t'], true)).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanShare', () => {
|
||||
it('is the share of slices without a single event', () => {
|
||||
expect(cleanShare([0, 0, 3, 0])).toBe(0.75);
|
||||
expect(cleanShare([])).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* The command center's arithmetic: which metrics it reads, and how raw
|
||||
* samples become the numbers on its gauges, readouts and meters. Kept apart
|
||||
* from the components so every figure can be tested on its own.
|
||||
*/
|
||||
import type { Metric } from './types/metrics';
|
||||
import { bucketize, metricToScalar } from './helpers';
|
||||
|
||||
/** Messages the server took in from outside. */
|
||||
export const RECEIVED = ['queue.message-queued'];
|
||||
/** Messages the server sent: people's mail, bounces and reports. */
|
||||
export const SENT = ['queue.authenticated-message-queued', 'queue.dsn-queued', 'queue.report-queued'];
|
||||
export const SPAM = ['message-ingest.spam'];
|
||||
export const HAM = ['message-ingest.ham'];
|
||||
export const AUTH_FAILED = ['auth.failed'];
|
||||
export const BANS = ['security.authentication-ban', 'security.abuse-ban', 'security.scan-ban', 'security.loiter-ban'];
|
||||
export const DMARC_WARNINGS = ['incoming-report.dmarc-report-with-warnings'];
|
||||
export const TLS_WARNINGS = ['incoming-report.tls-report-with-warnings'];
|
||||
|
||||
export interface Latency {
|
||||
id: string;
|
||||
metric: string;
|
||||
/** Below this is good (ms). */
|
||||
good: number;
|
||||
/** Above this needs a look (ms); the meter's full scale is twice this. */
|
||||
bad: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The timings worth watching, with where "fine" ends and "slow" begins.
|
||||
* The bounds are rules of thumb for a mail server on ordinary hardware.
|
||||
*/
|
||||
export const LATENCIES: Latency[] = [
|
||||
{ id: 'ingest', metric: 'message-ingest.time', good: 500, bad: 2000 },
|
||||
{ id: 'index', metric: 'message-ingest.index-time', good: 500, bad: 2000 },
|
||||
{ id: 'dns', metric: 'dns.lookup-time', good: 150, bad: 1000 },
|
||||
{ id: 'delivery', metric: 'delivery.attempt-time', good: 2000, bad: 10000 },
|
||||
{ id: 'dataRead', metric: 'store.data-read-time', good: 20, bad: 200 },
|
||||
{ id: 'dataWrite', metric: 'store.data-write-time', good: 50, bad: 500 },
|
||||
{ id: 'blobRead', metric: 'store.blob-read-time', good: 50, bad: 500 },
|
||||
{ id: 'blobWrite', metric: 'store.blob-write-time', good: 100, bad: 1000 },
|
||||
];
|
||||
|
||||
export interface Protocol {
|
||||
id: string;
|
||||
label: string;
|
||||
metric: string;
|
||||
}
|
||||
|
||||
/** Open connections, by what they're speaking. */
|
||||
export const PROTOCOLS: Protocol[] = [
|
||||
{ id: 'smtp', label: 'SMTP', metric: 'smtp.active-connections' },
|
||||
{ id: 'imap', label: 'IMAP', metric: 'imap.active-connections' },
|
||||
{ id: 'pop3', label: 'POP3', metric: 'pop3.active-connections' },
|
||||
{ id: 'http', label: 'HTTP', metric: 'http.active-connections' },
|
||||
{ id: 'sieve', label: 'Sieve', metric: 'sieve.active-connections' },
|
||||
{ id: 'out', label: 'OUT', metric: 'delivery.active-connections' },
|
||||
];
|
||||
|
||||
/** Where the command center's metrics are cached; the shell fetches them for every page. */
|
||||
export const COMMAND_CACHE = 'command-center';
|
||||
|
||||
export const LIVE_IDS = new Set<string>([
|
||||
'user.count',
|
||||
'domain.count',
|
||||
'queue.count',
|
||||
'server.memory',
|
||||
...PROTOCOLS.map((p) => p.metric),
|
||||
]);
|
||||
|
||||
export const HISTORY_IDS = new Set<string>([
|
||||
...RECEIVED,
|
||||
...SENT,
|
||||
...SPAM,
|
||||
...HAM,
|
||||
...AUTH_FAILED,
|
||||
...BANS,
|
||||
...DMARC_WARNINGS,
|
||||
...TLS_WARNINGS,
|
||||
...LATENCIES.map((l) => l.metric),
|
||||
]);
|
||||
|
||||
/** Every event counted over the period, across the given metrics. */
|
||||
export function total(samples: Metric[], ids: string[]): number {
|
||||
let n = 0;
|
||||
for (const s of samples) if (ids.includes(s.metric)) n += s.count;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** The average of a timing over the period, weighted by how often it happened; null if it never did. */
|
||||
export function mean(samples: Metric[], ids: string[]): number | null {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (const s of samples) {
|
||||
if (!ids.includes(s.metric) || s['@type'] !== 'Histogram') continue;
|
||||
sum += s.sum;
|
||||
count += s.count;
|
||||
}
|
||||
return count > 0 ? sum / count : null;
|
||||
}
|
||||
|
||||
/** Counts per time slice across the period, oldest first. */
|
||||
export function series(samples: Metric[], ids: string[], from: Date, to: Date, buckets: number): number[] {
|
||||
return bucketize(samples, from, to, buckets).map((b) =>
|
||||
b.filter((m) => ids.includes(m.metric)).reduce((s, m) => s + metricToScalar(m), 0),
|
||||
);
|
||||
}
|
||||
|
||||
export interface Load {
|
||||
/** Messages in the latest complete slice. */
|
||||
now: number;
|
||||
/** The busiest slice in the period. */
|
||||
peak: number;
|
||||
/** now as a share of peak, 0–1. */
|
||||
ratio: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How busy the server is right now against its busiest moment in the period.
|
||||
* The newest slice is still filling, so "now" is the one before it, unless
|
||||
* the newest has already overtaken it.
|
||||
*/
|
||||
export function load(flow: number[]): Load {
|
||||
const peak = Math.max(0, ...flow);
|
||||
const last = flow.at(-1) ?? 0;
|
||||
const prev = flow.at(-2) ?? 0;
|
||||
const now = Math.max(last, prev);
|
||||
return { now, peak, ratio: peak > 0 ? now / peak : 0 };
|
||||
}
|
||||
|
||||
export type Zone = 'good' | 'fair' | 'slow';
|
||||
|
||||
export function zoneOf(ms: number, l: Pick<Latency, 'good' | 'bad'>): Zone {
|
||||
if (ms <= l.good) return 'good';
|
||||
if (ms <= l.bad) return 'fair';
|
||||
return 'slow';
|
||||
}
|
||||
|
||||
/** Share of queued recipients that are simply waiting their turn, 0–1; 1 when the queue is empty. */
|
||||
export function queueHealth(waiting: { scheduled: number; retrying: number; failed: number }[] | undefined): number {
|
||||
let ok = 0;
|
||||
let all = 0;
|
||||
for (const w of waiting ?? []) {
|
||||
ok += w.scheduled;
|
||||
all += w.scheduled + w.retrying + w.failed;
|
||||
}
|
||||
return all === 0 ? 1 : ok / all;
|
||||
}
|
||||
|
||||
/** Accounts at or past the given share of their quota. */
|
||||
export function nearQuota(storage: { used: number; quota: number | null }[] | undefined, share = 0.9): number {
|
||||
return (storage ?? []).filter((a) => a.quota && a.used >= a.quota * share).length;
|
||||
}
|
||||
|
||||
/** The last reading of a level (a gauge metric such as queue size) in the period; null if none. */
|
||||
export function latest(samples: Metric[], ids: string[]): number | null {
|
||||
let at = -Infinity;
|
||||
let v: number | null = null;
|
||||
for (const s of samples) {
|
||||
if (!ids.includes(s.metric) || !s.timestamp) continue;
|
||||
const t = Date.parse(s.timestamp);
|
||||
if (t >= at) {
|
||||
at = t;
|
||||
v = s.count;
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* One figure for a metric over the period, the way it's meant to be read:
|
||||
* an average for timings, the latest reading for levels, a total for counts.
|
||||
*/
|
||||
export function summarize(samples: Metric[], ids: string[], timing: boolean): number | null {
|
||||
if (timing) return mean(samples, ids);
|
||||
const mine = samples.filter((s) => ids.includes(s.metric));
|
||||
if (mine.length > 0 && mine.every((s) => s['@type'] === 'Gauge')) return latest(mine, ids);
|
||||
return total(samples, ids);
|
||||
}
|
||||
|
||||
const CONNECTION_STARTS = [
|
||||
'smtp.connection-start',
|
||||
'imap.connection-start',
|
||||
'pop3.connection-start',
|
||||
'http.connection-start',
|
||||
'manage-sieve.connection-start',
|
||||
];
|
||||
|
||||
/** What each trend page's instruments read, beyond what the page's own cards and charts fetch. */
|
||||
export const DECK_METRICS: Record<string, string[]> = {
|
||||
network: [...CONNECTION_STARTS, 'delivery.attempt-start'],
|
||||
security: ['security.ip-blocked', ...BANS, ...AUTH_FAILED, ...SPAM, ...HAM],
|
||||
delivery: [...RECEIVED, ...SENT, 'delivery.attempt-time'],
|
||||
performance: [...LATENCIES.map((l) => l.metric), 'server.memory'],
|
||||
storage: LATENCIES.filter((l) => l.metric.startsWith('store.')).map((l) => l.metric),
|
||||
};
|
||||
|
||||
/** How the nodes talk to each other: errors and dropped links between them. */
|
||||
export const COORDINATION = ['cluster.subscriber-error', 'cluster.publisher-error', 'cluster.subscriber-disconnected'];
|
||||
/** The shared data layer the nodes stand on: every store backend's errors. */
|
||||
export const STORE_ERRORS = [
|
||||
'store.foundationdb-error',
|
||||
'store.mysql-error',
|
||||
'store.postgresql-error',
|
||||
'store.rocksdb-error',
|
||||
'store.sqlite-error',
|
||||
'store.ldap-error',
|
||||
'store.elasticsearch-error',
|
||||
'store.redis-error',
|
||||
'store.s3-error',
|
||||
'store.azure-error',
|
||||
'store.filesystem-error',
|
||||
'store.pool-error',
|
||||
'store.unexpected-error',
|
||||
'store.http-store-error',
|
||||
];
|
||||
export const THREAD_ERRORS = ['server.thread-error'];
|
||||
export const CLUSTER_IDS = new Set([...COORDINATION, ...STORE_ERRORS, ...THREAD_ERRORS]);
|
||||
|
||||
/** A node is reported stale when it hasn't renewed its lease in this long. */
|
||||
export const STALE_AFTER_MS = 3 * 60_000;
|
||||
|
||||
/** The share of the period's slices with nothing in them, 0–1; 1 for an empty period. */
|
||||
export function cleanShare(slices: number[]): number {
|
||||
if (slices.length === 0) return 1;
|
||||
return slices.filter((v) => v === 0).length / slices.length;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { createElement } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowRight, type LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface Attention {
|
||||
id: string;
|
||||
severity: 'crit' | 'warn';
|
||||
icon: LucideIcon;
|
||||
/** The count that leads the tile; left out when the tile isn't about a number. */
|
||||
count?: number;
|
||||
title: string;
|
||||
detail: string;
|
||||
href?: string | null;
|
||||
}
|
||||
|
||||
const ORDER = { crit: 0, warn: 1 };
|
||||
|
||||
/**
|
||||
* What needs a look, first thing on the page: one tile per matter, the
|
||||
* critical ones leading and red, the rest amber. Each tile opens where you'd
|
||||
* deal with it. With nothing to report the rail isn't drawn at all.
|
||||
*/
|
||||
export function AttentionRail({ items }: { items: Attention[] }) {
|
||||
const { t } = useTranslation();
|
||||
if (items.length === 0) return null;
|
||||
const sorted = [...items].sort((a, b) => ORDER[a.severity] - ORDER[b.severity]);
|
||||
const crit = items.filter((i) => i.severity === 'crit').length;
|
||||
|
||||
return (
|
||||
<section aria-labelledby="cc-attention">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-60',
|
||||
crit ? 'bg-destructive' : 'bg-highlight',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn('relative inline-flex h-2.5 w-2.5 rounded-full', crit ? 'bg-destructive' : 'bg-highlight')}
|
||||
/>
|
||||
</span>
|
||||
<h2 id="cc-attention" className="text-[11px] font-semibold uppercase tracking-[0.16em] text-foreground">
|
||||
{t('cc.attention', {
|
||||
count: items.length,
|
||||
defaultValue_one: 'Needs attention · {{count}}',
|
||||
defaultValue_other: 'Needs attention · {{count}}',
|
||||
})}
|
||||
</h2>
|
||||
<span className="h-px flex-1 bg-gradient-to-r from-border to-transparent" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 [&>*]:min-w-0 [&>*]:flex-[1_1_250px]">
|
||||
{sorted.map((a) => {
|
||||
const body = (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex h-full items-start gap-3 overflow-hidden rounded-xl border bg-card p-4 pl-5 shadow-soft transition-all',
|
||||
a.severity === 'crit' ? 'border-destructive/50' : 'border-highlight/50',
|
||||
a.href && 'hover:-translate-y-0.5 hover:shadow-md',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-y-0 left-0 w-1',
|
||||
a.severity === 'crit' ? 'bg-destructive' : 'bg-highlight',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 opacity-[0.07]',
|
||||
a.severity === 'crit'
|
||||
? 'bg-gradient-to-br from-destructive to-transparent'
|
||||
: 'bg-gradient-to-br from-highlight to-transparent',
|
||||
)}
|
||||
/>
|
||||
{createElement(a.icon, {
|
||||
className: cn('mt-0.5 h-5 w-5 shrink-0', a.severity === 'crit' ? 'text-destructive' : 'text-highlight'),
|
||||
'aria-hidden': true,
|
||||
})}
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
{a.count !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-2xl font-semibold leading-none tabular-nums',
|
||||
a.severity === 'crit' ? 'text-destructive' : 'text-highlight',
|
||||
)}
|
||||
>
|
||||
{a.count}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-semibold leading-tight">{a.title}</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs leading-snug text-muted-foreground">{a.detail}</p>
|
||||
</div>
|
||||
{a.href && (
|
||||
<ArrowRight className="relative mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return a.href ? (
|
||||
<Link
|
||||
key={a.id}
|
||||
to={a.href}
|
||||
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-xl"
|
||||
>
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={a.id}>{body}</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { formatValue } from '../helpers';
|
||||
import { TONE_VAR, type Tone } from '../tones';
|
||||
import { Panel } from './Panel';
|
||||
|
||||
export interface BreakdownRow {
|
||||
label: string;
|
||||
value: number;
|
||||
tone?: Tone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Totals side by side as glowing bars, longest first: what a whole was made
|
||||
* of over the period, readable at a glance.
|
||||
*/
|
||||
export function Breakdown({
|
||||
title,
|
||||
aside,
|
||||
rows,
|
||||
totalLabel,
|
||||
}: {
|
||||
title: string;
|
||||
aside?: string;
|
||||
rows: BreakdownRow[];
|
||||
/** What the footer's total counts, e.g. "Stopped in all". */
|
||||
totalLabel: string;
|
||||
}) {
|
||||
const sorted = [...rows].sort((a, b) => b.value - a.value);
|
||||
const max = Math.max(1, ...sorted.map((r) => r.value));
|
||||
const sum = sorted.reduce((s, r) => s + r.value, 0);
|
||||
return (
|
||||
<Panel
|
||||
title={title}
|
||||
aside={
|
||||
aside ? (
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">{aside}</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<ul className="flex flex-1 flex-col justify-evenly gap-3">
|
||||
{sorted.map((r) => {
|
||||
const color = TONE_VAR[r.tone ?? 'primary'];
|
||||
return (
|
||||
<li key={r.label} className="grid grid-cols-[8rem_minmax(0,1fr)_5.5rem] items-center gap-3 text-xs">
|
||||
<span className="truncate text-muted-foreground">{r.label}</span>
|
||||
<span className="h-2.5 rounded-full bg-muted">
|
||||
<span
|
||||
className="block h-full rounded-full transition-[width] duration-700"
|
||||
style={{
|
||||
width: `${r.value ? Math.max(2, (r.value / max) * 100) : 0}%`,
|
||||
background: color,
|
||||
boxShadow: `0 0 8px -2px ${color}`,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-right font-mono tabular-nums">
|
||||
<span className="font-semibold">{formatValue(r.value, 'number')}</span>
|
||||
<span className="ml-1.5 text-muted-foreground">
|
||||
{sum ? `${Math.round((r.value / sum) * 100)}%` : ''}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<div className="flex h-2.5 overflow-hidden rounded-full bg-muted" aria-hidden>
|
||||
{sorted.map((r) =>
|
||||
r.value ? (
|
||||
<span
|
||||
key={r.label}
|
||||
className="h-full transition-[width] duration-700"
|
||||
style={{
|
||||
width: `${(r.value / (sum || 1)) * 100}%`,
|
||||
background: TONE_VAR[r.tone ?? 'primary'],
|
||||
opacity: 0.85,
|
||||
}}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline justify-between text-xs text-muted-foreground">
|
||||
<span>{totalLabel}</span>
|
||||
<span className="font-mono text-lg font-semibold tabular-nums text-foreground">
|
||||
{formatValue(sum, 'number')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Gauge as GaugeIcon, RefreshCw } from 'lucide-react';
|
||||
import type { Dashboard } from '../types/schema';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AUTO_REFRESH, useDashboardStore, type AutoRefresh } from '../stores/dashboardStore';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { PRESET_KEYS } from '../types/metrics';
|
||||
import { startAutoRefresh } from '../autoRefresh';
|
||||
|
||||
/**
|
||||
* Local time to the second, with UTC beside it for comparing with logs, and
|
||||
* when the numbers on the page last came in; that line flashes as they do.
|
||||
*/
|
||||
export function Clock({ updated }: { updated?: Date | null }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
const local = new Intl.DateTimeFormat(i18n.language, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).format(now);
|
||||
const utc = new Intl.DateTimeFormat(i18n.language, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: 'UTC',
|
||||
}).format(now);
|
||||
return (
|
||||
<div className="text-right font-mono leading-tight tabular-nums">
|
||||
<div className="text-lg font-semibold tracking-tight">{local}</div>
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">UTC {utc}</div>
|
||||
{updated && (
|
||||
<div
|
||||
key={updated.getTime()}
|
||||
className="animate-[cc-flash_1.2s_ease-out] text-[10px] uppercase tracking-[0.12em] text-muted-foreground"
|
||||
>
|
||||
{t('cc.updated', 'Updated {{time}}', {
|
||||
time: new Intl.DateTimeFormat(i18n.language, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).format(updated),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 24H · 7D · 30D · 90D, as a row of keys; the one lit is the period every
|
||||
* panel reads. Beside it the refresh button and how often the page refreshes
|
||||
* by itself: off, or every 1, 5, 10 or 15 minutes (5 unless changed). The
|
||||
* choice is remembered, and shared by every dashboard page.
|
||||
*/
|
||||
export function PeriodSwitch({ loading = false }: { loading?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const period = useDashboardStore((s) => s.period);
|
||||
const setPreset = useDashboardStore((s) => s.setPreset);
|
||||
const every = useDashboardStore((s) => s.autoRefresh);
|
||||
const setEvery = useDashboardStore((s) => s.setAutoRefresh);
|
||||
const onRefresh = useDashboardStore((s) => s.bump);
|
||||
const current = period.kind === 'preset' ? period.preset : null;
|
||||
|
||||
useEffect(() => startAutoRefresh(every, onRefresh), [every, onRefresh]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t('cc.period', 'Period')}
|
||||
className="flex rounded-lg border bg-background/60 p-0.5 font-mono text-xs"
|
||||
>
|
||||
{PRESET_KEYS.map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={current === k}
|
||||
onClick={() => setPreset(k)}
|
||||
className={cn(
|
||||
'rounded-md px-2.5 py-1 font-semibold uppercase tracking-wider transition-colors',
|
||||
current === k
|
||||
? 'bg-primary text-primary-foreground shadow-[0_0_12px_-3px_var(--primary)]'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{k}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center rounded-lg border bg-background/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
aria-label={t('cc.refresh', 'Refresh')}
|
||||
title={t('cc.refresh', 'Refresh')}
|
||||
className="p-2 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<RefreshCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} />
|
||||
</button>
|
||||
<Select value={String(every)} onValueChange={(v) => setEvery(Number(v) as AutoRefresh)}>
|
||||
<SelectTrigger
|
||||
aria-label={t('cc.autoRefresh', 'Auto refresh')}
|
||||
className="h-7 w-auto gap-1 border-0 border-l bg-transparent px-2 font-mono text-[11px] font-semibold uppercase tracking-wider shadow-none focus:ring-0"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
{AUTO_REFRESH.map((m) => (
|
||||
<SelectItem key={m} value={String(m)} className="font-mono text-xs">
|
||||
{m === 0 ? t('cc.autoOff', 'Auto off') : t('cc.autoEvery', 'Every {{m}}m', { m })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A small status lamp for the header: a word or two and a colored dot. */
|
||||
export function Chip({ tone, children }: { tone: 'ok' | 'muted'; children: ReactNode }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 font-mono text-[10px] font-semibold uppercase tracking-[0.14em]',
|
||||
tone === 'ok' ? 'border-primary/30 text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The dashboards' own navigation, the same on every one of them: the command
|
||||
* center first, then each trend page, the current one lit.
|
||||
*/
|
||||
export function DashNav({
|
||||
dashboards,
|
||||
current,
|
||||
section,
|
||||
cluster = false,
|
||||
}: {
|
||||
dashboards: Dashboard[];
|
||||
current: string;
|
||||
section: string;
|
||||
/** Adds the Cluster page, for servers that are one node of several. */
|
||||
cluster?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const chip = (active: boolean) =>
|
||||
cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 font-mono text-[11px] font-semibold uppercase tracking-wider transition-colors',
|
||||
active
|
||||
? 'border-primary bg-primary text-primary-foreground shadow-[0_0_12px_-3px_var(--primary)]'
|
||||
: 'text-muted-foreground hover:border-primary/50 hover:text-foreground',
|
||||
);
|
||||
return (
|
||||
<nav aria-label={t('cc.dashboards', 'Dashboards')} className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
to={`/${section}/Dashboard/overview`}
|
||||
aria-current={current === 'overview' ? 'page' : undefined}
|
||||
className={chip(current === 'overview')}
|
||||
>
|
||||
<GaugeIcon className="h-3.5 w-3.5" />
|
||||
{t('cc.back', 'Command center')}
|
||||
</Link>
|
||||
<span className="mx-1 h-4 w-px bg-border" aria-hidden />
|
||||
<span className="mr-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{t('cc.trends', 'Trends')}
|
||||
</span>
|
||||
{dashboards
|
||||
.filter((d) => d.id !== 'overview')
|
||||
.map((d) => (
|
||||
<Link
|
||||
key={d.id}
|
||||
to={`/${section}/Dashboard/${d.id}`}
|
||||
aria-current={d.id === current ? 'page' : undefined}
|
||||
className={chip(d.id === current)}
|
||||
>
|
||||
{d.label}
|
||||
</Link>
|
||||
))}
|
||||
{cluster && (
|
||||
<Link
|
||||
to={`/${section}/Dashboard/cluster`}
|
||||
aria-current={current === 'cluster' ? 'page' : undefined}
|
||||
className={chip(current === 'cluster')}
|
||||
>
|
||||
{t('cc.cluster', 'Cluster')}
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
/*
|
||||
* 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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { presetLabel } from '../types/metrics';
|
||||
import type { Chart } from '../types/schema';
|
||||
import { useLiveMetricsStore } from '../stores/liveMetricsStore';
|
||||
import { formatValue, getBucketCount } from '../helpers';
|
||||
import {
|
||||
CLUSTER_IDS,
|
||||
COORDINATION,
|
||||
LIVE_IDS,
|
||||
STALE_AFTER_MS,
|
||||
STORE_ERRORS,
|
||||
THREAD_ERRORS,
|
||||
cleanShare,
|
||||
series,
|
||||
total,
|
||||
} from '../commandCenter';
|
||||
import type { ClusterNodeRow } from '../clusterHealth';
|
||||
import { useDashboardShared } from '../dashboardContext';
|
||||
import { usePageHistory } from '../usePageHistory';
|
||||
import type { Tone } from '../tones';
|
||||
import { Gauge } from './Gauge';
|
||||
import { Panel } from './Panel';
|
||||
import { Readout } from './Readout';
|
||||
import { TrendChart } from './TrendChart';
|
||||
|
||||
const NODES_VIEW = 'x:ClusterNode';
|
||||
const STRIP = 24;
|
||||
|
||||
/** Seconds since a lease was renewed, as of `now`; null if the time can't be read. */
|
||||
function ageOf(n: ClusterNodeRow, now: number): number | null {
|
||||
const t = Date.parse(n.lastRenewal);
|
||||
return Number.isNaN(t) ? null : Math.max(0, now - t);
|
||||
}
|
||||
|
||||
function ago(ms: number): string {
|
||||
const s = Math.round(ms / 1000);
|
||||
if (s < 90) return `${s} s`;
|
||||
const m = Math.round(s / 60);
|
||||
if (m < 90) return `${m} min`;
|
||||
const h = Math.round(m / 60);
|
||||
return h < 48 ? `${h} h` : `${Math.round(h / 24)} d`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cluster's own trends page, there only when this server is one node of
|
||||
* several. Dials for how many nodes are up, how fresh their heartbeats are,
|
||||
* and how much of the period the nodes' links and the shared data layer ran
|
||||
* without an error; a roster of every node with its heartbeat counting up
|
||||
* live; then the errors themselves over time.
|
||||
*/
|
||||
export function ClusterView() {
|
||||
const { t } = useTranslation();
|
||||
const { canViewObject } = usePermissions();
|
||||
const nodesSection = useSchemaStore((s) => s.viewToSection[NODES_VIEW]);
|
||||
const subscribeLive = useLiveMetricsStore((s) => s.subscribe);
|
||||
const unsubscribeLive = useLiveMetricsStore((s) => s.unsubscribe);
|
||||
const { cluster } = useDashboardShared();
|
||||
const { samples, window, period } = usePageHistory('cluster', CLUSTER_IDS);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
subscribeLive(LIVE_IDS);
|
||||
return () => unsubscribeLive();
|
||||
}, [subscribeLive, unsubscribeLive]);
|
||||
|
||||
const buckets = getBucketCount(period);
|
||||
const coord = series(samples, COORDINATION, window.from, window.to, buckets);
|
||||
const store = series(samples, [...STORE_ERRORS, ...THREAD_ERRORS], window.from, window.to, buckets);
|
||||
const periodName =
|
||||
period.kind === 'preset' ? presetLabel((k, d) => t(k, d), period.preset) : t('cc.customPeriod', 'Chosen period');
|
||||
|
||||
const charts = useMemo<Chart[]>(
|
||||
() => [
|
||||
{
|
||||
title: t('cc.c.coordination', 'Links between nodes'),
|
||||
kind: 'bar',
|
||||
stacked: true,
|
||||
description: t(
|
||||
'cc.c.coordinationDesc',
|
||||
'Errors and dropped links on the channel the nodes use to keep in step.',
|
||||
),
|
||||
series: [
|
||||
{ label: t('cc.c.subscriberErrors', 'receive errors'), metrics: ['cluster.subscriber-error'] },
|
||||
{ label: t('cc.c.publisherErrors', 'send errors'), metrics: ['cluster.publisher-error'] },
|
||||
{ label: t('cc.c.disconnects', 'dropped'), metrics: ['cluster.subscriber-disconnected'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('cc.c.dataLayer', 'Data layer errors'),
|
||||
kind: 'bar',
|
||||
stacked: true,
|
||||
description: t(
|
||||
'cc.c.dataLayerDesc',
|
||||
'Errors from the stores every node shares, and from the server’s own threads.',
|
||||
),
|
||||
series: [
|
||||
{ label: t('cc.c.storeErrors', 'store'), metrics: STORE_ERRORS },
|
||||
{ label: t('cc.c.threadErrors', 'threads'), metrics: THREAD_ERRORS },
|
||||
],
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
if (cluster === undefined) return null;
|
||||
if (cluster === null)
|
||||
return (
|
||||
<Panel title={t('cc.cluster', 'Cluster')}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('cc.notCluster', 'This server runs on its own, so there is no cluster to show.')}
|
||||
</p>
|
||||
</Panel>
|
||||
);
|
||||
|
||||
const { nodes, health } = cluster;
|
||||
const members = nodes.filter((n) => n.status !== 'inactive');
|
||||
const retired = nodes.filter((n) => n.status === 'inactive');
|
||||
const all = health.healthy + health.unhealthy;
|
||||
const activeAges = members
|
||||
.filter((n) => n.status === 'active')
|
||||
.map((n) => ageOf(n, now))
|
||||
.filter((a): a is number => a !== null);
|
||||
const oldest = activeAges.length ? Math.max(...activeAges) : null;
|
||||
const coordClean = cleanShare(coord);
|
||||
const storeClean = cleanShare(store);
|
||||
const cleanTone = (share: number): Tone => (share >= 0.99 ? 'ok' : share >= 0.95 ? 'warn' : 'crit');
|
||||
const cleanBands = [
|
||||
{ from: 0, to: 0.95, tone: 'crit' as Tone },
|
||||
{ from: 0.95, to: 0.99, tone: 'warn' as Tone },
|
||||
{ from: 0.99, to: 1, tone: 'ok' as Tone },
|
||||
];
|
||||
const nodesHref = nodesSection && canViewObject(NODES_VIEW) ? `/${nodesSection}/${NODES_VIEW}` : null;
|
||||
const n = (v: number) => formatValue(v, 'number');
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Panel
|
||||
title={t('cc.instruments', 'Instruments')}
|
||||
aside={
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">{periodName}</span>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-6 sm:grid-cols-3 xl:grid-cols-[repeat(auto-fit,minmax(170px,1fr))]">
|
||||
<Gauge
|
||||
value={health.healthy / all}
|
||||
tone={health.unhealthy ? 'crit' : 'ok'}
|
||||
reading={
|
||||
<>
|
||||
{health.healthy}
|
||||
<span className="text-muted-foreground">/{all}</span>
|
||||
</>
|
||||
}
|
||||
label={t('cc.g.nodes', 'Nodes up')}
|
||||
detail={
|
||||
health.unhealthy
|
||||
? health.silent.map((s) => s.hostname).join(', ')
|
||||
: t('cc.g.nodesDetail', 'Every node renewing')
|
||||
}
|
||||
tickEvery={1 / all}
|
||||
/>
|
||||
<Gauge
|
||||
value={oldest === null ? null : oldest / STALE_AFTER_MS}
|
||||
tone={
|
||||
oldest === null
|
||||
? 'primary'
|
||||
: oldest < STALE_AFTER_MS / 2
|
||||
? 'primary'
|
||||
: oldest < STALE_AFTER_MS * 0.83
|
||||
? 'warn'
|
||||
: 'crit'
|
||||
}
|
||||
reading={oldest === null ? '—' : ago(oldest)}
|
||||
label={t('cc.g.heartbeat', 'Oldest heartbeat')}
|
||||
detail={t('cc.g.heartbeatDetail', 'Nodes renew every minute; stale after 3')}
|
||||
bands={[
|
||||
{ from: 0, to: 0.5, tone: 'primary' },
|
||||
{ from: 0.5, to: 0.83, tone: 'warn' },
|
||||
{ from: 0.83, to: 1, tone: 'crit' },
|
||||
]}
|
||||
/>
|
||||
<Gauge
|
||||
value={coordClean}
|
||||
tone={cleanTone(coordClean)}
|
||||
reading={formatValue(coordClean * 100, 'percent')}
|
||||
label={t('cc.g.coordClean', 'Links clean')}
|
||||
detail={t('cc.g.errorsInPeriod', {
|
||||
count: total(samples, COORDINATION),
|
||||
defaultValue_one: '{{count}} error in the period',
|
||||
defaultValue_other: '{{count}} errors in the period',
|
||||
})}
|
||||
bands={cleanBands}
|
||||
/>
|
||||
<Gauge
|
||||
value={storeClean}
|
||||
tone={cleanTone(storeClean)}
|
||||
reading={formatValue(storeClean * 100, 'percent')}
|
||||
label={t('cc.g.storeClean', 'Data layer clean')}
|
||||
detail={t('cc.g.errorsInPeriod', {
|
||||
count: total(samples, [...STORE_ERRORS, ...THREAD_ERRORS]),
|
||||
defaultValue_one: '{{count}} error in the period',
|
||||
defaultValue_other: '{{count}} errors in the period',
|
||||
})}
|
||||
bands={cleanBands}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
title={t('cc.roster', 'Nodes')}
|
||||
href={nodesHref}
|
||||
hrefLabel={t('cc.rosterOpen', 'Node list')}
|
||||
aside={
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
{t('cc.rosterCount', '{{up}} of {{all}} up', { up: health.healthy, all })}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap gap-3 [&>*]:min-w-0 [&>*]:flex-[1_1_220px]">
|
||||
{members.map((node) => {
|
||||
const age = ageOf(node, now);
|
||||
const up = node.status === 'active';
|
||||
const share = age === null ? 1 : Math.min(1, age / STALE_AFTER_MS);
|
||||
return (
|
||||
<div
|
||||
key={node.hostname}
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-lg border bg-background/40 p-3.5',
|
||||
!up && 'border-destructive/50',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
{up && (
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-50" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex h-2.5 w-2.5 rounded-full',
|
||||
up ? 'bg-primary' : 'bg-destructive',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span className="truncate font-mono text-sm font-semibold">{node.hostname}</span>
|
||||
{node.nodeId !== undefined && (
|
||||
<span className="ml-auto font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
{t('cc.nodeId', 'id {{id}}', { id: node.nodeId })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2.5 flex items-baseline justify-between gap-2 whitespace-nowrap text-xs">
|
||||
<span className={cn('uppercase tracking-wider', up ? 'text-muted-foreground' : 'text-destructive')}>
|
||||
{up ? t('cc.nodeActive', 'Active') : t('cc.nodeSilent', 'Silent')}
|
||||
</span>
|
||||
<span className="font-mono tabular-nums">
|
||||
{age === null ? '—' : t('cc.heartbeatAgo', 'heartbeat {{ago}} ago', { ago: ago(age) })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1 rounded-full bg-muted" aria-hidden>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-[width] duration-1000 ease-linear',
|
||||
!up ? 'bg-destructive' : share > 0.5 ? 'bg-highlight' : 'bg-primary',
|
||||
)}
|
||||
style={{ width: `${Math.max(2, share * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{retired.length > 0 && (
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
{t('cc.retired', {
|
||||
count: retired.length,
|
||||
defaultValue_one: '{{count}} old lease not counted: {{names}}',
|
||||
defaultValue_other: '{{count}} old leases not counted: {{names}}',
|
||||
names: retired.map((r) => r.hostname).join(', '),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<div className="grid gap-3 grid-cols-2 lg:grid-cols-4">
|
||||
<Readout
|
||||
label={t('cc.r.receiveErrors', 'Receive errors')}
|
||||
value={n(total(samples, ['cluster.subscriber-error']))}
|
||||
bars={series(samples, ['cluster.subscriber-error'], window.from, window.to, STRIP)}
|
||||
tone="crit"
|
||||
/>
|
||||
<Readout
|
||||
label={t('cc.r.sendErrors', 'Send errors')}
|
||||
value={n(total(samples, ['cluster.publisher-error']))}
|
||||
bars={series(samples, ['cluster.publisher-error'], window.from, window.to, STRIP)}
|
||||
tone="crit"
|
||||
/>
|
||||
<Readout
|
||||
label={t('cc.r.drops', 'Dropped links')}
|
||||
value={n(total(samples, ['cluster.subscriber-disconnected']))}
|
||||
bars={series(samples, ['cluster.subscriber-disconnected'], window.from, window.to, STRIP)}
|
||||
tone="warn"
|
||||
/>
|
||||
<Readout
|
||||
label={t('cc.r.storeErrors', 'Store errors')}
|
||||
value={n(total(samples, STORE_ERRORS))}
|
||||
bars={series(samples, STORE_ERRORS, window.from, window.to, STRIP)}
|
||||
tone="crit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2 [&>*]:min-w-0">
|
||||
{charts.map((chart) => (
|
||||
<TrendChart key={chart.title} chart={chart} samples={samples} window={window} period={period} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import { presetLabel } from '../types/metrics';
|
||||
import { useDashboardStore } from '../stores/dashboardStore';
|
||||
import { useLiveMetricsStore } from '../stores/liveMetricsStore';
|
||||
import { bucketTimestamps, formatValue, getBucketCount, periodWindow } from '../helpers';
|
||||
import { hrefFor, linkForMetrics } from '../links';
|
||||
import { useDashboardShared } from '../dashboardContext';
|
||||
import {
|
||||
AUTH_FAILED,
|
||||
BANS,
|
||||
HAM,
|
||||
LATENCIES,
|
||||
LIVE_IDS,
|
||||
RECEIVED,
|
||||
SENT,
|
||||
SPAM,
|
||||
load,
|
||||
mean,
|
||||
queueHealth,
|
||||
series,
|
||||
total,
|
||||
zoneOf,
|
||||
} from '../commandCenter';
|
||||
import { Gauge } from './Gauge';
|
||||
import type { Tone } from '../tones';
|
||||
import { Readout } from './Readout';
|
||||
import { Panel } from './Panel';
|
||||
import { LiveConnections } from './LiveConnections';
|
||||
import { Latencies } from './Latencies';
|
||||
import { MailFlow } from './MailFlow';
|
||||
import { QueueWaiting } from './QueueWaiting';
|
||||
import { StorageTreemap } from './StorageTreemap';
|
||||
import { WeeklyHeatmap } from './WeeklyHeatmap';
|
||||
|
||||
const STRIP = 24;
|
||||
const QUEUE = 'x:QueuedMessage';
|
||||
|
||||
function sliceLabel(ms: number): string {
|
||||
const min = Math.round(ms / 60_000);
|
||||
if (min < 60) return `${min} min`;
|
||||
const h = Math.round(min / 60);
|
||||
if (h < 24) return `${h} h`;
|
||||
return `${Math.round(h / 24)} d`;
|
||||
}
|
||||
|
||||
const pct = (share: number) => `${Math.round(share * 100)}`;
|
||||
|
||||
/**
|
||||
* The first thing an administrator sees: a command center rather than a
|
||||
* page of charts. Under the shell's greeting and what needs a look, a row of
|
||||
* dials for the server's vital signs, quick stats with a strip of the period
|
||||
* behind each, live connection meters and response times, then where mail
|
||||
* is going and where the space is. Every figure opens the page where you'd
|
||||
* act on it.
|
||||
*/
|
||||
export function CommandCenter() {
|
||||
const { t } = useTranslation();
|
||||
const { canViewObject } = usePermissions();
|
||||
const period = useDashboardStore((s) => s.period);
|
||||
const subscribeLive = useLiveMetricsStore((s) => s.subscribe);
|
||||
const unsubscribeLive = useLiveMetricsStore((s) => s.unsubscribe);
|
||||
const { facts, cluster, samples, live, liveValues } = useDashboardShared();
|
||||
const health = cluster?.health ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
subscribeLive(LIVE_IDS);
|
||||
return () => unsubscribeLive();
|
||||
}, [subscribeLive, unsubscribeLive]);
|
||||
|
||||
const liveValue = (id: string) => (live ? liveValues.get(id) : undefined);
|
||||
|
||||
// The period in slices, for the flow strip and the load dial.
|
||||
const { from, to } = periodWindow(period);
|
||||
const buckets = getBucketCount(period);
|
||||
const received = series(samples, RECEIVED, from, to, buckets);
|
||||
const sent = series(samples, SENT, from, to, buckets);
|
||||
const times = bucketTimestamps(from, to, buckets);
|
||||
const flow = received.map((r, i) => r + (sent[i] ?? 0));
|
||||
const busy = load(flow);
|
||||
const strip = (ids: string[]) => series(samples, ids, from, to, STRIP);
|
||||
|
||||
const spam = total(samples, SPAM);
|
||||
const scanned = spam + total(samples, HAM);
|
||||
const ingestMs = mean(samples, ['message-ingest.time']);
|
||||
const ingest = LATENCIES[0];
|
||||
const queueOk = queueHealth(facts?.waiting);
|
||||
const queued = liveValue('queue.count') ?? facts?.queued;
|
||||
const retrying = facts?.retrying ?? 0;
|
||||
const quotaAccounts = facts?.storage?.filter((a) => a.quota) ?? [];
|
||||
const quotaUsed = quotaAccounts.reduce((s, a) => s + a.used, 0);
|
||||
const quotaTotal = quotaAccounts.reduce((s, a) => s + (a.quota ?? 0), 0);
|
||||
const storedTotal = facts?.storage?.reduce((s, a) => s + a.used, 0);
|
||||
|
||||
const periodName =
|
||||
period.kind === 'preset' ? presetLabel((k, d) => t(k, d), period.preset) : t('cc.customPeriod', 'Chosen period');
|
||||
|
||||
const may = (view: string) => canViewObject(view);
|
||||
const linkOf = (metrics: string[]) => {
|
||||
const l = linkForMetrics(metrics);
|
||||
return l && may(l.viewName) ? hrefFor(l) : null;
|
||||
};
|
||||
|
||||
const ingestZone = ingestMs === null ? null : zoneOf(ingestMs, ingest);
|
||||
const toneOfZone: Record<string, Tone> = { good: 'primary', fair: 'warn', slow: 'crit' };
|
||||
const queueTone: Tone = queueOk >= 0.9 ? 'ok' : queueOk >= 0.6 ? 'warn' : 'crit';
|
||||
const slice = sliceLabel((to.getTime() - from.getTime()) / buckets);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Vital signs */}
|
||||
<Panel
|
||||
title={t('cc.vitals', 'Vital signs')}
|
||||
aside={
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">{periodName}</span>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-6 sm:grid-cols-3 xl:grid-cols-[repeat(auto-fit,minmax(170px,1fr))]">
|
||||
<Gauge
|
||||
value={busy.peak > 0 ? busy.ratio : null}
|
||||
reading={formatValue(busy.now, 'number')}
|
||||
unit={t('cc.perSlice', '/{{slice}}', { slice })}
|
||||
label={t('cc.g.load', 'Load')}
|
||||
detail={
|
||||
busy.peak > 0
|
||||
? t('cc.g.loadDetail', '{{pct}}% of peak ({{peak}})', {
|
||||
pct: pct(busy.ratio),
|
||||
peak: formatValue(busy.peak, 'number'),
|
||||
})
|
||||
: t('cc.g.noTraffic', 'No mail recorded yet')
|
||||
}
|
||||
/>
|
||||
<Gauge
|
||||
value={facts?.waiting ? queueOk : null}
|
||||
tone={queueTone}
|
||||
reading={facts?.waiting ? pct(queueOk) : '—'}
|
||||
unit="%"
|
||||
label={t('cc.g.queue', 'Queue on schedule')}
|
||||
detail={
|
||||
queued !== undefined
|
||||
? t('cc.g.queueDetail', '{{n}} queued · {{r}} retrying', { n: queued, r: retrying })
|
||||
: undefined
|
||||
}
|
||||
bands={[
|
||||
{ from: 0, to: 0.6, tone: 'crit' },
|
||||
{ from: 0.6, to: 0.9, tone: 'warn' },
|
||||
{ from: 0.9, to: 1, tone: 'ok' },
|
||||
]}
|
||||
/>
|
||||
<Gauge
|
||||
value={scanned ? 1 - spam / scanned : null}
|
||||
tone="primary"
|
||||
reverse={scanned ? { value: spam / scanned, tone: 'crit' } : undefined}
|
||||
reading={formatValue(total(samples, RECEIVED), 'number')}
|
||||
label={t('cc.g.received', 'Mail received')}
|
||||
detail={
|
||||
scanned ? (
|
||||
<span className="inline-flex flex-wrap justify-center gap-x-2">
|
||||
<span className="text-primary">
|
||||
{t('cc.g.clean', '{{n}} to inboxes', { n: formatValue(scanned - spam, 'number') })}
|
||||
</span>
|
||||
<span className="text-destructive">
|
||||
{t('cc.g.filtered', '{{n}} spam filtered', { n: formatValue(spam, 'number') })}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
t('cc.g.noScans', 'Nothing scanned yet')
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Gauge
|
||||
value={ingestMs === null ? null : ingestMs / (ingest.bad * 2)}
|
||||
tone={ingestZone ? toneOfZone[ingestZone] : 'primary'}
|
||||
reading={ingestMs === null ? '—' : formatValue(ingestMs, 'duration')}
|
||||
label={t('cc.g.ingest', 'Take-in time')}
|
||||
detail={t('cc.g.ingestDetail', 'Average per message')}
|
||||
bands={[
|
||||
{ from: 0, to: ingest.good / (ingest.bad * 2), tone: 'primary' },
|
||||
{ from: ingest.good / (ingest.bad * 2), to: 0.5, tone: 'warn' },
|
||||
{ from: 0.5, to: 1, tone: 'crit' },
|
||||
]}
|
||||
/>
|
||||
{health && (
|
||||
<Gauge
|
||||
value={health.healthy / (health.healthy + health.unhealthy)}
|
||||
tone={health.unhealthy ? 'crit' : 'ok'}
|
||||
reading={
|
||||
<>
|
||||
{health.healthy}
|
||||
<span className="text-muted-foreground">/{health.healthy + health.unhealthy}</span>
|
||||
</>
|
||||
}
|
||||
label={t('cc.g.nodes', 'Nodes up')}
|
||||
detail={
|
||||
health.unhealthy
|
||||
? health.silent.map((n) => n.hostname).join(', ')
|
||||
: t('cc.g.nodesDetail', 'Every node renewing')
|
||||
}
|
||||
tickEvery={1 / (health.healthy + health.unhealthy)}
|
||||
/>
|
||||
)}
|
||||
{quotaTotal > 0 && (
|
||||
<Gauge
|
||||
value={quotaUsed / quotaTotal}
|
||||
tone={quotaUsed / quotaTotal >= 0.9 ? 'crit' : quotaUsed / quotaTotal >= 0.75 ? 'warn' : 'primary'}
|
||||
reading={pct(quotaUsed / quotaTotal)}
|
||||
unit="%"
|
||||
label={t('cc.g.storage', 'Quota used')}
|
||||
detail={t('cc.g.storageDetail', '{{used}} of {{quota}}', {
|
||||
used: formatValue(quotaUsed, 'bytes'),
|
||||
quota: formatValue(quotaTotal, 'bytes'),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<Readout
|
||||
label={t('cc.r.users', 'People')}
|
||||
value={formatValue(liveValue('user.count') ?? facts?.users ?? 0, 'number')}
|
||||
detail={
|
||||
facts?.groups
|
||||
? t('cc.r.groups', {
|
||||
count: facts.groups,
|
||||
defaultValue_one: '{{count}} group',
|
||||
defaultValue_other: '{{count}} groups',
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
href={linkOf(['user.count'])}
|
||||
/>
|
||||
<Readout
|
||||
label={t('cc.r.domains', 'Domains')}
|
||||
value={formatValue(liveValue('domain.count') ?? facts?.domains ?? 0, 'number')}
|
||||
href={linkOf(['domain.count'])}
|
||||
/>
|
||||
<Readout
|
||||
label={t('cc.r.received', 'Received')}
|
||||
value={formatValue(total(samples, RECEIVED), 'number')}
|
||||
bars={strip(RECEIVED)}
|
||||
href={linkOf(RECEIVED)}
|
||||
/>
|
||||
<Readout
|
||||
label={t('cc.r.sent', 'Sent')}
|
||||
value={formatValue(total(samples, SENT), 'number')}
|
||||
bars={strip(SENT)}
|
||||
tone="neutral"
|
||||
href={linkOf(SENT)}
|
||||
/>
|
||||
<Readout
|
||||
label={t('cc.r.queued', 'Queued')}
|
||||
value={queued === undefined ? '—' : formatValue(queued, 'number')}
|
||||
detail={retrying ? t('cc.r.retrying', '{{count}} retrying', { count: retrying }) : undefined}
|
||||
href={may(QUEUE) ? `/Management/${QUEUE}` : null}
|
||||
/>
|
||||
<Readout
|
||||
label={t('cc.r.spam', 'Spam blocked')}
|
||||
value={formatValue(spam, 'number')}
|
||||
bars={strip(SPAM)}
|
||||
tone="neutral"
|
||||
href={linkOf(SPAM)}
|
||||
/>
|
||||
<Readout
|
||||
label={t('cc.r.authFailed', 'Failed sign-ins')}
|
||||
value={formatValue(total(samples, AUTH_FAILED), 'number')}
|
||||
bars={strip(AUTH_FAILED)}
|
||||
tone="warn"
|
||||
href={linkOf(AUTH_FAILED)}
|
||||
/>
|
||||
<Readout
|
||||
label={t('cc.r.bans', 'Addresses banned')}
|
||||
value={formatValue(total(samples, BANS), 'number')}
|
||||
detail={
|
||||
facts?.blockedIps !== undefined
|
||||
? t('cc.r.blocked', '{{count}} on the block list', { count: facts.blockedIps })
|
||||
: undefined
|
||||
}
|
||||
bars={strip(BANS)}
|
||||
tone="crit"
|
||||
href={linkOf(BANS)}
|
||||
/>
|
||||
<Readout
|
||||
label={t('cc.r.stored', 'Mail stored')}
|
||||
value={storedTotal === undefined ? '—' : formatValue(storedTotal, 'bytes')}
|
||||
detail={
|
||||
facts?.storage
|
||||
? t('cc.r.storedDetail', {
|
||||
count: facts.storage.length,
|
||||
defaultValue_one: 'across {{count}} account',
|
||||
defaultValue_other: 'across {{count}} accounts',
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
href={may('x:Account/User') ? '/Management/x:Account/User' : null}
|
||||
/>
|
||||
<Readout
|
||||
label={t('cc.r.memory', 'Memory')}
|
||||
value={liveValue('server.memory') === undefined ? '—' : formatValue(liveValue('server.memory') ?? 0, 'bytes')}
|
||||
detail={t('cc.r.memoryDetail', 'This node, now')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Current state */}
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.25fr)] [&>*]:min-w-0">
|
||||
<LiveConnections values={liveValues} live={live} />
|
||||
<Latencies samples={samples} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2 [&>*]:min-w-0">
|
||||
<MailFlow received={received} sent={sent} times={times} periodLabel={periodName} />
|
||||
<WeeklyHeatmap samples={samples} />
|
||||
</div>
|
||||
|
||||
{facts && (facts.storage || facts.waiting) && (
|
||||
<div className="grid gap-5 lg:grid-cols-2 [&>*]:min-w-0">
|
||||
{facts.waiting && <QueueWaiting waiting={facts.waiting} />}
|
||||
{facts.storage && <StorageTreemap storage={facts.storage} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
AreaChart,
|
||||
Area,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
|
||||
function ChartSizedContainer({
|
||||
height,
|
||||
children,
|
||||
}: {
|
||||
height: number;
|
||||
children: (width: number, height: number) => React.ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const w = entry.contentRect.width;
|
||||
if (w > 0) setWidth(w);
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
const w = el.getBoundingClientRect().width;
|
||||
if (w > 0) setWidth(w);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ height }}>
|
||||
{width > 0 && children(width, height)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { Info } from 'lucide-react';
|
||||
import { GoLink } from './GoLink';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tooltip as UiTooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { getChartColor } from '@/components/ui/chart';
|
||||
import { ChartTooltipContent } from '@/components/ui/chart';
|
||||
import type { Chart as ChartSchema } from '../types/schema';
|
||||
import type { Metric, Period } from '../types/metrics';
|
||||
import {
|
||||
bucketize,
|
||||
bucketTimestamps,
|
||||
getBucketCount,
|
||||
seriesBucketValue,
|
||||
formatTimeTick,
|
||||
formatValue,
|
||||
} from '../helpers';
|
||||
|
||||
interface DashboardChartProps {
|
||||
chart: ChartSchema;
|
||||
historySamples: Metric[];
|
||||
historyWindow: { from: Date; to: Date };
|
||||
period: Period;
|
||||
}
|
||||
|
||||
export function DashboardChart({ chart, historySamples, historyWindow, period }: DashboardChartProps) {
|
||||
const { from, to } = historyWindow;
|
||||
const bucketCount = getBucketCount(period);
|
||||
const valueFormat = chart.valueFormat ?? 'number';
|
||||
|
||||
const data = useMemo(() => {
|
||||
const buckets = bucketize(historySamples, from, to, bucketCount);
|
||||
const timestamps = bucketTimestamps(from, to, bucketCount);
|
||||
|
||||
const points = timestamps.map((ts, i) => {
|
||||
const point: Record<string, unknown> = {
|
||||
time: ts.getTime(),
|
||||
timeLabel: formatTimeTick(ts, period),
|
||||
};
|
||||
for (const series of chart.series) {
|
||||
point[series.label] = seriesBucketValue(series, buckets[i]);
|
||||
}
|
||||
return point;
|
||||
});
|
||||
|
||||
if (chart.stacked) {
|
||||
const lastSeen: Record<string, number> = {};
|
||||
for (const point of points) {
|
||||
for (const series of chart.series) {
|
||||
const v = point[series.label];
|
||||
if (typeof v === 'number') {
|
||||
lastSeen[series.label] = v;
|
||||
} else {
|
||||
point[series.label] = lastSeen[series.label] ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return points;
|
||||
}, [historySamples, from, to, bucketCount, chart.series, chart.stacked, period]);
|
||||
|
||||
const tickFormatter = (value: number) => formatValue(value, valueFormat);
|
||||
|
||||
const tooltipFormatter = (value: number) => formatValue(value, valueFormat);
|
||||
|
||||
const renderChart = (chartWidth: number, chartHeight: number) => {
|
||||
const commonProps = {
|
||||
data,
|
||||
width: chartWidth,
|
||||
height: chartHeight,
|
||||
margin: { top: 5, right: 10, left: 10, bottom: 5 },
|
||||
};
|
||||
|
||||
const seriesElements = chart.series.map((s, i) => {
|
||||
const color = getChartColor(i);
|
||||
const key = s.label;
|
||||
|
||||
switch (chart.kind) {
|
||||
case 'line':
|
||||
return (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
connectNulls
|
||||
/>
|
||||
);
|
||||
case 'area':
|
||||
return (
|
||||
<Area
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={color}
|
||||
fill={color}
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
stackId={chart.stacked ? '1' : undefined}
|
||||
isAnimationActive={false}
|
||||
connectNulls
|
||||
/>
|
||||
);
|
||||
case 'bar':
|
||||
return (
|
||||
<Bar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
fill={color}
|
||||
stackId={chart.stacked ? '1' : undefined}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const axes = (
|
||||
<>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="timeLabel"
|
||||
tick={{ fontSize: 11 }}
|
||||
className="text-muted-foreground"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={tickFormatter}
|
||||
tick={{ fontSize: 11 }}
|
||||
className="text-muted-foreground"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload, label }) => (
|
||||
<ChartTooltipContent
|
||||
active={active}
|
||||
payload={payload}
|
||||
label={label as string}
|
||||
formatter={tooltipFormatter}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Legend iconType="circle" iconSize={8} wrapperStyle={{ fontSize: '12px' }} />
|
||||
</>
|
||||
);
|
||||
|
||||
switch (chart.kind) {
|
||||
case 'line':
|
||||
return (
|
||||
<LineChart {...commonProps}>
|
||||
{axes}
|
||||
{seriesElements}
|
||||
</LineChart>
|
||||
);
|
||||
case 'area':
|
||||
return (
|
||||
<AreaChart {...commonProps}>
|
||||
{axes}
|
||||
{seriesElements}
|
||||
</AreaChart>
|
||||
);
|
||||
case 'bar':
|
||||
return (
|
||||
<BarChart {...commonProps}>
|
||||
{axes}
|
||||
{seriesElements}
|
||||
</BarChart>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-base">{chart.title}</CardTitle>
|
||||
{chart.description && (
|
||||
<TooltipProvider>
|
||||
<UiTooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<p className="text-xs">{chart.description}</p>
|
||||
</TooltipContent>
|
||||
</UiTooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<GoLink metrics={chart.series.flatMap((x) => x.metrics)} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartSizedContainer height={288}>{(width, height) => renderChart(width, height)}</ChartSizedContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import { LEGACY_PROTOCOLS_VIEW } from '@/features/hardening/LegacyProtocolsBanner';
|
||||
import { useLegacyProtocolsOff } from '@/features/hardening/useLegacyProtocolsOff';
|
||||
import { useCriticalCount } from '@/features/security/useCriticalCount';
|
||||
import type { Dashboard } from '../types/schema';
|
||||
import type { Metric } from '../types/metrics';
|
||||
import { useDashboardStore } from '../stores/dashboardStore';
|
||||
import { useLiveMetricsStore } from '../stores/liveMetricsStore';
|
||||
import { useHistoryMetricsStore } from '../stores/historyMetricsStore';
|
||||
import { deltaHistograms, periodKey } from '../helpers';
|
||||
import { hrefFor, linkForMetrics } from '../links';
|
||||
import { useServerFacts } from '../serverFacts';
|
||||
import { useClusterState } from '../clusterHealth';
|
||||
import { COMMAND_CACHE, HISTORY_IDS } from '../commandCenter';
|
||||
import { buildAttention } from '../attention';
|
||||
import { DashboardContext, type DashboardShared } from '../dashboardContext';
|
||||
import { Greeting } from './Greeting';
|
||||
import { Chip, Clock, DashNav, PeriodSwitch } from './BridgeControls';
|
||||
import { AttentionRail } from './AttentionRail';
|
||||
import { LiveDot } from './Panel';
|
||||
|
||||
/**
|
||||
* What every dashboard page shares, and keeps while you move between them:
|
||||
* the dashboards' navigation, the greeting band with its status lamps,
|
||||
* period, refresh and clock, and what needs a look. It fetches the server's
|
||||
* facts, the cluster's leases and the command center's metrics once, and
|
||||
* hands them to the page inside.
|
||||
*/
|
||||
export function DashboardShell({
|
||||
dashboards,
|
||||
current,
|
||||
section,
|
||||
children,
|
||||
}: {
|
||||
dashboards: Dashboard[];
|
||||
current: string;
|
||||
section: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { canViewObject } = usePermissions();
|
||||
const period = useDashboardStore((s) => s.period);
|
||||
const tick = useDashboardStore((s) => s.tick);
|
||||
const fetchHistory = useHistoryMetricsStore((s) => s.fetch);
|
||||
const refreshHistory = useHistoryMetricsStore((s) => s.refresh);
|
||||
const historyCache = useHistoryMetricsStore((s) => s.cache);
|
||||
const historyStatus = useHistoryMetricsStore((s) => s.status);
|
||||
const liveStatus = useLiveMetricsStore((s) => s.status);
|
||||
const liveSnapshot = useLiveMetricsStore((s) => s.snapshot);
|
||||
const facts = useServerFacts(tick).facts;
|
||||
const cluster = useClusterState(tick);
|
||||
const critical = useCriticalCount();
|
||||
const legacyOff = useLegacyProtocolsOff();
|
||||
|
||||
const cacheKey = `${COMMAND_CACHE}|${periodKey(period)}`;
|
||||
const [version, setVersion] = useState(0);
|
||||
const [updated, setUpdated] = useState<Date | null>(null);
|
||||
|
||||
// The command center's metrics: its dials, and the attention rail everywhere.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = tick ? refreshHistory : fetchHistory;
|
||||
load(COMMAND_CACHE, period, HISTORY_IDS).then(() => {
|
||||
if (cancelled) return;
|
||||
setVersion((v) => v + 1);
|
||||
setUpdated(new Date());
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [period, tick, fetchHistory, refreshHistory]);
|
||||
|
||||
const samples = useMemo<Metric[]>(() => {
|
||||
void version;
|
||||
return deltaHistograms(historyCache.get(cacheKey)?.metrics ?? []);
|
||||
}, [historyCache, cacheKey, version]);
|
||||
|
||||
const live = liveStatus === 'open';
|
||||
const liveValues = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
for (const [id, s] of liveSnapshot) m.set(id, s.count);
|
||||
return m;
|
||||
}, [liveSnapshot]);
|
||||
|
||||
const health = cluster?.health ?? null;
|
||||
const attention = buildAttention({
|
||||
t,
|
||||
may: canViewObject,
|
||||
facts,
|
||||
health,
|
||||
critical,
|
||||
samples,
|
||||
liveStatus,
|
||||
nodesHref: health ? `/${section}/Dashboard/cluster` : null,
|
||||
slowHref: dashboards.some((d) => d.id === 'performance') ? `/${section}/Dashboard/performance` : null,
|
||||
linkOf: (metrics) => {
|
||||
const l = linkForMetrics(metrics);
|
||||
return l && canViewObject(l.viewName) ? hrefFor(l) : null;
|
||||
},
|
||||
});
|
||||
|
||||
const shared: DashboardShared = { facts, cluster, samples, live, liveValues, tick };
|
||||
const loading = [...historyStatus.entries()].some(([k, v]) => v === 'loading' && k.endsWith(periodKey(period)));
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<DashNav dashboards={dashboards} current={current} section={section} cluster={!!cluster} />
|
||||
<Greeting>
|
||||
<div className="flex flex-wrap items-center gap-3 lg:justify-end">
|
||||
<LiveDot on={live} label={live ? t('cc.live', 'Live') : t('cc.offline', 'No feed')} />
|
||||
{health && (
|
||||
<Link to={`/${section}/Dashboard/cluster`}>
|
||||
<Chip tone={health.unhealthy ? 'muted' : 'ok'}>
|
||||
{t('cc.nodesChip', '{{up}}/{{all}} nodes', {
|
||||
up: health.healthy,
|
||||
all: health.healthy + health.unhealthy,
|
||||
})}
|
||||
</Chip>
|
||||
</Link>
|
||||
)}
|
||||
{legacyOff && (
|
||||
<Link
|
||||
to={`/Settings/${LEGACY_PROTOCOLS_VIEW}`}
|
||||
title={t('cc.legacyTitle', 'Only inbuxa webmail and JMAP apps can sign in.')}
|
||||
>
|
||||
<Chip tone="ok">
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
{t('cc.legacyOff', 'Legacy off')}
|
||||
</Chip>
|
||||
</Link>
|
||||
)}
|
||||
<PeriodSwitch loading={loading} />
|
||||
<Clock updated={updated} />
|
||||
</div>
|
||||
</Greeting>
|
||||
<AttentionRail items={attention} />
|
||||
<DashboardContext.Provider value={shared}>{children}</DashboardContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,205 +7,47 @@
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Greeting } from './Greeting';
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import type { Dashboard } from '../types/schema';
|
||||
import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner';
|
||||
import { useDashboardStore } from '../stores/dashboardStore';
|
||||
import { useLiveMetricsStore } from '../stores/liveMetricsStore';
|
||||
import { useHistoryMetricsStore } from '../stores/historyMetricsStore';
|
||||
import { collectHistoryMetricIds, collectLiveMetricIds, periodKey, periodWindow, deltaHistograms } from '../helpers';
|
||||
import { StatCard } from './StatCard';
|
||||
import { DashboardChart } from './DashboardChart';
|
||||
import { PeriodSelector } from './PeriodSelector';
|
||||
import { StatusLine } from './StatusLine';
|
||||
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> = {
|
||||
'user.count': 'users',
|
||||
'domain.count': 'domains',
|
||||
'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';
|
||||
import { CommandCenter } from './CommandCenter';
|
||||
import { ClusterView } from './ClusterView';
|
||||
import { DashboardShell } from './DashboardShell';
|
||||
import { TrendsView } from './TrendsView';
|
||||
|
||||
interface DashboardViewProps {
|
||||
dashboardId: string;
|
||||
section: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* INBUXA: every dashboard page sits in the same shell, which stays in place
|
||||
* as you move between them. The landing page is the command center; the
|
||||
* others are its trend pages, plus Cluster when the server is part of one.
|
||||
*/
|
||||
export function DashboardView({ dashboardId, section }: DashboardViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const period = useDashboardStore((s) => s.period);
|
||||
const fetchHistory = useHistoryMetricsStore((s) => s.fetch);
|
||||
const refreshHistory = useHistoryMetricsStore((s) => s.refresh);
|
||||
const historyStatus = useHistoryMetricsStore((s) => s.status);
|
||||
const historyCache = useHistoryMetricsStore((s) => s.cache);
|
||||
const subscribeLive = useLiveMetricsStore((s) => s.subscribe);
|
||||
const unsubscribeLive = useLiveMetricsStore((s) => s.unsubscribe);
|
||||
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);
|
||||
const own = dashboardId === 'overview' || dashboardId === 'cluster';
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboard && dashboards.length > 0) {
|
||||
navigate(`/${section}/Dashboard/${dashboards[0].id}`, { replace: true });
|
||||
if (!own && !dashboard && dashboards.length > 0) {
|
||||
navigate(`/${section}/Dashboard/overview`, { replace: true });
|
||||
}
|
||||
}, [dashboard, dashboards, navigate, section]);
|
||||
|
||||
const historyIds = useMemo(
|
||||
() => (dashboard ? collectHistoryMetricIds(dashboard.cards, dashboard.charts) : new Set<string>()),
|
||||
[dashboard],
|
||||
);
|
||||
const liveIds = useMemo(() => (dashboard ? collectLiveMetricIds(dashboard.cards) : new Set<string>()), [dashboard]);
|
||||
|
||||
const cacheKey = dashboard ? `${dashboard.id}|${periodKey(period)}` : '';
|
||||
const [fetchVersion, setFetchVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboard || historyIds.size === 0) return;
|
||||
let cancelled = false;
|
||||
fetchHistory(dashboard.id, period, historyIds).then(() => {
|
||||
if (!cancelled) setFetchVersion((v) => v + 1);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [dashboard, period, historyIds, fetchHistory]);
|
||||
|
||||
const { historySamples, historyWindow } = useMemo(() => {
|
||||
void fetchVersion;
|
||||
const raw = historyCache.get(cacheKey)?.metrics ?? [];
|
||||
return {
|
||||
historySamples: deltaHistograms(raw),
|
||||
historyWindow: periodWindow(period),
|
||||
};
|
||||
}, [historyCache, cacheKey, fetchVersion, period]);
|
||||
|
||||
useEffect(() => {
|
||||
if (liveIds.size > 0) {
|
||||
subscribeLive(liveIds);
|
||||
}
|
||||
return () => {
|
||||
unsubscribeLive();
|
||||
};
|
||||
}, [liveIds, subscribeLive, unsubscribeLive]);
|
||||
|
||||
const isLoading = historyStatus.get(cacheKey) === 'loading';
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (dashboard && historyIds.size > 0) {
|
||||
refreshHistory(dashboard.id, period, historyIds).then(() => setFetchVersion((v) => v + 1));
|
||||
}
|
||||
}, [dashboard, period, historyIds, refreshHistory]);
|
||||
|
||||
if (!dashboard) {
|
||||
if (dashboards.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 text-muted-foreground">No dashboards configured.</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}, [own, dashboard, dashboards, navigate, section]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Greeting />
|
||||
<StatusLine facts={facts} />
|
||||
<LegacyProtocolsBanner />
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
{dashboards.length > 1 && (
|
||||
<Tabs value={dashboardId} onValueChange={(id) => navigate(`/${section}/Dashboard/${id}`)}>
|
||||
<TabsList>
|
||||
{dashboards.map((d) => (
|
||||
<TabsTrigger key={d.id} value={d.id}>
|
||||
{d.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
{dashboards.length === 1 && <h2 className="text-lg font-semibold">{dashboard.label}</h2>}
|
||||
|
||||
<PeriodSelector onRefresh={handleRefresh} loading={isLoading} />
|
||||
</div>
|
||||
|
||||
{liveStatus === 'error' && liveError && (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-highlight/40 bg-highlight-soft px-4 py-3 text-sm text-foreground">
|
||||
<AlertCircle className="h-4 w-4 shrink-0 text-highlight" />
|
||||
<span>
|
||||
{/404/.test(liveError)
|
||||
? t(
|
||||
'dashboard.liveUnavailable',
|
||||
"Live numbers aren't available on this server yet. The rest of the dashboard still works.",
|
||||
)
|
||||
: liveError}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dashboard.cards && dashboard.cards.length > 0 && (
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(220px,1fr))]">
|
||||
{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>
|
||||
)}
|
||||
|
||||
{dashboard.id === 'overview' && facts && (facts.storage || facts.waiting) && (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{facts.waiting && <QueueWaiting waiting={facts.waiting} />}
|
||||
{facts.storage && <StorageTreemap storage={facts.storage} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dashboard.id === 'overview' && <WeeklyHeatmap samples={historySamples} />}
|
||||
|
||||
{dashboard.charts && dashboard.charts.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{dashboard.charts.map((chart, i) => (
|
||||
<DashboardChart
|
||||
key={`${chart.title}-${i}`}
|
||||
chart={chart}
|
||||
historySamples={historySamples}
|
||||
historyWindow={historyWindow}
|
||||
period={period}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DashboardShell dashboards={dashboards} current={dashboardId} section={section}>
|
||||
{dashboardId === 'overview' ? (
|
||||
<CommandCenter />
|
||||
) : dashboardId === 'cluster' ? (
|
||||
<ClusterView />
|
||||
) : dashboard ? (
|
||||
<TrendsView key={dashboard.id} dashboard={dashboard} />
|
||||
) : null}
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useId, type ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TONE_VAR, type Tone } from '../tones';
|
||||
|
||||
/** Where a colored band sits along the dial, as shares of full scale. */
|
||||
export interface Band {
|
||||
from: number;
|
||||
to: number;
|
||||
tone: Tone;
|
||||
}
|
||||
|
||||
interface GaugeProps {
|
||||
/** How far round the dial the needle is, 0–1; null draws the dial empty. */
|
||||
value: number | null;
|
||||
tone?: Tone;
|
||||
/** The big figure in the middle. */
|
||||
reading: ReactNode;
|
||||
unit?: ReactNode;
|
||||
label: ReactNode;
|
||||
/** A line of detail under the label. */
|
||||
detail?: ReactNode;
|
||||
bands?: Band[];
|
||||
/** Ticks on the dial, one per this share of full scale. */
|
||||
tickEvery?: number;
|
||||
/**
|
||||
* A second reading on an inner ring that runs backwards from the far end,
|
||||
* for a whole split in two: the main arc fills from the left, this one
|
||||
* from the right, and they meet where the split falls.
|
||||
*/
|
||||
reverse?: { value: number; tone: Tone };
|
||||
}
|
||||
|
||||
const SWEEP = 240;
|
||||
const START = 90 + (360 - SWEEP) / 2; // clockwise from 3 o'clock, SVG angles
|
||||
const R = 78;
|
||||
const C = 100;
|
||||
|
||||
function point(angle: number, r: number) {
|
||||
const a = (angle * Math.PI) / 180;
|
||||
return [C + r * Math.cos(a), C + r * Math.sin(a)];
|
||||
}
|
||||
|
||||
function arc(from: number, to: number, r: number) {
|
||||
const a0 = START + SWEEP * from;
|
||||
const a1 = START + SWEEP * to;
|
||||
const [x0, y0] = point(a0, r);
|
||||
const [x1, y1] = point(a1, r);
|
||||
const large = a1 - a0 > 180 ? 1 : 0;
|
||||
return `M ${x0} ${y0} A ${r} ${r} 0 ${large} 1 ${x1} ${y1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A dial in the manner of an instrument panel: a 240° track with tick marks,
|
||||
* optional colored bands for fine, fair and slow, and a glowing arc for the
|
||||
* reading. The figure itself sits in the middle, where the eye lands.
|
||||
*/
|
||||
export function Gauge({
|
||||
value,
|
||||
tone = 'primary',
|
||||
reading,
|
||||
unit,
|
||||
label,
|
||||
detail,
|
||||
bands,
|
||||
tickEvery = 0.1,
|
||||
reverse,
|
||||
}: GaugeProps) {
|
||||
const glow = useId();
|
||||
const v = value === null ? 0 : Math.min(1, Math.max(0, value));
|
||||
const color = TONE_VAR[tone];
|
||||
const length = ((SWEEP / 360) * 2 * Math.PI * R).toFixed(1);
|
||||
const ticks = Math.round(1 / tickEvery);
|
||||
const needle = point(START + SWEEP * v, R);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative w-full max-w-[220px]">
|
||||
<svg viewBox="0 0 200 172" className="w-full overflow-visible" aria-hidden>
|
||||
<defs>
|
||||
<filter id={glow} x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="4" result="b" />
|
||||
<feMerge>
|
||||
<feMergeNode in="b" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
{/* Scale ticks outside the track */}
|
||||
{Array.from({ length: ticks + 1 }, (_, i) => {
|
||||
const a = START + (SWEEP * i) / ticks;
|
||||
const major = i % 5 === 0;
|
||||
const [x0, y0] = point(a, R + 10);
|
||||
const [x1, y1] = point(a, R + (major ? 17 : 14));
|
||||
return (
|
||||
<line
|
||||
key={i}
|
||||
x1={x0}
|
||||
y1={y0}
|
||||
x2={x1}
|
||||
y2={y1}
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeOpacity={major ? 0.7 : 0.35}
|
||||
strokeWidth={major ? 1.6 : 1}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<path d={arc(0, 1, R)} fill="none" stroke="var(--muted)" strokeWidth={12} strokeLinecap="round" />
|
||||
{bands?.map((b, i) => (
|
||||
<path
|
||||
key={i}
|
||||
d={arc(b.from, b.to, R + 10)}
|
||||
fill="none"
|
||||
stroke={TONE_VAR[b.tone]}
|
||||
strokeOpacity={0.55}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
))}
|
||||
{value !== null && (
|
||||
<>
|
||||
<path
|
||||
d={arc(0, 1, R)}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={12}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={length}
|
||||
strokeDashoffset={(1 - v) * Number(length)}
|
||||
filter={`url(#${glow})`}
|
||||
className="transition-[stroke-dashoffset] duration-700 ease-out"
|
||||
/>
|
||||
<circle cx={needle[0]} cy={needle[1]} r={4.5} fill="var(--card)" stroke={color} strokeWidth={2.5} />
|
||||
</>
|
||||
)}
|
||||
{reverse && reverse.value > 0 && (
|
||||
<>
|
||||
<path
|
||||
d={arc(0, 1, R - 16)}
|
||||
fill="none"
|
||||
stroke="var(--muted)"
|
||||
strokeOpacity={0.6}
|
||||
strokeWidth={6}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d={arc(1 - Math.min(1, reverse.value), 1, R - 16)}
|
||||
fill="none"
|
||||
stroke={TONE_VAR[reverse.tone]}
|
||||
strokeWidth={6}
|
||||
strokeLinecap="round"
|
||||
filter={`url(#${glow})`}
|
||||
/>
|
||||
<circle
|
||||
cx={point(START + SWEEP * (1 - Math.min(1, reverse.value)), R - 16)[0]}
|
||||
cy={point(START + SWEEP * (1 - Math.min(1, reverse.value)), R - 16)[1]}
|
||||
r={3.5}
|
||||
fill="var(--card)"
|
||||
stroke={TONE_VAR[reverse.tone]}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
<div className="absolute inset-x-0 top-[34%] flex flex-col items-center">
|
||||
<div className="flex items-baseline gap-1 font-mono text-[28px] font-semibold leading-none tabular-nums tracking-tight">
|
||||
{reading}
|
||||
{unit && <span className="text-sm font-medium text-muted-foreground">{unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mt-6 text-center">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">{label}</div>
|
||||
{detail && <div className={cn('mt-1 text-xs text-muted-foreground/80')}>{detail}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
import { hrefFor, useDashLink } from '../links';
|
||||
|
||||
/** "Open the queue ↗": the way from a chart to the page it's about. */
|
||||
export function GoLink({ metrics }: { metrics: string[] }) {
|
||||
const { t } = useTranslation();
|
||||
const link = useDashLink(metrics);
|
||||
if (!link) return null;
|
||||
return (
|
||||
<Link
|
||||
to={hrefFor(link)}
|
||||
className="ml-auto inline-flex shrink-0 items-center gap-1 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{t(`dashLink.${link.viewName}`, link.label)}
|
||||
<ArrowUpRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { getAccountId, jmapQueryAndGet } from '@/services/jmap/client';
|
||||
@@ -48,8 +48,12 @@ function useFullName(username: string | null): string | null {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
/** The dashboard's hello: to whoever is signed in, with a nod to the time of day. */
|
||||
export function Greeting() {
|
||||
/**
|
||||
* The dashboard's hello: to whoever is signed in, with a nod to the time of
|
||||
* day, on a gridded band like a console's header. Whatever the page passes
|
||||
* in (status, clock, period) sits on the right.
|
||||
*/
|
||||
export function Greeting({ children }: { children?: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const username = useAuthStore((s) => s.username);
|
||||
const fullName = useFullName(username);
|
||||
@@ -62,22 +66,35 @@ export function Greeting() {
|
||||
? t('greeting.afternoon', 'Good afternoon')
|
||||
: t('greeting.evening', 'Good evening');
|
||||
return (
|
||||
<div className="flex items-center gap-4 rounded-2xl border bg-gradient-to-br from-accent/70 via-card to-card px-5 py-4 shadow-soft">
|
||||
<img src={inbuxaMark} alt="" className="h-12 w-auto drop-shadow-sm" />
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-2xl font-semibold">
|
||||
{hello}
|
||||
{name && `, ${name}`}
|
||||
</h1>
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{username && (
|
||||
<>
|
||||
{t('greeting.signedInAs', 'Signed in as {{username}}', { username })}
|
||||
{' · '}
|
||||
</>
|
||||
)}
|
||||
{t('greeting.subtitle', "Here's how your mail server is doing.")}
|
||||
</p>
|
||||
<div className="relative overflow-hidden rounded-2xl border bg-card shadow-soft">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-60 [background-image:linear-gradient(var(--border)_1px,transparent_1px),linear-gradient(90deg,var(--border)_1px,transparent_1px)] [background-size:24px_24px] [mask-image:linear-gradient(90deg,transparent,black_45%,black)]"
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute -left-24 -top-24 h-64 w-64 rounded-full bg-primary/15 blur-3xl"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="relative flex flex-col gap-4 px-5 py-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<img src={inbuxaMark} alt="" className="h-12 w-auto drop-shadow-sm" />
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-2xl font-semibold">
|
||||
{hello}
|
||||
{name && `, ${name}`}
|
||||
</h1>
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{username && (
|
||||
<>
|
||||
{t('greeting.signedInAs', 'Signed in as {{username}}', { username })}
|
||||
{' · '}
|
||||
</>
|
||||
)}
|
||||
{t('greeting.subtitle', "Here's how your mail server is doing.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Metric } from '../types/metrics';
|
||||
import { formatValue } from '../helpers';
|
||||
import { LATENCIES, mean, zoneOf, type Zone } from '../commandCenter';
|
||||
import { Panel } from './Panel';
|
||||
|
||||
const ZONE_BAR: Record<Zone, string> = {
|
||||
good: 'bg-primary shadow-[0_0_8px_-2px_var(--primary)]',
|
||||
fair: 'bg-highlight shadow-[0_0_8px_-2px_var(--highlight)]',
|
||||
slow: 'bg-destructive shadow-[0_0_8px_-2px_var(--destructive)]',
|
||||
};
|
||||
|
||||
/**
|
||||
* How long the server's work takes, averaged over the period: one meter per
|
||||
* stage, from taking a message in to writing it to disk. The track is marked
|
||||
* where fine turns fair and fair turns slow, so a creeping store or a DNS
|
||||
* resolver in trouble stands out without reading a single number.
|
||||
*/
|
||||
export function Latencies({ samples }: { samples: Metric[] }) {
|
||||
const { t } = useTranslation();
|
||||
const labels: Record<string, string> = {
|
||||
ingest: t('cc.lat.ingest', 'Take in'),
|
||||
index: t('cc.lat.index', 'Index'),
|
||||
dns: t('cc.lat.dns', 'DNS lookup'),
|
||||
delivery: t('cc.lat.delivery', 'Delivery attempt'),
|
||||
dataRead: t('cc.lat.dataRead', 'Data read'),
|
||||
dataWrite: t('cc.lat.dataWrite', 'Data write'),
|
||||
blobRead: t('cc.lat.blobRead', 'Blob read'),
|
||||
blobWrite: t('cc.lat.blobWrite', 'Blob write'),
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel title={t('cc.latency', 'Response times')} href="/Management/x:Log" hrefLabel={t('cc.logs', 'Logs')}>
|
||||
<ul className="flex flex-1 flex-col justify-between gap-2.5">
|
||||
{LATENCIES.map((l) => {
|
||||
const ms = mean(samples, [l.metric]);
|
||||
const full = l.bad * 2;
|
||||
const zone = ms === null ? null : zoneOf(ms, l);
|
||||
return (
|
||||
<li key={l.id} className="grid grid-cols-[6.5rem_minmax(0,1fr)_4.5rem] items-center gap-3 text-xs">
|
||||
<span className="truncate text-muted-foreground">{labels[l.id]}</span>
|
||||
<span className="relative h-2 rounded-full bg-muted" aria-hidden>
|
||||
<span
|
||||
className="absolute inset-y-[-3px] w-px bg-highlight/70"
|
||||
style={{ left: `${(l.good / full) * 100}%` }}
|
||||
/>
|
||||
<span
|
||||
className="absolute inset-y-[-3px] w-px bg-destructive/70"
|
||||
style={{ left: `${(l.bad / full) * 100}%` }}
|
||||
/>
|
||||
{ms !== null && zone && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-y-0 left-0 rounded-full transition-[width] duration-700',
|
||||
ZONE_BAR[zone],
|
||||
)}
|
||||
style={{ width: `${Math.max(3, Math.min(100, (ms / full) * 100))}%` }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-right font-mono font-semibold tabular-nums',
|
||||
ms === null && 'font-normal text-muted-foreground/60',
|
||||
zone === 'fair' && 'text-highlight',
|
||||
zone === 'slow' && 'text-destructive',
|
||||
)}
|
||||
>
|
||||
{ms === null ? '—' : formatValue(ms, 'duration')}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PROTOCOLS } from '../commandCenter';
|
||||
import { Panel, LiveDot } from './Panel';
|
||||
|
||||
const SEGMENTS = 16;
|
||||
|
||||
/**
|
||||
* Who's connected right now, by protocol: a column of lit segments each,
|
||||
* like a level meter, scaled to the busiest. Fed by the live stream; when
|
||||
* that's down the meters go dark rather than show stale numbers.
|
||||
*/
|
||||
export function LiveConnections({ values, live }: { values: Map<string, number>; live: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
// A fixed floor, then headroom over the busiest, so a handful of sessions
|
||||
// never reads as a full meter. The color is the protocol's, not a warning:
|
||||
// being the busiest isn't a fault.
|
||||
const busiest = Math.max(0, ...PROTOCOLS.map((p) => values.get(p.metric) ?? 0));
|
||||
const max = Math.max(25, Math.ceil((busiest * 1.25) / 5) * 5);
|
||||
const sum = PROTOCOLS.reduce((s, p) => s + (values.get(p.metric) ?? 0), 0);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={t('cc.connections', 'Connections now')}
|
||||
aside={<LiveDot on={live} label={live ? t('cc.live', 'Live') : t('cc.offline', 'No feed')} />}
|
||||
>
|
||||
<div className="flex flex-1 items-stretch justify-between gap-2">
|
||||
{PROTOCOLS.map((p) => {
|
||||
const v = live ? (values.get(p.metric) ?? 0) : null;
|
||||
const lit = v === null ? 0 : Math.ceil((v / max) * SEGMENTS);
|
||||
return (
|
||||
<div key={p.id} className="flex min-w-0 flex-1 flex-col items-center gap-1.5">
|
||||
<span className="font-mono text-sm font-semibold tabular-nums">{v ?? '—'}</span>
|
||||
<div className="flex min-h-[124px] w-full max-w-7 flex-1 flex-col-reverse gap-[3px]" aria-hidden>
|
||||
{Array.from({ length: SEGMENTS }, (_, i) => {
|
||||
const on = i < lit;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
'min-h-[4px] flex-1 rounded-[1.5px] transition-colors duration-500',
|
||||
!on && 'bg-muted',
|
||||
on && 'bg-primary shadow-[0_0_6px_-1px_var(--primary)]',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="font-mono text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{p.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 flex shrink-0 items-center justify-between border-t pt-3 text-xs text-muted-foreground">
|
||||
<span>{t('cc.connectionsTotal', 'Open sessions')}</span>
|
||||
<span className="font-mono font-semibold tabular-nums text-foreground">{live ? sum : '—'}</span>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatValue } from '../helpers';
|
||||
import { Panel } from './Panel';
|
||||
|
||||
interface MailFlowProps {
|
||||
received: number[];
|
||||
sent: number[];
|
||||
/** When each slice starts, for its hover text. */
|
||||
times: Date[];
|
||||
periodLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mail in and out over the period, mirrored across one line: arrivals rise
|
||||
* above it, departures hang below. Lopsided traffic, a quiet stretch or a
|
||||
* burst of outbound mail shows in its shape.
|
||||
*/
|
||||
export function MailFlow({ received, sent, times, periodLabel }: MailFlowProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
// One scale for both directions, with the axis where the data puts it:
|
||||
// arrivals get the room their busiest slice needs, departures theirs.
|
||||
const maxIn = Math.max(0, ...received);
|
||||
const maxOut = Math.max(0, ...sent);
|
||||
const span = maxIn + maxOut || 1;
|
||||
const axis = maxIn + maxOut ? (maxIn / span) * 100 : 50;
|
||||
const inTotal = received.reduce((s, v) => s + v, 0);
|
||||
const outTotal = sent.reduce((s, v) => s + v, 0);
|
||||
const fmt = new Intl.DateTimeFormat(i18n.language, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={t('cc.flow', 'Mail flow')}
|
||||
aside={
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">{periodLabel}</span>
|
||||
}
|
||||
href="/Management/x:Trace/InboundDelivery"
|
||||
hrefLabel={t('cc.traces', 'Traces')}
|
||||
>
|
||||
<div className="mb-3 flex gap-6">
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
▲ {t('cc.in', 'In')}
|
||||
</div>
|
||||
<div className="font-mono text-xl font-semibold tabular-nums text-primary">
|
||||
{formatValue(inTotal, 'number')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
▼ {t('cc.out', 'Out')}
|
||||
</div>
|
||||
<div className="font-mono text-xl font-semibold tabular-nums text-[var(--chart-3)]">
|
||||
{formatValue(outTotal, 'number')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="relative min-h-36 flex-1"
|
||||
role="img"
|
||||
aria-label={t('cc.flowAria', '{{in}} messages in, {{out}} out', { in: inTotal, out: outTotal })}
|
||||
>
|
||||
<div className="absolute inset-x-0 h-px bg-border" style={{ top: `${axis}%` }} />
|
||||
<div className="absolute inset-0 flex gap-[2px]">
|
||||
{received.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="group relative flex flex-1 flex-col"
|
||||
title={t('cc.flowSlice', '{{time}} · {{in}} in, {{out}} out', {
|
||||
time: times[i] ? fmt.format(times[i]) : '',
|
||||
in: r,
|
||||
out: sent[i] ?? 0,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-end" style={{ height: `${axis}%` }}>
|
||||
<span
|
||||
className="w-full rounded-t-[2px] bg-primary/80 transition-[height] duration-500 group-hover:bg-primary"
|
||||
style={{ height: `${maxIn ? (r / maxIn) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start" style={{ height: `${100 - axis}%` }}>
|
||||
<span
|
||||
className="w-full rounded-b-[2px] bg-[var(--chart-3)] opacity-75 transition-[height] duration-500 group-hover:opacity-100"
|
||||
style={{ height: `${maxOut ? ((sent[i] ?? 0) / maxOut) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PanelProps {
|
||||
title: ReactNode;
|
||||
/** Something small on the right of the heading: a status, a count. */
|
||||
aside?: ReactNode;
|
||||
/** Where "more" leads, when there's a page behind the panel. */
|
||||
href?: string | null;
|
||||
hrefLabel?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const corner = 'pointer-events-none absolute h-2.5 w-2.5 border-primary/50';
|
||||
|
||||
/**
|
||||
* One instrument on the command center: a framed panel with bracketed
|
||||
* corners and a small-caps heading, like a console readout.
|
||||
*/
|
||||
export function Panel({ title, aside, href, hrefLabel, className, children }: PanelProps) {
|
||||
return (
|
||||
<section className={cn('relative flex flex-col rounded-xl border bg-card p-4 shadow-soft sm:p-5', className)}>
|
||||
<Corners />
|
||||
<header className="mb-4 flex items-center gap-3">
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">{title}</h2>
|
||||
<span className="h-px flex-1 bg-gradient-to-r from-border to-transparent" />
|
||||
{aside}
|
||||
{href && (
|
||||
<Link to={href} className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline">
|
||||
{hrefLabel}
|
||||
<ArrowUpRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
)}
|
||||
</header>
|
||||
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** The bracketed corners that frame every instrument. */
|
||||
export function Corners() {
|
||||
return (
|
||||
<>
|
||||
<span className={cn(corner, '-left-px -top-px rounded-tl-xl border-l-2 border-t-2')} />
|
||||
<span className={cn(corner, '-right-px -top-px rounded-tr-xl border-r-2 border-t-2')} />
|
||||
<span className={cn(corner, '-bottom-px -left-px rounded-bl-xl border-b-2 border-l-2')} />
|
||||
<span className={cn(corner, '-bottom-px -right-px rounded-br-xl border-b-2 border-r-2')} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The pulsing dot that says a panel is fed by the live stream. */
|
||||
export function LiveDot({ on, label }: { on: boolean; label: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 font-mono text-[10px] font-semibold uppercase tracking-[0.18em]',
|
||||
on ? 'text-primary' : 'text-muted-foreground/70',
|
||||
)}
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
{on && <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60" />}
|
||||
<span
|
||||
className={cn('relative inline-flex h-2 w-2 rounded-full', on ? 'bg-primary' : 'bg-muted-foreground/40')}
|
||||
/>
|
||||
</span>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Calendar, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useDashboardStore } from '../stores/dashboardStore';
|
||||
import type { PresetKey } from '../types/metrics';
|
||||
import { presetLabel, PRESET_KEYS } from '../types/metrics';
|
||||
|
||||
interface PeriodSelectorProps {
|
||||
onRefresh: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function PeriodSelector({ onRefresh, loading }: PeriodSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const period = useDashboardStore((s) => s.period);
|
||||
const setPreset = useDashboardStore((s) => s.setPreset);
|
||||
const setPeriod = useDashboardStore((s) => s.setPeriod);
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [customFrom, setCustomFrom] = useState('');
|
||||
const [customTo, setCustomTo] = useState('');
|
||||
|
||||
const currentValue = period.kind === 'preset' ? period.preset : 'custom';
|
||||
|
||||
const handleSelectChange = (value: string) => {
|
||||
if (value === 'custom') {
|
||||
setCustomOpen(true);
|
||||
} else {
|
||||
setPreset(value as PresetKey);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyCustom = () => {
|
||||
if (customFrom && customTo) {
|
||||
setPeriod({
|
||||
kind: 'custom',
|
||||
from: new Date(customFrom),
|
||||
to: new Date(customTo),
|
||||
});
|
||||
setCustomOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover open={customOpen} onOpenChange={setCustomOpen}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={currentValue} onValueChange={handleSelectChange}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRESET_KEYS.map((key) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{presetLabel(t, key)}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{t('dashboard.customEllipsis', 'Custom...')}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<PopoverTrigger asChild>
|
||||
<span />
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
|
||||
<PopoverContent className="w-72 p-4" align="end">
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">{t('dashboard.customRange', 'Custom range')}</h4>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom-from" className="text-xs">
|
||||
{t('dashboard.from', 'From')}
|
||||
</Label>
|
||||
<Input
|
||||
id="custom-from"
|
||||
type="datetime-local"
|
||||
value={customFrom}
|
||||
onChange={(e) => setCustomFrom(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom-to" className="text-xs">
|
||||
{t('dashboard.to', 'To')}
|
||||
</Label>
|
||||
<Input
|
||||
id="custom-to"
|
||||
type="datetime-local"
|
||||
value={customTo}
|
||||
onChange={(e) => setCustomTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" className="w-full" onClick={handleApplyCustom}>
|
||||
{t('common.apply', 'Apply')}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button variant="outline" size="icon" onClick={onRefresh} disabled={loading} className="h-9 w-9">
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowUpRight, PartyPopper } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Corners } from './Panel';
|
||||
import type { WaitingDomain } from '../serverFacts';
|
||||
|
||||
const MAX_ROWS = 8;
|
||||
@@ -22,17 +23,21 @@ export function QueueWaiting({ waiting }: { waiting: WaitingDomain[] }) {
|
||||
const { t } = useTranslation();
|
||||
const rows = waiting.slice(0, MAX_ROWS);
|
||||
const max = Math.max(1, ...rows.map((r) => r.scheduled + r.retrying + r.failed));
|
||||
const sum = (k: 'scheduled' | 'retrying' | 'failed') => waiting.reduce((s, w) => s + w[k], 0);
|
||||
const legend = [
|
||||
{ cls: 'bg-[var(--chart-1)]', label: t('queue.scheduled', 'Waiting its turn') },
|
||||
{ cls: 'bg-amber-500', label: t('queue.retrying', 'Retrying') },
|
||||
{ cls: 'bg-rose-500', label: t('queue.failed', 'Gave up') },
|
||||
{ cls: 'bg-[var(--chart-1)]', label: t('queue.scheduled', 'Waiting its turn'), total: sum('scheduled') },
|
||||
{ cls: 'bg-amber-500', label: t('queue.retrying', 'Retrying'), total: sum('retrying') },
|
||||
{ cls: 'bg-rose-500', label: t('queue.failed', 'Gave up'), total: sum('failed') },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="relative flex h-full flex-col">
|
||||
<Corners />
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0 pb-3">
|
||||
<div>
|
||||
<CardTitle className="text-base">{t('queue.title', 'Where mail is waiting')}</CardTitle>
|
||||
<CardTitle className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{t('queue.title', 'Where mail is waiting')}
|
||||
</CardTitle>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{t('queue.subtitle', 'Outgoing recipients still in the queue, by destination')}
|
||||
</p>
|
||||
@@ -45,49 +50,57 @@ export function QueueWaiting({ waiting }: { waiting: WaitingDomain[] }) {
|
||||
<ArrowUpRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="flex flex-1 flex-col">
|
||||
{rows.length === 0 ? (
|
||||
<div className="flex h-[228px] flex-col items-center justify-center gap-2 rounded-xl border border-dashed text-sm text-muted-foreground">
|
||||
<div className="flex min-h-[160px] flex-1 flex-col items-center justify-center gap-2 rounded-xl border border-dashed text-sm text-muted-foreground">
|
||||
<PartyPopper className="h-6 w-6 text-emerald-500" />
|
||||
{t('queue.empty', 'Nothing waiting. Everything has gone out.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{rows.map((r) => {
|
||||
const total = r.scheduled + r.retrying + r.failed;
|
||||
const seg = (n: number) => `${(n / max) * 100}%`;
|
||||
return (
|
||||
<Link
|
||||
key={r.domain}
|
||||
to={`/Management/x:QueuedMessage?f.to=${encodeURIComponent(r.domain)}`}
|
||||
className="group grid grid-cols-[minmax(0,9rem)_minmax(0,1fr)_2.5rem] items-center gap-3 text-sm"
|
||||
title={t('queue.rowTitle', '{{domain}}: {{s}} waiting, {{r}} retrying, {{f}} gave up', {
|
||||
domain: r.domain,
|
||||
s: r.scheduled,
|
||||
r: r.retrying,
|
||||
f: r.failed,
|
||||
})}
|
||||
>
|
||||
<span className="truncate font-mono text-xs group-hover:text-primary">{r.domain}</span>
|
||||
<span className="flex h-3 overflow-hidden rounded-full bg-muted">
|
||||
<span className="h-full bg-[var(--chart-1)] transition-all" style={{ width: seg(r.scheduled) }} />
|
||||
<span className="h-full bg-amber-500 transition-all" style={{ width: seg(r.retrying) }} />
|
||||
<span className="h-full bg-rose-500 transition-all" style={{ width: seg(r.failed) }} />
|
||||
</span>
|
||||
<span className="text-right tabular-nums text-muted-foreground">{total}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<div className="flex flex-wrap gap-4 pt-2 text-xs text-muted-foreground">
|
||||
{legend.map((l) => (
|
||||
<span key={l.label} className="inline-flex items-center gap-1.5">
|
||||
<span className={`h-2.5 w-2.5 rounded-full ${l.cls}`} />
|
||||
{l.label}
|
||||
</span>
|
||||
))}
|
||||
{waiting.length > MAX_ROWS &&
|
||||
t('queue.more', '+{{count}} more destinations', { count: waiting.length - MAX_ROWS })}
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex flex-1 flex-col justify-evenly gap-2.5">
|
||||
{rows.map((r) => {
|
||||
const total = r.scheduled + r.retrying + r.failed;
|
||||
const seg = (n: number) => `${(n / max) * 100}%`;
|
||||
return (
|
||||
<Link
|
||||
key={r.domain}
|
||||
to={`/Management/x:QueuedMessage?f.to=${encodeURIComponent(r.domain)}`}
|
||||
className="group grid grid-cols-[minmax(0,9rem)_minmax(0,1fr)_2.5rem] items-center gap-3 text-sm"
|
||||
title={t('queue.rowTitle', '{{domain}}: {{s}} waiting, {{r}} retrying, {{f}} gave up', {
|
||||
domain: r.domain,
|
||||
s: r.scheduled,
|
||||
r: r.retrying,
|
||||
f: r.failed,
|
||||
})}
|
||||
>
|
||||
<span className="truncate font-mono text-xs group-hover:text-primary">{r.domain}</span>
|
||||
<span className="flex h-3 overflow-hidden rounded-full bg-muted">
|
||||
<span className="h-full bg-[var(--chart-1)] transition-all" style={{ width: seg(r.scheduled) }} />
|
||||
<span className="h-full bg-amber-500 transition-all" style={{ width: seg(r.retrying) }} />
|
||||
<span className="h-full bg-rose-500 transition-all" style={{ width: seg(r.failed) }} />
|
||||
</span>
|
||||
<span className="text-right tabular-nums text-muted-foreground">{total}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-3 gap-3 border-t pt-3">
|
||||
{legend.map((l) => (
|
||||
<div key={l.label}>
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
<span className={`h-2 w-2 rounded-full ${l.cls}`} />
|
||||
<span className="truncate">{l.label}</span>
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-lg font-semibold tabular-nums">{l.total}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{waiting.length > MAX_ROWS && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t('queue.more', '+{{count}} more destinations', { count: waiting.length - MAX_ROWS })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TONE_VAR, type Tone } from '../tones';
|
||||
|
||||
interface ReadoutProps {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
/** A few words of context: "last 24 hours", "3 near quota". */
|
||||
detail?: ReactNode;
|
||||
/** The period in slices, drawn as a strip of bars under the figure. */
|
||||
bars?: number[];
|
||||
tone?: Tone;
|
||||
href?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A quick stat: small-caps label, the figure in a fixed-width face, and a
|
||||
* strip of bars showing how it moved over the period.
|
||||
*/
|
||||
export function Readout({ label, value, detail, bars, tone = 'primary', href }: ReadoutProps) {
|
||||
const max = bars ? Math.max(1, ...bars) : 1;
|
||||
const body = (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full flex-col rounded-lg border bg-background/40 px-3.5 py-3 transition-colors',
|
||||
href && 'hover:border-primary/50 hover:bg-accent/30',
|
||||
)}
|
||||
>
|
||||
<div className="truncate text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1.5 font-mono text-2xl font-semibold leading-none tabular-nums tracking-tight">{value}</div>
|
||||
{detail && <div className="mt-1 truncate text-[11px] text-muted-foreground">{detail}</div>}
|
||||
{bars && bars.length > 0 && (
|
||||
<div className="mt-auto flex h-5 items-end gap-px pt-2" aria-hidden>
|
||||
{bars.map((b, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="flex-1 rounded-[1px]"
|
||||
style={{
|
||||
height: `${Math.max(8, (b / max) * 100)}%`,
|
||||
background: TONE_VAR[tone],
|
||||
opacity: b === 0 ? 0.15 : 0.35 + 0.65 * (b / max),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return href ? (
|
||||
<Link
|
||||
to={href}
|
||||
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-lg"
|
||||
>
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
body
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { IconTile } from '@/components/common/IconTile';
|
||||
import { useMemo } from 'react';
|
||||
import { ArrowUpRight, Info } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { hrefFor, useDashLink } from '../links';
|
||||
import { LineChart, Line } from 'recharts';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import type { Card as CardSchema } from '../types/schema';
|
||||
import type { Metric } from '../types/metrics';
|
||||
import { cardValue, formatValue, sparklineData, computeDelta } from '../helpers';
|
||||
import { useLiveMetricsStore } from '../stores/liveMetricsStore';
|
||||
import { getChartColor } from '@/components/ui/chart';
|
||||
|
||||
interface StatCardProps {
|
||||
card: CardSchema;
|
||||
historySamples: Metric[];
|
||||
historyWindow: { from: Date; to: Date };
|
||||
/** INBUXA: a value counted from the server's objects, used when live metrics aren't available. */
|
||||
fallback?: number;
|
||||
}
|
||||
|
||||
export function StatCard({ card, historySamples, historyWindow, fallback }: StatCardProps) {
|
||||
const liveSnapshot = useLiveMetricsStore((s) => s.snapshot);
|
||||
const liveStatus = useLiveMetricsStore((s) => s.status);
|
||||
const link = useDashLink(card.metrics);
|
||||
|
||||
const value = useMemo(() => {
|
||||
if (card.source === 'live' && fallback !== undefined && liveStatus !== 'open') return fallback;
|
||||
if (card.source === 'live') {
|
||||
const liveSamples = card.metrics.map((id) => liveSnapshot.get(id)).filter((m): m is Metric => m !== undefined);
|
||||
return cardValue(card, liveSamples);
|
||||
}
|
||||
return cardValue(card, historySamples);
|
||||
}, [card, liveSnapshot, historySamples, fallback, liveStatus]);
|
||||
|
||||
// INBUXA: a live number the server can't report yet reads as unknown, not as zero.
|
||||
const unknown = card.source === 'live' && liveStatus !== 'open' && fallback === undefined;
|
||||
const formattedValue = unknown ? '—' : formatValue(value, card.format);
|
||||
|
||||
const { from, to } = historyWindow;
|
||||
|
||||
const sparkline = useMemo(() => {
|
||||
if (card.source !== 'history' || !card.sparkline) return null;
|
||||
return sparklineData(card, historySamples, from, to).map((v, i) => ({
|
||||
v,
|
||||
i,
|
||||
}));
|
||||
}, [card, historySamples, from, to]);
|
||||
|
||||
const delta = useMemo(() => {
|
||||
if (card.source !== 'history' || !card.delta) return null;
|
||||
return computeDelta(card, historySamples, from, to);
|
||||
}, [card, historySamples, from, to]);
|
||||
|
||||
const body = (
|
||||
<Card
|
||||
className={cn(
|
||||
'h-full transition-all hover:shadow-md',
|
||||
link &&
|
||||
'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={card.icon} size="sm" />
|
||||
<span className="text-sm font-medium text-muted-foreground">{card.title}</span>
|
||||
{link && (
|
||||
<ArrowUpRight className="ml-auto h-4 w-4 shrink-0 text-muted-foreground/0 transition-colors group-hover:text-primary" />
|
||||
)}
|
||||
{card.description && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<p className="text-xs">{card.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 font-display text-3xl font-semibold tracking-tight">{formattedValue}</div>
|
||||
|
||||
{(delta || sparkline) && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{delta && (
|
||||
<Badge variant="secondary" className="text-xs font-normal text-muted-foreground">
|
||||
{delta.direction === 'up'
|
||||
? `\u2191 ${Math.abs(delta.pct)}%`
|
||||
: delta.direction === 'down'
|
||||
? `\u2193 ${Math.abs(delta.pct)}%`
|
||||
: '\u2013'}
|
||||
</Badge>
|
||||
)}
|
||||
{sparkline && (
|
||||
<LineChart width={64} height={32} data={sparkline}>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="v"
|
||||
stroke={getChartColor(0)}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return link ? (
|
||||
<Link
|
||||
to={hrefFor(link)}
|
||||
className="group block focus-visible:outline-none"
|
||||
aria-label={`${card.title}: ${link.label}`}
|
||||
>
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
body
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Fragment, type ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CircleAlert } from 'lucide-react';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import type { ServerFacts } from '../serverFacts';
|
||||
import { hrefFor, type DashLink } from '../links';
|
||||
import { useCriticalCount } from '@/features/security/useCriticalCount';
|
||||
|
||||
interface Phrase {
|
||||
text: string;
|
||||
link: DashLink;
|
||||
}
|
||||
|
||||
/**
|
||||
* What needs a look, in one sentence: failed tasks, messages retrying,
|
||||
* recipients given up on. Nothing shows while all is well. Every phrase is
|
||||
* a link to where you'd deal with it.
|
||||
*/
|
||||
export function StatusLine({ facts }: { facts: ServerFacts | null }) {
|
||||
const { t } = useTranslation();
|
||||
const { canViewObject } = usePermissions();
|
||||
// inbuxa: SS-27, critical security items not accepted
|
||||
const critical = useCriticalCount();
|
||||
if (!facts) return null;
|
||||
|
||||
const n = (key: string, count: number, one: string, other: string) =>
|
||||
t(key, { count, defaultValue_one: one, defaultValue_other: other });
|
||||
|
||||
const attention: Phrase[] = [];
|
||||
if (facts.failedTasks)
|
||||
attention.push({
|
||||
text: n('status.failedTasks', facts.failedTasks, '{{count}} failed task', '{{count}} failed tasks'),
|
||||
link: { viewName: 'x:Task/TaskFailed', section: 'Management', label: '' },
|
||||
});
|
||||
if (facts.retrying)
|
||||
attention.push({
|
||||
text: n('status.retrying', facts.retrying, '{{count}} message retrying', '{{count}} messages retrying'),
|
||||
link: { viewName: 'x:QueuedMessage', section: 'Management', label: '' },
|
||||
});
|
||||
const bounced = facts.waiting?.reduce((s, w) => s + w.failed, 0) ?? 0;
|
||||
if (bounced)
|
||||
attention.push({
|
||||
text: n('status.bounced', bounced, '{{count}} recipient failed', '{{count}} recipients failed'),
|
||||
link: { viewName: 'x:QueuedMessage', section: 'Management', label: '' },
|
||||
});
|
||||
|
||||
// Quiet when all is well: the line only appears when something needs a look.
|
||||
const visible = attention.filter((p) => canViewObject(p.link.viewName));
|
||||
// Already limited to those who may see the Security page
|
||||
if (critical)
|
||||
visible.push({
|
||||
text: n(
|
||||
'status.securityCritical',
|
||||
critical,
|
||||
'{{count}} critical security item',
|
||||
'{{count}} critical security items',
|
||||
),
|
||||
link: { viewName: 'CustomComponent/LegacyProtocols', section: 'Settings', label: '' },
|
||||
});
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
const lead = n('status.needsLook', visible.length, 'One thing needs a look:', '{{count}} things need a look:');
|
||||
|
||||
const list: ReactNode[] = visible.map((p, i) => (
|
||||
<Fragment key={p.text}>
|
||||
{i > 0 && (i === visible.length - 1 ? t('status.and', ' and ') : ', ')}
|
||||
<Link
|
||||
to={hrefFor(p.link)}
|
||||
className="font-medium text-foreground underline decoration-primary/40 decoration-2 underline-offset-4 transition-colors hover:decoration-primary"
|
||||
>
|
||||
{p.text}
|
||||
</Link>
|
||||
</Fragment>
|
||||
));
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-highlight/40 bg-highlight-soft px-5 py-3.5 text-[15px]">
|
||||
<CircleAlert className="h-5 w-5 shrink-0 text-highlight" />
|
||||
<p className="text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{lead}</span> {list}.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowUpRight, HardDrive } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Corners } from './Panel';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatValue } from '../helpers';
|
||||
import { squarify } from '../treemap';
|
||||
@@ -65,10 +66,13 @@ export function StorageTreemap({ storage }: { storage: StorageUse[] }) {
|
||||
const nearFull = storage.filter((s) => s.quota && s.used / s.quota >= 0.9).length;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="relative flex h-full flex-col">
|
||||
<Corners />
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0 pb-3">
|
||||
<div>
|
||||
<CardTitle className="text-base">{t('storage.title', 'Who uses the space')}</CardTitle>
|
||||
<CardTitle className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{t('storage.title', 'Who uses the space')}
|
||||
</CardTitle>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{total > 0
|
||||
? t('storage.subtitle', '{{total}} across {{count}} people', {
|
||||
@@ -95,7 +99,7 @@ export function StorageTreemap({ storage }: { storage: StorageUse[] }) {
|
||||
<ArrowUpRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="flex flex-1 flex-col">
|
||||
<div ref={box} className="relative w-full overflow-hidden rounded-xl" style={{ height: HEIGHT }}>
|
||||
{total === 0 && (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 rounded-xl border border-dashed text-sm text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceArea,
|
||||
ReferenceDot,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { Chart } from '../types/schema';
|
||||
import type { Metric, Period } from '../types/metrics';
|
||||
import {
|
||||
bucketTimestamps,
|
||||
bucketize,
|
||||
formatTimeTick,
|
||||
formatValue,
|
||||
getBucketCount,
|
||||
seriesBucketValue,
|
||||
} from '../helpers';
|
||||
import { LATENCIES, summarize } from '../commandCenter';
|
||||
import { hrefFor, linkForMetrics } from '../links';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import { Panel } from './Panel';
|
||||
|
||||
const COLORS = ['var(--primary)', 'var(--chart-3)', 'var(--highlight)', 'var(--destructive)', 'var(--chart-5)'];
|
||||
const HEIGHT = 230;
|
||||
const AXIS = { fontSize: 10, fontFamily: 'ui-monospace, monospace', fill: 'var(--muted-foreground)' };
|
||||
|
||||
function useWidth() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
return [ref, width] as const;
|
||||
}
|
||||
|
||||
interface TrendChartProps {
|
||||
chart: Chart;
|
||||
samples: Metric[];
|
||||
window: { from: Date; to: Date };
|
||||
period: Period;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One trend over the period, framed like the command center's instruments.
|
||||
* Each series is a chip in the heading with its period total (or average,
|
||||
* for timings); counts glow as filled areas or columns, timings run as lines
|
||||
* over bands marking fine, fair and slow. The busiest moment is marked.
|
||||
*/
|
||||
export function TrendChart({ chart, samples, window: { from, to }, period, className }: TrendChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const gid = useId().replace(/:/g, '');
|
||||
const { canViewObject } = usePermissions();
|
||||
const [ref, width] = useWidth();
|
||||
const format = chart.valueFormat ?? 'number';
|
||||
const timing = format === 'duration';
|
||||
const buckets = getBucketCount(period);
|
||||
|
||||
const data = useMemo(() => {
|
||||
const slices = bucketize(samples, from, to, buckets);
|
||||
return bucketTimestamps(from, to, buckets).map((ts, i) => {
|
||||
const point: Record<string, number | string | null> = { tick: formatTimeTick(ts, period) };
|
||||
for (const s of chart.series) point[s.label] = seriesBucketValue(s, slices[i]) ?? (timing ? null : 0);
|
||||
return point;
|
||||
});
|
||||
}, [samples, from, to, buckets, chart.series, period, timing]);
|
||||
|
||||
const summaries = chart.series.map((s) => summarize(samples, s.metrics, timing));
|
||||
|
||||
// The busiest slice: of the first series, or of the whole stack when stacked.
|
||||
const peak = useMemo(() => {
|
||||
if (timing || chart.kind === 'bar' || chart.series.length === 0) return null;
|
||||
const keys = chart.stacked ? chart.series.map((s) => s.label) : [chart.series[0].label];
|
||||
let best: { tick: string; v: number } | null = null;
|
||||
for (const p of data) {
|
||||
const v = keys.reduce((sum, k) => sum + Number(p[k] ?? 0), 0);
|
||||
if (v > 0 && (!best || v > best.v)) best = { tick: String(p.tick), v };
|
||||
}
|
||||
return best;
|
||||
}, [data, chart.series, chart.stacked, chart.kind, timing]);
|
||||
|
||||
// Timing charts over a stage we know get the fine / fair / slow bands.
|
||||
const zones = timing ? LATENCIES.find((l) => chart.series.some((s) => s.metrics.includes(l.metric))) : undefined;
|
||||
|
||||
const link = linkForMetrics(chart.series.flatMap((s) => s.metrics));
|
||||
const href = link && canViewObject(link.viewName) ? hrefFor(link) : null;
|
||||
const fmt = (v: number) => formatValue(v, format);
|
||||
|
||||
const common = { data, width, height: HEIGHT, margin: { top: 16, right: 24, left: 0, bottom: 0 } };
|
||||
const chrome = (
|
||||
<>
|
||||
<defs>
|
||||
{chart.series.map((s, i) => (
|
||||
<linearGradient key={s.label} id={`${gid}-${i}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={COLORS[i % COLORS.length]} stopOpacity={0.45} />
|
||||
<stop offset="100%" stopColor={COLORS[i % COLORS.length]} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} stroke="var(--border)" />
|
||||
<XAxis dataKey="tick" tick={AXIS} tickLine={false} axisLine={false} minTickGap={28} />
|
||||
<YAxis tick={AXIS} tickLine={false} axisLine={false} width={56} tickFormatter={fmt} allowDecimals={timing} />
|
||||
{zones && (
|
||||
<>
|
||||
<ReferenceArea
|
||||
y1={zones.good}
|
||||
y2={zones.bad}
|
||||
fill="var(--highlight)"
|
||||
fillOpacity={0.06}
|
||||
ifOverflow="hidden"
|
||||
/>
|
||||
<ReferenceArea
|
||||
y1={zones.bad}
|
||||
y2={zones.bad * 100}
|
||||
fill="var(--destructive)"
|
||||
fillOpacity={0.07}
|
||||
ifOverflow="hidden"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Tooltip
|
||||
cursor={{ stroke: 'var(--primary)', strokeDasharray: '3 3' }}
|
||||
content={({ active, payload, label }) =>
|
||||
active && payload?.length ? (
|
||||
<div className="rounded-lg border bg-popover px-3 py-2 font-mono text-xs shadow-md">
|
||||
<div className="mb-1 text-muted-foreground">{label}</div>
|
||||
{payload.map((p) => (
|
||||
<div key={String(p.dataKey)} className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full" style={{ background: p.color }} />
|
||||
<span className="text-muted-foreground">{String(p.dataKey)}</span>
|
||||
<span className="ml-auto pl-4 font-semibold tabular-nums">
|
||||
{p.value === null || p.value === undefined ? '—' : fmt(Number(p.value))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{peak && (
|
||||
<ReferenceDot
|
||||
x={peak.tick}
|
||||
y={peak.v}
|
||||
r={4}
|
||||
fill="var(--card)"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth={2}
|
||||
label={{
|
||||
value: t('cc.peak', 'PEAK'),
|
||||
position: 'top',
|
||||
fontSize: 9,
|
||||
fill: 'var(--muted-foreground)',
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const series = chart.series.map((s, i) => {
|
||||
const color = COLORS[i % COLORS.length];
|
||||
if (chart.kind === 'bar')
|
||||
return (
|
||||
<Bar
|
||||
key={s.label}
|
||||
dataKey={s.label}
|
||||
fill={color}
|
||||
fillOpacity={0.85}
|
||||
radius={chart.stacked ? 0 : [3, 3, 0, 0]}
|
||||
stackId={chart.stacked ? 'a' : undefined}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
);
|
||||
if (timing || chart.kind === 'line')
|
||||
return (
|
||||
<Line
|
||||
key={s.label}
|
||||
type="monotone"
|
||||
dataKey={s.label}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: 'var(--card)', stroke: color, strokeWidth: 2 }}
|
||||
connectNulls
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Area
|
||||
key={s.label}
|
||||
type="monotone"
|
||||
dataKey={s.label}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
fill={`url(#${gid}-${i})`}
|
||||
stackId={chart.stacked ? 'a' : undefined}
|
||||
activeDot={{ r: 4, fill: 'var(--card)', stroke: color, strokeWidth: 2 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const Kind = chart.kind === 'bar' ? BarChart : timing || chart.kind === 'line' ? LineChart : AreaChart;
|
||||
|
||||
return (
|
||||
<Panel
|
||||
className={className}
|
||||
title={chart.title}
|
||||
href={href}
|
||||
hrefLabel={link ? t(`dashLink.${link.viewName}`, link.label) : undefined}
|
||||
>
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{chart.series.map((s, i) => (
|
||||
<span
|
||||
key={s.label}
|
||||
className="inline-flex items-center gap-2 rounded-md border bg-background/40 px-2.5 py-1 font-mono text-xs"
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ background: COLORS[i % COLORS.length], boxShadow: `0 0 6px ${COLORS[i % COLORS.length]}` }}
|
||||
/>
|
||||
<span className="uppercase tracking-wider text-muted-foreground">{s.label}</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{summaries[i] === null ? '—' : fmt(summaries[i] as number)}
|
||||
</span>
|
||||
{timing && <span className="text-[10px] uppercase text-muted-foreground">{t('cc.avg', 'avg')}</span>}
|
||||
</span>
|
||||
))}
|
||||
{chart.description && <span className="basis-full text-xs text-muted-foreground">{chart.description}</span>}
|
||||
</div>
|
||||
<div ref={ref} style={{ height: HEIGHT }}>
|
||||
{width > 0 && (
|
||||
<Kind {...common}>
|
||||
{chrome}
|
||||
{series}
|
||||
</Kind>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Metric, Period } from '../types/metrics';
|
||||
import type { ServerFacts } from '../serverFacts';
|
||||
import { bucketTimestamps, formatValue, getBucketCount } from '../helpers';
|
||||
import {
|
||||
DECK_METRICS,
|
||||
AUTH_FAILED,
|
||||
BANS,
|
||||
HAM,
|
||||
LATENCIES,
|
||||
RECEIVED,
|
||||
SENT,
|
||||
SPAM,
|
||||
latest,
|
||||
load,
|
||||
mean,
|
||||
queueHealth,
|
||||
series,
|
||||
total,
|
||||
zoneOf,
|
||||
type Zone,
|
||||
} from '../commandCenter';
|
||||
import type { Tone } from '../tones';
|
||||
import { Gauge } from './Gauge';
|
||||
import { Panel } from './Panel';
|
||||
import { LiveConnections } from './LiveConnections';
|
||||
import { Latencies } from './Latencies';
|
||||
import { MailFlow } from './MailFlow';
|
||||
import { QueueWaiting } from './QueueWaiting';
|
||||
import { StorageTreemap } from './StorageTreemap';
|
||||
import { WeeklyHeatmap } from './WeeklyHeatmap';
|
||||
import { Breakdown } from './Breakdown';
|
||||
|
||||
const CONNECTIONS = DECK_METRICS.network.filter((m) => m !== 'delivery.attempt-start');
|
||||
const LEGACY = ['imap.connection-start', 'pop3.connection-start', 'manage-sieve.connection-start'];
|
||||
const THREATS = ['security.ip-blocked', ...BANS, ...AUTH_FAILED];
|
||||
|
||||
interface TrendDeckProps {
|
||||
id: string;
|
||||
samples: Metric[];
|
||||
window: { from: Date; to: Date };
|
||||
period: Period;
|
||||
periodName: string;
|
||||
live: boolean;
|
||||
liveValues: Map<string, number>;
|
||||
facts: ServerFacts | null;
|
||||
/** The dials, or the panels under the readouts. */
|
||||
part: 'gauges' | 'panels';
|
||||
}
|
||||
|
||||
const ZONE_TONE: Record<Zone, Tone> = { good: 'primary', fair: 'warn', slow: 'crit' };
|
||||
const pct = (share: number) => `${Math.round(share * 100)}`;
|
||||
|
||||
/**
|
||||
* The instruments at the head of a trend page, in the command center's
|
||||
* manner: a row of dials for the page's vital signs, then the panels that
|
||||
* show its current state. Which ones depends on the page; a page this
|
||||
* doesn't know gets none, and keeps its readouts and charts.
|
||||
*/
|
||||
export function TrendDeck({ id, samples, window, period, periodName, live, liveValues, facts, part }: TrendDeckProps) {
|
||||
const { t } = useTranslation();
|
||||
const { from, to } = window;
|
||||
const buckets = getBucketCount(period);
|
||||
const slice = (ms: number) => {
|
||||
const min = Math.round(ms / 60_000);
|
||||
return min < 60 ? `${min} min` : min < 1440 ? `${Math.round(min / 60)} h` : `${Math.round(min / 1440)} d`;
|
||||
};
|
||||
const per = t('cc.perSlice', '/{{slice}}', { slice: slice((to.getTime() - from.getTime()) / buckets) });
|
||||
|
||||
const zoneGauge = (metric: string, label: string) => {
|
||||
const l = LATENCIES.find((x) => x.metric === metric)!;
|
||||
const ms = mean(samples, [metric]);
|
||||
const zone = ms === null ? null : zoneOf(ms, l);
|
||||
const full = l.bad * 2;
|
||||
return (
|
||||
<Gauge
|
||||
key={metric}
|
||||
value={ms === null ? null : ms / full}
|
||||
tone={zone ? ZONE_TONE[zone] : 'primary'}
|
||||
reading={ms === null ? '—' : formatValue(ms, 'duration')}
|
||||
label={label}
|
||||
detail={t('cc.g.zoneDetail', 'fine under {{good}} · slow over {{bad}}', {
|
||||
good: formatValue(l.good, 'duration'),
|
||||
bad: formatValue(l.bad, 'duration'),
|
||||
})}
|
||||
bands={[
|
||||
{ from: 0, to: l.good / full, tone: 'primary' },
|
||||
{ from: l.good / full, to: 0.5, tone: 'warn' },
|
||||
{ from: 0.5, to: 1, tone: 'crit' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const loadGauge = (ids: string[], label: string, tone: Tone = 'primary') => {
|
||||
const b = load(series(samples, ids, from, to, buckets));
|
||||
return (
|
||||
<Gauge
|
||||
key={label}
|
||||
value={b.peak > 0 ? b.ratio : null}
|
||||
tone={tone}
|
||||
reading={formatValue(b.now, 'number')}
|
||||
unit={per}
|
||||
label={label}
|
||||
detail={
|
||||
b.peak > 0
|
||||
? t('cc.g.loadDetail', '{{pct}}% of peak ({{peak}})', {
|
||||
pct: pct(b.ratio),
|
||||
peak: formatValue(b.peak, 'number'),
|
||||
})
|
||||
: t('cc.g.quiet', 'Nothing recorded yet')
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const splitGauge = (
|
||||
key: string,
|
||||
a: number,
|
||||
b: number,
|
||||
label: string,
|
||||
aText: string,
|
||||
bText: string,
|
||||
bTone: Tone = 'crit',
|
||||
) => (
|
||||
<Gauge
|
||||
key={key}
|
||||
value={a + b ? a / (a + b) : null}
|
||||
reverse={a + b ? { value: b / (a + b), tone: bTone } : undefined}
|
||||
reading={formatValue(a + b, 'number')}
|
||||
label={label}
|
||||
detail={
|
||||
a + b ? (
|
||||
<span className="inline-flex flex-wrap justify-center gap-x-2">
|
||||
<span className="text-primary">{aText}</span>
|
||||
<span style={{ color: bTone === 'crit' ? 'var(--destructive)' : 'var(--chart-3)' }}>{bText}</span>
|
||||
</span>
|
||||
) : (
|
||||
t('cc.g.quiet', 'Nothing recorded yet')
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
// Memory now against its highest in the period: how close to the peak the node is running.
|
||||
const memoryGauge = () => {
|
||||
const readings = samples.filter((m) => m.metric === 'server.memory').map((m) => m.count);
|
||||
const now = liveValues.get('server.memory') ?? latest(samples, ['server.memory']);
|
||||
const peak = Math.max(0, now ?? 0, ...readings);
|
||||
return (
|
||||
<Gauge
|
||||
key="memory"
|
||||
value={now === null || now === undefined || !peak ? null : now / peak}
|
||||
tone="neutral"
|
||||
reading={now === null || now === undefined ? '—' : formatValue(now, 'bytes')}
|
||||
label={t('cc.g.memory', 'Memory')}
|
||||
detail={
|
||||
peak
|
||||
? t('cc.g.memoryDetail', 'this node · peak {{peak}}', { peak: formatValue(peak, 'bytes') })
|
||||
: t('cc.g.quiet', 'Nothing recorded yet')
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const received = series(samples, RECEIVED, from, to, buckets);
|
||||
const sent = series(samples, SENT, from, to, buckets);
|
||||
const times = bucketTimestamps(from, to, buckets);
|
||||
const n = (v: number) => formatValue(v, 'number');
|
||||
|
||||
let gauges: ReactNode[];
|
||||
let panels: ReactNode;
|
||||
|
||||
switch (id) {
|
||||
case 'network': {
|
||||
const smtpIn = total(samples, ['smtp.connection-start']);
|
||||
const smtpOut = total(samples, ['delivery.attempt-start']);
|
||||
const web = total(samples, ['http.connection-start']);
|
||||
const legacy = total(samples, LEGACY);
|
||||
gauges = [
|
||||
loadGauge(CONNECTIONS, t('cc.g.connLoad', 'Connection load')),
|
||||
splitGauge(
|
||||
'smtp',
|
||||
smtpIn,
|
||||
smtpOut,
|
||||
t('cc.g.smtpSplit', 'SMTP sessions'),
|
||||
t('cc.g.smtpIn', '{{n}} in', { n: n(smtpIn) }),
|
||||
t('cc.g.smtpOut', '{{n}} out', { n: n(smtpOut) }),
|
||||
'neutral',
|
||||
),
|
||||
splitGauge(
|
||||
'access',
|
||||
web,
|
||||
legacy,
|
||||
t('cc.g.access', 'Mailbox access'),
|
||||
t('cc.g.web', '{{n}} web & JMAP', { n: n(web) }),
|
||||
t('cc.g.legacy', '{{n}} IMAP/POP3/Sieve', { n: n(legacy) }),
|
||||
'neutral',
|
||||
),
|
||||
];
|
||||
panels = (
|
||||
<div className="grid gap-5 lg:grid-cols-2 [&>*]:min-w-0">
|
||||
<LiveConnections values={liveValues} live={live} />
|
||||
<WeeklyHeatmap
|
||||
samples={samples}
|
||||
metrics={CONNECTIONS}
|
||||
title={t('cc.h.connections', 'When people connect')}
|
||||
subtitle={t('cc.h.connectionsSub', 'New connections by hour and day, over the period above')}
|
||||
color="var(--chart-3)"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'security': {
|
||||
const spam = total(samples, SPAM);
|
||||
const ham = total(samples, HAM);
|
||||
const authBans = total(samples, ['security.authentication-ban']);
|
||||
const otherBans = total(samples, BANS) - authBans;
|
||||
gauges = [
|
||||
loadGauge(THREATS, t('cc.g.threats', 'Threat pressure'), 'crit'),
|
||||
splitGauge(
|
||||
'spam',
|
||||
ham,
|
||||
spam,
|
||||
t('cc.g.received', 'Mail received'),
|
||||
t('cc.g.clean', '{{n}} to inboxes', { n: n(ham) }),
|
||||
t('cc.g.filtered', '{{n}} spam filtered', { n: n(spam) }),
|
||||
),
|
||||
splitGauge(
|
||||
'bans',
|
||||
authBans,
|
||||
otherBans,
|
||||
t('cc.g.bans', 'Addresses banned'),
|
||||
t('cc.g.authBans', '{{n}} for sign-ins', { n: n(authBans) }),
|
||||
t('cc.g.otherBans', '{{n}} abuse & scans', { n: n(otherBans) }),
|
||||
'warn',
|
||||
),
|
||||
];
|
||||
panels = (
|
||||
<div className="grid gap-5 lg:grid-cols-2 [&>*]:min-w-0">
|
||||
<Breakdown
|
||||
title={t('cc.b.stopped', 'What got stopped')}
|
||||
totalLabel={t('cc.b.total', 'Stopped in all')}
|
||||
aside={periodName}
|
||||
rows={[
|
||||
{
|
||||
label: t('cc.b.blocked', 'Blocked connections'),
|
||||
value: total(samples, ['security.ip-blocked']),
|
||||
tone: 'crit',
|
||||
},
|
||||
{ label: t('cc.b.authFailed', 'Failed sign-ins'), value: total(samples, AUTH_FAILED), tone: 'warn' },
|
||||
{ label: t('cc.b.spam', 'Spam filtered'), value: spam, tone: 'neutral' },
|
||||
{ label: t('cc.b.authBans', 'Sign-in bans'), value: authBans, tone: 'crit' },
|
||||
{
|
||||
label: t('cc.b.abuseBans', 'Abuse & scan bans'),
|
||||
value: total(samples, ['security.abuse-ban', 'security.scan-ban']),
|
||||
tone: 'crit',
|
||||
},
|
||||
{
|
||||
label: t('cc.b.loiterBans', 'Loiter bans'),
|
||||
value: total(samples, ['security.loiter-ban']),
|
||||
tone: 'warn',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<WeeklyHeatmap
|
||||
samples={samples}
|
||||
metrics={AUTH_FAILED}
|
||||
title={t('cc.h.attacks', 'When sign-ins fail')}
|
||||
subtitle={t('cc.h.attacksSub', 'Failed sign-ins by hour and day: a night-time block is usually a bot')}
|
||||
color="var(--destructive)"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'delivery': {
|
||||
const inTotal = total(samples, RECEIVED);
|
||||
const outTotal = total(samples, SENT);
|
||||
const ok = queueHealth(facts?.waiting);
|
||||
gauges = [
|
||||
<Gauge
|
||||
key="queue"
|
||||
value={facts?.waiting ? ok : null}
|
||||
tone={ok >= 0.9 ? 'ok' : ok >= 0.6 ? 'warn' : 'crit'}
|
||||
reading={facts?.waiting ? pct(ok) : '—'}
|
||||
unit="%"
|
||||
label={t('cc.g.queue', 'Queue on schedule')}
|
||||
detail={
|
||||
facts?.queued !== undefined
|
||||
? t('cc.g.queueDetail', '{{n}} queued · {{r}} retrying', { n: facts.queued, r: facts.retrying ?? 0 })
|
||||
: undefined
|
||||
}
|
||||
bands={[
|
||||
{ from: 0, to: 0.6, tone: 'crit' },
|
||||
{ from: 0.6, to: 0.9, tone: 'warn' },
|
||||
{ from: 0.9, to: 1, tone: 'ok' },
|
||||
]}
|
||||
/>,
|
||||
loadGauge([...RECEIVED, ...SENT], t('cc.g.load', 'Load')),
|
||||
splitGauge(
|
||||
'flow',
|
||||
inTotal,
|
||||
outTotal,
|
||||
t('cc.g.flow', 'Messages handled'),
|
||||
t('cc.g.flowIn', '{{n}} in', { n: n(inTotal) }),
|
||||
t('cc.g.flowOut', '{{n}} out', { n: n(outTotal) }),
|
||||
'neutral',
|
||||
),
|
||||
zoneGauge('delivery.attempt-time', t('cc.g.attempt', 'Delivery attempt')),
|
||||
];
|
||||
panels = (
|
||||
<div className="grid gap-5 lg:grid-cols-2 [&>*]:min-w-0">
|
||||
<MailFlow received={received} sent={sent} times={times} periodLabel={periodName} />
|
||||
{facts?.waiting && <QueueWaiting waiting={facts.waiting} />}
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'performance':
|
||||
gauges = [
|
||||
zoneGauge('message-ingest.time', t('cc.g.ingest', 'Take-in time')),
|
||||
zoneGauge('message-ingest.index-time', t('cc.g.index', 'Index time')),
|
||||
zoneGauge('dns.lookup-time', t('cc.g.dns', 'DNS lookup')),
|
||||
zoneGauge('delivery.attempt-time', t('cc.g.attempt', 'Delivery attempt')),
|
||||
memoryGauge(),
|
||||
];
|
||||
panels = <Latencies samples={samples} />;
|
||||
break;
|
||||
case 'storage': {
|
||||
const quotaAccounts = facts?.storage?.filter((a) => a.quota) ?? [];
|
||||
const used = quotaAccounts.reduce((s, a) => s + a.used, 0);
|
||||
const quota = quotaAccounts.reduce((s, a) => s + (a.quota ?? 0), 0);
|
||||
gauges = [
|
||||
zoneGauge('store.data-read-time', t('cc.g.dataRead', 'Data read')),
|
||||
zoneGauge('store.data-write-time', t('cc.g.dataWrite', 'Data write')),
|
||||
zoneGauge('store.blob-read-time', t('cc.g.blobRead', 'Blob read')),
|
||||
zoneGauge('store.blob-write-time', t('cc.g.blobWrite', 'Blob write')),
|
||||
];
|
||||
if (quota > 0)
|
||||
gauges.push(
|
||||
<Gauge
|
||||
key="quota"
|
||||
value={used / quota}
|
||||
tone={used / quota >= 0.9 ? 'crit' : used / quota >= 0.75 ? 'warn' : 'primary'}
|
||||
reading={pct(used / quota)}
|
||||
unit="%"
|
||||
label={t('cc.g.storage', 'Quota used')}
|
||||
detail={t('cc.g.storageDetail', '{{used}} of {{quota}}', {
|
||||
used: formatValue(used, 'bytes'),
|
||||
quota: formatValue(quota, 'bytes'),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
panels = facts?.storage ? <StorageTreemap storage={facts.storage} /> : null;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
if (part === 'panels') return panels;
|
||||
return (
|
||||
<Panel
|
||||
title={t('cc.instruments', 'Instruments')}
|
||||
aside={<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">{periodName}</span>}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-6 sm:grid-cols-3 xl:grid-cols-[repeat(auto-fit,minmax(170px,1fr))]">
|
||||
{gauges}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import type { Dashboard } from '../types/schema';
|
||||
import { presetLabel } from '../types/metrics';
|
||||
import { useLiveMetricsStore } from '../stores/liveMetricsStore';
|
||||
import { collectHistoryMetricIds, collectLiveMetricIds, formatValue } from '../helpers';
|
||||
import { hrefFor, linkForMetrics } from '../links';
|
||||
import { DECK_METRICS, LATENCIES, LIVE_IDS, PROTOCOLS, series, summarize, zoneOf } from '../commandCenter';
|
||||
import type { Tone } from '../tones';
|
||||
import { LiveDot, Panel } from './Panel';
|
||||
import { Readout } from './Readout';
|
||||
import { TrendChart } from './TrendChart';
|
||||
import { TrendDeck } from './TrendDeck';
|
||||
import { useDashboardShared } from '../dashboardContext';
|
||||
import { usePageHistory } from '../usePageHistory';
|
||||
|
||||
const STRIP = 24;
|
||||
|
||||
/**
|
||||
* A trend page behind the command center: Network, Security, Delivery,
|
||||
* Performance or Storage. A row of readouts for the page's figures, then
|
||||
* each of its trends as an instrument panel. The pages themselves, and what
|
||||
* they chart, come from the server's schema.
|
||||
*/
|
||||
export function TrendsView({ dashboard }: { dashboard: Dashboard }) {
|
||||
const { t } = useTranslation();
|
||||
const { canViewObject } = usePermissions();
|
||||
const subscribeLive = useLiveMetricsStore((s) => s.subscribe);
|
||||
const unsubscribeLive = useLiveMetricsStore((s) => s.unsubscribe);
|
||||
const { facts, live, liveValues } = useDashboardShared();
|
||||
|
||||
const historyIds = useMemo(() => {
|
||||
const ids = collectHistoryMetricIds(dashboard.cards, dashboard.charts);
|
||||
for (const id of DECK_METRICS[dashboard.id] ?? []) ids.add(id);
|
||||
return ids;
|
||||
}, [dashboard]);
|
||||
// The shell's lamps and the network meters want the usual live figures too.
|
||||
const liveIds = useMemo(() => {
|
||||
const ids = collectLiveMetricIds(dashboard.cards);
|
||||
for (const id of LIVE_IDS) ids.add(id);
|
||||
return ids;
|
||||
}, [dashboard]);
|
||||
const { samples, window, period } = usePageHistory(dashboard.id, historyIds);
|
||||
|
||||
useEffect(() => {
|
||||
subscribeLive(liveIds);
|
||||
return () => unsubscribeLive();
|
||||
}, [liveIds, subscribeLive, unsubscribeLive]);
|
||||
|
||||
const deck = {
|
||||
id: dashboard.id,
|
||||
samples,
|
||||
window,
|
||||
period,
|
||||
live,
|
||||
liveValues,
|
||||
facts,
|
||||
};
|
||||
// Live counts the connection meters already show don't need a readout too,
|
||||
// nor do timings that already have a dial of their own.
|
||||
const metered = new Set(dashboard.id === 'network' ? PROTOCOLS.map((p) => p.metric) : []);
|
||||
const dialed = new Set(
|
||||
(DECK_METRICS[dashboard.id] ?? []).filter((m) => m === 'server.memory' || LATENCIES.some((l) => l.metric === m)),
|
||||
);
|
||||
const cards = (dashboard.cards ?? []).filter(
|
||||
(c) => !(c.source === 'live' && c.metrics.every((m) => metered.has(m))) && !c.metrics.every((m) => dialed.has(m)),
|
||||
);
|
||||
const periodName =
|
||||
period.kind === 'preset' ? presetLabel((k, d) => t(k, d), period.preset) : t('cc.customPeriod', 'Chosen period');
|
||||
const zoneWord = {
|
||||
good: t('cc.zone.good', 'fine'),
|
||||
fair: t('cc.zone.fair', 'fair'),
|
||||
slow: t('cc.zone.slow', 'slow'),
|
||||
};
|
||||
const zoneTone: Record<string, Tone> = { good: 'primary', fair: 'warn', slow: 'crit' };
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<TrendDeck {...deck} periodName={periodName} part="gauges" />
|
||||
|
||||
{cards.length > 0 && (
|
||||
<Panel
|
||||
title={dashboard.label}
|
||||
aside={
|
||||
cards.some((c) => c.source === 'live') ? (
|
||||
<LiveDot on={live} label={live ? t('cc.live', 'Live') : t('cc.offline', 'No feed')} />
|
||||
) : (
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">{periodName}</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap gap-3 [&>*]:min-w-0 [&>*]:flex-[1_1_150px]">
|
||||
{cards.map((card, i) => {
|
||||
const link = linkForMetrics(card.metrics);
|
||||
const href = link && canViewObject(link.viewName) ? hrefFor(link) : null;
|
||||
const timing = card.format === 'duration';
|
||||
if (card.source === 'live') {
|
||||
const v = card.metrics.reduce<number | null>((s, id) => {
|
||||
const m = liveValues.get(id);
|
||||
return m === undefined ? s : (s ?? 0) + m;
|
||||
}, null);
|
||||
return (
|
||||
<Readout
|
||||
key={`${card.title}-${i}`}
|
||||
label={card.title}
|
||||
value={live && v !== null ? formatValue(v, card.format) : '—'}
|
||||
detail={t('cc.now', 'now')}
|
||||
href={href}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const v = summarize(samples, card.metrics, timing);
|
||||
const known = timing ? LATENCIES.find((l) => card.metrics.includes(l.metric)) : undefined;
|
||||
const zone = v !== null && known ? zoneOf(v, known) : null;
|
||||
return (
|
||||
<Readout
|
||||
key={`${card.title}-${i}`}
|
||||
label={card.title}
|
||||
value={v === null ? '—' : formatValue(v, card.format)}
|
||||
detail={
|
||||
zone ? (
|
||||
<span className={cn(zone === 'fair' && 'text-highlight', zone === 'slow' && 'text-destructive')}>
|
||||
{t('cc.avgZone', 'average · {{zone}}', { zone: zoneWord[zone] })}
|
||||
</span>
|
||||
) : timing ? (
|
||||
t('cc.average', 'average')
|
||||
) : (
|
||||
periodName
|
||||
)
|
||||
}
|
||||
bars={timing ? undefined : series(samples, card.metrics, window.from, window.to, STRIP)}
|
||||
tone={zone ? zoneTone[zone] : 'primary'}
|
||||
href={href}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
<TrendDeck {...deck} periodName={periodName} part="panels" />
|
||||
|
||||
{dashboard.charts && dashboard.charts.length > 0 && (
|
||||
<div className="grid gap-5 lg:grid-cols-2 [&>*]:min-w-0">
|
||||
{dashboard.charts.map((chart, i, all) => (
|
||||
<TrendChart
|
||||
key={`${chart.title}-${i}`}
|
||||
// A last chart on its own takes the whole row rather than leave half of it empty.
|
||||
className={all.length % 2 && i === all.length - 1 ? 'lg:col-span-2' : undefined}
|
||||
chart={chart}
|
||||
samples={samples}
|
||||
window={window}
|
||||
period={period}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Corners } from './Panel';
|
||||
import type { Metric } from '../types/metrics';
|
||||
import { weeklyGrid } from '../rhythm';
|
||||
|
||||
@@ -16,9 +17,22 @@ import { weeklyGrid } from '../rhythm';
|
||||
* spike that shouldn't be there all show at a glance. Hidden until
|
||||
* monitoring has recorded something.
|
||||
*/
|
||||
export function WeeklyHeatmap({ samples }: { samples: Metric[] }) {
|
||||
export function WeeklyHeatmap({
|
||||
samples,
|
||||
metrics,
|
||||
title,
|
||||
subtitle,
|
||||
color = 'var(--chart-1)',
|
||||
}: {
|
||||
samples: Metric[];
|
||||
/** What to count; mail handled when left out. */
|
||||
metrics?: string[];
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
color?: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const grid = useMemo(() => weeklyGrid(samples), [samples]);
|
||||
const grid = useMemo(() => weeklyGrid(samples, metrics), [samples, metrics]);
|
||||
const max = Math.max(...grid.flat());
|
||||
const days = useMemo(() => {
|
||||
const fmt = new Intl.DateTimeFormat(i18n.language, { weekday: 'short' });
|
||||
@@ -27,17 +41,23 @@ export function WeeklyHeatmap({ samples }: { samples: Metric[] }) {
|
||||
}, [i18n.language]);
|
||||
|
||||
if (max <= 0) return null;
|
||||
let peak = { d: 0, h: 0, v: 0 };
|
||||
grid.forEach((row, d) => row.forEach((v, h) => v > peak.v && (peak = { d, h, v })));
|
||||
const sum = grid.flat().reduce((a, b) => a + b, 0);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="relative flex h-full flex-col">
|
||||
<Corners />
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{t('rhythm.title', 'Your mail’s weekly rhythm')}</CardTitle>
|
||||
<CardTitle className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{title ?? t('rhythm.title', 'Your mail’s weekly rhythm')}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('rhythm.subtitle', 'Messages handled by hour and day, over the period above')}
|
||||
{subtitle ?? t('rhythm.subtitle', 'Messages handled by hour and day, over the period above')}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<CardContent className="flex flex-1 flex-col">
|
||||
<div className="mb-3 overflow-x-auto">
|
||||
<div className="grid min-w-[560px] grid-cols-[2.5rem_repeat(24,minmax(0,1fr))] gap-1">
|
||||
{grid.map((row, d) => (
|
||||
<div key={d} className="contents">
|
||||
@@ -45,13 +65,21 @@ export function WeeklyHeatmap({ samples }: { samples: Metric[] }) {
|
||||
{row.map((v, h) => (
|
||||
<span
|
||||
key={h}
|
||||
title={t('rhythm.cell', '{{day}} {{hour}}:00, {{count}} messages', {
|
||||
day: days[d],
|
||||
hour: String(h).padStart(2, '0'),
|
||||
count: v,
|
||||
})}
|
||||
className="aspect-square rounded-[3px] bg-[var(--chart-1)] transition-transform hover:scale-125"
|
||||
style={{ opacity: v === 0 ? 0.08 : 0.2 + 0.8 * (v / max) }}
|
||||
title={
|
||||
metrics
|
||||
? t('rhythm.cellCount', '{{day}} {{hour}}:00, {{count}}', {
|
||||
day: days[d],
|
||||
hour: String(h).padStart(2, '0'),
|
||||
count: v,
|
||||
})
|
||||
: t('rhythm.cell', '{{day}} {{hour}}:00, {{count}} messages', {
|
||||
day: days[d],
|
||||
hour: String(h).padStart(2, '0'),
|
||||
count: v,
|
||||
})
|
||||
}
|
||||
className="aspect-square rounded-[3px] transition-transform hover:scale-125"
|
||||
style={{ background: color, opacity: v === 0 ? 0.08 : 0.2 + 0.8 * (v / max) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -64,6 +92,25 @@ export function WeeklyHeatmap({ samples }: { samples: Metric[] }) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-t pt-3 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{t('rhythm.less', 'Less')}
|
||||
{[0.08, 0.3, 0.55, 0.8, 1].map((o) => (
|
||||
<span key={o} className="h-2.5 w-2.5 rounded-[2px]" style={{ background: color, opacity: o }} />
|
||||
))}
|
||||
{t('rhythm.more', 'More')}
|
||||
</span>
|
||||
<span>
|
||||
{t('rhythm.busiest', 'Busiest: {{day}} {{hour}}:00', {
|
||||
day: days[peak.d],
|
||||
hour: String(peak.h).padStart(2, '0'),
|
||||
})}{' '}
|
||||
<span className="font-mono font-semibold text-foreground tabular-nums">{peak.v}</span>
|
||||
{' · '}
|
||||
{t('rhythm.total', 'Total')}{' '}
|
||||
<span className="font-mono font-semibold text-foreground tabular-nums">{sum}</span>
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { Metric } from './types/metrics';
|
||||
import type { ServerFacts } from './serverFacts';
|
||||
import type { ClusterState } from './clusterHealth';
|
||||
|
||||
/**
|
||||
* What the dashboard shell has already fetched, for the page inside it:
|
||||
* fetched once, whichever page is open, and kept as you move between them.
|
||||
*/
|
||||
export interface DashboardShared {
|
||||
facts: ServerFacts | null;
|
||||
/** undefined while loading; null when this server isn't part of a cluster. */
|
||||
cluster: ClusterState | null | undefined;
|
||||
/** The command center's metrics over the period (mail, security, timings). */
|
||||
samples: Metric[];
|
||||
live: boolean;
|
||||
liveValues: Map<string, number>;
|
||||
/** Moves on every refresh, by hand or on the timer. */
|
||||
tick: number;
|
||||
}
|
||||
|
||||
export const DashboardContext = createContext<DashboardShared | null>(null);
|
||||
|
||||
export function useDashboardShared(): DashboardShared {
|
||||
const shared = useContext(DashboardContext);
|
||||
if (!shared) throw new Error('useDashboardShared outside the dashboard shell');
|
||||
return shared;
|
||||
}
|
||||
@@ -164,7 +164,7 @@ async function fetchFacts(): Promise<ServerFacts> {
|
||||
|
||||
const REFRESH_MS = 60_000;
|
||||
|
||||
export function useServerFacts() {
|
||||
export function useServerFacts(tick = 0) {
|
||||
const [facts, setFacts] = useState<ServerFacts | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -185,7 +185,7 @@ export function useServerFacts() {
|
||||
clearTimeout(first);
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [refresh]);
|
||||
}, [refresh, tick]);
|
||||
|
||||
return { facts, error, refresh };
|
||||
}
|
||||
@@ -1,17 +1,29 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { Period, PresetKey } from '../types/metrics';
|
||||
|
||||
export const AUTO_REFRESH = [0, 1, 5, 10, 15] as const;
|
||||
export type AutoRefresh = (typeof AUTO_REFRESH)[number];
|
||||
|
||||
interface DashboardState {
|
||||
period: Period;
|
||||
setPeriod: (period: Period) => void;
|
||||
setPreset: (preset: PresetKey) => void;
|
||||
/** INBUXA: minutes between automatic refreshes of the dashboards; 0 is off. */
|
||||
autoRefresh: AutoRefresh;
|
||||
setAutoRefresh: (minutes: AutoRefresh) => void;
|
||||
/** INBUXA: counts refreshes, by hand or on the timer; every panel re-fetches when it moves. */
|
||||
tick: number;
|
||||
bump: () => void;
|
||||
}
|
||||
|
||||
export const useDashboardStore = create<DashboardState>()(
|
||||
@@ -21,10 +33,14 @@ export const useDashboardStore = create<DashboardState>()(
|
||||
|
||||
setPeriod: (period) => set({ period }),
|
||||
setPreset: (preset) => set({ period: { kind: 'preset', preset } }),
|
||||
autoRefresh: 5,
|
||||
setAutoRefresh: (autoRefresh) => set({ autoRefresh }),
|
||||
tick: 0,
|
||||
bump: () => set((s) => ({ tick: s.tick + 1 })),
|
||||
}),
|
||||
{
|
||||
name: 'dashboard.period',
|
||||
partialize: (state) => ({ period: state.period }),
|
||||
partialize: (state) => ({ period: state.period, autoRefresh: state.autoRefresh }),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -1,7 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
@@ -99,10 +102,13 @@ export const useHistoryMetricsStore = create<HistoryMetricsState>()((set, get) =
|
||||
|
||||
refresh: async (dashboardId, period, ids) => {
|
||||
const key = `${dashboardId}|${periodKey(period)}`;
|
||||
// INBUXA: mark the entry stale rather than dropping it, so the dashboard
|
||||
// keeps showing the last numbers until the new ones arrive instead of
|
||||
// blanking every instrument for the length of the fetch.
|
||||
set((state) => {
|
||||
const newCache = new Map(state.cache);
|
||||
newCache.delete(key);
|
||||
return { cache: newCache };
|
||||
const entry = state.cache.get(key);
|
||||
if (!entry) return {};
|
||||
return { cache: new Map(state.cache).set(key, { ...entry, fetchedAt: 0 }) };
|
||||
});
|
||||
return get().fetch(dashboardId, period, ids);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/** The command center's few colors, by what they mean rather than what they are. */
|
||||
export type Tone = 'primary' | 'ok' | 'warn' | 'crit' | 'neutral';
|
||||
|
||||
export const TONE_VAR: Record<Tone, string> = {
|
||||
primary: 'var(--primary)',
|
||||
ok: 'var(--chart-4)',
|
||||
warn: 'var(--highlight)',
|
||||
crit: 'var(--destructive)',
|
||||
neutral: 'var(--chart-3)',
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { Metric, MetricId } from './types/metrics';
|
||||
import { useDashboardStore } from './stores/dashboardStore';
|
||||
import { useHistoryMetricsStore } from './stores/historyMetricsStore';
|
||||
import { deltaHistograms, periodKey, periodWindow } from './helpers';
|
||||
|
||||
/**
|
||||
* A dashboard page's own metrics over the chosen period: fetched when the
|
||||
* page or period changes, and fetched again on every refresh, keeping the
|
||||
* last numbers on screen meanwhile. The window slides forward with each
|
||||
* refresh, so "last 24 hours" stays the last 24 hours.
|
||||
*/
|
||||
export function usePageHistory(cacheId: string, ids: Set<MetricId>) {
|
||||
const period = useDashboardStore((s) => s.period);
|
||||
const tick = useDashboardStore((s) => s.tick);
|
||||
const fetchHistory = useHistoryMetricsStore((s) => s.fetch);
|
||||
const refreshHistory = useHistoryMetricsStore((s) => s.refresh);
|
||||
const historyCache = useHistoryMetricsStore((s) => s.cache);
|
||||
const cacheKey = `${cacheId}|${periodKey(period)}`;
|
||||
const [version, setVersion] = useState(0);
|
||||
const [firstTick] = useState(tick);
|
||||
|
||||
useEffect(() => {
|
||||
if (ids.size === 0) return;
|
||||
let cancelled = false;
|
||||
// A tick since the page opened asks for fresh numbers, not the cache.
|
||||
const load = tick !== firstTick ? refreshHistory : fetchHistory;
|
||||
load(cacheId, period, ids).then(() => {
|
||||
if (!cancelled) setVersion((v) => v + 1);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cacheId, period, ids, tick, firstTick, fetchHistory, refreshHistory]);
|
||||
|
||||
const samples = useMemo<Metric[]>(() => {
|
||||
void version;
|
||||
return deltaHistograms(historyCache.get(cacheKey)?.metrics ?? []);
|
||||
}, [historyCache, cacheKey, version]);
|
||||
const window = useMemo(() => periodWindow(period), [period, version]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return { samples, window, period };
|
||||
}
|
||||
@@ -10,49 +10,16 @@
|
||||
* when the reader may not see the policy, or when the server has no policy.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { fetchProtocolPolicy, fetchTenantPolicy } from './protocolPolicy';
|
||||
import { useLegacyProtocolsOff } from './useLegacyProtocolsOff';
|
||||
|
||||
export const LEGACY_PROTOCOLS_VIEW = 'CustomComponent/LegacyProtocols';
|
||||
|
||||
export function LegacyProtocolsBanner() {
|
||||
const { t } = useTranslation();
|
||||
const canGetServer = useAccountStore((s) => s.hasObjectPermission('sysNetworkListener', 'Get'));
|
||||
const canGetTenant = useAccountStore((s) => s.hasObjectPermission('sysDomain', 'Get'));
|
||||
const [off, setOff] = useState<null | 'server' | 'tenant'>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canGetServer && !canGetTenant) return;
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
(async () => {
|
||||
// The server's switch first. Inside a tenant it can't be read, and the
|
||||
// tenant's own is the one to report (LP-18 at tenant scope).
|
||||
try {
|
||||
if (canGetServer) {
|
||||
const policy = await fetchProtocolPolicy(signal);
|
||||
if (!signal.aborted) setOff(policy.legacyProtocols === 'disabled' ? 'server' : null);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the tenant's.
|
||||
}
|
||||
try {
|
||||
if (canGetTenant) {
|
||||
const policy = await fetchTenantPolicy(null, signal);
|
||||
if (!signal.aborted) setOff(policy.legacyProtocols === 'disabled' ? 'tenant' : null);
|
||||
}
|
||||
} catch {
|
||||
// A banner is not worth an error: an older server simply has no switch.
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [canGetServer, canGetTenant]);
|
||||
|
||||
const off = useLegacyProtocolsOff();
|
||||
if (!off) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { fetchProtocolPolicy, fetchTenantPolicy } from './protocolPolicy';
|
||||
|
||||
/** Whether legacy protocols are off, and where: this server, the reader's tenant, or neither (null). */
|
||||
export function useLegacyProtocolsOff(): null | 'server' | 'tenant' {
|
||||
const canGetServer = useAccountStore((s) => s.hasObjectPermission('sysNetworkListener', 'Get'));
|
||||
const canGetTenant = useAccountStore((s) => s.hasObjectPermission('sysDomain', 'Get'));
|
||||
const [off, setOff] = useState<null | 'server' | 'tenant'>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canGetServer && !canGetTenant) return;
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
(async () => {
|
||||
// The server's switch first. Inside a tenant it can't be read, and the
|
||||
// tenant's own is the one to report (LP-18 at tenant scope).
|
||||
try {
|
||||
if (canGetServer) {
|
||||
const policy = await fetchProtocolPolicy(signal);
|
||||
if (!signal.aborted) setOff(policy.legacyProtocols === 'disabled' ? 'server' : null);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the tenant's.
|
||||
}
|
||||
try {
|
||||
if (canGetTenant) {
|
||||
const policy = await fetchTenantPolicy(null, signal);
|
||||
if (!signal.aborted) setOff(policy.legacyProtocols === 'disabled' ? 'tenant' : null);
|
||||
}
|
||||
} catch {
|
||||
// A banner is not worth an error: an older server simply has no switch.
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [canGetServer, canGetTenant]);
|
||||
|
||||
return off;
|
||||
}
|
||||
+194
-1
@@ -798,7 +798,12 @@
|
||||
"rhythm": {
|
||||
"title": "Your mail’s weekly rhythm",
|
||||
"subtitle": "Messages handled by hour and day, over the period above",
|
||||
"cell": "{{day}} {{hour}}:00, {{count}} messages"
|
||||
"cell": "{{day}} {{hour}}:00, {{count}} messages",
|
||||
"cellCount": "{{day}} {{hour}}:00, {{count}}",
|
||||
"less": "Less",
|
||||
"more": "More",
|
||||
"busiest": "Busiest: {{day}} {{hour}}:00",
|
||||
"total": "Total"
|
||||
},
|
||||
"dirCard": {
|
||||
"chooseTitle": "Connect a sign-in directory",
|
||||
@@ -1756,5 +1761,193 @@
|
||||
"choose": "Choose a journal…",
|
||||
"noJournal": "Don’t journal it",
|
||||
"offSuffix": "(off)"
|
||||
},
|
||||
"cc": {
|
||||
"a": {
|
||||
"security_one": "critical security item",
|
||||
"security_other": "critical security items",
|
||||
"securityDetail": "On the Security to-do list and not yet accepted.",
|
||||
"nodes_one": "node not responding",
|
||||
"nodes_other": "nodes not responding",
|
||||
"bounced_one": "recipient given up on",
|
||||
"bounced_other": "recipients given up on",
|
||||
"bouncedDetail": "Delivery failed for good; the sender gets a bounce.",
|
||||
"tasks_one": "failed task",
|
||||
"tasks_other": "failed tasks",
|
||||
"tasksDetail": "Background work that stopped with an error.",
|
||||
"retrying_one": "message retrying",
|
||||
"retrying_other": "messages retrying",
|
||||
"retryingDetail": "A receiving server turned it away for now.",
|
||||
"quota_one": "account near its quota",
|
||||
"quota_other": "accounts near their quota",
|
||||
"quotaDetail": "At 90% or more of their storage; mail may soon be refused.",
|
||||
"slow_one": "stage running slow",
|
||||
"slow_other": "stages running slow",
|
||||
"slowDetailPage": "Performance shows which one.",
|
||||
"reports_one": "report with warnings",
|
||||
"reports_other": "reports with warnings",
|
||||
"reportsDetail": "Other providers flagged how your mail arrived ({{dmarc}} DMARC, {{tls}} TLS).",
|
||||
"feed": "Live feed unavailable",
|
||||
"feedDetail": "Counts come from the server’s records; connection meters are dark."
|
||||
},
|
||||
"attention_one": "Needs attention · {{count}}",
|
||||
"attention_other": "Needs attention · {{count}}",
|
||||
"updated": "Updated {{time}}",
|
||||
"period": "Period",
|
||||
"refresh": "Refresh",
|
||||
"autoRefresh": "Auto refresh",
|
||||
"autoOff": "Auto off",
|
||||
"autoEvery": "Every {{m}}m",
|
||||
"dashboards": "Dashboards",
|
||||
"back": "Command center",
|
||||
"trends": "Trends",
|
||||
"cluster": "Cluster",
|
||||
"customPeriod": "Chosen period",
|
||||
"c": {
|
||||
"coordination": "Links between nodes",
|
||||
"coordinationDesc": "Errors and dropped links on the channel the nodes use to keep in step.",
|
||||
"subscriberErrors": "receive errors",
|
||||
"publisherErrors": "send errors",
|
||||
"disconnects": "dropped",
|
||||
"dataLayer": "Data layer errors",
|
||||
"dataLayerDesc": "Errors from the stores every node shares, and from the server’s own threads.",
|
||||
"storeErrors": "store",
|
||||
"threadErrors": "threads"
|
||||
},
|
||||
"notCluster": "This server runs on its own, so there is no cluster to show.",
|
||||
"instruments": "Instruments",
|
||||
"g": {
|
||||
"nodes": "Nodes up",
|
||||
"nodesDetail": "Every node renewing",
|
||||
"heartbeat": "Oldest heartbeat",
|
||||
"heartbeatDetail": "Nodes renew every minute; stale after 3",
|
||||
"coordClean": "Links clean",
|
||||
"errorsInPeriod_one": "{{count}} error in the period",
|
||||
"errorsInPeriod_other": "{{count}} errors in the period",
|
||||
"storeClean": "Data layer clean",
|
||||
"load": "Load",
|
||||
"loadDetail": "{{pct}}% of peak ({{peak}})",
|
||||
"noTraffic": "No mail recorded yet",
|
||||
"queue": "Queue on schedule",
|
||||
"queueDetail": "{{n}} queued · {{r}} retrying",
|
||||
"received": "Mail received",
|
||||
"clean": "{{n}} to inboxes",
|
||||
"filtered": "{{n}} spam filtered",
|
||||
"noScans": "Nothing scanned yet",
|
||||
"ingest": "Take-in time",
|
||||
"ingestDetail": "Average per message",
|
||||
"storage": "Quota used",
|
||||
"storageDetail": "{{used}} of {{quota}}",
|
||||
"zoneDetail": "fine under {{good}} · slow over {{bad}}",
|
||||
"quiet": "Nothing recorded yet",
|
||||
"memory": "Memory",
|
||||
"memoryDetail": "this node · peak {{peak}}",
|
||||
"connLoad": "Connection load",
|
||||
"smtpSplit": "SMTP sessions",
|
||||
"smtpIn": "{{n}} in",
|
||||
"smtpOut": "{{n}} out",
|
||||
"access": "Mailbox access",
|
||||
"web": "{{n}} web & JMAP",
|
||||
"legacy": "{{n}} IMAP/POP3/Sieve",
|
||||
"threats": "Threat pressure",
|
||||
"bans": "Addresses banned",
|
||||
"authBans": "{{n}} for sign-ins",
|
||||
"otherBans": "{{n}} abuse & scans",
|
||||
"flow": "Messages handled",
|
||||
"flowIn": "{{n}} in",
|
||||
"flowOut": "{{n}} out",
|
||||
"attempt": "Delivery attempt",
|
||||
"index": "Index time",
|
||||
"dns": "DNS lookup",
|
||||
"dataRead": "Data read",
|
||||
"dataWrite": "Data write",
|
||||
"blobRead": "Blob read",
|
||||
"blobWrite": "Blob write"
|
||||
},
|
||||
"roster": "Nodes",
|
||||
"rosterOpen": "Node list",
|
||||
"rosterCount": "{{up}} of {{all}} up",
|
||||
"nodeId": "id {{id}}",
|
||||
"nodeActive": "Active",
|
||||
"nodeSilent": "Silent",
|
||||
"heartbeatAgo": "heartbeat {{ago}} ago",
|
||||
"retired_one": "{{count}} old lease not counted: {{names}}",
|
||||
"retired_other": "{{count}} old leases not counted: {{names}}",
|
||||
"r": {
|
||||
"receiveErrors": "Receive errors",
|
||||
"sendErrors": "Send errors",
|
||||
"drops": "Dropped links",
|
||||
"storeErrors": "Store errors",
|
||||
"users": "People",
|
||||
"groups_one": "{{count}} group",
|
||||
"groups_other": "{{count}} groups",
|
||||
"domains": "Domains",
|
||||
"received": "Received",
|
||||
"sent": "Sent",
|
||||
"queued": "Queued",
|
||||
"retrying": "{{count}} retrying",
|
||||
"spam": "Spam blocked",
|
||||
"authFailed": "Failed sign-ins",
|
||||
"bans": "Addresses banned",
|
||||
"blocked": "{{count}} on the block list",
|
||||
"stored": "Mail stored",
|
||||
"storedDetail_one": "across {{count}} account",
|
||||
"storedDetail_other": "across {{count}} accounts",
|
||||
"memory": "Memory",
|
||||
"memoryDetail": "This node, now"
|
||||
},
|
||||
"vitals": "Vital signs",
|
||||
"perSlice": "/{{slice}}",
|
||||
"live": "Live",
|
||||
"offline": "No feed",
|
||||
"nodesChip": "{{up}}/{{all}} nodes",
|
||||
"legacyTitle": "Only inbuxa webmail and JMAP apps can sign in.",
|
||||
"legacyOff": "Legacy off",
|
||||
"lat": {
|
||||
"ingest": "Take in",
|
||||
"index": "Index",
|
||||
"dns": "DNS lookup",
|
||||
"delivery": "Delivery attempt",
|
||||
"dataRead": "Data read",
|
||||
"dataWrite": "Data write",
|
||||
"blobRead": "Blob read",
|
||||
"blobWrite": "Blob write"
|
||||
},
|
||||
"latency": "Response times",
|
||||
"logs": "Logs",
|
||||
"connections": "Connections now",
|
||||
"connectionsTotal": "Open sessions",
|
||||
"flow": "Mail flow",
|
||||
"traces": "Traces",
|
||||
"in": "In",
|
||||
"out": "Out",
|
||||
"flowAria": "{{in}} messages in, {{out}} out",
|
||||
"flowSlice": "{{time}} · {{in}} in, {{out}} out",
|
||||
"peak": "PEAK",
|
||||
"avg": "avg",
|
||||
"h": {
|
||||
"connections": "When people connect",
|
||||
"connectionsSub": "New connections by hour and day, over the period above",
|
||||
"attacks": "When sign-ins fail",
|
||||
"attacksSub": "Failed sign-ins by hour and day: a night-time block is usually a bot"
|
||||
},
|
||||
"b": {
|
||||
"stopped": "What got stopped",
|
||||
"total": "Stopped in all",
|
||||
"blocked": "Blocked connections",
|
||||
"authFailed": "Failed sign-ins",
|
||||
"spam": "Spam filtered",
|
||||
"authBans": "Sign-in bans",
|
||||
"abuseBans": "Abuse & scan bans",
|
||||
"loiterBans": "Loiter bans"
|
||||
},
|
||||
"zone": {
|
||||
"good": "fine",
|
||||
"fair": "fair",
|
||||
"slow": "slow"
|
||||
},
|
||||
"now": "now",
|
||||
"avgZone": "average · {{zone}}",
|
||||
"average": "average"
|
||||
}
|
||||
}
|
||||
@@ -179,3 +179,15 @@
|
||||
.dark input[type='number'] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* inbuxa: the dashboard clock's "Updated" line, lit briefly as new numbers land */
|
||||
@keyframes cc-flash {
|
||||
0% {
|
||||
color: var(--primary);
|
||||
text-shadow: 0 0 8px var(--primary);
|
||||
}
|
||||
100% {
|
||||
color: var(--muted-foreground);
|
||||
text-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,11 @@ export default function AdminPanel() {
|
||||
if (viewName === 'Wizard/certificates') return `${t('certSetup.tabTitle', 'Certificates')} · ${section}`;
|
||||
if (viewName === 'Wizard/sending') return `${t('sendingSetup.tabTitle', 'Sending mail')} · ${section}`;
|
||||
if (viewName.startsWith('Dashboard/')) {
|
||||
const board = schema?.dashboards?.find((d) => d.id === viewName.slice('Dashboard/'.length));
|
||||
const boardId = viewName.slice('Dashboard/'.length);
|
||||
// INBUXA: the landing page is the command center; Cluster isn't in the schema.
|
||||
if (boardId === 'overview') return t('cc.back', 'Command center');
|
||||
if (boardId === 'cluster') return `${t('cc.cluster', 'Cluster')} · ${t('dashboard.title', 'Dashboard')}`;
|
||||
const board = schema?.dashboards?.find((d) => d.id === boardId);
|
||||
return `${board?.label ?? t('dashboard.title', 'Dashboard')} · ${t('dashboard.title', 'Dashboard')}`;
|
||||
}
|
||||
let label: string | undefined;
|
||||
|
||||
Reference in new issue
Block a user