Merge branch 'feat/multi-instance' into feat/multi-instance-ext

This commit is contained in:
Linus Rath
2026-10-31 23:32:31 +01:00
7 changed files with 178 additions and 2 deletions
+27 -1
View File
@@ -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<VersionData | null>(null);
const [instances, setInstances] = useState<InstancesData | null>(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() {
<SettingItem label="Application">
<span className="text-sm text-foreground">{config?.appName || '-'}</span>
</SettingItem>
<SettingItem label="Storage" description={storage.description}>
<span className="text-sm text-foreground">{storage.value}</span>
</SettingItem>
<SettingItem label="JMAP server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
<span className="text-sm text-foreground">{jmapHostname}</span>
</SettingItem>
@@ -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);
});
});
+38
View File
@@ -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 });
}
}
@@ -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');
});
});
@@ -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);
});
});
+2 -1
View File
@@ -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<string | number | B
const UNAVAILABLE_REPLY = /^(NOAUTH|WRONGPASS|LOADING|READONLY|MASTERDOWN|BUSY|CLUSTERDOWN|TRYAGAIN|ERR max number of clients)/;
function unavailable(error: unknown): never {
if (error instanceof StoreUnavailableError) throw error;
if (isStoreUnavailableError(error)) throw error;
const name = (error as Error)?.name;
const message = (error as Error)?.message ?? '';
if (name === 'ReplyError' && !UNAVAILABLE_REPLY.test(message)) throw error;
+10
View File
@@ -23,6 +23,16 @@ export class StoreUnavailableError extends Error {
}
}
/**
* Whether `error` is a StoreUnavailableError. Check with this, never with
* `instanceof`: Next evaluates this module once per bundle entry, and the
* store on globalThis throws the class of whichever copy created it, so
* `instanceof` in another copy is false.
*/
export function isStoreUnavailableError(error: unknown): boolean {
return (error as Error | null)?.name === 'StoreUnavailableError';
}
/** A collection or document id that would leave its namespace. */
export class InvalidStoreKeyError extends Error {
constructor(key: string) {