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