@@ -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