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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user