feat(web): rebuild the console around the fused control plane

Render the Hub feature set in the existing Animal Island styling: fleet
messages, notification centre, log centre, organisation panel, per-instance
device module panels and the settings backup, connection and maintenance
surfaces.
This commit is contained in:
chick
2026-09-05 18:53:04 +08:00
parent f9186bd851
commit a75badeb58
77 changed files with 20578 additions and 435 deletions
@@ -0,0 +1,114 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest';
import { createComponentBackupApiDataSource } from './component-backup-api-data-source.js';
const catalog = {
items: [
{
key: 'devices',
label: '设备与分组',
description: '节点地址、分组、标签与能力快照。',
rows: 7,
},
],
};
const backup = {
filename: 'multi-simadmin-components-2026-09-04T03-30-00-000Z.json',
createdAt: '2026-09-04T03:30:00.000Z',
sizeBytes: 4096,
appVersion: '0.1.0',
note: '升级前',
automatic: true,
integrity: 'ok',
compatible: true,
components: [{ key: 'devices', label: '设备与分组', description: '', rows: 7 }],
};
const settings = {
enabled: true,
components: ['devices'],
timeOfDay: '03:30',
weekday: -1,
maximumCount: 14,
lastRunAt: null,
};
const json = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), { status });
describe('component backup API data source', () => {
it('reads the catalog, list and schedule with same-origin controls', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(json(catalog))
.mockResolvedValueOnce(json({ items: [backup] }))
.mockResolvedValueOnce(json(settings));
const source = createComponentBackupApiDataSource(fetcher);
const signal = new AbortController().signal;
await expect(source.catalog(signal)).resolves.toEqual(catalog.items);
await expect(source.list(signal)).resolves.toEqual([backup]);
await expect(source.autoSettings(signal)).resolves.toEqual(settings);
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/v1/system/component-backups/catalog');
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/v1/system/component-backups');
expect(fetcher.mock.calls[2]?.[0]).toBe('/api/v1/system/component-backups/auto/settings');
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ credentials: 'same-origin', signal });
});
it('posts create, restore and delete against the encoded filename', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(json(backup, 201))
.mockResolvedValueOnce(json({ devices: 7 }))
.mockResolvedValueOnce(json({ filename: backup.filename }));
const source = createComponentBackupApiDataSource(fetcher);
await expect(source.create(['devices'], '升级前')).resolves.toEqual(backup);
await expect(source.restore(backup.filename, ['devices'])).resolves.toBeUndefined();
await expect(source.remove(backup.filename)).resolves.toBeUndefined();
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({
method: 'POST',
body: JSON.stringify({ components: ['devices'], note: '升级前' }),
});
expect(fetcher.mock.calls[1]?.[0]).toBe(
`/api/v1/system/component-backups/${encodeURIComponent(backup.filename)}/restore`,
);
expect(fetcher.mock.calls[2]?.[1]).toMatchObject({ method: 'DELETE' });
});
it('saves the schedule and rejects an unknown component key', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json(settings));
const source = createComponentBackupApiDataSource(fetcher);
await expect(
source.saveAutoSettings({
enabled: true,
components: ['devices'],
timeOfDay: '03:30',
weekday: -1,
maximumCount: 14,
}),
).resolves.toEqual(settings);
await expect(source.create(['secretReferences' as never], '')).rejects.toThrowError(
'Component backup response is invalid.',
);
});
it('refuses a payload whose integrity flag is not understood', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValue(json({ items: [{ ...backup, integrity: 'maybe' }] }));
await expect(createComponentBackupApiDataSource(fetcher).list()).rejects.toThrowError(
'Component backup response is invalid.',
);
});
it('refuses a filename that could escape the backup directory', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json(backup));
await expect(
createComponentBackupApiDataSource(fetcher).remove('../secret.json'),
).rejects.toThrowError('Component backup response is invalid.');
});
});
@@ -0,0 +1,263 @@
export type BackupComponentKey =
| 'devices'
| 'notifications'
| 'notificationRecords'
| 'automation'
| 'automationRecords'
| 'sms'
| 'settings'
| 'audit';
export type BackupComponent = Readonly<{
key: BackupComponentKey;
label: string;
description: string;
rows: number;
}>;
export type ComponentBackup = Readonly<{
filename: string;
createdAt: string;
sizeBytes: number;
appVersion: string;
note: string;
automatic: boolean;
integrity: 'ok' | 'failed';
compatible: boolean;
components: readonly BackupComponent[];
}>;
export type AutoBackupSettings = Readonly<{
enabled: boolean;
components: readonly BackupComponentKey[];
timeOfDay: string;
weekday: number;
maximumCount: number;
lastRunAt: string | null;
}>;
export interface ComponentBackupDataSource {
catalog(signal?: AbortSignal): Promise<readonly BackupComponent[]>;
list(signal?: AbortSignal): Promise<readonly ComponentBackup[]>;
create(components: readonly BackupComponentKey[], note: string): Promise<ComponentBackup>;
restore(filename: string, components: readonly BackupComponentKey[]): Promise<void>;
remove(filename: string): Promise<void>;
autoSettings(signal?: AbortSignal): Promise<AutoBackupSettings>;
saveAutoSettings(
settings: Readonly<{
enabled: boolean;
components: readonly BackupComponentKey[];
timeOfDay: string;
weekday: number;
maximumCount: number;
}>,
): Promise<AutoBackupSettings>;
}
const BASE = '/api/v1/system/component-backups';
const MAX_TEXT = 200;
const MAX_BACKUPS = 200;
const BACKUP_FILENAME = /^multi-simadmin-components-(?:auto-)?[A-Za-z0-9._-]{1,120}\.json$/u;
const KEYS: readonly BackupComponentKey[] = [
'devices',
'notifications',
'notificationRecords',
'automation',
'automationRecords',
'sms',
'settings',
'audit',
];
const record = (value: unknown): Record<string, unknown> | undefined =>
typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
const text = (value: unknown): value is string =>
typeof value === 'string' && value.length <= MAX_TEXT;
const shortText = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value.length <= MAX_TEXT;
const integer = (value: unknown, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum && value <= maximum;
const invalid = () => new Error('Component backup response is invalid.');
function safeName(filename: string): string {
if (!BACKUP_FILENAME.test(filename)) throw invalid();
return filename;
}
function parseComponentList(value: unknown): BackupComponent[] {
if (!Array.isArray(value)) throw invalid();
return value.map((candidate) => {
const item = record(candidate);
if (
!item ||
!KEYS.includes(item.key as BackupComponentKey) ||
!shortText(item.label) ||
!text(item.description) ||
!integer(item.rows)
)
throw invalid();
return {
key: item.key as BackupComponentKey,
label: item.label,
description: item.description,
rows: item.rows,
};
});
}
function parseBackup(value: unknown): ComponentBackup {
const source = record(value);
const createdAt = source?.createdAt;
if (
!source ||
!shortText(source.filename) ||
!BACKUP_FILENAME.test(source.filename) ||
!text(source.appVersion) ||
!text(source.note) ||
typeof source.automatic !== 'boolean' ||
(source.integrity !== 'ok' && source.integrity !== 'failed') ||
typeof source.compatible !== 'boolean' ||
!integer(source.sizeBytes, 1) ||
typeof createdAt !== 'string' ||
Number.isNaN(Date.parse(createdAt))
)
throw invalid();
return {
filename: source.filename,
createdAt: new Date(createdAt).toISOString(),
sizeBytes: source.sizeBytes,
appVersion: source.appVersion,
note: source.note,
automatic: source.automatic,
integrity: source.integrity,
compatible: source.compatible,
components: parseComponentList(source.components),
};
}
function parseAutoSettings(value: unknown): AutoBackupSettings {
const source = record(value);
const components = Array.isArray(source?.components) ? source.components : undefined;
if (
!source ||
typeof source.enabled !== 'boolean' ||
!components ||
!components.every((key) => KEYS.includes(key as BackupComponentKey)) ||
!shortText(source.timeOfDay) ||
!/^([01]\d|2[0-3]):[0-5]\d$/u.test(source.timeOfDay) ||
!integer(source.weekday, -1, 6) ||
!integer(source.maximumCount, 1, 500) ||
!(source.lastRunAt === null || typeof source.lastRunAt === 'string')
)
throw invalid();
return {
enabled: source.enabled,
components: components as BackupComponentKey[],
timeOfDay: source.timeOfDay,
weekday: source.weekday,
maximumCount: source.maximumCount,
lastRunAt: (source.lastRunAt as string | null) ?? null,
};
}
async function readJson(response: Response): Promise<unknown> {
try {
return await response.json();
} catch {
return undefined;
}
}
function requireOk(response: Response, body: unknown): unknown {
if (!response.ok || body === undefined || body === null)
throw new Error('Component backup request failed.');
return body;
}
const readHeaders = { accept: 'application/json' } as const;
const jsonHeaders = { accept: 'application/json', 'content-type': 'application/json' } as const;
export function createComponentBackupApiDataSource(
fetcher: typeof fetch = fetch,
): ComponentBackupDataSource {
const read = async (path: string, signal?: AbortSignal) => {
const response = await fetcher(path, {
method: 'GET',
credentials: 'same-origin',
headers: readHeaders,
...(signal ? { signal } : {}),
});
return requireOk(response, await readJson(response));
};
return {
async catalog(signal) {
const source = record(await read(`${BASE}/catalog`, signal));
if (!source || !Array.isArray(source.items)) throw invalid();
return parseComponentList(source.items);
},
async list(signal) {
const source = record(await read(BASE, signal));
if (!source || !Array.isArray(source.items) || source.items.length > MAX_BACKUPS)
throw invalid();
return source.items.map(parseBackup);
},
async create(components, note) {
if (
components.length === 0 ||
components.some((key) => !KEYS.includes(key)) ||
note.length > 120
)
throw invalid();
const response = await fetcher(BASE, {
method: 'POST',
credentials: 'same-origin',
headers: jsonHeaders,
body: JSON.stringify({ components, note }),
});
return parseBackup(requireOk(response, await readJson(response)));
},
async restore(filename, components) {
if (components.length === 0 || components.some((key) => !KEYS.includes(key))) throw invalid();
const response = await fetcher(`${BASE}/${encodeURIComponent(safeName(filename))}/restore`, {
method: 'POST',
credentials: 'same-origin',
headers: jsonHeaders,
body: JSON.stringify({ components }),
});
requireOk(response, await readJson(response));
},
async remove(filename) {
const response = await fetcher(`${BASE}/${encodeURIComponent(safeName(filename))}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: readHeaders,
});
requireOk(response, await readJson(response));
},
async autoSettings(signal) {
return parseAutoSettings(await read(`${BASE}/auto/settings`, signal));
},
async saveAutoSettings(settings) {
if (
settings.components.length === 0 ||
settings.components.some((key) => !KEYS.includes(key)) ||
!/^([01]\d|2[0-3]):[0-5]\d$/u.test(settings.timeOfDay) ||
settings.weekday < -1 ||
settings.weekday > 6 ||
settings.maximumCount < 1 ||
settings.maximumCount > 500
)
throw invalid();
const response = await fetcher(`${BASE}/auto/settings`, {
method: 'PUT',
credentials: 'same-origin',
headers: jsonHeaders,
body: JSON.stringify(settings),
});
return parseAutoSettings(requireOk(response, await readJson(response)));
},
};
}
@@ -0,0 +1,171 @@
// @vitest-environment jsdom
import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
ComponentBackupPanel,
type AutoBackupSettings,
type BackupComponent,
type ComponentBackup,
type ComponentBackupDataSource,
} from './component-backup-panel.js';
afterEach(cleanup);
const devices: BackupComponent = {
key: 'devices',
label: '设备与分组',
description: '节点地址、分组、标签与能力快照。',
rows: 7,
};
const sms: BackupComponent = {
key: 'sms',
label: '短信记录',
description: '集中保存的跨设备短信正文。',
rows: 120,
};
const catalog: readonly BackupComponent[] = [devices, sms];
const archive: ComponentBackup = {
filename: 'multi-simadmin-components-2026-09-04T03-30-00-000Z.json',
createdAt: '2026-09-04T03:30:00.000Z',
sizeBytes: 4096,
appVersion: '0.1.0',
note: '升级前快照',
automatic: false,
integrity: 'ok',
compatible: true,
components: [devices, sms],
};
const schedule: AutoBackupSettings = {
enabled: false,
components: ['devices'],
timeOfDay: '03:30',
weekday: -1,
maximumCount: 14,
lastRunAt: null,
};
function source(overrides: Partial<ComponentBackupDataSource> = {}): ComponentBackupDataSource {
return {
catalog: vi.fn(async () => catalog),
list: vi.fn(async () => [archive]),
create: vi.fn(async () => archive),
restore: vi.fn(async () => undefined),
remove: vi.fn(async () => undefined),
autoSettings: vi.fn(async () => schedule),
saveAutoSettings: vi.fn(async (value) => ({ ...schedule, ...value, lastRunAt: null })),
...overrides,
};
}
async function mount(dataSource: ComponentBackupDataSource): Promise<HTMLElement> {
render(<ComponentBackupPanel dataSource={dataSource} />);
return screen.findByRole('region', { name: '组件备份' });
}
async function picker(region: HTMLElement): Promise<HTMLElement> {
const group = within(region).getByRole('group', { name: '备份组件' });
await vi.waitFor(() => {
expect(within(group).getAllByRole('checkbox')).toHaveLength(catalog.length);
});
return group;
}
describe('ComponentBackupPanel', () => {
it('renders the catalog, the archive table and the schedule', async () => {
const region = await mount(source());
const group = await picker(region);
expect(within(group).getByText('短信记录')).toBeTruthy();
expect(within(group).getByText('120 条')).toBeTruthy();
expect(within(region).getByText('手动')).toBeTruthy();
expect(within(region).getByText('正常')).toBeTruthy();
expect(within(region).getByText(/上次执行:尚未执行/)).toBeTruthy();
});
it('creates a backup from the components the operator selects', async () => {
const dataSource = source();
const completed = vi.fn();
render(<ComponentBackupPanel dataSource={dataSource} onCompleted={completed} />);
const region = await screen.findByRole('region', { name: '组件备份' });
const group = await picker(region);
const create = within(region).getByRole('button', { name: '创建组件备份' });
expect((create as HTMLButtonElement).disabled).toBe(false);
await userEvent.click(within(group).getByRole('checkbox', { name: /短信记录/ }));
await userEvent.type(within(region).getByLabelText('备份备注'), '升级前快照');
await userEvent.click(create);
expect(dataSource.create).toHaveBeenCalledWith(
expect.arrayContaining(['devices', 'notifications', 'automation', 'sms']),
'升级前快照',
);
expect(completed).toHaveBeenCalledWith(`已创建组件备份 ${archive.filename}。`);
expect((within(region).getByLabelText('备份备注') as HTMLInputElement).value).toBe('');
});
it('restores only the components that stay checked', async () => {
const dataSource = source();
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
const region = await mount(dataSource);
await userEvent.click(within(region).getByRole('button', { name: '恢复' }));
const panel = await screen.findByRole('group', { name: '选择恢复组件' });
await userEvent.click(within(panel).getByRole('checkbox', { name: /短信记录/ }));
await userEvent.click(within(panel).getByRole('button', { name: '确认恢复' }));
expect(dataSource.restore).toHaveBeenCalledWith(archive.filename, ['devices']);
expect(confirm).toHaveBeenCalledTimes(1);
confirm.mockRestore();
});
it('does not restore when the operator cancels the confirmation', async () => {
const dataSource = source();
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
const region = await mount(dataSource);
await userEvent.click(within(region).getByRole('button', { name: '恢复' }));
const panel = await screen.findByRole('group', { name: '选择恢复组件' });
await userEvent.click(within(panel).getByRole('button', { name: '确认恢复' }));
expect(dataSource.restore).not.toHaveBeenCalled();
expect(within(region).getByRole('group', { name: '选择恢复组件' })).toBeTruthy();
confirm.mockRestore();
});
it('blocks restore when the archive failed its integrity check', async () => {
const broken: ComponentBackup = { ...archive, integrity: 'failed', compatible: false };
const region = await mount(source({ list: vi.fn(async () => [broken]) }));
const restore = within(region).getByRole('button', { name: '恢复' });
expect((restore as HTMLButtonElement).disabled).toBe(true);
expect(within(region).getByText('校验失败')).toBeTruthy();
});
it('saves the automatic backup plan', async () => {
const dataSource = source();
const completed = vi.fn();
render(<ComponentBackupPanel dataSource={dataSource} onCompleted={completed} />);
const region = await screen.findByRole('region', { name: '组件备份' });
await picker(region);
const automatic = within(region).getByRole('group', { name: '自动备份组件' });
expect(within(automatic).getByRole('checkbox', { name: /设备与分组/ })).toBeTruthy();
await userEvent.click(within(region).getByRole('checkbox', { name: /启用自动备份/ }));
await userEvent.click(within(region).getByRole('button', { name: '保存备份计划' }));
expect(dataSource.saveAutoSettings).toHaveBeenCalledWith(
expect.objectContaining({ enabled: true, timeOfDay: '03:30', maximumCount: 14 }),
);
expect(completed).toHaveBeenCalledWith('定时组件备份计划已保存。');
});
it('reports a load failure and recovers on retry', async () => {
const catalogLoader = vi
.fn<ComponentBackupDataSource['catalog']>()
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValue(catalog);
const dataSource = source({ catalog: catalogLoader });
render(<ComponentBackupPanel dataSource={dataSource} />);
expect(await screen.findByText('无法加载组件备份状态。')).toBeTruthy();
await userEvent.click(screen.getByRole('button', { name: '重试加载' }));
const region = await screen.findByRole('region', { name: '组件备份' });
await picker(region);
expect(catalogLoader).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,479 @@
import { useCallback, useEffect, useState } from 'react';
import { Button, Card, Tag } from 'animal-island-ui';
import {
createComponentBackupApiDataSource,
type AutoBackupSettings,
type BackupComponent,
type BackupComponentKey,
type ComponentBackup,
type ComponentBackupDataSource,
} from './component-backup-api-data-source.js';
export type {
AutoBackupSettings,
BackupComponent,
BackupComponentKey,
ComponentBackup,
ComponentBackupDataSource,
} from './component-backup-api-data-source.js';
const WEEKDAYS: readonly { value: number; label: string }[] = [
{ value: -1, label: '每天' },
{ value: 1, label: '每周一' },
{ value: 2, label: '每周二' },
{ value: 3, label: '每周三' },
{ value: 4, label: '每周四' },
{ value: 5, label: '每周五' },
{ value: 6, label: '每周六' },
{ value: 0, label: '每周日' },
];
function formatBytes(value: number): string {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(value < 10 * 1024 ? 1 : 0)} KB`;
if (value < 1024 * 1024 * 1024)
return `${(value / (1024 * 1024)).toFixed(value < 10 * 1024 * 1024 ? 1 : 0)} MB`;
return `${(value / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function formatDateTime(value: string): string {
return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(
new Date(value),
);
}
type Busy = 'create' | 'restore' | 'delete' | 'schedule' | undefined;
/** Component-scoped export, merge and scheduled backup, mirroring the Hub backup centre. */
export function ComponentBackupPanel({
dataSource,
onCompleted,
}: {
readonly dataSource?: ComponentBackupDataSource;
readonly onCompleted?: (message: string) => void;
}) {
const [source] = useState(() => dataSource ?? createComponentBackupApiDataSource());
const [catalog, setCatalog] = useState<readonly BackupComponent[]>([]);
const [backups, setBackups] = useState<readonly ComponentBackup[]>([]);
const [schedule, setSchedule] = useState<AutoBackupSettings>();
const [selected, setSelected] = useState<ReadonlySet<BackupComponentKey>>(
() => new Set<BackupComponentKey>(['devices', 'notifications', 'automation']),
);
const [note, setNote] = useState('');
const [restoreTarget, setRestoreTarget] = useState<ComponentBackup>();
const [restoreSelection, setRestoreSelection] = useState<ReadonlySet<BackupComponentKey>>(
() => new Set<BackupComponentKey>(),
);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [busy, setBusy] = useState<Busy>();
const [error, setError] = useState('');
const [attempt, setAttempt] = useState(0);
useEffect(() => {
const controller = new AbortController();
let active = true;
setLoading(true);
setFailed(false);
void Promise.all([
source.catalog(controller.signal),
source.list(controller.signal),
source.autoSettings(controller.signal),
]).then(
([nextCatalog, nextBackups, nextSchedule]) => {
if (!active) return;
setCatalog(nextCatalog);
setBackups(nextBackups);
setSchedule(nextSchedule);
setLoading(false);
},
() => {
if (!active || controller.signal.aborted) return;
setFailed(true);
setLoading(false);
},
);
return () => {
active = false;
controller.abort();
};
}, [attempt, source]);
const reload = useCallback(async () => {
const [nextCatalog, nextBackups, nextSchedule] = await Promise.all([
source.catalog(),
source.list(),
source.autoSettings(),
]);
setCatalog(nextCatalog);
setBackups(nextBackups);
setSchedule(nextSchedule);
}, [source]);
const run = async (action: Exclude<Busy, undefined>, work: () => Promise<string>) => {
if (busy) return;
setBusy(action);
setError('');
try {
const message = await work();
if (message) onCompleted?.(message);
} catch {
setError('组件备份操作未完成,请稍后重试。');
} finally {
setBusy(undefined);
}
};
const create = () =>
run('create', async () => {
const components = [...selected];
if (components.length === 0) return '';
const created = await source.create(components, note.trim());
setNote('');
await reload();
return `已创建组件备份 ${created.filename}。`;
});
const openRestore = (backup: ComponentBackup) => {
setError('');
setRestoreTarget(backup);
setRestoreSelection(new Set(backup.components.map((component) => component.key)));
};
const confirmRestore = () =>
run('restore', async () => {
if (!restoreTarget) return '';
const components = [...restoreSelection];
if (components.length === 0) return '';
const labels = restoreTarget.components
.filter((component) => components.includes(component.key))
.map((component) => component.label)
.join('、');
if (
!window.confirm(
`确认从 ${restoreTarget.filename} 恢复${labels}?所选组件将合并到当前控制台,其他数据保持不变。`,
)
)
return '';
await source.restore(restoreTarget.filename, components);
setRestoreTarget(undefined);
await reload();
return `${labels}已合并恢复。`;
});
const remove = (backup: ComponentBackup) =>
run('delete', async () => {
if (!window.confirm(`确认删除组件备份 ${backup.filename}?此操作不可恢复。`)) return '';
await source.remove(backup.filename);
setBackups((current) => current.filter((item) => item.filename !== backup.filename));
return `已删除组件备份 ${backup.filename}。`;
});
const saveSchedule = () =>
run('schedule', async () => {
if (!schedule) return '';
const saved = await source.saveAutoSettings({
enabled: schedule.enabled,
components: schedule.components,
timeOfDay: schedule.timeOfDay,
weekday: schedule.weekday,
maximumCount: schedule.maximumCount,
});
setSchedule(saved);
return saved.enabled ? '定时组件备份计划已保存。' : '定时组件备份已关闭。';
});
const patchSchedule = (patch: Partial<AutoBackupSettings>) =>
setSchedule((current) => (current ? { ...current, ...patch } : current));
const choices = (
checked: ReadonlySet<BackupComponentKey>,
onToggle: (key: BackupComponentKey, next: boolean) => void,
) =>
catalog.map((component) => (
<label key={component.key} className="backup-component-choice">
<input
type="checkbox"
checked={checked.has(component.key)}
disabled={busy !== undefined}
onChange={(event) => onToggle(component.key, event.target.checked)}
/>
<span className="backup-component-text">
<strong>
{component.label}
<em>{component.rows.toLocaleString('zh-CN')} 条</em>
</strong>
<small>{component.description}</small>
</span>
</label>
));
return (
<Card pattern="default" className="settings-card maintenance-card">
<section aria-label="组件备份">
<h2>组件备份</h2>
<p className="maintenance-hint">按组件导出与合并控制台数据,密钥材料不会被写入备份文件。</p>
{loading ? (
<p role="status" aria-label="组件备份加载状态">
正在读取组件备份状态…
</p>
) : null}
{failed ? (
<div role="alert">
<p>无法加载组件备份状态。</p>
<Button
htmlType="button"
onClick={() => {
setAttempt((value) => value + 1);
}}
>
重试加载
</Button>
</div>
) : null}
{!loading && !failed ? (
<>
<fieldset className="backup-component-picker" aria-label="备份组件">
<legend>备份组件</legend>
{choices(selected, (key, next) =>
setSelected((current) => {
const updated = new Set(current);
if (next) updated.add(key);
else updated.delete(key);
return updated;
}),
)}
</fieldset>
<div className="backup-create-row">
<label className="visually-hidden" htmlFor="component-backup-note">
备份备注
</label>
<input
id="component-backup-note"
type="text"
maxLength={120}
placeholder="备注(可选)"
value={note}
disabled={busy !== undefined}
onChange={(event) => setNote(event.target.value)}
/>
<Button
htmlType="button"
type="primary"
loading={busy === 'create'}
disabled={busy !== undefined || selected.size === 0}
onClick={() => void create()}
>
创建组件备份
</Button>
</div>
{backups.length === 0 ? (
<p className="maintenance-hint">尚无组件备份,先创建第一份。</p>
) : (
<div className="table-scroll">
<table className="dense-table" aria-label="组件备份列表">
<thead>
<tr>
<th>创建时间</th>
<th>组件</th>
<th>大小</th>
<th>来源</th>
<th>完整性</th>
<th aria-label="操作" />
</tr>
</thead>
<tbody>
{backups.map((backup) => (
<tr key={backup.filename}>
<td>{formatDateTime(backup.createdAt)}</td>
<td>
<span
className="backup-components"
title={`${backup.note ? `${backup.note} · ` : ''}${backup.filename}`}
>
{backup.components.map((component) => component.label).join('、') ||
'空'}
</span>
</td>
<td>{formatBytes(backup.sizeBytes)}</td>
<td>{backup.automatic ? '定时' : '手动'}</td>
<td>
{backup.integrity === 'ok' ? (
<Tag size="small" color="app-green" variant="soft">
{backup.compatible ? '正常' : '版本不兼容'}
</Tag>
) : (
<Tag size="small" color="app-red" variant="soft">
校验失败
</Tag>
)}
</td>
<td className="maintenance-backup-actions">
<Button
htmlType="button"
disabled={
busy !== undefined || backup.integrity !== 'ok' || !backup.compatible
}
onClick={() => openRestore(backup)}
>
恢复
</Button>
<Button
htmlType="button"
danger
disabled={busy !== undefined}
onClick={() => void remove(backup)}
>
删除
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{restoreTarget ? (
<div className="backup-restore-panel" role="group" aria-label="选择恢复组件">
<h3>从 {restoreTarget.filename} 恢复</h3>
<p className="maintenance-hint">
所选组件将合并到当前控制台;未选择的组件和现有其他数据保持不变。恢复设备组件后需要重新输入节点密码。
</p>
{restoreTarget.components.map((component) => (
<label key={component.key} className="cleanup-choice">
<input
type="checkbox"
checked={restoreSelection.has(component.key)}
disabled={busy !== undefined}
onChange={(event) =>
setRestoreSelection((current) => {
const next = new Set(current);
if (event.target.checked) next.add(component.key);
else next.delete(component.key);
return next;
})
}
/>
<span>
{component.label} · {component.rows.toLocaleString('zh-CN')} 条
</span>
</label>
))}
<div className="action-buttons">
<Button
htmlType="button"
danger
loading={busy === 'restore'}
disabled={busy !== undefined || restoreSelection.size === 0}
onClick={() => void confirmRestore()}
>
确认恢复
</Button>
<Button
htmlType="button"
disabled={busy !== undefined}
onClick={() => setRestoreTarget(undefined)}
>
取消
</Button>
</div>
</div>
) : null}
{schedule ? (
<section className="backup-schedule" aria-label="定时组件备份">
<h3>定时备份</h3>
<label className="toggle-row">
<span>
<strong>启用自动备份</strong>
<small>按计划创建组件备份并自动清理超出的历史文件</small>
</span>
<input
type="checkbox"
checked={schedule.enabled}
disabled={busy !== undefined}
onChange={(event) => patchSchedule({ enabled: event.target.checked })}
/>
</label>
<div className="backup-schedule-grid">
<label className="visually-hidden" htmlFor="backup-schedule-time">
执行时间
</label>
<input
id="backup-schedule-time"
type="time"
value={schedule.timeOfDay}
disabled={busy !== undefined}
onChange={(event) => patchSchedule({ timeOfDay: event.target.value })}
/>
<label className="visually-hidden" htmlFor="backup-schedule-weekday">
执行星期
</label>
<select
id="backup-schedule-weekday"
value={schedule.weekday}
disabled={busy !== undefined}
onChange={(event) => patchSchedule({ weekday: Number(event.target.value) })}
>
{WEEKDAYS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<label className="visually-hidden" htmlFor="backup-schedule-count">
最多保留备份数
</label>
<input
id="backup-schedule-count"
type="number"
min={1}
max={500}
value={schedule.maximumCount}
disabled={busy !== undefined}
onChange={(event) =>
patchSchedule({ maximumCount: Number(event.target.value) })
}
/>
</div>
<fieldset className="backup-component-picker" aria-label="自动备份组件">
<legend>自动备份组件</legend>
{choices(new Set(schedule.components), (key, next) =>
patchSchedule({
components: next
? [...schedule.components, key]
: schedule.components.filter((item) => item !== key),
}),
)}
</fieldset>
<p className="maintenance-hint">
上次执行:{schedule.lastRunAt ? formatDateTime(schedule.lastRunAt) : '尚未执行'}
</p>
<Button
htmlType="button"
loading={busy === 'schedule'}
disabled={busy !== undefined}
onClick={() => void saveSchedule()}
>
保存备份计划
</Button>
</section>
) : null}
</>
) : null}
{error ? (
<p role="alert" className="maintenance-error">
{error}
</p>
) : null}
</section>
</Card>
);
}
@@ -0,0 +1,119 @@
// @vitest-environment jsdom
import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ConnectionSettingsPanel } from './connection-settings-panel.js';
import type { SystemMaintenanceDataSource } from './system-maintenance-api-data-source.js';
afterEach(cleanup);
const defaults = { heartbeatSeconds: 30, offlineSeconds: 90 };
function source(overrides: Partial<SystemMaintenanceDataSource> = {}): SystemMaintenanceDataSource {
return {
connection: vi.fn(async () => defaults),
updateConnection: vi.fn(async (settings) => settings),
refreshHeartbeat: vi.fn(async () => ({
probed: 7,
failed: 2,
startedAt: '2026-09-05T00:00:00.000Z',
finishedAt: '2026-09-05T00:00:01.000Z',
})),
...overrides,
} as unknown as SystemMaintenanceDataSource;
}
async function mount(overrides: Partial<SystemMaintenanceDataSource> = {}) {
const data = source(overrides);
const view = render(<ConnectionSettingsPanel dataSource={data} />);
const region = await screen.findByRole('region', { name: '连接设置' });
return { ...view, data, region };
}
function field(region: HTMLElement, label: string): HTMLInputElement {
const node = within(region).getByLabelText(label) as HTMLInputElement;
return node;
}
describe('ConnectionSettingsPanel', () => {
it('loads the saved cadence into the inputs', async () => {
const { data, region } = await mount();
expect(data.connection).toHaveBeenCalledTimes(1);
expect(field(region, '心跳间隔(秒)').value).toBe('30');
expect(field(region, '离线判定(秒)').value).toBe('90');
});
it('keeps save disabled until a valid change is made', async () => {
const { region } = await mount();
const save = within(region).getByRole('button', { name: '保存设置' }) as HTMLButtonElement;
expect(save.disabled).toBe(true);
await userEvent.clear(field(region, '心跳间隔(秒)'));
await userEvent.type(field(region, '心跳间隔(秒)'), '4');
expect(save.disabled).toBe(true);
await userEvent.clear(field(region, '心跳间隔(秒)'));
await userEvent.type(field(region, '心跳间隔(秒)'), '45');
expect(save.disabled).toBe(false);
});
it('sends whole numbers and echoes the saved window', async () => {
const { data, region } = await mount();
await userEvent.clear(field(region, '心跳间隔(秒)'));
await userEvent.type(field(region, '心跳间隔(秒)'), '45');
await userEvent.clear(field(region, '离线判定(秒)'));
await userEvent.type(field(region, '离线判定(秒)'), '180');
await userEvent.click(within(region).getByRole('button', { name: '保存设置' }));
expect(data.updateConnection).toHaveBeenCalledWith({
heartbeatSeconds: 45,
offlineSeconds: 180,
});
const notice = await within(region).findByRole('status');
expect(notice.textContent).toContain('连接设置已保存,心跳 45 秒,离线判定 180 秒。');
});
it('blocks a save when the offline window drops below twice the heartbeat', async () => {
const { data, region } = await mount();
await userEvent.clear(field(region, '离线判定(秒)'));
await userEvent.type(field(region, '离线判定(秒)'), '59');
const save = within(region).getByRole('button', { name: '保存设置' }) as HTMLButtonElement;
expect(save.disabled).toBe(true);
expect(data.updateConnection).not.toHaveBeenCalled();
});
it('runs an on-demand beat and reports the offline devices', async () => {
const { data, region } = await mount();
await userEvent.click(within(region).getByRole('button', { name: '立即刷新设备状态' }));
expect(data.refreshHeartbeat).toHaveBeenCalledTimes(1);
const notice = await within(region).findByRole('status');
expect(notice.textContent).toContain('已检查 9 台设备,2 台无响应。');
});
it('surfaces a failed save without losing the form', async () => {
const { region } = await mount({
updateConnection: vi.fn(async () => {
throw new Error('offline');
}),
});
await userEvent.clear(field(region, '心跳间隔(秒)'));
await userEvent.type(field(region, '心跳间隔(秒)'), '60');
await userEvent.clear(field(region, '离线判定(秒)'));
await userEvent.type(field(region, '离线判定(秒)'), '150');
await userEvent.click(within(region).getByRole('button', { name: '保存设置' }));
const problem = await within(region).findByRole('alert');
expect(problem.textContent).toContain('连接设置未保存,请检查心跳与离线判定的取值范围。');
expect(field(region, '心跳间隔(秒)').value).toBe('60');
});
it('explains itself when the cadence cannot be read', async () => {
const { region } = await mount({
connection: vi.fn(async () => {
throw new Error('offline');
}),
});
const problem = await within(region).findByRole('alert');
expect(problem.textContent).toContain('无法读取连接设置,请刷新页面重试。');
});
});
@@ -0,0 +1,179 @@
import { useEffect, useState } from 'react';
import { Button, Card } from 'animal-island-ui';
import {
MAX_HEARTBEAT_SECONDS,
MAX_OFFLINE_SECONDS,
MIN_HEARTBEAT_SECONDS,
createSystemMaintenanceApiDataSource,
type ConnectionSettings,
type SystemMaintenanceDataSource,
} from './system-maintenance-api-data-source.js';
export type { ConnectionSettings } from './system-maintenance-api-data-source.js';
function clampSeconds(value: string, minimum: number, maximum: number): number | undefined {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) return undefined;
return parsed;
}
/**
* Heartbeat cadence and the offline window, the Hub connection panel rebuilt on the control
* plane's own snapshot journal. Changes apply to the running beat without a restart.
*/
export function ConnectionSettingsPanel({
dataSource,
}: {
readonly dataSource?: SystemMaintenanceDataSource;
}) {
const [source] = useState(() => dataSource ?? createSystemMaintenanceApiDataSource());
const [settings, setSettings] = useState<ConnectionSettings>();
const [heartbeat, setHeartbeat] = useState('');
const [offline, setOffline] = useState('');
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [busy, setBusy] = useState<'save' | 'refresh' | undefined>();
useEffect(() => {
const controller = new AbortController();
let active = true;
void source
.connection(controller.signal)
.then((value) => {
if (!active) return;
setSettings(value);
setHeartbeat(String(value.heartbeatSeconds));
setOffline(String(value.offlineSeconds));
})
.catch(() => active && setError('无法读取连接设置,请刷新页面重试。'));
return () => {
active = false;
controller.abort();
};
}, [source]);
const heartbeatSeconds = clampSeconds(heartbeat, MIN_HEARTBEAT_SECONDS, MAX_HEARTBEAT_SECONDS);
const offlineSeconds = clampSeconds(offline, (heartbeatSeconds ?? 0) * 2, MAX_OFFLINE_SECONDS);
const dirty =
settings !== undefined &&
(heartbeatSeconds !== settings.heartbeatSeconds || offlineSeconds !== settings.offlineSeconds);
const run = async (action: 'save' | 'refresh', work: () => Promise<string>) => {
if (busy) return;
setBusy(action);
setError('');
setNotice('');
try {
setNotice(await work());
} catch {
setError(
action === 'save'
? '连接设置未保存,请检查心跳与离线判定的取值范围。'
: '设备刷新未完成,请稍后重试。',
);
} finally {
setBusy(undefined);
}
};
return (
<Card pattern="default" className="settings-card connection-card">
<section aria-label="连接设置">
<div className="connection-head">
<div>
<h2>连接设置</h2>
<p className="maintenance-hint">
心跳决定管理台多久检查一次设备,离线判定决定多久没有响应才标记为离线。
</p>
</div>
<Button
htmlType="button"
loading={busy === 'refresh'}
disabled={busy !== undefined}
onClick={() =>
void run('refresh', async () => {
const summary = await source.refreshHeartbeat();
return `已检查 ${summary.probed + summary.failed} 台设备,${summary.failed} 台无响应。`;
})
}
>
立即刷新设备状态
</Button>
</div>
<div className="connection-grid">
<div className="connection-field">
<label htmlFor="connection-heartbeat">心跳间隔(秒)</label>
<input
id="connection-heartbeat"
type="number"
min={MIN_HEARTBEAT_SECONDS}
max={MAX_HEARTBEAT_SECONDS}
step={1}
value={heartbeat}
onChange={(event) => setHeartbeat(event.target.value)}
/>
<span className="maintenance-hint">
{MIN_HEARTBEAT_SECONDS} – {MAX_HEARTBEAT_SECONDS} 秒
</span>
</div>
<div className="connection-field">
<label htmlFor="connection-offline">离线判定(秒)</label>
<input
id="connection-offline"
type="number"
min={(heartbeatSeconds ?? MIN_HEARTBEAT_SECONDS) * 2}
max={MAX_OFFLINE_SECONDS}
step={1}
value={offline}
onChange={(event) => setOffline(event.target.value)}
/>
<span className="maintenance-hint">
至少为心跳的 2 倍,最大 {MAX_OFFLINE_SECONDS} 秒
</span>
</div>
</div>
<div className="action-buttons">
<Button
htmlType="button"
type="primary"
loading={busy === 'save'}
disabled={
busy !== undefined ||
!dirty ||
heartbeatSeconds === undefined ||
offlineSeconds === undefined
}
onClick={() =>
void run('save', async () => {
const saved = await source.updateConnection({
heartbeatSeconds: heartbeatSeconds as number,
offlineSeconds: offlineSeconds as number,
});
setSettings(saved);
setHeartbeat(String(saved.heartbeatSeconds));
setOffline(String(saved.offlineSeconds));
return `连接设置已保存,心跳 ${saved.heartbeatSeconds} 秒,离线判定 ${saved.offlineSeconds} 秒。`;
})
}
>
保存设置
</Button>
</div>
{error ? (
<p role="alert" className="state-panel state-error">
{error}
</p>
) : null}
{notice && !error ? (
<p role="status" className="maintenance-notice">
{notice}
</p>
) : null}
</section>
</Card>
);
}
@@ -0,0 +1,168 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest';
import { createSystemMaintenanceApiDataSource } from './system-maintenance-api-data-source.js';
const overview = {
runtime: {
version: '0.1.0',
platform: 'darwin',
arch: 'arm64',
uptimeSeconds: 120,
},
storage: {
databaseBytes: 2048,
walBytes: 512,
backupBytes: 8192,
reclaimableBytes: 256,
components: [
{ key: 'instances', count: 2, bytes: 4096 },
{ key: 'auditEvents', count: 12, bytes: 12_288 },
],
},
retention: {
instances: { enabled: false, days: 365, maximumCount: 0 },
auditEvents: { enabled: true, days: 180, maximumCount: 50_000 },
},
};
const backup = {
filename: 'multi-simadmin-test.db',
path: '/tmp/multi-simadmin-test.db',
sizeBytes: 2048,
sha256: 'a'.repeat(64),
createdAt: '2026-09-03T00:00:00.000Z',
};
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status });
}
describe('system maintenance API data source', () => {
it('loads the native overview with same-origin request controls', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json(overview));
const signal = new AbortController().signal;
await expect(createSystemMaintenanceApiDataSource(fetcher).overview(signal)).resolves.toEqual(
overview,
);
expect(fetcher).toHaveBeenCalledWith('/api/v1/system/maintenance', {
method: 'GET',
credentials: 'same-origin',
headers: { accept: 'application/json' },
signal,
});
});
it('sends exact retention and cleanup requests', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(json(overview.retention))
.mockResolvedValueOnce(json({ auditEvents: 1 }));
const policy = { auditEvents: { enabled: true, days: 30, maximumCount: 100 } };
await expect(
createSystemMaintenanceApiDataSource(fetcher).updateRetention(policy),
).resolves.toEqual(overview.retention);
await expect(
createSystemMaintenanceApiDataSource(fetcher).cleanup(['auditEvents']),
).resolves.toEqual({ auditEvents: 1 });
expect(fetcher).toHaveBeenNthCalledWith(1, '/api/v1/system/maintenance/retention', {
method: 'PUT',
credentials: 'same-origin',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify(policy),
});
expect(fetcher).toHaveBeenNthCalledWith(2, '/api/v1/system/maintenance/cleanup', {
method: 'POST',
credentials: 'same-origin',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify({ components: ['auditEvents'] }),
});
});
it('lists and creates backups without exposing response extras', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(json({ items: [backup] }))
.mockResolvedValueOnce(json(backup, 201));
const source = createSystemMaintenanceApiDataSource(fetcher);
await expect(source.listBackups()).resolves.toEqual([backup]);
await expect(source.createBackup()).resolves.toEqual(backup);
expect(fetcher).toHaveBeenNthCalledWith(1, '/api/v1/system/maintenance/backups', {
method: 'GET',
credentials: 'same-origin',
headers: { accept: 'application/json' },
});
expect(fetcher).toHaveBeenNthCalledWith(2, '/api/v1/system/maintenance/backups', {
method: 'POST',
credentials: 'same-origin',
headers: { accept: 'application/json' },
});
});
it('deletes a backup and builds a download url for the same path', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(json({ filename: backup.filename }));
const source = createSystemMaintenanceApiDataSource(fetcher);
expect(source.backupDownloadUrl(backup.filename)).toBe(
'/api/v1/system/maintenance/backups/multi-simadmin-test.db',
);
await expect(source.deleteBackup(backup.filename)).resolves.toBeUndefined();
expect(fetcher).toHaveBeenCalledWith(
'/api/v1/system/maintenance/backups/multi-simadmin-test.db',
expect.objectContaining({ method: 'DELETE' }),
);
expect(() => source.backupDownloadUrl('../secret.db')).toThrow();
await expect(source.deleteBackup('notes.txt')).rejects.toThrow();
expect(fetcher).toHaveBeenCalledTimes(1);
});
it('reads an older overview without byte counters as zero', async () => {
const legacy = {
...overview,
storage: {
databaseBytes: 2048,
components: [
{ key: 'instances', count: 2 },
{ key: 'auditEvents', count: 12 },
],
},
};
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json(legacy));
await expect(createSystemMaintenanceApiDataSource(fetcher).overview()).resolves.toEqual({
...overview,
storage: {
...overview.storage,
components: [
{ key: 'instances', count: 2, bytes: 0 },
{ key: 'auditEvents', count: 12, bytes: 0 },
],
walBytes: 0,
backupBytes: 0,
reclaimableBytes: 0,
},
});
});
it('rejects invalid native responses', async () => {
const invalid = [
{ ...overview, runtime: { ...overview.runtime, uptimeSeconds: -1 } },
{ ...overview, storage: { ...overview.storage, databaseBytes: 'bad' } },
{ ...overview, retention: { instances: { enabled: false, days: 0, maximumCount: 0 } } },
];
for (const body of invalid) {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json(body));
await expect(createSystemMaintenanceApiDataSource(fetcher).overview()).rejects.toThrow(
'System maintenance response is invalid.',
);
}
});
});
@@ -0,0 +1,375 @@
export type DataComponentKey =
| 'instances'
| 'statusSnapshots'
| 'jobs'
| 'auditEvents'
| 'scheduledRuns'
| 'notificationQueue'
| 'smsMessages'
| 'eventJournal'
| 'connectionLogs';
export type RetentionPolicy = Readonly<{
enabled: boolean;
days: number;
maximumCount: number;
}>;
export type ConnectionSettings = Readonly<{
heartbeatSeconds: number;
offlineSeconds: number;
}>;
export type HeartbeatSummary = Readonly<{
probed: number;
failed: number;
startedAt: string;
finishedAt: string;
}>;
export type SystemMaintenanceOverview = Readonly<{
runtime: Readonly<{
version: string;
platform: string;
arch: string;
uptimeSeconds: number;
}>;
storage: Readonly<{
databaseBytes: number;
/** Write-ahead log file size; 0 when the database is not in WAL mode. */
walBytes: number;
/** Total size of the retained backup files. */
backupBytes: number;
/** Free pages that the SQLite optimize step can hand back. */
reclaimableBytes: number;
components: readonly { key: DataComponentKey; count: number; bytes: number }[];
}>;
retention: Readonly<Partial<Record<DataComponentKey, RetentionPolicy>>>;
}>;
export type SystemMaintenanceBackup = Readonly<{
filename: string;
path: string;
sizeBytes: number;
sha256: string;
createdAt: string;
}>;
export interface SystemMaintenanceDataSource {
overview(signal?: AbortSignal): Promise<SystemMaintenanceOverview>;
updateRetention(
policy: Readonly<Partial<Record<DataComponentKey, RetentionPolicy>>>,
): Promise<SystemMaintenanceOverview['retention']>;
connection(signal?: AbortSignal): Promise<ConnectionSettings>;
updateConnection(settings: ConnectionSettings): Promise<ConnectionSettings>;
refreshHeartbeat(): Promise<HeartbeatSummary>;
cleanup(
components: readonly DataComponentKey[],
): Promise<Partial<Record<DataComponentKey, number>>>;
optimize(): Promise<{ checkpointed: boolean; vacuumed: boolean; analyzed: boolean }>;
listBackups(): Promise<readonly SystemMaintenanceBackup[]>;
createBackup(): Promise<SystemMaintenanceBackup>;
deleteBackup(filename: string): Promise<void>;
backupDownloadUrl(filename: string): string;
}
const COMPONENT_KEYS: readonly DataComponentKey[] = [
'instances',
'statusSnapshots',
'jobs',
'auditEvents',
'scheduledRuns',
'notificationQueue',
'smsMessages',
'eventJournal',
'connectionLogs',
];
export const MIN_HEARTBEAT_SECONDS = 5;
export const MAX_HEARTBEAT_SECONDS = 300;
export const MAX_OFFLINE_SECONDS = 1_800;
const MAX_TEXT = 128;
const MAX_BACKUPS = 100;
const BACKUP_FILENAME = /^multi-simadmin-[A-Za-z0-9._-]{1,120}\.db$/u;
function safeBackupName(filename: string): string {
if (!BACKUP_FILENAME.test(filename)) throw new Error('System maintenance query is invalid.');
return filename;
}
const record = (value: unknown): Record<string, unknown> | undefined =>
typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
const string = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value.length <= MAX_TEXT;
const integer = (value: unknown, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum && value <= maximum;
/** Optional byte counters: an older console build may omit them, which reads as zero. */
function optionalInteger(value: unknown): number {
return integer(value, 0, Number.MAX_SAFE_INTEGER) ? value : 0;
}
function parseRetention(value: unknown): SystemMaintenanceOverview['retention'] {
const source = record(value) ?? {};
const result: Partial<Record<DataComponentKey, RetentionPolicy>> = {};
for (const [key, candidate] of Object.entries(source)) {
const policy = record(candidate);
if (
!COMPONENT_KEYS.includes(key as DataComponentKey) ||
!policy ||
typeof policy.enabled !== 'boolean' ||
!integer(policy.days, 1, 3_650) ||
!integer(policy.maximumCount, 0, 1_000_000_000)
)
throw new Error('System maintenance response is invalid.');
result[key as DataComponentKey] = {
enabled: policy.enabled,
days: policy.days,
maximumCount: policy.maximumCount,
};
}
return result;
}
function parseOverview(value: unknown): SystemMaintenanceOverview {
const source = record(value);
const runtime = record(source?.runtime);
const storage = record(source?.storage);
const componentSource = Array.isArray(storage?.components) ? storage?.components : undefined;
if (
!source ||
!runtime ||
!storage ||
!string(runtime.version) ||
!string(runtime.platform) ||
!string(runtime.arch) ||
!integer(runtime.uptimeSeconds) ||
!integer(storage.databaseBytes, 0, Number.MAX_SAFE_INTEGER) ||
!componentSource
)
throw new Error('System maintenance response is invalid.');
const seen = new Set<DataComponentKey>();
const components = componentSource.map((candidate) => {
const item = record(candidate);
const key = item?.key;
if (
!item ||
!COMPONENT_KEYS.includes(key as DataComponentKey) ||
seen.has(key as DataComponentKey) ||
!integer(item.count)
)
throw new Error('System maintenance response is invalid.');
seen.add(key as DataComponentKey);
return {
key: key as DataComponentKey,
count: item.count,
bytes: optionalInteger(item.bytes),
};
});
return {
runtime: {
version: runtime.version,
platform: runtime.platform,
arch: runtime.arch,
uptimeSeconds: runtime.uptimeSeconds,
},
storage: {
databaseBytes: storage.databaseBytes,
walBytes: optionalInteger(storage.walBytes),
backupBytes: optionalInteger(storage.backupBytes),
reclaimableBytes: optionalInteger(storage.reclaimableBytes),
components,
},
retention: parseRetention(source.retention),
};
}
function parseConnectionSettings(value: unknown): ConnectionSettings {
const source = record(value);
if (
!source ||
!integer(source.heartbeatSeconds, MIN_HEARTBEAT_SECONDS, MAX_HEARTBEAT_SECONDS) ||
!integer(source.offlineSeconds, source.heartbeatSeconds * 2, MAX_OFFLINE_SECONDS)
)
throw new Error('System maintenance response is invalid.');
return { heartbeatSeconds: source.heartbeatSeconds, offlineSeconds: source.offlineSeconds };
}
function parseHeartbeat(value: unknown): HeartbeatSummary {
const source = record(value);
if (
!source ||
!integer(source.probed) ||
!integer(source.failed) ||
typeof source.startedAt !== 'string' ||
Number.isNaN(Date.parse(source.startedAt)) ||
typeof source.finishedAt !== 'string' ||
Number.isNaN(Date.parse(source.finishedAt))
)
throw new Error('System maintenance response is invalid.');
return {
probed: source.probed,
failed: source.failed,
startedAt: new Date(source.startedAt).toISOString(),
finishedAt: new Date(source.finishedAt).toISOString(),
};
}
function parseBackup(value: unknown): SystemMaintenanceBackup {
const source = record(value);
if (
!source ||
!string(source.filename) ||
!string(source.path) ||
!string(source.sha256) ||
!/^[a-f\d]{64}$/iu.test(source.sha256) ||
!integer(source.sizeBytes, 1) ||
typeof source.createdAt !== 'string' ||
Number.isNaN(Date.parse(source.createdAt))
)
throw new Error('System maintenance response is invalid.');
return {
filename: source.filename,
path: source.path,
sizeBytes: source.sizeBytes,
sha256: source.sha256,
createdAt: new Date(source.createdAt).toISOString(),
};
}
async function readJson(response: Response): Promise<unknown> {
try {
return await response.json();
} catch {
return undefined;
}
}
function requireOk(response: Response, body: unknown): unknown {
if (!response.ok || body === undefined || body === null)
throw new Error('System maintenance request failed.');
return body;
}
const jsonHeaders = { accept: 'application/json', 'content-type': 'application/json' } as const;
const readHeaders = { accept: 'application/json' } as const;
export function createSystemMaintenanceApiDataSource(
fetcher: typeof fetch = fetch,
): SystemMaintenanceDataSource {
const read = async (path: string, signal?: AbortSignal) => {
const response = await fetcher(path, {
method: 'GET',
credentials: 'same-origin',
headers: readHeaders,
...(signal ? { signal } : {}),
});
return requireOk(response, await readJson(response));
};
return {
async overview(signal) {
return parseOverview(await read('/api/v1/system/maintenance', signal));
},
async updateRetention(policy) {
const response = await fetcher('/api/v1/system/maintenance/retention', {
method: 'PUT',
credentials: 'same-origin',
headers: jsonHeaders,
body: JSON.stringify(policy),
});
return parseRetention(requireOk(response, await readJson(response)));
},
async connection(signal) {
return parseConnectionSettings(await read('/api/v1/system/connection', signal));
},
async updateConnection(settings) {
if (
!integer(settings.heartbeatSeconds, MIN_HEARTBEAT_SECONDS, MAX_HEARTBEAT_SECONDS) ||
!integer(settings.offlineSeconds, settings.heartbeatSeconds * 2, MAX_OFFLINE_SECONDS)
)
throw new Error('System maintenance query is invalid.');
const response = await fetcher('/api/v1/system/connection', {
method: 'PUT',
credentials: 'same-origin',
headers: jsonHeaders,
body: JSON.stringify(settings),
});
return parseConnectionSettings(requireOk(response, await readJson(response)));
},
async refreshHeartbeat() {
const response = await fetcher('/api/v1/system/connection/refresh', {
method: 'POST',
credentials: 'same-origin',
headers: readHeaders,
});
return parseHeartbeat(requireOk(response, await readJson(response)));
},
async cleanup(components) {
if (
components.length === 0 ||
components.some((component) => !COMPONENT_KEYS.includes(component))
)
throw new Error('System maintenance query is invalid.');
const response = await fetcher('/api/v1/system/maintenance/cleanup', {
method: 'POST',
credentials: 'same-origin',
headers: jsonHeaders,
body: JSON.stringify({ components }),
});
const source = record(requireOk(response, await readJson(response))) ?? {};
const result: Partial<Record<DataComponentKey, number>> = {};
for (const [key, count] of Object.entries(source)) {
if (!COMPONENT_KEYS.includes(key as DataComponentKey) || !integer(count))
throw new Error('System maintenance response is invalid.');
result[key as DataComponentKey] = count;
}
return result;
},
async optimize() {
const response = await fetcher('/api/v1/system/maintenance/optimize', {
method: 'POST',
credentials: 'same-origin',
headers: readHeaders,
});
const source = record(requireOk(response, await readJson(response)));
if (
!source ||
source.checkpointed !== true ||
source.vacuumed !== true ||
source.analyzed !== true
)
throw new Error('System maintenance response is invalid.');
return { checkpointed: true, vacuumed: true, analyzed: true };
},
async listBackups() {
const source = record(await read('/api/v1/system/maintenance/backups'));
if (!source || !Array.isArray(source.items) || source.items.length > MAX_BACKUPS)
throw new Error('System maintenance response is invalid.');
return source.items.map(parseBackup);
},
async createBackup() {
const response = await fetcher('/api/v1/system/maintenance/backups', {
method: 'POST',
credentials: 'same-origin',
headers: readHeaders,
});
return parseBackup(requireOk(response, await readJson(response)));
},
async deleteBackup(filename) {
const response = await fetcher(
`/api/v1/system/maintenance/backups/${encodeURIComponent(safeBackupName(filename))}`,
{
method: 'DELETE',
credentials: 'same-origin',
headers: readHeaders,
},
);
requireOk(response, await readJson(response));
},
backupDownloadUrl(filename) {
return `/api/v1/system/maintenance/backups/${encodeURIComponent(safeBackupName(filename))}`;
},
};
}
@@ -0,0 +1,168 @@
// @vitest-environment jsdom
import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
SystemMaintenancePage,
type SystemMaintenanceDataSource,
} from './system-maintenance-page.js';
afterEach(cleanup);
const backup = {
filename: 'multi-simadmin-2026.db',
path: '/tmp/multi-simadmin-2026.db',
sizeBytes: 2048,
sha256: 'a'.repeat(64),
createdAt: '2026-09-03T00:00:00.000Z',
};
const overview = {
runtime: {
version: '0.1.0',
platform: 'darwin',
arch: 'arm64',
uptimeSeconds: 120,
},
storage: {
databaseBytes: 4096,
walBytes: 1024,
backupBytes: 2048,
reclaimableBytes: 512,
components: [
{ key: 'instances' as const, count: 3, bytes: 4096 },
{ key: 'auditEvents' as const, count: 24, bytes: 24_576 },
],
},
retention: {
instances: { enabled: false, days: 365, maximumCount: 0 },
auditEvents: { enabled: true, days: 180, maximumCount: 50_000 },
},
};
function source(): SystemMaintenanceDataSource {
return {
overview: vi.fn(async () => overview),
updateRetention: vi.fn(async (policy) => policy),
connection: vi.fn(async () => ({ heartbeatSeconds: 30, offlineSeconds: 90 })),
updateConnection: vi.fn(async (settings) => settings),
refreshHeartbeat: vi.fn(async () => ({
probed: 3,
failed: 1,
startedAt: '2026-09-05T02:00:00.000Z',
finishedAt: '2026-09-05T02:00:01.000Z',
})),
cleanup: vi.fn(async () => ({ auditEvents: 1 })),
optimize: vi.fn(async () => ({ checkpointed: true, vacuumed: true, analyzed: true })),
listBackups: vi.fn(async () => [backup]),
createBackup: vi.fn(async () => ({ ...backup, filename: 'created.db' })),
deleteBackup: vi.fn(async () => undefined),
backupDownloadUrl: vi.fn(
(filename: string) => `/api/v1/system/maintenance/backups/${encodeURIComponent(filename)}`,
),
};
}
describe('System maintenance page', () => {
it('loads the native overview and backups', async () => {
const dataSource = source();
render(<SystemMaintenancePage dataSource={dataSource} />);
expect(screen.getByRole('status', { name: '系统维护加载状态' })).toBeTruthy();
const status = await screen.findByRole('region', { name: '运行状态' });
expect(within(status).getByText('0.1.0')).toBeTruthy();
expect(within(status).getByText('2 分钟')).toBeTruthy();
const storage = screen.getByRole('region', { name: '存储统计' });
const table = within(storage).getByRole('table');
expect(within(table).getByText('实例')).toBeTruthy();
expect(within(table).getByText('3')).toBeTruthy();
expect(within(table).getByText('28 KB')).toBeTruthy();
expect(within(table).getByText('4.0 KB')).toBeTruthy();
expect(within(table).getByText('24 KB')).toBeTruthy();
expect(within(storage).getByText('7.0 KB')).toBeTruthy();
expect(within(storage).getByText('SQLite 数据库')).toBeTruthy();
expect(within(storage).getByText('WAL 文件')).toBeTruthy();
expect(within(storage).getByText('备份文件')).toBeTruthy();
expect(within(storage).getByText('实际存储占用')).toBeTruthy();
expect(within(storage).getByText('合计')).toBeTruthy();
expect(within(table).getByText('27')).toBeTruthy();
expect(within(storage).getByText('整理空间可回收约 512 B 空闲页。')).toBeTruthy();
expect(screen.getByRole('table', { name: '本地备份' })).toBeTruthy();
expect(screen.getByText('multi-simadmin-2026.db')).toBeTruthy();
expect(dataSource.overview).toHaveBeenCalledWith(expect.any(AbortSignal));
expect(dataSource.listBackups).toHaveBeenCalledTimes(1);
});
it('saves edited retention policies', async () => {
const user = userEvent.setup();
const dataSource = source();
render(<SystemMaintenancePage dataSource={dataSource} />);
await screen.findByRole('region', { name: '数据保留' });
await user.type(screen.getByLabelText('审计事件保留天数'), '30');
await user.click(screen.getByRole('button', { name: '保存保留策略' }));
expect(dataSource.updateRetention).toHaveBeenCalledWith(
expect.objectContaining({
auditEvents: { enabled: true, days: 18030, maximumCount: 50_000 },
}),
);
expect(await screen.findByRole('status')).toBeTruthy();
});
it('runs selected cleanup, optimization, and backup creation', async () => {
const user = userEvent.setup();
const confirm = vi.fn(() => true);
window.confirm = confirm;
const dataSource = source();
render(<SystemMaintenancePage dataSource={dataSource} />);
await screen.findByRole('region', { name: '维护操作' });
await user.click(screen.getByRole('checkbox', { name: '审计事件' }));
await user.click(screen.getByRole('button', { name: '执行清理' }));
await user.click(screen.getByRole('button', { name: '优化数据库' }));
await user.click(screen.getByRole('button', { name: '创建备份' }));
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('审计事件'));
expect(dataSource.cleanup).toHaveBeenCalledWith(['auditEvents']);
expect(dataSource.optimize).toHaveBeenCalledTimes(1);
expect(dataSource.createBackup).toHaveBeenCalledTimes(1);
expect(await screen.findByText('已创建备份 created.db。')).toBeTruthy();
});
it('links each backup for download and deletes one after confirmation', async () => {
const user = userEvent.setup();
const confirm = vi.fn(() => true);
window.confirm = confirm;
const dataSource = source();
render(<SystemMaintenancePage dataSource={dataSource} />);
await screen.findByRole('table', { name: '本地备份' });
const row = screen.getByRole('row', { name: /multi-simadmin-2026\.db/ });
const link = within(row).getByRole('link', { name: '下载' });
expect(link.getAttribute('href')).toBe(
'/api/v1/system/maintenance/backups/multi-simadmin-2026.db',
);
expect(link.getAttribute('download')).toBe('multi-simadmin-2026.db');
await user.click(within(row).getByRole('button', { name: '删除' }));
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('multi-simadmin-2026.db'));
expect(dataSource.deleteBackup).toHaveBeenCalledWith('multi-simadmin-2026.db');
expect(await screen.findByText('已删除备份 multi-simadmin-2026.db。')).toBeTruthy();
expect(screen.queryByText('multi-simadmin-2026.db')).toBeNull();
});
it('keeps the backup when the operator cancels the delete prompt', async () => {
const user = userEvent.setup();
window.confirm = vi.fn(() => false);
const dataSource = source();
render(<SystemMaintenancePage dataSource={dataSource} />);
await screen.findByRole('table', { name: '本地备份' });
await user.click(screen.getByRole('button', { name: '删除' }));
expect(dataSource.deleteBackup).not.toHaveBeenCalled();
expect(screen.getByText('multi-simadmin-2026.db')).toBeTruthy();
});
});
@@ -0,0 +1,494 @@
import { useEffect, useState } from 'react';
import { Button, Card, Title } from 'animal-island-ui';
import type {
DataComponentKey,
RetentionPolicy,
SystemMaintenanceBackup,
SystemMaintenanceDataSource,
SystemMaintenanceOverview,
} from './system-maintenance-api-data-source.js';
import { createSystemMaintenanceApiDataSource } from './system-maintenance-api-data-source.js';
import { ComponentBackupPanel } from './component-backup-panel.js';
import { ConnectionSettingsPanel } from './connection-settings-panel.js';
export type { SystemMaintenanceDataSource } from './system-maintenance-api-data-source.js';
const COMPONENT_KEYS: readonly DataComponentKey[] = [
'instances',
'statusSnapshots',
'jobs',
'auditEvents',
'scheduledRuns',
'notificationQueue',
'smsMessages',
'eventJournal',
'connectionLogs',
];
const COMPONENT_LABELS: Readonly<Record<DataComponentKey, string>> = {
instances: '实例',
statusSnapshots: '状态快照',
jobs: '任务',
auditEvents: '审计事件',
scheduledRuns: '调度记录',
notificationQueue: '通知队列',
smsMessages: '短信消息',
eventJournal: '事件日志',
connectionLogs: '连接日志',
};
/** Everything the console actually keeps on disk: database, WAL and backup files. */
function storageTotal(overview: SystemMaintenanceOverview): number {
return overview.storage.databaseBytes + overview.storage.walBytes + overview.storage.backupBytes;
}
function formatBytes(value: number): string {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(value < 10 * 1024 ? 1 : 0)} KB`;
if (value < 1024 * 1024 * 1024)
return `${(value / (1024 * 1024)).toFixed(value < 10 * 1024 * 1024 ? 1 : 0)} MB`;
return `${(value / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function formatDateTime(value: string): string {
return new Intl.DateTimeFormat('zh-CN', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
function allRetention(
value: SystemMaintenanceOverview['retention'],
): Record<DataComponentKey, RetentionPolicy> {
return Object.fromEntries(
COMPONENT_KEYS.map((key) => [
key,
value[key] ?? { enabled: false, days: 365, maximumCount: 0 },
]),
) as Record<DataComponentKey, RetentionPolicy>;
}
type BusyAction = 'retention' | 'cleanup' | 'optimize' | 'backup' | 'delete' | undefined;
export function SystemMaintenancePage({
dataSource,
}: {
readonly dataSource?: SystemMaintenanceDataSource;
}) {
const [source] = useState(() => dataSource ?? createSystemMaintenanceApiDataSource());
const [overview, setOverview] = useState<SystemMaintenanceOverview>();
const [retention, setRetention] = useState<Record<DataComponentKey, RetentionPolicy>>();
const [selected, setSelected] = useState<ReadonlySet<DataComponentKey>>(new Set());
const [backups, setBackups] = useState<readonly SystemMaintenanceBackup[]>([]);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [busy, setBusy] = useState<BusyAction>();
const [notice, setNotice] = useState('');
const [error, setError] = useState('');
const [attempt, setAttempt] = useState(0);
useEffect(() => {
const controller = new AbortController();
let active = true;
setLoading(true);
setFailed(false);
setNotice('');
setError('');
void Promise.all([source.overview(controller.signal), source.listBackups()]).then(
([nextOverview, nextBackups]) => {
if (!active) return;
setOverview(nextOverview);
setRetention(allRetention(nextOverview.retention));
setBackups(nextBackups);
setLoading(false);
},
() => {
if (!active || controller.signal.aborted) return;
setFailed(true);
setLoading(false);
},
);
return () => {
active = false;
controller.abort();
};
}, [attempt, source]);
const run = async (action: BusyAction, work: () => Promise<string>) => {
if (busy) return;
setBusy(action);
setError('');
setNotice('');
try {
setNotice(await work());
} catch {
setError('系统维护操作未完成,请稍后重试。');
} finally {
setBusy(undefined);
}
};
const saveRetention = () =>
run('retention', async () => {
if (!retention) return '';
const saved = await source.updateRetention(retention);
setRetention(allRetention(saved));
setOverview((current) => (current ? { ...current, retention: saved } : current));
return '保留策略已保存。';
});
const cleanup = () =>
run('cleanup', async () => {
const components = [...selected];
if (components.length === 0) return '请先选择要清理的数据。';
const label = components.map((component) => COMPONENT_LABELS[component]).join('、');
if (!window.confirm(`确认清理选中的${label}数据?此操作不可恢复。`)) return '';
const result = await source.cleanup(components);
const removed = components.reduce((sum, component) => sum + (result[component] ?? 0), 0);
const primary = components.find((component) => result[component] !== undefined);
return primary
? `清理了 ${(result[primary] ?? 0).toLocaleString('zh-CN')} 条${COMPONENT_LABELS[primary]}数据。`
: `清理完成,共移除 ${removed.toLocaleString('zh-CN')} 条数据。`;
});
const optimize = () =>
run('optimize', async () => {
await source.optimize();
return '数据库检查点、碎片整理和统计优化已完成。';
});
const createBackup = () =>
run('backup', async () => {
const backup = await source.createBackup();
setBackups((current) => [backup, ...current].slice(0, 100));
return `已创建备份 ${backup.filename}。`;
});
const removeBackup = (filename: string) =>
run('delete', async () => {
if (!window.confirm(`确认删除备份 ${filename}?此操作不可恢复。`)) return '';
await source.deleteBackup(filename);
setBackups((current) => current.filter((backup) => backup.filename !== filename));
return `已删除备份 ${filename}。`;
});
const updatePolicy = (key: DataComponentKey, patch: Partial<RetentionPolicy>) => {
setRetention((current) =>
current
? {
...current,
[key]: { ...current[key], ...patch },
}
: current,
);
};
return (
<section
className="settings-page system-maintenance"
aria-labelledby="system-maintenance-title"
>
<header className="page-heading">
<h1 id="system-maintenance-title">
<Title color="app-green">系统维护</Title>
</h1>
<p>集中管理控制台运行状态、数据保留、SQLite 优化和本地备份。</p>
</header>
{loading ? (
<p role="status" aria-label="系统维护加载状态">
正在读取系统维护状态…
</p>
) : null}
{failed ? (
<div role="alert">
<p>无法加载系统维护状态。</p>
<Button
htmlType="button"
onClick={() => {
setAttempt((value) => value + 1);
}}
>
重试加载
</Button>
</div>
) : null}
{overview && retention ? (
<div className="maintenance-layout">
<Card pattern="default" className="settings-card maintenance-card">
<section aria-label="运行状态">
<h2>运行状态</h2>
<dl className="maintenance-summary">
<div>
<dt>控制台版本</dt>
<dd>{overview.runtime.version}</dd>
</div>
<div>
<dt>运行平台</dt>
<dd>
{overview.runtime.platform} / {overview.runtime.arch}
</dd>
</div>
<div>
<dt>运行时间</dt>
<dd>{Math.floor(overview.runtime.uptimeSeconds / 60)} 分钟</dd>
</div>
</dl>
</section>
<section aria-label="存储统计">
<h2>存储统计</h2>
<ul className="maintenance-components maintenance-storage-files">
<li>
<span>SQLite 数据库</span>
<strong>{formatBytes(overview.storage.databaseBytes)}</strong>
</li>
<li>
<span>WAL 文件</span>
<strong>{formatBytes(overview.storage.walBytes)}</strong>
</li>
<li>
<span>备份文件</span>
<strong>{formatBytes(overview.storage.backupBytes)}</strong>
</li>
<li>
<span>实际存储占用</span>
<strong>{formatBytes(storageTotal(overview))}</strong>
</li>
</ul>
<div className="table-scroll">
<table className="dense-table maintenance-storage-table">
<thead>
<tr>
<th scope="col">组件</th>
<th scope="col">记录数</th>
<th scope="col">估算占用</th>
</tr>
</thead>
<tbody>
{overview.storage.components.map((component) => (
<tr key={component.key}>
<th scope="row">{COMPONENT_LABELS[component.key]}</th>
<td>{component.count.toLocaleString('zh-CN')}</td>
<td>{formatBytes(component.bytes)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<th scope="row">合计</th>
<td>
{overview.storage.components
.reduce((sum, component) => sum + component.count, 0)
.toLocaleString('zh-CN')}
</td>
<td>
{formatBytes(
overview.storage.components.reduce(
(sum, component) => sum + component.bytes,
0,
),
)}
</td>
</tr>
</tfoot>
</table>
</div>
{overview.storage.reclaimableBytes > 0 ? (
<p className="maintenance-hint">
{`整理空间可回收约 ${formatBytes(overview.storage.reclaimableBytes)} 空闲页。`}
</p>
) : null}
</section>
</Card>
<Card pattern="default" className="settings-card maintenance-card">
<section aria-label="数据保留">
<h2>数据保留</h2>
<p className="maintenance-hint">清理按每个组件的独立保留规则执行。</p>
<div className="retention-grid">
{COMPONENT_KEYS.map((key) => {
const policy = retention[key];
return (
<div key={key} className="retention-item">
<label className="toggle-row">
<span>
<strong>{COMPONENT_LABELS[key]}</strong>
<small>保留天数 / 最大数量</small>
</span>
<input
type="checkbox"
checked={policy.enabled}
onChange={(event) => updatePolicy(key, { enabled: event.target.checked })}
/>
</label>
<label className="visually-hidden" htmlFor={`retention-days-${key}`}>
{COMPONENT_LABELS[key]}保留天数
</label>
<input
id={`retention-days-${key}`}
type="number"
min={1}
max={3650}
value={policy.days}
onChange={(event) =>
updatePolicy(key, { days: Number(event.target.value) })
}
/>
<label className="visually-hidden" htmlFor={`retention-limit-${key}`}>
{COMPONENT_LABELS[key]}最大保留数量
</label>
<input
id={`retention-limit-${key}`}
type="number"
min={0}
value={policy.maximumCount}
onChange={(event) =>
updatePolicy(key, { maximumCount: Number(event.target.value) })
}
/>
</div>
);
})}
</div>
<Button
htmlType="button"
type="primary"
loading={busy === 'retention'}
disabled={busy !== undefined}
onClick={() => void saveRetention()}
>
保存保留策略
</Button>
</section>
</Card>
<Card pattern="default" className="settings-card maintenance-card">
<section aria-label="维护操作">
<h2>维护操作</h2>
<div className="maintenance-actions">
<fieldset>
<legend>清理选择</legend>
{COMPONENT_KEYS.map((key) => (
<label key={key} className="cleanup-choice">
<input
type="checkbox"
checked={selected.has(key)}
onChange={(event) => {
const next = new Set(selected);
if (event.target.checked) next.add(key);
else next.delete(key);
setSelected(next);
}}
/>
<span>{COMPONENT_LABELS[key]}</span>
</label>
))}
</fieldset>
<div className="action-buttons">
<Button
htmlType="button"
danger
loading={busy === 'cleanup'}
disabled={busy !== undefined}
onClick={() => void cleanup()}
>
执行清理
</Button>
<Button
htmlType="button"
loading={busy === 'optimize'}
disabled={busy !== undefined}
onClick={() => void optimize()}
>
优化数据库
</Button>
<Button
htmlType="button"
loading={busy === 'backup'}
disabled={busy !== undefined}
onClick={() => void createBackup()}
>
创建备份
</Button>
</div>
</div>
</section>
</Card>
<Card pattern="default" className="settings-card maintenance-card">
<section aria-label="本地备份">
<h2>本地备份</h2>
{backups.length === 0 ? (
<p>暂无本地备份。</p>
) : (
<div className="table-scroll">
<table className="dense-table" aria-label="本地备份">
<thead>
<tr>
<th>文件</th>
<th>大小</th>
<th>创建时间</th>
<th>SHA256</th>
<th aria-label="操作" />
</tr>
</thead>
<tbody>
{backups.map((backup) => (
<tr key={backup.filename}>
<td>{backup.filename}</td>
<td>{formatBytes(backup.sizeBytes)}</td>
<td>{formatDateTime(backup.createdAt)}</td>
<td>
<code title={backup.sha256}>{backup.sha256.slice(0, 12)}</code>
</td>
<td className="maintenance-backup-actions">
<a
href={source.backupDownloadUrl(backup.filename)}
download={backup.filename}
>
下载
</a>
<Button
htmlType="button"
danger
disabled={busy !== undefined}
onClick={() => void removeBackup(backup.filename)}
>
删除
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</Card>
<ConnectionSettingsPanel dataSource={source} />
<ComponentBackupPanel
onCompleted={(message) => {
setError('');
setNotice(message);
}}
/>
</div>
) : null}
{!loading && notice ? (
<p role="status" className="maintenance-notice">
{notice}
</p>
) : null}
{!loading && error ? (
<p role="alert" className="maintenance-error">
{error}
</p>
) : null}
</section>
);
}