feat(web): fuse Hub device modules into instance panels with SMS aggregation

- Consolidate cellular, eSIM, network and device module interactions.

- Add aggregate SMS workflows and identity-aware action controls.

- Extend SMSC privacy handling and module action icons.
This commit is contained in:
chick
2026-09-07 00:45:38 +08:00
parent 864e1bd90c
commit 6d251dab7b
17 changed files with 565 additions and 31 deletions
@@ -88,6 +88,36 @@ describe('Phase 6.2 Cellular read module', () => {
expect(screen.queryByText(/仍不可用/i)).toBeNull(); expect(screen.queryByText(/仍不可用/i)).toBeNull();
}); });
it('resolves the reported MCC and MNC to a stable operator name', async () => {
const pending = deferredSource();
render(<CellularModule instance={owner} dataSource={pending.source} />);
pending.resolve({
...snapshot,
networkRegistration: { ...snapshot.networkRegistration, operator: 'CHN-CMCC' },
cellsLocation: { ...snapshot.cellsLocation, mcc: 460, mnc: 0 },
});
const registration = await screen.findByRole('region', { name: '网络注册' });
expect(within(registration).getByText('中国移动')).toBeTruthy();
// The raw string the device sent stays visible because it differs from the resolved name.
expect(within(registration).getByText('上报名称')).toBeTruthy();
expect(within(registration).getByText('CHN-CMCC')).toBeTruthy();
});
it('hides the reported-name row once the registry agrees with the device', async () => {
const pending = deferredSource();
render(<CellularModule instance={owner} dataSource={pending.source} />);
pending.resolve({
...snapshot,
networkRegistration: { ...snapshot.networkRegistration, operator: '中国移动' },
cellsLocation: { ...snapshot.cellsLocation, mcc: '460', mnc: '00' },
});
const registration = await screen.findByRole('region', { name: '网络注册' });
expect(within(registration).getByText('中国移动')).toBeTruthy();
expect(within(registration).queryByText('上报名称')).toBeNull();
});
it('renders the allowlisted cellular device actions served by the control plane', async () => { it('renders the allowlisted cellular device actions served by the control plane', async () => {
const load = vi.fn<CellularDataSource['load']>().mockResolvedValue(snapshot); const load = vi.fn<CellularDataSource['load']>().mockResolvedValue(snapshot);
const fetcher = stubCatalog([ const fetcher = stubCatalog([
+32 -1
View File
@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { operatorLabel } from '@multi-simadmin/contracts';
import type { InstanceContext } from '../app-shell.js'; import type { InstanceContext } from '../app-shell.js';
import { DeviceActions } from './device-action-controls.js'; import { DeviceActions } from './device-action-controls.js';
import { displayValue } from '../ui/locale.js'; import { displayValue } from '../ui/locale.js';
@@ -70,6 +72,7 @@ const SECTIONS = [
['状态', 'state', true], ['状态', 'state', true],
['模式', 'mode', true], ['模式', 'mode', true],
['运营商', 'operator'], ['运营商', 'operator'],
['上报名称', 'operatorReported'],
['漫游', 'roaming', true], ['漫游', 'roaming', true],
], ],
], ],
@@ -152,6 +155,28 @@ function StructuredSection({
); );
} }
/**
* Resolves the reported MCC/MNC into a stable brand name. The raw string the device sent stays
* visible as 上报名称, but only when it actually differs from the resolved name.
*/
function registrationView(
registration: CellularNetworkRegistration,
location: CellularLocation,
): CellularNetworkRegistration & { readonly operatorReported?: string | undefined } {
const reported = typeof registration.operator === 'string' ? registration.operator.trim() : '';
const name = operatorLabel({
mcc: location.mcc,
mnc: location.mnc,
operator: reported,
fallback: reported || undefined,
});
return {
...registration,
operator: name,
operatorReported: reported && reported !== name ? reported : undefined,
};
}
/** Device keys the third-party cell-location APIs (Amap, Baidu, Google) expect. */ /** Device keys the third-party cell-location APIs (Amap, Baidu, Google) expect. */
const LOCATION_KEYS: readonly (readonly [keyof CellularLocation, string])[] = [ const LOCATION_KEYS: readonly (readonly [keyof CellularLocation, string])[] = [
['mcc', 'mcc'], ['mcc', 'mcc'],
@@ -299,6 +324,7 @@ export function CellularModule({ instance, dataSource, refreshSignal }: Cellular
if (!ownedSnapshot) return null; if (!ownedSnapshot) return null;
const snapshot = ownedSnapshot.value; const snapshot = ownedSnapshot.value;
const registration = registrationView(snapshot.networkRegistration, snapshot.cellsLocation);
return ( return (
<div className="cellular-module"> <div className="cellular-module">
@@ -319,7 +345,12 @@ export function CellularModule({ instance, dataSource, refreshSignal }: Cellular
{snapshot.observedAt ? <p>{snapshot.observedAt}</p> : null} {snapshot.observedAt ? <p>{snapshot.observedAt}</p> : null}
<div className="cellular-grid"> <div className="cellular-grid">
{SECTIONS.map(([key, label, fields]) => ( {SECTIONS.map(([key, label, fields]) => (
<StructuredSection key={label} label={label} values={snapshot[key]} fields={fields} /> <StructuredSection
key={label}
label={label}
values={key === 'networkRegistration' ? registration : snapshot[key]}
fields={fields}
/>
))} ))}
<CellLocationExport location={snapshot.cellsLocation} /> <CellLocationExport location={snapshot.cellsLocation} />
<DeviceActions <DeviceActions
+13 -4
View File
@@ -61,7 +61,7 @@ export class DeviceActionClientError extends Error {
} }
export interface DeviceActionSource { export interface DeviceActionSource {
list(signal?: AbortSignal): Promise<readonly DeviceActionDescriptor[]>; list(signal?: AbortSignal): Promise<DeviceActionCatalog>;
execute( execute(
actionId: string, actionId: string,
params: Readonly<Record<string, unknown>>, params: Readonly<Record<string, unknown>>,
@@ -69,6 +69,12 @@ export interface DeviceActionSource {
): Promise<DeviceActionResult>; ): Promise<DeviceActionResult>;
} }
export interface DeviceActionCatalog {
readonly actions: readonly DeviceActionDescriptor[];
/** True while the identity guard holds this node; every control except unbind is refused. */
readonly identityBlocked: boolean;
}
const MAX_ACTIONS = 128; const MAX_ACTIONS = 128;
const RISK_VALUES: readonly DeviceActionRisk[] = ['R1', 'R2', 'R3']; const RISK_VALUES: readonly DeviceActionRisk[] = ['R1', 'R2', 'R3'];
const FIELD_KINDS: readonly DeviceActionFieldKind[] = [ const FIELD_KINDS: readonly DeviceActionFieldKind[] = [
@@ -193,13 +199,16 @@ export function createDeviceActionSource(
}); });
if (!response.ok) throw await readProblem(response); if (!response.ok) throw await readProblem(response);
const root = record(await response.json()); const root = record(await response.json());
if (!Array.isArray(root?.actions)) return []; if (!Array.isArray(root?.actions)) return { actions: [], identityBlocked: false };
return Object.freeze( return Object.freeze({
actions: Object.freeze(
root.actions.slice(0, MAX_ACTIONS).flatMap((item) => { root.actions.slice(0, MAX_ACTIONS).flatMap((item) => {
const parsed = parseDescriptor(item); const parsed = parseDescriptor(item);
return parsed ? [parsed] : []; return parsed ? [parsed] : [];
}), }),
); ),
identityBlocked: root?.identityBlocked === true,
});
}, },
async execute(actionId, params, confirm) { async execute(actionId, params, confirm) {
const response = await fetcher(`${base}/${encodeURIComponent(actionId)}`, { const response = await fetcher(`${base}/${encodeURIComponent(actionId)}`, {
@@ -4,6 +4,7 @@ import type { InstanceContext } from '../app-shell.js';
import { Icon, type IconName } from '../ui/icon.js'; import { Icon, type IconName } from '../ui/icon.js';
import { import {
createDeviceActionSource, createDeviceActionSource,
DeviceActionClientError,
type DeviceActionDescriptor, type DeviceActionDescriptor,
type DeviceActionField, type DeviceActionField,
type DeviceActionSource, type DeviceActionSource,
@@ -24,6 +25,10 @@ const RISK_LABELS: Readonly<Record<DeviceActionDescriptor['risk'], string>> = {
R3: '不可撤销', R3: '不可撤销',
}; };
const IDENTITY_HOLD_NOTE = '设备身份待确认,控制操作已暂停。';
/** The one control the guard never withholds: releasing the binding back to local management. */
const IDENTITY_EXEMPT_ACTION = 'hub.unbind';
const RISK_ICONS: Readonly<Record<DeviceActionDescriptor['risk'], IconName>> = { const RISK_ICONS: Readonly<Record<DeviceActionDescriptor['risk'], IconName>> = {
R1: 'check', R1: 'check',
R2: 'alert', R2: 'alert',
@@ -167,10 +172,13 @@ function ActionRow({
action, action,
source, source,
onExecuted, onExecuted,
blocked,
}: { }: {
action: DeviceActionDescriptor; action: DeviceActionDescriptor;
source: DeviceActionSource; source: DeviceActionSource;
onExecuted: () => void; onExecuted: () => void;
/** The identity guard holds this node; only the binding release stays available. */
blocked: boolean;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [values, setValues] = useState<Record<string, string | boolean>>(() => const [values, setValues] = useState<Record<string, string | boolean>>(() =>
@@ -204,8 +212,16 @@ function ActionRow({
onExecuted(); onExecuted();
} }
} catch (cause) { } catch (cause) {
const code =
cause instanceof DeviceActionClientError
? cause.code
: cause instanceof Error
? cause.message
: '';
setError( setError(
cause instanceof Error && cause.message === 'VALIDATION_FAILED' code === 'IDENTITY_UNCONFIRMED'
? '设备身份待确认,请先在“设备身份”面板核对 IMEI 与硬件信息。'
: code === 'VALIDATION_FAILED'
? '参数不符合要求,请检查后重试。' ? '参数不符合要求,请检查后重试。'
: '操作未能送达设备,请稍后重试。', : '操作未能送达设备,请稍后重试。',
); );
@@ -226,7 +242,8 @@ function ActionRow({
: 'primary-action' : 'primary-action'
} }
aria-expanded={parameterized ? open : undefined} aria-expanded={parameterized ? open : undefined}
disabled={pending} disabled={pending || blocked}
title={blocked ? IDENTITY_HOLD_NOTE : undefined}
onClick={() => { onClick={() => {
if (parameterized && !open) { if (parameterized && !open) {
setOpen(true); setOpen(true);
@@ -244,6 +261,12 @@ function ActionRow({
</span> </span>
</div> </div>
<p className="device-action-note">{action.description}</p> <p className="device-action-note">{action.description}</p>
{blocked ? (
<p className="device-action-note device-action-held">
<Icon name="shield" />
{IDENTITY_HOLD_NOTE}
</p>
) : null}
{open ? ( {open ? (
<div className="device-action-form"> <div className="device-action-form">
{action.fields.map((field) => ( {action.fields.map((field) => (
@@ -314,6 +337,8 @@ export interface DeviceActionControlsProps {
readonly module: string; readonly module: string;
readonly actions: readonly DeviceActionDescriptor[]; readonly actions: readonly DeviceActionDescriptor[];
readonly source: DeviceActionSource; readonly source: DeviceActionSource;
/** True while the identity guard holds the node; every control except unbind is disabled. */
readonly identityBlocked?: boolean;
readonly onExecuted?: (() => void) | undefined; readonly onExecuted?: (() => void) | undefined;
} }
@@ -321,6 +346,7 @@ export function DeviceActionControls({
module, module,
actions, actions,
source, source,
identityBlocked = false,
onExecuted, onExecuted,
}: DeviceActionControlsProps) { }: DeviceActionControlsProps) {
const owned = actions.filter((action) => action.module === module); const owned = actions.filter((action) => action.module === module);
@@ -328,12 +354,19 @@ export function DeviceActionControls({
return ( return (
<section className="cellular-card device-action-card" aria-label="设备操作"> <section className="cellular-card device-action-card" aria-label="设备操作">
<h2></h2> <h2></h2>
{identityBlocked ? (
<p className="device-action-banner" role="alert">
<Icon name="shield" />
</p>
) : null}
<ul className="device-action-list"> <ul className="device-action-list">
{owned.map((action) => ( {owned.map((action) => (
<ActionRow <ActionRow
key={action.id} key={action.id}
action={action} action={action}
source={source} source={source}
blocked={identityBlocked && action.id !== IDENTITY_EXEMPT_ACTION}
onExecuted={onExecuted ?? (() => undefined)} onExecuted={onExecuted ?? (() => undefined)}
/> />
))} ))}
@@ -360,12 +393,14 @@ export function DeviceActions({ instance, module, onExecuted, source }: DeviceAc
[source, instance.id], [source, instance.id],
); );
const [actions, setActions] = useState<readonly DeviceActionDescriptor[]>([]); const [actions, setActions] = useState<readonly DeviceActionDescriptor[]>([]);
const [identityBlocked, setIdentityBlocked] = useState(false);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
useEffect(() => { useEffect(() => {
if (instance.authentication !== 'authenticated') { if (instance.authentication !== 'authenticated') {
setActions([]); setActions([]);
setIdentityBlocked(false);
setLoaded(false); setLoaded(false);
setFailed(false); setFailed(false);
return; return;
@@ -374,13 +409,15 @@ export function DeviceActions({ instance, module, onExecuted, source }: DeviceAc
void resolved.list(controller.signal).then( void resolved.list(controller.signal).then(
(catalog) => { (catalog) => {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
setActions(catalog); setActions(catalog.actions);
setIdentityBlocked(catalog.identityBlocked);
setLoaded(true); setLoaded(true);
setFailed(false); setFailed(false);
}, },
() => { () => {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
setActions([]); setActions([]);
setIdentityBlocked(false);
setLoaded(true); setLoaded(true);
setFailed(true); setFailed(true);
}, },
@@ -413,6 +450,7 @@ export function DeviceActions({ instance, module, onExecuted, source }: DeviceAc
module={module} module={module}
actions={owned} actions={owned}
source={resolved} source={resolved}
identityBlocked={identityBlocked}
{...(onExecuted ? { onExecuted } : {})} {...(onExecuted ? { onExecuted } : {})}
/> />
); );
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import type { InstanceContext } from '../app-shell.js'; import type { InstanceContext } from '../app-shell.js';
import { SensitiveRevealProvider } from '../privacy/sensitive-reveal.js'; import { SensitiveRevealProvider } from '../privacy/sensitive-reveal.js';
import { ConfigurationModule, SimModule } from './device-module-panels.js'; import { ConfigurationModule, SimModule, VowifiModule } from './device-module-panels.js';
import type { InstanceModuleSnapshot } from './instance-module-api-data-source.js'; import type { InstanceModuleSnapshot } from './instance-module-api-data-source.js';
afterEach(() => { afterEach(() => {
@@ -205,4 +205,59 @@ describe('DeviceModulePanel', () => {
expect(alert.textContent).toContain('无法加载'); expect(alert.textContent).toContain('无法加载');
expect(screen.getByRole('button', { name: '重试' })).toBeTruthy(); expect(screen.getByRole('button', { name: '重试' })).toBeTruthy();
}); });
it('aggregates WiFi Calling SMS deliveries into China-day counters', async () => {
// Anchor the fixture to the current China day rather than the test runner's local timezone.
const chinaDayStartUtc = new Date(Date.now() + 8 * 3_600_000);
chinaDayStartUtc.setUTCHours(0, 0, 0, 0);
const chinaDayBase = chinaDayStartUtc.getTime() - 8 * 3_600_000;
const reader = {
read: vi.fn(async () =>
snapshot({
module: 'vowifi',
sections: [
{
key: 'smsDeliveries',
path: '/vowifi/sms/delivery?limit=20',
state: 'ok',
status: 200,
data: {
deliveries: [
{
created_at: new Date(chinaDayBase + 8 * 3_600_000).toISOString(),
direction: 'incoming',
sender: '10010',
},
{
created_at: new Date(chinaDayBase + 9 * 3_600_000).toISOString(),
direction: 'outgoing',
state: 'delivered',
},
{
created_at: new Date(chinaDayBase + 10 * 3_600_000).toISOString(),
direction: 'outgoing',
state: 'failed',
},
{
created_at: '2020-01-01T15:59:59.000Z',
direction: 'outgoing',
state: 'delivered',
},
],
},
},
],
}),
),
};
renderWithPrivacy(<VowifiModule instance={instance} reader={reader} />);
const summary = await screen.findByRole('region', { name: '今日短信统计' });
expect(summary.textContent).toContain('今日接收');
expect(summary.textContent).toContain('2 条');
expect(summary.textContent).toContain('今日发送');
expect(summary.textContent).toContain('2 条');
expect(summary.textContent).toContain('1 / 2');
expect(summary.textContent).not.toContain('10010');
});
}); });
+61 -1
View File
@@ -29,6 +29,8 @@ export interface DeviceModuleSectionSpec {
readonly only?: readonly string[]; readonly only?: readonly string[];
/** Device keys this section never renders; they belong to a sibling section instead. */ /** Device keys this section never renders; they belong to a sibling section instead. */
readonly exclude?: readonly string[]; readonly exclude?: readonly string[];
/** A fixed aggregate derived from the same section payload, such as Hub's SMS daily counts. */
readonly summary?: 'vowifi-sms';
/** Skip the whole card when the device reported none of this section's fields. */ /** Skip the whole card when the device reported none of this section's fields. */
readonly optional?: boolean; readonly optional?: boolean;
} }
@@ -109,6 +111,10 @@ const COMMON_LABELS: Readonly<Record<string, string>> = {
supported: '受支持', supported: '受支持',
reason: '原因', reason: '原因',
detail: '详情', detail: '详情',
sms_path: '短信路径',
received_today: '今日接收',
outgoing_today: '今日发送',
delivered_today: '今日发送成功',
}; };
function fieldLabel(key: string, fields?: Readonly<Record<string, string>>): string { function fieldLabel(key: string, fields?: Readonly<Record<string, string>>): string {
@@ -194,6 +200,57 @@ function rows(
return source.slice(0, limit).flatMap((item) => (isRecord(item) ? [item] : [])); return source.slice(0, limit).flatMap((item) => (isRecord(item) ? [item] : []));
} }
/** Formats a UTC timestamp as a local China date for Hub-style daily counters. */
function chinaDay(timestamp: number): string {
const shifted = new Date(timestamp + (new Date(timestamp).getTimezoneOffset() + 480) * 60_000);
return `${shifted.getFullYear()}-${String(shifted.getMonth() + 1).padStart(2, '0')}-${String(
shifted.getDate(),
).padStart(2, '0')}`;
}
/** Adds one computed Hub-style summary row without passing raw delivery rows into the display. */
function smsDeliverySummary(
section: InstanceModuleSection | undefined,
): readonly Readonly<Record<string, unknown>>[] {
if (!section || section.state !== 'ok' || !isRecord(section.data)) return [];
const source = section.data['deliveries'];
if (!Array.isArray(source)) return [];
const now = new Date();
const day = chinaDay(now.getTime());
const today = source.filter((item) => {
if (!isRecord(item) || typeof item['created_at'] !== 'string') return false;
const timestamp = Date.parse(item['created_at']);
if (Number.isNaN(timestamp)) return false;
return chinaDay(timestamp) === day;
});
const incoming = today.filter((item) => {
if (!isRecord(item)) return false;
return ['incoming', 'mobile_terminated', 'mt'].includes(
typeof item['direction'] === 'string' ? item['direction'].toLowerCase() : '',
);
});
const outgoing = today.filter((item) => {
if (!isRecord(item)) return false;
return ['outgoing', 'mobile_originated', 'mo'].includes(
typeof item['direction'] === 'string' ? item['direction'].toLowerCase() : '',
);
});
const delivered = outgoing.filter((item) => {
if (!isRecord(item)) return false;
return ['delivered', 'success', 'sent', 'accepted'].includes(
typeof item['state'] === 'string' ? item['state'].toLowerCase() : '',
);
});
return [
{
sms_path: 'WiFi Calling 短信',
received_today: `${incoming.length}`,
outgoing_today: `${outgoing.length}`,
delivered_today: `${delivered.length} / ${outgoing.length}`,
},
];
}
function columns(items: readonly Readonly<Record<string, unknown>>[]): readonly string[] { function columns(items: readonly Readonly<Record<string, unknown>>[]): readonly string[] {
const seen: string[] = []; const seen: string[] = [];
for (const item of items) { for (const item of items) {
@@ -240,7 +297,10 @@ function sectionBody(
</p> </p>
); );
} }
const list = rows(section.data, spec.limit ?? 20, spec.listKey); const list =
spec.summary === 'vowifi-sms'
? smsDeliverySummary(section)
: rows(section.data, spec.limit ?? 20, spec.listKey);
if (list.length) { if (list.length) {
const headers = tableColumns(list, spec); const headers = tableColumns(list, spec);
if (!headers.length) return null; if (!headers.length) return null;
@@ -303,6 +303,17 @@ const VOWIFI_SECTIONS: readonly DeviceModuleSectionSpec[] = [
error: '错误', error: '错误',
}, },
}, },
{
key: 'smsDeliveries',
label: '今日短信统计',
summary: 'vowifi-sms',
fields: {
sms_path: '短信路径',
received_today: '今日接收',
outgoing_today: '今日发送',
delivered_today: '今日发送成功',
},
},
{ {
key: 'soakRuns', key: 'soakRuns',
label: 'WiFi Calling 稳定性巡检', label: 'WiFi Calling 稳定性巡检',
@@ -10,7 +10,10 @@ import {
type DeviceNetworkSnapshot, type DeviceNetworkSnapshot,
} from './device-network-module.js'; } from './device-network-module.js';
afterEach(cleanup); afterEach(() => {
vi.unstubAllGlobals();
cleanup();
});
const owner: InstanceContext = { const owner: InstanceContext = {
id: 'alpha', id: 'alpha',
@@ -133,6 +136,7 @@ describe('Phase 6.3 Device Network read module', () => {
}); });
it('does not render passwords, credentials, arbitrary fields, or log bodies', async () => { it('does not render passwords, credentials, arbitrary fields, or log bodies', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('unavailable')));
render(<DeviceNetworkModule instance={owner} dataSource={{ load: async () => snapshot }} />); render(<DeviceNetworkModule instance={owner} dataSource={{ load: async () => snapshot }} />);
expect((await screen.findAllByText('Operations Wi-Fi')).length).toBe(2); 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('never-render-this');
@@ -19,6 +19,10 @@ const owner: InstanceContext = {
const snapshot: EsimSnapshot = { const snapshot: EsimSnapshot = {
profileCount: 4, profileCount: 4,
enabledProfileCount: 2, enabledProfileCount: 2,
profilesWithDeletionPolicy: 3,
deletableProfiles: 1,
profilesWithDisablePolicy: 2,
disableableProfiles: 2,
lpacStatus: 'available', lpacStatus: 'available',
workMode: 'idle', workMode: 'idle',
labels: ['Provisioned', 'Enabled'], labels: ['Provisioned', 'Enabled'],
@@ -41,6 +45,8 @@ describe('isolated safe eSIM read module', () => {
expect(within(section).getByText('2')).toBeTruthy(); expect(within(section).getByText('2')).toBeTruthy();
expect(within(section).getByText('可用')).toBeTruthy(); expect(within(section).getByText('可用')).toBeTruthy();
expect(within(section).getByText('空闲')).toBeTruthy(); expect(within(section).getByText('空闲')).toBeTruthy();
expect(within(section).getByText('1 / 3')).toBeTruthy();
expect(within(section).getByText('2 / 2')).toBeTruthy();
expect(within(section).getByText('已配置')).toBeTruthy(); expect(within(section).getByText('已配置')).toBeTruthy();
expect(screen.queryByRole('button')).toBeNull(); expect(screen.queryByRole('button')).toBeNull();
}); });
@@ -89,6 +95,10 @@ describe('isolated safe eSIM read module', () => {
imei: 'IMEI-secret', imei: 'IMEI-secret',
isdpAid: 'ISDP-AID-secret', isdpAid: 'ISDP-AID-secret',
providerData: { secret: 'raw-provider-secret' }, providerData: { secret: 'raw-provider-secret' },
profilesWithDeletionPolicy: 4,
deletableProfiles: 2,
profilesWithDisablePolicy: 3,
disableableProfiles: 1,
} as unknown as EsimSnapshot; } as unknown as EsimSnapshot;
render(<EsimModule instance={owner} dataSource={{ load: async () => hostile }} />); render(<EsimModule instance={owner} dataSource={{ load: async () => hostile }} />);
expect(await screen.findByText('已启用')).toBeTruthy(); expect(await screen.findByText('已启用')).toBeTruthy();
+33
View File
@@ -16,6 +16,10 @@ export type EsimSafeLabel = 'Provisioned' | 'Enabled' | 'Disabled' | 'Pending' |
export interface EsimSnapshot { export interface EsimSnapshot {
readonly profileCount?: number | null | undefined; readonly profileCount?: number | null | undefined;
readonly enabledProfileCount?: number | null | undefined; readonly enabledProfileCount?: number | null | undefined;
readonly profilesWithDeletionPolicy?: number | null | undefined;
readonly deletableProfiles?: number | null | undefined;
readonly profilesWithDisablePolicy?: number | null | undefined;
readonly disableableProfiles?: number | null | undefined;
readonly lpacStatus?: EsimLpacStatus | null | undefined; readonly lpacStatus?: EsimLpacStatus | null | undefined;
readonly workMode?: EsimWorkMode | null | undefined; readonly workMode?: EsimWorkMode | null | undefined;
readonly labels?: readonly EsimSafeLabel[] | undefined; readonly labels?: readonly EsimSafeLabel[] | undefined;
@@ -35,6 +39,10 @@ export interface EsimModuleProps {
type SafeSnapshot = { type SafeSnapshot = {
profileCount?: number; profileCount?: number;
enabledProfileCount?: number; enabledProfileCount?: number;
profilesWithDeletionPolicy?: number;
deletableProfiles?: number;
profilesWithDisablePolicy?: number;
disableableProfiles?: number;
lpacStatus?: EsimLpacStatus; lpacStatus?: EsimLpacStatus;
workMode?: EsimWorkMode; workMode?: EsimWorkMode;
labels: EsimSafeLabel[]; labels: EsimSafeLabel[];
@@ -71,6 +79,10 @@ function sanitizeSnapshot(value: unknown): SafeSnapshot {
const candidate = isRecord(value) ? value : {}; const candidate = isRecord(value) ? value : {};
const profileCount = safeCount(candidate.profileCount); const profileCount = safeCount(candidate.profileCount);
const enabledProfileCount = safeCount(candidate.enabledProfileCount); const enabledProfileCount = safeCount(candidate.enabledProfileCount);
const profilesWithDeletionPolicy = safeCount(candidate.profilesWithDeletionPolicy);
const deletableProfiles = safeCount(candidate.deletableProfiles);
const profilesWithDisablePolicy = safeCount(candidate.profilesWithDisablePolicy);
const disableableProfiles = safeCount(candidate.disableableProfiles);
const lpacStatus = LPAC_STATUSES.has(candidate.lpacStatus as EsimLpacStatus) const lpacStatus = LPAC_STATUSES.has(candidate.lpacStatus as EsimLpacStatus)
? (candidate.lpacStatus as EsimLpacStatus) ? (candidate.lpacStatus as EsimLpacStatus)
: undefined; : undefined;
@@ -88,6 +100,10 @@ function sanitizeSnapshot(value: unknown): SafeSnapshot {
return { return {
...(profileCount === undefined ? {} : { profileCount }), ...(profileCount === undefined ? {} : { profileCount }),
...(enabledProfileCount === undefined ? {} : { enabledProfileCount }), ...(enabledProfileCount === undefined ? {} : { enabledProfileCount }),
...(profilesWithDeletionPolicy === undefined ? {} : { profilesWithDeletionPolicy }),
...(deletableProfiles === undefined ? {} : { deletableProfiles }),
...(profilesWithDisablePolicy === undefined ? {} : { profilesWithDisablePolicy }),
...(disableableProfiles === undefined ? {} : { disableableProfiles }),
...(lpacStatus === undefined ? {} : { lpacStatus }), ...(lpacStatus === undefined ? {} : { lpacStatus }),
...(workMode === undefined ? {} : { workMode }), ...(workMode === undefined ? {} : { workMode }),
labels, labels,
@@ -119,6 +135,23 @@ function SnapshotView({ snapshot }: { snapshot: SafeSnapshot }) {
<dt></dt> <dt></dt>
<dd>{display(snapshot.enabledProfileCount)}</dd> <dd>{display(snapshot.enabledProfileCount)}</dd>
</div> </div>
{snapshot.profilesWithDeletionPolicy !== undefined ? (
<div>
<dt></dt>
<dd>
{display(snapshot.deletableProfiles)} / {display(snapshot.profilesWithDeletionPolicy)}
</dd>
</div>
) : null}
{snapshot.profilesWithDisablePolicy !== undefined ? (
<div>
<dt></dt>
<dd>
{display(snapshot.disableableProfiles)} /{' '}
{display(snapshot.profilesWithDisablePolicy)}
</dd>
</div>
) : null}
<div> <div>
<dt>LPAC </dt> <dt>LPAC </dt>
<dd>{display(snapshot.lpacStatus)}</dd> <dd>{display(snapshot.lpacStatus)}</dd>
@@ -49,6 +49,41 @@ describe('instance module mappers', () => {
expect(result.observedAt).toBe('2026-09-04T06:00:00.000Z'); expect(result.observedAt).toBe('2026-09-04T06:00:00.000Z');
}); });
it('maps the current cellular interface to the Hub-style network speed card', () => {
const result = mapOverview(
snapshot('overview', [
[
'stats',
{
network_speed: {
interfaces: [
{ interface: 'wlan0', rx_bytes_per_sec: 2048, tx_bytes_per_sec: 512 },
{
interface: 'wwan0',
rx_bytes_per_sec: 1536,
tx_bytes_per_sec: 256,
total_rx_bytes: 10240,
total_tx_bytes: 2048,
},
],
},
},
],
]),
);
expect(result.networkSpeed?.selectedInterface).toBe('wwan0');
expect(result.networkSpeed?.interfaces).toEqual([
{ name: 'wlan0', rxBytesPerSecond: 2048, txBytesPerSecond: 512 },
{
name: 'wwan0',
rxBytesPerSecond: 1536,
txBytesPerSecond: 256,
totalRxBytes: 10240,
totalTxBytes: 2048,
},
]);
});
it('reads cellular fields across the naming variants devices use', () => { it('reads cellular fields across the naming variants devices use', () => {
const result = mapCellular( const result = mapCellular(
snapshot('cellular', [ snapshot('cellular', [
@@ -126,8 +161,13 @@ describe('instance module mappers', () => {
'profiles', 'profiles',
{ {
profiles: [ profiles: [
{ iccid: '8986000000000000001', enabled: true }, { iccid: '8986000000000000001', enabled: true, delete_allowed: true },
{ iccid: '8986000000000000002', status: 'disabled' }, {
iccid: '8986000000000000002',
status: 'disabled',
delete_allowed: false,
disable_allowed: true,
},
{ iccid: '8986000000000000003', status: 'pending' }, { iccid: '8986000000000000003', status: 'pending' },
], ],
}, },
@@ -142,6 +182,10 @@ describe('instance module mappers', () => {
lpacStatus: 'available', lpacStatus: 'available',
workMode: 'idle', workMode: 'idle',
labels: ['Enabled', 'Disabled', 'Pending'], labels: ['Enabled', 'Disabled', 'Pending'],
profilesWithDeletionPolicy: 2,
deletableProfiles: 1,
profilesWithDisablePolicy: 1,
disableableProfiles: 1,
}); });
}); });
@@ -5,7 +5,11 @@ import type { DeviceNetworkSnapshot } from './device-network-module.js';
import type { EsimSafeLabel, EsimSnapshot } from './esim-module.js'; import type { EsimSafeLabel, EsimSnapshot } from './esim-module.js';
import type { NotificationsSnapshot } from './notifications-module.js'; import type { NotificationsSnapshot } from './notifications-module.js';
import type { OtaSnapshot } from './ota-module.js'; import type { OtaSnapshot } from './ota-module.js';
import type { OverviewFieldValue, OverviewSnapshot } from './overview-system.js'; import type {
NetworkSpeedInterface,
OverviewFieldValue,
OverviewSnapshot,
} from './overview-system.js';
/** /**
* Bridge between the control-plane instance module proxy and the device panel modules. * Bridge between the control-plane instance module proxy and the device panel modules.
@@ -196,6 +200,10 @@ function tally(items: readonly unknown[], predicate: (item: unknown) => boolean)
return items.reduce<number>((total, item) => (predicate(item) ? total + 1 : total), 0); return items.reduce<number>((total, item) => (predicate(item) ? total + 1 : total), 0);
} }
function nonNegativeNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
}
function statusOf(items: readonly unknown[]): 'healthy' | 'degraded' | 'failed' | 'idle' { function statusOf(items: readonly unknown[]): 'healthy' | 'degraded' | 'failed' | 'idle' {
if (items.length === 0) return 'idle'; if (items.length === 0) return 'idle';
const failed = tally(items, (item) => { const failed = tally(items, (item) => {
@@ -225,6 +233,53 @@ export function mapOverview(snapshot: InstanceModuleSnapshot): OverviewSnapshot
} }
// /stats/cpu is a separate probe; its core summary belongs beside the load averages. // /stats/cpu is a separate probe; its core summary belongs beside the load averages.
Object.assign(cpu, primitives(sectionRecord(snapshot, 'cpu'))); Object.assign(cpu, primitives(sectionRecord(snapshot, 'cpu')));
const speedSource = isRecord(stats.network_speed)
? (stats.network_speed as Readonly<Record<string, unknown>>)
: sectionRecord(snapshot, 'networkSpeed');
const networkSpeed: NetworkSpeedInterface[] = arrayFrom(speedSource, ['interfaces']).flatMap(
(item) => {
if (!isRecord(item)) return [];
const name = text(pick(item, ['interface', 'name']));
const rxBytesPerSecond = nonNegativeNumber(
pick(item, ['rx_bytes_per_sec', 'rxBytesPerSec', 'rx_rate']),
);
const txBytesPerSecond = nonNegativeNumber(
pick(item, ['tx_bytes_per_sec', 'txBytesPerSec', 'tx_rate']),
);
const totalRxBytes = nonNegativeNumber(
pick(item, ['total_rx_bytes', 'totalRxBytes', 'rx_bytes']),
);
const totalTxBytes = nonNegativeNumber(
pick(item, ['total_tx_bytes', 'totalTxBytes', 'tx_bytes']),
);
if (
name === undefined &&
rxBytesPerSecond === undefined &&
txBytesPerSecond === undefined &&
totalRxBytes === undefined &&
totalTxBytes === undefined
)
return [];
return [
{
...(name === undefined ? {} : { name }),
...(rxBytesPerSecond === undefined ? {} : { rxBytesPerSecond }),
...(txBytesPerSecond === undefined ? {} : { txBytesPerSecond }),
...(totalRxBytes === undefined ? {} : { totalRxBytes }),
...(totalTxBytes === undefined ? {} : { totalTxBytes }),
},
];
},
);
const selectedInterface =
networkSpeed.find(
(item) =>
item.name?.startsWith('wwan') ||
item.name?.startsWith('wwp') ||
item.name?.toLocaleLowerCase().includes('mbim'),
)?.name ??
networkSpeed.find((item) => item.name === 'wlan0')?.name ??
networkSpeed[0]?.name;
// Message counters read better beside the traffic totals than in their own card. // Message counters read better beside the traffic totals than in their own card.
Object.assign(rest, primitives(sectionRecord(snapshot, 'smsStats'))); Object.assign(rest, primitives(sectionRecord(snapshot, 'smsStats')));
return { return {
@@ -238,6 +293,14 @@ export function mapOverview(snapshot: InstanceModuleSnapshot): OverviewSnapshot
stats: rest, stats: rest,
cpu, cpu,
connectivity: primitives(sectionRecord(snapshot, 'connectivity')), connectivity: primitives(sectionRecord(snapshot, 'connectivity')),
...(networkSpeed.length
? {
networkSpeed: {
interfaces: networkSpeed,
...(selectedInterface ? { selectedInterface } : {}),
},
}
: {}),
}; };
} }
@@ -410,9 +473,12 @@ export function mapEsim(snapshot: InstanceModuleSnapshot): EsimSnapshot {
const profiles = sectionRecord(snapshot, 'profiles'); const profiles = sectionRecord(snapshot, 'profiles');
const euicc = sectionRecord(snapshot, 'euicc'); const euicc = sectionRecord(snapshot, 'euicc');
const lpac = sectionRecord(snapshot, 'lpacStatus'); const lpac = sectionRecord(snapshot, 'lpacStatus');
const items = arrayFrom(profiles, ['profiles', 'list', 'items', 'data']).flatMap((item) => const items: readonly Readonly<Record<string, unknown>>[] = arrayFrom(profiles, [
isRecord(item) ? [item] : [], 'profiles',
); 'list',
'items',
'data',
]).flatMap((item) => (isRecord(item) ? [item] : []));
const enabled = tally(items, (item) => { const enabled = tally(items, (item) => {
const record = isRecord(item) ? item : {}; const record = isRecord(item) ? item : {};
return ( return (
@@ -420,6 +486,24 @@ export function mapEsim(snapshot: InstanceModuleSnapshot): EsimSnapshot {
text(pick(record, ['status', 'state']))?.toLocaleLowerCase() === 'enabled' text(pick(record, ['status', 'state']))?.toLocaleLowerCase() === 'enabled'
); );
}); });
const deletable = tally(
items,
(item) => flag(pick(item as Readonly<Record<string, unknown>>, ['delete_allowed'])) === true,
);
const disableable = tally(
items,
(item) => flag(pick(item as Readonly<Record<string, unknown>>, ['disable_allowed'])) === true,
);
const withDeletionPolicy = tally(
items,
(item): boolean =>
pick(item as Readonly<Record<string, unknown>>, ['delete_allowed']) !== undefined,
);
const withDisablePolicy = tally(
items,
(item): boolean =>
pick(item as Readonly<Record<string, unknown>>, ['disable_allowed']) !== undefined,
);
const lpacValue = text( const lpacValue = text(
pick(lpac, ['status', 'state', 'available']) ?? pick(euicc, ['lpac_status', 'status']), pick(lpac, ['status', 'state', 'available']) ?? pick(euicc, ['lpac_status', 'status']),
)?.toLocaleLowerCase(); )?.toLocaleLowerCase();
@@ -444,6 +528,12 @@ export function mapEsim(snapshot: InstanceModuleSnapshot): EsimSnapshot {
return { return {
profileCount: items.length, profileCount: items.length,
enabledProfileCount: enabled, enabledProfileCount: enabled,
...(withDeletionPolicy
? { profilesWithDeletionPolicy: withDeletionPolicy, deletableProfiles: deletable }
: {}),
...(withDisablePolicy
? { profilesWithDisablePolicy: withDisablePolicy, disableableProfiles: disableable }
: {}),
lpacStatus: lpacStatus:
lpacValue === 'available' || lpacValue === 'unavailable' || lpacValue === 'degraded' lpacValue === 'available' || lpacValue === 'unavailable' || lpacValue === 'degraded'
? lpacValue ? lpacValue
@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { AppShell, type InstanceContext } from '../app-shell.js'; import { AppShell, type InstanceContext } from '../app-shell.js';
import { SensitiveRevealProvider } from '../privacy/sensitive-reveal.js';
import { import {
OverviewSystemPage, OverviewSystemPage,
type OverviewDataSource, type OverviewDataSource,
@@ -117,11 +118,14 @@ describe('Phase 6.1 Overview / System read slice', () => {
it('renders the route-owned Fleet resource summary without inventing another endpoint', () => { it('renders the route-owned Fleet resource summary without inventing another endpoint', () => {
render( render(
<SensitiveRevealProvider>
<AppShell <AppShell
pathname="/instances/alpha/overview" pathname="/instances/alpha/overview"
instance={owner} instance={owner}
capabilities={overviewCapabilities} capabilities={overviewCapabilities}
/>, />
,
</SensitiveRevealProvider>,
); );
const operations = screen.getByRole('region', { name: '运行状态与资源' }); const operations = screen.getByRole('region', { name: '运行状态与资源' });
expect(within(operations).getByText('在线')).toBeTruthy(); expect(within(operations).getByText('在线')).toBeTruthy();
@@ -132,12 +136,30 @@ describe('Phase 6.1 Overview / System read slice', () => {
within(operations).getByRole('meter', { name: '内存使用率' }).getAttribute('value'), within(operations).getByRole('meter', { name: '内存使用率' }).getAttribute('value'),
).toBe('63.2'); ).toBe('63.2');
expect(within(operations).getByText('46.7 °C')).toBeTruthy(); expect(within(operations).getByText('46.7 °C')).toBeTruthy();
expect(within(operations).getByText('13800138000')).toBeTruthy(); expect(within(operations).getByText('138 •••• 8000')).toBeTruthy();
expect(within(operations).queryByText('13800138000')).toBeNull();
expect(screen.queryByText(/没有可用的安全概览只读数据源/i)).toBeNull(); expect(screen.queryByText(/没有可用的安全概览只读数据源/i)).toBeNull();
expect(screen.getByRole('button', { name: '重启服务' })).toBeTruthy(); expect(screen.getByRole('button', { name: '重启服务' })).toBeTruthy();
expect(screen.getByRole('button', { name: '系统重启' })).toBeTruthy(); expect(screen.getByRole('button', { name: '系统重启' })).toBeTruthy();
}); });
it('reveals the overview phone number through the console-wide switch', async () => {
const user = userEvent.setup();
render(
<AppShell
pathname="/instances/alpha/overview"
instance={owner}
capabilities={overviewCapabilities}
/>,
);
const operations = screen.getByRole('region', { name: '运行状态与资源' });
expect(within(operations).getByText('138 •••• 8000')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '显示敏感标识' }));
expect(within(operations).getByText('13800138000')).toBeTruthy();
expect(within(operations).queryByText('138 •••• 8000')).toBeNull();
});
it('shows authentication-required without loading or leaking prior owner data', async () => { it('shows authentication-required without loading or leaking prior owner data', async () => {
const load = vi.fn<OverviewDataSource['load']>().mockResolvedValue(snapshot); const load = vi.fn<OverviewDataSource['load']>().mockResolvedValue(snapshot);
const { rerender } = render(<OverviewSystemPage instance={owner} dataSource={{ load }} />); const { rerender } = render(<OverviewSystemPage instance={owner} dataSource={{ load }} />);
+79 -1
View File
@@ -1,12 +1,27 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import type { InstanceContext } from '../app-shell.js'; import type { InstanceContext } from '../app-shell.js';
import { formatPhoneNumbers } from '../fleet/phone-privacy.js';
import { useSensitiveReveal } from '../privacy/sensitive-reveal.js';
import { createOperationClient, type OperationClient } from '../operations/operation-client.js'; import { createOperationClient, type OperationClient } from '../operations/operation-client.js';
import { safeUiError } from '../ui/locale.js'; import { safeUiError } from '../ui/locale.js';
export type OverviewFieldValue = string | number | boolean | null; export type OverviewFieldValue = string | number | boolean | null;
export type OverviewSection = Readonly<Record<string, OverviewFieldValue>>; export type OverviewSection = Readonly<Record<string, OverviewFieldValue>>;
export interface NetworkSpeedInterface {
readonly name?: string | undefined;
readonly rxBytesPerSecond?: number | undefined;
readonly txBytesPerSecond?: number | undefined;
readonly totalRxBytes?: number | undefined;
readonly totalTxBytes?: number | undefined;
}
export interface NetworkSpeedSnapshot {
readonly interfaces: readonly NetworkSpeedInterface[];
readonly selectedInterface?: string | undefined;
}
export interface OverviewSnapshot { export interface OverviewSnapshot {
readonly observedAt?: string | undefined; readonly observedAt?: string | undefined;
readonly device: OverviewSection; readonly device: OverviewSection;
@@ -15,6 +30,7 @@ export interface OverviewSnapshot {
readonly stats: OverviewSection; readonly stats: OverviewSection;
readonly cpu: OverviewSection; readonly cpu: OverviewSection;
readonly connectivity: OverviewSection; readonly connectivity: OverviewSection;
readonly networkSpeed?: NetworkSpeedSnapshot | undefined;
} }
export interface OverviewDataSource { export interface OverviewDataSource {
@@ -132,6 +148,7 @@ const FIELD_LABELS: Readonly<Record<string, string>> = {
interface: '网络接口', interface: '网络接口',
ssid: '无线网络', ssid: '无线网络',
apn: 'APN', apn: 'APN',
powered: '基带电源',
}; };
const ENUM_LABELS: Readonly<Record<string, string>> = { const ENUM_LABELS: Readonly<Record<string, string>> = {
@@ -172,6 +189,61 @@ function displayFieldValue(value: OverviewFieldValue): string {
return String(value); return String(value);
} }
function displayRate(value: number): string {
if (!Number.isFinite(value) || value < 0) return '不可用';
if (value === 0) return '0 B/s';
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s'];
const exponent = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
const normalized = value / 1024 ** exponent;
return `${normalized >= 10 || exponent === 0 ? Math.round(normalized) : normalized.toFixed(1)} ${units[exponent]}`;
}
function NetworkSpeedCard({ speed }: { speed: NetworkSpeedSnapshot }) {
const selected =
speed.interfaces.find((item) => item.name === speed.selectedInterface) ?? speed.interfaces[0];
if (!selected) return null;
return (
<section className="overview-card" aria-label="实时网速">
<h2></h2>
<p>{selected.name ?? '网络接口'}</p>
<dl>
<div>
<dt></dt>
<dd>
{selected.rxBytesPerSecond === undefined
? '不可用'
: displayRate(selected.rxBytesPerSecond)}
</dd>
</div>
<div>
<dt></dt>
<dd>
{selected.txBytesPerSecond === undefined
? '不可用'
: displayRate(selected.txBytesPerSecond)}
</dd>
</div>
<div>
<dt></dt>
<dd>
{selected.totalRxBytes === undefined
? '不可用'
: displayRate(selected.totalRxBytes).replace(' /s', '')}
</dd>
</div>
<div>
<dt></dt>
<dd>
{selected.totalTxBytes === undefined
? '不可用'
: displayRate(selected.totalTxBytes).replace(' /s', '')}
</dd>
</div>
</dl>
</section>
);
}
function StructuredSection({ label, values }: { label: string; values: OverviewSection }) { function StructuredSection({ label, values }: { label: string; values: OverviewSection }) {
const entries = Object.entries(values); const entries = Object.entries(values);
return ( return (
@@ -195,6 +267,7 @@ function StructuredSection({ label, values }: { label: string; values: OverviewS
function ResourceSummary({ instance }: { instance: InstanceContext }) { function ResourceSummary({ instance }: { instance: InstanceContext }) {
const resources = instance.resources; const resources = instance.resources;
const { revealed } = useSensitiveReveal();
const metric = (label: string, value: number | undefined) => ( const metric = (label: string, value: number | undefined) => (
<article className="overview-resource-card"> <article className="overview-resource-card">
<span>{label}</span> <span>{label}</span>
@@ -246,7 +319,11 @@ function ResourceSummary({ instance }: { instance: InstanceContext }) {
</article> </article>
<article className="overview-resource-card overview-resource-phone"> <article className="overview-resource-card overview-resource-phone">
<span></span> <span></span>
<strong>{resources?.phoneNumbers?.join('、') || '暂未获取'}</strong> <strong>
{resources?.phoneNumbers?.length
? formatPhoneNumbers(resources.phoneNumbers, revealed)
: '暂未获取'}
</strong>
</article> </article>
</div> </div>
</section> </section>
@@ -485,6 +562,7 @@ export function OverviewSystemPage({
{SECTIONS.map(([key, label]) => ( {SECTIONS.map(([key, label]) => (
<StructuredSection key={key} label={label} values={snapshot[key]} /> <StructuredSection key={key} label={label} values={snapshot[key]} />
))} ))}
{snapshot.networkSpeed ? <NetworkSpeedCard speed={snapshot.networkSpeed} /> : null}
</div> </div>
{systemOperations} {systemOperations}
</div> </div>
@@ -9,6 +9,8 @@ describe('sensitive field masking', () => {
expect(isSensitiveKey('phone_number')).toBe(true); expect(isSensitiveKey('phone_number')).toBe(true);
expect(isSensitiveKey('phoneNumber')).toBe(true); expect(isSensitiveKey('phoneNumber')).toBe(true);
expect(isSensitiveKey('phone-number')).toBe(true); expect(isSensitiveKey('phone-number')).toBe(true);
expect(isSensitiveKey('smsc')).toBe(true);
expect(isSensitiveKey('sms_center')).toBe(true);
expect(isSensitiveKey('slot')).toBe(false); expect(isSensitiveKey('slot')).toBe(false);
expect(isSensitiveKey('carrier')).toBe(false); expect(isSensitiveKey('carrier')).toBe(false);
}); });
@@ -29,5 +31,6 @@ describe('sensitive field masking', () => {
expect(protectValue('phone_number', '13800001234', false)).toBe('138 •••• 1234'); expect(protectValue('phone_number', '13800001234', false)).toBe('138 •••• 1234');
expect(protectValue('msisdn', '13800001234', false)).toBe('138 •••• 1234'); expect(protectValue('msisdn', '13800001234', false)).toBe('138 •••• 1234');
expect(protectValue('phoneNumber', '13800001234', true)).toBe('13800001234'); expect(protectValue('phoneNumber', '13800001234', true)).toBe('13800001234');
expect(protectValue('sms_center', '+85255500100', false)).toBe('+852 •••• 0100');
}); });
}); });
+15 -1
View File
@@ -13,6 +13,11 @@ export const SENSITIVE_KEYS: ReadonlySet<string> = new Set([
'phone_number', 'phone_number',
'msisdn', 'msisdn',
'phonenumber', 'phonenumber',
'smsc',
'sms_center',
'smsc_number',
'service_center',
'center_number',
]); ]);
const MASK = '••••'; const MASK = '••••';
@@ -22,7 +27,16 @@ function normalize(key: string): string {
return key.toLocaleLowerCase().replace(/[-\s]/gu, '_'); return key.toLocaleLowerCase().replace(/[-\s]/gu, '_');
} }
const PHONE_KEYS: ReadonlySet<string> = new Set(['phone_number', 'msisdn', 'phonenumber']); const PHONE_KEYS: ReadonlySet<string> = new Set([
'phone_number',
'msisdn',
'phonenumber',
'smsc',
'sms_center',
'smsc_number',
'service_center',
'center_number',
]);
export function isSensitiveKey(key: string): boolean { export function isSensitiveKey(key: string): boolean {
return SENSITIVE_KEYS.has(normalize(key)); return SENSITIVE_KEYS.has(normalize(key));
+2
View File
@@ -27,6 +27,7 @@ export type IconName =
| 'search' | 'search'
| 'server' | 'server'
| 'settings' | 'settings'
| 'shield'
| 'tag' | 'tag'
| 'temperature' | 'temperature'
| 'trash' | 'trash'
@@ -82,6 +83,7 @@ const paths: Readonly<Record<IconName, readonly string[]>> = {
'M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6V21h-4v-.1a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H3v-4h.1a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.6V3h4v.1a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.1v4H21a1.7 1.7 0 0 0-1.6 1Z', 'M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6V21h-4v-.1a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H3v-4h.1a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.6V3h4v.1a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.1v4H21a1.7 1.7 0 0 0-1.6 1Z',
], ],
tag: ['M20 13 13 20l-9-9V4h7Z', 'M8.5 8.5h.01'], tag: ['M20 13 13 20l-9-9V4h7Z', 'M8.5 8.5h.01'],
shield: ['M12 3.5 5 6v6c0 4 3 7 7 8.5 4-1.5 7-4.5 7-8.5V6Z', 'm9 12 2 2 4-4'],
temperature: ['M10 14.8V5a2 2 0 1 1 4 0v9.8a4 4 0 1 1-4 0Z', 'M12 17v-7'], temperature: ['M10 14.8V5a2 2 0 1 1 4 0v9.8a4 4 0 1 1-4 0Z', 'M12 17v-7'],
trash: ['M4 7h16', 'M10 11v6M14 11v6', 'M6 7l1 13h10l1-13', 'M9 7V4h6v3'], trash: ['M4 7h16', 'M10 11v6M14 11v6', 'M6 7l1 13h10l1-13', 'M9 7V4h6v3'],
version: ['M5 4h14v16H5z', 'M8 8h8M8 12h8M8 16h5'], version: ['M5 4h14v16H5z', 'M8 8h8M8 12h8M8 16h5'],