feat(web): localize and redesign operations console

This commit is contained in:
chick
2026-07-18 22:22:41 +08:00
parent d575ab4d9d
commit abfd07f8a3
34 changed files with 1536 additions and 1058 deletions
+3 -3
View File
@@ -1,10 +1,10 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<title>Multi SimAdmin</title>
<meta name="color-scheme" content="light" />
<title>多实例 SimAdmin 管理台</title>
</head>
<body>
<div id="root"></div>
+41 -41
View File
@@ -20,7 +20,7 @@ const snapshot: FleetSnapshot = {
{
id: 'bravo',
name: 'Bravo',
url: 'http://user:secret@bravo.example:8080/private',
url: 'http://user:***@bravo.example:8080/private',
tags: ['west'],
},
{ id: 'alpha', name: 'Alpha', url: 'https://alpha.example/admin' },
@@ -69,8 +69,8 @@ describe('React AppShell and Fleet vertical slice', () => {
const dataSource = source(() => new Promise((done) => (resolve = done)));
render(<AppShell pathname="/fleet" version="0.1.0" fleetDataSource={dataSource} />);
expect(screen.getByRole('status', { name: 'Fleet loading status' }).textContent).toContain(
'Loading instances',
expect(screen.getByRole('status', { name: '实例加载状态' }).textContent).toContain(
'正在加载实例',
);
resolve(snapshot);
@@ -80,10 +80,10 @@ describe('React AppShell and Fleet vertical slice', () => {
);
expect(
within(screen.getByRole('row', { name: /Bravo/ }))
.getByRole('link', { name: 'Open Bravo origin' })
.getByRole('link', { name: '打开 Bravo 的源站' })
.getAttribute('href'),
).toBe('http://bravo.example:8080');
expect(screen.getByText('Version 0.1.0')).toBeTruthy();
expect(screen.getByText('版本 0.1.0')).toBeTruthy();
expect(
consoleError.mock.calls.some((call) =>
call.some((argument) => String(argument).includes('unique "key" prop')),
@@ -96,23 +96,23 @@ describe('React AppShell and Fleet vertical slice', () => {
render(<AppShell pathname="/fleet" fleetDataSource={source(async () => snapshot)} />);
await screen.findByRole('row', { name: /Alpha/ });
await user.click(screen.getByRole('button', { name: /Sort by latency/i }));
await user.click(screen.getByRole('button', { name: /按延迟排序/i }));
let rows = screen.getAllByRole('row').slice(1);
expect(rows[0]?.textContent).toContain('Alpha');
await user.click(screen.getByRole('button', { name: /Sort by latency/i }));
await user.click(screen.getByRole('button', { name: /按延迟排序/i }));
rows = screen.getAllByRole('row').slice(1);
expect(rows[0]?.textContent).toContain('Bravo');
await user.selectOptions(screen.getByRole('combobox', { name: 'Status' }), 'auth');
await user.selectOptions(screen.getByRole('combobox', { name: '状态' }), 'auth');
expect(screen.queryByRole('row', { name: /Alpha/ })).toBeNull();
await user.click(screen.getByRole('checkbox', { name: 'Select all visible instances' }));
expect(
(screen.getByRole('checkbox', { name: 'Select Bravo' }) as HTMLInputElement).checked,
).toBe(true);
expect(screen.getByText('1 selected')).toBeTruthy();
await user.click(screen.getByRole('checkbox', { name: '选择当前页全部实例' }));
expect((screen.getByRole('checkbox', { name: '选择 Bravo' }) as HTMLInputElement).checked).toBe(
true,
);
expect(screen.getByText('已选择 1 项')).toBeTruthy();
await user.type(screen.getByRole('searchbox', { name: 'Search instances' }), 'missing');
expect(screen.getByText(/No instances match your search/i)).toBeTruthy();
await user.type(screen.getByRole('searchbox', { name: '搜索实例' }), 'missing');
expect(screen.getByText(/没有实例符合搜索与筛选条件/i)).toBeTruthy();
});
it('exposes error, retry, configured-empty, and unsafe-origin states without claiming a backend', async () => {
@@ -126,10 +126,10 @@ describe('React AppShell and Fleet vertical slice', () => {
});
const first = render(<AppShell pathname="/fleet" fleetDataSource={source(load)} />);
expect((await screen.findByRole('alert')).textContent).toContain('collector unavailable');
await user.click(screen.getByRole('button', { name: 'Retry loading instances' }));
expect((await screen.findByRole('alert')).textContent).toContain('实例加载失败');
await user.click(screen.getByRole('button', { name: '重试加载实例' }));
expect(await screen.findByRole('row', { name: /Unsafe/ })).toBeTruthy();
expect(screen.queryByRole('link', { name: 'Open Unsafe origin' })).toBeNull();
expect(screen.queryByRole('link', { name: '打开 Unsafe 的源站' })).toBeNull();
first.unmount();
const { unmount } = render(
@@ -138,7 +138,7 @@ describe('React AppShell and Fleet vertical slice', () => {
fleetDataSource={source(async () => ({ instances: [], statuses: new Map() }))}
/>,
);
expect(await screen.findByText(/No instances are configured/i)).toBeTruthy();
expect(await screen.findByText(/尚未配置实例/i)).toBeTruthy();
unmount();
});
@@ -147,20 +147,20 @@ describe('React AppShell and Fleet vertical slice', () => {
render(<AppShell pathname="/fleet" fleetDataSource={source(async () => snapshot)} />);
await screen.findByRole('row', { name: /Alpha/ });
await user.selectOptions(screen.getByRole('combobox', { name: 'Capability' }), 'sms');
await user.selectOptions(screen.getByRole('combobox', { name: '能力' }), 'sms');
expect(screen.queryByRole('row', { name: /Alpha/ })).toBeNull();
expect(screen.getByRole('row', { name: /Bravo/ }).textContent).toContain('clock drift');
await user.click(screen.getByRole('button', { name: 'Choose columns' }));
await user.click(screen.getByRole('checkbox', { name: 'Show anomalies column' }));
expect(screen.queryByRole('columnheader', { name: 'Anomalies' })).toBeNull();
await user.click(screen.getByRole('button', { name: '选择列' }));
await user.click(screen.getByRole('checkbox', { name: '显示异常列' }));
expect(screen.queryByRole('columnheader', { name: '异常' })).toBeNull();
await user.click(screen.getByRole('checkbox', { name: 'Select Bravo' }));
expect(
(screen.getByRole('button', { name: 'Batch actions' }) as HTMLButtonElement).disabled,
).toBe(false);
await user.click(screen.getByRole('button', { name: 'Batch actions' }));
expect(screen.getByText('Choose an action for 1 selected instance.')).toBeTruthy();
await user.click(screen.getByRole('checkbox', { name: '选择 Bravo' }));
expect((screen.getByRole('button', { name: '批量操作' }) as HTMLButtonElement).disabled).toBe(
false,
);
await user.click(screen.getByRole('button', { name: '批量操作' }));
expect(screen.getByText('请为已选择的 1 项选择操作。')).toBeTruthy();
});
it('paginates fleet rows and selects only the current page', async () => {
@@ -177,12 +177,12 @@ describe('React AppShell and Fleet vertical slice', () => {
await screen.findByRole('row', { name: /Instance 01/ });
expect(screen.queryByRole('row', { name: /Instance 11/ })).toBeNull();
expect(screen.getByText('Page 1 of 2')).toBeTruthy();
await user.click(screen.getByRole('checkbox', { name: 'Select all visible instances' }));
expect(screen.getByText('10 selected')).toBeTruthy();
await user.click(screen.getByRole('button', { name: 'Next page' }));
expect(screen.getByText('第 1 页,共 2 页')).toBeTruthy();
await user.click(screen.getByRole('checkbox', { name: '选择当前页全部实例' }));
expect(screen.getByText('已选择 10 项')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '下一页' }));
expect(await screen.findByRole('row', { name: /Instance 11/ })).toBeTruthy();
expect(screen.getByText('Page 2 of 2')).toBeTruthy();
expect(screen.getByText('第 2 页,共 2 页')).toBeTruthy();
});
it('binds instance context to the route owner and never leaks mismatched context', () => {
@@ -197,27 +197,27 @@ describe('React AppShell and Fleet vertical slice', () => {
const { rerender } = render(
<AppShell pathname="/instances/owner/messages" instance={instance} />,
);
expect(screen.getByRole('heading', { name: 'Messages' })).toBeTruthy();
expect(screen.getByRole('heading', { name: '消息' })).toBeTruthy();
expect(screen.getByText('Owner modem')).toBeTruthy();
expect(screen.getByRole('link', { name: 'Open original site' }).getAttribute('href')).toBe(
expect(screen.getByRole('link', { name: '打开源站' }).getAttribute('href')).toBe(
'https://owner.example',
);
rerender(<AppShell pathname="/instances/someone-else/messages" instance={instance} />);
expect(screen.queryByText('Owner modem')).toBeNull();
expect(screen.getByText(/Instance context is unavailable/i)).toBeTruthy();
expect(screen.getByText(/此路由缺少实例上下文/i)).toBeTruthy();
});
it.each([
{
pathname: '/jobs',
endpoint: '/api/v1/jobs?page=1&pageSize=25&sort=createdAt&direction=desc',
emptyMessage: /No jobs match the current query/i,
emptyMessage: /没有任务符合当前查询/i,
},
{
pathname: '/audit',
endpoint: '/api/v1/audit?sort=occurredAt&direction=desc&page=1&pageSize=25',
emptyMessage: /No audit events match the current query/i,
emptyMessage: /没有审计事件符合当前查询/i,
},
])(
'uses the default HTTP data source for $pathname',
@@ -247,12 +247,12 @@ describe('React AppShell and Fleet vertical slice', () => {
{
pathname: '/jobs',
sourceProp: 'jobsDataSource' as const,
emptyMessage: /No jobs match the current query/i,
emptyMessage: /没有任务符合当前查询/i,
},
{
pathname: '/audit',
sourceProp: 'auditDataSource' as const,
emptyMessage: /No audit events match the current query/i,
emptyMessage: /没有审计事件符合当前查询/i,
},
])(
'uses an explicitly injected data source instead of global fetch for $pathname',
+24 -27
View File
@@ -242,19 +242,16 @@ function Page({
if (route.kind === 'settings-system')
return (
<section aria-labelledby="system-settings-title">
<h1 id="system-settings-title">System settings</h1>
<p role="status">
System settings are unavailable because the control plane does not expose a settings
contract.
</p>
<h1 id="system-settings-title"></h1>
<p role="status"></p>
</section>
);
if (route.kind === 'not-found')
return (
<section>
<h1>Page not found</h1>
<p>The requested console page does not exist.</p>
<a href="/fleet">Return to Fleet</a>
<h1></h1>
<p></p>
<a href="/fleet"></a>
</section>
);
if (route.kind.startsWith('instance-')) {
@@ -369,19 +366,19 @@ function Page({
);
}
const labels: Partial<Record<RouteKind, string>> = {
'instance-new': 'Add instance',
jobs: 'Jobs',
'job-detail': 'Job details',
audit: 'Audit',
'audit-detail': 'Audit event',
'settings-instances': 'Instance settings',
'settings-instance-detail': 'Instance settings',
'settings-system': 'System settings',
'instance-new': '添加实例',
jobs: '任务',
'job-detail': '任务详情',
audit: '审计',
'audit-detail': '审计事件',
'settings-instances': '实例设置',
'settings-instance-detail': '实例设置',
'settings-system': '系统设置',
};
return (
<section>
<h1>{labels[route.kind] ?? 'Multi SimAdmin'}</h1>
<p>This route is ready for structured data and actions.</p>
<h1>{labels[route.kind] ?? '多实例 SimAdmin 管理台'}</h1>
<p></p>
</section>
);
}
@@ -429,24 +426,24 @@ export function AppShell({
return (
<div className="app-shell" data-route={route.kind}>
<a className="skip-link" href="#main-content">
Skip to main content
</a>
<header className="app-topbar">
<a className="product-name" href="/fleet">
Multi SimAdmin
SimAdmin
</a>
<span className="connection-status">Console</span>
<span>Version {version}</span>
<span className="connection-status"></span>
<span> {version}</span>
</header>
<div className="app-layout">
<nav className="global-navigation" aria-label="Global navigation">
<nav className="global-navigation" aria-label="全局导航">
<ul>
{(
[
['fleet', '/fleet', 'Fleet'],
['jobs', '/jobs', 'Jobs'],
['audit', '/audit', 'Audit'],
['settings', '/settings/instances', 'Settings'],
['fleet', '/fleet', '实例总览'],
['jobs', '/jobs', '任务'],
['audit', '/audit', '审计'],
['settings', '/settings/instances', '设置'],
] as const
).map(([key, href, label]) => (
<li key={key}>
+16 -18
View File
@@ -45,8 +45,8 @@ function deferredSource() {
describe('Audit workspace frozen contract', () => {
it('is explicitly unavailable without an injected source', () => {
render(<AuditPage />);
expect(screen.getByRole('status', { name: 'Audit unavailable' }).textContent).toMatch(
/no audit data source was provided/i,
expect(screen.getByRole('status', { name: '审计不可用' }).textContent).toMatch(
/未提供审计数据源/,
);
expect(screen.queryByRole('table')).toBeNull();
});
@@ -60,12 +60,12 @@ describe('Audit workspace frozen contract', () => {
);
pending.resolve({ items: [event], page: { page: 1, pageSize: 25, total: 1 } });
const table = await screen.findByRole('table', { name: 'Audit events' });
const table = await screen.findByRole('table', { name: '审计事件' });
for (const text of [
event.occurredAt,
event.actorId,
event.action,
'Partially succeeded',
'部分成功',
event.requestId,
event.instanceId,
event.jobId,
@@ -122,18 +122,18 @@ describe('Audit workspace frozen contract', () => {
page: { page: 1, pageSize: 25, total: 80 },
});
render(<AuditPage dataSource={{ load }} />);
await screen.findByText('No audit events match the current query.');
await screen.findByText('没有审计事件符合当前查询。');
await user.type(screen.getByRole('textbox', { name: 'Actor ID' }), 'alice');
await user.type(screen.getByRole('textbox', { name: 'Instance ID' }), 'alpha');
await user.type(screen.getByRole('textbox', { name: 'Job ID' }), 'job-1');
await user.type(screen.getByRole('textbox', { name: 'Operation ID' }), 'call.start');
await user.selectOptions(screen.getByRole('combobox', { name: 'Outcome' }), 'failed');
await user.type(screen.getByRole('textbox', { name: 'Request ID' }), 'req-1');
await user.type(screen.getByLabelText('Occurred from'), '2026-07-01T10:00');
await user.type(screen.getByLabelText('Occurred to'), '2026-07-17T10:00');
await user.click(screen.getByRole('button', { name: /sort by action/i }));
await user.click(screen.getByRole('button', { name: 'Next page' }));
await user.type(screen.getByRole('textbox', { name: '操作者 ID' }), 'alice');
await user.type(screen.getByRole('textbox', { name: '实例 ID' }), 'alpha');
await user.type(screen.getByRole('textbox', { name: '任务 ID' }), 'job-1');
await user.type(screen.getByRole('textbox', { name: '操作 ID' }), 'call.start');
await user.selectOptions(screen.getByRole('combobox', { name: '结果' }), 'failed');
await user.type(screen.getByRole('textbox', { name: '请求 ID' }), 'req-1');
await user.type(screen.getByLabelText('开始时间'), '2026-07-01T10:00');
await user.type(screen.getByLabelText('结束时间'), '2026-07-17T10:00');
await user.click(screen.getByRole('button', { name: /按操作排序/ }));
await user.click(screen.getByRole('button', { name: '下一页' }));
const query = load.mock.calls.at(-1)?.[0] as AuditPageQuery;
expect(query).toEqual({
@@ -195,9 +195,7 @@ describe('Audit workspace frozen contract', () => {
.fn<AuditDataSource['load']>()
.mockRejectedValue(new Error('private detail'));
render(<AuditPage dataSource={{ load: retryLoad }} />);
expect((await screen.findByRole('alert')).textContent).toContain(
'Audit events could not be loaded.',
);
expect((await screen.findByRole('alert')).textContent).toContain('无法加载审计事件。');
expect(screen.queryByText('private detail')).toBeNull();
});
});
+51 -46
View File
@@ -156,10 +156,15 @@ export function datetimeLocalToUtc(value: string): string | undefined {
}
function outcomeLabel(value: AuditOutcome): string {
return value
.split('-')
.map((part, index) => (index === 0 ? part.charAt(0).toUpperCase() + part.slice(1) : part))
.join(' ');
const labels: Readonly<Record<string, string>> = {
succeeded: '成功',
failed: '失败',
'partially-succeeded': '部分成功',
success: '成功',
failure: '失败',
denied: '已拒绝',
};
return labels[value] ?? value;
}
export function AuditPage({ dataSource, refreshSignal = 0 }: AuditPageProps) {
@@ -254,36 +259,36 @@ export function AuditPage({ dataSource, refreshSignal = 0 }: AuditPageProps) {
if (!dataSource)
return (
<section aria-labelledby="audit-title">
<h1 id="audit-title">Audit</h1>
<p role="status" aria-label="Audit unavailable">
Audit is unavailable at runtime because no audit data source was provided.
<h1 id="audit-title"></h1>
<p role="status" aria-label="审计不可用">
</p>
</section>
);
const sortableColumns: readonly [AuditSort, string][] = [
['occurredAt', 'Occurred at'],
['action', 'Action'],
['outcome', 'Outcome'],
['occurredAt', '发生时间'],
['action', '操作'],
['outcome', '结果'],
];
const pageCount = Math.max(1, Math.ceil((result?.page.total ?? 0) / PAGE_SIZE));
const identifiers: readonly [keyof typeof filters, string][] = [
['actorId', 'Actor ID'],
['instanceId', 'Instance ID'],
['jobId', 'Job ID'],
['operationId', 'Operation ID'],
['requestId', 'Request ID'],
['actorId', '操作者 ID'],
['instanceId', '实例 ID'],
['jobId', '任务 ID'],
['operationId', '操作 ID'],
['requestId', '请求 ID'],
];
return (
<section className="fleet-panel" aria-labelledby="audit-title">
<div className="fleet-heading">
<div>
<h1 id="audit-title">Audit</h1>
<p>Read-only operational audit events.</p>
<h1 id="audit-title"></h1>
<p></p>
</div>
<button type="button" onClick={() => setAttempt((value) => value + 1)}>
Refresh audit events
</button>
</div>
<div className="fleet-toolbar">
@@ -298,13 +303,13 @@ export function AuditPage({ dataSource, refreshSignal = 0 }: AuditPageProps) {
</label>
))}
<label>
<span>Outcome</span>
<span></span>
<select
aria-label="Outcome"
aria-label="结果"
value={filters.outcome}
onChange={(event) => changeFilter('outcome', event.currentTarget.value)}
>
<option value="">All outcomes</option>
<option value=""></option>
{AUDIT_OUTCOMES.map((outcome) => (
<option key={outcome} value={outcome}>
{outcomeLabel(outcome)}
@@ -313,18 +318,18 @@ export function AuditPage({ dataSource, refreshSignal = 0 }: AuditPageProps) {
</select>
</label>
<label>
<span>Occurred from</span>
<span></span>
<input
aria-label="Occurred from"
aria-label="开始时间"
type="datetime-local"
value={filters.occurredFrom}
onChange={(event) => changeFilter('occurredFrom', event.currentTarget.value)}
/>
</label>
<label>
<span>Occurred to</span>
<span></span>
<input
aria-label="Occurred to"
aria-label="结束时间"
type="datetime-local"
value={filters.occurredTo}
onChange={(event) => changeFilter('occurredTo', event.currentTarget.value)}
@@ -332,24 +337,24 @@ export function AuditPage({ dataSource, refreshSignal = 0 }: AuditPageProps) {
</label>
</div>
{loading ? (
<p role="status" aria-label="Audit loading status">
Loading audit events
<p role="status" aria-label="审计加载状态">
</p>
) : null}
{error ? (
<div role="alert" className="state-panel state-error">
<p>Audit events could not be loaded.</p>
<p></p>
<button type="button" onClick={() => setAttempt((value) => value + 1)}>
Retry loading audit events
</button>
</div>
) : null}
{!loading && result?.items.length === 0 ? (
<p className="state-panel">No audit events match the current query.</p>
<p className="state-panel"></p>
) : null}
{!loading && result ? (
<div className="table-scroll" tabIndex={0}>
<table className="dense-table" aria-label="Audit events">
<table className="dense-table" aria-label="审计事件">
<thead>
<tr>
{sortableColumns.map(([field, label]) => (
@@ -366,7 +371,7 @@ export function AuditPage({ dataSource, refreshSignal = 0 }: AuditPageProps) {
>
<button
type="button"
aria-label={`Sort by ${field}`}
aria-label={`${label}排序`}
onClick={() => changeSort(field)}
>
{label}
@@ -374,14 +379,14 @@ export function AuditPage({ dataSource, refreshSignal = 0 }: AuditPageProps) {
</th>
))}
{[
'Actor ID',
'Request ID',
'Instance ID',
'Job ID',
'Item ID',
'Attempt ID',
'Preparation ID',
'Parameters',
'操作者 ID',
'请求 ID',
'实例 ID',
'任务 ID',
'项目 ID',
'尝试 ID',
'准备 ID',
'参数',
].map((label) => (
<th key={label} scope="col">
{label}
@@ -418,25 +423,25 @@ export function AuditPage({ dataSource, refreshSignal = 0 }: AuditPageProps) {
</div>
) : null}
{!loading && result ? (
<nav className="pagination" aria-label="Audit pagination">
<nav className="pagination" aria-label="审计分页">
<button
type="button"
aria-label="Previous page"
aria-label="上一页"
disabled={page === 1}
onClick={() => setPage((value) => value - 1)}
>
Previous
</button>
<span>
Page {page} of {pageCount}
{page} / {pageCount}
</span>
<button
type="button"
aria-label="Next page"
aria-label="下一页"
disabled={page >= pageCount}
onClick={() => setPage((value) => value + 1)}
>
Next
</button>
</nav>
) : null}
+102 -78
View File
@@ -25,23 +25,43 @@ export interface FleetPageProps {
const EMPTY_SNAPSHOT: FleetSnapshot = { instances: [], statuses: new Map() };
const STATUS_LABELS: Readonly<Record<string, string>> = {
online: 'Online',
auth: 'Authentication required',
offline: 'Offline',
unknown: 'Unknown',
online: '在线',
auth: '需要认证',
offline: '离线',
unknown: '未知',
};
const COLUMN_LABELS: Readonly<Record<FleetSortColumn | 'origin', string>> = {
name: 'Name',
status: 'Status',
latency: 'Latency',
version: 'Version',
capabilities: 'Capabilities',
tags: 'Tags',
freshness: 'Freshness',
anomalies: 'Anomalies',
origin: 'Origin',
name: '名称',
status: '状态',
latency: '延迟',
version: '版本',
capabilities: '能力',
tags: '标签',
freshness: '数据新鲜度',
anomalies: '异常',
origin: '源地址',
};
const ALL_COLUMNS = Object.keys(COLUMN_LABELS) as readonly (FleetSortColumn | 'origin')[];
const CAPABILITY_LABELS: Readonly<Record<string, string>> = {
overview: '概览',
cellular: '蜂窝网络',
'device-network': '设备网络',
messages: '消息',
calls: '通话',
esim: 'eSIM',
notifications: '通知',
automation: '自动化',
ota: 'OTA',
};
const FRESHNESS_LABELS: Readonly<Record<string, string>> = {
fresh: '最新',
stale: '可能过期',
unknown: '未知',
};
function capabilityLabel(value: string): string {
return CAPABILITY_LABELS[value] ?? value;
}
export function canonicalHttpOrigin(value: string): string | null {
try {
@@ -93,9 +113,8 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
(data) => {
if (active) setSnapshot(data);
},
(reason: unknown) => {
if (active)
setError(reason instanceof Error ? reason.message : 'Unable to load instances.');
() => {
if (active) setError('实例加载失败。');
},
);
return () => {
@@ -171,6 +190,7 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
setValue: (value: string) => void,
choices: readonly string[],
allLabel: string,
displayChoice: (choice: string) => string = (choice) => choice,
) => (
<label>
<span>{label}</span>
@@ -181,7 +201,7 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
<option value="">{allLabel}</option>
{choices.map((choice) => (
<option key={choice} value={choice}>
{choice}
{displayChoice(choice)}
</option>
))}
</select>
@@ -192,92 +212,92 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
<section className="fleet-panel" aria-labelledby="fleet-title">
<div className="fleet-heading">
<div>
<h1 id="fleet-title">Fleet</h1>
<p>Find, compare, and manage SimAdmin instances.</p>
<h1 id="fleet-title"></h1>
<p> SimAdmin </p>
</div>
<div className="fleet-actions">
<span className="selection-summary" aria-live="polite">
{model.selectedIds.length} selected
{model.selectedIds.length}
</span>
<button
type="button"
disabled={model.selectedIds.length === 0}
onClick={() => setBatchOpen((open) => !open)}
>
Batch actions
</button>
</div>
</div>
{batchOpen ? (
<div className="batch-entry" role="region" aria-label="Batch action entry">
Choose an action for {model.selectedIds.length} selected{' '}
{model.selectedIds.length === 1 ? 'instance' : 'instances'}.
<div className="batch-entry" role="region" aria-label="批量操作入口">
{model.selectedIds.length}
</div>
) : null}
<div className="fleet-toolbar">
<label>
<span>Search instances</span>
<span></span>
<input
type="search"
value={query}
onChange={(event) => resetPage(() => setQuery(event.currentTarget.value))}
placeholder="Name, ID, tag, origin…"
placeholder="名称、ID、标签、源地址…"
/>
</label>
<label>
<span>Status</span>
<span></span>
<select
value={filter}
onChange={(event) =>
resetPage(() => setFilter(event.currentTarget.value as FleetFilter))
}
>
<option value="all">All statuses</option>
<option value="online">Online</option>
<option value="auth">Authentication required</option>
<option value="offline">Offline</option>
<option value="unknown">Unknown</option>
<option value="all"></option>
<option value="online">线</option>
<option value="auth"></option>
<option value="offline">线</option>
<option value="unknown"></option>
</select>
</label>
<label>
<span>Authentication</span>
<span></span>
<select
value={auth}
onChange={(event) =>
resetPage(() => setAuth(event.currentTarget.value as FleetAuthFilter))
}
>
<option value="all">All authentication</option>
<option value="authenticated">Authenticated</option>
<option value="required">Authentication required</option>
<option value="unknown">Unknown</option>
<option value="all"></option>
<option value="authenticated"></option>
<option value="required"></option>
<option value="unknown"></option>
</select>
</label>
{selectFilter(
'Capability',
'能力',
capability,
setCapability,
model.facets.capabilities,
'All capabilities',
'全部能力',
capabilityLabel,
)}
{selectFilter('Version', version, setVersion, model.facets.versions, 'All versions')}
{selectFilter('Tag', tag, setTag, model.facets.tags, 'All tags')}
{selectFilter('版本', version, setVersion, model.facets.versions, '全部版本')}
{selectFilter('标签', tag, setTag, model.facets.tags, '全部标签')}
<div className="column-picker">
<button
type="button"
aria-expanded={columnsOpen}
onClick={() => setColumnsOpen((open) => !open)}
>
Choose columns
</button>
{columnsOpen ? (
<fieldset>
<legend>Visible columns</legend>
<legend></legend>
{ALL_COLUMNS.map((column) => (
<label key={column}>
<input
type="checkbox"
aria-label={`Show ${column} column`}
aria-label={`显示${COLUMN_LABELS[column]}`}
checked={shownColumns.has(column)}
onChange={() => toggleColumn(column)}
/>
@@ -290,42 +310,42 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
</div>
{!snapshot && !error ? (
<p role="status" aria-label="Fleet loading status">
Loading instances
<p role="status" aria-label="实例加载状态">
</p>
) : null}
{error ? (
<div className="state-panel state-error" role="alert">
<p>Could not load instances: {error}</p>
<p>{error}</p>
<button type="button" onClick={() => setAttempt((value) => value + 1)}>
Retry loading instances
</button>
</div>
) : null}
{snapshot && model.emptyReason ? (
<div className="state-panel">
{model.emptyReason === 'config' ? <p>No instances are configured.</p> : null}
{model.emptyReason === 'search' ? (
<p>No instances match your search and filters.</p>
) : null}
{model.emptyReason === 'filter' ? <p>No instances match the active filters.</p> : null}
{model.emptyReason === 'config' ? <p></p> : null}
{model.emptyReason === 'search' ? <p></p> : null}
{model.emptyReason === 'filter' ? <p></p> : null}
</div>
) : null}
{snapshot && model.rows.length > 0 ? (
<>
<div className="table-scroll" tabIndex={0}>
<table className="dense-table">
<caption>Fleet instances</caption>
<caption></caption>
<thead>
<tr>
<th scope="col" className="select-column">
<input
ref={selectAllRef}
type="checkbox"
aria-label="Select all visible instances"
checked={model.visibleSelection.checked}
onChange={(event) => toggleVisible(event.currentTarget.checked)}
/>
<label className="touch-target">
<input
ref={selectAllRef}
type="checkbox"
aria-label="选择当前页全部实例"
checked={model.visibleSelection.checked}
onChange={(event) => toggleVisible(event.currentTarget.checked)}
/>
</label>
</th>
{ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => (
<th
@@ -345,7 +365,7 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
<button
type="button"
onClick={() => changeSort(column)}
aria-label={`Sort by ${column}, currently ${sort.column === column ? sort.direction : 'not sorted'}`}
aria-label={`${COLUMN_LABELS[column]}排序`}
>
{COLUMN_LABELS[column]}
</button>
@@ -375,9 +395,11 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
),
latency: <td>{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}</td>,
version: <td>{row.version ?? '—'}</td>,
capabilities: <td>{row.capabilities.join(', ') || '—'}</td>,
capabilities: (
<td>{row.capabilities.map(capabilityLabel).join(', ') || '—'}</td>
),
tags: <td>{row.tags.join(', ') || '—'}</td>,
freshness: <td>{row.freshness}</td>,
freshness: <td>{FRESHNESS_LABELS[row.freshness] ?? row.freshness}</td>,
anomalies: <td>{row.anomalies.join(', ') || '—'}</td>,
origin: (
<td>
@@ -386,12 +408,12 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
href={origin}
target="_blank"
rel="noopener noreferrer"
aria-label={`Open ${row.displayName} origin`}
aria-label={`打开 ${row.displayName} 的源站`}
>
{origin}
</a>
) : (
<span>Invalid origin</span>
<span></span>
)}
</td>
),
@@ -399,12 +421,14 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
return (
<tr key={row.id} aria-selected={row.selected}>
<td>
<input
type="checkbox"
aria-label={`Select ${row.displayName}`}
checked={row.selected}
onChange={(event) => toggleOne(row.id, event.currentTarget.checked)}
/>
<label className="touch-target">
<input
type="checkbox"
aria-label={`选择 ${row.displayName}`}
checked={row.selected}
onChange={(event) => toggleOne(row.id, event.currentTarget.checked)}
/>
</label>
</td>
{ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => (
<Fragment key={column}>{cells[column]}</Fragment>
@@ -415,25 +439,25 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
</tbody>
</table>
</div>
<nav className="pagination" aria-label="Fleet pagination">
<nav className="pagination" aria-label="实例分页">
<button
type="button"
aria-label="Previous page"
aria-label="上一页"
disabled={model.page === 1}
onClick={() => setPage((value) => value - 1)}
>
Previous
</button>
<span>
Page {model.page} of {model.pageCount}
{model.page} {model.pageCount}
</span>
<button
type="button"
aria-label="Next page"
aria-label="下一页"
disabled={model.page === model.pageCount}
onClick={() => setPage((value) => value + 1)}
>
Next
</button>
</nav>
</>
@@ -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');
});
});
+30 -30
View File
@@ -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} />
+15 -15
View File
@@ -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();
});
+30 -30
View File
@@ -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} />
+20 -19
View File
@@ -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();
});
+41 -40
View File
@@ -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} />
+21 -23
View File
@@ -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');
});
});
+32 -24
View File
@@ -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} />
+24 -26
View File
@@ -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);
});
});
+39 -40
View File
@@ -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>
)}
+15 -15
View File
@@ -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(
+25 -29
View File
@@ -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) ? (
+17 -17
View File
@@ -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();
});
+41 -37
View File
@@ -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');
});
});
+39 -40
View File
@@ -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} />
+17 -17
View File
@@ -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);
});
});
+24 -32
View File
@@ -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} />
+43 -20
View File
@@ -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);
});
+63 -28
View File
@@ -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>
);
+15 -15
View File
@@ -55,8 +55,8 @@ function deferredSource() {
describe('Phase 7 Jobs workspace', () => {
it('is explicitly runtime-unavailable without an injected source and offers no mutation controls', () => {
render(<JobsPage />);
expect(screen.getByRole('status', { name: 'Jobs unavailable' }).textContent).toMatch(
/no jobs data source was provided/i,
expect(screen.getByRole('status', { name: '任务不可用' }).textContent).toMatch(
/未提供任务数据源/,
);
expect(screen.queryByRole('button', { name: /cancel|retry/i })).toBeNull();
expect(screen.queryByRole('table')).toBeNull();
@@ -65,7 +65,7 @@ describe('Phase 7 Jobs workspace', () => {
it('loads through the injected source and renders sanitized, read-only job data', async () => {
const pending = deferredSource();
render(<JobsPage dataSource={pending.source} />);
expect(screen.getByRole('status', { name: 'Jobs loading status' })).toBeTruthy();
expect(screen.getByRole('status', { name: '任务加载状态' })).toBeTruthy();
expect(pending.load).toHaveBeenCalledWith(
{ page: 1, pageSize: 25, sort: 'createdAt', direction: 'desc' },
expect.any(AbortSignal),
@@ -75,20 +75,20 @@ describe('Phase 7 Jobs workspace', () => {
page: { page: 1, pageSize: 25, total: 1 },
confirmationToken: 'never-show',
});
const table = await screen.findByRole('table', { name: 'Jobs' });
const table = await screen.findByRole('table', { name: '任务' });
const columnHeaders = within(table).getAllByRole('columnheader');
expect(columnHeaders).toHaveLength(7);
for (const header of columnHeaders) expect(header.getAttribute('scope')).toBe('col');
expect(
within(table).getByRole('columnheader', { name: 'Created' }).getAttribute('aria-sort'),
within(table).getByRole('columnheader', { name: '创建时间' }).getAttribute('aria-sort'),
).toBe('descending');
expect(
within(table).getByRole('columnheader', { name: 'Operation' }).getAttribute('aria-sort'),
within(table).getByRole('columnheader', { name: '操作' }).getAttribute('aria-sort'),
).toBeNull();
for (const text of [
'job-1',
'message.send',
'Failed',
'失败',
'job-root',
'alpha',
'Delivery failed',
@@ -148,14 +148,14 @@ describe('Phase 7 Jobs workspace', () => {
.fn<JobsDataSource['load']>()
.mockResolvedValue({ items: [], page: { page: 1, pageSize: 25, total: 80 } });
render(<JobsPage dataSource={{ load }} />);
await screen.findByText('No jobs match the current query.');
await user.selectOptions(screen.getByRole('combobox', { name: 'Status' }), 'failed');
await user.type(screen.getByRole('textbox', { name: 'Operation' }), 'call.start');
await user.type(screen.getByRole('textbox', { name: 'Root job' }), 'root-1');
await user.type(screen.getByRole('textbox', { name: 'Instance' }), 'alpha');
await user.click(screen.getByRole('button', { name: /sort by operation/i }));
await user.click(screen.getByRole('button', { name: 'Next page' }));
await user.click(screen.getByRole('button', { name: 'Refresh jobs' }));
await screen.findByText('没有任务符合当前查询。');
await user.selectOptions(screen.getByRole('combobox', { name: '状态' }), 'failed');
await user.type(screen.getByRole('textbox', { name: '操作' }), 'call.start');
await user.type(screen.getByRole('textbox', { name: '根任务' }), 'root-1');
await user.type(screen.getByRole('textbox', { name: '实例' }), 'alpha');
await user.click(screen.getByRole('button', { name: /按操作排序/ }));
await user.click(screen.getByRole('button', { name: '下一页' }));
await user.click(screen.getByRole('button', { name: '刷新任务' }));
expect(load.mock.calls.at(-1)?.[0] as JobPageQuery).toEqual({
status: 'failed',
operationId: 'call.start',
+53 -46
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { displayStatus } from '../ui/locale.js';
import {
ATTEMPT_STATUSES,
@@ -182,10 +183,15 @@ export function sanitizeJobPage(value: unknown): SafeJobPage | null {
}
function statusLabel(value: string): string {
return value
.split('-')
.map((part) => part[0]?.toUpperCase() + part.slice(1))
.join(' ');
const labels: Readonly<Record<string, string>> = {
queued: '排队中',
running: '运行中',
cancelling: '取消中',
cancelled: '已取消',
succeeded: '成功',
failed: '失败',
};
return labels[value] ?? displayStatus(value);
}
function safeLoadError(value: unknown): SafeProblem | null {
return parseProblem(value) ?? null;
@@ -250,9 +256,9 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
if (!dataSource) {
return (
<section aria-labelledby="jobs-title">
<h1 id="jobs-title">Jobs</h1>
<p role="status" aria-label="Jobs unavailable">
Jobs are runtime-unavailable because no jobs data source was provided.
<h1 id="jobs-title"></h1>
<p role="status" aria-label="任务不可用">
</p>
</section>
);
@@ -276,20 +282,20 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
return (
<section aria-labelledby="jobs-title">
<header>
<h1 id="jobs-title">Jobs</h1>
<p>Read-only operation history.</p>
<h1 id="jobs-title"></h1>
<p></p>
</header>
<div className="jobs-toolbar">
<label>
Status
<select
aria-label="Status"
aria-label="状态"
value={status}
onChange={(event) =>
changeFilter(() => setStatus(event.currentTarget.value as JobStatus | ''))
}
>
<option value="">All statuses</option>
<option value=""></option>
{JOB_STATUSES.map((value) => (
<option key={value} value={value}>
{statusLabel(value)}
@@ -298,57 +304,58 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
</select>
</label>
<label>
Operation
<input
aria-label="Operation"
aria-label="操作"
value={operation}
onChange={(event) => changeFilter(() => setOperation(event.currentTarget.value))}
/>
</label>
<label>
Root job
<input
aria-label="Root job"
aria-label="根任务"
value={rootJob}
onChange={(event) => changeFilter(() => setRootJob(event.currentTarget.value))}
/>
</label>
<label>
Instance
<input
aria-label="Instance"
aria-label="实例"
value={instance}
onChange={(event) => changeFilter(() => setInstance(event.currentTarget.value))}
/>
</label>
<button
type="button"
aria-label="Refresh jobs"
aria-label="刷新任务"
onClick={() => setManualRefresh((value) => value + 1)}
>
Refresh
</button>
</div>
{loading ? (
<p role="status" aria-label="Jobs loading status">
Loading jobs
<p role="status" aria-label="任务加载状态">
</p>
) : null}
{failed ? (
<div role="alert">
<p>
Jobs could not be loaded.
{failed !== true ? ` ${failed.code}: ${failed.title} (${failed.status})` : ''}
</p>
<button type="button" onClick={() => setManualRefresh((value) => value + 1)}>
</button>
</div>
) : null}
{!loading && !failed && result?.items.length === 0 ? (
<p>No jobs match the current query.</p>
) : null}
{!loading && !failed && result?.items.length === 0 ? <p></p> : null}
{!loading && !failed && result ? (
<>
<div className="table-scroll" role="region" aria-label="Jobs table" tabIndex={0}>
<table className="dense-table" aria-label="Jobs">
<div className="table-scroll" role="region" aria-label="任务表格" tabIndex={0}>
<table className="dense-table" aria-label="任务">
<thead>
<tr>
<th
@@ -363,13 +370,13 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
>
<button
type="button"
aria-label="Sort by created time"
aria-label="按创建时间排序"
onClick={() => changeSort('createdAt')}
>
Created
</button>
</th>
<th scope="col">Job</th>
<th scope="col"></th>
<th
scope="col"
aria-sort={
@@ -382,10 +389,10 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
>
<button
type="button"
aria-label="Sort by operation"
aria-label="按操作排序"
onClick={() => changeSort('operationId')}
>
Operation
</button>
</th>
<th
@@ -400,15 +407,15 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
>
<button
type="button"
aria-label="Sort by status"
aria-label="按状态排序"
onClick={() => changeSort('status')}
>
Status
</button>
</th>
<th scope="col">Root job</th>
<th scope="col">Items</th>
<th scope="col">Attempts</th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
@@ -425,8 +432,8 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
{item.status === 'queued' ||
item.status === 'running' ||
item.status === 'cancelling'
? 'No terminal item results while this job is active.'
: 'No terminal item results.'}
? '任务活动期间暂无最终项目结果。'
: '暂无最终项目结果。'}
</span>
) : null}
{item.items.map((entry) => (
@@ -454,25 +461,25 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
</tbody>
</table>
</div>
<nav aria-label="Jobs pagination">
<nav aria-label="任务分页">
<button
type="button"
aria-label="Previous page"
aria-label="上一页"
disabled={page <= 1}
onClick={() => setPage((value) => value - 1)}
>
Previous
</button>
<span>
Page {page} of {pageCount}
{page} / {pageCount}
</span>
<button
type="button"
aria-label="Next page"
aria-label="下一页"
disabled={page >= pageCount}
onClick={() => setPage((value) => value + 1)}
>
Next
</button>
</nav>
</>
@@ -49,7 +49,7 @@ const snapshot: FleetSnapshot = {
authenticated: true,
latencyMs: 12,
summary: {
capabilities: ['messages', 'calls', 7],
capabilities: ['messages', 'calls', 'vendor-capability', 7],
freshness: 'fresh',
password: 'never-render',
credentialConfigured: true,
@@ -79,11 +79,11 @@ describe('Settings instances page', () => {
it('is explicitly unavailable without an injected fleet source', () => {
render(<InstanceSettingsPage />);
expect(screen.getByRole('heading', { name: 'Instances' })).toBeTruthy();
expect(screen.getByRole('status', { name: 'Instances unavailable' }).textContent).toMatch(
/no fleet data source/i,
expect(screen.getByRole('heading', { name: '实例' })).toBeTruthy();
expect(screen.getByRole('status', { name: '实例不可用' }).textContent).toMatch(
/未提供实例总览数据源/i,
);
expect(screen.getByRole('link', { name: 'Add instance' }).getAttribute('href')).toBe(
expect(screen.getByRole('link', { name: '添加实例' }).getAttribute('href')).toBe(
'/instances/new',
);
});
@@ -91,24 +91,25 @@ describe('Settings instances page', () => {
it('loads and renders an accessible, safe settings list using only fleet fields', async () => {
const pending = deferredSource();
render(<InstanceSettingsPage dataSource={pending.source} />);
expect(screen.getByRole('status', { name: 'Instances loading status' })).toBeTruthy();
expect(screen.getByRole('status', { name: '实例加载状态' })).toBeTruthy();
expect(pending.load).toHaveBeenCalledWith(expect.any(AbortSignal));
pending.resolve(snapshot);
const list = await screen.findByRole('list', { name: 'Configured instances' });
const list = await screen.findByRole('list', { name: '已配置实例' });
const west = within(list).getByRole('listitem', { name: 'West modem' });
expect(within(west).getByRole('link', { name: 'West modem' }).getAttribute('href')).toBe(
'/settings/instances/west%2Fone',
);
const origin = within(west).getByRole('link', { name: 'Open West modem origin' });
const origin = within(west).getByRole('link', { name: '打开 West modem 的源站' });
expect(origin.getAttribute('href')).toBe('https://west.example:8443');
expect(origin.getAttribute('rel')).toBe('noopener noreferrer');
for (const value of ['Online', 'Authenticated', 'Fresh', 'messages, calls'])
for (const value of ['在线', '已认证', '最新', '消息, 通话, vendor-capability'])
expect(within(west).getByText(value)).toBeTruthy();
expect(within(west).queryByText('messages, calls')).toBeNull();
const offline = within(list).getByRole('listitem', { name: 'offline' });
expect(within(offline).getByText('Invalid origin')).toBeTruthy();
expect(within(offline).getByText('Offline')).toBeTruthy();
expect(within(offline).getByText('源地址无效')).toBeTruthy();
expect(within(offline).getByText('离线')).toBeTruthy();
expect(within(offline).queryByText(/authentication|freshness|capabilities/i)).toBeNull();
expect(document.body.textContent).not.toMatch(
@@ -124,10 +125,10 @@ describe('Settings instances page', () => {
.mockResolvedValueOnce({ instances: [], statuses: new Map() });
render(<InstanceSettingsPage dataSource={{ load }} />);
const alert = await screen.findByRole('alert');
expect(alert.textContent).toBe('Instances could not be loaded.Retry loading instances');
expect(alert.textContent).toBe('无法加载实例。重试加载实例');
expect(document.body.textContent).not.toContain('top-secret');
await user.click(screen.getByRole('button', { name: 'Retry loading instances' }));
expect(await screen.findByText('No instances are configured.')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '重试加载实例' }));
expect(await screen.findByText('尚未配置实例。')).toBeTruthy();
expect(load).toHaveBeenCalledTimes(2);
});
@@ -114,19 +114,39 @@ export function sanitizeFleetSnapshot(value: unknown): SafeSnapshot | null {
function statusLabel(status: SafeStatus | undefined): string | null {
if (!status) return null;
if (!status.reachable) return 'Offline';
if (status.authenticated === false) return 'Authentication required';
return 'Online';
if (!status.reachable) return '离线';
if (status.authenticated === false) return '需要认证';
return '在线';
}
function authLabel(status: SafeStatus | undefined): string | null {
if (status?.authenticated === true) return 'Authenticated';
if (status?.authenticated === false) return 'Authentication required';
if (status?.authenticated === true) return '已认证';
if (status?.authenticated === false) return '需要认证';
return null;
}
function titleCase(value: string): string {
return value[0]!.toUpperCase() + value.slice(1);
function freshnessLabel(value: string): string {
return (
({ fresh: '最新', stale: '可能过期', unknown: '未知' } as Readonly<Record<string, string>>)[
value
] ?? value
);
}
const CAPABILITY_LABELS: Readonly<Record<string, string>> = {
overview: '概览',
cellular: '蜂窝网络',
'device-network': '设备网络',
messages: '消息',
calls: '通话',
esim: 'eSIM',
notifications: '通知',
automation: '自动化',
ota: 'OTA',
};
function capabilityLabel(value: string): string {
return CAPABILITY_LABELS[value] ?? value;
}
export function InstanceSettingsPage({
@@ -179,37 +199,35 @@ export function InstanceSettingsPage({
<section aria-labelledby="settings-instances-title">
<header>
<div>
<h1 id="settings-instances-title">Instances</h1>
<p>Configure the SimAdmin instances available to this workspace.</p>
<h1 id="settings-instances-title"></h1>
<p> SimAdmin </p>
</div>
<a href="/instances/new">Add instance</a>
<a href="/instances/new"></a>
</header>
{unavailable ? (
<p role="status" aria-label="Instances unavailable">
Instances are runtime-unavailable because no fleet data source was provided.
<p role="status" aria-label="实例不可用">
</p>
) : null}
{loading ? (
<p role="status" aria-label="Instances loading status">
Loading instances
<p role="status" aria-label="实例加载状态">
</p>
) : null}
{failed ? (
<div role="alert">
<p>Instances could not be loaded.</p>
<p></p>
{dataSource ? (
<button type="button" onClick={() => setAttempt((value) => value + 1)}>
Retry loading instances
</button>
) : null}
</div>
) : null}
{!loading && !failed && snapshot?.instances.length === 0 ? (
<p>No instances are configured.</p>
) : null}
{!loading && !failed && snapshot?.instances.length === 0 ? <p></p> : null}
{!loading && !failed && snapshot && snapshot.instances.length > 0 ? (
<ul aria-label="Configured instances">
<ul aria-label="已配置实例">
{snapshot.instances.map((instance) => {
const displayName = instance.name ?? instance.id;
const origin = canonicalHttpOrigin(instance.url);
@@ -226,44 +244,44 @@ export function InstanceSettingsPage({
{instance.name ? <p>{instance.id}</p> : null}
<dl>
<div>
<dt>Origin</dt>
<dt></dt>
<dd>
{origin ? (
<a
href={origin}
target="_blank"
rel="noopener noreferrer"
aria-label={`Open ${displayName} origin`}
aria-label={`打开 ${displayName} 的源站`}
>
{origin}
</a>
) : (
'Invalid origin'
'源地址无效'
)}
</dd>
</div>
{state ? (
<div>
<dt>Status</dt>
<dt></dt>
<dd>{state}</dd>
</div>
) : null}
{authentication ? (
<div>
<dt>Authentication</dt>
<dt></dt>
<dd>{authentication}</dd>
</div>
) : null}
{status?.freshness ? (
<div>
<dt>Freshness</dt>
<dd>{titleCase(status.freshness)}</dd>
<dt></dt>
<dd>{freshnessLabel(status.freshness)}</dd>
</div>
) : null}
{status?.capabilities?.length ? (
<div>
<dt>Capabilities</dt>
<dd>{status.capabilities.join(', ')}</dd>
<dt></dt>
<dd>{status.capabilities.map(capabilityLabel).join(', ')}</dd>
</div>
) : null}
</dl>
+439 -122
View File
@@ -1,40 +1,52 @@
:root {
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
color: #e8edf5;
background: #0b1018;
--surface: #121a26;
--surface-raised: #192434;
--border: #2b394d;
--muted: #a9b6c8;
--accent: #75a7ff;
--danger: #ff8b91;
--radius: 0.5rem;
font-family: 'PingFang SC', 'Microsoft YaHei', ui-rounded, system-ui, sans-serif;
color: #725d42;
background: #f8f8f0;
--mint: #19c8b9;
--mint-dark: #10a99d;
--brown: #794f27;
--text: #725d42;
--muted: #9f927d;
--canvas: #f8f8f0;
--surface: rgb(247, 243, 223);
--surface-raised: #fffdf5;
--border: #ded3bd;
--danger: #c95e55;
--warning: #f5c31c;
--radius: 16px;
--space: clamp(0.75rem, 2vw, 1.5rem);
font-synthesis: none;
}
* {
box-sizing: border-box;
}
html {
background: var(--canvas);
}
body {
margin: 0;
min-width: 20rem;
background: #0b1018;
background: var(--canvas);
}
body::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
opacity: 0.32;
pointer-events: none;
background-image: radial-gradient(circle, rgba(121, 79, 39, 0.12) 1px, transparent 1.5px);
background-size: 24px 24px;
}
a {
color: var(--accent);
color: #187f77;
text-underline-offset: 3px;
}
button,
input,
select {
font: inherit;
}
button,
input,
select {
color: inherit;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 0.35rem;
padding: 0.5rem 0.65rem;
}
button,
input,
@@ -42,44 +54,156 @@ select,
a {
outline-offset: 3px;
}
:focus-visible {
outline: 3px solid var(--warning);
outline-offset: 3px;
}
button,
input,
select {
min-height: 2.75rem;
padding: 0.5rem 0.75rem;
border: 2px solid #c4b89e;
border-radius: 999px;
background: var(--surface-raised);
}
button {
cursor: pointer;
font-weight: 700;
transition:
transform 120ms ease,
box-shadow 120ms ease,
background 120ms ease;
box-shadow: 0 2px 4px rgba(61, 52, 40, 0.06);
}
button:hover:not(:disabled) {
background: #fff;
transform: translateY(-1px);
}
button:active:not(:disabled) {
transform: translateY(2px);
}
button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
button[type='submit'],
.fleet-actions button,
.fleet-heading > button,
.form-actions button:first-child {
color: #fff;
border-color: var(--mint-dark);
background: var(--mint);
box-shadow: 0 5px 0 var(--mint-dark);
}
input:focus,
select:focus {
border-color: var(--mint);
}
h1,
h2,
h3 {
color: var(--brown);
letter-spacing: -0.02em;
}
h1 {
margin: 0 0 0.35rem;
font-size: clamp(1.5rem, 3vw, 2.2rem);
}
h2 {
font-size: 1.05rem;
}
p {
line-height: 1.6;
}
.skip-link {
position: fixed;
top: -5rem;
top: -6rem;
left: 1rem;
z-index: 10;
background: white;
color: black;
padding: 0.75rem;
z-index: 100;
color: #fff;
background: var(--brown);
padding: 0.75rem 1rem;
border-radius: 12px;
}
.skip-link:focus {
top: 1rem;
}
.app-topbar {
min-height: 3.5rem;
padding: 0 var(--space);
border-bottom: 1px solid var(--border);
min-height: 4.5rem;
padding: 0.75rem var(--space);
display: flex;
align-items: center;
gap: 1rem;
gap: 0.8rem;
color: var(--text);
background: rgba(255, 253, 245, 0.92);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(12px);
position: sticky;
top: 0;
z-index: 20;
}
.product-name {
margin-right: auto;
font-weight: 750;
color: inherit;
color: var(--brown);
text-decoration: none;
font-size: 1.15rem;
font-weight: 900;
}
.connection-status {
.product-name::before {
content: '◉';
display: inline-grid;
place-items: center;
width: 2rem;
height: 2rem;
margin-right: 0.55rem;
color: #fff;
background: var(--mint);
border-radius: 45% 55% 50% 50%;
box-shadow: 0 3px 0 var(--mint-dark);
}
.connection-status,
.version-badge {
padding: 0.25rem 0.65rem;
border-radius: 999px;
background: var(--surface);
color: var(--muted);
font-size: 0.82rem;
}
.connection-status::before {
content: '';
display: inline-block;
width: 0.5rem;
height: 0.5rem;
margin-right: 0.35rem;
border-radius: 50%;
background: var(--mint);
}
.app-layout {
display: grid;
grid-template-columns: 11rem minmax(0, 1fr);
min-height: calc(100vh - 3.5rem);
}
.global-navigation,
.instance-context {
grid-template-columns: 13rem minmax(0, 1fr);
gap: var(--space);
min-height: calc(100vh - 4.5rem);
padding: var(--space);
border-right: 1px solid var(--border);
}
.global-navigation {
align-self: start;
position: sticky;
top: 6rem;
padding: 1rem;
border: 1px solid #e9dfca;
border-radius: 20px;
background: var(--surface);
box-shadow: 0 10px 28px rgba(121, 79, 39, 0.1);
}
.global-navigation::before {
content: '工作区';
display: block;
padding: 0.35rem 0.6rem 0.75rem;
color: var(--muted);
font-size: 0.76rem;
font-weight: 800;
letter-spacing: 0.12em;
}
.global-navigation ul,
.instance-context ul {
@@ -92,64 +216,117 @@ a {
.global-navigation a,
.instance-context nav a {
display: block;
padding: 0.55rem;
border-radius: 0.35rem;
padding: 0.7rem 0.8rem;
border-radius: 12px;
color: var(--text);
text-decoration: none;
font-weight: 700;
}
a[aria-current='page'] {
background: var(--surface-raised);
color: white;
}
.instance-context {
grid-column: 2;
grid-row: 1;
border-bottom: 1px solid var(--border);
}
.instance-context + main {
grid-column: 2;
grid-row: 2;
}
.instance-context code {
display: block;
color: var(--muted);
}
.instance-context dl div {
display: flex;
justify-content: space-between;
gap: 1rem;
color: var(--brown);
background: #fff;
box-shadow: 0 3px 0 #d4c9b4;
}
main {
min-width: 0;
padding: var(--space);
padding: clamp(0.5rem, 2vw, 1.25rem);
border: 1px solid #ebe1ce;
border-radius: 22px;
background: rgba(255, 253, 245, 0.88);
box-shadow: 0 16px 40px rgba(121, 79, 39, 0.09);
}
.fleet-heading,
.fleet-toolbar {
main > section > header,
.fleet-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
padding-bottom: 1rem;
border-bottom: 1px dashed var(--border);
}
.fleet-toolbar {
justify-content: flex-start;
padding: 1rem 0;
.fleet-heading p,
main > section > header p {
margin: 0.25rem 0 0;
color: var(--muted);
}
.fleet-toolbar label {
display: grid;
gap: 0.3rem;
min-width: min(100%, 14rem);
}
.fleet-toolbar label:first-child {
flex: 1;
.fleet-actions,
.form-actions,
.pagination {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.selection-summary,
small {
color: var(--muted);
}
.fleet-toolbar,
.jobs-toolbar {
display: flex;
align-items: end;
gap: 0.65rem;
flex-wrap: wrap;
padding: 1rem;
margin: 1rem 0;
border-radius: var(--radius);
background: var(--surface);
}
.fleet-toolbar label,
.jobs-toolbar label {
display: grid;
gap: 0.3rem;
min-width: min(100%, 10rem);
font-size: 0.82rem;
font-weight: 700;
}
.fleet-toolbar label:first-child {
flex: 1;
min-width: min(100%, 15rem);
}
.column-picker {
position: relative;
}
.column-picker fieldset {
position: absolute;
right: 0;
z-index: 5;
width: 14rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 14px;
background: #fffdf5;
box-shadow: 0 12px 30px rgba(121, 79, 39, 0.16);
}
.column-picker fieldset label {
display: flex;
align-items: center;
}
.batch-entry,
.state-panel {
margin: 0.8rem 0;
padding: 1rem;
border: 1px dashed #cdbfa5;
border-radius: var(--radius);
background: var(--surface);
}
.state-panel {
background-image: radial-gradient(circle, rgba(121, 79, 39, 0.1) 1px, transparent 1px);
background-size: 18px 18px;
}
.state-error {
color: #a4423b;
border-color: #db938d;
background-color: #fff0e8;
}
.table-scroll {
max-width: 100%;
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fffdf5;
box-shadow: 0 5px 16px rgba(121, 79, 39, 0.07);
}
table {
width: 100%;
@@ -157,19 +334,56 @@ table {
white-space: nowrap;
}
caption {
padding: 0.8rem;
text-align: left;
padding: 0.75rem;
font-weight: 700;
font-weight: 800;
color: var(--brown);
}
th,
td {
padding: 0.6rem 0.75rem;
border-top: 1px solid var(--border);
padding: 0.65rem 0.75rem;
border-top: 1px solid #e9dfca;
text-align: left;
}
thead th {
color: var(--brown);
background: var(--surface);
font-size: 0.8rem;
}
thead button {
min-height: auto;
padding: 0.25rem;
border: 0;
background: transparent;
box-shadow: none;
}
input[type='checkbox'],
input[type='radio'] {
width: 1.25rem;
height: 1.25rem;
min-height: auto;
padding: 0;
border-radius: 0.25rem;
}
label:has(> input[type='checkbox']),
label:has(> input[type='radio']),
.touch-target {
min-width: 2.75rem;
min-height: 2.75rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.touch-target {
justify-content: center;
}
tbody tr {
transition: background 120ms ease;
}
tbody tr:hover,
tr[aria-selected='true'] {
background: var(--surface);
background: #f2f8e9;
}
tbody th small {
display: block;
@@ -180,25 +394,84 @@ tbody th small {
}
.status {
display: inline-block;
padding: 0.2rem 0.45rem;
padding: 0.25rem 0.55rem;
border-radius: 999px;
background: var(--surface-raised);
background: #eee5d2;
font-size: 0.78rem;
font-weight: 800;
}
.status-online {
color: #75e6a3;
color: #087b70;
background: #d8f5ec;
}
.status-offline,
.status-auth {
color: #ffbd76;
color: #9c6423;
background: #fff0c9;
}
.state-panel {
.pagination {
justify-content: flex-end;
padding-top: 1rem;
}
.instance-detail {
display: grid;
grid-template-columns: 13rem minmax(0, 1fr);
gap: 1rem;
}
.instance-context {
min-width: 0;
padding: 1rem;
border: 1px dashed var(--border);
border-radius: var(--radius);
border-radius: 18px;
background: var(--surface);
}
.state-error {
color: var(--danger);
border-color: currentColor;
.instance-context code {
display: block;
overflow-wrap: anywhere;
color: var(--muted);
}
.instance-context dl div,
.overview-card dl div,
.cellular-card dl div,
[class$='-card'] dl div {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.35rem 0;
border-bottom: 1px dashed #ddd0b8;
}
dd {
margin-inline-start: 1rem;
text-align: right;
overflow-wrap: anywhere;
}
.instance-module-detail {
min-width: 0;
}
.overview-grid,
.cellular-grid,
.device-network-grid,
.messages-grid,
.calls-grid,
.notifications-grid,
.automation-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
gap: 0.8rem;
}
.overview-card,
.cellular-card,
.device-network-card,
.messages-card,
.calls-card,
.esim-card,
.notifications-card,
.automation-card,
.ota-card,
main li[aria-label] {
padding: 1rem;
border: 1px solid #e2d6bf;
border-radius: var(--radius);
background: var(--surface-raised);
}
.instance-editor {
max-width: 48rem;
@@ -211,80 +484,124 @@ tbody th small {
}
.instance-editor form {
gap: 1rem;
}
.form-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
padding: 1rem;
border-radius: var(--radius);
background: var(--surface);
}
.danger-zone {
margin-top: 2rem;
padding: 1rem;
border: 1px solid var(--danger);
border: 1px solid #db938d;
border-radius: var(--radius);
background: #fff4ed;
}
.danger-zone div {
display: grid;
gap: 0.75rem;
}
main ul[aria-label='已配置实例'] {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
gap: 0.8rem;
padding: 0;
list-style: none;
}
@media (max-width: 48rem) {
.app-topbar {
position: static;
flex-wrap: wrap;
padding-block: 0.6rem;
}
.product-name {
width: 100%;
}
.app-layout {
display: block;
padding: 0.65rem;
}
.global-navigation {
border-right: 0;
border-bottom: 1px solid var(--border);
position: static;
padding: 0.55rem;
margin-bottom: 0.65rem;
overflow-x: auto;
}
.global-navigation::before {
display: none;
}
.global-navigation ul {
display: flex;
min-width: max-content;
}
main {
padding: 0.8rem;
border-radius: 16px;
}
.instance-detail {
display: block;
}
.instance-context {
border-right: 0;
margin-bottom: 1rem;
}
.fleet-toolbar label {
.instance-context ul {
display: flex;
overflow-x: auto;
}
.instance-context li {
min-width: max-content;
}
.fleet-toolbar label,
.jobs-toolbar label {
width: 100%;
}
.table-scroll {
overflow: visible;
border: 0;
overflow-x: auto;
border: 1px solid var(--border);
box-shadow: none;
background: var(--surface-raised);
}
.dense-table,
.dense-table tbody,
.dense-table tr,
.dense-table tbody {
min-width: 48rem;
}
.dense-table th,
.dense-table td {
display: block;
white-space: nowrap;
}
.dense-table button,
.pagination button,
.global-navigation a,
.instance-context nav a,
.fleet-actions a {
min-height: 2.75rem;
display: inline-flex;
align-items: center;
}
}
@media (max-width: 24.375rem) {
body {
min-width: 20rem;
}
.connection-status,
.version-badge {
font-size: 0.72rem;
}
.fleet-heading > *,
.fleet-actions,
.form-actions,
.pagination {
width: 100%;
}
button {
max-width: 100%;
white-space: normal;
}
.dense-table thead {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.dense-table tbody tr {
margin-block: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
}
.dense-table tbody td,
.dense-table tbody th {
border: 0;
padding: 0.3rem 0;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
+58
View File
@@ -0,0 +1,58 @@
export const STATUS_LABELS: Readonly<Record<string, string>> = {
online: '在线',
offline: '离线',
auth: '需要认证',
'auth-required': '需要认证',
authenticated: '已认证',
unknown: '未知',
fresh: '最新',
stale: '可能过期',
expired: '已过期',
degraded: '功能受限',
supported: '支持',
unsupported: '不支持',
queued: '排队中',
running: '运行中',
cancelling: '取消中',
cancelled: '已取消',
succeeded: '成功',
failed: '失败',
completed: '已完成',
available: '可用',
unavailable: '不可用',
idle: '空闲',
working: '工作中',
disabled: '已禁用',
enabled: '已启用',
pending: '等待中',
processing: '处理中',
healthy: '正常',
paused: '已暂停',
delivered: '已送达',
active: '活跃',
busy: '忙碌',
checking: '检查中',
'up-to-date': '已是最新',
downloading: '下载中',
verifying: '校验中',
installing: '安装中',
rebooting: '重启中',
error: '错误',
'partially-succeeded': '部分成功',
'unknown-result': '结果未知',
};
export function displayStatus(value: string): string {
return STATUS_LABELS[value] ?? value;
}
export function displayValue(value: string | number | boolean | null | undefined): string {
if (value == null) return '不可用';
if (typeof value === 'boolean') return value ? '是' : '否';
return typeof value === 'string' ? displayStatus(value) : String(value);
}
/** Transport errors are intentionally collapsed so backend free text can never expose secrets. */
export function safeUiError(_error: unknown, fallback = '操作失败,请稍后重试。'): string {
return fallback;
}