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:
chick
2026-09-05 18:53:04 +08:00
parent f9186bd851
commit a75badeb58
77 changed files with 20578 additions and 435 deletions
+169 -9
View File
@@ -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('未上报');
});
});