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
@@ -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.');
});
});