feat(web): localize and redesign operations console
This commit is contained in:
@@ -44,18 +44,16 @@ describe('isolated Automation read module', () => {
|
||||
it('loads through the injected exact-owner source and renders only aggregate task status', async () => {
|
||||
const pending = deferredSource();
|
||||
render(<AutomationModule instance={owner} dataSource={pending.source} />);
|
||||
expect(screen.getByRole('status', { name: 'Automation loading status' })).toBeTruthy();
|
||||
expect(screen.getByRole('status', { name: '自动化加载状态' })).toBeTruthy();
|
||||
expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
|
||||
pending.resolve(snapshot);
|
||||
|
||||
const counts = await screen.findByRole('region', { name: 'Task counts' });
|
||||
const counts = await screen.findByRole('region', { name: '任务计数' });
|
||||
expect(within(counts).getByText('12')).toBeTruthy();
|
||||
expect(within(counts).getByText('9')).toBeTruthy();
|
||||
expect(screen.getByRole('region', { name: 'Automation status' }).textContent).toContain(
|
||||
'healthy',
|
||||
);
|
||||
expect(screen.queryByRole('region', { name: 'Safe labels' })).toBeNull();
|
||||
expect(screen.getByText(/Observed 2026-07-17T13:00:00Z/)).toBeTruthy();
|
||||
expect(screen.getByRole('region', { name: '自动化状态' }).textContent).toContain('正常');
|
||||
expect(screen.queryByRole('region', { name: '安全标签' })).toBeNull();
|
||||
expect(screen.getByText(/观测时间:2026-07-17T13:00:00Z/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('omits labels and never exposes task config, endpoints, tokens, phones, messages, payloads, logs, or operations', async () => {
|
||||
@@ -72,12 +70,12 @@ describe('isolated Automation read module', () => {
|
||||
labels: ['Daily jobs'],
|
||||
};
|
||||
render(<AutomationModule instance={owner} dataSource={{ load: async () => unsafe }} />);
|
||||
expect(await screen.findByText(/read-only aggregate view/i)).toBeTruthy();
|
||||
expect(await screen.findByText(/只读汇总视图/i)).toBeTruthy();
|
||||
expect(document.body.textContent).not.toMatch(
|
||||
/Daily jobs|secret\.example|secret-token|555|private message|private payload|private raw log|cron config|execute|nested-token|nested-message/i,
|
||||
);
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
expect(screen.getByText(/read-only aggregate view/i)).toBeTruthy();
|
||||
expect(screen.getByText(/只读汇总视图/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('copies and bounds enums, counts, and timestamps while dropping all labels', async () => {
|
||||
@@ -102,13 +100,13 @@ describe('isolated Automation read module', () => {
|
||||
],
|
||||
};
|
||||
render(<AutomationModule instance={owner} dataSource={{ load: async () => unsafe }} />);
|
||||
await screen.findByRole('region', { name: 'Task counts' });
|
||||
expect(within(screen.getByRole('region', { name: 'Task counts' })).getByText('2')).toBeTruthy();
|
||||
expect(within(screen.getByRole('region', { name: 'Task counts' })).getByText('3')).toBeTruthy();
|
||||
await screen.findByRole('region', { name: '任务计数' });
|
||||
expect(within(screen.getByRole('region', { name: '任务计数' })).getByText('2')).toBeTruthy();
|
||||
expect(within(screen.getByRole('region', { name: '任务计数' })).getByText('3')).toBeTruthy();
|
||||
expect(document.body.textContent).not.toMatch(
|
||||
/scheduler-token|state-secret|scheduler-secret|worker-secret|Safe [1-9]|script|Infinity|1\.5|-1/,
|
||||
);
|
||||
expect(screen.queryByText(/^Observed /)).toBeNull();
|
||||
expect(screen.queryByText(/^观测时间:/)).toBeNull();
|
||||
unsafe.labels[0] = 'Mutated';
|
||||
unsafe.tasks.disabled = 99;
|
||||
expect(screen.queryByText('Mutated')).toBeNull();
|
||||
@@ -123,12 +121,12 @@ describe('isolated Automation read module', () => {
|
||||
dataSource={{ load }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/需要先完成认证/i);
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
|
||||
rerender(<AutomationModule instance={owner} />);
|
||||
expect(screen.getByRole('status', { name: 'Automation unavailable' }).textContent).toMatch(
|
||||
/no safe Automation read data source.*will not invent.*production endpoint/i,
|
||||
expect(screen.getByRole('status', { name: '自动化不可用' }).textContent).toMatch(
|
||||
/没有可用的安全自动化只读数据源.*不会臆造.*生产端点/i,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -143,19 +141,17 @@ describe('isolated Automation read module', () => {
|
||||
const { rerender } = render(
|
||||
<AutomationModule instance={owner} dataSource={source} refreshSignal={0} />,
|
||||
);
|
||||
const counts = await screen.findByRole('region', { name: 'Task counts' });
|
||||
const counts = await screen.findByRole('region', { name: '任务计数' });
|
||||
expect(within(counts).getByText('12')).toBeTruthy();
|
||||
rerender(<AutomationModule instance={owner} dataSource={source} refreshSignal={1} />);
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert.textContent).toMatch(/showing the last known Automation data/i);
|
||||
expect(alert.textContent).toMatch(/正在显示上次已知的自动化数据/i);
|
||||
expect(alert.textContent).not.toMatch(/secret endpoint token/i);
|
||||
expect(within(screen.getByRole('region', { name: '任务计数' })).getByText('12')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '重试加载自动化' }));
|
||||
expect(
|
||||
within(screen.getByRole('region', { name: 'Task counts' })).getByText('12'),
|
||||
).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: 'Retry loading Automation' }));
|
||||
expect(
|
||||
await within(screen.getByRole('region', { name: 'Task counts' })).findByText('13'),
|
||||
await within(screen.getByRole('region', { name: '任务计数' })).findByText('13'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -190,7 +186,7 @@ describe('isolated Automation read module', () => {
|
||||
/>,
|
||||
);
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert.textContent).toContain('Automation data could not be loaded.');
|
||||
expect(alert.textContent).toContain('无法加载自动化数据。');
|
||||
expect(alert.textContent).not.toContain('private automation token');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
import type { InstanceContext } from '../app-shell.js';
|
||||
import { displayValue } from '../ui/locale.js';
|
||||
|
||||
/** Aggregate task counts only; per-task records and configuration are intentionally unsupported. */
|
||||
export interface AutomationTaskCounts {
|
||||
@@ -46,7 +47,7 @@ type ReadState =
|
||||
| { kind: 'ready'; ownerId: string; snapshot: AutomationSnapshot }
|
||||
| { kind: 'error'; ownerId: string; snapshot?: AutomationSnapshot };
|
||||
|
||||
const SAFE_LOAD_ERROR = 'Automation data could not be loaded.';
|
||||
const SAFE_LOAD_ERROR = '无法加载自动化数据。';
|
||||
const STATES = new Set<AutomationState>(['healthy', 'degraded', 'failed', 'disabled', 'unknown']);
|
||||
const SCHEDULERS = new Set<AutomationScheduler>([
|
||||
'active',
|
||||
@@ -138,13 +139,13 @@ function Fields({
|
||||
values: readonly (readonly [string, string | number | null | undefined])[];
|
||||
}) {
|
||||
const supplied = values.filter(([, value]) => value !== undefined);
|
||||
if (!supplied.length) return <p>No aggregate status was supplied.</p>;
|
||||
if (!supplied.length) return <p>未提供汇总状态。</p>;
|
||||
return (
|
||||
<dl>
|
||||
{supplied.map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value == null ? 'Unavailable' : String(value)}</dd>
|
||||
<dd>{displayValue(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
@@ -156,28 +157,28 @@ function SnapshotView({ snapshot }: { snapshot: AutomationSnapshot }) {
|
||||
const tasks = snapshot.tasks;
|
||||
return (
|
||||
<>
|
||||
{snapshot.observedAt ? <p>Observed {snapshot.observedAt}</p> : null}
|
||||
<p>Read-only aggregate view; task details and operational controls are excluded.</p>
|
||||
{snapshot.observedAt ? <p>观测时间:{snapshot.observedAt}</p> : null}
|
||||
<p>此处仅显示只读汇总视图,不包含任务详情和操作控件。</p>
|
||||
<div className="automation-grid">
|
||||
<Section label="Automation status">
|
||||
<Section label="自动化状态">
|
||||
<Fields
|
||||
values={[
|
||||
['State', status.state],
|
||||
['Scheduler', status.scheduler],
|
||||
['Workers', status.workers],
|
||||
['状态', status.state],
|
||||
['调度器', status.scheduler],
|
||||
['工作进程', status.workers],
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
<Section label="Task counts">
|
||||
<Section label="任务计数">
|
||||
<Fields
|
||||
values={[
|
||||
['Total', tasks.total],
|
||||
['Enabled', tasks.enabled],
|
||||
['Disabled', tasks.disabled],
|
||||
['Running', tasks.running],
|
||||
['Queued', tasks.queued],
|
||||
['Succeeded', tasks.succeeded],
|
||||
['Failed', tasks.failed],
|
||||
['总计', tasks.total],
|
||||
['已启用', tasks.enabled],
|
||||
['已禁用', tasks.disabled],
|
||||
['运行中', tasks.running],
|
||||
['排队中', tasks.queued],
|
||||
['成功', tasks.succeeded],
|
||||
['失败', tasks.failed],
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
@@ -238,15 +239,14 @@ export function AutomationModule({ instance, dataSource, refreshSignal }: Automa
|
||||
if (instance.authentication !== 'authenticated')
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
Authentication is required before Automation data can be read for this instance.
|
||||
需要先完成认证,才能读取自动化数据。
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!dataSource)
|
||||
return (
|
||||
<div className="state-panel" role="status" aria-label="Automation unavailable">
|
||||
No safe Automation read data source is available. This console will not invent or call an
|
||||
uncontracted production endpoint.
|
||||
<div className="state-panel" role="status" aria-label="自动化不可用">
|
||||
没有可用的安全自动化只读数据源。此控制台不会臆造或调用未签订契约的生产端点。
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -256,17 +256,17 @@ export function AutomationModule({ instance, dataSource, refreshSignal }: Automa
|
||||
: undefined;
|
||||
if ((state.kind === 'idle' || state.kind === 'loading') && !retained)
|
||||
return (
|
||||
<p role="status" aria-label="Automation loading status">
|
||||
Loading Automation…
|
||||
<p role="status" aria-label="自动化加载状态">
|
||||
正在加载自动化数据…
|
||||
</p>
|
||||
);
|
||||
|
||||
if (state.kind === 'error' && !retained)
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Unable to load Automation: {SAFE_LOAD_ERROR}</p>
|
||||
<p>{SAFE_LOAD_ERROR}</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading Automation
|
||||
重试加载自动化
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -276,18 +276,18 @@ export function AutomationModule({ instance, dataSource, refreshSignal }: Automa
|
||||
if (!snapshot) return null;
|
||||
return (
|
||||
<div className="automation-module">
|
||||
{state.kind === 'loading' ? <p role="status">Refreshing Automation…</p> : null}
|
||||
{state.kind === 'loading' ? <p role="status">正在刷新自动化数据…</p> : null}
|
||||
{state.kind === 'error' ? (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Refresh failed; showing the last known Automation data.</p>
|
||||
<p>刷新失败,正在显示上次已知的自动化数据。</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading Automation
|
||||
重试加载自动化
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{instance.freshness !== 'fresh' ? (
|
||||
<p className="state-panel" role="status" aria-label="Automation freshness">
|
||||
Automation data is {instance.freshness}; verify freshness before relying on these values.
|
||||
<p className="state-panel" role="status" aria-label="自动化数据时效性">
|
||||
自动化数据可能不是最新数据,请核实时效性后再使用这些数值。
|
||||
</p>
|
||||
) : null}
|
||||
<SnapshotView snapshot={snapshot} />
|
||||
|
||||
@@ -40,16 +40,16 @@ describe('isolated Calls read module', () => {
|
||||
it('loads only through the injected source and renders aggregate call and device status', async () => {
|
||||
const pending = deferredSource();
|
||||
render(<CallsModule instance={owner} dataSource={pending.source} />);
|
||||
expect(screen.getByRole('status', { name: 'Calls loading status' })).toBeTruthy();
|
||||
expect(screen.getByRole('status', { name: '通话加载状态' })).toBeTruthy();
|
||||
expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
|
||||
pending.resolve(snapshot);
|
||||
|
||||
const calls = await screen.findByRole('region', { name: 'Call status' });
|
||||
const devices = screen.getByRole('region', { name: 'Device status' });
|
||||
expect(within(calls).getByText('active')).toBeTruthy();
|
||||
const calls = await screen.findByRole('region', { name: '通话状态' });
|
||||
const devices = screen.getByRole('region', { name: '设备状态' });
|
||||
expect(within(calls).getByText('活跃')).toBeTruthy();
|
||||
expect(within(calls).getByText('2')).toBeTruthy();
|
||||
expect(within(devices).getByText('available')).toBeTruthy();
|
||||
expect(screen.getByText(/Observed 2026-07-17T12:30:00Z/)).toBeTruthy();
|
||||
expect(within(devices).getByText('可用')).toBeTruthy();
|
||||
expect(screen.getByText(/观测时间:2026-07-17T12:30:00Z/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('strictly allowlists aggregate fields and exposes no phone numbers, audio, logs, or controls', async () => {
|
||||
@@ -62,7 +62,7 @@ describe('isolated Calls read module', () => {
|
||||
devices: { ...snapshot.devices, phoneNumber: '+1-555-0102', audioUrl: 'secret-audio-url' },
|
||||
} as CallsSnapshot;
|
||||
render(<CallsModule instance={owner} dataSource={{ load: async () => unsafe }} />);
|
||||
expect(await screen.findByText('available')).toBeTruthy();
|
||||
expect(await screen.findByText('可用')).toBeTruthy();
|
||||
expect(document.body.textContent).not.toMatch(/555|private|secret-audio/i);
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
@@ -75,12 +75,12 @@ describe('isolated Calls read module', () => {
|
||||
dataSource={{ load }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/需要先完成认证/i);
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
|
||||
rerender(<CallsModule instance={owner} />);
|
||||
expect(screen.getByRole('status', { name: 'Calls unavailable' }).textContent).toMatch(
|
||||
/no safe calls read data source.*will not invent.*production endpoint/i,
|
||||
expect(screen.getByRole('status', { name: '通话不可用' }).textContent).toMatch(
|
||||
/没有可用的安全通话只读数据源.*不会臆造.*生产端点/i,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -95,16 +95,16 @@ describe('isolated Calls read module', () => {
|
||||
const { rerender } = render(
|
||||
<CallsModule instance={owner} dataSource={source} refreshSignal={0} />,
|
||||
);
|
||||
expect(await screen.findByText('available')).toBeTruthy();
|
||||
expect(await screen.findByText('可用')).toBeTruthy();
|
||||
rerender(<CallsModule instance={owner} dataSource={source} refreshSignal={1} />);
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert.textContent).toMatch(/showing the last known Calls data/i);
|
||||
expect(alert.textContent).toMatch(/正在显示上次已知的通话数据/i);
|
||||
expect(alert.textContent).not.toMatch(/secret endpoint|telephone/i);
|
||||
expect(screen.getByText('available')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: 'Retry loading Calls' }));
|
||||
expect(screen.getByText('可用')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '重试加载通话' }));
|
||||
expect(
|
||||
await within(screen.getByRole('region', { name: 'Call status' })).findByText('3'),
|
||||
await within(screen.getByRole('region', { name: '通话状态' })).findByText('3'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
import type { InstanceContext } from '../app-shell.js';
|
||||
import { displayValue } from '../ui/locale.js';
|
||||
|
||||
/** Bounded values permitted in aggregate call/device status. */
|
||||
export type CallsStatusValue = string | number | boolean | null;
|
||||
@@ -49,23 +50,23 @@ type ReadState =
|
||||
| { kind: 'ready'; snapshot: OwnedSnapshot }
|
||||
| { kind: 'error'; snapshot?: OwnedSnapshot };
|
||||
|
||||
const SAFE_LOAD_ERROR = 'Calls data could not be loaded.';
|
||||
const SAFE_LOAD_ERROR = '无法加载通话数据。';
|
||||
|
||||
const CALL_FIELDS = [
|
||||
['State', 'state'],
|
||||
['Total', 'total'],
|
||||
['Active', 'active'],
|
||||
['Ringing', 'ringing'],
|
||||
['Held', 'held'],
|
||||
['Failed', 'failed'],
|
||||
['状态', 'state'],
|
||||
['总计', 'total'],
|
||||
['活动中', 'active'],
|
||||
['响铃中', 'ringing'],
|
||||
['保持中', 'held'],
|
||||
['失败', 'failed'],
|
||||
] as const;
|
||||
|
||||
const DEVICE_FIELDS = [
|
||||
['State', 'state'],
|
||||
['Total', 'total'],
|
||||
['Online', 'online'],
|
||||
['Offline', 'offline'],
|
||||
['Busy', 'busy'],
|
||||
['状态', 'state'],
|
||||
['总计', 'total'],
|
||||
['在线', 'online'],
|
||||
['离线', 'offline'],
|
||||
['忙碌', 'busy'],
|
||||
] as const;
|
||||
|
||||
function Section({ label, children }: { label: string; children: ReactNode }) {
|
||||
@@ -86,13 +87,13 @@ function AggregateFields({
|
||||
}) {
|
||||
const safeValues = values as Readonly<Record<string, CallsStatusValue | undefined>>;
|
||||
const supplied = fields.filter(([, key]) => safeValues[key] !== undefined);
|
||||
if (!supplied.length) return <p>No aggregate status was supplied.</p>;
|
||||
if (!supplied.length) return <p>未提供汇总状态。</p>;
|
||||
return (
|
||||
<dl>
|
||||
{supplied.map(([label, key]) => (
|
||||
<div key={key}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{safeValues[key] == null ? 'Unavailable' : String(safeValues[key])}</dd>
|
||||
<dd>{displayValue(safeValues[key])}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
@@ -102,12 +103,12 @@ function AggregateFields({
|
||||
function SnapshotView({ snapshot }: { snapshot: CallsSnapshot }) {
|
||||
return (
|
||||
<>
|
||||
{snapshot.observedAt ? <p>Observed {snapshot.observedAt}</p> : null}
|
||||
{snapshot.observedAt ? <p>观测时间:{snapshot.observedAt}</p> : null}
|
||||
<div className="calls-grid">
|
||||
<Section label="Call status">
|
||||
<Section label="通话状态">
|
||||
<AggregateFields values={snapshot.calls} fields={CALL_FIELDS} />
|
||||
</Section>
|
||||
<Section label="Device status">
|
||||
<Section label="设备状态">
|
||||
<AggregateFields values={snapshot.devices} fields={DEVICE_FIELDS} />
|
||||
</Section>
|
||||
</div>
|
||||
@@ -162,15 +163,14 @@ export function CallsModule({ instance, dataSource, refreshSignal }: CallsModule
|
||||
if (instance.authentication !== 'authenticated')
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
Authentication is required before Calls data can be read for this instance.
|
||||
需要先完成认证,才能读取通话数据。
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!dataSource)
|
||||
return (
|
||||
<div className="state-panel" role="status" aria-label="Calls unavailable">
|
||||
No safe Calls read data source is available. This console will not invent or call an
|
||||
uncontracted production endpoint.
|
||||
<div className="state-panel" role="status" aria-label="通话不可用">
|
||||
没有可用的安全通话只读数据源。此控制台不会臆造或调用未签订契约的生产端点。
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -179,17 +179,17 @@ export function CallsModule({ instance, dataSource, refreshSignal }: CallsModule
|
||||
|
||||
if ((state.kind === 'idle' || state.kind === 'loading') && !snapshot)
|
||||
return (
|
||||
<p role="status" aria-label="Calls loading status">
|
||||
Loading Calls…
|
||||
<p role="status" aria-label="通话加载状态">
|
||||
正在加载通话数据…
|
||||
</p>
|
||||
);
|
||||
|
||||
if (state.kind === 'error' && !snapshot)
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Unable to load Calls: {SAFE_LOAD_ERROR}</p>
|
||||
<p>{SAFE_LOAD_ERROR}</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading Calls
|
||||
重试加载通话
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -198,18 +198,18 @@ export function CallsModule({ instance, dataSource, refreshSignal }: CallsModule
|
||||
|
||||
return (
|
||||
<div className="calls-module">
|
||||
{state.kind === 'loading' ? <p role="status">Refreshing Calls…</p> : null}
|
||||
{state.kind === 'loading' ? <p role="status">正在刷新通话数据…</p> : null}
|
||||
{state.kind === 'error' ? (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Refresh failed; showing the last known Calls data.</p>
|
||||
<p>刷新失败,正在显示上次已知的通话数据。</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading Calls
|
||||
重试加载通话
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{instance.freshness !== 'fresh' ? (
|
||||
<p className="state-panel" role="status" aria-label="Calls freshness">
|
||||
Calls data is {instance.freshness}; verify freshness before relying on these values.
|
||||
<p className="state-panel" role="status" aria-label="通话数据时效性">
|
||||
通话数据可能不是最新数据,请核实时效性后再使用这些数值。
|
||||
</p>
|
||||
) : null}
|
||||
<SnapshotView snapshot={snapshot.value} />
|
||||
|
||||
@@ -23,7 +23,7 @@ const owner: InstanceContext = {
|
||||
|
||||
const snapshot: CellularSnapshot = {
|
||||
observedAt: '2026-07-17T12:00:00Z',
|
||||
networkRegistration: { state: 'registered', roaming: false },
|
||||
networkRegistration: { state: 'online', roaming: false },
|
||||
signal: { rssi: '-71 dBm', quality: 82 },
|
||||
cellsLocation: { cell: '12345', area: 19, latitude: null },
|
||||
operators: { current: 'Example Mobile', available: 3 },
|
||||
@@ -54,27 +54,30 @@ describe('Phase 6.2 Cellular read module', () => {
|
||||
const pending = deferredSource();
|
||||
render(<CellularModule instance={owner} dataSource={pending.source} />);
|
||||
|
||||
expect(screen.getByRole('status', { name: 'Cellular loading status' })).toBeTruthy();
|
||||
expect(screen.getByRole('status', { name: '蜂窝网络加载状态' })).toBeTruthy();
|
||||
expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
|
||||
pending.resolve(snapshot);
|
||||
|
||||
for (const heading of ['Network registration', 'Signal', 'Cells / location', 'Operators']) {
|
||||
for (const heading of ['网络注册', '信号', '小区与位置', '运营商']) {
|
||||
expect(await screen.findByRole('heading', { name: heading })).toBeTruthy();
|
||||
}
|
||||
expect(
|
||||
within(screen.getByRole('region', { name: 'Signal' })).getByText('-71 dBm'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('Unavailable')).toBeTruthy();
|
||||
expect(screen.getByText(/Observed 2026-07-17T12:00:00Z/)).toBeTruthy();
|
||||
expect(within(screen.getByRole('region', { name: '信号' })).getByText('-71 dBm')).toBeTruthy();
|
||||
const registration = screen.getByRole('region', { name: '网络注册' });
|
||||
expect(within(registration).getByText('在线')).toBeTruthy();
|
||||
expect(within(registration).getByText('否')).toBeTruthy();
|
||||
expect(within(registration).queryByText('online')).toBeNull();
|
||||
expect(within(registration).queryByText('false')).toBeNull();
|
||||
expect(screen.getByText('不可用')).toBeTruthy();
|
||||
expect(screen.getByText(/观测时间:2026-07-17T12:00:00Z/)).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /register|operator|network/i })).toBeNull();
|
||||
expect(screen.getByText(/automatic network registration is available only/i)).toBeTruthy();
|
||||
expect(screen.getByText(/audited R2 prepare, confirm, and execute flow/i)).toBeTruthy();
|
||||
expect(screen.getByText(/自动网络注册仅可通过/i)).toBeTruthy();
|
||||
expect(screen.getByText(/经审计的 R2 准备、确认和执行流程/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('is honest when no read source is injected', () => {
|
||||
render(<CellularModule instance={owner} />);
|
||||
expect(screen.getByRole('status', { name: 'Cellular unavailable' }).textContent).toMatch(
|
||||
/no safe cellular read data source.*will not invent.*production endpoint/i,
|
||||
expect(screen.getByRole('status', { name: '蜂窝网络不可用' }).textContent).toMatch(
|
||||
/没有可用的安全蜂窝网络只读数据源.*不会臆造.*生产端点/i,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -95,7 +98,7 @@ describe('Phase 6.2 Cellular read module', () => {
|
||||
dataSource={{ load }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/需要先完成认证/i);
|
||||
expect(screen.queryByText('-71 dBm')).toBeNull();
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -114,13 +117,11 @@ describe('Phase 6.2 Cellular read module', () => {
|
||||
expect(await screen.findByText('-71 dBm')).toBeTruthy();
|
||||
|
||||
rerender(<CellularModule instance={owner} dataSource={source} refreshSignal={1} />);
|
||||
expect(
|
||||
await screen.findByText(/Refresh failed; showing the last known cellular data/i),
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByText(/刷新失败,正在显示上次已知的蜂窝网络数据/i)).toBeTruthy();
|
||||
expect(screen.getByText('-71 dBm')).toBeTruthy();
|
||||
expect(screen.queryByText(/secret upstream details/i)).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Retry loading cellular data' }));
|
||||
await user.click(screen.getByRole('button', { name: '重试加载蜂窝网络数据' }));
|
||||
expect(await screen.findByText('-65 dBm')).toBeTruthy();
|
||||
expect(load).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
@@ -134,9 +135,9 @@ describe('Phase 6.2 Cellular read module', () => {
|
||||
render(<CellularModule instance={owner} dataSource={{ load }} />);
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert.textContent).toMatch(/Cellular data could not be loaded/i);
|
||||
expect(alert.textContent).toMatch(/无法加载蜂窝网络数据/i);
|
||||
expect(alert.textContent).not.toMatch(/private failure/i);
|
||||
await user.click(screen.getByRole('button', { name: 'Retry loading cellular data' }));
|
||||
await user.click(screen.getByRole('button', { name: '重试加载蜂窝网络数据' }));
|
||||
expect(await screen.findByText('-71 dBm')).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { InstanceContext } from '../app-shell.js';
|
||||
import { displayValue } from '../ui/locale.js';
|
||||
|
||||
/** Cellular data is deliberately limited to display-safe primitive values. */
|
||||
export type CellularFieldValue = string | number | boolean | null;
|
||||
@@ -59,42 +60,42 @@ type ReadState =
|
||||
const SECTIONS = [
|
||||
[
|
||||
'networkRegistration',
|
||||
'Network registration',
|
||||
'网络注册',
|
||||
[
|
||||
['State', 'state'],
|
||||
['Mode', 'mode'],
|
||||
['Operator', 'operator'],
|
||||
['Roaming', 'roaming'],
|
||||
['状态', 'state', true],
|
||||
['模式', 'mode', true],
|
||||
['运营商', 'operator'],
|
||||
['漫游', 'roaming', true],
|
||||
],
|
||||
],
|
||||
[
|
||||
'signal',
|
||||
'Signal',
|
||||
'信号',
|
||||
[
|
||||
['RSSI', 'rssi'],
|
||||
['RSRP', 'rsrp'],
|
||||
['RSRQ', 'rsrq'],
|
||||
['SINR', 'sinr'],
|
||||
['Quality', 'quality'],
|
||||
['质量', 'quality', true],
|
||||
],
|
||||
],
|
||||
[
|
||||
'cellsLocation',
|
||||
'Cells / location',
|
||||
'小区与位置',
|
||||
[
|
||||
['Cell', 'cell'],
|
||||
['Area', 'area'],
|
||||
['Technology', 'technology'],
|
||||
['Latitude', 'latitude'],
|
||||
['Longitude', 'longitude'],
|
||||
['小区', 'cell'],
|
||||
['区域', 'area'],
|
||||
['技术', 'technology', true],
|
||||
['纬度', 'latitude'],
|
||||
['经度', 'longitude'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'operators',
|
||||
'Operators',
|
||||
'运营商',
|
||||
[
|
||||
['Current', 'current'],
|
||||
['Available', 'available'],
|
||||
['当前', 'current'],
|
||||
['可用', 'available'],
|
||||
],
|
||||
],
|
||||
] as const;
|
||||
@@ -106,7 +107,7 @@ function StructuredSection({
|
||||
}: {
|
||||
label: string;
|
||||
values: object;
|
||||
fields: readonly (readonly [string, string])[];
|
||||
fields: readonly (readonly [string, string, boolean?])[];
|
||||
}) {
|
||||
const safeValues = values as Readonly<Record<string, CellularFieldValue | undefined>>;
|
||||
const entries = fields.filter(([, key]) => safeValues[key] !== undefined);
|
||||
@@ -115,18 +116,20 @@ function StructuredSection({
|
||||
<h2>{label}</h2>
|
||||
{entries.length ? (
|
||||
<dl>
|
||||
{entries.map(([labelText, fieldKey]) => {
|
||||
{entries.map(([labelText, fieldKey, localized]) => {
|
||||
const value = safeValues[fieldKey];
|
||||
return (
|
||||
<div key={fieldKey}>
|
||||
<dt>{labelText}</dt>
|
||||
<dd>{value == null ? 'Unavailable' : String(value)}</dd>
|
||||
<dd>
|
||||
{localized ? displayValue(value) : value == null ? '不可用' : String(value)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
) : (
|
||||
<p>No {label.toLocaleLowerCase()} data was supplied.</p>
|
||||
<p>未提供{label}数据。</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
@@ -169,7 +172,7 @@ export function CellularModule({ instance, dataSource, refreshSignal }: Cellular
|
||||
: undefined;
|
||||
return {
|
||||
kind: 'error',
|
||||
message: 'Cellular data could not be loaded.',
|
||||
message: '无法加载蜂窝网络数据。',
|
||||
...(snapshot ? { snapshot } : {}),
|
||||
};
|
||||
});
|
||||
@@ -182,16 +185,15 @@ export function CellularModule({ instance, dataSource, refreshSignal }: Cellular
|
||||
if (instance.authentication !== 'authenticated') {
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
Authentication is required before cellular data can be read for this instance.
|
||||
需要先完成认证,才能读取蜂窝网络数据。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dataSource) {
|
||||
return (
|
||||
<div className="state-panel" role="status" aria-label="Cellular unavailable">
|
||||
No safe cellular read data source is available. This console will not invent or call an
|
||||
uncontracted production endpoint.
|
||||
<div className="state-panel" role="status" aria-label="蜂窝网络不可用">
|
||||
没有可用的安全蜂窝网络只读数据源。此控制台不会臆造或调用未签订契约的生产端点。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -201,8 +203,8 @@ export function CellularModule({ instance, dataSource, refreshSignal }: Cellular
|
||||
|
||||
if ((state.kind === 'idle' || state.kind === 'loading') && !ownedSnapshot) {
|
||||
return (
|
||||
<p role="status" aria-label="Cellular loading status">
|
||||
Loading cellular data…
|
||||
<p role="status" aria-label="蜂窝网络加载状态">
|
||||
正在加载蜂窝网络数据…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -210,9 +212,9 @@ export function CellularModule({ instance, dataSource, refreshSignal }: Cellular
|
||||
if (state.kind === 'error' && !ownedSnapshot) {
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Unable to load cellular data: {state.message}</p>
|
||||
<p>{state.message}</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading cellular data
|
||||
重试加载蜂窝网络数据
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -223,32 +225,31 @@ export function CellularModule({ instance, dataSource, refreshSignal }: Cellular
|
||||
|
||||
return (
|
||||
<div className="cellular-module">
|
||||
{state.kind === 'loading' ? <p role="status">Refreshing cellular data…</p> : null}
|
||||
{state.kind === 'loading' ? <p role="status">正在刷新蜂窝网络数据…</p> : null}
|
||||
{state.kind === 'error' ? (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Refresh failed; showing the last known cellular data.</p>
|
||||
<p>刷新失败,正在显示上次已知的蜂窝网络数据。</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading cellular data
|
||||
重试加载蜂窝网络数据
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{instance.freshness !== 'fresh' ? (
|
||||
<p className="state-panel" role="status" aria-label="Cellular freshness">
|
||||
Cellular data is {instance.freshness}; verify freshness before relying on these values.
|
||||
<p className="state-panel" role="status" aria-label="蜂窝网络数据新鲜度">
|
||||
蜂窝网络数据可能已过期;使用这些值前请核实数据新鲜度。
|
||||
</p>
|
||||
) : null}
|
||||
{snapshot.observedAt ? <p>Observed {snapshot.observedAt}</p> : null}
|
||||
{snapshot.observedAt ? <p>观测时间:{snapshot.observedAt}</p> : null}
|
||||
<div className="cellular-grid">
|
||||
{SECTIONS.map(([key, label, fields]) => (
|
||||
<StructuredSection key={key} label={label} values={snapshot[key]} fields={fields} />
|
||||
))}
|
||||
</div>
|
||||
<section className="state-panel" aria-label="Network registration operations">
|
||||
<h2>Network registration operations</h2>
|
||||
<section className="state-panel" aria-label="网络注册操作">
|
||||
<h2>网络注册操作</h2>
|
||||
<p>
|
||||
Automatic network registration is available only through the audited R2 prepare, confirm,
|
||||
and execute flow. Manual registration and monitoring controls remain unavailable. This
|
||||
read-only panel does not bypass those gates.
|
||||
自动网络注册仅可通过经审计的 R2 准备、确认和执行流程进行。手动注册和监控控件仍不可用。
|
||||
此只读面板不会绕过这些限制。
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -26,8 +26,8 @@ const snapshot: DeviceNetworkSnapshot = {
|
||||
wlan: {
|
||||
status: {
|
||||
enabled: true,
|
||||
radioState: 'on',
|
||||
connectionState: 'connected',
|
||||
radioState: 'enabled',
|
||||
connectionState: 'active',
|
||||
activeProfile: 'office',
|
||||
ssid: 'Operations Wi-Fi',
|
||||
},
|
||||
@@ -101,22 +101,30 @@ describe('Phase 6.3 Device Network read module', () => {
|
||||
const pending = deferredSource();
|
||||
render(<DeviceNetworkModule instance={owner} dataSource={pending.source} />);
|
||||
|
||||
expect(screen.getByRole('status', { name: 'Device Network loading status' })).toBeTruthy();
|
||||
expect(screen.getByRole('status', { name: '设备网络加载状态' })).toBeTruthy();
|
||||
expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
|
||||
pending.resolve(snapshot);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'WLAN status' })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: 'WLAN profiles' })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: 'Interfaces and addresses' })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: 'DDNS status' })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: 'DDNS configuration' })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: 'DDNS log summary' })).toBeTruthy();
|
||||
expect(await screen.findByRole('heading', { name: 'WLAN 状态' })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: 'WLAN 配置文件' })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: '接口与地址' })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: 'DDNS 状态' })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: 'DDNS 配置' })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: 'DDNS 日志摘要' })).toBeTruthy();
|
||||
expect(
|
||||
within(screen.getByRole('region', { name: 'WLAN profiles' })).getByText('WPA3'),
|
||||
within(screen.getByRole('region', { name: 'WLAN 配置文件' })).getByText('WPA3'),
|
||||
).toBeTruthy();
|
||||
const wlanStatus = screen.getByRole('region', { name: 'WLAN 状态' });
|
||||
expect(within(wlanStatus).getAllByText('已启用')).toHaveLength(2);
|
||||
expect(within(wlanStatus).getByText('活跃')).toBeTruthy();
|
||||
expect(within(wlanStatus).getByText('是')).toBeTruthy();
|
||||
expect(within(wlanStatus).queryByText('enabled')).toBeNull();
|
||||
expect(within(wlanStatus).queryByText('active')).toBeNull();
|
||||
expect(within(wlanStatus).queryByText('true')).toBeNull();
|
||||
|
||||
expect(screen.getByText('192.0.2.10/24')).toBeTruthy();
|
||||
expect(screen.getByText('gateway.example.test')).toBeTruthy();
|
||||
expect(screen.getByText(/Observed 2026-07-17T11:00:00Z/)).toBeTruthy();
|
||||
expect(screen.getByText(/观测时间:2026-07-17T11:00:00Z/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not render passwords, credentials, arbitrary fields, log bodies, or write controls', async () => {
|
||||
@@ -127,14 +135,14 @@ describe('Phase 6.3 Device Network read module', () => {
|
||||
expect(document.body.textContent).not.toContain('private-password');
|
||||
expect(screen.queryByText(/password/i)).toBeNull();
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
expect(screen.getByText(/R1.*unavailable.*executable backend support/i)).toBeTruthy();
|
||||
expect(screen.getByText(/R2.*unavailable.*executable backend support/i)).toBeTruthy();
|
||||
expect(screen.getByText(/可执行后端支持.*R1.*不可用/i)).toBeTruthy();
|
||||
expect(screen.getByText(/可执行后端支持.*R2.*不可用/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('is honest when no source is injected and does not invent an endpoint', () => {
|
||||
render(<DeviceNetworkModule instance={owner} />);
|
||||
expect(screen.getByRole('status', { name: 'Device Network unavailable' }).textContent).toMatch(
|
||||
/no safe device network read data source.*uncontracted production endpoint/i,
|
||||
expect(screen.getByRole('status', { name: '设备网络不可用' }).textContent).toMatch(
|
||||
/没有可用的安全设备网络只读数据源.*未签订契约的生产端点/i,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -155,7 +163,7 @@ describe('Phase 6.3 Device Network read module', () => {
|
||||
dataSource={{ load }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/需要先完成认证/i);
|
||||
expect(screen.queryByText('gateway.example.test')).toBeNull();
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -169,9 +177,9 @@ describe('Phase 6.3 Device Network read module', () => {
|
||||
render(<DeviceNetworkModule instance={owner} dataSource={{ load }} />);
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert.textContent).toContain('Device Network data could not be loaded.');
|
||||
expect(alert.textContent).toContain('无法加载设备网络数据。');
|
||||
expect(alert.textContent).not.toContain('secret upstream');
|
||||
await user.click(screen.getByRole('button', { name: 'Retry loading Device Network' }));
|
||||
await user.click(screen.getByRole('button', { name: '重试加载设备网络' }));
|
||||
expect(await screen.findByText('gateway.example.test')).toBeTruthy();
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -193,9 +201,9 @@ describe('Phase 6.3 Device Network read module', () => {
|
||||
expect(await screen.findByText('gateway.example.test')).toBeTruthy();
|
||||
|
||||
rerender(<DeviceNetworkModule instance={owner} dataSource={source} refreshSignal={1} />);
|
||||
expect((await screen.findByRole('alert')).textContent).toMatch(/showing the last known/i);
|
||||
expect((await screen.findByRole('alert')).textContent).toMatch(/正在显示上次已知/i);
|
||||
expect(screen.getByText('gateway.example.test')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: 'Retry loading Device Network' }));
|
||||
await user.click(screen.getByRole('button', { name: '重试加载设备网络' }));
|
||||
expect(await screen.findByText('new.example.test')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -250,9 +258,9 @@ describe('Phase 6.3 Device Network read module', () => {
|
||||
dataSource={{ load: async () => snapshot }}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
(await screen.findByRole('status', { name: 'Device Network freshness' })).textContent,
|
||||
).toMatch(/stale/i);
|
||||
expect((await screen.findByRole('status', { name: '设备网络数据新鲜度' })).textContent).toMatch(
|
||||
/可能已过期/i,
|
||||
);
|
||||
expect(screen.getByText('gateway.example.test')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
import type { InstanceContext } from '../app-shell.js';
|
||||
import { displayValue } from '../ui/locale.js';
|
||||
|
||||
/** Only these bounded primitives may cross the injected read boundary into the UI. */
|
||||
export type DeviceNetworkValue = string | number | boolean | null;
|
||||
@@ -90,23 +91,27 @@ type ReadState =
|
||||
| { kind: 'ready'; ownerId: string; snapshot: DeviceNetworkSnapshot }
|
||||
| { kind: 'error'; ownerId: string; snapshot?: DeviceNetworkSnapshot };
|
||||
|
||||
const SAFE_LOAD_ERROR = 'Device Network data could not be loaded.';
|
||||
const SAFE_LOAD_ERROR = '无法加载设备网络数据。';
|
||||
|
||||
function display(value: DeviceNetworkValue | undefined): string {
|
||||
return value == null ? 'Unavailable' : String(value);
|
||||
function displayRaw(value: DeviceNetworkValue | undefined): string {
|
||||
return value == null ? '不可用' : String(value);
|
||||
}
|
||||
|
||||
function Fields({
|
||||
values,
|
||||
}: {
|
||||
values: readonly (readonly [label: string, value: DeviceNetworkValue | undefined])[];
|
||||
values: readonly (readonly [
|
||||
label: string,
|
||||
value: DeviceNetworkValue | undefined,
|
||||
localized?: boolean,
|
||||
])[];
|
||||
}) {
|
||||
return (
|
||||
<dl>
|
||||
{values.map(([label, value]) => (
|
||||
{values.map(([label, value, localized]) => (
|
||||
<div key={label}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{display(value)}</dd>
|
||||
<dd>{localized ? displayValue(value) : displayRaw(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
@@ -129,115 +134,115 @@ function SnapshotView({ snapshot }: { snapshot: DeviceNetworkSnapshot }) {
|
||||
const logs = snapshot.ddns.logSummary;
|
||||
return (
|
||||
<>
|
||||
{snapshot.observedAt ? <p>Observed {snapshot.observedAt}</p> : null}
|
||||
{snapshot.observedAt ? <p>观测时间:{snapshot.observedAt}</p> : null}
|
||||
<div className="device-network-grid">
|
||||
<Section label="WLAN status">
|
||||
<Section label="WLAN 状态">
|
||||
<Fields
|
||||
values={[
|
||||
['Enabled', status.enabled],
|
||||
['Radio state', status.radioState],
|
||||
['Connection state', status.connectionState],
|
||||
['Active profile', status.activeProfile],
|
||||
['已启用', status.enabled, true],
|
||||
['无线电状态', status.radioState, true],
|
||||
['连接状态', status.connectionState, true],
|
||||
['活动配置文件', status.activeProfile],
|
||||
['SSID', status.ssid],
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
<Section label="WLAN profiles">
|
||||
<Section label="WLAN 配置文件">
|
||||
{snapshot.wlan.profiles.length ? (
|
||||
snapshot.wlan.profiles.map((profile, index) => (
|
||||
<article key={`${profile.name ?? 'profile'}-${index}`}>
|
||||
<Fields
|
||||
values={[
|
||||
['Name', profile.name],
|
||||
['名称', profile.name],
|
||||
['SSID', profile.ssid],
|
||||
['Security', profile.security],
|
||||
['Enabled', profile.enabled],
|
||||
['Priority', profile.priority],
|
||||
['安全性', profile.security, true],
|
||||
['已启用', profile.enabled, true],
|
||||
['优先级', profile.priority],
|
||||
]}
|
||||
/>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<p>No WLAN profiles were supplied.</p>
|
||||
<p>未提供 WLAN 配置。</p>
|
||||
)}
|
||||
</Section>
|
||||
<Section label="Interfaces and addresses">
|
||||
<Section label="接口与地址">
|
||||
{snapshot.interfaces.length ? (
|
||||
snapshot.interfaces.map((networkInterface, index) => (
|
||||
<article key={`${networkInterface.name ?? 'interface'}-${index}`}>
|
||||
<Fields
|
||||
values={[
|
||||
['Name', networkInterface.name],
|
||||
['Kind', networkInterface.kind],
|
||||
['State', networkInterface.state],
|
||||
['MAC address', networkInterface.macAddress],
|
||||
['名称', networkInterface.name],
|
||||
['类型', networkInterface.kind, true],
|
||||
['状态', networkInterface.state, true],
|
||||
['MAC 地址', networkInterface.macAddress],
|
||||
['MTU', networkInterface.mtu],
|
||||
]}
|
||||
/>
|
||||
<h3>Addresses</h3>
|
||||
<h3>地址</h3>
|
||||
{networkInterface.addresses.length ? (
|
||||
<ul>
|
||||
{networkInterface.addresses.map((address, addressIndex) => (
|
||||
<li key={`${address.address ?? 'address'}-${addressIndex}`}>
|
||||
<Fields
|
||||
values={[
|
||||
['Family', address.family],
|
||||
['地址族', address.family, true],
|
||||
[
|
||||
'Address',
|
||||
'地址',
|
||||
address.address == null
|
||||
? null
|
||||
: address.prefixLength == null
|
||||
? address.address
|
||||
: `${address.address}/${address.prefixLength}`,
|
||||
],
|
||||
['Scope', address.scope],
|
||||
['作用域', address.scope, true],
|
||||
]}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>No addresses were supplied.</p>
|
||||
<p>未提供地址。</p>
|
||||
)}
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<p>No network interfaces were supplied.</p>
|
||||
<p>未提供网络接口。</p>
|
||||
)}
|
||||
</Section>
|
||||
<Section label="DDNS status">
|
||||
<Section label="DDNS 状态">
|
||||
<Fields
|
||||
values={[
|
||||
['Enabled', ddnsStatus.enabled],
|
||||
['State', ddnsStatus.state],
|
||||
['Last update', ddnsStatus.lastUpdateAt],
|
||||
['已启用', ddnsStatus.enabled, true],
|
||||
['状态', ddnsStatus.state, true],
|
||||
['最近更新', ddnsStatus.lastUpdateAt],
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
<Section label="DDNS configuration">
|
||||
<Section label="DDNS 配置">
|
||||
<Fields
|
||||
values={[
|
||||
['Provider', config.provider],
|
||||
['Hostname', config.hostname],
|
||||
['Update interval (seconds)', config.updateIntervalSeconds],
|
||||
['服务商', config.provider],
|
||||
['主机名', config.hostname],
|
||||
['更新间隔(秒)', config.updateIntervalSeconds],
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
<Section label="DDNS log summary">
|
||||
<Section label="DDNS 日志摘要">
|
||||
<Fields
|
||||
values={[
|
||||
['Total entries', logs.totalEntries],
|
||||
['Successful updates', logs.successfulUpdates],
|
||||
['Failed updates', logs.failedUpdates],
|
||||
['Last event', logs.lastEventAt],
|
||||
['条目总数', logs.totalEntries],
|
||||
['成功更新', logs.successfulUpdates],
|
||||
['失败更新', logs.failedUpdates],
|
||||
['最近事件', logs.lastEventAt],
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
<section className="state-panel" aria-label="Device Network actions">
|
||||
<h2>Device Network actions</h2>
|
||||
<p>R1 configuration actions are unavailable until executable backend support exists.</p>
|
||||
<p>R2 operational actions are unavailable until executable backend support exists.</p>
|
||||
<section className="state-panel" aria-label="设备网络操作">
|
||||
<h2>设备网络操作</h2>
|
||||
<p>在具备可执行后端支持前,R1 配置操作不可用。</p>
|
||||
<p>在具备可执行后端支持前,R2 运行操作不可用。</p>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
@@ -294,16 +299,15 @@ export function DeviceNetworkModule({
|
||||
if (instance.authentication !== 'authenticated') {
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
Authentication is required before Device Network data can be read for this instance.
|
||||
需要先完成认证,才能读取设备网络数据。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dataSource)
|
||||
return (
|
||||
<div className="state-panel" role="status" aria-label="Device Network unavailable">
|
||||
No safe Device Network read data source is available. This console will not invent or call
|
||||
an uncontracted production endpoint.
|
||||
<div className="state-panel" role="status" aria-label="设备网络不可用">
|
||||
没有可用的安全设备网络只读数据源。此控制台不会臆造或调用未签订契约的生产端点。
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -314,17 +318,17 @@ export function DeviceNetworkModule({
|
||||
|
||||
if ((state.kind === 'idle' || state.kind === 'loading') && !retainedSnapshot)
|
||||
return (
|
||||
<p role="status" aria-label="Device Network loading status">
|
||||
Loading Device Network…
|
||||
<p role="status" aria-label="设备网络加载状态">
|
||||
正在加载设备网络…
|
||||
</p>
|
||||
);
|
||||
|
||||
if (state.kind === 'error' && !retainedSnapshot)
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Unable to load Device Network: {SAFE_LOAD_ERROR}</p>
|
||||
<p>{SAFE_LOAD_ERROR}</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading Device Network
|
||||
重试加载设备网络
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -335,19 +339,18 @@ export function DeviceNetworkModule({
|
||||
|
||||
return (
|
||||
<div className="device-network-module">
|
||||
{state.kind === 'loading' ? <p role="status">Refreshing Device Network…</p> : null}
|
||||
{state.kind === 'loading' ? <p role="status">正在刷新设备网络…</p> : null}
|
||||
{state.kind === 'error' ? (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Refresh failed; showing the last known Device Network data.</p>
|
||||
<p>刷新失败,正在显示上次已知的设备网络数据。</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading Device Network
|
||||
重试加载设备网络
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{instance.freshness !== 'fresh' ? (
|
||||
<p className="state-panel" role="status" aria-label="Device Network freshness">
|
||||
Device Network data is {instance.freshness}; verify freshness before relying on these
|
||||
values.
|
||||
<p className="state-panel" role="status" aria-label="设备网络数据新鲜度">
|
||||
设备网络数据可能已过期;使用这些值前请核实数据新鲜度。
|
||||
</p>
|
||||
) : null}
|
||||
<SnapshotView snapshot={currentSnapshot} />
|
||||
|
||||
@@ -36,19 +36,19 @@ describe('isolated safe eSIM read module', () => {
|
||||
render(<EsimModule instance={owner} dataSource={pending.source} />);
|
||||
expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
|
||||
pending.resolve(snapshot);
|
||||
const section = await screen.findByRole('region', { name: 'eSIM safe summary' });
|
||||
const section = await screen.findByRole('region', { name: 'eSIM 安全摘要' });
|
||||
expect(within(section).getByText('4')).toBeTruthy();
|
||||
expect(within(section).getByText('2')).toBeTruthy();
|
||||
expect(within(section).getByText('available')).toBeTruthy();
|
||||
expect(within(section).getByText('idle')).toBeTruthy();
|
||||
expect(within(section).getByText('Provisioned')).toBeTruthy();
|
||||
expect(within(section).getByText('可用')).toBeTruthy();
|
||||
expect(within(section).getByText('空闲')).toBeTruthy();
|
||||
expect(within(section).getByText('已配置')).toBeTruthy();
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
|
||||
it('has no endpoint fallback and requires the exact authenticated owner', async () => {
|
||||
const load = vi.fn<EsimDataSource['load']>().mockResolvedValue(snapshot);
|
||||
const { rerender } = render(<EsimModule instance={owner} dataSource={{ load }} />);
|
||||
expect(await screen.findByText('Provisioned')).toBeTruthy();
|
||||
expect(await screen.findByText('已配置')).toBeTruthy();
|
||||
rerender(
|
||||
<EsimModule
|
||||
instance={{
|
||||
@@ -60,13 +60,13 @@ describe('isolated safe eSIM read module', () => {
|
||||
dataSource={{ load }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i);
|
||||
expect(screen.queryByText('Provisioned')).toBeNull();
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/需要先完成认证/i);
|
||||
expect(screen.queryByText('已配置')).toBeNull();
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<EsimModule instance={owner} />);
|
||||
expect(screen.getByRole('status', { name: 'eSIM unavailable' }).textContent).toMatch(
|
||||
/no safe esim read data source.*will not invent or call.*endpoint/i,
|
||||
expect(screen.getByRole('status', { name: 'eSIM 不可用' }).textContent).toMatch(
|
||||
/没有可用的安全 eSIM 只读数据源.*不会臆造或调用.*端点/i,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('isolated safe eSIM read module', () => {
|
||||
providerData: { secret: 'raw-provider-secret' },
|
||||
} as unknown as EsimSnapshot;
|
||||
render(<EsimModule instance={owner} dataSource={{ load: async () => hostile }} />);
|
||||
expect(await screen.findByText('Enabled')).toBeTruthy();
|
||||
expect(await screen.findByText('已启用')).toBeTruthy();
|
||||
for (const forbidden of [
|
||||
'EID-secret',
|
||||
'ICCID-secret',
|
||||
@@ -109,7 +109,7 @@ describe('isolated safe eSIM read module', () => {
|
||||
expect(document.body.textContent).not.toContain(forbidden);
|
||||
expect(document.querySelector('img')).toBeNull();
|
||||
expect(document.querySelector('script')).toBeNull();
|
||||
expect(screen.getAllByText('Unavailable')).toHaveLength(4);
|
||||
expect(screen.getAllByText('不可用')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('retains same-owner data during refresh and uses fixed safe errors with retry', async () => {
|
||||
@@ -123,14 +123,12 @@ describe('isolated safe eSIM read module', () => {
|
||||
const { rerender } = render(
|
||||
<EsimModule instance={owner} dataSource={source} refreshSignal={0} />,
|
||||
);
|
||||
expect(await screen.findByText('Provisioned')).toBeTruthy();
|
||||
expect(await screen.findByText('已配置')).toBeTruthy();
|
||||
rerender(<EsimModule instance={owner} dataSource={source} refreshSignal={1} />);
|
||||
expect(
|
||||
await screen.findByText(/Refresh failed; showing the last known eSIM summary/i),
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByText(/刷新失败,正在显示上次已知的 eSIM 摘要/i)).toBeTruthy();
|
||||
expect(document.body.textContent).not.toContain('EID-secret');
|
||||
expect(screen.getByText('4')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: 'Retry loading eSIM data' }));
|
||||
await user.click(screen.getByRole('button', { name: '重试加载 eSIM 数据' }));
|
||||
expect(await screen.findByText('5')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -146,7 +144,7 @@ describe('isolated safe eSIM read module', () => {
|
||||
/>,
|
||||
);
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert.textContent).toContain('eSIM data could not be loaded.');
|
||||
expect(alert.textContent).toContain('无法加载 eSIM 数据。');
|
||||
expect(alert.textContent).not.toContain('private code');
|
||||
});
|
||||
|
||||
@@ -163,8 +161,8 @@ describe('isolated safe eSIM read module', () => {
|
||||
expect(alphaSignal?.aborted).toBe(true);
|
||||
alpha.resolve({ ...snapshot, labels: ['Error'] });
|
||||
bravo.resolve({ ...snapshot, labels: ['Pending'] });
|
||||
expect(await screen.findByText('Pending')).toBeTruthy();
|
||||
expect(screen.queryByText('Error')).toBeNull();
|
||||
expect(await screen.findByText('等待中')).toBeTruthy();
|
||||
expect(screen.queryByText('错误')).toBeNull();
|
||||
});
|
||||
|
||||
it('caps labels at eight and defensively sanitizes unknown and null payloads', async () => {
|
||||
@@ -176,13 +174,13 @@ describe('isolated safe eSIM read module', () => {
|
||||
const { rerender } = render(
|
||||
<EsimModule instance={owner} dataSource={{ load: async () => ({ labels }) }} />,
|
||||
);
|
||||
const summary = await screen.findByRole('region', { name: 'eSIM safe summary' });
|
||||
const summary = await screen.findByRole('region', { name: 'eSIM 安全摘要' });
|
||||
expect(within(summary).getAllByRole('listitem')).toHaveLength(8);
|
||||
rerender(
|
||||
<EsimModule instance={owner} dataSource={{ load: async () => null }} refreshSignal={1} />,
|
||||
);
|
||||
expect(await screen.findByText('No safe labels were supplied.')).toBeTruthy();
|
||||
expect(screen.getAllByText('Unavailable')).toHaveLength(4);
|
||||
expect(await screen.findByText('未提供安全标签。')).toBeTruthy();
|
||||
expect(screen.getAllByText('不可用')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('turns a synchronous source throw into the fixed initial-load error', async () => {
|
||||
@@ -197,7 +195,7 @@ describe('isolated safe eSIM read module', () => {
|
||||
/>,
|
||||
);
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert.textContent).toContain('eSIM data could not be loaded.');
|
||||
expect(alert.textContent).toContain('无法加载 eSIM 数据。');
|
||||
expect(alert.textContent).not.toContain('private eSIM identifier');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { InstanceContext } from '../app-shell.js';
|
||||
import { displayValue } from '../ui/locale.js';
|
||||
|
||||
/** Bounded status vocabularies are the only strings this module may present. */
|
||||
export type EsimLpacStatus = 'available' | 'unavailable' | 'degraded' | 'unknown';
|
||||
@@ -53,7 +54,7 @@ const SAFE_LABELS = new Set<EsimSafeLabel>([
|
||||
'Pending',
|
||||
'Error',
|
||||
]);
|
||||
const SAFE_LOAD_ERROR = 'eSIM data could not be loaded.';
|
||||
const SAFE_LOAD_ERROR = '无法加载 eSIM 数据。';
|
||||
const MAX_SAFE_LABELS = 8;
|
||||
|
||||
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
||||
@@ -93,40 +94,48 @@ function sanitizeSnapshot(value: unknown): SafeSnapshot {
|
||||
}
|
||||
|
||||
function display(value: number | string | undefined): string {
|
||||
return value === undefined ? 'Unavailable' : String(value);
|
||||
return displayValue(value);
|
||||
}
|
||||
|
||||
const SAFE_LABEL_DISPLAY_VALUES: Readonly<Record<EsimSafeLabel, string>> = {
|
||||
Provisioned: '已配置',
|
||||
Enabled: 'enabled',
|
||||
Disabled: 'disabled',
|
||||
Pending: 'pending',
|
||||
Error: 'error',
|
||||
};
|
||||
|
||||
function SnapshotView({ snapshot }: { snapshot: SafeSnapshot }) {
|
||||
return (
|
||||
<section className="esim-card" aria-label="eSIM safe summary">
|
||||
<h2>eSIM safe summary</h2>
|
||||
<section className="esim-card" aria-label="eSIM 安全摘要">
|
||||
<h2>eSIM 安全摘要</h2>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Profile count</dt>
|
||||
<dt>配置数量</dt>
|
||||
<dd>{display(snapshot.profileCount)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Enabled profile count</dt>
|
||||
<dt>已启用配置数量</dt>
|
||||
<dd>{display(snapshot.enabledProfileCount)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>lpac status</dt>
|
||||
<dt>LPAC 状态</dt>
|
||||
<dd>{display(snapshot.lpacStatus)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Work mode</dt>
|
||||
<dt>工作模式</dt>
|
||||
<dd>{display(snapshot.workMode)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<h3>Safe labels</h3>
|
||||
<h3>安全标签</h3>
|
||||
{snapshot.labels.length ? (
|
||||
<ul>
|
||||
{snapshot.labels.map((label, index) => (
|
||||
<li key={`${label}-${index}`}>{label}</li>
|
||||
<li key={`${label}-${index}`}>{displayValue(SAFE_LABEL_DISPLAY_VALUES[label])}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>No safe labels were supplied.</p>
|
||||
<p>未提供安全标签。</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
@@ -187,16 +196,15 @@ export function EsimModule({ instance, dataSource, refreshSignal }: EsimModulePr
|
||||
if (instance.authentication !== 'authenticated') {
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
Authentication is required before eSIM data can be read for this instance.
|
||||
需要先完成认证,才能读取 eSIM 数据。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dataSource) {
|
||||
return (
|
||||
<div className="state-panel" role="status" aria-label="eSIM unavailable">
|
||||
No safe eSIM read data source is available. This console will not invent or call an
|
||||
uncontracted production endpoint.
|
||||
<div className="state-panel" role="status" aria-label="eSIM 不可用">
|
||||
没有可用的安全 eSIM 只读数据源。此控制台不会臆造或调用未签订契约的生产端点。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -208,8 +216,8 @@ export function EsimModule({ instance, dataSource, refreshSignal }: EsimModulePr
|
||||
|
||||
if ((state.kind === 'idle' || state.kind === 'loading') && !retained) {
|
||||
return (
|
||||
<p role="status" aria-label="eSIM loading status">
|
||||
Loading eSIM summary…
|
||||
<p role="status" aria-label="eSIM 加载状态">
|
||||
正在加载 eSIM 摘要…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -217,9 +225,9 @@ export function EsimModule({ instance, dataSource, refreshSignal }: EsimModulePr
|
||||
if (state.kind === 'error' && !retained) {
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Unable to load eSIM data: {SAFE_LOAD_ERROR}</p>
|
||||
<p>{SAFE_LOAD_ERROR}</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading eSIM data
|
||||
重试加载 eSIM 数据
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -231,18 +239,18 @@ export function EsimModule({ instance, dataSource, refreshSignal }: EsimModulePr
|
||||
|
||||
return (
|
||||
<div className="esim-module">
|
||||
{state.kind === 'loading' ? <p role="status">Refreshing eSIM summary…</p> : null}
|
||||
{state.kind === 'loading' ? <p role="status">正在刷新 eSIM 摘要…</p> : null}
|
||||
{state.kind === 'error' ? (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Refresh failed; showing the last known eSIM summary.</p>
|
||||
<p>刷新失败,正在显示上次已知的 eSIM 摘要。</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading eSIM data
|
||||
重试加载 eSIM 数据
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{instance.freshness !== 'fresh' ? (
|
||||
<p className="state-panel" role="status" aria-label="eSIM freshness">
|
||||
eSIM data is {instance.freshness}; verify freshness before relying on these values.
|
||||
<p className="state-panel" role="status" aria-label="eSIM 数据时效性">
|
||||
eSIM 数据可能不是最新数据,请核实时效性后再使用这些数值。
|
||||
</p>
|
||||
) : null}
|
||||
<SnapshotView snapshot={snapshot} />
|
||||
|
||||
@@ -33,13 +33,13 @@ describe('Instance CRUD form', () => {
|
||||
const source = dataSource();
|
||||
render(<InstanceEditor mode="create" dataSource={source} />);
|
||||
|
||||
await user.type(screen.getByLabelText('Name'), 'Lab modem');
|
||||
await user.type(screen.getByLabelText('Origin'), 'https://lab.example/admin');
|
||||
await user.type(screen.getByLabelText('Tags'), 'lab, west, lab');
|
||||
await user.selectOptions(screen.getByLabelText('Authentication method'), 'password');
|
||||
await user.type(screen.getByLabelText('Password'), 'do-not-render');
|
||||
await user.type(screen.getByLabelText('名称'), 'Lab modem');
|
||||
await user.type(screen.getByLabelText('源地址'), 'https://lab.example/admin');
|
||||
await user.type(screen.getByLabelText('标签'), 'lab, west, lab');
|
||||
await user.selectOptions(screen.getByLabelText('认证方式'), 'password');
|
||||
await user.type(screen.getByLabelText('密码'), 'do-not-render');
|
||||
expect(document.body.textContent).not.toContain('do-not-render');
|
||||
await user.click(screen.getByRole('button', { name: 'Add instance' }));
|
||||
await user.click(screen.getByRole('button', { name: '添加实例' }));
|
||||
|
||||
expect(source.create).toHaveBeenCalledWith({
|
||||
name: 'Lab modem',
|
||||
@@ -58,17 +58,17 @@ describe('Instance CRUD form', () => {
|
||||
expect(await screen.findByDisplayValue('Owner modem')).toBeTruthy();
|
||||
expect(source.get).toHaveBeenCalledWith('owner');
|
||||
|
||||
await user.clear(screen.getByLabelText('Name'));
|
||||
await user.type(screen.getByLabelText('Name'), 'Renamed');
|
||||
await user.click(screen.getByRole('button', { name: 'Save changes' }));
|
||||
await user.clear(screen.getByLabelText('名称'));
|
||||
await user.type(screen.getByLabelText('名称'), 'Renamed');
|
||||
await user.click(screen.getByRole('button', { name: '保存更改' }));
|
||||
expect(source.update).toHaveBeenLastCalledWith(
|
||||
'owner',
|
||||
3,
|
||||
expect.objectContaining({ password: { action: 'preserve' } }),
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText('Password action'), 'clear');
|
||||
await user.click(screen.getByRole('button', { name: 'Save changes' }));
|
||||
await user.selectOptions(screen.getByLabelText('密码操作'), 'clear');
|
||||
await user.click(screen.getByRole('button', { name: '保存更改' }));
|
||||
expect(source.update).toHaveBeenLastCalledWith(
|
||||
'owner',
|
||||
4,
|
||||
@@ -76,7 +76,7 @@ describe('Instance CRUD form', () => {
|
||||
);
|
||||
|
||||
rerender(<InstanceEditor mode="edit" instanceId="intruder" dataSource={source} />);
|
||||
expect((await screen.findByRole('alert')).textContent).toMatch(/does not match this route/i);
|
||||
expect((await screen.findByRole('alert')).textContent).toMatch(/实例操作失败,请稍后重试/i);
|
||||
expect(screen.queryByDisplayValue('Owner modem')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -84,19 +84,17 @@ describe('Instance CRUD form', () => {
|
||||
const user = userEvent.setup();
|
||||
const source = dataSource({
|
||||
update: vi.fn(async () => {
|
||||
throw new Error('Could not save this instance.');
|
||||
throw new Error('无法保存此实例。');
|
||||
}),
|
||||
});
|
||||
render(<InstanceEditor mode="edit" instanceId="owner" dataSource={source} />);
|
||||
await screen.findByDisplayValue('Owner modem');
|
||||
await user.click(screen.getByRole('button', { name: 'Test connection' }));
|
||||
expect((await screen.findByRole('status')).textContent).toMatch(/reachable and authenticated/i);
|
||||
await user.click(screen.getByRole('button', { name: '测试连接' }));
|
||||
expect((await screen.findByRole('status')).textContent).toMatch(/连接可达且已认证/i);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save changes' }));
|
||||
expect((await screen.findByRole('alert')).textContent).toContain(
|
||||
'Could not save this instance.',
|
||||
);
|
||||
await user.type(screen.getByLabelText('Name'), ' still here');
|
||||
await user.click(screen.getByRole('button', { name: '保存更改' }));
|
||||
expect((await screen.findByRole('alert')).textContent).toContain('实例操作失败,请稍后重试。');
|
||||
await user.type(screen.getByLabelText('名称'), ' still here');
|
||||
expect(screen.getByRole('alert')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -105,12 +103,12 @@ describe('Instance CRUD form', () => {
|
||||
const source = dataSource();
|
||||
render(<InstanceEditor mode="edit" instanceId="owner" dataSource={source} />);
|
||||
await screen.findByDisplayValue('Owner modem');
|
||||
await user.click(screen.getByRole('button', { name: 'Delete instance' }));
|
||||
expect(
|
||||
(screen.getByRole('button', { name: 'Confirm deletion' }) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
await user.type(screen.getByLabelText('Type owner to confirm'), 'owner');
|
||||
await user.click(screen.getByRole('button', { name: 'Confirm deletion' }));
|
||||
await user.click(screen.getByRole('button', { name: '删除实例' }));
|
||||
expect((screen.getByRole('button', { name: '确认删除' }) as HTMLButtonElement).disabled).toBe(
|
||||
true,
|
||||
);
|
||||
await user.type(screen.getByLabelText('输入 owner 以确认'), 'owner');
|
||||
await user.click(screen.getByRole('button', { name: '确认删除' }));
|
||||
expect(source.delete).toHaveBeenCalledWith('owner', 3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,8 +16,8 @@ export interface InstanceEditorProps {
|
||||
}
|
||||
type PasswordAction = 'preserve' | 'set' | 'clear';
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error instanceof Error && error.message ? error.message : 'The instance operation failed.';
|
||||
function message(): string {
|
||||
return '实例操作失败,请稍后重试。';
|
||||
}
|
||||
|
||||
export function InstanceEditor({
|
||||
@@ -43,7 +43,7 @@ export function InstanceEditor({
|
||||
useEffect(() => {
|
||||
if (mode !== 'edit') return;
|
||||
if (!instanceId) {
|
||||
setError('The instance route is missing an owner.');
|
||||
setError('实例路由缺少所有者。');
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
@@ -53,8 +53,7 @@ export function InstanceEditor({
|
||||
.get(instanceId)
|
||||
.then((loaded) => {
|
||||
if (!active) return;
|
||||
if (loaded.id !== instanceId)
|
||||
throw new Error('The returned instance does not match this route.');
|
||||
if (loaded.id !== instanceId) throw new Error('返回的实例与当前路由不匹配。');
|
||||
setOwner(loaded);
|
||||
setName(loaded.name);
|
||||
setOrigin(loaded.origin);
|
||||
@@ -63,7 +62,7 @@ export function InstanceEditor({
|
||||
setPasswordAction('preserve');
|
||||
setPassword('');
|
||||
})
|
||||
.catch((cause: unknown) => active && setError(message(cause)));
|
||||
.catch(() => active && setError(message()));
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
@@ -88,14 +87,14 @@ export function InstanceEditor({
|
||||
? await dataSource.create(input)
|
||||
: await dataSource.update(owner!.id, owner!.revision, input);
|
||||
if (mode === 'edit' && saved.id !== instanceId)
|
||||
throw new Error('The returned instance does not match this route.');
|
||||
throw new Error('返回的实例与当前路由不匹配。');
|
||||
setOwner(saved);
|
||||
setPassword('');
|
||||
setPasswordAction('preserve');
|
||||
setStatus(mode === 'create' ? 'Instance added.' : 'Changes saved.');
|
||||
setStatus(mode === 'create' ? '实例已添加。' : '更改已保存。');
|
||||
setError(undefined);
|
||||
} catch (cause) {
|
||||
setError(message(cause));
|
||||
} catch {
|
||||
setError(message());
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -109,13 +108,13 @@ export function InstanceEditor({
|
||||
setStatus(
|
||||
result.reachable
|
||||
? result.authenticated
|
||||
? 'Connection is reachable and authenticated.'
|
||||
: 'Connection is reachable; authentication is required.'
|
||||
: 'Connection is not reachable.',
|
||||
? '连接可达且已认证。'
|
||||
: '连接可达,但需要认证。'
|
||||
: '连接不可达。',
|
||||
);
|
||||
setError(undefined);
|
||||
} catch (cause) {
|
||||
setError(message(cause));
|
||||
} catch {
|
||||
setError(message());
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -126,11 +125,11 @@ export function InstanceEditor({
|
||||
setBusy(true);
|
||||
try {
|
||||
await dataSource.delete(owner.id, owner.revision);
|
||||
setStatus('Instance deletion accepted.');
|
||||
setStatus('实例删除请求已接受。');
|
||||
setError(undefined);
|
||||
setConfirming(false);
|
||||
} catch (cause) {
|
||||
setError(message(cause));
|
||||
} catch {
|
||||
setError(message());
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -142,12 +141,12 @@ export function InstanceEditor({
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
<p role="status">Loading instance…</p>
|
||||
<p role="status">正在加载实例…</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="instance-editor">
|
||||
<h1>{mode === 'create' ? 'Add instance' : 'Instance settings'}</h1>
|
||||
<h1>{mode === 'create' ? '添加实例' : '实例设置'}</h1>
|
||||
{error ? (
|
||||
<p role="alert" className="state-panel state-error">
|
||||
{error}
|
||||
@@ -160,11 +159,11 @@ export function InstanceEditor({
|
||||
) : null}
|
||||
<form onSubmit={submit}>
|
||||
<label>
|
||||
Name
|
||||
名称
|
||||
<input required value={name} onChange={(event) => setName(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Origin
|
||||
源地址
|
||||
<input
|
||||
required
|
||||
type="url"
|
||||
@@ -173,29 +172,29 @@ export function InstanceEditor({
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Tags
|
||||
标签
|
||||
<input
|
||||
value={tags}
|
||||
onChange={(event) => setTags(event.target.value)}
|
||||
aria-describedby="tags-help"
|
||||
/>
|
||||
</label>
|
||||
<small id="tags-help">Comma-separated tags</small>
|
||||
<small id="tags-help">使用英文逗号分隔标签</small>
|
||||
<label>
|
||||
Authentication method
|
||||
认证方式
|
||||
<select
|
||||
value={authMethod}
|
||||
onChange={(event) => setAuthMethod(event.target.value as 'none' | 'password')}
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="password">Password</option>
|
||||
<option value="none">无</option>
|
||||
<option value="password">密码</option>
|
||||
</select>
|
||||
</label>
|
||||
{authMethod === 'password' ? (
|
||||
<>
|
||||
{mode === 'edit' ? (
|
||||
<label>
|
||||
Password action
|
||||
密码操作
|
||||
<select
|
||||
value={passwordAction}
|
||||
onChange={(event) => {
|
||||
@@ -203,15 +202,15 @@ export function InstanceEditor({
|
||||
setPassword('');
|
||||
}}
|
||||
>
|
||||
<option value="preserve">Keep saved password</option>
|
||||
<option value="set">Set new password</option>
|
||||
<option value="clear">Clear saved password</option>
|
||||
<option value="preserve">保留已保存的密码</option>
|
||||
<option value="set">设置新密码</option>
|
||||
<option value="clear">清除已保存的密码</option>
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
{mode === 'create' || passwordAction === 'set' ? (
|
||||
<label>
|
||||
Password
|
||||
密码
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
@@ -225,27 +224,27 @@ export function InstanceEditor({
|
||||
) : null}
|
||||
<div className="form-actions">
|
||||
<button disabled={busy} type="submit">
|
||||
{mode === 'create' ? 'Add instance' : 'Save changes'}
|
||||
{mode === 'create' ? '添加实例' : '保存更改'}
|
||||
</button>
|
||||
{mode === 'edit' ? (
|
||||
<button disabled={busy} type="button" onClick={() => void testConnection()}>
|
||||
Test connection
|
||||
测试连接
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
{mode === 'edit' ? (
|
||||
<section className="danger-zone" aria-labelledby="danger-heading">
|
||||
<h2 id="danger-heading">Danger zone</h2>
|
||||
<h2 id="danger-heading">危险操作区</h2>
|
||||
{!confirming ? (
|
||||
<button type="button" onClick={() => setConfirming(true)}>
|
||||
Delete instance
|
||||
删除实例
|
||||
</button>
|
||||
) : (
|
||||
<div>
|
||||
<p>Deletion cannot be undone.</p>
|
||||
<p>删除后无法撤销。</p>
|
||||
<label>
|
||||
Type {owner!.id} to confirm
|
||||
输入 {owner!.id} 以确认
|
||||
<input
|
||||
value={confirmation}
|
||||
onChange={(event) => setConfirmation(event.target.value)}
|
||||
@@ -256,7 +255,7 @@ export function InstanceEditor({
|
||||
type="button"
|
||||
onClick={() => void remove()}
|
||||
>
|
||||
Confirm deletion
|
||||
确认删除
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -265,7 +264,7 @@ export function InstanceEditor({
|
||||
setConfirmation('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -22,9 +22,9 @@ const owner: InstanceContext = {
|
||||
|
||||
const capabilities: InstanceCapabilityMap = {
|
||||
overview: { state: 'supported' },
|
||||
messages: { state: 'degraded', explanation: 'Message history is read-only.' },
|
||||
calls: { state: 'unsupported', explanation: 'This modem has no voice support.' },
|
||||
esim: { state: 'unknown', explanation: 'Capability discovery did not report eSIM.' },
|
||||
messages: { state: 'degraded', explanation: '消息历史记录为只读。' },
|
||||
calls: { state: 'unsupported', explanation: '此调制解调器不支持语音功能。' },
|
||||
esim: { state: 'unknown', explanation: '能力探测结果未包含 eSIM。' },
|
||||
};
|
||||
|
||||
describe('InstanceDetail', () => {
|
||||
@@ -38,14 +38,14 @@ describe('InstanceDetail', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Overview' }).getAttribute('href')).toBe(
|
||||
expect(screen.getByRole('link', { name: '概览' }).getAttribute('href')).toBe(
|
||||
'/instances/owner/overview',
|
||||
);
|
||||
expect(screen.queryByRole('link', { name: 'Messages' })).toBeNull();
|
||||
expect(screen.getByText('Message history is read-only.')).toBeTruthy();
|
||||
expect(screen.getByText('This modem has no voice support.')).toBeTruthy();
|
||||
expect(screen.getByText('Capability discovery did not report eSIM.')).toBeTruthy();
|
||||
expect(screen.getAllByText('Capability state is unknown.').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByRole('link', { name: '消息' })).toBeNull();
|
||||
expect(screen.getByText('消息历史记录为只读。')).toBeTruthy();
|
||||
expect(screen.getByText('此调制解调器不支持语音功能。')).toBeTruthy();
|
||||
expect(screen.getByText('能力探测结果未包含 eSIM。')).toBeTruthy();
|
||||
expect(screen.getAllByText('能力状态未知。').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('never renders or loads context when the direct-route owner does not match', () => {
|
||||
@@ -60,8 +60,8 @@ describe('InstanceDetail', () => {
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Owner modem')).toBeNull();
|
||||
expect(screen.queryByRole('navigation', { name: 'Instance modules' })).toBeNull();
|
||||
expect(screen.getByText(/Instance context is unavailable/i)).toBeTruthy();
|
||||
expect(screen.queryByRole('navigation', { name: '实例模块' })).toBeNull();
|
||||
expect(screen.getByText(/此路由缺少实例上下文/i)).toBeTruthy();
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('InstanceDetail', () => {
|
||||
expect(pending.get('owner')?.signal.aborted).toBe(true);
|
||||
|
||||
pending.get('second')?.resolve({ overview: { state: 'supported' } });
|
||||
expect(await screen.findByRole('link', { name: 'Overview' })).toBeTruthy();
|
||||
expect(await screen.findByRole('link', { name: '概览' })).toBeTruthy();
|
||||
pending
|
||||
.get('owner')
|
||||
?.resolve({ overview: { state: 'unsupported', explanation: 'Stale owner result' } });
|
||||
@@ -107,9 +107,9 @@ describe('InstanceDetail', () => {
|
||||
});
|
||||
|
||||
it.each<[InstanceModule, string]>([
|
||||
['cellular', 'Cellular'],
|
||||
['device-network', 'Device Network'],
|
||||
['automation', 'Automation'],
|
||||
['cellular', '蜂窝网络'],
|
||||
['device-network', '设备网络'],
|
||||
['automation', '自动化'],
|
||||
['ota', 'OTA'],
|
||||
])('uses the existing module contract for %s', (module, label) => {
|
||||
render(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { InstanceContext, InstanceModule } from '../app-shell.js';
|
||||
import { canonicalHttpOrigin } from '../fleet/fleet-page.js';
|
||||
import { displayStatus } from '../ui/locale.js';
|
||||
|
||||
export type CapabilityState = 'supported' | 'degraded' | 'unsupported' | 'unknown';
|
||||
|
||||
@@ -27,23 +28,23 @@ export interface InstanceDetailProps {
|
||||
}
|
||||
|
||||
export const INSTANCE_MODULE_LABELS: Readonly<Record<InstanceModule, string>> = {
|
||||
overview: 'Overview',
|
||||
cellular: 'Cellular',
|
||||
'device-network': 'Device Network',
|
||||
messages: 'Messages',
|
||||
calls: 'Calls',
|
||||
overview: '概览',
|
||||
cellular: '蜂窝网络',
|
||||
'device-network': '设备网络',
|
||||
messages: '消息',
|
||||
calls: '通话',
|
||||
esim: 'eSIM',
|
||||
notifications: 'Notifications',
|
||||
automation: 'Automation',
|
||||
notifications: '通知',
|
||||
automation: '自动化',
|
||||
ota: 'OTA',
|
||||
};
|
||||
|
||||
const MODULES = Object.keys(INSTANCE_MODULE_LABELS) as readonly InstanceModule[];
|
||||
|
||||
const DEFAULT_EXPLANATIONS: Readonly<Record<Exclude<CapabilityState, 'supported'>, string>> = {
|
||||
degraded: 'This module is available with limited functionality.',
|
||||
unsupported: 'This instance does not support this module.',
|
||||
unknown: 'Capability state is unknown.',
|
||||
degraded: '此模块可用,但功能受限。',
|
||||
unsupported: '此实例不支持该模块。',
|
||||
unknown: '能力状态未知。',
|
||||
};
|
||||
|
||||
function capabilityFor(map: InstanceCapabilityMap, module: InstanceModule): InstanceCapability {
|
||||
@@ -99,7 +100,7 @@ export function InstanceDetail({
|
||||
(error: unknown) => {
|
||||
void error;
|
||||
if (!controller.signal.aborted && requestRef.current === request) {
|
||||
setLoadError('Capability discovery failed.');
|
||||
setLoadError('能力探测失败。');
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
@@ -111,7 +112,7 @@ export function InstanceDetail({
|
||||
return (
|
||||
<section>
|
||||
<h1>{INSTANCE_MODULE_LABELS[module]}</h1>
|
||||
<p>Instance context is unavailable for this route.</p>
|
||||
<p>此路由缺少实例上下文。</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -122,29 +123,29 @@ export function InstanceDetail({
|
||||
|
||||
return (
|
||||
<section className="instance-detail">
|
||||
<aside className="instance-context" aria-label="Current instance">
|
||||
<aside className="instance-context" aria-label="当前实例">
|
||||
<strong>{instance.name}</strong>
|
||||
<code>{instance.id}</code>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>{instance.status}</dd>
|
||||
<dt>状态</dt>
|
||||
<dd>{displayStatus(instance.status)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Authentication</dt>
|
||||
<dd>{instance.authentication}</dd>
|
||||
<dt>认证</dt>
|
||||
<dd>{displayStatus(instance.authentication)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Freshness</dt>
|
||||
<dd>{instance.freshness}</dd>
|
||||
<dt>数据新鲜度</dt>
|
||||
<dd>{displayStatus(instance.freshness)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{origin ? (
|
||||
<a href={origin} target="_blank" rel="noopener noreferrer">
|
||||
Open original site
|
||||
打开源站
|
||||
</a>
|
||||
) : null}
|
||||
<nav aria-label="Instance modules">
|
||||
<nav aria-label="实例模块">
|
||||
<ul>
|
||||
{MODULES.map((item) => {
|
||||
const capability = capabilityFor(map, item);
|
||||
@@ -172,19 +173,14 @@ export function InstanceDetail({
|
||||
</aside>
|
||||
<div className="instance-module-detail">
|
||||
<h1>{INSTANCE_MODULE_LABELS[module]}</h1>
|
||||
{loading ? <p role="status">Loading capabilities…</p> : null}
|
||||
{loadError ? <p role="alert">Capabilities unavailable: {loadError}</p> : null}
|
||||
{loading ? <p role="status">正在加载能力…</p> : null}
|
||||
{loadError ? <p role="alert">能力不可用:{loadError}</p> : null}
|
||||
{!loading && canRender(activeCapability) ? (
|
||||
<>
|
||||
{activeCapability.state === 'degraded' ? (
|
||||
<p data-capability-state="degraded">{explanation(activeCapability)}</p>
|
||||
) : null}
|
||||
{moduleContent ?? (
|
||||
<p>
|
||||
Inspect {INSTANCE_MODULE_LABELS[module].toLowerCase()} data and available
|
||||
operations.
|
||||
</p>
|
||||
)}
|
||||
{moduleContent ?? <p>查看{INSTANCE_MODULE_LABELS[module]}数据和可用操作。</p>}
|
||||
</>
|
||||
) : null}
|
||||
{!loading && !canRender(activeCapability) ? (
|
||||
|
||||
@@ -81,17 +81,19 @@ describe('Messages isolated read module', () => {
|
||||
const pending = deferredSource();
|
||||
render(<MessagesModule instance={owner} dataSource={pending.source} />);
|
||||
|
||||
expect(screen.getByRole('status', { name: 'Messages loading status' })).toBeTruthy();
|
||||
expect(screen.getByRole('status', { name: '消息加载状态' })).toBeTruthy();
|
||||
expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
|
||||
pending.resolve(snapshot);
|
||||
|
||||
const sms = await screen.findByRole('region', { name: 'SMS aggregate' });
|
||||
const sms = await screen.findByRole('region', { name: '短信汇总' });
|
||||
expect(within(sms).getByText('42')).toBeTruthy();
|
||||
expect(within(sms).getByText('2026-07-17T11:55:00Z')).toBeTruthy();
|
||||
const devices = screen.getByRole('region', { name: 'Device message aggregates' });
|
||||
const devices = screen.getByRole('region', { name: '设备消息汇总' });
|
||||
expect(within(devices).getByText('modem-1')).toBeTruthy();
|
||||
expect(within(devices).getByText('Primary modem')).toBeTruthy();
|
||||
expect(screen.getByText(/Observed 2026-07-17T12:00:00Z/)).toBeTruthy();
|
||||
expect(within(devices).getByText('在线')).toBeTruthy();
|
||||
expect(within(devices).queryByText('online')).toBeNull();
|
||||
expect(screen.getByText(/观测时间:2026-07-17T12:00:00Z/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uses an explicit safe allowlist and exposes no bodies, content, recipients, credentials, or write actions', async () => {
|
||||
@@ -111,16 +113,14 @@ describe('Messages isolated read module', () => {
|
||||
}
|
||||
expect(screen.queryByText(/recipient|phone number|password/i)).toBeNull();
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
expect(screen.getByText(/aggregate metadata only/i)).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/sending, deleting, and changing messages are unavailable/i),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText(/仅显示汇总元数据/i)).toBeTruthy();
|
||||
expect(screen.getByText(/此只读模块不支持发送、删除或修改消息/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not invent an endpoint when no source is injected', () => {
|
||||
render(<MessagesModule instance={owner} />);
|
||||
expect(screen.getByRole('status', { name: 'Messages unavailable' }).textContent).toMatch(
|
||||
/no safe messages read data source.*uncontracted production endpoint/i,
|
||||
expect(screen.getByRole('status', { name: '消息不可用' }).textContent).toMatch(
|
||||
/没有可用的安全消息只读数据源.*未签订契约的生产端点/i,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('Messages isolated read module', () => {
|
||||
dataSource={{ load }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/需要先完成认证/i);
|
||||
expect(screen.queryByText('Primary modem')).toBeNull();
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -149,9 +149,9 @@ describe('Messages isolated read module', () => {
|
||||
render(<MessagesModule instance={owner} dataSource={{ load }} />);
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert.textContent).toContain('Messages data could not be loaded.');
|
||||
expect(alert.textContent).toContain('无法加载消息数据。');
|
||||
expect(alert.textContent).not.toContain('secret URL');
|
||||
await user.click(screen.getByRole('button', { name: 'Retry loading Messages' }));
|
||||
await user.click(screen.getByRole('button', { name: '重试加载消息' }));
|
||||
expect(await screen.findByText('Primary modem')).toBeTruthy();
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -171,9 +171,9 @@ describe('Messages isolated read module', () => {
|
||||
|
||||
rerender(<MessagesModule instance={owner} dataSource={source} refreshSignal={1} />);
|
||||
expect(screen.getByText('Primary modem')).toBeTruthy();
|
||||
expect((await screen.findByRole('alert')).textContent).toMatch(/showing the last known/i);
|
||||
expect((await screen.findByRole('alert')).textContent).toMatch(/正在显示上次已知/i);
|
||||
expect(screen.getByText('Primary modem')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: 'Retry loading Messages' }));
|
||||
await user.click(screen.getByRole('button', { name: '重试加载消息' }));
|
||||
expect(await screen.findByText('Replacement modem')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -207,8 +207,8 @@ describe('Messages isolated read module', () => {
|
||||
dataSource={{ load: async () => snapshot }}
|
||||
/>,
|
||||
);
|
||||
expect((await screen.findByRole('status', { name: 'Messages freshness' })).textContent).toMatch(
|
||||
/stale/i,
|
||||
expect((await screen.findByRole('status', { name: '消息数据新鲜度' })).textContent).toMatch(
|
||||
/可能已过期/i,
|
||||
);
|
||||
expect(screen.getByText('Primary modem')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
import type { InstanceContext } from '../app-shell.js';
|
||||
import { displayValue } from '../ui/locale.js';
|
||||
|
||||
/** Bounded primitives permitted at the Messages presentation boundary. */
|
||||
export type MessageMetadataValue = string | number | boolean | null;
|
||||
@@ -47,33 +48,37 @@ type ReadState =
|
||||
| { kind: 'ready'; ownerId: string; snapshot: MessagesSnapshot }
|
||||
| { kind: 'error'; ownerId: string; snapshot?: MessagesSnapshot };
|
||||
|
||||
const SAFE_LOAD_ERROR = 'Messages data could not be loaded.';
|
||||
const SAFE_LOAD_ERROR = '无法加载消息数据。';
|
||||
|
||||
const AGGREGATE_FIELDS = [
|
||||
['Total', 'total'],
|
||||
['Inbound', 'inbound'],
|
||||
['Outbound', 'outbound'],
|
||||
['Unread', 'unread'],
|
||||
['Failed', 'failed'],
|
||||
['Queued', 'queued'],
|
||||
['Last activity', 'lastActivityAt'],
|
||||
['总计', 'total'],
|
||||
['接收', 'inbound'],
|
||||
['发送', 'outbound'],
|
||||
['未读', 'unread'],
|
||||
['失败', 'failed'],
|
||||
['排队中', 'queued'],
|
||||
['最近活动', 'lastActivityAt'],
|
||||
] as const;
|
||||
|
||||
function display(value: MessageMetadataValue | undefined): string {
|
||||
return value == null ? 'Unavailable' : String(value);
|
||||
function displayRaw(value: MessageMetadataValue | undefined): string {
|
||||
return value == null ? '不可用' : String(value);
|
||||
}
|
||||
|
||||
function Fields({
|
||||
values,
|
||||
}: {
|
||||
values: readonly (readonly [label: string, value: MessageMetadataValue | undefined])[];
|
||||
values: readonly (readonly [
|
||||
label: string,
|
||||
value: MessageMetadataValue | undefined,
|
||||
localized?: boolean,
|
||||
])[];
|
||||
}) {
|
||||
return (
|
||||
<dl>
|
||||
{values.map(([label, value]) => (
|
||||
{values.map(([label, value, localized]) => (
|
||||
<div key={label}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{display(value)}</dd>
|
||||
<dd>{localized ? displayValue(value) : displayRaw(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
@@ -99,34 +104,34 @@ function aggregateFields(
|
||||
function SnapshotView({ snapshot }: { snapshot: MessagesSnapshot }) {
|
||||
return (
|
||||
<>
|
||||
{snapshot.observedAt ? <p>Observed {snapshot.observedAt}</p> : null}
|
||||
<p>Aggregate metadata only; sensitive payload and addressing details are excluded.</p>
|
||||
{snapshot.observedAt ? <p>观测时间:{snapshot.observedAt}</p> : null}
|
||||
<p>仅显示汇总元数据;敏感内容和寻址详情已排除。</p>
|
||||
<div className="messages-grid">
|
||||
<Section label="SMS aggregate">
|
||||
<Section label="短信汇总">
|
||||
<Fields values={aggregateFields(snapshot.sms)} />
|
||||
</Section>
|
||||
<Section label="Device message aggregates">
|
||||
<Section label="设备消息汇总">
|
||||
{snapshot.devices.length ? (
|
||||
snapshot.devices.map((device, index) => (
|
||||
<article key={`${device.deviceId ?? device.label ?? 'device'}-${index}`}>
|
||||
<Fields
|
||||
values={[
|
||||
['Device ID', device.deviceId],
|
||||
['Label', device.label],
|
||||
['State', device.state],
|
||||
['设备 ID', device.deviceId],
|
||||
['标签', device.label],
|
||||
['状态', device.state, true],
|
||||
...aggregateFields(device),
|
||||
]}
|
||||
/>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<p>No device message aggregates were supplied.</p>
|
||||
<p>未提供设备消息汇总。</p>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
<section className="state-panel" aria-label="Messages actions">
|
||||
<h2>Messages actions</h2>
|
||||
<p>Sending, deleting, and changing messages are unavailable in this read-only module.</p>
|
||||
<section className="state-panel" aria-label="消息操作">
|
||||
<h2>消息操作</h2>
|
||||
<p>此只读模块不支持发送、删除或修改消息。</p>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
@@ -183,16 +188,15 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
|
||||
if (instance.authentication !== 'authenticated') {
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
Authentication is required before Messages data can be read for this instance.
|
||||
需要先完成认证,才能读取消息数据。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dataSource) {
|
||||
return (
|
||||
<div className="state-panel" role="status" aria-label="Messages unavailable">
|
||||
No safe Messages read data source is available. This console will not invent or call an
|
||||
uncontracted production endpoint.
|
||||
<div className="state-panel" role="status" aria-label="消息不可用">
|
||||
没有可用的安全消息只读数据源。此控制台不会臆造或调用未签订契约的生产端点。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -204,8 +208,8 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
|
||||
|
||||
if ((state.kind === 'idle' || state.kind === 'loading') && !retainedSnapshot) {
|
||||
return (
|
||||
<p role="status" aria-label="Messages loading status">
|
||||
Loading Messages…
|
||||
<p role="status" aria-label="消息加载状态">
|
||||
正在加载消息…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -213,9 +217,9 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
|
||||
if (state.kind === 'error' && !retainedSnapshot) {
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Unable to load Messages: {SAFE_LOAD_ERROR}</p>
|
||||
<p>{SAFE_LOAD_ERROR}</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading Messages
|
||||
重试加载消息
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -227,18 +231,18 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
|
||||
|
||||
return (
|
||||
<div className="messages-module">
|
||||
{state.kind === 'loading' ? <p role="status">Refreshing Messages…</p> : null}
|
||||
{state.kind === 'loading' ? <p role="status">正在刷新消息…</p> : null}
|
||||
{state.kind === 'error' ? (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Refresh failed; showing the last known Messages data.</p>
|
||||
<p>刷新失败,正在显示上次已知的消息数据。</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading Messages
|
||||
重试加载消息
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{instance.freshness !== 'fresh' ? (
|
||||
<p className="state-panel" role="status" aria-label="Messages freshness">
|
||||
Messages data is {instance.freshness}; verify freshness before relying on these values.
|
||||
<p className="state-panel" role="status" aria-label="消息数据新鲜度">
|
||||
消息数据可能已过期;使用这些值前请核实数据新鲜度。
|
||||
</p>
|
||||
) : null}
|
||||
<SnapshotView snapshot={currentSnapshot} />
|
||||
|
||||
@@ -82,25 +82,21 @@ describe('Notifications isolated safe read module', () => {
|
||||
const pending = deferredSource();
|
||||
render(<NotificationsModule instance={owner} dataSource={pending.source} />);
|
||||
|
||||
expect(screen.getByRole('status', { name: 'Notifications loading status' })).toBeTruthy();
|
||||
expect(screen.getByRole('status', { name: '通知加载状态' })).toBeTruthy();
|
||||
expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
|
||||
pending.resolve(snapshot);
|
||||
|
||||
const channels = await screen.findByRole('region', { name: 'Channel aggregate' });
|
||||
expect(within(channels).getByText('healthy')).toBeTruthy();
|
||||
const channels = await screen.findByRole('region', { name: '渠道汇总' });
|
||||
expect(within(channels).getByText('正常', { selector: 'dd' })).toBeTruthy();
|
||||
expect(within(channels).getByText('5')).toBeTruthy();
|
||||
expect(
|
||||
within(screen.getByRole('region', { name: 'Queue aggregate' })).getByText('18'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(screen.getByRole('region', { name: 'Log aggregate' })).getByText('30'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText(/Observed 2026-07-17T12:00:00Z/)).toBeTruthy();
|
||||
expect(within(screen.getByRole('region', { name: '队列汇总' })).getByText('18')).toBeTruthy();
|
||||
expect(within(screen.getByRole('region', { name: '日志汇总' })).getByText('30')).toBeTruthy();
|
||||
expect(screen.getByText(/观测时间:2026-07-17T12:00:00Z/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('never exposes polymorphic config, endpoints, destinations, tokens, phone numbers, payloads, raw logs, or actions', async () => {
|
||||
render(<NotificationsModule instance={owner} dataSource={{ load: async () => snapshot }} />);
|
||||
expect(await screen.findByText(/aggregate counts and status only/i)).toBeTruthy();
|
||||
expect(await screen.findByText(/仅显示汇总计数和状态/i)).toBeTruthy();
|
||||
|
||||
const text = document.body.textContent ?? '';
|
||||
for (const secret of [
|
||||
@@ -124,8 +120,8 @@ describe('Notifications isolated safe read module', () => {
|
||||
it('fails closed without an injected source or authenticated owner', async () => {
|
||||
const load = vi.fn<NotificationsDataSource['load']>().mockResolvedValue(snapshot);
|
||||
const { rerender } = render(<NotificationsModule instance={owner} />);
|
||||
expect(screen.getByRole('status', { name: 'Notifications unavailable' }).textContent).toMatch(
|
||||
/no safe notifications read data source.*uncontracted production endpoint/i,
|
||||
expect(screen.getByRole('status', { name: '通知不可用' }).textContent).toMatch(
|
||||
/没有可用的安全通知只读数据源.*未签订契约的生产端点/i,
|
||||
);
|
||||
|
||||
rerender(
|
||||
@@ -134,7 +130,7 @@ describe('Notifications isolated safe read module', () => {
|
||||
dataSource={{ load }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/需要先完成认证/i);
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -160,8 +156,8 @@ describe('Notifications isolated safe read module', () => {
|
||||
...snapshot,
|
||||
channels: { ...snapshot.channels, status: 'degraded', total: 9 },
|
||||
});
|
||||
expect(await screen.findByText('degraded')).toBeTruthy();
|
||||
expect(screen.queryByText('healthy')).toBeNull();
|
||||
expect(await screen.findByText('功能受限', { selector: 'dd' })).toBeTruthy();
|
||||
expect(screen.queryByText('正常', { selector: 'dd' })).toBeNull();
|
||||
});
|
||||
|
||||
it('uses fixed errors and retains only same-owner last-good data across refresh failure and retry', async () => {
|
||||
@@ -175,18 +171,16 @@ describe('Notifications isolated safe read module', () => {
|
||||
const { rerender } = render(
|
||||
<NotificationsModule instance={owner} dataSource={source} refreshSignal={0} />,
|
||||
);
|
||||
expect(await screen.findByText('healthy')).toBeTruthy();
|
||||
expect(await screen.findByText('正常', { selector: 'dd' })).toBeTruthy();
|
||||
|
||||
rerender(<NotificationsModule instance={owner} dataSource={source} refreshSignal={1} />);
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert.textContent).toMatch(/refresh failed; showing the last known notifications data/i);
|
||||
expect(alert.textContent).toMatch(/刷新失败,正在显示上次已知的通知数据/i);
|
||||
expect(alert.textContent).not.toContain('secret endpoint');
|
||||
expect(screen.getByText('healthy')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: 'Retry loading Notifications' }));
|
||||
expect(screen.getByText('正常', { selector: 'dd' })).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '重试加载通知数据' }));
|
||||
expect(
|
||||
await within(screen.getByRole('region', { name: 'Channel aggregate' })).findByText(
|
||||
'available',
|
||||
),
|
||||
await within(screen.getByRole('region', { name: '渠道汇总' })).findByText('可用'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -204,15 +198,13 @@ describe('Notifications isolated safe read module', () => {
|
||||
logs: { status: { token: 'nested-secret' }, total: Number.MAX_SAFE_INTEGER + 1, error: 3 },
|
||||
};
|
||||
render(<NotificationsModule instance={owner} dataSource={{ load: async () => unsafe }} />);
|
||||
const logs = await screen.findByRole('region', { name: 'Log aggregate' });
|
||||
const logs = await screen.findByRole('region', { name: '日志汇总' });
|
||||
expect(within(logs).getByText('3')).toBeTruthy();
|
||||
expect(
|
||||
within(screen.getByRole('region', { name: 'Channel aggregate' })).getByText('2'),
|
||||
).toBeTruthy();
|
||||
expect(within(screen.getByRole('region', { name: '渠道汇总' })).getByText('2')).toBeTruthy();
|
||||
expect(document.body.textContent).not.toMatch(
|
||||
/api-token|channel-secret|private payload|nested-secret|Infinity|1\.5|-1/,
|
||||
);
|
||||
expect(screen.queryByText(/^Observed /)).toBeNull();
|
||||
expect(screen.queryByText(/^观测时间:/)).toBeNull();
|
||||
unsafe.channels.disabled = 99;
|
||||
expect(screen.queryByText('99')).toBeNull();
|
||||
});
|
||||
@@ -225,7 +217,7 @@ describe('Notifications isolated safe read module', () => {
|
||||
/>,
|
||||
);
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert.textContent).toContain('Notifications data could not be loaded.');
|
||||
expect(alert.textContent).toContain('无法加载通知数据。');
|
||||
expect(alert.textContent).not.toContain('private token');
|
||||
});
|
||||
|
||||
@@ -241,7 +233,7 @@ describe('Notifications isolated safe read module', () => {
|
||||
/>,
|
||||
);
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert.textContent).toContain('Notifications data could not be loaded.');
|
||||
expect(alert.textContent).toContain('无法加载通知数据。');
|
||||
expect(alert.textContent).not.toContain('private notification token');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
import type { InstanceContext } from '../app-shell.js';
|
||||
import { displayValue } from '../ui/locale.js';
|
||||
|
||||
/** Values permitted at the Notifications presentation boundary. */
|
||||
export type NotificationStatus =
|
||||
@@ -73,7 +74,7 @@ type ReadState =
|
||||
| { kind: 'ready'; snapshot: OwnedSnapshot }
|
||||
| { kind: 'error'; snapshot?: OwnedSnapshot };
|
||||
|
||||
const SAFE_LOAD_ERROR = 'Notifications data could not be loaded.';
|
||||
const SAFE_LOAD_ERROR = '无法加载通知数据。';
|
||||
const NOTIFICATION_STATUSES = new Set<NotificationStatus>([
|
||||
'healthy',
|
||||
'degraded',
|
||||
@@ -139,30 +140,30 @@ export function sanitizeNotificationsSnapshot(value: unknown): NotificationsSnap
|
||||
}
|
||||
|
||||
const CHANNEL_FIELDS = [
|
||||
['Status', 'status'],
|
||||
['Total', 'total'],
|
||||
['Enabled', 'enabled'],
|
||||
['Disabled', 'disabled'],
|
||||
['Healthy', 'healthy'],
|
||||
['Degraded', 'degraded'],
|
||||
['Failed', 'failed'],
|
||||
['状态', 'status'],
|
||||
['总计', 'total'],
|
||||
['已启用', 'enabled'],
|
||||
['已禁用', 'disabled'],
|
||||
['正常', 'healthy'],
|
||||
['功能受限', 'degraded'],
|
||||
['失败', 'failed'],
|
||||
] as const;
|
||||
|
||||
const QUEUE_FIELDS = [
|
||||
['Status', 'status'],
|
||||
['Total', 'total'],
|
||||
['Pending', 'pending'],
|
||||
['Processing', 'processing'],
|
||||
['Delivered', 'delivered'],
|
||||
['Failed', 'failed'],
|
||||
['状态', 'status'],
|
||||
['总计', 'total'],
|
||||
['等待中', 'pending'],
|
||||
['处理中', 'processing'],
|
||||
['已送达', 'delivered'],
|
||||
['失败', 'failed'],
|
||||
] as const;
|
||||
|
||||
const LOG_FIELDS = [
|
||||
['Status', 'status'],
|
||||
['Total', 'total'],
|
||||
['Info', 'info'],
|
||||
['Warning', 'warning'],
|
||||
['Error', 'error'],
|
||||
['状态', 'status'],
|
||||
['总计', 'total'],
|
||||
['信息', 'info'],
|
||||
['警告', 'warning'],
|
||||
['错误', 'error'],
|
||||
] as const;
|
||||
|
||||
function Section({ label, children }: { label: string; children: ReactNode }) {
|
||||
@@ -183,7 +184,7 @@ function AggregateFields({
|
||||
}) {
|
||||
// Constant-key projection is the security boundary: never enumerate or spread source objects.
|
||||
const supplied = fields.filter(([, key]) => aggregate[key] !== undefined);
|
||||
if (!supplied.length) return <p>No aggregate status was supplied.</p>;
|
||||
if (!supplied.length) return <p>未提供汇总状态。</p>;
|
||||
|
||||
return (
|
||||
<dl>
|
||||
@@ -192,7 +193,7 @@ function AggregateFields({
|
||||
return (
|
||||
<div key={key}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value == null ? 'Unavailable' : String(value)}</dd>
|
||||
<dd>{displayValue(value)}</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -203,16 +204,16 @@ function AggregateFields({
|
||||
function SnapshotView({ snapshot }: { snapshot: NotificationsSnapshot }) {
|
||||
return (
|
||||
<>
|
||||
{snapshot.observedAt ? <p>Observed {snapshot.observedAt}</p> : null}
|
||||
<p>Aggregate counts and status only; notification details and contents are excluded.</p>
|
||||
{snapshot.observedAt ? <p>观测时间:{snapshot.observedAt}</p> : null}
|
||||
<p>仅显示汇总计数和状态;不包含通知详情和内容。</p>
|
||||
<div className="notifications-grid">
|
||||
<Section label="Channel aggregate">
|
||||
<Section label="渠道汇总">
|
||||
<AggregateFields aggregate={snapshot.channels} fields={CHANNEL_FIELDS} />
|
||||
</Section>
|
||||
<Section label="Queue aggregate">
|
||||
<Section label="队列汇总">
|
||||
<AggregateFields aggregate={snapshot.queue} fields={QUEUE_FIELDS} />
|
||||
</Section>
|
||||
<Section label="Log aggregate">
|
||||
<Section label="日志汇总">
|
||||
<AggregateFields aggregate={snapshot.logs} fields={LOG_FIELDS} />
|
||||
</Section>
|
||||
</div>
|
||||
@@ -282,16 +283,15 @@ export function NotificationsModule({
|
||||
if (instance.authentication !== 'authenticated') {
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
Authentication is required before Notifications data can be read for this instance.
|
||||
需要先完成认证,才能读取通知数据。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dataSource) {
|
||||
return (
|
||||
<div className="state-panel" role="status" aria-label="Notifications unavailable">
|
||||
No safe Notifications read data source is available. This console will not invent or call an
|
||||
uncontracted production endpoint.
|
||||
<div className="state-panel" role="status" aria-label="通知不可用">
|
||||
没有可用的安全通知只读数据源。此控制台不会臆造或调用未签订契约的生产端点。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -301,8 +301,8 @@ export function NotificationsModule({
|
||||
|
||||
if ((state.kind === 'idle' || state.kind === 'loading') && !snapshot) {
|
||||
return (
|
||||
<p role="status" aria-label="Notifications loading status">
|
||||
Loading Notifications…
|
||||
<p role="status" aria-label="通知加载状态">
|
||||
正在加载通知数据…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -310,9 +310,9 @@ export function NotificationsModule({
|
||||
if (state.kind === 'error' && !snapshot) {
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Unable to load Notifications: {SAFE_LOAD_ERROR}</p>
|
||||
<p>无法加载通知数据: {SAFE_LOAD_ERROR}</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading Notifications
|
||||
重试加载通知数据
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -322,19 +322,18 @@ export function NotificationsModule({
|
||||
|
||||
return (
|
||||
<div className="notifications-module">
|
||||
{state.kind === 'loading' ? <p role="status">Refreshing Notifications…</p> : null}
|
||||
{state.kind === 'loading' ? <p role="status">正在刷新通知数据…</p> : null}
|
||||
{state.kind === 'error' ? (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Refresh failed; showing the last known Notifications data.</p>
|
||||
<p>刷新失败,正在显示上次已知的通知数据。</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading Notifications
|
||||
重试加载通知数据
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{instance.freshness !== 'fresh' ? (
|
||||
<p className="state-panel" role="status" aria-label="Notifications freshness">
|
||||
Notifications data is {instance.freshness}; verify freshness before relying on these
|
||||
values.
|
||||
<p className="state-panel" role="status" aria-label="通知数据时效性">
|
||||
通知数据可能已过期;使用这些值前请先核实数据新鲜度。
|
||||
</p>
|
||||
) : null}
|
||||
<SnapshotView snapshot={snapshot.value} />
|
||||
|
||||
@@ -42,15 +42,15 @@ describe('isolated safe OTA read module', () => {
|
||||
const pending = deferredSource();
|
||||
render(<OtaModule instance={owner} dataSource={pending.source} />);
|
||||
|
||||
expect(screen.getByRole('status', { name: 'OTA loading status' })).toBeTruthy();
|
||||
expect(screen.getByRole('status', { name: 'OTA 加载状态' })).toBeTruthy();
|
||||
expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
|
||||
pending.resolve(snapshot);
|
||||
|
||||
const summary = await screen.findByRole('region', { name: 'OTA safe summary' });
|
||||
const summary = await screen.findByRole('region', { name: 'OTA 安全摘要' });
|
||||
expect(within(summary).getByText('v2.4.1-build.7')).toBeTruthy();
|
||||
expect(within(summary).getByText('downloading')).toBeTruthy();
|
||||
expect(within(summary).getByText('下载中')).toBeTruthy();
|
||||
expect(within(summary).getByText('42%')).toBeTruthy();
|
||||
expect(within(summary).getByText('Available')).toBeTruthy();
|
||||
expect(within(summary).getByText('是')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('strictly projects safe values and rejects URLs, proxy prefixes, notes, logs, and file paths', async () => {
|
||||
@@ -67,8 +67,8 @@ describe('isolated safe OTA read module', () => {
|
||||
} as unknown as OtaSnapshot;
|
||||
render(<OtaModule instance={owner} dataSource={{ load: async () => unsafe }} />);
|
||||
|
||||
const summary = await screen.findByRole('region', { name: 'OTA safe summary' });
|
||||
expect(within(summary).getAllByText('Unavailable')).toHaveLength(2);
|
||||
const summary = await screen.findByRole('region', { name: 'OTA 安全摘要' });
|
||||
expect(within(summary).getAllByText('不可用')).toHaveLength(2);
|
||||
expect(document.body.textContent).not.toMatch(
|
||||
/private\.example|admin\/ota|api\/proxy|private release notes|private raw OTA log|var\/lib|tmp\/private/i,
|
||||
);
|
||||
@@ -79,7 +79,7 @@ describe('isolated safe OTA read module', () => {
|
||||
it('offers no endpoint or apply, cancel, or upload operations', async () => {
|
||||
render(<OtaModule instance={owner} dataSource={{ load: async () => snapshot }} />);
|
||||
expect(await screen.findByText('v2.4.1-build.7')).toBeTruthy();
|
||||
expect(screen.getByText(/read-only OTA metadata/i)).toBeTruthy();
|
||||
expect(screen.getByText(/只读 OTA 元数据/i)).toBeTruthy();
|
||||
expect(screen.queryByText(/endpoint/i)).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /apply|cancel|upload/i })).toBeNull();
|
||||
expect(screen.queryByRole('textbox')).toBeNull();
|
||||
@@ -88,14 +88,14 @@ describe('isolated safe OTA read module', () => {
|
||||
it('fails closed without an injected source or authenticated owner', () => {
|
||||
const load = vi.fn<OtaDataSource['load']>().mockResolvedValue(snapshot);
|
||||
const { rerender } = render(<OtaModule instance={owner} />);
|
||||
expect(screen.getByRole('status', { name: 'OTA unavailable' }).textContent).toMatch(
|
||||
/no safe OTA read data source.*will not invent.*production endpoint/i,
|
||||
expect(screen.getByRole('status', { name: 'OTA 不可用' }).textContent).toMatch(
|
||||
/没有可用的安全 OTA 只读数据源.*不会臆造.*生产端点/i,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<OtaModule instance={{ ...owner, authentication: 'auth-required' }} dataSource={{ load }} />,
|
||||
);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/需要先完成认证/i);
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -132,11 +132,11 @@ describe('isolated safe OTA read module', () => {
|
||||
|
||||
rerender(<OtaModule instance={owner} dataSource={source} refreshSignal={1} />);
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert.textContent).toMatch(/refresh failed; showing the last known OTA data/i);
|
||||
expect(alert.textContent).toMatch(/刷新失败,正在显示上次已知的 OTA 数据/i);
|
||||
expect(alert.textContent).not.toMatch(/secret URL|var\/private/i);
|
||||
expect(screen.getByText('v2.4.1-build.7')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Retry loading OTA data' }));
|
||||
await user.click(screen.getByRole('button', { name: '重试加载 OTA 数据' }));
|
||||
expect(await screen.findByText('v2.4.2')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -148,7 +148,7 @@ describe('isolated safe OTA read module', () => {
|
||||
/>,
|
||||
);
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert.textContent).toContain('OTA data could not be loaded.');
|
||||
expect(alert.textContent).toContain('无法加载 OTA 数据。');
|
||||
expect(alert.textContent).not.toContain('private release URL');
|
||||
});
|
||||
|
||||
@@ -160,13 +160,13 @@ describe('isolated safe OTA read module', () => {
|
||||
dataSource={{ load: async () => ({ currentVersion: opaque }) }}
|
||||
/>,
|
||||
);
|
||||
let summary = await screen.findByRole('region', { name: 'OTA safe summary' });
|
||||
expect(within(summary).getAllByText('Unavailable')).toHaveLength(4);
|
||||
let summary = await screen.findByRole('region', { name: 'OTA 安全摘要' });
|
||||
expect(within(summary).getAllByText('不可用')).toHaveLength(4);
|
||||
expect(document.body.textContent).not.toContain(opaque);
|
||||
rerender(
|
||||
<OtaModule instance={owner} dataSource={{ load: async () => null }} refreshSignal={1} />,
|
||||
);
|
||||
summary = await screen.findByRole('region', { name: 'OTA safe summary' });
|
||||
expect(within(summary).getAllByText('Unavailable')).toHaveLength(4);
|
||||
summary = await screen.findByRole('region', { name: 'OTA 安全摘要' });
|
||||
expect(within(summary).getAllByText('不可用')).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { InstanceContext } from '../app-shell.js';
|
||||
import { displayValue } from '../ui/locale.js';
|
||||
|
||||
export type OtaStatus =
|
||||
| 'idle'
|
||||
@@ -52,7 +53,7 @@ type ReadState =
|
||||
| { kind: 'ready'; ownerId: string; snapshot: SafeOtaSnapshot }
|
||||
| { kind: 'error'; ownerId: string; snapshot?: SafeOtaSnapshot };
|
||||
|
||||
const SAFE_LOAD_ERROR = 'OTA data could not be loaded.';
|
||||
const SAFE_LOAD_ERROR = '无法加载 OTA 数据。';
|
||||
const OTA_STATUSES = new Set<OtaStatus>([
|
||||
'idle',
|
||||
'checking',
|
||||
@@ -107,40 +108,32 @@ function sanitizeSnapshot(value: unknown): SafeOtaSnapshot {
|
||||
}
|
||||
|
||||
function display(value: string | undefined): string {
|
||||
return value ?? 'Unavailable';
|
||||
return displayValue(value);
|
||||
}
|
||||
|
||||
function SnapshotView({ snapshot }: { snapshot: SafeOtaSnapshot }) {
|
||||
return (
|
||||
<section className="ota-card" aria-label="OTA safe summary">
|
||||
<h2>OTA safe summary</h2>
|
||||
<p>Read-only OTA metadata: update operations and source-specific details are excluded.</p>
|
||||
<section className="ota-card" aria-label="OTA 安全摘要">
|
||||
<h2>OTA 安全摘要</h2>
|
||||
<p>仅显示只读 OTA 元数据;不包含更新操作和来源特定详情。</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Current version</dt>
|
||||
<dt>当前版本</dt>
|
||||
<dd>{display(snapshot.currentVersion)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dt>状态</dt>
|
||||
<dd>{display(snapshot.status)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Progress</dt>
|
||||
<dt>进度</dt>
|
||||
<dd>
|
||||
{snapshot.progressPercent === undefined
|
||||
? 'Unavailable'
|
||||
: `${snapshot.progressPercent}%`}
|
||||
{snapshot.progressPercent === undefined ? '不可用' : `${snapshot.progressPercent}%`}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Update availability</dt>
|
||||
<dd>
|
||||
{snapshot.updateAvailable === undefined
|
||||
? 'Unavailable'
|
||||
: snapshot.updateAvailable
|
||||
? 'Available'
|
||||
: 'No update available'}
|
||||
</dd>
|
||||
<dt>更新可用性</dt>
|
||||
<dd>{displayValue(snapshot.updateAvailable)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
@@ -204,16 +197,15 @@ export function OtaModule({ instance, dataSource, refreshSignal }: OtaModuleProp
|
||||
if (instance.authentication !== 'authenticated') {
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
Authentication is required before OTA data can be read for this instance.
|
||||
需要先完成认证,才能读取 OTA 数据。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dataSource) {
|
||||
return (
|
||||
<div className="state-panel" role="status" aria-label="OTA unavailable">
|
||||
No safe OTA read data source is available. This console will not invent or call an
|
||||
uncontracted production endpoint.
|
||||
<div className="state-panel" role="status" aria-label="OTA 不可用">
|
||||
没有可用的安全 OTA 只读数据源。此控制台不会臆造或调用未签订契约的生产端点。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -225,8 +217,8 @@ export function OtaModule({ instance, dataSource, refreshSignal }: OtaModuleProp
|
||||
|
||||
if ((state.kind === 'idle' || state.kind === 'loading') && !retained) {
|
||||
return (
|
||||
<p role="status" aria-label="OTA loading status">
|
||||
Loading OTA data…
|
||||
<p role="status" aria-label="OTA 加载状态">
|
||||
正在加载 OTA 数据…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -234,9 +226,9 @@ export function OtaModule({ instance, dataSource, refreshSignal }: OtaModuleProp
|
||||
if (state.kind === 'error' && !retained) {
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Unable to load OTA data: {SAFE_LOAD_ERROR}</p>
|
||||
<p>无法加载 OTA 数据: {SAFE_LOAD_ERROR}</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading OTA data
|
||||
重试加载 OTA 数据
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -248,18 +240,18 @@ export function OtaModule({ instance, dataSource, refreshSignal }: OtaModuleProp
|
||||
|
||||
return (
|
||||
<div className="ota-module">
|
||||
{state.kind === 'loading' ? <p role="status">Refreshing OTA data…</p> : null}
|
||||
{state.kind === 'loading' ? <p role="status">正在刷新 OTA 数据…</p> : null}
|
||||
{state.kind === 'error' ? (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Refresh failed; showing the last known OTA data.</p>
|
||||
<p>刷新失败,正在显示上次已知的 OTA 数据。</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading OTA data
|
||||
重试加载 OTA 数据
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{instance.freshness !== 'fresh' ? (
|
||||
<p className="state-panel" role="status" aria-label="OTA freshness">
|
||||
OTA data is {instance.freshness}; verify freshness before relying on these values.
|
||||
<p className="state-panel" role="status" aria-label="OTA 数据时效性">
|
||||
OTA 数据可能已过期;使用这些值前请先核实数据新鲜度。
|
||||
</p>
|
||||
) : null}
|
||||
<SnapshotView snapshot={snapshot} />
|
||||
|
||||
@@ -21,7 +21,7 @@ const owner: InstanceContext = {
|
||||
authentication: 'authenticated',
|
||||
freshness: 'fresh',
|
||||
};
|
||||
const overviewCapability: InstanceCapabilityMap = {
|
||||
const overviewCapabilities: InstanceCapabilityMap = {
|
||||
overview: { state: 'supported' },
|
||||
};
|
||||
const snapshot: OverviewSnapshot = {
|
||||
@@ -61,26 +61,51 @@ describe('Phase 6.1 Overview / System read slice', () => {
|
||||
<AppShell
|
||||
pathname="/instances/alpha/overview"
|
||||
instance={owner}
|
||||
capabilities={overviewCapability}
|
||||
capabilities={overviewCapabilities}
|
||||
overviewDataSource={pending.source}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('status', { name: 'Overview loading status' }).textContent).toContain(
|
||||
'Loading overview',
|
||||
expect(screen.getByRole('status', { name: '概览加载状态' }).textContent).toContain(
|
||||
'正在加载概览',
|
||||
);
|
||||
expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
|
||||
pending.resolve(snapshot);
|
||||
|
||||
for (const heading of ['Device', 'SIM', 'Network', 'Statistics', 'CPU', 'Connectivity']) {
|
||||
for (const heading of ['设备', 'SIM', '网络', '统计', 'CPU', '连接状态']) {
|
||||
expect(await screen.findByRole('heading', { name: heading })).toBeTruthy();
|
||||
}
|
||||
expect(
|
||||
within(screen.getByRole('region', { name: 'Device' })).getByText('SIMBox 8'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText(/Observed 2026-07-17T10:00:00Z/)).toBeTruthy();
|
||||
expect(within(screen.getByRole('region', { name: '设备' })).getByText('SIMBox 8')).toBeTruthy();
|
||||
expect(screen.getByText(/观测时间:2026-07-17T10:00:00Z/)).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /restart/i })).toBeNull();
|
||||
expect(screen.getByText(/Restart is unavailable.*R3/i)).toBeTruthy();
|
||||
expect(screen.getByText(/重启不可用.*R3/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('translates only known field names and values while preserving unknown safe data', async () => {
|
||||
render(
|
||||
<OverviewSystemPage
|
||||
instance={owner}
|
||||
dataSource={{
|
||||
load: async () => ({
|
||||
...snapshot,
|
||||
device: { Model: 'SIMBox 8', Enabled: true, Mystery: 'vendor-value' },
|
||||
network: { Registration: 'registered' },
|
||||
connectivity: { State: 'connected' },
|
||||
}),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const device = await screen.findByRole('region', { name: '设备' });
|
||||
expect(within(device).getByText('型号')).toBeTruthy();
|
||||
expect(within(device).getByText('Enabled')).toBeTruthy();
|
||||
expect(within(device).getByText('是')).toBeTruthy();
|
||||
expect(within(device).getByText('Mystery')).toBeTruthy();
|
||||
expect(within(device).getByText('vendor-value')).toBeTruthy();
|
||||
expect(within(screen.getByRole('region', { name: '网络' })).getByText('已注册')).toBeTruthy();
|
||||
expect(
|
||||
within(screen.getByRole('region', { name: '连接状态' })).getByText('已连接'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('is honest when no safe read source is injected and never attempts a production endpoint', () => {
|
||||
@@ -88,11 +113,11 @@ describe('Phase 6.1 Overview / System read slice', () => {
|
||||
<AppShell
|
||||
pathname="/instances/alpha/overview"
|
||||
instance={owner}
|
||||
capabilities={overviewCapability}
|
||||
capabilities={overviewCapabilities}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('status', { name: 'Overview unavailable' }).textContent).toMatch(
|
||||
/no safe overview read data source/i,
|
||||
expect(screen.getByRole('status', { name: '概览不可用' }).textContent).toMatch(
|
||||
/没有可用的安全概览只读数据源/i,
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: /restart/i })).toBeNull();
|
||||
});
|
||||
@@ -114,7 +139,7 @@ describe('Phase 6.1 Overview / System read slice', () => {
|
||||
dataSource={{ load }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/需要先完成认证/i);
|
||||
expect(screen.queryByText('SIMBox 8')).toBeNull();
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -126,8 +151,8 @@ describe('Phase 6.1 Overview / System read slice', () => {
|
||||
dataSource={{ load: async () => snapshot }}
|
||||
/>,
|
||||
);
|
||||
expect((await screen.findByRole('status', { name: 'Overview freshness' })).textContent).toMatch(
|
||||
/stale/i,
|
||||
expect((await screen.findByRole('status', { name: '概览数据新鲜度' })).textContent).toMatch(
|
||||
/可能已过期/i,
|
||||
);
|
||||
expect(screen.getByText('SIMBox 8')).toBeTruthy();
|
||||
});
|
||||
@@ -140,10 +165,8 @@ describe('Phase 6.1 Overview / System read slice', () => {
|
||||
.mockResolvedValueOnce(snapshot);
|
||||
render(<OverviewSystemPage instance={owner} dataSource={{ load }} />);
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toContain(
|
||||
'Overview data could not be loaded.',
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: 'Retry loading overview' }));
|
||||
expect((await screen.findByRole('alert')).textContent).toContain('无法加载概览数据。');
|
||||
await user.click(screen.getByRole('button', { name: '重试加载概览' }));
|
||||
expect(await screen.findByText('SIMBox 8')).toBeTruthy();
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -34,14 +34,53 @@ type ReadState =
|
||||
| { kind: 'error'; message: string; snapshot?: OverviewSnapshot };
|
||||
|
||||
const SECTIONS = [
|
||||
['device', 'Device'],
|
||||
['device', '设备'],
|
||||
['sim', 'SIM'],
|
||||
['network', 'Network'],
|
||||
['stats', 'Statistics'],
|
||||
['network', '网络'],
|
||||
['stats', '统计'],
|
||||
['cpu', 'CPU'],
|
||||
['connectivity', 'Connectivity'],
|
||||
['connectivity', '连接状态'],
|
||||
] as const;
|
||||
|
||||
const FIELD_LABELS: Readonly<Record<string, string>> = {
|
||||
Model: '型号',
|
||||
Uptime: '运行时间',
|
||||
Slots: '卡槽数',
|
||||
Active: '活跃数',
|
||||
Operator: '运营商',
|
||||
Registration: '注册状态',
|
||||
'Messages today': '今日消息数',
|
||||
Calls: '通话数',
|
||||
Usage: '使用率',
|
||||
Temperature: '温度',
|
||||
State: '状态',
|
||||
Latency: '延迟',
|
||||
};
|
||||
|
||||
const ENUM_LABELS: Readonly<Record<string, string>> = {
|
||||
connected: '已连接',
|
||||
disconnected: '未连接',
|
||||
connecting: '正在连接',
|
||||
registered: '已注册',
|
||||
unregistered: '未注册',
|
||||
searching: '正在搜索',
|
||||
roaming: '漫游中',
|
||||
online: '在线',
|
||||
offline: '离线',
|
||||
unknown: '未知',
|
||||
};
|
||||
|
||||
function displayFieldLabel(key: string): string {
|
||||
return FIELD_LABELS[key] ?? key;
|
||||
}
|
||||
|
||||
function displayFieldValue(value: OverviewFieldValue): string {
|
||||
if (value == null) return '不可用';
|
||||
if (typeof value === 'boolean') return value ? '是' : '否';
|
||||
if (typeof value === 'string') return ENUM_LABELS[value.toLocaleLowerCase()] ?? value;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function StructuredSection({ label, values }: { label: string; values: OverviewSection }) {
|
||||
const entries = Object.entries(values);
|
||||
return (
|
||||
@@ -51,13 +90,13 @@ function StructuredSection({ label, values }: { label: string; values: OverviewS
|
||||
<dl>
|
||||
{entries.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<dt>{key}</dt>
|
||||
<dd>{value == null ? 'Unavailable' : String(value)}</dd>
|
||||
<dt>{displayFieldLabel(key)}</dt>
|
||||
<dd>{displayFieldValue(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : (
|
||||
<p>No {label.toLocaleLowerCase()} data was supplied.</p>
|
||||
<p>未提供{label}数据。</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
@@ -103,7 +142,7 @@ export function OverviewSystemPage({
|
||||
if (request === requestOwner.current && !controller.signal.aborted)
|
||||
setState((current) => ({
|
||||
kind: 'error',
|
||||
message: 'Overview data could not be loaded.',
|
||||
message: '无法加载概览数据。',
|
||||
...(current.kind === 'loading' && current.snapshot
|
||||
? { snapshot: current.snapshot }
|
||||
: {}),
|
||||
@@ -116,15 +155,14 @@ export function OverviewSystemPage({
|
||||
if (instance.authentication === 'auth-required')
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
Authentication is required before overview data can be read for this instance.
|
||||
需要先完成认证,才能读取概览数据。
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!dataSource)
|
||||
return (
|
||||
<div className="state-panel" role="status" aria-label="Overview unavailable">
|
||||
No safe overview read data source is available. This console will not invent or call an
|
||||
uncontracted production endpoint.
|
||||
<div className="state-panel" role="status" aria-label="概览不可用">
|
||||
没有可用的安全概览只读数据源。此控制台不会臆造或调用未签订契约的生产端点。
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -133,17 +171,17 @@ export function OverviewSystemPage({
|
||||
|
||||
if ((state.kind === 'loading' || state.kind === 'idle') && !retainedSnapshot)
|
||||
return (
|
||||
<p role="status" aria-label="Overview loading status">
|
||||
Loading overview…
|
||||
<p role="status" aria-label="概览加载状态">
|
||||
正在加载概览…
|
||||
</p>
|
||||
);
|
||||
|
||||
if (state.kind === 'error' && !state.snapshot)
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Unable to load overview: {state.message}</p>
|
||||
<p>无法加载概览: {state.message}</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading overview
|
||||
重试加载概览
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -153,32 +191,29 @@ export function OverviewSystemPage({
|
||||
|
||||
return (
|
||||
<div className="overview-system">
|
||||
{state.kind === 'loading' ? <p role="status">Refreshing overview…</p> : null}
|
||||
{state.kind === 'loading' ? <p role="status">正在刷新概览…</p> : null}
|
||||
{state.kind === 'error' ? (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Refresh failed; showing the last known overview.</p>
|
||||
<p>刷新失败,正在显示上次已知的概览数据。</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading overview
|
||||
重试加载概览
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{instance.freshness !== 'fresh' ? (
|
||||
<p className="state-panel" role="status" aria-label="Overview freshness">
|
||||
Overview data is {instance.freshness}; verify freshness before relying on these values.
|
||||
<p className="state-panel" role="status" aria-label="概览数据新鲜度">
|
||||
概览数据可能已过期;使用这些值前请先核实数据新鲜度。
|
||||
</p>
|
||||
) : null}
|
||||
{snapshot.observedAt ? <p>Observed {snapshot.observedAt}</p> : null}
|
||||
{snapshot.observedAt ? <p>观测时间:{snapshot.observedAt}</p> : null}
|
||||
<div className="overview-grid">
|
||||
{SECTIONS.map(([key, label]) => (
|
||||
<StructuredSection key={key} label={label} values={snapshot[key]} />
|
||||
))}
|
||||
</div>
|
||||
<section className="state-panel" aria-label="System actions">
|
||||
<h2>System actions</h2>
|
||||
<p>
|
||||
Restart is unavailable because the backend R3 restart operation is not implemented. No
|
||||
action is offered.
|
||||
</p>
|
||||
<section className="state-panel" aria-label="系统操作">
|
||||
<h2>系统操作</h2>
|
||||
<p>重启不可用,因为后端尚未实现 R3 重启操作,因此不提供任何操作。</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user