Dashboard: a command center, with trend pages to match #63

Merged
jcoffey-dev merged 1 commits from feature/command-center into main 2026-09-29 05:28:24 +00:00
43 changed files with 3865 additions and 1056 deletions

No files matched your search

+196
View File
@@ -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);
});
});
+22
View File
@@ -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);
}
+24 -9
View File
@@ -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);
});
});
+233
View File
@@ -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>
);
}
+183
View File
@@ -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>
);
}
+36 -19
View File
@@ -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;
}
+2 -2
View File
@@ -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);
},
+16
View File
@@ -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)',
};
+49
View File
@@ -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
View File
@@ -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"
}
}
+12
View File
@@ -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;
}
}
+5 -1
View File
@@ -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;