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:
@@ -14,6 +14,7 @@ export function ConsoleAuthSettings({
|
|||||||
const [enabled, setEnabled] = useState(false);
|
const [enabled, setEnabled] = useState(false);
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmation, setConfirmation] = useState('');
|
const [confirmation, setConfirmation] = useState('');
|
||||||
|
const [idleMinutes, setIdleMinutes] = useState('30');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [notice, setNotice] = useState('');
|
const [notice, setNotice] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -25,6 +26,7 @@ export function ConsoleAuthSettings({
|
|||||||
if (!active) return;
|
if (!active) return;
|
||||||
setStatus(next);
|
setStatus(next);
|
||||||
setEnabled(next.protectionEnabled);
|
setEnabled(next.protectionEnabled);
|
||||||
|
setIdleMinutes(String(next.sessionIdleTimeoutMinutes));
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
if (active) setError('无法读取密码保护设置。');
|
if (active) setError('无法读取密码保护设置。');
|
||||||
@@ -52,11 +54,15 @@ export function ConsoleAuthSettings({
|
|||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const input =
|
const input = {
|
||||||
enabled && !status.configured ? { enabled: true, newPassword: password } : { enabled };
|
enabled,
|
||||||
|
...(enabled && !status.configured ? { newPassword: password } : {}),
|
||||||
|
idleTimeoutMinutes: Math.min(1440, Math.max(5, Math.round(Number(idleMinutes) || 30))),
|
||||||
|
};
|
||||||
const next = await source.update(input);
|
const next = await source.update(input);
|
||||||
setStatus(next);
|
setStatus(next);
|
||||||
setEnabled(next.protectionEnabled);
|
setEnabled(next.protectionEnabled);
|
||||||
|
setIdleMinutes(String(next.sessionIdleTimeoutMinutes));
|
||||||
setPassword('');
|
setPassword('');
|
||||||
setConfirmation('');
|
setConfirmation('');
|
||||||
setNotice(next.protectionEnabled ? '密码保护已启用。' : '密码保护已关闭。');
|
setNotice(next.protectionEnabled ? '密码保护已启用。' : '密码保护已关闭。');
|
||||||
@@ -115,6 +121,17 @@ export function ConsoleAuthSettings({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{status.configured ? <p>访问密码已配置,不会在页面或 API 中回显。</p> : 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}
|
{error ? <p role="alert">{error}</p> : null}
|
||||||
{notice ? <p role="status">{notice}</p> : null}
|
{notice ? <p role="status">{notice}</p> : null}
|
||||||
<Button htmlType="submit" type="primary" disabled={saving} loading={saving}>
|
<Button htmlType="submit" type="primary" disabled={saving} loading={saving}>
|
||||||
|
|||||||
@@ -1,24 +1,30 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
|
import { act } from 'react';
|
||||||
import { cleanup, render, screen } from '@testing-library/react';
|
import { cleanup, render, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { ConsoleAuthGate, type ConsoleAuthDataSource } from './console-auth.js';
|
import { ConsoleAuthGate, type ConsoleAuthDataSource } from './console-auth.js';
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
const status = {
|
const status = {
|
||||||
configured: true,
|
configured: true,
|
||||||
protectionEnabled: true,
|
protectionEnabled: true,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
|
sessionIdleTimeoutMinutes: 30,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('aggregate-console authentication UI', () => {
|
describe('aggregate-console authentication UI', () => {
|
||||||
it('gates the entire console with the single-SimAdmin-style password form', async () => {
|
it('gates the entire console with the single-SimAdmin-style password form', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const login = vi.fn().mockResolvedValue(undefined);
|
const login = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const authenticatedStatus = { ...status, authenticated: true };
|
||||||
const dataSource: ConsoleAuthDataSource = {
|
const dataSource: ConsoleAuthDataSource = {
|
||||||
status: vi.fn().mockResolvedValue(status),
|
status: vi.fn().mockResolvedValueOnce(status).mockResolvedValueOnce(authenticatedStatus),
|
||||||
login,
|
login,
|
||||||
logout: vi.fn(),
|
logout: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
@@ -40,6 +46,34 @@ describe('aggregate-console authentication UI', () => {
|
|||||||
expect(await screen.findByText('secret fleet')).toBeTruthy();
|
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 () => {
|
it('shows a generic login failure without echoing credentials', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const dataSource: ConsoleAuthDataSource = {
|
const dataSource: ConsoleAuthDataSource = {
|
||||||
@@ -61,13 +95,24 @@ describe('aggregate-console authentication UI', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const update = vi
|
const update = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce({ configured: true, protectionEnabled: true, authenticated: true })
|
.mockResolvedValueOnce({
|
||||||
.mockResolvedValueOnce({ configured: true, protectionEnabled: false, authenticated: true });
|
configured: true,
|
||||||
|
protectionEnabled: true,
|
||||||
|
authenticated: true,
|
||||||
|
sessionIdleTimeoutMinutes: 10,
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
configured: true,
|
||||||
|
protectionEnabled: false,
|
||||||
|
authenticated: true,
|
||||||
|
sessionIdleTimeoutMinutes: 10,
|
||||||
|
});
|
||||||
const dataSource: ConsoleAuthDataSource = {
|
const dataSource: ConsoleAuthDataSource = {
|
||||||
status: vi.fn().mockResolvedValue({
|
status: vi.fn().mockResolvedValue({
|
||||||
configured: false,
|
configured: false,
|
||||||
protectionEnabled: false,
|
protectionEnabled: false,
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
|
sessionIdleTimeoutMinutes: 30,
|
||||||
}),
|
}),
|
||||||
login: vi.fn(),
|
login: vi.fn(),
|
||||||
logout: vi.fn(),
|
logout: vi.fn(),
|
||||||
@@ -91,12 +136,18 @@ describe('aggregate-console authentication UI', () => {
|
|||||||
await user.click(toggle);
|
await user.click(toggle);
|
||||||
await user.type(screen.getByLabelText('设置访问密码'), 'StrongPass!9');
|
await user.type(screen.getByLabelText('设置访问密码'), 'StrongPass!9');
|
||||||
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: '保存密码保护设置' }));
|
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();
|
expect(await screen.findByText('密码保护已启用。')).toBeTruthy();
|
||||||
|
|
||||||
await user.click(toggle);
|
await user.click(toggle);
|
||||||
await user.click(screen.getByRole('button', { name: '保存密码保护设置' }));
|
await user.click(screen.getByRole('button', { name: '保存密码保护设置' }));
|
||||||
expect(update).toHaveBeenNthCalledWith(2, { enabled: false });
|
expect(update).toHaveBeenNthCalledWith(2, { enabled: false, idleTimeoutMinutes: 10 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface ConsoleAuthStatus {
|
|||||||
readonly configured: boolean;
|
readonly configured: boolean;
|
||||||
readonly protectionEnabled: boolean;
|
readonly protectionEnabled: boolean;
|
||||||
readonly authenticated: boolean;
|
readonly authenticated: boolean;
|
||||||
|
readonly sessionIdleTimeoutMinutes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConsoleAuthDataSource {
|
export interface ConsoleAuthDataSource {
|
||||||
@@ -13,6 +14,7 @@ export interface ConsoleAuthDataSource {
|
|||||||
update(input: {
|
update(input: {
|
||||||
readonly enabled: boolean;
|
readonly enabled: boolean;
|
||||||
readonly newPassword?: string;
|
readonly newPassword?: string;
|
||||||
|
readonly idleTimeoutMinutes?: number;
|
||||||
}): Promise<ConsoleAuthStatus>;
|
}): Promise<ConsoleAuthStatus>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +69,15 @@ export interface ConsoleAuthGateProps {
|
|||||||
readonly dataSource?: ConsoleAuthDataSource;
|
readonly dataSource?: ConsoleAuthDataSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CONSOLE_ACTIVITY_EVENTS = [
|
||||||
|
'click',
|
||||||
|
'keydown',
|
||||||
|
'pointerdown',
|
||||||
|
'scroll',
|
||||||
|
'touchstart',
|
||||||
|
'visibilitychange',
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function ConsoleAuthGate({ children, dataSource }: ConsoleAuthGateProps) {
|
export function ConsoleAuthGate({ children, dataSource }: ConsoleAuthGateProps) {
|
||||||
const defaultDataSource = useMemo(() => createConsoleAuthApiDataSource(), []);
|
const defaultDataSource = useMemo(() => createConsoleAuthApiDataSource(), []);
|
||||||
const source = dataSource ?? defaultDataSource;
|
const source = dataSource ?? defaultDataSource;
|
||||||
@@ -75,6 +86,7 @@ export function ConsoleAuthGate({ children, dataSource }: ConsoleAuthGateProps)
|
|||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [loginFailed, setLoginFailed] = useState(false);
|
const [loginFailed, setLoginFailed] = useState(false);
|
||||||
|
const [lastActivity, setLastActivity] = useState(() => Date.now());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -90,6 +102,10 @@ export function ConsoleAuthGate({ children, dataSource }: ConsoleAuthGateProps)
|
|||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [source]);
|
}, [source]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!status?.authenticated || !status?.protectionEnabled) return;
|
||||||
|
setLastActivity(Date.now());
|
||||||
|
}, [status?.authenticated, status?.protectionEnabled]);
|
||||||
|
|
||||||
const submit = async (event: FormEvent) => {
|
const submit = async (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -98,7 +114,8 @@ export function ConsoleAuthGate({ children, dataSource }: ConsoleAuthGateProps)
|
|||||||
setLoginFailed(false);
|
setLoginFailed(false);
|
||||||
try {
|
try {
|
||||||
await source.login(password);
|
await source.login(password);
|
||||||
setStatus({ configured: true, protectionEnabled: true, authenticated: true });
|
const nextStatus = await source.status();
|
||||||
|
setStatus(nextStatus);
|
||||||
setPassword('');
|
setPassword('');
|
||||||
} catch {
|
} catch {
|
||||||
setLoginFailed(true);
|
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)
|
if (failed)
|
||||||
return (
|
return (
|
||||||
<main className="auth-screen">
|
<main className="auth-screen">
|
||||||
|
|||||||
@@ -183,13 +183,16 @@ describe('AutomationPage', () => {
|
|||||||
expect(await screen.findByText('Morning restart copy')).not.toBeNull();
|
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 user = userEvent.setup();
|
||||||
const createSchedule = vi.fn(source().createSchedule);
|
const createSchedule = vi.fn(source().createSchedule);
|
||||||
render(
|
render(
|
||||||
<AutomationPage
|
<AutomationPage
|
||||||
dataSource={{ ...source(), createSchedule }}
|
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.selectOptions(within(dialog).getByLabelText('操作类型'), 'send-sms');
|
||||||
await user.type(within(dialog).getByLabelText('收件号码'), '13800138000');
|
await user.type(within(dialog).getByLabelText('收件号码'), '13800138000');
|
||||||
await user.type(within(dialog).getByLabelText('短信内容'), '巡检开始');
|
await user.type(within(dialog).getByLabelText('短信内容'), '巡检开始');
|
||||||
await user.selectOptions(within(dialog).getByLabelText('目标方式'), 'group');
|
await user.selectOptions(within(dialog).getByLabelText('目标方式'), 'groups');
|
||||||
await user.selectOptions(within(dialog).getByLabelText('分组'), 'g1');
|
await user.click(within(dialog).getByRole('button', { name: '外场' }));
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: '办公室' }));
|
||||||
await user.clear(within(dialog).getByLabelText('随机延迟(秒)'));
|
await user.clear(within(dialog).getByLabelText('随机延迟(秒)'));
|
||||||
await user.type(within(dialog).getByLabelText('随机延迟(秒)'), '30');
|
await user.type(within(dialog).getByLabelText('随机延迟(秒)'), '30');
|
||||||
|
|
||||||
await user.click(within(dialog).getByRole('button', { name: '检查并继续' }));
|
await user.click(within(dialog).getByRole('button', { name: '检查并继续' }));
|
||||||
const confirmation = within(dialog).getByRole('group', { name: '最终确认' });
|
const confirmation = within(dialog).getByRole('group', { name: '最终确认' });
|
||||||
expect(within(confirmation).getByText('发送短信')).not.toBeNull();
|
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(confirmation).getByRole('checkbox', { name: /我已核对/ }));
|
||||||
await user.click(within(dialog).getByRole('button', { name: '确认并创建' }));
|
await user.click(within(dialog).getByRole('button', { name: '确认并创建' }));
|
||||||
@@ -215,7 +219,7 @@ describe('AutomationPage', () => {
|
|||||||
expect(createSchedule).toHaveBeenCalledWith(
|
expect(createSchedule).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
operationType: 'send-sms',
|
operationType: 'send-sms',
|
||||||
targetSelector: { mode: 'group', groupId: 'g1' },
|
targetSelector: { mode: 'groups', groupIds: ['g1', 'g2'] },
|
||||||
sms: expect.objectContaining({
|
sms: expect.objectContaining({
|
||||||
recipients: ['13800138000'],
|
recipients: ['13800138000'],
|
||||||
content: '巡检开始',
|
content: '巡检开始',
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ function targetLabel(task: ScheduledTask, groupName?: (id: string) => string): s
|
|||||||
if (selector.mode === 'all') return '全部设备';
|
if (selector.mode === 'all') return '全部设备';
|
||||||
if (selector.mode === 'group')
|
if (selector.mode === 'group')
|
||||||
return `分组:${groupName?.(selector.groupId) ?? selector.groupId}`;
|
return `分组:${groupName?.(selector.groupId) ?? selector.groupId}`;
|
||||||
|
if (selector.mode === 'groups') return `${selector.groupIds.length} 个分组`;
|
||||||
if (selector.mode === 'fixed') return `${selector.instanceIds.length} 个固定实例`;
|
if (selector.mode === 'fixed') return `${selector.instanceIds.length} 个固定实例`;
|
||||||
return `${selector.match === 'all' ? '全部匹配' : '任一匹配'}:${selector.tags.join('、')}`;
|
return `${selector.match === 'all' ? '全部匹配' : '任一匹配'}:${selector.tags.join('、')}`;
|
||||||
}
|
}
|
||||||
@@ -112,11 +113,12 @@ function outcomeLabel(value: ScheduledRun['outcome']): string {
|
|||||||
interface EditorForm {
|
interface EditorForm {
|
||||||
name: string;
|
name: string;
|
||||||
operationType: ScheduledTask['operationType'];
|
operationType: ScheduledTask['operationType'];
|
||||||
targetMode: 'fixed' | 'tags' | 'group' | 'all';
|
targetMode: 'fixed' | 'tags' | 'group' | 'groups' | 'all';
|
||||||
fixedIds: string;
|
fixedIds: string;
|
||||||
tags: string;
|
tags: string;
|
||||||
tagMatch: 'any' | 'all';
|
tagMatch: 'any' | 'all';
|
||||||
groupId: string;
|
groupId: string;
|
||||||
|
groupIds: readonly string[];
|
||||||
recipients: string;
|
recipients: string;
|
||||||
content: string;
|
content: string;
|
||||||
triggerKind: ScheduleTrigger['kind'];
|
triggerKind: ScheduleTrigger['kind'];
|
||||||
@@ -143,6 +145,7 @@ const defaults: EditorForm = {
|
|||||||
tags: '',
|
tags: '',
|
||||||
tagMatch: 'any' as 'any' | 'all',
|
tagMatch: 'any' as 'any' | 'all',
|
||||||
groupId: '',
|
groupId: '',
|
||||||
|
groupIds: [],
|
||||||
recipients: '',
|
recipients: '',
|
||||||
content: '',
|
content: '',
|
||||||
triggerKind: 'cron' as ScheduleTrigger['kind'],
|
triggerKind: 'cron' as ScheduleTrigger['kind'],
|
||||||
@@ -278,6 +281,7 @@ export function AutomationPage({
|
|||||||
tags: task.targetSelector.mode === 'tags' ? task.targetSelector.tags.join(', ') : '',
|
tags: task.targetSelector.mode === 'tags' ? task.targetSelector.tags.join(', ') : '',
|
||||||
tagMatch: task.targetSelector.mode === 'tags' ? task.targetSelector.match : 'any',
|
tagMatch: task.targetSelector.mode === 'tags' ? task.targetSelector.match : 'any',
|
||||||
groupId: task.targetSelector.mode === 'group' ? task.targetSelector.groupId : '',
|
groupId: task.targetSelector.mode === 'group' ? task.targetSelector.groupId : '',
|
||||||
|
groupIds: task.targetSelector.mode === 'groups' ? [...task.targetSelector.groupIds] : [],
|
||||||
recipients: '',
|
recipients: '',
|
||||||
content: '',
|
content: '',
|
||||||
triggerKind: trigger.kind,
|
triggerKind: trigger.kind,
|
||||||
@@ -343,13 +347,15 @@ export function AutomationPage({
|
|||||||
cronExpression: form.cronExpression.trim(),
|
cronExpression: form.cronExpression.trim(),
|
||||||
timezone: 'Asia/Shanghai',
|
timezone: 'Asia/Shanghai',
|
||||||
targetSelector:
|
targetSelector:
|
||||||
form.targetMode === 'group'
|
form.targetMode === 'groups'
|
||||||
? { mode: 'group', groupId: form.groupId.trim() }
|
? { mode: 'groups', groupIds: [...form.groupIds] }
|
||||||
: form.targetMode === 'all'
|
: form.targetMode === 'group'
|
||||||
? { mode: 'all' }
|
? { mode: 'group', groupId: form.groupId.trim() }
|
||||||
: form.targetMode === 'fixed'
|
: form.targetMode === 'all'
|
||||||
? { mode: 'fixed', instanceIds: split(form.fixedIds) }
|
? { mode: 'all' }
|
||||||
: { mode: 'tags', match: form.tagMatch, tags: split(form.tags) },
|
: form.targetMode === 'fixed'
|
||||||
|
? { mode: 'fixed', instanceIds: split(form.fixedIds) }
|
||||||
|
: { mode: 'tags', match: form.tagMatch, tags: split(form.tags) },
|
||||||
...(form.operationType === 'send-sms' && (!editingTask?.sms || smsChanged)
|
...(form.operationType === 'send-sms' && (!editingTask?.sms || smsChanged)
|
||||||
? {
|
? {
|
||||||
sms: {
|
sms: {
|
||||||
@@ -719,13 +725,36 @@ export function AutomationPage({
|
|||||||
>
|
>
|
||||||
<option value="fixed">固定实例</option>
|
<option value="fixed">固定实例</option>
|
||||||
<option value="tags">动态标签</option>
|
<option value="tags">动态标签</option>
|
||||||
<option value="group">指定分组</option>
|
<option value="groups">多个分组</option>
|
||||||
|
<option value="group">单个分组</option>
|
||||||
<option value="all">全部设备</option>
|
<option value="all">全部设备</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{form.targetMode === 'all' ? (
|
{form.targetMode === 'all' ? (
|
||||||
<p className="field-note">每次执行时选取当前全部已启用节点。</p>
|
<p className="field-note">每次执行时选取当前全部已启用节点。</p>
|
||||||
) : null}
|
) : 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' ? (
|
{form.targetMode === 'group' ? (
|
||||||
<label>
|
<label>
|
||||||
<span>分组</span>
|
<span>分组</span>
|
||||||
@@ -816,6 +845,9 @@ export function AutomationPage({
|
|||||||
onChange={(event) => field('content', event.currentTarget.value)}
|
onChange={(event) => field('content', event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<p className="field-note">
|
||||||
|
发送时可插入 <code>{'${time}'}</code> 和 <code>{'${random}'}</code>。
|
||||||
|
</p>
|
||||||
<label>
|
<label>
|
||||||
<span>随机延迟(秒)</span>
|
<span>随机延迟(秒)</span>
|
||||||
<input
|
<input
|
||||||
@@ -1061,11 +1093,13 @@ export function AutomationPage({
|
|||||||
<dd>
|
<dd>
|
||||||
{form.targetMode === 'all'
|
{form.targetMode === 'all'
|
||||||
? '全部设备'
|
? '全部设备'
|
||||||
: form.targetMode === 'group'
|
: form.targetMode === 'groups'
|
||||||
? `分组:${groupOptions.find((group) => group.id === form.groupId)?.name ?? form.groupId}`
|
? `${form.groupIds.length} 个分组`
|
||||||
: form.targetMode === 'fixed'
|
: form.targetMode === 'group'
|
||||||
? `${form.fixedIds.split(',').filter((item) => item.trim()).length} 个固定实例`
|
? `分组:${groupOptions.find((group) => group.id === form.groupId)?.name ?? form.groupId}`
|
||||||
: `${form.tagMatch === 'all' ? '全部匹配' : '任一匹配'} · ${form.tags}`}
|
: form.targetMode === 'fixed'
|
||||||
|
? `${form.fixedIds.split(',').filter((item) => item.trim()).length} 个固定实例`
|
||||||
|
: `${form.tagMatch === 'all' ? '全部匹配' : '任一匹配'} · ${form.tags}`}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const catalog = {
|
|||||||
rows: 7,
|
rows: 7,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
directory: '/Users/chick/.local/opt/multi-simadmin/data/backups',
|
||||||
};
|
};
|
||||||
|
|
||||||
const backup = {
|
const backup = {
|
||||||
@@ -19,6 +20,7 @@ const backup = {
|
|||||||
createdAt: '2026-09-04T03:30:00.000Z',
|
createdAt: '2026-09-04T03:30:00.000Z',
|
||||||
sizeBytes: 4096,
|
sizeBytes: 4096,
|
||||||
appVersion: '0.1.0',
|
appVersion: '0.1.0',
|
||||||
|
formatVersion: 1,
|
||||||
note: '升级前',
|
note: '升级前',
|
||||||
automatic: true,
|
automatic: true,
|
||||||
integrity: 'ok',
|
integrity: 'ok',
|
||||||
@@ -48,7 +50,7 @@ describe('component backup API data source', () => {
|
|||||||
const source = createComponentBackupApiDataSource(fetcher);
|
const source = createComponentBackupApiDataSource(fetcher);
|
||||||
const signal = new AbortController().signal;
|
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.list(signal)).resolves.toEqual([backup]);
|
||||||
await expect(source.autoSettings(signal)).resolves.toEqual(settings);
|
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[0]?.[0]).toBe('/api/v1/system/component-backups/catalog');
|
||||||
@@ -61,12 +63,16 @@ describe('component backup API data source', () => {
|
|||||||
const fetcher = vi
|
const fetcher = vi
|
||||||
.fn<typeof fetch>()
|
.fn<typeof fetch>()
|
||||||
.mockResolvedValueOnce(json(backup, 201))
|
.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 }));
|
.mockResolvedValueOnce(json({ filename: backup.filename }));
|
||||||
const source = createComponentBackupApiDataSource(fetcher);
|
const source = createComponentBackupApiDataSource(fetcher);
|
||||||
|
|
||||||
await expect(source.create(['devices'], '升级前')).resolves.toEqual(backup);
|
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();
|
await expect(source.remove(backup.filename)).resolves.toBeUndefined();
|
||||||
|
|
||||||
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({
|
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' });
|
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 () => {
|
it('saves the schedule and rejects an unknown component key', async () => {
|
||||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json(settings));
|
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json(settings));
|
||||||
const source = createComponentBackupApiDataSource(fetcher);
|
const source = createComponentBackupApiDataSource(fetcher);
|
||||||
@@ -111,4 +131,18 @@ describe('component backup API data source', () => {
|
|||||||
createComponentBackupApiDataSource(fetcher).remove('../secret.json'),
|
createComponentBackupApiDataSource(fetcher).remove('../secret.json'),
|
||||||
).rejects.toThrowError('Component backup response is invalid.');
|
).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'
|
| 'notificationRecords'
|
||||||
| 'automation'
|
| 'automation'
|
||||||
| 'automationRecords'
|
| 'automationRecords'
|
||||||
|
| 'jobs'
|
||||||
| 'sms'
|
| 'sms'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
| 'audit';
|
| 'audit';
|
||||||
@@ -20,6 +21,7 @@ export type ComponentBackup = Readonly<{
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
sizeBytes: number;
|
sizeBytes: number;
|
||||||
appVersion: string;
|
appVersion: string;
|
||||||
|
formatVersion: number;
|
||||||
note: string;
|
note: string;
|
||||||
automatic: boolean;
|
automatic: boolean;
|
||||||
integrity: 'ok' | 'failed';
|
integrity: 'ok' | 'failed';
|
||||||
@@ -36,12 +38,20 @@ export type AutoBackupSettings = Readonly<{
|
|||||||
lastRunAt: string | null;
|
lastRunAt: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type ComponentCatalog = Readonly<{
|
||||||
|
items: readonly BackupComponent[];
|
||||||
|
directory: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
export interface ComponentBackupDataSource {
|
export interface ComponentBackupDataSource {
|
||||||
catalog(signal?: AbortSignal): Promise<readonly BackupComponent[]>;
|
catalog(signal?: AbortSignal): Promise<ComponentCatalog>;
|
||||||
list(signal?: AbortSignal): Promise<readonly ComponentBackup[]>;
|
list(signal?: AbortSignal): Promise<readonly ComponentBackup[]>;
|
||||||
create(components: readonly BackupComponentKey[], note: string): Promise<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>;
|
remove(filename: string): Promise<void>;
|
||||||
|
preview(filename: string): Promise<ComponentBackup>;
|
||||||
|
backupDownloadUrl(filename: string): string;
|
||||||
autoSettings(signal?: AbortSignal): Promise<AutoBackupSettings>;
|
autoSettings(signal?: AbortSignal): Promise<AutoBackupSettings>;
|
||||||
saveAutoSettings(
|
saveAutoSettings(
|
||||||
settings: Readonly<{
|
settings: Readonly<{
|
||||||
@@ -57,6 +67,7 @@ export interface ComponentBackupDataSource {
|
|||||||
const BASE = '/api/v1/system/component-backups';
|
const BASE = '/api/v1/system/component-backups';
|
||||||
const MAX_TEXT = 200;
|
const MAX_TEXT = 200;
|
||||||
const MAX_BACKUPS = 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 BACKUP_FILENAME = /^multi-simadmin-components-(?:auto-)?[A-Za-z0-9._-]{1,120}\.json$/u;
|
||||||
const KEYS: readonly BackupComponentKey[] = [
|
const KEYS: readonly BackupComponentKey[] = [
|
||||||
'devices',
|
'devices',
|
||||||
@@ -64,6 +75,7 @@ const KEYS: readonly BackupComponentKey[] = [
|
|||||||
'notificationRecords',
|
'notificationRecords',
|
||||||
'automation',
|
'automation',
|
||||||
'automationRecords',
|
'automationRecords',
|
||||||
|
'jobs',
|
||||||
'sms',
|
'sms',
|
||||||
'settings',
|
'settings',
|
||||||
'audit',
|
'audit',
|
||||||
@@ -116,6 +128,7 @@ function parseBackup(value: unknown): ComponentBackup {
|
|||||||
!shortText(source.filename) ||
|
!shortText(source.filename) ||
|
||||||
!BACKUP_FILENAME.test(source.filename) ||
|
!BACKUP_FILENAME.test(source.filename) ||
|
||||||
!text(source.appVersion) ||
|
!text(source.appVersion) ||
|
||||||
|
!integer(source.formatVersion, 0, 1) ||
|
||||||
!text(source.note) ||
|
!text(source.note) ||
|
||||||
typeof source.automatic !== 'boolean' ||
|
typeof source.automatic !== 'boolean' ||
|
||||||
(source.integrity !== 'ok' && source.integrity !== 'failed') ||
|
(source.integrity !== 'ok' && source.integrity !== 'failed') ||
|
||||||
@@ -130,6 +143,7 @@ function parseBackup(value: unknown): ComponentBackup {
|
|||||||
createdAt: new Date(createdAt).toISOString(),
|
createdAt: new Date(createdAt).toISOString(),
|
||||||
sizeBytes: source.sizeBytes,
|
sizeBytes: source.sizeBytes,
|
||||||
appVersion: source.appVersion,
|
appVersion: source.appVersion,
|
||||||
|
formatVersion: source.formatVersion,
|
||||||
note: source.note,
|
note: source.note,
|
||||||
automatic: source.automatic,
|
automatic: source.automatic,
|
||||||
integrity: source.integrity,
|
integrity: source.integrity,
|
||||||
@@ -195,8 +209,10 @@ export function createComponentBackupApiDataSource(
|
|||||||
return {
|
return {
|
||||||
async catalog(signal) {
|
async catalog(signal) {
|
||||||
const source = record(await read(`${BASE}/catalog`, signal));
|
const source = record(await read(`${BASE}/catalog`, signal));
|
||||||
if (!source || !Array.isArray(source.items)) throw invalid();
|
if (!source || !Array.isArray(source.items) || typeof source.directory !== 'string')
|
||||||
return parseComponentList(source.items);
|
throw invalid();
|
||||||
|
if (source.directory.length > MAX_PATH) throw invalid();
|
||||||
|
return { items: parseComponentList(source.items), directory: source.directory };
|
||||||
},
|
},
|
||||||
async list(signal) {
|
async list(signal) {
|
||||||
const source = record(await read(BASE, signal));
|
const source = record(await read(BASE, signal));
|
||||||
@@ -227,7 +243,10 @@ export function createComponentBackupApiDataSource(
|
|||||||
headers: jsonHeaders,
|
headers: jsonHeaders,
|
||||||
body: JSON.stringify({ components }),
|
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) {
|
async remove(filename) {
|
||||||
const response = await fetcher(`${BASE}/${encodeURIComponent(safeName(filename))}`, {
|
const response = await fetcher(`${BASE}/${encodeURIComponent(safeName(filename))}`, {
|
||||||
@@ -237,6 +256,12 @@ export function createComponentBackupApiDataSource(
|
|||||||
});
|
});
|
||||||
requireOk(response, await readJson(response));
|
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) {
|
async autoSettings(signal) {
|
||||||
return parseAutoSettings(await read(`${BASE}/auto/settings`, signal));
|
return parseAutoSettings(await read(`${BASE}/auto/settings`, signal));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ComponentBackupPanel,
|
ComponentBackupPanel,
|
||||||
type AutoBackupSettings,
|
type AutoBackupSettings,
|
||||||
type BackupComponent,
|
type BackupComponent,
|
||||||
|
type ComponentCatalog,
|
||||||
type ComponentBackup,
|
type ComponentBackup,
|
||||||
type ComponentBackupDataSource,
|
type ComponentBackupDataSource,
|
||||||
} from './component-backup-panel.js';
|
} from './component-backup-panel.js';
|
||||||
@@ -27,13 +28,19 @@ const sms: BackupComponent = {
|
|||||||
rows: 120,
|
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 = {
|
const archive: ComponentBackup = {
|
||||||
filename: 'multi-simadmin-components-2026-09-04T03-30-00-000Z.json',
|
filename: 'multi-simadmin-components-2026-09-04T03-30-00-000Z.json',
|
||||||
createdAt: '2026-09-04T03:30:00.000Z',
|
createdAt: '2026-09-04T03:30:00.000Z',
|
||||||
sizeBytes: 4096,
|
sizeBytes: 4096,
|
||||||
appVersion: '0.1.0',
|
appVersion: '0.1.0',
|
||||||
|
formatVersion: 1,
|
||||||
note: '升级前快照',
|
note: '升级前快照',
|
||||||
automatic: false,
|
automatic: false,
|
||||||
integrity: 'ok',
|
integrity: 'ok',
|
||||||
@@ -44,9 +51,9 @@ const archive: ComponentBackup = {
|
|||||||
const schedule: AutoBackupSettings = {
|
const schedule: AutoBackupSettings = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
components: ['devices'],
|
components: ['devices'],
|
||||||
timeOfDay: '03:30',
|
timeOfDay: '02:00',
|
||||||
weekday: -1,
|
weekday: -1,
|
||||||
maximumCount: 14,
|
maximumCount: 10,
|
||||||
lastRunAt: null,
|
lastRunAt: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,10 +62,14 @@ function source(overrides: Partial<ComponentBackupDataSource> = {}): ComponentBa
|
|||||||
catalog: vi.fn(async () => catalog),
|
catalog: vi.fn(async () => catalog),
|
||||||
list: vi.fn(async () => [archive]),
|
list: vi.fn(async () => [archive]),
|
||||||
create: 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),
|
remove: vi.fn(async () => undefined),
|
||||||
autoSettings: vi.fn(async () => schedule),
|
autoSettings: vi.fn(async () => schedule),
|
||||||
saveAutoSettings: vi.fn(async (value) => ({ ...schedule, ...value, lastRunAt: null })),
|
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,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -71,7 +82,7 @@ async function mount(dataSource: ComponentBackupDataSource): Promise<HTMLElement
|
|||||||
async function picker(region: HTMLElement): Promise<HTMLElement> {
|
async function picker(region: HTMLElement): Promise<HTMLElement> {
|
||||||
const group = within(region).getByRole('group', { name: '备份组件' });
|
const group = within(region).getByRole('group', { name: '备份组件' });
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(within(group).getAllByRole('checkbox')).toHaveLength(catalog.length);
|
expect(within(group).getAllByRole('checkbox')).toHaveLength(catalogItems.length);
|
||||||
});
|
});
|
||||||
return group;
|
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('正常')).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 () => {
|
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 () => {
|
it('restores only the components that stay checked', async () => {
|
||||||
const dataSource = source();
|
const dataSource = source();
|
||||||
|
const completed = vi.fn();
|
||||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
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: '恢复' }));
|
await userEvent.click(within(region).getByRole('button', { name: '恢复' }));
|
||||||
const panel = await screen.findByRole('group', { name: '选择恢复组件' });
|
const panel = await screen.findByRole('group', { name: '选择恢复组件' });
|
||||||
await userEvent.click(within(panel).getByRole('checkbox', { name: /短信记录/ }));
|
await userEvent.click(within(panel).getByRole('checkbox', { name: /短信记录/ }));
|
||||||
await userEvent.click(within(panel).getByRole('button', { name: '确认恢复' }));
|
await userEvent.click(within(panel).getByRole('button', { name: '确认恢复' }));
|
||||||
expect(dataSource.restore).toHaveBeenCalledWith(archive.filename, ['devices']);
|
expect(dataSource.restore).toHaveBeenCalledWith(archive.filename, ['devices']);
|
||||||
|
expect(completed).toHaveBeenCalledWith(
|
||||||
|
'设备与分组已合并恢复,恢复前的状态保存在 multi-simadmin-components-auto-safety.json。',
|
||||||
|
);
|
||||||
expect(confirm).toHaveBeenCalledTimes(1);
|
expect(confirm).toHaveBeenCalledTimes(1);
|
||||||
confirm.mockRestore();
|
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 () => {
|
it('does not restore when the operator cancels the confirmation', async () => {
|
||||||
const dataSource = source();
|
const dataSource = source();
|
||||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
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('checkbox', { name: /启用自动备份/ }));
|
||||||
await userEvent.click(within(region).getByRole('button', { name: '保存备份计划' }));
|
await userEvent.click(within(region).getByRole('button', { name: '保存备份计划' }));
|
||||||
expect(dataSource.saveAutoSettings).toHaveBeenCalledWith(
|
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('定时组件备份计划已保存。');
|
expect(completed).toHaveBeenCalledWith('定时组件备份计划已保存。');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type {
|
|||||||
AutoBackupSettings,
|
AutoBackupSettings,
|
||||||
BackupComponent,
|
BackupComponent,
|
||||||
BackupComponentKey,
|
BackupComponentKey,
|
||||||
|
ComponentCatalog,
|
||||||
ComponentBackup,
|
ComponentBackup,
|
||||||
ComponentBackupDataSource,
|
ComponentBackupDataSource,
|
||||||
} from './component-backup-api-data-source.js';
|
} 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. */
|
/** Component-scoped export, merge and scheduled backup, mirroring the Hub backup centre. */
|
||||||
export function ComponentBackupPanel({
|
export function ComponentBackupPanel({
|
||||||
@@ -55,6 +63,7 @@ export function ComponentBackupPanel({
|
|||||||
}) {
|
}) {
|
||||||
const [source] = useState(() => dataSource ?? createComponentBackupApiDataSource());
|
const [source] = useState(() => dataSource ?? createComponentBackupApiDataSource());
|
||||||
const [catalog, setCatalog] = useState<readonly BackupComponent[]>([]);
|
const [catalog, setCatalog] = useState<readonly BackupComponent[]>([]);
|
||||||
|
const [directory, setDirectory] = useState('');
|
||||||
const [backups, setBackups] = useState<readonly ComponentBackup[]>([]);
|
const [backups, setBackups] = useState<readonly ComponentBackup[]>([]);
|
||||||
const [schedule, setSchedule] = useState<AutoBackupSettings>();
|
const [schedule, setSchedule] = useState<AutoBackupSettings>();
|
||||||
const [selected, setSelected] = useState<ReadonlySet<BackupComponentKey>>(
|
const [selected, setSelected] = useState<ReadonlySet<BackupComponentKey>>(
|
||||||
@@ -65,6 +74,7 @@ export function ComponentBackupPanel({
|
|||||||
const [restoreSelection, setRestoreSelection] = useState<ReadonlySet<BackupComponentKey>>(
|
const [restoreSelection, setRestoreSelection] = useState<ReadonlySet<BackupComponentKey>>(
|
||||||
() => new Set<BackupComponentKey>(),
|
() => new Set<BackupComponentKey>(),
|
||||||
);
|
);
|
||||||
|
const [previewTarget, setPreviewTarget] = useState<ComponentBackup>();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const [busy, setBusy] = useState<Busy>();
|
const [busy, setBusy] = useState<Busy>();
|
||||||
@@ -83,7 +93,8 @@ export function ComponentBackupPanel({
|
|||||||
]).then(
|
]).then(
|
||||||
([nextCatalog, nextBackups, nextSchedule]) => {
|
([nextCatalog, nextBackups, nextSchedule]) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setCatalog(nextCatalog);
|
setCatalog(nextCatalog.items);
|
||||||
|
setDirectory(nextCatalog.directory);
|
||||||
setBackups(nextBackups);
|
setBackups(nextBackups);
|
||||||
setSchedule(nextSchedule);
|
setSchedule(nextSchedule);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -106,7 +117,8 @@ export function ComponentBackupPanel({
|
|||||||
source.list(),
|
source.list(),
|
||||||
source.autoSettings(),
|
source.autoSettings(),
|
||||||
]);
|
]);
|
||||||
setCatalog(nextCatalog);
|
setCatalog(nextCatalog.items);
|
||||||
|
setDirectory(nextCatalog.directory);
|
||||||
setBackups(nextBackups);
|
setBackups(nextBackups);
|
||||||
setSchedule(nextSchedule);
|
setSchedule(nextSchedule);
|
||||||
}, [source]);
|
}, [source]);
|
||||||
@@ -137,10 +149,25 @@ export function ComponentBackupPanel({
|
|||||||
|
|
||||||
const openRestore = (backup: ComponentBackup) => {
|
const openRestore = (backup: ComponentBackup) => {
|
||||||
setError('');
|
setError('');
|
||||||
|
setPreviewTarget(undefined);
|
||||||
setRestoreTarget(backup);
|
setRestoreTarget(backup);
|
||||||
setRestoreSelection(new Set(backup.components.map((component) => component.key)));
|
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 = () =>
|
const confirmRestore = () =>
|
||||||
run('restore', async () => {
|
run('restore', async () => {
|
||||||
if (!restoreTarget) return '';
|
if (!restoreTarget) return '';
|
||||||
@@ -156,10 +183,12 @@ export function ComponentBackupPanel({
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return '';
|
return '';
|
||||||
await source.restore(restoreTarget.filename, components);
|
const safety = await source.restore(restoreTarget.filename, components);
|
||||||
setRestoreTarget(undefined);
|
setRestoreTarget(undefined);
|
||||||
await reload();
|
await reload();
|
||||||
return `${labels}已合并恢复。`;
|
return safety
|
||||||
|
? `${labels}已合并恢复,恢复前的状态保存在 ${safety}。`
|
||||||
|
: `${labels}已合并恢复。`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const remove = (backup: ComponentBackup) =>
|
const remove = (backup: ComponentBackup) =>
|
||||||
@@ -214,6 +243,12 @@ export function ComponentBackupPanel({
|
|||||||
<section aria-label="组件备份">
|
<section aria-label="组件备份">
|
||||||
<h2>组件备份</h2>
|
<h2>组件备份</h2>
|
||||||
<p className="maintenance-hint">按组件导出与合并控制台数据,密钥材料不会被写入备份文件。</p>
|
<p className="maintenance-hint">按组件导出与合并控制台数据,密钥材料不会被写入备份文件。</p>
|
||||||
|
{directory ? (
|
||||||
|
<p className="maintenance-hint backup-directory">
|
||||||
|
<span>备份目录</span>
|
||||||
|
<code title={directory}>{directory}</code>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p role="status" aria-label="组件备份加载状态">
|
<p role="status" aria-label="组件备份加载状态">
|
||||||
@@ -290,7 +325,12 @@ export function ComponentBackupPanel({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{backups.map((backup) => (
|
{backups.map((backup) => (
|
||||||
<tr key={backup.filename}>
|
<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>
|
<td>
|
||||||
<span
|
<span
|
||||||
className="backup-components"
|
className="backup-components"
|
||||||
@@ -301,7 +341,7 @@ export function ComponentBackupPanel({
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{formatBytes(backup.sizeBytes)}</td>
|
<td>{formatBytes(backup.sizeBytes)}</td>
|
||||||
<td>{backup.automatic ? '定时' : '手动'}</td>
|
<td>{originOf(backup)}</td>
|
||||||
<td>
|
<td>
|
||||||
{backup.integrity === 'ok' ? (
|
{backup.integrity === 'ok' ? (
|
||||||
<Tag size="small" color="app-green" variant="soft">
|
<Tag size="small" color="app-green" variant="soft">
|
||||||
@@ -314,6 +354,19 @@ export function ComponentBackupPanel({
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="maintenance-backup-actions">
|
<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
|
<Button
|
||||||
htmlType="button"
|
htmlType="button"
|
||||||
disabled={
|
disabled={
|
||||||
@@ -339,6 +392,66 @@ export function ComponentBackupPanel({
|
|||||||
</div>
|
</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 ? (
|
{restoreTarget ? (
|
||||||
<div className="backup-restore-panel" role="group" aria-label="选择恢复组件">
|
<div className="backup-restore-panel" role="group" aria-label="选择恢复组件">
|
||||||
<h3>从 {restoreTarget.filename} 恢复</h3>
|
<h3>从 {restoreTarget.filename} 恢复</h3>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { SystemMaintenanceDataSource } from './system-maintenance-api-data-
|
|||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
const defaults = { heartbeatSeconds: 30, offlineSeconds: 90 };
|
const defaults = { heartbeatSeconds: 30, offlineSeconds: 90, authorizationMode: 'auto' };
|
||||||
|
|
||||||
function source(overrides: Partial<SystemMaintenanceDataSource> = {}): SystemMaintenanceDataSource {
|
function source(overrides: Partial<SystemMaintenanceDataSource> = {}): SystemMaintenanceDataSource {
|
||||||
return {
|
return {
|
||||||
@@ -36,6 +36,10 @@ function field(region: HTMLElement, label: string): HTMLInputElement {
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function modeButton(region: HTMLElement, label: string): HTMLButtonElement {
|
||||||
|
return within(region).getByRole('button', { name: label }) as HTMLButtonElement;
|
||||||
|
}
|
||||||
|
|
||||||
describe('ConnectionSettingsPanel', () => {
|
describe('ConnectionSettingsPanel', () => {
|
||||||
it('loads the saved cadence into the inputs', async () => {
|
it('loads the saved cadence into the inputs', async () => {
|
||||||
const { data, region } = await mount();
|
const { data, region } = await mount();
|
||||||
@@ -44,6 +48,30 @@ describe('ConnectionSettingsPanel', () => {
|
|||||||
expect(field(region, '离线判定(秒)').value).toBe('90');
|
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 () => {
|
it('keeps save disabled until a valid change is made', async () => {
|
||||||
const { region } = await mount();
|
const { region } = await mount();
|
||||||
const save = within(region).getByRole('button', { name: '保存设置' }) as HTMLButtonElement;
|
const save = within(region).getByRole('button', { name: '保存设置' }) as HTMLButtonElement;
|
||||||
@@ -69,9 +97,12 @@ describe('ConnectionSettingsPanel', () => {
|
|||||||
expect(data.updateConnection).toHaveBeenCalledWith({
|
expect(data.updateConnection).toHaveBeenCalledWith({
|
||||||
heartbeatSeconds: 45,
|
heartbeatSeconds: 45,
|
||||||
offlineSeconds: 180,
|
offlineSeconds: 180,
|
||||||
|
authorizationMode: 'auto',
|
||||||
});
|
});
|
||||||
const notice = await within(region).findByRole('status');
|
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 () => {
|
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.type(field(region, '离线判定(秒)'), '150');
|
||||||
await userEvent.click(within(region).getByRole('button', { name: '保存设置' }));
|
await userEvent.click(within(region).getByRole('button', { name: '保存设置' }));
|
||||||
const problem = await within(region).findByRole('alert');
|
const problem = await within(region).findByRole('alert');
|
||||||
expect(problem.textContent).toContain('连接设置未保存,请检查心跳与离线判定的取值范围。');
|
expect(problem.textContent).toContain('连接设置未保存,请检查各项设置的取值范围。');
|
||||||
expect(field(region, '心跳间隔(秒)').value).toBe('60');
|
expect(field(region, '心跳间隔(秒)').value).toBe('60');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function ConnectionSettingsPanel({
|
|||||||
const [settings, setSettings] = useState<ConnectionSettings>();
|
const [settings, setSettings] = useState<ConnectionSettings>();
|
||||||
const [heartbeat, setHeartbeat] = useState('');
|
const [heartbeat, setHeartbeat] = useState('');
|
||||||
const [offline, setOffline] = useState('');
|
const [offline, setOffline] = useState('');
|
||||||
|
const [authorizationMode, setAuthorizationMode] = useState<'auto' | 'manual'>('auto');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [notice, setNotice] = useState('');
|
const [notice, setNotice] = useState('');
|
||||||
const [busy, setBusy] = useState<'save' | 'refresh' | undefined>();
|
const [busy, setBusy] = useState<'save' | 'refresh' | undefined>();
|
||||||
@@ -45,6 +46,7 @@ export function ConnectionSettingsPanel({
|
|||||||
setSettings(value);
|
setSettings(value);
|
||||||
setHeartbeat(String(value.heartbeatSeconds));
|
setHeartbeat(String(value.heartbeatSeconds));
|
||||||
setOffline(String(value.offlineSeconds));
|
setOffline(String(value.offlineSeconds));
|
||||||
|
setAuthorizationMode(value.authorizationMode);
|
||||||
})
|
})
|
||||||
.catch(() => active && setError('无法读取连接设置,请刷新页面重试。'));
|
.catch(() => active && setError('无法读取连接设置,请刷新页面重试。'));
|
||||||
return () => {
|
return () => {
|
||||||
@@ -57,7 +59,9 @@ export function ConnectionSettingsPanel({
|
|||||||
const offlineSeconds = clampSeconds(offline, (heartbeatSeconds ?? 0) * 2, MAX_OFFLINE_SECONDS);
|
const offlineSeconds = clampSeconds(offline, (heartbeatSeconds ?? 0) * 2, MAX_OFFLINE_SECONDS);
|
||||||
const dirty =
|
const dirty =
|
||||||
settings !== undefined &&
|
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>) => {
|
const run = async (action: 'save' | 'refresh', work: () => Promise<string>) => {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
@@ -69,7 +73,7 @@ export function ConnectionSettingsPanel({
|
|||||||
} catch {
|
} catch {
|
||||||
setError(
|
setError(
|
||||||
action === 'save'
|
action === 'save'
|
||||||
? '连接设置未保存,请检查心跳与离线判定的取值范围。'
|
? '连接设置未保存,请检查各项设置的取值范围。'
|
||||||
: '设备刷新未完成,请稍后重试。',
|
: '设备刷新未完成,请稍后重试。',
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -84,7 +88,7 @@ export function ConnectionSettingsPanel({
|
|||||||
<div>
|
<div>
|
||||||
<h2>连接设置</h2>
|
<h2>连接设置</h2>
|
||||||
<p className="maintenance-hint">
|
<p className="maintenance-hint">
|
||||||
心跳决定管理台多久检查一次设备,离线判定决定多久没有响应才标记为离线。
|
心跳、离线判定和新设备授权方式决定控制台如何建立并保持设备连接。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -135,6 +139,33 @@ export function ConnectionSettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="action-buttons">
|
||||||
<Button
|
<Button
|
||||||
htmlType="button"
|
htmlType="button"
|
||||||
@@ -151,11 +182,15 @@ export function ConnectionSettingsPanel({
|
|||||||
const saved = await source.updateConnection({
|
const saved = await source.updateConnection({
|
||||||
heartbeatSeconds: heartbeatSeconds as number,
|
heartbeatSeconds: heartbeatSeconds as number,
|
||||||
offlineSeconds: offlineSeconds as number,
|
offlineSeconds: offlineSeconds as number,
|
||||||
|
authorizationMode,
|
||||||
});
|
});
|
||||||
setSettings(saved);
|
setSettings(saved);
|
||||||
setHeartbeat(String(saved.heartbeatSeconds));
|
setHeartbeat(String(saved.heartbeatSeconds));
|
||||||
setOffline(String(saved.offlineSeconds));
|
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: {
|
storage: {
|
||||||
databaseBytes: 2048,
|
databaseBytes: 2048,
|
||||||
|
databasePath: '/tmp/multi-simadmin.db',
|
||||||
walBytes: 512,
|
walBytes: 512,
|
||||||
backupBytes: 8192,
|
backupBytes: 8192,
|
||||||
reclaimableBytes: 256,
|
reclaimableBytes: 256,
|
||||||
@@ -39,6 +40,74 @@ function json(body: unknown, status = 200): Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('system maintenance API data source', () => {
|
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 () => {
|
it('loads the native overview with same-origin request controls', async () => {
|
||||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json(overview));
|
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json(overview));
|
||||||
const signal = new AbortController().signal;
|
const signal = new AbortController().signal;
|
||||||
@@ -141,6 +210,7 @@ describe('system maintenance API data source', () => {
|
|||||||
...overview,
|
...overview,
|
||||||
storage: {
|
storage: {
|
||||||
...overview.storage,
|
...overview.storage,
|
||||||
|
databasePath: undefined,
|
||||||
components: [
|
components: [
|
||||||
{ key: 'instances', count: 2, bytes: 0 },
|
{ key: 'instances', count: 2, bytes: 0 },
|
||||||
{ key: 'auditEvents', count: 12, bytes: 0 },
|
{ key: 'auditEvents', count: 12, bytes: 0 },
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type RetentionPolicy = Readonly<{
|
|||||||
export type ConnectionSettings = Readonly<{
|
export type ConnectionSettings = Readonly<{
|
||||||
heartbeatSeconds: number;
|
heartbeatSeconds: number;
|
||||||
offlineSeconds: number;
|
offlineSeconds: number;
|
||||||
|
authorizationMode: 'auto' | 'manual';
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type HeartbeatSummary = Readonly<{
|
export type HeartbeatSummary = Readonly<{
|
||||||
@@ -36,6 +37,8 @@ export type SystemMaintenanceOverview = Readonly<{
|
|||||||
}>;
|
}>;
|
||||||
storage: Readonly<{
|
storage: Readonly<{
|
||||||
databaseBytes: number;
|
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. */
|
/** Write-ahead log file size; 0 when the database is not in WAL mode. */
|
||||||
walBytes: number;
|
walBytes: number;
|
||||||
/** Total size of the retained backup files. */
|
/** Total size of the retained backup files. */
|
||||||
@@ -177,6 +180,10 @@ function parseOverview(value: unknown): SystemMaintenanceOverview {
|
|||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
databaseBytes: storage.databaseBytes,
|
databaseBytes: storage.databaseBytes,
|
||||||
|
databasePath:
|
||||||
|
typeof storage.databasePath === 'string' && storage.databasePath.length <= 1_024
|
||||||
|
? storage.databasePath
|
||||||
|
: undefined,
|
||||||
walBytes: optionalInteger(storage.walBytes),
|
walBytes: optionalInteger(storage.walBytes),
|
||||||
backupBytes: optionalInteger(storage.backupBytes),
|
backupBytes: optionalInteger(storage.backupBytes),
|
||||||
reclaimableBytes: optionalInteger(storage.reclaimableBytes),
|
reclaimableBytes: optionalInteger(storage.reclaimableBytes),
|
||||||
@@ -188,13 +195,19 @@ function parseOverview(value: unknown): SystemMaintenanceOverview {
|
|||||||
|
|
||||||
function parseConnectionSettings(value: unknown): ConnectionSettings {
|
function parseConnectionSettings(value: unknown): ConnectionSettings {
|
||||||
const source = record(value);
|
const source = record(value);
|
||||||
|
const authorizationMode = source?.authorizationMode ?? 'auto';
|
||||||
if (
|
if (
|
||||||
!source ||
|
!source ||
|
||||||
!integer(source.heartbeatSeconds, MIN_HEARTBEAT_SECONDS, MAX_HEARTBEAT_SECONDS) ||
|
!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.');
|
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 {
|
function parseHeartbeat(value: unknown): HeartbeatSummary {
|
||||||
@@ -287,7 +300,8 @@ export function createSystemMaintenanceApiDataSource(
|
|||||||
async updateConnection(settings) {
|
async updateConnection(settings) {
|
||||||
if (
|
if (
|
||||||
!integer(settings.heartbeatSeconds, MIN_HEARTBEAT_SECONDS, MAX_HEARTBEAT_SECONDS) ||
|
!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.');
|
throw new Error('System maintenance query is invalid.');
|
||||||
const response = await fetcher('/api/v1/system/connection', {
|
const response = await fetcher('/api/v1/system/connection', {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
SystemMaintenancePage,
|
SystemMaintenancePage,
|
||||||
type SystemMaintenanceDataSource,
|
type SystemMaintenanceDataSource,
|
||||||
} from './system-maintenance-page.js';
|
} from './system-maintenance-page.js';
|
||||||
|
import type { ConsoleUpdateDataSource, UpdateStatus } from './console-update-api-data-source.js';
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ const overview = {
|
|||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
databaseBytes: 4096,
|
databaseBytes: 4096,
|
||||||
|
databasePath: '/tmp/multi-simadmin.db',
|
||||||
walBytes: 1024,
|
walBytes: 1024,
|
||||||
backupBytes: 2048,
|
backupBytes: 2048,
|
||||||
reclaimableBytes: 512,
|
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', () => {
|
describe('System maintenance page', () => {
|
||||||
it('loads the native overview and backups', async () => {
|
it('loads the native overview and backups', async () => {
|
||||||
const dataSource = source();
|
const dataSource = source();
|
||||||
render(<SystemMaintenancePage dataSource={dataSource} />);
|
render(<SystemMaintenancePage dataSource={dataSource} updateDataSource={updateSource()} />);
|
||||||
|
|
||||||
expect(screen.getByRole('status', { name: '系统维护加载状态' })).toBeTruthy();
|
expect(screen.getByRole('status', { name: '系统维护加载状态' })).toBeTruthy();
|
||||||
const status = await screen.findByRole('region', { name: '运行状态' });
|
const status = await screen.findByRole('region', { name: '运行状态' });
|
||||||
expect(within(status).getByText('0.1.0')).toBeTruthy();
|
expect(within(status).getByText('0.1.0')).toBeTruthy();
|
||||||
expect(within(status).getByText('2 分钟')).toBeTruthy();
|
expect(within(status).getByText('2 分钟')).toBeTruthy();
|
||||||
|
expect(within(status).getByText('/tmp/multi-simadmin.db')).toBeTruthy();
|
||||||
|
|
||||||
const storage = screen.getByRole('region', { name: '存储统计' });
|
const storage = screen.getByRole('region', { name: '存储统计' });
|
||||||
const table = within(storage).getByRole('table');
|
const table = within(storage).getByRole('table');
|
||||||
@@ -98,7 +123,7 @@ describe('System maintenance page', () => {
|
|||||||
it('saves edited retention policies', async () => {
|
it('saves edited retention policies', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const dataSource = source();
|
const dataSource = source();
|
||||||
render(<SystemMaintenancePage dataSource={dataSource} />);
|
render(<SystemMaintenancePage dataSource={dataSource} updateDataSource={updateSource()} />);
|
||||||
await screen.findByRole('region', { name: '数据保留' });
|
await screen.findByRole('region', { name: '数据保留' });
|
||||||
|
|
||||||
await user.type(screen.getByLabelText('审计事件保留天数'), '30');
|
await user.type(screen.getByLabelText('审计事件保留天数'), '30');
|
||||||
@@ -117,7 +142,7 @@ describe('System maintenance page', () => {
|
|||||||
const confirm = vi.fn(() => true);
|
const confirm = vi.fn(() => true);
|
||||||
window.confirm = confirm;
|
window.confirm = confirm;
|
||||||
const dataSource = source();
|
const dataSource = source();
|
||||||
render(<SystemMaintenancePage dataSource={dataSource} />);
|
render(<SystemMaintenancePage dataSource={dataSource} updateDataSource={updateSource()} />);
|
||||||
await screen.findByRole('region', { name: '维护操作' });
|
await screen.findByRole('region', { name: '维护操作' });
|
||||||
|
|
||||||
await user.click(screen.getByRole('checkbox', { name: '审计事件' }));
|
await user.click(screen.getByRole('checkbox', { name: '审计事件' }));
|
||||||
@@ -137,7 +162,7 @@ describe('System maintenance page', () => {
|
|||||||
const confirm = vi.fn(() => true);
|
const confirm = vi.fn(() => true);
|
||||||
window.confirm = confirm;
|
window.confirm = confirm;
|
||||||
const dataSource = source();
|
const dataSource = source();
|
||||||
render(<SystemMaintenancePage dataSource={dataSource} />);
|
render(<SystemMaintenancePage dataSource={dataSource} updateDataSource={updateSource()} />);
|
||||||
await screen.findByRole('table', { name: '本地备份' });
|
await screen.findByRole('table', { name: '本地备份' });
|
||||||
|
|
||||||
const row = screen.getByRole('row', { name: /multi-simadmin-2026\.db/ });
|
const row = screen.getByRole('row', { name: /multi-simadmin-2026\.db/ });
|
||||||
@@ -158,11 +183,24 @@ describe('System maintenance page', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
window.confirm = vi.fn(() => false);
|
window.confirm = vi.fn(() => false);
|
||||||
const dataSource = source();
|
const dataSource = source();
|
||||||
render(<SystemMaintenancePage dataSource={dataSource} />);
|
render(<SystemMaintenancePage dataSource={dataSource} updateDataSource={updateSource()} />);
|
||||||
await screen.findByRole('table', { name: '本地备份' });
|
await screen.findByRole('table', { name: '本地备份' });
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: '删除' }));
|
await user.click(screen.getByRole('button', { name: '删除' }));
|
||||||
expect(dataSource.deleteBackup).not.toHaveBeenCalled();
|
expect(dataSource.deleteBackup).not.toHaveBeenCalled();
|
||||||
expect(screen.getByText('multi-simadmin-2026.db')).toBeTruthy();
|
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,
|
SystemMaintenanceOverview,
|
||||||
} from './system-maintenance-api-data-source.js';
|
} from './system-maintenance-api-data-source.js';
|
||||||
import { createSystemMaintenanceApiDataSource } 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 { ComponentBackupPanel } from './component-backup-panel.js';
|
||||||
import { ConnectionSettingsPanel } from './connection-settings-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 { SystemMaintenanceDataSource } from './system-maintenance-api-data-source.js';
|
||||||
|
export type { ConsoleUpdateDataSource } from './console-update-api-data-source.js';
|
||||||
|
|
||||||
const COMPONENT_KEYS: readonly DataComponentKey[] = [
|
const COMPONENT_KEYS: readonly DataComponentKey[] = [
|
||||||
'instances',
|
'instances',
|
||||||
@@ -73,8 +76,10 @@ type BusyAction = 'retention' | 'cleanup' | 'optimize' | 'backup' | 'delete' | u
|
|||||||
|
|
||||||
export function SystemMaintenancePage({
|
export function SystemMaintenancePage({
|
||||||
dataSource,
|
dataSource,
|
||||||
|
updateDataSource,
|
||||||
}: {
|
}: {
|
||||||
readonly dataSource?: SystemMaintenanceDataSource;
|
readonly dataSource?: SystemMaintenanceDataSource;
|
||||||
|
readonly updateDataSource?: ConsoleUpdateDataSource;
|
||||||
}) {
|
}) {
|
||||||
const [source] = useState(() => dataSource ?? createSystemMaintenanceApiDataSource());
|
const [source] = useState(() => dataSource ?? createSystemMaintenanceApiDataSource());
|
||||||
const [overview, setOverview] = useState<SystemMaintenanceOverview>();
|
const [overview, setOverview] = useState<SystemMaintenanceOverview>();
|
||||||
@@ -193,9 +198,18 @@ export function SystemMaintenancePage({
|
|||||||
<h1 id="system-maintenance-title">
|
<h1 id="system-maintenance-title">
|
||||||
<Title color="app-green">系统维护</Title>
|
<Title color="app-green">系统维护</Title>
|
||||||
</h1>
|
</h1>
|
||||||
<p>集中管理控制台运行状态、数据保留、SQLite 优化和本地备份。</p>
|
<p>集中管理控制台版本更新、运行状态、数据保留、SQLite 优化和本地备份。</p>
|
||||||
</header>
|
</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 ? (
|
{loading ? (
|
||||||
<p role="status" aria-label="系统维护加载状态">
|
<p role="status" aria-label="系统维护加载状态">
|
||||||
正在读取系统维护状态…
|
正在读取系统维护状态…
|
||||||
@@ -235,6 +249,12 @@ export function SystemMaintenancePage({
|
|||||||
<dt>运行时间</dt>
|
<dt>运行时间</dt>
|
||||||
<dd>{Math.floor(overview.runtime.uptimeSeconds / 60)} 分钟</dd>
|
<dd>{Math.floor(overview.runtime.uptimeSeconds / 60)} 分钟</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>数据路径</dt>
|
||||||
|
<dd>
|
||||||
|
<code>{overview.storage.databasePath || '—'}</code>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -581,6 +581,13 @@ main > section > header p,
|
|||||||
.fleet-cross-health-item strong[data-tone='attention'] {
|
.fleet-cross-health-item strong[data-tone='attention'] {
|
||||||
color: var(--warning);
|
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 {
|
.fleet-toolbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
@@ -1396,6 +1403,34 @@ main > section > header p,
|
|||||||
letter-spacing: 0;
|
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 {
|
.backup-component-picker {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
@@ -1481,6 +1516,40 @@ main > section > header p,
|
|||||||
font-size: 0.86rem;
|
font-size: 0.86rem;
|
||||||
overflow-wrap: anywhere;
|
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 {
|
.backup-schedule {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
@@ -1601,6 +1670,26 @@ main > section > header p,
|
|||||||
font-weight: 400;
|
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-notice,
|
||||||
.maintenance-error {
|
.maintenance-error {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -1619,6 +1708,70 @@ main > section > header p,
|
|||||||
background: var(--danger-soft);
|
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 */
|
||||||
.instance-detail {
|
.instance-detail {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1922,6 +2075,36 @@ dd {
|
|||||||
font-size: 0.74rem;
|
font-size: 0.74rem;
|
||||||
line-height: 1.5;
|
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 {
|
.device-action-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
@@ -3327,6 +3510,317 @@ main {
|
|||||||
color: var(--workbench-muted);
|
color: var(--workbench-muted);
|
||||||
font-size: 0.78rem;
|
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 {
|
.color-picker {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
Reference in New Issue
Block a user