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:
@@ -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
@@ -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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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('未上报');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 }),
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 }));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user