feat(web): add console update panel, system maintenance path and connection settings UI

- Add online update workflow with plan, backup and install states.

- Expose maintenance paths, component backup jobs and connection authorization modes.

- Align auth and automation screens with the fused control plane.
This commit is contained in:
chick
2026-09-07 00:45:44 +08:00
parent 6d251dab7b
commit f6319374cd
20 changed files with 1985 additions and 67 deletions
+19 -2
View File
@@ -14,6 +14,7 @@ export function ConsoleAuthSettings({
const [enabled, setEnabled] = useState(false);
const [password, setPassword] = useState('');
const [confirmation, setConfirmation] = useState('');
const [idleMinutes, setIdleMinutes] = useState('30');
const [saving, setSaving] = useState(false);
const [notice, setNotice] = useState('');
const [error, setError] = useState('');
@@ -25,6 +26,7 @@ export function ConsoleAuthSettings({
if (!active) return;
setStatus(next);
setEnabled(next.protectionEnabled);
setIdleMinutes(String(next.sessionIdleTimeoutMinutes));
},
() => {
if (active) setError('无法读取密码保护设置。');
@@ -52,11 +54,15 @@ export function ConsoleAuthSettings({
}
setSaving(true);
try {
const input =
enabled && !status.configured ? { enabled: true, newPassword: password } : { enabled };
const input = {
enabled,
...(enabled && !status.configured ? { newPassword: password } : {}),
idleTimeoutMinutes: Math.min(1440, Math.max(5, Math.round(Number(idleMinutes) || 30))),
};
const next = await source.update(input);
setStatus(next);
setEnabled(next.protectionEnabled);
setIdleMinutes(String(next.sessionIdleTimeoutMinutes));
setPassword('');
setConfirmation('');
setNotice(next.protectionEnabled ? '密码保护已启用。' : '密码保护已关闭。');
@@ -115,6 +121,17 @@ export function ConsoleAuthSettings({
</div>
) : null}
{status.configured ? <p>访 API </p> : null}
<label htmlFor="console-idle-timeout">退</label>
<input
id="console-idle-timeout"
type="number"
min={5}
max={1440}
step={1}
value={idleMinutes}
onChange={(event) => setIdleMinutes(event.target.value)}
/>
<small>退 5 1440 </small>
{error ? <p role="alert">{error}</p> : null}
{notice ? <p role="status">{notice}</p> : null}
<Button htmlType="submit" type="primary" disabled={saving} loading={saving}>
+57 -6
View File
@@ -1,24 +1,30 @@
// @vitest-environment jsdom
import { act } from 'react';
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ConsoleAuthGate, type ConsoleAuthDataSource } from './console-auth.js';
afterEach(cleanup);
afterEach(() => {
vi.useRealTimers();
cleanup();
});
const status = {
configured: true,
protectionEnabled: true,
authenticated: false,
sessionIdleTimeoutMinutes: 30,
};
describe('aggregate-console authentication UI', () => {
it('gates the entire console with the single-SimAdmin-style password form', async () => {
const user = userEvent.setup();
const login = vi.fn().mockResolvedValue(undefined);
const authenticatedStatus = { ...status, authenticated: true };
const dataSource: ConsoleAuthDataSource = {
status: vi.fn().mockResolvedValue(status),
status: vi.fn().mockResolvedValueOnce(status).mockResolvedValueOnce(authenticatedStatus),
login,
logout: vi.fn(),
update: vi.fn(),
@@ -40,6 +46,34 @@ describe('aggregate-console authentication UI', () => {
expect(await screen.findByText('secret fleet')).toBeTruthy();
});
it('signs the browser out after the configured idle timeout', async () => {
vi.useFakeTimers({ toFake: ['setInterval', 'Date'] });
const idleStatus = { ...status, authenticated: true, sessionIdleTimeoutMinutes: 10 };
const dataSource: ConsoleAuthDataSource = {
status: vi.fn().mockResolvedValue(idleStatus),
login: vi.fn(),
logout: vi.fn().mockResolvedValue(undefined),
update: vi.fn(),
};
render(
<ConsoleAuthGate dataSource={dataSource}>
<p>secret fleet</p>
</ConsoleAuthGate>,
);
await act(async () => {});
expect(screen.getByText('secret fleet')).toBeTruthy();
act(() => {
vi.advanceTimersByTime(10 * 60 * 1000);
});
await act(async () => {});
expect(dataSource.logout).toHaveBeenCalledTimes(1);
expect(await screen.findByRole('heading', { name: '请输入访问密码' })).toBeTruthy();
expect(screen.queryByText('secret fleet')).toBeNull();
vi.useRealTimers();
});
it('shows a generic login failure without echoing credentials', async () => {
const user = userEvent.setup();
const dataSource: ConsoleAuthDataSource = {
@@ -61,13 +95,24 @@ describe('aggregate-console authentication UI', () => {
const user = userEvent.setup();
const update = vi
.fn()
.mockResolvedValueOnce({ configured: true, protectionEnabled: true, authenticated: true })
.mockResolvedValueOnce({ configured: true, protectionEnabled: false, authenticated: true });
.mockResolvedValueOnce({
configured: true,
protectionEnabled: true,
authenticated: true,
sessionIdleTimeoutMinutes: 10,
})
.mockResolvedValueOnce({
configured: true,
protectionEnabled: false,
authenticated: true,
sessionIdleTimeoutMinutes: 10,
});
const dataSource: ConsoleAuthDataSource = {
status: vi.fn().mockResolvedValue({
configured: false,
protectionEnabled: false,
authenticated: true,
sessionIdleTimeoutMinutes: 30,
}),
login: vi.fn(),
logout: vi.fn(),
@@ -91,12 +136,18 @@ describe('aggregate-console authentication UI', () => {
await user.click(toggle);
await user.type(screen.getByLabelText('设置访问密码'), 'StrongPass!9');
await user.type(screen.getByLabelText('确认访问密码'), 'StrongPass!9');
await user.clear(screen.getByLabelText('空闲自动退出(分钟)'));
await user.type(screen.getByLabelText('空闲自动退出(分钟)'), '10');
await user.click(screen.getByRole('button', { name: '保存密码保护设置' }));
expect(update).toHaveBeenNthCalledWith(1, { enabled: true, newPassword: 'StrongPass!9' });
expect(update).toHaveBeenNthCalledWith(1, {
enabled: true,
newPassword: 'StrongPass!9',
idleTimeoutMinutes: 10,
});
expect(await screen.findByText('密码保护已启用。')).toBeTruthy();
await user.click(toggle);
await user.click(screen.getByRole('button', { name: '保存密码保护设置' }));
expect(update).toHaveBeenNthCalledWith(2, { enabled: false });
expect(update).toHaveBeenNthCalledWith(2, { enabled: false, idleTimeoutMinutes: 10 });
});
});
+37 -1
View File
@@ -4,6 +4,7 @@ export interface ConsoleAuthStatus {
readonly configured: boolean;
readonly protectionEnabled: boolean;
readonly authenticated: boolean;
readonly sessionIdleTimeoutMinutes: number;
}
export interface ConsoleAuthDataSource {
@@ -13,6 +14,7 @@ export interface ConsoleAuthDataSource {
update(input: {
readonly enabled: boolean;
readonly newPassword?: string;
readonly idleTimeoutMinutes?: number;
}): Promise<ConsoleAuthStatus>;
}
@@ -67,6 +69,15 @@ export interface ConsoleAuthGateProps {
readonly dataSource?: ConsoleAuthDataSource;
}
const CONSOLE_ACTIVITY_EVENTS = [
'click',
'keydown',
'pointerdown',
'scroll',
'touchstart',
'visibilitychange',
] as const;
export function ConsoleAuthGate({ children, dataSource }: ConsoleAuthGateProps) {
const defaultDataSource = useMemo(() => createConsoleAuthApiDataSource(), []);
const source = dataSource ?? defaultDataSource;
@@ -75,6 +86,7 @@ export function ConsoleAuthGate({ children, dataSource }: ConsoleAuthGateProps)
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [loginFailed, setLoginFailed] = useState(false);
const [lastActivity, setLastActivity] = useState(() => Date.now());
useEffect(() => {
let active = true;
@@ -90,6 +102,10 @@ export function ConsoleAuthGate({ children, dataSource }: ConsoleAuthGateProps)
active = false;
};
}, [source]);
useEffect(() => {
if (!status?.authenticated || !status?.protectionEnabled) return;
setLastActivity(Date.now());
}, [status?.authenticated, status?.protectionEnabled]);
const submit = async (event: FormEvent) => {
event.preventDefault();
@@ -98,7 +114,8 @@ export function ConsoleAuthGate({ children, dataSource }: ConsoleAuthGateProps)
setLoginFailed(false);
try {
await source.login(password);
setStatus({ configured: true, protectionEnabled: true, authenticated: true });
const nextStatus = await source.status();
setStatus(nextStatus);
setPassword('');
} catch {
setLoginFailed(true);
@@ -107,6 +124,25 @@ export function ConsoleAuthGate({ children, dataSource }: ConsoleAuthGateProps)
}
};
useEffect(() => {
if (!status?.protectionEnabled || !status.authenticated) return;
const markActive = () => setLastActivity(Date.now());
for (const eventName of CONSOLE_ACTIVITY_EVENTS)
window.addEventListener(eventName, markActive, { passive: true });
const tick = window.setInterval(() => {
if (Date.now() - lastActivity >= status.sessionIdleTimeoutMinutes * 60 * 1000)
void source.logout().then(
() => setStatus({ ...status, authenticated: false }),
() => undefined,
);
}, 30_000);
return () => {
for (const eventName of CONSOLE_ACTIVITY_EVENTS)
window.removeEventListener(eventName, markActive);
window.clearInterval(tick);
};
}, [lastActivity, source, status]);
if (failed)
return (
<main className="auth-screen">
@@ -183,13 +183,16 @@ describe('AutomationPage', () => {
expect(await screen.findByText('Morning restart copy')).not.toBeNull();
});
it('schedules an SMS blast for a device group with a random delay', async () => {
it('schedules an SMS blast for multiple device groups with a random delay', async () => {
const user = userEvent.setup();
const createSchedule = vi.fn(source().createSchedule);
render(
<AutomationPage
dataSource={{ ...source(), createSchedule }}
groupOptions={[{ id: 'g1', name: '外场' }]}
groupOptions={[
{ id: 'g1', name: '外场' },
{ id: 'g2', name: '办公室' },
]}
/>,
);
@@ -199,15 +202,16 @@ describe('AutomationPage', () => {
await user.selectOptions(within(dialog).getByLabelText('操作类型'), 'send-sms');
await user.type(within(dialog).getByLabelText('收件号码'), '13800138000');
await user.type(within(dialog).getByLabelText('短信内容'), '巡检开始');
await user.selectOptions(within(dialog).getByLabelText('目标方式'), 'group');
await user.selectOptions(within(dialog).getByLabelText('分组'), 'g1');
await user.selectOptions(within(dialog).getByLabelText('目标方式'), 'groups');
await user.click(within(dialog).getByRole('button', { name: '外场' }));
await user.click(within(dialog).getByRole('button', { name: '办公室' }));
await user.clear(within(dialog).getByLabelText('随机延迟(秒)'));
await user.type(within(dialog).getByLabelText('随机延迟(秒)'), '30');
await user.click(within(dialog).getByRole('button', { name: '检查并继续' }));
const confirmation = within(dialog).getByRole('group', { name: '最终确认' });
expect(within(confirmation).getByText('发送短信')).not.toBeNull();
expect(within(confirmation).getByText('分组:外场')).not.toBeNull();
expect(within(confirmation).getByText('2 个分组')).not.toBeNull();
await user.click(within(confirmation).getByRole('checkbox', { name: /我已核对/ }));
await user.click(within(dialog).getByRole('button', { name: '确认并创建' }));
@@ -215,7 +219,7 @@ describe('AutomationPage', () => {
expect(createSchedule).toHaveBeenCalledWith(
expect.objectContaining({
operationType: 'send-sms',
targetSelector: { mode: 'group', groupId: 'g1' },
targetSelector: { mode: 'groups', groupIds: ['g1', 'g2'] },
sms: expect.objectContaining({
recipients: ['13800138000'],
content: '巡检开始',
+37 -3
View File
@@ -78,6 +78,7 @@ function targetLabel(task: ScheduledTask, groupName?: (id: string) => string): s
if (selector.mode === 'all') return '全部设备';
if (selector.mode === 'group')
return `分组:${groupName?.(selector.groupId) ?? selector.groupId}`;
if (selector.mode === 'groups') return `${selector.groupIds.length} 个分组`;
if (selector.mode === 'fixed') return `${selector.instanceIds.length} 个固定实例`;
return `${selector.match === 'all' ? '全部匹配' : '任一匹配'}${selector.tags.join('、')}`;
}
@@ -112,11 +113,12 @@ function outcomeLabel(value: ScheduledRun['outcome']): string {
interface EditorForm {
name: string;
operationType: ScheduledTask['operationType'];
targetMode: 'fixed' | 'tags' | 'group' | 'all';
targetMode: 'fixed' | 'tags' | 'group' | 'groups' | 'all';
fixedIds: string;
tags: string;
tagMatch: 'any' | 'all';
groupId: string;
groupIds: readonly string[];
recipients: string;
content: string;
triggerKind: ScheduleTrigger['kind'];
@@ -143,6 +145,7 @@ const defaults: EditorForm = {
tags: '',
tagMatch: 'any' as 'any' | 'all',
groupId: '',
groupIds: [],
recipients: '',
content: '',
triggerKind: 'cron' as ScheduleTrigger['kind'],
@@ -278,6 +281,7 @@ export function AutomationPage({
tags: task.targetSelector.mode === 'tags' ? task.targetSelector.tags.join(', ') : '',
tagMatch: task.targetSelector.mode === 'tags' ? task.targetSelector.match : 'any',
groupId: task.targetSelector.mode === 'group' ? task.targetSelector.groupId : '',
groupIds: task.targetSelector.mode === 'groups' ? [...task.targetSelector.groupIds] : [],
recipients: '',
content: '',
triggerKind: trigger.kind,
@@ -343,7 +347,9 @@ export function AutomationPage({
cronExpression: form.cronExpression.trim(),
timezone: 'Asia/Shanghai',
targetSelector:
form.targetMode === 'group'
form.targetMode === 'groups'
? { mode: 'groups', groupIds: [...form.groupIds] }
: form.targetMode === 'group'
? { mode: 'group', groupId: form.groupId.trim() }
: form.targetMode === 'all'
? { mode: 'all' }
@@ -719,13 +725,36 @@ export function AutomationPage({
>
<option value="fixed"></option>
<option value="tags"></option>
<option value="group"></option>
<option value="groups"></option>
<option value="group"></option>
<option value="all"></option>
</select>
</label>
{form.targetMode === 'all' ? (
<p className="field-note"></p>
) : null}
{form.targetMode === 'groups' ? (
<div className="organization-groups" role="group" aria-label="目标分组">
{groupOptions.map((group) => (
<button
key={group.id}
type="button"
className={`organization-group${form.groupIds.includes(group.id) ? ' is-active' : ''}`}
aria-pressed={form.groupIds.includes(group.id)}
onClick={() =>
field(
'groupIds',
form.groupIds.includes(group.id)
? form.groupIds.filter((id) => id !== group.id)
: [...form.groupIds, group.id],
)
}
>
{group.name}
</button>
))}
</div>
) : null}
{form.targetMode === 'group' ? (
<label>
<span></span>
@@ -816,6 +845,9 @@ export function AutomationPage({
onChange={(event) => field('content', event.currentTarget.value)}
/>
</label>
<p className="field-note">
<code>{'${time}'}</code> <code>{'${random}'}</code>
</p>
<label>
<span></span>
<input
@@ -1061,6 +1093,8 @@ export function AutomationPage({
<dd>
{form.targetMode === 'all'
? '全部设备'
: form.targetMode === 'groups'
? `${form.groupIds.length} 个分组`
: form.targetMode === 'group'
? `分组:${groupOptions.find((group) => group.id === form.groupId)?.name ?? form.groupId}`
: form.targetMode === 'fixed'
@@ -12,6 +12,7 @@ const catalog = {
rows: 7,
},
],
directory: '/Users/chick/.local/opt/multi-simadmin/data/backups',
};
const backup = {
@@ -19,6 +20,7 @@ const backup = {
createdAt: '2026-09-04T03:30:00.000Z',
sizeBytes: 4096,
appVersion: '0.1.0',
formatVersion: 1,
note: '升级前',
automatic: true,
integrity: 'ok',
@@ -48,7 +50,7 @@ describe('component backup API data source', () => {
const source = createComponentBackupApiDataSource(fetcher);
const signal = new AbortController().signal;
await expect(source.catalog(signal)).resolves.toEqual(catalog.items);
await expect(source.catalog(signal)).resolves.toEqual(catalog);
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');
@@ -61,12 +63,16 @@ describe('component backup API data source', () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(json(backup, 201))
.mockResolvedValueOnce(json({ devices: 7 }))
.mockResolvedValueOnce(
json({ written: { devices: 7 }, safetyBackup: 'multi-simadmin-components-auto-x.json' }),
)
.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.restore(backup.filename, ['devices'])).resolves.toBe(
'multi-simadmin-components-auto-x.json',
);
await expect(source.remove(backup.filename)).resolves.toBeUndefined();
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({
@@ -79,6 +85,20 @@ describe('component backup API data source', () => {
expect(fetcher.mock.calls[2]?.[1]).toMatchObject({ method: 'DELETE' });
});
it('previews an archive and builds a safe download URL', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(json(backup));
const source = createComponentBackupApiDataSource(fetcher);
await expect(source.preview(backup.filename)).resolves.toEqual(backup);
expect(source.backupDownloadUrl(backup.filename)).toBe(
`/api/v1/system/component-backups/${encodeURIComponent(backup.filename)}/download`,
);
expect(fetcher.mock.calls[0]?.[0]).toBe(
`/api/v1/system/component-backups/${encodeURIComponent(backup.filename)}/preview`,
);
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ method: 'GET' });
});
it('saves the schedule and rejects an unknown component key', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json(settings));
const source = createComponentBackupApiDataSource(fetcher);
@@ -111,4 +131,18 @@ describe('component backup API data source', () => {
createComponentBackupApiDataSource(fetcher).remove('../secret.json'),
).rejects.toThrowError('Component backup response is invalid.');
});
it('refuses a catalog that does not say where the backups live', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json({ items: catalog.items }));
await expect(createComponentBackupApiDataSource(fetcher).catalog()).rejects.toThrowError(
'Component backup response is invalid.',
);
});
it('refuses a restore that cannot prove it took a safety snapshot first', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json({ written: { devices: 7 } }));
await expect(
createComponentBackupApiDataSource(fetcher).restore(backup.filename, ['devices']),
).rejects.toThrowError('Component backup response is invalid.');
});
});
@@ -4,6 +4,7 @@ export type BackupComponentKey =
| 'notificationRecords'
| 'automation'
| 'automationRecords'
| 'jobs'
| 'sms'
| 'settings'
| 'audit';
@@ -20,6 +21,7 @@ export type ComponentBackup = Readonly<{
createdAt: string;
sizeBytes: number;
appVersion: string;
formatVersion: number;
note: string;
automatic: boolean;
integrity: 'ok' | 'failed';
@@ -36,12 +38,20 @@ export type AutoBackupSettings = Readonly<{
lastRunAt: string | null;
}>;
export type ComponentCatalog = Readonly<{
items: readonly BackupComponent[];
directory: string;
}>;
export interface ComponentBackupDataSource {
catalog(signal?: AbortSignal): Promise<readonly BackupComponent[]>;
catalog(signal?: AbortSignal): Promise<ComponentCatalog>;
list(signal?: AbortSignal): Promise<readonly ComponentBackup[]>;
create(components: readonly BackupComponentKey[], note: string): Promise<ComponentBackup>;
restore(filename: string, components: readonly BackupComponentKey[]): Promise<void>;
/** Resolves to the snapshot taken just before the merge overwrote the selected units. */
restore(filename: string, components: readonly BackupComponentKey[]): Promise<string>;
remove(filename: string): Promise<void>;
preview(filename: string): Promise<ComponentBackup>;
backupDownloadUrl(filename: string): string;
autoSettings(signal?: AbortSignal): Promise<AutoBackupSettings>;
saveAutoSettings(
settings: Readonly<{
@@ -57,6 +67,7 @@ export interface ComponentBackupDataSource {
const BASE = '/api/v1/system/component-backups';
const MAX_TEXT = 200;
const MAX_BACKUPS = 200;
const MAX_PATH = 300;
const BACKUP_FILENAME = /^multi-simadmin-components-(?:auto-)?[A-Za-z0-9._-]{1,120}\.json$/u;
const KEYS: readonly BackupComponentKey[] = [
'devices',
@@ -64,6 +75,7 @@ const KEYS: readonly BackupComponentKey[] = [
'notificationRecords',
'automation',
'automationRecords',
'jobs',
'sms',
'settings',
'audit',
@@ -116,6 +128,7 @@ function parseBackup(value: unknown): ComponentBackup {
!shortText(source.filename) ||
!BACKUP_FILENAME.test(source.filename) ||
!text(source.appVersion) ||
!integer(source.formatVersion, 0, 1) ||
!text(source.note) ||
typeof source.automatic !== 'boolean' ||
(source.integrity !== 'ok' && source.integrity !== 'failed') ||
@@ -130,6 +143,7 @@ function parseBackup(value: unknown): ComponentBackup {
createdAt: new Date(createdAt).toISOString(),
sizeBytes: source.sizeBytes,
appVersion: source.appVersion,
formatVersion: source.formatVersion,
note: source.note,
automatic: source.automatic,
integrity: source.integrity,
@@ -195,8 +209,10 @@ export function createComponentBackupApiDataSource(
return {
async catalog(signal) {
const source = record(await read(`${BASE}/catalog`, signal));
if (!source || !Array.isArray(source.items)) throw invalid();
return parseComponentList(source.items);
if (!source || !Array.isArray(source.items) || typeof source.directory !== 'string')
throw invalid();
if (source.directory.length > MAX_PATH) throw invalid();
return { items: parseComponentList(source.items), directory: source.directory };
},
async list(signal) {
const source = record(await read(BASE, signal));
@@ -227,7 +243,10 @@ export function createComponentBackupApiDataSource(
headers: jsonHeaders,
body: JSON.stringify({ components }),
});
requireOk(response, await readJson(response));
const body = record(requireOk(response, await readJson(response)));
if (!body || typeof body.safetyBackup !== 'string' || body.safetyBackup.length > MAX_PATH)
throw invalid();
return body.safetyBackup;
},
async remove(filename) {
const response = await fetcher(`${BASE}/${encodeURIComponent(safeName(filename))}`, {
@@ -237,6 +256,12 @@ export function createComponentBackupApiDataSource(
});
requireOk(response, await readJson(response));
},
async preview(filename) {
return parseBackup(await read(`${BASE}/${encodeURIComponent(safeName(filename))}/preview`));
},
backupDownloadUrl(filename) {
return `${BASE}/${encodeURIComponent(safeName(filename))}/download`;
},
async autoSettings(signal) {
return parseAutoSettings(await read(`${BASE}/auto/settings`, signal));
},
@@ -7,6 +7,7 @@ import {
ComponentBackupPanel,
type AutoBackupSettings,
type BackupComponent,
type ComponentCatalog,
type ComponentBackup,
type ComponentBackupDataSource,
} from './component-backup-panel.js';
@@ -27,13 +28,19 @@ const sms: BackupComponent = {
rows: 120,
};
const catalog: readonly BackupComponent[] = [devices, sms];
const catalogItems: readonly BackupComponent[] = [devices, sms];
const catalog: ComponentCatalog = {
items: catalogItems,
directory: '/var/lib/multi-simadmin/backups',
};
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',
formatVersion: 1,
note: '升级前快照',
automatic: false,
integrity: 'ok',
@@ -44,9 +51,9 @@ const archive: ComponentBackup = {
const schedule: AutoBackupSettings = {
enabled: false,
components: ['devices'],
timeOfDay: '03:30',
timeOfDay: '02:00',
weekday: -1,
maximumCount: 14,
maximumCount: 10,
lastRunAt: null,
};
@@ -55,10 +62,14 @@ function source(overrides: Partial<ComponentBackupDataSource> = {}): ComponentBa
catalog: vi.fn(async () => catalog),
list: vi.fn(async () => [archive]),
create: vi.fn(async () => archive),
restore: vi.fn(async () => undefined),
restore: vi.fn(async () => 'multi-simadmin-components-auto-safety.json'),
remove: vi.fn(async () => undefined),
autoSettings: vi.fn(async () => schedule),
saveAutoSettings: vi.fn(async (value) => ({ ...schedule, ...value, lastRunAt: null })),
preview: vi.fn(async () => archive),
backupDownloadUrl: vi.fn(
(filename) => `/api/v1/system/component-backups/${encodeURIComponent(filename)}/download`,
),
...overrides,
};
}
@@ -71,7 +82,7 @@ async function mount(dataSource: ComponentBackupDataSource): Promise<HTMLElement
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);
expect(within(group).getAllByRole('checkbox')).toHaveLength(catalogItems.length);
});
return group;
}
@@ -85,6 +96,8 @@ describe('ComponentBackupPanel', () => {
expect(within(region).getByText('手动')).toBeTruthy();
expect(within(region).getByText('正常')).toBeTruthy();
expect(within(region).getByText(/上次执行:尚未执行/)).toBeTruthy();
expect(within(region).getByText('/var/lib/multi-simadmin/backups')).toBeTruthy();
expect(within(region).getByText('升级前快照')).toBeTruthy();
});
it('creates a backup from the components the operator selects', async () => {
@@ -108,17 +121,40 @@ describe('ComponentBackupPanel', () => {
it('restores only the components that stay checked', async () => {
const dataSource = source();
const completed = vi.fn();
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
const region = await mount(dataSource);
render(<ComponentBackupPanel dataSource={dataSource} onCompleted={completed} />);
const region = await screen.findByRole('region', { name: '组件备份' });
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(completed).toHaveBeenCalledWith(
'设备与分组已合并恢复,恢复前的状态保存在 multi-simadmin-components-auto-safety.json。',
);
expect(confirm).toHaveBeenCalledTimes(1);
confirm.mockRestore();
});
it('previews an archive and links it for download', async () => {
const dataSource = source();
const region = await mount(dataSource);
const download = within(region).getByRole('link', { name: '下载' });
expect(download.getAttribute('href')).toBe(
`/api/v1/system/component-backups/${encodeURIComponent(archive.filename)}/download`,
);
expect(download.getAttribute('download')).toBe(archive.filename);
await userEvent.click(within(region).getByRole('button', { name: '预览' }));
const preview = await screen.findByRole('group', { name: '备份预览' });
expect(dataSource.preview).toHaveBeenCalledWith(archive.filename);
expect(within(preview).getByText('0.1.0')).toBeTruthy();
expect(within(preview).getByText('1')).toBeTruthy();
expect(within(preview).getByText('设备与分组 · 7 条')).toBeTruthy();
});
it('does not restore when the operator cancels the confirmation', async () => {
const dataSource = source();
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
@@ -150,7 +186,7 @@ describe('ComponentBackupPanel', () => {
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.objectContaining({ enabled: true, timeOfDay: '02:00', maximumCount: 10 }),
);
expect(completed).toHaveBeenCalledWith('定时组件备份计划已保存。');
});
@@ -14,6 +14,7 @@ export type {
AutoBackupSettings,
BackupComponent,
BackupComponentKey,
ComponentCatalog,
ComponentBackup,
ComponentBackupDataSource,
} from './component-backup-api-data-source.js';
@@ -43,7 +44,14 @@ function formatDateTime(value: string): string {
);
}
type Busy = 'create' | 'restore' | 'delete' | 'schedule' | undefined;
/** The note is the only thing that tells a safety snapshot apart from a scheduled backup. */
function originOf(backup: ComponentBackup): string {
if (backup.note.startsWith('恢复前自动备份:')) return '恢复前';
if (backup.note.startsWith('更新前自动备份:')) return '更新前';
return backup.automatic ? '定时' : '手动';
}
type Busy = 'create' | 'restore' | 'delete' | 'schedule' | 'preview' | undefined;
/** Component-scoped export, merge and scheduled backup, mirroring the Hub backup centre. */
export function ComponentBackupPanel({
@@ -55,6 +63,7 @@ export function ComponentBackupPanel({
}) {
const [source] = useState(() => dataSource ?? createComponentBackupApiDataSource());
const [catalog, setCatalog] = useState<readonly BackupComponent[]>([]);
const [directory, setDirectory] = useState('');
const [backups, setBackups] = useState<readonly ComponentBackup[]>([]);
const [schedule, setSchedule] = useState<AutoBackupSettings>();
const [selected, setSelected] = useState<ReadonlySet<BackupComponentKey>>(
@@ -65,6 +74,7 @@ export function ComponentBackupPanel({
const [restoreSelection, setRestoreSelection] = useState<ReadonlySet<BackupComponentKey>>(
() => new Set<BackupComponentKey>(),
);
const [previewTarget, setPreviewTarget] = useState<ComponentBackup>();
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [busy, setBusy] = useState<Busy>();
@@ -83,7 +93,8 @@ export function ComponentBackupPanel({
]).then(
([nextCatalog, nextBackups, nextSchedule]) => {
if (!active) return;
setCatalog(nextCatalog);
setCatalog(nextCatalog.items);
setDirectory(nextCatalog.directory);
setBackups(nextBackups);
setSchedule(nextSchedule);
setLoading(false);
@@ -106,7 +117,8 @@ export function ComponentBackupPanel({
source.list(),
source.autoSettings(),
]);
setCatalog(nextCatalog);
setCatalog(nextCatalog.items);
setDirectory(nextCatalog.directory);
setBackups(nextBackups);
setSchedule(nextSchedule);
}, [source]);
@@ -137,10 +149,25 @@ export function ComponentBackupPanel({
const openRestore = (backup: ComponentBackup) => {
setError('');
setPreviewTarget(undefined);
setRestoreTarget(backup);
setRestoreSelection(new Set(backup.components.map((component) => component.key)));
};
const openPreview = async (backup: ComponentBackup) => {
if (busy) return;
setError('');
setBusy('preview');
try {
setPreviewTarget(await source.preview(backup.filename));
} catch {
setPreviewTarget(undefined);
setError('无法读取组件备份预览,请稍后重试。');
} finally {
setBusy(undefined);
}
};
const confirmRestore = () =>
run('restore', async () => {
if (!restoreTarget) return '';
@@ -156,10 +183,12 @@ export function ComponentBackupPanel({
)
)
return '';
await source.restore(restoreTarget.filename, components);
const safety = await source.restore(restoreTarget.filename, components);
setRestoreTarget(undefined);
await reload();
return `${labels}已合并恢复。`;
return safety
? `${labels}已合并恢复,恢复前的状态保存在 ${safety}`
: `${labels}已合并恢复。`;
});
const remove = (backup: ComponentBackup) =>
@@ -214,6 +243,12 @@ export function ComponentBackupPanel({
<section aria-label="组件备份">
<h2></h2>
<p className="maintenance-hint"></p>
{directory ? (
<p className="maintenance-hint backup-directory">
<span></span>
<code title={directory}>{directory}</code>
</p>
) : null}
{loading ? (
<p role="status" aria-label="组件备份加载状态">
@@ -290,7 +325,12 @@ export function ComponentBackupPanel({
<tbody>
{backups.map((backup) => (
<tr key={backup.filename}>
<td>{formatDateTime(backup.createdAt)}</td>
<td>
{formatDateTime(backup.createdAt)}
{backup.note ? (
<small className="backup-note">{backup.note}</small>
) : null}
</td>
<td>
<span
className="backup-components"
@@ -301,7 +341,7 @@ export function ComponentBackupPanel({
</span>
</td>
<td>{formatBytes(backup.sizeBytes)}</td>
<td>{backup.automatic ? '定时' : '手动'}</td>
<td>{originOf(backup)}</td>
<td>
{backup.integrity === 'ok' ? (
<Tag size="small" color="app-green" variant="soft">
@@ -314,6 +354,19 @@ export function ComponentBackupPanel({
)}
</td>
<td className="maintenance-backup-actions">
<Button
htmlType="button"
disabled={busy !== undefined || backup.integrity !== 'ok'}
onClick={() => void openPreview(backup)}
>
</Button>
<a
href={source.backupDownloadUrl(backup.filename)}
download={backup.filename}
>
</a>
<Button
htmlType="button"
disabled={
@@ -339,6 +392,66 @@ export function ComponentBackupPanel({
</div>
)}
{previewTarget ? (
<div className="backup-preview-panel" role="group" aria-label="备份预览">
<h3>{previewTarget.filename}</h3>
<div className="backup-preview-grid">
<span></span>
<strong>{formatDateTime(previewTarget.createdAt)}</strong>
<span></span>
<strong>{formatBytes(previewTarget.sizeBytes)}</strong>
<span></span>
<strong>{originOf(previewTarget)}</strong>
<span></span>
<strong>{previewTarget.appVersion || '未知'}</strong>
<span></span>
<strong>{previewTarget.formatVersion}</strong>
<span></span>
<strong>
{previewTarget.integrity === 'ok'
? previewTarget.compatible
? '校验通过,可恢复'
: '校验通过,版本不兼容'
: '校验失败'}
</strong>
</div>
{previewTarget.note ? (
<p className="maintenance-hint">{previewTarget.note}</p>
) : null}
{previewTarget.components.length === 0 ? (
<p className="maintenance-hint"></p>
) : (
<ul className="backup-preview-components">
{previewTarget.components.map((component) => (
<li key={component.key}>
{component.label} · {component.rows.toLocaleString('zh-CN')}
</li>
))}
</ul>
)}
<div className="action-buttons">
<Button
htmlType="button"
disabled={
busy !== undefined ||
previewTarget.integrity !== 'ok' ||
!previewTarget.compatible
}
onClick={() => openRestore(previewTarget)}
>
</Button>
<Button
htmlType="button"
disabled={busy !== undefined}
onClick={() => setPreviewTarget(undefined)}
>
</Button>
</div>
</div>
) : null}
{restoreTarget ? (
<div className="backup-restore-panel" role="group" aria-label="选择恢复组件">
<h3> {restoreTarget.filename} </h3>
@@ -8,7 +8,7 @@ import type { SystemMaintenanceDataSource } from './system-maintenance-api-data-
afterEach(cleanup);
const defaults = { heartbeatSeconds: 30, offlineSeconds: 90 };
const defaults = { heartbeatSeconds: 30, offlineSeconds: 90, authorizationMode: 'auto' };
function source(overrides: Partial<SystemMaintenanceDataSource> = {}): SystemMaintenanceDataSource {
return {
@@ -36,6 +36,10 @@ function field(region: HTMLElement, label: string): HTMLInputElement {
return node;
}
function modeButton(region: HTMLElement, label: string): HTMLButtonElement {
return within(region).getByRole('button', { name: label }) as HTMLButtonElement;
}
describe('ConnectionSettingsPanel', () => {
it('loads the saved cadence into the inputs', async () => {
const { data, region } = await mount();
@@ -44,6 +48,30 @@ describe('ConnectionSettingsPanel', () => {
expect(field(region, '离线判定(秒)').value).toBe('90');
});
it('starts new-device authorization in automatic mode', async () => {
const { region } = await mount();
expect(modeButton(region, '自动授权').getAttribute('aria-pressed')).toBe('true');
expect(modeButton(region, '人工确认').getAttribute('aria-pressed')).toBe('false');
});
it('saves a selected manual authorization mode and echoes it', async () => {
const { data, region } = await mount();
await userEvent.click(modeButton(region, '人工确认'));
const save = within(region).getByRole('button', { name: '保存设置' }) as HTMLButtonElement;
expect(save.disabled).toBe(false);
await userEvent.click(save);
expect(data.updateConnection).toHaveBeenCalledWith({
heartbeatSeconds: 30,
offlineSeconds: 90,
authorizationMode: 'manual',
});
const notice = await within(region).findByRole('status');
expect(notice.textContent).toContain('新设备人工确认');
});
it('keeps save disabled until a valid change is made', async () => {
const { region } = await mount();
const save = within(region).getByRole('button', { name: '保存设置' }) as HTMLButtonElement;
@@ -69,9 +97,12 @@ describe('ConnectionSettingsPanel', () => {
expect(data.updateConnection).toHaveBeenCalledWith({
heartbeatSeconds: 45,
offlineSeconds: 180,
authorizationMode: 'auto',
});
const notice = await within(region).findByRole('status');
expect(notice.textContent).toContain('连接设置已保存,心跳 45 秒,离线判定 180 秒。');
expect(notice.textContent).toContain(
'连接设置已保存,新设备自动授权,心跳 45 秒,离线判定 180 秒。',
);
});
it('blocks a save when the offline window drops below twice the heartbeat', async () => {
@@ -103,7 +134,7 @@ describe('ConnectionSettingsPanel', () => {
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(problem.textContent).toContain('连接设置未保存,请检查各项设置的取值范围。');
expect(field(region, '心跳间隔(秒)').value).toBe('60');
});
@@ -31,6 +31,7 @@ export function ConnectionSettingsPanel({
const [settings, setSettings] = useState<ConnectionSettings>();
const [heartbeat, setHeartbeat] = useState('');
const [offline, setOffline] = useState('');
const [authorizationMode, setAuthorizationMode] = useState<'auto' | 'manual'>('auto');
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [busy, setBusy] = useState<'save' | 'refresh' | undefined>();
@@ -45,6 +46,7 @@ export function ConnectionSettingsPanel({
setSettings(value);
setHeartbeat(String(value.heartbeatSeconds));
setOffline(String(value.offlineSeconds));
setAuthorizationMode(value.authorizationMode);
})
.catch(() => active && setError('无法读取连接设置,请刷新页面重试。'));
return () => {
@@ -57,7 +59,9 @@ export function ConnectionSettingsPanel({
const offlineSeconds = clampSeconds(offline, (heartbeatSeconds ?? 0) * 2, MAX_OFFLINE_SECONDS);
const dirty =
settings !== undefined &&
(heartbeatSeconds !== settings.heartbeatSeconds || offlineSeconds !== settings.offlineSeconds);
(heartbeatSeconds !== settings.heartbeatSeconds ||
offlineSeconds !== settings.offlineSeconds ||
authorizationMode !== settings.authorizationMode);
const run = async (action: 'save' | 'refresh', work: () => Promise<string>) => {
if (busy) return;
@@ -69,7 +73,7 @@ export function ConnectionSettingsPanel({
} catch {
setError(
action === 'save'
? '连接设置未保存,请检查心跳与离线判定的取值范围。'
? '连接设置未保存,请检查各项设置的取值范围。'
: '设备刷新未完成,请稍后重试。',
);
} finally {
@@ -84,7 +88,7 @@ export function ConnectionSettingsPanel({
<div>
<h2></h2>
<p className="maintenance-hint">
线线
线
</p>
</div>
<Button
@@ -135,6 +139,33 @@ export function ConnectionSettingsPanel({
</div>
</div>
<div className="connection-mode">
<span className="connection-mode-label"></span>
<div className="segmented-control connection-mode-control" role="group">
{(
[
['auto', '自动授权'],
['manual', '人工确认'],
] as const
).map(([mode, label]) => (
<button
key={mode}
type="button"
className={authorizationMode === mode ? 'is-active' : undefined}
aria-pressed={authorizationMode === mode}
onClick={() => setAuthorizationMode(mode)}
>
{label}
</button>
))}
</div>
<p className="maintenance-hint">
{authorizationMode === 'manual'
? '首次上报身份的新设备会等待确认,确认前不执行控制操作。'
: '首次上报身份的新设备自动建立可信基线。'}
</p>
</div>
<div className="action-buttons">
<Button
htmlType="button"
@@ -151,11 +182,15 @@ export function ConnectionSettingsPanel({
const saved = await source.updateConnection({
heartbeatSeconds: heartbeatSeconds as number,
offlineSeconds: offlineSeconds as number,
authorizationMode,
});
setSettings(saved);
setHeartbeat(String(saved.heartbeatSeconds));
setOffline(String(saved.offlineSeconds));
return `连接设置已保存,心跳 ${saved.heartbeatSeconds} 秒,离线判定 ${saved.offlineSeconds} 秒。`;
setAuthorizationMode(saved.authorizationMode);
const modeLabel =
saved.authorizationMode === 'manual' ? '新设备人工确认' : '新设备自动授权';
return `连接设置已保存,${modeLabel},心跳 ${saved.heartbeatSeconds} 秒,离线判定 ${saved.offlineSeconds} 秒。`;
})
}
>
@@ -0,0 +1,162 @@
import { describe, expect, it, vi } from 'vitest';
import {
BUSY_UPDATE_PHASES,
createConsoleUpdateApiDataSource,
UPDATE_PHASES,
} from './console-update-api-data-source.js';
const status = {
phase: 'update_available',
message: '发现 3 个新提交。',
error: '',
progressPercent: 0,
checkedAt: '2026-09-06T00:00:00.000Z',
deploymentMode: 'git',
installSupported: true,
restartSupported: false,
release: {
status: 'update_available',
remote: 'origin',
branch: 'main',
currentCommit: 'a'.repeat(40),
latestCommit: 'b'.repeat(40),
currentVersion: '0.1.0',
latestVersion: '0.9.0',
ahead: 3,
changedFiles: 12,
changedLines: 340,
summary: 'feat: 在线更新',
},
};
function reply(body: unknown, init: { status?: number; contentType?: string } = {}): Response {
const { status: code = 200, contentType = 'application/json' } = init;
return new Response(JSON.stringify(body), {
status: code,
headers: { 'content-type': contentType },
});
}
describe('createConsoleUpdateApiDataSource', () => {
it('reads the status without a body', async () => {
const fetcher = vi.fn(async () => reply(status));
const parsed = await createConsoleUpdateApiDataSource(
fetcher as unknown as typeof fetch,
).status();
expect(fetcher).toHaveBeenCalledWith(
'/api/v1/system/update',
expect.objectContaining({ method: 'GET' }),
);
expect(parsed.release?.latestVersion).toBe('0.9.0');
expect(parsed.checkedAt).toBe('2026-09-06T00:00:00.000Z');
});
it('posts every action to its own route', async () => {
const calls: string[] = [];
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
calls.push(String(input));
return reply({ ...status, phase: 'idle', release: null });
});
const dataSource = createConsoleUpdateApiDataSource(fetcher as unknown as typeof fetch);
await dataSource.check();
await dataSource.download();
await dataSource.install();
await dataSource.restart();
expect(calls).toEqual([
'/api/v1/system/update/check',
'/api/v1/system/update/download',
'/api/v1/system/update/install',
'/api/v1/system/restart',
]);
});
it('accepts a null release and the never-checked timestamp', async () => {
const fetcher = vi.fn(async () => reply({ ...status, release: null, checkedAt: '' }));
const parsed = await createConsoleUpdateApiDataSource(
fetcher as unknown as typeof fetch,
).status();
expect(parsed.release).toBeNull();
expect(parsed.checkedAt).toBe('');
});
it('surfaces the problem detail so the operator sees the real reason', async () => {
const fetcher = vi.fn(async () =>
reply(
{
type: 'about:blank',
title: 'Bad Request',
status: 400,
code: 'UPDATE_STATE',
detail: '工作区存在未提交修改,无法安全更新。',
},
{ status: 400, contentType: 'application/problem+json' },
),
);
await expect(
createConsoleUpdateApiDataSource(fetcher as unknown as typeof fetch).install(),
).rejects.toThrow('工作区存在未提交修改,无法安全更新。');
});
it('falls back to a generic failure when the body is unusable', async () => {
const fetcher = vi.fn(async () => new Response('nope', { status: 502 }));
await expect(
createConsoleUpdateApiDataSource(fetcher as unknown as typeof fetch).check(),
).rejects.toThrow('更新请求失败。');
});
it('rejects responses that do not match the contract', async () => {
const cases: unknown[] = [
{ ...status, phase: 'installing ' },
{ ...status, progressPercent: 101 },
{ ...status, checkedAt: 'not a date' },
{ ...status, deploymentMode: 'docker' },
{ ...status, installSupported: 'yes' },
{ ...status, release: { ...status.release, ahead: -1 } },
{ ...status, release: { ...status.release, latestCommit: '../../etc/passwd' } },
{ ...status, release: { ...status.release, remote: 'or ign' } },
{ ...status, release: { ...status.release, summary: 'x'.repeat(600) } },
{ ...status, release: { ...status.release, status: 'pending' } },
'nope',
];
for (const body of cases) {
const fetcher = vi.fn(async () => reply(body));
await expect(
createConsoleUpdateApiDataSource(fetcher as unknown as typeof fetch).status(),
).rejects.toThrow('Update response is invalid.');
}
});
it('forwards the abort signal so an unmount cancels the read', async () => {
const fetcher = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.signal?.aborted) throw new Error('aborted');
return reply(status);
});
const controller = new AbortController();
controller.abort();
await expect(
createConsoleUpdateApiDataSource(fetcher as unknown as typeof fetch).status(
controller.signal,
),
).rejects.toThrow('aborted');
});
it('keeps the phase vocabulary in step with the API', () => {
expect([...UPDATE_PHASES]).toEqual([
'idle',
'checking',
'up_to_date',
'update_available',
'downloading',
'ready',
'installing',
'install_queued',
'restarting',
'failed',
'rolled_back',
]);
expect([...BUSY_UPDATE_PHASES].sort()).toEqual(
['checking', 'downloading', 'install_queued', 'installing', 'restarting'].sort(),
);
});
});
@@ -0,0 +1,195 @@
export const UPDATE_PHASES = [
'idle',
'checking',
'up_to_date',
'update_available',
'downloading',
'ready',
'installing',
'install_queued',
'restarting',
'failed',
'rolled_back',
] as const;
export type UpdatePhase = (typeof UPDATE_PHASES)[number];
/** Phases where the console is busy: the panel polls fast and disables every button. */
export const BUSY_UPDATE_PHASES: ReadonlySet<UpdatePhase> = new Set<UpdatePhase>([
'checking',
'downloading',
'installing',
'install_queued',
'restarting',
]);
export type UpdateRelease = Readonly<{
status: 'latest' | 'update_available' | 'unavailable';
remote: string;
branch: string;
currentCommit: string;
latestCommit: string;
currentVersion: string;
latestVersion: string;
ahead: number;
changedFiles: number;
changedLines: number;
summary: string;
}>;
export type UpdateStatus = Readonly<{
phase: UpdatePhase;
message: string;
error: string;
progressPercent: number;
checkedAt: string;
deploymentMode: 'git' | 'manual';
installSupported: boolean;
restartSupported: boolean;
release: UpdateRelease | null;
}>;
export interface ConsoleUpdateDataSource {
status(signal?: AbortSignal): Promise<UpdateStatus>;
check(): Promise<UpdateStatus>;
download(): Promise<UpdateStatus>;
install(): Promise<UpdateStatus>;
restart(): Promise<UpdateStatus>;
}
const MAX_TEXT = 512;
const COMMIT = /^[0-9a-f]{7,64}$/iu;
const VERSION = /^[A-Za-z0-9._+-]{1,64}$/u;
const REF_NAME = /^[A-Za-z0-9._/-]{1,128}$/u;
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, maximum = Number.MAX_SAFE_INTEGER): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= maximum;
const invalid = () => new Error('Update response is invalid.');
function hash(value: unknown): value is string {
return text(value) && (value === '' || COMMIT.test(value));
}
function version(value: unknown): value is string {
return text(value) && (value === '' || VERSION.test(value));
}
function parseRelease(value: unknown): UpdateRelease | null {
if (value === null) return null;
const source = record(value);
const status = source?.status;
if (
!source ||
(status !== 'latest' && status !== 'update_available' && status !== 'unavailable') ||
!shortText(source.remote) ||
!REF_NAME.test(source.remote) ||
!shortText(source.branch) ||
!REF_NAME.test(source.branch) ||
!hash(source.currentCommit) ||
!hash(source.latestCommit) ||
!version(source.currentVersion) ||
!version(source.latestVersion) ||
!integer(source.ahead, 100_000) ||
!integer(source.changedFiles, 1_000_000) ||
!integer(source.changedLines, 1_000_000_000) ||
!text(source.summary)
)
throw invalid();
return {
status,
remote: source.remote,
branch: source.branch,
currentCommit: source.currentCommit,
latestCommit: source.latestCommit,
currentVersion: source.currentVersion,
latestVersion: source.latestVersion,
ahead: source.ahead,
changedFiles: source.changedFiles,
changedLines: source.changedLines,
summary: source.summary,
};
}
/** An empty timestamp means the console has never checked for an update. */
function parseCheckedAt(value: unknown): string {
if (typeof value !== 'string') throw invalid();
if (value === '') return '';
const parsed = Date.parse(value);
if (Number.isNaN(parsed)) throw invalid();
return new Date(parsed).toISOString();
}
function parseStatus(value: unknown): UpdateStatus {
const source = record(value);
if (
!source ||
!UPDATE_PHASES.includes(source.phase as UpdatePhase) ||
!text(source.message) ||
!text(source.error) ||
!integer(source.progressPercent, 100) ||
(source.deploymentMode !== 'git' && source.deploymentMode !== 'manual') ||
typeof source.installSupported !== 'boolean' ||
typeof source.restartSupported !== 'boolean'
)
throw invalid();
return {
phase: source.phase as UpdatePhase,
message: source.message,
error: source.error,
progressPercent: source.progressPercent,
checkedAt: parseCheckedAt(source.checkedAt),
deploymentMode: source.deploymentMode,
installSupported: source.installSupported,
restartSupported: source.restartSupported,
release: parseRelease(source.release),
};
}
async function readJson(response: Response): Promise<unknown> {
try {
return await response.json();
} catch {
return undefined;
}
}
/** Failures arrive as RFC 7807 documents; reuse the human-readable detail for the operator. */
function requireOk(response: Response, body: unknown): unknown {
if (!response.ok) {
const problem = record(body);
const detail = typeof problem?.detail === 'string' ? problem.detail.slice(0, MAX_TEXT) : '';
throw new Error(detail || '更新请求失败。');
}
if (body === undefined || body === null) throw new Error('更新请求失败。');
return body;
}
export function createConsoleUpdateApiDataSource(
fetcher: typeof fetch = fetch,
): ConsoleUpdateDataSource {
const send = async (path: string, method: 'GET' | 'POST', signal?: AbortSignal) => {
const response = await fetcher(path, {
method,
credentials: 'same-origin',
headers: { accept: 'application/json' },
...(signal ? { signal } : {}),
});
return parseStatus(requireOk(response, await readJson(response)));
};
return {
status: (signal) => send('/api/v1/system/update', 'GET', signal),
check: () => send('/api/v1/system/update/check', 'POST'),
download: () => send('/api/v1/system/update/download', 'POST'),
install: () => send('/api/v1/system/update/install', 'POST'),
restart: () => send('/api/v1/system/restart', 'POST'),
};
}
@@ -0,0 +1,172 @@
// @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 {
ConsoleUpdatePanel,
type ConsoleUpdateDataSource,
type UpdateStatus,
} from './console-update-panel.js';
afterEach(cleanup);
const idle: UpdateStatus = {
phase: 'idle',
message: '',
error: '',
progressPercent: 0,
checkedAt: '',
deploymentMode: 'git',
installSupported: true,
restartSupported: true,
release: null,
};
const available: UpdateStatus = {
...idle,
phase: 'update_available',
message: '发现 3 个新提交,可下载并安装。',
checkedAt: '2026-09-06T00:00:00.000Z',
release: {
status: 'update_available',
remote: 'origin',
branch: 'main',
currentCommit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
latestCommit: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
currentVersion: '0.1.0',
latestVersion: '0.9.0',
ahead: 3,
changedFiles: 12,
changedLines: 340,
summary: 'feat: 在线更新',
},
};
const ready: UpdateStatus = { ...available, phase: 'ready', message: '更新已就绪,可安装。' };
const installed: UpdateStatus = { ...ready, phase: 'install_queued', message: '安装已排队。' };
const current: UpdateStatus = {
...available,
phase: 'up_to_date',
message: '已是最新版本。',
error: '',
};
function source(overrides: Partial<ConsoleUpdateDataSource> = {}): ConsoleUpdateDataSource {
return {
status: vi.fn(async () => idle),
check: vi.fn(async () => available),
download: vi.fn(async () => ready),
install: vi.fn(async () => installed),
restart: vi.fn(async () => ({ ...installed, phase: 'restarting' as const })),
...overrides,
};
}
async function mount(dataSource: ConsoleUpdateDataSource): Promise<HTMLElement> {
render(<ConsoleUpdatePanel dataSource={dataSource} />);
return screen.findByRole('region', { name: '在线更新' });
}
function button(region: HTMLElement, label: string): HTMLButtonElement {
return within(region).getByRole('button', { name: label }) as HTMLButtonElement;
}
describe('ConsoleUpdatePanel', () => {
it('starts from the stored status and keeps install locked until a package is verified', async () => {
const region = await mount(source());
expect(within(region).getByText('待检查')).toBeTruthy();
expect(within(region).getByText('Git 仓库')).toBeTruthy();
expect(button(region, '下载并准备更新').disabled).toBe(true);
expect(button(region, '安装更新').disabled).toBe(true);
expect(button(region, '重启服务').disabled).toBe(false);
});
it('walks check, download and install, confirming the destructive steps', async () => {
const confirm = vi
.spyOn(window, 'confirm')
.mockImplementation((message) => !String(message).includes('确认立即重启'));
const dataSource = source();
const region = await mount(dataSource);
await userEvent.click(button(region, '检查更新'));
await vi.waitFor(() => expect(dataSource.check).toHaveBeenCalledTimes(1));
expect(within(region).getByText('发现新版本')).toBeTruthy();
expect(within(region).getByText('0.9.0 (bbbbbbbb)')).toBeTruthy();
expect(within(region).getByText('origin / main')).toBeTruthy();
expect(within(region).getByText('3 个')).toBeTruthy();
expect(within(region).getByText('12 文件 / 340 行')).toBeTruthy();
expect(within(region).getByText('feat: 在线更新')).toBeTruthy();
await userEvent.click(button(region, '下载并准备更新'));
await vi.waitFor(() => expect(dataSource.download).toHaveBeenCalledTimes(1));
expect(button(region, '安装更新').disabled).toBe(false);
await userEvent.click(button(region, '安装更新'));
await vi.waitFor(() => expect(dataSource.install).toHaveBeenCalledTimes(1));
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('确认安装已验证的更新'));
expect(within(region).getByText('安装已排队')).toBeTruthy();
confirm.mockRestore();
});
it('does not install when the operator cancels the confirmation', async () => {
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
const dataSource = source({ download: vi.fn(async () => ready) });
const region = await mount(dataSource);
await userEvent.click(button(region, '检查更新'));
await userEvent.click(button(region, '下载并准备更新'));
await userEvent.click(button(region, '安装更新'));
expect(dataSource.install).not.toHaveBeenCalled();
confirm.mockRestore();
});
it('hands a queued install to the supervisor', async () => {
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
const dataSource = source();
const region = await mount(dataSource);
await userEvent.click(button(region, '重启服务'));
await vi.waitFor(() => expect(dataSource.restart).toHaveBeenCalledTimes(1));
expect(within(region).getByText('正在重启')).toBeTruthy();
confirm.mockRestore();
});
it('shows the server problem detail and recovers by reloading the status', async () => {
const dataSource = source({
check: vi.fn(async () => {
throw new Error('工作区存在未提交修改,无法安全更新。');
}),
status: vi
.fn<ConsoleUpdateDataSource['status']>()
.mockResolvedValueOnce(idle)
.mockResolvedValueOnce(current),
});
const region = await mount(dataSource);
await userEvent.click(button(region, '检查更新'));
await vi.waitFor(() => expect(within(region).getByText('已是最新')).toBeTruthy());
expect(within(region).getByText('工作区存在未提交修改,无法安全更新。')).toBeTruthy();
expect(dataSource.status).toHaveBeenCalledTimes(2);
});
it('explains that a manual deployment cannot install updates', async () => {
const dataSource = source({
status: vi.fn(async () => ({ ...idle, deploymentMode: 'manual', installSupported: false })),
});
const region = await mount(dataSource);
expect(within(region).getByText('手动部署')).toBeTruthy();
expect(within(region).getByText(/当前为手动部署,无法在线安装更新/)).toBeTruthy();
});
it('reports a load failure and retries on demand', async () => {
const dataSource = source({
status: vi
.fn<ConsoleUpdateDataSource['status']>()
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce(current),
});
render(<ConsoleUpdatePanel dataSource={dataSource} />);
const region = await screen.findByRole('region', { name: '在线更新' });
expect(within(region).getByText('无法加载更新状态。')).toBeTruthy();
await userEvent.click(button(region, '重试加载'));
await vi.waitFor(() => expect(within(region).getByText('已是最新版本。')).toBeTruthy());
});
});
@@ -0,0 +1,337 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button, Card, Progress, Tag } from 'animal-island-ui';
import { Icon } from '../ui/icon.js';
import {
BUSY_UPDATE_PHASES,
createConsoleUpdateApiDataSource,
type ConsoleUpdateDataSource,
type UpdatePhase,
type UpdateStatus,
} from './console-update-api-data-source.js';
export type {
ConsoleUpdateDataSource,
UpdatePhase,
UpdateStatus,
} from './console-update-api-data-source.js';
const FAST_POLL_MS = 1_000;
const SLOW_POLL_MS = 30_000;
const PHASE_LABELS: Readonly<Record<UpdatePhase, string>> = {
idle: '待检查',
checking: '正在检查',
up_to_date: '已是最新',
update_available: '发现新版本',
downloading: '正在下载',
ready: '待安装',
installing: '正在安装',
install_queued: '安装已排队',
restarting: '正在重启',
failed: '更新失败',
rolled_back: '已回滚',
};
function formatTime(value: string): string {
if (!value) return '尚未检查';
return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(
new Date(value),
);
}
function shortHash(commit: string): string {
return commit ? commit.slice(0, 8) : '--';
}
/**
* Online update for the console itself, in the same four steps the Hub uses:
* check, download, install, restart. The panel keeps polling so a restart that
* swaps the process underneath it lands on the confirmed version on its own.
*/
export function ConsoleUpdatePanel({
dataSource,
onCompleted,
}: {
readonly dataSource?: ConsoleUpdateDataSource | undefined;
readonly onCompleted?: ((message: string) => void) | undefined;
}) {
const [source] = useState(() => dataSource ?? createConsoleUpdateApiDataSource());
const [status, setStatus] = useState<UpdateStatus>();
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [attempt, setAttempt] = useState(0);
const timer = useRef<number | undefined>(undefined);
const phaseRef = useRef<UpdatePhase>('idle');
const apply = useCallback((next: UpdateStatus) => {
phaseRef.current = next.phase;
setStatus(next);
setLoading(false);
setFailed(false);
}, []);
// Silent refresh used by the poll loop: a transient failure keeps the last known state.
const refresh = useCallback(async () => {
try {
apply(await source.status());
} catch {
/* the explicit reload path reports the problem */
}
}, [apply, source]);
useEffect(() => {
const controller = new AbortController();
let active = true;
setLoading(true);
setFailed(false);
source.status(controller.signal).then(
(next) => {
if (!active) return;
apply(next);
},
() => {
if (!active || controller.signal.aborted) return;
setFailed(true);
setLoading(false);
},
);
return () => {
active = false;
controller.abort();
};
}, [apply, attempt, source]);
// Poll fast while a phase is in flight, otherwise stay quiet but self-healing. The phase
// lives in a ref so a status change never restarts the timer mid-countdown.
const pollingEnabled = !loading && !failed;
useEffect(() => {
if (!pollingEnabled) return;
let cancelled = false;
const schedule = () => {
const delay = BUSY_UPDATE_PHASES.has(phaseRef.current) ? FAST_POLL_MS : SLOW_POLL_MS;
timer.current = window.setTimeout(() => {
void refresh().finally(() => {
if (!cancelled) schedule();
});
}, delay);
};
schedule();
return () => {
cancelled = true;
window.clearTimeout(timer.current);
};
}, [pollingEnabled, refresh]);
const run = async (action: () => Promise<UpdateStatus>, confirmText?: string) => {
if (busy) return;
if (confirmText && !window.confirm(confirmText)) return;
setBusy(true);
setError('');
try {
const next = await action();
apply(next);
if (next.message) onCompleted?.(next.message);
} catch (caught) {
setError(caught instanceof Error ? caught.message : '更新操作未完成,请稍后重试。');
try {
apply(await source.status());
} catch {
/* keep the reported error on screen */
}
} finally {
setBusy(false);
}
};
const phase = status?.phase ?? 'idle';
const release = status?.release ?? null;
const busyPhase = BUSY_UPDATE_PHASES.has(phase);
const blocked = busy || busyPhase;
const canDownload = phase === 'update_available' || phase === 'failed' || phase === 'rolled_back';
const canInstall = phase === 'ready';
const canRestart = Boolean(status?.restartSupported) && phase !== 'restarting';
return (
<Card pattern="default" className="settings-card maintenance-card update-card">
<section aria-label="在线更新">
<h2>
<Icon name="version" />
线
</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 && status ? (
<>
<div className="update-status-row">
<Tag
size="small"
variant="soft"
color={
busyPhase
? 'app-orange'
: phase === 'failed' || phase === 'rolled_back'
? 'app-red'
: 'app-green'
}
>
{PHASE_LABELS[phase]}
</Tag>
<span className="update-phase-message">{status.message || '等待检查更新。'}</span>
</div>
{busyPhase ? (
<div className="update-progress">
<Progress
percent={status.progressPercent}
showInfo={false}
duration={0}
aria-label="更新进度"
/>
<strong>{status.progressPercent}%</strong>
</div>
) : null}
<dl className="maintenance-summary update-summary">
<div>
<dt></dt>
<dd>{status.deploymentMode === 'git' ? 'Git 仓库' : '手动部署'}</dd>
</div>
<div>
<dt></dt>
<dd>
{release
? `${release.currentVersion || '--'} (${shortHash(release.currentCommit)})`
: '--'}
</dd>
</div>
<div>
<dt></dt>
<dd>
{release && release.status !== 'unavailable'
? `${release.latestVersion || '--'} (${shortHash(release.latestCommit)})`
: '--'}
</dd>
</div>
<div>
<dt></dt>
<dd>{release ? `${release.remote} / ${release.branch}` : '--'}</dd>
</div>
<div>
<dt></dt>
<dd>{release ? `${release.ahead}` : '--'}</dd>
</div>
<div>
<dt></dt>
<dd>
{release && release.changedFiles > 0
? `${release.changedFiles} 文件 / ${release.changedLines}`
: '--'}
</dd>
</div>
<div>
<dt></dt>
<dd>{formatTime(status.checkedAt)}</dd>
</div>
<div>
<dt></dt>
<dd>{status.installSupported ? '支持' : '不支持'}</dd>
</div>
</dl>
{release?.summary ? <p className="update-release-summary">{release.summary}</p> : null}
{status.error ? (
<p role="alert" className="maintenance-error">
{status.error}
</p>
) : null}
<div className="update-actions">
<Button
htmlType="button"
loading={busy && phase === 'checking'}
disabled={blocked}
onClick={() => void run(() => source.check())}
>
</Button>
<Button
htmlType="button"
type="primary"
loading={busy && phase === 'downloading'}
disabled={blocked || !canDownload}
onClick={() => void run(() => source.download())}
>
</Button>
<Button
htmlType="button"
danger
loading={busy && (phase === 'installing' || phase === 'install_queued')}
disabled={blocked || !canInstall || !status.installSupported}
onClick={() =>
void run(
() => source.install(),
'确认安装已验证的更新?控制台将更新依赖、重建前端并重启服务。',
)
}
>
</Button>
<Button
htmlType="button"
loading={busy && phase === 'restarting'}
disabled={blocked || !canRestart}
onClick={() =>
void run(
() => source.restart(),
'确认立即重启控制台服务?正在执行的设备任务会被中断。',
)
}
>
<Icon name="restart" />
</Button>
</div>
{!status.installSupported ? (
<p className="maintenance-hint">
线 git pull
</p>
) : null}
{error ? (
<p role="alert" className="maintenance-error">
{error}
</p>
) : null}
</>
) : null}
</section>
</Card>
);
}
@@ -12,6 +12,7 @@ const overview = {
},
storage: {
databaseBytes: 2048,
databasePath: '/tmp/multi-simadmin.db',
walBytes: 512,
backupBytes: 8192,
reclaimableBytes: 256,
@@ -39,6 +40,74 @@ function json(body: unknown, status = 200): Response {
}
describe('system maintenance API data source', () => {
it('reads the device authorization mode from the connection settings', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(
json({
heartbeatSeconds: 30,
offlineSeconds: 90,
authorizationMode: 'manual',
}),
);
await expect(createSystemMaintenanceApiDataSource(fetcher).connection()).resolves.toEqual({
heartbeatSeconds: 30,
offlineSeconds: 90,
authorizationMode: 'manual',
});
});
it('treats a missing device authorization mode as the original automatic behavior', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(json({ heartbeatSeconds: 30, offlineSeconds: 90 }));
await expect(createSystemMaintenanceApiDataSource(fetcher).connection()).resolves.toEqual({
heartbeatSeconds: 30,
offlineSeconds: 90,
authorizationMode: 'auto',
});
});
it('sends an explicit device authorization mode with the cadence update', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(
json({
heartbeatSeconds: 45,
offlineSeconds: 120,
authorizationMode: 'manual',
}),
);
const settings = {
heartbeatSeconds: 45,
offlineSeconds: 120,
authorizationMode: 'manual' as const,
};
await expect(
createSystemMaintenanceApiDataSource(fetcher).updateConnection(settings),
).resolves.toEqual(settings);
expect(fetcher).toHaveBeenCalledWith('/api/v1/system/connection', {
method: 'PUT',
credentials: 'same-origin',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify(settings),
});
});
it('rejects an unknown device authorization mode', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(
json({
heartbeatSeconds: 30,
offlineSeconds: 90,
authorizationMode: 'operator',
}),
);
await expect(createSystemMaintenanceApiDataSource(fetcher).connection()).rejects.toThrow(
'System maintenance response is invalid.',
);
});
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;
@@ -141,6 +210,7 @@ describe('system maintenance API data source', () => {
...overview,
storage: {
...overview.storage,
databasePath: undefined,
components: [
{ key: 'instances', count: 2, bytes: 0 },
{ key: 'auditEvents', count: 12, bytes: 0 },
@@ -18,6 +18,7 @@ export type RetentionPolicy = Readonly<{
export type ConnectionSettings = Readonly<{
heartbeatSeconds: number;
offlineSeconds: number;
authorizationMode: 'auto' | 'manual';
}>;
export type HeartbeatSummary = Readonly<{
@@ -36,6 +37,8 @@ export type SystemMaintenanceOverview = Readonly<{
}>;
storage: Readonly<{
databaseBytes: number;
/** Present on current API builds; missing values render as unknown. */
databasePath?: string | undefined;
/** Write-ahead log file size; 0 when the database is not in WAL mode. */
walBytes: number;
/** Total size of the retained backup files. */
@@ -177,6 +180,10 @@ function parseOverview(value: unknown): SystemMaintenanceOverview {
},
storage: {
databaseBytes: storage.databaseBytes,
databasePath:
typeof storage.databasePath === 'string' && storage.databasePath.length <= 1_024
? storage.databasePath
: undefined,
walBytes: optionalInteger(storage.walBytes),
backupBytes: optionalInteger(storage.backupBytes),
reclaimableBytes: optionalInteger(storage.reclaimableBytes),
@@ -188,13 +195,19 @@ function parseOverview(value: unknown): SystemMaintenanceOverview {
function parseConnectionSettings(value: unknown): ConnectionSettings {
const source = record(value);
const authorizationMode = source?.authorizationMode ?? 'auto';
if (
!source ||
!integer(source.heartbeatSeconds, MIN_HEARTBEAT_SECONDS, MAX_HEARTBEAT_SECONDS) ||
!integer(source.offlineSeconds, source.heartbeatSeconds * 2, MAX_OFFLINE_SECONDS)
!integer(source.offlineSeconds, source.heartbeatSeconds * 2, MAX_OFFLINE_SECONDS) ||
(authorizationMode !== 'auto' && authorizationMode !== 'manual')
)
throw new Error('System maintenance response is invalid.');
return { heartbeatSeconds: source.heartbeatSeconds, offlineSeconds: source.offlineSeconds };
return {
heartbeatSeconds: source.heartbeatSeconds,
offlineSeconds: source.offlineSeconds,
authorizationMode,
};
}
function parseHeartbeat(value: unknown): HeartbeatSummary {
@@ -287,7 +300,8 @@ export function createSystemMaintenanceApiDataSource(
async updateConnection(settings) {
if (
!integer(settings.heartbeatSeconds, MIN_HEARTBEAT_SECONDS, MAX_HEARTBEAT_SECONDS) ||
!integer(settings.offlineSeconds, settings.heartbeatSeconds * 2, MAX_OFFLINE_SECONDS)
!integer(settings.offlineSeconds, settings.heartbeatSeconds * 2, MAX_OFFLINE_SECONDS) ||
(settings.authorizationMode !== 'auto' && settings.authorizationMode !== 'manual')
)
throw new Error('System maintenance query is invalid.');
const response = await fetcher('/api/v1/system/connection', {
@@ -7,6 +7,7 @@ import {
SystemMaintenancePage,
type SystemMaintenanceDataSource,
} from './system-maintenance-page.js';
import type { ConsoleUpdateDataSource, UpdateStatus } from './console-update-api-data-source.js';
afterEach(cleanup);
@@ -27,6 +28,7 @@ const overview = {
},
storage: {
databaseBytes: 4096,
databasePath: '/tmp/multi-simadmin.db',
walBytes: 1024,
backupBytes: 2048,
reclaimableBytes: 512,
@@ -64,15 +66,38 @@ function source(): SystemMaintenanceDataSource {
};
}
const upToDate: UpdateStatus = {
phase: 'up_to_date',
message: '已是最新版本。',
error: '',
progressPercent: 100,
checkedAt: '2026-09-06T00:00:00.000Z',
deploymentMode: 'git',
installSupported: true,
restartSupported: true,
release: null,
};
function updateSource(): ConsoleUpdateDataSource {
return {
status: vi.fn(async () => upToDate),
check: vi.fn(async () => upToDate),
download: vi.fn(async () => upToDate),
install: vi.fn(async () => upToDate),
restart: vi.fn(async () => upToDate),
};
}
describe('System maintenance page', () => {
it('loads the native overview and backups', async () => {
const dataSource = source();
render(<SystemMaintenancePage dataSource={dataSource} />);
render(<SystemMaintenancePage dataSource={dataSource} updateDataSource={updateSource()} />);
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();
expect(within(status).getByText('/tmp/multi-simadmin.db')).toBeTruthy();
const storage = screen.getByRole('region', { name: '存储统计' });
const table = within(storage).getByRole('table');
@@ -98,7 +123,7 @@ describe('System maintenance page', () => {
it('saves edited retention policies', async () => {
const user = userEvent.setup();
const dataSource = source();
render(<SystemMaintenancePage dataSource={dataSource} />);
render(<SystemMaintenancePage dataSource={dataSource} updateDataSource={updateSource()} />);
await screen.findByRole('region', { name: '数据保留' });
await user.type(screen.getByLabelText('审计事件保留天数'), '30');
@@ -117,7 +142,7 @@ describe('System maintenance page', () => {
const confirm = vi.fn(() => true);
window.confirm = confirm;
const dataSource = source();
render(<SystemMaintenancePage dataSource={dataSource} />);
render(<SystemMaintenancePage dataSource={dataSource} updateDataSource={updateSource()} />);
await screen.findByRole('region', { name: '维护操作' });
await user.click(screen.getByRole('checkbox', { name: '审计事件' }));
@@ -137,7 +162,7 @@ describe('System maintenance page', () => {
const confirm = vi.fn(() => true);
window.confirm = confirm;
const dataSource = source();
render(<SystemMaintenancePage dataSource={dataSource} />);
render(<SystemMaintenancePage dataSource={dataSource} updateDataSource={updateSource()} />);
await screen.findByRole('table', { name: '本地备份' });
const row = screen.getByRole('row', { name: /multi-simadmin-2026\.db/ });
@@ -158,11 +183,24 @@ describe('System maintenance page', () => {
const user = userEvent.setup();
window.confirm = vi.fn(() => false);
const dataSource = source();
render(<SystemMaintenancePage dataSource={dataSource} />);
render(<SystemMaintenancePage dataSource={dataSource} updateDataSource={updateSource()} />);
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();
});
it('keeps the update panel usable when the storage overview cannot be read', async () => {
const dataSource = source();
vi.mocked(dataSource.overview).mockRejectedValueOnce(new Error('offline'));
const updates = updateSource();
render(<SystemMaintenancePage dataSource={dataSource} updateDataSource={updates} />);
expect(await screen.findByText('无法加载系统维护状态。')).toBeTruthy();
const region = await screen.findByRole('region', { name: '在线更新' });
expect(within(region).getByText('已是最新')).toBeTruthy();
await userEvent.click(within(region).getByRole('button', { name: '检查更新' }));
expect(updates.check).toHaveBeenCalledTimes(1);
});
});
@@ -9,10 +9,13 @@ import type {
SystemMaintenanceOverview,
} from './system-maintenance-api-data-source.js';
import { createSystemMaintenanceApiDataSource } from './system-maintenance-api-data-source.js';
import type { ConsoleUpdateDataSource } from './console-update-api-data-source.js';
import { ComponentBackupPanel } from './component-backup-panel.js';
import { ConnectionSettingsPanel } from './connection-settings-panel.js';
import { ConsoleUpdatePanel } from './console-update-panel.js';
export type { SystemMaintenanceDataSource } from './system-maintenance-api-data-source.js';
export type { ConsoleUpdateDataSource } from './console-update-api-data-source.js';
const COMPONENT_KEYS: readonly DataComponentKey[] = [
'instances',
@@ -73,8 +76,10 @@ type BusyAction = 'retention' | 'cleanup' | 'optimize' | 'backup' | 'delete' | u
export function SystemMaintenancePage({
dataSource,
updateDataSource,
}: {
readonly dataSource?: SystemMaintenanceDataSource;
readonly updateDataSource?: ConsoleUpdateDataSource;
}) {
const [source] = useState(() => dataSource ?? createSystemMaintenanceApiDataSource());
const [overview, setOverview] = useState<SystemMaintenanceOverview>();
@@ -193,9 +198,18 @@ export function SystemMaintenancePage({
<h1 id="system-maintenance-title">
<Title color="app-green"></Title>
</h1>
<p>SQLite </p>
<p>SQLite </p>
</header>
{/* The update panel carries its own data source so a storage read failure never hides it. */}
<ConsoleUpdatePanel
dataSource={updateDataSource}
onCompleted={(message) => {
setError('');
setNotice(message);
}}
/>
{loading ? (
<p role="status" aria-label="系统维护加载状态">
@@ -235,6 +249,12 @@ export function SystemMaintenancePage({
<dt></dt>
<dd>{Math.floor(overview.runtime.uptimeSeconds / 60)} </dd>
</div>
<div>
<dt></dt>
<dd>
<code>{overview.storage.databasePath || '—'}</code>
</dd>
</div>
</dl>
</section>
+494
View File
@@ -581,6 +581,13 @@ main > section > header p,
.fleet-cross-health-item strong[data-tone='attention'] {
color: var(--warning);
}
/* The identity rail entry opens a drawer instead of a page, so it is a button dressed as a link. */
button.fleet-cross-health-item {
width: 100%;
font: inherit;
text-align: left;
cursor: pointer;
}
.fleet-toolbar {
display: grid;
gap: 0.7rem;
@@ -1396,6 +1403,34 @@ main > section > header p,
letter-spacing: 0;
}
.backup-directory {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.4rem;
}
.backup-directory span {
color: var(--muted);
font-size: 0.72rem;
}
.backup-directory code {
min-width: 0;
overflow-wrap: anywhere;
color: var(--muted);
font-size: 0.72rem;
letter-spacing: 0;
}
.backup-note {
display: block;
margin-top: 0.15rem;
overflow-wrap: anywhere;
color: var(--muted);
font-size: 0.7rem;
}
.backup-component-picker {
display: grid;
gap: 0.4rem;
@@ -1481,6 +1516,40 @@ main > section > header p,
font-size: 0.86rem;
overflow-wrap: anywhere;
}
.backup-preview-panel {
display: grid;
gap: 0.6rem;
padding: 0.8rem;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--surface-soft);
}
.backup-preview-panel h3 {
margin: 0;
font-size: 0.86rem;
overflow-wrap: anywhere;
}
.backup-preview-grid {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 0.35rem 0.8rem;
}
.backup-preview-grid span {
color: var(--muted);
font-size: 0.72rem;
}
.backup-preview-grid strong {
font-size: 0.78rem;
min-width: 0;
overflow-wrap: anywhere;
}
.backup-preview-components {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.25rem;
font-size: 0.78rem;
}
.backup-schedule {
display: grid;
gap: 0.6rem;
@@ -1601,6 +1670,26 @@ main > section > header p,
font-weight: 400;
}
.connection-mode {
display: grid;
gap: 0.35rem;
margin: 1rem 0 0;
}
.connection-mode-label {
font-size: 0.8rem;
font-weight: 700;
}
.connection-mode .connection-mode-control {
max-width: 20rem;
margin-bottom: 0;
}
.connection-mode .maintenance-hint {
margin: 0;
}
.maintenance-notice,
.maintenance-error {
margin: 0;
@@ -1619,6 +1708,70 @@ main > section > header p,
background: var(--danger-soft);
}
/* Console online update */
.system-maintenance > .update-card {
margin-bottom: 1rem;
}
.update-card h2 {
display: flex;
align-items: center;
gap: 0.45rem;
}
.update-card h2 svg {
width: 1.05rem;
height: 1.05rem;
}
.update-status-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.6rem;
margin-bottom: 0.75rem;
}
.update-phase-message {
font-weight: 700;
line-height: 1.5;
}
.update-progress {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.75rem;
}
.update-progress > div {
flex: 1 1 auto;
min-width: 8rem;
}
.update-progress strong {
min-width: 3rem;
font-variant-numeric: tabular-nums;
}
.update-summary {
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
}
.update-release-summary {
margin: 0.75rem 0 0;
padding: 0.7rem 0.9rem;
border: 1px dashed rgba(37, 59, 88, 0.18);
border-radius: 10px;
background: rgba(255, 255, 255, 0.55);
font-size: 0.86rem;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.update-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
margin-top: 0.9rem;
}
.update-actions button svg {
width: 0.95rem;
height: 0.95rem;
}
/* Instance detail */
.instance-detail {
display: grid;
@@ -1922,6 +2075,36 @@ dd {
font-size: 0.74rem;
line-height: 1.5;
}
.device-action-banner {
display: flex;
gap: 0.4rem;
align-items: flex-start;
margin: 0.55rem 0 0;
padding: 0.5rem 0.6rem;
border: 1px solid rgba(217, 87, 87, 0.2);
border-radius: 10px;
color: #98393a;
background: rgba(253, 233, 231, 0.72);
font-size: 0.74rem;
line-height: 1.5;
}
.device-action-banner svg {
width: 0.9rem;
height: 0.9rem;
flex: none;
margin-top: 0.1rem;
}
.device-action-held {
display: flex;
gap: 0.35rem;
align-items: center;
color: #a3413c;
}
.device-action-held svg {
width: 0.85rem;
height: 0.85rem;
flex: none;
}
.device-action-form {
display: grid;
gap: 0.55rem;
@@ -3327,6 +3510,317 @@ main {
color: var(--workbench-muted);
font-size: 0.78rem;
}
.identity-drawer .identity-summary {
margin: 0.15rem 0 0;
color: var(--workbench-muted);
font-size: 0.74rem;
}
.identity-toolbar {
display: flex;
gap: 0.5rem;
align-items: center;
padding: 0.75rem 1.1rem;
border-bottom: 1px solid var(--workbench-border);
}
.identity-toolbar button:first-child {
min-height: 2.1rem;
padding: 0 0.7rem;
border: 1px solid var(--workbench-border);
border-radius: 999px;
color: var(--workbench-muted);
background: #fff;
font: inherit;
font-size: 0.75rem;
font-weight: 700;
cursor: pointer;
}
.identity-toolbar button:first-child.is-active {
color: #98393a;
border-color: #e0a3a3;
background: #fdf0ef;
}
.identity-notice {
margin: 0.75rem 1.1rem 0;
padding: 0.55rem 0.7rem;
border: 1px solid var(--workbench-border-strong);
border-radius: 10px;
color: var(--workbench-text);
background: var(--workbench-surface-muted);
font-size: 0.76rem;
}
.identity-list {
display: grid;
gap: 0.7rem;
margin: 0;
padding: 0.9rem 1.1rem;
list-style: none;
}
.identity-card {
display: grid;
gap: 0.55rem;
padding: 0.8rem 0.85rem;
border: 1px solid var(--workbench-border);
border-radius: 12px;
background: #fff;
}
.identity-card.identity-pending {
border-color: #e0a3a3;
background: #fdf6f5;
}
.identity-card-head {
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: space-between;
}
.identity-card-head strong {
color: var(--workbench-text);
font-size: 0.86rem;
overflow-wrap: anywhere;
}
.identity-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.35rem 0.8rem;
margin: 0;
}
.identity-fields > div {
display: flex;
gap: 0.4rem;
align-items: baseline;
min-width: 0;
}
.identity-fields dt {
color: var(--workbench-muted);
font-size: 0.7rem;
white-space: nowrap;
}
.identity-fields dd {
margin: 0;
color: var(--workbench-text);
font-size: 0.76rem;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.identity-changes,
.identity-reasons {
display: grid;
gap: 0.3rem;
margin: 0;
padding: 0;
list-style: none;
font-size: 0.75rem;
}
.identity-changes li {
display: flex;
gap: 0.45rem;
align-items: baseline;
flex-wrap: wrap;
}
.identity-changes span {
color: var(--workbench-muted);
}
.identity-changes s {
color: #a3413c;
}
.identity-changes em {
color: var(--workbench-text);
font-style: normal;
font-weight: 700;
}
.identity-reasons li {
display: grid;
gap: 0.15rem;
color: #98393a;
}
.identity-reasons small {
color: var(--workbench-muted);
font-size: 0.72rem;
overflow-wrap: anywhere;
}
.identity-fingerprints {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
margin: 0;
color: var(--workbench-muted);
font-size: 0.7rem;
font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, monospace);
}
.identity-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.identity-actions button {
min-height: 2.1rem;
padding: 0 0.8rem;
border: 1px solid var(--workbench-border-strong);
border-radius: 999px;
color: var(--workbench-text);
background: #fff;
font: inherit;
font-size: 0.75rem;
font-weight: 700;
cursor: pointer;
}
.identity-actions button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.identity-actions .primary-action {
color: #fff;
border-color: var(--workbench-mint);
background: var(--workbench-mint);
}
.identity-hint {
margin: 0;
padding: 0.8rem 1.1rem 1.1rem;
border-top: 1px solid var(--workbench-border);
color: var(--workbench-muted);
font-size: 0.72rem;
}
.fleet-card-identity {
display: grid;
gap: 0.25rem;
margin: 0;
padding: 0.45rem 0.55rem;
border: 1px solid rgba(217, 87, 87, 0.16);
border-radius: 12px;
background: rgba(253, 233, 231, 0.72);
font-size: 0.74rem;
}
.fleet-card-identity small {
color: #a3413c;
}
.device-context-drawer {
max-width: min(34rem, 100%);
}
.device-context-eyebrow {
display: flex;
gap: 0.35rem;
align-items: center;
margin: 0;
color: var(--workbench-muted);
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
}
.device-context-content {
padding: 1.1rem;
}
.device-context-section {
margin-bottom: 1rem;
padding: 0.85rem;
border: 1px solid var(--workbench-border);
border-radius: 8px;
background: #fff;
}
.device-context-section dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
margin: 0;
}
.device-context-section dl > div {
min-width: 0;
}
.device-context-section dt {
margin-bottom: 0.2rem;
color: var(--workbench-muted);
font-size: 0.7rem;
}
.device-context-section dd {
margin: 0;
color: var(--workbench-text);
font-size: 0.82rem;
overflow-wrap: anywhere;
}
.device-context-section time {
display: block;
margin-top: 0.35rem;
color: var(--workbench-muted);
font-size: 0.72rem;
}
.device-context-actions {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
gap: 0.55rem;
margin: 0;
padding: 0;
}
.device-context-actions a {
display: flex;
gap: 0.45rem;
align-items: center;
min-height: 2.5rem;
padding: 0 0.8rem;
border: 1px solid var(--workbench-border);
border-radius: 999px;
color: var(--workbench-text);
background: #fff;
font-size: 0.78rem;
font-weight: 700;
text-decoration: none;
}
.device-context-actions a:hover {
border-color: var(--workbench-border-strong);
background: var(--workbench-surface-muted);
}
.device-context-primary {
color: #fff;
border-color: var(--workbench-mint);
background: var(--workbench-mint);
}
.device-context-primary:hover {
color: #fff;
border-color: var(--workbench-mint);
background: var(--workbench-mint);
}
.fleet-card-context-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid transparent;
border-radius: 50%;
color: var(--workbench-muted);
background: transparent;
cursor: pointer;
}
.fleet-card-context-trigger:hover {
border-color: var(--workbench-border);
color: var(--workbench-text);
background: var(--workbench-surface-muted);
}
.device-context-backdrop {
background: rgba(56, 47, 36, 0.24);
backdrop-filter: blur(4px);
}
@media (max-width: 640px) {
.device-context-section dl {
grid-template-columns: minmax(0, 1fr);
}
}
.color-picker {
display: flex;
flex-wrap: wrap;