From fd4a319eaea6c0ae10ed269ac317b7db47f89dc2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:46:08 +0100 Subject: [PATCH 1/2] feat: show the storage and how many instances checked in on the admin overview #1066 --- app/(main)/admin/_pages/dashboard.tsx | 28 +++++++- .../admin/__tests__/instances-route.test.ts | 67 +++++++++++++++++++ app/api/admin/instances/route.ts | 38 +++++++++++ .../__tests__/dashboard-storage.test.tsx | 20 ++++++ 4 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 app/api/admin/__tests__/instances-route.test.ts create mode 100644 app/api/admin/instances/route.ts create mode 100644 components/admin/__tests__/dashboard-storage.test.tsx diff --git a/app/(main)/admin/_pages/dashboard.tsx b/app/(main)/admin/_pages/dashboard.tsx index 6f4767212..9a2137f76 100644 --- a/app/(main)/admin/_pages/dashboard.tsx +++ b/app/(main)/admin/_pages/dashboard.tsx @@ -34,6 +34,22 @@ interface VersionData { status: UpdateStatus | null; } +interface InstancesData { + mode: 'local' | 'redis'; + location: string; + instances: number | null; +} + +/** The storage line: which store, and how many instances share it (#1066). */ +export function storageSummary(data: InstancesData | null): { value: string; description?: string } { + if (!data) return { value: '-' }; + if (data.mode === 'local') return { value: 'Local files on this server' }; + const count = data.instances === null + ? 'store unreachable' + : `${data.instances} ${data.instances === 1 ? 'instance' : 'instances'} checked in during the last minute`; + return { value: `Shared store, ${count}`, description: data.location }; +} + const PAGE = adminPageDef('dashboard'); export function DashboardPage() { @@ -47,13 +63,14 @@ export function DashboardPage() { const [themeCount, setThemeCount] = useState(0); const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null); const [version, setVersion] = useState(null); + const [instances, setInstances] = useState(null); useEffect(() => { fetchDashboardData(); }, []); async function fetchDashboardData() { - const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, approvalsRes, themeRes, telemetryRes, versionRes] = await Promise.all([ + const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, approvalsRes, themeRes, telemetryRes, versionRes, instancesRes] = await Promise.all([ apiFetch('/api/admin/auth'), apiFetch('/api/admin/audit?limit=10'), apiFetch('/api/config'), @@ -63,6 +80,7 @@ export function DashboardPage() { apiFetch('/api/admin/themes').catch(() => null), apiFetch('/api/admin/telemetry').catch(() => null), apiFetch('/api/admin/version').catch(() => null), + apiFetch('/api/admin/instances').catch(() => null), ]); if (statusRes.ok) setStatus(await statusRes.json()); @@ -99,6 +117,10 @@ export function DashboardPage() { const data = await versionRes.json(); if (typeof data?.current === 'string') setVersion({ current: data.current, status: data.status ?? null }); } + if (instancesRes?.ok) { + const data = await instancesRes.json(); + if (data?.mode === 'local' || data?.mode === 'redis') setInstances(data); + } const w: string[] = []; if (adminConfigRes.ok) { @@ -118,6 +140,7 @@ export function DashboardPage() { setWarnings(w); } + const storage = storageSummary(instances); const jmapUrl = config?.jmapServerUrl || '-'; const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-'; @@ -172,6 +195,9 @@ export function DashboardPage() { {config?.appName || '-'} + + {storage.value} + {jmapHostname} diff --git a/app/api/admin/__tests__/instances-route.test.ts b/app/api/admin/__tests__/instances-route.test.ts new file mode 100644 index 000000000..fcbb93e69 --- /dev/null +++ b/app/api/admin/__tests__/instances-route.test.ts @@ -0,0 +1,67 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const auth = vi.hoisted(() => ({ ok: true })); +vi.mock('@/lib/admin/session', async () => { + const { NextResponse } = await import('next/server'); + return { + requireAdminAuth: async () => (auth.ok + ? { payload: { role: 'admin' } } + : { error: NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) }), + }; +}); + +import { GET } from '@/app/api/admin/instances/route'; +import { setStoreForTests } from '@/lib/shared-store'; +import { resetLocalStoreForTests } from '@/lib/shared-store/local'; +import { fakeSharedStore } from '@/lib/shared-store/__tests__/fake-shared'; + +const get = () => GET(new NextRequest('https://mail.example/api/admin/instances')); + +afterEach(() => { + auth.ok = true; + setStoreForTests(null); + resetLocalStoreForTests(); +}); + +describe('GET /api/admin/instances', () => { + it('says local files and one instance without a shared store', async () => { + const body = await (await get()).json(); + expect(body).toMatchObject({ mode: 'local', location: 'local files', instances: 1 }); + }); + + it('counts the instances that checked in during the last minute', async () => { + const store = fakeSharedStore(); + setStoreForTests(store); + await store.replicas.beat({ id: 'web-2', version: '1.20.0', startedAt: 1 }); + await store.replicas.beat({ id: 'web-1', version: '1.20.0', startedAt: 1 }); + await store.replicas.beat({ id: 'web-3', version: '1.20.0', startedAt: 1 }); + const body = await (await get()).json(); + expect(body.mode).toBe('redis'); + expect(body.location).toBe('redis://fake:6379/0'); + expect(body.instances).toBe(3); + expect(body.replicas.map((r: { id: string }) => r.id)).toEqual(['web-1', 'web-2', 'web-3']); + }); + + it('leaves out instances that stopped checking in', async () => { + const store = fakeSharedStore(); + setStoreForTests(store); + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date('2026-11-01T10:00:00Z')); + await store.replicas.beat({ id: 'gone', version: '1', startedAt: 1 }); + vi.setSystemTime(new Date('2026-11-01T10:02:00Z')); + await store.replicas.beat({ id: 'alive', version: '1', startedAt: 1 }); + const body = await (await get()).json(); + expect(body.instances).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + + it('is for admins only', async () => { + auth.ok = false; + expect((await get()).status).toBe(401); + }); +}); diff --git a/app/api/admin/instances/route.ts b/app/api/admin/instances/route.ts new file mode 100644 index 000000000..41df43624 --- /dev/null +++ b/app/api/admin/instances/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAdminAuth } from '@/lib/admin/session'; +import { getStore, replicaId } from '@/lib/shared-store'; + +/** Instances that checked in this recently count as running. */ +const RECENT_MS = 60_000; + +/** + * GET /api/admin/instances + * Which store this deployment uses and how many instances checked in during + * the last minute (#1066), for the line on the admin overview. The store's + * address never carries its password. + */ +export async function GET(request: NextRequest) { + const auth = await requireAdminAuth(request); + if ('error' in auth) return auth.error; + + const store = getStore(); + const { mode, location } = store.status(); + const headers = { 'Cache-Control': 'no-store' }; + if (mode === 'local') { + return NextResponse.json({ mode, location, instances: 1, replica: null }, { headers }); + } + try { + const replicas = await store.replicas.recent(RECENT_MS); + return NextResponse.json({ + mode, + location, + instances: replicas.length, + replica: replicaId(), + replicas: replicas + .map((r) => ({ id: r.id, version: r.version, startedAt: r.startedAt, lastSeenAt: r.lastSeenAt })) + .sort((a, b) => a.id.localeCompare(b.id)), + }, { headers }); + } catch { + return NextResponse.json({ mode, location, instances: null, replica: replicaId(), error: 'unavailable' }, { headers }); + } +} diff --git a/components/admin/__tests__/dashboard-storage.test.tsx b/components/admin/__tests__/dashboard-storage.test.tsx new file mode 100644 index 000000000..62d57d98b --- /dev/null +++ b/components/admin/__tests__/dashboard-storage.test.tsx @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { storageSummary } from '@/app/(main)/admin/_pages/dashboard'; + +describe('storageSummary (admin overview)', () => { + it('names local files for a single instance', () => { + expect(storageSummary({ mode: 'local', location: 'local files', instances: 1 })).toEqual({ value: 'Local files on this server' }); + }); + + it('names the shared store and how many instances checked in', () => { + expect(storageSummary({ mode: 'redis', location: 'redis://valkey:6379/0', instances: 3 })).toEqual({ + value: 'Shared store, 3 instances checked in during the last minute', + description: 'redis://valkey:6379/0', + }); + expect(storageSummary({ mode: 'redis', location: 'redis://v:6379/0', instances: 1 }).value).toBe('Shared store, 1 instance checked in during the last minute'); + }); + + it('says when the store is unreachable', () => { + expect(storageSummary({ mode: 'redis', location: 'redis://v:6379/0', instances: null }).value).toBe('Shared store, store unreachable'); + }); +}); From 9e2b59847f0e765c6c2e88c0d1811f2426f89ff2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:31:02 +0100 Subject: [PATCH 2/2] refactor: recognise a shared-store outage by name, so every bundle's module copy sees it --- lib/shared-store/__tests__/store-errors.test.ts | 14 ++++++++++++++ lib/shared-store/redis.ts | 3 ++- lib/shared-store/types.ts | 10 ++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 lib/shared-store/__tests__/store-errors.test.ts diff --git a/lib/shared-store/__tests__/store-errors.test.ts b/lib/shared-store/__tests__/store-errors.test.ts new file mode 100644 index 000000000..a957b63b6 --- /dev/null +++ b/lib/shared-store/__tests__/store-errors.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it, vi } from 'vitest'; + +describe('isStoreUnavailableError', () => { + it('recognises the error from another module copy, where instanceof does not', async () => { + const first = await import('../types'); + const thrown = new first.StoreUnavailableError('down'); + vi.resetModules(); + const second = await import('../types'); + expect(thrown instanceof second.StoreUnavailableError).toBe(false); + expect(second.isStoreUnavailableError(thrown)).toBe(true); + expect(second.isStoreUnavailableError(new Error('other'))).toBe(false); + expect(second.isStoreUnavailableError(null)).toBe(false); + }); +}); diff --git a/lib/shared-store/redis.ts b/lib/shared-store/redis.ts index ef6445124..5483f4e5d 100644 --- a/lib/shared-store/redis.ts +++ b/lib/shared-store/redis.ts @@ -5,6 +5,7 @@ import { assertDocId, assertKey, assertName, toBuffer, versionOf } from './keys' import { localCollection } from './local'; import { StoreUnavailableError, + isStoreUnavailableError, type AppendLog, type CollectionOptions, type CounterStore, @@ -130,7 +131,7 @@ type Scripted = Redis & { [K in ScriptName]: (...args: Array