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:
@@ -88,6 +88,36 @@ describe('Phase 6.2 Cellular read module', () => {
|
||||
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 () => {
|
||||
const load = vi.fn<CellularDataSource['load']>().mockResolvedValue(snapshot);
|
||||
const fetcher = stubCatalog([
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { operatorLabel } from '@multi-simadmin/contracts';
|
||||
|
||||
import type { InstanceContext } from '../app-shell.js';
|
||||
import { DeviceActions } from './device-action-controls.js';
|
||||
import { displayValue } from '../ui/locale.js';
|
||||
@@ -70,6 +72,7 @@ const SECTIONS = [
|
||||
['状态', 'state', true],
|
||||
['模式', 'mode', true],
|
||||
['运营商', 'operator'],
|
||||
['上报名称', 'operatorReported'],
|
||||
['漫游', '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. */
|
||||
const LOCATION_KEYS: readonly (readonly [keyof CellularLocation, string])[] = [
|
||||
['mcc', 'mcc'],
|
||||
@@ -299,6 +324,7 @@ export function CellularModule({ instance, dataSource, refreshSignal }: Cellular
|
||||
|
||||
if (!ownedSnapshot) return null;
|
||||
const snapshot = ownedSnapshot.value;
|
||||
const registration = registrationView(snapshot.networkRegistration, snapshot.cellsLocation);
|
||||
|
||||
return (
|
||||
<div className="cellular-module">
|
||||
@@ -319,7 +345,12 @@ 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={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} />
|
||||
<DeviceActions
|
||||
|
||||
@@ -61,7 +61,7 @@ export class DeviceActionClientError extends Error {
|
||||
}
|
||||
|
||||
export interface DeviceActionSource {
|
||||
list(signal?: AbortSignal): Promise<readonly DeviceActionDescriptor[]>;
|
||||
list(signal?: AbortSignal): Promise<DeviceActionCatalog>;
|
||||
execute(
|
||||
actionId: string,
|
||||
params: Readonly<Record<string, unknown>>,
|
||||
@@ -69,6 +69,12 @@ export interface DeviceActionSource {
|
||||
): 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 RISK_VALUES: readonly DeviceActionRisk[] = ['R1', 'R2', 'R3'];
|
||||
const FIELD_KINDS: readonly DeviceActionFieldKind[] = [
|
||||
@@ -193,13 +199,16 @@ export function createDeviceActionSource(
|
||||
});
|
||||
if (!response.ok) throw await readProblem(response);
|
||||
const root = record(await response.json());
|
||||
if (!Array.isArray(root?.actions)) return [];
|
||||
return Object.freeze(
|
||||
if (!Array.isArray(root?.actions)) return { actions: [], identityBlocked: false };
|
||||
return Object.freeze({
|
||||
actions: Object.freeze(
|
||||
root.actions.slice(0, MAX_ACTIONS).flatMap((item) => {
|
||||
const parsed = parseDescriptor(item);
|
||||
return parsed ? [parsed] : [];
|
||||
}),
|
||||
);
|
||||
),
|
||||
identityBlocked: root?.identityBlocked === true,
|
||||
});
|
||||
},
|
||||
async execute(actionId, params, confirm) {
|
||||
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 {
|
||||
createDeviceActionSource,
|
||||
DeviceActionClientError,
|
||||
type DeviceActionDescriptor,
|
||||
type DeviceActionField,
|
||||
type DeviceActionSource,
|
||||
@@ -24,6 +25,10 @@ const RISK_LABELS: Readonly<Record<DeviceActionDescriptor['risk'], string>> = {
|
||||
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>> = {
|
||||
R1: 'check',
|
||||
R2: 'alert',
|
||||
@@ -167,10 +172,13 @@ function ActionRow({
|
||||
action,
|
||||
source,
|
||||
onExecuted,
|
||||
blocked,
|
||||
}: {
|
||||
action: DeviceActionDescriptor;
|
||||
source: DeviceActionSource;
|
||||
onExecuted: () => void;
|
||||
/** The identity guard holds this node; only the binding release stays available. */
|
||||
blocked: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [values, setValues] = useState<Record<string, string | boolean>>(() =>
|
||||
@@ -204,8 +212,16 @@ function ActionRow({
|
||||
onExecuted();
|
||||
}
|
||||
} catch (cause) {
|
||||
const code =
|
||||
cause instanceof DeviceActionClientError
|
||||
? cause.code
|
||||
: cause instanceof Error
|
||||
? cause.message
|
||||
: '';
|
||||
setError(
|
||||
cause instanceof Error && cause.message === 'VALIDATION_FAILED'
|
||||
code === 'IDENTITY_UNCONFIRMED'
|
||||
? '设备身份待确认,请先在“设备身份”面板核对 IMEI 与硬件信息。'
|
||||
: code === 'VALIDATION_FAILED'
|
||||
? '参数不符合要求,请检查后重试。'
|
||||
: '操作未能送达设备,请稍后重试。',
|
||||
);
|
||||
@@ -226,7 +242,8 @@ function ActionRow({
|
||||
: 'primary-action'
|
||||
}
|
||||
aria-expanded={parameterized ? open : undefined}
|
||||
disabled={pending}
|
||||
disabled={pending || blocked}
|
||||
title={blocked ? IDENTITY_HOLD_NOTE : undefined}
|
||||
onClick={() => {
|
||||
if (parameterized && !open) {
|
||||
setOpen(true);
|
||||
@@ -244,6 +261,12 @@ function ActionRow({
|
||||
</span>
|
||||
</div>
|
||||
<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 ? (
|
||||
<div className="device-action-form">
|
||||
{action.fields.map((field) => (
|
||||
@@ -314,6 +337,8 @@ export interface DeviceActionControlsProps {
|
||||
readonly module: string;
|
||||
readonly actions: readonly DeviceActionDescriptor[];
|
||||
readonly source: DeviceActionSource;
|
||||
/** True while the identity guard holds the node; every control except unbind is disabled. */
|
||||
readonly identityBlocked?: boolean;
|
||||
readonly onExecuted?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
@@ -321,6 +346,7 @@ export function DeviceActionControls({
|
||||
module,
|
||||
actions,
|
||||
source,
|
||||
identityBlocked = false,
|
||||
onExecuted,
|
||||
}: DeviceActionControlsProps) {
|
||||
const owned = actions.filter((action) => action.module === module);
|
||||
@@ -328,12 +354,19 @@ export function DeviceActionControls({
|
||||
return (
|
||||
<section className="cellular-card device-action-card" aria-label="设备操作">
|
||||
<h2>设备操作</h2>
|
||||
{identityBlocked ? (
|
||||
<p className="device-action-banner" role="alert">
|
||||
<Icon name="shield" />
|
||||
该节点的设备身份尚未确认,控制操作已暂停;确认身份前仅可解除回连绑定。
|
||||
</p>
|
||||
) : null}
|
||||
<ul className="device-action-list">
|
||||
{owned.map((action) => (
|
||||
<ActionRow
|
||||
key={action.id}
|
||||
action={action}
|
||||
source={source}
|
||||
blocked={identityBlocked && action.id !== IDENTITY_EXEMPT_ACTION}
|
||||
onExecuted={onExecuted ?? (() => undefined)}
|
||||
/>
|
||||
))}
|
||||
@@ -360,12 +393,14 @@ export function DeviceActions({ instance, module, onExecuted, source }: DeviceAc
|
||||
[source, instance.id],
|
||||
);
|
||||
const [actions, setActions] = useState<readonly DeviceActionDescriptor[]>([]);
|
||||
const [identityBlocked, setIdentityBlocked] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (instance.authentication !== 'authenticated') {
|
||||
setActions([]);
|
||||
setIdentityBlocked(false);
|
||||
setLoaded(false);
|
||||
setFailed(false);
|
||||
return;
|
||||
@@ -374,13 +409,15 @@ export function DeviceActions({ instance, module, onExecuted, source }: DeviceAc
|
||||
void resolved.list(controller.signal).then(
|
||||
(catalog) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setActions(catalog);
|
||||
setActions(catalog.actions);
|
||||
setIdentityBlocked(catalog.identityBlocked);
|
||||
setLoaded(true);
|
||||
setFailed(false);
|
||||
},
|
||||
() => {
|
||||
if (controller.signal.aborted) return;
|
||||
setActions([]);
|
||||
setIdentityBlocked(false);
|
||||
setLoaded(true);
|
||||
setFailed(true);
|
||||
},
|
||||
@@ -413,6 +450,7 @@ export function DeviceActions({ instance, module, onExecuted, source }: DeviceAc
|
||||
module={module}
|
||||
actions={owned}
|
||||
source={resolved}
|
||||
identityBlocked={identityBlocked}
|
||||
{...(onExecuted ? { onExecuted } : {})}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 { ConfigurationModule, SimModule, VowifiModule } from './device-module-panels.js';
|
||||
import type { InstanceModuleSnapshot } from './instance-module-api-data-source.js';
|
||||
|
||||
afterEach(() => {
|
||||
@@ -205,4 +205,59 @@ describe('DeviceModulePanel', () => {
|
||||
expect(alert.textContent).toContain('无法加载');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface DeviceModuleSectionSpec {
|
||||
readonly only?: readonly string[];
|
||||
/** Device keys this section never renders; they belong to a sibling section instead. */
|
||||
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. */
|
||||
readonly optional?: boolean;
|
||||
}
|
||||
@@ -109,6 +111,10 @@ const COMMON_LABELS: Readonly<Record<string, string>> = {
|
||||
supported: '受支持',
|
||||
reason: '原因',
|
||||
detail: '详情',
|
||||
sms_path: '短信路径',
|
||||
received_today: '今日接收',
|
||||
outgoing_today: '今日发送',
|
||||
delivered_today: '今日发送成功',
|
||||
};
|
||||
|
||||
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] : []));
|
||||
}
|
||||
|
||||
/** 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[] {
|
||||
const seen: string[] = [];
|
||||
for (const item of items) {
|
||||
@@ -240,7 +297,10 @@ function sectionBody(
|
||||
</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) {
|
||||
const headers = tableColumns(list, spec);
|
||||
if (!headers.length) return null;
|
||||
|
||||
@@ -303,6 +303,17 @@ const VOWIFI_SECTIONS: readonly DeviceModuleSectionSpec[] = [
|
||||
error: '错误',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'smsDeliveries',
|
||||
label: '今日短信统计',
|
||||
summary: 'vowifi-sms',
|
||||
fields: {
|
||||
sms_path: '短信路径',
|
||||
received_today: '今日接收',
|
||||
outgoing_today: '今日发送',
|
||||
delivered_today: '今日发送成功',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'soakRuns',
|
||||
label: 'WiFi Calling 稳定性巡检',
|
||||
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
type DeviceNetworkSnapshot,
|
||||
} from './device-network-module.js';
|
||||
|
||||
afterEach(cleanup);
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const owner: InstanceContext = {
|
||||
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 () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('unavailable')));
|
||||
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');
|
||||
|
||||
@@ -19,6 +19,10 @@ const owner: InstanceContext = {
|
||||
const snapshot: EsimSnapshot = {
|
||||
profileCount: 4,
|
||||
enabledProfileCount: 2,
|
||||
profilesWithDeletionPolicy: 3,
|
||||
deletableProfiles: 1,
|
||||
profilesWithDisablePolicy: 2,
|
||||
disableableProfiles: 2,
|
||||
lpacStatus: 'available',
|
||||
workMode: 'idle',
|
||||
labels: ['Provisioned', 'Enabled'],
|
||||
@@ -41,6 +45,8 @@ describe('isolated safe eSIM read module', () => {
|
||||
expect(within(section).getByText('2')).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(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
@@ -89,6 +95,10 @@ describe('isolated safe eSIM read module', () => {
|
||||
imei: 'IMEI-secret',
|
||||
isdpAid: 'ISDP-AID-secret',
|
||||
providerData: { secret: 'raw-provider-secret' },
|
||||
profilesWithDeletionPolicy: 4,
|
||||
deletableProfiles: 2,
|
||||
profilesWithDisablePolicy: 3,
|
||||
disableableProfiles: 1,
|
||||
} as unknown as EsimSnapshot;
|
||||
render(<EsimModule instance={owner} dataSource={{ load: async () => hostile }} />);
|
||||
expect(await screen.findByText('已启用')).toBeTruthy();
|
||||
|
||||
@@ -16,6 +16,10 @@ export type EsimSafeLabel = 'Provisioned' | 'Enabled' | 'Disabled' | 'Pending' |
|
||||
export interface EsimSnapshot {
|
||||
readonly profileCount?: 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 workMode?: EsimWorkMode | null | undefined;
|
||||
readonly labels?: readonly EsimSafeLabel[] | undefined;
|
||||
@@ -35,6 +39,10 @@ export interface EsimModuleProps {
|
||||
type SafeSnapshot = {
|
||||
profileCount?: number;
|
||||
enabledProfileCount?: number;
|
||||
profilesWithDeletionPolicy?: number;
|
||||
deletableProfiles?: number;
|
||||
profilesWithDisablePolicy?: number;
|
||||
disableableProfiles?: number;
|
||||
lpacStatus?: EsimLpacStatus;
|
||||
workMode?: EsimWorkMode;
|
||||
labels: EsimSafeLabel[];
|
||||
@@ -71,6 +79,10 @@ function sanitizeSnapshot(value: unknown): SafeSnapshot {
|
||||
const candidate = isRecord(value) ? value : {};
|
||||
const profileCount = safeCount(candidate.profileCount);
|
||||
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)
|
||||
? (candidate.lpacStatus as EsimLpacStatus)
|
||||
: undefined;
|
||||
@@ -88,6 +100,10 @@ function sanitizeSnapshot(value: unknown): SafeSnapshot {
|
||||
return {
|
||||
...(profileCount === undefined ? {} : { profileCount }),
|
||||
...(enabledProfileCount === undefined ? {} : { enabledProfileCount }),
|
||||
...(profilesWithDeletionPolicy === undefined ? {} : { profilesWithDeletionPolicy }),
|
||||
...(deletableProfiles === undefined ? {} : { deletableProfiles }),
|
||||
...(profilesWithDisablePolicy === undefined ? {} : { profilesWithDisablePolicy }),
|
||||
...(disableableProfiles === undefined ? {} : { disableableProfiles }),
|
||||
...(lpacStatus === undefined ? {} : { lpacStatus }),
|
||||
...(workMode === undefined ? {} : { workMode }),
|
||||
labels,
|
||||
@@ -119,6 +135,23 @@ function SnapshotView({ snapshot }: { snapshot: SafeSnapshot }) {
|
||||
<dt>已启用配置数量</dt>
|
||||
<dd>{display(snapshot.enabledProfileCount)}</dd>
|
||||
</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>
|
||||
<dt>LPAC 状态</dt>
|
||||
<dd>{display(snapshot.lpacStatus)}</dd>
|
||||
|
||||
@@ -49,6 +49,41 @@ describe('instance module mappers', () => {
|
||||
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', () => {
|
||||
const result = mapCellular(
|
||||
snapshot('cellular', [
|
||||
@@ -126,8 +161,13 @@ describe('instance module mappers', () => {
|
||||
'profiles',
|
||||
{
|
||||
profiles: [
|
||||
{ iccid: '8986000000000000001', enabled: true },
|
||||
{ iccid: '8986000000000000002', status: 'disabled' },
|
||||
{ iccid: '8986000000000000001', enabled: true, delete_allowed: true },
|
||||
{
|
||||
iccid: '8986000000000000002',
|
||||
status: 'disabled',
|
||||
delete_allowed: false,
|
||||
disable_allowed: true,
|
||||
},
|
||||
{ iccid: '8986000000000000003', status: 'pending' },
|
||||
],
|
||||
},
|
||||
@@ -142,6 +182,10 @@ describe('instance module mappers', () => {
|
||||
lpacStatus: 'available',
|
||||
workMode: 'idle',
|
||||
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 { NotificationsSnapshot } from './notifications-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.
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
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' {
|
||||
if (items.length === 0) return 'idle';
|
||||
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.
|
||||
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.
|
||||
Object.assign(rest, primitives(sectionRecord(snapshot, 'smsStats')));
|
||||
return {
|
||||
@@ -238,6 +293,14 @@ export function mapOverview(snapshot: InstanceModuleSnapshot): OverviewSnapshot
|
||||
stats: rest,
|
||||
cpu,
|
||||
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 euicc = sectionRecord(snapshot, 'euicc');
|
||||
const lpac = sectionRecord(snapshot, 'lpacStatus');
|
||||
const items = arrayFrom(profiles, ['profiles', 'list', 'items', 'data']).flatMap((item) =>
|
||||
isRecord(item) ? [item] : [],
|
||||
);
|
||||
const items: readonly Readonly<Record<string, unknown>>[] = arrayFrom(profiles, [
|
||||
'profiles',
|
||||
'list',
|
||||
'items',
|
||||
'data',
|
||||
]).flatMap((item) => (isRecord(item) ? [item] : []));
|
||||
const enabled = tally(items, (item) => {
|
||||
const record = isRecord(item) ? item : {};
|
||||
return (
|
||||
@@ -420,6 +486,24 @@ export function mapEsim(snapshot: InstanceModuleSnapshot): EsimSnapshot {
|
||||
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(
|
||||
pick(lpac, ['status', 'state', 'available']) ?? pick(euicc, ['lpac_status', 'status']),
|
||||
)?.toLocaleLowerCase();
|
||||
@@ -444,6 +528,12 @@ export function mapEsim(snapshot: InstanceModuleSnapshot): EsimSnapshot {
|
||||
return {
|
||||
profileCount: items.length,
|
||||
enabledProfileCount: enabled,
|
||||
...(withDeletionPolicy
|
||||
? { profilesWithDeletionPolicy: withDeletionPolicy, deletableProfiles: deletable }
|
||||
: {}),
|
||||
...(withDisablePolicy
|
||||
? { profilesWithDisablePolicy: withDisablePolicy, disableableProfiles: disableable }
|
||||
: {}),
|
||||
lpacStatus:
|
||||
lpacValue === 'available' || lpacValue === 'unavailable' || lpacValue === 'degraded'
|
||||
? lpacValue
|
||||
|
||||
@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AppShell, type InstanceContext } from '../app-shell.js';
|
||||
import { SensitiveRevealProvider } from '../privacy/sensitive-reveal.js';
|
||||
import {
|
||||
OverviewSystemPage,
|
||||
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', () => {
|
||||
render(
|
||||
<SensitiveRevealProvider>
|
||||
<AppShell
|
||||
pathname="/instances/alpha/overview"
|
||||
instance={owner}
|
||||
capabilities={overviewCapabilities}
|
||||
/>,
|
||||
/>
|
||||
,
|
||||
</SensitiveRevealProvider>,
|
||||
);
|
||||
const operations = screen.getByRole('region', { name: '运行状态与资源' });
|
||||
expect(within(operations).getByText('在线')).toBeTruthy();
|
||||
@@ -132,12 +136,30 @@ describe('Phase 6.1 Overview / System read slice', () => {
|
||||
within(operations).getByRole('meter', { name: '内存使用率' }).getAttribute('value'),
|
||||
).toBe('63.2');
|
||||
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.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 () => {
|
||||
const load = vi.fn<OverviewDataSource['load']>().mockResolvedValue(snapshot);
|
||||
const { rerender } = render(<OverviewSystemPage instance={owner} dataSource={{ load }} />);
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
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 { safeUiError } from '../ui/locale.js';
|
||||
|
||||
export type OverviewFieldValue = string | number | boolean | null;
|
||||
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 {
|
||||
readonly observedAt?: string | undefined;
|
||||
readonly device: OverviewSection;
|
||||
@@ -15,6 +30,7 @@ export interface OverviewSnapshot {
|
||||
readonly stats: OverviewSection;
|
||||
readonly cpu: OverviewSection;
|
||||
readonly connectivity: OverviewSection;
|
||||
readonly networkSpeed?: NetworkSpeedSnapshot | undefined;
|
||||
}
|
||||
|
||||
export interface OverviewDataSource {
|
||||
@@ -132,6 +148,7 @@ const FIELD_LABELS: Readonly<Record<string, string>> = {
|
||||
interface: '网络接口',
|
||||
ssid: '无线网络',
|
||||
apn: 'APN',
|
||||
powered: '基带电源',
|
||||
};
|
||||
|
||||
const ENUM_LABELS: Readonly<Record<string, string>> = {
|
||||
@@ -172,6 +189,61 @@ function displayFieldValue(value: OverviewFieldValue): string {
|
||||
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 }) {
|
||||
const entries = Object.entries(values);
|
||||
return (
|
||||
@@ -195,6 +267,7 @@ function StructuredSection({ label, values }: { label: string; values: OverviewS
|
||||
|
||||
function ResourceSummary({ instance }: { instance: InstanceContext }) {
|
||||
const resources = instance.resources;
|
||||
const { revealed } = useSensitiveReveal();
|
||||
const metric = (label: string, value: number | undefined) => (
|
||||
<article className="overview-resource-card">
|
||||
<span>{label}</span>
|
||||
@@ -246,7 +319,11 @@ function ResourceSummary({ instance }: { instance: InstanceContext }) {
|
||||
</article>
|
||||
<article className="overview-resource-card overview-resource-phone">
|
||||
<span>手机号</span>
|
||||
<strong>{resources?.phoneNumbers?.join('、') || '暂未获取'}</strong>
|
||||
<strong>
|
||||
{resources?.phoneNumbers?.length
|
||||
? formatPhoneNumbers(resources.phoneNumbers, revealed)
|
||||
: '暂未获取'}
|
||||
</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
@@ -485,6 +562,7 @@ export function OverviewSystemPage({
|
||||
{SECTIONS.map(([key, label]) => (
|
||||
<StructuredSection key={key} label={label} values={snapshot[key]} />
|
||||
))}
|
||||
{snapshot.networkSpeed ? <NetworkSpeedCard speed={snapshot.networkSpeed} /> : null}
|
||||
</div>
|
||||
{systemOperations}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,8 @@ describe('sensitive field masking', () => {
|
||||
expect(isSensitiveKey('phone_number')).toBe(true);
|
||||
expect(isSensitiveKey('phoneNumber')).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('carrier')).toBe(false);
|
||||
});
|
||||
@@ -29,5 +31,6 @@ describe('sensitive field masking', () => {
|
||||
expect(protectValue('phone_number', '13800001234', false)).toBe('138 •••• 1234');
|
||||
expect(protectValue('msisdn', '13800001234', false)).toBe('138 •••• 1234');
|
||||
expect(protectValue('phoneNumber', '13800001234', true)).toBe('13800001234');
|
||||
expect(protectValue('sms_center', '+85255500100', false)).toBe('+852 •••• 0100');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,11 @@ export const SENSITIVE_KEYS: ReadonlySet<string> = new Set([
|
||||
'phone_number',
|
||||
'msisdn',
|
||||
'phonenumber',
|
||||
'smsc',
|
||||
'sms_center',
|
||||
'smsc_number',
|
||||
'service_center',
|
||||
'center_number',
|
||||
]);
|
||||
|
||||
const MASK = '••••';
|
||||
@@ -22,7 +27,16 @@ function normalize(key: string): string {
|
||||
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 {
|
||||
return SENSITIVE_KEYS.has(normalize(key));
|
||||
|
||||
@@ -27,6 +27,7 @@ export type IconName =
|
||||
| 'search'
|
||||
| 'server'
|
||||
| 'settings'
|
||||
| 'shield'
|
||||
| 'tag'
|
||||
| 'temperature'
|
||||
| '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',
|
||||
],
|
||||
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'],
|
||||
trash: ['M4 7h16', 'M10 11v6M14 11v6', 'M6 7l1 13h10l1-13', 'M9 7V4h6v3'],
|
||||
version: ['M5 4h14v16H5z', 'M8 8h8M8 12h8M8 16h5'],
|
||||
|
||||
Reference in New Issue
Block a user