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
+255 -3
View File
@@ -9,8 +9,15 @@ import type { AutomationDataSource as ScheduleDataSource } from './automation/au
import type { EventStreamClient } from './events/event-stream-client.js';
import { type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js';
import type { FleetMessagesDataSource } from './fleet/fleet-messages-api-data-source.js';
import type { FleetMessagesCenterDataSource } from './fleet/fleet-messages-page.js';
import type { InstanceDataSource } from './instances/instance-api-data-source.js';
import type { JobsDataSource } from './jobs/jobs-page.js';
import type {
NotificationCenterDataSource,
NotificationCenterSnapshot,
} from './fleet/notification-center-types.js';
import type { OrganizationDataSource } from './fleet/organization-api-data-source.js';
import type { SystemMaintenanceDataSource } from './settings/system-maintenance-page.js';
afterEach(() => {
cleanup();
@@ -71,6 +78,43 @@ const quietEventStreamClient: EventStreamClient = {
const emptyPage = { items: [], page: { page: 1, pageSize: 25, total: 0 } };
function notificationSnapshot(): NotificationCenterSnapshot {
return {
observedAt: '2026-09-03T01:00:00.000Z',
devices: [{ id: 'device-1', name: 'Modem A', state: 'ready', tags: ['office'] }],
config: {
channelCount: 1,
channelEnabled: 1,
ruleCount: 1,
ruleEnabled: 1,
channelTypes: [{ type: 'webhook', total: 1, enabled: 1 }],
},
logs: { total: 1, success: 1, failed: 0, recent: [] },
queue: {
total: 1,
pending: 1,
retrying: 0,
sending: 0,
succeeded: 0,
failed: 0,
recent: [
{
id: 'q-1',
eventType: 'sms',
status: 'pending',
state: 'pending',
title: '来自 13800000000 的新短信',
attempts: 0,
maxAttempts: 3,
createdAt: '2026-09-03T01:00:00.000Z',
ruleName: '余额提醒',
channelName: '值班 Webhook',
},
],
},
};
}
function emptyScheduleDataSource(): ScheduleDataSource {
return {
listSchedules: vi.fn().mockResolvedValue([]),
@@ -79,12 +123,26 @@ function emptyScheduleDataSource(): ScheduleDataSource {
duplicateSchedule: vi.fn(),
setEnabled: vi.fn(),
removeSchedule: vi.fn(),
previewCron: vi.fn().mockResolvedValue([]),
previewSchedule: vi.fn().mockResolvedValue([]),
runNow: vi.fn(),
listRuns: vi.fn().mockResolvedValue([]),
};
}
function emptyOrganizationDataSource(): OrganizationDataSource {
return {
listGroups: vi.fn().mockResolvedValue([]),
createGroup: vi.fn(),
updateGroup: vi.fn(),
deleteGroup: vi.fn(),
listTags: vi.fn().mockResolvedValue([]),
createTag: vi.fn(),
updateTag: vi.fn(),
deleteTag: vi.fn(),
assignGroup: vi.fn(),
};
}
describe('React AppShell and Fleet vertical slice', () => {
it('loads real injected data and renders canonical origins and owner routes without React key warnings', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
@@ -212,7 +270,7 @@ describe('React AppShell and Fleet vertical slice', () => {
duplicateSchedule: vi.fn(),
setEnabled: vi.fn(),
removeSchedule: vi.fn(),
previewCron: vi.fn().mockResolvedValue([]),
previewSchedule: vi.fn().mockResolvedValue([]),
runNow: vi.fn(),
listRuns: vi.fn().mockResolvedValue([]),
};
@@ -253,7 +311,29 @@ describe('React AppShell and Fleet vertical slice', () => {
);
expect(await screen.findByText(/没有审计事件符合当前查询/i)).toBeTruthy();
rerender(<AppShell pathname="/settings/system" eventStreamClient={quietEventStreamClient} />);
const maintenanceDataSource: SystemMaintenanceDataSource = {
overview: vi.fn(async () => ({
runtime: { version: '0.1.0', platform: 'test', arch: 'arm64', uptimeSeconds: 1 },
storage: { databaseBytes: 0, components: [] },
retention: {},
})),
updateRetention: vi.fn(),
connection: vi.fn(async () => ({ heartbeatSeconds: 30, offlineSeconds: 90 })),
updateConnection: vi.fn(),
refreshHeartbeat: vi.fn(),
cleanup: vi.fn(),
optimize: vi.fn(),
listBackups: vi.fn(async () => []),
createBackup: vi.fn(),
};
rerender(
<AppShell
pathname="/settings/system"
maintenanceDataSource={maintenanceDataSource}
eventStreamClient={quietEventStreamClient}
/>,
);
expect(screen.getByRole('heading', { name: '系统维护' })).toBeTruthy();
expect(screen.getByRole('heading', { name: '密码保护' })).toBeTruthy();
expect(screen.queryByRole('navigation', { name: '设置导航' })).toBeNull();
});
@@ -500,6 +580,7 @@ describe('React AppShell and Fleet vertical slice', () => {
pathname={pathname}
eventStreamClient={quietEventStreamClient}
scheduleDataSource={emptyScheduleDataSource()}
organizationDataSource={emptyOrganizationDataSource()}
/>,
);
@@ -540,6 +621,7 @@ describe('React AppShell and Fleet vertical slice', () => {
pathname={pathname}
eventStreamClient={quietEventStreamClient}
scheduleDataSource={emptyScheduleDataSource()}
organizationDataSource={emptyOrganizationDataSource()}
{...(sourceProp === 'jobsDataSource'
? { jobsDataSource: injectedSource as JobsDataSource }
: { auditDataSource: injectedSource as AuditDataSource })}
@@ -551,4 +633,174 @@ describe('React AppShell and Fleet vertical slice', () => {
expect(fetcher).not.toHaveBeenCalled();
},
);
it('keeps fleet cross-node workspaces inside the Fleet domain and rejects the old Hub namespace', async () => {
expect(resolveRoute('/fleet')).toMatchObject({ kind: 'fleet', pathname: '/fleet' });
expect(resolveRoute('/fleet/messages')).toMatchObject({
kind: 'fleet-messages',
pathname: '/fleet/messages',
});
expect(resolveRoute('/fleet/notifications')).toMatchObject({
kind: 'fleet-notifications',
pathname: '/fleet/notifications',
});
expect(resolveRoute('/hub')).toMatchObject({ kind: 'not-found' });
expect(resolveRoute('/hub/messages')).toMatchObject({ kind: 'not-found' });
expect(resolveRoute('/hub/notifications')).toMatchObject({ kind: 'not-found' });
render(
<AppShell
pathname="/fleet"
fleetDataSource={source(async () => snapshot)}
eventStreamClient={quietEventStreamClient}
/>,
);
expect(await screen.findByRole('article', { name: /Alpha 实例概览/ })).toBeTruthy();
const subnavigation = screen.getByRole('navigation', { name: 'Fleet 功能' });
expect(within(subnavigation).getByRole('link', { name: '节点总览' }).getAttribute('href')).toBe(
'/fleet',
);
expect(within(subnavigation).getByRole('link', { name: '短信中心' }).getAttribute('href')).toBe(
'/fleet/messages',
);
expect(screen.queryByRole('link', { name: '打开 Hub' })).toBeNull();
expect(screen.queryByTitle('SimAdminHub 管理界面')).toBeNull();
});
it('renders the Fleet SMS center route through an injected local data source', async () => {
const load = vi.fn<FleetMessagesDataSource['load']>().mockResolvedValue({
messages: [
{
id: 'm1',
instanceId: 'device-1',
instanceName: 'Modem A',
direction: 'incoming',
phoneNumber: '10086',
content: 'balance updated',
timestamp: '2026-07-29 12:00:00',
status: 'received',
transport: 'gsm',
},
],
devices: [{ id: 'device-1', name: 'Modem A' }],
total: 1,
});
const messageCenterDataSource: FleetMessagesCenterDataSource = {
load,
send: vi.fn().mockResolvedValue(undefined),
deleteMany: vi.fn().mockResolvedValue({
requested: 1,
deleted: 1,
failed: 0,
failures: [],
}),
};
render(
<AppShell
pathname="/fleet/messages"
fleetMessageCenterDataSource={messageCenterDataSource}
eventStreamClient={quietEventStreamClient}
/>,
);
expect(await screen.findByRole('heading', { name: '短信中心' })).toBeTruthy();
expect(screen.getAllByText('Modem A')).toHaveLength(2);
expect(
screen.getByText('balance updated', { selector: '.fleet-messages-list p' }),
).toBeTruthy();
expect(load).toHaveBeenCalledTimes(1);
expect(screen.getByRole('link', { name: '节点总览' }).getAttribute('href')).toBe('/fleet');
expect(screen.queryByRole('link', { name: '返回 Hub 总览' })).toBeNull();
});
it('renders the Notification center route through an injected local data source', async () => {
const user = userEvent.setup();
const loadSnapshot = vi
.fn<NotificationCenterDataSource['loadSnapshot']>()
.mockResolvedValue(notificationSnapshot());
const listChannels = vi.fn<NotificationCenterDataSource['listChannels']>().mockResolvedValue([
{
id: 'ch-1',
name: '值班 Webhook',
type: 'webhook',
enabled: true,
config: { url: 'https://example.invalid/hook' },
},
]);
const listRules = vi.fn<NotificationCenterDataSource['listRules']>().mockResolvedValue([
{
id: 'rule-1',
name: '余额提醒',
eventType: 'sms',
enabled: true,
condition: { field: 'status', mode: 'contains', value: '余额' },
scope: { mode: 'all', tags: [], match: 'any', instanceIds: [] },
channelIds: ['ch-1'],
templates: { title: '余额提醒', body: '{{content}}' },
},
]);
const listQueue = vi.fn<NotificationCenterDataSource['listQueue']>().mockResolvedValue({
items: [],
total: 0,
});
const listLogs = vi.fn<NotificationCenterDataSource['listLogs']>().mockResolvedValue({
items: [],
total: 0,
});
const processQueue = vi
.fn<NotificationCenterDataSource['processQueue']>()
.mockResolvedValue({ processed: 1, succeeded: 1, failed: 0 });
const notificationCenterDataSource: NotificationCenterDataSource = {
loadSnapshot,
listChannels,
saveChannel: vi.fn().mockResolvedValue(undefined),
deleteChannel: vi.fn().mockResolvedValue(undefined),
testChannel: vi.fn().mockResolvedValue({ ok: true }),
listRules,
saveRule: vi.fn().mockResolvedValue(undefined),
deleteRule: vi.fn().mockResolvedValue(undefined),
listLogs,
clearLogs: vi.fn().mockResolvedValue(0),
getLogCleanup: vi.fn().mockResolvedValue({
retentionDaysEnabled: true,
retentionDays: 180,
maxEntriesEnabled: false,
maxEntries: 10_000,
}),
saveLogCleanup: vi.fn().mockResolvedValue({
retentionDaysEnabled: true,
retentionDays: 180,
maxEntriesEnabled: false,
maxEntries: 10_000,
}),
pruneLogs: vi.fn().mockResolvedValue(0),
listQueue,
retryQueueItem: vi.fn().mockResolvedValue(undefined),
deleteQueueItem: vi.fn().mockResolvedValue(undefined),
clearQueue: vi.fn().mockResolvedValue(0),
processQueue,
};
render(
<AppShell
pathname="/fleet/notifications"
fleetNotificationCenterDataSource={notificationCenterDataSource}
eventStreamClient={quietEventStreamClient}
/>,
);
expect(await screen.findByRole('heading', { name: '通知中心' })).toBeTruthy();
expect(screen.getAllByText('1/1').length).toBeGreaterThan(0);
expect(screen.getByText('Modem A')).toBeTruthy();
expect(loadSnapshot).toHaveBeenCalledTimes(1);
expect(listChannels).toHaveBeenCalled();
expect(screen.getByRole('tab', { name: '队列' })).toBeTruthy();
await user.click(screen.getByRole('tab', { name: '队列' }));
expect(await screen.findByRole('button', { name: '立即投递' })).toBeTruthy();
expect(listQueue).toHaveBeenCalled();
expect(screen.getByRole('link', { name: '节点总览' }).getAttribute('href')).toBe('/fleet');
expect(screen.queryByRole('link', { name: '返回 Hub 总览' })).toBeNull();
});
});
+326 -100
View File
@@ -1,10 +1,16 @@
import { ConsoleAuthSettings } from './auth/console-auth-settings.js';
import {
SystemMaintenancePage,
type SystemMaintenanceDataSource,
} from './settings/system-maintenance-page.js';
import { createSystemMaintenanceApiDataSource } from './settings/system-maintenance-api-data-source.js';
import { AuditPage, type AuditDataSource } from './audit/audit-page.js';
import { createAuditApiDataSource } from './audit/audit-api-data-source.js';
import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Tag } from 'animal-island-ui';
import { Icon } from './ui/icon.js';
import { SensitiveRevealProvider, SensitiveRevealToggle } from './privacy/sensitive-reveal.js';
import {
AutomationPage,
type AutomationDataSource as ScheduleDataSource,
@@ -20,9 +26,34 @@ import {
createFleetMessagesApiDataSource,
type FleetMessagesDataSource,
} from './fleet/fleet-messages-api-data-source.js';
import {
FleetMessagesPage,
type FleetMessagesCenterDataSource,
} from './fleet/fleet-messages-page.js';
import { FleetNotificationsPage } from './fleet/fleet-notifications-page.js';
import { FleetLogsPage } from './fleet/fleet-logs-page.js';
import { createLogCenterApiDataSource } from './fleet/log-center-api-data-source.js';
import type { LogCenterDataSource } from './fleet/log-center-types.js';
import {
createOrganizationApiDataSource,
type OrganizationDataSource,
type OrganizationGroup,
} from './fleet/organization-api-data-source.js';
import type { NotificationCenterDataSource } from './fleet/notification-center-types.js';
import { AutomationModule, type AutomationDataSource } from './instances/automation-module.js';
import { CallsModule, type CallsDataSource } from './instances/calls-module.js';
import { CellularModule, type CellularDataSource } from './instances/cellular-module.js';
import {
ConfigurationModule,
DeviceBackupModule,
SimModule,
VowifiModule,
} from './instances/device-module-panels.js';
import {
createInstanceModuleApiReader,
createInstanceModuleDataSources,
type InstanceModuleReader,
} from './instances/instance-module-api-data-source.js';
import {
DeviceNetworkModule,
type DeviceNetworkDataSource,
@@ -50,18 +81,25 @@ import {
export type GlobalSection = 'fleet' | 'automation' | 'settings';
export type InstanceModule =
| 'overview'
| 'sim'
| 'cellular'
| 'device-network'
| 'messages'
| 'calls'
| 'esim'
| 'vowifi'
| 'notifications'
| 'automation'
| 'ota';
| 'ota'
| 'configuration'
| 'device-backup';
export type RouteKind =
| 'redirect'
| 'fleet'
| 'automation'
| 'fleet-messages'
| 'fleet-notifications'
| 'fleet-logs'
| 'instance-new'
| `instance-${InstanceModule}`
| 'jobs'
@@ -100,6 +138,9 @@ export interface AppShellProps {
instance?: InstanceContext;
fleetDataSource?: FleetDataSource;
fleetMessagesDataSource?: FleetMessagesDataSource;
fleetMessageCenterDataSource?: FleetMessagesCenterDataSource;
fleetNotificationCenterDataSource?: NotificationCenterDataSource;
fleetLogsDataSource?: LogCenterDataSource;
fleetData?: FleetSnapshot;
instanceDataSource?: InstanceDataSource;
capabilities?: InstanceCapabilityMap;
@@ -116,6 +157,9 @@ export interface AppShellProps {
jobsDataSource?: JobsDataSource;
auditDataSource?: AuditDataSource;
scheduleDataSource?: ScheduleDataSource;
maintenanceDataSource?: SystemMaintenanceDataSource;
organizationDataSource?: OrganizationDataSource;
deviceGroups?: readonly OrganizationGroup[];
eventStreamClient?: EventStreamClient;
}
@@ -141,6 +185,9 @@ export function resolveRoute(input: string): ResolvedRoute {
return { kind: 'redirect', pathname, to: '/settings/system' };
const staticRoutes: Readonly<Record<string, RouteKind>> = {
'/fleet': 'fleet',
'/fleet/messages': 'fleet-messages',
'/fleet/notifications': 'fleet-notifications',
'/fleet/logs': 'fleet-logs',
'/automation': 'automation',
'/instances/new': 'instance-new',
'/jobs': 'jobs',
@@ -169,7 +216,14 @@ export function resolveRoute(input: string): ResolvedRoute {
}
function section(route: ResolvedRoute): GlobalSection | undefined {
if (route.kind === 'fleet' || route.kind === 'instance-new' || route.kind.startsWith('instance-'))
if (
route.kind === 'fleet' ||
route.kind === 'fleet-messages' ||
route.kind === 'fleet-notifications' ||
route.kind === 'fleet-logs' ||
route.kind === 'instance-new' ||
route.kind.startsWith('instance-')
)
return 'fleet';
if (route.kind === 'automation' || route.kind.startsWith('job') || route.kind.startsWith('audit'))
return 'automation';
@@ -177,10 +231,37 @@ function section(route: ResolvedRoute): GlobalSection | undefined {
return undefined;
}
const FLEET_NAVIGATION = [
{ href: '/fleet', label: '节点总览' },
{ href: '/fleet/messages', label: '短信中心' },
{ href: '/fleet/notifications', label: '通知中心' },
{ href: '/fleet/logs', label: '日志中心' },
] as const;
function FleetSubnavigation({ pathname }: { pathname: string }): ReactNode {
const current = normalize(pathname);
return (
<nav className="fleet-subnavigation" aria-label="Fleet 功能">
{FLEET_NAVIGATION.map((item) => (
<a
key={item.href}
href={item.href}
aria-current={current === item.href ? 'page' : undefined}
>
{item.label}
</a>
))}
</nav>
);
}
function Page({
route,
fleetDataSource,
fleetMessagesDataSource,
fleetMessageCenterDataSource,
fleetNotificationCenterDataSource,
fleetLogsDataSource,
fleetData,
instance,
instanceDataSource,
@@ -198,12 +279,19 @@ function Page({
jobsDataSource,
auditDataSource,
scheduleDataSource,
maintenanceDataSource,
deviceGroups,
onGroupsChanged,
fleetRefreshSignal,
detailRefreshSignal,
moduleReader,
}: {
route: ResolvedRoute;
fleetDataSource: FleetDataSource | undefined;
fleetMessagesDataSource: FleetMessagesDataSource | undefined;
fleetMessageCenterDataSource: FleetMessagesCenterDataSource | undefined;
fleetNotificationCenterDataSource: NotificationCenterDataSource | undefined;
fleetLogsDataSource: LogCenterDataSource | undefined;
fleetData: FleetSnapshot | undefined;
instance: InstanceContext | undefined;
instanceDataSource: InstanceDataSource | undefined;
@@ -221,17 +309,45 @@ function Page({
jobsDataSource: JobsDataSource | undefined;
auditDataSource: AuditDataSource | undefined;
scheduleDataSource: ScheduleDataSource;
maintenanceDataSource: SystemMaintenanceDataSource | undefined;
deviceGroups: readonly OrganizationGroup[];
onGroupsChanged: () => void;
fleetRefreshSignal: number;
detailRefreshSignal: number;
moduleReader: InstanceModuleReader;
}): ReactNode {
if (route.kind === 'fleet')
if (
route.kind === 'fleet' ||
route.kind === 'fleet-messages' ||
route.kind === 'fleet-notifications' ||
route.kind === 'fleet-logs'
)
return (
<FleetPage
{...(fleetDataSource ? { dataSource: fleetDataSource } : {})}
{...(fleetMessagesDataSource ? { messagesDataSource: fleetMessagesDataSource } : {})}
{...(fleetData ? { initialData: fleetData } : {})}
refreshSignal={fleetRefreshSignal}
/>
<>
<FleetSubnavigation pathname={route.pathname} />
{route.kind === 'fleet' ? (
<FleetPage
{...(fleetDataSource ? { dataSource: fleetDataSource } : {})}
{...(fleetMessagesDataSource ? { messagesDataSource: fleetMessagesDataSource } : {})}
{...(fleetData ? { initialData: fleetData } : {})}
groups={deviceGroups}
onGroupsChanged={onGroupsChanged}
refreshSignal={fleetRefreshSignal}
/>
) : route.kind === 'fleet-messages' ? (
<FleetMessagesPage
{...(fleetMessageCenterDataSource ? { dataSource: fleetMessageCenterDataSource } : {})}
/>
) : route.kind === 'fleet-notifications' ? (
<FleetNotificationsPage
{...(fleetNotificationCenterDataSource
? { dataSource: fleetNotificationCenterDataSource }
: {})}
/>
) : (
<FleetLogsPage {...(fleetLogsDataSource ? { dataSource: fleetLogsDataSource } : {})} />
)}
</>
);
if (route.kind === 'instance-new')
return (
@@ -252,6 +368,7 @@ function Page({
return (
<AutomationPage
dataSource={scheduleDataSource}
groupOptions={deviceGroups.map((item) => ({ id: item.id, name: item.name }))}
initialTab={
route.kind === 'jobs' ? 'runs' : route.kind === 'audit' ? 'records' : 'schedules'
}
@@ -269,7 +386,15 @@ function Page({
}
/>
);
if (route.kind === 'settings-system') return <ConsoleAuthSettings />;
if (route.kind === 'settings-system')
return (
<>
<SystemMaintenancePage
{...(maintenanceDataSource ? { dataSource: maintenanceDataSource } : {})}
/>
<ConsoleAuthSettings />
</>
);
if (route.kind === 'not-found')
return (
<section>
@@ -310,6 +435,50 @@ function Page({
),
}
: {})}
{...(module === 'sim' && instance
? {
moduleContent: (
<SimModule
instance={instance}
reader={moduleReader}
refreshSignal={detailRefreshSignal}
/>
),
}
: {})}
{...(module === 'configuration' && instance
? {
moduleContent: (
<ConfigurationModule
instance={instance}
reader={moduleReader}
refreshSignal={detailRefreshSignal}
/>
),
}
: {})}
{...(module === 'device-backup' && instance
? {
moduleContent: (
<DeviceBackupModule
instance={instance}
reader={moduleReader}
refreshSignal={detailRefreshSignal}
/>
),
}
: {})}
{...(module === 'vowifi' && instance
? {
moduleContent: (
<VowifiModule
instance={instance}
reader={moduleReader}
refreshSignal={detailRefreshSignal}
/>
),
}
: {})}
{...(module === 'device-network' && instance
? {
moduleContent: (
@@ -418,7 +587,7 @@ const GLOBAL_NAVIGATION: readonly {
section: GlobalSection;
href: string;
label: string;
icon: 'grid' | 'jobs' | 'settings';
icon: 'grid' | 'jobs' | 'settings' | 'wifi';
}[] = [
{ section: 'fleet', href: '/fleet', label: '节点', icon: 'grid' },
{ section: 'automation', href: '/automation', label: '自动化', icon: 'jobs' },
@@ -447,6 +616,9 @@ export function AppShell({
instance,
fleetDataSource,
fleetMessagesDataSource,
fleetMessageCenterDataSource,
fleetNotificationCenterDataSource,
fleetLogsDataSource,
fleetData,
instanceDataSource,
capabilities,
@@ -463,13 +635,22 @@ export function AppShell({
jobsDataSource,
auditDataSource,
scheduleDataSource,
maintenanceDataSource,
organizationDataSource,
deviceGroups: injectedDeviceGroups,
eventStreamClient,
}: AppShellProps) {
const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []);
const defaultFleetDataSource = useMemo(() => createFleetApiDataSource(), []);
const defaultFleetMessagesDataSource = useMemo(() => createFleetMessagesApiDataSource(), []);
const defaultFleetLogsDataSource = useMemo(() => createLogCenterApiDataSource(), []);
const defaultInstanceDataSource = useMemo(() => createInstanceApiDataSource(), []);
const defaultMessagesDataSource = useMemo(() => createMessagesApiDataSource(), []);
const defaultModuleReader = useMemo(() => createInstanceModuleApiReader(), []);
const defaultModuleSources = useMemo(
() => createInstanceModuleDataSources(defaultModuleReader),
[defaultModuleReader],
);
const resolvedJobsDataSource = useMemo(
() => jobsDataSource ?? createJobsApiDataSource(),
[jobsDataSource],
@@ -482,6 +663,37 @@ export function AppShell({
() => scheduleDataSource ?? createAutomationApiDataSource(),
[scheduleDataSource],
);
const resolvedMaintenanceDataSource = useMemo(
() => maintenanceDataSource ?? createSystemMaintenanceApiDataSource(),
[maintenanceDataSource],
);
const resolvedOrganizationDataSource = useMemo(
() => organizationDataSource ?? createOrganizationApiDataSource(),
[organizationDataSource],
);
const [loadedGroups, setLoadedGroups] = useState<readonly OrganizationGroup[]>([]);
const [groupsRevision, setGroupsRevision] = useState(0);
const deviceGroups = injectedDeviceGroups ?? loadedGroups;
useEffect(() => {
if (injectedDeviceGroups) return;
const controller = new AbortController();
let active = true;
void resolvedOrganizationDataSource
.listGroups(controller.signal)
.then((items) => {
if (active) setLoadedGroups(items);
})
.catch(() => {
/* Group selection degrades to manual id entry when the registry is unavailable. */
});
return () => {
active = false;
controller.abort();
};
}, [injectedDeviceGroups, resolvedOrganizationDataSource, groupsRevision]);
const refreshGroups = useCallback(() => setGroupsRevision((value) => value + 1), []);
const resolved = resolveRoute(pathname);
const route = resolved.kind === 'redirect' ? resolveRoute(resolved.to ?? '/fleet') : resolved;
const routeInstanceId = route.params?.instanceId;
@@ -634,98 +846,112 @@ export function AppShell({
const currentSection = section(route);
const consoleVersion = displayConsoleVersion(version);
return (
<div className="app-shell" data-route={route.kind} data-section={currentSection ?? 'other'}>
<a className="skip-link" href="#main-content">
</a>
<header className="app-topbar">
<a className="product-name" href="/fleet" aria-label="多实例 SimAdmin 管理台首页">
<span className="product-mark" aria-hidden="true">
<span className="product-signal">
<i />
<i />
<i />
</span>
</span>
<span className="product-copy">
<strong>SimAdmin Control</strong>
</span>
<SensitiveRevealProvider>
<div className="app-shell" data-route={route.kind} data-section={currentSection ?? 'other'}>
<a className="skip-link" href="#main-content">
</a>
<nav className="global-navigation" aria-label="全局导航">
{GLOBAL_NAVIGATION.map((item) => (
<a
key={item.section}
href={item.href}
aria-current={currentSection === item.section ? 'page' : undefined}
>
<Icon name={item.icon} />
<span>{item.label}</span>
</a>
))}
</nav>
<div className="topbar-actions">
<Tag
className="connection-status"
color={STREAM_COLORS[refresh.stream]}
variant="soft"
size="small"
>
<span data-state={refresh.stream} role="status" aria-label="实时连接状态">
{STREAM_LABELS[refresh.stream]}
<header className="app-topbar">
<a className="product-name" href="/fleet" aria-label="多实例 SimAdmin 管理台首页">
<span className="product-mark" aria-hidden="true">
<span className="product-signal">
<i />
<i />
<i />
</span>
</span>
</Tag>
{consoleVersion ? (
<Tag className="version-badge" color="brown" variant="soft" size="small">
<span aria-label={`控制台版本 ${consoleVersion}`}>v{consoleVersion}</span>
<span className="product-copy">
<strong>SimAdmin Control</strong>
</span>
</a>
<nav className="global-navigation" aria-label="全局导航">
{GLOBAL_NAVIGATION.map((item) => (
<a
key={item.section}
href={item.href}
aria-current={currentSection === item.section ? 'page' : undefined}
>
<Icon name={item.icon} />
<span>{item.label}</span>
</a>
))}
</nav>
<div className="topbar-actions">
<SensitiveRevealToggle />
<Tag
className="connection-status"
color={STREAM_COLORS[refresh.stream]}
variant="soft"
size="small"
>
<span data-state={refresh.stream} role="status" aria-label="实时连接状态">
{STREAM_LABELS[refresh.stream]}
</span>
</Tag>
) : null}
{consoleVersion ? (
<Tag className="version-badge" color="brown" variant="soft" size="small">
<span aria-label={`控制台版本 ${consoleVersion}`}>v{consoleVersion}</span>
</Tag>
) : null}
</div>
</header>
<div className="app-layout app-layout-single">
<main id="main-content" tabIndex={-1}>
{routeInstanceId && instanceLoading ? (
<p role="status" aria-label="实例加载状态">
</p>
) : routeInstanceId && instanceLoadFailed ? (
<p role="alert" className="state-panel state-error">
</p>
) : (
<Page
route={route}
fleetDataSource={fleetDataSource ?? defaultFleetDataSource}
fleetMessagesDataSource={fleetMessagesDataSource ?? defaultFleetMessagesDataSource}
fleetMessageCenterDataSource={fleetMessageCenterDataSource}
fleetNotificationCenterDataSource={fleetNotificationCenterDataSource}
fleetLogsDataSource={fleetLogsDataSource ?? defaultFleetLogsDataSource}
fleetData={fleetData}
instance={routeInstance}
instanceDataSource={resolvedInstanceDataSource}
capabilities={capabilities}
capabilityDataSource={capabilityDataSource}
overviewDataSource={overviewDataSource ?? defaultModuleSources.overview}
cellularDataSource={cellularDataSource ?? defaultModuleSources.cellular}
deviceNetworkDataSource={
deviceNetworkDataSource ?? defaultModuleSources.deviceNetwork
}
messagesDataSource={messagesDataSource ?? defaultMessagesDataSource}
callsDataSource={callsDataSource ?? defaultModuleSources.calls}
esimDataSource={esimDataSource ?? defaultModuleSources.esim}
notificationsDataSource={
notificationsDataSource ?? defaultModuleSources.notifications
}
automationDataSource={automationDataSource ?? defaultModuleSources.automation}
otaDataSource={otaDataSource ?? defaultModuleSources.ota}
moduleReader={defaultModuleReader}
jobsDataSource={resolvedJobsDataSource}
auditDataSource={resolvedAuditDataSource}
scheduleDataSource={resolvedScheduleDataSource}
maintenanceDataSource={resolvedMaintenanceDataSource}
deviceGroups={deviceGroups}
onGroupsChanged={refreshGroups}
fleetRefreshSignal={refresh.fleet}
detailRefreshSignal={refresh.detail}
/>
)}
</main>
</div>
</header>
<div className="app-layout app-layout-single">
<main id="main-content" tabIndex={-1}>
{routeInstanceId && instanceLoading ? (
<p role="status" aria-label="实例加载状态">
</p>
) : routeInstanceId && instanceLoadFailed ? (
<p role="alert" className="state-panel state-error">
</p>
) : (
<Page
route={route}
fleetDataSource={fleetDataSource ?? defaultFleetDataSource}
fleetMessagesDataSource={fleetMessagesDataSource ?? defaultFleetMessagesDataSource}
fleetData={fleetData}
instance={routeInstance}
instanceDataSource={resolvedInstanceDataSource}
capabilities={capabilities}
capabilityDataSource={capabilityDataSource}
overviewDataSource={overviewDataSource}
cellularDataSource={cellularDataSource}
deviceNetworkDataSource={deviceNetworkDataSource}
messagesDataSource={messagesDataSource ?? defaultMessagesDataSource}
callsDataSource={callsDataSource}
esimDataSource={esimDataSource}
notificationsDataSource={notificationsDataSource}
automationDataSource={automationDataSource}
otaDataSource={otaDataSource}
jobsDataSource={resolvedJobsDataSource}
auditDataSource={resolvedAuditDataSource}
scheduleDataSource={resolvedScheduleDataSource}
fleetRefreshSignal={refresh.fleet}
detailRefreshSignal={refresh.detail}
/>
)}
</main>
<footer className="app-footer" aria-label="项目与组件库信息">
<p>
UI: <a href="https://github.com/guokaigdg/animal-island-ui">animal-island-ui</a>
<span aria-hidden="true"> · </span>
<a href="https://creativecommons.org/licenses/by-nc/4.0/">CC BY-NC 4.0</a>
</p>
</footer>
</div>
<footer className="app-footer" aria-label="项目与组件库信息">
<p>
UI: <a href="https://github.com/guokaigdg/animal-island-ui">animal-island-ui</a>
<span aria-hidden="true"> · </span>
<a href="https://creativecommons.org/licenses/by-nc/4.0/">CC BY-NC 4.0</a>
</p>
</footer>
</div>
</SensitiveRevealProvider>
);
}
@@ -48,9 +48,9 @@ describe('Automation API data source', () => {
).resolves.toEqual(task);
await expect(source.duplicateSchedule(task.id, task.version)).resolves.toEqual(task);
await expect(source.setEnabled(task.id, task.version, false)).resolves.toEqual(task);
await expect(source.previewCron(input.cronExpression)).resolves.toEqual([
'2026-07-30T01:00:00.000Z',
]);
await expect(
source.previewSchedule({ kind: 'cron', expression: input.cronExpression }),
).resolves.toEqual(['2026-07-30T01:00:00.000Z']);
await expect(source.runNow(task.id)).resolves.toEqual({ runId: 'run-1' });
await expect(source.listRuns(controller.signal)).resolves.toEqual([]);
await expect(source.removeSchedule(task.id, task.version)).resolves.toBeUndefined();
@@ -1,5 +1,6 @@
import type {
CreateScheduledTaskRequest,
ScheduleTrigger,
ScheduledRun,
ScheduledTask,
} from '@multi-simadmin/contracts';
@@ -72,12 +73,12 @@ export function createAutomationApiDataSource(fetcher: typeof fetch = fetch): Au
});
if (!response.ok) throw new Error(`Automation request failed (${response.status})`);
},
async previewCron(cronExpression) {
async previewSchedule(trigger: ScheduleTrigger) {
const preview = await json<{ occurrences: string[] }>(
await fetcher('/api/v1/automation/cron/preview', {
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify({ cronExpression, count: 5 }),
body: JSON.stringify({ trigger, count: 5 }),
}),
);
return preview.occurrences;
@@ -25,7 +25,7 @@ function source(): AutomationDataSource {
duplicateSchedule: async () => undefined as never,
setEnabled: async () => undefined,
removeSchedule: async () => undefined,
previewCron: async () => [
previewSchedule: async () => [
'2026-07-30T01:00:00.000Z',
'2026-07-31T01:00:00.000Z',
'2026-08-01T01:00:00.000Z',
@@ -182,4 +182,46 @@ describe('AutomationPage', () => {
expect(duplicateSchedule).toHaveBeenCalledWith('task-1', 5);
expect(await screen.findByText('Morning restart copy')).not.toBeNull();
});
it('schedules an SMS blast for a device group with a random delay', async () => {
const user = userEvent.setup();
const createSchedule = vi.fn(source().createSchedule);
render(
<AutomationPage
dataSource={{ ...source(), createSchedule }}
groupOptions={[{ id: 'g1', name: '外场' }]}
/>,
);
await user.click(screen.getByRole('button', { name: '创建任务' }));
const dialog = screen.getByRole('dialog', { name: '创建任务' });
await user.type(within(dialog).getByLabelText('任务名称'), 'Morning notice');
await user.selectOptions(within(dialog).getByLabelText('操作类型'), 'send-sms');
await user.type(within(dialog).getByLabelText('收件号码'), '13800138000');
await user.type(within(dialog).getByLabelText('短信内容'), '巡检开始');
await user.selectOptions(within(dialog).getByLabelText('目标方式'), 'group');
await user.selectOptions(within(dialog).getByLabelText('分组'), 'g1');
await user.clear(within(dialog).getByLabelText('随机延迟(秒)'));
await user.type(within(dialog).getByLabelText('随机延迟(秒)'), '30');
await user.click(within(dialog).getByRole('button', { name: '检查并继续' }));
const confirmation = within(dialog).getByRole('group', { name: '最终确认' });
expect(within(confirmation).getByText('发送短信')).not.toBeNull();
expect(within(confirmation).getByText('分组:外场')).not.toBeNull();
await user.click(within(confirmation).getByRole('checkbox', { name: /我已核对/ }));
await user.click(within(dialog).getByRole('button', { name: '确认并创建' }));
expect(createSchedule).toHaveBeenCalledWith(
expect.objectContaining({
operationType: 'send-sms',
targetSelector: { mode: 'group', groupId: 'g1' },
sms: expect.objectContaining({
recipients: ['13800138000'],
content: '巡检开始',
randomDelaySeconds: 30,
}),
}),
);
});
});
+276 -25
View File
@@ -2,8 +2,10 @@ import type {
CreateScheduledTaskRequest,
ScheduledRun,
ScheduledTask,
ScheduleTrigger,
UpdateScheduledTaskRequest,
} from '@multi-simadmin/contracts';
import { scheduleTriggerOf } from '@multi-simadmin/contracts';
import {
useEffect,
useRef,
@@ -25,7 +27,7 @@ export interface AutomationDataSource {
duplicateSchedule(id: string, version: number): Promise<ScheduledTask>;
setEnabled(id: string, version: number, enabled: boolean): Promise<ScheduledTask | undefined>;
removeSchedule(id: string, version: number): Promise<void>;
previewCron(expression: string): Promise<readonly string[]>;
previewSchedule(trigger: ScheduleTrigger): Promise<readonly string[]>;
runNow(id: string): Promise<unknown>;
listRuns(signal?: AbortSignal): Promise<readonly ScheduledRun[]>;
}
@@ -35,6 +37,7 @@ export interface AutomationPageProps {
readonly initialTab?: AutomationTab;
readonly runsContent?: ReactNode;
readonly recordsContent?: ReactNode;
readonly groupOptions?: readonly { readonly id: string; readonly name: string }[];
}
function beijingTime(value: string): string {
@@ -64,14 +67,34 @@ function operationLabel(value: ScheduledTask['operationType']): string {
return {
'restart-service': '重启 SimAdmin 服务',
'reboot-system': '重启设备系统',
'restart-baseband': '重启基带',
'send-sms': '发送短信',
'backup-data': '备份管理数据',
}[value];
}
function targetLabel(task: ScheduledTask): string {
return task.targetSelector.mode === 'fixed'
? `${task.targetSelector.instanceIds.length} 个固定实例`
: `${task.targetSelector.match === 'all' ? '全部匹配' : '任一匹配'}${task.targetSelector.tags.join('、')}`;
function targetLabel(task: ScheduledTask, groupName?: (id: string) => string): string {
const selector = task.targetSelector;
if (selector.mode === 'all') return '全部设备';
if (selector.mode === 'group')
return `分组:${groupName?.(selector.groupId) ?? selector.groupId}`;
if (selector.mode === 'fixed') return `${selector.instanceIds.length} 个固定实例`;
return `${selector.match === 'all' ? '全部匹配' : '任一匹配'}${selector.tags.join('、')}`;
}
const WEEKDAY_LABELS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
function triggerLabel(trigger: ScheduleTrigger): string {
if (trigger.kind === 'cron') return trigger.expression;
if (trigger.kind === 'interval') {
const unit = { mins: '分钟', hours: '小时', days: '天' }[trigger.unit];
return `每隔 ${trigger.value} ${unit}`;
}
const days =
trigger.weekdays.length === 7
? '每天'
: trigger.weekdays.map((day) => WEEKDAY_LABELS[day - 1]).join('');
return `${days} ${trigger.times.join('、')}`;
}
function outcomeLabel(value: ScheduledRun['outcome']): string {
@@ -89,13 +112,21 @@ function outcomeLabel(value: ScheduledRun['outcome']): string {
interface EditorForm {
name: string;
operationType: ScheduledTask['operationType'];
targetMode: 'fixed' | 'tags';
targetMode: 'fixed' | 'tags' | 'group' | 'all';
fixedIds: string;
tags: string;
tagMatch: 'any' | 'all';
groupId: string;
recipients: string;
content: string;
triggerKind: ScheduleTrigger['kind'];
cronExpression: string;
weekdays: number[];
times: string;
intervalValue: number;
intervalUnit: 'mins' | 'hours' | 'days';
delaySeconds: number;
randomDelaySeconds: number;
misfirePolicy: ScheduledTask['misfirePolicy'];
overlapPolicy: ScheduledTask['overlapPolicy'];
maxRetries: number;
@@ -107,13 +138,21 @@ interface EditorForm {
const defaults: EditorForm = {
name: '',
operationType: 'restart-service' as ScheduledTask['operationType'],
targetMode: 'fixed' as 'fixed' | 'tags',
targetMode: 'fixed' as EditorForm['targetMode'],
fixedIds: '',
tags: '',
tagMatch: 'any' as 'any' | 'all',
groupId: '',
recipients: '',
content: '',
triggerKind: 'cron' as ScheduleTrigger['kind'],
cronExpression: '0 9 * * *',
weekdays: [1, 2, 3, 4, 5] as number[],
times: '09:00',
intervalValue: 6,
intervalUnit: 'hours' as EditorForm['intervalUnit'],
delaySeconds: 5,
randomDelaySeconds: 0,
misfirePolicy: 'skip',
overlapPolicy: 'skip',
maxRetries: 0,
@@ -127,6 +166,7 @@ export function AutomationPage({
initialTab = 'schedules',
runsContent,
recordsContent,
groupOptions = [],
}: AutomationPageProps) {
const [tab, setTab] = useState<AutomationTab>(initialTab);
const [tasks, setTasks] = useState<readonly ScheduledTask[]>([]);
@@ -177,7 +217,26 @@ export function AutomationPage({
const highFrequencyRestart =
form.operationType === 'restart-service' &&
/^\*|^\*\/([1-5])(?:\s|$)/.test(form.cronExpression);
(form.triggerKind === 'interval'
? form.intervalUnit === 'mins'
? form.intervalValue <= 5
: false
: /^\*|^\*\/([1-5])(?:\s|$)/.test(form.cronExpression));
function formTrigger(source: EditorForm): ScheduleTrigger {
if (source.triggerKind === 'interval')
return { kind: 'interval', value: source.intervalValue, unit: source.intervalUnit };
if (source.triggerKind === 'fixed')
return {
kind: 'fixed',
weekdays: [...source.weekdays].sort((left, right) => left - right),
times: source.times
.split(/[,\s]+/u)
.map((item) => item.trim())
.filter(Boolean),
};
return { kind: 'cron', expression: source.cronExpression.trim() };
}
function field<K extends keyof typeof defaults>(key: K, value: (typeof defaults)[K]) {
setForm((current) => ({ ...current, [key]: value }));
@@ -209,6 +268,7 @@ export function AutomationPage({
function editTask(task: ScheduledTask) {
setEditingTask(task);
const trigger = scheduleTriggerOf(task);
setForm({
name: task.name,
operationType: task.operationType,
@@ -217,9 +277,17 @@ export function AutomationPage({
task.targetSelector.mode === 'fixed' ? task.targetSelector.instanceIds.join(', ') : '',
tags: task.targetSelector.mode === 'tags' ? task.targetSelector.tags.join(', ') : '',
tagMatch: task.targetSelector.mode === 'tags' ? task.targetSelector.match : 'any',
groupId: task.targetSelector.mode === 'group' ? task.targetSelector.groupId : '',
recipients: '',
content: '',
cronExpression: task.cronExpression,
triggerKind: trigger.kind,
cronExpression: trigger.kind === 'cron' ? trigger.expression : task.cronExpression,
weekdays: trigger.kind === 'fixed' ? [...trigger.weekdays] : [1, 2, 3, 4, 5],
times: trigger.kind === 'fixed' ? trigger.times.join(', ') : '09:00',
intervalValue: trigger.kind === 'interval' ? trigger.value : 6,
intervalUnit: trigger.kind === 'interval' ? trigger.unit : 'hours',
delaySeconds: task.delaySeconds ?? 5,
randomDelaySeconds: 0,
misfirePolicy: task.misfirePolicy,
overlapPolicy: task.overlapPolicy,
maxRetries: task.retryPolicy.maxRetries,
@@ -267,18 +335,33 @@ export function AutomationPage({
const effectiveStartAt = beijingIso(form.effectiveStart);
const effectiveEndAt = beijingIso(form.effectiveEnd);
const smsChanged = form.recipients.trim().length > 0 || form.content.length > 0;
const trigger = formTrigger(form);
const request: CreateScheduledTaskRequest = {
name: form.name.trim(),
operationType: form.operationType,
trigger,
cronExpression: form.cronExpression.trim(),
timezone: 'Asia/Shanghai',
targetSelector:
form.targetMode === 'fixed'
? { mode: 'fixed', instanceIds: split(form.fixedIds) }
: { mode: 'tags', match: form.tagMatch, tags: split(form.tags) },
form.targetMode === 'group'
? { mode: 'group', groupId: form.groupId.trim() }
: form.targetMode === 'all'
? { mode: 'all' }
: 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)
? { sms: { recipients: split(form.recipients), content: form.content } }
? {
sms: {
recipients: split(form.recipients),
content: form.content,
...(form.randomDelaySeconds > 0
? { randomDelaySeconds: form.randomDelaySeconds }
: {}),
},
}
: {}),
...(form.operationType === 'reboot-system' ? { delaySeconds: form.delaySeconds } : {}),
...(effectiveStartAt ? { effectiveStartAt } : {}),
...(effectiveEndAt ? { effectiveEndAt } : {}),
misfirePolicy: form.misfirePolicy,
@@ -406,9 +489,14 @@ export function AutomationPage({
{operationLabel(task.operationType)}
</span>
</td>
<td>{targetLabel(task)}</td>
<td>
<code>{task.cronExpression}</code>
{targetLabel(
task,
(id) => groupOptions.find((group) => group.id === id)?.name ?? id,
)}
</td>
<td>
<code>{triggerLabel(scheduleTriggerOf(task))}</code>
</td>
<td>
{task.enabled && task.nextDueAt
@@ -599,9 +687,25 @@ export function AutomationPage({
>
<option value="restart-service"> SimAdmin </option>
<option value="reboot-system"></option>
<option value="restart-baseband"></option>
<option value="send-sms"></option>
<option value="backup-data"></option>
</select>
</label>
{form.operationType === 'reboot-system' ? (
<label>
<span></span>
<input
type="number"
min={0}
max={3600}
value={form.delaySeconds}
onChange={(event) =>
field('delaySeconds', Number(event.currentTarget.value) || 0)
}
/>
</label>
) : null}
</section>
<section>
<h3></h3>
@@ -615,8 +719,39 @@ export function AutomationPage({
>
<option value="fixed"></option>
<option value="tags"></option>
<option value="group"></option>
<option value="all"></option>
</select>
</label>
{form.targetMode === 'all' ? (
<p className="field-note"></p>
) : null}
{form.targetMode === 'group' ? (
<label>
<span></span>
{groupOptions.length > 0 ? (
<select
required
value={form.groupId}
onChange={(event) => field('groupId', event.currentTarget.value)}
>
<option value=""></option>
{groupOptions.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
) : (
<input
required
value={form.groupId}
placeholder="分组 ID"
onChange={(event) => field('groupId', event.currentTarget.value)}
/>
)}
</label>
) : null}
{form.targetMode === 'fixed' ? (
<label>
<span> ID</span>
@@ -627,7 +762,8 @@ export function AutomationPage({
onChange={(event) => field('fixedIds', event.currentTarget.value)}
/>
</label>
) : (
) : null}
{form.targetMode === 'tags' ? (
<>
<label>
<span></span>
@@ -651,7 +787,7 @@ export function AutomationPage({
/>
</label>
</>
)}
) : null}
</section>
{form.operationType === 'send-sms' ? (
<section>
@@ -680,15 +816,120 @@ export function AutomationPage({
onChange={(event) => field('content', event.currentTarget.value)}
/>
</label>
<label>
<span></span>
<input
type="number"
min={0}
max={600}
value={form.randomDelaySeconds}
onChange={(event) =>
field('randomDelaySeconds', Number(event.currentTarget.value) || 0)
}
/>
</label>
</section>
) : null}
<section>
<h3></h3>
<div className="segmented-control" role="group" aria-label="调度方式">
{(
[
['fixed', '每周固定'],
['interval', '固定间隔'],
['cron', 'Cron 表达式'],
] as const
).map(([kind, label]) => (
<button
key={kind}
type="button"
className={form.triggerKind === kind ? 'is-active' : undefined}
aria-pressed={form.triggerKind === kind}
onClick={() => {
field('triggerKind', kind);
setPreview([]);
}}
>
{label}
</button>
))}
</div>
{form.triggerKind === 'fixed' ? (
<>
<fieldset className="weekday-picker">
<legend></legend>
{WEEKDAY_LABELS.map((label, index) => (
<label key={label} className="weekday-option">
<input
type="checkbox"
checked={form.weekdays.includes(index + 1)}
onChange={(event) =>
field(
'weekdays',
event.currentTarget.checked
? [...form.weekdays, index + 1]
: form.weekdays.filter((day) => day !== index + 1),
)
}
/>
<span>{label}</span>
</label>
))}
</fieldset>
<label>
<span></span>
<input
required
value={form.times}
placeholder="09:00, 21:30"
onChange={(event) => {
field('times', event.currentTarget.value);
setPreview([]);
}}
/>
</label>
</>
) : null}
{form.triggerKind === 'interval' ? (
<div className="inline-field">
<label>
<span></span>
<input
type="number"
required
min={1}
max={10000}
value={form.intervalValue}
onChange={(event) => {
field('intervalValue', Number(event.currentTarget.value) || 1);
setPreview([]);
}}
/>
</label>
<label>
<span></span>
<select
value={form.intervalUnit}
onChange={(event) => {
field(
'intervalUnit',
event.currentTarget.value as EditorForm['intervalUnit'],
);
setPreview([]);
}}
>
<option value="mins"></option>
<option value="hours"></option>
<option value="days"></option>
</select>
</label>
</div>
) : null}
<label>
<span> Cron </span>
<span> Cron </span>
<div className="inline-field">
<input
required
disabled={form.triggerKind !== 'cron'}
value={form.cronExpression}
onChange={(event) => {
field('cronExpression', event.currentTarget.value);
@@ -698,7 +939,7 @@ export function AutomationPage({
<button
type="button"
onClick={() =>
void dataSource.previewCron(form.cronExpression).then((items) => {
void dataSource.previewSchedule(formTrigger(form)).then((items) => {
setPreview(items);
setConfirmedFrequency(false);
})
@@ -818,20 +1059,30 @@ export function AutomationPage({
<div>
<dt></dt>
<dd>
{form.targetMode === 'fixed'
? `${form.fixedIds.split(',').filter((item) => item.trim()).length} 个固定实例`
: `${form.tagMatch === 'all' ? '全部匹配' : '任一匹配'} · ${form.tags}`}
{form.targetMode === 'all'
? '全部设备'
: form.targetMode === 'group'
? `分组:${groupOptions.find((group) => group.id === form.groupId)?.name ?? form.groupId}`
: form.targetMode === 'fixed'
? `${form.fixedIds.split(',').filter((item) => item.trim()).length} 个固定实例`
: `${form.tagMatch === 'all' ? '全部匹配' : '任一匹配'} · ${form.tags}`}
</dd>
</div>
<div>
<dt></dt>
<dd>
<code>{form.cronExpression.trim()}</code> ·
<code>{triggerLabel(formTrigger(form))}</code> ·
</dd>
</div>
<div>
<dt></dt>
<dd>{form.operationType === 'reboot-system' ? 'R3' : 'R2'}</dd>
<dd>
{form.operationType === 'reboot-system'
? 'R3'
: form.operationType === 'backup-data'
? 'R1'
: 'R2'}
</dd>
</div>
</dl>
<label className="risk-confirm">
+215 -69
View File
@@ -3,39 +3,46 @@ import { describe, expect, it, vi } from 'vitest';
import { createFleetApiDataSource } from './fleet-api-data-source.js';
describe('Fleet API data source', () => {
it('loads instances and their allowlisted resource summaries', async () => {
it('loads the aggregate fleet overview and its resource summaries', async () => {
const fetcher = vi.fn(
async (input: RequestInfo | URL) =>
async () =>
new Response(
JSON.stringify(
String(input).endsWith('/resources')
? {
JSON.stringify({
items: [
{
id: 'alpha',
name: 'Alpha',
origin: 'https://alpha.example/admin',
tags: ['lab'],
revision: 4,
capabilityStatus: 'unknown',
freshness: 'unknown',
credentialConfigured: false,
resources: {
cpuPercent: 23.4,
memoryPercent: 67.8,
maxTemperatureCelsius: 52.6,
phoneNumbers: ['13800000000'],
hardwareOnline: true,
controlOnline: true,
simPresent: true,
carrier: 'China Mobile',
cellularRegistration: 'registered_home',
accessTechnology: 'LTE',
cellularOnline: true,
signalPercent: 72,
uptimeSeconds: 93784.9,
version: '1.9.4',
}
: {
items: [
{
id: 'alpha',
name: 'Alpha',
origin: 'https://alpha.example/admin',
tags: ['lab'],
revision: 4,
capabilityStatus: 'unknown',
freshness: 'unknown',
credentialConfigured: false,
},
],
page: { page: 1, pageSize: 20, total: 1 },
},
),
},
],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
);
const snapshot = await createFleetApiDataSource(fetcher as typeof fetch).load();
expect(snapshot.instances).toEqual([
{
id: 'alpha',
@@ -50,63 +57,57 @@ describe('Fleet API data source', () => {
memoryPercent: 67.8,
maxTemperatureCelsius: 52.6,
phoneNumbers: ['13800000000'],
hardwareOnline: true,
controlOnline: true,
simPresent: true,
carrier: 'China Mobile',
cellularRegistration: 'registered_home',
accessTechnology: 'LTE',
cellularOnline: true,
signalPercent: 72,
uptimeSeconds: 93784,
});
expect(snapshot.statuses.get('alpha')?.summary?.version).toBe('1.9.4');
expect(fetcher).toHaveBeenCalledTimes(1);
expect(fetcher).toHaveBeenCalledWith(
'/api/v1/instances',
expect.objectContaining({ credentials: 'same-origin' }),
);
expect(fetcher).toHaveBeenCalledWith(
'/api/v1/instances/alpha/resources',
'/api/v1/fleet/overview',
expect.objectContaining({ credentials: 'same-origin' }),
);
});
it('emits a partial fleet snapshot before resource enrichment completes', async () => {
let resolveResources!: (value: Response) => void;
const resourcesPromise = new Promise<Response>((resolve) => {
resolveResources = resolve;
});
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
if (String(input).endsWith('/resources')) return resourcesPromise;
return new Response(
JSON.stringify({
items: [
{
id: 'alpha',
name: 'Alpha',
origin: 'https://alpha.example/admin',
tags: [],
revision: 1,
capabilityStatus: 'unknown',
freshness: 'unknown',
credentialConfigured: false,
},
],
page: { page: 1, pageSize: 20, total: 1 },
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
});
it('emits skeleton and complete snapshots from the aggregate overview', async () => {
const fetcher = vi.fn(
async () =>
new Response(
JSON.stringify({
items: [
{
id: 'alpha',
name: 'Alpha',
origin: 'https://alpha.example/admin',
tags: [],
revision: 1,
capabilityStatus: 'unknown',
freshness: 'unknown',
credentialConfigured: false,
resources: { cpuPercent: 11, memoryPercent: 22 },
},
],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
);
const partials: unknown[] = [];
const pending = createFleetApiDataSource(fetcher as typeof fetch).load(
undefined,
(snapshot) => {
partials.push({
resources: snapshot.statuses.get('alpha')?.summary?.resources,
freshness: snapshot.statuses.get('alpha')?.summary?.freshness,
});
},
);
await vi.waitFor(() => expect(partials.length).toBe(1));
await createFleetApiDataSource(fetcher as typeof fetch).load(undefined, (snapshot) => {
partials.push({
resources: snapshot.statuses.get('alpha')?.summary?.resources,
freshness: snapshot.statuses.get('alpha')?.summary?.freshness,
});
});
expect(partials.length).toBe(2);
expect(partials[0]).toEqual({ resources: undefined, freshness: 'unknown' });
resolveResources(
new Response(JSON.stringify({ cpuPercent: 11, memoryPercent: 22 }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
await pending;
expect(partials.at(-1)).toEqual({
resources: { cpuPercent: 11, memoryPercent: 22 },
freshness: 'fresh',
@@ -120,3 +121,148 @@ describe('Fleet API data source', () => {
);
});
});
function overviewWith(connection: unknown) {
return vi.fn(
async () =>
new Response(
JSON.stringify({
items: [
{
id: 'alpha',
name: 'Alpha',
origin: 'https://alpha.example/admin',
tags: [],
revision: 1,
capabilityStatus: 'unknown',
freshness: 'unknown',
credentialConfigured: false,
connection,
resources: {},
},
],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
);
}
describe('Fleet resource bounds', () => {
const overviewWithResources = (resources: Record<string, unknown>) =>
vi.fn(
async () =>
new Response(
JSON.stringify({
items: [
{
id: 'alpha',
name: 'Alpha',
origin: 'https://alpha.example/admin',
tags: [],
revision: 1,
capabilityStatus: 'unknown',
freshness: 'unknown',
credentialConfigured: false,
resources,
},
],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
) as unknown as typeof fetch;
it('drops a negative or non-numeric uptime instead of rendering a bogus clock', async () => {
const negative = await createFleetApiDataSource(
overviewWithResources({ uptimeSeconds: -1 }),
).load();
const text = await createFleetApiDataSource(
overviewWithResources({ uptimeSeconds: '93784' }),
).load();
expect(negative.statuses.get('alpha')?.summary?.resources?.uptimeSeconds).toBeUndefined();
expect(text.statuses.get('alpha')?.summary?.resources?.uptimeSeconds).toBeUndefined();
});
it('keeps a zero uptime, which means the device just booted', async () => {
const snapshot = await createFleetApiDataSource(
overviewWithResources({ uptimeSeconds: 0 }),
).load();
expect(snapshot.statuses.get('alpha')?.summary?.resources?.uptimeSeconds).toBe(0);
});
});
describe('Fleet heartbeat connection state', () => {
it('carries a live heartbeat through to the row status', async () => {
const snapshot = await createFleetApiDataSource(
overviewWith({
probed: true,
reachable: true,
authenticated: true,
checkedAt: '2026-09-05T00:00:00.000Z',
}) as typeof fetch,
).load();
expect(snapshot.statuses.get('alpha')).toMatchObject({
probed: true,
reachable: true,
authenticated: true,
checkedAt: '2026-09-05T00:00:00.000Z',
});
});
it('marks a device the heartbeat lost as offline but still checked', async () => {
const snapshot = await createFleetApiDataSource(
overviewWith({
probed: true,
reachable: false,
authenticated: false,
checkedAt: '2026-09-05T00:00:00.000Z',
}) as typeof fetch,
).load();
expect(snapshot.statuses.get('alpha')).toMatchObject({
probed: true,
reachable: false,
authenticated: false,
checkedAt: '2026-09-05T00:00:00.000Z',
});
});
it('reports a device that has never been probed as unknown', async () => {
const snapshot = await createFleetApiDataSource(
overviewWith({
probed: false,
reachable: false,
authenticated: false,
checkedAt: null,
}) as typeof fetch,
).load();
expect(snapshot.statuses.get('alpha')).toMatchObject({
probed: false,
checkedAt: null,
});
});
it('keeps the optimistic shape for a server without the heartbeat journal', async () => {
const snapshot = await createFleetApiDataSource(overviewWith(undefined) as typeof fetch).load();
expect(snapshot.statuses.get('alpha')).toMatchObject({
reachable: true,
authenticated: true,
});
expect(snapshot.statuses.get('alpha')?.probed).toBeUndefined();
expect(snapshot.statuses.get('alpha')?.checkedAt).toBeUndefined();
});
it('applies the heartbeat state to the skeleton snapshot too', async () => {
const partials: unknown[] = [];
await createFleetApiDataSource(
overviewWith({
probed: true,
reachable: false,
authenticated: false,
checkedAt: null,
}) as typeof fetch,
).load(undefined, (snapshot) => {
partials.push(snapshot.statuses.get('alpha'));
});
expect(partials[0]).toMatchObject({ probed: true, reachable: false });
expect(partials.at(-1)).toMatchObject({ probed: true, reachable: false });
});
});
+76 -41
View File
@@ -6,6 +6,9 @@ const integer = (value: unknown): value is number =>
const finite = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);
const string = (value: unknown): value is string => typeof value === 'string' && value.length > 0;
const boolean = (value: unknown): value is boolean => typeof value === 'boolean';
const boundedText = (value: unknown, maximum: number): string | undefined =>
typeof value === 'string' && value.length > 0 && value.length <= maximum ? value : undefined;
const record = (value: unknown): Record<string, unknown> | undefined =>
typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
@@ -29,6 +32,11 @@ function parseInstance(value: unknown): FleetInstance | undefined {
name: item.name,
url: item.origin,
tags: item.tags as string[],
...(typeof item.groupId === 'string' && item.groupId.length > 0
? { groupId: item.groupId }
: item.groupId === null
? { groupId: null }
: {}),
...(revision === undefined ? {} : { revision }),
};
}
@@ -40,6 +48,15 @@ function parseResources(value: unknown): NonNullable<FleetStatus['summary']> {
memoryPercent?: number;
maxTemperatureCelsius?: number;
phoneNumbers?: string[];
hardwareOnline?: boolean;
controlOnline?: boolean;
simPresent?: boolean;
carrier?: string;
cellularRegistration?: string;
accessTechnology?: string;
cellularOnline?: boolean;
signalPercent?: number;
uptimeSeconds?: number;
} = {};
const version = string(body.version)
? body.version
@@ -55,6 +72,20 @@ function parseResources(value: unknown): NonNullable<FleetStatus['summary']> {
body.phoneNumbers.every((item) => typeof item === 'string' && item.length > 0)
)
resources.phoneNumbers = body.phoneNumbers as string[];
if (boolean(body.hardwareOnline)) resources.hardwareOnline = body.hardwareOnline;
if (boolean(body.controlOnline)) resources.controlOnline = body.controlOnline;
if (boolean(body.simPresent)) resources.simPresent = body.simPresent;
const carrier = boundedText(body.carrier, 64);
if (carrier) resources.carrier = carrier;
const cellularRegistration = boundedText(body.cellularRegistration, 64);
if (cellularRegistration) resources.cellularRegistration = cellularRegistration;
const accessTechnology = boundedText(body.accessTechnology, 32);
if (accessTechnology) resources.accessTechnology = accessTechnology;
if (boolean(body.cellularOnline)) resources.cellularOnline = body.cellularOnline;
if (finite(body.signalPercent) && body.signalPercent >= 0 && body.signalPercent <= 100)
resources.signalPercent = body.signalPercent;
if (finite(body.uptimeSeconds) && body.uptimeSeconds >= 0)
resources.uptimeSeconds = Math.floor(body.uptimeSeconds);
return {
freshness: 'fresh',
...(version ? { version } : {}),
@@ -70,15 +101,36 @@ async function readJson(response: Response): Promise<unknown> {
}
}
function skeletonStatuses(instances: readonly FleetInstance[]): Map<string, FleetStatus> {
type ConnectionView = Pick<FleetStatus, 'reachable' | 'authenticated' | 'probed' | 'checkedAt'>;
/**
* A payload without a connection block means the server predates the heartbeat journal, so the
* row keeps its previous optimistic shape rather than flashing 未知.
*/
const unprobed: ConnectionView = {
reachable: true,
authenticated: true,
};
function parseConnection(value: unknown): ConnectionView {
const source = record(value);
if (!source || typeof source.probed !== 'boolean') return unprobed;
return {
probed: source.probed,
reachable: source.probed === true && source.reachable === true,
authenticated: source.probed === true && source.authenticated === true,
checkedAt: typeof source.checkedAt === 'string' ? source.checkedAt : null,
};
}
function skeletonStatuses(
instances: readonly FleetInstance[],
connections: readonly ConnectionView[],
): Map<string, FleetStatus> {
return new Map(
instances.map((instance) => [
instances.map((instance, index) => [
instance.id,
{
reachable: true,
authenticated: true,
summary: { freshness: 'unknown' },
},
{ ...(connections[index] ?? unprobed), summary: { freshness: 'unknown' } },
]),
);
}
@@ -86,54 +138,37 @@ function skeletonStatuses(instances: readonly FleetInstance[]): Map<string, Flee
export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDataSource {
return {
async load(signal, onPartial) {
const listResponse = await fetcher('/api/v1/instances', {
const overviewResponse = await fetcher('/api/v1/fleet/overview', {
method: 'GET',
credentials: 'same-origin',
headers: { accept: 'application/json' },
...(signal ? { signal } : {}),
});
const listBody = record(await readJson(listResponse));
if (!listResponse.ok || !listBody || !Array.isArray(listBody.items) || !record(listBody.page))
const overviewBody = record(await readJson(overviewResponse));
if (!overviewResponse.ok || !overviewBody || !Array.isArray(overviewBody.items))
throw new Error('Fleet response is invalid.');
const instances = listBody.items.map(parseInstance);
const instances = overviewBody.items.map(parseInstance);
if (instances.some((item) => !item)) throw new Error('Fleet response is invalid.');
const readyInstances = instances as FleetInstance[];
const connections = overviewBody.items.map((item) =>
parseConnection(record(item)?.connection),
);
const partial: FleetSnapshot = {
instances: readyInstances,
statuses: skeletonStatuses(readyInstances),
statuses: skeletonStatuses(readyInstances, connections),
};
onPartial?.(partial);
const statuses = new Map(
await Promise.all(
readyInstances.map(async (instance) => {
try {
const response = await fetcher(
`/api/v1/instances/${encodeURIComponent(instance.id)}/resources`,
{
method: 'GET',
credentials: 'same-origin',
headers: { accept: 'application/json' },
...(signal ? { signal } : {}),
},
);
if (!response.ok) throw new Error('resource unavailable');
const status: FleetStatus = {
reachable: true,
authenticated: true,
summary: parseResources(await readJson(response)),
};
return [instance.id, status] as const;
} catch {
const status: FleetStatus = {
reachable: true,
authenticated: true,
summary: { freshness: 'unknown' },
};
return [instance.id, status] as const;
}
}),
),
overviewBody.items.map((item, index) => {
const instance = readyInstances[index]!;
const resources = record(item)?.resources;
const status: FleetStatus = {
...connections[index]!,
summary: resources ? parseResources(resources) : { freshness: 'unknown' },
};
return [instance.id, status] as const;
}),
);
const complete: FleetSnapshot = { instances: readyInstances, statuses };
onPartial?.(complete);
+185
View File
@@ -0,0 +1,185 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { FleetLogsPage } from './fleet-logs-page.js';
import type { LogCenterDataSource, LogQuery } from './log-center-types.js';
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const runtimePage = {
items: [
{
id: 'log-1',
source: 'audit' as const,
level: 'error' as const,
occurredAt: '2026-09-04T03:00:00.000Z',
instanceId: 'node-a',
message: '操作 restart-service 结果 failed',
code: 'failed',
actor: 'console',
durationMs: 42,
},
],
page: { page: 1, pageSize: 50, total: 1 },
counts: { event: 0, audit: 1, schedule: 0, delivery: 0 },
};
const connectionPage = {
items: [
{
id: 'conn-1',
instanceId: 'node-a',
outcome: 'failed' as const,
state: 'unknown',
errorCode: 'ECONNREFUSED',
httpStatus: null,
durationMs: 30,
observedAt: '2026-09-04T06:00:00.000Z',
},
],
page: { page: 1, pageSize: 50, total: 1 },
};
const diagnostics = [
{
instanceId: 'node-a',
name: 'Node A',
origin: 'http://node-a.local',
enabled: true,
revision: 3,
tags: ['lab'],
health: {
category: 'connection' as const,
state: 'fresh',
observedAt: '2026-09-04T06:00:00.000Z',
errorCode: 'AUTH_REQUIRED',
httpStatus: null,
durationMs: null,
version: null,
platform: null,
status: 'reachable',
authenticated: false,
},
snapshots: [{ category: 'connection', state: 'fresh', observedAt: '2026-09-04T06:00:00.000Z' }],
capabilities: {
total: 2,
supported: 1,
unsupported: 1,
authRequired: 0,
degraded: 0,
unknown: 0,
},
connections: {
instanceId: 'node-a',
total: 4,
success: 3,
failed: 1,
averageDurationMs: 18,
lastObservedAt: '2026-09-04T06:00:00.000Z',
lastErrorCode: 'ECONNREFUSED',
availabilityPercent: 75,
},
recentFailures: [
{ occurredAt: '2026-09-04T03:00:00.000Z', code: 'failed', message: 'restart-service' },
],
},
];
function source(overrides: Partial<LogCenterDataSource> = {}): LogCenterDataSource {
return {
listRuntime: vi.fn(async () => runtimePage),
listConnections: vi.fn(async () => connectionPage),
listDiagnostics: vi.fn(async () => diagnostics),
pruneConnections: vi.fn(async () => 1),
...overrides,
};
}
describe('FleetLogsPage', () => {
it('renders the unified runtime timeline on first paint', async () => {
const dataSource = source();
render(<FleetLogsPage dataSource={dataSource} />);
expect(await screen.findByText('操作 restart-service 结果 failed')).toBeTruthy();
expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([
'运行日志',
'连接日志',
'设备诊断',
]);
expect(dataSource.listRuntime).toHaveBeenCalledTimes(1);
expect(dataSource.listConnections).not.toHaveBeenCalled();
});
it('passes the selected filters to the runtime query', async () => {
const dataSource = source();
render(<FleetLogsPage dataSource={dataSource} />);
await screen.findByText('操作 restart-service 结果 failed');
const listRuntime = dataSource.listRuntime as ReturnType<typeof vi.fn>;
const lastQuery = (): LogQuery | undefined => listRuntime.mock.calls.at(-1)?.[0];
await userEvent.selectOptions(screen.getByLabelText('日志级别筛选'), 'error');
await waitFor(() => expect(lastQuery()).toMatchObject({ level: 'error', page: 1 }));
await userEvent.type(screen.getByLabelText('运行日志关键词'), 'restart');
await waitFor(() => expect(lastQuery()).toMatchObject({ search: 'restart' }));
});
it('loads connection logs only when that tab opens', async () => {
const dataSource = source();
render(<FleetLogsPage dataSource={dataSource} />);
await screen.findByText('操作 restart-service 结果 failed');
await userEvent.click(screen.getByRole('tab', { name: '连接日志' }));
expect(await screen.findByText('ECONNREFUSED')).toBeTruthy();
expect(dataSource.listConnections).toHaveBeenCalledTimes(1);
expect(dataSource.listDiagnostics).not.toHaveBeenCalled();
});
it('prunes the connection journal from the panel action', async () => {
const dataSource = source();
render(<FleetLogsPage dataSource={dataSource} />);
await screen.findByText('操作 restart-service 结果 failed');
await userEvent.click(screen.getByRole('tab', { name: '连接日志' }));
await screen.findByText('ECONNREFUSED');
await userEvent.click(screen.getByRole('button', { name: //u }));
const prune = dataSource.pruneConnections as ReturnType<typeof vi.fn>;
await waitFor(() => expect(typeof prune.mock.calls.at(-1)?.[0]?.before).toBe('string'));
expect(await screen.findByText(/ 1 /u)).toBeTruthy();
});
it('summarises fleet health in the diagnostics tab', async () => {
const dataSource = source();
render(<FleetLogsPage dataSource={dataSource} />);
await screen.findByText('操作 restart-service 结果 failed');
await userEvent.click(screen.getByRole('tab', { name: '设备诊断' }));
expect(await screen.findByText('Node A')).toBeTruthy();
expect(screen.getByText('http://node-a.local')).toBeTruthy();
expect(screen.getByText('75%')).toBeTruthy();
expect(screen.getByText(/restart-service \(failed\)/u)).toBeTruthy();
});
it('filters the diagnostics list by name, origin or tag', async () => {
const dataSource = source();
render(<FleetLogsPage dataSource={dataSource} />);
await screen.findByText('操作 restart-service 结果 failed');
await userEvent.click(screen.getByRole('tab', { name: '设备诊断' }));
await screen.findByText('Node A');
await userEvent.type(screen.getByLabelText('设备诊断搜索'), 'office');
expect(await screen.findByText('没有匹配的设备。')).toBeTruthy();
});
it('surfaces a load failure instead of an empty timeline', async () => {
const dataSource = source({
listRuntime: vi.fn(async () => {
throw new Error('日志服务不可用');
}),
});
render(<FleetLogsPage dataSource={dataSource} />);
expect(await screen.findByRole('alert')).toBeTruthy();
expect(screen.getByText('日志服务不可用')).toBeTruthy();
});
});
+704
View File
@@ -0,0 +1,704 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactElement,
type ReactNode,
} from 'react';
import { Icon } from '../ui/icon.js';
import { createLogCenterApiDataSource } from './log-center-api-data-source.js';
import { formatTimestamp } from './notification-center-types.js';
import {
CONNECTION_OUTCOMES,
LEVEL_LABELS,
LOG_LEVELS,
LOG_SOURCES,
OUTCOME_LABELS,
SOURCE_LABELS,
type ConnectionLogEntry,
type ConnectionOutcome,
type InstanceDiagnostics,
type LogCenterDataSource,
type LogLevel,
type LogQuery,
type LogSource,
type RuntimeLogEntry,
} from './log-center-types.js';
export interface FleetLogsPageProps {
readonly dataSource?: LogCenterDataSource;
}
export const LOG_PAGE_SIZE = 50;
type LogTab = 'runtime' | 'connections' | 'diagnostics';
const TABS: readonly [LogTab, string][] = [
['runtime', '运行日志'],
['connections', '连接日志'],
['diagnostics', '设备诊断'],
];
function Panel({
title,
detail,
actions,
children,
}: {
title: string;
detail: string;
actions?: ReactNode;
children: ReactNode;
}): ReactElement {
return (
<section className="fleet-notifications-panel" aria-label={title}>
<header className="fleet-notifications-panel-heading">
<div>
<h2>{title}</h2>
<span>{detail}</span>
</div>
{actions ?? null}
</header>
{children}
</section>
);
}
function Empty({ text: content }: { text: string }): ReactElement {
return <p className="fleet-notifications-empty">{content}</p>;
}
function Pager({
page,
total,
busy,
onChange,
}: {
page: number;
total: number;
busy: boolean;
onChange: (page: number) => void;
}): ReactElement {
return (
<div className="fleet-messages-more">
<button
type="button"
className="fleet-notifications-retry-all"
disabled={busy || page <= 1}
onClick={() => onChange(Math.max(1, page - 1))}
>
</button>
<span>
{page} / {total}
</span>
<button
type="button"
className="fleet-notifications-retry-all"
disabled={busy || page * LOG_PAGE_SIZE >= total}
onClick={() => onChange(page + 1)}
>
</button>
</div>
);
}
const LEVEL_TONES: Readonly<Record<LogLevel, string>> = {
info: 'ok',
warning: 'warn',
error: 'attention',
};
const OUTCOME_TONES: Readonly<Record<ConnectionOutcome, string>> = {
success: 'ok',
stale: 'warn',
failed: 'attention',
unsupported: 'neutral',
};
function dayStart(value: string, endOfDay = false): string | undefined {
if (!/^\d{4}-\d{2}-\d{2}$/u.test(value)) return undefined;
const parsed = Date.parse(`${value}T${endOfDay ? '23:59:59.999' : '00:00:00.000'}Z`);
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined;
}
export function FleetLogsPage({ dataSource }: FleetLogsPageProps = {}): ReactElement {
const source = useMemo(() => dataSource ?? createLogCenterApiDataSource(), [dataSource]);
const [tab, setTab] = useState<LogTab>('runtime');
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
const [notice, setNotice] = useState<string>();
const [sourceFilter, setSourceFilter] = useState<LogSource | ''>('');
const [levelFilter, setLevelFilter] = useState<LogLevel | ''>('');
const [runtimeSearch, setRuntimeSearch] = useState('');
const [runtimeFrom, setRuntimeFrom] = useState('');
const [runtimeTo, setRuntimeTo] = useState('');
const [runtimePage, setRuntimePage] = useState(1);
const [runtimeEntries, setRuntimeEntries] = useState<readonly RuntimeLogEntry[]>([]);
const [runtimeTotal, setRuntimeTotal] = useState(0);
const [runtimeCounts, setRuntimeCounts] = useState<Readonly<Record<LogSource, number>>>({
event: 0,
audit: 0,
schedule: 0,
delivery: 0,
});
const [outcomeFilter, setOutcomeFilter] = useState<ConnectionOutcome | ''>('');
const [connectionSearch, setConnectionSearch] = useState('');
const [connectionFrom, setConnectionFrom] = useState('');
const [connectionTo, setConnectionTo] = useState('');
const [connectionPage, setConnectionPage] = useState(1);
const [connections, setConnections] = useState<readonly ConnectionLogEntry[]>([]);
const [connectionTotal, setConnectionTotal] = useState(0);
const [diagnostics, setDiagnostics] = useState<readonly InstanceDiagnostics[]>([]);
const [diagnosticsSearch, setDiagnosticsSearch] = useState('');
// One token per request so a stale response can never overwrite a newer one.
const token = useRef(0);
const runtimeQuery = useMemo<LogQuery>(
() => ({
page: runtimePage,
pageSize: LOG_PAGE_SIZE,
...(sourceFilter ? { source: sourceFilter } : {}),
...(levelFilter ? { level: levelFilter } : {}),
...(runtimeSearch.trim() ? { search: runtimeSearch.trim() } : {}),
...(dayStart(runtimeFrom) ? { from: dayStart(runtimeFrom) } : {}),
...(dayStart(runtimeTo, true) ? { to: dayStart(runtimeTo, true) } : {}),
}),
[runtimePage, sourceFilter, levelFilter, runtimeSearch, runtimeFrom, runtimeTo],
);
const connectionQuery = useMemo<LogQuery>(
() => ({
page: connectionPage,
pageSize: LOG_PAGE_SIZE,
...(outcomeFilter ? { outcome: outcomeFilter } : {}),
...(connectionSearch.trim() ? { search: connectionSearch.trim() } : {}),
...(dayStart(connectionFrom) ? { from: dayStart(connectionFrom) } : {}),
...(dayStart(connectionTo, true) ? { to: dayStart(connectionTo, true) } : {}),
}),
[connectionPage, outcomeFilter, connectionSearch, connectionFrom, connectionTo],
);
const loadRuntime = useCallback(async (): Promise<void> => {
const attempt = ++token.current;
setLoading(true);
setError(undefined);
try {
const page = await source.listRuntime(runtimeQuery);
if (attempt !== token.current) return;
setRuntimeEntries(page.items);
setRuntimeTotal(page.page.total);
setRuntimeCounts(page.counts);
} catch (cause) {
if (attempt !== token.current) return;
setError(cause instanceof Error ? cause.message : '运行日志加载失败。');
} finally {
if (attempt === token.current) setLoading(false);
}
}, [runtimeQuery, source]);
const loadConnections = useCallback(async (): Promise<void> => {
const attempt = ++token.current;
setLoading(true);
setError(undefined);
try {
const page = await source.listConnections(connectionQuery);
if (attempt !== token.current) return;
setConnections(page.items);
setConnectionTotal(page.page.total);
} catch (cause) {
if (attempt !== token.current) return;
setError(cause instanceof Error ? cause.message : '连接日志加载失败。');
} finally {
if (attempt === token.current) setLoading(false);
}
}, [connectionQuery, source]);
const loadDiagnostics = useCallback(async (): Promise<void> => {
const attempt = ++token.current;
setLoading(true);
setError(undefined);
try {
const items = await source.listDiagnostics();
if (attempt !== token.current) return;
setDiagnostics(items);
} catch (cause) {
if (attempt !== token.current) return;
setError(cause instanceof Error ? cause.message : '设备诊断加载失败。');
} finally {
if (attempt === token.current) setLoading(false);
}
}, [source]);
useEffect(() => {
if (tab === 'runtime') void loadRuntime();
if (tab === 'connections') void loadConnections();
if (tab === 'diagnostics') void loadDiagnostics();
}, [tab, loadRuntime, loadConnections, loadDiagnostics]);
const run = useCallback(async (operation: () => Promise<void>): Promise<void> => {
setBusy(true);
setError(undefined);
try {
await operation();
} catch (cause) {
setError(cause instanceof Error ? cause.message : '操作失败。');
} finally {
setBusy(false);
}
}, []);
const visibleDiagnostics = useMemo(() => {
const needle = diagnosticsSearch.trim().toLowerCase();
if (needle === '') return diagnostics;
return diagnostics.filter((item) =>
[item.name, item.instanceId, item.origin, ...item.tags].some((field) =>
field.toLowerCase().includes(needle),
),
);
}, [diagnostics, diagnosticsSearch]);
const offlineCount = diagnostics.filter(
(item) => item.health === null || item.health.status === 'unreachable',
).length;
const degradedCount = diagnostics.filter(
(item) => item.health?.errorCode != null || item.connections?.failed,
).length;
return (
<div className="fleet-notifications-page">
<header className="workbench-heading">
<div>
<p className="page-kicker">FLEET</p>
<h1></h1>
<p></p>
</div>
<button
type="button"
className="primary-action"
disabled={busy || loading}
onClick={() =>
void run(async () => {
if (tab === 'runtime') await loadRuntime();
else if (tab === 'connections') await loadConnections();
else await loadDiagnostics();
setNotice('日志已刷新。');
})
}
>
<Icon name="restart" />
</button>
</header>
<div className="workbench-tabs" role="tablist" aria-label="日志中心视图">
{TABS.map(([value, label]) => (
<button
key={value}
type="button"
role="tab"
aria-selected={tab === value}
onClick={() => setTab(value)}
>
{label}
</button>
))}
</div>
{error ? (
<p className="state-panel state-error" role="alert">
{error}
</p>
) : null}
{notice ? (
<p className="state-panel" role="status">
{notice}
</p>
) : null}
{loading ? (
<p className="state-panel" role="status">
...
</p>
) : null}
{tab === 'runtime' ? (
<div role="tabpanel" aria-label="运行日志" className="fleet-notifications-grid">
<Panel
title="统一时间线"
detail="事件流、操作审计、定时任务与通知投递共用一条时间线,按时间倒序展示。"
>
<div className="fleet-log-filters">
<div className="fleet-notifications-counts">
{LOG_SOURCES.map((value) => (
<div key={value} data-state={runtimeCounts[value] > 0 ? 'ok' : 'neutral'}>
<dt>{SOURCE_LABELS[value]}</dt>
<dd>{runtimeCounts[value]}</dd>
</div>
))}
</div>
<select
aria-label="日志来源筛选"
value={sourceFilter}
onChange={(event) => {
setSourceFilter(event.currentTarget.value as LogSource | '');
setRuntimePage(1);
}}
>
<option value=""></option>
{LOG_SOURCES.map((value) => (
<option key={value} value={value}>
{SOURCE_LABELS[value]}
</option>
))}
</select>
<select
aria-label="日志级别筛选"
value={levelFilter}
onChange={(event) => {
setLevelFilter(event.currentTarget.value as LogLevel | '');
setRuntimePage(1);
}}
>
<option value=""></option>
{LOG_LEVELS.map((value) => (
<option key={value} value={value}>
{LEVEL_LABELS[value]}
</option>
))}
</select>
<input
type="search"
aria-label="运行日志关键词"
placeholder="搜索内容、结果码或设备"
value={runtimeSearch}
onChange={(event) => {
setRuntimeSearch(event.currentTarget.value);
setRuntimePage(1);
}}
/>
<label className="fleet-log-filter-date">
<span></span>
<input
type="date"
aria-label="运行日志起始日期"
value={runtimeFrom}
max={runtimeTo || undefined}
onChange={(event) => {
setRuntimeFrom(event.currentTarget.value);
setRuntimePage(1);
}}
/>
</label>
<label className="fleet-log-filter-date">
<span></span>
<input
type="date"
aria-label="运行日志结束日期"
value={runtimeTo}
min={runtimeFrom || undefined}
onChange={(event) => {
setRuntimeTo(event.currentTarget.value);
setRuntimePage(1);
}}
/>
</label>
{sourceFilter || levelFilter || runtimeSearch || runtimeFrom || runtimeTo ? (
<button
type="button"
className="fleet-notifications-retry-all"
onClick={() => {
setSourceFilter('');
setLevelFilter('');
setRuntimeSearch('');
setRuntimeFrom('');
setRuntimeTo('');
setRuntimePage(1);
}}
>
<Icon name="close" />
</button>
) : null}
</div>
{runtimeEntries.length === 0 && !loading ? (
<Empty text="暂无运行日志。" />
) : (
<div className="schedule-table-wrap">
<table className="schedule-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{runtimeEntries.map((entry) => (
<tr key={entry.id}>
<td>{formatTimestamp(entry.occurredAt)}</td>
<td>{SOURCE_LABELS[entry.source]}</td>
<td data-tone={LEVEL_TONES[entry.level]}>{LEVEL_LABELS[entry.level]}</td>
<td>{entry.instanceId ?? '--'}</td>
<td>
{entry.message}
{entry.actor ? (
<small className="field-note"> {entry.actor}</small>
) : null}
</td>
<td>{entry.durationMs === null ? '--' : `${entry.durationMs} ms`}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pager page={runtimePage} total={runtimeTotal} busy={busy} onChange={setRuntimePage} />
</Panel>
</div>
) : null}
{tab === 'connections' ? (
<div role="tabpanel" aria-label="连接日志" className="fleet-notifications-grid">
<Panel
title="连接探测记录"
detail="每次节点可达性探测都会留痕,保留最近 20000 条。"
actions={
<div className="fleet-card-actions">
<button
type="button"
className="fleet-notifications-retry-all"
disabled={busy}
onClick={() =>
void run(async () => {
const cutoff = dayStart(connectionFrom) ?? defaultCutoff();
const removed = await source.pruneConnections({ before: cutoff });
await loadConnections();
setNotice(`已清理 ${removed} 条连接日志。`);
})
}
>
<Icon name="trash" />
</button>
</div>
}
>
<div className="fleet-log-filters">
<select
aria-label="连接结果筛选"
value={outcomeFilter}
onChange={(event) => {
setOutcomeFilter(event.currentTarget.value as ConnectionOutcome | '');
setConnectionPage(1);
}}
>
<option value=""></option>
{CONNECTION_OUTCOMES.map((value) => (
<option key={value} value={value}>
{OUTCOME_LABELS[value]}
</option>
))}
</select>
<input
type="search"
aria-label="连接日志关键词"
placeholder="搜索设备、错误码"
value={connectionSearch}
onChange={(event) => {
setConnectionSearch(event.currentTarget.value);
setConnectionPage(1);
}}
/>
<label className="fleet-log-filter-date">
<span></span>
<input
type="date"
aria-label="连接日志起始日期"
value={connectionFrom}
max={connectionTo || undefined}
onChange={(event) => {
setConnectionFrom(event.currentTarget.value);
setConnectionPage(1);
}}
/>
</label>
<label className="fleet-log-filter-date">
<span></span>
<input
type="date"
aria-label="连接日志结束日期"
value={connectionTo}
min={connectionFrom || undefined}
onChange={(event) => {
setConnectionTo(event.currentTarget.value);
setConnectionPage(1);
}}
/>
</label>
{outcomeFilter || connectionSearch || connectionFrom || connectionTo ? (
<button
type="button"
className="fleet-notifications-retry-all"
onClick={() => {
setOutcomeFilter('');
setConnectionSearch('');
setConnectionFrom('');
setConnectionTo('');
setConnectionPage(1);
}}
>
<Icon name="close" />
</button>
) : null}
</div>
{connections.length === 0 && !loading ? (
<Empty text="暂无连接日志,节点探测成功后会自动记录。" />
) : (
<div className="schedule-table-wrap">
<table className="schedule-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{connections.map((entry) => (
<tr key={entry.id}>
<td>{formatTimestamp(entry.observedAt)}</td>
<td>{entry.instanceId}</td>
<td data-tone={OUTCOME_TONES[entry.outcome]}>
{OUTCOME_LABELS[entry.outcome]}
</td>
<td>{entry.httpStatus ?? '--'}</td>
<td>{entry.errorCode ?? '--'}</td>
<td>{entry.durationMs} ms</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pager
page={connectionPage}
total={connectionTotal}
busy={busy}
onChange={setConnectionPage}
/>
</Panel>
</div>
) : null}
{tab === 'diagnostics' ? (
<div role="tabpanel" aria-label="设备诊断" className="fleet-notifications-grid">
<Panel
title="设备诊断总览"
detail={`${diagnostics.length} 台设备,其中 ${offlineCount} 台不可达、${degradedCount} 台存在异常。`}
actions={
<input
type="search"
aria-label="设备诊断搜索"
placeholder="搜索名称、地址或标签"
value={diagnosticsSearch}
onChange={(event) => setDiagnosticsSearch(event.currentTarget.value)}
/>
}
>
{visibleDiagnostics.length === 0 && !loading ? (
<Empty text={diagnostics.length === 0 ? '尚未接入任何设备。' : '没有匹配的设备。'} />
) : (
<div className="fleet-diagnostics-list">
{visibleDiagnostics.map((device) => (
<DiagnosticsCard key={device.instanceId} device={device} />
))}
</div>
)}
</Panel>
</div>
) : null}
</div>
);
}
function defaultCutoff(): string {
return new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
}
function DiagnosticsCard({ device }: { device: InstanceDiagnostics }): ReactElement {
const summary = device.connections;
const tone =
device.health === null
? 'neutral'
: device.health.errorCode
? 'warn'
: device.health.status === 'unreachable'
? 'attention'
: 'ok';
return (
<div className="fleet-diagnostics-card" data-tone={tone}>
<header>
<h3>{device.name}</h3>
<span>{device.origin}</span>
{device.tags.map((tag) => (
<em key={tag}>{tag}</em>
))}
</header>
<dl className="fleet-notifications-counts">
<div data-state={device.enabled ? 'ok' : 'neutral'}>
<dt></dt>
<dd>{device.enabled ? '是' : '否'}</dd>
</div>
<div data-state={tone === 'ok' ? 'ok' : 'warn'}>
<dt></dt>
<dd>{device.health?.status ?? '未知'}</dd>
</div>
<div data-state={summary && summary.failed > 0 ? 'attention' : 'ok'}>
<dt></dt>
<dd>{summary ? `${summary.availabilityPercent}%` : '--'}</dd>
</div>
<div data-state="neutral">
<dt></dt>
<dd>{summary?.total ?? 0}</dd>
</div>
<div data-state="neutral">
<dt></dt>
<dd>
{device.capabilities.supported}/{device.capabilities.total}
</dd>
</div>
</dl>
{device.health?.version || device.health?.platform ? (
<p className="field-note">
{device.health.version ?? '--'} / {device.health.platform ?? '--'}
</p>
) : null}
{device.recentFailures.length > 0 ? (
<ul className="fleet-diagnostics-failures">
{device.recentFailures.map((failure, index) => (
<li key={`${failure.occurredAt}-${index}`}>
{formatTimestamp(failure.occurredAt)} {failure.message}
{failure.code ? ` (${failure.code})` : ''}
</li>
))}
</ul>
) : null}
</div>
);
}
@@ -0,0 +1,488 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
FleetMessagesPage,
type FleetMessagesConversation,
type FleetMessagesMessage,
type FleetMessagesCenterDataSource,
type FleetOutboxItem,
type FleetOutboxPage,
} from './fleet-messages-page.js';
afterEach(() => {
cleanup();
});
const load = vi.fn<FleetMessagesCenterDataSource['load']>().mockResolvedValue({
messages: [
{
id: 'm3',
instanceId: 'device-1',
instanceName: 'Modem A',
direction: 'incoming',
phoneNumber: '10086',
content: '最新余额提醒',
timestamp: '2026-07-29T12:05:00.000Z',
status: 'received',
transport: 'gsm',
},
{
id: 'm1',
instanceId: 'device-1',
instanceName: 'Modem A',
direction: 'outgoing',
phoneNumber: '10086',
content: '查询余额',
timestamp: '2026-07-29T12:00:00.000Z',
status: 'sent',
transport: 'gsm',
},
{
id: 'm2',
instanceId: 'device-2',
instanceName: 'Modem B',
direction: 'incoming',
phoneNumber: '13900139000',
content: '另一个会话',
timestamp: '2026-07-29T11:59:00.000Z',
status: 'received',
transport: 'gsm',
},
],
devices: [
{ id: 'device-1', name: 'Modem A', availability: 'online' },
{ id: 'device-2', name: 'Modem B', availability: 'unavailable' },
],
total: 3,
});
function dataSource(): FleetMessagesCenterDataSource {
return {
load,
send: vi.fn().mockResolvedValue({ delivered: true }),
deleteMany: vi.fn().mockResolvedValue({
requested: 0,
deleted: 0,
failed: 0,
failures: [],
}),
};
}
// Message bubbles only; the session list previews the same text.
const threadSelector = '.fleet-messages-list p';
const threadText = (text: string): Promise<HTMLElement> =>
screen.findByText(text, { selector: threadSelector });
const threadQuery = (text: string): HTMLElement | null =>
screen.queryByText(text, { selector: threadSelector });
describe('Fleet SMS center workflow', () => {
it('groups messages into sessions and opens the newest conversation', async () => {
render(<FleetMessagesPage dataSource={dataSource()} />);
const thread = () => within(screen.getByRole('list', { name: '消息记录' }));
expect(await screen.findByRole('button', { name: /10086/ })).toBeTruthy();
expect(thread().getByText('最新余额提醒')).toBeTruthy();
expect(thread().queryByText('另一个会话')).toBeNull();
const composer = within(screen.getByRole('form', { name: '发送短信' }));
expect((composer.getByLabelText('目标节点') as HTMLSelectElement).value).toBe('device-1');
expect(await screen.findByDisplayValue('10086')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /13900139000/ }));
expect(thread().getByText('另一个会话')).toBeTruthy();
expect(thread().queryByText('最新余额提醒')).toBeNull();
// The recipient follows the selected conversation until the user edits it by hand.
expect(await screen.findByDisplayValue('13900139000')).toBeTruthy();
fireEvent.change(composer.getByLabelText('手机号'), { target: { value: '13800001111' } });
fireEvent.click(screen.getByRole('button', { name: /10086/ }));
await thread().findByText('最新余额提醒');
expect((composer.getByLabelText('手机号') as HTMLInputElement).value).toBe('13800001111');
});
it('shows failed nodes as read errors instead of empty devices', async () => {
render(<FleetMessagesPage dataSource={dataSource()} />);
const broken = await screen.findByRole('button', { name: /Modem B/ });
expect(broken.getAttribute('aria-disabled')).toBe('true');
expect(within(broken).getByText('读取异常')).toBeTruthy();
expect(
within(screen.getByRole('form', { name: '发送短信' })).getByLabelText('目标节点'),
).toBeTruthy();
});
});
function threadMessage(
overrides: Partial<FleetMessagesMessage> & { id: string },
): FleetMessagesMessage {
return {
instanceId: 'device-1',
instanceName: 'Modem A',
direction: 'incoming',
phoneNumber: '10086',
content: '内容',
timestamp: '2026-07-29T12:00:00.000Z',
status: 'received',
transport: 'gsm',
...overrides,
};
}
const serverConversations: readonly FleetMessagesConversation[] = [
{
instanceId: 'device-1',
instanceName: 'Modem A',
phoneNumber: '10086',
messageCount: 58,
incomingCount: 21,
lastMessage: threadMessage({ id: 'a1', content: '余额提醒' }),
},
{
instanceId: 'device-2',
instanceName: 'Modem B',
phoneNumber: '13900139000',
messageCount: 4,
incomingCount: 0,
lastMessage: threadMessage({
id: 'b1',
instanceId: 'device-2',
instanceName: 'Modem B',
phoneNumber: '13900139000',
direction: 'outgoing',
status: 'sent',
content: '发货通知',
timestamp: '2026-07-29T11:00:00.000Z',
}),
},
];
const laterConversation: FleetMessagesConversation = {
instanceId: 'device-1',
instanceName: 'Modem A',
phoneNumber: '13800001111',
messageCount: 2,
incomingCount: 1,
lastMessage: threadMessage({ id: 'c1', phoneNumber: '13800001111', content: '预约提醒' }),
};
describe('Fleet SMS center with server grouped conversations', () => {
function serverSource() {
const load = vi
.fn<FleetMessagesCenterDataSource['load']>()
.mockImplementation(async (_signal, query = {}) => {
if (query.phoneNumber) {
return {
messages: [
threadMessage({
id: `t-${query.phoneNumber}`,
instanceId: query.instanceId ?? 'device-1',
phoneNumber: query.phoneNumber,
content: query.phoneNumber === '10086' ? '余额提醒' : '发货通知',
}),
],
devices: [],
total: query.phoneNumber === '10086' ? 58 : 4,
};
}
return {
messages: [],
devices: [
{ id: 'device-1', name: 'Modem A', availability: 'online' },
{ id: 'device-2', name: 'Modem B', availability: 'online' },
],
total: 62,
};
});
const listConversations = vi
.fn<NonNullable<FleetMessagesCenterDataSource['listConversations']>>()
.mockImplementation(async (_signal, query = {}) => ({
conversations: (query.offset ?? 0) === 0 ? serverConversations : [laterConversation],
total: 45,
stats: { incoming: 128, outgoing: 34, total: 162 },
}));
const source: FleetMessagesCenterDataSource = {
load,
listConversations,
send: vi.fn().mockResolvedValue({ delivered: true }),
deleteMany: vi.fn().mockResolvedValue({
requested: 0,
deleted: 0,
failed: 0,
failures: [],
}),
};
return { load, listConversations, source };
}
it('renders the whole-archive session list and opens the first conversation', async () => {
const { load, source } = serverSource();
render(<FleetMessagesPage dataSource={source} />);
expect(await screen.findByText('45 个会话')).toBeTruthy();
const first = screen.getByRole('button', { name: '会话 10086' });
expect(within(first).getByText('Modem A')).toBeTruthy();
expect(within(first).getByText('21')).toBeTruthy();
expect(within(first).getByText('58 条')).toBeTruthy();
expect(within(first).getByText('余额提醒')).toBeTruthy();
await expect(threadText('余额提醒')).resolves.toBeTruthy();
expect(load).toHaveBeenCalledWith(expect.any(AbortSignal), {
limit: 24,
offset: 0,
instanceId: 'device-1',
phoneNumber: '10086',
});
});
it('refetches the thread when another conversation is selected', async () => {
const { load, source } = serverSource();
render(<FleetMessagesPage dataSource={source} />);
await screen.findByText('45 个会话');
fireEvent.click(screen.getByRole('button', { name: '会话 13900139000' }));
await expect(threadText('发货通知')).resolves.toBeTruthy();
expect(load).toHaveBeenCalledWith(expect.any(AbortSignal), {
limit: 24,
offset: 0,
instanceId: 'device-2',
phoneNumber: '13900139000',
});
expect(threadQuery('余额提醒')).toBeNull();
});
it('appends further conversations without dropping the open thread', async () => {
const { listConversations, source } = serverSource();
render(<FleetMessagesPage dataSource={source} />);
await screen.findByText('45 个会话');
fireEvent.click(screen.getByRole('button', { name: '更多会话' }));
expect(listConversations).toHaveBeenLastCalledWith(expect.any(AbortSignal), {
limit: 40,
offset: 40,
});
expect(await screen.findByRole('button', { name: '会话 13800001111' })).toBeTruthy();
expect(screen.getByRole('button', { name: '会话 10086' })).toBeTruthy();
expect(threadQuery('余额提醒')).toBeTruthy();
});
it('shows archive direction counters and filters threads by direction', async () => {
const { listConversations, source } = serverSource();
const { container } = render(<FleetMessagesPage dataSource={source} />);
await screen.findByText('45 个会话');
const summary = container.querySelector<HTMLElement>('.fleet-messages-summary');
expect(summary?.textContent).toContain('接收');
expect(summary?.textContent).toContain('128');
expect(summary?.textContent).toContain('发送');
expect(summary?.textContent).toContain('34');
fireEvent.click(screen.getByRole('button', { name: '仅发送' }));
expect(listConversations).toHaveBeenLastCalledWith(expect.any(AbortSignal), {
limit: 40,
offset: 0,
direction: 'outgoing',
});
expect(screen.getByRole('button', { name: '仅发送' }).getAttribute('aria-pressed')).toBe(
'true',
);
expect(screen.getByRole('button', { name: '全部方向' }).getAttribute('aria-pressed')).toBe(
'false',
);
fireEvent.click(screen.getByRole('button', { name: '全部方向' }));
expect(listConversations).toHaveBeenLastCalledWith(expect.any(AbortSignal), {
limit: 40,
offset: 0,
});
});
});
const queuedItem: FleetOutboxItem = Object.freeze({
id: 'q-1',
instanceId: 'device-2',
instanceName: 'Modem B',
phoneNumber: '13900139000',
content: '离线补投的短信',
status: 'queued' as const,
attempts: 2,
maxAttempts: 24,
lastError: 'DEVICE_OFFLINE',
availableAt: '',
sentAt: '',
createdAt: '2026-07-29T12:00:00.000Z',
updatedAt: '2026-07-29T12:04:00.000Z',
});
const failedItem: FleetOutboxItem = Object.freeze({
...queuedItem,
id: 'q-2',
content: '放弃重试的短信',
status: 'failed' as const,
attempts: 24,
lastError: 'UPSTREAM_FAILED',
});
function queuePage(items: readonly FleetOutboxItem[]): FleetOutboxPage {
return Object.freeze({
items,
total: items.length,
summary: Object.freeze({
queued: items.filter((item) => item.status === 'queued').length,
sending: 0,
failed: items.filter((item) => item.status === 'failed').length,
sent: 0,
}),
});
}
describe('Fleet offline send queue panel', () => {
function queueSource(items: readonly FleetOutboxItem[]) {
const listOutbox = vi
.fn<NonNullable<FleetMessagesCenterDataSource['listOutbox']>>()
.mockResolvedValue(queuePage(items));
const cancelOutbox = vi
.fn<NonNullable<FleetMessagesCenterDataSource['cancelOutbox']>>()
.mockResolvedValue({ ...queuedItem, status: 'cancelled' });
const retryOutbox = vi
.fn<NonNullable<FleetMessagesCenterDataSource['retryOutbox']>>()
.mockResolvedValue({ ...failedItem, status: 'queued', attempts: 0 });
const flushOutbox = vi
.fn<NonNullable<FleetMessagesCenterDataSource['flushOutbox']>>()
.mockResolvedValue({ attempted: 2, delivered: 1, deferred: 1, failed: 0, remaining: 0 });
const source: FleetMessagesCenterDataSource = {
load,
send: vi.fn().mockResolvedValue({ delivered: false, queueId: 'q-1' }),
deleteMany: vi.fn().mockResolvedValue({ requested: 0, deleted: 0, failed: 0, failures: [] }),
listOutbox,
cancelOutbox,
retryOutbox,
flushOutbox,
};
return { listOutbox, cancelOutbox, retryOutbox, flushOutbox, source };
}
it('lists waiting and failed deliveries with the matching actions', async () => {
const { source } = queueSource([queuedItem, failedItem]);
render(<FleetMessagesPage dataSource={source} />);
const table = await screen.findByRole('table');
expect(within(table).getByText('离线补投的短信')).toBeTruthy();
expect(within(table).getByText('重试中')).toBeTruthy();
expect(within(table).getByText('投递失败')).toBeTruthy();
expect(within(table).getByText('节点未响应')).toBeTruthy();
expect(within(table).getByText('2 / 24')).toBeTruthy();
fireEvent.click(within(table).getByRole('button', { name: '取消' }));
fireEvent.click(within(table).getByRole('button', { name: '重新排队' }));
});
it('flushes the queue on demand and reports the round', async () => {
const { flushOutbox, source } = queueSource([queuedItem]);
render(<FleetMessagesPage dataSource={source} />);
fireEvent.click(await screen.findByRole('button', { name: '立即投递' }));
expect(flushOutbox).toHaveBeenCalledTimes(1);
await expect(
screen.findByText('本轮尝试 2 条:送达 1,继续等待 1,失败 0。'),
).resolves.toBeTruthy();
});
it('switches the queue filter and keeps an empty queue honest', async () => {
const { listOutbox, source } = queueSource([]);
render(<FleetMessagesPage dataSource={source} />);
await expect(screen.findByText('没有等待投递的短信。')).resolves.toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '全部记录' }));
expect(listOutbox).toHaveBeenLastCalledWith(expect.any(AbortSignal), {
status: 'all',
limit: 10,
});
await expect(screen.findByText('暂无队列记录。')).resolves.toBeTruthy();
});
it('hides the queue panel when the control plane has no outbox', async () => {
render(<FleetMessagesPage dataSource={dataSource()} />);
await screen.findByRole('button', { name: /10086/ });
expect(screen.queryByText('发送队列')).toBeNull();
});
});
describe('Fleet SMS center deep link', () => {
function serverSource() {
const load = vi.fn<FleetMessagesCenterDataSource['load']>().mockResolvedValue({
messages: [],
devices: [
{ id: 'device-1', name: 'Modem A', availability: 'online' },
{ id: 'device-2', name: 'Modem B', availability: 'online' },
],
total: 62,
});
const listConversations = vi
.fn<NonNullable<FleetMessagesCenterDataSource['listConversations']>>()
.mockResolvedValue({ conversations: serverConversations, total: 45 });
return {
listConversations,
source: {
load,
listConversations,
send: vi.fn().mockResolvedValue({ delivered: true }),
} as unknown as FleetMessagesCenterDataSource,
};
}
it('preselects the node named in the query string', async () => {
window.history.replaceState(null, '', '/fleet/messages?device=device-2');
try {
const { listConversations, source } = serverSource();
render(<FleetMessagesPage dataSource={source} />);
await screen.findByRole('button', { name: '会话 10086' });
expect(listConversations).toHaveBeenCalledWith(
expect.any(AbortSignal),
expect.objectContaining({ instanceId: 'device-2' }),
);
expect(screen.getByRole('button', { name: /Modem B/ }).getAttribute('data-active')).toBe(
'true',
);
} finally {
window.history.replaceState(null, '', '/');
}
});
it('falls back to every node when the deep linked device is gone', async () => {
window.history.replaceState(null, '', '/fleet/messages?device=ghost-device');
try {
const { listConversations, source } = serverSource();
render(<FleetMessagesPage dataSource={source} />);
await screen.findByRole('button', { name: '会话 10086' });
const first = listConversations.mock.calls[0]?.[1] as { instanceId?: string } | undefined;
expect(first?.instanceId).toBe('ghost-device');
// The unknown id is dropped once the node list arrives, which refetches every conversation.
await vi.waitFor(() => {
const calls = listConversations.mock.calls;
const last = calls[calls.length - 1]?.[1] as { instanceId?: string } | undefined;
expect(last?.instanceId).toBeUndefined();
});
expect(screen.getByRole('button', { name: /全部节点/ }).getAttribute('data-active')).toBe(
'true',
);
} finally {
window.history.replaceState(null, '', '/');
}
});
});
@@ -0,0 +1,533 @@
import { describe, expect, it, vi } from 'vitest';
import {
createFleetMessagesCenterApiDataSource,
sanitizeFleetMessagesDeleteResult,
sanitizeFleetMessagesSnapshot,
sanitizeFleetOutboxFlushResult,
sanitizeFleetOutboxPage,
} from './fleet-messages-page.js';
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
const smsResponse = {
data: {
messages: [
{
id: 'm2',
instanceId: 'device-1',
instanceName: 'Modem A',
direction: 'incoming',
phoneNumber: '10086',
content: '余额已更新',
timestamp: '2026-07-29 12:00:00',
status: 'received',
transport: 'gsm',
},
{
id: 'm1',
instanceId: 'device-2',
instanceName: 'Modem B',
direction: 'outgoing',
phoneNumber: '13900139000',
content: 'Hello',
timestamp: '2026-07-29T11:59:00.000Z',
status: 'delivered',
},
],
devices: [
{ id: 'device-1', name: 'Modem A', availability: 'online' },
{ id: 'device-2', name: 'Modem B', availability: 'unavailable' },
],
total: 42,
},
};
describe('Fleet message center', () => {
it('loads aggregate SMS through the local control-plane endpoint', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(json(smsResponse));
const source = createFleetMessagesCenterApiDataSource(fetcher);
const snapshot = await source.load(new AbortController().signal, {
limit: 24,
offset: 0,
search: '余额',
});
expect(snapshot).toEqual({
messages: [
expect.objectContaining({
id: 'm2',
instanceName: 'Modem A',
phoneNumber: '10086',
transport: 'gsm',
}),
expect.objectContaining({
id: 'm1',
instanceName: 'Modem B',
transport: 'modem',
}),
],
devices: [
{ id: 'device-1', name: 'Modem A', availability: 'online' },
{ id: 'device-2', name: 'Modem B', availability: 'unavailable' },
],
total: 42,
});
expect(fetcher).toHaveBeenCalledTimes(1);
expect(fetcher).toHaveBeenCalledWith(
'/api/v1/fleet/messages?limit=24&offset=0&search=%E4%BD%99%E9%A2%9D',
{
method: 'GET',
credentials: 'same-origin',
headers: { accept: 'application/json' },
signal: expect.any(AbortSignal),
},
);
expect(fetcher).not.toHaveBeenCalledWith('/hub-api/messages', expect.anything());
});
it('reads server-grouped conversations from the local Fleet endpoint', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(
json({
conversations: [
{
instanceId: 'device-1',
instanceName: 'Modem A',
phoneNumber: '10086',
messageCount: 7,
incomingCount: 3,
lastMessage: smsResponse.data.messages[0],
},
],
total: 12,
}),
);
const source = createFleetMessagesCenterApiDataSource(fetcher);
const page = await source.listConversations?.(new AbortController().signal, {
limit: 40,
offset: 40,
search: '余额',
instanceId: 'device-1',
});
expect(page?.total).toBe(12);
expect(page?.conversations[0]).toEqual(
expect.objectContaining({
instanceId: 'device-1',
instanceName: 'Modem A',
phoneNumber: '10086',
messageCount: 7,
incomingCount: 3,
}),
);
expect(fetcher).toHaveBeenCalledWith(
'/api/v1/fleet/messages/conversations?limit=40&offset=40&search=%E4%BD%99%E9%A2%9D&instanceId=device-1',
expect.objectContaining({ method: 'GET' }),
);
});
it('sends the direction filter and reads back archive-wide counters', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(
json({
conversations: [],
total: 0,
stats: { incoming: 18, outgoing: 5, total: 23 },
}),
);
const source = createFleetMessagesCenterApiDataSource(fetcher);
const page = await source.listConversations?.(new AbortController().signal, {
limit: 24,
offset: 0,
direction: 'outgoing',
});
expect(page?.stats).toEqual({ incoming: 18, outgoing: 5, total: 23 });
expect(fetcher).toHaveBeenCalledWith(
'/api/v1/fleet/messages/conversations?limit=24&offset=0&direction=outgoing',
expect.objectContaining({ method: 'GET' }),
);
});
it('degrades to no counters when the stats block is unusable', async () => {
const source = createFleetMessagesCenterApiDataSource(
vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
json({
conversations: [],
total: 0,
stats: { incoming: 'many', outgoing: 5, total: 23 },
}),
)
.mockResolvedValueOnce(json({ conversations: [], total: 0 })),
);
await expect(
source.listConversations?.(new AbortController().signal, { direction: 'incoming' }),
).resolves.toMatchObject({ total: 0 });
const page = await source.listConversations?.(new AbortController().signal, {});
expect(page?.stats).toBeUndefined();
});
it('fails closed when the conversation page is malformed', async () => {
const source = createFleetMessagesCenterApiDataSource(
vi.fn<typeof fetch>().mockResolvedValueOnce(
json({
conversations: [{ instanceId: 'device-1', phoneNumber: '10086' }],
total: 1,
}),
),
);
await expect(
source.listConversations?.(new AbortController().signal, { limit: 40, offset: 0 }),
).rejects.toThrow('Fleet conversation response is invalid.');
});
it('posts a send action to the local Fleet endpoint', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(json({ sent: true, queued: false, instanceId: 'device-1' }));
const source = createFleetMessagesCenterApiDataSource(fetcher);
await expect(
source.send({
instanceId: 'device-1',
phoneNumber: '13900139000',
content: 'Verified via Hub',
}),
).resolves.toEqual({ delivered: true });
expect(fetcher).toHaveBeenCalledWith('/api/v1/fleet/messages/send', {
method: 'POST',
credentials: 'same-origin',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify({
instanceId: 'device-1',
phoneNumber: '13900139000',
content: 'Verified via Hub',
}),
});
});
it('posts batch delete items to the local Fleet endpoint', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(
json({
requested: 2,
deleted: 1,
failed: 1,
failures: [
{
instanceId: 'device-2',
instanceName: 'Modem B',
id: 'm1',
code: 'UPSTREAM_FAILED',
},
],
}),
);
const source = createFleetMessagesCenterApiDataSource(fetcher);
await expect(
source.deleteMany({
items: [
{ instanceId: 'device-1', id: 'm2' },
{ instanceId: 'device-2', id: 'm1' },
],
}),
).resolves.toEqual({
requested: 2,
deleted: 1,
failed: 1,
failures: [
{
instanceId: 'device-2',
instanceName: 'Modem B',
id: 'm1',
code: 'UPSTREAM_FAILED',
},
],
});
expect(fetcher).toHaveBeenCalledWith('/api/v1/fleet/messages/delete', {
method: 'POST',
credentials: 'same-origin',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify({
items: [
{ instanceId: 'device-1', id: 'm2' },
{ instanceId: 'device-2', id: 'm1' },
],
}),
});
});
it('sanitizes aggregate messages and fails closed on invalid pages', () => {
const snapshot = sanitizeFleetMessagesSnapshot(smsResponse);
expect(snapshot?.messages).toHaveLength(2);
expect(snapshot?.devices[1]).toEqual({
id: 'device-2',
name: 'Modem B',
availability: 'unavailable',
});
expect(
sanitizeFleetMessagesSnapshot({ data: { messages: [], devices: [], total: 1 } }),
).toEqual({
messages: [],
devices: [],
total: 1,
});
expect(
sanitizeFleetMessagesSnapshot({ data: { messages: [], devices: [], total: -1 } }),
).toBeNull();
expect(
sanitizeFleetMessagesSnapshot({
data: { messages: [{ id: 'x' }], devices: [], total: 1 },
}),
).toBeNull();
expect(
sanitizeFleetMessagesSnapshot({
data: {
messages: [],
devices: [{ id: 'device-1', name: 'Modem A' }],
total: 0,
},
}),
).toBeNull();
});
it('sanitizes delete results and rejects inconsistent failure summaries', () => {
expect(
sanitizeFleetMessagesDeleteResult({
requested: 2,
deleted: 1,
failed: 1,
failures: [
{
instanceId: 'device-2',
instanceName: 'Modem B',
id: 'm1',
code: 'UPSTREAM_FAILED',
},
],
}),
).toEqual({
requested: 2,
deleted: 1,
failed: 1,
failures: [
{
instanceId: 'device-2',
instanceName: 'Modem B',
id: 'm1',
code: 'UPSTREAM_FAILED',
},
],
});
expect(
sanitizeFleetMessagesDeleteResult({
requested: 2,
deleted: 1,
failed: 0,
failures: [],
}),
).toBeNull();
expect(
sanitizeFleetMessagesDeleteResult({
requested: 1,
deleted: 1,
failed: 0,
failures: [{ instanceId: 'device-1' }],
}),
).toBeNull();
});
it('rejects non-2xx responses and malformed send replies', async () => {
await expect(
createFleetMessagesCenterApiDataSource(
vi.fn<typeof fetch>().mockResolvedValueOnce(json({}, 500)),
).load(new AbortController().signal),
).rejects.toThrow('Fleet message request failed (500).');
await expect(
createFleetMessagesCenterApiDataSource(
vi.fn<typeof fetch>().mockResolvedValueOnce(json({ ok: true })),
).send({
instanceId: 'device-1',
phoneNumber: '13900139000',
content: 'Hello',
}),
).rejects.toThrow('Fleet message send response is invalid.');
await expect(
createFleetMessagesCenterApiDataSource(
vi.fn<typeof fetch>().mockResolvedValueOnce(json({ requested: 1 })),
).deleteMany({
items: [{ instanceId: 'device-1', id: 'm1' }],
}),
).rejects.toThrow('Fleet message delete response is invalid.');
});
});
const outboxItem = {
id: 'q-1',
instanceId: 'device-2',
instanceName: 'Modem B',
phoneNumber: '13900139000',
content: 'Queued while offline',
status: 'queued',
attempts: 2,
maxAttempts: 24,
lastError: 'DEVICE_OFFLINE',
availableAt: '2026-07-29T12:05:00.000Z',
sentAt: '',
createdAt: '2026-07-29T12:00:00.000Z',
updatedAt: '2026-07-29T12:04:00.000Z',
};
describe('Fleet offline send queue', () => {
it('reports a queued send with its queue id', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
json({ sent: false, queued: true, instanceId: 'device-2', queueId: 'q-1' }),
);
const source = createFleetMessagesCenterApiDataSource(fetcher);
await expect(
source.send({
instanceId: 'device-2',
phoneNumber: '13900139000',
content: 'Queued while offline',
}),
).resolves.toEqual({ delivered: false, queueId: 'q-1' });
});
it('rejects a send reply without the queued flag', async () => {
const source = createFleetMessagesCenterApiDataSource(
vi.fn<typeof fetch>().mockResolvedValueOnce(json({ sent: true })),
);
await expect(
source.send({ instanceId: 'device-1', phoneNumber: '13900139000', content: 'Hello' }),
).rejects.toThrow('Fleet message send response is invalid.');
});
it('reads the queue from the local Fleet endpoint', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(
json({
items: [outboxItem],
total: 3,
summary: { queued: 2, sending: 0, failed: 1, sent: 9 },
}),
);
const source = createFleetMessagesCenterApiDataSource(fetcher);
const page = await source.listOutbox?.(new AbortController().signal, { status: 'open' });
expect(page?.total).toBe(3);
expect(page?.items[0]).toEqual(expect.objectContaining({ id: 'q-1', attempts: 2 }));
expect(page?.summary).toEqual({ queued: 2, sending: 0, failed: 1, sent: 9 });
expect(fetcher).toHaveBeenCalledWith(
'/api/v1/fleet/messages/outbox?status=open&limit=10&offset=0',
expect.objectContaining({ method: 'GET' }),
);
});
it('cancels, retries and flushes the queue through local Fleet routes', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(json({ ...outboxItem, status: 'cancelled' }))
.mockResolvedValueOnce(json({ ...outboxItem, attempts: 0 }))
.mockResolvedValueOnce(
json({ attempted: 2, delivered: 1, deferred: 1, failed: 0, remaining: 0 }),
);
const source = createFleetMessagesCenterApiDataSource(fetcher);
await expect(source.cancelOutbox?.('q-1')).resolves.toEqual(
expect.objectContaining({ id: 'q-1', status: 'cancelled' }),
);
await expect(source.retryOutbox?.('q-1')).resolves.toEqual(
expect.objectContaining({ id: 'q-1', status: 'queued', attempts: 0 }),
);
await expect(source.flushOutbox?.(new AbortController().signal)).resolves.toEqual({
attempted: 2,
delivered: 1,
deferred: 1,
failed: 0,
remaining: 0,
});
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/v1/fleet/messages/outbox/q-1/cancel');
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/v1/fleet/messages/outbox/q-1/retry');
expect(fetcher.mock.calls[2]?.[0]).toBe('/api/v1/fleet/messages/outbox/flush');
});
it('refuses to build a url from an unsafe queue id', async () => {
const fetcher = vi.fn<typeof fetch>();
const source = createFleetMessagesCenterApiDataSource(fetcher);
await expect(source.retryOutbox?.('../../etc/passwd')).rejects.toThrow(
'Fleet send queue id is invalid.',
);
expect(fetcher).not.toHaveBeenCalled();
});
});
describe('Fleet send queue sanitizers', () => {
it('freezes a valid queue page and fails closed otherwise', () => {
const page = sanitizeFleetOutboxPage({
items: [outboxItem],
total: 1,
summary: { queued: 1, sending: 0, failed: 0, sent: 0 },
});
expect(page?.items[0]?.instanceName).toBe('Modem B');
expect(Object.isFrozen(page)).toBe(true);
expect(Object.isFrozen(page?.items)).toBe(true);
expect(sanitizeFleetOutboxPage({ items: [], total: 0 })).toEqual({
items: [],
total: 0,
summary: { queued: 0, sending: 0, failed: 0, sent: 0 },
});
expect(
sanitizeFleetOutboxPage({ items: [{ ...outboxItem, status: 'exploded' }], total: 1 }),
).toBeNull();
expect(sanitizeFleetOutboxPage({ items: [], total: 'many' })).toBeNull();
expect(sanitizeFleetOutboxPage({ items: {}, total: 0 })).toBeNull();
});
it('rejects a flush result whose parts exceed the attempt count', () => {
expect(
sanitizeFleetOutboxFlushResult({
attempted: 1,
delivered: 1,
deferred: 1,
failed: 0,
remaining: 0,
}),
).toBeNull();
expect(
sanitizeFleetOutboxFlushResult({
attempted: 2,
delivered: 1,
deferred: 1,
failed: 0,
remaining: 3,
}),
).toEqual({ attempted: 2, delivered: 1, deferred: 1, failed: 0, remaining: 3 });
expect(sanitizeFleetOutboxFlushResult({ attempted: 1 })).toBeNull();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,435 @@
// @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 { FleetNotificationsPage } from './fleet-notifications-page.js';
import type {
NotificationCenterDataSource,
NotificationCenterSnapshot,
} from './notification-center-types.js';
import { DEFAULT_LOG_CLEANUP } from './notification-center-types.js';
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
function inputValue(element: HTMLElement | null): string {
return element instanceof HTMLInputElement ? element.value : '';
}
function snapshot(overrides: Partial<NotificationCenterSnapshot> = {}): NotificationCenterSnapshot {
return {
observedAt: '2026-09-03T01:00:00.000Z',
devices: [
{ id: 'device-1', name: 'Modem A', state: 'ready', tags: ['office'] },
{ id: 'device-2', name: 'Modem B', state: 'unknown', tags: [] },
],
config: {
channelCount: 1,
channelEnabled: 1,
ruleCount: 1,
ruleEnabled: 1,
channelTypes: [{ type: 'bark', total: 1, enabled: 1 }],
},
logs: {
total: 2,
success: 1,
failed: 1,
suppressed: 1,
recent: [
{
id: 'log-1',
eventType: 'sms',
status: 'failed',
channelName: '值班 Bark',
createdAt: '2026-09-03T01:00:00.000Z',
},
],
},
queue: {
total: 2,
pending: 1,
retrying: 0,
sending: 0,
succeeded: 1,
failed: 1,
recent: [],
},
...overrides,
};
}
function dataSource(
overrides: Partial<NotificationCenterDataSource> = {},
): NotificationCenterDataSource {
return {
loadSnapshot: vi.fn().mockResolvedValue(snapshot()),
listChannels: vi.fn().mockResolvedValue([
{
id: 'ch-1',
name: '值班 Bark',
type: 'bark',
enabled: true,
config: { server_url: 'https://bark.example.test', group: 'Island' },
hasSecret: true,
secretFields: ['device_key'],
},
]),
saveChannel: vi.fn().mockResolvedValue(undefined),
deleteChannel: vi.fn().mockResolvedValue(undefined),
testChannel: vi.fn().mockResolvedValue({ ok: true }),
listRules: vi.fn().mockResolvedValue([
{
id: 'rule-1',
name: '余额提醒',
eventType: 'sms',
enabled: true,
condition: { field: 'content', mode: 'contains', value: '余额' },
scope: { mode: 'tags', tags: ['office'], match: 'all', instanceIds: [] },
channels: [{ id: 'ch-1', name: '值班 Bark' }],
templates: { title: '余额提醒', body: '{{content}}' },
rateLimit: { enabled: true, maxMessages: 20, windowSeconds: 60 },
quietHours: [{ start: '22:00', end: '08:00' }],
},
]),
saveRule: vi.fn().mockResolvedValue(undefined),
deleteRule: vi.fn().mockResolvedValue(undefined),
listLogs: vi.fn().mockResolvedValue({
items: [
{
id: 'log-1',
eventType: 'sms',
status: 'failed',
channelName: '值班 Bark',
createdAt: '2026-09-03T01:00:00.000Z',
},
],
total: 1,
}),
clearLogs: vi.fn().mockResolvedValue(1),
getLogCleanup: vi.fn().mockResolvedValue(DEFAULT_LOG_CLEANUP),
saveLogCleanup: vi.fn().mockResolvedValue(DEFAULT_LOG_CLEANUP),
pruneLogs: vi.fn().mockResolvedValue(0),
listQueue: vi.fn().mockResolvedValue({
items: [
{
id: 'q-1',
eventType: 'sms',
status: 'failed',
state: 'failed',
title: '来自 13800000000 的新短信',
attempts: 3,
maxAttempts: 3,
createdAt: '2026-09-03T01:00:00.000Z',
},
],
total: 1,
}),
retryQueueItem: vi.fn().mockResolvedValue(undefined),
deleteQueueItem: vi.fn().mockResolvedValue(undefined),
clearQueue: vi.fn().mockResolvedValue(1),
processQueue: vi.fn().mockResolvedValue({ processed: 1, succeeded: 1, failed: 0 }),
...overrides,
};
}
describe('FleetNotificationsPage', () => {
it('shows the central overview with channels, rules, queue and devices', async () => {
const source = dataSource();
render(<FleetNotificationsPage dataSource={source} />);
expect(await screen.findByRole('heading', { name: '通知中心' })).toBeTruthy();
expect(screen.getByRole('tab', { name: '总览' })).toBeTruthy();
expect(screen.getByText('Bark')).toBeTruthy();
expect(screen.getByText('1/1 启用')).toBeTruthy();
expect(screen.getByText('Modem A')).toBeTruthy();
expect(screen.getByText('Modem B')).toBeTruthy();
expect(source.loadSnapshot).toHaveBeenCalledTimes(1);
expect(source.listChannels).toHaveBeenCalledTimes(1);
expect(source.listRules).toHaveBeenCalledTimes(1);
});
it('drains the queue from the header action', async () => {
const user = userEvent.setup();
const source = dataSource();
render(<FleetNotificationsPage dataSource={source} />);
await screen.findByRole('heading', { name: '通知中心' });
await user.click(screen.getByRole('button', { name: /立即投递/ }));
expect(source.processQueue).toHaveBeenCalledTimes(1);
expect(await screen.findByText(/已投递 1 项,成功 1 项/)).toBeTruthy();
});
it('manages channels from the channel tab', async () => {
const user = userEvent.setup();
const source = dataSource();
render(<FleetNotificationsPage dataSource={source} />);
await screen.findByRole('heading', { name: '通知中心' });
await user.click(screen.getByRole('tab', { name: '通道' }));
expect(await screen.findByRole('button', { name: '测试 值班 Bark' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '测试 值班 Bark' }));
expect(source.testChannel).toHaveBeenCalledWith('ch-1');
await user.click(screen.getByRole('button', { name: '停用 值班 Bark' }));
expect(source.saveChannel).toHaveBeenCalledWith(
expect.objectContaining({ id: 'ch-1', enabled: false }),
);
await user.click(screen.getByRole('button', { name: '删除 值班 Bark' }));
expect(source.deleteChannel).toHaveBeenCalledWith('ch-1');
});
it('renders the Hub field schema for the selected channel type', async () => {
const user = userEvent.setup();
const source = dataSource();
render(<FleetNotificationsPage dataSource={source} />);
await screen.findByRole('heading', { name: '通知中心' });
await user.click(screen.getByRole('tab', { name: '通道' }));
await user.click(await screen.findByRole('button', { name: /新建通道/ }));
expect(await screen.findByRole('heading', { name: '新建通知通道' })).toBeTruthy();
// Webhook starts with its own endpoint plus a signature secret.
expect(screen.getByLabelText('回调地址 *')).toBeTruthy();
expect(screen.getByLabelText('签名密钥')).toBeTruthy();
expect(screen.queryByLabelText('设备 Key')).toBeNull();
await user.selectOptions(screen.getByLabelText('通道类型'), 'email');
expect(await screen.findByLabelText('SMTP 地址 *')).toBeTruthy();
expect(screen.getByLabelText('SMTP 端口')).toBeTruthy();
expect(screen.getByLabelText('传输加密')).toBeTruthy();
expect(screen.getByLabelText('允许自签证书')).toBeTruthy();
await user.selectOptions(screen.getByLabelText('通道类型'), 'bark');
// The spec prefills the public Bark server, so replace rather than append.
const server = screen.getByLabelText('服务器地址');
await user.clear(server);
await user.type(server, 'https://bark.example.test');
await user.type(screen.getByLabelText('设备 Key *'), 'device-key');
await user.click(screen.getByRole('button', { name: /保存通道/ }));
expect(source.saveChannel).toHaveBeenCalledWith(
expect.objectContaining({
type: 'bark',
config: expect.objectContaining({
device_key: 'device-key',
server_url: 'https://bark.example.test',
auto_copy: true,
save_history: true,
}),
}),
);
});
it('keeps stored channel secrets out of the editor and only labels them', async () => {
const user = userEvent.setup();
const source = dataSource();
render(<FleetNotificationsPage dataSource={source} />);
await screen.findByRole('heading', { name: '通知中心' });
await user.click(screen.getByRole('tab', { name: '通道' }));
await user.click(screen.getByRole('button', { name: '编辑 值班 Bark' }));
expect(await screen.findByRole('heading', { name: '编辑通知通道' })).toBeTruthy();
const key = screen.getByLabelText('设备 Key *') as HTMLInputElement;
expect(key.type).toBe('password');
expect(key.value).toBe('');
expect(key.placeholder).toBe('已保存,留空保持不变');
// The saved endpoint comes back from the config, the secret never does.
expect(inputValue(screen.getByLabelText('服务器地址'))).toBe('https://bark.example.test');
expect(screen.getByText('当前值已加密保存在本机密钥库中。')).toBeTruthy();
await user.click(screen.getByRole('button', { name: /保存通道/ }));
expect(source.saveChannel).toHaveBeenCalledWith(
expect.objectContaining({
id: 'ch-1',
config: expect.not.objectContaining({ device_key: expect.anything() }),
}),
);
});
it('filters, retries and deletes queue items', async () => {
const user = userEvent.setup();
const source = dataSource();
render(<FleetNotificationsPage dataSource={source} />);
await screen.findByRole('heading', { name: '通知中心' });
await user.click(screen.getByRole('tab', { name: '队列' }));
expect(await screen.findByText('来自 13800000000 的新短信')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '重试 来自 13800000000 的新短信' }));
expect(source.retryQueueItem).toHaveBeenCalledWith('q-1');
await user.click(screen.getByRole('button', { name: '删除 来自 13800000000 的新短信' }));
expect(source.deleteQueueItem).toHaveBeenCalledWith('q-1');
await user.selectOptions(screen.getByLabelText('队列状态筛选'), 'failed');
expect(source.listQueue).toHaveBeenLastCalledWith(1, 'failed', expect.anything());
});
it('clears delivery logs from the log tab', async () => {
const user = userEvent.setup();
const source = dataSource();
render(<FleetNotificationsPage dataSource={source} />);
await screen.findByRole('heading', { name: '通知中心' });
await user.click(screen.getByRole('tab', { name: '投递日志' }));
expect(await screen.findByText('值班 Bark')).toBeTruthy();
await user.click(screen.getByRole('button', { name: /清空日志/ }));
expect(source.clearLogs).toHaveBeenCalledTimes(1);
expect(await screen.findByText(/已清空 1 条日志/)).toBeTruthy();
});
it('reloads delivery logs through the filter controls', async () => {
const user = userEvent.setup();
const source = dataSource();
render(<FleetNotificationsPage dataSource={source} />);
await screen.findByRole('heading', { name: '通知中心' });
await user.click(screen.getByRole('tab', { name: '投递日志' }));
await screen.findByText('值班 Bark');
await user.selectOptions(screen.getByLabelText('日志结果筛选'), 'failed');
expect(source.listLogs).toHaveBeenLastCalledWith(1, { status: 'failed' }, expect.anything());
await user.type(screen.getByLabelText('日志起始日期'), '2026-09-01');
expect(source.listLogs).toHaveBeenLastCalledWith(
1,
{ status: 'failed', from: '2026-09-01T00:00:00' },
expect.anything(),
);
await user.click(screen.getByRole('button', { name: /清空筛选结果/ }));
expect(source.clearLogs).toHaveBeenLastCalledWith({
status: 'failed',
from: '2026-09-01T00:00:00',
});
await user.click(screen.getByRole('button', { name: /清除筛选/ }));
expect(source.listLogs).toHaveBeenLastCalledWith(1, {}, expect.anything());
});
it('saves and prunes the delivery log retention policy', async () => {
const user = userEvent.setup();
const source = dataSource({
saveLogCleanup: vi.fn().mockResolvedValue({
retentionDaysEnabled: true,
retentionDays: 90,
maxEntriesEnabled: false,
maxEntries: 10_000,
}),
pruneLogs: vi.fn().mockResolvedValue(7),
});
render(<FleetNotificationsPage dataSource={source} />);
await screen.findByRole('heading', { name: '通知中心' });
await user.click(screen.getByRole('tab', { name: '投递日志' }));
expect(await screen.findByLabelText('保留天数')).toBeTruthy();
await user.clear(screen.getByLabelText('保留天数'));
await user.type(screen.getByLabelText('保留天数'), '90');
await user.click(screen.getByRole('button', { name: /保存设置/ }));
expect(source.saveLogCleanup).toHaveBeenCalledWith({
retentionDaysEnabled: true,
retentionDays: 90,
maxEntriesEnabled: false,
maxEntries: 10_000,
});
await user.click(screen.getByRole('button', { name: /立即清理/ }));
expect(source.pruneLogs).toHaveBeenCalledTimes(1);
expect(await screen.findByText(/已按策略清理 7 条日志/)).toBeTruthy();
});
it('shows suppression settings in the rules table', async () => {
const user = userEvent.setup();
const source = dataSource();
render(<FleetNotificationsPage dataSource={source} />);
await screen.findByRole('heading', { name: '通知中心' });
await user.click(screen.getByRole('tab', { name: '规则' }));
expect(await screen.findByText('限流 60秒/20条')).toBeTruthy();
expect(screen.getByText('免打扰 22:00-08:00')).toBeTruthy();
expect(screen.getByRole('columnheader', { name: '抑制' })).toBeTruthy();
});
it('edits the rate limit and quiet windows in the rule drawer', async () => {
const user = userEvent.setup();
const source = dataSource();
render(<FleetNotificationsPage dataSource={source} />);
await screen.findByRole('heading', { name: '通知中心' });
await user.click(screen.getByRole('tab', { name: '规则' }));
await user.click(await screen.findByRole('button', { name: '编辑 余额提醒' }));
const drawer = await screen.findByRole('dialog', { name: '编辑通知规则' });
const switchButton = within(drawer).getByRole('switch', { name: /已开启|已关闭/ });
expect(switchButton.getAttribute('aria-checked')).toBe('true');
expect(inputValue(within(drawer).getByLabelText('最多条数'))).toBe('20');
expect(inputValue(within(drawer).getByLabelText('统计周期(秒)'))).toBe('60');
expect(inputValue(within(drawer).getAllByLabelText('开始')[0])).toBe('22:00');
await user.click(within(drawer).getByRole('button', { name: '添加时段' }));
const ends = within(drawer).getAllByLabelText('结束');
expect(ends).toHaveLength(2);
expect(inputValue(ends[1])).toBe('08:00');
await user.click(within(drawer).getByRole('button', { name: '删除第 2 个免打扰时段' }));
expect(within(drawer).getAllByLabelText('开始')).toHaveLength(1);
await user.click(within(drawer).getByRole('button', { name: /保存规则/ }));
expect(source.saveRule).toHaveBeenCalledWith(
expect.objectContaining({
id: 'rule-1',
rateLimit: { enabled: true, maxMessages: 20, windowSeconds: 60 },
quietHours: [{ start: '22:00', end: '08:00' }],
}),
);
});
it('keeps suppression off by default for a new rule', async () => {
const user = userEvent.setup();
const source = dataSource();
render(<FleetNotificationsPage dataSource={source} />);
await screen.findByRole('heading', { name: '通知中心' });
await user.click(screen.getByRole('tab', { name: '规则' }));
await user.click(await screen.findByRole('button', { name: /新建规则/ }));
const drawer = await screen.findByRole('dialog', { name: '新建通知规则' });
expect(
within(drawer)
.getByRole('switch', { name: /已开启|已关闭/ })
.getAttribute('aria-checked'),
).toBe('false');
expect(within(drawer).queryByLabelText('最多条数')).toBeNull();
expect(within(drawer).getByText('未设置时段,规则全天生效。')).toBeTruthy();
await user.click(within(drawer).getByRole('button', { name: /保存规则/ }));
expect(source.saveRule).toHaveBeenCalledWith(
expect.objectContaining({
rateLimit: { enabled: false, maxMessages: 20, windowSeconds: 60 },
quietHours: [],
}),
);
});
it('surfaces a failed central request instead of rendering stale counters', async () => {
const user = userEvent.setup();
const source = dataSource({
processQueue: vi.fn().mockRejectedValue(new Error('通道不可用')),
});
render(<FleetNotificationsPage dataSource={source} />);
await screen.findByRole('heading', { name: '通知中心' });
await user.click(screen.getByRole('button', { name: /立即投递/ }));
expect(screen.getByRole('alert').textContent).toContain('通道不可用');
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,151 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { FleetOrganizationPanel } from './fleet-organization-panel.js';
import type { OrganizationDataSource } from './organization-api-data-source.js';
afterEach(() => {
cleanup();
});
function source(
overrides: Partial<OrganizationDataSource> = {},
): OrganizationDataSource & Record<string, ReturnType<typeof vi.fn>> {
return {
listGroups: vi.fn().mockResolvedValue([
{ id: 'g1', name: '核心', description: '机房核心', deviceCount: 2 },
{ id: 'g2', name: '外场', description: '', deviceCount: 0 },
]),
listTags: vi.fn().mockResolvedValue([{ tag: 'east', color: 'turquoise', deviceCount: 3 }]),
createGroup: vi
.fn()
.mockResolvedValue({ id: 'g3', name: '仓库', description: '', deviceCount: 0 }),
updateGroup: vi
.fn()
.mockResolvedValue({ id: 'g2', name: '外场', description: '', deviceCount: 0 }),
deleteGroup: vi.fn().mockResolvedValue(undefined),
createTag: vi.fn().mockResolvedValue({ tag: 'west', color: 'coral', deviceCount: 0 }),
updateTag: vi.fn().mockResolvedValue({ tag: 'east', color: 'pink', deviceCount: 3 }),
deleteTag: vi.fn().mockResolvedValue(undefined),
assignGroup: vi.fn().mockResolvedValue(undefined),
...overrides,
} as unknown as OrganizationDataSource & Record<string, ReturnType<typeof vi.fn>>;
}
describe('FleetOrganizationPanel', () => {
it('lists groups and tags with their device counts', async () => {
render(
<FleetOrganizationPanel dataSource={source()} onClose={() => {}} onChanged={() => {}} />,
);
expect(await screen.findByRole('dialog', { name: '分组与标签' })).toBeTruthy();
const core = screen.getByRole('button', { name: '编辑分组 核心' });
const row = core.closest('li');
expect(row?.textContent).toContain('2 台设备');
expect(row?.textContent).toContain('机房核心');
expect(screen.getByText('east')).toBeTruthy();
expect(screen.getByText('3 台设备')).toBeTruthy();
});
it('creates a group and notifies the caller', async () => {
const dataSource = source();
const onChanged = vi.fn();
render(
<FleetOrganizationPanel dataSource={dataSource} onClose={() => {}} onChanged={onChanged} />,
);
await screen.findByRole('dialog', { name: '分组与标签' });
fireEvent.click(screen.getByRole('button', { name: '新增分组' }));
const form = screen.getByRole('form', { name: '新增分组' });
fireEvent.change(within(form).getByLabelText('分组名称'), { target: { value: '仓库' } });
fireEvent.change(within(form).getByLabelText('分组说明'), { target: { value: '临时堆放' } });
fireEvent.submit(form);
await waitFor(() => expect(dataSource.createGroup).toHaveBeenCalledTimes(1));
expect(dataSource.createGroup).toHaveBeenCalledWith({ name: '仓库', description: '临时堆放' });
await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1));
expect(screen.queryByRole('form')).toBeNull();
});
it('blocks an empty name without calling the service', async () => {
const dataSource = source();
render(
<FleetOrganizationPanel dataSource={dataSource} onClose={() => {}} onChanged={() => {}} />,
);
await screen.findByRole('dialog', { name: '分组与标签' });
fireEvent.click(screen.getByRole('button', { name: '新增标签' }));
fireEvent.submit(screen.getByRole('form', { name: '新增标签' }));
expect(screen.getByRole('alert').textContent).toContain('标签名称不能为空');
expect(dataSource.createTag).not.toHaveBeenCalled();
});
it('edits a tag colour through the swatch picker', async () => {
const dataSource = source();
render(
<FleetOrganizationPanel dataSource={dataSource} onClose={() => {}} onChanged={() => {}} />,
);
await screen.findByRole('dialog', { name: '分组与标签' });
fireEvent.click(screen.getByRole('button', { name: '编辑标签 east' }));
const form = screen.getByRole('form', { name: '编辑标签' });
expect((within(form).getByLabelText('标签名称') as HTMLInputElement).value).toBe('east');
fireEvent.click(within(form).getByRole('button', { name: '粉' }));
fireEvent.submit(form);
await waitFor(() =>
expect(dataSource.updateTag).toHaveBeenCalledWith('east', { color: 'pink' }),
);
});
it('refuses to delete a group that still holds devices', async () => {
const dataSource = source();
render(
<FleetOrganizationPanel dataSource={dataSource} onClose={() => {}} onChanged={() => {}} />,
);
await screen.findByRole('dialog', { name: '分组与标签' });
expect(
(screen.getByRole('button', { name: '删除分组 核心' }) as HTMLButtonElement).disabled,
).toBe(true);
fireEvent.click(screen.getByRole('button', { name: '删除分组 外场' }));
await waitFor(() => expect(dataSource.deleteGroup).toHaveBeenCalledWith('g2'));
await waitFor(() => expect(dataSource.listGroups).toHaveBeenCalledTimes(2));
});
it('closes on escape and on a backdrop click', async () => {
const onClose = vi.fn();
render(<FleetOrganizationPanel dataSource={source()} onClose={onClose} onChanged={() => {}} />);
const dialog = await screen.findByRole('dialog', { name: '分组与标签' });
fireEvent.click(screen.getByRole('button', { name: '新增分组' }));
fireEvent.keyDown(dialog, { key: 'Escape' });
expect(screen.queryByRole('form')).toBeNull();
expect(onClose).not.toHaveBeenCalled();
fireEvent.keyDown(dialog, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
const backdrop = document.querySelector('.drawer-backdrop');
if (!backdrop) throw new Error('Missing drawer backdrop.');
fireEvent.mouseDown(backdrop);
expect(onClose).toHaveBeenCalledTimes(2);
});
it('reports a load failure instead of showing an empty fleet', async () => {
const dataSource = source({
listGroups: vi.fn().mockRejectedValue(new Error('offline')) as never,
});
render(
<FleetOrganizationPanel dataSource={dataSource} onClose={() => {}} onChanged={() => {}} />,
);
expect((await screen.findByRole('alert')).textContent).toContain('加载失败');
expect(screen.getByText('分组暂时无法读取,请重试。')).toBeTruthy();
expect(screen.getByText('标签暂时无法读取,请重试。')).toBeTruthy();
expect(screen.queryByText('暂无分组,创建分组后可按分组筛选与批量调度。')).toBeNull();
});
});
@@ -0,0 +1,347 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
import { TAG_COLORS } from '@multi-simadmin/contracts';
import { Icon } from '../ui/icon.js';
import type {
OrganizationDataSource,
OrganizationGroup,
OrganizationTag,
} from './organization-api-data-source.js';
export interface FleetOrganizationPanelProps {
readonly dataSource: OrganizationDataSource;
readonly onClose: () => void;
readonly onChanged: () => void;
}
type Draft = Readonly<{
kind: 'group' | 'tag';
/** Existing identifier when editing, undefined when creating. */
id?: string;
name: string;
description: string;
color: string;
}>;
const COLOR_LABELS: Readonly<Record<string, string>> = {
'': '默认',
coral: '珊瑚',
turquoise: '青绿',
lime: '青柠',
yellow: '明黄',
pink: '粉',
purple: '紫',
blue: '蓝',
};
export function FleetOrganizationPanel({
dataSource,
onClose,
onChanged,
}: FleetOrganizationPanelProps) {
const [groups, setGroups] = useState<readonly OrganizationGroup[]>([]);
const [tags, setTags] = useState<readonly OrganizationTag[]>([]);
const [loading, setLoading] = useState(true);
const [loadFailed, setLoadFailed] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState<Draft | null>(null);
const panelRef = useRef<HTMLElement | null>(null);
const closeRef = useRef<HTMLButtonElement | null>(null);
const reload = useMemo(
() => async (): Promise<void> => {
setLoading(true);
setError(null);
try {
const [nextGroups, nextTags] = await Promise.all([
dataSource.listGroups(),
dataSource.listTags(),
]);
setGroups(nextGroups);
setTags(nextTags);
setLoadFailed(false);
} catch {
setError('分组与标签加载失败。');
setLoadFailed(true);
} finally {
setLoading(false);
}
},
[dataSource],
);
useEffect(() => {
void reload();
}, [reload]);
useEffect(() => {
closeRef.current?.focus();
}, []);
async function run(operation: () => Promise<unknown>): Promise<boolean> {
setBusy(true);
setError(null);
try {
await operation();
await reload();
onChanged();
return true;
} catch {
setError('操作失败,请检查输入后重试。');
return false;
} finally {
setBusy(false);
}
}
async function saveDraft(): Promise<void> {
if (!draft) return;
const name = draft.name.trim();
if (!name) {
setError(draft.kind === 'group' ? '分组名称不能为空。' : '标签名称不能为空。');
return;
}
const saved =
draft.kind === 'group'
? await run(async () =>
draft.id === undefined
? dataSource.createGroup({ name, description: draft.description.trim() })
: dataSource.updateGroup(draft.id, {
name,
description: draft.description.trim(),
}),
)
: await run(async () =>
draft.id === undefined
? dataSource.createTag({ tag: name, color: draft.color })
: dataSource.updateTag(draft.id, { color: draft.color }),
);
if (saved) setDraft(null);
}
function handleKeyDown(event: KeyboardEvent<HTMLElement>): void {
if (event.key !== 'Escape') return;
event.stopPropagation();
if (draft) setDraft(null);
else onClose();
}
return (
<div
className="drawer-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget && !busy) onClose();
}}
>
<aside
ref={panelRef}
className="schedule-drawer organization-drawer"
role="dialog"
aria-modal="true"
aria-labelledby="organization-panel-title"
onKeyDown={handleKeyDown}
>
<header>
<div>
<h2 id="organization-panel-title"></h2>
</div>
<button
ref={closeRef}
type="button"
className="icon-button"
aria-label="关闭分组与标签面板"
onClick={onClose}
>
<Icon name="close" />
</button>
</header>
{error ? (
<p role="alert" className="organization-alert">
{error}
</p>
) : null}
{draft ? (
<form
className="organization-editor"
aria-labelledby="organization-editor-title"
onSubmit={(event) => {
event.preventDefault();
void saveDraft();
}}
>
<h3 id="organization-editor-title">
{`${draft.id ? '编辑' : '新增'}${draft.kind === 'group' ? '分组' : '标签'}`}
</h3>
<label>
<span>{draft.kind === 'group' ? '分组名称' : '标签名称'}</span>
<input
autoFocus
maxLength={100}
value={draft.name}
onChange={(event) => setDraft({ ...draft, name: event.currentTarget.value })}
/>
</label>
{draft.kind === 'group' ? (
<label>
<span></span>
<input
maxLength={500}
value={draft.description}
placeholder="可选,用于说明分组用途"
onChange={(event) =>
setDraft({ ...draft, description: event.currentTarget.value })
}
/>
</label>
) : (
<fieldset className="color-picker">
<legend></legend>
{TAG_COLORS.map((color) => (
<button
key={color || 'default'}
type="button"
className={draft.color === color ? 'is-active' : undefined}
aria-label={COLOR_LABELS[color] ?? color}
aria-pressed={draft.color === color}
onClick={() => setDraft({ ...draft, color })}
>
<span className={`color-swatch color-${color || 'default'}`} />
</button>
))}
</fieldset>
)}
<footer>
<button type="button" disabled={busy} onClick={() => setDraft(null)}>
</button>
<button type="submit" className="primary-action" disabled={busy}>
{busy ? '正在保存...' : '保存'}
</button>
</footer>
</form>
) : null}
<section>
<div className="organization-section-head">
<h3></h3>
<button
type="button"
className="organization-add"
onClick={() => setDraft({ kind: 'group', name: '', description: '', color: '' })}
>
<Icon name="plus" />
</button>
</div>
{loading ? (
<p className="organization-empty"></p>
) : loadFailed ? (
<p className="organization-empty"></p>
) : groups.length === 0 ? (
<p className="organization-empty"></p>
) : (
<ul className="organization-list">
{groups.map((group) => (
<li key={group.id}>
<div className="organization-row-main">
<strong>{group.name}</strong>
<small>
{group.deviceCount}
{group.description ? ` · ${group.description}` : ''}
</small>
</div>
<button
type="button"
className="icon-button"
aria-label={`编辑分组 ${group.name}`}
onClick={() =>
setDraft({
kind: 'group',
id: group.id,
name: group.name,
description: group.description,
color: '',
})
}
>
<Icon name="edit" />
</button>
<button
type="button"
className="icon-button danger"
aria-label={`删除分组 ${group.name}`}
disabled={busy || group.deviceCount > 0}
title={group.deviceCount > 0 ? '分组内仍有设备' : undefined}
onClick={() => void run(() => dataSource.deleteGroup(group.id))}
>
<Icon name="trash" />
</button>
</li>
))}
</ul>
)}
</section>
<section>
<div className="organization-section-head">
<h3></h3>
<button
type="button"
className="organization-add"
onClick={() => setDraft({ kind: 'tag', name: '', description: '', color: '' })}
>
<Icon name="plus" />
</button>
</div>
{loading ? (
<p className="organization-empty"></p>
) : loadFailed ? (
<p className="organization-empty"></p>
) : tags.length === 0 ? (
<p className="organization-empty"></p>
) : (
<ul className="organization-list">
{tags.map((tag) => (
<li key={tag.tag}>
<div className="organization-row-main">
<strong>
<span className={`color-swatch color-${tag.color || 'default'}`} />
{tag.tag}
</strong>
<small>{tag.deviceCount} </small>
</div>
<button
type="button"
className="icon-button"
aria-label={`编辑标签 ${tag.tag}`}
onClick={() =>
setDraft({
kind: 'tag',
id: tag.tag,
name: tag.tag,
description: '',
color: tag.color,
})
}
>
<Icon name="edit" />
</button>
<button
type="button"
className="icon-button danger"
aria-label={`删除标签 ${tag.tag}`}
disabled={busy}
onClick={() => void run(() => dataSource.deleteTag(tag.tag))}
>
<Icon name="trash" />
</button>
</li>
))}
</ul>
)}
</section>
</aside>
</div>
);
}
+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('未上报');
});
});
+410 -16
View File
@@ -17,6 +17,13 @@ import type {
} from './fleet-messages-api-data-source.js';
import { loadFleetMessageSummaries } from './fleet-messages-api-data-source.js';
import { formatPhoneNumbers } from './phone-privacy.js';
import { useSensitiveReveal } from '../privacy/sensitive-reveal.js';
import { FleetOrganizationPanel } from './fleet-organization-panel.js';
import {
createOrganizationApiDataSource,
type OrganizationDataSource,
type OrganizationGroup,
} from './organization-api-data-source.js';
import { createOperationClient, type OperationClient } from '../operations/operation-client.js';
import { safeUiError } from '../ui/locale.js';
import { Icon, type IconName } from '../ui/icon.js';
@@ -31,6 +38,10 @@ export interface FleetDataSource {
export interface FleetPageProps {
readonly dataSource?: FleetDataSource;
readonly messagesDataSource?: FleetMessagesDataSource;
readonly organizationDataSource?: OrganizationDataSource;
/** Groups owned by the shell; the panel mutates them and asks the shell to reload. */
readonly groups?: readonly OrganizationGroup[];
readonly onGroupsChanged?: () => void;
readonly initialData?: FleetSnapshot;
readonly refreshSignal?: number;
readonly operationClient?: OperationClient;
@@ -57,6 +68,7 @@ const SORT_COLUMN_LABELS: Readonly<Record<FleetSortColumn, string>> = {
version: '版本',
capabilities: '能力',
tags: '标签',
group: '分组',
freshness: '数据新鲜度',
anomalies: '异常',
};
@@ -102,8 +114,30 @@ function initials(value: string): string {
const percent = (value: number | undefined): string =>
value === undefined ? '--' : `${value.toFixed(1)}%`;
const registrationLabel = (value: string | undefined): string => {
if (value === 'registered_home' || value === 'registered_roaming') return '已注册';
if (value === 'searching') return '搜网中';
if (value === 'registration_denied') return '受限';
if (value === 'not_registered') return '未注册';
return '状态未知';
};
const booleanStateLabel = (value: boolean | undefined): string =>
value === undefined ? '未知' : value ? '正常' : '断开';
const temperature = (value: number | undefined): string =>
value === undefined ? '--' : `${value.toFixed(1)} °C`;
/** Device uptime in the compact 天/小时/分 shape the Hub device list uses. */
function formatUptime(seconds: number | undefined): string {
if (seconds === undefined) return '--';
const total = Math.max(0, Math.floor(seconds));
const days = Math.floor(total / 86400);
const hours = Math.floor((total % 86400) / 3600);
const minutes = Math.floor((total % 3600) / 60);
if (days > 0) return `${days}${hours}小时`;
if (hours > 0) return `${hours}小时 ${minutes}`;
if (minutes > 0) return `${minutes}`;
return `${total}`;
}
const messageDirection = (value: string): string =>
value === 'incoming' || value === 'received'
? '收到'
@@ -116,6 +150,53 @@ const messageTime = (value: string): string => {
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN');
};
/** Heartbeat age in words; the console polls often enough that seconds stay meaningful. */
function heartbeatAge(value: string, now: number): string {
const timestamp = Date.parse(value);
if (Number.isNaN(timestamp)) return '时间未知';
const seconds = Math.max(0, Math.round((now - timestamp) / 1000));
if (seconds < 60) return `${seconds} 秒前`;
if (seconds < 3600) return `${Math.floor(seconds / 60)} 分钟前`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)} 小时前`;
return `${Math.floor(seconds / 86400)} 天前`;
}
const PRIVATE_NETWORKS = [
/^10\./u,
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./u,
/^127\./u,
/^169\.254\./u,
/^172\.(1[6-9]|2\d|3[01])\./u,
/^192\.168\./u,
/^(?:fe80|fc|fd)/u,
];
/**
* How the control plane reaches the node. Hub labels the same idea 接入方式, and the answer is
* already implied by the origin, so it is derived rather than stored.
*/
export function accessMethod(
value: string,
): Readonly<{ label: string; kind: 'local' | 'lan' | 'wan' }> | null {
const origin = canonicalHttpOrigin(value);
if (!origin) return null;
let host: string | null;
try {
host = new URL(origin).hostname;
} catch {
return null;
}
if (!host) return null;
const bare = host.replace(/^\[(.*)\]$/u, '$1').toLowerCase();
if (bare === 'localhost' || bare === '::1' || /^127\./u.test(bare))
return { label: '本机直连', kind: 'local' };
if (/^\d+\.\d+\.\d+\.\d+$/u.test(bare) || /^[0-9a-f:]+$/u.test(bare))
return PRIVATE_NETWORKS.some((pattern) => pattern.test(bare))
? { label: '局域网接入', kind: 'lan' }
: { label: '公网地址', kind: 'wan' };
return { label: '域名接入', kind: 'wan' };
}
export function canonicalHttpOrigin(value: string): string | null {
try {
const url = new URL(value);
@@ -153,10 +234,14 @@ const SYSTEM_REBOOT = {
export function FleetPage({
dataSource,
messagesDataSource,
organizationDataSource,
groups: controlledGroups,
initialData,
refreshSignal = 0,
operationClient,
onGroupsChanged,
}: FleetPageProps) {
const { revealed: sensitive } = useSensitiveReveal();
const [snapshot, setSnapshot] = useState<FleetSnapshot | null>(initialData ?? null);
const [error, setError] = useState<string | null>(null);
const [attempt, setAttempt] = useState(0);
@@ -166,6 +251,10 @@ export function FleetPage({
const [capability, setCapability] = useState('');
const [version, setVersion] = useState('');
const [tag, setTag] = useState('');
const [group, setGroup] = useState('');
const [ownGroups, setOwnGroups] = useState<readonly OrganizationGroup[]>([]);
const [organizationRevision, setOrganizationRevision] = useState(0);
const [organizationOpen, setOrganizationOpen] = useState(false);
const [page, setPage] = useState(1);
const [sort, setSort] = useState<{ column: FleetSortColumn; direction: SortDirection }>({
column: 'name',
@@ -195,6 +284,32 @@ export function FleetPage({
() => operationClient ?? createOperationClient(),
[operationClient],
);
const resolvedOrganizationDataSource = useMemo(
() => organizationDataSource ?? createOrganizationApiDataSource(),
[organizationDataSource],
);
const groups = controlledGroups ?? ownGroups;
const groupNames = useMemo(() => new Map(groups.map((item) => [item.id, item.name])), [groups]);
useEffect(() => {
// The shell owns the shared group registry; standalone usage loads its own copy.
if (controlledGroups) return;
const controller = new AbortController();
let active = true;
void resolvedOrganizationDataSource
.listGroups(controller.signal)
.then((items) => {
if (!active) return;
setOwnGroups(items);
})
.catch(() => {
/* Group filtering degrades to "all nodes" when the registry is unavailable. */
});
return () => {
active = false;
controller.abort();
};
}, [controlledGroups, resolvedOrganizationDataSource, organizationRevision]);
useEffect(() => {
if (initialData && refreshSignal === 0) {
@@ -291,11 +406,11 @@ export function FleetPage({
}
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
const items = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not(:disabled)'),
event.currentTarget.querySelectorAll<HTMLElement>('[role="menuitem"]:not(:disabled)'),
);
if (!items.length) return;
event.preventDefault();
const currentIndex = items.indexOf(document.activeElement as HTMLButtonElement);
const currentIndex = items.indexOf(document.activeElement as HTMLElement);
const nextIndex =
event.key === 'Home'
? 0
@@ -316,11 +431,26 @@ export function FleetPage({
...(capability ? { capability } : {}),
...(version ? { version } : {}),
...(tag ? { tag } : {}),
...(group ? { group } : {}),
groupNames,
sort,
selectedIds,
page,
}),
[auth, capability, filter, page, query, selectedIds, snapshot, sort, tag, version],
[
auth,
capability,
filter,
group,
groupNames,
page,
query,
selectedIds,
snapshot,
sort,
tag,
version,
],
);
const fleetSummary = useMemo(() => {
@@ -328,13 +458,80 @@ export function FleetPage({
const online = statuses.filter(
(status) => status.reachable && status.authenticated !== false,
).length;
// Mirrors the Hub health rule: a node counts as usable only when the radio chain is up.
const cellular = statuses.filter((status) => {
const resources = status.summary?.resources;
return (
status.reachable &&
status.authenticated !== false &&
resources?.hardwareOnline === true &&
resources?.controlOnline === true &&
resources?.simPresent === true &&
resources?.cellularOnline === true
);
}).length;
return {
total: snapshot?.instances.length ?? 0,
online,
cellular,
attention: (snapshot?.instances.length ?? 0) - online,
};
}, [snapshot]);
const smsUnavailableCount = useMemo(
() => [...messageStates.values()].filter((state) => state.unavailable).length,
[messageStates],
);
const [notificationHealth, setNotificationHealth] = useState<{
pending: number;
failed: number;
} | null>(null);
useEffect(() => {
const controller = new AbortController();
let active = true;
void (async () => {
try {
const response = await fetch('/api/v1/notifications/overview', {
method: 'GET',
credentials: 'same-origin',
headers: { accept: 'application/json' },
signal: controller.signal,
});
if (!response.ok) throw new Error('notification health request failed');
const body = (await response.json()) as unknown;
const source =
body !== null && typeof body === 'object' && !Array.isArray(body)
? (body as Record<string, unknown>)
: undefined;
const wrapped =
source?.data !== null && typeof source?.data === 'object' && !Array.isArray(source?.data)
? (source.data as Record<string, unknown>)
: source;
const queue =
wrapped?.queue !== null && typeof wrapped?.queue === 'object'
? (wrapped.queue as Record<string, unknown>)
: undefined;
const pending =
typeof queue?.pending === 'number' && Number.isSafeInteger(queue.pending)
? queue.pending
: 0;
const failed =
typeof queue?.failed === 'number' && Number.isSafeInteger(queue.failed)
? queue.failed
: 0;
if (active) setNotificationHealth({ pending, failed });
} catch {
if (active && !controller.signal.aborted) setNotificationHealth(null);
}
})();
return () => {
active = false;
controller.abort();
};
}, [snapshot]);
const statusFacetCounts = useMemo(() => {
const counts: Record<FleetFilter, number> = {
all: snapshot?.instances.length ?? 0,
@@ -415,12 +612,27 @@ export function FleetPage({
},
});
}
if (group) {
chips.push({
key: 'group',
label: `分组:${groupNames.get(group) ?? group}`,
onClear: () => {
setGroup('');
setPage(1);
},
});
}
return chips;
}, [auth, capability, filter, query, tag, version]);
}, [auth, capability, filter, group, groupNames, query, tag, version]);
const advancedFilterCount = useMemo(
() => (auth !== 'all' ? 1 : 0) + (capability ? 1 : 0) + (version ? 1 : 0) + (tag ? 1 : 0),
[auth, capability, tag, version],
() =>
(auth !== 'all' ? 1 : 0) +
(capability ? 1 : 0) +
(version ? 1 : 0) +
(tag ? 1 : 0) +
(group ? 1 : 0),
[auth, capability, group, tag, version],
);
useEffect(() => {
@@ -439,6 +651,7 @@ export function FleetPage({
setCapability('');
setVersion('');
setTag('');
setGroup('');
setPage(1);
}
function toggleOne(id: string, checked: boolean): void {
@@ -718,6 +931,13 @@ export function FleetPage({
</span>
<strong>{fleetSummary.online}</strong>
</div>
<div data-tone="cellular">
<span>
<Icon name="wifi" />
</span>
<strong>{fleetSummary.cellular}</strong>
</div>
<div data-tone="attention">
<span>
<Icon name="alert" />
@@ -727,6 +947,34 @@ export function FleetPage({
</div>
</section>
) : null}
<section className="fleet-cross-health" aria-label="跨域健康">
<a href="/fleet/messages" className="fleet-cross-health-item">
<span>
<Icon name="message" />
</span>
<strong data-tone={smsUnavailableCount > 0 ? 'attention' : 'ok'}>
{smsUnavailableCount}
</strong>
</a>
<a href="/fleet/notifications" className="fleet-cross-health-item">
<span>
<Icon name="alert" />
</span>
<strong
data-tone={
notificationHealth && notificationHealth.pending + notificationHealth.failed > 0
? 'attention'
: 'ok'
}
>
{notificationHealth
? notificationHealth.pending + notificationHealth.failed
: '--'}
</strong>
</a>
</section>
{selectionMode ? (
<div className="batch-entry" role="region" aria-label="批量操作入口">
<div className="batch-entry-copy">
@@ -858,6 +1106,16 @@ export function FleetPage({
)}
{selectFilter('版本', version, setVersion, model.facets.versions, '全部版本')}
{selectFilter('标签', tag, setTag, model.facets.tags, '全部标签')}
{groups.length > 0
? selectFilter(
'分组',
group,
setGroup,
groups.map((item) => item.id),
'全部分组',
(choice) => groupNames.get(choice) ?? choice,
)
: null}
</div>
</details>
</div>
@@ -868,23 +1126,32 @@ export function FleetPage({
<div className="fleet-group-tabs" role="group" aria-label="节点分组">
<button
type="button"
className={tag === '' ? 'is-active' : undefined}
aria-pressed={tag === ''}
onClick={() => resetPage(() => setTag(''))}
className={group === '' ? 'is-active' : undefined}
aria-pressed={group === ''}
onClick={() => resetPage(() => setGroup(''))}
>
</button>
{model.facets.tags.map((item) => (
{groups.map((item) => (
<button
type="button"
className={tag === item ? 'is-active' : undefined}
aria-pressed={tag === item}
key={item}
onClick={() => resetPage(() => setTag(item))}
className={group === item.id ? 'is-active' : undefined}
aria-pressed={group === item.id}
key={item.id}
onClick={() => resetPage(() => setGroup(item.id))}
>
{item}
{item.name}
<span className="fleet-group-count">{item.deviceCount}</span>
</button>
))}
<button
type="button"
className="fleet-group-manage"
onClick={() => setOrganizationOpen(true)}
>
<Icon name="tag" />
</button>
</div>
<div className="fleet-results-header">
<div>
@@ -972,7 +1239,8 @@ export function FleetPage({
{model.rows.map((row) => {
const phoneNumbers = row.status?.summary?.resources?.phoneNumbers ?? [];
const latestMessage = messageStates.get(row.id)?.latest;
const phoneNumbersRevealed = revealedPhoneIds.has(row.id);
// The console-wide switch wins; the per-card button still works while it is off.
const phoneNumbersRevealed = sensitive || revealedPhoneIds.has(row.id);
const hasPrivatePhone = phoneNumbers.length > 0 || Boolean(latestMessage);
return (
<Card
@@ -1064,6 +1332,17 @@ export function FleetPage({
aria-label={`${row.displayName} 操作`}
onKeyDown={(event) => moveCardMenuFocus(event, row.id)}
>
<a
role="menuitem"
tabIndex={-1}
className="fleet-card-menu-item"
aria-label={`打开 ${row.displayName} 短信中心`}
href={`/fleet/messages?device=${encodeURIComponent(row.id)}`}
onClick={() => closeCardMenu(row.id, false)}
>
<Icon name="message" />
<span></span>
</a>
<button
type="button"
role="menuitem"
@@ -1101,6 +1380,14 @@ export function FleetPage({
<div className="fleet-card-metadata">
<div className="fleet-card-origin-row">
<Icon name="globe" />
{(() => {
const method = accessMethod(row.instance.url);
return method ? (
<span className="fleet-card-access" data-kind={method.kind}>
{method.label}
</span>
) : null;
})()}
{(() => {
const origin = canonicalHttpOrigin(row.instance.url);
return origin ? (
@@ -1125,9 +1412,39 @@ export function FleetPage({
);
})()}
</div>
{(() => {
const checkedAt = row.status?.checkedAt;
return (
<div
className="fleet-card-heartbeat"
data-stale={
checkedAt && row.statusKind === 'online' ? 'false' : 'true'
}
title={checkedAt ? messageTime(checkedAt) : '心跳尚未到达该节点'}
>
<Icon name="history" />
<span></span>
{checkedAt ? (
<time dateTime={checkedAt}>
{heartbeatAge(checkedAt, Date.now())}
</time>
) : (
<span className="fleet-resource-unavailable"></span>
)}
</div>
);
})()}
{row.instance.description ? (
<p className="fleet-card-description">{row.instance.description}</p>
) : null}
{row.groupId ? (
<div className="fleet-instance-groups" aria-label="实例分组">
<Tag size="small" color="app-teal" variant="soft">
<Icon name="grid" />
{groupNames.get(row.groupId) ?? row.groupId}
</Tag>
</div>
) : null}
{row.anomalies.length > 0 ? (
<p
className="fleet-card-anomalies"
@@ -1183,6 +1500,13 @@ export function FleetPage({
) : null}
</dd>
</div>
<div>
<dt>
<Icon name="power" />
</dt>
<dd>{formatUptime(row.status?.summary?.resources?.uptimeSeconds)}</dd>
</div>
<div>
<dt>
<Icon name="temperature" />
@@ -1193,6 +1517,65 @@ export function FleetPage({
</dd>
</div>
</dl>
{(() => {
const resources = row.status?.summary?.resources;
const links = [
{ label: '硬件', online: resources?.hardwareOnline },
{ label: '控制', online: resources?.controlOnline },
{ label: 'SIM', online: resources?.simPresent },
{ label: '蜂窝', online: resources?.cellularOnline },
];
return (
<>
<section className="fleet-link-rail" aria-label="链路状态">
{links.map((link) => (
<div
key={link.label}
className="fleet-link-state"
data-state={
link.online === undefined
? 'unknown'
: link.online
? 'online'
: 'offline'
}
>
<span className="fleet-link-dot" aria-hidden="true" />
<span>{link.label}</span>
<strong>{booleanStateLabel(link.online)}</strong>
</div>
))}
</section>
<section className="fleet-cellular" aria-label="蜂窝网络">
<div className="fleet-cellular-heading">
<strong>{resources?.carrier ?? '运营商未知'}</strong>
<span>
{[
resources?.accessTechnology ?? '制式未知',
registrationLabel(resources?.cellularRegistration),
].join(' · ')}
</span>
</div>
<div className="fleet-cellular-signal">
{resources?.signalPercent === undefined ? (
<span className="fleet-resource-unavailable">--</span>
) : (
<div className="fleet-resource-meter">
<Progress
percent={resources.signalPercent}
size="small"
showInfo={false}
duration={0}
aria-label="蜂窝信号"
/>
<strong>{percent(resources.signalPercent)}</strong>
</div>
)}
</div>
</section>
</>
);
})()}
<section className="fleet-card-telemetry" aria-label="资源遥测">
<div className="fleet-resource-row">
<span className="fleet-resource-label">
@@ -1318,6 +1701,17 @@ export function FleetPage({
) : null}
</div>
</div>
{organizationOpen ? (
<FleetOrganizationPanel
dataSource={resolvedOrganizationDataSource}
onClose={() => setOrganizationOpen(false)}
onChanged={() => {
setAttempt((value) => value + 1);
setOrganizationRevision((value) => value + 1);
onGroupsChanged?.();
}}
/>
) : null}
</section>
);
}
@@ -48,6 +48,16 @@ describe('fleet table view model', () => {
expect(fleetStatusKind({ reachable: true, authenticated: true })).toBe('online');
});
it('treats a device the heartbeat has never reached as unknown, not offline', () => {
expect(fleetStatusKind({ probed: false, reachable: false, authenticated: false })).toBe(
'unknown',
);
expect(fleetStatusKind({ probed: true, reachable: false, authenticated: false })).toBe(
'offline',
);
expect(fleetStatusKind({ probed: true, reachable: true, authenticated: true })).toBe('online');
});
it('builds stable rows and defaults to ascending display-name order', () => {
const model = buildFleetTableViewModel(instances, statuses);
@@ -197,6 +207,74 @@ describe('fleet table view model', () => {
capabilities: ['sms', 'ussd'],
versions: ['2.4.1'],
tags: ['east', 'west'],
groups: [],
});
});
it('filters, sorts, and searches by device group', () => {
const grouped: readonly FleetInstance[] = [
{ id: 'one', name: 'One', url: 'https://one.example', groupId: 'group-field' },
{ id: 'two', name: 'Two', url: 'https://two.example', groupId: 'group-office' },
{ id: 'three', name: 'Three', url: 'https://three.example', groupId: null },
{ id: 'four', name: 'Four', url: 'https://four.example' },
];
const groupNames = new Map([
['group-field', 'Field Ops'],
['group-office', 'Office'],
]);
const model = buildFleetTableViewModel(grouped, new Map(), { groupNames });
expect(model.facets.groups).toEqual(['group-field', 'group-office']);
expect(model.rows.map((row) => [row.id, row.groupId])).toEqual([
['four', null],
['one', 'group-field'],
['three', null],
['two', 'group-office'],
]);
expect(
buildFleetTableViewModel(grouped, new Map(), {
group: 'group-field',
groupNames,
}).rows.map((row) => row.id),
).toEqual(['one']);
// Group names are searchable, not just the raw identifier.
expect(
buildFleetTableViewModel(grouped, new Map(), { query: 'office', groupNames }).rows.map(
(row) => row.id,
),
).toEqual(['two']);
expect(
buildFleetTableViewModel(grouped, new Map(), {
query: '外场',
groupNames: new Map([['group-field', '外场']]),
}).rows.map((row) => row.id),
).toEqual(['one']);
expect(
buildFleetTableViewModel(grouped, new Map(), {
sort: { column: 'group', direction: 'asc' },
groupNames,
}).rows.map((row) => row.id),
).toEqual(['one', 'two', 'four', 'three']);
// Sorting follows the visible group name, never the opaque identifier.
expect(
buildFleetTableViewModel(grouped, new Map(), {
sort: { column: 'group', direction: 'asc' },
groupNames: new Map([
['group-field', 'Zeta'],
['group-office', 'Alpha'],
]),
}).rows.map((row) => row.id),
).toEqual(['two', 'one', 'four', 'three']);
expect(
buildFleetTableViewModel(grouped, new Map(), {
group: 'group-missing',
groupNames,
}).emptyReason,
).toBe('filter');
});
});
+57 -6
View File
@@ -8,6 +8,7 @@ export type FleetSortColumn =
| 'version'
| 'capabilities'
| 'tags'
| 'group'
| 'freshness'
| 'anomalies';
export type SortDirection = 'asc' | 'desc';
@@ -19,6 +20,8 @@ export interface FleetInstance {
readonly url: string;
readonly description?: string;
readonly tags?: readonly string[];
/** Device group identifier assigned by the organization service; null means unassigned. */
readonly groupId?: string | null;
/** Optional config revision for prepare/execute CAS when available from the control plane. */
readonly revision?: number;
}
@@ -26,6 +29,10 @@ export interface FleetInstance {
export interface FleetStatus {
readonly reachable: boolean;
readonly authenticated?: boolean;
/** False when the heartbeat has never reached this device; the row then reads 未知. */
readonly probed?: boolean;
/** When the heartbeat last checked this device; null means it has never run. */
readonly checkedAt?: string | null;
readonly latencyMs?: number;
readonly summary?: Readonly<
Record<string, unknown> & {
@@ -34,6 +41,16 @@ export interface FleetStatus {
memoryPercent?: number;
maxTemperatureCelsius?: number;
phoneNumbers?: readonly string[];
hardwareOnline?: boolean;
controlOnline?: boolean;
simPresent?: boolean;
carrier?: string;
cellularRegistration?: string;
accessTechnology?: string;
cellularOnline?: boolean;
signalPercent?: number;
/** Whole seconds the device reports it has been running. */
uptimeSeconds?: number;
}>;
}
>;
@@ -46,6 +63,9 @@ export interface FleetTableOptions {
readonly capability?: string;
readonly version?: string;
readonly tag?: string;
readonly group?: string;
/** Group id to display name; used for labels and free-text search. */
readonly groupNames?: ReadonlyMap<string, string>;
readonly sort?: Readonly<{ column: FleetSortColumn; direction: SortDirection }>;
readonly selectedIds?: ReadonlySet<string>;
readonly page?: number;
@@ -65,6 +85,7 @@ export interface FleetTableRow {
readonly version: string | null;
readonly capabilities: readonly string[];
readonly tags: readonly string[];
readonly groupId: string | null;
readonly freshness: FleetFreshness;
readonly anomalies: readonly string[];
}
@@ -91,6 +112,7 @@ export interface FleetTableViewModel {
capabilities: readonly string[];
versions: readonly string[];
tags: readonly string[];
groups: readonly string[];
}>;
}
@@ -103,6 +125,7 @@ const STATUS_RANK: Readonly<Record<FleetStatusKind, number>> = {
export function fleetStatusKind(status: FleetStatus | undefined): FleetStatusKind {
if (status === undefined) return 'unknown';
if (status.probed === false) return 'unknown';
if (!status.reachable) return 'offline';
if (status.authenticated === false) return 'auth';
return 'online';
@@ -134,6 +157,8 @@ function metadata(instance: FleetInstance, status: FleetStatus | undefined) {
version: summaryString(status, 'version'),
capabilities: summaryStrings(status, 'capabilities'),
tags: (instance.tags ?? []).filter((tag) => Boolean(tag.trim())),
groupId:
typeof instance.groupId === 'string' && instance.groupId.trim() ? instance.groupId : null,
freshness,
anomalies: summaryStrings(status, 'anomalies'),
};
@@ -146,13 +171,22 @@ type FleetMetadataSortColumn = Exclude<FleetSortColumn, 'name' | 'status' | 'lat
function metadataSortValue(
metadataValue: FleetMetadata,
column: FleetMetadataSortColumn,
groupNames?: ReadonlyMap<string, string>,
): string | null {
if (column === 'version') return metadataValue.version;
if (column === 'group')
return metadataValue.groupId === null
? null
: (groupNames?.get(metadataValue.groupId) ?? metadataValue.groupId);
if (column === 'freshness') return metadataValue.freshness;
return metadataValue[column].join(', ') || null;
}
function searchableText(instance: FleetInstance, status: FleetStatus | undefined): string {
function searchableText(
instance: FleetInstance,
status: FleetStatus | undefined,
groupName: string,
): string {
const details = metadata(instance, status);
return [
instance.id,
@@ -160,6 +194,8 @@ function searchableText(instance: FleetInstance, status: FleetStatus | undefined
instance.url,
instance.description ?? '',
...details.tags,
details.groupId ?? '',
groupName,
details.version ?? '',
...details.capabilities,
details.freshness,
@@ -185,6 +221,7 @@ function compareInstances(
statuses: ReadonlyMap<string, FleetStatus>,
column: FleetSortColumn,
direction: SortDirection,
groupNames?: ReadonlyMap<string, string>,
): number {
const leftStatus = statuses.get(left.id);
const rightStatus = statuses.get(right.id);
@@ -205,8 +242,8 @@ function compareInstances(
} else comparison = (leftStatus?.latencyMs ?? 0) - (rightStatus?.latencyMs ?? 0);
} else if (column === 'name') comparison = compareNames(left, right);
else {
const leftValue = metadataSortValue(leftMeta, column);
const rightValue = metadataSortValue(rightMeta, column);
const leftValue = metadataSortValue(leftMeta, column, groupNames);
const rightValue = metadataSortValue(rightMeta, column, groupNames);
comparison = compareOptional(leftValue, rightValue);
missingComparison = (leftValue === null) !== (rightValue === null);
}
@@ -243,6 +280,11 @@ export function buildFleetTableViewModel(
const knownIds = new Set(instances.map((instance) => instance.id));
const selectedIds = [...(options.selectedIds ?? [])].filter((id) => knownIds.has(id)).sort();
const selectedIdSet = new Set(selectedIds);
const groupNames = options.groupNames ?? new Map<string, string>();
const groupNameOf = (instance: FleetInstance): string =>
instance.groupId === undefined || instance.groupId === null
? ''
: (groupNames.get(instance.groupId) ?? '');
const filteredInstances = instances
.filter((instance) => {
@@ -253,10 +295,13 @@ export function buildFleetTableViewModel(
if (options.capability && !details.capabilities.includes(options.capability)) return false;
if (options.version && details.version !== options.version) return false;
if (options.tag && !details.tags.includes(options.tag)) return false;
return !query || searchableText(instance, status).includes(query);
if (options.group && details.groupId !== options.group) return false;
return !query || searchableText(instance, status, groupNameOf(instance)).includes(query);
})
.slice()
.sort((left, right) => compareInstances(left, right, statuses, sort.column, sort.direction));
.sort((left, right) =>
compareInstances(left, right, statuses, sort.column, sort.direction, groupNames),
);
const filteredCount = filteredInstances.length;
const pageCount = Math.max(1, Math.ceil(filteredCount / pageSize));
@@ -277,7 +322,8 @@ export function buildFleetTableViewModel(
});
const visibleSelectedCount = rows.reduce((count, row) => count + Number(row.selected), 0);
const hasMetadataFilter =
auth !== 'all' || Boolean(options.capability || options.version || options.tag);
auth !== 'all' ||
Boolean(options.capability || options.version || options.tag || options.group);
const emptyReason: EmptyFleetReason | null =
instances.length === 0
? 'config'
@@ -310,6 +356,11 @@ export function buildFleetTableViewModel(
capabilities: sortedUnique(allMetadata.flatMap((item) => item.capabilities)),
versions: sortedUnique(allMetadata.flatMap((item) => (item.version ? [item.version] : []))),
tags: sortedUnique(allMetadata.flatMap((item) => item.tags)),
groups: sortedUnique(
allMetadata
.flatMap((item) => (item.groupId === null ? [] : [item.groupId ?? '']))
.filter((value) => Boolean(value)),
),
},
};
}
@@ -0,0 +1,78 @@
import {
logQueryParams,
parseLogPage,
parseRuntimeLogPage,
sanitizeConnectionLogEntry,
sanitizeInstanceDiagnostics,
type ConnectionLogEntry,
type InstanceDiagnostics,
type LogCenterDataSource,
type LogPage,
type LogQuery,
type RuntimeLogPage,
} from './log-center-types.js';
const BASE = '/api/v1/logs';
async function requestJson(
fetcher: typeof fetch,
path: string,
init?: RequestInit,
): Promise<unknown> {
const response = await fetcher(path, {
method: 'GET',
credentials: 'same-origin',
headers: { accept: 'application/json' },
...init,
});
if (!response.ok) throw new Error(`请求失败 (${response.status})`);
if (response.status === 204) return undefined;
try {
return (await response.json()) as unknown;
} catch {
return undefined;
}
}
export function createLogCenterApiDataSource(fetcher: typeof fetch = fetch): LogCenterDataSource {
return {
async listRuntime(query: LogQuery, signal?: AbortSignal): Promise<RuntimeLogPage> {
const payload = await requestJson(fetcher, `${BASE}/runtime?${logQueryParams(query)}`, {
...(signal ? { signal } : {}),
});
return parseRuntimeLogPage(payload);
},
async listConnections(
query: LogQuery,
signal?: AbortSignal,
): Promise<LogPage<ConnectionLogEntry>> {
const payload = await requestJson(fetcher, `${BASE}/connections?${logQueryParams(query)}`, {
...(signal ? { signal } : {}),
});
return parseLogPage(payload, sanitizeConnectionLogEntry);
},
async listDiagnostics(signal?: AbortSignal): Promise<readonly InstanceDiagnostics[]> {
const payload = await requestJson(fetcher, `${BASE}/diagnostics`, {
...(signal ? { signal } : {}),
});
const root =
payload !== null && typeof payload === 'object' ? (payload as Record<string, unknown>) : {};
const items = Array.isArray(root.items) ? root.items : [];
return items
.slice(0, 200)
.map(sanitizeInstanceDiagnostics)
.filter((item): item is InstanceDiagnostics => item !== undefined);
},
async pruneConnections(input): Promise<number> {
const payload = await requestJson(fetcher, `${BASE}/connections/prune`, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify(input),
});
const root =
payload !== null && typeof payload === 'object' ? (payload as Record<string, unknown>) : {};
const removed = root.removed;
return typeof removed === 'number' && Number.isFinite(removed) ? Math.max(0, removed) : 0;
},
};
}
+328
View File
@@ -0,0 +1,328 @@
export type LogSource = 'event' | 'audit' | 'schedule' | 'delivery';
export type LogLevel = 'info' | 'warning' | 'error';
export type ConnectionOutcome = 'success' | 'stale' | 'failed' | 'unsupported';
export const LOG_SOURCES: readonly LogSource[] = ['event', 'audit', 'schedule', 'delivery'];
export const LOG_LEVELS: readonly LogLevel[] = ['info', 'warning', 'error'];
export const CONNECTION_OUTCOMES: readonly ConnectionOutcome[] = [
'success',
'stale',
'failed',
'unsupported',
];
export const SOURCE_LABELS: Readonly<Record<LogSource, string>> = {
event: '事件流',
audit: '操作审计',
schedule: '定时任务',
delivery: '通知投递',
};
export const LEVEL_LABELS: Readonly<Record<LogLevel, string>> = {
info: '信息',
warning: '警告',
error: '错误',
};
export const OUTCOME_LABELS: Readonly<Record<ConnectionOutcome, string>> = {
success: '成功',
stale: '需登录',
failed: '失败',
unsupported: '不支持',
};
export interface RuntimeLogEntry {
readonly id: string;
readonly source: LogSource;
readonly level: LogLevel;
readonly occurredAt: string;
readonly instanceId: string | null;
readonly message: string;
readonly code: string | null;
readonly actor: string | null;
readonly durationMs: number | null;
}
export interface ConnectionLogEntry {
readonly id: string;
readonly instanceId: string;
readonly outcome: ConnectionOutcome;
readonly state: string;
readonly errorCode: string | null;
readonly httpStatus: number | null;
readonly durationMs: number;
readonly observedAt: string;
}
export interface ConnectionSummary {
readonly instanceId: string;
readonly total: number;
readonly success: number;
readonly failed: number;
readonly averageDurationMs: number;
readonly lastObservedAt: string | null;
readonly lastErrorCode: string | null;
readonly availabilityPercent: number;
}
export interface InstanceDiagnostics {
readonly instanceId: string;
readonly name: string;
readonly origin: string;
readonly enabled: boolean;
readonly revision: number;
readonly tags: readonly string[];
readonly health: {
readonly category: 'health' | 'connection';
readonly state: string;
readonly observedAt: string;
readonly errorCode: string | null;
readonly httpStatus: number | null;
readonly durationMs: number | null;
readonly version: string | null;
readonly platform: string | null;
readonly status: string | null;
readonly authenticated: boolean | null;
} | null;
readonly snapshots: readonly {
readonly category: string;
readonly state: string;
readonly observedAt: string;
}[];
readonly capabilities: {
readonly total: number;
readonly supported: number;
readonly unsupported: number;
readonly authRequired: number;
readonly degraded: number;
readonly unknown: number;
};
readonly connections: ConnectionSummary | null;
readonly recentFailures: readonly {
readonly occurredAt: string;
readonly code: string | null;
readonly message: string;
}[];
}
export interface LogPage<T> {
readonly items: readonly T[];
readonly page: { readonly page: number; readonly pageSize: number; readonly total: number };
}
export interface RuntimeLogPage extends LogPage<RuntimeLogEntry> {
readonly counts: Readonly<Record<LogSource, number>>;
}
export interface LogQuery {
readonly page: number;
readonly pageSize: number;
readonly source?: LogSource | undefined;
readonly level?: LogLevel | undefined;
readonly instanceId?: string | undefined;
readonly outcome?: ConnectionOutcome | undefined;
readonly search?: string | undefined;
readonly from?: string | undefined;
readonly to?: string | undefined;
}
export interface LogCenterDataSource {
listRuntime(query: LogQuery, signal?: AbortSignal): Promise<RuntimeLogPage>;
listConnections(query: LogQuery, signal?: AbortSignal): Promise<LogPage<ConnectionLogEntry>>;
listDiagnostics(signal?: AbortSignal): Promise<readonly InstanceDiagnostics[]>;
pruneConnections(input: {
readonly before?: string;
readonly instanceId?: string;
}): Promise<number>;
}
function record(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function text(value: unknown, maximum = 512): string {
return typeof value === 'string' ? value.slice(0, maximum) : '';
}
function nullableText(value: unknown, maximum = 512): string | null {
return typeof value === 'string' ? value.slice(0, maximum) : null;
}
function count(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : fallback;
}
function optionalCount(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : null;
}
function flag(value: unknown): boolean {
return value === true;
}
export function sanitizeRuntimeLogEntry(value: unknown): RuntimeLogEntry | undefined {
const item = record(value);
if (!item) return undefined;
const source = text(item.source, 32);
const level = text(item.level, 16);
if (!LOG_SOURCES.includes(source as LogSource) || !LOG_LEVELS.includes(level as LogLevel))
return undefined;
return {
id: text(item.id, 128),
source: source as LogSource,
level: level as LogLevel,
occurredAt: text(item.occurredAt, 64),
instanceId: nullableText(item.instanceId, 128),
message: text(item.message, 2_000),
code: nullableText(item.code, 128),
actor: nullableText(item.actor, 128),
durationMs: optionalCount(item.durationMs),
};
}
export function sanitizeConnectionLogEntry(value: unknown): ConnectionLogEntry | undefined {
const item = record(value);
if (!item) return undefined;
const outcome = text(item.outcome, 32);
if (!CONNECTION_OUTCOMES.includes(outcome as ConnectionOutcome)) return undefined;
return {
id: text(item.id, 128),
instanceId: text(item.instanceId, 128),
outcome: outcome as ConnectionOutcome,
state: text(item.state, 32),
errorCode: nullableText(item.errorCode, 128),
httpStatus: optionalCount(item.httpStatus),
durationMs: count(item.durationMs),
observedAt: text(item.observedAt, 64),
};
}
export function sanitizeConnectionSummary(value: unknown): ConnectionSummary | undefined {
const item = record(value);
if (!item) return undefined;
return {
instanceId: text(item.instanceId, 128),
total: count(item.total),
success: count(item.success),
failed: count(item.failed),
averageDurationMs: count(item.averageDurationMs),
lastObservedAt: nullableText(item.lastObservedAt, 64),
lastErrorCode: nullableText(item.lastErrorCode, 128),
availabilityPercent: Math.min(100, count(item.availabilityPercent)),
};
}
export function sanitizeInstanceDiagnostics(value: unknown): InstanceDiagnostics | undefined {
const item = record(value);
if (!item) return undefined;
const instanceId = text(item.instanceId, 128);
if (instanceId === '') return undefined;
const health = record(item.health);
const capabilities = record(item.capabilities);
const snapshots = Array.isArray(item.snapshots)
? item.snapshots.slice(0, 64).flatMap((entry) => {
const row = record(entry);
if (!row) return [];
return [
{
category: text(row.category, 32),
state: text(row.state, 32),
observedAt: text(row.observedAt, 64),
},
];
})
: [];
const failures = Array.isArray(item.recentFailures)
? item.recentFailures.slice(0, 16).flatMap((entry) => {
const row = record(entry);
if (!row) return [];
return [
{
occurredAt: text(row.occurredAt, 64),
code: nullableText(row.code, 128),
message: text(row.message, 512),
},
];
})
: [];
return {
instanceId,
name: text(item.name, 128) || instanceId,
origin: text(item.origin, 512),
enabled: flag(item.enabled),
revision: count(item.revision, 1),
tags: Array.isArray(item.tags) ? item.tags.slice(0, 32).map((tag) => text(tag, 64)) : [],
health: health
? {
category: text(health.category, 32) === 'connection' ? 'connection' : 'health',
state: text(health.state, 32),
observedAt: text(health.observedAt, 64),
errorCode: nullableText(health.errorCode, 128),
httpStatus: optionalCount(health.httpStatus),
durationMs: optionalCount(health.durationMs),
version: nullableText(health.version, 128),
platform: nullableText(health.platform, 128),
status: nullableText(health.status, 128),
authenticated: typeof health.authenticated === 'boolean' ? health.authenticated : null,
}
: null,
snapshots,
capabilities: {
total: count(capabilities?.total),
supported: count(capabilities?.supported),
unsupported: count(capabilities?.unsupported),
authRequired: count(capabilities?.authRequired),
degraded: count(capabilities?.degraded),
unknown: count(capabilities?.unknown),
},
connections: sanitizeConnectionSummary(item.connections) ?? null,
recentFailures: failures,
};
}
export function parseLogPage<T>(
value: unknown,
parse: (entry: unknown) => T | undefined,
maximum = 200,
): LogPage<T> {
const root = record(value);
const raw = Array.isArray(root?.items) ? root.items : [];
const page = record(root?.page);
return {
items: raw.slice(0, maximum).flatMap((entry) => {
const parsed = parse(entry);
return parsed === undefined ? [] : [parsed];
}),
page: {
page: count(page?.page, 1) || 1,
pageSize: count(page?.pageSize, 50) || 50,
total: count(page?.total),
},
};
}
export function parseRuntimeLogPage(value: unknown): RuntimeLogPage {
const base = parseLogPage(value, sanitizeRuntimeLogEntry);
const root = record(value);
const counts = record(root?.counts);
const parsed = Object.fromEntries(
LOG_SOURCES.map((source) => [source, count(counts?.[source])]),
) as Record<LogSource, number>;
return { ...base, counts: parsed };
}
export function logQueryParams(query: LogQuery): string {
const parameters = new URLSearchParams();
parameters.set('page', String(query.page));
parameters.set('pageSize', String(query.pageSize));
if (query.source) parameters.set('source', query.source);
if (query.level) parameters.set('level', query.level);
if (query.outcome) parameters.set('outcome', query.outcome);
if (query.instanceId) parameters.set('instanceId', query.instanceId);
if (query.search) parameters.set('search', query.search);
if (query.from) parameters.set('from', query.from);
if (query.to) parameters.set('to', query.to);
return parameters.toString();
}
@@ -0,0 +1,217 @@
import { describe, expect, it, vi } from 'vitest';
import {
createNotificationCenterApiDataSource,
NOTIFICATION_PAGE_SIZE,
} from './notification-center-api.js';
import { sanitizeNotificationCenterSnapshot } from './notification-center-sanitize.js';
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
describe('notification center API data source', () => {
it('loads the central overview and keeps queue counters intact', async () => {
const fetcher = vi.fn().mockResolvedValue(
json({
observedAt: '2026-09-03T01:00:00.000Z',
devices: [{ id: 'device-1', name: 'Modem A', state: 'ready', tags: ['office'] }],
config: {
channelCount: 2,
channelEnabled: 1,
ruleCount: 3,
ruleEnabled: 2,
channelTypes: [{ type: 'bark', total: 2, enabled: 1 }],
},
logs: { total: 9, success: 7, failed: 2, recent: [] },
queue: {
total: 5,
pending: 2,
retrying: 1,
sending: 0,
succeeded: 1,
failed: 1,
recent: [],
},
}),
);
const source = createNotificationCenterApiDataSource(fetcher);
const snapshot = await source.loadSnapshot();
expect(fetcher).toHaveBeenCalledWith('/api/v1/notifications/overview', expect.anything());
expect(snapshot.devices).toEqual([
{ id: 'device-1', name: 'Modem A', state: 'ready', tags: ['office'] },
]);
expect(snapshot.queue.pending).toBe(2);
expect(snapshot.queue.total).toBe(5);
expect(snapshot.logs.failed).toBe(2);
});
it('creates channels through POST and updates them through PUT', async () => {
const channel = { id: 'ch-1', name: '值班 Bark', type: 'bark', enabled: true, config: {} };
const fetcher = vi
.fn()
.mockResolvedValueOnce(json(channel, 201))
.mockResolvedValueOnce(json(channel));
const source = createNotificationCenterApiDataSource(fetcher);
await source.saveChannel({
name: '值班 Bark',
type: 'bark',
enabled: true,
config: { key: 'secret-key' },
});
await source.saveChannel({
id: 'ch-1',
name: '值班 Bark',
type: 'bark',
enabled: false,
config: { key: 'secret-key' },
});
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/v1/notifications/channels');
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ method: 'POST' });
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/v1/notifications/channels/ch-1');
expect(fetcher.mock.calls[1]?.[1]).toMatchObject({ method: 'PUT' });
});
it('sends only the scope fields the rule mode needs', async () => {
const fetcher = vi
.fn()
.mockResolvedValue(
json({ id: 'rule-1', name: '余额提醒', eventType: 'sms', enabled: true }, 201),
);
const source = createNotificationCenterApiDataSource(fetcher);
await source.saveRule({
name: '余额提醒',
eventType: 'sms',
enabled: true,
condition: { field: 'content', mode: 'contains', value: '余额' },
scope: { mode: 'tags', tags: ['office'], match: 'all', instanceIds: [] },
channelIds: ['ch-1'],
templates: { title: '余额', body: '{{content}}' },
});
const body = JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body)) as Record<string, unknown>;
expect(body.scope).toEqual({ mode: 'tags', tags: ['office'], match: 'all' });
expect(body.condition).toEqual({ field: 'content', mode: 'contains', value: '余额' });
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ method: 'POST' });
});
it('pages logs and queue with the shared page size', async () => {
const fetcher = vi
.fn()
.mockResolvedValueOnce(json({ items: [], page: { page: 2, pageSize: 20, total: 41 } }))
.mockResolvedValueOnce(json({ items: [], page: { page: 3, pageSize: 20, total: 12 } }));
const source = createNotificationCenterApiDataSource(fetcher);
const logs = await source.listLogs(2);
const queue = await source.listQueue(3, 'failed');
expect(logs.total).toBe(41);
expect(queue.total).toBe(12);
expect(fetcher.mock.calls[0]?.[0]).toBe(
`/api/v1/notifications/logs?page=2&pageSize=${NOTIFICATION_PAGE_SIZE}`,
);
expect(fetcher.mock.calls[1]?.[0]).toBe(
`/api/v1/notifications/queue?page=3&pageSize=${NOTIFICATION_PAGE_SIZE}&status=failed`,
);
});
it('narrows log reads and deletes with the same filter', async () => {
const fetcher = vi
.fn()
.mockResolvedValueOnce(json({ items: [], page: { page: 1, pageSize: 20, total: 0 } }))
.mockResolvedValueOnce(json({ affected: 3 }));
const source = createNotificationCenterApiDataSource(fetcher);
const filter = { status: 'failed', eventType: 'sms', from: '2026-09-01', to: '2026-09-03' };
await source.listLogs(1, filter);
expect(await source.clearLogs(filter)).toBe(3);
expect(fetcher.mock.calls[0]?.[0]).toBe(
'/api/v1/notifications/logs?page=1&pageSize=' +
NOTIFICATION_PAGE_SIZE +
'&status=failed&eventType=sms&from=2026-09-01&to=2026-09-03',
);
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/v1/notifications/logs/clear');
expect(fetcher.mock.calls[1]?.[1]).toMatchObject({
method: 'POST',
body: JSON.stringify(filter),
});
});
it('reads, saves and runs the delivery log retention policy', async () => {
const cleanup = {
retentionDaysEnabled: true,
retentionDays: 90,
maxEntriesEnabled: true,
maxEntries: 5000,
};
const fetcher = vi
.fn()
.mockResolvedValueOnce(json(cleanup))
.mockResolvedValueOnce(json(cleanup))
.mockResolvedValueOnce(json({ affected: 12 }));
const source = createNotificationCenterApiDataSource(fetcher);
expect(await source.getLogCleanup()).toEqual(cleanup);
expect(await source.saveLogCleanup(cleanup)).toEqual(cleanup);
expect(await source.pruneLogs()).toBe(12);
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/v1/notifications/logs/cleanup-settings');
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/v1/notifications/logs/cleanup-settings');
expect(fetcher.mock.calls[1]?.[1]).toMatchObject({
method: 'PUT',
body: JSON.stringify(cleanup),
});
expect(fetcher.mock.calls[2]?.[0]).toBe('/api/v1/notifications/logs/prune');
expect(fetcher.mock.calls[2]?.[1]).toMatchObject({ method: 'POST' });
});
it('falls back to the default retention policy when the payload is malformed', async () => {
const fetcher = vi.fn().mockResolvedValue(json({ retentionDays: 'many', maxEntries: 0 }));
const source = createNotificationCenterApiDataSource(fetcher);
expect(await source.getLogCleanup()).toEqual({
retentionDaysEnabled: true,
retentionDays: 180,
maxEntriesEnabled: false,
maxEntries: 10_000,
});
});
it('reports delivery results and clears the queue by status', async () => {
const fetcher = vi
.fn()
.mockResolvedValueOnce(json({ processed: 3, succeeded: 2, failed: 1 }))
.mockResolvedValueOnce(json({ affected: 4 }));
const source = createNotificationCenterApiDataSource(fetcher);
expect(await source.processQueue()).toEqual({ processed: 3, succeeded: 2, failed: 1 });
expect(await source.clearQueue('succeeded')).toBe(4);
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/v1/notifications/queue?status=succeeded');
expect(fetcher.mock.calls[1]?.[1]).toMatchObject({ method: 'DELETE' });
});
it('surfaces a failed request as an actionable message', async () => {
const fetcher = vi.fn().mockResolvedValue(new Response('', { status: 502 }));
const source = createNotificationCenterApiDataSource(fetcher);
await expect(source.deleteChannel('ch-1')).rejects.toThrow('请求失败 (502)');
});
it('ignores malformed overview payloads instead of trusting them', () => {
expect(sanitizeNotificationCenterSnapshot({ queue: { pending: -3, total: 'many' } })).toEqual(
expect.objectContaining({
queue: expect.objectContaining({ pending: 0, total: 0 }),
}),
);
expect(sanitizeNotificationCenterSnapshot(null)).toBeNull();
});
});
@@ -0,0 +1,290 @@
import {
sanitizeNotificationChannel,
sanitizeNotificationLogEntry,
sanitizeNotificationQueueEntry,
sanitizeNotificationRule,
sanitizeNotificationCenterSnapshot,
} from './notification-center-sanitize.js';
import type {
NotificationChannel,
NotificationChannelDraft,
NotificationCenterDataSource,
NotificationCenterSnapshot,
NotificationLogEntry,
NotificationLogCleanup,
NotificationLogFilter,
NotificationPage,
NotificationQueueEntry,
NotificationRule,
NotificationRuleDraft,
} from './notification-center-types.js';
import { logFilterQuery, sanitizeLogCleanup } from './notification-center-types.js';
const BASE = '/api/v1/notifications';
export const NOTIFICATION_PAGE_SIZE = 20;
interface CallInit {
readonly method: string;
readonly body?: string | undefined;
readonly headers?: Readonly<Record<string, string>> | undefined;
readonly signal?: AbortSignal | undefined;
}
async function requestJson(
fetcher: typeof fetch,
path: string,
init: CallInit = { method: 'GET' },
): Promise<unknown> {
const response = await fetcher(path, {
method: init.method,
...(init.body !== undefined ? { body: init.body } : {}),
...(init.signal !== undefined ? { signal: init.signal } : {}),
credentials: 'same-origin',
headers: { accept: 'application/json', ...(init.headers ?? {}) },
});
if (!response.ok) throw new Error(`请求失败 (${response.status})`);
if (response.status === 204) return undefined;
try {
return (await response.json()) as unknown;
} catch {
return undefined;
}
}
function items(value: unknown): readonly unknown[] {
const raw =
value !== null && typeof value === 'object'
? (value as Record<string, unknown>).items
: undefined;
return Array.isArray(raw) ? raw.slice(0, 500) : [];
}
function total(value: unknown): number {
if (value === null || typeof value !== 'object') return 0;
const page = (value as Record<string, unknown>).page;
if (page === null || typeof page !== 'object') return 0;
const count = (page as Record<string, unknown>).total;
return typeof count === 'number' && Number.isSafeInteger(count) && count >= 0 ? count : 0;
}
function affected(value: unknown): number {
if (value === null || typeof value !== 'object') return 0;
const count = (value as Record<string, unknown>).affected;
return typeof count === 'number' && Number.isSafeInteger(count) && count >= 0 ? count : 0;
}
const jsonHeaders = { 'content-type': 'application/json' };
export function createNotificationCenterApiDataSource(
fetcher: typeof fetch = fetch,
): NotificationCenterDataSource {
return {
async loadSnapshot(signal?: AbortSignal): Promise<NotificationCenterSnapshot> {
const payload = await requestJson(fetcher, `${BASE}/overview`, { method: 'GET', signal });
const snapshot = sanitizeNotificationCenterSnapshot(payload);
if (!snapshot) throw new Error('通知总览响应无效。');
return snapshot;
},
async listChannels(signal?: AbortSignal): Promise<readonly NotificationChannel[]> {
const payload = await requestJson(fetcher, `${BASE}/channels`, { method: 'GET', signal });
return items(payload)
.map(sanitizeNotificationChannel)
.filter((item): item is NotificationChannel => item !== undefined);
},
async saveChannel(draft: NotificationChannelDraft): Promise<NotificationChannel> {
const body = JSON.stringify({
name: draft.name,
type: draft.type,
enabled: draft.enabled,
config: draft.config,
});
const payload = draft.id
? await requestJson(fetcher, `${BASE}/channels/${encodeURIComponent(draft.id)}`, {
method: 'PUT',
body,
headers: jsonHeaders,
})
: await requestJson(fetcher, `${BASE}/channels`, {
method: 'POST',
body,
headers: jsonHeaders,
});
const channel = sanitizeNotificationChannel(payload);
if (!channel) throw new Error('通道响应无效。');
return channel;
},
async deleteChannel(id: string): Promise<void> {
await requestJson(fetcher, `${BASE}/channels/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
},
async testChannel(id: string): Promise<{ readonly ok: boolean; readonly detail?: string }> {
const payload = await requestJson(
fetcher,
`${BASE}/channels/${encodeURIComponent(id)}/test`,
{
method: 'POST',
body: '{}',
headers: jsonHeaders,
},
);
const source =
payload !== null && typeof payload === 'object' ? (payload as Record<string, unknown>) : {};
const detail = typeof source.detail === 'string' ? source.detail.slice(0, 500) : '';
return Object.freeze({ ok: source.ok === true, ...(detail ? { detail } : {}) });
},
async listRules(signal?: AbortSignal): Promise<readonly NotificationRule[]> {
const payload = await requestJson(fetcher, `${BASE}/rules?pageSize=100`, {
method: 'GET',
signal,
});
return items(payload)
.map(sanitizeNotificationRule)
.filter((item): item is NotificationRule => item !== undefined);
},
async saveRule(draft: NotificationRuleDraft): Promise<NotificationRule> {
const body = JSON.stringify({
name: draft.name,
eventType: draft.eventType,
enabled: draft.enabled,
condition: {
field: draft.condition.field,
mode: draft.condition.mode,
...(draft.condition.mode === 'all' ? {} : { value: draft.condition.value }),
},
scope:
draft.scope.mode === 'tags'
? { mode: 'tags', tags: [...draft.scope.tags], match: draft.scope.match }
: draft.scope.mode === 'devices'
? { mode: 'devices', instanceIds: [...draft.scope.instanceIds] }
: { mode: 'all' },
channelIds: [...draft.channelIds],
templates: { title: draft.templates.title, body: draft.templates.body },
...(draft.rateLimit === undefined ? {} : { rateLimit: draft.rateLimit }),
...(draft.quietHours === undefined ? {} : { quietHours: [...draft.quietHours] }),
});
const payload = draft.id
? await requestJson(fetcher, `${BASE}/rules/${encodeURIComponent(draft.id)}`, {
method: 'PATCH',
body,
headers: jsonHeaders,
})
: await requestJson(fetcher, `${BASE}/rules`, {
method: 'POST',
body,
headers: jsonHeaders,
});
const rule = sanitizeNotificationRule(payload);
if (!rule) throw new Error('规则响应无效。');
return rule;
},
async deleteRule(id: string): Promise<void> {
await requestJson(fetcher, `${BASE}/rules/${encodeURIComponent(id)}`, { method: 'DELETE' });
},
async listLogs(
page: number,
filter?: NotificationLogFilter,
signal?: AbortSignal,
): Promise<NotificationPage<NotificationLogEntry>> {
const payload = await requestJson(
fetcher,
`${BASE}/logs?page=${page}&pageSize=${NOTIFICATION_PAGE_SIZE}${logFilterQuery(filter)}`,
{ method: 'GET', signal },
);
return Object.freeze({
items: Object.freeze(
items(payload)
.map(sanitizeNotificationLogEntry)
.filter((item): item is NotificationLogEntry => item !== undefined),
),
total: total(payload),
});
},
async clearLogs(filter?: NotificationLogFilter): Promise<number> {
return affected(
await requestJson(fetcher, `${BASE}/logs/clear`, {
method: 'POST',
body: JSON.stringify(filter ?? {}),
headers: jsonHeaders,
}),
);
},
async getLogCleanup(signal?: AbortSignal): Promise<NotificationLogCleanup> {
const payload = await requestJson(fetcher, `${BASE}/logs/cleanup-settings`, {
method: 'GET',
signal,
});
return sanitizeLogCleanup(payload);
},
async saveLogCleanup(cleanup: NotificationLogCleanup): Promise<NotificationLogCleanup> {
const payload = await requestJson(fetcher, `${BASE}/logs/cleanup-settings`, {
method: 'PUT',
body: JSON.stringify({
retentionDaysEnabled: cleanup.retentionDaysEnabled,
retentionDays: cleanup.retentionDays,
maxEntriesEnabled: cleanup.maxEntriesEnabled,
maxEntries: cleanup.maxEntries,
}),
headers: jsonHeaders,
});
return sanitizeLogCleanup(payload);
},
async pruneLogs(): Promise<number> {
return affected(
await requestJson(fetcher, `${BASE}/logs/prune`, {
method: 'POST',
body: '{}',
headers: jsonHeaders,
}),
);
},
async listQueue(
page: number,
status?: string,
signal?: AbortSignal,
): Promise<NotificationPage<NotificationQueueEntry>> {
const query = status ? `&status=${encodeURIComponent(status)}` : '';
const payload = await requestJson(
fetcher,
`${BASE}/queue?page=${page}&pageSize=${NOTIFICATION_PAGE_SIZE}${query}`,
{ method: 'GET', signal },
);
return Object.freeze({
items: Object.freeze(
items(payload)
.map(sanitizeNotificationQueueEntry)
.filter((item): item is NotificationQueueEntry => item !== undefined),
),
total: total(payload),
});
},
async retryQueueItem(id: string): Promise<void> {
await requestJson(fetcher, `${BASE}/queue/${encodeURIComponent(id)}/retry`, {
method: 'POST',
});
},
async deleteQueueItem(id: string): Promise<void> {
await requestJson(fetcher, `${BASE}/queue/${encodeURIComponent(id)}`, { method: 'DELETE' });
},
async clearQueue(status?: string): Promise<number> {
const query = status ? `?status=${encodeURIComponent(status)}` : '';
return affected(await requestJson(fetcher, `${BASE}/queue${query}`, { method: 'DELETE' }));
},
async processQueue(): Promise<{
readonly processed: number;
readonly succeeded: number;
readonly failed: number;
}> {
const payload = await requestJson(fetcher, `${BASE}/queue/process`, { method: 'POST' });
const source =
payload !== null && typeof payload === 'object' ? (payload as Record<string, unknown>) : {};
const number = (value: unknown): number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0;
return Object.freeze({
processed: number(source.processed),
succeeded: number(source.succeeded),
failed: number(source.failed),
});
},
};
}
@@ -0,0 +1,287 @@
import type {
NotificationCenterSnapshot,
NotificationChannelConfigValue,
NotificationChannel,
NotificationChannelType,
NotificationDevice,
NotificationLogEntry,
NotificationQuietWindow,
NotificationQueueEntry,
NotificationRateLimit,
NotificationRule,
} from './notification-center-types.js';
import {
CLOCK_TIME_PATTERN,
DEFAULT_RATE_LIMIT,
MAXIMUM_QUIET_WINDOWS,
RATE_LIMIT_LIMITS,
channelSecretKeys,
} from './notification-center-types.js';
const MAX_TEXT = 4_000;
function record(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function text(value: unknown, maximum = MAX_TEXT): string {
return typeof value === 'string' ? value.slice(0, maximum) : '';
}
function count(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : fallback;
}
function stringList(value: unknown): string[] {
return Array.isArray(value)
? value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.slice(0, 256))
.slice(0, 200)
: [];
}
function bounded(value: unknown, fallback: number, minimum: number, maximum: number): number {
return typeof value === 'number' &&
Number.isSafeInteger(value) &&
value >= minimum &&
value <= maximum
? value
: fallback;
}
function sanitizeRateLimit(value: unknown): NotificationRateLimit {
const item = record(value);
return Object.freeze({
enabled: item?.enabled === true,
maxMessages: bounded(
item?.maxMessages,
DEFAULT_RATE_LIMIT.maxMessages,
RATE_LIMIT_LIMITS.minimumMessages,
RATE_LIMIT_LIMITS.maximumMessages,
),
windowSeconds: bounded(
item?.windowSeconds,
DEFAULT_RATE_LIMIT.windowSeconds,
RATE_LIMIT_LIMITS.minimumWindowSeconds,
RATE_LIMIT_LIMITS.maximumWindowSeconds,
),
});
}
function sanitizeQuietHours(value: unknown): readonly NotificationQuietWindow[] {
if (!Array.isArray(value)) return Object.freeze([]);
return Object.freeze(
value
.map(record)
.filter((entry): entry is Record<string, unknown> => Boolean(entry))
.map((entry) => Object.freeze({ start: text(entry.start, 5), end: text(entry.end, 5) }))
.filter(
(window) =>
CLOCK_TIME_PATTERN.test(window.start) &&
CLOCK_TIME_PATTERN.test(window.end) &&
window.start !== window.end,
)
.slice(0, MAXIMUM_QUIET_WINDOWS),
);
}
function sanitizeConfig(value: unknown): NotificationChannel['config'] {
const source = record(value);
if (!source) return Object.freeze({});
const output: Record<string, NotificationChannelConfigValue> = {};
for (const [key, raw] of Object.entries(source).slice(0, 40)) {
if (typeof raw === 'string') output[key.slice(0, 60)] = raw.slice(0, MAX_TEXT);
else if (typeof raw === 'number' || typeof raw === 'boolean') output[key.slice(0, 60)] = raw;
}
return Object.freeze(output);
}
function secretFieldList(value: unknown, type: NotificationChannelType): readonly string[] {
if (Array.isArray(value)) {
const keys = value
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.slice(0, 60))
.slice(0, 12);
if (keys.length > 0) return Object.freeze(keys);
}
// Older control planes report only "hasSecret"; assume the first declared secret field.
return Object.freeze(channelSecretKeys(type).slice(0, 1));
}
export function sanitizeNotificationChannel(value: unknown): NotificationChannel | undefined {
const item = record(value);
const id = text(item?.id, 256);
if (!item || !id) return undefined;
const type = (text(item.type, 40) || 'webhook') as NotificationChannelType;
return Object.freeze({
id,
name: text(item.name, 160) || '未命名通道',
type,
enabled: item.enabled === true,
config: sanitizeConfig(item.config),
hasSecret: item.hasSecret === true,
secretFields: secretFieldList(item.secretFields, type),
});
}
export function sanitizeNotificationRule(value: unknown): NotificationRule | undefined {
const item = record(value);
const id = text(item?.id, 256);
if (!item || !id) return undefined;
const condition = record(item.condition);
const scope = record(item.scope);
const templates = record(item.templates);
const channels = Array.isArray(item.channels)
? item.channels
.map(record)
.filter((entry): entry is Record<string, unknown> => Boolean(entry?.id))
.slice(0, 50)
.map((entry) => Object.freeze({ id: text(entry.id, 256), name: text(entry.name, 160) }))
: [];
return Object.freeze({
id,
name: text(item.name, 160) || '未命名规则',
eventType: text(item.eventType, 40) || 'sms',
enabled: item.enabled === true,
condition: Object.freeze({
field: text(condition?.field, 40) || 'content',
mode: text(condition?.mode, 20) || 'all',
value: text(condition?.value, 500),
}),
scope: Object.freeze({
mode: text(scope?.mode, 20) || 'all',
tags: Object.freeze(stringList(scope?.tags)),
match: text(scope?.match, 20) || 'any',
instanceIds: Object.freeze(stringList(scope?.instanceIds)),
}),
channels: Object.freeze(channels),
templates: Object.freeze({
title: text(templates?.title, 300),
body: text(templates?.body, 3_000),
}),
rateLimit: sanitizeRateLimit(item.rateLimit),
quietHours: sanitizeQuietHours(item.quietHours),
});
}
export function sanitizeNotificationLogEntry(value: unknown): NotificationLogEntry | undefined {
const item = record(value);
const id = text(item?.id, 256);
if (!item || !id) return undefined;
const ruleName = text(item.ruleName, 160);
const detail = text(item.detail, 500);
return Object.freeze({
id,
eventType: text(item.eventType, 40) || 'system',
status: text(item.status, 40) || 'pending',
channelName: text(item.channelName, 160) || '未知通道',
createdAt: text(item.createdAt, 80),
...(ruleName ? { ruleName } : {}),
...(detail ? { detail } : {}),
});
}
export function sanitizeNotificationQueueEntry(value: unknown): NotificationQueueEntry | undefined {
const item = record(value);
const id = text(item?.id, 256);
if (!item || !id) return undefined;
const ruleName = text(item.ruleName, 160);
const channelName = text(item.channelName, 160);
const instanceId = text(item.instanceId, 256);
const status = text(item.status, 40) || 'pending';
return Object.freeze({
id,
eventType: text(item.eventType, 40) || 'system',
status,
state: text(item.state, 40) || status,
title: text(item.title, 300) || '通知',
attempts: count(item.attempts),
maxAttempts: count(item.maxAttempts, 3),
createdAt: text(item.createdAt, 80),
...(ruleName ? { ruleName } : {}),
...(channelName ? { channelName } : {}),
...(instanceId ? { instanceId } : {}),
});
}
function deviceList(value: unknown): readonly NotificationDevice[] {
if (!Array.isArray(value)) return Object.freeze([]);
return Object.freeze(
value
.map(record)
.filter((entry): entry is Record<string, unknown> => Boolean(entry?.id))
.slice(0, 500)
.map((entry) =>
Object.freeze({
id: text(entry.id, 256),
name: text(entry.name, 160) || '未命名设备',
state: text(entry.state, 40) || 'unavailable',
tags: Object.freeze(stringList(entry.tags)),
}),
),
);
}
export function sanitizeNotificationCenterSnapshot(
value: unknown,
): NotificationCenterSnapshot | null {
const source = record(record(value)?.data ?? value);
if (!source) return null;
const config = record(source.config);
const logs = record(source.logs);
const queue = record(source.queue);
const channelTypes = Array.isArray(config?.channelTypes)
? Object.freeze(
config.channelTypes
.map(record)
.filter((entry): entry is Record<string, unknown> => Boolean(entry?.type))
.slice(0, 80)
.map((entry) =>
Object.freeze({
type: text(entry.type, 40),
total: count(entry.total),
enabled: count(entry.enabled),
}),
),
)
: Object.freeze([]);
const logRecent = Array.isArray(logs?.recent)
? logs.recent
.map(sanitizeNotificationLogEntry)
.filter((entry): entry is NotificationLogEntry => entry !== undefined)
: [];
const queueRecent = Array.isArray(queue?.recent)
? queue.recent
.map(sanitizeNotificationQueueEntry)
.filter((entry): entry is NotificationQueueEntry => entry !== undefined)
: [];
return Object.freeze({
observedAt: text(source.observedAt, 80),
devices: deviceList(source.devices),
config: Object.freeze({
channelCount: count(config?.channelCount),
channelEnabled: count(config?.channelEnabled),
ruleCount: count(config?.ruleCount),
ruleEnabled: count(config?.ruleEnabled),
channelTypes,
}),
logs: Object.freeze({
total: count(logs?.total),
success: count(logs?.success),
failed: count(logs?.failed),
suppressed: count(logs?.suppressed),
recent: Object.freeze(logRecent),
}),
queue: Object.freeze({
total: count(queue?.total),
pending: count(queue?.pending),
retrying: count(queue?.retrying),
sending: count(queue?.sending),
succeeded: count(queue?.succeeded),
failed: count(queue?.failed),
recent: Object.freeze(queueRecent),
}),
});
}
@@ -0,0 +1,418 @@
import {
NOTIFICATION_CHANNEL_SPECS,
NOTIFICATION_EVENT_TYPES,
notificationChannelDefaults,
notificationChannelFields,
notificationChannelLabel,
notificationChannelSecretKeys,
notificationChannelSpec,
type NotificationChannelConfigValue,
type NotificationChannelFieldSpec,
type NotificationChannelType,
type NotificationChannelTypeSpec,
} from '@multi-simadmin/contracts';
export type {
NotificationChannelConfigValue,
NotificationChannelFieldSpec,
NotificationChannelType,
NotificationChannelTypeSpec,
} from '@multi-simadmin/contracts';
export type NotificationEventType = (typeof NOTIFICATION_EVENT_TYPES)[number];
export interface NotificationChannel {
readonly id: string;
readonly name: string;
readonly type: NotificationChannelType;
readonly enabled: boolean;
readonly config: Readonly<Record<string, NotificationChannelConfigValue>>;
readonly hasSecret: boolean;
/** Secret keys whose values are already held in the local secret store. */
readonly secretFields: readonly string[];
}
export interface NotificationRule {
readonly id: string;
readonly name: string;
readonly eventType: string;
readonly enabled: boolean;
readonly condition: { readonly field: string; readonly mode: string; readonly value: string };
readonly scope: {
readonly mode: string;
readonly tags: readonly string[];
readonly match: string;
readonly instanceIds: readonly string[];
};
readonly channels: readonly { readonly id: string; readonly name: string }[];
readonly templates: { readonly title: string; readonly body: string };
readonly rateLimit: NotificationRateLimit;
readonly quietHours: readonly NotificationQuietWindow[];
}
/** Hub-style per-rule throttle, mirrored from the control plane. */
export interface NotificationRateLimit {
readonly enabled: boolean;
readonly maxMessages: number;
readonly windowSeconds: number;
}
/** A do-not-disturb window on the Asia/Shanghai wall clock; `end` may wrap past midnight. */
export interface NotificationQuietWindow {
readonly start: string;
readonly end: string;
}
export interface NotificationLogEntry {
readonly id: string;
readonly eventType: string;
readonly status: string;
readonly channelName: string;
readonly createdAt: string;
readonly ruleName?: string;
readonly detail?: string;
}
export interface NotificationQueueEntry {
readonly id: string;
readonly eventType: string;
readonly status: string;
readonly state: string;
readonly title: string;
readonly attempts: number;
readonly maxAttempts: number;
readonly createdAt: string;
readonly ruleName?: string;
readonly channelName?: string;
readonly instanceId?: string;
}
export interface NotificationDevice {
readonly id: string;
readonly name: string;
readonly state: string;
readonly tags: readonly string[];
}
export interface NotificationCenterSnapshot {
readonly observedAt: string;
readonly devices: readonly NotificationDevice[];
readonly config: {
readonly channelCount: number;
readonly channelEnabled: number;
readonly ruleCount: number;
readonly ruleEnabled: number;
readonly channelTypes: readonly {
readonly type: string;
readonly total: number;
readonly enabled: number;
}[];
};
readonly logs: {
readonly total: number;
readonly success: number;
readonly failed: number;
readonly suppressed: number;
readonly recent: readonly NotificationLogEntry[];
};
readonly queue: {
readonly total: number;
readonly pending: number;
readonly retrying: number;
readonly sending: number;
readonly succeeded: number;
readonly failed: number;
readonly recent: readonly NotificationQueueEntry[];
};
}
export interface NotificationChannelDraft {
readonly id?: string;
readonly name: string;
readonly type: NotificationChannelType;
readonly enabled: boolean;
readonly config: Readonly<Record<string, NotificationChannelConfigValue>>;
}
export interface NotificationRuleDraft {
readonly id?: string;
readonly name: string;
readonly eventType: string;
readonly enabled: boolean;
readonly condition: { readonly field: string; readonly mode: string; readonly value: string };
readonly scope: {
readonly mode: string;
readonly tags: readonly string[];
readonly match: string;
readonly instanceIds: readonly string[];
};
readonly channelIds: readonly string[];
readonly templates: { readonly title: string; readonly body: string };
/** Optional because the PATCH endpoint treats an absent field as "leave it alone". */
readonly rateLimit?: NotificationRateLimit;
readonly quietHours?: readonly NotificationQuietWindow[];
}
export interface NotificationPage<T> {
readonly items: readonly T[];
readonly total: number;
}
/** Optional predicates for reading and deleting delivery logs. */
export interface NotificationLogFilter {
readonly status?: string;
readonly eventType?: string;
readonly from?: string;
readonly to?: string;
}
/** Retention policy for the delivery log, mirrored from the Hub settings. */
export interface NotificationLogCleanup {
readonly retentionDaysEnabled: boolean;
readonly retentionDays: number;
readonly maxEntriesEnabled: boolean;
readonly maxEntries: number;
}
export const DEFAULT_LOG_CLEANUP: NotificationLogCleanup = Object.freeze({
retentionDaysEnabled: true,
retentionDays: 180,
maxEntriesEnabled: false,
maxEntries: 10_000,
});
export const LOG_CLEANUP_LIMITS = Object.freeze({
minimumDays: 1,
maximumDays: 36_500,
minimumEntries: 1,
maximumEntries: 1_000_000,
});
export const DEFAULT_RATE_LIMIT: NotificationRateLimit = Object.freeze({
enabled: false,
maxMessages: 20,
windowSeconds: 60,
});
export const RATE_LIMIT_LIMITS = Object.freeze({
minimumMessages: 1,
maximumMessages: 10_000,
minimumWindowSeconds: 1,
maximumWindowSeconds: 86_400,
});
export const MAXIMUM_QUIET_WINDOWS = 8;
/** Hub's default do-not-disturb window, used when a new row is added in the editor. */
export const DEFAULT_QUIET_WINDOW: NotificationQuietWindow = Object.freeze({
start: '22:00',
end: '08:00',
});
/** 24-hour `HH:MM` wall-clock text, the same shape the Hub uses for quiet hours. */
export const CLOCK_TIME_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/u;
export function isClockTime(value: string): boolean {
return CLOCK_TIME_PATTERN.test(value);
}
export function sanitizeLogCleanup(value: unknown): NotificationLogCleanup {
const item =
value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const bounded = (raw: unknown, fallback: number, minimum: number, maximum: number): number =>
typeof raw === 'number' && Number.isSafeInteger(raw) && raw >= minimum && raw <= maximum
? raw
: fallback;
return Object.freeze({
retentionDaysEnabled:
typeof item.retentionDaysEnabled === 'boolean'
? item.retentionDaysEnabled
: DEFAULT_LOG_CLEANUP.retentionDaysEnabled,
retentionDays: bounded(
item.retentionDays,
DEFAULT_LOG_CLEANUP.retentionDays,
LOG_CLEANUP_LIMITS.minimumDays,
LOG_CLEANUP_LIMITS.maximumDays,
),
maxEntriesEnabled:
typeof item.maxEntriesEnabled === 'boolean'
? item.maxEntriesEnabled
: DEFAULT_LOG_CLEANUP.maxEntriesEnabled,
maxEntries: bounded(
item.maxEntries,
DEFAULT_LOG_CLEANUP.maxEntries,
LOG_CLEANUP_LIMITS.minimumEntries,
LOG_CLEANUP_LIMITS.maximumEntries,
),
});
}
export function logFilterQuery(filter?: NotificationLogFilter): string {
if (!filter) return '';
const parameters = new URLSearchParams();
for (const key of ['status', 'eventType', 'from', 'to'] as const) {
const value = filter[key];
if (typeof value === 'string' && value.trim() !== '') parameters.set(key, value.trim());
}
return parameters.size > 0 ? `&${parameters.toString()}` : '';
}
export interface NotificationCenterDataSource {
loadSnapshot(signal?: AbortSignal): Promise<NotificationCenterSnapshot>;
listChannels(signal?: AbortSignal): Promise<readonly NotificationChannel[]>;
saveChannel(draft: NotificationChannelDraft): Promise<NotificationChannel>;
deleteChannel(id: string): Promise<void>;
testChannel(id: string): Promise<{ readonly ok: boolean; readonly detail?: string }>;
listRules(signal?: AbortSignal): Promise<readonly NotificationRule[]>;
saveRule(draft: NotificationRuleDraft): Promise<NotificationRule>;
deleteRule(id: string): Promise<void>;
listLogs(
page: number,
filter?: NotificationLogFilter,
signal?: AbortSignal,
): Promise<NotificationPage<NotificationLogEntry>>;
clearLogs(filter?: NotificationLogFilter): Promise<number>;
getLogCleanup(signal?: AbortSignal): Promise<NotificationLogCleanup>;
saveLogCleanup(cleanup: NotificationLogCleanup): Promise<NotificationLogCleanup>;
pruneLogs(): Promise<number>;
listQueue(
page: number,
status?: string,
signal?: AbortSignal,
): Promise<NotificationPage<NotificationQueueEntry>>;
retryQueueItem(id: string): Promise<void>;
deleteQueueItem(id: string): Promise<void>;
clearQueue(status?: string): Promise<number>;
processQueue(): Promise<{
readonly processed: number;
readonly succeeded: number;
readonly failed: number;
}>;
}
/** Channel type catalogue derived from the shared Hub-compatible field specs. */
export const CHANNEL_TYPE_PRESETS: readonly NotificationChannelTypeSpec[] =
NOTIFICATION_CHANNEL_SPECS;
/** Editor value for one channel field, before it is split into config and secrets. */
export type ChannelFieldValue = string | number | boolean;
/** Initial editor values: saved config first, then the spec defaults. */
export function channelFieldValues(
type: NotificationChannelType,
config?: Readonly<Record<string, NotificationChannelConfigValue>>,
): Record<string, ChannelFieldValue> {
const values: Record<string, ChannelFieldValue> = { ...notificationChannelDefaults(type) };
for (const field of notificationChannelFields(type)) {
const saved = config?.[field.key];
if (saved === undefined) continue;
values[field.key] = saved;
}
return values;
}
/** The field the channel list row shows as its primary endpoint text. */
export function channelPrimaryField(
type: NotificationChannelType,
): NotificationChannelFieldSpec | undefined {
const fields = notificationChannelFields(type);
return (
fields.find((field) => field.kind === 'url') ??
fields.find((field) => !field.required) ??
fields[0]
);
}
export function channelSecretKeys(type: NotificationChannelType): readonly string[] {
return notificationChannelSecretKeys(type);
}
export { notificationChannelFields };
export function channelSpec(type: string): NotificationChannelTypeSpec | undefined {
return notificationChannelSpec(type);
}
export const EVENT_TYPE_PRESETS: readonly {
readonly value: NotificationEventType;
readonly label: string;
}[] = [
{ value: 'sms', label: '短信' },
{ value: 'ddns', label: 'DDNS' },
{ value: 'version', label: '版本' },
{ value: 'system', label: '系统' },
{ value: 'device', label: '设备' },
{ value: 'automation', label: '自动化' },
];
export const CONDITION_FIELDS: readonly { readonly value: string; readonly label: string }[] = [
{ value: 'content', label: '内容' },
{ value: 'sender', label: '发件人' },
{ value: 'title', label: '标题' },
{ value: 'status', label: '状态' },
];
export const CONDITION_MODES: readonly { readonly value: string; readonly label: string }[] = [
{ value: 'all', label: '全部命中' },
{ value: 'contains', label: '包含' },
{ value: 'equals', label: '等于' },
{ value: 'regex', label: '正则' },
];
export const QUEUE_STATUSES: readonly { readonly value: string; readonly label: string }[] = [
{ value: '', label: '全部' },
{ value: 'pending', label: '等待' },
{ value: 'sending', label: '发送中' },
{ value: 'succeeded', label: '成功' },
{ value: 'failed', label: '失败' },
{ value: 'cancelled', label: '取消' },
];
export const STATUS_LABELS: Readonly<Record<string, string>> = {
success: '成功',
failed: '失败',
pending: '等待',
sending: '发送中',
retrying: '重试中',
succeeded: '成功',
cancelled: '已取消',
unmatched: '未匹配',
no_available_channel: '无可用通道',
quiet_hours: '免打扰',
};
export const DEVICE_STATE_LABELS: Readonly<Record<string, string>> = {
ready: '在线',
unavailable: '离线',
failed: '异常',
};
export function statusLabel(status: string): string {
return STATUS_LABELS[status] ?? status;
}
export function statusTone(status: string): 'ok' | 'warn' | 'attention' | 'neutral' {
if (status === 'success' || status === 'succeeded') return 'ok';
if (status === 'sending' || status === 'pending' || status === 'retrying') return 'warn';
if (status === 'failed') return 'attention';
return 'neutral';
}
export function channelTypeLabel(type: string): string {
return notificationChannelLabel(type);
}
export function eventTypeLabel(eventType: string): string {
return EVENT_TYPE_PRESETS.find((item) => item.value === eventType)?.label ?? eventType;
}
export function formatTimestamp(value: string): string {
const date = new Date(value);
if (!Number.isFinite(date.getTime())) return value;
const pad = (part: number): string => part.toString().padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
date.getHours(),
)}:${pad(date.getMinutes())}`;
}
@@ -0,0 +1,159 @@
import { describe, expect, it, vi } from 'vitest';
import { createOrganizationApiDataSource } from './organization-api-data-source.js';
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
function noContent(): Response {
return new Response(null, { status: 204 });
}
describe('organization API data source', () => {
it('reads groups and drops malformed rows', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
json({
items: [
{ id: 'g1', name: '核心', description: '机房核心节点', deviceCount: 3 },
{ id: ' ', name: 'blank id' },
{ id: 'g2', name: '' },
{ id: 'g3', name: '外场' },
],
}),
);
const groups = await createOrganizationApiDataSource(fetcher).listGroups();
expect(fetcher).toHaveBeenCalledWith('/api/v1/groups', {
credentials: 'same-origin',
headers: { accept: 'application/json' },
});
expect(groups).toEqual([
{ id: 'g1', name: '核心', description: '机房核心节点', deviceCount: 3 },
{ id: 'g3', name: '外场', description: '', deviceCount: 0 },
]);
});
it('creates, updates, and deletes a group through the REST surface', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(json({ id: 'g9', name: '仓库', deviceCount: 0 }))
.mockResolvedValueOnce(json({ id: 'g9', name: '华东仓库', deviceCount: 0 }))
.mockResolvedValueOnce(noContent());
const source = createOrganizationApiDataSource(fetcher);
await expect(source.createGroup({ name: '仓库', description: '临时' })).resolves.toEqual({
id: 'g9',
name: '仓库',
description: '',
deviceCount: 0,
});
expect(fetcher).toHaveBeenNthCalledWith(1, '/api/v1/groups', {
method: 'POST',
credentials: 'same-origin',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify({ name: '仓库', description: '临时' }),
});
await expect(source.updateGroup('g9', { name: '华东仓库' })).resolves.toMatchObject({
name: '华东仓库',
});
expect(fetcher).toHaveBeenNthCalledWith(2, '/api/v1/groups/g9', {
method: 'PUT',
credentials: 'same-origin',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify({ name: '华东仓库' }),
});
await source.deleteGroup('g/9');
expect(fetcher).toHaveBeenNthCalledWith(3, '/api/v1/groups/g%2F9', {
method: 'DELETE',
credentials: 'same-origin',
headers: { accept: 'application/json' },
});
});
it('reads and writes tag colors', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(json({ items: [{ tag: 'east', color: 'app-teal', deviceCount: 2 }] }))
.mockResolvedValueOnce(json({ tag: 'new', color: 'app-yellow' }))
.mockResolvedValueOnce(json({ tag: 'east', color: 'app-red', deviceCount: 2 }))
.mockResolvedValueOnce(noContent());
const source = createOrganizationApiDataSource(fetcher);
await expect(source.listTags()).resolves.toEqual([
{ tag: 'east', color: 'app-teal', deviceCount: 2 },
]);
await expect(source.createTag({ tag: 'new' })).resolves.toEqual({
tag: 'new',
color: 'app-yellow',
deviceCount: 0,
});
await expect(source.updateTag('east', { color: 'app-red' })).resolves.toMatchObject({
color: 'app-red',
});
expect(fetcher).toHaveBeenNthCalledWith(3, '/api/v1/tags/east', {
method: 'PUT',
credentials: 'same-origin',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify({ color: 'app-red' }),
});
await source.deleteTag('east');
expect(fetcher).toHaveBeenNthCalledWith(4, '/api/v1/tags/east', {
method: 'DELETE',
credentials: 'same-origin',
headers: { accept: 'application/json' },
});
});
it('assigns a group with optimistic concurrency and fails closed on bad revisions', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(noContent());
const source = createOrganizationApiDataSource(fetcher);
await source.assignGroup('beta', 4, 'g1');
expect(fetcher).toHaveBeenCalledWith('/api/v1/instances/beta', {
method: 'PATCH',
credentials: 'same-origin',
headers: {
accept: 'application/json',
'content-type': 'application/json',
'if-match': '"rev-4"',
},
body: JSON.stringify({ groupId: 'g1' }),
});
await source.assignGroup('beta', 5, null);
expect(fetcher).toHaveBeenNthCalledWith(
2,
'/api/v1/instances/beta',
expect.objectContaining({ body: JSON.stringify({ groupId: null }) }),
);
await expect(source.assignGroup('beta', 0, 'g1')).rejects.toThrow(
'Instance revision is required.',
);
});
it('surfaces transport errors instead of returning empty collections', async () => {
const source = createOrganizationApiDataSource(
vi.fn<typeof fetch>().mockResolvedValue(json({ items: [] }, 500)),
);
await expect(source.listGroups()).rejects.toThrow('Organization request failed (500)');
});
it('rejects an unusable create response', async () => {
const source = createOrganizationApiDataSource(
vi.fn<typeof fetch>().mockResolvedValue(json({})),
);
await expect(source.createGroup({ name: '仓库' })).rejects.toThrow(
'Organization response is invalid.',
);
});
});
@@ -0,0 +1,194 @@
export interface OrganizationGroup {
readonly id: string;
readonly name: string;
readonly description: string;
readonly deviceCount: number;
}
export interface OrganizationTag {
readonly tag: string;
readonly color: string;
readonly deviceCount: number;
}
export interface OrganizationDataSource {
listGroups(signal?: AbortSignal): Promise<readonly OrganizationGroup[]>;
createGroup(input: Readonly<{ name: string; description?: string }>): Promise<OrganizationGroup>;
updateGroup(
id: string,
input: Readonly<{ name?: string; description?: string }>,
): Promise<OrganizationGroup>;
deleteGroup(id: string): Promise<void>;
listTags(signal?: AbortSignal): Promise<readonly OrganizationTag[]>;
createTag(input: Readonly<{ tag: string; color?: string }>): Promise<OrganizationTag>;
updateTag(tag: string, input: Readonly<{ color: string }>): Promise<OrganizationTag>;
deleteTag(tag: string): Promise<void>;
assignGroup(instanceId: string, revision: number, groupId: string | null): Promise<void>;
}
async function json<T>(response: Response): Promise<T> {
if (!response.ok) throw new Error(`Organization request failed (${response.status})`);
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
function optionalText(value: unknown, maximum: number): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
if (!trimmed || trimmed.length > maximum) return undefined;
return trimmed;
}
function parseGroup(value: unknown): OrganizationGroup | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const item = value as Record<string, unknown>;
const id = optionalText(item.id, 256);
const name = optionalText(item.name, 100);
if (!id || !name) return undefined;
return {
id,
name,
description: typeof item.description === 'string' ? item.description : '',
deviceCount:
typeof item.deviceCount === 'number' && Number.isSafeInteger(item.deviceCount)
? item.deviceCount
: 0,
};
}
function parseTag(value: unknown): OrganizationTag | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const item = value as Record<string, unknown>;
const tag = optionalText(item.tag, 100);
if (!tag) return undefined;
return {
tag,
color: typeof item.color === 'string' ? item.color : '',
deviceCount:
typeof item.deviceCount === 'number' && Number.isSafeInteger(item.deviceCount)
? item.deviceCount
: 0,
};
}
function items(value: unknown, key: string): readonly unknown[] {
const body = value as Record<string, unknown> | undefined;
return Array.isArray(body?.[key]) ? (body?.[key] as readonly unknown[]) : [];
}
export function createOrganizationApiDataSource(
fetcher: typeof fetch = fetch,
): OrganizationDataSource {
return {
async listGroups(signal) {
const body = await json<unknown>(
await fetcher('/api/v1/groups', {
credentials: 'same-origin',
headers: { accept: 'application/json' },
...(signal ? { signal } : {}),
}),
);
return items(body, 'items')
.map(parseGroup)
.filter((group): group is OrganizationGroup => group !== undefined);
},
async createGroup(input) {
const created = await json<unknown>(
await fetcher('/api/v1/groups', {
method: 'POST',
credentials: 'same-origin',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify(input),
}),
);
const group = parseGroup(created);
if (!group) throw new Error('Organization response is invalid.');
return group;
},
async updateGroup(id, input) {
const updated = await json<unknown>(
await fetcher(`/api/v1/groups/${encodeURIComponent(id)}`, {
method: 'PUT',
credentials: 'same-origin',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify(input),
}),
);
const group = parseGroup(updated);
if (!group) throw new Error('Organization response is invalid.');
return group;
},
async deleteGroup(id) {
await json<unknown>(
await fetcher(`/api/v1/groups/${encodeURIComponent(id)}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: { accept: 'application/json' },
}),
);
},
async listTags(signal) {
const body = await json<unknown>(
await fetcher('/api/v1/tags', {
credentials: 'same-origin',
headers: { accept: 'application/json' },
...(signal ? { signal } : {}),
}),
);
return items(body, 'items')
.map(parseTag)
.filter((tag): tag is OrganizationTag => tag !== undefined);
},
async createTag(input) {
const created = await json<unknown>(
await fetcher('/api/v1/tags', {
method: 'POST',
credentials: 'same-origin',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify(input),
}),
);
const tag = parseTag(created);
if (!tag) throw new Error('Organization response is invalid.');
return tag;
},
async updateTag(tag, input) {
const updated = await json<unknown>(
await fetcher(`/api/v1/tags/${encodeURIComponent(tag)}`, {
method: 'PUT',
credentials: 'same-origin',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify(input),
}),
);
const parsed = parseTag(updated);
if (!parsed) throw new Error('Organization response is invalid.');
return parsed;
},
async deleteTag(tag) {
await json<unknown>(
await fetcher(`/api/v1/tags/${encodeURIComponent(tag)}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: { accept: 'application/json' },
}),
);
},
async assignGroup(instanceId, revision, groupId) {
if (!Number.isSafeInteger(revision) || revision < 1)
throw new Error('Instance revision is required.');
await json<unknown>(
await fetcher(`/api/v1/instances/${encodeURIComponent(instanceId)}`, {
method: 'PATCH',
credentials: 'same-origin',
headers: {
accept: 'application/json',
'content-type': 'application/json',
'if-match': `"rev-${revision}"`,
},
body: JSON.stringify({ groupId }),
}),
);
},
};
}
+96
View File
@@ -9,11 +9,105 @@ export {
} from './app-shell.js';
export {
FleetPage,
accessMethod,
canonicalHttpOrigin,
type FleetDataSource,
type FleetPageProps,
type FleetSnapshot,
} from './fleet/fleet-page.js';
export {
FleetMessagesPage,
createFleetMessagesCenterApiDataSource,
sanitizeFleetMessagesDeleteResult,
sanitizeFleetMessagesSnapshot,
sanitizeFleetOutboxFlushResult,
sanitizeFleetOutboxPage,
type FleetMessagesCenterDataSource,
type FleetMessagesDeleteFailure,
type FleetMessagesDeleteInput,
type FleetMessagesDeleteItem,
type FleetMessagesDeleteResult,
type FleetMessagesDevice,
type FleetMessagesLoadQuery,
type FleetMessagesMessage,
type FleetMessagesPageProps,
type FleetMessagesSendInput,
type FleetMessagesSendResult,
type FleetMessagesSnapshot,
type FleetOutboxFlushResult,
type FleetOutboxItem,
type FleetOutboxPage,
type FleetOutboxQuery,
type FleetOutboxStatus,
type FleetOutboxSummary,
} from './fleet/fleet-messages-page.js';
export {
FleetNotificationsPage,
type FleetNotificationsPageProps,
} from './fleet/fleet-notifications-page.js';
export { FleetLogsPage, LOG_PAGE_SIZE } from './fleet/fleet-logs-page.js';
export { createLogCenterApiDataSource } from './fleet/log-center-api-data-source.js';
export {
CONNECTION_OUTCOMES,
LEVEL_LABELS,
LOG_LEVELS,
LOG_SOURCES,
OUTCOME_LABELS,
SOURCE_LABELS,
logQueryParams,
parseLogPage,
parseRuntimeLogPage,
sanitizeConnectionLogEntry,
sanitizeConnectionSummary,
sanitizeInstanceDiagnostics,
sanitizeRuntimeLogEntry,
type ConnectionLogEntry,
type ConnectionOutcome,
type InstanceDiagnostics,
type LogCenterDataSource,
type LogLevel,
type LogQuery,
type LogSource,
type RuntimeLogEntry,
type RuntimeLogPage,
} from './fleet/log-center-types.js';
export {
createNotificationCenterApiDataSource,
NOTIFICATION_PAGE_SIZE,
} from './fleet/notification-center-api.js';
export {
sanitizeNotificationCenterSnapshot,
sanitizeNotificationChannel,
sanitizeNotificationLogEntry,
sanitizeNotificationQueueEntry,
sanitizeNotificationRule,
} from './fleet/notification-center-sanitize.js';
export {
CHANNEL_TYPE_PRESETS,
CONDITION_FIELDS,
CONDITION_MODES,
DEVICE_STATE_LABELS,
EVENT_TYPE_PRESETS,
QUEUE_STATUSES,
STATUS_LABELS,
channelTypeLabel,
eventTypeLabel,
formatTimestamp,
statusLabel,
statusTone,
type NotificationChannel,
type NotificationChannelDraft,
type NotificationChannelType,
type NotificationCenterDataSource,
type NotificationCenterSnapshot,
type NotificationDevice,
type NotificationEventType,
type NotificationLogEntry,
type NotificationPage,
type NotificationQueueEntry,
type NotificationRule,
type NotificationRuleDraft,
} from './fleet/notification-center-types.js';
export * from './fleet/fleet-table-view-model.js';
export { InstanceEditor, type InstanceEditorProps } from './instances/instance-crud.js';
export {
@@ -140,8 +234,10 @@ export {
export {
JobsPage,
sanitizeJobPage,
sanitizeReconcileResult,
type JobsDataSource,
type JobsPageProps,
type SafeReconcileResult,
} from './jobs/jobs-page.js';
export {
InstanceSettingsPage,
+20 -13
View File
@@ -2,16 +2,17 @@ import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { InstanceContext } from '../app-shell.js';
import { displayValue } from '../ui/locale.js';
import { DeviceActions } from './device-action-controls.js';
/** Aggregate task counts only; per-task records and configuration are intentionally unsupported. */
export interface AutomationTaskCounts {
readonly total?: number | null;
readonly enabled?: number | null;
readonly disabled?: number | null;
readonly running?: number | null;
readonly queued?: number | null;
readonly succeeded?: number | null;
readonly failed?: number | null;
readonly total?: number | null | undefined;
readonly enabled?: number | null | undefined;
readonly disabled?: number | null | undefined;
readonly running?: number | null | undefined;
readonly queued?: number | null | undefined;
readonly succeeded?: number | null | undefined;
readonly failed?: number | null | undefined;
}
/** Bounded high-level status labels only, never status payloads or raw logs. */
@@ -19,13 +20,13 @@ export type AutomationState = 'healthy' | 'degraded' | 'failed' | 'disabled' | '
export type AutomationScheduler = 'active' | 'idle' | 'paused' | 'disabled' | 'unavailable';
export type AutomationWorkers = 'available' | 'busy' | 'degraded' | 'disabled' | 'unavailable';
export interface AutomationStatus {
readonly state?: AutomationState | null;
readonly scheduler?: AutomationScheduler | null;
readonly workers?: AutomationWorkers | null;
readonly state?: AutomationState | null | undefined;
readonly scheduler?: AutomationScheduler | null | undefined;
readonly workers?: AutomationWorkers | null | undefined;
}
export interface AutomationSnapshot {
readonly observedAt?: string;
readonly observedAt?: string | undefined;
readonly status: AutomationStatus;
readonly tasks: AutomationTaskCounts;
}
@@ -37,8 +38,8 @@ export interface AutomationDataSource {
export interface AutomationModuleProps {
readonly instance: InstanceContext;
readonly dataSource?: AutomationDataSource;
readonly refreshSignal?: unknown;
readonly dataSource?: AutomationDataSource | undefined;
readonly refreshSignal?: unknown | undefined;
}
type ReadState =
@@ -291,6 +292,12 @@ export function AutomationModule({ instance, dataSource, refreshSignal }: Automa
</p>
) : null}
<SnapshotView snapshot={snapshot} />
<DeviceActions
instance={instance}
module="automation"
onExecuted={() => setRetry((value) => value + 1)}
/>
</div>
);
}
+56 -14
View File
@@ -2,33 +2,42 @@ import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { InstanceContext } from '../app-shell.js';
import { displayValue } from '../ui/locale.js';
import { DeviceActions } from './device-action-controls.js';
/** Bounded values permitted in aggregate call/device status. */
export type CallsStatusValue = string | number | boolean | null;
/** Aggregate call state only. Per-call identity, phone numbers, logs, and media are unsupported. */
export interface AggregateCallStatus {
readonly state?: CallsStatusValue;
readonly total?: CallsStatusValue;
readonly active?: CallsStatusValue;
readonly ringing?: CallsStatusValue;
readonly held?: CallsStatusValue;
readonly failed?: CallsStatusValue;
readonly state?: CallsStatusValue | undefined;
readonly total?: CallsStatusValue | undefined;
readonly active?: CallsStatusValue | undefined;
readonly ringing?: CallsStatusValue | undefined;
readonly held?: CallsStatusValue | undefined;
readonly failed?: CallsStatusValue | undefined;
}
/** Aggregate device state only. Device phone numbers and media are intentionally unsupported. */
export interface AggregateCallDeviceStatus {
readonly state?: CallsStatusValue;
readonly total?: CallsStatusValue;
readonly online?: CallsStatusValue;
readonly offline?: CallsStatusValue;
readonly busy?: CallsStatusValue;
readonly state?: CallsStatusValue | undefined;
readonly total?: CallsStatusValue | undefined;
readonly online?: CallsStatusValue | undefined;
readonly offline?: CallsStatusValue | undefined;
readonly busy?: CallsStatusValue | undefined;
}
/** One optional call capability the device exposes, with the verdict the firmware reported. */
export interface CallFeatureStatus {
readonly name: string;
readonly state: 'available' | 'unavailable' | 'unknown';
readonly detail?: string | undefined;
}
export interface CallsSnapshot {
readonly observedAt?: string;
readonly observedAt?: string | undefined;
readonly calls: AggregateCallStatus;
readonly devices: AggregateCallDeviceStatus;
readonly features?: readonly CallFeatureStatus[] | undefined;
}
export interface CallsDataSource {
@@ -38,9 +47,9 @@ export interface CallsDataSource {
export interface CallsModuleProps {
readonly instance: InstanceContext;
readonly dataSource?: CallsDataSource;
readonly dataSource?: CallsDataSource | undefined;
/** Change this owner-provided value to request another read. */
readonly refreshSignal?: unknown;
readonly refreshSignal?: unknown | undefined;
}
type OwnedSnapshot = { readonly ownerId: string; readonly value: CallsSnapshot };
@@ -100,6 +109,28 @@ function AggregateFields({
);
}
const FEATURE_STATE_LABELS: Readonly<Record<CallFeatureStatus['state'], string>> = {
available: '可用',
unavailable: '未开放',
unknown: '未知',
};
function FeatureList({ features }: { readonly features: readonly CallFeatureStatus[] }) {
return (
<dl>
{features.map((feature) => (
<div key={feature.name}>
<dt>{feature.name}</dt>
<dd>
{FEATURE_STATE_LABELS[feature.state]}
{feature.detail ? `${feature.detail}` : ''}
</dd>
</div>
))}
</dl>
);
}
function SnapshotView({ snapshot }: { snapshot: CallsSnapshot }) {
return (
<>
@@ -111,6 +142,11 @@ function SnapshotView({ snapshot }: { snapshot: CallsSnapshot }) {
<Section label="设备状态">
<AggregateFields values={snapshot.devices} fields={DEVICE_FIELDS} />
</Section>
{snapshot.features?.length ? (
<Section label="呼叫能力">
<FeatureList features={snapshot.features} />
</Section>
) : null}
</div>
</>
);
@@ -213,6 +249,12 @@ export function CallsModule({ instance, dataSource, refreshSignal }: CallsModule
</p>
) : null}
<SnapshotView snapshot={snapshot.value} />
<DeviceActions
instance={instance}
module="calls"
onExecuted={() => setRetry((value) => value + 1)}
/>
</div>
);
}
@@ -10,7 +10,21 @@ import {
type CellularSnapshot,
} from './cellular-module.js';
afterEach(cleanup);
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function stubCatalog(actions: readonly Record<string, unknown>[]) {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
new Response(JSON.stringify({ actions }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetcher);
return fetcher;
}
const owner: InstanceContext = {
id: 'alpha',
@@ -70,8 +84,87 @@ describe('Phase 6.2 Cellular read module', () => {
expect(screen.getByText('不可用')).toBeTruthy();
expect(screen.getByText(/观测时间:2026-07-17T12:00:00Z/)).toBeTruthy();
expect(screen.queryByRole('button', { name: /register|operator|network/i })).toBeNull();
expect(screen.getByText(/自动网络注册仅可通过/i)).toBeTruthy();
expect(screen.getByText(/经审计的 R2 准备、确认和执行流程/i)).toBeTruthy();
// The old read-only disclaimer is gone: the control plane now serves real device controls.
expect(screen.queryByText(/仍不可用/i)).toBeNull();
});
it('renders the allowlisted cellular device actions served by the control plane', async () => {
const load = vi.fn<CellularDataSource['load']>().mockResolvedValue(snapshot);
const fetcher = stubCatalog([
{
id: 'network.scan',
module: 'cellular',
title: '扫描运营商',
description: '让设备重新扫描可见运营商列表。',
risk: 'R1',
method: 'POST',
fields: [],
},
{
id: 'radio-mode.set',
module: 'cellular',
title: '切换网络模式',
description: '切换首选无线制式。',
risk: 'R2',
method: 'POST',
fields: [
{
id: 'mode',
label: '制式',
kind: 'choice',
required: true,
choices: [{ value: 'lte', label: '仅 LTE' }],
},
],
},
{
id: 'sim.refresh-details',
module: 'sim',
title: '刷新 SIM',
description: '',
risk: 'R1',
method: 'POST',
fields: [],
},
]);
render(<CellularModule instance={owner} dataSource={{ load }} />);
await screen.findByRole('button', { name: /扫描运营商/ });
const card = screen.getByRole('region', { name: '设备操作' });
expect(fetcher).toHaveBeenCalledWith(
'/api/v1/instances/alpha/device-actions',
expect.objectContaining({ method: 'GET' }),
);
// Only the actions the control plane assigns to this module appear here.
expect(within(card).getByRole('button', { name: /扫描运营商/ })).toBeTruthy();
expect(within(card).getByRole('button', { name: /切换网络模式/ })).toBeTruthy();
expect(within(card).queryByRole('button', { name: /刷新 SIM/ })).toBeNull();
});
it('asks for an explicit confirmation before a risky action can run', async () => {
const load = vi.fn<CellularDataSource['load']>().mockResolvedValue(snapshot);
stubCatalog([
{
id: 'network.register-manual',
module: 'cellular',
title: '手动注册网络',
description: '按 MCCMNC 强制注册。',
risk: 'R2',
method: 'POST',
fields: [{ id: 'mccmnc', label: '运营商 MCCMNC', kind: 'string', required: true }],
},
]);
render(<CellularModule instance={owner} dataSource={{ load }} />);
const trigger = await screen.findByRole('button', { name: /手动注册网络/ });
const card = screen.getByRole('region', { name: '设备操作' });
await userEvent.click(within(card).getByRole('button', { name: /手动注册网络/ }));
expect(trigger).toBeTruthy();
const confirm = within(card).getByRole('checkbox', { name: /确认执行/ });
const run = within(card).getByRole('button', { name: '执行' });
expect((run as HTMLButtonElement).disabled).toBe(true);
await userEvent.click(confirm);
expect((run as HTMLButtonElement).disabled).toBe(false);
});
it('is honest when no read source is injected', () => {
+103 -27
View File
@@ -1,37 +1,42 @@
import { useEffect, useRef, useState } from 'react';
import type { InstanceContext } from '../app-shell.js';
import { DeviceActions } from './device-action-controls.js';
import { displayValue } from '../ui/locale.js';
/** Cellular data is deliberately limited to display-safe primitive values. */
export type CellularFieldValue = string | number | boolean | null;
export interface CellularNetworkRegistration {
readonly state?: CellularFieldValue;
readonly mode?: CellularFieldValue;
readonly operator?: CellularFieldValue;
readonly roaming?: CellularFieldValue;
readonly state?: CellularFieldValue | undefined;
readonly mode?: CellularFieldValue | undefined;
readonly operator?: CellularFieldValue | undefined;
readonly roaming?: CellularFieldValue | undefined;
}
export interface CellularSignal {
readonly rssi?: CellularFieldValue;
readonly rsrp?: CellularFieldValue;
readonly rsrq?: CellularFieldValue;
readonly sinr?: CellularFieldValue;
readonly quality?: CellularFieldValue;
readonly rssi?: CellularFieldValue | undefined;
readonly rsrp?: CellularFieldValue | undefined;
readonly rsrq?: CellularFieldValue | undefined;
readonly sinr?: CellularFieldValue | undefined;
readonly quality?: CellularFieldValue | undefined;
}
export interface CellularLocation {
readonly cell?: CellularFieldValue;
readonly area?: CellularFieldValue;
readonly technology?: CellularFieldValue;
readonly latitude?: CellularFieldValue;
readonly longitude?: CellularFieldValue;
readonly cell?: CellularFieldValue | undefined;
readonly area?: CellularFieldValue | undefined;
readonly technology?: CellularFieldValue | undefined;
readonly latitude?: CellularFieldValue | undefined;
readonly longitude?: CellularFieldValue | undefined;
readonly mcc?: CellularFieldValue | undefined;
readonly mnc?: CellularFieldValue | undefined;
readonly pci?: CellularFieldValue | undefined;
readonly arfcn?: CellularFieldValue | undefined;
}
export interface CellularOperators {
readonly current?: CellularFieldValue;
readonly available?: CellularFieldValue;
readonly current?: CellularFieldValue | undefined;
readonly available?: CellularFieldValue | undefined;
}
export interface CellularSnapshot {
readonly observedAt?: string;
readonly observedAt?: string | undefined;
readonly networkRegistration: CellularNetworkRegistration;
readonly signal: CellularSignal;
readonly cellsLocation: CellularLocation;
@@ -45,9 +50,9 @@ export interface CellularDataSource {
export interface CellularModuleProps {
readonly instance: InstanceContext;
readonly dataSource?: CellularDataSource;
readonly dataSource?: CellularDataSource | undefined;
/** Change this value when the owning application requests a refresh. */
readonly refreshSignal?: unknown;
readonly refreshSignal?: unknown | undefined;
}
type OwnedSnapshot = { readonly ownerId: string; readonly value: CellularSnapshot };
@@ -90,6 +95,18 @@ const SECTIONS = [
['经度', 'longitude'],
],
],
[
'cellsLocation',
'基站定位参数',
[
['国家码', 'mcc'],
['运营商码', 'mnc'],
['跟踪区', 'area'],
['小区编号', 'cell'],
['物理小区号', 'pci'],
['频点', 'arfcn'],
],
],
[
'operators',
'运营商',
@@ -135,6 +152,66 @@ function StructuredSection({
);
}
/** Device keys the third-party cell-location APIs (Amap, Baidu, Google) expect. */
const LOCATION_KEYS: readonly (readonly [keyof CellularLocation, string])[] = [
['mcc', 'mcc'],
['mnc', 'mnc'],
['area', 'lac'],
['cell', 'cid'],
['pci', 'pci'],
['arfcn', 'arfcn'],
['technology', 'rat'],
['latitude', 'latitude'],
['longitude', 'longitude'],
];
function locationPayload(location: CellularLocation): string {
const payload: Record<string, string> = {};
for (const [source, target] of LOCATION_KEYS) {
const value = location[source];
if (value === undefined || value === null || value === '') continue;
payload[target] = String(value);
}
return JSON.stringify(payload, null, 2);
}
/**
* Mirrors the Hub's 基站定位参数 export: the same parameters, ready to paste into a third-party
* positioning request. The payload is also shown so the copy works without clipboard permission.
*/
function CellLocationExport({ location }: { readonly location: CellularLocation }) {
const [copied, setCopied] = useState<'idle' | 'copied' | 'denied'>('idle');
const payload = locationPayload(location);
if (payload === '{}') return null;
async function copy(): Promise<void> {
try {
await navigator.clipboard.writeText(payload);
setCopied('copied');
} catch {
setCopied('denied');
}
}
return (
<section className="cellular-card cell-location-export" aria-label="基站定位参数导出">
<h2></h2>
<p className="cellular-card-note"> Google </p>
<pre className="cell-location-payload">{payload}</pre>
<div className="fleet-card-actions">
<button type="button" className="fleet-messages-more" onClick={() => void copy()}>
</button>
</div>
{copied === 'copied' ? (
<p role="status"></p>
) : copied === 'denied' ? (
<p role="alert">访</p>
) : null}
</section>
);
}
export function CellularModule({ instance, dataSource, refreshSignal }: CellularModuleProps) {
const requestOwner = useRef(0);
const [retry, setRetry] = useState(0);
@@ -242,16 +319,15 @@ export function CellularModule({ instance, dataSource, refreshSignal }: Cellular
{snapshot.observedAt ? <p>{snapshot.observedAt}</p> : null}
<div className="cellular-grid">
{SECTIONS.map(([key, label, fields]) => (
<StructuredSection key={key} label={label} values={snapshot[key]} fields={fields} />
<StructuredSection key={label} label={label} values={snapshot[key]} fields={fields} />
))}
<CellLocationExport location={snapshot.cellsLocation} />
<DeviceActions
instance={instance}
module="cellular"
onExecuted={() => setRetry((value) => value + 1)}
/>
</div>
<section className="state-panel" aria-label="网络注册操作">
<h2></h2>
<p>
R2
</p>
</section>
</div>
);
}
@@ -0,0 +1,228 @@
/**
* Console-side client for the control plane's allowlisted device actions.
*
* The browser never learns a device path: the server hands back descriptors (id, title, risk,
* typed fields) and the console renders a form from them. That keeps the UI in step with the
* catalog without shipping a second copy of the device contract to the client.
*/
export type DeviceActionRisk = 'R1' | 'R2' | 'R3';
export type DeviceActionFieldKind =
| 'boolean'
| 'number'
| 'string'
| 'secret'
| 'choice'
| 'string-list';
export interface DeviceActionField {
readonly id: string;
readonly label: string;
readonly kind: DeviceActionFieldKind;
readonly in?: 'body' | 'path' | 'query';
readonly required?: boolean;
readonly choices?: readonly { readonly value: string; readonly label: string }[];
readonly min?: number;
readonly max?: number;
readonly maxLength?: number;
readonly pattern?: string;
readonly hint?: string;
}
export interface DeviceActionDescriptor {
readonly id: string;
readonly module: string;
readonly title: string;
readonly description: string;
readonly risk: DeviceActionRisk;
readonly method: 'POST' | 'DELETE';
readonly fields: readonly DeviceActionField[];
}
export interface DeviceActionResult {
readonly actionId: string;
readonly title: string;
readonly status: number;
readonly ok: boolean;
readonly data: unknown;
readonly message: string | null;
readonly durationMs: number;
}
export class DeviceActionClientError extends Error {
constructor(
readonly code: string,
readonly status: number,
) {
super(code);
this.name = 'DeviceActionClientError';
}
}
export interface DeviceActionSource {
list(signal?: AbortSignal): Promise<readonly DeviceActionDescriptor[]>;
execute(
actionId: string,
params: Readonly<Record<string, unknown>>,
confirm: boolean,
): Promise<DeviceActionResult>;
}
const MAX_ACTIONS = 128;
const RISK_VALUES: readonly DeviceActionRisk[] = ['R1', 'R2', 'R3'];
const FIELD_KINDS: readonly DeviceActionFieldKind[] = [
'boolean',
'number',
'string',
'secret',
'choice',
'string-list',
];
function record(value: unknown): Readonly<Record<string, unknown>> | undefined {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Readonly<Record<string, unknown>>)
: undefined;
}
function text(value: unknown, max: number): string | undefined {
return typeof value === 'string' && value.length > 0 ? value.slice(0, max) : undefined;
}
function boundedNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function parseField(value: unknown): DeviceActionField | undefined {
const entry = record(value);
const id = text(entry?.id, 64);
const label = text(entry?.label, 128);
const kind = entry?.kind;
if (
!id ||
!label ||
typeof kind !== 'string' ||
!FIELD_KINDS.includes(kind as DeviceActionFieldKind)
)
return undefined;
const field: {
-readonly [K in keyof DeviceActionField]: DeviceActionField[K];
} = { id, label, kind: kind as DeviceActionFieldKind };
const place = entry?.in;
if (place === 'path' || place === 'query') field.in = place;
if (entry?.required === true) field.required = true;
if (Array.isArray(entry?.choices)) {
const choices = entry.choices
.slice(0, 32)
.flatMap((item): { value: string; label: string }[] => {
const choice = record(item);
const value = text(choice?.value, 128);
return value ? [{ value, label: text(choice?.label, 128) ?? value }] : [];
});
if (choices.length) field.choices = Object.freeze(choices);
}
const min = boundedNumber(entry?.min);
if (min !== undefined) field.min = min;
const max = boundedNumber(entry?.max);
if (max !== undefined) field.max = max;
const maxLength = boundedNumber(entry?.maxLength);
if (maxLength !== undefined) field.maxLength = maxLength;
const pattern = text(entry?.pattern, 256);
if (pattern) field.pattern = pattern;
const hint = text(entry?.hint, 256);
if (hint) field.hint = hint;
return Object.freeze(field);
}
function parseDescriptor(value: unknown): DeviceActionDescriptor | undefined {
const entry = record(value);
const id = text(entry?.id, 128);
const module = text(entry?.module, 64);
const title = text(entry?.title, 128);
const risk = entry?.risk;
if (
!id ||
!module ||
!title ||
typeof risk !== 'string' ||
!RISK_VALUES.includes(risk as DeviceActionRisk)
)
return undefined;
return Object.freeze({
id,
module,
title,
description: text(entry?.description, 512) ?? '',
risk: risk as DeviceActionRisk,
method: entry?.method === 'DELETE' ? 'DELETE' : 'POST',
fields: Object.freeze(
Array.isArray(entry?.fields)
? entry.fields.slice(0, 16).flatMap((field) => {
const parsed = parseField(field);
return parsed ? [parsed] : [];
})
: [],
),
});
}
async function readProblem(response: Response): Promise<DeviceActionClientError> {
let code = 'REQUEST_FAILED';
try {
const root = record(await response.json());
code = text(root?.code, 64) ?? code;
} catch {
// A non-JSON error body still maps to the status code below.
}
return new DeviceActionClientError(code, response.status);
}
export function createDeviceActionSource(
instanceId: string,
fetcher: typeof fetch = fetch,
): DeviceActionSource {
const base = `/api/v1/instances/${encodeURIComponent(instanceId)}/device-actions`;
return {
async list(signal?: AbortSignal) {
const response = await fetcher(base, {
method: 'GET',
credentials: 'same-origin',
headers: { accept: 'application/json' },
...(signal ? { signal } : {}),
});
if (!response.ok) throw await readProblem(response);
const root = record(await response.json());
if (!Array.isArray(root?.actions)) return [];
return Object.freeze(
root.actions.slice(0, MAX_ACTIONS).flatMap((item) => {
const parsed = parseDescriptor(item);
return parsed ? [parsed] : [];
}),
);
},
async execute(actionId, params, confirm) {
const response = await fetcher(`${base}/${encodeURIComponent(actionId)}`, {
method: 'POST',
credentials: 'same-origin',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify({ params, ...(confirm ? { confirm: true } : {}) }),
});
if (!response.ok) throw await readProblem(response);
const entry = record(await response.json());
const status = typeof entry?.status === 'number' ? entry.status : 0;
return Object.freeze({
actionId: text(entry?.actionId, 128) ?? actionId,
title: text(entry?.title, 128) ?? '',
status,
ok: entry?.ok === true,
data: entry?.data ?? null,
message: text(entry?.message, 512) ?? null,
durationMs:
typeof entry?.durationMs === 'number' && Number.isFinite(entry.durationMs)
? entry.durationMs
: 0,
});
},
};
}
@@ -0,0 +1,419 @@
import { useEffect, useMemo, useState } from 'react';
import type { InstanceContext } from '../app-shell.js';
import { Icon, type IconName } from '../ui/icon.js';
import {
createDeviceActionSource,
type DeviceActionDescriptor,
type DeviceActionField,
type DeviceActionSource,
type DeviceActionResult,
} from './device-action-client.js';
/**
* Renders the control-plane device-action catalog as native console controls.
*
* The component is entirely data driven: the server decides which actions exist for a module and
* what fields they take, so a firmware capability that the official Hub exposes shows up here
* without a frontend change.
*/
const RISK_LABELS: Readonly<Record<DeviceActionDescriptor['risk'], string>> = {
R1: '常规',
R2: '影响业务',
R3: '不可撤销',
};
const RISK_ICONS: Readonly<Record<DeviceActionDescriptor['risk'], IconName>> = {
R1: 'check',
R2: 'alert',
R3: 'alert',
};
function iconFor(action: DeviceActionDescriptor): IconName {
if (action.method === 'DELETE' || /\bdelete\b|clear|forget|unbind|remove/u.test(action.id))
return 'trash';
if (
/restart|reboot|refresh|scan|sync|check|prepare|register|connect|apply|download/u.test(
action.id,
)
)
return 'restart';
if (/test/u.test(action.id)) return 'activity';
if (/dial|answer|hangup/u.test(action.id)) return 'phone';
if (/export|backup/u.test(action.id)) return 'server';
if (/set|configure|enable|disable|lock|mode/u.test(action.id)) return 'settings';
return 'check';
}
function initialValues(fields: readonly DeviceActionField[]): Record<string, string | boolean> {
const values: Record<string, string | boolean> = {};
for (const field of fields) values[field.id] = field.kind === 'boolean' ? false : '';
return values;
}
function buildParams(
fields: readonly DeviceActionField[],
values: Record<string, string | boolean>,
): Record<string, unknown> {
const params: Record<string, unknown> = {};
for (const field of fields) {
const raw = values[field.id];
if (field.kind === 'boolean') {
if (raw === true) params[field.id] = true;
continue;
}
const text = typeof raw === 'string' ? raw.trim() : '';
if (!text) continue;
if (field.kind === 'number') {
const parsed = Number(text);
if (Number.isSafeInteger(parsed)) params[field.id] = parsed;
continue;
}
if (field.kind === 'string-list') {
params[field.id] = text
.split(/[,\s]+/u)
.map((item) => item.trim())
.filter((item) => item.length > 0)
.slice(0, 32);
continue;
}
params[field.id] = text;
}
return params;
}
function missingRequired(
fields: readonly DeviceActionField[],
values: Record<string, string | boolean>,
): string[] {
return fields
.filter((field) => {
if (!field.required) return false;
const raw = values[field.id];
return field.kind === 'boolean' ? raw !== true : String(raw ?? '').trim().length === 0;
})
.map((field) => field.label);
}
function FieldInput({
field,
value,
disabled,
onChange,
}: {
field: DeviceActionField;
value: string | boolean;
disabled: boolean;
onChange: (next: string | boolean) => void;
}) {
const id = `${field.id}-input`;
if (field.kind === 'boolean') {
return (
<label className="device-action-toggle" htmlFor={id}>
<input
id={id}
type="checkbox"
checked={value === true}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
/>
<span>{field.label}</span>
</label>
);
}
if (field.kind === 'choice' && field.choices?.length) {
return (
<label className="device-action-field" htmlFor={id}>
<span>{field.label}</span>
<select
id={id}
value={typeof value === 'string' ? value : ''}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
>
<option value=""></option>
{field.choices.map((choice) => (
<option key={choice.value} value={choice.value}>
{choice.label}
</option>
))}
</select>
{field.hint ? <small>{field.hint}</small> : null}
</label>
);
}
return (
<label className="device-action-field" htmlFor={id}>
<span>{field.label}</span>
<input
id={id}
type={field.kind === 'secret' ? 'password' : field.kind === 'number' ? 'number' : 'text'}
value={typeof value === 'string' ? value : ''}
disabled={disabled}
{...(field.min !== undefined ? { min: field.min } : {})}
{...(field.max !== undefined ? { max: field.max } : {})}
{...(field.maxLength !== undefined ? { maxLength: field.maxLength } : {})}
{...(field.pattern ? { pattern: field.pattern } : {})}
placeholder={field.hint ?? ''}
onChange={(event) => onChange(event.target.value)}
/>
{field.hint && !field.pattern ? <small>{field.hint}</small> : null}
</label>
);
}
function ActionRow({
action,
source,
onExecuted,
}: {
action: DeviceActionDescriptor;
source: DeviceActionSource;
onExecuted: () => void;
}) {
const [open, setOpen] = useState(false);
const [values, setValues] = useState<Record<string, string | boolean>>(() =>
initialValues(action.fields),
);
const [pending, setPending] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [result, setResult] = useState<DeviceActionResult | null>(null);
const [error, setError] = useState<string | null>(null);
const needsConfirm = action.risk !== 'R1';
const parameterized = action.fields.length > 0;
async function run() {
const missing = missingRequired(action.fields, values);
if (missing.length) {
setError(`请先填写:${missing.join('、')}`);
return;
}
setError(null);
setPending(true);
setResult(null);
try {
const outcome = await source.execute(
action.id,
buildParams(action.fields, values),
needsConfirm && confirmed,
);
setResult(outcome);
if (outcome.ok) {
setConfirmed(false);
onExecuted();
}
} catch (cause) {
setError(
cause instanceof Error && cause.message === 'VALIDATION_FAILED'
? '参数不符合要求,请检查后重试。'
: '操作未能送达设备,请稍后重试。',
);
}
setPending(false);
}
return (
<li className={`device-action device-action-${action.risk.toLocaleLowerCase()}`}>
<div className="device-action-head">
<button
type="button"
className={
action.risk === 'R3'
? 'danger-button'
: parameterized
? 'fleet-messages-more'
: 'primary-action'
}
aria-expanded={parameterized ? open : undefined}
disabled={pending}
onClick={() => {
if (parameterized && !open) {
setOpen(true);
return;
}
void run();
}}
>
<Icon name={pending ? 'activity' : iconFor(action)} />
{pending ? '执行中' : action.title}
</button>
<span className="device-action-risk">
<Icon name={RISK_ICONS[action.risk]} />
{RISK_LABELS[action.risk]}
</span>
</div>
<p className="device-action-note">{action.description}</p>
{open ? (
<div className="device-action-form">
{action.fields.map((field) => (
<FieldInput
key={field.id}
field={field}
disabled={pending}
value={values[field.id] ?? (field.kind === 'boolean' ? false : '')}
onChange={(next) => setValues((current) => ({ ...current, [field.id]: next }))}
/>
))}
{needsConfirm ? (
<label className="device-action-toggle" htmlFor={`${action.id}-confirm`}>
<input
id={`${action.id}-confirm`}
type="checkbox"
checked={confirmed}
disabled={pending}
onChange={(event) => setConfirmed(event.target.checked)}
/>
<span>{action.title}</span>
</label>
) : null}
<div className="fleet-card-actions">
<button
type="button"
className={action.risk === 'R3' ? 'danger-button' : 'primary-action'}
disabled={pending || (needsConfirm && !confirmed)}
onClick={() => void run()}
>
{pending ? '执行中' : '执行'}
</button>
<button
type="button"
className="fleet-messages-more"
disabled={pending}
onClick={() => {
setOpen(false);
setError(null);
setResult(null);
}}
>
</button>
</div>
</div>
) : null}
{error ? (
<p className="device-action-result is-error" role="alert">
{error}
</p>
) : null}
{result ? (
<p
className={`device-action-result ${result.ok ? 'is-ok' : 'is-error'}`}
role={result.ok ? 'status' : 'alert'}
>
{result.ok ? '设备已接受该操作' : '设备拒绝了该操作'}
{result.message ? `${result.message}` : '。'}
{result.durationMs > 0 ? ` 用时 ${(result.durationMs / 1000).toFixed(1)} 秒。` : ''}
</p>
) : null}
</li>
);
}
export interface DeviceActionControlsProps {
readonly module: string;
readonly actions: readonly DeviceActionDescriptor[];
readonly source: DeviceActionSource;
readonly onExecuted?: (() => void) | undefined;
}
export function DeviceActionControls({
module,
actions,
source,
onExecuted,
}: DeviceActionControlsProps) {
const owned = actions.filter((action) => action.module === module);
if (!owned.length) return null;
return (
<section className="cellular-card device-action-card" aria-label="设备操作">
<h2></h2>
<ul className="device-action-list">
{owned.map((action) => (
<ActionRow
key={action.id}
action={action}
source={source}
onExecuted={onExecuted ?? (() => undefined)}
/>
))}
</ul>
</section>
);
}
export interface DeviceActionsProps {
readonly instance: InstanceContext;
readonly module: string;
/** Fires after the device accepts a mutation so the host can re-read its probes. */
readonly onExecuted?: (() => void) | undefined;
readonly source?: DeviceActionSource | undefined;
}
/**
* Drop-in block that loads the catalog for one instance and renders its controls. Any module can
* claim the Hub actions it owns simply by rendering this component.
*/
export function DeviceActions({ instance, module, onExecuted, source }: DeviceActionsProps) {
const resolved = useMemo(
() => source ?? createDeviceActionSource(instance.id),
[source, instance.id],
);
const [actions, setActions] = useState<readonly DeviceActionDescriptor[]>([]);
const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false);
useEffect(() => {
if (instance.authentication !== 'authenticated') {
setActions([]);
setLoaded(false);
setFailed(false);
return;
}
const controller = new AbortController();
void resolved.list(controller.signal).then(
(catalog) => {
if (controller.signal.aborted) return;
setActions(catalog);
setLoaded(true);
setFailed(false);
},
() => {
if (controller.signal.aborted) return;
setActions([]);
setLoaded(true);
setFailed(true);
},
);
return () => controller.abort();
}, [resolved, instance.authentication, instance.id]);
if (instance.authentication !== 'authenticated') return null;
const owned = actions.filter((action) => action.module === module);
if (failed) {
return (
<section className="cellular-card device-action-card" aria-label="设备操作">
<h2></h2>
<p className="field-note"></p>
</section>
);
}
if (!owned.length) {
return loaded ? null : (
<section className="cellular-card device-action-card" aria-label="设备操作">
<h2></h2>
<p role="status" className="field-note">
</p>
</section>
);
}
return (
<DeviceActionControls
module={module}
actions={owned}
source={resolved}
{...(onExecuted ? { onExecuted } : {})}
/>
);
}
@@ -0,0 +1,164 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DeviceDiscoveryPanel, type DiscoveryDataSource } from './device-discovery-panel.js';
import type { DeviceProbe, DiscoverySession } from './discovery-api-data-source.js';
afterEach(cleanup);
const completed: DiscoverySession = {
sessionId: '7a1f0c22-0000-4000-8000-000000000001',
status: 'completed',
createdAt: '2026-09-05T02:00:00.000Z',
expiresAt: '2026-09-05T02:01:00.000Z',
scanned: 508,
total: 508,
ranges: ['192.168.68.0/24'],
devices: [
{
origin: 'http://192.168.68.1:3000',
address: '192.168.68.1',
port: 3000,
secure: false,
httpStatus: 200,
identity: { model: 'iPhone 14', firmware_version: '16.1' },
knownInstanceId: null,
},
{
origin: 'http://192.168.68.2:3000',
address: '192.168.68.2',
port: 3000,
secure: false,
httpStatus: 401,
identity: {},
knownInstanceId: null,
},
{
origin: 'http://192.168.68.3:3000',
address: '192.168.68.3',
port: 3000,
secure: false,
httpStatus: 200,
identity: { brand: 'Google' },
knownInstanceId: 'inst-9',
},
],
};
const reachable: DeviceProbe = {
origin: 'http://192.168.68.9:3000',
reachable: true,
httpStatus: 200,
identity: { model: 'Pixel 7' },
knownInstanceId: null,
};
function source(overrides: Partial<DiscoveryDataSource> = {}): DiscoveryDataSource {
return {
start: vi.fn(async () => completed),
status: vi.fn(async () => completed),
renew: vi.fn(async () => completed),
stop: vi.fn(async () => undefined),
probe: vi.fn(async () => reachable),
...overrides,
};
}
async function mount(dataSource: DiscoveryDataSource): Promise<HTMLElement> {
render(<DeviceDiscoveryPanel dataSource={dataSource} onSelect={vi.fn()} />);
return screen.findByRole('region', { name: '局域网设备发现' });
}
describe('DeviceDiscoveryPanel', () => {
it('shows discovered devices with a status tag per row', async () => {
const region = await mount(source());
const table = await within(region).findByRole('table', { name: '发现的设备' });
expect(within(table).getByText('http://192.168.68.1:3000')).toBeTruthy();
expect(within(table).getByText('型号 iPhone 14 · 固件版本 16.1')).toBeTruthy();
expect(within(table).getByText('未公开身份信息')).toBeTruthy();
expect(within(table).getAllByText('可接入')).toHaveLength(1);
expect(within(table).getByText('需要登录')).toBeTruthy();
expect(within(table).getByText('已接入')).toBeTruthy();
expect(within(region).getByText(/扫描完成 · 已检查 508 个地址 · 发现 3 台设备/)).toBeTruthy();
});
it('hands the chosen device to the parent and locks a device already added', async () => {
const onSelect = vi.fn();
const dataSource = source();
render(<DeviceDiscoveryPanel dataSource={dataSource} onSelect={onSelect} />);
const table = await screen.findByRole('table', { name: '发现的设备' });
const rows = within(table).getAllByRole('button', { name: '接入' });
expect(rows).toHaveLength(3);
expect((rows[2] as HTMLButtonElement).disabled).toBe(true);
await userEvent.click(rows[0] as HTMLButtonElement);
expect(onSelect).toHaveBeenCalledWith({
origin: 'http://192.168.68.1:3000',
name: 'iPhone 14',
});
});
it('restarts the scan and stops the lease when unmounted', async () => {
const dataSource = source();
const { unmount } = render(<DeviceDiscoveryPanel dataSource={dataSource} onSelect={vi.fn()} />);
await screen.findByRole('table', { name: '发现的设备' });
await userEvent.click(screen.getByRole('button', { name: '重新扫描' }));
await waitFor(() => expect(dataSource.start).toHaveBeenCalledTimes(2));
unmount();
await waitFor(() => expect(dataSource.stop).toHaveBeenCalledWith(completed.sessionId));
});
it('probes a manual address and offers to fill the form', async () => {
const onSelect = vi.fn();
const dataSource = source();
render(<DeviceDiscoveryPanel dataSource={dataSource} onSelect={onSelect} />);
const region = await screen.findByRole('region', { name: '局域网设备发现' });
const field = within(region).getByLabelText('设备地址');
expect((field as HTMLInputElement).disabled).toBe(false);
await userEvent.type(field, '192.168.68.9:3000');
await userEvent.click(within(region).getByRole('button', { name: '检测地址' }));
expect(dataSource.probe).toHaveBeenCalledWith('192.168.68.9:3000');
await userEvent.click(await within(region).findByRole('button', { name: '填入添加表单' }));
expect(onSelect).toHaveBeenCalledWith({
origin: 'http://192.168.68.9:3000',
name: 'Pixel 7',
});
});
it('reports an unusable address without throwing', async () => {
const dataSource = source({
probe: vi.fn(async () => {
throw new Error('Discovery request failed.');
}),
});
render(<DeviceDiscoveryPanel dataSource={dataSource} onSelect={vi.fn()} />);
const region = await screen.findByRole('region', { name: '局域网设备发现' });
await userEvent.type(within(region).getByLabelText('设备地址'), 'http://example.com');
await userEvent.click(within(region).getByRole('button', { name: '检测地址' }));
expect(await within(region).findByText(/设备地址无法识别/)).toBeTruthy();
});
it('falls back to manual entry when discovery cannot start', async () => {
const dataSource = source({
start: vi.fn(async () => {
throw new Error('Discovery request failed.');
}),
});
render(<DeviceDiscoveryPanel dataSource={dataSource} onSelect={vi.fn()} />);
const region = await screen.findByRole('region', { name: '局域网设备发现' });
expect(await within(region).findByRole('alert')).toBeTruthy();
expect(within(region).getByRole('alert').textContent).toBe(
'无法启动局域网发现,请改用设备地址接入。',
);
expect(within(region).queryByRole('table', { name: '发现的设备' })).toBeNull();
expect(within(region).getByLabelText('设备地址')).toBeTruthy();
});
});
@@ -0,0 +1,277 @@
import { useCallback, useEffect, useState } from 'react';
import { Button, Card, Tag } from 'animal-island-ui';
import {
createDiscoveryApiDataSource,
type DeviceProbe,
type DiscoveredDevice,
type DiscoveryDataSource,
type DiscoverySession,
} from './discovery-api-data-source.js';
export type { DiscoveryDataSource } from './discovery-api-data-source.js';
const POLL_INTERVAL_MS = 2_000;
const RENEW_INTERVAL_MS = 15_000;
const IDENTITY_LABELS: Readonly<Record<string, string>> = {
model: '型号',
manufacturer: '制造商',
brand: '品牌',
firmware_version: '固件版本',
os_version: '系统版本',
version: '版本',
};
export interface DeviceSelection {
readonly origin: string;
readonly name: string;
}
function suggestName(device: {
readonly identity: Readonly<Record<string, string>>;
readonly address: string;
}): string {
return device.identity.model ?? device.identity.brand ?? device.address;
}
function identitySummary(device: { readonly identity: Readonly<Record<string, string>> }): string {
const entries = Object.entries(device.identity);
if (entries.length === 0) return '未公开身份信息';
return entries
.slice(0, 3)
.map(([field, value]) => `${IDENTITY_LABELS[field] ?? field} ${value}`)
.join(' · ');
}
/**
* LAN discovery plus manual address entry, the Hub onboarding flow rebuilt on top of the
* control plane's own private-address transport. The scan only lives while this panel is mounted.
*/
export function DeviceDiscoveryPanel({
dataSource,
onSelect,
selectedOrigin,
}: {
readonly dataSource?: DiscoveryDataSource;
readonly onSelect: (selection: DeviceSelection) => void;
readonly selectedOrigin?: string;
}) {
const [source] = useState(() => dataSource ?? createDiscoveryApiDataSource());
const [session, setSession] = useState<DiscoverySession>();
const [error, setError] = useState('');
const [attempt, setAttempt] = useState(0);
const [address, setAddress] = useState('');
const [probe, setProbe] = useState<DeviceProbe>();
const [probeError, setProbeError] = useState('');
const [probing, setProbing] = useState(false);
useEffect(() => {
const controller = new AbortController();
let active = true;
let sessionId = '';
let pollTimer: ReturnType<typeof setInterval> | undefined;
let renewTimer: ReturnType<typeof setInterval> | undefined;
setError('');
setSession(undefined);
const read = async () => {
if (!sessionId) return;
try {
const next = await source.status(sessionId, controller.signal);
if (active) setSession(next);
} catch {
/* A dropped poll is retried on the next tick; the lease keeps the scan alive. */
}
};
void (async () => {
try {
const started = await source.start(controller.signal);
if (!active) return;
sessionId = started.sessionId;
setSession(started);
pollTimer = setInterval(() => void read(), POLL_INTERVAL_MS);
renewTimer = setInterval(() => {
void source.renew(sessionId, controller.signal).catch(() => undefined);
}, RENEW_INTERVAL_MS);
} catch {
if (active) setError('无法启动局域网发现,请改用设备地址接入。');
}
})();
return () => {
active = false;
if (pollTimer) clearInterval(pollTimer);
if (renewTimer) clearInterval(renewTimer);
controller.abort();
if (sessionId) void source.stop(sessionId).catch(() => undefined);
};
}, [attempt, source]);
const pick = useCallback(
(device: {
readonly origin: string;
readonly identity: Readonly<Record<string, string>>;
readonly address: string;
}) => {
onSelect({ origin: device.origin, name: suggestName(device) });
},
[onSelect],
);
const runProbe = async () => {
const value = address.trim();
if (!value || probing) return;
setProbing(true);
setProbe(undefined);
setProbeError('');
try {
const result = await source.probe(value);
setProbe(result);
if (!result.reachable) setProbeError('该地址没有响应,请确认设备与本机在同一局域网。');
} catch {
setProbeError('设备地址无法识别,请填写私有网段地址,例如 http://192.168.68.1:3000。');
} finally {
setProbing(false);
}
};
const devices = session?.devices ?? [];
const progress = session
? session.status === 'scanning'
? `正在扫描 ${session.ranges.join('、') || '本机网段'} · 已检查 ${session.scanned.toLocaleString('zh-CN')} / ${session.total.toLocaleString('zh-CN')}`
: `扫描完成 · 已检查 ${session.total.toLocaleString('zh-CN')} 个地址 · 发现 ${devices.length} 台设备`
: '正在启动局域网发现…';
return (
<Card pattern="default" className="settings-card discovery-card">
<section aria-label="局域网设备发现">
<div className="discovery-head">
<div>
<h2></h2>
<p className="maintenance-hint">
</p>
</div>
<Button
htmlType="button"
loading={session === undefined && !error}
onClick={() => setAttempt((value) => value + 1)}
>
</Button>
</div>
{error ? (
<p role="alert" className="state-panel state-error">
{error}
</p>
) : (
<p role="status" className="discovery-progress">
{progress}
</p>
)}
{!error ? (
devices.length === 0 ? (
<p className="maintenance-hint">
{session?.status === 'completed'
? '没有发现可接入的设备,可直接填写设备地址。'
: '尚未发现设备。'}
</p>
) : (
<div className="table-scroll">
<table className="dense-table" aria-label="发现的设备">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th aria-label="操作" />
</tr>
</thead>
<tbody>
{devices.map((device: DiscoveredDevice) => (
<tr key={device.origin}>
<td>
<code>{device.origin}</code>
</td>
<td>{identitySummary(device)}</td>
<td>
{device.knownInstanceId ? (
<Tag size="small" color="app-gray" variant="soft">
</Tag>
) : device.httpStatus >= 400 ? (
<Tag size="small" color="app-orange" variant="soft">
</Tag>
) : (
<Tag size="small" color="app-green" variant="soft">
</Tag>
)}
</td>
<td className="discovery-actions">
<Button
htmlType="button"
type={selectedOrigin === device.origin ? 'default' : 'primary'}
disabled={device.knownInstanceId !== null}
onClick={() => pick(device)}
>
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
) : null}
<div className="discovery-manual" role="group" aria-label="按设备地址接入">
<label>
<input
type="text"
inputMode="url"
placeholder="http://192.168.68.1:3000"
value={address}
maxLength={200}
onChange={(event) => setAddress(event.target.value)}
/>
</label>
<Button
htmlType="button"
loading={probing}
disabled={probing || address.trim().length === 0}
onClick={() => void runProbe()}
>
</Button>
</div>
{probeError ? (
<p role="alert" className="state-panel state-error">
{probeError}
</p>
) : null}
{probe?.reachable ? (
<div className="discovery-probe">
<p>
<code>{probe.origin}</code> 访 · {identitySummary(probe)}
</p>
<Button
htmlType="button"
type="primary"
disabled={probe.knownInstanceId !== null}
onClick={() =>
pick({ origin: probe.origin, identity: probe.identity, address: probe.origin })
}
>
{probe.knownInstanceId ? '该地址已接入' : '填入添加表单'}
</Button>
</div>
) : null}
</section>
</Card>
);
}
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { deviceFieldLabel, deviceValueLabel, normalizeDeviceToken } from './device-field-labels';
describe('device field labels', () => {
it('normalizes separators so camelCase and snake_case match', () => {
expect(normalizeDeviceToken(' Signal-Strength ')).toBe('signal_strength');
expect(deviceFieldLabel('signalStrength')).toBe(deviceFieldLabel('signal_strength'));
expect(deviceFieldLabel('iccid')).toBe('ICCID');
});
it('leaves unknown keys undefined so panels fall back', () => {
expect(deviceFieldLabel('totally_unknown_field')).toBeUndefined();
});
it('localizes enum-like values', () => {
expect(deviceValueLabel('sim_status', 'ready')).toBe('就绪');
expect(deviceValueLabel('pin1_status', 'PIN_LOCKED')).toBe(
deviceValueLabel('pin1_status', 'pin_locked'),
);
});
it('keeps free text values untouched', () => {
expect(deviceValueLabel('ssid', 'ready')).toBeUndefined();
expect(deviceValueLabel('name', 'online')).toBeUndefined();
});
it('refuses to translate long or symbol rich values', () => {
expect(deviceValueLabel('state', '8986 01 12 345')).toBeUndefined();
expect(deviceValueLabel('state', 'a'.repeat(40))).toBeUndefined();
expect(deviceValueLabel('state', '#ready')).toBeUndefined();
});
});
@@ -0,0 +1,581 @@
/**
* Chinese names for the SimAdmin device payload vocabulary.
*
* The read-only device tabs render whatever a probe returns, so a field the console has never
* seen would otherwise fall back to its raw snake_case key. This dictionary is shared by every
* module panel and covers the field and value names the official Hub panel labels, which keeps
* the fused console readable even when a device answers with an unusual payload shape.
*
* Keys and values are matched after normalisation (lowercase, `-`/space → `_`), so a device that
* answers `signalStrength` and one that answers `signal_strength` both render as 信号强度.
*/
export function normalizeDeviceToken(token: string): string {
return token
.trim()
.replace(/([a-z0-9])([A-Z])/gu, '$1_$2')
.toLowerCase()
.replace(/[\s.-]+/gu, '_');
}
const FIELD_LABELS: Readonly<Record<string, string>> = {
// Identity and SIM card
slot: '卡槽',
slot_index: '卡槽',
slot_name: '卡槽名称',
sim_status: 'SIM 状态',
sim_type: 'SIM 类型',
sim_path: '短信路径',
iccid: 'ICCID',
imsi: 'IMSI',
imei: 'IMEI',
meid: 'MEID',
eid: 'EID',
msisdn: '本机号码',
phone_number: '手机号',
spn: '运营商显示名',
alpha_id: '运营商显示名',
nai: '网络接入标识',
subscriber_id: '用户标识',
operator: '运营商',
operator_name: '运营商',
carrier: '运营商',
carrier_name: '运营商',
plmn: 'PLMN',
mcc: '国家码(MCC',
mnc: '运营商码(MNC',
smsc: '短信中心号码',
sms_center: '短信中心号码',
smsc_number: '短信中心号码',
cdma_prl: '漫游列表版本',
enabled: '已启用',
active: '使用中',
present: '已插入',
readable: '可读取',
// Lock and security state
pin1_status: 'PIN1 状态',
pin2_status: 'PIN2 状态',
puk1_status: 'PUK1 状态',
puk2_status: 'PUK2 状态',
pin1_retry: 'PIN1 剩余尝试',
pin2_retry: 'PIN2 剩余尝试',
puk1_retry: 'PUK1 剩余尝试',
puk2_retry: 'PUK2 剩余尝试',
lock_state: '锁卡状态',
fdn_enabled: '固定拨号',
auth_enabled: '已启用认证',
two_factor_enabled: '双因素验证',
password_required: '需要密码',
session_timeout_seconds: '会话超时(秒)',
max_login_attempts: '最大登录尝试',
allow_recovery: '允许恢复',
// Cellular registration and radio
registration: '注册状态',
register_state: '注册状态',
attach_state: '附着状态',
network_type: '网络制式',
technology: '无线技术',
rat: '制式',
radio_mode: '射频模式',
preferred_mode: '首选制式',
roaming: '漫游',
roaming_enabled: '漫游开关',
roaming_data: '漫游数据',
roaming_protocol: '漫游协议',
airplane_mode: '飞行模式',
data_enabled: '数据连接',
sms_enabled: '短信',
voice_enabled: '语音',
auto_reconnect: '自动重连',
band: '频段',
bands: '频段',
lte_bands: 'LTE 频段',
nr_bands: 'NR 频段',
lte_fdd_bands: 'LTE FDD 频段',
lte_tdd_bands: 'LTE TDD 频段',
nr_fdd_bands: 'NR FDD 频段',
nr_tdd_bands: 'NR TDD 频段',
support_bands: '支持频段',
band_width: '信道带宽',
downlink_bandwidth: '下行带宽',
uplink_bandwidth: '上行带宽',
// Signal and cell
rssi: 'RSSI',
rsrp: 'RSRP',
rsrq: 'RSRQ',
sinr: 'SINR',
iacs: '接收信号强度',
quality: '信号质量',
signal: '信号强度',
signal_strength: '信号强度',
signal_percent: '信号强度',
signal_level: '信号格数',
cell: '小区',
cell_id: '小区编号',
cid: '小区编号',
pci: '物理小区号(PCI',
scrambling: '扰码',
tac: '跟踪区码(TAC',
lac: '位置区码(LAC',
arfcn: '频点(ARFCN',
earfcn: '频点(EARFCN',
nrarfcn: '频点(NR-ARFCN',
channel: '信道',
timing_advance: '定时提前',
neighbor_cells: '邻区',
cells: '小区列表',
latitude: '纬度',
longitude: '经度',
accuracy: '定位精度',
altitude: '海拔',
// Operators
current: '当前运营商',
available: '可用运营商',
operators: '运营商列表',
networks: '可用网络',
status: '状态',
state: '状态',
mode: '模式',
detail: '详情',
reason: '原因',
message: '内容',
error: '错误',
last_error: '最近错误',
// Device system resources
hostname: '主机名',
model: '型号',
manufacturer: '制造商',
brand: '品牌',
vendor: '厂商',
architecture: '架构',
arch: '架构',
kernel: '内核版本',
firmware: '固件版本',
firmware_version: '固件版本',
baseband_version: '基带版本',
hardware_version: '硬件版本',
os_version: '系统版本',
version: '版本',
build_time: '构建时间',
uptime: '运行时长',
uptime_seconds: '运行时长(秒)',
boot_time: '启动时间',
cpu_percent: 'CPU 占用',
load_average: '平均负载',
memory_total: '内存总量',
memory_used: '已用内存',
memory_available: '可用内存',
memory_percent: '内存占用',
disk_total: '磁盘总量',
disk_used: '已用磁盘',
disk_free: '可用磁盘',
disk_percent: '磁盘占用',
temperature: '温度',
max_temperature: '最高温度',
max_temperature_celsius: '最高温度',
min_temperature: '最低温度',
battery_percent: '电量',
battery_status: '电池状态',
charging: '充电状态',
power_state: '电源状态',
processes: '进程数',
// Traffic
rx_bytes: '下行流量',
tx_bytes: '上行流量',
download_bytes: '下行流量',
upload_bytes: '上行流量',
total_bytes: '总流量',
rx_rate: '下行速率',
tx_rate: '上行速率',
since: '统计起始时间',
// Device network, WLAN and DDNS
interface: '网卡',
interfaces: '网卡',
name: '名称',
label: '标签',
ip_address: 'IP 地址',
ipv4: 'IPv4 地址',
ipv6: 'IPv6 地址',
ipv6_public: 'IPv6 公网地址',
public_ip: '公网地址',
gateway: '网关',
netmask: '子网掩码',
prefix: '前缀长度',
dns: 'DNS 服务器',
dns_servers: 'DNS 服务器',
mac: 'MAC 地址',
mac_address: 'MAC 地址',
method: '获取方式',
dhcp: 'DHCP',
internal: '内部接口',
tx_packets: '发送数据包',
rx_packets: '接收数据包',
tx_bytes_total: '发送字节',
rx_bytes_total: '接收字节',
ssid: '网络名称(SSID',
bssid: '热点标识(BSSID',
strength: '信号强度',
frequency: '工作频率',
security: '安全类型',
connection_id: '连接标识',
uuid: '配置 UUID',
auto_join: '自动加入',
last_seen: '最近在线',
last_seen_at: '最近在线',
provider: '服务商',
domain: '域名',
domains: '解析域名',
records: '解析记录',
interval: '更新间隔',
interval_seconds: '更新间隔(秒)',
update_interval_seconds: '更新间隔(秒)',
last_update_at: '最近更新',
last_sync_at: '最近同步',
record_type: '记录类型',
ttl: 'TTL',
// eSIM
profiles: 'Profile 列表',
nickname: '名称',
profile_name: 'Profile 名称',
profile_class: 'Profile 类型',
is_default: '当前使用',
is_enabled: '已启用',
enabled_count: '已启用数量',
total_count: '总数',
smdp: 'SM-DP+ 地址',
smdp_address: 'SM-DP+ 地址',
matching_id: '标识码(Matching ID',
confirmation_code: '确认码',
notification_address: '通知地址',
total_capacity: '芯片总容量(KB',
used_capacity: '已用容量(KB',
available_capacity: '可用容量(KB',
lpac: 'lpac 版本',
lpac_status: 'lpac 状态',
ready: '就绪',
// Calls
direction: '方向',
duration: '通话时长',
duration_seconds: '通话时长(秒)',
started_at: '开始时间',
finished_at: '结束时间',
ended_at: '结束时间',
call_type: '通话类型',
line1_number: '主卡号码',
line2_number: '副卡号码',
// Messages
content: '正文',
timestamp: '时间',
received_at: '接收时间',
sent_at: '发送时间',
transport: '通道',
unread: '未读',
thread_id: '会话编号',
// Notifications and automation
channels: '渠道',
channel_type: '渠道类型',
rules: '规则',
trigger: '触发条件',
action: '动作',
actions: '动作',
attempts: '尝试次数',
retries: '重试次数',
delivered: '已送达',
pending: '待处理',
processing: '处理中',
failed: '失败',
success: '成功',
total: '总计',
level: '级别',
kind: '类别',
title: '事件',
trace_id: '追踪编号',
timeline: '时间线',
events: '事件',
schedule: '计划',
cron: 'Cron 表达式',
next_run_at: '下次执行',
last_run_at: '上次执行',
last_attempt_at: '最近尝试',
last_success_at: '最近成功',
result: '结果',
reconnects: '重连次数',
failures: '失败次数',
phase: '阶段',
// OTA
current_version: '当前版本',
latest_version: '最新版本',
release_notes: '更新说明',
package: '更新包',
package_name: '更新包名称',
asset_name: '升级包名称',
size: '大小',
size_bytes: '大小(字节)',
md5: 'MD5 校验值',
sha256: 'SHA256 摘要',
proxy_prefix: '加速节点前缀',
restart_now: '立即重启',
pending_update: '待安装更新',
// Backup
files: '备份文件',
filename: '文件名',
path: '路径',
components: '组件',
retention_days: '保留天数',
retention: '保留数量',
max_backups: '最大备份数',
destination: '存储位置',
created_at: '创建时间',
updated_at: '更新时间',
type: '类型',
description: '说明',
required: '必选',
// Hub binding and device settings
bound: '已绑定',
connected: '已连接',
connecting: '连接中',
hub_url: 'Hub 地址',
hub_name: 'Hub 名称',
hub_id: 'Hub 编号',
device_id: '设备编号',
device_name: '设备名称',
work_mode: '工作模式',
local_fallback_enabled: '本地兜底',
local_fallback_timeout_seconds: '本地兜底超时(秒)',
address: '地址',
supported: '受支持',
toggleable: '可切换',
registered: '已注册',
recommended: '推荐',
observed_at: '观测时间',
};
const VALUE_LABELS: Readonly<Record<string, string>> = {
// Generic lifecycle
ok: '正常',
healthy: '正常',
normal: '正常',
success: '成功',
succeeded: '成功',
completed: '已完成',
failed: '失败',
error: '错误',
warning: '警告',
critical: '严重',
info: '信息',
debug: '调试',
unknown: '未知',
none: '无',
null: '无',
idle: '空闲',
busy: '忙碌',
waiting: '等待中',
pending: '等待中',
queued: '排队中',
processing: '处理中',
running: '运行中',
in_progress: '进行中',
paused: '已暂停',
skipped: '已跳过',
aborted: '已中止',
cancelled: '已取消',
timeout: '超时',
ready: '就绪',
not_ready: '未就绪',
// Enablement
enabled: '已启用',
disabled: '已停用',
active: '已激活',
inactive: '未激活',
permitted: '已允许',
denied: '已拒绝',
allowed: '已允许',
blocked: '已锁死',
unsupported: '不支持',
supported: '支持',
available: '可用',
unavailable: '不可用',
required: '需要',
optional: '可选',
// SIM presence and lock
present: '已插入',
absent: '未插入',
inserted: '已插入',
removed: '未插入',
not_inserted: '未插入',
ready_pin: '就绪(需要 PIN',
pin_required: '需要 PIN',
pin_locked: 'PIN 已锁定',
puk_required: '需要 PUK 解锁',
puk_locked: 'PUK 已锁死',
locked: '已锁定',
unlocked: '未锁定',
local_locked: '本地锁定',
unknown_lock: '状态未知',
// Registration
not_registered: '未注册',
registered: '已注册',
registering: '注册中',
searching: '搜索中',
limited: '受限服务',
emergency_only: '仅紧急呼叫',
home: '本地网络',
roaming: '漫游中',
attached: '已附着',
detached: '未附着',
detached_registered: '已注册',
// Radio technologies
gsm: '2G GSM',
cdma: 'CDMA',
umts: '3G UMTS',
wcdma: '3G WCDMA',
td_scdma: '3G TD-SCDMA',
evdo: '3G EVDO',
lte: '4G LTE',
lte_ca: '4G LTE 载波聚合',
nr: '5G NR',
nr_sa: '5G 独立组网',
nr_ns: '5G 非独立组网',
auto: '自动',
manual: '手动',
// Data connection
connected: '已连接',
connecting: '连接中',
disconnected: '已断开',
suspended: '已暂停',
scanning: '扫描中',
online: '在线',
offline: '离线',
// APN / auth
ipv4: 'IPv4',
ipv6: 'IPv6',
ipv4v6: 'IPv4/IPv6 双栈',
pap: 'PAP',
chap: 'CHAP',
wpapersonal: 'WPA-PSK',
wpapsk: 'WPA-PSK',
wpa2psk: 'WPA2-PSK',
sae: 'WPA3-SAE',
nopassword: '开放网络',
open: '开放网络',
// Call and message direction
incoming: '来电',
outgoing: '去电',
missed: '未接',
rejected: '已拒接',
answered: '已接听',
dialing: '拨号中',
alerting: '对方振铃',
hold: '保持中',
// Work modes
sim: '本机 SIM 管理',
sim_overseas: '海外卡模式',
esim: 'eSIM 模式',
replace: '整体替换',
merge: '合并',
// OTA
up_to_date: '已是最新',
update_available: '发现新版本',
downloading: '下载中',
downloaded: '已下载',
verifying: '校验中',
verified: '校验通过',
installing: '安装中',
installed: '已安装',
applied: '已应用',
rebooting: '重启中',
staged: '待安装',
// Power
charging: '充电中',
discharging: '电池供电',
full: '已充满',
not_charging: '未充电',
on_battery: '电池供电',
plugged: '已接通电源',
// eSIM profile classes
default: '默认',
testing: '测试',
provisioning: '预配置',
managing: '管理型',
// VoWiFi
connected_registered: '已注册',
connection_failed: '连接失败',
not_enabled: '未启用',
};
/**
* Keys whose values are free text. Localizing them would rewrite a device-chosen name such as an
* SSID called "open", so value lookup is skipped for these.
*/
const FREE_TEXT_KEYS: ReadonlySet<string> = new Set([
'alpha_id',
'apn',
'asset_name',
'brand',
'carrier',
'carrier_name',
'content',
'description',
'detail',
'device_name',
'domain',
'filename',
'hub_name',
'hostname',
'label',
'manufacturer',
'message',
'model',
'name',
'nickname',
'operator',
'operator_name',
'package',
'package_name',
'path',
'profile_name',
'reason',
'release_notes',
'spn',
'ssid',
'title',
'vendor',
]);
export function deviceFieldLabel(key: string): string | undefined {
return FIELD_LABELS[normalizeDeviceToken(key)];
}
/** Returns a Chinese label only for short enum-like values on keys that are not free text. */
export function deviceValueLabel(key: string, value: string): string | undefined {
const normalizedKey = normalizeDeviceToken(key);
if (FREE_TEXT_KEYS.has(normalizedKey)) return undefined;
if (!/^[A-Za-z][A-Za-z0-9 +/_-]{0,31}$/u.test(value.trim())) return undefined;
return VALUE_LABELS[normalizeDeviceToken(value)];
}
@@ -0,0 +1,208 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ReactElement } from 'react';
import type { InstanceContext } from '../app-shell.js';
import { SensitiveRevealProvider } from '../privacy/sensitive-reveal.js';
import { ConfigurationModule, SimModule } from './device-module-panels.js';
import type { InstanceModuleSnapshot } from './instance-module-api-data-source.js';
afterEach(() => {
cleanup();
vi.restoreAllMocks();
localStorage.clear();
});
function renderWithPrivacy(ui: ReactElement) {
return render(<SensitiveRevealProvider>{ui}</SensitiveRevealProvider>);
}
const instance: InstanceContext = {
id: 'owner',
name: 'Owner modem',
origin: 'https://node.internal',
status: 'online',
authentication: 'authenticated',
freshness: 'fresh',
};
function snapshot(overrides: Partial<InstanceModuleSnapshot> = {}): InstanceModuleSnapshot {
return {
instanceId: 'owner',
module: 'sim',
observedAt: '2026-09-04T02:00:00.000Z',
authenticated: true,
sections: [
{
key: 'sim',
path: '/sim',
state: 'ok',
status: 200,
data: {
slots: [
{
slot: 1,
status: 'ready',
iccid: '89860112345678901234',
phone_number: '13800001234',
},
],
},
},
{ key: 'apn', path: '/apn', state: 'empty', status: 204, data: null },
{ key: 'bandLock', path: '/band-lock', state: 'unsupported', status: 404, data: null },
],
...overrides,
};
}
describe('DeviceModulePanel', () => {
it('renders nested device rows and reports per-section availability', async () => {
const reader = { read: vi.fn(async () => snapshot()) };
renderWithPrivacy(<SimModule instance={instance} reader={reader} />);
expect(await screen.findByRole('heading', { name: /SIM 卡状态/ })).toBeTruthy();
expect(screen.getByRole('heading', { name: /APN 配置/ })).toBeTruthy();
expect(screen.getByText('设备返回成功,但没有可用数据。')).toBeTruthy();
expect(screen.getByText('当前设备固件未提供该接口。')).toBeTruthy();
// Device enum values arrive in English; the shared dictionary renders them in Chinese.
expect(screen.getByRole('cell', { name: '就绪' })).toBeTruthy();
expect(screen.queryByRole('heading', { name: /安全与锁卡/ })).toBeNull();
});
it('spreads a nested settings object into the parent card', async () => {
const reader = {
read: vi.fn(async () =>
snapshot({
module: 'configuration',
sections: [
{
key: 'authSettings',
path: '/auth/settings',
state: 'ok',
status: 200,
data: {
configured: true,
settings: { password_min_length: 8, session_ttl_seconds: 604_800 },
},
},
{ key: 'workMode', path: '/work-mode', state: 'empty', status: 200, data: {} },
{
key: 'authStatus',
path: '/auth/status',
state: 'unsupported',
status: 404,
data: null,
},
{ key: 'hub', path: '/hub', state: 'empty', status: 200, data: {} },
],
}),
),
};
renderWithPrivacy(<ConfigurationModule instance={instance} reader={reader} />);
expect(await screen.findByRole('heading', { name: /设备安全设置/ })).toBeTruthy();
expect(screen.getByText('604800')).toBeTruthy();
expect(screen.getByText('是')).toBeTruthy();
// A probe the firmware never exposed is stated plainly instead of rendered as raw JSON.
expect(screen.getByRole('heading', { name: /设备认证状态/ })).toBeTruthy();
expect(screen.getAllByText('当前设备固件未提供该接口。').length).toBeGreaterThan(0);
});
it('splits PIN lock fields out of the SIM identity card', async () => {
const reader = {
read: vi.fn(async () =>
snapshot({
sections: [
{
key: 'sim',
path: '/sim',
state: 'ok',
status: 200,
data: {
slot: 1,
iccid: '8986011234567890123',
pin1_status: 'pin_locked',
pin1_retry: 2,
puk1_retry: 9,
},
},
],
}),
),
};
renderWithPrivacy(<SimModule instance={instance} reader={reader} />);
const identity = await screen.findByRole('region', { name: 'SIM 卡状态' });
const lock = screen.getByRole('region', { name: '安全与锁卡' });
expect(identity.textContent).toContain('ICCID');
expect(identity.textContent).not.toContain('PIN1');
expect(lock.textContent).toContain('PIN1 状态');
expect(lock.textContent).toContain('PIN 已锁定');
expect(lock.textContent).toContain('PIN1 剩余尝试');
expect(lock.textContent).toContain('PUK1 剩余尝试');
});
it('labels snake_case device keys and localizes boolean values', async () => {
const reader = {
read: vi.fn(async () =>
snapshot({
sections: [
{ key: 'sim', path: '/sim', state: 'ok', status: 200, data: { slots: [] } },
{
key: 'bandLock',
path: '/band-lock',
state: 'ok',
status: 200,
data: { enabled: true, mode: 'auto', support_bands: [1, 3, 41] },
},
],
}),
),
};
renderWithPrivacy(<SimModule instance={instance} reader={reader} />);
const band = await screen.findByRole('region', { name: '频段锁定' });
expect(band.textContent).toContain('已启用');
expect(band.textContent).toContain('自动');
expect(band.textContent).toContain('支持频段');
expect(band.textContent).not.toContain('support bands');
});
it('masks subscriber identifiers until the operator reveals them', async () => {
const reader = { read: vi.fn(async () => snapshot()) };
renderWithPrivacy(<SimModule instance={instance} reader={reader} />);
expect(await screen.findByText(/^+ 1234$/u)).toBeTruthy();
expect(screen.queryByText('89860112345678901234')).toBeNull();
const toggle = screen.getByRole('button', { name: '显示敏感标识' });
toggle.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await waitFor(() => expect(screen.getByText('89860112345678901234')).toBeTruthy());
expect(toggle.getAttribute('aria-pressed')).toBe('true');
});
it('stays idle with a clear message when the device is not authenticated', () => {
const reader = { read: vi.fn(async () => snapshot()) };
renderWithPrivacy(
<SimModule instance={{ ...instance, authentication: 'auth-required' }} reader={reader} />,
);
expect(screen.getByRole('alert').textContent).toContain('需要先完成设备认证');
expect(reader.read).not.toHaveBeenCalled();
});
it('shows a retry panel instead of a blank tab when the read fails', async () => {
const reader = {
read: vi.fn(async () => {
throw new Error('upstream down');
}),
};
renderWithPrivacy(<SimModule instance={instance} reader={reader} />);
const alert = await screen.findByRole('alert');
expect(alert.textContent).toContain('无法加载');
expect(screen.getByRole('button', { name: '重试' })).toBeTruthy();
});
});
@@ -0,0 +1,415 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { InstanceContext } from '../app-shell.js';
import { protectValue } from '../privacy/sensitive-fields.js';
import { useSensitiveReveal } from '../privacy/sensitive-reveal.js';
import { displayValue } from '../ui/locale.js';
import { deviceFieldLabel, deviceValueLabel, normalizeDeviceToken } from './device-field-labels.js';
import { DeviceActions } from './device-action-controls.js';
import type {
InstanceModuleReader,
InstanceModuleSection,
InstanceModuleSnapshot,
} from './instance-module-api-data-source.js';
export interface DeviceModuleSectionSpec {
/** Section key produced by the control-plane module probe. */
readonly key: string;
readonly label: string;
/** Optional Chinese labels for device field names. */
readonly fields?: Readonly<Record<string, string>>;
/** Set when the device nests the interesting rows under one key, such as `slots` or `files`. */
readonly listKey?: string;
/** Maximum rows rendered when the section is a list. */
readonly limit?: number;
/**
* When set, the section renders only these device keys. Lets one probe feed two cards, such as
* the SIM identity table and the separate PIN/PUK lock panel.
*/
readonly only?: readonly string[];
/** Device keys this section never renders; they belong to a sibling section instead. */
readonly exclude?: readonly string[];
/** Skip the whole card when the device reported none of this section's fields. */
readonly optional?: boolean;
}
export interface DeviceModulePanelProps {
readonly instance: InstanceContext;
readonly module: string;
readonly sections: readonly DeviceModuleSectionSpec[];
readonly reader?: InstanceModuleReader;
readonly emptyText?: string;
readonly refreshSignal?: unknown;
}
type ReadState =
| { kind: 'idle' }
| { kind: 'loading'; snapshot?: InstanceModuleSnapshot }
| { kind: 'ready'; snapshot: InstanceModuleSnapshot }
| { kind: 'error'; message: string; snapshot?: InstanceModuleSnapshot };
const STATE_LABELS: Readonly<Record<InstanceModuleSection['state'], string>> = {
ok: '正常',
empty: '无数据',
'auth-required': '需要认证',
unsupported: '设备不支持',
failed: '读取失败',
};
const COMMON_LABELS: Readonly<Record<string, string>> = {
enabled: '已启用',
disabled: '已停用',
state: '状态',
status: '状态',
mode: '模式',
name: '名称',
label: '标签',
provider: '服务商',
hostname: '主机名',
ssid: '网络名称',
security: '安全类型',
priority: '优先级',
interval: '更新间隔',
interval_seconds: '更新间隔(秒)',
update_interval_seconds: '更新间隔(秒)',
last_update_at: '最近更新',
last_event_at: '最近事件',
total: '总计',
pending: '待处理',
processing: '处理中',
delivered: '已送达',
failed: '失败',
level: '级别',
message: '内容',
created_at: '创建时间',
updated_at: '更新时间',
size: '大小',
size_bytes: '大小(字节)',
path: '路径',
filename: '文件名',
version: '版本',
current_version: '当前版本',
latest_version: '最新版本',
model: '型号',
manufacturer: '制造商',
imei: 'IMEI',
iccid: 'ICCID',
imsi: 'IMSI',
phone_number: '手机号',
operator: '运营商',
operator_name: '运营商',
roaming: '漫游',
auto_renew: '自动续期',
components: '组件',
retention_days: '保留天数',
max_backups: '最大备份数',
registered: '已注册',
connected: '已连接',
available: '可用',
supported: '受支持',
reason: '原因',
detail: '详情',
};
function fieldLabel(key: string, fields?: Readonly<Record<string, string>>): string {
return fields?.[key] ?? COMMON_LABELS[key] ?? deviceFieldLabel(key) ?? key.replace(/_/gu, ' ');
}
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function scalar(value: unknown, key?: string): string | null {
if (typeof value === 'string') {
const trimmed = value.length > 200 ? `${value.slice(0, 200)}` : value;
return key === undefined ? trimmed : (deviceValueLabel(key, trimmed) ?? trimmed);
}
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
if (typeof value === 'boolean') return value ? '是' : '否';
if (value === null) return '—';
if (Array.isArray(value)) {
const parts = value
.slice(0, 8)
.map((item) => (isRecord(item) ? scalar(item['name'] ?? item['label']) : scalar(item)))
.filter((item): item is string => item !== null);
return parts.length ? parts.join('、') : '空列表';
}
return null;
}
/** A nested record of plain values reads better spread across the parent table than dropped. */
function flattenable(value: unknown): value is Readonly<Record<string, unknown>> {
if (!isRecord(value)) return false;
const entries = Object.entries(value);
return entries.length > 0 && entries.length <= 12 && entries.every(([, item]) => !isRecord(item));
}
function selected(data: unknown, spec: DeviceModuleSectionSpec): readonly [string, unknown][] {
if (!isRecord(data)) return [];
const only = spec.only ? new Set(spec.only.map(normalizeDeviceToken)) : undefined;
const exclude = spec.exclude ? new Set(spec.exclude.map(normalizeDeviceToken)) : undefined;
const keeps = (key: string): boolean => {
const normalized = normalizeDeviceToken(key);
return (!only || only.has(normalized)) && !(exclude && exclude.has(normalized));
};
const picked: [string, unknown][] = [];
for (const [key, value] of Object.entries(data)) {
if (picked.length >= 40) break;
if (!keeps(key)) continue;
if (flattenable(value)) {
for (const [nestedKey, nestedValue] of Object.entries(value)) {
if (picked.length >= 40) break;
if (keeps(nestedKey)) picked.push([nestedKey, nestedValue]);
}
continue;
}
picked.push([key, value]);
}
return picked;
}
function entries(data: unknown, spec: DeviceModuleSectionSpec): readonly [string, string][] {
const rows: [string, string][] = [];
for (const [key, value] of selected(data, spec)) {
const rendered = scalar(value, key);
if (rendered !== null) rows.push([key, rendered]);
}
return rows;
}
function rows(
data: unknown,
limit: number,
listKey?: string,
): readonly Readonly<Record<string, unknown>>[] {
const nested = listKey && isRecord(data) ? data[listKey] : undefined;
const source = Array.isArray(nested)
? nested
: Array.isArray(data)
? data
: isRecord(data)
? (data['items'] ?? data['list'] ?? data['profiles'])
: undefined;
if (!Array.isArray(source)) return [];
return source.slice(0, limit).flatMap((item) => (isRecord(item) ? [item] : []));
}
function columns(items: readonly Readonly<Record<string, unknown>>[]): readonly string[] {
const seen: string[] = [];
for (const item of items) {
for (const key of Object.keys(item)) {
if (!seen.includes(key)) seen.push(key);
if (seen.length >= 8) return seen;
}
}
return seen;
}
function tableColumns(
items: readonly Readonly<Record<string, unknown>>[],
spec: DeviceModuleSectionSpec,
): readonly string[] {
const all = columns(items);
const only = spec.only ? new Set(spec.only.map(normalizeDeviceToken)) : undefined;
const exclude = spec.exclude ? new Set(spec.exclude.map(normalizeDeviceToken)) : undefined;
const filtered = all.filter((key) => {
const normalized = normalizeDeviceToken(key);
return (!only || only.has(normalized)) && !(exclude && exclude.has(normalized));
});
// An `only` list that matches nothing means the section genuinely has no data; an `exclude`
// list that removes everything falls back so a probe shape change cannot blank the card.
return only ? filtered : filtered.length ? filtered : all;
}
function sectionBody(
section: InstanceModuleSection | undefined,
spec: DeviceModuleSectionSpec,
revealed: boolean,
): ReactNode {
if (!section) return <p>{`未提供${spec.label}数据。`}</p>;
if (section.state !== 'ok') {
return (
<p className="field-note">
{section.state === 'auth-required'
? '设备需要重新认证后才能读取该数据。'
: section.state === 'unsupported'
? '当前设备固件未提供该接口。'
: section.state === 'empty'
? '设备返回成功,但没有可用数据。'
: `无法从设备读取${spec.label}`}
</p>
);
}
const list = rows(section.data, spec.limit ?? 20, spec.listKey);
if (list.length) {
const headers = tableColumns(list, spec);
if (!headers.length) return null;
return (
<div className="schedule-table-wrap">
<table className="dense-table">
<thead>
<tr>
{headers.map((header) => (
<th key={header} scope="col">
{fieldLabel(header, spec.fields)}
</th>
))}
</tr>
</thead>
<tbody>
{list.map((item, index) => (
<tr key={index}>
{headers.map((header) => (
<td key={header}>
{protectValue(header, scalar(item[header], header) ?? '—', revealed)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
const fields = entries(section.data, spec);
if (!fields.length) return null;
return (
<dl>
{fields.map(([key, value]) => (
<div key={key}>
<dt>{fieldLabel(key, spec.fields)}</dt>
<dd>{protectValue(key, value, revealed)}</dd>
</div>
))}
</dl>
);
}
/**
* Data-driven panel shared by the device tabs that mirror the official Hub: every section is a
* read-only probe the control plane already performed, so the console never talks to a device
* directly and never invents an endpoint of its own.
*/
export function DeviceModulePanel({
instance,
module,
sections,
reader,
emptyText,
refreshSignal,
}: DeviceModulePanelProps) {
const requestOwner = useRef(0);
const [retry, setRetry] = useState(0);
// One console-wide switch: the topbar button and this in-place shortcut drive the same state.
const { revealed, toggle } = useSensitiveReveal();
const [state, setState] = useState<ReadState>({ kind: 'idle' });
useEffect(() => {
const request = ++requestOwner.current;
const controller = new AbortController();
if (instance.authentication !== 'authenticated' || !reader) {
setState({ kind: 'idle' });
return () => controller.abort();
}
setState((current) => ({
kind: 'loading',
...(current.kind !== 'idle' && current.snapshot?.instanceId === instance.id
? { snapshot: current.snapshot }
: {}),
}));
void reader.read(instance.id, module, controller.signal).then(
(snapshot) => {
if (request === requestOwner.current && !controller.signal.aborted)
setState({ kind: 'ready', snapshot });
},
() => {
if (request === requestOwner.current && !controller.signal.aborted)
setState((current) => ({
kind: 'error',
message: `无法加载${sections[0]?.label ?? '设备'}数据。`,
...(current.kind !== 'idle' && current.snapshot?.instanceId === instance.id
? { snapshot: current.snapshot }
: {}),
}));
},
);
return () => controller.abort();
}, [instance.authentication, instance.id, module, reader, refreshSignal, retry, sections]);
if (instance.authentication !== 'authenticated' || !reader) {
return (
<div className="state-panel state-error" role="alert">
{emptyText ?? '需要先完成设备认证,才能读取该模块的数据。'}
</div>
);
}
const snapshot =
state.kind !== 'idle' && state.snapshot?.instanceId === instance.id
? state.snapshot
: undefined;
if (!snapshot) {
if (state.kind === 'error')
return (
<div className="state-panel state-error" role="alert">
<p>{state.message}</p>
<button type="button" onClick={() => setRetry((value) => value + 1)}>
</button>
</div>
);
return (
<p role="status" aria-label="模块加载状态">
</p>
);
}
return (
<>
<div className="fleet-card-actions">
<button
type="button"
className="fleet-messages-more"
aria-pressed={revealed}
onClick={toggle}
>
{revealed ? '隐藏敏感标识' : '显示敏感标识'}
</button>
</div>
<div className="cellular-grid">
{state.kind === 'error' ? (
<div className="state-panel state-error" role="alert">
<p></p>
<button type="button" onClick={() => setRetry((value) => value + 1)}>
</button>
</div>
) : null}
{sections.map((spec, index) => {
const section = snapshot.sections.find((candidate) => candidate.key === spec.key);
const body = sectionBody(section, spec, revealed);
if (body === null && spec.optional) return null;
return (
<section key={`${spec.key}-${index}`} className="cellular-card" aria-label={spec.label}>
<h2>
{spec.label}
<span className="device-module-state">
{STATE_LABELS[section?.state ?? 'failed']}
</span>
</h2>
{body ?? <p>{`未提供${spec.label}数据。`}</p>}
</section>
);
})}
<DeviceActions
instance={instance}
module={module}
onExecuted={() => setRetry((value) => value + 1)}
/>
<p className="field-note">{displayValue(snapshot.observedAt)}</p>
</div>
</>
);
}
@@ -0,0 +1,372 @@
import type { ReactNode } from 'react';
import type { InstanceContext } from '../app-shell.js';
import type { InstanceModuleReader } from './instance-module-api-data-source.js';
import { DeviceModulePanel, type DeviceModuleSectionSpec } from './device-module-panel.js';
/**
* Device tabs that exist in the official Hub. Each one is a thin declaration of which read-only
* probes the control plane should collect and how their fields are named in Chinese; the shared
* panel does the loading, masking, and rendering.
*/
export interface DeviceModuleProps {
readonly instance: InstanceContext;
readonly reader?: InstanceModuleReader;
readonly refreshSignal?: unknown;
}
/**
* PIN/PUK fields the Hub shows as a separate 安全与锁卡 card. They arrive inside the same `/sim`
* payload as the identity fields, so the two sections split the probe by key.
*/
const SIM_LOCK_KEYS: readonly string[] = [
'pin1_status',
'pin2_status',
'puk1_status',
'puk2_status',
'pin_state',
'puk_state',
'pin_locked',
'puk_locked',
'pin1_retry',
'pin2_retry',
'puk1_retry',
'puk2_retry',
'lock_state',
'fdn_enabled',
];
const SIM_SECTIONS: readonly DeviceModuleSectionSpec[] = [
{
key: 'sim',
label: 'SIM 卡状态',
listKey: 'slots',
exclude: SIM_LOCK_KEYS,
fields: {
slot: '卡槽',
slot_index: '卡槽',
status: '状态',
state: '状态',
sim_status: '状态',
operator: '运营商',
carrier: '运营商',
network_type: '网络制式',
imsi: 'IMSI',
iccid: 'ICCID',
phone_number: '号码',
msisdn: '号码',
imei: 'IMEI',
mcc: 'MCC',
mnc: 'MNC',
enabled: '已启用',
active: '已激活',
},
},
{
key: 'sim',
label: '安全与锁卡',
listKey: 'slots',
only: SIM_LOCK_KEYS,
optional: true,
},
{
key: 'apn',
label: 'APN 配置',
listKey: 'apns',
fields: {
apn: 'APN',
name: '名称',
protocol: '协议',
pdn_type: 'PDN 类型',
mcc: 'MCC',
mnc: 'MNC',
active: '使用中',
enabled: '已启用',
profile_id: '配置编号',
roaming_protocol: '漫游协议',
},
},
{
key: 'bandLock',
label: '频段锁定',
fields: {
enabled: '已启用',
mode: '模式',
bands: '频段',
lte_bands: 'LTE 频段',
nr_bands: 'NR 频段',
support_bands: '支持频段',
},
},
{
key: 'cellLock',
label: '小区锁定',
fields: {
enabled: '已启用',
mode: '模式',
band: '频段',
arfcn: '频点',
pci: 'PCI',
cell_id: '小区编号',
tac: 'TAC',
},
},
];
const CONFIGURATION_SECTIONS: readonly DeviceModuleSectionSpec[] = [
{
key: 'workMode',
label: '工作模式',
fields: {
mode: '模式',
work_mode: '工作模式',
enabled: '已启用',
sms_enabled: '短信',
data_enabled: '数据',
voice_enabled: '语音',
auto_reconnect: '自动重连',
},
},
{
key: 'authSettings',
label: '设备安全设置',
fields: {
configured: '已配置密码',
password_protection_enabled: '启用密码保护',
password_min_length: '密码最小长度',
password_require_letters: '密码须含字母',
password_require_digits: '密码须含数字',
password_require_symbols: '密码须含符号',
session_ttl_seconds: '会话有效期(秒)',
idle_timeout_seconds: '空闲超时(秒)',
},
},
{
key: 'authStatus',
label: '设备认证状态',
optional: true,
fields: {
configured: '已配置密码',
authenticated: '已认证',
locked_until: '锁定至',
remaining_attempts: '剩余尝试次数',
},
},
{
key: 'hub',
label: 'Hub 绑定',
fields: {
bound: '已绑定',
connected: '已连接',
hub_name: 'Hub 名称',
hub_id: 'Hub 编号',
device_name: '设备名称',
device_id: '设备编号',
address: '地址',
version: '版本',
last_seen_at: '最近在线',
},
},
];
const DEVICE_BACKUP_SECTIONS: readonly DeviceModuleSectionSpec[] = [
{
key: 'files',
label: '备份文件',
listKey: 'files',
limit: 40,
fields: {
name: '文件名',
size: '大小',
size_bytes: '大小(字节)',
created_at: '创建时间',
updated_at: '更新时间',
type: '类型',
components: '包含内容',
},
},
{
key: 'config',
label: '备份配置',
fields: {
enabled: '自动备份',
schedule: '计划',
retention: '保留数量',
destination: '存储位置',
components: '包含内容',
},
},
{
key: 'options',
label: '可备份内容',
listKey: 'options',
fields: {
key: '项目',
name: '名称',
label: '名称',
description: '说明',
enabled: '已选择',
required: '必选',
size: '大小',
},
},
];
const VOWIFI_SECTIONS: readonly DeviceModuleSectionSpec[] = [
{
key: 'status',
label: 'WiFi Calling 状态',
fields: {
enabled: '已启用',
registered: '已注册',
state: '状态',
status: '状态',
carrier: '运营商',
domain: '域名',
last_error: '最近错误',
},
},
{
key: 'control',
label: 'WiFi Calling 控制',
fields: {
enabled: '已启用',
supported: '支持',
toggleable: '可切换',
reason: '原因',
},
},
{
key: 'profile',
label: '当前配置',
fields: {
id: '编号',
name: '名称',
carrier: '运营商',
domain: '域名',
imsi: 'IMSI',
enabled: '已启用',
},
},
{
key: 'profiles',
label: '可用配置',
listKey: 'profiles',
fields: {
id: '编号',
name: '名称',
carrier: '运营商',
domain: '域名',
enabled: '已启用',
recommended: '推荐',
},
},
{
key: 'diagnostics',
label: '注册诊断时间线',
listKey: 'timeline',
limit: 50,
fields: {
timestamp: '时间',
kind: '类别',
level: '级别',
title: '事件',
detail: '详情',
},
},
{
key: 'events',
label: 'WiFi Calling 事件',
listKey: 'events',
limit: 50,
fields: {
timestamp: '时间',
kind: '类别',
level: '级别',
title: '事件',
detail: '详情',
trace_id: '追踪编号',
},
},
{
key: 'smsDeliveries',
label: 'WiFi Calling 短信送达',
listKey: 'deliveries',
limit: 20,
fields: {
created_at: '时间',
direction: '方向',
state: '状态',
sender: '发送方',
recipient: '接收方',
carrier: '运营商',
error: '错误',
},
},
{
key: 'soakRuns',
label: 'WiFi Calling 稳定性巡检',
listKey: 'runs',
limit: 20,
fields: {
started_at: '开始时间',
finished_at: '结束时间',
duration_seconds: '持续(秒)',
result: '结果',
reconnects: '重连次数',
failures: '失败次数',
},
},
{
key: 'restore',
label: 'eSIM 恢复状态',
fields: {
state: '状态',
phase: '阶段',
enabled: '已启用',
last_attempt_at: '最近尝试',
last_success_at: '最近成功',
attempts: '尝试次数',
detail: '详情',
reason: '原因',
},
},
];
type DeviceModuleComponent = (props: DeviceModuleProps) => ReactNode;
function panel(
module: string,
sections: readonly DeviceModuleSectionSpec[],
emptyText: string,
): DeviceModuleComponent {
return function DeviceModule({ instance, reader, refreshSignal }: DeviceModuleProps) {
return (
<DeviceModulePanel
instance={instance}
module={module}
sections={sections}
{...(reader ? { reader } : {})}
emptyText={emptyText}
{...(refreshSignal === undefined ? {} : { refreshSignal })}
/>
);
};
}
export const SimModule = panel('sim', SIM_SECTIONS, '需要先完成设备认证,才能读取 SIM 卡数据。');
export const ConfigurationModule = panel(
'configuration',
CONFIGURATION_SECTIONS,
'需要先完成设备认证,才能读取设备设置。',
);
export const DeviceBackupModule = panel(
'device-backup',
DEVICE_BACKUP_SECTIONS,
'需要先完成设备认证,才能读取设备备份。',
);
export const VowifiModule = panel(
'vowifi',
VOWIFI_SECTIONS,
'需要先完成设备认证,才能读取 WiFi Calling 数据。',
);
@@ -53,6 +53,7 @@ const snapshot: DeviceNetworkSnapshot = {
addresses: [{ family: 'IPv4', address: '192.0.2.10', prefixLength: 24, scope: 'global' }],
},
],
connectionAddresses: { ipv4: ['198.51.100.7'], ipv6: ['2001:db8::42'] },
ddns: {
status: {
enabled: true,
@@ -108,6 +109,7 @@ describe('Phase 6.3 Device Network read module', () => {
expect(await screen.findByRole('heading', { name: 'WLAN 状态' })).toBeTruthy();
expect(screen.getByRole('heading', { name: 'WLAN 配置文件' })).toBeTruthy();
expect(screen.getByRole('heading', { name: '接口与地址' })).toBeTruthy();
expect(screen.getByRole('heading', { name: '连接地址' })).toBeTruthy();
expect(screen.getByRole('heading', { name: 'DDNS 状态' })).toBeTruthy();
expect(screen.getByRole('heading', { name: 'DDNS 配置' })).toBeTruthy();
expect(screen.getByRole('heading', { name: 'DDNS 日志摘要' })).toBeTruthy();
@@ -124,19 +126,26 @@ describe('Phase 6.3 Device Network read module', () => {
expect(screen.getByText('192.0.2.10/24')).toBeTruthy();
expect(screen.getByText('gateway.example.test')).toBeTruthy();
const connection = screen.getByRole('region', { name: '连接地址' });
expect(within(connection).getByText('198.51.100.7')).toBeTruthy();
expect(within(connection).getByText('2001:db8::42')).toBeTruthy();
expect(screen.getByText(/观测时间:2026-07-17T11:00:00Z/)).toBeTruthy();
});
it('does not render passwords, credentials, arbitrary fields, log bodies, or write controls', async () => {
it('does not render passwords, credentials, arbitrary fields, or log bodies', async () => {
render(<DeviceNetworkModule instance={owner} dataSource={{ load: async () => snapshot }} />);
expect((await screen.findAllByText('Operations Wi-Fi')).length).toBe(2);
expect(document.body.textContent).not.toContain('never-render-this');
expect(document.body.textContent).not.toContain('private-user');
expect(document.body.textContent).not.toContain('private-password');
expect(screen.queryByText(/password/i)).toBeNull();
expect(screen.queryByRole('button')).toBeNull();
expect(screen.getByText(/可执行后端支持.*R1.*不可用/i)).toBeTruthy();
expect(screen.getByText(/可执行后端支持.*R2.*不可用/i)).toBeTruthy();
// The read-only snapshot never carries a control of its own.
expect(
within(screen.getByRole('region', { name: '接口与地址' })).queryByRole('button'),
).toBeNull();
// Actions come from the pinned catalog; without a reachable API the card stays honest.
const actions = await screen.findByRole('region', { name: '设备操作' });
expect(actions.textContent).toContain('无法读取该模块的可用操作');
});
it('is honest when no source is injected and does not invent an endpoint', () => {
@@ -2,70 +2,78 @@ import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { InstanceContext } from '../app-shell.js';
import { displayValue } from '../ui/locale.js';
import { DeviceActions } from './device-action-controls.js';
/** Only these bounded primitives may cross the injected read boundary into the UI. */
export type DeviceNetworkValue = string | number | boolean | null;
export interface WlanStatus {
readonly enabled?: boolean | null;
readonly radioState?: string | null;
readonly connectionState?: string | null;
readonly activeProfile?: string | null;
readonly ssid?: string | null;
readonly enabled?: boolean | null | undefined;
readonly radioState?: string | null | undefined;
readonly connectionState?: string | null | undefined;
readonly activeProfile?: string | null | undefined;
readonly ssid?: string | null | undefined;
}
export interface WlanProfile {
readonly name?: string | null;
readonly ssid?: string | null;
readonly security?: string | null;
readonly enabled?: boolean | null;
readonly priority?: number | null;
readonly name?: string | null | undefined;
readonly ssid?: string | null | undefined;
readonly security?: string | null | undefined;
readonly enabled?: boolean | null | undefined;
readonly priority?: number | null | undefined;
}
export interface NetworkAddress {
readonly family?: string | null;
readonly address?: string | null;
readonly prefixLength?: number | null;
readonly scope?: string | null;
readonly family?: string | null | undefined;
readonly address?: string | null | undefined;
readonly prefixLength?: number | null | undefined;
readonly scope?: string | null | undefined;
}
export interface NetworkInterface {
readonly name?: string | null;
readonly kind?: string | null;
readonly state?: string | null;
readonly macAddress?: string | null;
readonly mtu?: number | null;
readonly name?: string | null | undefined;
readonly kind?: string | null | undefined;
readonly state?: string | null | undefined;
readonly macAddress?: string | null | undefined;
readonly mtu?: number | null | undefined;
readonly addresses: readonly NetworkAddress[];
}
export interface DdnsStatus {
readonly enabled?: boolean | null;
readonly state?: string | null;
readonly lastUpdateAt?: string | null;
readonly enabled?: boolean | null | undefined;
readonly state?: string | null | undefined;
readonly lastUpdateAt?: string | null | undefined;
}
/** Credentials are intentionally absent. Do not add password, token, secret, or username fields. */
export interface DdnsConfig {
readonly provider?: string | null;
readonly hostname?: string | null;
readonly updateIntervalSeconds?: number | null;
readonly provider?: string | null | undefined;
readonly hostname?: string | null | undefined;
readonly updateIntervalSeconds?: number | null | undefined;
}
/** This is aggregate metadata only; raw DDNS log messages are intentionally unsupported. */
export interface DdnsLogSummary {
readonly totalEntries?: number | null;
readonly successfulUpdates?: number | null;
readonly failedUpdates?: number | null;
readonly lastEventAt?: string | null;
readonly totalEntries?: number | null | undefined;
readonly successfulUpdates?: number | null | undefined;
readonly failedUpdates?: number | null | undefined;
readonly lastEventAt?: string | null | undefined;
}
export interface DeviceNetworkSnapshot {
readonly observedAt?: string;
readonly observedAt?: string | undefined;
readonly wlan: {
readonly status: WlanStatus;
readonly profiles: readonly WlanProfile[];
};
readonly interfaces: readonly NetworkInterface[];
/** Uplink addresses reported by the device, used for the connection summary. */
readonly connectionAddresses?:
| {
readonly ipv4: readonly string[];
readonly ipv6: readonly string[];
}
| undefined;
readonly ddns: {
readonly status: DdnsStatus;
readonly config: DdnsConfig;
@@ -80,9 +88,9 @@ export interface DeviceNetworkDataSource {
export interface DeviceNetworkModuleProps {
readonly instance: InstanceContext;
readonly dataSource?: DeviceNetworkDataSource;
readonly dataSource?: DeviceNetworkDataSource | undefined;
/** Change this owner-provided value to request another read. */
readonly refreshSignal?: unknown;
readonly refreshSignal?: unknown | undefined;
}
type ReadState =
@@ -132,10 +140,25 @@ function SnapshotView({ snapshot }: { snapshot: DeviceNetworkSnapshot }) {
const ddnsStatus = snapshot.ddns.status;
const config = snapshot.ddns.config;
const logs = snapshot.ddns.logSummary;
const connection = snapshot.connectionAddresses;
return (
<>
{snapshot.observedAt ? <p>{snapshot.observedAt}</p> : null}
<div className="device-network-grid">
<Section label="连接地址">
<Fields
values={[
[
'IPv4',
connection && connection.ipv4.length > 0 ? connection.ipv4.join('、') : null,
],
[
'IPv6',
connection && connection.ipv6.length > 0 ? connection.ipv6.join('、') : null,
],
]}
/>
</Section>
<Section label="WLAN 状态">
<Fields
values={[
@@ -239,11 +262,6 @@ function SnapshotView({ snapshot }: { snapshot: DeviceNetworkSnapshot }) {
/>
</Section>
</div>
<section className="state-panel" aria-label="设备网络操作">
<h2></h2>
<p>R1 </p>
<p>R2 </p>
</section>
</>
);
}
@@ -354,6 +372,12 @@ export function DeviceNetworkModule({
</p>
) : null}
<SnapshotView snapshot={currentSnapshot} />
<DeviceActions
instance={instance}
module="device-network"
onExecuted={() => setRetry((value) => value + 1)}
/>
</div>
);
}
@@ -0,0 +1,132 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest';
import { createDiscoveryApiDataSource } from './discovery-api-data-source.js';
const device = {
origin: 'http://192.168.68.1:3000',
address: '192.168.68.1',
port: 3000,
secure: false,
httpStatus: 200,
identity: { model: 'iPhone 14', imei: '356938035643809' },
knownInstanceId: null,
};
const session = {
sessionId: '7a1f0c22-0000-4000-8000-000000000001',
status: 'scanning',
createdAt: '2026-09-05T02:00:00.000Z',
expiresAt: '2026-09-05T02:01:00.000Z',
scanned: 12,
total: 508,
ranges: ['192.168.68.0/24'],
devices: [device],
};
const probe = {
origin: 'http://192.168.68.9:3000',
reachable: true,
httpStatus: 200,
identity: { brand: 'Google', serial: 'SN-1' },
knownInstanceId: 'inst-1',
};
const json = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), { status });
describe('discovery API data source', () => {
it('starts a session and posts an empty body with same-origin controls', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json({ data: session }, 201));
const signal = new AbortController().signal;
await expect(createDiscoveryApiDataSource(fetcher).start(signal)).resolves.toMatchObject({
sessionId: session.sessionId,
status: 'scanning',
total: 508,
ranges: ['192.168.68.0/24'],
});
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/v1/discovery/sessions');
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({
method: 'POST',
credentials: 'same-origin',
body: '{}',
signal,
});
});
it('keeps only whitelisted identity fields from a discovered device', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json({ data: session }));
const started = await createDiscoveryApiDataSource(fetcher).start();
expect(started.devices[0]?.identity).toEqual({ model: 'iPhone 14' });
expect(JSON.stringify(started)).not.toContain('356938035643809');
});
it('reads status, renews and stops against the encoded session id', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(json({ data: { ...session, status: 'completed' } }))
.mockResolvedValueOnce(json({ data: session }))
.mockResolvedValueOnce(new Response(null, { status: 204 }));
const source = createDiscoveryApiDataSource(fetcher);
await expect(source.status(session.sessionId)).resolves.toMatchObject({ status: 'completed' });
await expect(source.renew(session.sessionId)).resolves.toMatchObject({
sessionId: session.sessionId,
});
await expect(source.stop(session.sessionId)).resolves.toBeUndefined();
expect(fetcher.mock.calls[0]).toEqual([
`/api/v1/discovery/sessions/${encodeURIComponent(session.sessionId)}`,
expect.objectContaining({ method: 'GET' }),
]);
expect(fetcher.mock.calls[1]?.[1]).toMatchObject({ method: 'PUT' });
expect(fetcher.mock.calls[2]?.[1]).toMatchObject({ method: 'DELETE' });
});
it('probes a manual address and drops unknown identity keys', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json({ data: probe }));
await expect(createDiscoveryApiDataSource(fetcher).probe('192.168.68.9:3000')).resolves.toEqual(
{
origin: probe.origin,
reachable: true,
httpStatus: 200,
identity: { brand: 'Google' },
knownInstanceId: 'inst-1',
},
);
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({
method: 'POST',
body: JSON.stringify({ device_url: '192.168.68.9:3000' }),
});
});
it('surfaces a failed request as an error', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValue(json({ code: 'DISCOVERY_NOT_FOUND' }, 404));
await expect(createDiscoveryApiDataSource(fetcher).status('nope')).rejects.toThrowError(
'Discovery request failed.',
);
});
it('refuses a session payload that does not match the envelope shape', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json({ data: { sessionId: 'x' } }));
await expect(createDiscoveryApiDataSource(fetcher).start()).rejects.toThrowError(
'Discovery response is invalid.',
);
});
it('refuses a device whose origin is not a plain private origin', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
json({
data: { ...session, devices: [{ ...device, origin: 'http://a@evil.test:3000/x' }] },
}),
);
await expect(createDiscoveryApiDataSource(fetcher).start()).rejects.toThrowError(
'Discovery response is invalid.',
);
});
});
@@ -0,0 +1,193 @@
export type DiscoveredDevice = Readonly<{
origin: string;
address: string;
port: number;
secure: boolean;
httpStatus: number;
identity: Readonly<Record<string, string>>;
knownInstanceId: string | null;
}>;
export type DiscoverySession = Readonly<{
sessionId: string;
status: 'scanning' | 'completed';
createdAt: string;
expiresAt: string;
scanned: number;
total: number;
ranges: readonly string[];
devices: readonly DiscoveredDevice[];
}>;
export type DeviceProbe = Readonly<{
origin: string;
reachable: boolean;
httpStatus: number | null;
identity: Readonly<Record<string, string>>;
knownInstanceId: string | null;
}>;
export interface DiscoveryDataSource {
start(signal?: AbortSignal): Promise<DiscoverySession>;
status(sessionId: string, signal?: AbortSignal): Promise<DiscoverySession>;
renew(sessionId: string, signal?: AbortSignal): Promise<DiscoverySession>;
stop(sessionId: string): Promise<void>;
probe(deviceUrl: string, signal?: AbortSignal): Promise<DeviceProbe>;
}
const BASE = '/api/v1/discovery';
const MAX_TEXT = 200;
const SESSION_ID = /^[0-9a-fA-F-]{1,64}$/u;
const ORIGIN = /^https?:\/\/[A-Za-z0-9.[\]:_-]{1,120}(?::\d{1,5})?$/u;
const IDENTITY_FIELDS: readonly string[] = [
'model',
'manufacturer',
'brand',
'firmware_version',
'os_version',
'version',
];
const record = (value: unknown): Record<string, unknown> | undefined =>
typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
const shortText = (value: unknown): value is string =>
typeof value === 'string' &&
value.length > 0 &&
value.length <= MAX_TEXT &&
!/[\x00-\x1F\x7F]/u.test(value);
const integer = (value: unknown, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): value is number =>
typeof value === 'number' && Number.isInteger(value) && value >= minimum && value <= maximum;
function identity(value: unknown): Readonly<Record<string, string>> {
const source = record(value);
if (!source) return {};
const result: Record<string, string> = {};
for (const field of IDENTITY_FIELDS) {
const entry = source[field];
if (shortText(entry)) result[field] = entry;
}
return result;
}
function device(value: unknown): DiscoveredDevice | undefined {
const source = record(value);
if (!source) return undefined;
const { origin, address, port, secure, httpStatus, knownInstanceId } = source;
if (!shortText(origin) || !ORIGIN.test(origin)) return undefined;
if (!shortText(address) || !integer(port, 1, 65_535) || typeof secure !== 'boolean')
return undefined;
if (!integer(httpStatus, 100, 599)) return undefined;
if (knownInstanceId !== null && !shortText(knownInstanceId)) return undefined;
return {
origin,
address,
port,
secure,
httpStatus,
identity: identity(source.identity),
knownInstanceId: typeof knownInstanceId === 'string' ? knownInstanceId : null,
};
}
function session(value: unknown): DiscoverySession {
const source = record(value);
const sessionId = source?.sessionId;
if (!source || !shortText(sessionId) || !SESSION_ID.test(sessionId))
throw new Error('Discovery response is invalid.');
const { status, createdAt, expiresAt, scanned, total, ranges } = source;
if (status !== 'scanning' && status !== 'completed')
throw new Error('Discovery response is invalid.');
if (!shortText(createdAt) || !shortText(expiresAt))
throw new Error('Discovery response is invalid.');
if (!integer(scanned) || !integer(total) || !Array.isArray(ranges))
throw new Error('Discovery response is invalid.');
const devices = ranges.every((entry) => shortText(entry)) ? source.devices : undefined;
if (!Array.isArray(devices)) throw new Error('Discovery response is invalid.');
const parsed = devices.map(device);
if (parsed.some((entry) => entry === undefined))
throw new Error('Discovery response is invalid.');
return {
sessionId,
status,
createdAt,
expiresAt,
scanned,
total,
ranges: ranges as string[],
devices: parsed as DiscoveredDevice[],
};
}
function probe(value: unknown): DeviceProbe {
const source = record(value);
if (!source) throw new Error('Discovery response is invalid.');
const { origin, reachable, httpStatus, knownInstanceId } = source;
if (!shortText(origin) || !ORIGIN.test(origin) || typeof reachable !== 'boolean')
throw new Error('Discovery response is invalid.');
if (httpStatus !== null && !integer(httpStatus, 100, 599))
throw new Error('Discovery response is invalid.');
if (knownInstanceId !== null && !shortText(knownInstanceId))
throw new Error('Discovery response is invalid.');
return {
origin,
reachable,
httpStatus: typeof httpStatus === 'number' ? httpStatus : null,
identity: identity(source.identity),
knownInstanceId: typeof knownInstanceId === 'string' ? knownInstanceId : null,
};
}
export function createDiscoveryApiDataSource(
fetcher: typeof fetch = globalThis.fetch,
): DiscoveryDataSource {
const request = async (
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
signal?: AbortSignal,
body?: unknown,
): Promise<unknown> => {
const response = await fetcher(`${BASE}${path}`, {
method,
credentials: 'same-origin',
headers:
body === undefined
? { accept: 'application/json' }
: { accept: 'application/json', 'content-type': 'application/json' },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
...(signal ? { signal } : {}),
});
if (!response.ok) throw new Error('Discovery request failed.');
if (response.status === 204) return undefined;
return (await response.json().catch(() => undefined)) as unknown;
};
const data = async (
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
signal?: AbortSignal,
body?: unknown,
): Promise<Record<string, unknown>> => {
const payload = record(await request(method, path, signal, body));
const envelope = payload ? record(payload.data) : undefined;
if (!envelope) throw new Error('Discovery response is invalid.');
return envelope;
};
return {
async start(signal) {
return session(await data('POST', '/sessions', signal, {}));
},
async status(sessionId, signal) {
return session(await data('GET', `/sessions/${encodeURIComponent(sessionId)}`, signal));
},
async renew(sessionId, signal) {
return session(await data('PUT', `/sessions/${encodeURIComponent(sessionId)}`, signal));
},
async stop(sessionId) {
await request('DELETE', `/sessions/${encodeURIComponent(sessionId)}`);
},
async probe(deviceUrl, signal) {
return probe(await data('POST', '/probe', signal, { device_url: deviceUrl }));
},
};
}
+14 -7
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import type { InstanceContext } from '../app-shell.js';
import { displayValue } from '../ui/locale.js';
import { DeviceActions } from './device-action-controls.js';
/** Bounded status vocabularies are the only strings this module may present. */
export type EsimLpacStatus = 'available' | 'unavailable' | 'degraded' | 'unknown';
@@ -13,11 +14,11 @@ export type EsimSafeLabel = 'Provisioned' | 'Enabled' | 'Disabled' | 'Pending' |
* identifiers have no place in this contract.
*/
export interface EsimSnapshot {
readonly profileCount?: number | null;
readonly enabledProfileCount?: number | null;
readonly lpacStatus?: EsimLpacStatus | null;
readonly workMode?: EsimWorkMode | null;
readonly labels?: readonly EsimSafeLabel[];
readonly profileCount?: number | null | undefined;
readonly enabledProfileCount?: number | null | undefined;
readonly lpacStatus?: EsimLpacStatus | null | undefined;
readonly workMode?: EsimWorkMode | null | undefined;
readonly labels?: readonly EsimSafeLabel[] | undefined;
}
export interface EsimDataSource {
@@ -27,8 +28,8 @@ export interface EsimDataSource {
export interface EsimModuleProps {
readonly instance: InstanceContext;
readonly dataSource?: EsimDataSource;
readonly refreshSignal?: unknown;
readonly dataSource?: EsimDataSource | undefined;
readonly refreshSignal?: unknown | undefined;
}
type SafeSnapshot = {
@@ -254,6 +255,12 @@ export function EsimModule({ instance, dataSource, refreshSignal }: EsimModulePr
</p>
) : null}
<SnapshotView snapshot={snapshot} />
<DeviceActions
instance={instance}
module="esim"
onExecuted={() => setRetry((value) => value + 1)}
/>
</div>
);
}
+9
View File
@@ -1,6 +1,7 @@
import { useEffect, useState, type FormEvent } from 'react';
import { Button, Card, Title } from 'animal-island-ui';
import { DeviceDiscoveryPanel, type DeviceSelection } from './device-discovery-panel.js';
import {
createInstanceApiDataSource,
passwordUpdate,
@@ -40,6 +41,13 @@ export function InstanceEditor({
const [busy, setBusy] = useState(false);
const [confirming, setConfirming] = useState(false);
const [confirmation, setConfirmation] = useState('');
const [autoName, setAutoName] = useState('');
const adopt = (selection: DeviceSelection) => {
setOrigin(selection.origin);
setName((current) => (current.trim() && current !== autoName ? current : selection.name));
setAutoName(selection.name);
};
useEffect(() => {
if (mode !== 'edit') return;
@@ -160,6 +168,7 @@ export function InstanceEditor({
{status}
</p>
) : null}
{mode === 'create' ? <DeviceDiscoveryPanel onSelect={adopt} selectedOrigin={origin} /> : null}
<Card pattern="default" className="editor-card">
<form onSubmit={submit}>
<label>
@@ -32,7 +32,7 @@ function instanceNavigation() {
}
describe('InstanceDetail', () => {
it('converges primary navigation on messages and device information only', () => {
it('exposes every fused device module in the instance navigation', () => {
render(
<InstanceDetail
instanceId="owner"
@@ -44,12 +44,21 @@ describe('InstanceDetail', () => {
const navigation = instanceNavigation();
const links = navigation.getAllByRole('link');
expect(links).toHaveLength(2);
expect(links.map((link) => [link.textContent, link.getAttribute('href')])).toEqual([
['仪表盘', '/instances/owner/overview'],
['SIM 卡', '/instances/owner/sim'],
['蜂窝', '/instances/owner/cellular'],
['设备网络', '/instances/owner/device-network'],
['短信', '/instances/owner/messages'],
['通话', '/instances/owner/calls'],
['eSIM', '/instances/owner/esim'],
['WiFi 通话', '/instances/owner/vowifi'],
['通知', '/instances/owner/notifications'],
['自动化', '/instances/owner/automation'],
['OTA', '/instances/owner/ota'],
['设置', '/instances/owner/configuration'],
['备份', '/instances/owner/device-backup'],
]);
expect(navigation.queryByText(/蜂窝网络|设备网络|通话|eSIM|通知|自动化|OTA/)).toBeNull();
expect(screen.getByRole('heading', { name: '实例仪表盘' })).toBeTruthy();
expect(screen.queryByText('能力状态未知。')).toBeNull();
expect(screen.queryByText('此调制解调器不支持语音功能。')).toBeNull();
@@ -59,7 +68,6 @@ describe('InstanceDetail', () => {
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
expect(screen.getByText('已认证')).toBeTruthy();
expect(screen.getByText('数据最新')).toBeTruthy();
expect(links.map((link) => link.textContent)).toEqual(['仪表盘', '短信']);
expect(screen.getByRole('link', { name: '编辑实例' }).getAttribute('href')).toBe(
'/settings/instances/owner',
);
@@ -94,7 +102,7 @@ describe('InstanceDetail', () => {
<InstanceDetail instanceId="owner" module="overview" instance={owner} capabilities={{}} />,
);
expect(instanceNavigation().getAllByRole('link')).toHaveLength(2);
expect(instanceNavigation().getAllByRole('link')).toHaveLength(13);
expect(screen.queryByText('能力状态未知。')).toBeNull();
});
@@ -184,6 +192,10 @@ describe('InstanceDetail', () => {
['notifications', '通知'],
['automation', '自动化'],
['ota', 'OTA'],
['sim', 'SIM 卡'],
['vowifi', 'WiFi 通话'],
['configuration', '设备设置'],
['device-backup', '设备备份'],
])('keeps the active legacy deep link and content compatible for %s', (module, label) => {
render(
<InstanceDetail
@@ -197,6 +209,7 @@ describe('InstanceDetail', () => {
expect(screen.getByRole('heading', { name: label })).toBeTruthy();
expect(screen.getByText(`旧深链接内容:${label}`)).toBeTruthy();
expect(instanceNavigation().queryByRole('link', { name: label })).toBeNull();
const link = document.querySelector(`nav[aria-label="实例模块"] a[href$="/${module}"]`);
expect(link?.getAttribute('aria-current')).toBe('page');
});
});
+33 -7
View File
@@ -30,20 +30,43 @@ export interface InstanceDetailProps {
export const INSTANCE_MODULE_LABELS: Readonly<Record<InstanceModule, string>> = {
overview: '实例仪表盘',
sim: 'SIM 卡',
cellular: '蜂窝网络',
'device-network': '设备网络',
messages: '短信管理',
calls: '通话',
esim: 'eSIM',
vowifi: 'WiFi 通话',
notifications: '通知',
automation: '自动化',
ota: 'OTA',
configuration: '设备设置',
'device-backup': '设备备份',
};
const PRIMARY_MODULES = ['overview', 'messages'] as const satisfies readonly InstanceModule[];
const PRIMARY_LABELS: Readonly<Record<(typeof PRIMARY_MODULES)[number], string>> = {
const MODULE_NAV: readonly InstanceModule[] = [
'overview',
'sim',
'cellular',
'device-network',
'messages',
'calls',
'esim',
'vowifi',
'notifications',
'automation',
'ota',
'configuration',
'device-backup',
] as const satisfies readonly InstanceModule[];
const SHORT_LABELS: Readonly<Partial<Record<InstanceModule, string>>> = {
overview: '仪表盘',
cellular: '蜂窝',
'device-network': '设备网络',
messages: '短信',
notifications: '通知',
configuration: '设置',
'device-backup': '备份',
};
const AUTH_LABELS = { authenticated: '已认证', 'auth-required': '需要认证' } as const;
const FRESHNESS_LABELS = {
@@ -63,9 +86,12 @@ function canRender(capability: InstanceCapability | undefined): boolean {
return capability.state === 'supported' || capability.state === 'degraded';
}
const CORE_MODULES = new Set<InstanceModule>(['overview', 'messages']);
function canOpen(module: InstanceModule, capability: InstanceCapability | undefined): boolean {
return capability ? canRender(capability) : CORE_MODULES.has(module);
/**
* Every tab is backed by a live device probe, so an unknown capability no longer hides a module;
* the module itself reports the sections a device cannot serve.
*/
function canOpen(_module: InstanceModule, capability: InstanceCapability | undefined): boolean {
return capability ? canRender(capability) : true;
}
function explanation(capability: InstanceCapability | undefined): string | null {
@@ -191,14 +217,14 @@ export function InstanceDetail({
</div>
<nav aria-label="实例模块">
<ul>
{PRIMARY_MODULES.map((item) => {
{MODULE_NAV.map((item) => {
return (
<li key={item}>
<a
href={`/instances/${encodeURIComponent(instanceId)}/${item}`}
aria-current={module === item ? 'page' : undefined}
>
{PRIMARY_LABELS[item]}
{SHORT_LABELS[item] ?? INSTANCE_MODULE_LABELS[item]}
</a>
</li>
);
@@ -0,0 +1,304 @@
import { describe, expect, it, vi } from 'vitest';
import {
createInstanceModuleApiReader,
createInstanceModuleDataSources,
mapAutomation,
mapCalls,
mapCellular,
mapDeviceNetwork,
mapEsim,
mapNotifications,
mapOta,
mapOverview,
type InstanceModuleSnapshot,
} from './instance-module-api-data-source.js';
function snapshot(module: string, sections: readonly [string, unknown][]): InstanceModuleSnapshot {
return {
instanceId: 'node-a',
module,
observedAt: '2026-09-04T06:00:00.000Z',
authenticated: true,
sections: sections.map(([key, data]) => ({
key,
path: `/${key}`,
state: 'ok' as const,
status: 200,
data,
})),
};
}
describe('instance module mappers', () => {
it('splits device stats between the resource and throughput cards', () => {
const result = mapOverview(
snapshot('overview', [
['device', { model: 'LPAX-1', imei: '860000000000001', uptime: '3天' }],
['sim', { slots: [{ slot: 1, operator: 'China Mobile' }] }],
['network', { registration_state: 'registered', operator_name: 'China Mobile' }],
['stats', { cpu_percent: 12.5, memory_percent: 44, download_bytes: 1024 }],
['connectivity', { connected: true, latency: 38 }],
]),
);
expect(result.device).toMatchObject({ model: 'LPAX-1', uptime: '3天' });
expect(result.cpu).toEqual({ cpu_percent: 12.5, memory_percent: 44 });
expect(result.stats).toEqual({ download_bytes: 1024 });
expect(result.network).toMatchObject({ registration_state: 'registered' });
expect(result.connectivity).toMatchObject({ connected: true, latency: 38 });
expect(result.observedAt).toBe('2026-09-04T06:00:00.000Z');
});
it('reads cellular fields across the naming variants devices use', () => {
const result = mapCellular(
snapshot('cellular', [
['network', { registration_state: 'registered', radio_mode: 'NR/LTE', operator: 'CUCC' }],
['signalStrength', { rsrp: -92, sinr: 14, signal_percent: 78 }],
['cells', { cell_id: '0x1A2B', tac: '0x00C8', network_type: 'nr' }],
['cellLocation', { latitude: 24.5, longitude: 118.1 }],
['operators', { operators: [{ name: 'CUCC' }, { name: 'CMCC' }] }],
['roaming', { state: 'roaming' }],
]),
);
expect(result.networkRegistration).toEqual({
state: 'registered',
mode: 'NR/LTE',
operator: 'CUCC',
roaming: 'roaming',
});
expect(result.signal).toEqual({ rsrp: '-92', sinr: '14', quality: '78' });
expect(result.cellsLocation).toEqual({
cell: '0x1A2B',
area: '0x00C8',
technology: 'nr',
latitude: '24.5',
longitude: '118.1',
});
expect(result.operators.available).toBe('CUCC、CMCC');
});
it('keeps wlan, interface and ddns detail together', () => {
const result = mapDeviceNetwork(
snapshot('device-network', [
['wlanStatus', { enabled: true, connection_state: 'connected', ssid: 'office' }],
['wlanProfiles', { profiles: [{ name: 'office', security: 'WPA2', priority: 1 }] }],
[
'interfaces',
{
interfaces: [{ name: 'rmnet_data0', state: 'up', mtu: 1500, addresses: ['10.0.0.5'] }],
},
],
['ddnsStatus', { enabled: true, state: 'updated', last_update_at: '2026-09-04' }],
['ddnsConfig', { provider: 'duckdns', hostname: 'node.example', interval: 300 }],
[
'ddnsLogs',
{
logs: [{ status: 'success' }, { status: 'success' }, { status: 'failed' }],
},
],
]),
);
expect(result.wlan.status).toEqual({
enabled: true,
connectionState: 'connected',
ssid: 'office',
});
expect(result.wlan.profiles).toEqual([{ name: 'office', security: 'WPA2', priority: 1 }]);
expect(result.interfaces[0]?.addresses).toEqual([
{ address: '10.0.0.5', family: undefined, prefixLength: undefined, scope: undefined },
]);
expect(result.ddns.config).toEqual({
provider: 'duckdns',
hostname: 'node.example',
updateIntervalSeconds: 300,
});
expect(result.ddns.logSummary).toMatchObject({
totalEntries: 3,
successfulUpdates: 2,
failedUpdates: 1,
});
});
it('aggregates eSIM profiles into the safe contract', () => {
const result = mapEsim(
snapshot('esim', [
[
'profiles',
{
profiles: [
{ iccid: '8986000000000000001', enabled: true },
{ iccid: '8986000000000000002', status: 'disabled' },
{ iccid: '8986000000000000003', status: 'pending' },
],
},
],
['euicc', { work_mode: 'idle' }],
['lpacStatus', { status: 'available' }],
]),
);
expect(result).toEqual({
profileCount: 3,
enabledProfileCount: 1,
lpacStatus: 'available',
workMode: 'idle',
labels: ['Enabled', 'Disabled', 'Pending'],
});
});
it('summarises call state and history counts', () => {
const result = mapCalls(
snapshot('calls', [
['calls', { state: 'active', active: 1 }],
['history', { calls: [{}, {}, {}] }],
['settings', { slots: 2 }],
]),
);
expect(result.calls).toMatchObject({ state: 'active', active: 1, total: 3 });
expect(result.devices).toMatchObject({ state: 'active', total: 2, busy: 1 });
});
it('reports each call capability using the firmware verdict', () => {
const base = snapshot('calls', [['calls', { state: 'idle' }]]);
const result = mapCalls({
...base,
sections: [
...base.sections,
{ key: 'ims', path: '/ims', state: 'ok', status: 200, data: { registered: true } },
{
key: 'forwarding',
path: '/forwarding',
state: 'unsupported',
status: 200,
data: { status: 'error', message: 'Call forwarding is not exposed' },
},
{ key: 'volume', path: '/volume', state: 'auth-required', status: 401, data: null },
],
});
expect(result.features).toEqual([
{ name: '呼叫转移', state: 'unavailable' },
{ name: '通话音量', state: 'unavailable' },
{ name: 'IMS 语音', state: 'available' },
]);
});
it('turns notification payloads into aggregate counts', () => {
const result = mapNotifications(
snapshot('notifications', [
[
'config',
{
enabled: true,
channels: [
{ name: 'email', enabled: true },
{ name: 'webhook', enabled: false },
],
},
],
[
'queue',
{
items: [{ status: 'pending' }, { status: 'delivered' }, { status: 'failed' }],
},
],
['logs', { items: [{ level: 'info' }, { level: 'warn' }, { level: 'error' }] }],
]),
);
expect(result.channels).toMatchObject({
status: 'degraded',
total: 2,
enabled: 1,
disabled: 1,
});
expect(result.queue).toMatchObject({ total: 3, pending: 1, delivered: 1, failed: 1 });
expect(result.logs).toMatchObject({ total: 3, info: 1, warning: 1, error: 1 });
});
it('reports automation health from rule and execution payloads', () => {
const result = mapAutomation(
snapshot('automation', [
[
'config',
{
enabled: true,
rules: [
{ id: 'r1', enabled: true },
{ id: 'r2', enabled: false },
],
},
],
['logs', { items: [{ status: 'success' }, { status: 'failed' }] }],
]),
);
expect(result.status).toEqual({ state: 'degraded', scheduler: 'active', workers: 'degraded' });
expect(result.tasks).toMatchObject({
total: 2,
enabled: 1,
disabled: 1,
succeeded: 1,
failed: 1,
});
});
it('clamps OTA progress and normalises the status token', () => {
expect(mapOta(snapshot('ota', [['status', { state: 'Downloading', progress: 140 }]]))).toEqual({
currentVersion: undefined,
status: 'downloading',
progressPercent: 100,
updateAvailable: false,
});
expect(mapOta(snapshot('ota', [['status', { status: 'available' }]])).updateAvailable).toBe(
true,
);
});
it('reads a module snapshot through the api reader', async () => {
const fetcher = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({
instanceId: 'node-a',
module: 'sim',
observedAt: '2026-09-04T06:00:00.000Z',
authenticated: true,
sections: [{ key: 'sim', path: '/sim', state: 'ok', status: 200, data: { slots: [] } }],
}),
})) as unknown as typeof fetch;
const reader = createInstanceModuleApiReader(fetcher);
const result = await reader.read('node-a', 'sim');
expect(result.sections).toHaveLength(1);
expect(
String((fetcher as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0]),
).toBe('/api/v1/instances/node-a/modules/sim');
});
it('rejects a snapshot that belongs to another instance', async () => {
const fetcher = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({
instanceId: 'other',
module: 'sim',
observedAt: '2026-09-04T06:00:00.000Z',
authenticated: true,
sections: [],
}),
})) as unknown as typeof fetch;
await expect(createInstanceModuleApiReader(fetcher).read('node-a', 'sim')).rejects.toThrow(
'服务端返回的模块快照无效。',
);
});
it('serves a fresh read to every module without re-probing the device', async () => {
const read = vi.fn(async (instanceId: string, module: string) =>
snapshot(module, [['status', { state: 'idle' }]]),
);
const sources = createInstanceModuleDataSources({ read });
await Promise.all([
sources.ota.load('node-a', new AbortController().signal),
sources.ota.load('node-a', new AbortController().signal),
]);
expect(read).toHaveBeenCalledTimes(1);
await sources.automation.load('node-a', new AbortController().signal);
expect(read).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,749 @@
import type { AutomationSnapshot } from './automation-module.js';
import type { CallFeatureStatus, CallsSnapshot } from './calls-module.js';
import type { CellularSnapshot } from './cellular-module.js';
import type { DeviceNetworkSnapshot } from './device-network-module.js';
import type { EsimSafeLabel, EsimSnapshot } from './esim-module.js';
import type { NotificationsSnapshot } from './notifications-module.js';
import type { OtaSnapshot } from './ota-module.js';
import type { OverviewFieldValue, OverviewSnapshot } from './overview-system.js';
/**
* Bridge between the control-plane instance module proxy and the device panel modules.
*
* The API reads the device's own read-only endpoints, classifies each probe, and hands back a
* flat list of sections. This file is the single place that knows how those device sections map
* onto the console's presentation contracts, so every module renders live data instead of an
* empty shell.
*/
export type InstanceModuleState = 'ok' | 'empty' | 'auth-required' | 'unsupported' | 'failed';
export interface InstanceModuleSection {
readonly key: string;
readonly path: string;
readonly state: InstanceModuleState;
readonly status: number;
readonly data: unknown;
}
export interface InstanceModuleSnapshot {
readonly instanceId: string;
readonly module: string;
readonly observedAt: string;
readonly authenticated: boolean;
readonly sections: readonly InstanceModuleSection[];
}
export interface InstanceModuleReader {
read(instanceId: string, module: string, signal?: AbortSignal): Promise<InstanceModuleSnapshot>;
}
const MAX_ENTRIES = 40;
const MAX_STRING_LENGTH = 240;
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function parseSnapshot(value: unknown, instanceId: string, module: string): InstanceModuleSnapshot {
if (
!isRecord(value) ||
typeof value.instanceId !== 'string' ||
value.instanceId !== instanceId ||
typeof value.module !== 'string' ||
typeof value.observedAt !== 'string' ||
typeof value.authenticated !== 'boolean' ||
!Array.isArray(value.sections)
)
throw new Error('服务端返回的模块快照无效。');
const sections = value.sections.slice(0, 64).flatMap((entry): InstanceModuleSection[] => {
if (!isRecord(entry) || typeof entry.key !== 'string' || typeof entry.path !== 'string')
return [];
const state = entry.state;
return [
{
key: entry.key.slice(0, 64),
path: entry.path.slice(0, 256),
state:
state === 'ok' ||
state === 'empty' ||
state === 'auth-required' ||
state === 'unsupported' ||
state === 'failed'
? state
: 'failed',
status:
typeof entry.status === 'number' && Number.isFinite(entry.status) ? entry.status : 0,
data: entry.data,
},
];
});
return {
instanceId: value.instanceId,
module: value.module || module,
observedAt: value.observedAt,
authenticated: value.authenticated,
sections,
};
}
export function createInstanceModuleApiReader(fetcher: typeof fetch = fetch): InstanceModuleReader {
return {
async read(instanceId, module, signal) {
const url = `/api/v1/instances/${encodeURIComponent(instanceId)}/modules/${encodeURIComponent(
module,
)}`;
const response = await fetcher(url, {
method: 'GET',
credentials: 'same-origin',
headers: { accept: 'application/json' },
...(signal ? { signal } : {}),
});
if (!response.ok) throw new Error(`请求失败 (${response.status})`);
return parseSnapshot(await response.json(), instanceId, module);
},
};
}
function section(snapshot: InstanceModuleSnapshot, key: string): InstanceModuleSection | undefined {
return snapshot.sections.find((candidate) => candidate.key === key);
}
function sectionRecord(
snapshot: InstanceModuleSnapshot,
key: string,
): Readonly<Record<string, unknown>> {
const entry = section(snapshot, key);
if (!entry) return {};
if (isRecord(entry.data)) return entry.data;
if (Array.isArray(entry.data)) return { items: entry.data };
return {};
}
/** First present value among candidate device field names; device payloads are not uniform. */
function pick(source: Readonly<Record<string, unknown>>, keys: readonly string[]): unknown {
for (const key of keys) {
const value = source[key];
if (value !== undefined && value !== null) return value;
}
return undefined;
}
function text(value: unknown): string | undefined {
if (typeof value === 'string')
return value.length > MAX_STRING_LENGTH ? `${value.slice(0, MAX_STRING_LENGTH)}` : value;
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
if (typeof value === 'boolean') return value ? 'true' : 'false';
return undefined;
}
function flag(value: unknown): boolean | undefined {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
if (typeof value === 'string') {
const lowered = value.toLocaleLowerCase();
if (['true', 'yes', 'on', 'enabled', '1'].includes(lowered)) return true;
if (['false', 'no', 'off', 'disabled', '0'].includes(lowered)) return false;
}
return undefined;
}
function count(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
/** Keep only display-safe primitives, preserving device key names for the label table. */
function primitives(source: Readonly<Record<string, unknown>>): Record<string, OverviewFieldValue> {
const result: Record<string, OverviewFieldValue> = {};
for (const [key, value] of Object.entries(source)) {
if (Object.keys(result).length >= MAX_ENTRIES) break;
if (typeof value === 'string') {
result[key] = text(value) ?? null;
} else if (typeof value === 'number') {
result[key] = Number.isFinite(value) ? value : null;
} else if (typeof value === 'boolean') {
result[key] = value;
} else if (Array.isArray(value)) {
const entries = value
.slice(0, 6)
.map((item) => (isRecord(item) ? text(pick(item, ['name', 'label', 'value'])) : text(item)))
.filter((item): item is string => item !== undefined);
result[key] = entries.length ? entries.join('、') : null;
} else {
result[key] = null;
}
}
return result;
}
function arrayFrom(
source: Readonly<Record<string, unknown>>,
keys: readonly string[],
): readonly unknown[] {
for (const key of keys) {
const value = source[key];
if (Array.isArray(value)) return value.slice(0, MAX_ENTRIES);
}
return [];
}
function tally(items: readonly unknown[], predicate: (item: unknown) => boolean): number {
return items.reduce<number>((total, item) => (predicate(item) ? total + 1 : total), 0);
}
function statusOf(items: readonly unknown[]): 'healthy' | 'degraded' | 'failed' | 'idle' {
if (items.length === 0) return 'idle';
const failed = tally(items, (item) => {
const record = isRecord(item) ? item : {};
const value = text(pick(record, ['status', 'state', 'result']))?.toLocaleLowerCase();
return value === 'failed' || value === 'error';
});
if (failed === 0) return 'healthy';
return failed >= items.length ? 'failed' : 'degraded';
}
function observed(snapshot: InstanceModuleSnapshot): { readonly observedAt: string } {
return { observedAt: snapshot.observedAt };
}
export function mapOverview(snapshot: InstanceModuleSnapshot): OverviewSnapshot {
const stats = sectionRecord(snapshot, 'stats');
// Anchored on word boundaries: "download_bytes" contains "load" but is throughput, not a load average.
const cpuKeys = Object.keys(stats).filter((key) =>
/(^|_)(cpu|memory|mem|temp|thermal|load)(_|$)/u.test(key),
);
const cpu: Record<string, OverviewFieldValue> = {};
const rest: Record<string, OverviewFieldValue> = {};
for (const [key, value] of Object.entries(primitives(stats))) {
if (cpuKeys.includes(key)) cpu[key] = value;
else rest[key] = value;
}
// /stats/cpu is a separate probe; its core summary belongs beside the load averages.
Object.assign(cpu, primitives(sectionRecord(snapshot, 'cpu')));
// Message counters read better beside the traffic totals than in their own card.
Object.assign(rest, primitives(sectionRecord(snapshot, 'smsStats')));
return {
...observed(snapshot),
device: primitives(sectionRecord(snapshot, 'device')),
sim: primitives(sectionRecord(snapshot, 'sim')),
network: {
...primitives(sectionRecord(snapshot, 'network')),
...primitives(sectionRecord(snapshot, 'data')),
},
stats: rest,
cpu,
connectivity: primitives(sectionRecord(snapshot, 'connectivity')),
};
}
export function mapCellular(snapshot: InstanceModuleSnapshot): CellularSnapshot {
const network = sectionRecord(snapshot, 'network');
const signal = sectionRecord(snapshot, 'signalStrength');
const cells = isRecord(sectionRecord(snapshot, 'cells').cells)
? (sectionRecord(snapshot, 'cells').cells as Readonly<Record<string, unknown>>)
: sectionRecord(snapshot, 'cells');
const location = sectionRecord(snapshot, 'cellLocation');
const operators = sectionRecord(snapshot, 'operators');
const available = arrayFrom(operators, ['operators', 'available', 'items', 'list']).flatMap(
(item): string[] => {
const name = isRecord(item) ? text(pick(item, ['name', 'long_name', 'alpha'])) : text(item);
return name ? [name] : [];
},
);
const roaming = flag(pick(network, ['roaming', 'is_roaming', 'roaming_active']))
? 'roaming'
: text(pick(sectionRecord(snapshot, 'roaming'), ['state', 'status', 'roaming']));
return {
...observed(snapshot),
networkRegistration: {
state: text(pick(network, ['registration_state', 'register_state', 'state', 'status'])),
mode: text(pick(network, ['radio_mode', 'mode', 'network_mode', 'preferred_mode'])),
operator: text(pick(network, ['operator_name', 'operator', 'carrier', 'name'])),
roaming,
},
signal: {
rssi: text(pick(signal, ['rssi', 'signal_dbm', 'dbm', 'strength'])),
rsrp: text(pick(signal, ['rsrp', 'lte_rsrp', 'nr_rsrp'])),
rsrq: text(pick(signal, ['rsrq', 'lte_rsrq'])),
sinr: text(pick(signal, ['sinr', 'lte_sinr', 'nr_sinr'])),
quality: text(pick(signal, ['signal_percent', 'level', 'quality', 'asu'])),
},
cellsLocation: {
cell: text(pick(cells, ['cell_id', 'cellId', 'eci', 'cid', 'nr_cell_id'])),
area: text(pick(cells, ['tac', 'tracking_area', 'area_code', 'rac'])),
technology: text(
pick(cells, ['network_type', 'technology', 'rat']) ??
pick(network, ['network_type', 'technology', 'rat']),
),
latitude: text(pick(location, ['latitude', 'lat'])),
longitude: text(pick(location, ['longitude', 'lng', 'lon'])),
mcc: text(pick(location, ['mcc']) ?? pick(cells, ['mcc'])),
mnc: text(pick(location, ['mnc']) ?? pick(cells, ['mnc'])),
pci: text(pick(location, ['pci']) ?? pick(cells, ['pci', 'phys_cell_id'])),
arfcn: text(
pick(location, ['earfcn', 'arfcn', 'nr_arfcn']) ??
pick(cells, ['earfcn', 'arfcn', 'nr_arfcn']),
),
},
operators: {
current: text(pick(network, ['operator_name', 'operator', 'carrier'])),
available: available.length ? available.slice(0, 12).join('、') : undefined,
},
};
}
export function mapDeviceNetwork(snapshot: InstanceModuleSnapshot): DeviceNetworkSnapshot {
const wlanStatus = sectionRecord(snapshot, 'wlanStatus');
const wlanProfiles = sectionRecord(snapshot, 'wlanProfiles');
const interfaces = sectionRecord(snapshot, 'interfaces');
const addresses = sectionRecord(snapshot, 'addresses');
const ddnsStatus = sectionRecord(snapshot, 'ddnsStatus');
const ddnsConfig = sectionRecord(snapshot, 'ddnsConfig');
const ddnsLogs = sectionRecord(snapshot, 'ddnsLogs');
const profileItems = arrayFrom(wlanProfiles, ['profiles', 'items', 'list', 'networks']);
const interfaceItems = arrayFrom(interfaces, ['interfaces', 'items', 'list']);
const logItems = arrayFrom(ddnsLogs, ['logs', 'entries', 'items']);
const addressList = (keys: readonly string[]): readonly string[] =>
arrayFrom(addresses, keys)
.map((item) => text(item))
.filter((item): item is string => item !== undefined)
.slice(0, 8);
const ipv4 = addressList(['ipv4', 'ipv4_addresses', 'v4']);
const ipv6 = addressList(['ipv6', 'ipv6_addresses', 'v6']);
return {
...observed(snapshot),
connectionAddresses: { ipv4, ipv6 },
wlan: {
status: {
enabled: flag(pick(wlanStatus, ['enabled', 'wlan_enabled', 'is_enabled'])),
radioState: text(pick(wlanStatus, ['radio_state', 'radioState', 'state'])),
connectionState: text(
pick(wlanStatus, ['connection_state', 'connectionState', 'status', 'state']),
),
activeProfile: text(pick(wlanStatus, ['active_profile', 'activeProfile', 'profile'])),
ssid: text(pick(wlanStatus, ['ssid', 'network_name'])),
},
profiles: profileItems.flatMap((item) =>
isRecord(item)
? [
{
name: text(pick(item, ['name', 'profile_name'])),
ssid: text(pick(item, ['ssid'])),
security: text(pick(item, ['security', 'key_management', 'encryption'])),
enabled: flag(pick(item, ['enabled', 'active'])),
priority: count(pick(item, ['priority', 'order'])),
},
]
: [],
),
},
interfaces: interfaceItems.flatMap((item) =>
isRecord(item)
? [
{
name: text(pick(item, ['name', 'interface', 'ifname'])),
kind: text(pick(item, ['kind', 'type', 'driver'])),
state: text(pick(item, ['state', 'status', 'operstate'])),
macAddress: text(pick(item, ['mac_address', 'macAddress', 'mac'])),
mtu: count(pick(item, ['mtu'])),
addresses: arrayFrom(item, ['addresses', 'ips']).flatMap((address) =>
isRecord(address)
? [
{
family: text(pick(address, ['family', 'scope'])),
address: text(pick(address, ['address', 'ip', 'value'])),
prefixLength: count(pick(address, ['prefix_length', 'prefix', 'cidr'])),
scope: text(pick(address, ['scope'])),
},
]
: typeof address === 'string'
? [{ address, family: undefined, prefixLength: undefined, scope: undefined }]
: [],
),
},
]
: [],
),
ddns: {
status: {
enabled: flag(pick(ddnsStatus, ['enabled', 'configured'])),
state: text(pick(ddnsStatus, ['state', 'status', 'last_result'])),
lastUpdateAt: text(pick(ddnsStatus, ['last_update_at', 'lastUpdateAt', 'updated_at'])),
},
config: {
provider: text(pick(ddnsConfig, ['provider', 'service'])),
hostname: text(pick(ddnsConfig, ['hostname', 'domain'])),
updateIntervalSeconds: count(
pick(ddnsConfig, ['update_interval_seconds', 'interval_seconds', 'interval']),
),
},
logSummary: {
totalEntries: count(pick(ddnsLogs, ['total', 'total_entries', 'count'])) ?? logItems.length,
successfulUpdates:
count(pick(ddnsLogs, ['successful', 'success', 'successful_updates'])) ??
tally(logItems, (item) => {
const value = text(
pick(isRecord(item) ? item : {}, ['status', 'result', 'state']),
)?.toLocaleLowerCase();
return value === 'success' || value === 'ok' || value === 'updated';
}),
failedUpdates:
count(pick(ddnsLogs, ['failed', 'failures', 'failed_updates'])) ??
tally(logItems, (item) => {
const value = text(
pick(isRecord(item) ? item : {}, ['status', 'result', 'state']),
)?.toLocaleLowerCase();
return value === 'failed' || value === 'error';
}),
lastEventAt: text(pick(ddnsLogs, ['last_event_at', 'latest', 'last_at'])),
},
},
};
}
export function mapEsim(snapshot: InstanceModuleSnapshot): EsimSnapshot {
const profiles = sectionRecord(snapshot, 'profiles');
const euicc = sectionRecord(snapshot, 'euicc');
const lpac = sectionRecord(snapshot, 'lpacStatus');
const items = arrayFrom(profiles, ['profiles', 'list', 'items', 'data']).flatMap((item) =>
isRecord(item) ? [item] : [],
);
const enabled = tally(items, (item) => {
const record = isRecord(item) ? item : {};
return (
flag(pick(record, ['enabled', 'is_enabled'])) === true ||
text(pick(record, ['status', 'state']))?.toLocaleLowerCase() === 'enabled'
);
});
const lpacValue = text(
pick(lpac, ['status', 'state', 'available']) ?? pick(euicc, ['lpac_status', 'status']),
)?.toLocaleLowerCase();
const workModeValue = text(pick(euicc, ['work_mode', 'workMode']))?.toLocaleLowerCase();
const labels: EsimSafeLabel[] = [];
for (const item of items.slice(0, 8)) {
const record = isRecord(item) ? item : {};
const enabled = flag(pick(record, ['enabled', 'is_enabled']));
const itemStatus = text(pick(record, ['status', 'state']))?.toLocaleLowerCase();
const label: EsimSafeLabel =
itemStatus === 'error' || itemStatus === 'failed'
? 'Error'
: itemStatus === 'pending' || itemStatus === 'downloading'
? 'Pending'
: enabled === true || itemStatus === 'enabled'
? 'Enabled'
: enabled === false || itemStatus === 'disabled'
? 'Disabled'
: 'Provisioned';
if (!labels.includes(label)) labels.push(label);
}
return {
profileCount: items.length,
enabledProfileCount: enabled,
lpacStatus:
lpacValue === 'available' || lpacValue === 'unavailable' || lpacValue === 'degraded'
? lpacValue
: lpacValue === 'true' || flag(pick(lpac, ['available', 'supported'])) === true
? 'available'
: 'unknown',
workMode:
workModeValue === 'idle' || workModeValue === 'working' || workModeValue === 'disabled'
? workModeValue
: 'unknown',
labels,
};
}
/** Call capabilities the Hub surfaces as separate panels; each maps to one probe. */
const CALL_FEATURE_PROBES: readonly { readonly key: string; readonly name: string }[] = [
{ key: 'forwarding', name: '呼叫转移' },
{ key: 'volume', name: '通话音量' },
{ key: 'voicemail', name: '语音信箱' },
{ key: 'ims', name: 'IMS 语音' },
];
export function mapCalls(snapshot: InstanceModuleSnapshot): CallsSnapshot {
const calls = sectionRecord(snapshot, 'calls');
const history = sectionRecord(snapshot, 'history');
const settings = sectionRecord(snapshot, 'settings');
const active =
count(pick(calls, ['active', 'active_calls', 'current'])) ??
tally(arrayFrom(calls, ['calls', 'items', 'data', 'list']), (item) => {
const value = text(
pick(isRecord(item) ? item : {}, ['state', 'status']),
)?.toLocaleLowerCase();
return value === 'active' || value === 'dialing' || value === 'ringing';
});
const historyItems = arrayFrom(history, ['calls', 'items', 'history', 'list', 'data']);
return {
...observed(snapshot),
calls: {
state: text(pick(calls, ['state', 'status', 'call_status'])),
total: count(pick(calls, ['total', 'count'])) ?? historyItems.length,
active,
ringing: count(pick(calls, ['ringing', 'incoming'])),
held: count(pick(calls, ['held', 'hold'])),
failed: count(pick(calls, ['failed', 'error_count'])),
},
devices: {
state: text(pick(settings, ['state', 'status'])) ?? text(pick(calls, ['state', 'status'])),
total: count(pick(settings, ['total', 'line_count', 'slots'])),
online: count(pick(settings, ['online'])),
offline: count(pick(settings, ['offline'])),
busy: active && active > 0 ? active : count(pick(settings, ['busy'])),
},
features: CALL_FEATURE_PROBES.flatMap(({ key, name }): CallFeatureStatus[] => {
const entry = section(snapshot, key);
// A probe the firmware never answered is unknown; one that answered is a live verdict.
if (!entry || entry.state === 'failed') return [];
if (entry.state === 'unsupported' || entry.state === 'auth-required')
return [{ name, state: 'unavailable' as const }];
const message = isRecord(entry.data) ? text(entry.data['message']) : undefined;
return [{ name, state: 'available' as const, ...(message ? { detail: message } : {}) }];
}),
};
}
export function mapNotifications(snapshot: InstanceModuleSnapshot): NotificationsSnapshot {
const config = sectionRecord(snapshot, 'config');
const queue = sectionRecord(snapshot, 'queue');
const logs = sectionRecord(snapshot, 'logs');
const channels = arrayFrom(config, ['channels', 'items', 'list', 'webhooks']);
const queueItems = arrayFrom(queue, ['items', 'jobs', 'queue', 'notifications', 'data']);
const logItems = arrayFrom(logs, ['items', 'logs', 'entries', 'data']);
const enabledChannels = tally(channels, (item) => {
const record = isRecord(item) ? item : {};
return flag(pick(record, ['enabled', 'active'])) !== false;
});
return {
...observed(snapshot),
channels: {
status:
flag(pick(config, ['enabled'])) === false
? 'unavailable'
: enabledChannels === 0 && channels.length > 0
? 'unavailable'
: enabledChannels < channels.length
? 'degraded'
: statusOf(channels),
total: count(pick(config, ['total', 'count'])) ?? channels.length,
enabled: enabledChannels,
disabled: Math.max(
0,
(count(pick(config, ['total', 'count'])) ?? channels.length) - enabledChannels,
),
},
queue: {
status: statusOf(queueItems),
total: count(pick(queue, ['total', 'count'])) ?? queueItems.length,
pending:
count(pick(queue, ['pending', 'queued'])) ??
tally(queueItems, (item) => {
const value = text(pick(isRecord(item) ? item : {}, ['status', 'state']))
?.toLocaleLowerCase()
.trim();
return value === 'pending' || value === 'queued';
}),
processing:
count(pick(queue, ['processing', 'in_progress'])) ??
tally(queueItems, (item) => {
const value = text(pick(isRecord(item) ? item : {}, ['status', 'state']))
?.toLocaleLowerCase()
.trim();
return value === 'processing' || value === 'sending';
}),
delivered:
count(pick(queue, ['delivered', 'sent', 'succeeded'])) ??
tally(queueItems, (item) => {
const value = text(pick(isRecord(item) ? item : {}, ['status', 'state']))
?.toLocaleLowerCase()
.trim();
return value === 'delivered' || value === 'sent' || value === 'success';
}),
failed:
count(pick(queue, ['failed', 'errors'])) ??
tally(queueItems, (item) => {
const value = text(pick(isRecord(item) ? item : {}, ['status', 'state']))
?.toLocaleLowerCase()
.trim();
return value === 'failed' || value === 'error';
}),
},
logs: {
status: statusOf(logItems),
total: count(pick(logs, ['total', 'count'])) ?? logItems.length,
info: tally(logItems, (item) => {
const value = text(pick(isRecord(item) ? item : {}, ['level']))?.toLocaleLowerCase();
return value === 'info' || value === 'debug';
}),
warning: tally(logItems, (item) => {
const value = text(pick(isRecord(item) ? item : {}, ['level']))?.toLocaleLowerCase();
return value === 'warn' || value === 'warning';
}),
error: tally(logItems, (item) => {
const value = text(pick(isRecord(item) ? item : {}, ['level']))?.toLocaleLowerCase();
return value === 'error' || value === 'fatal';
}),
},
};
}
export function mapAutomation(snapshot: InstanceModuleSnapshot): AutomationSnapshot {
const config = sectionRecord(snapshot, 'config');
const logs = sectionRecord(snapshot, 'logs');
const rules = arrayFrom(config, ['rules', 'tasks', 'items', 'list', 'jobs']);
const logItems = arrayFrom(logs, ['items', 'executions', 'logs', 'entries']);
const enabledRules = tally(
rules,
(item) => flag(pick(isRecord(item) ? item : {}, ['enabled'])) !== false,
);
const failedRuns = tally(logItems, (item) => {
const value = text(
pick(isRecord(item) ? item : {}, ['status', 'result', 'state']),
)?.toLocaleLowerCase();
return value === 'failed' || value === 'error';
});
const scheduler = text(pick(config, ['scheduler', 'scheduler_state']))?.toLocaleLowerCase();
return {
...observed(snapshot),
status: {
state:
flag(pick(config, ['enabled'])) === false
? 'disabled'
: failedRuns > 0
? 'degraded'
: logItems.length || rules.length
? 'healthy'
: 'unknown',
scheduler:
scheduler === 'active' ||
scheduler === 'idle' ||
scheduler === 'paused' ||
scheduler === 'disabled' ||
scheduler === 'unavailable'
? scheduler
: flag(pick(config, ['enabled'])) === false
? 'disabled'
: rules.length
? 'active'
: 'idle',
workers:
flag(pick(config, ['enabled'])) === false
? 'disabled'
: failedRuns > 0
? 'degraded'
: logItems.length
? 'available'
: 'unavailable',
},
tasks: {
total: count(pick(config, ['total', 'count'])) ?? rules.length,
enabled: enabledRules,
disabled: Math.max(
0,
(count(pick(config, ['total', 'count'])) ?? rules.length) - enabledRules,
),
running: count(pick(config, ['running'])),
queued: count(pick(config, ['queued'])),
succeeded: tally(logItems, (item) => {
const value = text(
pick(isRecord(item) ? item : {}, ['status', 'result', 'state']),
)?.toLocaleLowerCase();
return value === 'success' || value === 'ok' || value === 'completed';
}),
failed: failedRuns,
},
};
}
export function mapOta(snapshot: InstanceModuleSnapshot): OtaSnapshot {
const status = sectionRecord(snapshot, 'status');
const state = text(pick(status, ['status', 'state', 'phase']))?.toLocaleLowerCase();
const progress = count(pick(status, ['progress_percent', 'progressPercent', 'progress']));
const available = flag(pick(status, ['update_available', 'updateAvailable', 'available']));
return {
currentVersion: text(pick(status, ['current_version', 'currentVersion', 'version'])),
status:
state === 'idle' ||
state === 'checking' ||
state === 'up-to-date' ||
state === 'available' ||
state === 'downloading' ||
state === 'verifying' ||
state === 'installing' ||
state === 'rebooting' ||
state === 'completed' ||
state === 'failed'
? state
: 'unknown',
progressPercent: progress === undefined ? undefined : Math.max(0, Math.min(100, progress)),
updateAvailable: available ?? state === 'available',
};
}
export interface InstanceModuleDataSources {
readonly overview: { load(instanceId: string, signal: AbortSignal): Promise<OverviewSnapshot> };
readonly cellular: { load(instanceId: string, signal: AbortSignal): Promise<CellularSnapshot> };
readonly deviceNetwork: {
load(instanceId: string, signal: AbortSignal): Promise<DeviceNetworkSnapshot>;
};
readonly esim: { load(instanceId: string, signal: AbortSignal): Promise<unknown> };
readonly calls: { load(instanceId: string, signal: AbortSignal): Promise<CallsSnapshot> };
readonly notifications: {
load(instanceId: string, signal: AbortSignal): Promise<unknown>;
};
readonly automation: { load(instanceId: string, signal: AbortSignal): Promise<unknown> };
readonly ota: { load(instanceId: string, signal: AbortSignal): Promise<unknown> };
}
/**
* One reader, one in-flight cache per module, eight module contracts. The device panel and the
* fleet drill-down share this so a tab switch never re-probes a device that was just read.
*/
export function createInstanceModuleDataSources(
reader: InstanceModuleReader = createInstanceModuleApiReader(),
ttlMs = 15_000,
): InstanceModuleDataSources {
const cache = new Map<string, { at: number; value: InstanceModuleSnapshot }>();
const inflight = new Map<string, Promise<InstanceModuleSnapshot>>();
const read = (module: string) => (instanceId: string, signal: AbortSignal) => {
const key = `${instanceId}:${module}`;
const cached = cache.get(key);
if (cached && Date.now() - cached.at < ttlMs) return Promise.resolve(cached.value);
const pending = inflight.get(key);
if (pending) return pending;
const next = reader.read(instanceId, module, signal).then(
(value) => {
cache.set(key, { at: Date.now(), value });
inflight.delete(key);
return value;
},
(error: unknown) => {
inflight.delete(key);
throw error;
},
);
inflight.set(key, next);
return next;
};
return {
overview: { load: async (id, signal) => mapOverview(await read('overview')(id, signal)) },
cellular: { load: async (id, signal) => mapCellular(await read('cellular')(id, signal)) },
deviceNetwork: {
load: async (id, signal) => mapDeviceNetwork(await read('device-network')(id, signal)),
},
esim: { load: async (id, signal) => mapEsim(await read('esim')(id, signal)) },
calls: { load: async (id, signal) => mapCalls(await read('calls')(id, signal)) },
notifications: {
load: async (id, signal) => mapNotifications(await read('notifications')(id, signal)),
},
automation: {
load: async (id, signal) => mapAutomation(await read('automation')(id, signal)),
},
ota: { load: async (id, signal) => mapOta(await read('ota')(id, signal)) },
};
}
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { InstanceContext } from '../app-shell.js';
import { DeviceActions } from './device-action-controls.js';
export interface SmsMessage {
readonly id: string;
@@ -281,6 +282,11 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
) : null}
</>
)}
<DeviceActions
instance={instance}
module="messages"
onExecuted={() => setRetry((value) => value + 1)}
/>
</section>
{newConversationOpen ? (
+28 -21
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { InstanceContext } from '../app-shell.js';
import { displayValue } from '../ui/locale.js';
import { DeviceActions } from './device-action-controls.js';
/** Values permitted at the Notifications presentation boundary. */
export type NotificationStatus =
@@ -17,39 +18,39 @@ export type NotificationAggregateValue = NotificationStatus | number | null;
/** Aggregate channel counts/status only. All source-specific details are ignored. */
export interface NotificationChannelAggregate {
readonly status?: NotificationStatus | null;
readonly total?: number | null;
readonly enabled?: number | null;
readonly disabled?: number | null;
readonly healthy?: number | null;
readonly degraded?: number | null;
readonly failed?: number | null;
readonly status?: NotificationStatus | null | undefined;
readonly total?: number | null | undefined;
readonly enabled?: number | null | undefined;
readonly disabled?: number | null | undefined;
readonly healthy?: number | null | undefined;
readonly degraded?: number | null | undefined;
readonly failed?: number | null | undefined;
readonly [sourceField: string]: unknown;
}
/** Aggregate queue counts/status only. Individual jobs and their payloads are unsupported. */
export interface NotificationQueueAggregate {
readonly status?: NotificationStatus | null;
readonly total?: number | null;
readonly pending?: number | null;
readonly processing?: number | null;
readonly delivered?: number | null;
readonly failed?: number | null;
readonly status?: NotificationStatus | null | undefined;
readonly total?: number | null | undefined;
readonly pending?: number | null | undefined;
readonly processing?: number | null | undefined;
readonly delivered?: number | null | undefined;
readonly failed?: number | null | undefined;
readonly [sourceField: string]: unknown;
}
/** Aggregate log counts/status only. Raw records are unsupported. */
export interface NotificationLogAggregate {
readonly status?: NotificationStatus | null;
readonly total?: number | null;
readonly info?: number | null;
readonly warning?: number | null;
readonly error?: number | null;
readonly status?: NotificationStatus | null | undefined;
readonly total?: number | null | undefined;
readonly info?: number | null | undefined;
readonly warning?: number | null | undefined;
readonly error?: number | null | undefined;
readonly [sourceField: string]: unknown;
}
export interface NotificationsSnapshot {
readonly observedAt?: string;
readonly observedAt?: string | undefined;
readonly channels: NotificationChannelAggregate;
readonly queue: NotificationQueueAggregate;
readonly logs: NotificationLogAggregate;
@@ -62,9 +63,9 @@ export interface NotificationsDataSource {
export interface NotificationsModuleProps {
readonly instance: InstanceContext;
readonly dataSource?: NotificationsDataSource;
readonly dataSource?: NotificationsDataSource | undefined;
/** Change this owner-provided value to request another read. */
readonly refreshSignal?: unknown;
readonly refreshSignal?: unknown | undefined;
}
type OwnedSnapshot = { readonly ownerId: string; readonly value: NotificationsSnapshot };
@@ -337,6 +338,12 @@ export function NotificationsModule({
</p>
) : null}
<SnapshotView snapshot={snapshot.value} />
<DeviceActions
instance={instance}
module="notifications"
onExecuted={() => setRetry((value) => value + 1)}
/>
</div>
);
}
+17 -10
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import type { InstanceContext } from '../app-shell.js';
import { displayValue } from '../ui/locale.js';
import { DeviceActions } from './device-action-controls.js';
export type OtaStatus =
| 'idle'
@@ -21,10 +22,10 @@ export type OtaStatus =
* the type boundary but are never copied into render state.
*/
export interface OtaSnapshot {
readonly currentVersion?: string | null;
readonly status?: OtaStatus | null;
readonly progressPercent?: number | null;
readonly updateAvailable?: boolean | null;
readonly currentVersion?: string | null | undefined;
readonly status?: OtaStatus | null | undefined;
readonly progressPercent?: number | null | undefined;
readonly updateAvailable?: boolean | null | undefined;
readonly [sourceField: string]: unknown;
}
@@ -35,16 +36,16 @@ export interface OtaDataSource {
export interface OtaModuleProps {
readonly instance: InstanceContext;
readonly dataSource?: OtaDataSource;
readonly dataSource?: OtaDataSource | undefined;
/** Change this owner-provided value to request another read. */
readonly refreshSignal?: unknown;
readonly refreshSignal?: unknown | undefined;
}
type SafeOtaSnapshot = {
readonly currentVersion?: string;
readonly status?: OtaStatus;
readonly progressPercent?: number;
readonly updateAvailable?: boolean;
readonly currentVersion?: string | undefined;
readonly status?: OtaStatus | undefined;
readonly progressPercent?: number | undefined;
readonly updateAvailable?: boolean | undefined;
};
type ReadState =
@@ -255,6 +256,12 @@ export function OtaModule({ instance, dataSource, refreshSignal }: OtaModuleProp
</p>
) : null}
<SnapshotView snapshot={snapshot} />
<DeviceActions
instance={instance}
module="ota"
onExecuted={() => setRetry((value) => value + 1)}
/>
</div>
);
}
+81 -5
View File
@@ -8,7 +8,7 @@ export type OverviewFieldValue = string | number | boolean | null;
export type OverviewSection = Readonly<Record<string, OverviewFieldValue>>;
export interface OverviewSnapshot {
readonly observedAt?: string;
readonly observedAt?: string | undefined;
readonly device: OverviewSection;
readonly sim: OverviewSection;
readonly network: OverviewSection;
@@ -24,11 +24,11 @@ export interface OverviewDataSource {
export interface OverviewSystemPageProps {
readonly instance: InstanceContext;
readonly dataSource?: OverviewDataSource;
readonly dataSource?: OverviewDataSource | undefined;
/** Change this value when an external owner has requested a refresh. */
readonly refreshSignal?: unknown;
readonly operationClient?: OperationClient;
readonly revision?: number;
readonly refreshSignal?: unknown | undefined;
readonly operationClient?: OperationClient | undefined;
readonly revision?: number | undefined;
}
type ReadState =
@@ -70,10 +70,86 @@ const FIELD_LABELS: Readonly<Record<string, string>> = {
Temperature: '温度',
State: '状态',
Latency: '延迟',
// Device payloads arrive with their native snake_case field names.
model: '型号',
manufacturer: '制造商',
brand: '品牌',
imei: 'IMEI',
meid: 'MEID',
iccid: 'ICCID',
imsi: 'IMSI',
phone_number: '号码',
firmware_version: '固件版本',
os_version: '系统版本',
version: '版本',
baseband_version: '基带版本',
uptime: '运行时间',
uptime_seconds: '运行时间(秒)',
slots: '卡槽数',
active_slots: '活跃数',
active: '已激活',
enabled: '已启用',
operator: '运营商',
carrier: '运营商',
network_type: '接入制式',
technology: '技术',
radio_mode: '网络模式',
signal: '信号强度',
signal_dbm: '信号强度(dBm',
signal_percent: '信号质量',
rsrp: 'RSRP',
rsrq: 'RSRQ',
sinr: 'SINR',
mcc: 'MCC',
mnc: 'MNC',
registration: '注册状态',
registration_state: '注册状态',
roaming: '漫游',
airplane_mode: '飞行模式',
ipv4: 'IPv4 地址',
ipv6: 'IPv6 地址',
ip_address: 'IP 地址',
gateway: '网关',
dns: 'DNS',
download: '下行速率',
upload: '上行速率',
download_bytes: '下行流量',
upload_bytes: '上行流量',
messages_today: '今日消息数',
calls: '通话数',
usage: '使用率',
temperature: '温度',
max_temperature_c: '最高温度',
cpu_percent: 'CPU 使用率',
cpu_load: 'CPU 负载',
memory_percent: '内存使用率',
memory_total_mb: '内存总量',
memory_available_mb: '可用内存',
battery_level: '电量',
battery_percent: '电量',
latency: '延迟',
connected: '连接状态',
interface: '网络接口',
ssid: '无线网络',
apn: 'APN',
};
const ENUM_LABELS: Readonly<Record<string, string>> = {
connected: '已连接',
not_registered: '未注册',
attached: '已附着',
detached: '已分离',
enabled: '已启用',
disabled: '已停用',
true: '是',
false: '否',
lte: 'LTE',
nr: 'NR',
nr5g: '5G NR',
gsm: 'GSM',
wcdma: 'WCDMA',
td_scdma: 'TD-SCDMA',
evdo: 'EVDO',
disconnected: '未连接',
connecting: '正在连接',
registered: '已注册',
@@ -160,4 +160,48 @@ describe('Jobs API data source', () => {
expect(error.message).not.toMatch(/private|title|token|secret/i);
expect('detail' in error).toBe(false);
});
it('POSTs a bounded cancellation and returns the terminal job', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json({ ...job, status: 'cancelled' }));
const result = await createJobsApiDataSource(fetcher).cancel?.(
'job-1',
new AbortController().signal,
);
expect(fetcher).toHaveBeenCalledWith('/api/v1/jobs/job-1/cancel', {
method: 'POST',
credentials: 'same-origin',
headers: { accept: 'application/json' },
signal: expect.any(AbortSignal),
});
expect(result).toMatchObject({ id: 'job-1', status: 'cancelled' });
});
it('refuses to build a cancellation URL from an unsafe job id', async () => {
const fetcher = vi.fn<typeof fetch>();
await expect(
createJobsApiDataSource(fetcher).cancel?.('https://evil/..', new AbortController().signal),
).rejects.toThrow('Jobs request is invalid.');
expect(fetcher).not.toHaveBeenCalled();
});
it('POSTs the reconcile sweep and hands the raw counters back to the page', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json({ interrupted: 3, pending: 0 }));
const result = await createJobsApiDataSource(fetcher).reconcile?.(new AbortController().signal);
expect(fetcher).toHaveBeenCalledWith('/api/v1/jobs/reconcile', {
method: 'POST',
credentials: 'same-origin',
headers: { accept: 'application/json' },
signal: expect.any(AbortSignal),
});
expect(result).toEqual({ interrupted: 3, pending: 0 });
});
it('maps a rejected reconcile to the same redacted error type', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json(problem, 502));
const error = await createJobsApiDataSource(fetcher)
.reconcile?.(new AbortController().signal)
.catch((value: unknown) => value as JobsApiError);
expect(error).toBeInstanceOf(JobsApiError);
expect(error).toMatchObject({ status: 502, code: 'UPSTREAM_FAILURE' });
expect(error.message).not.toMatch(/remain|detail/i);
});
});
+35
View File
@@ -292,6 +292,8 @@ async function responseError(response: Response): Promise<JobsApiError> {
return new JobsApiError(response.status, code, requestId);
}
const JOB_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/u;
export function createJobsApiDataSource(fetcher: typeof fetch = fetch): JobsDataSource {
return {
async load(query, signal): Promise<JobPage> {
@@ -310,5 +312,38 @@ export function createJobsApiDataSource(fetcher: typeof fetch = fetch): JobsData
}
return parseJobPage(body);
},
async cancel(jobId, signal): Promise<Job> {
if (!JOB_ID_PATTERN.test(jobId)) throw new Error('Jobs request is invalid.');
const response = await fetcher(`/api/v1/jobs/${encodeURIComponent(jobId)}/cancel`, {
method: 'POST',
credentials: 'same-origin',
headers: { accept: 'application/json' },
signal,
});
if (!response.ok) throw await responseError(response);
let body: unknown;
try {
body = await response.json();
} catch {
throw new Error('Jobs response is invalid.');
}
const job = parseJob(body);
if (!job) throw new Error('Jobs response is invalid.');
return job;
},
async reconcile(signal): Promise<unknown> {
const response = await fetcher('/api/v1/jobs/reconcile', {
method: 'POST',
credentials: 'same-origin',
headers: { accept: 'application/json' },
signal,
});
if (!response.ok) throw await responseError(response);
try {
return await response.json();
} catch {
throw new Error('Jobs response is invalid.');
}
},
};
}
+83
View File
@@ -203,3 +203,86 @@ describe('Phase 7 Jobs workspace', () => {
expect(screen.queryByRole('button', { name: /retry/i })).toBeNull();
});
});
describe('Job control actions', () => {
const running: Job = { ...job, id: 'job-live', status: 'running' };
const page = (items: readonly Job[]) => ({
items,
page: { page: 1, pageSize: 25, total: items.length },
});
it('offers cancellation only for jobs that can still be cancelled', async () => {
const cancel = vi.fn<NonNullable<JobsDataSource['cancel']>>().mockResolvedValue(running);
render(
<JobsPage
dataSource={{
load: vi
.fn<JobsDataSource['load']>()
.mockResolvedValue(page([running, { ...job, id: 'job-done', status: 'succeeded' }])),
cancel,
}}
/>,
);
const button = await screen.findByRole('button', { name: '取消任务 job-live' });
expect(screen.queryByRole('button', { name: '取消任务 job-done' })).toBeNull();
expect(screen.getByRole('columnheader', { name: '控制' })).toBeTruthy();
await userEvent.click(button);
expect(cancel).toHaveBeenCalledWith('job-live', expect.any(AbortSignal));
expect(await screen.findByRole('button', { name: '取消任务 job-live' })).toBeTruthy();
});
it('reports a failed cancellation without leaking the rejection payload', async () => {
const cancel = vi
.fn<NonNullable<JobsDataSource['cancel']>>()
.mockRejectedValue(new Error('upstream token=leak'));
render(
<JobsPage
dataSource={{
load: vi.fn<JobsDataSource['load']>().mockResolvedValue(page([running])),
cancel,
}}
/>,
);
await userEvent.click(await screen.findByRole('button', { name: '取消任务 job-live' }));
const alert = await screen.findByRole('alert');
expect(alert.textContent).toContain('无法取消任务 job-live');
expect(alert.textContent).not.toContain('leak');
});
it('reconciles interrupted jobs and summarizes the result', async () => {
const reconcile = vi
.fn<NonNullable<JobsDataSource['reconcile']>>()
.mockResolvedValue({ interrupted: 2, pending: 0, extra: 'ignored' });
render(
<JobsPage
dataSource={{
load: vi.fn<JobsDataSource['load']>().mockResolvedValue(page([job])),
reconcile,
}}
/>,
);
await userEvent.click(screen.getByRole('button', { name: '对账中断任务' }));
expect(reconcile).toHaveBeenCalledWith(expect.any(AbortSignal));
const status = await screen.findByRole('status', { name: '任务对账结果' });
expect(status.textContent).toContain('关闭 2 个中断任务');
expect(status.textContent).toContain('仍有 0 个任务在进行中');
});
it('hides every control when the runtime cannot mutate jobs', async () => {
render(
<JobsPage
dataSource={{
load: vi.fn<JobsDataSource['load']>().mockResolvedValue(page([running])),
}}
/>,
);
await screen.findByRole('table', { name: '任务' });
expect(screen.queryByRole('button', { name: '取消任务 job-live' })).toBeNull();
expect(screen.queryByRole('button', { name: '对账中断任务' })).toBeNull();
expect(screen.queryByRole('columnheader', { name: '控制' })).toBeNull();
});
});
+123 -1
View File
@@ -30,8 +30,17 @@ export interface SafeJobPage {
readonly page: JobPage['page'];
}
/** Result of an interrupted-job reconciliation, reduced to two counters. */
export interface SafeReconcileResult {
readonly interrupted: number;
readonly pending: number;
}
export interface JobsDataSource {
load(query: JobPageQuery, signal: AbortSignal): Promise<unknown>;
/** Optional: only present when the runtime can actually close a job out. */
cancel?(jobId: string, signal: AbortSignal): Promise<unknown>;
reconcile?(signal: AbortSignal): Promise<unknown>;
}
export interface JobsPageProps {
readonly dataSource?: JobsDataSource;
@@ -40,6 +49,8 @@ export interface JobsPageProps {
type SortField = NonNullable<JobPageQuery['sort']>;
const ACTIVE_JOB_STATUSES = new Set<JobStatus>(['queued', 'running', 'cancelling']);
function record(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
@@ -198,6 +209,20 @@ function safeLoadError(value: unknown): SafeProblem | null {
return parseProblem(value) ?? null;
}
/** Keeps only the two integers the reconcile banner needs; anything else is treated as absent. */
export function sanitizeReconcileResult(value: unknown): SafeReconcileResult | null {
const source = record(value);
if (!source) return null;
const counter = (candidate: unknown): number | null =>
typeof candidate === 'number' && Number.isSafeInteger(candidate) && candidate >= 0
? candidate
: null;
const interrupted = counter(source.interrupted);
const pending = counter(source.pending);
if (interrupted === null || pending === null) return null;
return { interrupted, pending };
}
export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
const [result, setResult] = useState<SafeJobPage | null>(null);
const [loading, setLoading] = useState(false);
@@ -211,6 +236,18 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
const [direction, setDirection] = useState<SortDirection>('desc');
const [page, setPage] = useState(1);
const request = useRef(0);
const [busyJob, setBusyJob] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [reconcileResult, setReconcileResult] = useState<SafeReconcileResult | null>(null);
const [reconciling, setReconciling] = useState(false);
const action = useRef(0);
// Bumping the ticket on unmount makes any in-flight action resolve into nothing.
useEffect(() => {
return () => {
action.current += 1;
};
}, []);
const query = useMemo<JobPageQuery>(
() => ({
@@ -280,11 +317,62 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
setPage(1);
};
const canCancel = typeof dataSource.cancel === 'function';
const canReconcile = typeof dataSource.reconcile === 'function';
const runCancel = (jobId: string) => {
const cancel = dataSource.cancel;
if (typeof cancel !== 'function' || busyJob !== null) return;
const ticket = ++action.current;
setBusyJob(jobId);
setActionError(null);
const controller = new AbortController();
void cancel(jobId, controller.signal).then(
() => {
if (action.current !== ticket) return;
setBusyJob(null);
setManualRefresh((value) => value + 1);
},
() => {
if (action.current !== ticket) return;
setBusyJob(null);
setActionError(`无法取消任务 ${jobId}`);
},
);
};
const runReconcile = () => {
const reconcile = dataSource.reconcile;
if (typeof reconcile !== 'function' || reconciling) return;
const ticket = ++action.current;
setReconciling(true);
setActionError(null);
setReconcileResult(null);
const controller = new AbortController();
void reconcile(controller.signal).then(
(raw) => {
if (action.current !== ticket) return;
setReconciling(false);
setReconcileResult(sanitizeReconcileResult(raw));
setManualRefresh((value) => value + 1);
},
() => {
if (action.current !== ticket) return;
setReconciling(false);
setActionError('无法执行任务对账。');
},
);
};
return (
<section aria-labelledby="jobs-title">
<header>
<h1 id="jobs-title"></h1>
<p></p>
<p>
{canCancel || canReconcile
? '操作历史,可取消进行中的任务并对账中断任务。'
: '只读操作历史。'}
</p>
</header>
<div className="jobs-toolbar">
<label>
@@ -336,7 +424,22 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
>
</Button>
{canReconcile ? (
<Button htmlType="button" size="small" aria-label="对账中断任务" onClick={runReconcile}>
{reconciling ? '对账中…' : '对账中断任务'}
</Button>
) : null}
</div>
{actionError ? (
<p role="alert" className="jobs-action-alert">
{actionError}
</p>
) : null}
{reconcileResult ? (
<p role="status" aria-label="任务对账结果" className="jobs-action-status">
{`本次对账关闭 ${reconcileResult.interrupted} 个中断任务,仍有 ${reconcileResult.pending} 个任务在进行中。`}
</p>
) : null}
{loading ? (
<p role="status" aria-label="任务加载状态">
@@ -418,6 +521,7 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
{canCancel ? <th scope="col"></th> : null}
</tr>
</thead>
<tbody>
@@ -458,6 +562,24 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
</div>
))}
</td>
{canCancel ? (
<td>
{ACTIVE_JOB_STATUSES.has(item.status) ? (
<Button
htmlType="button"
size="small"
ghost
aria-label={`取消任务 ${item.id}`}
disabled={busyJob !== null}
onClick={() => runCancel(item.id)}
>
{busyJob === item.id ? '取消中…' : '取消'}
</Button>
) : (
<span></span>
)}
</td>
) : null}
</tr>
))}
</tbody>
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { isSensitiveKey, maskTail, protectValue } from './sensitive-fields.js';
describe('sensitive field masking', () => {
it('recognises the same field under every naming style', () => {
expect(isSensitiveKey('iccid')).toBe(true);
expect(isSensitiveKey('ICCID')).toBe(true);
expect(isSensitiveKey('phone_number')).toBe(true);
expect(isSensitiveKey('phoneNumber')).toBe(true);
expect(isSensitiveKey('phone-number')).toBe(true);
expect(isSensitiveKey('slot')).toBe(false);
expect(isSensitiveKey('carrier')).toBe(false);
});
it('keeps the last four digits of a subscriber identifier', () => {
expect(maskTail('89860112345678901234')).toBe('•••••••• 1234');
expect(maskTail('490154203237518')).toBe('•••••••• 7518');
expect(maskTail('123')).toBe('••••');
});
it('masks only sensitive values and only while hidden', () => {
expect(protectValue('iccid', '89860112345678901234', false)).toBe('•••••••• 1234');
expect(protectValue('iccid', '89860112345678901234', true)).toBe('89860112345678901234');
expect(protectValue('status', 'ready', false)).toBe('ready');
});
it('uses the phone mask for subscriber numbers', () => {
expect(protectValue('phone_number', '13800001234', false)).toBe('138 •••• 1234');
expect(protectValue('msisdn', '13800001234', false)).toBe('138 •••• 1234');
expect(protectValue('phoneNumber', '13800001234', true)).toBe('13800001234');
});
});
+42
View File
@@ -0,0 +1,42 @@
import { maskPhoneNumber } from '../fleet/phone-privacy.js';
/**
* Subscriber identifiers the console keeps out of screenshots and shoulder-surfing range by
* default. The list is shared so a card, a table cell and a fleet row never disagree about what
* counts as sensitive.
*/
export const SENSITIVE_KEYS: ReadonlySet<string> = new Set([
'iccid',
'imei',
'imsi',
'meid',
'phone_number',
'msisdn',
'phonenumber',
]);
const MASK = '••••';
/** `phone-number`, `phoneNumber` and `phone_number` all name the same field. */
function normalize(key: string): string {
return key.toLocaleLowerCase().replace(/[-\s]/gu, '_');
}
const PHONE_KEYS: ReadonlySet<string> = new Set(['phone_number', 'msisdn', 'phonenumber']);
export function isSensitiveKey(key: string): boolean {
return SENSITIVE_KEYS.has(normalize(key));
}
/** Keeps the last four digits, which is usually all an operator needs to pick the right card. */
export function maskTail(value: string): string {
const digits = value.replace(/\D/gu, '');
if (digits.length <= 4) return MASK;
return `${'•'.repeat(Math.min(8, Math.max(4, digits.length - 4)))} ${digits.slice(-4)}`;
}
export function protectValue(key: string, value: string, revealed: boolean): string {
if (revealed || !isSensitiveKey(key)) return value;
if (PHONE_KEYS.has(normalize(key))) return maskPhoneNumber(value);
return maskTail(value);
}
@@ -0,0 +1,97 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it } from 'vitest';
import {
SensitiveRevealProvider,
SensitiveRevealToggle,
useSensitiveReveal,
} from './sensitive-reveal.js';
afterEach(() => {
cleanup();
localStorage.clear();
});
function Probe() {
const { revealed, toggle } = useSensitiveReveal();
return (
<>
<span data-testid="state">{revealed ? 'revealed' : 'masked'}</span>
<button type="button" onClick={toggle}>
</button>
</>
);
}
describe('SensitiveRevealProvider', () => {
it('masks by default and shares one switch across the tree', async () => {
render(
<SensitiveRevealProvider>
<Probe />
<Probe />
</SensitiveRevealProvider>,
);
const states = screen.getAllByTestId('state');
expect(states[0]!.textContent).toBe('masked');
expect(states[1]!.textContent).toBe('masked');
await userEvent.click(screen.getAllByRole('button', { name: '切换' })[0]!);
expect(screen.getAllByTestId('state')[0]!.textContent).toBe('revealed');
expect(screen.getAllByTestId('state')[1]!.textContent).toBe('revealed');
});
it('exposes one labelled switch in the topbar', async () => {
render(
<SensitiveRevealProvider>
<SensitiveRevealToggle />
<Probe />
</SensitiveRevealProvider>,
);
const toggle = screen.getByRole('button', { name: '显示敏感标识' });
expect(toggle.getAttribute('aria-pressed')).toBe('false');
await userEvent.click(toggle);
expect(screen.getByTestId('state').textContent).toBe('revealed');
expect(screen.getByRole('button', { name: '隐藏敏感标识' }).getAttribute('aria-pressed')).toBe(
'true',
);
});
it('remembers the choice for the next page load', async () => {
const view = render(
<SensitiveRevealProvider>
<Probe />
</SensitiveRevealProvider>,
);
await userEvent.click(screen.getByRole('button', { name: '切换' }));
expect(localStorage.getItem('multi-simadmin.reveal-sensitive')).toBe('true');
view.unmount();
render(
<SensitiveRevealProvider>
<Probe />
</SensitiveRevealProvider>,
);
expect(screen.getByTestId('state').textContent).toBe('revealed');
});
it('starts masked again once the stored choice is turned off', async () => {
localStorage.setItem('multi-simadmin.reveal-sensitive', 'false');
render(
<SensitiveRevealProvider>
<Probe />
</SensitiveRevealProvider>,
);
expect(screen.getByTestId('state').textContent).toBe('masked');
await userEvent.click(screen.getByRole('button', { name: '切换' }));
expect(localStorage.getItem('multi-simadmin.reveal-sensitive')).toBe('true');
});
it('leaves consumers harmless without a provider', () => {
render(<Probe />);
expect(screen.getByTestId('state').textContent).toBe('masked');
});
});
+72
View File
@@ -0,0 +1,72 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { Button } from 'animal-island-ui';
import { Icon } from '../ui/icon.js';
const STORAGE_KEY = 'multi-simadmin.reveal-sensitive';
export interface SensitiveRevealState {
/** One switch for the whole console: masked by default, revealed only on purpose. */
readonly revealed: boolean;
readonly toggle: () => void;
}
const SensitiveRevealContext = createContext<SensitiveRevealState>({
revealed: false,
toggle: () => undefined,
});
function readStoredPreference(): boolean {
if (typeof localStorage === 'undefined') return false;
try {
return localStorage.getItem(STORAGE_KEY) === 'true';
} catch {
return false;
}
}
function writeStoredPreference(revealed: boolean): void {
if (typeof localStorage === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, String(revealed));
} catch {
// A blocked storage still keeps the switch working for this page view.
}
}
export function SensitiveRevealProvider({ children }: { readonly children: ReactNode }) {
const [revealed, setRevealed] = useState(readStoredPreference);
const toggle = useCallback(() => setRevealed((value) => !value), []);
useEffect(() => {
writeStoredPreference(revealed);
}, [revealed]);
const value = useMemo<SensitiveRevealState>(() => ({ revealed, toggle }), [revealed, toggle]);
return (
<SensitiveRevealContext.Provider value={value}>{children}</SensitiveRevealContext.Provider>
);
}
export function useSensitiveReveal(): SensitiveRevealState {
return useContext(SensitiveRevealContext);
}
export function SensitiveRevealToggle() {
const { revealed, toggle } = useSensitiveReveal();
return (
<Button
htmlType="button"
className="sensitive-reveal-toggle"
size="small"
ghost
aria-pressed={revealed}
title={revealed ? '恢复隐藏 ICCID、IMEI 与手机号' : '显示 ICCID、IMEI 与手机号'}
icon={<Icon name={revealed ? 'eye-off' : 'eye'} aria-hidden="true" />}
onClick={toggle}
>
{revealed ? '隐藏敏感标识' : '显示敏感标识'}
</Button>
);
}
@@ -0,0 +1,114 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest';
import { createComponentBackupApiDataSource } from './component-backup-api-data-source.js';
const catalog = {
items: [
{
key: 'devices',
label: '设备与分组',
description: '节点地址、分组、标签与能力快照。',
rows: 7,
},
],
};
const backup = {
filename: 'multi-simadmin-components-2026-09-04T03-30-00-000Z.json',
createdAt: '2026-09-04T03:30:00.000Z',
sizeBytes: 4096,
appVersion: '0.1.0',
note: '升级前',
automatic: true,
integrity: 'ok',
compatible: true,
components: [{ key: 'devices', label: '设备与分组', description: '', rows: 7 }],
};
const settings = {
enabled: true,
components: ['devices'],
timeOfDay: '03:30',
weekday: -1,
maximumCount: 14,
lastRunAt: null,
};
const json = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), { status });
describe('component backup API data source', () => {
it('reads the catalog, list and schedule with same-origin controls', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(json(catalog))
.mockResolvedValueOnce(json({ items: [backup] }))
.mockResolvedValueOnce(json(settings));
const source = createComponentBackupApiDataSource(fetcher);
const signal = new AbortController().signal;
await expect(source.catalog(signal)).resolves.toEqual(catalog.items);
await expect(source.list(signal)).resolves.toEqual([backup]);
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[1]?.[0]).toBe('/api/v1/system/component-backups');
expect(fetcher.mock.calls[2]?.[0]).toBe('/api/v1/system/component-backups/auto/settings');
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ credentials: 'same-origin', signal });
});
it('posts create, restore and delete against the encoded filename', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(json(backup, 201))
.mockResolvedValueOnce(json({ devices: 7 }))
.mockResolvedValueOnce(json({ filename: backup.filename }));
const source = createComponentBackupApiDataSource(fetcher);
await expect(source.create(['devices'], '升级前')).resolves.toEqual(backup);
await expect(source.restore(backup.filename, ['devices'])).resolves.toBeUndefined();
await expect(source.remove(backup.filename)).resolves.toBeUndefined();
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({
method: 'POST',
body: JSON.stringify({ components: ['devices'], note: '升级前' }),
});
expect(fetcher.mock.calls[1]?.[0]).toBe(
`/api/v1/system/component-backups/${encodeURIComponent(backup.filename)}/restore`,
);
expect(fetcher.mock.calls[2]?.[1]).toMatchObject({ method: 'DELETE' });
});
it('saves the schedule and rejects an unknown component key', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json(settings));
const source = createComponentBackupApiDataSource(fetcher);
await expect(
source.saveAutoSettings({
enabled: true,
components: ['devices'],
timeOfDay: '03:30',
weekday: -1,
maximumCount: 14,
}),
).resolves.toEqual(settings);
await expect(source.create(['secretReferences' as never], '')).rejects.toThrowError(
'Component backup response is invalid.',
);
});
it('refuses a payload whose integrity flag is not understood', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValue(json({ items: [{ ...backup, integrity: 'maybe' }] }));
await expect(createComponentBackupApiDataSource(fetcher).list()).rejects.toThrowError(
'Component backup response is invalid.',
);
});
it('refuses a filename that could escape the backup directory', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json(backup));
await expect(
createComponentBackupApiDataSource(fetcher).remove('../secret.json'),
).rejects.toThrowError('Component backup response is invalid.');
});
});
@@ -0,0 +1,263 @@
export type BackupComponentKey =
| 'devices'
| 'notifications'
| 'notificationRecords'
| 'automation'
| 'automationRecords'
| 'sms'
| 'settings'
| 'audit';
export type BackupComponent = Readonly<{
key: BackupComponentKey;
label: string;
description: string;
rows: number;
}>;
export type ComponentBackup = Readonly<{
filename: string;
createdAt: string;
sizeBytes: number;
appVersion: string;
note: string;
automatic: boolean;
integrity: 'ok' | 'failed';
compatible: boolean;
components: readonly BackupComponent[];
}>;
export type AutoBackupSettings = Readonly<{
enabled: boolean;
components: readonly BackupComponentKey[];
timeOfDay: string;
weekday: number;
maximumCount: number;
lastRunAt: string | null;
}>;
export interface ComponentBackupDataSource {
catalog(signal?: AbortSignal): Promise<readonly BackupComponent[]>;
list(signal?: AbortSignal): Promise<readonly ComponentBackup[]>;
create(components: readonly BackupComponentKey[], note: string): Promise<ComponentBackup>;
restore(filename: string, components: readonly BackupComponentKey[]): Promise<void>;
remove(filename: string): Promise<void>;
autoSettings(signal?: AbortSignal): Promise<AutoBackupSettings>;
saveAutoSettings(
settings: Readonly<{
enabled: boolean;
components: readonly BackupComponentKey[];
timeOfDay: string;
weekday: number;
maximumCount: number;
}>,
): Promise<AutoBackupSettings>;
}
const BASE = '/api/v1/system/component-backups';
const MAX_TEXT = 200;
const MAX_BACKUPS = 200;
const BACKUP_FILENAME = /^multi-simadmin-components-(?:auto-)?[A-Za-z0-9._-]{1,120}\.json$/u;
const KEYS: readonly BackupComponentKey[] = [
'devices',
'notifications',
'notificationRecords',
'automation',
'automationRecords',
'sms',
'settings',
'audit',
];
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, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum && value <= maximum;
const invalid = () => new Error('Component backup response is invalid.');
function safeName(filename: string): string {
if (!BACKUP_FILENAME.test(filename)) throw invalid();
return filename;
}
function parseComponentList(value: unknown): BackupComponent[] {
if (!Array.isArray(value)) throw invalid();
return value.map((candidate) => {
const item = record(candidate);
if (
!item ||
!KEYS.includes(item.key as BackupComponentKey) ||
!shortText(item.label) ||
!text(item.description) ||
!integer(item.rows)
)
throw invalid();
return {
key: item.key as BackupComponentKey,
label: item.label,
description: item.description,
rows: item.rows,
};
});
}
function parseBackup(value: unknown): ComponentBackup {
const source = record(value);
const createdAt = source?.createdAt;
if (
!source ||
!shortText(source.filename) ||
!BACKUP_FILENAME.test(source.filename) ||
!text(source.appVersion) ||
!text(source.note) ||
typeof source.automatic !== 'boolean' ||
(source.integrity !== 'ok' && source.integrity !== 'failed') ||
typeof source.compatible !== 'boolean' ||
!integer(source.sizeBytes, 1) ||
typeof createdAt !== 'string' ||
Number.isNaN(Date.parse(createdAt))
)
throw invalid();
return {
filename: source.filename,
createdAt: new Date(createdAt).toISOString(),
sizeBytes: source.sizeBytes,
appVersion: source.appVersion,
note: source.note,
automatic: source.automatic,
integrity: source.integrity,
compatible: source.compatible,
components: parseComponentList(source.components),
};
}
function parseAutoSettings(value: unknown): AutoBackupSettings {
const source = record(value);
const components = Array.isArray(source?.components) ? source.components : undefined;
if (
!source ||
typeof source.enabled !== 'boolean' ||
!components ||
!components.every((key) => KEYS.includes(key as BackupComponentKey)) ||
!shortText(source.timeOfDay) ||
!/^([01]\d|2[0-3]):[0-5]\d$/u.test(source.timeOfDay) ||
!integer(source.weekday, -1, 6) ||
!integer(source.maximumCount, 1, 500) ||
!(source.lastRunAt === null || typeof source.lastRunAt === 'string')
)
throw invalid();
return {
enabled: source.enabled,
components: components as BackupComponentKey[],
timeOfDay: source.timeOfDay,
weekday: source.weekday,
maximumCount: source.maximumCount,
lastRunAt: (source.lastRunAt as string | null) ?? null,
};
}
async function readJson(response: Response): Promise<unknown> {
try {
return await response.json();
} catch {
return undefined;
}
}
function requireOk(response: Response, body: unknown): unknown {
if (!response.ok || body === undefined || body === null)
throw new Error('Component backup request failed.');
return body;
}
const readHeaders = { accept: 'application/json' } as const;
const jsonHeaders = { accept: 'application/json', 'content-type': 'application/json' } as const;
export function createComponentBackupApiDataSource(
fetcher: typeof fetch = fetch,
): ComponentBackupDataSource {
const read = async (path: string, signal?: AbortSignal) => {
const response = await fetcher(path, {
method: 'GET',
credentials: 'same-origin',
headers: readHeaders,
...(signal ? { signal } : {}),
});
return requireOk(response, await readJson(response));
};
return {
async catalog(signal) {
const source = record(await read(`${BASE}/catalog`, signal));
if (!source || !Array.isArray(source.items)) throw invalid();
return parseComponentList(source.items);
},
async list(signal) {
const source = record(await read(BASE, signal));
if (!source || !Array.isArray(source.items) || source.items.length > MAX_BACKUPS)
throw invalid();
return source.items.map(parseBackup);
},
async create(components, note) {
if (
components.length === 0 ||
components.some((key) => !KEYS.includes(key)) ||
note.length > 120
)
throw invalid();
const response = await fetcher(BASE, {
method: 'POST',
credentials: 'same-origin',
headers: jsonHeaders,
body: JSON.stringify({ components, note }),
});
return parseBackup(requireOk(response, await readJson(response)));
},
async restore(filename, components) {
if (components.length === 0 || components.some((key) => !KEYS.includes(key))) throw invalid();
const response = await fetcher(`${BASE}/${encodeURIComponent(safeName(filename))}/restore`, {
method: 'POST',
credentials: 'same-origin',
headers: jsonHeaders,
body: JSON.stringify({ components }),
});
requireOk(response, await readJson(response));
},
async remove(filename) {
const response = await fetcher(`${BASE}/${encodeURIComponent(safeName(filename))}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: readHeaders,
});
requireOk(response, await readJson(response));
},
async autoSettings(signal) {
return parseAutoSettings(await read(`${BASE}/auto/settings`, signal));
},
async saveAutoSettings(settings) {
if (
settings.components.length === 0 ||
settings.components.some((key) => !KEYS.includes(key)) ||
!/^([01]\d|2[0-3]):[0-5]\d$/u.test(settings.timeOfDay) ||
settings.weekday < -1 ||
settings.weekday > 6 ||
settings.maximumCount < 1 ||
settings.maximumCount > 500
)
throw invalid();
const response = await fetcher(`${BASE}/auto/settings`, {
method: 'PUT',
credentials: 'same-origin',
headers: jsonHeaders,
body: JSON.stringify(settings),
});
return parseAutoSettings(requireOk(response, await readJson(response)));
},
};
}
@@ -0,0 +1,171 @@
// @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 {
ComponentBackupPanel,
type AutoBackupSettings,
type BackupComponent,
type ComponentBackup,
type ComponentBackupDataSource,
} from './component-backup-panel.js';
afterEach(cleanup);
const devices: BackupComponent = {
key: 'devices',
label: '设备与分组',
description: '节点地址、分组、标签与能力快照。',
rows: 7,
};
const sms: BackupComponent = {
key: 'sms',
label: '短信记录',
description: '集中保存的跨设备短信正文。',
rows: 120,
};
const catalog: readonly BackupComponent[] = [devices, sms];
const archive: ComponentBackup = {
filename: 'multi-simadmin-components-2026-09-04T03-30-00-000Z.json',
createdAt: '2026-09-04T03:30:00.000Z',
sizeBytes: 4096,
appVersion: '0.1.0',
note: '升级前快照',
automatic: false,
integrity: 'ok',
compatible: true,
components: [devices, sms],
};
const schedule: AutoBackupSettings = {
enabled: false,
components: ['devices'],
timeOfDay: '03:30',
weekday: -1,
maximumCount: 14,
lastRunAt: null,
};
function source(overrides: Partial<ComponentBackupDataSource> = {}): ComponentBackupDataSource {
return {
catalog: vi.fn(async () => catalog),
list: vi.fn(async () => [archive]),
create: vi.fn(async () => archive),
restore: vi.fn(async () => undefined),
remove: vi.fn(async () => undefined),
autoSettings: vi.fn(async () => schedule),
saveAutoSettings: vi.fn(async (value) => ({ ...schedule, ...value, lastRunAt: null })),
...overrides,
};
}
async function mount(dataSource: ComponentBackupDataSource): Promise<HTMLElement> {
render(<ComponentBackupPanel dataSource={dataSource} />);
return screen.findByRole('region', { name: '组件备份' });
}
async function picker(region: HTMLElement): Promise<HTMLElement> {
const group = within(region).getByRole('group', { name: '备份组件' });
await vi.waitFor(() => {
expect(within(group).getAllByRole('checkbox')).toHaveLength(catalog.length);
});
return group;
}
describe('ComponentBackupPanel', () => {
it('renders the catalog, the archive table and the schedule', async () => {
const region = await mount(source());
const group = await picker(region);
expect(within(group).getByText('短信记录')).toBeTruthy();
expect(within(group).getByText('120 条')).toBeTruthy();
expect(within(region).getByText('手动')).toBeTruthy();
expect(within(region).getByText('正常')).toBeTruthy();
expect(within(region).getByText(/上次执行:尚未执行/)).toBeTruthy();
});
it('creates a backup from the components the operator selects', async () => {
const dataSource = source();
const completed = vi.fn();
render(<ComponentBackupPanel dataSource={dataSource} onCompleted={completed} />);
const region = await screen.findByRole('region', { name: '组件备份' });
const group = await picker(region);
const create = within(region).getByRole('button', { name: '创建组件备份' });
expect((create as HTMLButtonElement).disabled).toBe(false);
await userEvent.click(within(group).getByRole('checkbox', { name: /短信记录/ }));
await userEvent.type(within(region).getByLabelText('备份备注'), '升级前快照');
await userEvent.click(create);
expect(dataSource.create).toHaveBeenCalledWith(
expect.arrayContaining(['devices', 'notifications', 'automation', 'sms']),
'升级前快照',
);
expect(completed).toHaveBeenCalledWith(`已创建组件备份 ${archive.filename}`);
expect((within(region).getByLabelText('备份备注') as HTMLInputElement).value).toBe('');
});
it('restores only the components that stay checked', async () => {
const dataSource = source();
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
const region = await mount(dataSource);
await userEvent.click(within(region).getByRole('button', { name: '恢复' }));
const panel = await screen.findByRole('group', { name: '选择恢复组件' });
await userEvent.click(within(panel).getByRole('checkbox', { name: /短信记录/ }));
await userEvent.click(within(panel).getByRole('button', { name: '确认恢复' }));
expect(dataSource.restore).toHaveBeenCalledWith(archive.filename, ['devices']);
expect(confirm).toHaveBeenCalledTimes(1);
confirm.mockRestore();
});
it('does not restore when the operator cancels the confirmation', async () => {
const dataSource = source();
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
const region = await mount(dataSource);
await userEvent.click(within(region).getByRole('button', { name: '恢复' }));
const panel = await screen.findByRole('group', { name: '选择恢复组件' });
await userEvent.click(within(panel).getByRole('button', { name: '确认恢复' }));
expect(dataSource.restore).not.toHaveBeenCalled();
expect(within(region).getByRole('group', { name: '选择恢复组件' })).toBeTruthy();
confirm.mockRestore();
});
it('blocks restore when the archive failed its integrity check', async () => {
const broken: ComponentBackup = { ...archive, integrity: 'failed', compatible: false };
const region = await mount(source({ list: vi.fn(async () => [broken]) }));
const restore = within(region).getByRole('button', { name: '恢复' });
expect((restore as HTMLButtonElement).disabled).toBe(true);
expect(within(region).getByText('校验失败')).toBeTruthy();
});
it('saves the automatic backup plan', async () => {
const dataSource = source();
const completed = vi.fn();
render(<ComponentBackupPanel dataSource={dataSource} onCompleted={completed} />);
const region = await screen.findByRole('region', { name: '组件备份' });
await picker(region);
const automatic = within(region).getByRole('group', { name: '自动备份组件' });
expect(within(automatic).getByRole('checkbox', { name: /设备与分组/ })).toBeTruthy();
await userEvent.click(within(region).getByRole('checkbox', { name: /启用自动备份/ }));
await userEvent.click(within(region).getByRole('button', { name: '保存备份计划' }));
expect(dataSource.saveAutoSettings).toHaveBeenCalledWith(
expect.objectContaining({ enabled: true, timeOfDay: '03:30', maximumCount: 14 }),
);
expect(completed).toHaveBeenCalledWith('定时组件备份计划已保存。');
});
it('reports a load failure and recovers on retry', async () => {
const catalogLoader = vi
.fn<ComponentBackupDataSource['catalog']>()
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValue(catalog);
const dataSource = source({ catalog: catalogLoader });
render(<ComponentBackupPanel dataSource={dataSource} />);
expect(await screen.findByText('无法加载组件备份状态。')).toBeTruthy();
await userEvent.click(screen.getByRole('button', { name: '重试加载' }));
const region = await screen.findByRole('region', { name: '组件备份' });
await picker(region);
expect(catalogLoader).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,479 @@
import { useCallback, useEffect, useState } from 'react';
import { Button, Card, Tag } from 'animal-island-ui';
import {
createComponentBackupApiDataSource,
type AutoBackupSettings,
type BackupComponent,
type BackupComponentKey,
type ComponentBackup,
type ComponentBackupDataSource,
} from './component-backup-api-data-source.js';
export type {
AutoBackupSettings,
BackupComponent,
BackupComponentKey,
ComponentBackup,
ComponentBackupDataSource,
} from './component-backup-api-data-source.js';
const WEEKDAYS: readonly { value: number; label: string }[] = [
{ value: -1, label: '每天' },
{ value: 1, label: '每周一' },
{ value: 2, label: '每周二' },
{ value: 3, label: '每周三' },
{ value: 4, label: '每周四' },
{ value: 5, label: '每周五' },
{ value: 6, label: '每周六' },
{ value: 0, label: '每周日' },
];
function formatBytes(value: number): string {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(value < 10 * 1024 ? 1 : 0)} KB`;
if (value < 1024 * 1024 * 1024)
return `${(value / (1024 * 1024)).toFixed(value < 10 * 1024 * 1024 ? 1 : 0)} MB`;
return `${(value / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function formatDateTime(value: string): string {
return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(
new Date(value),
);
}
type Busy = 'create' | 'restore' | 'delete' | 'schedule' | undefined;
/** Component-scoped export, merge and scheduled backup, mirroring the Hub backup centre. */
export function ComponentBackupPanel({
dataSource,
onCompleted,
}: {
readonly dataSource?: ComponentBackupDataSource;
readonly onCompleted?: (message: string) => void;
}) {
const [source] = useState(() => dataSource ?? createComponentBackupApiDataSource());
const [catalog, setCatalog] = useState<readonly BackupComponent[]>([]);
const [backups, setBackups] = useState<readonly ComponentBackup[]>([]);
const [schedule, setSchedule] = useState<AutoBackupSettings>();
const [selected, setSelected] = useState<ReadonlySet<BackupComponentKey>>(
() => new Set<BackupComponentKey>(['devices', 'notifications', 'automation']),
);
const [note, setNote] = useState('');
const [restoreTarget, setRestoreTarget] = useState<ComponentBackup>();
const [restoreSelection, setRestoreSelection] = useState<ReadonlySet<BackupComponentKey>>(
() => new Set<BackupComponentKey>(),
);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [busy, setBusy] = useState<Busy>();
const [error, setError] = useState('');
const [attempt, setAttempt] = useState(0);
useEffect(() => {
const controller = new AbortController();
let active = true;
setLoading(true);
setFailed(false);
void Promise.all([
source.catalog(controller.signal),
source.list(controller.signal),
source.autoSettings(controller.signal),
]).then(
([nextCatalog, nextBackups, nextSchedule]) => {
if (!active) return;
setCatalog(nextCatalog);
setBackups(nextBackups);
setSchedule(nextSchedule);
setLoading(false);
},
() => {
if (!active || controller.signal.aborted) return;
setFailed(true);
setLoading(false);
},
);
return () => {
active = false;
controller.abort();
};
}, [attempt, source]);
const reload = useCallback(async () => {
const [nextCatalog, nextBackups, nextSchedule] = await Promise.all([
source.catalog(),
source.list(),
source.autoSettings(),
]);
setCatalog(nextCatalog);
setBackups(nextBackups);
setSchedule(nextSchedule);
}, [source]);
const run = async (action: Exclude<Busy, undefined>, work: () => Promise<string>) => {
if (busy) return;
setBusy(action);
setError('');
try {
const message = await work();
if (message) onCompleted?.(message);
} catch {
setError('组件备份操作未完成,请稍后重试。');
} finally {
setBusy(undefined);
}
};
const create = () =>
run('create', async () => {
const components = [...selected];
if (components.length === 0) return '';
const created = await source.create(components, note.trim());
setNote('');
await reload();
return `已创建组件备份 ${created.filename}`;
});
const openRestore = (backup: ComponentBackup) => {
setError('');
setRestoreTarget(backup);
setRestoreSelection(new Set(backup.components.map((component) => component.key)));
};
const confirmRestore = () =>
run('restore', async () => {
if (!restoreTarget) return '';
const components = [...restoreSelection];
if (components.length === 0) return '';
const labels = restoreTarget.components
.filter((component) => components.includes(component.key))
.map((component) => component.label)
.join('、');
if (
!window.confirm(
`确认从 ${restoreTarget.filename} 恢复${labels}?所选组件将合并到当前控制台,其他数据保持不变。`,
)
)
return '';
await source.restore(restoreTarget.filename, components);
setRestoreTarget(undefined);
await reload();
return `${labels}已合并恢复。`;
});
const remove = (backup: ComponentBackup) =>
run('delete', async () => {
if (!window.confirm(`确认删除组件备份 ${backup.filename}?此操作不可恢复。`)) return '';
await source.remove(backup.filename);
setBackups((current) => current.filter((item) => item.filename !== backup.filename));
return `已删除组件备份 ${backup.filename}`;
});
const saveSchedule = () =>
run('schedule', async () => {
if (!schedule) return '';
const saved = await source.saveAutoSettings({
enabled: schedule.enabled,
components: schedule.components,
timeOfDay: schedule.timeOfDay,
weekday: schedule.weekday,
maximumCount: schedule.maximumCount,
});
setSchedule(saved);
return saved.enabled ? '定时组件备份计划已保存。' : '定时组件备份已关闭。';
});
const patchSchedule = (patch: Partial<AutoBackupSettings>) =>
setSchedule((current) => (current ? { ...current, ...patch } : current));
const choices = (
checked: ReadonlySet<BackupComponentKey>,
onToggle: (key: BackupComponentKey, next: boolean) => void,
) =>
catalog.map((component) => (
<label key={component.key} className="backup-component-choice">
<input
type="checkbox"
checked={checked.has(component.key)}
disabled={busy !== undefined}
onChange={(event) => onToggle(component.key, event.target.checked)}
/>
<span className="backup-component-text">
<strong>
{component.label}
<em>{component.rows.toLocaleString('zh-CN')} </em>
</strong>
<small>{component.description}</small>
</span>
</label>
));
return (
<Card pattern="default" className="settings-card maintenance-card">
<section aria-label="组件备份">
<h2></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 ? (
<>
<fieldset className="backup-component-picker" aria-label="备份组件">
<legend></legend>
{choices(selected, (key, next) =>
setSelected((current) => {
const updated = new Set(current);
if (next) updated.add(key);
else updated.delete(key);
return updated;
}),
)}
</fieldset>
<div className="backup-create-row">
<label className="visually-hidden" htmlFor="component-backup-note">
</label>
<input
id="component-backup-note"
type="text"
maxLength={120}
placeholder="备注(可选)"
value={note}
disabled={busy !== undefined}
onChange={(event) => setNote(event.target.value)}
/>
<Button
htmlType="button"
type="primary"
loading={busy === 'create'}
disabled={busy !== undefined || selected.size === 0}
onClick={() => void create()}
>
</Button>
</div>
{backups.length === 0 ? (
<p className="maintenance-hint"></p>
) : (
<div className="table-scroll">
<table className="dense-table" aria-label="组件备份列表">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th aria-label="操作" />
</tr>
</thead>
<tbody>
{backups.map((backup) => (
<tr key={backup.filename}>
<td>{formatDateTime(backup.createdAt)}</td>
<td>
<span
className="backup-components"
title={`${backup.note ? `${backup.note} · ` : ''}${backup.filename}`}
>
{backup.components.map((component) => component.label).join('、') ||
'空'}
</span>
</td>
<td>{formatBytes(backup.sizeBytes)}</td>
<td>{backup.automatic ? '定时' : '手动'}</td>
<td>
{backup.integrity === 'ok' ? (
<Tag size="small" color="app-green" variant="soft">
{backup.compatible ? '正常' : '版本不兼容'}
</Tag>
) : (
<Tag size="small" color="app-red" variant="soft">
</Tag>
)}
</td>
<td className="maintenance-backup-actions">
<Button
htmlType="button"
disabled={
busy !== undefined || backup.integrity !== 'ok' || !backup.compatible
}
onClick={() => openRestore(backup)}
>
</Button>
<Button
htmlType="button"
danger
disabled={busy !== undefined}
onClick={() => void remove(backup)}
>
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{restoreTarget ? (
<div className="backup-restore-panel" role="group" aria-label="选择恢复组件">
<h3> {restoreTarget.filename} </h3>
<p className="maintenance-hint">
</p>
{restoreTarget.components.map((component) => (
<label key={component.key} className="cleanup-choice">
<input
type="checkbox"
checked={restoreSelection.has(component.key)}
disabled={busy !== undefined}
onChange={(event) =>
setRestoreSelection((current) => {
const next = new Set(current);
if (event.target.checked) next.add(component.key);
else next.delete(component.key);
return next;
})
}
/>
<span>
{component.label} · {component.rows.toLocaleString('zh-CN')}
</span>
</label>
))}
<div className="action-buttons">
<Button
htmlType="button"
danger
loading={busy === 'restore'}
disabled={busy !== undefined || restoreSelection.size === 0}
onClick={() => void confirmRestore()}
>
</Button>
<Button
htmlType="button"
disabled={busy !== undefined}
onClick={() => setRestoreTarget(undefined)}
>
</Button>
</div>
</div>
) : null}
{schedule ? (
<section className="backup-schedule" aria-label="定时组件备份">
<h3></h3>
<label className="toggle-row">
<span>
<strong></strong>
<small></small>
</span>
<input
type="checkbox"
checked={schedule.enabled}
disabled={busy !== undefined}
onChange={(event) => patchSchedule({ enabled: event.target.checked })}
/>
</label>
<div className="backup-schedule-grid">
<label className="visually-hidden" htmlFor="backup-schedule-time">
</label>
<input
id="backup-schedule-time"
type="time"
value={schedule.timeOfDay}
disabled={busy !== undefined}
onChange={(event) => patchSchedule({ timeOfDay: event.target.value })}
/>
<label className="visually-hidden" htmlFor="backup-schedule-weekday">
</label>
<select
id="backup-schedule-weekday"
value={schedule.weekday}
disabled={busy !== undefined}
onChange={(event) => patchSchedule({ weekday: Number(event.target.value) })}
>
{WEEKDAYS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<label className="visually-hidden" htmlFor="backup-schedule-count">
</label>
<input
id="backup-schedule-count"
type="number"
min={1}
max={500}
value={schedule.maximumCount}
disabled={busy !== undefined}
onChange={(event) =>
patchSchedule({ maximumCount: Number(event.target.value) })
}
/>
</div>
<fieldset className="backup-component-picker" aria-label="自动备份组件">
<legend></legend>
{choices(new Set(schedule.components), (key, next) =>
patchSchedule({
components: next
? [...schedule.components, key]
: schedule.components.filter((item) => item !== key),
}),
)}
</fieldset>
<p className="maintenance-hint">
{schedule.lastRunAt ? formatDateTime(schedule.lastRunAt) : '尚未执行'}
</p>
<Button
htmlType="button"
loading={busy === 'schedule'}
disabled={busy !== undefined}
onClick={() => void saveSchedule()}
>
</Button>
</section>
) : null}
</>
) : null}
{error ? (
<p role="alert" className="maintenance-error">
{error}
</p>
) : null}
</section>
</Card>
);
}
@@ -0,0 +1,119 @@
// @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 { ConnectionSettingsPanel } from './connection-settings-panel.js';
import type { SystemMaintenanceDataSource } from './system-maintenance-api-data-source.js';
afterEach(cleanup);
const defaults = { heartbeatSeconds: 30, offlineSeconds: 90 };
function source(overrides: Partial<SystemMaintenanceDataSource> = {}): SystemMaintenanceDataSource {
return {
connection: vi.fn(async () => defaults),
updateConnection: vi.fn(async (settings) => settings),
refreshHeartbeat: vi.fn(async () => ({
probed: 7,
failed: 2,
startedAt: '2026-09-05T00:00:00.000Z',
finishedAt: '2026-09-05T00:00:01.000Z',
})),
...overrides,
} as unknown as SystemMaintenanceDataSource;
}
async function mount(overrides: Partial<SystemMaintenanceDataSource> = {}) {
const data = source(overrides);
const view = render(<ConnectionSettingsPanel dataSource={data} />);
const region = await screen.findByRole('region', { name: '连接设置' });
return { ...view, data, region };
}
function field(region: HTMLElement, label: string): HTMLInputElement {
const node = within(region).getByLabelText(label) as HTMLInputElement;
return node;
}
describe('ConnectionSettingsPanel', () => {
it('loads the saved cadence into the inputs', async () => {
const { data, region } = await mount();
expect(data.connection).toHaveBeenCalledTimes(1);
expect(field(region, '心跳间隔(秒)').value).toBe('30');
expect(field(region, '离线判定(秒)').value).toBe('90');
});
it('keeps save disabled until a valid change is made', async () => {
const { region } = await mount();
const save = within(region).getByRole('button', { name: '保存设置' }) as HTMLButtonElement;
expect(save.disabled).toBe(true);
await userEvent.clear(field(region, '心跳间隔(秒)'));
await userEvent.type(field(region, '心跳间隔(秒)'), '4');
expect(save.disabled).toBe(true);
await userEvent.clear(field(region, '心跳间隔(秒)'));
await userEvent.type(field(region, '心跳间隔(秒)'), '45');
expect(save.disabled).toBe(false);
});
it('sends whole numbers and echoes the saved window', async () => {
const { data, region } = await mount();
await userEvent.clear(field(region, '心跳间隔(秒)'));
await userEvent.type(field(region, '心跳间隔(秒)'), '45');
await userEvent.clear(field(region, '离线判定(秒)'));
await userEvent.type(field(region, '离线判定(秒)'), '180');
await userEvent.click(within(region).getByRole('button', { name: '保存设置' }));
expect(data.updateConnection).toHaveBeenCalledWith({
heartbeatSeconds: 45,
offlineSeconds: 180,
});
const notice = await within(region).findByRole('status');
expect(notice.textContent).toContain('连接设置已保存,心跳 45 秒,离线判定 180 秒。');
});
it('blocks a save when the offline window drops below twice the heartbeat', async () => {
const { data, region } = await mount();
await userEvent.clear(field(region, '离线判定(秒)'));
await userEvent.type(field(region, '离线判定(秒)'), '59');
const save = within(region).getByRole('button', { name: '保存设置' }) as HTMLButtonElement;
expect(save.disabled).toBe(true);
expect(data.updateConnection).not.toHaveBeenCalled();
});
it('runs an on-demand beat and reports the offline devices', async () => {
const { data, region } = await mount();
await userEvent.click(within(region).getByRole('button', { name: '立即刷新设备状态' }));
expect(data.refreshHeartbeat).toHaveBeenCalledTimes(1);
const notice = await within(region).findByRole('status');
expect(notice.textContent).toContain('已检查 9 台设备,2 台无响应。');
});
it('surfaces a failed save without losing the form', async () => {
const { region } = await mount({
updateConnection: vi.fn(async () => {
throw new Error('offline');
}),
});
await userEvent.clear(field(region, '心跳间隔(秒)'));
await userEvent.type(field(region, '心跳间隔(秒)'), '60');
await userEvent.clear(field(region, '离线判定(秒)'));
await userEvent.type(field(region, '离线判定(秒)'), '150');
await userEvent.click(within(region).getByRole('button', { name: '保存设置' }));
const problem = await within(region).findByRole('alert');
expect(problem.textContent).toContain('连接设置未保存,请检查心跳与离线判定的取值范围。');
expect(field(region, '心跳间隔(秒)').value).toBe('60');
});
it('explains itself when the cadence cannot be read', async () => {
const { region } = await mount({
connection: vi.fn(async () => {
throw new Error('offline');
}),
});
const problem = await within(region).findByRole('alert');
expect(problem.textContent).toContain('无法读取连接设置,请刷新页面重试。');
});
});
@@ -0,0 +1,179 @@
import { useEffect, useState } from 'react';
import { Button, Card } from 'animal-island-ui';
import {
MAX_HEARTBEAT_SECONDS,
MAX_OFFLINE_SECONDS,
MIN_HEARTBEAT_SECONDS,
createSystemMaintenanceApiDataSource,
type ConnectionSettings,
type SystemMaintenanceDataSource,
} from './system-maintenance-api-data-source.js';
export type { ConnectionSettings } from './system-maintenance-api-data-source.js';
function clampSeconds(value: string, minimum: number, maximum: number): number | undefined {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) return undefined;
return parsed;
}
/**
* Heartbeat cadence and the offline window, the Hub connection panel rebuilt on the control
* plane's own snapshot journal. Changes apply to the running beat without a restart.
*/
export function ConnectionSettingsPanel({
dataSource,
}: {
readonly dataSource?: SystemMaintenanceDataSource;
}) {
const [source] = useState(() => dataSource ?? createSystemMaintenanceApiDataSource());
const [settings, setSettings] = useState<ConnectionSettings>();
const [heartbeat, setHeartbeat] = useState('');
const [offline, setOffline] = useState('');
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [busy, setBusy] = useState<'save' | 'refresh' | undefined>();
useEffect(() => {
const controller = new AbortController();
let active = true;
void source
.connection(controller.signal)
.then((value) => {
if (!active) return;
setSettings(value);
setHeartbeat(String(value.heartbeatSeconds));
setOffline(String(value.offlineSeconds));
})
.catch(() => active && setError('无法读取连接设置,请刷新页面重试。'));
return () => {
active = false;
controller.abort();
};
}, [source]);
const heartbeatSeconds = clampSeconds(heartbeat, MIN_HEARTBEAT_SECONDS, MAX_HEARTBEAT_SECONDS);
const offlineSeconds = clampSeconds(offline, (heartbeatSeconds ?? 0) * 2, MAX_OFFLINE_SECONDS);
const dirty =
settings !== undefined &&
(heartbeatSeconds !== settings.heartbeatSeconds || offlineSeconds !== settings.offlineSeconds);
const run = async (action: 'save' | 'refresh', work: () => Promise<string>) => {
if (busy) return;
setBusy(action);
setError('');
setNotice('');
try {
setNotice(await work());
} catch {
setError(
action === 'save'
? '连接设置未保存,请检查心跳与离线判定的取值范围。'
: '设备刷新未完成,请稍后重试。',
);
} finally {
setBusy(undefined);
}
};
return (
<Card pattern="default" className="settings-card connection-card">
<section aria-label="连接设置">
<div className="connection-head">
<div>
<h2></h2>
<p className="maintenance-hint">
线线
</p>
</div>
<Button
htmlType="button"
loading={busy === 'refresh'}
disabled={busy !== undefined}
onClick={() =>
void run('refresh', async () => {
const summary = await source.refreshHeartbeat();
return `已检查 ${summary.probed + summary.failed} 台设备,${summary.failed} 台无响应。`;
})
}
>
</Button>
</div>
<div className="connection-grid">
<div className="connection-field">
<label htmlFor="connection-heartbeat"></label>
<input
id="connection-heartbeat"
type="number"
min={MIN_HEARTBEAT_SECONDS}
max={MAX_HEARTBEAT_SECONDS}
step={1}
value={heartbeat}
onChange={(event) => setHeartbeat(event.target.value)}
/>
<span className="maintenance-hint">
{MIN_HEARTBEAT_SECONDS} {MAX_HEARTBEAT_SECONDS}
</span>
</div>
<div className="connection-field">
<label htmlFor="connection-offline">线</label>
<input
id="connection-offline"
type="number"
min={(heartbeatSeconds ?? MIN_HEARTBEAT_SECONDS) * 2}
max={MAX_OFFLINE_SECONDS}
step={1}
value={offline}
onChange={(event) => setOffline(event.target.value)}
/>
<span className="maintenance-hint">
2 {MAX_OFFLINE_SECONDS}
</span>
</div>
</div>
<div className="action-buttons">
<Button
htmlType="button"
type="primary"
loading={busy === 'save'}
disabled={
busy !== undefined ||
!dirty ||
heartbeatSeconds === undefined ||
offlineSeconds === undefined
}
onClick={() =>
void run('save', async () => {
const saved = await source.updateConnection({
heartbeatSeconds: heartbeatSeconds as number,
offlineSeconds: offlineSeconds as number,
});
setSettings(saved);
setHeartbeat(String(saved.heartbeatSeconds));
setOffline(String(saved.offlineSeconds));
return `连接设置已保存,心跳 ${saved.heartbeatSeconds} 秒,离线判定 ${saved.offlineSeconds} 秒。`;
})
}
>
</Button>
</div>
{error ? (
<p role="alert" className="state-panel state-error">
{error}
</p>
) : null}
{notice && !error ? (
<p role="status" className="maintenance-notice">
{notice}
</p>
) : null}
</section>
</Card>
);
}
@@ -0,0 +1,168 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest';
import { createSystemMaintenanceApiDataSource } from './system-maintenance-api-data-source.js';
const overview = {
runtime: {
version: '0.1.0',
platform: 'darwin',
arch: 'arm64',
uptimeSeconds: 120,
},
storage: {
databaseBytes: 2048,
walBytes: 512,
backupBytes: 8192,
reclaimableBytes: 256,
components: [
{ key: 'instances', count: 2, bytes: 4096 },
{ key: 'auditEvents', count: 12, bytes: 12_288 },
],
},
retention: {
instances: { enabled: false, days: 365, maximumCount: 0 },
auditEvents: { enabled: true, days: 180, maximumCount: 50_000 },
},
};
const backup = {
filename: 'multi-simadmin-test.db',
path: '/tmp/multi-simadmin-test.db',
sizeBytes: 2048,
sha256: 'a'.repeat(64),
createdAt: '2026-09-03T00:00:00.000Z',
};
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status });
}
describe('system maintenance API data source', () => {
it('loads the native overview with same-origin request controls', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json(overview));
const signal = new AbortController().signal;
await expect(createSystemMaintenanceApiDataSource(fetcher).overview(signal)).resolves.toEqual(
overview,
);
expect(fetcher).toHaveBeenCalledWith('/api/v1/system/maintenance', {
method: 'GET',
credentials: 'same-origin',
headers: { accept: 'application/json' },
signal,
});
});
it('sends exact retention and cleanup requests', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(json(overview.retention))
.mockResolvedValueOnce(json({ auditEvents: 1 }));
const policy = { auditEvents: { enabled: true, days: 30, maximumCount: 100 } };
await expect(
createSystemMaintenanceApiDataSource(fetcher).updateRetention(policy),
).resolves.toEqual(overview.retention);
await expect(
createSystemMaintenanceApiDataSource(fetcher).cleanup(['auditEvents']),
).resolves.toEqual({ auditEvents: 1 });
expect(fetcher).toHaveBeenNthCalledWith(1, '/api/v1/system/maintenance/retention', {
method: 'PUT',
credentials: 'same-origin',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify(policy),
});
expect(fetcher).toHaveBeenNthCalledWith(2, '/api/v1/system/maintenance/cleanup', {
method: 'POST',
credentials: 'same-origin',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify({ components: ['auditEvents'] }),
});
});
it('lists and creates backups without exposing response extras', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(json({ items: [backup] }))
.mockResolvedValueOnce(json(backup, 201));
const source = createSystemMaintenanceApiDataSource(fetcher);
await expect(source.listBackups()).resolves.toEqual([backup]);
await expect(source.createBackup()).resolves.toEqual(backup);
expect(fetcher).toHaveBeenNthCalledWith(1, '/api/v1/system/maintenance/backups', {
method: 'GET',
credentials: 'same-origin',
headers: { accept: 'application/json' },
});
expect(fetcher).toHaveBeenNthCalledWith(2, '/api/v1/system/maintenance/backups', {
method: 'POST',
credentials: 'same-origin',
headers: { accept: 'application/json' },
});
});
it('deletes a backup and builds a download url for the same path', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(json({ filename: backup.filename }));
const source = createSystemMaintenanceApiDataSource(fetcher);
expect(source.backupDownloadUrl(backup.filename)).toBe(
'/api/v1/system/maintenance/backups/multi-simadmin-test.db',
);
await expect(source.deleteBackup(backup.filename)).resolves.toBeUndefined();
expect(fetcher).toHaveBeenCalledWith(
'/api/v1/system/maintenance/backups/multi-simadmin-test.db',
expect.objectContaining({ method: 'DELETE' }),
);
expect(() => source.backupDownloadUrl('../secret.db')).toThrow();
await expect(source.deleteBackup('notes.txt')).rejects.toThrow();
expect(fetcher).toHaveBeenCalledTimes(1);
});
it('reads an older overview without byte counters as zero', async () => {
const legacy = {
...overview,
storage: {
databaseBytes: 2048,
components: [
{ key: 'instances', count: 2 },
{ key: 'auditEvents', count: 12 },
],
},
};
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json(legacy));
await expect(createSystemMaintenanceApiDataSource(fetcher).overview()).resolves.toEqual({
...overview,
storage: {
...overview.storage,
components: [
{ key: 'instances', count: 2, bytes: 0 },
{ key: 'auditEvents', count: 12, bytes: 0 },
],
walBytes: 0,
backupBytes: 0,
reclaimableBytes: 0,
},
});
});
it('rejects invalid native responses', async () => {
const invalid = [
{ ...overview, runtime: { ...overview.runtime, uptimeSeconds: -1 } },
{ ...overview, storage: { ...overview.storage, databaseBytes: 'bad' } },
{ ...overview, retention: { instances: { enabled: false, days: 0, maximumCount: 0 } } },
];
for (const body of invalid) {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json(body));
await expect(createSystemMaintenanceApiDataSource(fetcher).overview()).rejects.toThrow(
'System maintenance response is invalid.',
);
}
});
});
@@ -0,0 +1,375 @@
export type DataComponentKey =
| 'instances'
| 'statusSnapshots'
| 'jobs'
| 'auditEvents'
| 'scheduledRuns'
| 'notificationQueue'
| 'smsMessages'
| 'eventJournal'
| 'connectionLogs';
export type RetentionPolicy = Readonly<{
enabled: boolean;
days: number;
maximumCount: number;
}>;
export type ConnectionSettings = Readonly<{
heartbeatSeconds: number;
offlineSeconds: number;
}>;
export type HeartbeatSummary = Readonly<{
probed: number;
failed: number;
startedAt: string;
finishedAt: string;
}>;
export type SystemMaintenanceOverview = Readonly<{
runtime: Readonly<{
version: string;
platform: string;
arch: string;
uptimeSeconds: number;
}>;
storage: Readonly<{
databaseBytes: number;
/** Write-ahead log file size; 0 when the database is not in WAL mode. */
walBytes: number;
/** Total size of the retained backup files. */
backupBytes: number;
/** Free pages that the SQLite optimize step can hand back. */
reclaimableBytes: number;
components: readonly { key: DataComponentKey; count: number; bytes: number }[];
}>;
retention: Readonly<Partial<Record<DataComponentKey, RetentionPolicy>>>;
}>;
export type SystemMaintenanceBackup = Readonly<{
filename: string;
path: string;
sizeBytes: number;
sha256: string;
createdAt: string;
}>;
export interface SystemMaintenanceDataSource {
overview(signal?: AbortSignal): Promise<SystemMaintenanceOverview>;
updateRetention(
policy: Readonly<Partial<Record<DataComponentKey, RetentionPolicy>>>,
): Promise<SystemMaintenanceOverview['retention']>;
connection(signal?: AbortSignal): Promise<ConnectionSettings>;
updateConnection(settings: ConnectionSettings): Promise<ConnectionSettings>;
refreshHeartbeat(): Promise<HeartbeatSummary>;
cleanup(
components: readonly DataComponentKey[],
): Promise<Partial<Record<DataComponentKey, number>>>;
optimize(): Promise<{ checkpointed: boolean; vacuumed: boolean; analyzed: boolean }>;
listBackups(): Promise<readonly SystemMaintenanceBackup[]>;
createBackup(): Promise<SystemMaintenanceBackup>;
deleteBackup(filename: string): Promise<void>;
backupDownloadUrl(filename: string): string;
}
const COMPONENT_KEYS: readonly DataComponentKey[] = [
'instances',
'statusSnapshots',
'jobs',
'auditEvents',
'scheduledRuns',
'notificationQueue',
'smsMessages',
'eventJournal',
'connectionLogs',
];
export const MIN_HEARTBEAT_SECONDS = 5;
export const MAX_HEARTBEAT_SECONDS = 300;
export const MAX_OFFLINE_SECONDS = 1_800;
const MAX_TEXT = 128;
const MAX_BACKUPS = 100;
const BACKUP_FILENAME = /^multi-simadmin-[A-Za-z0-9._-]{1,120}\.db$/u;
function safeBackupName(filename: string): string {
if (!BACKUP_FILENAME.test(filename)) throw new Error('System maintenance query is invalid.');
return filename;
}
const record = (value: unknown): Record<string, unknown> | undefined =>
typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
const string = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value.length <= MAX_TEXT;
const integer = (value: unknown, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum && value <= maximum;
/** Optional byte counters: an older console build may omit them, which reads as zero. */
function optionalInteger(value: unknown): number {
return integer(value, 0, Number.MAX_SAFE_INTEGER) ? value : 0;
}
function parseRetention(value: unknown): SystemMaintenanceOverview['retention'] {
const source = record(value) ?? {};
const result: Partial<Record<DataComponentKey, RetentionPolicy>> = {};
for (const [key, candidate] of Object.entries(source)) {
const policy = record(candidate);
if (
!COMPONENT_KEYS.includes(key as DataComponentKey) ||
!policy ||
typeof policy.enabled !== 'boolean' ||
!integer(policy.days, 1, 3_650) ||
!integer(policy.maximumCount, 0, 1_000_000_000)
)
throw new Error('System maintenance response is invalid.');
result[key as DataComponentKey] = {
enabled: policy.enabled,
days: policy.days,
maximumCount: policy.maximumCount,
};
}
return result;
}
function parseOverview(value: unknown): SystemMaintenanceOverview {
const source = record(value);
const runtime = record(source?.runtime);
const storage = record(source?.storage);
const componentSource = Array.isArray(storage?.components) ? storage?.components : undefined;
if (
!source ||
!runtime ||
!storage ||
!string(runtime.version) ||
!string(runtime.platform) ||
!string(runtime.arch) ||
!integer(runtime.uptimeSeconds) ||
!integer(storage.databaseBytes, 0, Number.MAX_SAFE_INTEGER) ||
!componentSource
)
throw new Error('System maintenance response is invalid.');
const seen = new Set<DataComponentKey>();
const components = componentSource.map((candidate) => {
const item = record(candidate);
const key = item?.key;
if (
!item ||
!COMPONENT_KEYS.includes(key as DataComponentKey) ||
seen.has(key as DataComponentKey) ||
!integer(item.count)
)
throw new Error('System maintenance response is invalid.');
seen.add(key as DataComponentKey);
return {
key: key as DataComponentKey,
count: item.count,
bytes: optionalInteger(item.bytes),
};
});
return {
runtime: {
version: runtime.version,
platform: runtime.platform,
arch: runtime.arch,
uptimeSeconds: runtime.uptimeSeconds,
},
storage: {
databaseBytes: storage.databaseBytes,
walBytes: optionalInteger(storage.walBytes),
backupBytes: optionalInteger(storage.backupBytes),
reclaimableBytes: optionalInteger(storage.reclaimableBytes),
components,
},
retention: parseRetention(source.retention),
};
}
function parseConnectionSettings(value: unknown): ConnectionSettings {
const source = record(value);
if (
!source ||
!integer(source.heartbeatSeconds, MIN_HEARTBEAT_SECONDS, MAX_HEARTBEAT_SECONDS) ||
!integer(source.offlineSeconds, source.heartbeatSeconds * 2, MAX_OFFLINE_SECONDS)
)
throw new Error('System maintenance response is invalid.');
return { heartbeatSeconds: source.heartbeatSeconds, offlineSeconds: source.offlineSeconds };
}
function parseHeartbeat(value: unknown): HeartbeatSummary {
const source = record(value);
if (
!source ||
!integer(source.probed) ||
!integer(source.failed) ||
typeof source.startedAt !== 'string' ||
Number.isNaN(Date.parse(source.startedAt)) ||
typeof source.finishedAt !== 'string' ||
Number.isNaN(Date.parse(source.finishedAt))
)
throw new Error('System maintenance response is invalid.');
return {
probed: source.probed,
failed: source.failed,
startedAt: new Date(source.startedAt).toISOString(),
finishedAt: new Date(source.finishedAt).toISOString(),
};
}
function parseBackup(value: unknown): SystemMaintenanceBackup {
const source = record(value);
if (
!source ||
!string(source.filename) ||
!string(source.path) ||
!string(source.sha256) ||
!/^[a-f\d]{64}$/iu.test(source.sha256) ||
!integer(source.sizeBytes, 1) ||
typeof source.createdAt !== 'string' ||
Number.isNaN(Date.parse(source.createdAt))
)
throw new Error('System maintenance response is invalid.');
return {
filename: source.filename,
path: source.path,
sizeBytes: source.sizeBytes,
sha256: source.sha256,
createdAt: new Date(source.createdAt).toISOString(),
};
}
async function readJson(response: Response): Promise<unknown> {
try {
return await response.json();
} catch {
return undefined;
}
}
function requireOk(response: Response, body: unknown): unknown {
if (!response.ok || body === undefined || body === null)
throw new Error('System maintenance request failed.');
return body;
}
const jsonHeaders = { accept: 'application/json', 'content-type': 'application/json' } as const;
const readHeaders = { accept: 'application/json' } as const;
export function createSystemMaintenanceApiDataSource(
fetcher: typeof fetch = fetch,
): SystemMaintenanceDataSource {
const read = async (path: string, signal?: AbortSignal) => {
const response = await fetcher(path, {
method: 'GET',
credentials: 'same-origin',
headers: readHeaders,
...(signal ? { signal } : {}),
});
return requireOk(response, await readJson(response));
};
return {
async overview(signal) {
return parseOverview(await read('/api/v1/system/maintenance', signal));
},
async updateRetention(policy) {
const response = await fetcher('/api/v1/system/maintenance/retention', {
method: 'PUT',
credentials: 'same-origin',
headers: jsonHeaders,
body: JSON.stringify(policy),
});
return parseRetention(requireOk(response, await readJson(response)));
},
async connection(signal) {
return parseConnectionSettings(await read('/api/v1/system/connection', signal));
},
async updateConnection(settings) {
if (
!integer(settings.heartbeatSeconds, MIN_HEARTBEAT_SECONDS, MAX_HEARTBEAT_SECONDS) ||
!integer(settings.offlineSeconds, settings.heartbeatSeconds * 2, MAX_OFFLINE_SECONDS)
)
throw new Error('System maintenance query is invalid.');
const response = await fetcher('/api/v1/system/connection', {
method: 'PUT',
credentials: 'same-origin',
headers: jsonHeaders,
body: JSON.stringify(settings),
});
return parseConnectionSettings(requireOk(response, await readJson(response)));
},
async refreshHeartbeat() {
const response = await fetcher('/api/v1/system/connection/refresh', {
method: 'POST',
credentials: 'same-origin',
headers: readHeaders,
});
return parseHeartbeat(requireOk(response, await readJson(response)));
},
async cleanup(components) {
if (
components.length === 0 ||
components.some((component) => !COMPONENT_KEYS.includes(component))
)
throw new Error('System maintenance query is invalid.');
const response = await fetcher('/api/v1/system/maintenance/cleanup', {
method: 'POST',
credentials: 'same-origin',
headers: jsonHeaders,
body: JSON.stringify({ components }),
});
const source = record(requireOk(response, await readJson(response))) ?? {};
const result: Partial<Record<DataComponentKey, number>> = {};
for (const [key, count] of Object.entries(source)) {
if (!COMPONENT_KEYS.includes(key as DataComponentKey) || !integer(count))
throw new Error('System maintenance response is invalid.');
result[key as DataComponentKey] = count;
}
return result;
},
async optimize() {
const response = await fetcher('/api/v1/system/maintenance/optimize', {
method: 'POST',
credentials: 'same-origin',
headers: readHeaders,
});
const source = record(requireOk(response, await readJson(response)));
if (
!source ||
source.checkpointed !== true ||
source.vacuumed !== true ||
source.analyzed !== true
)
throw new Error('System maintenance response is invalid.');
return { checkpointed: true, vacuumed: true, analyzed: true };
},
async listBackups() {
const source = record(await read('/api/v1/system/maintenance/backups'));
if (!source || !Array.isArray(source.items) || source.items.length > MAX_BACKUPS)
throw new Error('System maintenance response is invalid.');
return source.items.map(parseBackup);
},
async createBackup() {
const response = await fetcher('/api/v1/system/maintenance/backups', {
method: 'POST',
credentials: 'same-origin',
headers: readHeaders,
});
return parseBackup(requireOk(response, await readJson(response)));
},
async deleteBackup(filename) {
const response = await fetcher(
`/api/v1/system/maintenance/backups/${encodeURIComponent(safeBackupName(filename))}`,
{
method: 'DELETE',
credentials: 'same-origin',
headers: readHeaders,
},
);
requireOk(response, await readJson(response));
},
backupDownloadUrl(filename) {
return `/api/v1/system/maintenance/backups/${encodeURIComponent(safeBackupName(filename))}`;
},
};
}
@@ -0,0 +1,168 @@
// @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 {
SystemMaintenancePage,
type SystemMaintenanceDataSource,
} from './system-maintenance-page.js';
afterEach(cleanup);
const backup = {
filename: 'multi-simadmin-2026.db',
path: '/tmp/multi-simadmin-2026.db',
sizeBytes: 2048,
sha256: 'a'.repeat(64),
createdAt: '2026-09-03T00:00:00.000Z',
};
const overview = {
runtime: {
version: '0.1.0',
platform: 'darwin',
arch: 'arm64',
uptimeSeconds: 120,
},
storage: {
databaseBytes: 4096,
walBytes: 1024,
backupBytes: 2048,
reclaimableBytes: 512,
components: [
{ key: 'instances' as const, count: 3, bytes: 4096 },
{ key: 'auditEvents' as const, count: 24, bytes: 24_576 },
],
},
retention: {
instances: { enabled: false, days: 365, maximumCount: 0 },
auditEvents: { enabled: true, days: 180, maximumCount: 50_000 },
},
};
function source(): SystemMaintenanceDataSource {
return {
overview: vi.fn(async () => overview),
updateRetention: vi.fn(async (policy) => policy),
connection: vi.fn(async () => ({ heartbeatSeconds: 30, offlineSeconds: 90 })),
updateConnection: vi.fn(async (settings) => settings),
refreshHeartbeat: vi.fn(async () => ({
probed: 3,
failed: 1,
startedAt: '2026-09-05T02:00:00.000Z',
finishedAt: '2026-09-05T02:00:01.000Z',
})),
cleanup: vi.fn(async () => ({ auditEvents: 1 })),
optimize: vi.fn(async () => ({ checkpointed: true, vacuumed: true, analyzed: true })),
listBackups: vi.fn(async () => [backup]),
createBackup: vi.fn(async () => ({ ...backup, filename: 'created.db' })),
deleteBackup: vi.fn(async () => undefined),
backupDownloadUrl: vi.fn(
(filename: string) => `/api/v1/system/maintenance/backups/${encodeURIComponent(filename)}`,
),
};
}
describe('System maintenance page', () => {
it('loads the native overview and backups', async () => {
const dataSource = source();
render(<SystemMaintenancePage dataSource={dataSource} />);
expect(screen.getByRole('status', { name: '系统维护加载状态' })).toBeTruthy();
const status = await screen.findByRole('region', { name: '运行状态' });
expect(within(status).getByText('0.1.0')).toBeTruthy();
expect(within(status).getByText('2 分钟')).toBeTruthy();
const storage = screen.getByRole('region', { name: '存储统计' });
const table = within(storage).getByRole('table');
expect(within(table).getByText('实例')).toBeTruthy();
expect(within(table).getByText('3')).toBeTruthy();
expect(within(table).getByText('28 KB')).toBeTruthy();
expect(within(table).getByText('4.0 KB')).toBeTruthy();
expect(within(table).getByText('24 KB')).toBeTruthy();
expect(within(storage).getByText('7.0 KB')).toBeTruthy();
expect(within(storage).getByText('SQLite 数据库')).toBeTruthy();
expect(within(storage).getByText('WAL 文件')).toBeTruthy();
expect(within(storage).getByText('备份文件')).toBeTruthy();
expect(within(storage).getByText('实际存储占用')).toBeTruthy();
expect(within(storage).getByText('合计')).toBeTruthy();
expect(within(table).getByText('27')).toBeTruthy();
expect(within(storage).getByText('整理空间可回收约 512 B 空闲页。')).toBeTruthy();
expect(screen.getByRole('table', { name: '本地备份' })).toBeTruthy();
expect(screen.getByText('multi-simadmin-2026.db')).toBeTruthy();
expect(dataSource.overview).toHaveBeenCalledWith(expect.any(AbortSignal));
expect(dataSource.listBackups).toHaveBeenCalledTimes(1);
});
it('saves edited retention policies', async () => {
const user = userEvent.setup();
const dataSource = source();
render(<SystemMaintenancePage dataSource={dataSource} />);
await screen.findByRole('region', { name: '数据保留' });
await user.type(screen.getByLabelText('审计事件保留天数'), '30');
await user.click(screen.getByRole('button', { name: '保存保留策略' }));
expect(dataSource.updateRetention).toHaveBeenCalledWith(
expect.objectContaining({
auditEvents: { enabled: true, days: 18030, maximumCount: 50_000 },
}),
);
expect(await screen.findByRole('status')).toBeTruthy();
});
it('runs selected cleanup, optimization, and backup creation', async () => {
const user = userEvent.setup();
const confirm = vi.fn(() => true);
window.confirm = confirm;
const dataSource = source();
render(<SystemMaintenancePage dataSource={dataSource} />);
await screen.findByRole('region', { name: '维护操作' });
await user.click(screen.getByRole('checkbox', { name: '审计事件' }));
await user.click(screen.getByRole('button', { name: '执行清理' }));
await user.click(screen.getByRole('button', { name: '优化数据库' }));
await user.click(screen.getByRole('button', { name: '创建备份' }));
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('审计事件'));
expect(dataSource.cleanup).toHaveBeenCalledWith(['auditEvents']);
expect(dataSource.optimize).toHaveBeenCalledTimes(1);
expect(dataSource.createBackup).toHaveBeenCalledTimes(1);
expect(await screen.findByText('已创建备份 created.db。')).toBeTruthy();
});
it('links each backup for download and deletes one after confirmation', async () => {
const user = userEvent.setup();
const confirm = vi.fn(() => true);
window.confirm = confirm;
const dataSource = source();
render(<SystemMaintenancePage dataSource={dataSource} />);
await screen.findByRole('table', { name: '本地备份' });
const row = screen.getByRole('row', { name: /multi-simadmin-2026\.db/ });
const link = within(row).getByRole('link', { name: '下载' });
expect(link.getAttribute('href')).toBe(
'/api/v1/system/maintenance/backups/multi-simadmin-2026.db',
);
expect(link.getAttribute('download')).toBe('multi-simadmin-2026.db');
await user.click(within(row).getByRole('button', { name: '删除' }));
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('multi-simadmin-2026.db'));
expect(dataSource.deleteBackup).toHaveBeenCalledWith('multi-simadmin-2026.db');
expect(await screen.findByText('已删除备份 multi-simadmin-2026.db。')).toBeTruthy();
expect(screen.queryByText('multi-simadmin-2026.db')).toBeNull();
});
it('keeps the backup when the operator cancels the delete prompt', async () => {
const user = userEvent.setup();
window.confirm = vi.fn(() => false);
const dataSource = source();
render(<SystemMaintenancePage dataSource={dataSource} />);
await screen.findByRole('table', { name: '本地备份' });
await user.click(screen.getByRole('button', { name: '删除' }));
expect(dataSource.deleteBackup).not.toHaveBeenCalled();
expect(screen.getByText('multi-simadmin-2026.db')).toBeTruthy();
});
});
@@ -0,0 +1,494 @@
import { useEffect, useState } from 'react';
import { Button, Card, Title } from 'animal-island-ui';
import type {
DataComponentKey,
RetentionPolicy,
SystemMaintenanceBackup,
SystemMaintenanceDataSource,
SystemMaintenanceOverview,
} from './system-maintenance-api-data-source.js';
import { createSystemMaintenanceApiDataSource } from './system-maintenance-api-data-source.js';
import { ComponentBackupPanel } from './component-backup-panel.js';
import { ConnectionSettingsPanel } from './connection-settings-panel.js';
export type { SystemMaintenanceDataSource } from './system-maintenance-api-data-source.js';
const COMPONENT_KEYS: readonly DataComponentKey[] = [
'instances',
'statusSnapshots',
'jobs',
'auditEvents',
'scheduledRuns',
'notificationQueue',
'smsMessages',
'eventJournal',
'connectionLogs',
];
const COMPONENT_LABELS: Readonly<Record<DataComponentKey, string>> = {
instances: '实例',
statusSnapshots: '状态快照',
jobs: '任务',
auditEvents: '审计事件',
scheduledRuns: '调度记录',
notificationQueue: '通知队列',
smsMessages: '短信消息',
eventJournal: '事件日志',
connectionLogs: '连接日志',
};
/** Everything the console actually keeps on disk: database, WAL and backup files. */
function storageTotal(overview: SystemMaintenanceOverview): number {
return overview.storage.databaseBytes + overview.storage.walBytes + overview.storage.backupBytes;
}
function formatBytes(value: number): string {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(value < 10 * 1024 ? 1 : 0)} KB`;
if (value < 1024 * 1024 * 1024)
return `${(value / (1024 * 1024)).toFixed(value < 10 * 1024 * 1024 ? 1 : 0)} MB`;
return `${(value / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function formatDateTime(value: string): string {
return new Intl.DateTimeFormat('zh-CN', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
function allRetention(
value: SystemMaintenanceOverview['retention'],
): Record<DataComponentKey, RetentionPolicy> {
return Object.fromEntries(
COMPONENT_KEYS.map((key) => [
key,
value[key] ?? { enabled: false, days: 365, maximumCount: 0 },
]),
) as Record<DataComponentKey, RetentionPolicy>;
}
type BusyAction = 'retention' | 'cleanup' | 'optimize' | 'backup' | 'delete' | undefined;
export function SystemMaintenancePage({
dataSource,
}: {
readonly dataSource?: SystemMaintenanceDataSource;
}) {
const [source] = useState(() => dataSource ?? createSystemMaintenanceApiDataSource());
const [overview, setOverview] = useState<SystemMaintenanceOverview>();
const [retention, setRetention] = useState<Record<DataComponentKey, RetentionPolicy>>();
const [selected, setSelected] = useState<ReadonlySet<DataComponentKey>>(new Set());
const [backups, setBackups] = useState<readonly SystemMaintenanceBackup[]>([]);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [busy, setBusy] = useState<BusyAction>();
const [notice, setNotice] = useState('');
const [error, setError] = useState('');
const [attempt, setAttempt] = useState(0);
useEffect(() => {
const controller = new AbortController();
let active = true;
setLoading(true);
setFailed(false);
setNotice('');
setError('');
void Promise.all([source.overview(controller.signal), source.listBackups()]).then(
([nextOverview, nextBackups]) => {
if (!active) return;
setOverview(nextOverview);
setRetention(allRetention(nextOverview.retention));
setBackups(nextBackups);
setLoading(false);
},
() => {
if (!active || controller.signal.aborted) return;
setFailed(true);
setLoading(false);
},
);
return () => {
active = false;
controller.abort();
};
}, [attempt, source]);
const run = async (action: BusyAction, work: () => Promise<string>) => {
if (busy) return;
setBusy(action);
setError('');
setNotice('');
try {
setNotice(await work());
} catch {
setError('系统维护操作未完成,请稍后重试。');
} finally {
setBusy(undefined);
}
};
const saveRetention = () =>
run('retention', async () => {
if (!retention) return '';
const saved = await source.updateRetention(retention);
setRetention(allRetention(saved));
setOverview((current) => (current ? { ...current, retention: saved } : current));
return '保留策略已保存。';
});
const cleanup = () =>
run('cleanup', async () => {
const components = [...selected];
if (components.length === 0) return '请先选择要清理的数据。';
const label = components.map((component) => COMPONENT_LABELS[component]).join('、');
if (!window.confirm(`确认清理选中的${label}数据?此操作不可恢复。`)) return '';
const result = await source.cleanup(components);
const removed = components.reduce((sum, component) => sum + (result[component] ?? 0), 0);
const primary = components.find((component) => result[component] !== undefined);
return primary
? `清理了 ${(result[primary] ?? 0).toLocaleString('zh-CN')}${COMPONENT_LABELS[primary]}数据。`
: `清理完成,共移除 ${removed.toLocaleString('zh-CN')} 条数据。`;
});
const optimize = () =>
run('optimize', async () => {
await source.optimize();
return '数据库检查点、碎片整理和统计优化已完成。';
});
const createBackup = () =>
run('backup', async () => {
const backup = await source.createBackup();
setBackups((current) => [backup, ...current].slice(0, 100));
return `已创建备份 ${backup.filename}`;
});
const removeBackup = (filename: string) =>
run('delete', async () => {
if (!window.confirm(`确认删除备份 ${filename}?此操作不可恢复。`)) return '';
await source.deleteBackup(filename);
setBackups((current) => current.filter((backup) => backup.filename !== filename));
return `已删除备份 ${filename}`;
});
const updatePolicy = (key: DataComponentKey, patch: Partial<RetentionPolicy>) => {
setRetention((current) =>
current
? {
...current,
[key]: { ...current[key], ...patch },
}
: current,
);
};
return (
<section
className="settings-page system-maintenance"
aria-labelledby="system-maintenance-title"
>
<header className="page-heading">
<h1 id="system-maintenance-title">
<Title color="app-green"></Title>
</h1>
<p>SQLite </p>
</header>
{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}
{overview && retention ? (
<div className="maintenance-layout">
<Card pattern="default" className="settings-card maintenance-card">
<section aria-label="运行状态">
<h2></h2>
<dl className="maintenance-summary">
<div>
<dt></dt>
<dd>{overview.runtime.version}</dd>
</div>
<div>
<dt></dt>
<dd>
{overview.runtime.platform} / {overview.runtime.arch}
</dd>
</div>
<div>
<dt></dt>
<dd>{Math.floor(overview.runtime.uptimeSeconds / 60)} </dd>
</div>
</dl>
</section>
<section aria-label="存储统计">
<h2></h2>
<ul className="maintenance-components maintenance-storage-files">
<li>
<span>SQLite </span>
<strong>{formatBytes(overview.storage.databaseBytes)}</strong>
</li>
<li>
<span>WAL </span>
<strong>{formatBytes(overview.storage.walBytes)}</strong>
</li>
<li>
<span></span>
<strong>{formatBytes(overview.storage.backupBytes)}</strong>
</li>
<li>
<span></span>
<strong>{formatBytes(storageTotal(overview))}</strong>
</li>
</ul>
<div className="table-scroll">
<table className="dense-table maintenance-storage-table">
<thead>
<tr>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{overview.storage.components.map((component) => (
<tr key={component.key}>
<th scope="row">{COMPONENT_LABELS[component.key]}</th>
<td>{component.count.toLocaleString('zh-CN')}</td>
<td>{formatBytes(component.bytes)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<th scope="row"></th>
<td>
{overview.storage.components
.reduce((sum, component) => sum + component.count, 0)
.toLocaleString('zh-CN')}
</td>
<td>
{formatBytes(
overview.storage.components.reduce(
(sum, component) => sum + component.bytes,
0,
),
)}
</td>
</tr>
</tfoot>
</table>
</div>
{overview.storage.reclaimableBytes > 0 ? (
<p className="maintenance-hint">
{`整理空间可回收约 ${formatBytes(overview.storage.reclaimableBytes)} 空闲页。`}
</p>
) : null}
</section>
</Card>
<Card pattern="default" className="settings-card maintenance-card">
<section aria-label="数据保留">
<h2></h2>
<p className="maintenance-hint"></p>
<div className="retention-grid">
{COMPONENT_KEYS.map((key) => {
const policy = retention[key];
return (
<div key={key} className="retention-item">
<label className="toggle-row">
<span>
<strong>{COMPONENT_LABELS[key]}</strong>
<small> / </small>
</span>
<input
type="checkbox"
checked={policy.enabled}
onChange={(event) => updatePolicy(key, { enabled: event.target.checked })}
/>
</label>
<label className="visually-hidden" htmlFor={`retention-days-${key}`}>
{COMPONENT_LABELS[key]}
</label>
<input
id={`retention-days-${key}`}
type="number"
min={1}
max={3650}
value={policy.days}
onChange={(event) =>
updatePolicy(key, { days: Number(event.target.value) })
}
/>
<label className="visually-hidden" htmlFor={`retention-limit-${key}`}>
{COMPONENT_LABELS[key]}
</label>
<input
id={`retention-limit-${key}`}
type="number"
min={0}
value={policy.maximumCount}
onChange={(event) =>
updatePolicy(key, { maximumCount: Number(event.target.value) })
}
/>
</div>
);
})}
</div>
<Button
htmlType="button"
type="primary"
loading={busy === 'retention'}
disabled={busy !== undefined}
onClick={() => void saveRetention()}
>
</Button>
</section>
</Card>
<Card pattern="default" className="settings-card maintenance-card">
<section aria-label="维护操作">
<h2></h2>
<div className="maintenance-actions">
<fieldset>
<legend></legend>
{COMPONENT_KEYS.map((key) => (
<label key={key} className="cleanup-choice">
<input
type="checkbox"
checked={selected.has(key)}
onChange={(event) => {
const next = new Set(selected);
if (event.target.checked) next.add(key);
else next.delete(key);
setSelected(next);
}}
/>
<span>{COMPONENT_LABELS[key]}</span>
</label>
))}
</fieldset>
<div className="action-buttons">
<Button
htmlType="button"
danger
loading={busy === 'cleanup'}
disabled={busy !== undefined}
onClick={() => void cleanup()}
>
</Button>
<Button
htmlType="button"
loading={busy === 'optimize'}
disabled={busy !== undefined}
onClick={() => void optimize()}
>
</Button>
<Button
htmlType="button"
loading={busy === 'backup'}
disabled={busy !== undefined}
onClick={() => void createBackup()}
>
</Button>
</div>
</div>
</section>
</Card>
<Card pattern="default" className="settings-card maintenance-card">
<section aria-label="本地备份">
<h2></h2>
{backups.length === 0 ? (
<p></p>
) : (
<div className="table-scroll">
<table className="dense-table" aria-label="本地备份">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th>SHA256</th>
<th aria-label="操作" />
</tr>
</thead>
<tbody>
{backups.map((backup) => (
<tr key={backup.filename}>
<td>{backup.filename}</td>
<td>{formatBytes(backup.sizeBytes)}</td>
<td>{formatDateTime(backup.createdAt)}</td>
<td>
<code title={backup.sha256}>{backup.sha256.slice(0, 12)}</code>
</td>
<td className="maintenance-backup-actions">
<a
href={source.backupDownloadUrl(backup.filename)}
download={backup.filename}
>
</a>
<Button
htmlType="button"
danger
disabled={busy !== undefined}
onClick={() => void removeBackup(backup.filename)}
>
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</Card>
<ConnectionSettingsPanel dataSource={source} />
<ComponentBackupPanel
onCompleted={(message) => {
setError('');
setNotice(message);
}}
/>
</div>
) : null}
{!loading && notice ? (
<p role="status" className="maintenance-notice">
{notice}
</p>
) : null}
{!loading && error ? (
<p role="alert" className="maintenance-error">
{error}
</p>
) : null}
</section>
);
}
+2482 -5
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -10,6 +10,7 @@ export type IconName =
| 'chevron-right'
| 'cpu'
| 'filter'
| 'edit'
| 'globe'
| 'grid'
| 'history'
@@ -21,12 +22,14 @@ export type IconName =
| 'more'
| 'phone'
| 'plus'
| 'power'
| 'restart'
| 'search'
| 'server'
| 'settings'
| 'tag'
| 'temperature'
| 'trash'
| 'version'
| 'wifi';
@@ -44,6 +47,7 @@ const paths: Readonly<Record<IconName, readonly string[]>> = {
'chevron-right': ['m9 18 6-6-6-6'],
cpu: ['M9 9h6v6H9z', 'M4 9h2M4 15h2M18 9h2M18 15h2M9 4v2M15 4v2M9 18v2M15 18v2', 'M6 6h12v12H6z'],
filter: ['M4 5h16l-6 7v5l-4 2v-7Z'],
edit: ['M12 20h9', 'M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z'],
eye: [
'M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6S2.5 12 2.5 12Z',
'M12 9a3 3 0 1 1 0 6 3 3 0 0 1 0-6Z',
@@ -69,6 +73,7 @@ const paths: Readonly<Record<IconName, readonly string[]>> = {
'M7 3h3l1.5 4-2 1.5a15 15 0 0 0 6 6L17 12.5l4 1.5v3c0 2.2-1.8 4-4 4A14 14 0 0 1 3 7c0-2.2 1.8-4 4-4Z',
],
plus: ['M12 5v14M5 12h14'],
power: ['M12 2v10', 'M18.4 6.6a9 9 0 1 1-12.8 0'],
restart: ['M20 11a8 8 0 1 0-2.3 5.7', 'M20 4v7h-7'],
search: ['M11 19a8 8 0 1 1 0-16 8 8 0 0 1 0 16ZM17 17l4 4'],
server: ['M4 4h16v6H4zM4 14h16v6H4z', 'M8 7h.01M8 17h.01'],
@@ -78,6 +83,7 @@ const paths: Readonly<Record<IconName, readonly string[]>> = {
],
tag: ['M20 13 13 20l-9-9V4h7Z', 'M8.5 8.5h.01'],
temperature: ['M10 14.8V5a2 2 0 1 1 4 0v9.8a4 4 0 1 1-4 0Z', 'M12 17v-7'],
trash: ['M4 7h16', 'M10 11v6M14 11v6', 'M6 7l1 13h10l1-13', 'M9 7V4h6v3'],
version: ['M5 4h14v16H5z', 'M8 8h8M8 12h8M8 16h5'],
wifi: [
'M3 8.5a14 14 0 0 1 18 0',