feat(web): rebuild fleet page with Hub-style identity guard and context actions

- Surface device identity state, carrier details and upstream link health.

- Add fleet-level identity monitoring and restart/baseband context actions.

- Apply consistent notification scoping across fleet views.
This commit is contained in:
chick
2026-09-07 00:45:28 +08:00
parent 635527dd26
commit 864e1bd90c
16 changed files with 1596 additions and 90 deletions
+194 -2
View File
@@ -2,7 +2,8 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { FleetPage, accessMethod, type FleetSnapshot } from './fleet-page.js';
import { accessMethod } from './fleet-table-view-model.js';
import { FleetPage, type FleetSnapshot } from './fleet-page.js';
const snapshot: FleetSnapshot = {
instances: [
@@ -202,6 +203,55 @@ describe('FleetPage card navigation', () => {
).toBe(true);
});
it('opens the Hub-style device context drawer and keeps the dashboard one click away', async () => {
const messagesDataSource = {
load: vi.fn(async () => ({
latest: {
id: 'sms-1',
direction: 'incoming',
phoneNumber: '13900139000',
content: 'status',
timestamp: '2026-07-19T08:30:00.000Z',
},
})),
};
render(<FleetPage initialData={snapshot} messagesDataSource={messagesDataSource} />);
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
expect(screen.queryByRole('dialog', { name: 'Alpha modem 设备上下文' })).toBeNull();
fireEvent.click(within(card).getByRole('button', { name: '打开 Alpha modem 设备上下文' }));
const drawer = screen.getByRole('dialog', { name: 'Alpha modem 设备上下文' });
const focusables = Array.from(
drawer.querySelectorAll<HTMLElement>('a[href], button:not([disabled])'),
);
fireEvent.focus(focusables[0]!);
const dashboard = within(drawer).getByRole('link', { name: '打开 Alpha modem 实例仪表盘' });
expect(dashboard.getAttribute('href')).toBe('/instances/alpha/overview');
expect(
within(drawer).getByRole('link', { name: '打开 Alpha modem 节点入口' }).getAttribute('href'),
).toBe('http://192.168.1.2');
expect(within(drawer).getByText('局域网接入')).toBeTruthy();
expect(
within(drawer).getByRole('progressbar', { name: 'CPU 使用率' }).getAttribute('aria-valuenow'),
).toBe('24');
expect(await within(drawer).findByRole('region', { name: '短信状态' })).toBeTruthy();
fireEvent.click(within(drawer).getByRole('button', { name: '关闭设备上下文' }));
expect(screen.queryByRole('dialog', { name: 'Alpha modem 设备上下文' })).toBeNull();
expect(document.activeElement).toBe(
within(card).getByRole('button', { name: '打开 Alpha modem 设备上下文' }),
);
fireEvent.click(within(card).getByRole('button', { name: '打开 Alpha modem 设备上下文' }));
fireEvent.click(screen.getByRole('dialog', { name: 'Alpha modem 设备上下文' }));
expect(screen.getByRole('dialog', { name: 'Alpha modem 设备上下文' })).toBeTruthy();
fireEvent.click(
screen.getByRole('dialog', { name: 'Alpha modem 设备上下文' }).closest('.drawer-backdrop')!,
);
expect(screen.queryByRole('dialog', { name: 'Alpha modem 设备上下文' })).toBeNull();
});
it('shows an explicit fallback when the upstream SimAdmin version is unavailable', () => {
const withoutVersion: FleetSnapshot = {
...snapshot,
@@ -240,12 +290,17 @@ describe('FleetPage card navigation', () => {
const serviceRestart = within(card).getByRole('menuitem', {
name: '重启服务 Alpha modem',
});
const basebandRestart = within(card).getByRole('menuitem', {
name: '重启基带 Alpha modem',
});
const systemReboot = within(card).getByRole('menuitem', { name: '系统重启 Alpha modem' });
expect(document.activeElement).toBe(messagesLink);
fireEvent.keyDown(messagesLink, { key: 'ArrowDown' });
expect(document.activeElement).toBe(serviceRestart);
fireEvent.keyDown(serviceRestart, { key: 'ArrowDown' });
expect(document.activeElement).toBe(basebandRestart);
fireEvent.keyDown(basebandRestart, { key: 'ArrowDown' });
expect(document.activeElement).toBe(systemReboot);
fireEvent.keyDown(systemReboot, { key: 'ArrowDown' });
expect(document.activeElement).toBe(messagesLink);
@@ -287,6 +342,32 @@ describe('FleetPage search and filter toolbar', () => {
expect(document.querySelector('.fleet-result-count')?.textContent).toMatch(/显示\s*1\s*\/\s*1/);
});
it('filters devices by access method and reports the active condition', () => {
const accessSnapshot: FleetSnapshot = {
instances: [
...snapshot.instances,
{
id: 'wan',
name: 'Wan modem',
url: 'https://wan.example',
tags: [],
},
],
statuses: new Map([...snapshot.statuses, ['wan', { reachable: true, authenticated: true }]]),
};
render(<FleetPage initialData={accessSnapshot} />);
fireEvent.click(screen.getByText('更多筛选'));
fireEvent.change(screen.getByLabelText('接入方式'), {
target: { value: 'lan' },
});
expect(screen.getByRole('article', { name: 'Alpha modem 实例概览' })).toBeTruthy();
expect(screen.queryByRole('article', { name: 'Wan modem 实例概览' })).toBeNull();
expect(screen.getByRole('button', { name: '移除筛选 接入:局域网接入' })).toBeTruthy();
});
it('filters the matrix from the device group row without moving search out of the sidebar', () => {
const groupedSnapshot: FleetSnapshot = {
instances: [
@@ -382,6 +463,14 @@ describe('FleetPage batch and restart actions', () => {
batchable: false,
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
},
{
operationId: 'postBasebandRestart',
title: 'Restart Baseband',
risk: 'R3',
capability: 'job',
batchable: false,
parameterSchemaId: 'simadmin.58e2204.postBasebandRestart.parameters.v1',
},
{
operationId: 'postSystemReboot',
title: 'Reboot System',
@@ -391,7 +480,7 @@ describe('FleetPage batch and restart actions', () => {
parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1',
},
],
page: { page: 1, pageSize: 100, total: 2 },
page: { page: 1, pageSize: 100, total: 3 },
}));
vi.stubGlobal('confirm', () => true);
render(
@@ -406,7 +495,21 @@ describe('FleetPage batch and restart actions', () => {
});
expect(messages.getAttribute('href')).toBe('/fleet/messages?device=alpha');
expect(within(card).getByRole('menuitem', { name: '重启服务 Alpha modem' })).toBeTruthy();
expect(within(card).getByRole('menuitem', { name: '重启基带 Alpha modem' })).toBeTruthy();
expect(within(card).getByRole('menuitem', { name: '系统重启 Alpha modem' })).toBeTruthy();
fireEvent.click(within(card).getByRole('menuitem', { name: '重启基带 Alpha modem' }));
await vi.waitFor(() =>
expect(prepare).toHaveBeenCalledWith(
expect.objectContaining({
operationId: 'postBasebandRestart',
parameters: expect.objectContaining({
parameterSchemaId: 'simadmin.58e2204.postBasebandRestart.parameters.v1',
fields: [],
}),
}),
),
);
expect(execute).toHaveBeenCalledWith('prep-1');
fireEvent.click(screen.getByRole('button', { name: '批量选择' }));
fireEvent.click(within(card).getByRole('checkbox', { name: '选择 Alpha modem' }));
expect(
@@ -537,4 +640,93 @@ describe('FleetPage heartbeat reporting', () => {
expect(row?.querySelector('time')).toBeNull();
expect(row?.textContent).toContain('未上报');
});
const withIdentity = (identity: Record<string, unknown> | undefined): FleetSnapshot => ({
instances: snapshot.instances,
statuses: new Map([
[
'alpha',
{
...snapshot.statuses.get('alpha')!,
...(identity ? { identity } : {}),
},
],
]),
});
it('warns on the card that the identity guard is holding the node', () => {
render(
<FleetPage
initialData={withIdentity({ tracked: true, status: 'pending', reasons: ['imei_claimed'] })}
/>,
);
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
expect(card.querySelector('.fleet-card-identity')?.textContent).toContain('身份待确认');
expect(card.querySelector('.fleet-card-identity')?.textContent).toContain('控制操作已暂停');
});
it('stays quiet for a confirmed node and for one that never reported identity', () => {
render(
<FleetPage initialData={withIdentity({ tracked: true, status: 'confirmed', reasons: [] })} />,
);
expect(
screen
.getByRole('article', { name: 'Alpha modem 实例概览' })
.querySelector('.fleet-card-identity'),
).toBeNull();
cleanup();
render(<FleetPage initialData={withIdentity(undefined)} />);
expect(
screen
.getByRole('article', { name: 'Alpha modem 实例概览' })
.querySelector('.fleet-card-identity'),
).toBeNull();
});
it('opens the identity guard from the node toolbar', async () => {
const identityDataSource = {
list: vi.fn().mockResolvedValue({ items: [], tracked: 0, pending: 0 }),
confirm: vi.fn(),
refresh: vi.fn(),
};
render(
<FleetPage initialData={withIdentity(undefined)} identityDataSource={identityDataSource} />,
);
fireEvent.click(screen.getByRole('button', { name: '设备身份核对' }));
expect(await screen.findByRole('dialog', { name: '设备身份核对' })).toBeTruthy();
expect(identityDataSource.list).toHaveBeenCalled();
});
it('counts held nodes in the cross-health rail and opens the guard from there', async () => {
const identityDataSource = {
list: vi.fn().mockResolvedValue({ items: [], tracked: 0, pending: 0 }),
confirm: vi.fn(),
refresh: vi.fn(),
};
render(
<FleetPage
initialData={withIdentity({ tracked: true, status: 'pending', reasons: ['imei_claimed'] })}
identityDataSource={identityDataSource}
/>,
);
const rail = screen.getByRole('button', { name: '设备身份待确认节点' });
expect(rail.textContent).toContain('身份待确认');
expect(rail.querySelector('strong')?.textContent).toBe('1');
expect(rail.querySelector('strong')?.getAttribute('data-tone')).toBe('attention');
fireEvent.click(rail);
expect(await screen.findByRole('dialog', { name: '设备身份核对' })).toBeTruthy();
});
it('shows a clean identity rail when every node matches its record', () => {
render(
<FleetPage initialData={withIdentity({ tracked: true, status: 'confirmed', reasons: [] })} />,
);
const rail = screen.getByRole('button', { name: '设备身份待确认节点' });
expect(rail.querySelector('strong')?.textContent).toBe('0');
expect(rail.querySelector('strong')?.getAttribute('data-tone')).toBe('ok');
});
});