From 6d251dab7b7eb5c31a726e69f01658d408894bd2 Mon Sep 17 00:00:00 2001 From: chick Date: Mon, 7 Sep 2026 00:45:38 +0800 Subject: [PATCH] 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. --- .../src/instances/cellular-module.test.tsx | 30 ++++++ apps/web/src/instances/cellular-module.tsx | 33 ++++++- .../web/src/instances/device-action-client.ts | 25 +++-- .../src/instances/device-action-controls.tsx | 48 ++++++++- .../instances/device-module-panel.test.tsx | 57 ++++++++++- .../web/src/instances/device-module-panel.tsx | 62 +++++++++++- .../src/instances/device-module-panels.tsx | 11 +++ .../instances/device-network-module.test.tsx | 6 +- apps/web/src/instances/esim-module.test.tsx | 10 ++ apps/web/src/instances/esim-module.tsx | 33 +++++++ .../instance-module-api-data-source.test.ts | 48 ++++++++- .../instance-module-api-data-source.ts | 98 ++++++++++++++++++- .../src/instances/overview-system.test.tsx | 34 +++++-- apps/web/src/instances/overview-system.tsx | 80 ++++++++++++++- apps/web/src/privacy/sensitive-fields.test.ts | 3 + apps/web/src/privacy/sensitive-fields.ts | 16 ++- apps/web/src/ui/icon.tsx | 2 + 17 files changed, 565 insertions(+), 31 deletions(-) diff --git a/apps/web/src/instances/cellular-module.test.tsx b/apps/web/src/instances/cellular-module.test.tsx index a4d5790..591fdf1 100644 --- a/apps/web/src/instances/cellular-module.test.tsx +++ b/apps/web/src/instances/cellular-module.test.tsx @@ -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(); + 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(); + 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().mockResolvedValue(snapshot); const fetcher = stubCatalog([ diff --git a/apps/web/src/instances/cellular-module.tsx b/apps/web/src/instances/cellular-module.tsx index c083e57..da7bbac 100644 --- a/apps/web/src/instances/cellular-module.tsx +++ b/apps/web/src/instances/cellular-module.tsx @@ -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 (
@@ -319,7 +345,12 @@ export function CellularModule({ instance, dataSource, refreshSignal }: Cellular {snapshot.observedAt ?

观测时间:{snapshot.observedAt}

: null}
{SECTIONS.map(([key, label, fields]) => ( - + ))} ; + list(signal?: AbortSignal): Promise; execute( actionId: string, params: Readonly>, @@ -69,6 +69,12 @@ export interface DeviceActionSource { ): Promise; } +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( - root.actions.slice(0, MAX_ACTIONS).flatMap((item) => { - const parsed = parseDescriptor(item); - return parsed ? [parsed] : []; - }), - ); + 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)}`, { diff --git a/apps/web/src/instances/device-action-controls.tsx b/apps/web/src/instances/device-action-controls.tsx index 56ebb8b..1b8f696 100644 --- a/apps/web/src/instances/device-action-controls.tsx +++ b/apps/web/src/instances/device-action-controls.tsx @@ -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> = { 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> = { 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>(() => @@ -204,10 +212,18 @@ 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' + ? '参数不符合要求,请检查后重试。' + : '操作未能送达设备,请稍后重试。', ); } setPending(false); @@ -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({

{action.description}

+ {blocked ? ( +

+ + {IDENTITY_HOLD_NOTE} +

+ ) : null} {open ? (
{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 (

设备操作

+ {identityBlocked ? ( +

+ + 该节点的设备身份尚未确认,控制操作已暂停;确认身份前仅可解除回连绑定。 +

+ ) : null}
    {owned.map((action) => ( undefined)} /> ))} @@ -360,12 +393,14 @@ export function DeviceActions({ instance, module, onExecuted, source }: DeviceAc [source, instance.id], ); const [actions, setActions] = useState([]); + 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 } : {})} /> ); diff --git a/apps/web/src/instances/device-module-panel.test.tsx b/apps/web/src/instances/device-module-panel.test.tsx index 767b17a..d34cd9a 100644 --- a/apps/web/src/instances/device-module-panel.test.tsx +++ b/apps/web/src/instances/device-module-panel.test.tsx @@ -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(); + + 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'); + }); }); diff --git a/apps/web/src/instances/device-module-panel.tsx b/apps/web/src/instances/device-module-panel.tsx index c9096dd..f24d3df 100644 --- a/apps/web/src/instances/device-module-panel.tsx +++ b/apps/web/src/instances/device-module-panel.tsx @@ -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> = { supported: '受支持', reason: '原因', detail: '详情', + sms_path: '短信路径', + received_today: '今日接收', + outgoing_today: '今日发送', + delivered_today: '今日发送成功', }; function fieldLabel(key: string, fields?: Readonly>): 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>[] { + 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>[]): readonly string[] { const seen: string[] = []; for (const item of items) { @@ -240,7 +297,10 @@ function sectionBody(

    ); } - 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; diff --git a/apps/web/src/instances/device-module-panels.tsx b/apps/web/src/instances/device-module-panels.tsx index b5ec34d..479b048 100644 --- a/apps/web/src/instances/device-module-panels.tsx +++ b/apps/web/src/instances/device-module-panels.tsx @@ -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 稳定性巡检', diff --git a/apps/web/src/instances/device-network-module.test.tsx b/apps/web/src/instances/device-network-module.test.tsx index 73fb949..e395457 100644 --- a/apps/web/src/instances/device-network-module.test.tsx +++ b/apps/web/src/instances/device-network-module.test.tsx @@ -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( snapshot }} />); expect((await screen.findAllByText('Operations Wi-Fi')).length).toBe(2); expect(document.body.textContent).not.toContain('never-render-this'); diff --git a/apps/web/src/instances/esim-module.test.tsx b/apps/web/src/instances/esim-module.test.tsx index 0537f2b..df6484d 100644 --- a/apps/web/src/instances/esim-module.test.tsx +++ b/apps/web/src/instances/esim-module.test.tsx @@ -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( hostile }} />); expect(await screen.findByText('已启用')).toBeTruthy(); diff --git a/apps/web/src/instances/esim-module.tsx b/apps/web/src/instances/esim-module.tsx index deb74c4..e202e5d 100644 --- a/apps/web/src/instances/esim-module.tsx +++ b/apps/web/src/instances/esim-module.tsx @@ -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 }) {
    已启用配置数量
    {display(snapshot.enabledProfileCount)}
+ {snapshot.profilesWithDeletionPolicy !== undefined ? ( +
+
允许删除
+
+ {display(snapshot.deletableProfiles)} / {display(snapshot.profilesWithDeletionPolicy)} +
+
+ ) : null} + {snapshot.profilesWithDisablePolicy !== undefined ? ( +
+
允许禁用
+
+ {display(snapshot.disableableProfiles)} /{' '} + {display(snapshot.profilesWithDisablePolicy)} +
+
+ ) : null}
LPAC 状态
{display(snapshot.lpacStatus)}
diff --git a/apps/web/src/instances/instance-module-api-data-source.test.ts b/apps/web/src/instances/instance-module-api-data-source.test.ts index 72a177d..b2e63d5 100644 --- a/apps/web/src/instances/instance-module-api-data-source.test.ts +++ b/apps/web/src/instances/instance-module-api-data-source.test.ts @@ -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, }); }); diff --git a/apps/web/src/instances/instance-module-api-data-source.ts b/apps/web/src/instances/instance-module-api-data-source.ts index ae77e02..f783e47 100644 --- a/apps/web/src/instances/instance-module-api-data-source.ts +++ b/apps/web/src/instances/instance-module-api-data-source.ts @@ -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((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>) + : 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>[] = 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>, ['delete_allowed'])) === true, + ); + const disableable = tally( + items, + (item) => flag(pick(item as Readonly>, ['disable_allowed'])) === true, + ); + const withDeletionPolicy = tally( + items, + (item): boolean => + pick(item as Readonly>, ['delete_allowed']) !== undefined, + ); + const withDisablePolicy = tally( + items, + (item): boolean => + pick(item as Readonly>, ['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 diff --git a/apps/web/src/instances/overview-system.test.tsx b/apps/web/src/instances/overview-system.test.tsx index bf40a84..4d56033 100644 --- a/apps/web/src/instances/overview-system.test.tsx +++ b/apps/web/src/instances/overview-system.test.tsx @@ -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( - , + + + , + , ); 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( + , + ); + 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().mockResolvedValue(snapshot); const { rerender } = render(); diff --git a/apps/web/src/instances/overview-system.tsx b/apps/web/src/instances/overview-system.tsx index 0a31219..cd5ca31 100644 --- a/apps/web/src/instances/overview-system.tsx +++ b/apps/web/src/instances/overview-system.tsx @@ -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>; +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> = { interface: '网络接口', ssid: '无线网络', apn: 'APN', + powered: '基带电源', }; const ENUM_LABELS: Readonly> = { @@ -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 ( +
+

实时网速

+

{selected.name ?? '网络接口'}

+
+
+
下行
+
+ {selected.rxBytesPerSecond === undefined + ? '不可用' + : displayRate(selected.rxBytesPerSecond)} +
+
+
+
上行
+
+ {selected.txBytesPerSecond === undefined + ? '不可用' + : displayRate(selected.txBytesPerSecond)} +
+
+
+
累计下行
+
+ {selected.totalRxBytes === undefined + ? '不可用' + : displayRate(selected.totalRxBytes).replace(' /s', '')} +
+
+
+
累计上行
+
+ {selected.totalTxBytes === undefined + ? '不可用' + : displayRate(selected.totalTxBytes).replace(' /s', '')} +
+
+
+
+ ); +} + 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) => (
{label} @@ -246,7 +319,11 @@ function ResourceSummary({ instance }: { instance: InstanceContext }) {
手机号 - {resources?.phoneNumbers?.join('、') || '暂未获取'} + + {resources?.phoneNumbers?.length + ? formatPhoneNumbers(resources.phoneNumbers, revealed) + : '暂未获取'} +
@@ -485,6 +562,7 @@ export function OverviewSystemPage({ {SECTIONS.map(([key, label]) => ( ))} + {snapshot.networkSpeed ? : null}
{systemOperations} diff --git a/apps/web/src/privacy/sensitive-fields.test.ts b/apps/web/src/privacy/sensitive-fields.test.ts index 55ffb97..f4c12a1 100644 --- a/apps/web/src/privacy/sensitive-fields.test.ts +++ b/apps/web/src/privacy/sensitive-fields.test.ts @@ -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'); }); }); diff --git a/apps/web/src/privacy/sensitive-fields.ts b/apps/web/src/privacy/sensitive-fields.ts index 0ea722a..4893d0b 100644 --- a/apps/web/src/privacy/sensitive-fields.ts +++ b/apps/web/src/privacy/sensitive-fields.ts @@ -13,6 +13,11 @@ export const SENSITIVE_KEYS: ReadonlySet = 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 = new Set(['phone_number', 'msisdn', 'phonenumber']); +const PHONE_KEYS: ReadonlySet = 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)); diff --git a/apps/web/src/ui/icon.tsx b/apps/web/src/ui/icon.tsx index 2dbf7fb..ddbe206 100644 --- a/apps/web/src/ui/icon.tsx +++ b/apps/web/src/ui/icon.tsx @@ -27,6 +27,7 @@ export type IconName = | 'search' | 'server' | 'settings' + | 'shield' | 'tag' | 'temperature' | 'trash' @@ -82,6 +83,7 @@ const paths: Readonly> = { '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'],