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