mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
Merge branch 'feat/multi-instance' into feat/multi-instance-ext
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user