diff --git a/src/features/dashboard/attention.ts b/src/features/dashboard/attention.ts new file mode 100644 index 0000000..9734e75 --- /dev/null +++ b/src/features/dashboard/attention.ts @@ -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; +} diff --git a/src/features/dashboard/autoRefresh.test.ts b/src/features/dashboard/autoRefresh.test.ts new file mode 100644 index 0000000..1cb6b2e --- /dev/null +++ b/src/features/dashboard/autoRefresh.test.ts @@ -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); + }); +}); diff --git a/src/features/dashboard/autoRefresh.ts b/src/features/dashboard/autoRefresh.ts new file mode 100644 index 0000000..2f2a35c --- /dev/null +++ b/src/features/dashboard/autoRefresh.ts @@ -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); +} diff --git a/src/features/dashboard/clusterHealth.ts b/src/features/dashboard/clusterHealth.ts index 71d7bc7..569499d 100644 --- a/src/features/dashboard/clusterHealth.ts +++ b/src/features/dashboard/clusterHealth.ts @@ -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 { { 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 { const list = ((got[1] as Record).list as Record[] | 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 { const REFRESH_MS = 30_000; -/** The cluster's health, refreshed every 30 seconds; null when this isn't a cluster. */ -export function useClusterHealth() { - const [health, setHealth] = useState(null); +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(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; } diff --git a/src/features/dashboard/commandCenter.test.ts b/src/features/dashboard/commandCenter.test.ts new file mode 100644 index 0000000..8ab2ebd --- /dev/null +++ b/src/features/dashboard/commandCenter.test.ts @@ -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); + }); +}); diff --git a/src/features/dashboard/commandCenter.ts b/src/features/dashboard/commandCenter.ts new file mode 100644 index 0000000..5821195 --- /dev/null +++ b/src/features/dashboard/commandCenter.ts @@ -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([ + 'user.count', + 'domain.count', + 'queue.count', + 'server.memory', + ...PROTOCOLS.map((p) => p.metric), +]); + +export const HISTORY_IDS = new Set([ + ...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): 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 = { + 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; +} diff --git a/src/features/dashboard/components/AttentionRail.tsx b/src/features/dashboard/components/AttentionRail.tsx new file mode 100644 index 0000000..0eeab90 --- /dev/null +++ b/src/features/dashboard/components/AttentionRail.tsx @@ -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 ( +
+
+ + + + +

+ {t('cc.attention', { + count: items.length, + defaultValue_one: 'Needs attention · {{count}}', + defaultValue_other: 'Needs attention · {{count}}', + })} +

+ +
+
+ {sorted.map((a) => { + const body = ( +
+ + + {createElement(a.icon, { + className: cn('mt-0.5 h-5 w-5 shrink-0', a.severity === 'crit' ? 'text-destructive' : 'text-highlight'), + 'aria-hidden': true, + })} +
+
+ {a.count !== undefined && ( + + {a.count} + + )} + {a.title} +
+

{a.detail}

+
+ {a.href && ( + + )} +
+ ); + return a.href ? ( + + {body} + + ) : ( +
{body}
+ ); + })} +
+
+ ); +} diff --git a/src/features/dashboard/components/Breakdown.tsx b/src/features/dashboard/components/Breakdown.tsx new file mode 100644 index 0000000..8603116 --- /dev/null +++ b/src/features/dashboard/components/Breakdown.tsx @@ -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 ( + {aside} + ) : undefined + } + > +
    + {sorted.map((r) => { + const color = TONE_VAR[r.tone ?? 'primary']; + return ( +
  • + {r.label} + + + + + {formatValue(r.value, 'number')} + + {sum ? `${Math.round((r.value / sum) * 100)}%` : ''} + + +
  • + ); + })} +
+
+
+ {sorted.map((r) => + r.value ? ( + + ) : null, + )} +
+
+ {totalLabel} + + {formatValue(sum, 'number')} + +
+
+
+ ); +} diff --git a/src/features/dashboard/components/BridgeControls.tsx b/src/features/dashboard/components/BridgeControls.tsx new file mode 100644 index 0000000..3463a45 --- /dev/null +++ b/src/features/dashboard/components/BridgeControls.tsx @@ -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 ( +
+
{local}
+
UTC {utc}
+ {updated && ( +
+ {t('cc.updated', 'Updated {{time}}', { + time: new Intl.DateTimeFormat(i18n.language, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).format(updated), + })} +
+ )} +
+ ); +} + +/** + * 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 ( +
+
+ {PRESET_KEYS.map((k) => ( + + ))} +
+
+ + +
+
+ ); +} + +/** 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 ( + + {children} + + ); +} + +/** + * 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 ( + + ); +} diff --git a/src/features/dashboard/components/ClusterHealthCard.tsx b/src/features/dashboard/components/ClusterHealthCard.tsx deleted file mode 100644 index 5ef65a4..0000000 --- a/src/features/dashboard/components/ClusterHealthCard.tsx +++ /dev/null @@ -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 = ( - - -
- - {t('cluster.health', 'Cluster Health')} - {href && ( - - )} -
- -
- {health.healthy} - / {total} -
- -
- {t('cluster.healthy', { - count: health.healthy, - defaultValue_one: '{{count}} healthy node', - defaultValue_other: '{{count}} healthy nodes', - })} - {' · '} - - {t('cluster.unhealthy', { - count: health.unhealthy, - defaultValue_one: '{{count}} unhealthy', - defaultValue_other: '{{count}} unhealthy', - })} - -
- - {health.silent.length > 0 && ( -
    - {health.silent.map((n) => ( -
  • - {n.hostname} {quietFor(n.lastRenewal)} -
  • - ))} -
- )} -
-
- ); - - return href ? ( - - {body} - - ) : ( - body - ); -} diff --git a/src/features/dashboard/components/ClusterView.tsx b/src/features/dashboard/components/ClusterView.tsx new file mode 100644 index 0000000..83cecd7 --- /dev/null +++ b/src/features/dashboard/components/ClusterView.tsx @@ -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( + () => [ + { + 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 ( + +

+ {t('cc.notCluster', 'This server runs on its own, so there is no cluster to show.')} +

+
+ ); + + 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 ( +
+ {periodName} + } + > +
+ + {health.healthy} + /{all} + + } + 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} + /> + + + +
+
+ + + {t('cc.rosterCount', '{{up}} of {{all}} up', { up: health.healthy, all })} + + } + > +
+ {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 ( +
+
+ + {up && ( + + )} + + + {node.hostname} + {node.nodeId !== undefined && ( + + {t('cc.nodeId', 'id {{id}}', { id: node.nodeId })} + + )} +
+
+ + {up ? t('cc.nodeActive', 'Active') : t('cc.nodeSilent', 'Silent')} + + + {age === null ? '—' : t('cc.heartbeatAgo', 'heartbeat {{ago}} ago', { ago: ago(age) })} + +
+
+
0.5 ? 'bg-highlight' : 'bg-primary', + )} + style={{ width: `${Math.max(2, share * 100)}%` }} + /> +
+
+ ); + })} +
+ {retired.length > 0 && ( +

+ {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(', '), + })} +

+ )} + + +
+ + + + +
+ +
+ {charts.map((chart) => ( + + ))} +
+
+ ); +} diff --git a/src/features/dashboard/components/CommandCenter.tsx b/src/features/dashboard/components/CommandCenter.tsx new file mode 100644 index 0000000..e679992 --- /dev/null +++ b/src/features/dashboard/components/CommandCenter.tsx @@ -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 = { 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 ( +
+ {/* Vital signs */} + {periodName} + } + > +
+ 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') + } + /> + + + + {t('cc.g.clean', '{{n}} to inboxes', { n: formatValue(scanned - spam, 'number') })} + + + {t('cc.g.filtered', '{{n}} spam filtered', { n: formatValue(spam, 'number') })} + + + ) : ( + t('cc.g.noScans', 'Nothing scanned yet') + ) + } + /> + + {health && ( + + {health.healthy} + /{health.healthy + health.unhealthy} + + } + 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 && ( + = 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'), + })} + /> + )} +
+
+ + {/* Quick stats */} +
+ + + + + + + + + + +
+ + {/* Current state */} +
+ + +
+ +
+ + +
+ + {facts && (facts.storage || facts.waiting) && ( +
+ {facts.waiting && } + {facts.storage && } +
+ )} +
+ ); +} diff --git a/src/features/dashboard/components/DashboardChart.tsx b/src/features/dashboard/components/DashboardChart.tsx deleted file mode 100644 index 14d74a6..0000000 --- a/src/features/dashboard/components/DashboardChart.tsx +++ /dev/null @@ -1,257 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * 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(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 ( -
- {width > 0 && children(width, height)} -
- ); -} -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 = { - 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 = {}; - 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 ( - - ); - case 'area': - return ( - - ); - case 'bar': - return ( - - ); - } - }); - - const axes = ( - <> - - - - ( - - )} - /> - - - ); - - switch (chart.kind) { - case 'line': - return ( - - {axes} - {seriesElements} - - ); - case 'area': - return ( - - {axes} - {seriesElements} - - ); - case 'bar': - return ( - - {axes} - {seriesElements} - - ); - } - }; - - return ( - - -
- {chart.title} - {chart.description && ( - - - - - - -

{chart.description}

-
-
-
- )} - x.metrics)} /> -
-
- - {(width, height) => renderChart(width, height)} - -
- ); -} diff --git a/src/features/dashboard/components/DashboardShell.tsx b/src/features/dashboard/components/DashboardShell.tsx new file mode 100644 index 0000000..83b0e27 --- /dev/null +++ b/src/features/dashboard/components/DashboardShell.tsx @@ -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(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(() => { + void version; + return deltaHistograms(historyCache.get(cacheKey)?.metrics ?? []); + }, [historyCache, cacheKey, version]); + + const live = liveStatus === 'open'; + const liveValues = useMemo(() => { + const m = new Map(); + 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 ( +
+ + +
+ + {health && ( + + + {t('cc.nodesChip', '{{up}}/{{all}} nodes', { + up: health.healthy, + all: health.healthy + health.unhealthy, + })} + + + )} + {legacyOff && ( + + + + {t('cc.legacyOff', 'Legacy off')} + + + )} + + +
+
+ + {children} +
+ ); +} diff --git a/src/features/dashboard/components/DashboardView.tsx b/src/features/dashboard/components/DashboardView.tsx index 17b6601..060ddf9 100644 --- a/src/features/dashboard/components/DashboardView.tsx +++ b/src/features/dashboard/components/DashboardView.tsx @@ -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 = { - '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(() => 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()), - [dashboard], - ); - const liveIds = useMemo(() => (dashboard ? collectLiveMetricIds(dashboard.cards) : new Set()), [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 ( -
No dashboards configured.
- ); - } - return null; - } + }, [own, dashboard, dashboards, navigate, section]); return ( -
- - - -
- {dashboards.length > 1 && ( - navigate(`/${section}/Dashboard/${id}`)}> - - {dashboards.map((d) => ( - - {d.label} - - ))} - - - )} - {dashboards.length === 1 &&

{dashboard.label}

} - - -
- - {liveStatus === 'error' && liveError && ( -
- - - {/404/.test(liveError) - ? t( - 'dashboard.liveUnavailable', - "Live numbers aren't available on this server yet. The rest of the dashboard still works.", - ) - : liveError} - -
- )} - - {dashboard.cards && dashboard.cards.length > 0 && ( -
- {dashboard.cards.map((card, i) => - clusterHealth && replacedInCluster(card.metrics) ? ( - - ) : ( - - ), - )} -
- )} - - {dashboard.id === 'overview' && facts && (facts.storage || facts.waiting) && ( -
- {facts.waiting && } - {facts.storage && } -
- )} - - {dashboard.id === 'overview' && } - - {dashboard.charts && dashboard.charts.length > 0 && ( -
- {dashboard.charts.map((chart, i) => ( - - ))} -
- )} -
+ + {dashboardId === 'overview' ? ( + + ) : dashboardId === 'cluster' ? ( + + ) : dashboard ? ( + + ) : null} + ); } diff --git a/src/features/dashboard/components/Gauge.tsx b/src/features/dashboard/components/Gauge.tsx new file mode 100644 index 0000000..b640934 --- /dev/null +++ b/src/features/dashboard/components/Gauge.tsx @@ -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 ( +
+
+ + + + + + + + + + + {/* 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 ( + + ); + })} + + {bands?.map((b, i) => ( + + ))} + {value !== null && ( + <> + + + + )} + {reverse && reverse.value > 0 && ( + <> + + + + + )} + +
+
+ {reading} + {unit && {unit}} +
+
+
+
+
{label}
+ {detail &&
{detail}
} +
+
+ ); +} diff --git a/src/features/dashboard/components/GoLink.tsx b/src/features/dashboard/components/GoLink.tsx deleted file mode 100644 index fc788a9..0000000 --- a/src/features/dashboard/components/GoLink.tsx +++ /dev/null @@ -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 ( - - {t(`dashLink.${link.viewName}`, link.label)} - - - ); -} diff --git a/src/features/dashboard/components/Greeting.tsx b/src/features/dashboard/components/Greeting.tsx index eaed44d..29ad608 100644 --- a/src/features/dashboard/components/Greeting.tsx +++ b/src/features/dashboard/components/Greeting.tsx @@ -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 ( -
- -
-

- {hello} - {name && `, ${name}`} -

-

- {username && ( - <> - {t('greeting.signedInAs', 'Signed in as {{username}}', { username })} - {' · '} - - )} - {t('greeting.subtitle', "Here's how your mail server is doing.")} -

+
+
+
+
+
+ +
+

+ {hello} + {name && `, ${name}`} +

+

+ {username && ( + <> + {t('greeting.signedInAs', 'Signed in as {{username}}', { username })} + {' · '} + + )} + {t('greeting.subtitle', "Here's how your mail server is doing.")} +

+
+
+ {children}
); diff --git a/src/features/dashboard/components/Latencies.tsx b/src/features/dashboard/components/Latencies.tsx new file mode 100644 index 0000000..525579e --- /dev/null +++ b/src/features/dashboard/components/Latencies.tsx @@ -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 = { + 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 = { + 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 ( + +
    + {LATENCIES.map((l) => { + const ms = mean(samples, [l.metric]); + const full = l.bad * 2; + const zone = ms === null ? null : zoneOf(ms, l); + return ( +
  • + {labels[l.id]} + + + + {ms !== null && zone && ( + + )} + + + {ms === null ? '—' : formatValue(ms, 'duration')} + +
  • + ); + })} +
+
+ ); +} diff --git a/src/features/dashboard/components/LiveConnections.tsx b/src/features/dashboard/components/LiveConnections.tsx new file mode 100644 index 0000000..f3ef1af --- /dev/null +++ b/src/features/dashboard/components/LiveConnections.tsx @@ -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; 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 ( + } + > +
+ {PROTOCOLS.map((p) => { + const v = live ? (values.get(p.metric) ?? 0) : null; + const lit = v === null ? 0 : Math.ceil((v / max) * SEGMENTS); + return ( +
+ {v ?? '—'} +
+ {Array.from({ length: SEGMENTS }, (_, i) => { + const on = i < lit; + return ( + + ); + })} +
+ + {p.label} + +
+ ); + })} +
+
+ {t('cc.connectionsTotal', 'Open sessions')} + {live ? sum : '—'} +
+
+ ); +} diff --git a/src/features/dashboard/components/MailFlow.tsx b/src/features/dashboard/components/MailFlow.tsx new file mode 100644 index 0000000..45542ca --- /dev/null +++ b/src/features/dashboard/components/MailFlow.tsx @@ -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 ( + {periodLabel} + } + href="/Management/x:Trace/InboundDelivery" + hrefLabel={t('cc.traces', 'Traces')} + > +
+
+
+ ▲ {t('cc.in', 'In')} +
+
+ {formatValue(inTotal, 'number')} +
+
+
+
+ ▼ {t('cc.out', 'Out')} +
+
+ {formatValue(outTotal, 'number')} +
+
+
+
+
+
+ {received.map((r, i) => ( +
+
+ +
+
+ +
+
+ ))} +
+
+ + ); +} diff --git a/src/features/dashboard/components/Panel.tsx b/src/features/dashboard/components/Panel.tsx new file mode 100644 index 0000000..8172716 --- /dev/null +++ b/src/features/dashboard/components/Panel.tsx @@ -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 ( +
+ +
+

{title}

+ + {aside} + {href && ( + + {hrefLabel} + + + )} +
+
{children}
+
+ ); +} + +/** The bracketed corners that frame every instrument. */ +export function Corners() { + return ( + <> + + + + + + ); +} + +/** The pulsing dot that says a panel is fed by the live stream. */ +export function LiveDot({ on, label }: { on: boolean; label: string }) { + return ( + + + {on && } + + + {label} + + ); +} diff --git a/src/features/dashboard/components/PeriodSelector.tsx b/src/features/dashboard/components/PeriodSelector.tsx deleted file mode 100644 index 471f053..0000000 --- a/src/features/dashboard/components/PeriodSelector.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * 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 ( -
- -
- - - - - -
- - -
-

{t('dashboard.customRange', 'Custom range')}

-
- - setCustomFrom(e.target.value)} - /> -
-
- - setCustomTo(e.target.value)} - /> -
- -
-
-
- - -
- ); -} diff --git a/src/features/dashboard/components/QueueWaiting.tsx b/src/features/dashboard/components/QueueWaiting.tsx index eaaf8da..b7c49af 100644 --- a/src/features/dashboard/components/QueueWaiting.tsx +++ b/src/features/dashboard/components/QueueWaiting.tsx @@ -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 ( - + +
- {t('queue.title', 'Where mail is waiting')} + + {t('queue.title', 'Where mail is waiting')} +

{t('queue.subtitle', 'Outgoing recipients still in the queue, by destination')}

@@ -45,49 +50,57 @@ export function QueueWaiting({ waiting }: { waiting: WaitingDomain[] }) { - + {rows.length === 0 ? ( -
+
{t('queue.empty', 'Nothing waiting. Everything has gone out.')}
) : ( -
- {rows.map((r) => { - const total = r.scheduled + r.retrying + r.failed; - const seg = (n: number) => `${(n / max) * 100}%`; - return ( - - {r.domain} - - - - - - {total} - - ); - })} -
- {legend.map((l) => ( - - - {l.label} - - ))} - {waiting.length > MAX_ROWS && - t('queue.more', '+{{count}} more destinations', { count: waiting.length - MAX_ROWS })} +
+
+ {rows.map((r) => { + const total = r.scheduled + r.retrying + r.failed; + const seg = (n: number) => `${(n / max) * 100}%`; + return ( + + {r.domain} + + + + + + {total} + + ); + })}
+
+ {legend.map((l) => ( +
+
+ + {l.label} +
+
{l.total}
+
+ ))} +
+ {waiting.length > MAX_ROWS && ( +

+ {t('queue.more', '+{{count}} more destinations', { count: waiting.length - MAX_ROWS })} +

+ )}
)} diff --git a/src/features/dashboard/components/Readout.tsx b/src/features/dashboard/components/Readout.tsx new file mode 100644 index 0000000..0f51f2a --- /dev/null +++ b/src/features/dashboard/components/Readout.tsx @@ -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 = ( +
+
+ {label} +
+
{value}
+ {detail &&
{detail}
} + {bars && bars.length > 0 && ( +
+ {bars.map((b, i) => ( + + ))} +
+ )} +
+ ); + return href ? ( + + {body} + + ) : ( + body + ); +} diff --git a/src/features/dashboard/components/StatCard.tsx b/src/features/dashboard/components/StatCard.tsx deleted file mode 100644 index 80efcbe..0000000 --- a/src/features/dashboard/components/StatCard.tsx +++ /dev/null @@ -1,138 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * 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.title} - {link && ( - - )} - {card.description && ( - - - - - - -

{card.description}

-
-
-
- )} -
- -
{formattedValue}
- - {(delta || sparkline) && ( -
- {delta && ( - - {delta.direction === 'up' - ? `\u2191 ${Math.abs(delta.pct)}%` - : delta.direction === 'down' - ? `\u2193 ${Math.abs(delta.pct)}%` - : '\u2013'} - - )} - {sparkline && ( - - - - )} -
- )} -
-
- ); - - return link ? ( - - {body} - - ) : ( - body - ); -} diff --git a/src/features/dashboard/components/StatusLine.tsx b/src/features/dashboard/components/StatusLine.tsx deleted file mode 100644 index 4c3e4b0..0000000 --- a/src/features/dashboard/components/StatusLine.tsx +++ /dev/null @@ -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) => ( - - {i > 0 && (i === visible.length - 1 ? t('status.and', ' and ') : ', ')} - - {p.text} - - - )); - - return ( -
- -

- {lead} {list}. -

-
- ); -} diff --git a/src/features/dashboard/components/StorageTreemap.tsx b/src/features/dashboard/components/StorageTreemap.tsx index 94ee125..ec3451c 100644 --- a/src/features/dashboard/components/StorageTreemap.tsx +++ b/src/features/dashboard/components/StorageTreemap.tsx @@ -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 ( - + +
- {t('storage.title', 'Who uses the space')} + + {t('storage.title', 'Who uses the space')} +

{total > 0 ? t('storage.subtitle', '{{total}} across {{count}} people', { @@ -95,7 +99,7 @@ export function StorageTreemap({ storage }: { storage: StorageUse[] }) { - +

{total === 0 && (
diff --git a/src/features/dashboard/components/TrendChart.tsx b/src/features/dashboard/components/TrendChart.tsx new file mode 100644 index 0000000..482030c --- /dev/null +++ b/src/features/dashboard/components/TrendChart.tsx @@ -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(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 = { 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 = ( + <> + + {chart.series.map((s, i) => ( + + + + + ))} + + + + + {zones && ( + <> + + + + )} + + active && payload?.length ? ( +
+
{label}
+ {payload.map((p) => ( +
+ + {String(p.dataKey)} + + {p.value === null || p.value === undefined ? '—' : fmt(Number(p.value))} + +
+ ))} +
+ ) : null + } + /> + {peak && ( + + )} + + ); + + const series = chart.series.map((s, i) => { + const color = COLORS[i % COLORS.length]; + if (chart.kind === 'bar') + return ( + + ); + if (timing || chart.kind === 'line') + return ( + + ); + return ( + + ); + }); + + const Kind = chart.kind === 'bar' ? BarChart : timing || chart.kind === 'line' ? LineChart : AreaChart; + + return ( + +
+ {chart.series.map((s, i) => ( + + + {s.label} + + {summaries[i] === null ? '—' : fmt(summaries[i] as number)} + + {timing && {t('cc.avg', 'avg')}} + + ))} + {chart.description && {chart.description}} +
+
+ {width > 0 && ( + + {chrome} + {series} + + )} +
+
+ ); +} diff --git a/src/features/dashboard/components/TrendDeck.tsx b/src/features/dashboard/components/TrendDeck.tsx new file mode 100644 index 0000000..1d6054d --- /dev/null +++ b/src/features/dashboard/components/TrendDeck.tsx @@ -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; + facts: ServerFacts | null; + /** The dials, or the panels under the readouts. */ + part: 'gauges' | 'panels'; +} + +const ZONE_TONE: Record = { 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 ( + + ); + }; + + const loadGauge = (ids: string[], label: string, tone: Tone = 'primary') => { + const b = load(series(samples, ids, from, to, buckets)); + return ( + 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', + ) => ( + + {aText} + {bText} + + ) : ( + 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 ( + + ); + }; + + 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 = ( +
+ + +
+ ); + 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 = ( +
+ + +
+ ); + break; + } + case 'delivery': { + const inTotal = total(samples, RECEIVED); + const outTotal = total(samples, SENT); + const ok = queueHealth(facts?.waiting); + gauges = [ + = 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 = ( +
+ + {facts?.waiting && } +
+ ); + 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 = ; + 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( + = 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 ? : null; + break; + } + default: + return null; + } + + if (part === 'panels') return panels; + return ( + {periodName}} + > +
+ {gauges} +
+
+ ); +} diff --git a/src/features/dashboard/components/TrendsView.tsx b/src/features/dashboard/components/TrendsView.tsx new file mode 100644 index 0000000..1c18810 --- /dev/null +++ b/src/features/dashboard/components/TrendsView.tsx @@ -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 = { good: 'primary', fair: 'warn', slow: 'crit' }; + + return ( +
+ + + {cards.length > 0 && ( + c.source === 'live') ? ( + + ) : ( + {periodName} + ) + } + > +
+ {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((s, id) => { + const m = liveValues.get(id); + return m === undefined ? s : (s ?? 0) + m; + }, null); + return ( + + ); + } + 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 ( + + {t('cc.avgZone', 'average · {{zone}}', { zone: zoneWord[zone] })} + + ) : 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} + /> + ); + })} +
+
+ )} + + + + {dashboard.charts && dashboard.charts.length > 0 && ( +
+ {dashboard.charts.map((chart, i, all) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/features/dashboard/components/WeeklyHeatmap.tsx b/src/features/dashboard/components/WeeklyHeatmap.tsx index 54d96ff..ab62618 100644 --- a/src/features/dashboard/components/WeeklyHeatmap.tsx +++ b/src/features/dashboard/components/WeeklyHeatmap.tsx @@ -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 ( - + + - {t('rhythm.title', 'Your mail’s weekly rhythm')} + + {title ?? t('rhythm.title', 'Your mail’s weekly rhythm')} +

- {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')}

- -
+ +
{grid.map((row, d) => (
@@ -45,13 +65,21 @@ export function WeeklyHeatmap({ samples }: { samples: Metric[] }) { {row.map((v, h) => ( ))}
@@ -64,6 +92,25 @@ export function WeeklyHeatmap({ samples }: { samples: Metric[] }) { ))}
+
+ + {t('rhythm.less', 'Less')} + {[0.08, 0.3, 0.55, 0.8, 1].map((o) => ( + + ))} + {t('rhythm.more', 'More')} + + + {t('rhythm.busiest', 'Busiest: {{day}} {{hour}}:00', { + day: days[peak.d], + hour: String(peak.h).padStart(2, '0'), + })}{' '} + {peak.v} + {' · '} + {t('rhythm.total', 'Total')}{' '} + {sum} + +
); diff --git a/src/features/dashboard/dashboardContext.ts b/src/features/dashboard/dashboardContext.ts new file mode 100644 index 0000000..a5d8b99 --- /dev/null +++ b/src/features/dashboard/dashboardContext.ts @@ -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; + /** Moves on every refresh, by hand or on the timer. */ + tick: number; +} + +export const DashboardContext = createContext(null); + +export function useDashboardShared(): DashboardShared { + const shared = useContext(DashboardContext); + if (!shared) throw new Error('useDashboardShared outside the dashboard shell'); + return shared; +} diff --git a/src/features/dashboard/serverFacts.ts b/src/features/dashboard/serverFacts.ts index 3fadf66..5884b24 100644 --- a/src/features/dashboard/serverFacts.ts +++ b/src/features/dashboard/serverFacts.ts @@ -164,7 +164,7 @@ async function fetchFacts(): Promise { const REFRESH_MS = 60_000; -export function useServerFacts() { +export function useServerFacts(tick = 0) { const [facts, setFacts] = useState(null); const [error, setError] = useState(null); @@ -185,7 +185,7 @@ export function useServerFacts() { clearTimeout(first); clearInterval(timer); }; - }, [refresh]); + }, [refresh, tick]); return { facts, error, refresh }; } diff --git a/src/features/dashboard/stores/dashboardStore.ts b/src/features/dashboard/stores/dashboardStore.ts index b9158e6..93adf11 100644 --- a/src/features/dashboard/stores/dashboardStore.ts +++ b/src/features/dashboard/stores/dashboardStore.ts @@ -1,17 +1,29 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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()( @@ -21,10 +33,14 @@ export const useDashboardStore = create()( 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 }), }, ), ); diff --git a/src/features/dashboard/stores/historyMetricsStore.ts b/src/features/dashboard/stores/historyMetricsStore.ts index 11dd713..f8146fc 100644 --- a/src/features/dashboard/stores/historyMetricsStore.ts +++ b/src/features/dashboard/stores/historyMetricsStore.ts @@ -1,7 +1,10 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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()((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); }, diff --git a/src/features/dashboard/tones.ts b/src/features/dashboard/tones.ts new file mode 100644 index 0000000..653eddb --- /dev/null +++ b/src/features/dashboard/tones.ts @@ -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 = { + primary: 'var(--primary)', + ok: 'var(--chart-4)', + warn: 'var(--highlight)', + crit: 'var(--destructive)', + neutral: 'var(--chart-3)', +}; diff --git a/src/features/dashboard/usePageHistory.ts b/src/features/dashboard/usePageHistory.ts new file mode 100644 index 0000000..d7f1947 --- /dev/null +++ b/src/features/dashboard/usePageHistory.ts @@ -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) { + 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(() => { + 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 }; +} diff --git a/src/features/hardening/LegacyProtocolsBanner.tsx b/src/features/hardening/LegacyProtocolsBanner.tsx index eb9a7ae..93d2533 100644 --- a/src/features/hardening/LegacyProtocolsBanner.tsx +++ b/src/features/hardening/LegacyProtocolsBanner.tsx @@ -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); - - 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 ( diff --git a/src/features/hardening/useLegacyProtocolsOff.ts b/src/features/hardening/useLegacyProtocolsOff.ts new file mode 100644 index 0000000..ea38c09 --- /dev/null +++ b/src/features/hardening/useLegacyProtocolsOff.ts @@ -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); + + 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; +} diff --git a/src/i18n/en.json b/src/i18n/en.json index eb0d8eb..6bc44cc 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -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" } } diff --git a/src/index.css b/src/index.css index b4ebacc..abc4df6 100644 --- a/src/index.css +++ b/src/index.css @@ -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; + } +} diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 322403f..64d3196 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -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;