feat(web): rebuild the console around the fused control plane
Render the Hub feature set in the existing Animal Island styling: fleet messages, notification centre, log centre, organisation panel, per-instance device module panels and the settings backup, connection and maintenance surfaces.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FleetPage, type FleetSnapshot } from './fleet-page.js';
|
||||
import { FleetPage, accessMethod, type FleetSnapshot } from './fleet-page.js';
|
||||
|
||||
const snapshot: FleetSnapshot = {
|
||||
instances: [
|
||||
@@ -28,6 +28,14 @@ const snapshot: FleetSnapshot = {
|
||||
memoryPercent: 51,
|
||||
maxTemperatureCelsius: 42,
|
||||
phoneNumbers: ['+852 5550 0100'],
|
||||
hardwareOnline: true,
|
||||
controlOnline: true,
|
||||
simPresent: true,
|
||||
carrier: 'China Mobile',
|
||||
cellularRegistration: 'registered_home',
|
||||
accessTechnology: 'LTE',
|
||||
cellularOnline: true,
|
||||
signalPercent: 72,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -155,6 +163,16 @@ describe('FleetPage card navigation', () => {
|
||||
within(card).getByRole('progressbar', { name: '内存使用率' }).getAttribute('aria-valuenow'),
|
||||
).toBe('51');
|
||||
expect(within(card).getByText('进入仪表盘')).toBeTruthy();
|
||||
const links = within(card).getByRole('region', { name: '链路状态' });
|
||||
for (const label of ['硬件', '控制', 'SIM', '蜂窝']) {
|
||||
expect(within(links).getByText(label)).toBeTruthy();
|
||||
}
|
||||
expect(links.textContent).toMatch(/硬件\s*正常.*控制\s*正常.*SIM\s*正常.*蜂窝\s*正常/s);
|
||||
expect(within(card).getByText('China Mobile')).toBeTruthy();
|
||||
expect(within(card).getByText('LTE · 已注册')).toBeTruthy();
|
||||
expect(
|
||||
within(card).getByRole('progressbar', { name: '蜂窝信号' }).getAttribute('aria-valuenow'),
|
||||
).toBe('72');
|
||||
const nodeEntry = within(card).getByRole('link', { name: '打开 Alpha modem 节点入口' });
|
||||
expect(nodeEntry.getAttribute('href')).toBe('http://192.168.1.2');
|
||||
expect(nodeEntry.getAttribute('target')).toBe('_blank');
|
||||
@@ -216,17 +234,22 @@ describe('FleetPage card navigation', () => {
|
||||
trigger.focus();
|
||||
fireEvent.keyDown(trigger, { key: 'ArrowDown' });
|
||||
|
||||
const messagesLink = within(card).getByRole('menuitem', {
|
||||
name: '打开 Alpha modem 短信中心',
|
||||
});
|
||||
const serviceRestart = within(card).getByRole('menuitem', {
|
||||
name: '重启服务 Alpha modem',
|
||||
});
|
||||
const systemReboot = within(card).getByRole('menuitem', { name: '系统重启 Alpha modem' });
|
||||
expect(document.activeElement).toBe(serviceRestart);
|
||||
expect(document.activeElement).toBe(messagesLink);
|
||||
|
||||
fireEvent.keyDown(messagesLink, { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toBe(serviceRestart);
|
||||
fireEvent.keyDown(serviceRestart, { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toBe(systemReboot);
|
||||
fireEvent.keyDown(systemReboot, { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toBe(serviceRestart);
|
||||
fireEvent.keyDown(serviceRestart, { key: 'ArrowUp' });
|
||||
expect(document.activeElement).toBe(messagesLink);
|
||||
fireEvent.keyDown(messagesLink, { key: 'ArrowUp' });
|
||||
expect(document.activeElement).toBe(systemReboot);
|
||||
|
||||
fireEvent.keyDown(systemReboot, { key: 'Escape' });
|
||||
@@ -264,15 +287,16 @@ describe('FleetPage search and filter toolbar', () => {
|
||||
expect(document.querySelector('.fleet-result-count')?.textContent).toMatch(/显示\s*1\s*\/\s*1/);
|
||||
});
|
||||
|
||||
it('filters the matrix from the tag group row without moving search out of the sidebar', () => {
|
||||
it('filters the matrix from the device group row without moving search out of the sidebar', () => {
|
||||
const groupedSnapshot: FleetSnapshot = {
|
||||
instances: [
|
||||
{ ...snapshot.instances[0]!, tags: ['核心'] },
|
||||
{ ...snapshot.instances[0]!, groupId: 'group-core' },
|
||||
{
|
||||
id: 'beta',
|
||||
name: 'Beta modem',
|
||||
url: 'http://192.168.1.3',
|
||||
tags: ['外场'],
|
||||
groupId: 'group-field',
|
||||
revision: 1,
|
||||
},
|
||||
],
|
||||
@@ -281,17 +305,30 @@ describe('FleetPage search and filter toolbar', () => {
|
||||
['beta', { reachable: false, authenticated: false }],
|
||||
]),
|
||||
};
|
||||
render(<FleetPage initialData={groupedSnapshot} />);
|
||||
render(
|
||||
<FleetPage
|
||||
initialData={groupedSnapshot}
|
||||
groups={[
|
||||
{ id: 'group-core', name: '核心', description: '', deviceCount: 1 },
|
||||
{ id: 'group-field', name: '外场', description: '', deviceCount: 1 },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const groups = screen.getByRole('group', { name: '节点分组' });
|
||||
const betaCard = screen.getByRole('article', { name: 'Beta modem 实例概览' });
|
||||
expect(screen.queryByRole('region', { name: '节点资源健康' })).toBeNull();
|
||||
expect(within(betaCard).getAllByText('--')).toHaveLength(3);
|
||||
// CPU, 内存, 信号, 运行时长 and 最高温度 all fall back to a dash with no telemetry.
|
||||
expect(within(betaCard).getAllByText('--')).toHaveLength(5);
|
||||
// The group chip and the tag chip are separate rows even when they read the same.
|
||||
expect(betaCard.querySelector('.fleet-instance-groups')?.textContent).toContain('外场');
|
||||
expect(betaCard.querySelector('.fleet-instance-tags')?.textContent).toContain('外场');
|
||||
expect(within(groups).getByRole('button', { name: '全部节点' })).toBeTruthy();
|
||||
fireEvent.click(within(groups).getByRole('button', { name: '核心' }));
|
||||
fireEvent.click(within(groups).getByRole('button', { name: /核心/ }));
|
||||
|
||||
expect(screen.getByRole('article', { name: 'Alpha modem 实例概览' })).toBeTruthy();
|
||||
expect(screen.queryByRole('article', { name: 'Beta modem 实例概览' })).toBeNull();
|
||||
expect(betaCard.isConnected).toBe(false);
|
||||
expect(
|
||||
screen
|
||||
.getByRole('search', { name: '实例搜索与筛选' })
|
||||
@@ -364,6 +401,10 @@ describe('FleetPage batch and restart actions', () => {
|
||||
const cardMenuTrigger = within(card).getByRole('button', { name: '实例操作 Alpha modem' });
|
||||
expect(cardMenuTrigger.getAttribute('aria-expanded')).toBe('false');
|
||||
fireEvent.click(cardMenuTrigger);
|
||||
const messages = within(card).getByRole('menuitem', {
|
||||
name: '打开 Alpha modem 短信中心',
|
||||
});
|
||||
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();
|
||||
fireEvent.click(screen.getByRole('button', { name: '批量选择' }));
|
||||
@@ -378,3 +419,122 @@ describe('FleetPage batch and restart actions', () => {
|
||||
expect(execute).toHaveBeenCalledWith('prep-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FleetPage access method', () => {
|
||||
it('classifies how the control plane reaches each node', () => {
|
||||
expect(accessMethod('http://127.0.0.1:3000/admin')).toEqual({
|
||||
label: '本机直连',
|
||||
kind: 'local',
|
||||
});
|
||||
expect(accessMethod('http://localhost:3000')).toEqual({ label: '本机直连', kind: 'local' });
|
||||
expect(accessMethod('http://[::1]:3000')).toEqual({ label: '本机直连', kind: 'local' });
|
||||
expect(accessMethod('http://192.168.2.69:3000')).toEqual({ label: '局域网接入', kind: 'lan' });
|
||||
expect(accessMethod('http://172.16.5.4')).toEqual({ label: '局域网接入', kind: 'lan' });
|
||||
expect(accessMethod('http://172.32.5.4')).toEqual({ label: '公网地址', kind: 'wan' });
|
||||
expect(accessMethod('http://100.64.0.9')).toEqual({ label: '局域网接入', kind: 'lan' });
|
||||
expect(accessMethod('http://203.0.113.9')).toEqual({ label: '公网地址', kind: 'wan' });
|
||||
expect(accessMethod('https://modem.example.com/admin')).toEqual({
|
||||
label: '域名接入',
|
||||
kind: 'wan',
|
||||
});
|
||||
expect(accessMethod('not-a-url')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the derived access method on the card origin row', () => {
|
||||
render(<FleetPage initialData={snapshot} />);
|
||||
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
||||
const chip = card.querySelector('.fleet-card-access');
|
||||
expect(chip?.textContent).toBe('局域网接入');
|
||||
expect(chip?.getAttribute('data-kind')).toBe('lan');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FleetPage heartbeat reporting', () => {
|
||||
const withConnection = (status: Record<string, unknown>): FleetSnapshot => ({
|
||||
instances: snapshot.instances,
|
||||
statuses: new Map([['alpha', { ...snapshot.statuses.get('alpha')!, ...status }]]),
|
||||
});
|
||||
|
||||
it('renders the age of the last heartbeat the console received', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-09-05T12:00:00.000Z'));
|
||||
try {
|
||||
render(
|
||||
<FleetPage
|
||||
initialData={withConnection({ probed: true, checkedAt: '2026-09-05T11:59:20.000Z' })}
|
||||
/>,
|
||||
);
|
||||
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
||||
const row = card.querySelector('.fleet-card-heartbeat');
|
||||
const time = row?.querySelector('time');
|
||||
expect(row?.getAttribute('data-stale')).toBe('false');
|
||||
expect(time?.getAttribute('datetime')).toBe('2026-09-05T11:59:20.000Z');
|
||||
expect(time?.textContent).toBe('40 秒前');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('ages a long-dead heartbeat into days and flags the card stale', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-09-05T12:00:00.000Z'));
|
||||
try {
|
||||
render(
|
||||
<FleetPage
|
||||
initialData={withConnection({
|
||||
probed: true,
|
||||
reachable: false,
|
||||
authenticated: false,
|
||||
checkedAt: '2026-09-02T12:00:00.000Z',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
||||
const row = card.querySelector('.fleet-card-heartbeat');
|
||||
expect(row?.getAttribute('data-stale')).toBe('true');
|
||||
expect(row?.querySelector('time')?.textContent).toBe('3 天前');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders the device uptime the node reports, down to seconds', () => {
|
||||
const withUptime = (uptimeSeconds: number | undefined): FleetSnapshot => ({
|
||||
instances: snapshot.instances,
|
||||
statuses: new Map([
|
||||
[
|
||||
'alpha',
|
||||
{
|
||||
...snapshot.statuses.get('alpha')!,
|
||||
summary: {
|
||||
...snapshot.statuses.get('alpha')!.summary,
|
||||
resources: {
|
||||
...snapshot.statuses.get('alpha')!.summary?.resources,
|
||||
...(uptimeSeconds === undefined ? {} : { uptimeSeconds }),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
const { unmount } = render(<FleetPage initialData={withUptime(93_784)} />);
|
||||
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
||||
const values = [...card.querySelectorAll<HTMLElement>('.fleet-card-hardware dd')];
|
||||
expect(values.map((node) => node.textContent)).toContain('1天 2小时');
|
||||
unmount();
|
||||
|
||||
render(<FleetPage initialData={withUptime(45)} />);
|
||||
expect(
|
||||
screen.getByRole('article', { name: 'Alpha modem 实例概览' }).textContent?.includes('45秒'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('says 未上报 until the heartbeat has reached the node', () => {
|
||||
render(<FleetPage initialData={withConnection({ probed: false, checkedAt: null })} />);
|
||||
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
||||
const row = card.querySelector('.fleet-card-heartbeat');
|
||||
expect(row?.getAttribute('data-stale')).toBe('true');
|
||||
expect(row?.querySelector('time')).toBeNull();
|
||||
expect(row?.textContent).toContain('未上报');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user