feat(web): rebuild the console around the fused control plane

Render the Hub feature set in the existing Animal Island styling: fleet
messages, notification centre, log centre, organisation panel, per-instance
device module panels and the settings backup, connection and maintenance
surfaces.
This commit is contained in:
chick
2026-09-05 18:53:04 +08:00
parent f9186bd851
commit a75badeb58
77 changed files with 20578 additions and 435 deletions
+20 -13
View File
@@ -2,16 +2,17 @@ import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { InstanceContext } from '../app-shell.js';
import { displayValue } from '../ui/locale.js';
import { DeviceActions } from './device-action-controls.js';
/** Aggregate task counts only; per-task records and configuration are intentionally unsupported. */
export interface AutomationTaskCounts {
readonly total?: number | null;
readonly enabled?: number | null;
readonly disabled?: number | null;
readonly running?: number | null;
readonly queued?: number | null;
readonly succeeded?: number | null;
readonly failed?: number | null;
readonly total?: number | null | undefined;
readonly enabled?: number | null | undefined;
readonly disabled?: number | null | undefined;
readonly running?: number | null | undefined;
readonly queued?: number | null | undefined;
readonly succeeded?: number | null | undefined;
readonly failed?: number | null | undefined;
}
/** Bounded high-level status labels only, never status payloads or raw logs. */
@@ -19,13 +20,13 @@ export type AutomationState = 'healthy' | 'degraded' | 'failed' | 'disabled' | '
export type AutomationScheduler = 'active' | 'idle' | 'paused' | 'disabled' | 'unavailable';
export type AutomationWorkers = 'available' | 'busy' | 'degraded' | 'disabled' | 'unavailable';
export interface AutomationStatus {
readonly state?: AutomationState | null;
readonly scheduler?: AutomationScheduler | null;
readonly workers?: AutomationWorkers | null;
readonly state?: AutomationState | null | undefined;
readonly scheduler?: AutomationScheduler | null | undefined;
readonly workers?: AutomationWorkers | null | undefined;
}
export interface AutomationSnapshot {
readonly observedAt?: string;
readonly observedAt?: string | undefined;
readonly status: AutomationStatus;
readonly tasks: AutomationTaskCounts;
}
@@ -37,8 +38,8 @@ export interface AutomationDataSource {
export interface AutomationModuleProps {
readonly instance: InstanceContext;
readonly dataSource?: AutomationDataSource;
readonly refreshSignal?: unknown;
readonly dataSource?: AutomationDataSource | undefined;
readonly refreshSignal?: unknown | undefined;
}
type ReadState =
@@ -291,6 +292,12 @@ export function AutomationModule({ instance, dataSource, refreshSignal }: Automa
</p>
) : null}
<SnapshotView snapshot={snapshot} />
<DeviceActions
instance={instance}
module="automation"
onExecuted={() => setRetry((value) => value + 1)}
/>
</div>
);
}
+56 -14
View File
@@ -2,33 +2,42 @@ import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { InstanceContext } from '../app-shell.js';
import { displayValue } from '../ui/locale.js';
import { DeviceActions } from './device-action-controls.js';
/** Bounded values permitted in aggregate call/device status. */
export type CallsStatusValue = string | number | boolean | null;
/** Aggregate call state only. Per-call identity, phone numbers, logs, and media are unsupported. */
export interface AggregateCallStatus {
readonly state?: CallsStatusValue;
readonly total?: CallsStatusValue;
readonly active?: CallsStatusValue;
readonly ringing?: CallsStatusValue;
readonly held?: CallsStatusValue;
readonly failed?: CallsStatusValue;
readonly state?: CallsStatusValue | undefined;
readonly total?: CallsStatusValue | undefined;
readonly active?: CallsStatusValue | undefined;
readonly ringing?: CallsStatusValue | undefined;
readonly held?: CallsStatusValue | undefined;
readonly failed?: CallsStatusValue | undefined;
}
/** Aggregate device state only. Device phone numbers and media are intentionally unsupported. */
export interface AggregateCallDeviceStatus {
readonly state?: CallsStatusValue;
readonly total?: CallsStatusValue;
readonly online?: CallsStatusValue;
readonly offline?: CallsStatusValue;
readonly busy?: CallsStatusValue;
readonly state?: CallsStatusValue | undefined;
readonly total?: CallsStatusValue | undefined;
readonly online?: CallsStatusValue | undefined;
readonly offline?: CallsStatusValue | undefined;
readonly busy?: CallsStatusValue | undefined;
}
/** One optional call capability the device exposes, with the verdict the firmware reported. */
export interface CallFeatureStatus {
readonly name: string;
readonly state: 'available' | 'unavailable' | 'unknown';
readonly detail?: string | undefined;
}
export interface CallsSnapshot {
readonly observedAt?: string;
readonly observedAt?: string | undefined;
readonly calls: AggregateCallStatus;
readonly devices: AggregateCallDeviceStatus;
readonly features?: readonly CallFeatureStatus[] | undefined;
}
export interface CallsDataSource {
@@ -38,9 +47,9 @@ export interface CallsDataSource {
export interface CallsModuleProps {
readonly instance: InstanceContext;
readonly dataSource?: CallsDataSource;
readonly dataSource?: CallsDataSource | undefined;
/** Change this owner-provided value to request another read. */
readonly refreshSignal?: unknown;
readonly refreshSignal?: unknown | undefined;
}
type OwnedSnapshot = { readonly ownerId: string; readonly value: CallsSnapshot };
@@ -100,6 +109,28 @@ function AggregateFields({
);
}
const FEATURE_STATE_LABELS: Readonly<Record<CallFeatureStatus['state'], string>> = {
available: '可用',
unavailable: '未开放',
unknown: '未知',
};
function FeatureList({ features }: { readonly features: readonly CallFeatureStatus[] }) {
return (
<dl>
{features.map((feature) => (
<div key={feature.name}>
<dt>{feature.name}</dt>
<dd>
{FEATURE_STATE_LABELS[feature.state]}
{feature.detail ? `(${feature.detail})` : ''}
</dd>
</div>
))}
</dl>
);
}
function SnapshotView({ snapshot }: { snapshot: CallsSnapshot }) {
return (
<>
@@ -111,6 +142,11 @@ function SnapshotView({ snapshot }: { snapshot: CallsSnapshot }) {
<Section label="设备状态">
<AggregateFields values={snapshot.devices} fields={DEVICE_FIELDS} />
</Section>
{snapshot.features?.length ? (
<Section label="呼叫能力">
<FeatureList features={snapshot.features} />
</Section>
) : null}
</div>
</>
);
@@ -213,6 +249,12 @@ export function CallsModule({ instance, dataSource, refreshSignal }: CallsModule
</p>
) : null}
<SnapshotView snapshot={snapshot.value} />
<DeviceActions
instance={instance}
module="calls"
onExecuted={() => setRetry((value) => value + 1)}
/>
</div>
);
}
@@ -10,7 +10,21 @@ import {
type CellularSnapshot,
} from './cellular-module.js';
afterEach(cleanup);
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function stubCatalog(actions: readonly Record<string, unknown>[]) {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
new Response(JSON.stringify({ actions }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetcher);
return fetcher;
}
const owner: InstanceContext = {
id: 'alpha',
@@ -70,8 +84,87 @@ describe('Phase 6.2 Cellular read module', () => {
expect(screen.getByText('不可用')).toBeTruthy();
expect(screen.getByText(/观测时间:2026-07-17T12:00:00Z/)).toBeTruthy();
expect(screen.queryByRole('button', { name: /register|operator|network/i })).toBeNull();
expect(screen.getByText(/自动网络注册仅可通过/i)).toBeTruthy();
expect(screen.getByText(/经审计的 R2 准备、确认和执行流程/i)).toBeTruthy();
// The old read-only disclaimer is gone: the control plane now serves real device controls.
expect(screen.queryByText(/仍不可用/i)).toBeNull();
});
it('renders the allowlisted cellular device actions served by the control plane', async () => {
const load = vi.fn<CellularDataSource['load']>().mockResolvedValue(snapshot);
const fetcher = stubCatalog([
{
id: 'network.scan',
module: 'cellular',
title: '扫描运营商',
description: '让设备重新扫描可见运营商列表。',
risk: 'R1',
method: 'POST',
fields: [],
},
{
id: 'radio-mode.set',
module: 'cellular',
title: '切换网络模式',
description: '切换首选无线制式。',
risk: 'R2',
method: 'POST',
fields: [
{
id: 'mode',
label: '制式',
kind: 'choice',
required: true,
choices: [{ value: 'lte', label: '仅 LTE' }],
},
],
},
{
id: 'sim.refresh-details',
module: 'sim',
title: '刷新 SIM',
description: '',
risk: 'R1',
method: 'POST',
fields: [],
},
]);
render(<CellularModule instance={owner} dataSource={{ load }} />);
await screen.findByRole('button', { name: /扫描运营商/ });
const card = screen.getByRole('region', { name: '设备操作' });
expect(fetcher).toHaveBeenCalledWith(
'/api/v1/instances/alpha/device-actions',
expect.objectContaining({ method: 'GET' }),
);
// Only the actions the control plane assigns to this module appear here.
expect(within(card).getByRole('button', { name: /扫描运营商/ })).toBeTruthy();
expect(within(card).getByRole('button', { name: /切换网络模式/ })).toBeTruthy();
expect(within(card).queryByRole('button', { name: /刷新 SIM/ })).toBeNull();
});
it('asks for an explicit confirmation before a risky action can run', async () => {
const load = vi.fn<CellularDataSource['load']>().mockResolvedValue(snapshot);
stubCatalog([
{
id: 'network.register-manual',
module: 'cellular',
title: '手动注册网络',
description: '按 MCCMNC 强制注册。',
risk: 'R2',
method: 'POST',
fields: [{ id: 'mccmnc', label: '运营商 MCCMNC', kind: 'string', required: true }],
},
]);
render(<CellularModule instance={owner} dataSource={{ load }} />);
const trigger = await screen.findByRole('button', { name: /手动注册网络/ });
const card = screen.getByRole('region', { name: '设备操作' });
await userEvent.click(within(card).getByRole('button', { name: /手动注册网络/ }));
expect(trigger).toBeTruthy();
const confirm = within(card).getByRole('checkbox', { name: /确认执行/ });
const run = within(card).getByRole('button', { name: '执行' });
expect((run as HTMLButtonElement).disabled).toBe(true);
await userEvent.click(confirm);
expect((run as HTMLButtonElement).disabled).toBe(false);
});
it('is honest when no read source is injected', () => {
+103 -27
View File
@@ -1,37 +1,42 @@
import { useEffect, useRef, useState } from 'react';
import type { InstanceContext } from '../app-shell.js';
import { DeviceActions } from './device-action-controls.js';
import { displayValue } from '../ui/locale.js';
/** Cellular data is deliberately limited to display-safe primitive values. */
export type CellularFieldValue = string | number | boolean | null;
export interface CellularNetworkRegistration {
readonly state?: CellularFieldValue;
readonly mode?: CellularFieldValue;
readonly operator?: CellularFieldValue;
readonly roaming?: CellularFieldValue;
readonly state?: CellularFieldValue | undefined;
readonly mode?: CellularFieldValue | undefined;
readonly operator?: CellularFieldValue | undefined;
readonly roaming?: CellularFieldValue | undefined;
}
export interface CellularSignal {
readonly rssi?: CellularFieldValue;
readonly rsrp?: CellularFieldValue;
readonly rsrq?: CellularFieldValue;
readonly sinr?: CellularFieldValue;
readonly quality?: CellularFieldValue;
readonly rssi?: CellularFieldValue | undefined;
readonly rsrp?: CellularFieldValue | undefined;
readonly rsrq?: CellularFieldValue | undefined;
readonly sinr?: CellularFieldValue | undefined;
readonly quality?: CellularFieldValue | undefined;
}
export interface CellularLocation {
readonly cell?: CellularFieldValue;
readonly area?: CellularFieldValue;
readonly technology?: CellularFieldValue;
readonly latitude?: CellularFieldValue;
readonly longitude?: CellularFieldValue;
readonly cell?: CellularFieldValue | undefined;
readonly area?: CellularFieldValue | undefined;
readonly technology?: CellularFieldValue | undefined;
readonly latitude?: CellularFieldValue | undefined;
readonly longitude?: CellularFieldValue | undefined;
readonly mcc?: CellularFieldValue | undefined;
readonly mnc?: CellularFieldValue | undefined;
readonly pci?: CellularFieldValue | undefined;
readonly arfcn?: CellularFieldValue | undefined;
}
export interface CellularOperators {
readonly current?: CellularFieldValue;
readonly available?: CellularFieldValue;
readonly current?: CellularFieldValue | undefined;
readonly available?: CellularFieldValue | undefined;
}
export interface CellularSnapshot {
readonly observedAt?: string;
readonly observedAt?: string | undefined;
readonly networkRegistration: CellularNetworkRegistration;
readonly signal: CellularSignal;
readonly cellsLocation: CellularLocation;
@@ -45,9 +50,9 @@ export interface CellularDataSource {
export interface CellularModuleProps {
readonly instance: InstanceContext;
readonly dataSource?: CellularDataSource;
readonly dataSource?: CellularDataSource | undefined;
/** Change this value when the owning application requests a refresh. */
readonly refreshSignal?: unknown;
readonly refreshSignal?: unknown | undefined;
}
type OwnedSnapshot = { readonly ownerId: string; readonly value: CellularSnapshot };
@@ -90,6 +95,18 @@ const SECTIONS = [
['经度', 'longitude'],
],
],
[
'cellsLocation',
'基站定位参数',
[
['国家码', 'mcc'],
['运营商码', 'mnc'],
['跟踪区', 'area'],
['小区编号', 'cell'],
['物理小区号', 'pci'],
['频点', 'arfcn'],
],
],
[
'operators',
'运营商',
@@ -135,6 +152,66 @@ function StructuredSection({
);
}
/** Device keys the third-party cell-location APIs (Amap, Baidu, Google) expect. */
const LOCATION_KEYS: readonly (readonly [keyof CellularLocation, string])[] = [
['mcc', 'mcc'],
['mnc', 'mnc'],
['area', 'lac'],
['cell', 'cid'],
['pci', 'pci'],
['arfcn', 'arfcn'],
['technology', 'rat'],
['latitude', 'latitude'],
['longitude', 'longitude'],
];
function locationPayload(location: CellularLocation): string {
const payload: Record<string, string> = {};
for (const [source, target] of LOCATION_KEYS) {
const value = location[source];
if (value === undefined || value === null || value === '') continue;
payload[target] = String(value);
}
return JSON.stringify(payload, null, 2);
}
/**
* Mirrors the Hub's 基站定位参数 export: the same parameters, ready to paste into a third-party
* positioning request. The payload is also shown so the copy works without clipboard permission.
*/
function CellLocationExport({ location }: { readonly location: CellularLocation }) {
const [copied, setCopied] = useState<'idle' | 'copied' | 'denied'>('idle');
const payload = locationPayload(location);
if (payload === '{}') return null;
async function copy(): Promise<void> {
try {
await navigator.clipboard.writeText(payload);
setCopied('copied');
} catch {
setCopied('denied');
}
}
return (
<section className="cellular-card cell-location-export" aria-label="基站定位参数导出">
<h2>第三方定位接口参数</h2>
<p className="cellular-card-note">可直接提交给高德、百度或 Google 的基站定位服务。</p>
<pre className="cell-location-payload">{payload}</pre>
<div className="fleet-card-actions">
<button type="button" className="fleet-messages-more" onClick={() => void copy()}>
复制定位参数
</button>
</div>
{copied === 'copied' ? (
<p role="status">已复制基站定位参数。</p>
) : copied === 'denied' ? (
<p role="alert">浏览器未允许访问剪贴板,请手动复制上方参数。</p>
) : null}
</section>
);
}
export function CellularModule({ instance, dataSource, refreshSignal }: CellularModuleProps) {
const requestOwner = useRef(0);
const [retry, setRetry] = useState(0);
@@ -242,16 +319,15 @@ export function CellularModule({ instance, dataSource, refreshSignal }: Cellular
{snapshot.observedAt ? <p>观测时间:{snapshot.observedAt}</p> : null}
<div className="cellular-grid">
{SECTIONS.map(([key, label, fields]) => (
<StructuredSection key={key} label={label} values={snapshot[key]} fields={fields} />
<StructuredSection key={label} label={label} values={snapshot[key]} fields={fields} />
))}
<CellLocationExport location={snapshot.cellsLocation} />
<DeviceActions
instance={instance}
module="cellular"
onExecuted={() => setRetry((value) => value + 1)}
/>
</div>
<section className="state-panel" aria-label="网络注册操作">
<h2>网络注册操作</h2>
<p>
自动网络注册仅可通过经审计的 R2 准备、确认和执行流程进行。手动注册和监控控件仍不可用。
此只读面板不会绕过这些限制。
</p>
</section>
</div>
);
}
@@ -0,0 +1,228 @@
/**
* Console-side client for the control plane's allowlisted device actions.
*
* The browser never learns a device path: the server hands back descriptors (id, title, risk,
* typed fields) and the console renders a form from them. That keeps the UI in step with the
* catalog without shipping a second copy of the device contract to the client.
*/
export type DeviceActionRisk = 'R1' | 'R2' | 'R3';
export type DeviceActionFieldKind =
| 'boolean'
| 'number'
| 'string'
| 'secret'
| 'choice'
| 'string-list';
export interface DeviceActionField {
readonly id: string;
readonly label: string;
readonly kind: DeviceActionFieldKind;
readonly in?: 'body' | 'path' | 'query';
readonly required?: boolean;
readonly choices?: readonly { readonly value: string; readonly label: string }[];
readonly min?: number;
readonly max?: number;
readonly maxLength?: number;
readonly pattern?: string;
readonly hint?: string;
}
export interface DeviceActionDescriptor {
readonly id: string;
readonly module: string;
readonly title: string;
readonly description: string;
readonly risk: DeviceActionRisk;
readonly method: 'POST' | 'DELETE';
readonly fields: readonly DeviceActionField[];
}
export interface DeviceActionResult {
readonly actionId: string;
readonly title: string;
readonly status: number;
readonly ok: boolean;
readonly data: unknown;
readonly message: string | null;
readonly durationMs: number;
}
export class DeviceActionClientError extends Error {
constructor(
readonly code: string,
readonly status: number,
) {
super(code);
this.name = 'DeviceActionClientError';
}
}
export interface DeviceActionSource {
list(signal?: AbortSignal): Promise<readonly DeviceActionDescriptor[]>;
execute(
actionId: string,
params: Readonly<Record<string, unknown>>,
confirm: boolean,
): Promise<DeviceActionResult>;
}
const MAX_ACTIONS = 128;
const RISK_VALUES: readonly DeviceActionRisk[] = ['R1', 'R2', 'R3'];
const FIELD_KINDS: readonly DeviceActionFieldKind[] = [
'boolean',
'number',
'string',
'secret',
'choice',
'string-list',
];
function record(value: unknown): Readonly<Record<string, unknown>> | undefined {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Readonly<Record<string, unknown>>)
: undefined;
}
function text(value: unknown, max: number): string | undefined {
return typeof value === 'string' && value.length > 0 ? value.slice(0, max) : undefined;
}
function boundedNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function parseField(value: unknown): DeviceActionField | undefined {
const entry = record(value);
const id = text(entry?.id, 64);
const label = text(entry?.label, 128);
const kind = entry?.kind;
if (
!id ||
!label ||
typeof kind !== 'string' ||
!FIELD_KINDS.includes(kind as DeviceActionFieldKind)
)
return undefined;
const field: {
-readonly [K in keyof DeviceActionField]: DeviceActionField[K];
} = { id, label, kind: kind as DeviceActionFieldKind };
const place = entry?.in;
if (place === 'path' || place === 'query') field.in = place;
if (entry?.required === true) field.required = true;
if (Array.isArray(entry?.choices)) {
const choices = entry.choices
.slice(0, 32)
.flatMap((item): { value: string; label: string }[] => {
const choice = record(item);
const value = text(choice?.value, 128);
return value ? [{ value, label: text(choice?.label, 128) ?? value }] : [];
});
if (choices.length) field.choices = Object.freeze(choices);
}
const min = boundedNumber(entry?.min);
if (min !== undefined) field.min = min;
const max = boundedNumber(entry?.max);
if (max !== undefined) field.max = max;
const maxLength = boundedNumber(entry?.maxLength);
if (maxLength !== undefined) field.maxLength = maxLength;
const pattern = text(entry?.pattern, 256);
if (pattern) field.pattern = pattern;
const hint = text(entry?.hint, 256);
if (hint) field.hint = hint;
return Object.freeze(field);
}
function parseDescriptor(value: unknown): DeviceActionDescriptor | undefined {
const entry = record(value);
const id = text(entry?.id, 128);
const module = text(entry?.module, 64);
const title = text(entry?.title, 128);
const risk = entry?.risk;
if (
!id ||
!module ||
!title ||
typeof risk !== 'string' ||
!RISK_VALUES.includes(risk as DeviceActionRisk)
)
return undefined;
return Object.freeze({
id,
module,
title,
description: text(entry?.description, 512) ?? '',
risk: risk as DeviceActionRisk,
method: entry?.method === 'DELETE' ? 'DELETE' : 'POST',
fields: Object.freeze(
Array.isArray(entry?.fields)
? entry.fields.slice(0, 16).flatMap((field) => {
const parsed = parseField(field);
return parsed ? [parsed] : [];
})
: [],
),
});
}
async function readProblem(response: Response): Promise<DeviceActionClientError> {
let code = 'REQUEST_FAILED';
try {
const root = record(await response.json());
code = text(root?.code, 64) ?? code;
} catch {
// A non-JSON error body still maps to the status code below.
}
return new DeviceActionClientError(code, response.status);
}
export function createDeviceActionSource(
instanceId: string,
fetcher: typeof fetch = fetch,
): DeviceActionSource {
const base = `/api/v1/instances/${encodeURIComponent(instanceId)}/device-actions`;
return {
async list(signal?: AbortSignal) {
const response = await fetcher(base, {
method: 'GET',
credentials: 'same-origin',
headers: { accept: 'application/json' },
...(signal ? { signal } : {}),
});
if (!response.ok) throw await readProblem(response);
const root = record(await response.json());
if (!Array.isArray(root?.actions)) return [];
return Object.freeze(
root.actions.slice(0, MAX_ACTIONS).flatMap((item) => {
const parsed = parseDescriptor(item);
return parsed ? [parsed] : [];
}),
);
},
async execute(actionId, params, confirm) {
const response = await fetcher(`${base}/${encodeURIComponent(actionId)}`, {
method: 'POST',
credentials: 'same-origin',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify({ params, ...(confirm ? { confirm: true } : {}) }),
});
if (!response.ok) throw await readProblem(response);
const entry = record(await response.json());
const status = typeof entry?.status === 'number' ? entry.status : 0;
return Object.freeze({
actionId: text(entry?.actionId, 128) ?? actionId,
title: text(entry?.title, 128) ?? '',
status,
ok: entry?.ok === true,
data: entry?.data ?? null,
message: text(entry?.message, 512) ?? null,
durationMs:
typeof entry?.durationMs === 'number' && Number.isFinite(entry.durationMs)
? entry.durationMs
: 0,
});
},
};
}
@@ -0,0 +1,419 @@
import { useEffect, useMemo, useState } from 'react';
import type { InstanceContext } from '../app-shell.js';
import { Icon, type IconName } from '../ui/icon.js';
import {
createDeviceActionSource,
type DeviceActionDescriptor,
type DeviceActionField,
type DeviceActionSource,
type DeviceActionResult,
} from './device-action-client.js';
/**
* Renders the control-plane device-action catalog as native console controls.
*
* The component is entirely data driven: the server decides which actions exist for a module and
* what fields they take, so a firmware capability that the official Hub exposes shows up here
* without a frontend change.
*/
const RISK_LABELS: Readonly<Record<DeviceActionDescriptor['risk'], string>> = {
R1: '常规',
R2: '影响业务',
R3: '不可撤销',
};
const RISK_ICONS: Readonly<Record<DeviceActionDescriptor['risk'], IconName>> = {
R1: 'check',
R2: 'alert',
R3: 'alert',
};
function iconFor(action: DeviceActionDescriptor): IconName {
if (action.method === 'DELETE' || /\bdelete\b|clear|forget|unbind|remove/u.test(action.id))
return 'trash';
if (
/restart|reboot|refresh|scan|sync|check|prepare|register|connect|apply|download/u.test(
action.id,
)
)
return 'restart';
if (/test/u.test(action.id)) return 'activity';
if (/dial|answer|hangup/u.test(action.id)) return 'phone';
if (/export|backup/u.test(action.id)) return 'server';
if (/set|configure|enable|disable|lock|mode/u.test(action.id)) return 'settings';
return 'check';
}
function initialValues(fields: readonly DeviceActionField[]): Record<string, string | boolean> {
const values: Record<string, string | boolean> = {};
for (const field of fields) values[field.id] = field.kind === 'boolean' ? false : '';
return values;
}
function buildParams(
fields: readonly DeviceActionField[],
values: Record<string, string | boolean>,
): Record<string, unknown> {
const params: Record<string, unknown> = {};
for (const field of fields) {
const raw = values[field.id];
if (field.kind === 'boolean') {
if (raw === true) params[field.id] = true;
continue;
}
const text = typeof raw === 'string' ? raw.trim() : '';
if (!text) continue;
if (field.kind === 'number') {
const parsed = Number(text);
if (Number.isSafeInteger(parsed)) params[field.id] = parsed;
continue;
}
if (field.kind === 'string-list') {
params[field.id] = text
.split(/[,,\s]+/u)
.map((item) => item.trim())
.filter((item) => item.length > 0)
.slice(0, 32);
continue;
}
params[field.id] = text;
}
return params;
}
function missingRequired(
fields: readonly DeviceActionField[],
values: Record<string, string | boolean>,
): string[] {
return fields
.filter((field) => {
if (!field.required) return false;
const raw = values[field.id];
return field.kind === 'boolean' ? raw !== true : String(raw ?? '').trim().length === 0;
})
.map((field) => field.label);
}
function FieldInput({
field,
value,
disabled,
onChange,
}: {
field: DeviceActionField;
value: string | boolean;
disabled: boolean;
onChange: (next: string | boolean) => void;
}) {
const id = `${field.id}-input`;
if (field.kind === 'boolean') {
return (
<label className="device-action-toggle" htmlFor={id}>
<input
id={id}
type="checkbox"
checked={value === true}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
/>
<span>{field.label}</span>
</label>
);
}
if (field.kind === 'choice' && field.choices?.length) {
return (
<label className="device-action-field" htmlFor={id}>
<span>{field.label}</span>
<select
id={id}
value={typeof value === 'string' ? value : ''}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
>
<option value="">未选择</option>
{field.choices.map((choice) => (
<option key={choice.value} value={choice.value}>
{choice.label}
</option>
))}
</select>
{field.hint ? <small>{field.hint}</small> : null}
</label>
);
}
return (
<label className="device-action-field" htmlFor={id}>
<span>{field.label}</span>
<input
id={id}
type={field.kind === 'secret' ? 'password' : field.kind === 'number' ? 'number' : 'text'}
value={typeof value === 'string' ? value : ''}
disabled={disabled}
{...(field.min !== undefined ? { min: field.min } : {})}
{...(field.max !== undefined ? { max: field.max } : {})}
{...(field.maxLength !== undefined ? { maxLength: field.maxLength } : {})}
{...(field.pattern ? { pattern: field.pattern } : {})}
placeholder={field.hint ?? ''}
onChange={(event) => onChange(event.target.value)}
/>
{field.hint && !field.pattern ? <small>{field.hint}</small> : null}
</label>
);
}
function ActionRow({
action,
source,
onExecuted,
}: {
action: DeviceActionDescriptor;
source: DeviceActionSource;
onExecuted: () => void;
}) {
const [open, setOpen] = useState(false);
const [values, setValues] = useState<Record<string, string | boolean>>(() =>
initialValues(action.fields),
);
const [pending, setPending] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [result, setResult] = useState<DeviceActionResult | null>(null);
const [error, setError] = useState<string | null>(null);
const needsConfirm = action.risk !== 'R1';
const parameterized = action.fields.length > 0;
async function run() {
const missing = missingRequired(action.fields, values);
if (missing.length) {
setError(`请先填写:${missing.join('、')}`);
return;
}
setError(null);
setPending(true);
setResult(null);
try {
const outcome = await source.execute(
action.id,
buildParams(action.fields, values),
needsConfirm && confirmed,
);
setResult(outcome);
if (outcome.ok) {
setConfirmed(false);
onExecuted();
}
} catch (cause) {
setError(
cause instanceof Error && cause.message === 'VALIDATION_FAILED'
? '参数不符合要求,请检查后重试。'
: '操作未能送达设备,请稍后重试。',
);
}
setPending(false);
}
return (
<li className={`device-action device-action-${action.risk.toLocaleLowerCase()}`}>
<div className="device-action-head">
<button
type="button"
className={
action.risk === 'R3'
? 'danger-button'
: parameterized
? 'fleet-messages-more'
: 'primary-action'
}
aria-expanded={parameterized ? open : undefined}
disabled={pending}
onClick={() => {
if (parameterized && !open) {
setOpen(true);
return;
}
void run();
}}
>
<Icon name={pending ? 'activity' : iconFor(action)} />
{pending ? '执行中' : action.title}
</button>
<span className="device-action-risk">
<Icon name={RISK_ICONS[action.risk]} />
{RISK_LABELS[action.risk]}
</span>
</div>
<p className="device-action-note">{action.description}</p>
{open ? (
<div className="device-action-form">
{action.fields.map((field) => (
<FieldInput
key={field.id}
field={field}
disabled={pending}
value={values[field.id] ?? (field.kind === 'boolean' ? false : '')}
onChange={(next) => setValues((current) => ({ ...current, [field.id]: next }))}
/>
))}
{needsConfirm ? (
<label className="device-action-toggle" htmlFor={`${action.id}-confirm`}>
<input
id={`${action.id}-confirm`}
type="checkbox"
checked={confirmed}
disabled={pending}
onChange={(event) => setConfirmed(event.target.checked)}
/>
<span>我已了解风险,确认执行「{action.title}」</span>
</label>
) : null}
<div className="fleet-card-actions">
<button
type="button"
className={action.risk === 'R3' ? 'danger-button' : 'primary-action'}
disabled={pending || (needsConfirm && !confirmed)}
onClick={() => void run()}
>
{pending ? '执行中' : '执行'}
</button>
<button
type="button"
className="fleet-messages-more"
disabled={pending}
onClick={() => {
setOpen(false);
setError(null);
setResult(null);
}}
>
收起
</button>
</div>
</div>
) : null}
{error ? (
<p className="device-action-result is-error" role="alert">
{error}
</p>
) : null}
{result ? (
<p
className={`device-action-result ${result.ok ? 'is-ok' : 'is-error'}`}
role={result.ok ? 'status' : 'alert'}
>
{result.ok ? '设备已接受该操作' : '设备拒绝了该操作'}
{result.message ? `:${result.message}` : '。'}
{result.durationMs > 0 ? ` 用时 ${(result.durationMs / 1000).toFixed(1)} 秒。` : ''}
</p>
) : null}
</li>
);
}
export interface DeviceActionControlsProps {
readonly module: string;
readonly actions: readonly DeviceActionDescriptor[];
readonly source: DeviceActionSource;
readonly onExecuted?: (() => void) | undefined;
}
export function DeviceActionControls({
module,
actions,
source,
onExecuted,
}: DeviceActionControlsProps) {
const owned = actions.filter((action) => action.module === module);
if (!owned.length) return null;
return (
<section className="cellular-card device-action-card" aria-label="设备操作">
<h2>设备操作</h2>
<ul className="device-action-list">
{owned.map((action) => (
<ActionRow
key={action.id}
action={action}
source={source}
onExecuted={onExecuted ?? (() => undefined)}
/>
))}
</ul>
</section>
);
}
export interface DeviceActionsProps {
readonly instance: InstanceContext;
readonly module: string;
/** Fires after the device accepts a mutation so the host can re-read its probes. */
readonly onExecuted?: (() => void) | undefined;
readonly source?: DeviceActionSource | undefined;
}
/**
* Drop-in block that loads the catalog for one instance and renders its controls. Any module can
* claim the Hub actions it owns simply by rendering this component.
*/
export function DeviceActions({ instance, module, onExecuted, source }: DeviceActionsProps) {
const resolved = useMemo(
() => source ?? createDeviceActionSource(instance.id),
[source, instance.id],
);
const [actions, setActions] = useState<readonly DeviceActionDescriptor[]>([]);
const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false);
useEffect(() => {
if (instance.authentication !== 'authenticated') {
setActions([]);
setLoaded(false);
setFailed(false);
return;
}
const controller = new AbortController();
void resolved.list(controller.signal).then(
(catalog) => {
if (controller.signal.aborted) return;
setActions(catalog);
setLoaded(true);
setFailed(false);
},
() => {
if (controller.signal.aborted) return;
setActions([]);
setLoaded(true);
setFailed(true);
},
);
return () => controller.abort();
}, [resolved, instance.authentication, instance.id]);
if (instance.authentication !== 'authenticated') return null;
const owned = actions.filter((action) => action.module === module);
if (failed) {
return (
<section className="cellular-card device-action-card" aria-label="设备操作">
<h2>设备操作</h2>
<p className="field-note">无法读取该模块的可用操作,请稍后重试。</p>
</section>
);
}
if (!owned.length) {
return loaded ? null : (
<section className="cellular-card device-action-card" aria-label="设备操作">
<h2>设备操作</h2>
<p role="status" className="field-note">
正在读取设备可用操作…
</p>
</section>
);
}
return (
<DeviceActionControls
module={module}
actions={owned}
source={resolved}
{...(onExecuted ? { onExecuted } : {})}
/>
);
}
@@ -0,0 +1,164 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DeviceDiscoveryPanel, type DiscoveryDataSource } from './device-discovery-panel.js';
import type { DeviceProbe, DiscoverySession } from './discovery-api-data-source.js';
afterEach(cleanup);
const completed: DiscoverySession = {
sessionId: '7a1f0c22-0000-4000-8000-000000000001',
status: 'completed',
createdAt: '2026-09-05T02:00:00.000Z',
expiresAt: '2026-09-05T02:01:00.000Z',
scanned: 508,
total: 508,
ranges: ['192.168.68.0/24'],
devices: [
{
origin: 'http://192.168.68.1:3000',
address: '192.168.68.1',
port: 3000,
secure: false,
httpStatus: 200,
identity: { model: 'iPhone 14', firmware_version: '16.1' },
knownInstanceId: null,
},
{
origin: 'http://192.168.68.2:3000',
address: '192.168.68.2',
port: 3000,
secure: false,
httpStatus: 401,
identity: {},
knownInstanceId: null,
},
{
origin: 'http://192.168.68.3:3000',
address: '192.168.68.3',
port: 3000,
secure: false,
httpStatus: 200,
identity: { brand: 'Google' },
knownInstanceId: 'inst-9',
},
],
};
const reachable: DeviceProbe = {
origin: 'http://192.168.68.9:3000',
reachable: true,
httpStatus: 200,
identity: { model: 'Pixel 7' },
knownInstanceId: null,
};
function source(overrides: Partial<DiscoveryDataSource> = {}): DiscoveryDataSource {
return {
start: vi.fn(async () => completed),
status: vi.fn(async () => completed),
renew: vi.fn(async () => completed),
stop: vi.fn(async () => undefined),
probe: vi.fn(async () => reachable),
...overrides,
};
}
async function mount(dataSource: DiscoveryDataSource): Promise<HTMLElement> {
render(<DeviceDiscoveryPanel dataSource={dataSource} onSelect={vi.fn()} />);
return screen.findByRole('region', { name: '局域网设备发现' });
}
describe('DeviceDiscoveryPanel', () => {
it('shows discovered devices with a status tag per row', async () => {
const region = await mount(source());
const table = await within(region).findByRole('table', { name: '发现的设备' });
expect(within(table).getByText('http://192.168.68.1:3000')).toBeTruthy();
expect(within(table).getByText('型号 iPhone 14 · 固件版本 16.1')).toBeTruthy();
expect(within(table).getByText('未公开身份信息')).toBeTruthy();
expect(within(table).getAllByText('可接入')).toHaveLength(1);
expect(within(table).getByText('需要登录')).toBeTruthy();
expect(within(table).getByText('已接入')).toBeTruthy();
expect(within(region).getByText(/扫描完成 · 已检查 508 个地址 · 发现 3 台设备/)).toBeTruthy();
});
it('hands the chosen device to the parent and locks a device already added', async () => {
const onSelect = vi.fn();
const dataSource = source();
render(<DeviceDiscoveryPanel dataSource={dataSource} onSelect={onSelect} />);
const table = await screen.findByRole('table', { name: '发现的设备' });
const rows = within(table).getAllByRole('button', { name: '接入' });
expect(rows).toHaveLength(3);
expect((rows[2] as HTMLButtonElement).disabled).toBe(true);
await userEvent.click(rows[0] as HTMLButtonElement);
expect(onSelect).toHaveBeenCalledWith({
origin: 'http://192.168.68.1:3000',
name: 'iPhone 14',
});
});
it('restarts the scan and stops the lease when unmounted', async () => {
const dataSource = source();
const { unmount } = render(<DeviceDiscoveryPanel dataSource={dataSource} onSelect={vi.fn()} />);
await screen.findByRole('table', { name: '发现的设备' });
await userEvent.click(screen.getByRole('button', { name: '重新扫描' }));
await waitFor(() => expect(dataSource.start).toHaveBeenCalledTimes(2));
unmount();
await waitFor(() => expect(dataSource.stop).toHaveBeenCalledWith(completed.sessionId));
});
it('probes a manual address and offers to fill the form', async () => {
const onSelect = vi.fn();
const dataSource = source();
render(<DeviceDiscoveryPanel dataSource={dataSource} onSelect={onSelect} />);
const region = await screen.findByRole('region', { name: '局域网设备发现' });
const field = within(region).getByLabelText('设备地址');
expect((field as HTMLInputElement).disabled).toBe(false);
await userEvent.type(field, '192.168.68.9:3000');
await userEvent.click(within(region).getByRole('button', { name: '检测地址' }));
expect(dataSource.probe).toHaveBeenCalledWith('192.168.68.9:3000');
await userEvent.click(await within(region).findByRole('button', { name: '填入添加表单' }));
expect(onSelect).toHaveBeenCalledWith({
origin: 'http://192.168.68.9:3000',
name: 'Pixel 7',
});
});
it('reports an unusable address without throwing', async () => {
const dataSource = source({
probe: vi.fn(async () => {
throw new Error('Discovery request failed.');
}),
});
render(<DeviceDiscoveryPanel dataSource={dataSource} onSelect={vi.fn()} />);
const region = await screen.findByRole('region', { name: '局域网设备发现' });
await userEvent.type(within(region).getByLabelText('设备地址'), 'http://example.com');
await userEvent.click(within(region).getByRole('button', { name: '检测地址' }));
expect(await within(region).findByText(/设备地址无法识别/)).toBeTruthy();
});
it('falls back to manual entry when discovery cannot start', async () => {
const dataSource = source({
start: vi.fn(async () => {
throw new Error('Discovery request failed.');
}),
});
render(<DeviceDiscoveryPanel dataSource={dataSource} onSelect={vi.fn()} />);
const region = await screen.findByRole('region', { name: '局域网设备发现' });
expect(await within(region).findByRole('alert')).toBeTruthy();
expect(within(region).getByRole('alert').textContent).toBe(
'无法启动局域网发现,请改用设备地址接入。',
);
expect(within(region).queryByRole('table', { name: '发现的设备' })).toBeNull();
expect(within(region).getByLabelText('设备地址')).toBeTruthy();
});
});
@@ -0,0 +1,277 @@
import { useCallback, useEffect, useState } from 'react';
import { Button, Card, Tag } from 'animal-island-ui';
import {
createDiscoveryApiDataSource,
type DeviceProbe,
type DiscoveredDevice,
type DiscoveryDataSource,
type DiscoverySession,
} from './discovery-api-data-source.js';
export type { DiscoveryDataSource } from './discovery-api-data-source.js';
const POLL_INTERVAL_MS = 2_000;
const RENEW_INTERVAL_MS = 15_000;
const IDENTITY_LABELS: Readonly<Record<string, string>> = {
model: '型号',
manufacturer: '制造商',
brand: '品牌',
firmware_version: '固件版本',
os_version: '系统版本',
version: '版本',
};
export interface DeviceSelection {
readonly origin: string;
readonly name: string;
}
function suggestName(device: {
readonly identity: Readonly<Record<string, string>>;
readonly address: string;
}): string {
return device.identity.model ?? device.identity.brand ?? device.address;
}
function identitySummary(device: { readonly identity: Readonly<Record<string, string>> }): string {
const entries = Object.entries(device.identity);
if (entries.length === 0) return '未公开身份信息';
return entries
.slice(0, 3)
.map(([field, value]) => `${IDENTITY_LABELS[field] ?? field} ${value}`)
.join(' · ');
}
/**
* LAN discovery plus manual address entry, the Hub onboarding flow rebuilt on top of the
* control plane's own private-address transport. The scan only lives while this panel is mounted.
*/
export function DeviceDiscoveryPanel({
dataSource,
onSelect,
selectedOrigin,
}: {
readonly dataSource?: DiscoveryDataSource;
readonly onSelect: (selection: DeviceSelection) => void;
readonly selectedOrigin?: string;
}) {
const [source] = useState(() => dataSource ?? createDiscoveryApiDataSource());
const [session, setSession] = useState<DiscoverySession>();
const [error, setError] = useState('');
const [attempt, setAttempt] = useState(0);
const [address, setAddress] = useState('');
const [probe, setProbe] = useState<DeviceProbe>();
const [probeError, setProbeError] = useState('');
const [probing, setProbing] = useState(false);
useEffect(() => {
const controller = new AbortController();
let active = true;
let sessionId = '';
let pollTimer: ReturnType<typeof setInterval> | undefined;
let renewTimer: ReturnType<typeof setInterval> | undefined;
setError('');
setSession(undefined);
const read = async () => {
if (!sessionId) return;
try {
const next = await source.status(sessionId, controller.signal);
if (active) setSession(next);
} catch {
/* A dropped poll is retried on the next tick; the lease keeps the scan alive. */
}
};
void (async () => {
try {
const started = await source.start(controller.signal);
if (!active) return;
sessionId = started.sessionId;
setSession(started);
pollTimer = setInterval(() => void read(), POLL_INTERVAL_MS);
renewTimer = setInterval(() => {
void source.renew(sessionId, controller.signal).catch(() => undefined);
}, RENEW_INTERVAL_MS);
} catch {
if (active) setError('无法启动局域网发现,请改用设备地址接入。');
}
})();
return () => {
active = false;
if (pollTimer) clearInterval(pollTimer);
if (renewTimer) clearInterval(renewTimer);
controller.abort();
if (sessionId) void source.stop(sessionId).catch(() => undefined);
};
}, [attempt, source]);
const pick = useCallback(
(device: {
readonly origin: string;
readonly identity: Readonly<Record<string, string>>;
readonly address: string;
}) => {
onSelect({ origin: device.origin, name: suggestName(device) });
},
[onSelect],
);
const runProbe = async () => {
const value = address.trim();
if (!value || probing) return;
setProbing(true);
setProbe(undefined);
setProbeError('');
try {
const result = await source.probe(value);
setProbe(result);
if (!result.reachable) setProbeError('该地址没有响应,请确认设备与本机在同一局域网。');
} catch {
setProbeError('设备地址无法识别,请填写私有网段地址,例如 http://192.168.68.1:3000。');
} finally {
setProbing(false);
}
};
const devices = session?.devices ?? [];
const progress = session
? session.status === 'scanning'
? `正在扫描 ${session.ranges.join('、') || '本机网段'} · 已检查 ${session.scanned.toLocaleString('zh-CN')} / ${session.total.toLocaleString('zh-CN')}`
: `扫描完成 · 已检查 ${session.total.toLocaleString('zh-CN')} 个地址 · 发现 ${devices.length} 台设备`
: '正在启动局域网发现…';
return (
<Card pattern="default" className="settings-card discovery-card">
<section aria-label="局域网设备发现">
<div className="discovery-head">
<div>
<h2>局域网设备发现</h2>
<p className="maintenance-hint">
仅扫描本机所在的私有网段,关闭窗口后停止;不会读取公网地址。
</p>
</div>
<Button
htmlType="button"
loading={session === undefined && !error}
onClick={() => setAttempt((value) => value + 1)}
>
重新扫描
</Button>
</div>
{error ? (
<p role="alert" className="state-panel state-error">
{error}
</p>
) : (
<p role="status" className="discovery-progress">
{progress}
</p>
)}
{!error ? (
devices.length === 0 ? (
<p className="maintenance-hint">
{session?.status === 'completed'
? '没有发现可接入的设备,可直接填写设备地址。'
: '尚未发现设备。'}
</p>
) : (
<div className="table-scroll">
<table className="dense-table" aria-label="发现的设备">
<thead>
<tr>
<th>设备地址</th>
<th>识别信息</th>
<th>状态</th>
<th aria-label="操作" />
</tr>
</thead>
<tbody>
{devices.map((device: DiscoveredDevice) => (
<tr key={device.origin}>
<td>
<code>{device.origin}</code>
</td>
<td>{identitySummary(device)}</td>
<td>
{device.knownInstanceId ? (
<Tag size="small" color="app-gray" variant="soft">
已接入
</Tag>
) : device.httpStatus >= 400 ? (
<Tag size="small" color="app-orange" variant="soft">
需要登录
</Tag>
) : (
<Tag size="small" color="app-green" variant="soft">
可接入
</Tag>
)}
</td>
<td className="discovery-actions">
<Button
htmlType="button"
type={selectedOrigin === device.origin ? 'default' : 'primary'}
disabled={device.knownInstanceId !== null}
onClick={() => pick(device)}
>
接入
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
) : null}
<div className="discovery-manual" role="group" aria-label="按设备地址接入">
<label>
设备地址
<input
type="text"
inputMode="url"
placeholder="http://192.168.68.1:3000"
value={address}
maxLength={200}
onChange={(event) => setAddress(event.target.value)}
/>
</label>
<Button
htmlType="button"
loading={probing}
disabled={probing || address.trim().length === 0}
onClick={() => void runProbe()}
>
检测地址
</Button>
</div>
{probeError ? (
<p role="alert" className="state-panel state-error">
{probeError}
</p>
) : null}
{probe?.reachable ? (
<div className="discovery-probe">
<p>
<code>{probe.origin}</code> 可访问 · {identitySummary(probe)}
</p>
<Button
htmlType="button"
type="primary"
disabled={probe.knownInstanceId !== null}
onClick={() =>
pick({ origin: probe.origin, identity: probe.identity, address: probe.origin })
}
>
{probe.knownInstanceId ? '该地址已接入' : '填入添加表单'}
</Button>
</div>
) : null}
</section>
</Card>
);
}
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { deviceFieldLabel, deviceValueLabel, normalizeDeviceToken } from './device-field-labels';
describe('device field labels', () => {
it('normalizes separators so camelCase and snake_case match', () => {
expect(normalizeDeviceToken(' Signal-Strength ')).toBe('signal_strength');
expect(deviceFieldLabel('signalStrength')).toBe(deviceFieldLabel('signal_strength'));
expect(deviceFieldLabel('iccid')).toBe('ICCID');
});
it('leaves unknown keys undefined so panels fall back', () => {
expect(deviceFieldLabel('totally_unknown_field')).toBeUndefined();
});
it('localizes enum-like values', () => {
expect(deviceValueLabel('sim_status', 'ready')).toBe('就绪');
expect(deviceValueLabel('pin1_status', 'PIN_LOCKED')).toBe(
deviceValueLabel('pin1_status', 'pin_locked'),
);
});
it('keeps free text values untouched', () => {
expect(deviceValueLabel('ssid', 'ready')).toBeUndefined();
expect(deviceValueLabel('name', 'online')).toBeUndefined();
});
it('refuses to translate long or symbol rich values', () => {
expect(deviceValueLabel('state', '8986 01 12 345')).toBeUndefined();
expect(deviceValueLabel('state', 'a'.repeat(40))).toBeUndefined();
expect(deviceValueLabel('state', '#ready')).toBeUndefined();
});
});
@@ -0,0 +1,581 @@
/**
* Chinese names for the SimAdmin device payload vocabulary.
*
* The read-only device tabs render whatever a probe returns, so a field the console has never
* seen would otherwise fall back to its raw snake_case key. This dictionary is shared by every
* module panel and covers the field and value names the official Hub panel labels, which keeps
* the fused console readable even when a device answers with an unusual payload shape.
*
* Keys and values are matched after normalisation (lowercase, `-`/space → `_`), so a device that
* answers `signalStrength` and one that answers `signal_strength` both render as 信号强度.
*/
export function normalizeDeviceToken(token: string): string {
return token
.trim()
.replace(/([a-z0-9])([A-Z])/gu, '$1_$2')
.toLowerCase()
.replace(/[\s.-]+/gu, '_');
}
const FIELD_LABELS: Readonly<Record<string, string>> = {
// Identity and SIM card
slot: '卡槽',
slot_index: '卡槽',
slot_name: '卡槽名称',
sim_status: 'SIM 状态',
sim_type: 'SIM 类型',
sim_path: '短信路径',
iccid: 'ICCID',
imsi: 'IMSI',
imei: 'IMEI',
meid: 'MEID',
eid: 'EID',
msisdn: '本机号码',
phone_number: '手机号',
spn: '运营商显示名',
alpha_id: '运营商显示名',
nai: '网络接入标识',
subscriber_id: '用户标识',
operator: '运营商',
operator_name: '运营商',
carrier: '运营商',
carrier_name: '运营商',
plmn: 'PLMN',
mcc: '国家码(MCC)',
mnc: '运营商码(MNC)',
smsc: '短信中心号码',
sms_center: '短信中心号码',
smsc_number: '短信中心号码',
cdma_prl: '漫游列表版本',
enabled: '已启用',
active: '使用中',
present: '已插入',
readable: '可读取',
// Lock and security state
pin1_status: 'PIN1 状态',
pin2_status: 'PIN2 状态',
puk1_status: 'PUK1 状态',
puk2_status: 'PUK2 状态',
pin1_retry: 'PIN1 剩余尝试',
pin2_retry: 'PIN2 剩余尝试',
puk1_retry: 'PUK1 剩余尝试',
puk2_retry: 'PUK2 剩余尝试',
lock_state: '锁卡状态',
fdn_enabled: '固定拨号',
auth_enabled: '已启用认证',
two_factor_enabled: '双因素验证',
password_required: '需要密码',
session_timeout_seconds: '会话超时(秒)',
max_login_attempts: '最大登录尝试',
allow_recovery: '允许恢复',
// Cellular registration and radio
registration: '注册状态',
register_state: '注册状态',
attach_state: '附着状态',
network_type: '网络制式',
technology: '无线技术',
rat: '制式',
radio_mode: '射频模式',
preferred_mode: '首选制式',
roaming: '漫游',
roaming_enabled: '漫游开关',
roaming_data: '漫游数据',
roaming_protocol: '漫游协议',
airplane_mode: '飞行模式',
data_enabled: '数据连接',
sms_enabled: '短信',
voice_enabled: '语音',
auto_reconnect: '自动重连',
band: '频段',
bands: '频段',
lte_bands: 'LTE 频段',
nr_bands: 'NR 频段',
lte_fdd_bands: 'LTE FDD 频段',
lte_tdd_bands: 'LTE TDD 频段',
nr_fdd_bands: 'NR FDD 频段',
nr_tdd_bands: 'NR TDD 频段',
support_bands: '支持频段',
band_width: '信道带宽',
downlink_bandwidth: '下行带宽',
uplink_bandwidth: '上行带宽',
// Signal and cell
rssi: 'RSSI',
rsrp: 'RSRP',
rsrq: 'RSRQ',
sinr: 'SINR',
iacs: '接收信号强度',
quality: '信号质量',
signal: '信号强度',
signal_strength: '信号强度',
signal_percent: '信号强度',
signal_level: '信号格数',
cell: '小区',
cell_id: '小区编号',
cid: '小区编号',
pci: '物理小区号(PCI)',
scrambling: '扰码',
tac: '跟踪区码(TAC)',
lac: '位置区码(LAC)',
arfcn: '频点(ARFCN)',
earfcn: '频点(EARFCN)',
nrarfcn: '频点(NR-ARFCN)',
channel: '信道',
timing_advance: '定时提前',
neighbor_cells: '邻区',
cells: '小区列表',
latitude: '纬度',
longitude: '经度',
accuracy: '定位精度',
altitude: '海拔',
// Operators
current: '当前运营商',
available: '可用运营商',
operators: '运营商列表',
networks: '可用网络',
status: '状态',
state: '状态',
mode: '模式',
detail: '详情',
reason: '原因',
message: '内容',
error: '错误',
last_error: '最近错误',
// Device system resources
hostname: '主机名',
model: '型号',
manufacturer: '制造商',
brand: '品牌',
vendor: '厂商',
architecture: '架构',
arch: '架构',
kernel: '内核版本',
firmware: '固件版本',
firmware_version: '固件版本',
baseband_version: '基带版本',
hardware_version: '硬件版本',
os_version: '系统版本',
version: '版本',
build_time: '构建时间',
uptime: '运行时长',
uptime_seconds: '运行时长(秒)',
boot_time: '启动时间',
cpu_percent: 'CPU 占用',
load_average: '平均负载',
memory_total: '内存总量',
memory_used: '已用内存',
memory_available: '可用内存',
memory_percent: '内存占用',
disk_total: '磁盘总量',
disk_used: '已用磁盘',
disk_free: '可用磁盘',
disk_percent: '磁盘占用',
temperature: '温度',
max_temperature: '最高温度',
max_temperature_celsius: '最高温度',
min_temperature: '最低温度',
battery_percent: '电量',
battery_status: '电池状态',
charging: '充电状态',
power_state: '电源状态',
processes: '进程数',
// Traffic
rx_bytes: '下行流量',
tx_bytes: '上行流量',
download_bytes: '下行流量',
upload_bytes: '上行流量',
total_bytes: '总流量',
rx_rate: '下行速率',
tx_rate: '上行速率',
since: '统计起始时间',
// Device network, WLAN and DDNS
interface: '网卡',
interfaces: '网卡',
name: '名称',
label: '标签',
ip_address: 'IP 地址',
ipv4: 'IPv4 地址',
ipv6: 'IPv6 地址',
ipv6_public: 'IPv6 公网地址',
public_ip: '公网地址',
gateway: '网关',
netmask: '子网掩码',
prefix: '前缀长度',
dns: 'DNS 服务器',
dns_servers: 'DNS 服务器',
mac: 'MAC 地址',
mac_address: 'MAC 地址',
method: '获取方式',
dhcp: 'DHCP',
internal: '内部接口',
tx_packets: '发送数据包',
rx_packets: '接收数据包',
tx_bytes_total: '发送字节',
rx_bytes_total: '接收字节',
ssid: '网络名称(SSID)',
bssid: '热点标识(BSSID)',
strength: '信号强度',
frequency: '工作频率',
security: '安全类型',
connection_id: '连接标识',
uuid: '配置 UUID',
auto_join: '自动加入',
last_seen: '最近在线',
last_seen_at: '最近在线',
provider: '服务商',
domain: '域名',
domains: '解析域名',
records: '解析记录',
interval: '更新间隔',
interval_seconds: '更新间隔(秒)',
update_interval_seconds: '更新间隔(秒)',
last_update_at: '最近更新',
last_sync_at: '最近同步',
record_type: '记录类型',
ttl: 'TTL',
// eSIM
profiles: 'Profile 列表',
nickname: '名称',
profile_name: 'Profile 名称',
profile_class: 'Profile 类型',
is_default: '当前使用',
is_enabled: '已启用',
enabled_count: '已启用数量',
total_count: '总数',
smdp: 'SM-DP+ 地址',
smdp_address: 'SM-DP+ 地址',
matching_id: '标识码(Matching ID)',
confirmation_code: '确认码',
notification_address: '通知地址',
total_capacity: '芯片总容量(KB)',
used_capacity: '已用容量(KB)',
available_capacity: '可用容量(KB)',
lpac: 'lpac 版本',
lpac_status: 'lpac 状态',
ready: '就绪',
// Calls
direction: '方向',
duration: '通话时长',
duration_seconds: '通话时长(秒)',
started_at: '开始时间',
finished_at: '结束时间',
ended_at: '结束时间',
call_type: '通话类型',
line1_number: '主卡号码',
line2_number: '副卡号码',
// Messages
content: '正文',
timestamp: '时间',
received_at: '接收时间',
sent_at: '发送时间',
transport: '通道',
unread: '未读',
thread_id: '会话编号',
// Notifications and automation
channels: '渠道',
channel_type: '渠道类型',
rules: '规则',
trigger: '触发条件',
action: '动作',
actions: '动作',
attempts: '尝试次数',
retries: '重试次数',
delivered: '已送达',
pending: '待处理',
processing: '处理中',
failed: '失败',
success: '成功',
total: '总计',
level: '级别',
kind: '类别',
title: '事件',
trace_id: '追踪编号',
timeline: '时间线',
events: '事件',
schedule: '计划',
cron: 'Cron 表达式',
next_run_at: '下次执行',
last_run_at: '上次执行',
last_attempt_at: '最近尝试',
last_success_at: '最近成功',
result: '结果',
reconnects: '重连次数',
failures: '失败次数',
phase: '阶段',
// OTA
current_version: '当前版本',
latest_version: '最新版本',
release_notes: '更新说明',
package: '更新包',
package_name: '更新包名称',
asset_name: '升级包名称',
size: '大小',
size_bytes: '大小(字节)',
md5: 'MD5 校验值',
sha256: 'SHA256 摘要',
proxy_prefix: '加速节点前缀',
restart_now: '立即重启',
pending_update: '待安装更新',
// Backup
files: '备份文件',
filename: '文件名',
path: '路径',
components: '组件',
retention_days: '保留天数',
retention: '保留数量',
max_backups: '最大备份数',
destination: '存储位置',
created_at: '创建时间',
updated_at: '更新时间',
type: '类型',
description: '说明',
required: '必选',
// Hub binding and device settings
bound: '已绑定',
connected: '已连接',
connecting: '连接中',
hub_url: 'Hub 地址',
hub_name: 'Hub 名称',
hub_id: 'Hub 编号',
device_id: '设备编号',
device_name: '设备名称',
work_mode: '工作模式',
local_fallback_enabled: '本地兜底',
local_fallback_timeout_seconds: '本地兜底超时(秒)',
address: '地址',
supported: '受支持',
toggleable: '可切换',
registered: '已注册',
recommended: '推荐',
observed_at: '观测时间',
};
const VALUE_LABELS: Readonly<Record<string, string>> = {
// Generic lifecycle
ok: '正常',
healthy: '正常',
normal: '正常',
success: '成功',
succeeded: '成功',
completed: '已完成',
failed: '失败',
error: '错误',
warning: '警告',
critical: '严重',
info: '信息',
debug: '调试',
unknown: '未知',
none: '无',
null: '无',
idle: '空闲',
busy: '忙碌',
waiting: '等待中',
pending: '等待中',
queued: '排队中',
processing: '处理中',
running: '运行中',
in_progress: '进行中',
paused: '已暂停',
skipped: '已跳过',
aborted: '已中止',
cancelled: '已取消',
timeout: '超时',
ready: '就绪',
not_ready: '未就绪',
// Enablement
enabled: '已启用',
disabled: '已停用',
active: '已激活',
inactive: '未激活',
permitted: '已允许',
denied: '已拒绝',
allowed: '已允许',
blocked: '已锁死',
unsupported: '不支持',
supported: '支持',
available: '可用',
unavailable: '不可用',
required: '需要',
optional: '可选',
// SIM presence and lock
present: '已插入',
absent: '未插入',
inserted: '已插入',
removed: '未插入',
not_inserted: '未插入',
ready_pin: '就绪(需要 PIN)',
pin_required: '需要 PIN',
pin_locked: 'PIN 已锁定',
puk_required: '需要 PUK 解锁',
puk_locked: 'PUK 已锁死',
locked: '已锁定',
unlocked: '未锁定',
local_locked: '本地锁定',
unknown_lock: '状态未知',
// Registration
not_registered: '未注册',
registered: '已注册',
registering: '注册中',
searching: '搜索中',
limited: '受限服务',
emergency_only: '仅紧急呼叫',
home: '本地网络',
roaming: '漫游中',
attached: '已附着',
detached: '未附着',
detached_registered: '已注册',
// Radio technologies
gsm: '2G GSM',
cdma: 'CDMA',
umts: '3G UMTS',
wcdma: '3G WCDMA',
td_scdma: '3G TD-SCDMA',
evdo: '3G EVDO',
lte: '4G LTE',
lte_ca: '4G LTE 载波聚合',
nr: '5G NR',
nr_sa: '5G 独立组网',
nr_ns: '5G 非独立组网',
auto: '自动',
manual: '手动',
// Data connection
connected: '已连接',
connecting: '连接中',
disconnected: '已断开',
suspended: '已暂停',
scanning: '扫描中',
online: '在线',
offline: '离线',
// APN / auth
ipv4: 'IPv4',
ipv6: 'IPv6',
ipv4v6: 'IPv4/IPv6 双栈',
pap: 'PAP',
chap: 'CHAP',
wpapersonal: 'WPA-PSK',
wpapsk: 'WPA-PSK',
wpa2psk: 'WPA2-PSK',
sae: 'WPA3-SAE',
nopassword: '开放网络',
open: '开放网络',
// Call and message direction
incoming: '来电',
outgoing: '去电',
missed: '未接',
rejected: '已拒接',
answered: '已接听',
dialing: '拨号中',
alerting: '对方振铃',
hold: '保持中',
// Work modes
sim: '本机 SIM 管理',
sim_overseas: '海外卡模式',
esim: 'eSIM 模式',
replace: '整体替换',
merge: '合并',
// OTA
up_to_date: '已是最新',
update_available: '发现新版本',
downloading: '下载中',
downloaded: '已下载',
verifying: '校验中',
verified: '校验通过',
installing: '安装中',
installed: '已安装',
applied: '已应用',
rebooting: '重启中',
staged: '待安装',
// Power
charging: '充电中',
discharging: '电池供电',
full: '已充满',
not_charging: '未充电',
on_battery: '电池供电',
plugged: '已接通电源',
// eSIM profile classes
default: '默认',
testing: '测试',
provisioning: '预配置',
managing: '管理型',
// VoWiFi
connected_registered: '已注册',
connection_failed: '连接失败',
not_enabled: '未启用',
};
/**
* Keys whose values are free text. Localizing them would rewrite a device-chosen name such as an
* SSID called "open", so value lookup is skipped for these.
*/
const FREE_TEXT_KEYS: ReadonlySet<string> = new Set([
'alpha_id',
'apn',
'asset_name',
'brand',
'carrier',
'carrier_name',
'content',
'description',
'detail',
'device_name',
'domain',
'filename',
'hub_name',
'hostname',
'label',
'manufacturer',
'message',
'model',
'name',
'nickname',
'operator',
'operator_name',
'package',
'package_name',
'path',
'profile_name',
'reason',
'release_notes',
'spn',
'ssid',
'title',
'vendor',
]);
export function deviceFieldLabel(key: string): string | undefined {
return FIELD_LABELS[normalizeDeviceToken(key)];
}
/** Returns a Chinese label only for short enum-like values on keys that are not free text. */
export function deviceValueLabel(key: string, value: string): string | undefined {
const normalizedKey = normalizeDeviceToken(key);
if (FREE_TEXT_KEYS.has(normalizedKey)) return undefined;
if (!/^[A-Za-z][A-Za-z0-9 +/_-]{0,31}$/u.test(value.trim())) return undefined;
return VALUE_LABELS[normalizeDeviceToken(value)];
}
@@ -0,0 +1,208 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ReactElement } from 'react';
import type { InstanceContext } from '../app-shell.js';
import { SensitiveRevealProvider } from '../privacy/sensitive-reveal.js';
import { ConfigurationModule, SimModule } from './device-module-panels.js';
import type { InstanceModuleSnapshot } from './instance-module-api-data-source.js';
afterEach(() => {
cleanup();
vi.restoreAllMocks();
localStorage.clear();
});
function renderWithPrivacy(ui: ReactElement) {
return render(<SensitiveRevealProvider>{ui}</SensitiveRevealProvider>);
}
const instance: InstanceContext = {
id: 'owner',
name: 'Owner modem',
origin: 'https://node.internal',
status: 'online',
authentication: 'authenticated',
freshness: 'fresh',
};
function snapshot(overrides: Partial<InstanceModuleSnapshot> = {}): InstanceModuleSnapshot {
return {
instanceId: 'owner',
module: 'sim',
observedAt: '2026-09-04T02:00:00.000Z',
authenticated: true,
sections: [
{
key: 'sim',
path: '/sim',
state: 'ok',
status: 200,
data: {
slots: [
{
slot: 1,
status: 'ready',
iccid: '89860112345678901234',
phone_number: '13800001234',
},
],
},
},
{ key: 'apn', path: '/apn', state: 'empty', status: 204, data: null },
{ key: 'bandLock', path: '/band-lock', state: 'unsupported', status: 404, data: null },
],
...overrides,
};
}
describe('DeviceModulePanel', () => {
it('renders nested device rows and reports per-section availability', async () => {
const reader = { read: vi.fn(async () => snapshot()) };
renderWithPrivacy(<SimModule instance={instance} reader={reader} />);
expect(await screen.findByRole('heading', { name: /SIM 卡状态/ })).toBeTruthy();
expect(screen.getByRole('heading', { name: /APN 配置/ })).toBeTruthy();
expect(screen.getByText('设备返回成功,但没有可用数据。')).toBeTruthy();
expect(screen.getByText('当前设备固件未提供该接口。')).toBeTruthy();
// Device enum values arrive in English; the shared dictionary renders them in Chinese.
expect(screen.getByRole('cell', { name: '就绪' })).toBeTruthy();
expect(screen.queryByRole('heading', { name: /安全与锁卡/ })).toBeNull();
});
it('spreads a nested settings object into the parent card', async () => {
const reader = {
read: vi.fn(async () =>
snapshot({
module: 'configuration',
sections: [
{
key: 'authSettings',
path: '/auth/settings',
state: 'ok',
status: 200,
data: {
configured: true,
settings: { password_min_length: 8, session_ttl_seconds: 604_800 },
},
},
{ key: 'workMode', path: '/work-mode', state: 'empty', status: 200, data: {} },
{
key: 'authStatus',
path: '/auth/status',
state: 'unsupported',
status: 404,
data: null,
},
{ key: 'hub', path: '/hub', state: 'empty', status: 200, data: {} },
],
}),
),
};
renderWithPrivacy(<ConfigurationModule instance={instance} reader={reader} />);
expect(await screen.findByRole('heading', { name: /设备安全设置/ })).toBeTruthy();
expect(screen.getByText('604800')).toBeTruthy();
expect(screen.getByText('是')).toBeTruthy();
// A probe the firmware never exposed is stated plainly instead of rendered as raw JSON.
expect(screen.getByRole('heading', { name: /设备认证状态/ })).toBeTruthy();
expect(screen.getAllByText('当前设备固件未提供该接口。').length).toBeGreaterThan(0);
});
it('splits PIN lock fields out of the SIM identity card', async () => {
const reader = {
read: vi.fn(async () =>
snapshot({
sections: [
{
key: 'sim',
path: '/sim',
state: 'ok',
status: 200,
data: {
slot: 1,
iccid: '8986011234567890123',
pin1_status: 'pin_locked',
pin1_retry: 2,
puk1_retry: 9,
},
},
],
}),
),
};
renderWithPrivacy(<SimModule instance={instance} reader={reader} />);
const identity = await screen.findByRole('region', { name: 'SIM 卡状态' });
const lock = screen.getByRole('region', { name: '安全与锁卡' });
expect(identity.textContent).toContain('ICCID');
expect(identity.textContent).not.toContain('PIN1');
expect(lock.textContent).toContain('PIN1 状态');
expect(lock.textContent).toContain('PIN 已锁定');
expect(lock.textContent).toContain('PIN1 剩余尝试');
expect(lock.textContent).toContain('PUK1 剩余尝试');
});
it('labels snake_case device keys and localizes boolean values', async () => {
const reader = {
read: vi.fn(async () =>
snapshot({
sections: [
{ key: 'sim', path: '/sim', state: 'ok', status: 200, data: { slots: [] } },
{
key: 'bandLock',
path: '/band-lock',
state: 'ok',
status: 200,
data: { enabled: true, mode: 'auto', support_bands: [1, 3, 41] },
},
],
}),
),
};
renderWithPrivacy(<SimModule instance={instance} reader={reader} />);
const band = await screen.findByRole('region', { name: '频段锁定' });
expect(band.textContent).toContain('已启用');
expect(band.textContent).toContain('自动');
expect(band.textContent).toContain('支持频段');
expect(band.textContent).not.toContain('support bands');
});
it('masks subscriber identifiers until the operator reveals them', async () => {
const reader = { read: vi.fn(async () => snapshot()) };
renderWithPrivacy(<SimModule instance={instance} reader={reader} />);
expect(await screen.findByText(/^•+ 1234$/u)).toBeTruthy();
expect(screen.queryByText('89860112345678901234')).toBeNull();
const toggle = screen.getByRole('button', { name: '显示敏感标识' });
toggle.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await waitFor(() => expect(screen.getByText('89860112345678901234')).toBeTruthy());
expect(toggle.getAttribute('aria-pressed')).toBe('true');
});
it('stays idle with a clear message when the device is not authenticated', () => {
const reader = { read: vi.fn(async () => snapshot()) };
renderWithPrivacy(
<SimModule instance={{ ...instance, authentication: 'auth-required' }} reader={reader} />,
);
expect(screen.getByRole('alert').textContent).toContain('需要先完成设备认证');
expect(reader.read).not.toHaveBeenCalled();
});
it('shows a retry panel instead of a blank tab when the read fails', async () => {
const reader = {
read: vi.fn(async () => {
throw new Error('upstream down');
}),
};
renderWithPrivacy(<SimModule instance={instance} reader={reader} />);
const alert = await screen.findByRole('alert');
expect(alert.textContent).toContain('无法加载');
expect(screen.getByRole('button', { name: '重试' })).toBeTruthy();
});
});
@@ -0,0 +1,415 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { InstanceContext } from '../app-shell.js';
import { protectValue } from '../privacy/sensitive-fields.js';
import { useSensitiveReveal } from '../privacy/sensitive-reveal.js';
import { displayValue } from '../ui/locale.js';
import { deviceFieldLabel, deviceValueLabel, normalizeDeviceToken } from './device-field-labels.js';
import { DeviceActions } from './device-action-controls.js';
import type {
InstanceModuleReader,
InstanceModuleSection,
InstanceModuleSnapshot,
} from './instance-module-api-data-source.js';
export interface DeviceModuleSectionSpec {
/** Section key produced by the control-plane module probe. */
readonly key: string;
readonly label: string;
/** Optional Chinese labels for device field names. */
readonly fields?: Readonly<Record<string, string>>;
/** Set when the device nests the interesting rows under one key, such as `slots` or `files`. */
readonly listKey?: string;
/** Maximum rows rendered when the section is a list. */
readonly limit?: number;
/**
* When set, the section renders only these device keys. Lets one probe feed two cards, such as
* the SIM identity table and the separate PIN/PUK lock panel.
*/
readonly only?: readonly string[];
/** Device keys this section never renders; they belong to a sibling section instead. */
readonly exclude?: readonly string[];
/** Skip the whole card when the device reported none of this section's fields. */
readonly optional?: boolean;
}
export interface DeviceModulePanelProps {
readonly instance: InstanceContext;
readonly module: string;
readonly sections: readonly DeviceModuleSectionSpec[];
readonly reader?: InstanceModuleReader;
readonly emptyText?: string;
readonly refreshSignal?: unknown;
}
type ReadState =
| { kind: 'idle' }
| { kind: 'loading'; snapshot?: InstanceModuleSnapshot }
| { kind: 'ready'; snapshot: InstanceModuleSnapshot }
| { kind: 'error'; message: string; snapshot?: InstanceModuleSnapshot };
const STATE_LABELS: Readonly<Record<InstanceModuleSection['state'], string>> = {
ok: '正常',
empty: '无数据',
'auth-required': '需要认证',
unsupported: '设备不支持',
failed: '读取失败',
};
const COMMON_LABELS: Readonly<Record<string, string>> = {
enabled: '已启用',
disabled: '已停用',
state: '状态',
status: '状态',
mode: '模式',
name: '名称',
label: '标签',
provider: '服务商',
hostname: '主机名',
ssid: '网络名称',
security: '安全类型',
priority: '优先级',
interval: '更新间隔',
interval_seconds: '更新间隔(秒)',
update_interval_seconds: '更新间隔(秒)',
last_update_at: '最近更新',
last_event_at: '最近事件',
total: '总计',
pending: '待处理',
processing: '处理中',
delivered: '已送达',
failed: '失败',
level: '级别',
message: '内容',
created_at: '创建时间',
updated_at: '更新时间',
size: '大小',
size_bytes: '大小(字节)',
path: '路径',
filename: '文件名',
version: '版本',
current_version: '当前版本',
latest_version: '最新版本',
model: '型号',
manufacturer: '制造商',
imei: 'IMEI',
iccid: 'ICCID',
imsi: 'IMSI',
phone_number: '手机号',
operator: '运营商',
operator_name: '运营商',
roaming: '漫游',
auto_renew: '自动续期',
components: '组件',
retention_days: '保留天数',
max_backups: '最大备份数',
registered: '已注册',
connected: '已连接',
available: '可用',
supported: '受支持',
reason: '原因',
detail: '详情',
};
function fieldLabel(key: string, fields?: Readonly<Record<string, string>>): string {
return fields?.[key] ?? COMMON_LABELS[key] ?? deviceFieldLabel(key) ?? key.replace(/_/gu, ' ');
}
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function scalar(value: unknown, key?: string): string | null {
if (typeof value === 'string') {
const trimmed = value.length > 200 ? `${value.slice(0, 200)}…` : value;
return key === undefined ? trimmed : (deviceValueLabel(key, trimmed) ?? trimmed);
}
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
if (typeof value === 'boolean') return value ? '是' : '否';
if (value === null) return '—';
if (Array.isArray(value)) {
const parts = value
.slice(0, 8)
.map((item) => (isRecord(item) ? scalar(item['name'] ?? item['label']) : scalar(item)))
.filter((item): item is string => item !== null);
return parts.length ? parts.join('、') : '空列表';
}
return null;
}
/** A nested record of plain values reads better spread across the parent table than dropped. */
function flattenable(value: unknown): value is Readonly<Record<string, unknown>> {
if (!isRecord(value)) return false;
const entries = Object.entries(value);
return entries.length > 0 && entries.length <= 12 && entries.every(([, item]) => !isRecord(item));
}
function selected(data: unknown, spec: DeviceModuleSectionSpec): readonly [string, unknown][] {
if (!isRecord(data)) return [];
const only = spec.only ? new Set(spec.only.map(normalizeDeviceToken)) : undefined;
const exclude = spec.exclude ? new Set(spec.exclude.map(normalizeDeviceToken)) : undefined;
const keeps = (key: string): boolean => {
const normalized = normalizeDeviceToken(key);
return (!only || only.has(normalized)) && !(exclude && exclude.has(normalized));
};
const picked: [string, unknown][] = [];
for (const [key, value] of Object.entries(data)) {
if (picked.length >= 40) break;
if (!keeps(key)) continue;
if (flattenable(value)) {
for (const [nestedKey, nestedValue] of Object.entries(value)) {
if (picked.length >= 40) break;
if (keeps(nestedKey)) picked.push([nestedKey, nestedValue]);
}
continue;
}
picked.push([key, value]);
}
return picked;
}
function entries(data: unknown, spec: DeviceModuleSectionSpec): readonly [string, string][] {
const rows: [string, string][] = [];
for (const [key, value] of selected(data, spec)) {
const rendered = scalar(value, key);
if (rendered !== null) rows.push([key, rendered]);
}
return rows;
}
function rows(
data: unknown,
limit: number,
listKey?: string,
): readonly Readonly<Record<string, unknown>>[] {
const nested = listKey && isRecord(data) ? data[listKey] : undefined;
const source = Array.isArray(nested)
? nested
: Array.isArray(data)
? data
: isRecord(data)
? (data['items'] ?? data['list'] ?? data['profiles'])
: undefined;
if (!Array.isArray(source)) return [];
return source.slice(0, limit).flatMap((item) => (isRecord(item) ? [item] : []));
}
function columns(items: readonly Readonly<Record<string, unknown>>[]): readonly string[] {
const seen: string[] = [];
for (const item of items) {
for (const key of Object.keys(item)) {
if (!seen.includes(key)) seen.push(key);
if (seen.length >= 8) return seen;
}
}
return seen;
}
function tableColumns(
items: readonly Readonly<Record<string, unknown>>[],
spec: DeviceModuleSectionSpec,
): readonly string[] {
const all = columns(items);
const only = spec.only ? new Set(spec.only.map(normalizeDeviceToken)) : undefined;
const exclude = spec.exclude ? new Set(spec.exclude.map(normalizeDeviceToken)) : undefined;
const filtered = all.filter((key) => {
const normalized = normalizeDeviceToken(key);
return (!only || only.has(normalized)) && !(exclude && exclude.has(normalized));
});
// An `only` list that matches nothing means the section genuinely has no data; an `exclude`
// list that removes everything falls back so a probe shape change cannot blank the card.
return only ? filtered : filtered.length ? filtered : all;
}
function sectionBody(
section: InstanceModuleSection | undefined,
spec: DeviceModuleSectionSpec,
revealed: boolean,
): ReactNode {
if (!section) return <p>{`未提供${spec.label}数据。`}</p>;
if (section.state !== 'ok') {
return (
<p className="field-note">
{section.state === 'auth-required'
? '设备需要重新认证后才能读取该数据。'
: section.state === 'unsupported'
? '当前设备固件未提供该接口。'
: section.state === 'empty'
? '设备返回成功,但没有可用数据。'
: `无法从设备读取${spec.label}。`}
</p>
);
}
const list = rows(section.data, spec.limit ?? 20, spec.listKey);
if (list.length) {
const headers = tableColumns(list, spec);
if (!headers.length) return null;
return (
<div className="schedule-table-wrap">
<table className="dense-table">
<thead>
<tr>
{headers.map((header) => (
<th key={header} scope="col">
{fieldLabel(header, spec.fields)}
</th>
))}
</tr>
</thead>
<tbody>
{list.map((item, index) => (
<tr key={index}>
{headers.map((header) => (
<td key={header}>
{protectValue(header, scalar(item[header], header) ?? '—', revealed)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
const fields = entries(section.data, spec);
if (!fields.length) return null;
return (
<dl>
{fields.map(([key, value]) => (
<div key={key}>
<dt>{fieldLabel(key, spec.fields)}</dt>
<dd>{protectValue(key, value, revealed)}</dd>
</div>
))}
</dl>
);
}
/**
* Data-driven panel shared by the device tabs that mirror the official Hub: every section is a
* read-only probe the control plane already performed, so the console never talks to a device
* directly and never invents an endpoint of its own.
*/
export function DeviceModulePanel({
instance,
module,
sections,
reader,
emptyText,
refreshSignal,
}: DeviceModulePanelProps) {
const requestOwner = useRef(0);
const [retry, setRetry] = useState(0);
// One console-wide switch: the topbar button and this in-place shortcut drive the same state.
const { revealed, toggle } = useSensitiveReveal();
const [state, setState] = useState<ReadState>({ kind: 'idle' });
useEffect(() => {
const request = ++requestOwner.current;
const controller = new AbortController();
if (instance.authentication !== 'authenticated' || !reader) {
setState({ kind: 'idle' });
return () => controller.abort();
}
setState((current) => ({
kind: 'loading',
...(current.kind !== 'idle' && current.snapshot?.instanceId === instance.id
? { snapshot: current.snapshot }
: {}),
}));
void reader.read(instance.id, module, controller.signal).then(
(snapshot) => {
if (request === requestOwner.current && !controller.signal.aborted)
setState({ kind: 'ready', snapshot });
},
() => {
if (request === requestOwner.current && !controller.signal.aborted)
setState((current) => ({
kind: 'error',
message: `无法加载${sections[0]?.label ?? '设备'}数据。`,
...(current.kind !== 'idle' && current.snapshot?.instanceId === instance.id
? { snapshot: current.snapshot }
: {}),
}));
},
);
return () => controller.abort();
}, [instance.authentication, instance.id, module, reader, refreshSignal, retry, sections]);
if (instance.authentication !== 'authenticated' || !reader) {
return (
<div className="state-panel state-error" role="alert">
{emptyText ?? '需要先完成设备认证,才能读取该模块的数据。'}
</div>
);
}
const snapshot =
state.kind !== 'idle' && state.snapshot?.instanceId === instance.id
? state.snapshot
: undefined;
if (!snapshot) {
if (state.kind === 'error')
return (
<div className="state-panel state-error" role="alert">
<p>{state.message}</p>
<button type="button" onClick={() => setRetry((value) => value + 1)}>
重试
</button>
</div>
);
return (
<p role="status" aria-label="模块加载状态">
正在加载设备数据…
</p>
);
}
return (
<>
<div className="fleet-card-actions">
<button
type="button"
className="fleet-messages-more"
aria-pressed={revealed}
onClick={toggle}
>
{revealed ? '隐藏敏感标识' : '显示敏感标识'}
</button>
</div>
<div className="cellular-grid">
{state.kind === 'error' ? (
<div className="state-panel state-error" role="alert">
<p>刷新失败,正在显示上次已知的数据。</p>
<button type="button" onClick={() => setRetry((value) => value + 1)}>
重试
</button>
</div>
) : null}
{sections.map((spec, index) => {
const section = snapshot.sections.find((candidate) => candidate.key === spec.key);
const body = sectionBody(section, spec, revealed);
if (body === null && spec.optional) return null;
return (
<section key={`${spec.key}-${index}`} className="cellular-card" aria-label={spec.label}>
<h2>
{spec.label}
<span className="device-module-state">
{STATE_LABELS[section?.state ?? 'failed']}
</span>
</h2>
{body ?? <p>{`未提供${spec.label}数据。`}</p>}
</section>
);
})}
<DeviceActions
instance={instance}
module={module}
onExecuted={() => setRetry((value) => value + 1)}
/>
<p className="field-note">观测时间:{displayValue(snapshot.observedAt)}</p>
</div>
</>
);
}
@@ -0,0 +1,372 @@
import type { ReactNode } from 'react';
import type { InstanceContext } from '../app-shell.js';
import type { InstanceModuleReader } from './instance-module-api-data-source.js';
import { DeviceModulePanel, type DeviceModuleSectionSpec } from './device-module-panel.js';
/**
* Device tabs that exist in the official Hub. Each one is a thin declaration of which read-only
* probes the control plane should collect and how their fields are named in Chinese; the shared
* panel does the loading, masking, and rendering.
*/
export interface DeviceModuleProps {
readonly instance: InstanceContext;
readonly reader?: InstanceModuleReader;
readonly refreshSignal?: unknown;
}
/**
* PIN/PUK fields the Hub shows as a separate 安全与锁卡 card. They arrive inside the same `/sim`
* payload as the identity fields, so the two sections split the probe by key.
*/
const SIM_LOCK_KEYS: readonly string[] = [
'pin1_status',
'pin2_status',
'puk1_status',
'puk2_status',
'pin_state',
'puk_state',
'pin_locked',
'puk_locked',
'pin1_retry',
'pin2_retry',
'puk1_retry',
'puk2_retry',
'lock_state',
'fdn_enabled',
];
const SIM_SECTIONS: readonly DeviceModuleSectionSpec[] = [
{
key: 'sim',
label: 'SIM 卡状态',
listKey: 'slots',
exclude: SIM_LOCK_KEYS,
fields: {
slot: '卡槽',
slot_index: '卡槽',
status: '状态',
state: '状态',
sim_status: '状态',
operator: '运营商',
carrier: '运营商',
network_type: '网络制式',
imsi: 'IMSI',
iccid: 'ICCID',
phone_number: '号码',
msisdn: '号码',
imei: 'IMEI',
mcc: 'MCC',
mnc: 'MNC',
enabled: '已启用',
active: '已激活',
},
},
{
key: 'sim',
label: '安全与锁卡',
listKey: 'slots',
only: SIM_LOCK_KEYS,
optional: true,
},
{
key: 'apn',
label: 'APN 配置',
listKey: 'apns',
fields: {
apn: 'APN',
name: '名称',
protocol: '协议',
pdn_type: 'PDN 类型',
mcc: 'MCC',
mnc: 'MNC',
active: '使用中',
enabled: '已启用',
profile_id: '配置编号',
roaming_protocol: '漫游协议',
},
},
{
key: 'bandLock',
label: '频段锁定',
fields: {
enabled: '已启用',
mode: '模式',
bands: '频段',
lte_bands: 'LTE 频段',
nr_bands: 'NR 频段',
support_bands: '支持频段',
},
},
{
key: 'cellLock',
label: '小区锁定',
fields: {
enabled: '已启用',
mode: '模式',
band: '频段',
arfcn: '频点',
pci: 'PCI',
cell_id: '小区编号',
tac: 'TAC',
},
},
];
const CONFIGURATION_SECTIONS: readonly DeviceModuleSectionSpec[] = [
{
key: 'workMode',
label: '工作模式',
fields: {
mode: '模式',
work_mode: '工作模式',
enabled: '已启用',
sms_enabled: '短信',
data_enabled: '数据',
voice_enabled: '语音',
auto_reconnect: '自动重连',
},
},
{
key: 'authSettings',
label: '设备安全设置',
fields: {
configured: '已配置密码',
password_protection_enabled: '启用密码保护',
password_min_length: '密码最小长度',
password_require_letters: '密码须含字母',
password_require_digits: '密码须含数字',
password_require_symbols: '密码须含符号',
session_ttl_seconds: '会话有效期(秒)',
idle_timeout_seconds: '空闲超时(秒)',
},
},
{
key: 'authStatus',
label: '设备认证状态',
optional: true,
fields: {
configured: '已配置密码',
authenticated: '已认证',
locked_until: '锁定至',
remaining_attempts: '剩余尝试次数',
},
},
{
key: 'hub',
label: 'Hub 绑定',
fields: {
bound: '已绑定',
connected: '已连接',
hub_name: 'Hub 名称',
hub_id: 'Hub 编号',
device_name: '设备名称',
device_id: '设备编号',
address: '地址',
version: '版本',
last_seen_at: '最近在线',
},
},
];
const DEVICE_BACKUP_SECTIONS: readonly DeviceModuleSectionSpec[] = [
{
key: 'files',
label: '备份文件',
listKey: 'files',
limit: 40,
fields: {
name: '文件名',
size: '大小',
size_bytes: '大小(字节)',
created_at: '创建时间',
updated_at: '更新时间',
type: '类型',
components: '包含内容',
},
},
{
key: 'config',
label: '备份配置',
fields: {
enabled: '自动备份',
schedule: '计划',
retention: '保留数量',
destination: '存储位置',
components: '包含内容',
},
},
{
key: 'options',
label: '可备份内容',
listKey: 'options',
fields: {
key: '项目',
name: '名称',
label: '名称',
description: '说明',
enabled: '已选择',
required: '必选',
size: '大小',
},
},
];
const VOWIFI_SECTIONS: readonly DeviceModuleSectionSpec[] = [
{
key: 'status',
label: 'WiFi Calling 状态',
fields: {
enabled: '已启用',
registered: '已注册',
state: '状态',
status: '状态',
carrier: '运营商',
domain: '域名',
last_error: '最近错误',
},
},
{
key: 'control',
label: 'WiFi Calling 控制',
fields: {
enabled: '已启用',
supported: '支持',
toggleable: '可切换',
reason: '原因',
},
},
{
key: 'profile',
label: '当前配置',
fields: {
id: '编号',
name: '名称',
carrier: '运营商',
domain: '域名',
imsi: 'IMSI',
enabled: '已启用',
},
},
{
key: 'profiles',
label: '可用配置',
listKey: 'profiles',
fields: {
id: '编号',
name: '名称',
carrier: '运营商',
domain: '域名',
enabled: '已启用',
recommended: '推荐',
},
},
{
key: 'diagnostics',
label: '注册诊断时间线',
listKey: 'timeline',
limit: 50,
fields: {
timestamp: '时间',
kind: '类别',
level: '级别',
title: '事件',
detail: '详情',
},
},
{
key: 'events',
label: 'WiFi Calling 事件',
listKey: 'events',
limit: 50,
fields: {
timestamp: '时间',
kind: '类别',
level: '级别',
title: '事件',
detail: '详情',
trace_id: '追踪编号',
},
},
{
key: 'smsDeliveries',
label: 'WiFi Calling 短信送达',
listKey: 'deliveries',
limit: 20,
fields: {
created_at: '时间',
direction: '方向',
state: '状态',
sender: '发送方',
recipient: '接收方',
carrier: '运营商',
error: '错误',
},
},
{
key: 'soakRuns',
label: 'WiFi Calling 稳定性巡检',
listKey: 'runs',
limit: 20,
fields: {
started_at: '开始时间',
finished_at: '结束时间',
duration_seconds: '持续(秒)',
result: '结果',
reconnects: '重连次数',
failures: '失败次数',
},
},
{
key: 'restore',
label: 'eSIM 恢复状态',
fields: {
state: '状态',
phase: '阶段',
enabled: '已启用',
last_attempt_at: '最近尝试',
last_success_at: '最近成功',
attempts: '尝试次数',
detail: '详情',
reason: '原因',
},
},
];
type DeviceModuleComponent = (props: DeviceModuleProps) => ReactNode;
function panel(
module: string,
sections: readonly DeviceModuleSectionSpec[],
emptyText: string,
): DeviceModuleComponent {
return function DeviceModule({ instance, reader, refreshSignal }: DeviceModuleProps) {
return (
<DeviceModulePanel
instance={instance}
module={module}
sections={sections}
{...(reader ? { reader } : {})}
emptyText={emptyText}
{...(refreshSignal === undefined ? {} : { refreshSignal })}
/>
);
};
}
export const SimModule = panel('sim', SIM_SECTIONS, '需要先完成设备认证,才能读取 SIM 卡数据。');
export const ConfigurationModule = panel(
'configuration',
CONFIGURATION_SECTIONS,
'需要先完成设备认证,才能读取设备设置。',
);
export const DeviceBackupModule = panel(
'device-backup',
DEVICE_BACKUP_SECTIONS,
'需要先完成设备认证,才能读取设备备份。',
);
export const VowifiModule = panel(
'vowifi',
VOWIFI_SECTIONS,
'需要先完成设备认证,才能读取 WiFi Calling 数据。',
);
@@ -53,6 +53,7 @@ const snapshot: DeviceNetworkSnapshot = {
addresses: [{ family: 'IPv4', address: '192.0.2.10', prefixLength: 24, scope: 'global' }],
},
],
connectionAddresses: { ipv4: ['198.51.100.7'], ipv6: ['2001:db8::42'] },
ddns: {
status: {
enabled: true,
@@ -108,6 +109,7 @@ describe('Phase 6.3 Device Network read module', () => {
expect(await screen.findByRole('heading', { name: 'WLAN 状态' })).toBeTruthy();
expect(screen.getByRole('heading', { name: 'WLAN 配置文件' })).toBeTruthy();
expect(screen.getByRole('heading', { name: '接口与地址' })).toBeTruthy();
expect(screen.getByRole('heading', { name: '连接地址' })).toBeTruthy();
expect(screen.getByRole('heading', { name: 'DDNS 状态' })).toBeTruthy();
expect(screen.getByRole('heading', { name: 'DDNS 配置' })).toBeTruthy();
expect(screen.getByRole('heading', { name: 'DDNS 日志摘要' })).toBeTruthy();
@@ -124,19 +126,26 @@ describe('Phase 6.3 Device Network read module', () => {
expect(screen.getByText('192.0.2.10/24')).toBeTruthy();
expect(screen.getByText('gateway.example.test')).toBeTruthy();
const connection = screen.getByRole('region', { name: '连接地址' });
expect(within(connection).getByText('198.51.100.7')).toBeTruthy();
expect(within(connection).getByText('2001:db8::42')).toBeTruthy();
expect(screen.getByText(/观测时间:2026-07-17T11:00:00Z/)).toBeTruthy();
});
it('does not render passwords, credentials, arbitrary fields, log bodies, or write controls', async () => {
it('does not render passwords, credentials, arbitrary fields, or log bodies', async () => {
render(<DeviceNetworkModule instance={owner} dataSource={{ load: async () => snapshot }} />);
expect((await screen.findAllByText('Operations Wi-Fi')).length).toBe(2);
expect(document.body.textContent).not.toContain('never-render-this');
expect(document.body.textContent).not.toContain('private-user');
expect(document.body.textContent).not.toContain('private-password');
expect(screen.queryByText(/password/i)).toBeNull();
expect(screen.queryByRole('button')).toBeNull();
expect(screen.getByText(/可执行后端支持.*R1.*不可用/i)).toBeTruthy();
expect(screen.getByText(/可执行后端支持.*R2.*不可用/i)).toBeTruthy();
// The read-only snapshot never carries a control of its own.
expect(
within(screen.getByRole('region', { name: '接口与地址' })).queryByRole('button'),
).toBeNull();
// Actions come from the pinned catalog; without a reachable API the card stays honest.
const actions = await screen.findByRole('region', { name: '设备操作' });
expect(actions.textContent).toContain('无法读取该模块的可用操作');
});
it('is honest when no source is injected and does not invent an endpoint', () => {
@@ -2,70 +2,78 @@ import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { InstanceContext } from '../app-shell.js';
import { displayValue } from '../ui/locale.js';
import { DeviceActions } from './device-action-controls.js';
/** Only these bounded primitives may cross the injected read boundary into the UI. */
export type DeviceNetworkValue = string | number | boolean | null;
export interface WlanStatus {
readonly enabled?: boolean | null;
readonly radioState?: string | null;
readonly connectionState?: string | null;
readonly activeProfile?: string | null;
readonly ssid?: string | null;
readonly enabled?: boolean | null | undefined;
readonly radioState?: string | null | undefined;
readonly connectionState?: string | null | undefined;
readonly activeProfile?: string | null | undefined;
readonly ssid?: string | null | undefined;
}
export interface WlanProfile {
readonly name?: string | null;
readonly ssid?: string | null;
readonly security?: string | null;
readonly enabled?: boolean | null;
readonly priority?: number | null;
readonly name?: string | null | undefined;
readonly ssid?: string | null | undefined;
readonly security?: string | null | undefined;
readonly enabled?: boolean | null | undefined;
readonly priority?: number | null | undefined;
}
export interface NetworkAddress {
readonly family?: string | null;
readonly address?: string | null;
readonly prefixLength?: number | null;
readonly scope?: string | null;
readonly family?: string | null | undefined;
readonly address?: string | null | undefined;
readonly prefixLength?: number | null | undefined;
readonly scope?: string | null | undefined;
}
export interface NetworkInterface {
readonly name?: string | null;
readonly kind?: string | null;
readonly state?: string | null;
readonly macAddress?: string | null;
readonly mtu?: number | null;
readonly name?: string | null | undefined;
readonly kind?: string | null | undefined;
readonly state?: string | null | undefined;
readonly macAddress?: string | null | undefined;
readonly mtu?: number | null | undefined;
readonly addresses: readonly NetworkAddress[];
}
export interface DdnsStatus {
readonly enabled?: boolean | null;
readonly state?: string | null;
readonly lastUpdateAt?: string | null;
readonly enabled?: boolean | null | undefined;
readonly state?: string | null | undefined;
readonly lastUpdateAt?: string | null | undefined;
}
/** Credentials are intentionally absent. Do not add password, token, secret, or username fields. */
export interface DdnsConfig {
readonly provider?: string | null;
readonly hostname?: string | null;
readonly updateIntervalSeconds?: number | null;
readonly provider?: string | null | undefined;
readonly hostname?: string | null | undefined;
readonly updateIntervalSeconds?: number | null | undefined;
}
/** This is aggregate metadata only; raw DDNS log messages are intentionally unsupported. */
export interface DdnsLogSummary {
readonly totalEntries?: number | null;
readonly successfulUpdates?: number | null;
readonly failedUpdates?: number | null;
readonly lastEventAt?: string | null;
readonly totalEntries?: number | null | undefined;
readonly successfulUpdates?: number | null | undefined;
readonly failedUpdates?: number | null | undefined;
readonly lastEventAt?: string | null | undefined;
}
export interface DeviceNetworkSnapshot {
readonly observedAt?: string;
readonly observedAt?: string | undefined;
readonly wlan: {
readonly status: WlanStatus;
readonly profiles: readonly WlanProfile[];
};
readonly interfaces: readonly NetworkInterface[];
/** Uplink addresses reported by the device, used for the connection summary. */
readonly connectionAddresses?:
| {
readonly ipv4: readonly string[];
readonly ipv6: readonly string[];
}
| undefined;
readonly ddns: {
readonly status: DdnsStatus;
readonly config: DdnsConfig;
@@ -80,9 +88,9 @@ export interface DeviceNetworkDataSource {
export interface DeviceNetworkModuleProps {
readonly instance: InstanceContext;
readonly dataSource?: DeviceNetworkDataSource;
readonly dataSource?: DeviceNetworkDataSource | undefined;
/** Change this owner-provided value to request another read. */
readonly refreshSignal?: unknown;
readonly refreshSignal?: unknown | undefined;
}
type ReadState =
@@ -132,10 +140,25 @@ function SnapshotView({ snapshot }: { snapshot: DeviceNetworkSnapshot }) {
const ddnsStatus = snapshot.ddns.status;
const config = snapshot.ddns.config;
const logs = snapshot.ddns.logSummary;
const connection = snapshot.connectionAddresses;
return (
<>
{snapshot.observedAt ? <p>观测时间:{snapshot.observedAt}</p> : null}
<div className="device-network-grid">
<Section label="连接地址">
<Fields
values={[
[
'IPv4',
connection && connection.ipv4.length > 0 ? connection.ipv4.join('、') : null,
],
[
'IPv6',
connection && connection.ipv6.length > 0 ? connection.ipv6.join('、') : null,
],
]}
/>
</Section>
<Section label="WLAN 状态">
<Fields
values={[
@@ -239,11 +262,6 @@ function SnapshotView({ snapshot }: { snapshot: DeviceNetworkSnapshot }) {
/>
</Section>
</div>
<section className="state-panel" aria-label="设备网络操作">
<h2>设备网络操作</h2>
<p>在具备可执行后端支持前,R1 配置操作不可用。</p>
<p>在具备可执行后端支持前,R2 运行操作不可用。</p>
</section>
</>
);
}
@@ -354,6 +372,12 @@ export function DeviceNetworkModule({
</p>
) : null}
<SnapshotView snapshot={currentSnapshot} />
<DeviceActions
instance={instance}
module="device-network"
onExecuted={() => setRetry((value) => value + 1)}
/>
</div>
);
}
@@ -0,0 +1,132 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest';
import { createDiscoveryApiDataSource } from './discovery-api-data-source.js';
const device = {
origin: 'http://192.168.68.1:3000',
address: '192.168.68.1',
port: 3000,
secure: false,
httpStatus: 200,
identity: { model: 'iPhone 14', imei: '356938035643809' },
knownInstanceId: null,
};
const session = {
sessionId: '7a1f0c22-0000-4000-8000-000000000001',
status: 'scanning',
createdAt: '2026-09-05T02:00:00.000Z',
expiresAt: '2026-09-05T02:01:00.000Z',
scanned: 12,
total: 508,
ranges: ['192.168.68.0/24'],
devices: [device],
};
const probe = {
origin: 'http://192.168.68.9:3000',
reachable: true,
httpStatus: 200,
identity: { brand: 'Google', serial: 'SN-1' },
knownInstanceId: 'inst-1',
};
const json = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), { status });
describe('discovery API data source', () => {
it('starts a session and posts an empty body with same-origin controls', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json({ data: session }, 201));
const signal = new AbortController().signal;
await expect(createDiscoveryApiDataSource(fetcher).start(signal)).resolves.toMatchObject({
sessionId: session.sessionId,
status: 'scanning',
total: 508,
ranges: ['192.168.68.0/24'],
});
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/v1/discovery/sessions');
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({
method: 'POST',
credentials: 'same-origin',
body: '{}',
signal,
});
});
it('keeps only whitelisted identity fields from a discovered device', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json({ data: session }));
const started = await createDiscoveryApiDataSource(fetcher).start();
expect(started.devices[0]?.identity).toEqual({ model: 'iPhone 14' });
expect(JSON.stringify(started)).not.toContain('356938035643809');
});
it('reads status, renews and stops against the encoded session id', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(json({ data: { ...session, status: 'completed' } }))
.mockResolvedValueOnce(json({ data: session }))
.mockResolvedValueOnce(new Response(null, { status: 204 }));
const source = createDiscoveryApiDataSource(fetcher);
await expect(source.status(session.sessionId)).resolves.toMatchObject({ status: 'completed' });
await expect(source.renew(session.sessionId)).resolves.toMatchObject({
sessionId: session.sessionId,
});
await expect(source.stop(session.sessionId)).resolves.toBeUndefined();
expect(fetcher.mock.calls[0]).toEqual([
`/api/v1/discovery/sessions/${encodeURIComponent(session.sessionId)}`,
expect.objectContaining({ method: 'GET' }),
]);
expect(fetcher.mock.calls[1]?.[1]).toMatchObject({ method: 'PUT' });
expect(fetcher.mock.calls[2]?.[1]).toMatchObject({ method: 'DELETE' });
});
it('probes a manual address and drops unknown identity keys', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json({ data: probe }));
await expect(createDiscoveryApiDataSource(fetcher).probe('192.168.68.9:3000')).resolves.toEqual(
{
origin: probe.origin,
reachable: true,
httpStatus: 200,
identity: { brand: 'Google' },
knownInstanceId: 'inst-1',
},
);
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({
method: 'POST',
body: JSON.stringify({ device_url: '192.168.68.9:3000' }),
});
});
it('surfaces a failed request as an error', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValue(json({ code: 'DISCOVERY_NOT_FOUND' }, 404));
await expect(createDiscoveryApiDataSource(fetcher).status('nope')).rejects.toThrowError(
'Discovery request failed.',
);
});
it('refuses a session payload that does not match the envelope shape', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json({ data: { sessionId: 'x' } }));
await expect(createDiscoveryApiDataSource(fetcher).start()).rejects.toThrowError(
'Discovery response is invalid.',
);
});
it('refuses a device whose origin is not a plain private origin', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
json({
data: { ...session, devices: [{ ...device, origin: 'http://a@evil.test:3000/x' }] },
}),
);
await expect(createDiscoveryApiDataSource(fetcher).start()).rejects.toThrowError(
'Discovery response is invalid.',
);
});
});
@@ -0,0 +1,193 @@
export type DiscoveredDevice = Readonly<{
origin: string;
address: string;
port: number;
secure: boolean;
httpStatus: number;
identity: Readonly<Record<string, string>>;
knownInstanceId: string | null;
}>;
export type DiscoverySession = Readonly<{
sessionId: string;
status: 'scanning' | 'completed';
createdAt: string;
expiresAt: string;
scanned: number;
total: number;
ranges: readonly string[];
devices: readonly DiscoveredDevice[];
}>;
export type DeviceProbe = Readonly<{
origin: string;
reachable: boolean;
httpStatus: number | null;
identity: Readonly<Record<string, string>>;
knownInstanceId: string | null;
}>;
export interface DiscoveryDataSource {
start(signal?: AbortSignal): Promise<DiscoverySession>;
status(sessionId: string, signal?: AbortSignal): Promise<DiscoverySession>;
renew(sessionId: string, signal?: AbortSignal): Promise<DiscoverySession>;
stop(sessionId: string): Promise<void>;
probe(deviceUrl: string, signal?: AbortSignal): Promise<DeviceProbe>;
}
const BASE = '/api/v1/discovery';
const MAX_TEXT = 200;
const SESSION_ID = /^[0-9a-fA-F-]{1,64}$/u;
const ORIGIN = /^https?:\/\/[A-Za-z0-9.[\]:_-]{1,120}(?::\d{1,5})?$/u;
const IDENTITY_FIELDS: readonly string[] = [
'model',
'manufacturer',
'brand',
'firmware_version',
'os_version',
'version',
];
const record = (value: unknown): Record<string, unknown> | undefined =>
typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
const shortText = (value: unknown): value is string =>
typeof value === 'string' &&
value.length > 0 &&
value.length <= MAX_TEXT &&
!/[\x00-\x1F\x7F]/u.test(value);
const integer = (value: unknown, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): value is number =>
typeof value === 'number' && Number.isInteger(value) && value >= minimum && value <= maximum;
function identity(value: unknown): Readonly<Record<string, string>> {
const source = record(value);
if (!source) return {};
const result: Record<string, string> = {};
for (const field of IDENTITY_FIELDS) {
const entry = source[field];
if (shortText(entry)) result[field] = entry;
}
return result;
}
function device(value: unknown): DiscoveredDevice | undefined {
const source = record(value);
if (!source) return undefined;
const { origin, address, port, secure, httpStatus, knownInstanceId } = source;
if (!shortText(origin) || !ORIGIN.test(origin)) return undefined;
if (!shortText(address) || !integer(port, 1, 65_535) || typeof secure !== 'boolean')
return undefined;
if (!integer(httpStatus, 100, 599)) return undefined;
if (knownInstanceId !== null && !shortText(knownInstanceId)) return undefined;
return {
origin,
address,
port,
secure,
httpStatus,
identity: identity(source.identity),
knownInstanceId: typeof knownInstanceId === 'string' ? knownInstanceId : null,
};
}
function session(value: unknown): DiscoverySession {
const source = record(value);
const sessionId = source?.sessionId;
if (!source || !shortText(sessionId) || !SESSION_ID.test(sessionId))
throw new Error('Discovery response is invalid.');
const { status, createdAt, expiresAt, scanned, total, ranges } = source;
if (status !== 'scanning' && status !== 'completed')
throw new Error('Discovery response is invalid.');
if (!shortText(createdAt) || !shortText(expiresAt))
throw new Error('Discovery response is invalid.');
if (!integer(scanned) || !integer(total) || !Array.isArray(ranges))
throw new Error('Discovery response is invalid.');
const devices = ranges.every((entry) => shortText(entry)) ? source.devices : undefined;
if (!Array.isArray(devices)) throw new Error('Discovery response is invalid.');
const parsed = devices.map(device);
if (parsed.some((entry) => entry === undefined))
throw new Error('Discovery response is invalid.');
return {
sessionId,
status,
createdAt,
expiresAt,
scanned,
total,
ranges: ranges as string[],
devices: parsed as DiscoveredDevice[],
};
}
function probe(value: unknown): DeviceProbe {
const source = record(value);
if (!source) throw new Error('Discovery response is invalid.');
const { origin, reachable, httpStatus, knownInstanceId } = source;
if (!shortText(origin) || !ORIGIN.test(origin) || typeof reachable !== 'boolean')
throw new Error('Discovery response is invalid.');
if (httpStatus !== null && !integer(httpStatus, 100, 599))
throw new Error('Discovery response is invalid.');
if (knownInstanceId !== null && !shortText(knownInstanceId))
throw new Error('Discovery response is invalid.');
return {
origin,
reachable,
httpStatus: typeof httpStatus === 'number' ? httpStatus : null,
identity: identity(source.identity),
knownInstanceId: typeof knownInstanceId === 'string' ? knownInstanceId : null,
};
}
export function createDiscoveryApiDataSource(
fetcher: typeof fetch = globalThis.fetch,
): DiscoveryDataSource {
const request = async (
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
signal?: AbortSignal,
body?: unknown,
): Promise<unknown> => {
const response = await fetcher(`${BASE}${path}`, {
method,
credentials: 'same-origin',
headers:
body === undefined
? { accept: 'application/json' }
: { accept: 'application/json', 'content-type': 'application/json' },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
...(signal ? { signal } : {}),
});
if (!response.ok) throw new Error('Discovery request failed.');
if (response.status === 204) return undefined;
return (await response.json().catch(() => undefined)) as unknown;
};
const data = async (
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
signal?: AbortSignal,
body?: unknown,
): Promise<Record<string, unknown>> => {
const payload = record(await request(method, path, signal, body));
const envelope = payload ? record(payload.data) : undefined;
if (!envelope) throw new Error('Discovery response is invalid.');
return envelope;
};
return {
async start(signal) {
return session(await data('POST', '/sessions', signal, {}));
},
async status(sessionId, signal) {
return session(await data('GET', `/sessions/${encodeURIComponent(sessionId)}`, signal));
},
async renew(sessionId, signal) {
return session(await data('PUT', `/sessions/${encodeURIComponent(sessionId)}`, signal));
},
async stop(sessionId) {
await request('DELETE', `/sessions/${encodeURIComponent(sessionId)}`);
},
async probe(deviceUrl, signal) {
return probe(await data('POST', '/probe', signal, { device_url: deviceUrl }));
},
};
}
+14 -7
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import type { InstanceContext } from '../app-shell.js';
import { displayValue } from '../ui/locale.js';
import { DeviceActions } from './device-action-controls.js';
/** Bounded status vocabularies are the only strings this module may present. */
export type EsimLpacStatus = 'available' | 'unavailable' | 'degraded' | 'unknown';
@@ -13,11 +14,11 @@ export type EsimSafeLabel = 'Provisioned' | 'Enabled' | 'Disabled' | 'Pending' |
* identifiers have no place in this contract.
*/
export interface EsimSnapshot {
readonly profileCount?: number | null;
readonly enabledProfileCount?: number | null;
readonly lpacStatus?: EsimLpacStatus | null;
readonly workMode?: EsimWorkMode | null;
readonly labels?: readonly EsimSafeLabel[];
readonly profileCount?: number | null | undefined;
readonly enabledProfileCount?: number | null | undefined;
readonly lpacStatus?: EsimLpacStatus | null | undefined;
readonly workMode?: EsimWorkMode | null | undefined;
readonly labels?: readonly EsimSafeLabel[] | undefined;
}
export interface EsimDataSource {
@@ -27,8 +28,8 @@ export interface EsimDataSource {
export interface EsimModuleProps {
readonly instance: InstanceContext;
readonly dataSource?: EsimDataSource;
readonly refreshSignal?: unknown;
readonly dataSource?: EsimDataSource | undefined;
readonly refreshSignal?: unknown | undefined;
}
type SafeSnapshot = {
@@ -254,6 +255,12 @@ export function EsimModule({ instance, dataSource, refreshSignal }: EsimModulePr
</p>
) : null}
<SnapshotView snapshot={snapshot} />
<DeviceActions
instance={instance}
module="esim"
onExecuted={() => setRetry((value) => value + 1)}
/>
</div>
);
}
+9
View File
@@ -1,6 +1,7 @@
import { useEffect, useState, type FormEvent } from 'react';
import { Button, Card, Title } from 'animal-island-ui';
import { DeviceDiscoveryPanel, type DeviceSelection } from './device-discovery-panel.js';
import {
createInstanceApiDataSource,
passwordUpdate,
@@ -40,6 +41,13 @@ export function InstanceEditor({
const [busy, setBusy] = useState(false);
const [confirming, setConfirming] = useState(false);
const [confirmation, setConfirmation] = useState('');
const [autoName, setAutoName] = useState('');
const adopt = (selection: DeviceSelection) => {
setOrigin(selection.origin);
setName((current) => (current.trim() && current !== autoName ? current : selection.name));
setAutoName(selection.name);
};
useEffect(() => {
if (mode !== 'edit') return;
@@ -160,6 +168,7 @@ export function InstanceEditor({
{status}
</p>
) : null}
{mode === 'create' ? <DeviceDiscoveryPanel onSelect={adopt} selectedOrigin={origin} /> : null}
<Card pattern="default" className="editor-card">
<form onSubmit={submit}>
<label>
@@ -32,7 +32,7 @@ function instanceNavigation() {
}
describe('InstanceDetail', () => {
it('converges primary navigation on messages and device information only', () => {
it('exposes every fused device module in the instance navigation', () => {
render(
<InstanceDetail
instanceId="owner"
@@ -44,12 +44,21 @@ describe('InstanceDetail', () => {
const navigation = instanceNavigation();
const links = navigation.getAllByRole('link');
expect(links).toHaveLength(2);
expect(links.map((link) => [link.textContent, link.getAttribute('href')])).toEqual([
['仪表盘', '/instances/owner/overview'],
['SIM 卡', '/instances/owner/sim'],
['蜂窝', '/instances/owner/cellular'],
['设备网络', '/instances/owner/device-network'],
['短信', '/instances/owner/messages'],
['通话', '/instances/owner/calls'],
['eSIM', '/instances/owner/esim'],
['WiFi 通话', '/instances/owner/vowifi'],
['通知', '/instances/owner/notifications'],
['自动化', '/instances/owner/automation'],
['OTA', '/instances/owner/ota'],
['设置', '/instances/owner/configuration'],
['备份', '/instances/owner/device-backup'],
]);
expect(navigation.queryByText(/蜂窝网络|设备网络|通话|eSIM|通知|自动化|OTA/)).toBeNull();
expect(screen.getByRole('heading', { name: '实例仪表盘' })).toBeTruthy();
expect(screen.queryByText('能力状态未知。')).toBeNull();
expect(screen.queryByText('此调制解调器不支持语音功能。')).toBeNull();
@@ -59,7 +68,6 @@ describe('InstanceDetail', () => {
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
expect(screen.getByText('已认证')).toBeTruthy();
expect(screen.getByText('数据最新')).toBeTruthy();
expect(links.map((link) => link.textContent)).toEqual(['仪表盘', '短信']);
expect(screen.getByRole('link', { name: '编辑实例' }).getAttribute('href')).toBe(
'/settings/instances/owner',
);
@@ -94,7 +102,7 @@ describe('InstanceDetail', () => {
<InstanceDetail instanceId="owner" module="overview" instance={owner} capabilities={{}} />,
);
expect(instanceNavigation().getAllByRole('link')).toHaveLength(2);
expect(instanceNavigation().getAllByRole('link')).toHaveLength(13);
expect(screen.queryByText('能力状态未知。')).toBeNull();
});
@@ -184,6 +192,10 @@ describe('InstanceDetail', () => {
['notifications', '通知'],
['automation', '自动化'],
['ota', 'OTA'],
['sim', 'SIM 卡'],
['vowifi', 'WiFi 通话'],
['configuration', '设备设置'],
['device-backup', '设备备份'],
])('keeps the active legacy deep link and content compatible for %s', (module, label) => {
render(
<InstanceDetail
@@ -197,6 +209,7 @@ describe('InstanceDetail', () => {
expect(screen.getByRole('heading', { name: label })).toBeTruthy();
expect(screen.getByText(`旧深链接内容:${label}`)).toBeTruthy();
expect(instanceNavigation().queryByRole('link', { name: label })).toBeNull();
const link = document.querySelector(`nav[aria-label="实例模块"] a[href$="/${module}"]`);
expect(link?.getAttribute('aria-current')).toBe('page');
});
});
+33 -7
View File
@@ -30,20 +30,43 @@ export interface InstanceDetailProps {
export const INSTANCE_MODULE_LABELS: Readonly<Record<InstanceModule, string>> = {
overview: '实例仪表盘',
sim: 'SIM 卡',
cellular: '蜂窝网络',
'device-network': '设备网络',
messages: '短信管理',
calls: '通话',
esim: 'eSIM',
vowifi: 'WiFi 通话',
notifications: '通知',
automation: '自动化',
ota: 'OTA',
configuration: '设备设置',
'device-backup': '设备备份',
};
const PRIMARY_MODULES = ['overview', 'messages'] as const satisfies readonly InstanceModule[];
const PRIMARY_LABELS: Readonly<Record<(typeof PRIMARY_MODULES)[number], string>> = {
const MODULE_NAV: readonly InstanceModule[] = [
'overview',
'sim',
'cellular',
'device-network',
'messages',
'calls',
'esim',
'vowifi',
'notifications',
'automation',
'ota',
'configuration',
'device-backup',
] as const satisfies readonly InstanceModule[];
const SHORT_LABELS: Readonly<Partial<Record<InstanceModule, string>>> = {
overview: '仪表盘',
cellular: '蜂窝',
'device-network': '设备网络',
messages: '短信',
notifications: '通知',
configuration: '设置',
'device-backup': '备份',
};
const AUTH_LABELS = { authenticated: '已认证', 'auth-required': '需要认证' } as const;
const FRESHNESS_LABELS = {
@@ -63,9 +86,12 @@ function canRender(capability: InstanceCapability | undefined): boolean {
return capability.state === 'supported' || capability.state === 'degraded';
}
const CORE_MODULES = new Set<InstanceModule>(['overview', 'messages']);
function canOpen(module: InstanceModule, capability: InstanceCapability | undefined): boolean {
return capability ? canRender(capability) : CORE_MODULES.has(module);
/**
* Every tab is backed by a live device probe, so an unknown capability no longer hides a module;
* the module itself reports the sections a device cannot serve.
*/
function canOpen(_module: InstanceModule, capability: InstanceCapability | undefined): boolean {
return capability ? canRender(capability) : true;
}
function explanation(capability: InstanceCapability | undefined): string | null {
@@ -191,14 +217,14 @@ export function InstanceDetail({
</div>
<nav aria-label="实例模块">
<ul>
{PRIMARY_MODULES.map((item) => {
{MODULE_NAV.map((item) => {
return (
<li key={item}>
<a
href={`/instances/${encodeURIComponent(instanceId)}/${item}`}
aria-current={module === item ? 'page' : undefined}
>
{PRIMARY_LABELS[item]}
{SHORT_LABELS[item] ?? INSTANCE_MODULE_LABELS[item]}
</a>
</li>
);
@@ -0,0 +1,304 @@
import { describe, expect, it, vi } from 'vitest';
import {
createInstanceModuleApiReader,
createInstanceModuleDataSources,
mapAutomation,
mapCalls,
mapCellular,
mapDeviceNetwork,
mapEsim,
mapNotifications,
mapOta,
mapOverview,
type InstanceModuleSnapshot,
} from './instance-module-api-data-source.js';
function snapshot(module: string, sections: readonly [string, unknown][]): InstanceModuleSnapshot {
return {
instanceId: 'node-a',
module,
observedAt: '2026-09-04T06:00:00.000Z',
authenticated: true,
sections: sections.map(([key, data]) => ({
key,
path: `/${key}`,
state: 'ok' as const,
status: 200,
data,
})),
};
}
describe('instance module mappers', () => {
it('splits device stats between the resource and throughput cards', () => {
const result = mapOverview(
snapshot('overview', [
['device', { model: 'LPAX-1', imei: '860000000000001', uptime: '3天' }],
['sim', { slots: [{ slot: 1, operator: 'China Mobile' }] }],
['network', { registration_state: 'registered', operator_name: 'China Mobile' }],
['stats', { cpu_percent: 12.5, memory_percent: 44, download_bytes: 1024 }],
['connectivity', { connected: true, latency: 38 }],
]),
);
expect(result.device).toMatchObject({ model: 'LPAX-1', uptime: '3天' });
expect(result.cpu).toEqual({ cpu_percent: 12.5, memory_percent: 44 });
expect(result.stats).toEqual({ download_bytes: 1024 });
expect(result.network).toMatchObject({ registration_state: 'registered' });
expect(result.connectivity).toMatchObject({ connected: true, latency: 38 });
expect(result.observedAt).toBe('2026-09-04T06:00:00.000Z');
});
it('reads cellular fields across the naming variants devices use', () => {
const result = mapCellular(
snapshot('cellular', [
['network', { registration_state: 'registered', radio_mode: 'NR/LTE', operator: 'CUCC' }],
['signalStrength', { rsrp: -92, sinr: 14, signal_percent: 78 }],
['cells', { cell_id: '0x1A2B', tac: '0x00C8', network_type: 'nr' }],
['cellLocation', { latitude: 24.5, longitude: 118.1 }],
['operators', { operators: [{ name: 'CUCC' }, { name: 'CMCC' }] }],
['roaming', { state: 'roaming' }],
]),
);
expect(result.networkRegistration).toEqual({
state: 'registered',
mode: 'NR/LTE',
operator: 'CUCC',
roaming: 'roaming',
});
expect(result.signal).toEqual({ rsrp: '-92', sinr: '14', quality: '78' });
expect(result.cellsLocation).toEqual({
cell: '0x1A2B',
area: '0x00C8',
technology: 'nr',
latitude: '24.5',
longitude: '118.1',
});
expect(result.operators.available).toBe('CUCC、CMCC');
});
it('keeps wlan, interface and ddns detail together', () => {
const result = mapDeviceNetwork(
snapshot('device-network', [
['wlanStatus', { enabled: true, connection_state: 'connected', ssid: 'office' }],
['wlanProfiles', { profiles: [{ name: 'office', security: 'WPA2', priority: 1 }] }],
[
'interfaces',
{
interfaces: [{ name: 'rmnet_data0', state: 'up', mtu: 1500, addresses: ['10.0.0.5'] }],
},
],
['ddnsStatus', { enabled: true, state: 'updated', last_update_at: '2026-09-04' }],
['ddnsConfig', { provider: 'duckdns', hostname: 'node.example', interval: 300 }],
[
'ddnsLogs',
{
logs: [{ status: 'success' }, { status: 'success' }, { status: 'failed' }],
},
],
]),
);
expect(result.wlan.status).toEqual({
enabled: true,
connectionState: 'connected',
ssid: 'office',
});
expect(result.wlan.profiles).toEqual([{ name: 'office', security: 'WPA2', priority: 1 }]);
expect(result.interfaces[0]?.addresses).toEqual([
{ address: '10.0.0.5', family: undefined, prefixLength: undefined, scope: undefined },
]);
expect(result.ddns.config).toEqual({
provider: 'duckdns',
hostname: 'node.example',
updateIntervalSeconds: 300,
});
expect(result.ddns.logSummary).toMatchObject({
totalEntries: 3,
successfulUpdates: 2,
failedUpdates: 1,
});
});
it('aggregates eSIM profiles into the safe contract', () => {
const result = mapEsim(
snapshot('esim', [
[
'profiles',
{
profiles: [
{ iccid: '8986000000000000001', enabled: true },
{ iccid: '8986000000000000002', status: 'disabled' },
{ iccid: '8986000000000000003', status: 'pending' },
],
},
],
['euicc', { work_mode: 'idle' }],
['lpacStatus', { status: 'available' }],
]),
);
expect(result).toEqual({
profileCount: 3,
enabledProfileCount: 1,
lpacStatus: 'available',
workMode: 'idle',
labels: ['Enabled', 'Disabled', 'Pending'],
});
});
it('summarises call state and history counts', () => {
const result = mapCalls(
snapshot('calls', [
['calls', { state: 'active', active: 1 }],
['history', { calls: [{}, {}, {}] }],
['settings', { slots: 2 }],
]),
);
expect(result.calls).toMatchObject({ state: 'active', active: 1, total: 3 });
expect(result.devices).toMatchObject({ state: 'active', total: 2, busy: 1 });
});
it('reports each call capability using the firmware verdict', () => {
const base = snapshot('calls', [['calls', { state: 'idle' }]]);
const result = mapCalls({
...base,
sections: [
...base.sections,
{ key: 'ims', path: '/ims', state: 'ok', status: 200, data: { registered: true } },
{
key: 'forwarding',
path: '/forwarding',
state: 'unsupported',
status: 200,
data: { status: 'error', message: 'Call forwarding is not exposed' },
},
{ key: 'volume', path: '/volume', state: 'auth-required', status: 401, data: null },
],
});
expect(result.features).toEqual([
{ name: '呼叫转移', state: 'unavailable' },
{ name: '通话音量', state: 'unavailable' },
{ name: 'IMS 语音', state: 'available' },
]);
});
it('turns notification payloads into aggregate counts', () => {
const result = mapNotifications(
snapshot('notifications', [
[
'config',
{
enabled: true,
channels: [
{ name: 'email', enabled: true },
{ name: 'webhook', enabled: false },
],
},
],
[
'queue',
{
items: [{ status: 'pending' }, { status: 'delivered' }, { status: 'failed' }],
},
],
['logs', { items: [{ level: 'info' }, { level: 'warn' }, { level: 'error' }] }],
]),
);
expect(result.channels).toMatchObject({
status: 'degraded',
total: 2,
enabled: 1,
disabled: 1,
});
expect(result.queue).toMatchObject({ total: 3, pending: 1, delivered: 1, failed: 1 });
expect(result.logs).toMatchObject({ total: 3, info: 1, warning: 1, error: 1 });
});
it('reports automation health from rule and execution payloads', () => {
const result = mapAutomation(
snapshot('automation', [
[
'config',
{
enabled: true,
rules: [
{ id: 'r1', enabled: true },
{ id: 'r2', enabled: false },
],
},
],
['logs', { items: [{ status: 'success' }, { status: 'failed' }] }],
]),
);
expect(result.status).toEqual({ state: 'degraded', scheduler: 'active', workers: 'degraded' });
expect(result.tasks).toMatchObject({
total: 2,
enabled: 1,
disabled: 1,
succeeded: 1,
failed: 1,
});
});
it('clamps OTA progress and normalises the status token', () => {
expect(mapOta(snapshot('ota', [['status', { state: 'Downloading', progress: 140 }]]))).toEqual({
currentVersion: undefined,
status: 'downloading',
progressPercent: 100,
updateAvailable: false,
});
expect(mapOta(snapshot('ota', [['status', { status: 'available' }]])).updateAvailable).toBe(
true,
);
});
it('reads a module snapshot through the api reader', async () => {
const fetcher = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({
instanceId: 'node-a',
module: 'sim',
observedAt: '2026-09-04T06:00:00.000Z',
authenticated: true,
sections: [{ key: 'sim', path: '/sim', state: 'ok', status: 200, data: { slots: [] } }],
}),
})) as unknown as typeof fetch;
const reader = createInstanceModuleApiReader(fetcher);
const result = await reader.read('node-a', 'sim');
expect(result.sections).toHaveLength(1);
expect(
String((fetcher as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0]),
).toBe('/api/v1/instances/node-a/modules/sim');
});
it('rejects a snapshot that belongs to another instance', async () => {
const fetcher = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({
instanceId: 'other',
module: 'sim',
observedAt: '2026-09-04T06:00:00.000Z',
authenticated: true,
sections: [],
}),
})) as unknown as typeof fetch;
await expect(createInstanceModuleApiReader(fetcher).read('node-a', 'sim')).rejects.toThrow(
'服务端返回的模块快照无效。',
);
});
it('serves a fresh read to every module without re-probing the device', async () => {
const read = vi.fn(async (instanceId: string, module: string) =>
snapshot(module, [['status', { state: 'idle' }]]),
);
const sources = createInstanceModuleDataSources({ read });
await Promise.all([
sources.ota.load('node-a', new AbortController().signal),
sources.ota.load('node-a', new AbortController().signal),
]);
expect(read).toHaveBeenCalledTimes(1);
await sources.automation.load('node-a', new AbortController().signal);
expect(read).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,749 @@
import type { AutomationSnapshot } from './automation-module.js';
import type { CallFeatureStatus, CallsSnapshot } from './calls-module.js';
import type { CellularSnapshot } from './cellular-module.js';
import type { DeviceNetworkSnapshot } from './device-network-module.js';
import type { EsimSafeLabel, EsimSnapshot } from './esim-module.js';
import type { NotificationsSnapshot } from './notifications-module.js';
import type { OtaSnapshot } from './ota-module.js';
import type { OverviewFieldValue, OverviewSnapshot } from './overview-system.js';
/**
* Bridge between the control-plane instance module proxy and the device panel modules.
*
* The API reads the device's own read-only endpoints, classifies each probe, and hands back a
* flat list of sections. This file is the single place that knows how those device sections map
* onto the console's presentation contracts, so every module renders live data instead of an
* empty shell.
*/
export type InstanceModuleState = 'ok' | 'empty' | 'auth-required' | 'unsupported' | 'failed';
export interface InstanceModuleSection {
readonly key: string;
readonly path: string;
readonly state: InstanceModuleState;
readonly status: number;
readonly data: unknown;
}
export interface InstanceModuleSnapshot {
readonly instanceId: string;
readonly module: string;
readonly observedAt: string;
readonly authenticated: boolean;
readonly sections: readonly InstanceModuleSection[];
}
export interface InstanceModuleReader {
read(instanceId: string, module: string, signal?: AbortSignal): Promise<InstanceModuleSnapshot>;
}
const MAX_ENTRIES = 40;
const MAX_STRING_LENGTH = 240;
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function parseSnapshot(value: unknown, instanceId: string, module: string): InstanceModuleSnapshot {
if (
!isRecord(value) ||
typeof value.instanceId !== 'string' ||
value.instanceId !== instanceId ||
typeof value.module !== 'string' ||
typeof value.observedAt !== 'string' ||
typeof value.authenticated !== 'boolean' ||
!Array.isArray(value.sections)
)
throw new Error('服务端返回的模块快照无效。');
const sections = value.sections.slice(0, 64).flatMap((entry): InstanceModuleSection[] => {
if (!isRecord(entry) || typeof entry.key !== 'string' || typeof entry.path !== 'string')
return [];
const state = entry.state;
return [
{
key: entry.key.slice(0, 64),
path: entry.path.slice(0, 256),
state:
state === 'ok' ||
state === 'empty' ||
state === 'auth-required' ||
state === 'unsupported' ||
state === 'failed'
? state
: 'failed',
status:
typeof entry.status === 'number' && Number.isFinite(entry.status) ? entry.status : 0,
data: entry.data,
},
];
});
return {
instanceId: value.instanceId,
module: value.module || module,
observedAt: value.observedAt,
authenticated: value.authenticated,
sections,
};
}
export function createInstanceModuleApiReader(fetcher: typeof fetch = fetch): InstanceModuleReader {
return {
async read(instanceId, module, signal) {
const url = `/api/v1/instances/${encodeURIComponent(instanceId)}/modules/${encodeURIComponent(
module,
)}`;
const response = await fetcher(url, {
method: 'GET',
credentials: 'same-origin',
headers: { accept: 'application/json' },
...(signal ? { signal } : {}),
});
if (!response.ok) throw new Error(`请求失败 (${response.status})`);
return parseSnapshot(await response.json(), instanceId, module);
},
};
}
function section(snapshot: InstanceModuleSnapshot, key: string): InstanceModuleSection | undefined {
return snapshot.sections.find((candidate) => candidate.key === key);
}
function sectionRecord(
snapshot: InstanceModuleSnapshot,
key: string,
): Readonly<Record<string, unknown>> {
const entry = section(snapshot, key);
if (!entry) return {};
if (isRecord(entry.data)) return entry.data;
if (Array.isArray(entry.data)) return { items: entry.data };
return {};
}
/** First present value among candidate device field names; device payloads are not uniform. */
function pick(source: Readonly<Record<string, unknown>>, keys: readonly string[]): unknown {
for (const key of keys) {
const value = source[key];
if (value !== undefined && value !== null) return value;
}
return undefined;
}
function text(value: unknown): string | undefined {
if (typeof value === 'string')
return value.length > MAX_STRING_LENGTH ? `${value.slice(0, MAX_STRING_LENGTH)}…` : value;
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
if (typeof value === 'boolean') return value ? 'true' : 'false';
return undefined;
}
function flag(value: unknown): boolean | undefined {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
if (typeof value === 'string') {
const lowered = value.toLocaleLowerCase();
if (['true', 'yes', 'on', 'enabled', '1'].includes(lowered)) return true;
if (['false', 'no', 'off', 'disabled', '0'].includes(lowered)) return false;
}
return undefined;
}
function count(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
/** Keep only display-safe primitives, preserving device key names for the label table. */
function primitives(source: Readonly<Record<string, unknown>>): Record<string, OverviewFieldValue> {
const result: Record<string, OverviewFieldValue> = {};
for (const [key, value] of Object.entries(source)) {
if (Object.keys(result).length >= MAX_ENTRIES) break;
if (typeof value === 'string') {
result[key] = text(value) ?? null;
} else if (typeof value === 'number') {
result[key] = Number.isFinite(value) ? value : null;
} else if (typeof value === 'boolean') {
result[key] = value;
} else if (Array.isArray(value)) {
const entries = value
.slice(0, 6)
.map((item) => (isRecord(item) ? text(pick(item, ['name', 'label', 'value'])) : text(item)))
.filter((item): item is string => item !== undefined);
result[key] = entries.length ? entries.join('、') : null;
} else {
result[key] = null;
}
}
return result;
}
function arrayFrom(
source: Readonly<Record<string, unknown>>,
keys: readonly string[],
): readonly unknown[] {
for (const key of keys) {
const value = source[key];
if (Array.isArray(value)) return value.slice(0, MAX_ENTRIES);
}
return [];
}
function tally(items: readonly unknown[], predicate: (item: unknown) => boolean): number {
return items.reduce<number>((total, item) => (predicate(item) ? total + 1 : total), 0);
}
function statusOf(items: readonly unknown[]): 'healthy' | 'degraded' | 'failed' | 'idle' {
if (items.length === 0) return 'idle';
const failed = tally(items, (item) => {
const record = isRecord(item) ? item : {};
const value = text(pick(record, ['status', 'state', 'result']))?.toLocaleLowerCase();
return value === 'failed' || value === 'error';
});
if (failed === 0) return 'healthy';
return failed >= items.length ? 'failed' : 'degraded';
}
function observed(snapshot: InstanceModuleSnapshot): { readonly observedAt: string } {
return { observedAt: snapshot.observedAt };
}
export function mapOverview(snapshot: InstanceModuleSnapshot): OverviewSnapshot {
const stats = sectionRecord(snapshot, 'stats');
// Anchored on word boundaries: "download_bytes" contains "load" but is throughput, not a load average.
const cpuKeys = Object.keys(stats).filter((key) =>
/(^|_)(cpu|memory|mem|temp|thermal|load)(_|$)/u.test(key),
);
const cpu: Record<string, OverviewFieldValue> = {};
const rest: Record<string, OverviewFieldValue> = {};
for (const [key, value] of Object.entries(primitives(stats))) {
if (cpuKeys.includes(key)) cpu[key] = value;
else rest[key] = value;
}
// /stats/cpu is a separate probe; its core summary belongs beside the load averages.
Object.assign(cpu, primitives(sectionRecord(snapshot, 'cpu')));
// Message counters read better beside the traffic totals than in their own card.
Object.assign(rest, primitives(sectionRecord(snapshot, 'smsStats')));
return {
...observed(snapshot),
device: primitives(sectionRecord(snapshot, 'device')),
sim: primitives(sectionRecord(snapshot, 'sim')),
network: {
...primitives(sectionRecord(snapshot, 'network')),
...primitives(sectionRecord(snapshot, 'data')),
},
stats: rest,
cpu,
connectivity: primitives(sectionRecord(snapshot, 'connectivity')),
};
}
export function mapCellular(snapshot: InstanceModuleSnapshot): CellularSnapshot {
const network = sectionRecord(snapshot, 'network');
const signal = sectionRecord(snapshot, 'signalStrength');
const cells = isRecord(sectionRecord(snapshot, 'cells').cells)
? (sectionRecord(snapshot, 'cells').cells as Readonly<Record<string, unknown>>)
: sectionRecord(snapshot, 'cells');
const location = sectionRecord(snapshot, 'cellLocation');
const operators = sectionRecord(snapshot, 'operators');
const available = arrayFrom(operators, ['operators', 'available', 'items', 'list']).flatMap(
(item): string[] => {
const name = isRecord(item) ? text(pick(item, ['name', 'long_name', 'alpha'])) : text(item);
return name ? [name] : [];
},
);
const roaming = flag(pick(network, ['roaming', 'is_roaming', 'roaming_active']))
? 'roaming'
: text(pick(sectionRecord(snapshot, 'roaming'), ['state', 'status', 'roaming']));
return {
...observed(snapshot),
networkRegistration: {
state: text(pick(network, ['registration_state', 'register_state', 'state', 'status'])),
mode: text(pick(network, ['radio_mode', 'mode', 'network_mode', 'preferred_mode'])),
operator: text(pick(network, ['operator_name', 'operator', 'carrier', 'name'])),
roaming,
},
signal: {
rssi: text(pick(signal, ['rssi', 'signal_dbm', 'dbm', 'strength'])),
rsrp: text(pick(signal, ['rsrp', 'lte_rsrp', 'nr_rsrp'])),
rsrq: text(pick(signal, ['rsrq', 'lte_rsrq'])),
sinr: text(pick(signal, ['sinr', 'lte_sinr', 'nr_sinr'])),
quality: text(pick(signal, ['signal_percent', 'level', 'quality', 'asu'])),
},
cellsLocation: {
cell: text(pick(cells, ['cell_id', 'cellId', 'eci', 'cid', 'nr_cell_id'])),
area: text(pick(cells, ['tac', 'tracking_area', 'area_code', 'rac'])),
technology: text(
pick(cells, ['network_type', 'technology', 'rat']) ??
pick(network, ['network_type', 'technology', 'rat']),
),
latitude: text(pick(location, ['latitude', 'lat'])),
longitude: text(pick(location, ['longitude', 'lng', 'lon'])),
mcc: text(pick(location, ['mcc']) ?? pick(cells, ['mcc'])),
mnc: text(pick(location, ['mnc']) ?? pick(cells, ['mnc'])),
pci: text(pick(location, ['pci']) ?? pick(cells, ['pci', 'phys_cell_id'])),
arfcn: text(
pick(location, ['earfcn', 'arfcn', 'nr_arfcn']) ??
pick(cells, ['earfcn', 'arfcn', 'nr_arfcn']),
),
},
operators: {
current: text(pick(network, ['operator_name', 'operator', 'carrier'])),
available: available.length ? available.slice(0, 12).join('、') : undefined,
},
};
}
export function mapDeviceNetwork(snapshot: InstanceModuleSnapshot): DeviceNetworkSnapshot {
const wlanStatus = sectionRecord(snapshot, 'wlanStatus');
const wlanProfiles = sectionRecord(snapshot, 'wlanProfiles');
const interfaces = sectionRecord(snapshot, 'interfaces');
const addresses = sectionRecord(snapshot, 'addresses');
const ddnsStatus = sectionRecord(snapshot, 'ddnsStatus');
const ddnsConfig = sectionRecord(snapshot, 'ddnsConfig');
const ddnsLogs = sectionRecord(snapshot, 'ddnsLogs');
const profileItems = arrayFrom(wlanProfiles, ['profiles', 'items', 'list', 'networks']);
const interfaceItems = arrayFrom(interfaces, ['interfaces', 'items', 'list']);
const logItems = arrayFrom(ddnsLogs, ['logs', 'entries', 'items']);
const addressList = (keys: readonly string[]): readonly string[] =>
arrayFrom(addresses, keys)
.map((item) => text(item))
.filter((item): item is string => item !== undefined)
.slice(0, 8);
const ipv4 = addressList(['ipv4', 'ipv4_addresses', 'v4']);
const ipv6 = addressList(['ipv6', 'ipv6_addresses', 'v6']);
return {
...observed(snapshot),
connectionAddresses: { ipv4, ipv6 },
wlan: {
status: {
enabled: flag(pick(wlanStatus, ['enabled', 'wlan_enabled', 'is_enabled'])),
radioState: text(pick(wlanStatus, ['radio_state', 'radioState', 'state'])),
connectionState: text(
pick(wlanStatus, ['connection_state', 'connectionState', 'status', 'state']),
),
activeProfile: text(pick(wlanStatus, ['active_profile', 'activeProfile', 'profile'])),
ssid: text(pick(wlanStatus, ['ssid', 'network_name'])),
},
profiles: profileItems.flatMap((item) =>
isRecord(item)
? [
{
name: text(pick(item, ['name', 'profile_name'])),
ssid: text(pick(item, ['ssid'])),
security: text(pick(item, ['security', 'key_management', 'encryption'])),
enabled: flag(pick(item, ['enabled', 'active'])),
priority: count(pick(item, ['priority', 'order'])),
},
]
: [],
),
},
interfaces: interfaceItems.flatMap((item) =>
isRecord(item)
? [
{
name: text(pick(item, ['name', 'interface', 'ifname'])),
kind: text(pick(item, ['kind', 'type', 'driver'])),
state: text(pick(item, ['state', 'status', 'operstate'])),
macAddress: text(pick(item, ['mac_address', 'macAddress', 'mac'])),
mtu: count(pick(item, ['mtu'])),
addresses: arrayFrom(item, ['addresses', 'ips']).flatMap((address) =>
isRecord(address)
? [
{
family: text(pick(address, ['family', 'scope'])),
address: text(pick(address, ['address', 'ip', 'value'])),
prefixLength: count(pick(address, ['prefix_length', 'prefix', 'cidr'])),
scope: text(pick(address, ['scope'])),
},
]
: typeof address === 'string'
? [{ address, family: undefined, prefixLength: undefined, scope: undefined }]
: [],
),
},
]
: [],
),
ddns: {
status: {
enabled: flag(pick(ddnsStatus, ['enabled', 'configured'])),
state: text(pick(ddnsStatus, ['state', 'status', 'last_result'])),
lastUpdateAt: text(pick(ddnsStatus, ['last_update_at', 'lastUpdateAt', 'updated_at'])),
},
config: {
provider: text(pick(ddnsConfig, ['provider', 'service'])),
hostname: text(pick(ddnsConfig, ['hostname', 'domain'])),
updateIntervalSeconds: count(
pick(ddnsConfig, ['update_interval_seconds', 'interval_seconds', 'interval']),
),
},
logSummary: {
totalEntries: count(pick(ddnsLogs, ['total', 'total_entries', 'count'])) ?? logItems.length,
successfulUpdates:
count(pick(ddnsLogs, ['successful', 'success', 'successful_updates'])) ??
tally(logItems, (item) => {
const value = text(
pick(isRecord(item) ? item : {}, ['status', 'result', 'state']),
)?.toLocaleLowerCase();
return value === 'success' || value === 'ok' || value === 'updated';
}),
failedUpdates:
count(pick(ddnsLogs, ['failed', 'failures', 'failed_updates'])) ??
tally(logItems, (item) => {
const value = text(
pick(isRecord(item) ? item : {}, ['status', 'result', 'state']),
)?.toLocaleLowerCase();
return value === 'failed' || value === 'error';
}),
lastEventAt: text(pick(ddnsLogs, ['last_event_at', 'latest', 'last_at'])),
},
},
};
}
export function mapEsim(snapshot: InstanceModuleSnapshot): EsimSnapshot {
const profiles = sectionRecord(snapshot, 'profiles');
const euicc = sectionRecord(snapshot, 'euicc');
const lpac = sectionRecord(snapshot, 'lpacStatus');
const items = arrayFrom(profiles, ['profiles', 'list', 'items', 'data']).flatMap((item) =>
isRecord(item) ? [item] : [],
);
const enabled = tally(items, (item) => {
const record = isRecord(item) ? item : {};
return (
flag(pick(record, ['enabled', 'is_enabled'])) === true ||
text(pick(record, ['status', 'state']))?.toLocaleLowerCase() === 'enabled'
);
});
const lpacValue = text(
pick(lpac, ['status', 'state', 'available']) ?? pick(euicc, ['lpac_status', 'status']),
)?.toLocaleLowerCase();
const workModeValue = text(pick(euicc, ['work_mode', 'workMode']))?.toLocaleLowerCase();
const labels: EsimSafeLabel[] = [];
for (const item of items.slice(0, 8)) {
const record = isRecord(item) ? item : {};
const enabled = flag(pick(record, ['enabled', 'is_enabled']));
const itemStatus = text(pick(record, ['status', 'state']))?.toLocaleLowerCase();
const label: EsimSafeLabel =
itemStatus === 'error' || itemStatus === 'failed'
? 'Error'
: itemStatus === 'pending' || itemStatus === 'downloading'
? 'Pending'
: enabled === true || itemStatus === 'enabled'
? 'Enabled'
: enabled === false || itemStatus === 'disabled'
? 'Disabled'
: 'Provisioned';
if (!labels.includes(label)) labels.push(label);
}
return {
profileCount: items.length,
enabledProfileCount: enabled,
lpacStatus:
lpacValue === 'available' || lpacValue === 'unavailable' || lpacValue === 'degraded'
? lpacValue
: lpacValue === 'true' || flag(pick(lpac, ['available', 'supported'])) === true
? 'available'
: 'unknown',
workMode:
workModeValue === 'idle' || workModeValue === 'working' || workModeValue === 'disabled'
? workModeValue
: 'unknown',
labels,
};
}
/** Call capabilities the Hub surfaces as separate panels; each maps to one probe. */
const CALL_FEATURE_PROBES: readonly { readonly key: string; readonly name: string }[] = [
{ key: 'forwarding', name: '呼叫转移' },
{ key: 'volume', name: '通话音量' },
{ key: 'voicemail', name: '语音信箱' },
{ key: 'ims', name: 'IMS 语音' },
];
export function mapCalls(snapshot: InstanceModuleSnapshot): CallsSnapshot {
const calls = sectionRecord(snapshot, 'calls');
const history = sectionRecord(snapshot, 'history');
const settings = sectionRecord(snapshot, 'settings');
const active =
count(pick(calls, ['active', 'active_calls', 'current'])) ??
tally(arrayFrom(calls, ['calls', 'items', 'data', 'list']), (item) => {
const value = text(
pick(isRecord(item) ? item : {}, ['state', 'status']),
)?.toLocaleLowerCase();
return value === 'active' || value === 'dialing' || value === 'ringing';
});
const historyItems = arrayFrom(history, ['calls', 'items', 'history', 'list', 'data']);
return {
...observed(snapshot),
calls: {
state: text(pick(calls, ['state', 'status', 'call_status'])),
total: count(pick(calls, ['total', 'count'])) ?? historyItems.length,
active,
ringing: count(pick(calls, ['ringing', 'incoming'])),
held: count(pick(calls, ['held', 'hold'])),
failed: count(pick(calls, ['failed', 'error_count'])),
},
devices: {
state: text(pick(settings, ['state', 'status'])) ?? text(pick(calls, ['state', 'status'])),
total: count(pick(settings, ['total', 'line_count', 'slots'])),
online: count(pick(settings, ['online'])),
offline: count(pick(settings, ['offline'])),
busy: active && active > 0 ? active : count(pick(settings, ['busy'])),
},
features: CALL_FEATURE_PROBES.flatMap(({ key, name }): CallFeatureStatus[] => {
const entry = section(snapshot, key);
// A probe the firmware never answered is unknown; one that answered is a live verdict.
if (!entry || entry.state === 'failed') return [];
if (entry.state === 'unsupported' || entry.state === 'auth-required')
return [{ name, state: 'unavailable' as const }];
const message = isRecord(entry.data) ? text(entry.data['message']) : undefined;
return [{ name, state: 'available' as const, ...(message ? { detail: message } : {}) }];
}),
};
}
export function mapNotifications(snapshot: InstanceModuleSnapshot): NotificationsSnapshot {
const config = sectionRecord(snapshot, 'config');
const queue = sectionRecord(snapshot, 'queue');
const logs = sectionRecord(snapshot, 'logs');
const channels = arrayFrom(config, ['channels', 'items', 'list', 'webhooks']);
const queueItems = arrayFrom(queue, ['items', 'jobs', 'queue', 'notifications', 'data']);
const logItems = arrayFrom(logs, ['items', 'logs', 'entries', 'data']);
const enabledChannels = tally(channels, (item) => {
const record = isRecord(item) ? item : {};
return flag(pick(record, ['enabled', 'active'])) !== false;
});
return {
...observed(snapshot),
channels: {
status:
flag(pick(config, ['enabled'])) === false
? 'unavailable'
: enabledChannels === 0 && channels.length > 0
? 'unavailable'
: enabledChannels < channels.length
? 'degraded'
: statusOf(channels),
total: count(pick(config, ['total', 'count'])) ?? channels.length,
enabled: enabledChannels,
disabled: Math.max(
0,
(count(pick(config, ['total', 'count'])) ?? channels.length) - enabledChannels,
),
},
queue: {
status: statusOf(queueItems),
total: count(pick(queue, ['total', 'count'])) ?? queueItems.length,
pending:
count(pick(queue, ['pending', 'queued'])) ??
tally(queueItems, (item) => {
const value = text(pick(isRecord(item) ? item : {}, ['status', 'state']))
?.toLocaleLowerCase()
.trim();
return value === 'pending' || value === 'queued';
}),
processing:
count(pick(queue, ['processing', 'in_progress'])) ??
tally(queueItems, (item) => {
const value = text(pick(isRecord(item) ? item : {}, ['status', 'state']))
?.toLocaleLowerCase()
.trim();
return value === 'processing' || value === 'sending';
}),
delivered:
count(pick(queue, ['delivered', 'sent', 'succeeded'])) ??
tally(queueItems, (item) => {
const value = text(pick(isRecord(item) ? item : {}, ['status', 'state']))
?.toLocaleLowerCase()
.trim();
return value === 'delivered' || value === 'sent' || value === 'success';
}),
failed:
count(pick(queue, ['failed', 'errors'])) ??
tally(queueItems, (item) => {
const value = text(pick(isRecord(item) ? item : {}, ['status', 'state']))
?.toLocaleLowerCase()
.trim();
return value === 'failed' || value === 'error';
}),
},
logs: {
status: statusOf(logItems),
total: count(pick(logs, ['total', 'count'])) ?? logItems.length,
info: tally(logItems, (item) => {
const value = text(pick(isRecord(item) ? item : {}, ['level']))?.toLocaleLowerCase();
return value === 'info' || value === 'debug';
}),
warning: tally(logItems, (item) => {
const value = text(pick(isRecord(item) ? item : {}, ['level']))?.toLocaleLowerCase();
return value === 'warn' || value === 'warning';
}),
error: tally(logItems, (item) => {
const value = text(pick(isRecord(item) ? item : {}, ['level']))?.toLocaleLowerCase();
return value === 'error' || value === 'fatal';
}),
},
};
}
export function mapAutomation(snapshot: InstanceModuleSnapshot): AutomationSnapshot {
const config = sectionRecord(snapshot, 'config');
const logs = sectionRecord(snapshot, 'logs');
const rules = arrayFrom(config, ['rules', 'tasks', 'items', 'list', 'jobs']);
const logItems = arrayFrom(logs, ['items', 'executions', 'logs', 'entries']);
const enabledRules = tally(
rules,
(item) => flag(pick(isRecord(item) ? item : {}, ['enabled'])) !== false,
);
const failedRuns = tally(logItems, (item) => {
const value = text(
pick(isRecord(item) ? item : {}, ['status', 'result', 'state']),
)?.toLocaleLowerCase();
return value === 'failed' || value === 'error';
});
const scheduler = text(pick(config, ['scheduler', 'scheduler_state']))?.toLocaleLowerCase();
return {
...observed(snapshot),
status: {
state:
flag(pick(config, ['enabled'])) === false
? 'disabled'
: failedRuns > 0
? 'degraded'
: logItems.length || rules.length
? 'healthy'
: 'unknown',
scheduler:
scheduler === 'active' ||
scheduler === 'idle' ||
scheduler === 'paused' ||
scheduler === 'disabled' ||
scheduler === 'unavailable'
? scheduler
: flag(pick(config, ['enabled'])) === false
? 'disabled'
: rules.length
? 'active'
: 'idle',
workers:
flag(pick(config, ['enabled'])) === false
? 'disabled'
: failedRuns > 0
? 'degraded'
: logItems.length
? 'available'
: 'unavailable',
},
tasks: {
total: count(pick(config, ['total', 'count'])) ?? rules.length,
enabled: enabledRules,
disabled: Math.max(
0,
(count(pick(config, ['total', 'count'])) ?? rules.length) - enabledRules,
),
running: count(pick(config, ['running'])),
queued: count(pick(config, ['queued'])),
succeeded: tally(logItems, (item) => {
const value = text(
pick(isRecord(item) ? item : {}, ['status', 'result', 'state']),
)?.toLocaleLowerCase();
return value === 'success' || value === 'ok' || value === 'completed';
}),
failed: failedRuns,
},
};
}
export function mapOta(snapshot: InstanceModuleSnapshot): OtaSnapshot {
const status = sectionRecord(snapshot, 'status');
const state = text(pick(status, ['status', 'state', 'phase']))?.toLocaleLowerCase();
const progress = count(pick(status, ['progress_percent', 'progressPercent', 'progress']));
const available = flag(pick(status, ['update_available', 'updateAvailable', 'available']));
return {
currentVersion: text(pick(status, ['current_version', 'currentVersion', 'version'])),
status:
state === 'idle' ||
state === 'checking' ||
state === 'up-to-date' ||
state === 'available' ||
state === 'downloading' ||
state === 'verifying' ||
state === 'installing' ||
state === 'rebooting' ||
state === 'completed' ||
state === 'failed'
? state
: 'unknown',
progressPercent: progress === undefined ? undefined : Math.max(0, Math.min(100, progress)),
updateAvailable: available ?? state === 'available',
};
}
export interface InstanceModuleDataSources {
readonly overview: { load(instanceId: string, signal: AbortSignal): Promise<OverviewSnapshot> };
readonly cellular: { load(instanceId: string, signal: AbortSignal): Promise<CellularSnapshot> };
readonly deviceNetwork: {
load(instanceId: string, signal: AbortSignal): Promise<DeviceNetworkSnapshot>;
};
readonly esim: { load(instanceId: string, signal: AbortSignal): Promise<unknown> };
readonly calls: { load(instanceId: string, signal: AbortSignal): Promise<CallsSnapshot> };
readonly notifications: {
load(instanceId: string, signal: AbortSignal): Promise<unknown>;
};
readonly automation: { load(instanceId: string, signal: AbortSignal): Promise<unknown> };
readonly ota: { load(instanceId: string, signal: AbortSignal): Promise<unknown> };
}
/**
* One reader, one in-flight cache per module, eight module contracts. The device panel and the
* fleet drill-down share this so a tab switch never re-probes a device that was just read.
*/
export function createInstanceModuleDataSources(
reader: InstanceModuleReader = createInstanceModuleApiReader(),
ttlMs = 15_000,
): InstanceModuleDataSources {
const cache = new Map<string, { at: number; value: InstanceModuleSnapshot }>();
const inflight = new Map<string, Promise<InstanceModuleSnapshot>>();
const read = (module: string) => (instanceId: string, signal: AbortSignal) => {
const key = `${instanceId}:${module}`;
const cached = cache.get(key);
if (cached && Date.now() - cached.at < ttlMs) return Promise.resolve(cached.value);
const pending = inflight.get(key);
if (pending) return pending;
const next = reader.read(instanceId, module, signal).then(
(value) => {
cache.set(key, { at: Date.now(), value });
inflight.delete(key);
return value;
},
(error: unknown) => {
inflight.delete(key);
throw error;
},
);
inflight.set(key, next);
return next;
};
return {
overview: { load: async (id, signal) => mapOverview(await read('overview')(id, signal)) },
cellular: { load: async (id, signal) => mapCellular(await read('cellular')(id, signal)) },
deviceNetwork: {
load: async (id, signal) => mapDeviceNetwork(await read('device-network')(id, signal)),
},
esim: { load: async (id, signal) => mapEsim(await read('esim')(id, signal)) },
calls: { load: async (id, signal) => mapCalls(await read('calls')(id, signal)) },
notifications: {
load: async (id, signal) => mapNotifications(await read('notifications')(id, signal)),
},
automation: {
load: async (id, signal) => mapAutomation(await read('automation')(id, signal)),
},
ota: { load: async (id, signal) => mapOta(await read('ota')(id, signal)) },
};
}
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { InstanceContext } from '../app-shell.js';
import { DeviceActions } from './device-action-controls.js';
export interface SmsMessage {
readonly id: string;
@@ -281,6 +282,11 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
) : null}
</>
)}
<DeviceActions
instance={instance}
module="messages"
onExecuted={() => setRetry((value) => value + 1)}
/>
</section>
{newConversationOpen ? (
+28 -21
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { InstanceContext } from '../app-shell.js';
import { displayValue } from '../ui/locale.js';
import { DeviceActions } from './device-action-controls.js';
/** Values permitted at the Notifications presentation boundary. */
export type NotificationStatus =
@@ -17,39 +18,39 @@ export type NotificationAggregateValue = NotificationStatus | number | null;
/** Aggregate channel counts/status only. All source-specific details are ignored. */
export interface NotificationChannelAggregate {
readonly status?: NotificationStatus | null;
readonly total?: number | null;
readonly enabled?: number | null;
readonly disabled?: number | null;
readonly healthy?: number | null;
readonly degraded?: number | null;
readonly failed?: number | null;
readonly status?: NotificationStatus | null | undefined;
readonly total?: number | null | undefined;
readonly enabled?: number | null | undefined;
readonly disabled?: number | null | undefined;
readonly healthy?: number | null | undefined;
readonly degraded?: number | null | undefined;
readonly failed?: number | null | undefined;
readonly [sourceField: string]: unknown;
}
/** Aggregate queue counts/status only. Individual jobs and their payloads are unsupported. */
export interface NotificationQueueAggregate {
readonly status?: NotificationStatus | null;
readonly total?: number | null;
readonly pending?: number | null;
readonly processing?: number | null;
readonly delivered?: number | null;
readonly failed?: number | null;
readonly status?: NotificationStatus | null | undefined;
readonly total?: number | null | undefined;
readonly pending?: number | null | undefined;
readonly processing?: number | null | undefined;
readonly delivered?: number | null | undefined;
readonly failed?: number | null | undefined;
readonly [sourceField: string]: unknown;
}
/** Aggregate log counts/status only. Raw records are unsupported. */
export interface NotificationLogAggregate {
readonly status?: NotificationStatus | null;
readonly total?: number | null;
readonly info?: number | null;
readonly warning?: number | null;
readonly error?: number | null;
readonly status?: NotificationStatus | null | undefined;
readonly total?: number | null | undefined;
readonly info?: number | null | undefined;
readonly warning?: number | null | undefined;
readonly error?: number | null | undefined;
readonly [sourceField: string]: unknown;
}
export interface NotificationsSnapshot {
readonly observedAt?: string;
readonly observedAt?: string | undefined;
readonly channels: NotificationChannelAggregate;
readonly queue: NotificationQueueAggregate;
readonly logs: NotificationLogAggregate;
@@ -62,9 +63,9 @@ export interface NotificationsDataSource {
export interface NotificationsModuleProps {
readonly instance: InstanceContext;
readonly dataSource?: NotificationsDataSource;
readonly dataSource?: NotificationsDataSource | undefined;
/** Change this owner-provided value to request another read. */
readonly refreshSignal?: unknown;
readonly refreshSignal?: unknown | undefined;
}
type OwnedSnapshot = { readonly ownerId: string; readonly value: NotificationsSnapshot };
@@ -337,6 +338,12 @@ export function NotificationsModule({
</p>
) : null}
<SnapshotView snapshot={snapshot.value} />
<DeviceActions
instance={instance}
module="notifications"
onExecuted={() => setRetry((value) => value + 1)}
/>
</div>
);
}
+17 -10
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import type { InstanceContext } from '../app-shell.js';
import { displayValue } from '../ui/locale.js';
import { DeviceActions } from './device-action-controls.js';
export type OtaStatus =
| 'idle'
@@ -21,10 +22,10 @@ export type OtaStatus =
* the type boundary but are never copied into render state.
*/
export interface OtaSnapshot {
readonly currentVersion?: string | null;
readonly status?: OtaStatus | null;
readonly progressPercent?: number | null;
readonly updateAvailable?: boolean | null;
readonly currentVersion?: string | null | undefined;
readonly status?: OtaStatus | null | undefined;
readonly progressPercent?: number | null | undefined;
readonly updateAvailable?: boolean | null | undefined;
readonly [sourceField: string]: unknown;
}
@@ -35,16 +36,16 @@ export interface OtaDataSource {
export interface OtaModuleProps {
readonly instance: InstanceContext;
readonly dataSource?: OtaDataSource;
readonly dataSource?: OtaDataSource | undefined;
/** Change this owner-provided value to request another read. */
readonly refreshSignal?: unknown;
readonly refreshSignal?: unknown | undefined;
}
type SafeOtaSnapshot = {
readonly currentVersion?: string;
readonly status?: OtaStatus;
readonly progressPercent?: number;
readonly updateAvailable?: boolean;
readonly currentVersion?: string | undefined;
readonly status?: OtaStatus | undefined;
readonly progressPercent?: number | undefined;
readonly updateAvailable?: boolean | undefined;
};
type ReadState =
@@ -255,6 +256,12 @@ export function OtaModule({ instance, dataSource, refreshSignal }: OtaModuleProp
</p>
) : null}
<SnapshotView snapshot={snapshot} />
<DeviceActions
instance={instance}
module="ota"
onExecuted={() => setRetry((value) => value + 1)}
/>
</div>
);
}
+81 -5
View File
@@ -8,7 +8,7 @@ export type OverviewFieldValue = string | number | boolean | null;
export type OverviewSection = Readonly<Record<string, OverviewFieldValue>>;
export interface OverviewSnapshot {
readonly observedAt?: string;
readonly observedAt?: string | undefined;
readonly device: OverviewSection;
readonly sim: OverviewSection;
readonly network: OverviewSection;
@@ -24,11 +24,11 @@ export interface OverviewDataSource {
export interface OverviewSystemPageProps {
readonly instance: InstanceContext;
readonly dataSource?: OverviewDataSource;
readonly dataSource?: OverviewDataSource | undefined;
/** Change this value when an external owner has requested a refresh. */
readonly refreshSignal?: unknown;
readonly operationClient?: OperationClient;
readonly revision?: number;
readonly refreshSignal?: unknown | undefined;
readonly operationClient?: OperationClient | undefined;
readonly revision?: number | undefined;
}
type ReadState =
@@ -70,10 +70,86 @@ const FIELD_LABELS: Readonly<Record<string, string>> = {
Temperature: '温度',
State: '状态',
Latency: '延迟',
// Device payloads arrive with their native snake_case field names.
model: '型号',
manufacturer: '制造商',
brand: '品牌',
imei: 'IMEI',
meid: 'MEID',
iccid: 'ICCID',
imsi: 'IMSI',
phone_number: '号码',
firmware_version: '固件版本',
os_version: '系统版本',
version: '版本',
baseband_version: '基带版本',
uptime: '运行时间',
uptime_seconds: '运行时间(秒)',
slots: '卡槽数',
active_slots: '活跃数',
active: '已激活',
enabled: '已启用',
operator: '运营商',
carrier: '运营商',
network_type: '接入制式',
technology: '技术',
radio_mode: '网络模式',
signal: '信号强度',
signal_dbm: '信号强度(dBm)',
signal_percent: '信号质量',
rsrp: 'RSRP',
rsrq: 'RSRQ',
sinr: 'SINR',
mcc: 'MCC',
mnc: 'MNC',
registration: '注册状态',
registration_state: '注册状态',
roaming: '漫游',
airplane_mode: '飞行模式',
ipv4: 'IPv4 地址',
ipv6: 'IPv6 地址',
ip_address: 'IP 地址',
gateway: '网关',
dns: 'DNS',
download: '下行速率',
upload: '上行速率',
download_bytes: '下行流量',
upload_bytes: '上行流量',
messages_today: '今日消息数',
calls: '通话数',
usage: '使用率',
temperature: '温度',
max_temperature_c: '最高温度',
cpu_percent: 'CPU 使用率',
cpu_load: 'CPU 负载',
memory_percent: '内存使用率',
memory_total_mb: '内存总量',
memory_available_mb: '可用内存',
battery_level: '电量',
battery_percent: '电量',
latency: '延迟',
connected: '连接状态',
interface: '网络接口',
ssid: '无线网络',
apn: 'APN',
};
const ENUM_LABELS: Readonly<Record<string, string>> = {
connected: '已连接',
not_registered: '未注册',
attached: '已附着',
detached: '已分离',
enabled: '已启用',
disabled: '已停用',
true: '是',
false: '否',
lte: 'LTE',
nr: 'NR',
nr5g: '5G NR',
gsm: 'GSM',
wcdma: 'WCDMA',
td_scdma: 'TD-SCDMA',
evdo: 'EVDO',
disconnected: '未连接',
connecting: '正在连接',
registered: '已注册',