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> <!doctype html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" /> <meta name="color-scheme" content="light" />
<title>Multi SimAdmin</title> <title>多实例 SimAdmin 管理台</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+41 -41
View File
@@ -20,7 +20,7 @@ const snapshot: FleetSnapshot = {
{ {
id: 'bravo', id: 'bravo',
name: 'Bravo', name: 'Bravo',
url: 'http://user:secret@bravo.example:8080/private', url: 'http://user:***@bravo.example:8080/private',
tags: ['west'], tags: ['west'],
}, },
{ id: 'alpha', name: 'Alpha', url: 'https://alpha.example/admin' }, { 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))); const dataSource = source(() => new Promise((done) => (resolve = done)));
render(<AppShell pathname="/fleet" version="0.1.0" fleetDataSource={dataSource} />); render(<AppShell pathname="/fleet" version="0.1.0" fleetDataSource={dataSource} />);
expect(screen.getByRole('status', { name: 'Fleet loading status' }).textContent).toContain( expect(screen.getByRole('status', { name: '实例加载状态' }).textContent).toContain(
'Loading instances', '正在加载实例',
); );
resolve(snapshot); resolve(snapshot);
@@ -80,10 +80,10 @@ describe('React AppShell and Fleet vertical slice', () => {
); );
expect( expect(
within(screen.getByRole('row', { name: /Bravo/ })) within(screen.getByRole('row', { name: /Bravo/ }))
.getByRole('link', { name: 'Open Bravo origin' }) .getByRole('link', { name: '打开 Bravo 的源站' })
.getAttribute('href'), .getAttribute('href'),
).toBe('http://bravo.example:8080'); ).toBe('http://bravo.example:8080');
expect(screen.getByText('Version 0.1.0')).toBeTruthy(); expect(screen.getByText('版本 0.1.0')).toBeTruthy();
expect( expect(
consoleError.mock.calls.some((call) => consoleError.mock.calls.some((call) =>
call.some((argument) => String(argument).includes('unique "key" prop')), 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)} />); render(<AppShell pathname="/fleet" fleetDataSource={source(async () => snapshot)} />);
await screen.findByRole('row', { name: /Alpha/ }); 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); let rows = screen.getAllByRole('row').slice(1);
expect(rows[0]?.textContent).toContain('Alpha'); 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); rows = screen.getAllByRole('row').slice(1);
expect(rows[0]?.textContent).toContain('Bravo'); 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(); expect(screen.queryByRole('row', { name: /Alpha/ })).toBeNull();
await user.click(screen.getByRole('checkbox', { name: 'Select all visible instances' })); await user.click(screen.getByRole('checkbox', { name: '选择当前页全部实例' }));
expect( expect((screen.getByRole('checkbox', { name: '选择 Bravo' }) as HTMLInputElement).checked).toBe(
(screen.getByRole('checkbox', { name: 'Select Bravo' }) as HTMLInputElement).checked, true,
).toBe(true); );
expect(screen.getByText('1 selected')).toBeTruthy(); expect(screen.getByText('已选择 1 项')).toBeTruthy();
await user.type(screen.getByRole('searchbox', { name: 'Search instances' }), 'missing'); await user.type(screen.getByRole('searchbox', { name: '搜索实例' }), 'missing');
expect(screen.getByText(/No instances match your search/i)).toBeTruthy(); expect(screen.getByText(/没有实例符合搜索与筛选条件/i)).toBeTruthy();
}); });
it('exposes error, retry, configured-empty, and unsafe-origin states without claiming a backend', async () => { 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)} />); const first = render(<AppShell pathname="/fleet" fleetDataSource={source(load)} />);
expect((await screen.findByRole('alert')).textContent).toContain('collector unavailable'); expect((await screen.findByRole('alert')).textContent).toContain('实例加载失败');
await user.click(screen.getByRole('button', { name: 'Retry loading instances' })); await user.click(screen.getByRole('button', { name: '重试加载实例' }));
expect(await screen.findByRole('row', { name: /Unsafe/ })).toBeTruthy(); 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(); first.unmount();
const { unmount } = render( const { unmount } = render(
@@ -138,7 +138,7 @@ describe('React AppShell and Fleet vertical slice', () => {
fleetDataSource={source(async () => ({ instances: [], statuses: new Map() }))} fleetDataSource={source(async () => ({ instances: [], statuses: new Map() }))}
/>, />,
); );
expect(await screen.findByText(/No instances are configured/i)).toBeTruthy(); expect(await screen.findByText(/尚未配置实例/i)).toBeTruthy();
unmount(); unmount();
}); });
@@ -147,20 +147,20 @@ describe('React AppShell and Fleet vertical slice', () => {
render(<AppShell pathname="/fleet" fleetDataSource={source(async () => snapshot)} />); render(<AppShell pathname="/fleet" fleetDataSource={source(async () => snapshot)} />);
await screen.findByRole('row', { name: /Alpha/ }); 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.queryByRole('row', { name: /Alpha/ })).toBeNull();
expect(screen.getByRole('row', { name: /Bravo/ }).textContent).toContain('clock drift'); expect(screen.getByRole('row', { name: /Bravo/ }).textContent).toContain('clock drift');
await user.click(screen.getByRole('button', { name: 'Choose columns' })); await user.click(screen.getByRole('button', { name: '选择列' }));
await user.click(screen.getByRole('checkbox', { name: 'Show anomalies column' })); await user.click(screen.getByRole('checkbox', { name: '显示异常列' }));
expect(screen.queryByRole('columnheader', { name: 'Anomalies' })).toBeNull(); expect(screen.queryByRole('columnheader', { name: '异常' })).toBeNull();
await user.click(screen.getByRole('checkbox', { name: 'Select Bravo' })); await user.click(screen.getByRole('checkbox', { name: '选择 Bravo' }));
expect( expect((screen.getByRole('button', { name: '批量操作' }) as HTMLButtonElement).disabled).toBe(
(screen.getByRole('button', { name: 'Batch actions' }) as HTMLButtonElement).disabled, false,
).toBe(false); );
await user.click(screen.getByRole('button', { name: 'Batch actions' })); await user.click(screen.getByRole('button', { name: '批量操作' }));
expect(screen.getByText('Choose an action for 1 selected instance.')).toBeTruthy(); expect(screen.getByText('请为已选择的 1 项选择操作。')).toBeTruthy();
}); });
it('paginates fleet rows and selects only the current page', async () => { 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/ }); await screen.findByRole('row', { name: /Instance 01/ });
expect(screen.queryByRole('row', { name: /Instance 11/ })).toBeNull(); expect(screen.queryByRole('row', { name: /Instance 11/ })).toBeNull();
expect(screen.getByText('Page 1 of 2')).toBeTruthy(); expect(screen.getByText('第 1 页,共 2 页')).toBeTruthy();
await user.click(screen.getByRole('checkbox', { name: 'Select all visible instances' })); await user.click(screen.getByRole('checkbox', { name: '选择当前页全部实例' }));
expect(screen.getByText('10 selected')).toBeTruthy(); expect(screen.getByText('已选择 10 项')).toBeTruthy();
await user.click(screen.getByRole('button', { name: 'Next page' })); await user.click(screen.getByRole('button', { name: '下一页' }));
expect(await screen.findByRole('row', { name: /Instance 11/ })).toBeTruthy(); 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', () => { 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( const { rerender } = render(
<AppShell pathname="/instances/owner/messages" instance={instance} />, <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.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', 'https://owner.example',
); );
rerender(<AppShell pathname="/instances/someone-else/messages" instance={instance} />); rerender(<AppShell pathname="/instances/someone-else/messages" instance={instance} />);
expect(screen.queryByText('Owner modem')).toBeNull(); expect(screen.queryByText('Owner modem')).toBeNull();
expect(screen.getByText(/Instance context is unavailable/i)).toBeTruthy(); expect(screen.getByText(/此路由缺少实例上下文/i)).toBeTruthy();
}); });
it.each([ it.each([
{ {
pathname: '/jobs', pathname: '/jobs',
endpoint: '/api/v1/jobs?page=1&pageSize=25&sort=createdAt&direction=desc', endpoint: '/api/v1/jobs?page=1&pageSize=25&sort=createdAt&direction=desc',
emptyMessage: /No jobs match the current query/i, emptyMessage: /没有任务符合当前查询/i,
}, },
{ {
pathname: '/audit', pathname: '/audit',
endpoint: '/api/v1/audit?sort=occurredAt&direction=desc&page=1&pageSize=25', 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', 'uses the default HTTP data source for $pathname',
@@ -247,12 +247,12 @@ describe('React AppShell and Fleet vertical slice', () => {
{ {
pathname: '/jobs', pathname: '/jobs',
sourceProp: 'jobsDataSource' as const, sourceProp: 'jobsDataSource' as const,
emptyMessage: /No jobs match the current query/i, emptyMessage: /没有任务符合当前查询/i,
}, },
{ {
pathname: '/audit', pathname: '/audit',
sourceProp: 'auditDataSource' as const, 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', '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') if (route.kind === 'settings-system')
return ( return (
<section aria-labelledby="system-settings-title"> <section aria-labelledby="system-settings-title">
<h1 id="system-settings-title">System settings</h1> <h1 id="system-settings-title"></h1>
<p role="status"> <p role="status"></p>
System settings are unavailable because the control plane does not expose a settings
contract.
</p>
</section> </section>
); );
if (route.kind === 'not-found') if (route.kind === 'not-found')
return ( return (
<section> <section>
<h1>Page not found</h1> <h1></h1>
<p>The requested console page does not exist.</p> <p></p>
<a href="/fleet">Return to Fleet</a> <a href="/fleet"></a>
</section> </section>
); );
if (route.kind.startsWith('instance-')) { if (route.kind.startsWith('instance-')) {
@@ -369,19 +366,19 @@ function Page({
); );
} }
const labels: Partial<Record<RouteKind, string>> = { const labels: Partial<Record<RouteKind, string>> = {
'instance-new': 'Add instance', 'instance-new': '添加实例',
jobs: 'Jobs', jobs: '任务',
'job-detail': 'Job details', 'job-detail': '任务详情',
audit: 'Audit', audit: '审计',
'audit-detail': 'Audit event', 'audit-detail': '审计事件',
'settings-instances': 'Instance settings', 'settings-instances': '实例设置',
'settings-instance-detail': 'Instance settings', 'settings-instance-detail': '实例设置',
'settings-system': 'System settings', 'settings-system': '系统设置',
}; };
return ( return (
<section> <section>
<h1>{labels[route.kind] ?? 'Multi SimAdmin'}</h1> <h1>{labels[route.kind] ?? '多实例 SimAdmin 管理台'}</h1>
<p>This route is ready for structured data and actions.</p> <p></p>
</section> </section>
); );
} }
@@ -429,24 +426,24 @@ export function AppShell({
return ( return (
<div className="app-shell" data-route={route.kind}> <div className="app-shell" data-route={route.kind}>
<a className="skip-link" href="#main-content"> <a className="skip-link" href="#main-content">
Skip to main content
</a> </a>
<header className="app-topbar"> <header className="app-topbar">
<a className="product-name" href="/fleet"> <a className="product-name" href="/fleet">
Multi SimAdmin SimAdmin
</a> </a>
<span className="connection-status">Console</span> <span className="connection-status"></span>
<span>Version {version}</span> <span> {version}</span>
</header> </header>
<div className="app-layout"> <div className="app-layout">
<nav className="global-navigation" aria-label="Global navigation"> <nav className="global-navigation" aria-label="全局导航">
<ul> <ul>
{( {(
[ [
['fleet', '/fleet', 'Fleet'], ['fleet', '/fleet', '实例总览'],
['jobs', '/jobs', 'Jobs'], ['jobs', '/jobs', '任务'],
['audit', '/audit', 'Audit'], ['audit', '/audit', '审计'],
['settings', '/settings/instances', 'Settings'], ['settings', '/settings/instances', '设置'],
] as const ] as const
).map(([key, href, label]) => ( ).map(([key, href, label]) => (
<li key={key}> <li key={key}>
+16 -18
View File
@@ -45,8 +45,8 @@ function deferredSource() {
describe('Audit workspace frozen contract', () => { describe('Audit workspace frozen contract', () => {
it('is explicitly unavailable without an injected source', () => { it('is explicitly unavailable without an injected source', () => {
render(<AuditPage />); render(<AuditPage />);
expect(screen.getByRole('status', { name: 'Audit unavailable' }).textContent).toMatch( expect(screen.getByRole('status', { name: '审计不可用' }).textContent).toMatch(
/no audit data source was provided/i, /未提供审计数据源/,
); );
expect(screen.queryByRole('table')).toBeNull(); 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 } }); 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 [ for (const text of [
event.occurredAt, event.occurredAt,
event.actorId, event.actorId,
event.action, event.action,
'Partially succeeded', '部分成功',
event.requestId, event.requestId,
event.instanceId, event.instanceId,
event.jobId, event.jobId,
@@ -122,18 +122,18 @@ describe('Audit workspace frozen contract', () => {
page: { page: 1, pageSize: 25, total: 80 }, page: { page: 1, pageSize: 25, total: 80 },
}); });
render(<AuditPage dataSource={{ load }} />); 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: '操作者 ID' }), 'alice');
await user.type(screen.getByRole('textbox', { name: 'Instance ID' }), 'alpha'); await user.type(screen.getByRole('textbox', { name: '实例 ID' }), 'alpha');
await user.type(screen.getByRole('textbox', { name: 'Job ID' }), 'job-1'); await user.type(screen.getByRole('textbox', { name: '任务 ID' }), 'job-1');
await user.type(screen.getByRole('textbox', { name: 'Operation ID' }), 'call.start'); await user.type(screen.getByRole('textbox', { name: '操作 ID' }), 'call.start');
await user.selectOptions(screen.getByRole('combobox', { name: 'Outcome' }), 'failed'); await user.selectOptions(screen.getByRole('combobox', { name: '结果' }), 'failed');
await user.type(screen.getByRole('textbox', { name: 'Request ID' }), 'req-1'); await user.type(screen.getByRole('textbox', { name: '请求 ID' }), 'req-1');
await user.type(screen.getByLabelText('Occurred from'), '2026-07-01T10:00'); await user.type(screen.getByLabelText('开始时间'), '2026-07-01T10:00');
await user.type(screen.getByLabelText('Occurred to'), '2026-07-17T10:00'); await user.type(screen.getByLabelText('结束时间'), '2026-07-17T10:00');
await user.click(screen.getByRole('button', { name: /sort by action/i })); await user.click(screen.getByRole('button', { name: /按操作排序/ }));
await user.click(screen.getByRole('button', { name: 'Next page' })); await user.click(screen.getByRole('button', { name: '下一页' }));
const query = load.mock.calls.at(-1)?.[0] as AuditPageQuery; const query = load.mock.calls.at(-1)?.[0] as AuditPageQuery;
expect(query).toEqual({ expect(query).toEqual({
@@ -195,9 +195,7 @@ describe('Audit workspace frozen contract', () => {
.fn<AuditDataSource['load']>() .fn<AuditDataSource['load']>()
.mockRejectedValue(new Error('private detail')); .mockRejectedValue(new Error('private detail'));
render(<AuditPage dataSource={{ load: retryLoad }} />); render(<AuditPage dataSource={{ load: retryLoad }} />);
expect((await screen.findByRole('alert')).textContent).toContain( expect((await screen.findByRole('alert')).textContent).toContain('无法加载审计事件。');
'Audit events could not be loaded.',
);
expect(screen.queryByText('private detail')).toBeNull(); 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 { function outcomeLabel(value: AuditOutcome): string {
return value const labels: Readonly<Record<string, string>> = {
.split('-') succeeded: '成功',
.map((part, index) => (index === 0 ? part.charAt(0).toUpperCase() + part.slice(1) : part)) failed: '失败',
.join(' '); 'partially-succeeded': '部分成功',
success: '成功',
failure: '失败',
denied: '已拒绝',
};
return labels[value] ?? value;
} }
export function AuditPage({ dataSource, refreshSignal = 0 }: AuditPageProps) { export function AuditPage({ dataSource, refreshSignal = 0 }: AuditPageProps) {
@@ -254,36 +259,36 @@ export function AuditPage({ dataSource, refreshSignal = 0 }: AuditPageProps) {
if (!dataSource) if (!dataSource)
return ( return (
<section aria-labelledby="audit-title"> <section aria-labelledby="audit-title">
<h1 id="audit-title">Audit</h1> <h1 id="audit-title"></h1>
<p role="status" aria-label="Audit unavailable"> <p role="status" aria-label="审计不可用">
Audit is unavailable at runtime because no audit data source was provided.
</p> </p>
</section> </section>
); );
const sortableColumns: readonly [AuditSort, string][] = [ const sortableColumns: readonly [AuditSort, string][] = [
['occurredAt', 'Occurred at'], ['occurredAt', '发生时间'],
['action', 'Action'], ['action', '操作'],
['outcome', 'Outcome'], ['outcome', '结果'],
]; ];
const pageCount = Math.max(1, Math.ceil((result?.page.total ?? 0) / PAGE_SIZE)); const pageCount = Math.max(1, Math.ceil((result?.page.total ?? 0) / PAGE_SIZE));
const identifiers: readonly [keyof typeof filters, string][] = [ const identifiers: readonly [keyof typeof filters, string][] = [
['actorId', 'Actor ID'], ['actorId', '操作者 ID'],
['instanceId', 'Instance ID'], ['instanceId', '实例 ID'],
['jobId', 'Job ID'], ['jobId', '任务 ID'],
['operationId', 'Operation ID'], ['operationId', '操作 ID'],
['requestId', 'Request ID'], ['requestId', '请求 ID'],
]; ];
return ( return (
<section className="fleet-panel" aria-labelledby="audit-title"> <section className="fleet-panel" aria-labelledby="audit-title">
<div className="fleet-heading"> <div className="fleet-heading">
<div> <div>
<h1 id="audit-title">Audit</h1> <h1 id="audit-title"></h1>
<p>Read-only operational audit events.</p> <p></p>
</div> </div>
<button type="button" onClick={() => setAttempt((value) => value + 1)}> <button type="button" onClick={() => setAttempt((value) => value + 1)}>
Refresh audit events
</button> </button>
</div> </div>
<div className="fleet-toolbar"> <div className="fleet-toolbar">
@@ -298,13 +303,13 @@ export function AuditPage({ dataSource, refreshSignal = 0 }: AuditPageProps) {
</label> </label>
))} ))}
<label> <label>
<span>Outcome</span> <span></span>
<select <select
aria-label="Outcome" aria-label="结果"
value={filters.outcome} value={filters.outcome}
onChange={(event) => changeFilter('outcome', event.currentTarget.value)} onChange={(event) => changeFilter('outcome', event.currentTarget.value)}
> >
<option value="">All outcomes</option> <option value=""></option>
{AUDIT_OUTCOMES.map((outcome) => ( {AUDIT_OUTCOMES.map((outcome) => (
<option key={outcome} value={outcome}> <option key={outcome} value={outcome}>
{outcomeLabel(outcome)} {outcomeLabel(outcome)}
@@ -313,18 +318,18 @@ export function AuditPage({ dataSource, refreshSignal = 0 }: AuditPageProps) {
</select> </select>
</label> </label>
<label> <label>
<span>Occurred from</span> <span></span>
<input <input
aria-label="Occurred from" aria-label="开始时间"
type="datetime-local" type="datetime-local"
value={filters.occurredFrom} value={filters.occurredFrom}
onChange={(event) => changeFilter('occurredFrom', event.currentTarget.value)} onChange={(event) => changeFilter('occurredFrom', event.currentTarget.value)}
/> />
</label> </label>
<label> <label>
<span>Occurred to</span> <span></span>
<input <input
aria-label="Occurred to" aria-label="结束时间"
type="datetime-local" type="datetime-local"
value={filters.occurredTo} value={filters.occurredTo}
onChange={(event) => changeFilter('occurredTo', event.currentTarget.value)} onChange={(event) => changeFilter('occurredTo', event.currentTarget.value)}
@@ -332,24 +337,24 @@ export function AuditPage({ dataSource, refreshSignal = 0 }: AuditPageProps) {
</label> </label>
</div> </div>
{loading ? ( {loading ? (
<p role="status" aria-label="Audit loading status"> <p role="status" aria-label="审计加载状态">
Loading audit events
</p> </p>
) : null} ) : null}
{error ? ( {error ? (
<div role="alert" className="state-panel state-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)}> <button type="button" onClick={() => setAttempt((value) => value + 1)}>
Retry loading audit events
</button> </button>
</div> </div>
) : null} ) : null}
{!loading && result?.items.length === 0 ? ( {!loading && result?.items.length === 0 ? (
<p className="state-panel">No audit events match the current query.</p> <p className="state-panel"></p>
) : null} ) : null}
{!loading && result ? ( {!loading && result ? (
<div className="table-scroll" tabIndex={0}> <div className="table-scroll" tabIndex={0}>
<table className="dense-table" aria-label="Audit events"> <table className="dense-table" aria-label="审计事件">
<thead> <thead>
<tr> <tr>
{sortableColumns.map(([field, label]) => ( {sortableColumns.map(([field, label]) => (
@@ -366,7 +371,7 @@ export function AuditPage({ dataSource, refreshSignal = 0 }: AuditPageProps) {
> >
<button <button
type="button" type="button"
aria-label={`Sort by ${field}`} aria-label={`${label}排序`}
onClick={() => changeSort(field)} onClick={() => changeSort(field)}
> >
{label} {label}
@@ -374,14 +379,14 @@ export function AuditPage({ dataSource, refreshSignal = 0 }: AuditPageProps) {
</th> </th>
))} ))}
{[ {[
'Actor ID', '操作者 ID',
'Request ID', '请求 ID',
'Instance ID', '实例 ID',
'Job ID', '任务 ID',
'Item ID', '项目 ID',
'Attempt ID', '尝试 ID',
'Preparation ID', '准备 ID',
'Parameters', '参数',
].map((label) => ( ].map((label) => (
<th key={label} scope="col"> <th key={label} scope="col">
{label} {label}
@@ -418,25 +423,25 @@ export function AuditPage({ dataSource, refreshSignal = 0 }: AuditPageProps) {
</div> </div>
) : null} ) : null}
{!loading && result ? ( {!loading && result ? (
<nav className="pagination" aria-label="Audit pagination"> <nav className="pagination" aria-label="审计分页">
<button <button
type="button" type="button"
aria-label="Previous page" aria-label="上一页"
disabled={page === 1} disabled={page === 1}
onClick={() => setPage((value) => value - 1)} onClick={() => setPage((value) => value - 1)}
> >
Previous
</button> </button>
<span> <span>
Page {page} of {pageCount} {page} / {pageCount}
</span> </span>
<button <button
type="button" type="button"
aria-label="Next page" aria-label="下一页"
disabled={page >= pageCount} disabled={page >= pageCount}
onClick={() => setPage((value) => value + 1)} onClick={() => setPage((value) => value + 1)}
> >
Next
</button> </button>
</nav> </nav>
) : null} ) : null}
+91 -67
View File
@@ -25,23 +25,43 @@ export interface FleetPageProps {
const EMPTY_SNAPSHOT: FleetSnapshot = { instances: [], statuses: new Map() }; const EMPTY_SNAPSHOT: FleetSnapshot = { instances: [], statuses: new Map() };
const STATUS_LABELS: Readonly<Record<string, string>> = { const STATUS_LABELS: Readonly<Record<string, string>> = {
online: 'Online', online: '在线',
auth: 'Authentication required', auth: '需要认证',
offline: 'Offline', offline: '离线',
unknown: 'Unknown', unknown: '未知',
}; };
const COLUMN_LABELS: Readonly<Record<FleetSortColumn | 'origin', string>> = { const COLUMN_LABELS: Readonly<Record<FleetSortColumn | 'origin', string>> = {
name: 'Name', name: '名称',
status: 'Status', status: '状态',
latency: 'Latency', latency: '延迟',
version: 'Version', version: '版本',
capabilities: 'Capabilities', capabilities: '能力',
tags: 'Tags', tags: '标签',
freshness: 'Freshness', freshness: '数据新鲜度',
anomalies: 'Anomalies', anomalies: '异常',
origin: 'Origin', origin: '源地址',
}; };
const ALL_COLUMNS = Object.keys(COLUMN_LABELS) as readonly (FleetSortColumn | '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 { export function canonicalHttpOrigin(value: string): string | null {
try { try {
@@ -93,9 +113,8 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
(data) => { (data) => {
if (active) setSnapshot(data); if (active) setSnapshot(data);
}, },
(reason: unknown) => { () => {
if (active) if (active) setError('实例加载失败。');
setError(reason instanceof Error ? reason.message : 'Unable to load instances.');
}, },
); );
return () => { return () => {
@@ -171,6 +190,7 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
setValue: (value: string) => void, setValue: (value: string) => void,
choices: readonly string[], choices: readonly string[],
allLabel: string, allLabel: string,
displayChoice: (choice: string) => string = (choice) => choice,
) => ( ) => (
<label> <label>
<span>{label}</span> <span>{label}</span>
@@ -181,7 +201,7 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
<option value="">{allLabel}</option> <option value="">{allLabel}</option>
{choices.map((choice) => ( {choices.map((choice) => (
<option key={choice} value={choice}> <option key={choice} value={choice}>
{choice} {displayChoice(choice)}
</option> </option>
))} ))}
</select> </select>
@@ -192,92 +212,92 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
<section className="fleet-panel" aria-labelledby="fleet-title"> <section className="fleet-panel" aria-labelledby="fleet-title">
<div className="fleet-heading"> <div className="fleet-heading">
<div> <div>
<h1 id="fleet-title">Fleet</h1> <h1 id="fleet-title"></h1>
<p>Find, compare, and manage SimAdmin instances.</p> <p> SimAdmin </p>
</div> </div>
<div className="fleet-actions"> <div className="fleet-actions">
<span className="selection-summary" aria-live="polite"> <span className="selection-summary" aria-live="polite">
{model.selectedIds.length} selected {model.selectedIds.length}
</span> </span>
<button <button
type="button" type="button"
disabled={model.selectedIds.length === 0} disabled={model.selectedIds.length === 0}
onClick={() => setBatchOpen((open) => !open)} onClick={() => setBatchOpen((open) => !open)}
> >
Batch actions
</button> </button>
</div> </div>
</div> </div>
{batchOpen ? ( {batchOpen ? (
<div className="batch-entry" role="region" aria-label="Batch action entry"> <div className="batch-entry" role="region" aria-label="批量操作入口">
Choose an action for {model.selectedIds.length} selected{' '} {model.selectedIds.length}
{model.selectedIds.length === 1 ? 'instance' : 'instances'}.
</div> </div>
) : null} ) : null}
<div className="fleet-toolbar"> <div className="fleet-toolbar">
<label> <label>
<span>Search instances</span> <span></span>
<input <input
type="search" type="search"
value={query} value={query}
onChange={(event) => resetPage(() => setQuery(event.currentTarget.value))} onChange={(event) => resetPage(() => setQuery(event.currentTarget.value))}
placeholder="Name, ID, tag, origin…" placeholder="名称、ID、标签、源地址…"
/> />
</label> </label>
<label> <label>
<span>Status</span> <span></span>
<select <select
value={filter} value={filter}
onChange={(event) => onChange={(event) =>
resetPage(() => setFilter(event.currentTarget.value as FleetFilter)) resetPage(() => setFilter(event.currentTarget.value as FleetFilter))
} }
> >
<option value="all">All statuses</option> <option value="all"></option>
<option value="online">Online</option> <option value="online">线</option>
<option value="auth">Authentication required</option> <option value="auth"></option>
<option value="offline">Offline</option> <option value="offline">线</option>
<option value="unknown">Unknown</option> <option value="unknown"></option>
</select> </select>
</label> </label>
<label> <label>
<span>Authentication</span> <span></span>
<select <select
value={auth} value={auth}
onChange={(event) => onChange={(event) =>
resetPage(() => setAuth(event.currentTarget.value as FleetAuthFilter)) resetPage(() => setAuth(event.currentTarget.value as FleetAuthFilter))
} }
> >
<option value="all">All authentication</option> <option value="all"></option>
<option value="authenticated">Authenticated</option> <option value="authenticated"></option>
<option value="required">Authentication required</option> <option value="required"></option>
<option value="unknown">Unknown</option> <option value="unknown"></option>
</select> </select>
</label> </label>
{selectFilter( {selectFilter(
'Capability', '能力',
capability, capability,
setCapability, setCapability,
model.facets.capabilities, model.facets.capabilities,
'All capabilities', '全部能力',
capabilityLabel,
)} )}
{selectFilter('Version', version, setVersion, model.facets.versions, 'All versions')} {selectFilter('版本', version, setVersion, model.facets.versions, '全部版本')}
{selectFilter('Tag', tag, setTag, model.facets.tags, 'All tags')} {selectFilter('标签', tag, setTag, model.facets.tags, '全部标签')}
<div className="column-picker"> <div className="column-picker">
<button <button
type="button" type="button"
aria-expanded={columnsOpen} aria-expanded={columnsOpen}
onClick={() => setColumnsOpen((open) => !open)} onClick={() => setColumnsOpen((open) => !open)}
> >
Choose columns
</button> </button>
{columnsOpen ? ( {columnsOpen ? (
<fieldset> <fieldset>
<legend>Visible columns</legend> <legend></legend>
{ALL_COLUMNS.map((column) => ( {ALL_COLUMNS.map((column) => (
<label key={column}> <label key={column}>
<input <input
type="checkbox" type="checkbox"
aria-label={`Show ${column} column`} aria-label={`显示${COLUMN_LABELS[column]}`}
checked={shownColumns.has(column)} checked={shownColumns.has(column)}
onChange={() => toggleColumn(column)} onChange={() => toggleColumn(column)}
/> />
@@ -290,42 +310,42 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
</div> </div>
{!snapshot && !error ? ( {!snapshot && !error ? (
<p role="status" aria-label="Fleet loading status"> <p role="status" aria-label="实例加载状态">
Loading instances
</p> </p>
) : null} ) : null}
{error ? ( {error ? (
<div className="state-panel state-error" role="alert"> <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)}> <button type="button" onClick={() => setAttempt((value) => value + 1)}>
Retry loading instances
</button> </button>
</div> </div>
) : null} ) : null}
{snapshot && model.emptyReason ? ( {snapshot && model.emptyReason ? (
<div className="state-panel"> <div className="state-panel">
{model.emptyReason === 'config' ? <p>No instances are configured.</p> : null} {model.emptyReason === 'config' ? <p></p> : null}
{model.emptyReason === 'search' ? ( {model.emptyReason === 'search' ? <p></p> : null}
<p>No instances match your search and filters.</p> {model.emptyReason === 'filter' ? <p></p> : null}
) : null}
{model.emptyReason === 'filter' ? <p>No instances match the active filters.</p> : null}
</div> </div>
) : null} ) : null}
{snapshot && model.rows.length > 0 ? ( {snapshot && model.rows.length > 0 ? (
<> <>
<div className="table-scroll" tabIndex={0}> <div className="table-scroll" tabIndex={0}>
<table className="dense-table"> <table className="dense-table">
<caption>Fleet instances</caption> <caption></caption>
<thead> <thead>
<tr> <tr>
<th scope="col" className="select-column"> <th scope="col" className="select-column">
<label className="touch-target">
<input <input
ref={selectAllRef} ref={selectAllRef}
type="checkbox" type="checkbox"
aria-label="Select all visible instances" aria-label="选择当前页全部实例"
checked={model.visibleSelection.checked} checked={model.visibleSelection.checked}
onChange={(event) => toggleVisible(event.currentTarget.checked)} onChange={(event) => toggleVisible(event.currentTarget.checked)}
/> />
</label>
</th> </th>
{ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => ( {ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => (
<th <th
@@ -345,7 +365,7 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
<button <button
type="button" type="button"
onClick={() => changeSort(column)} onClick={() => changeSort(column)}
aria-label={`Sort by ${column}, currently ${sort.column === column ? sort.direction : 'not sorted'}`} aria-label={`${COLUMN_LABELS[column]}排序`}
> >
{COLUMN_LABELS[column]} {COLUMN_LABELS[column]}
</button> </button>
@@ -375,9 +395,11 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
), ),
latency: <td>{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}</td>, latency: <td>{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}</td>,
version: <td>{row.version ?? '—'}</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>, 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>, anomalies: <td>{row.anomalies.join(', ') || '—'}</td>,
origin: ( origin: (
<td> <td>
@@ -386,12 +408,12 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
href={origin} href={origin}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label={`Open ${row.displayName} origin`} aria-label={`打开 ${row.displayName} 的源站`}
> >
{origin} {origin}
</a> </a>
) : ( ) : (
<span>Invalid origin</span> <span></span>
)} )}
</td> </td>
), ),
@@ -399,12 +421,14 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
return ( return (
<tr key={row.id} aria-selected={row.selected}> <tr key={row.id} aria-selected={row.selected}>
<td> <td>
<label className="touch-target">
<input <input
type="checkbox" type="checkbox"
aria-label={`Select ${row.displayName}`} aria-label={`选择 ${row.displayName}`}
checked={row.selected} checked={row.selected}
onChange={(event) => toggleOne(row.id, event.currentTarget.checked)} onChange={(event) => toggleOne(row.id, event.currentTarget.checked)}
/> />
</label>
</td> </td>
{ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => ( {ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => (
<Fragment key={column}>{cells[column]}</Fragment> <Fragment key={column}>{cells[column]}</Fragment>
@@ -415,25 +439,25 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
</tbody> </tbody>
</table> </table>
</div> </div>
<nav className="pagination" aria-label="Fleet pagination"> <nav className="pagination" aria-label="实例分页">
<button <button
type="button" type="button"
aria-label="Previous page" aria-label="上一页"
disabled={model.page === 1} disabled={model.page === 1}
onClick={() => setPage((value) => value - 1)} onClick={() => setPage((value) => value - 1)}
> >
Previous
</button> </button>
<span> <span>
Page {model.page} of {model.pageCount} {model.page} {model.pageCount}
</span> </span>
<button <button
type="button" type="button"
aria-label="Next page" aria-label="下一页"
disabled={model.page === model.pageCount} disabled={model.page === model.pageCount}
onClick={() => setPage((value) => value + 1)} onClick={() => setPage((value) => value + 1)}
> >
Next
</button> </button>
</nav> </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 () => { it('loads through the injected exact-owner source and renders only aggregate task status', async () => {
const pending = deferredSource(); const pending = deferredSource();
render(<AutomationModule instance={owner} dataSource={pending.source} />); 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)); expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
pending.resolve(snapshot); 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('12')).toBeTruthy();
expect(within(counts).getByText('9')).toBeTruthy(); expect(within(counts).getByText('9')).toBeTruthy();
expect(screen.getByRole('region', { name: 'Automation status' }).textContent).toContain( expect(screen.getByRole('region', { name: '自动化状态' }).textContent).toContain('正常');
'healthy', expect(screen.queryByRole('region', { name: '安全标签' })).toBeNull();
); expect(screen.getByText(/观测时间:2026-07-17T13:00:00Z/)).toBeTruthy();
expect(screen.queryByRole('region', { name: 'Safe labels' })).toBeNull();
expect(screen.getByText(/Observed 2026-07-17T13:00:00Z/)).toBeTruthy();
}); });
it('omits labels and never exposes task config, endpoints, tokens, phones, messages, payloads, logs, or operations', async () => { 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'], labels: ['Daily jobs'],
}; };
render(<AutomationModule instance={owner} dataSource={{ load: async () => unsafe }} />); 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( 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, /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.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 () => { 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 }} />); render(<AutomationModule instance={owner} dataSource={{ load: async () => unsafe }} />);
await screen.findByRole('region', { name: 'Task counts' }); await screen.findByRole('region', { name: '任务计数' });
expect(within(screen.getByRole('region', { name: 'Task counts' })).getByText('2')).toBeTruthy(); expect(within(screen.getByRole('region', { name: '任务计数' })).getByText('2')).toBeTruthy();
expect(within(screen.getByRole('region', { name: 'Task counts' })).getByText('3')).toBeTruthy(); expect(within(screen.getByRole('region', { name: '任务计数' })).getByText('3')).toBeTruthy();
expect(document.body.textContent).not.toMatch( expect(document.body.textContent).not.toMatch(
/scheduler-token|state-secret|scheduler-secret|worker-secret|Safe [1-9]|script|Infinity|1\.5|-1/, /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.labels[0] = 'Mutated';
unsafe.tasks.disabled = 99; unsafe.tasks.disabled = 99;
expect(screen.queryByText('Mutated')).toBeNull(); expect(screen.queryByText('Mutated')).toBeNull();
@@ -123,12 +121,12 @@ describe('isolated Automation read module', () => {
dataSource={{ load }} dataSource={{ load }}
/>, />,
); );
expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i); expect(screen.getByRole('alert').textContent).toMatch(/需要先完成认证/i);
expect(load).not.toHaveBeenCalled(); expect(load).not.toHaveBeenCalled();
rerender(<AutomationModule instance={owner} />); rerender(<AutomationModule instance={owner} />);
expect(screen.getByRole('status', { name: 'Automation unavailable' }).textContent).toMatch( expect(screen.getByRole('status', { name: '自动化不可用' }).textContent).toMatch(
/no safe Automation read data source.*will not invent.*production endpoint/i, /没有可用的安全自动化只读数据源.*不会臆造.*生产端点/i,
); );
}); });
@@ -143,19 +141,17 @@ describe('isolated Automation read module', () => {
const { rerender } = render( const { rerender } = render(
<AutomationModule instance={owner} dataSource={source} refreshSignal={0} />, <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(); expect(within(counts).getByText('12')).toBeTruthy();
rerender(<AutomationModule instance={owner} dataSource={source} refreshSignal={1} />); rerender(<AutomationModule instance={owner} dataSource={source} refreshSignal={1} />);
const alert = await screen.findByRole('alert'); 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(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( expect(
within(screen.getByRole('region', { name: 'Task counts' })).getByText('12'), await within(screen.getByRole('region', { name: '任务计数' })).findByText('13'),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: 'Retry loading Automation' }));
expect(
await within(screen.getByRole('region', { name: 'Task counts' })).findByText('13'),
).toBeTruthy(); ).toBeTruthy();
}); });
@@ -190,7 +186,7 @@ describe('isolated Automation read module', () => {
/>, />,
); );
const alert = await screen.findByRole('alert'); 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'); 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 { useEffect, useRef, useState, type ReactNode } from 'react';
import type { InstanceContext } from '../app-shell.js'; 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. */ /** Aggregate task counts only; per-task records and configuration are intentionally unsupported. */
export interface AutomationTaskCounts { export interface AutomationTaskCounts {
@@ -46,7 +47,7 @@ type ReadState =
| { kind: 'ready'; ownerId: string; snapshot: AutomationSnapshot } | { kind: 'ready'; ownerId: string; snapshot: AutomationSnapshot }
| { kind: 'error'; 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 STATES = new Set<AutomationState>(['healthy', 'degraded', 'failed', 'disabled', 'unknown']);
const SCHEDULERS = new Set<AutomationScheduler>([ const SCHEDULERS = new Set<AutomationScheduler>([
'active', 'active',
@@ -138,13 +139,13 @@ function Fields({
values: readonly (readonly [string, string | number | null | undefined])[]; values: readonly (readonly [string, string | number | null | undefined])[];
}) { }) {
const supplied = values.filter(([, value]) => value !== 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 ( return (
<dl> <dl>
{supplied.map(([label, value]) => ( {supplied.map(([label, value]) => (
<div key={label}> <div key={label}>
<dt>{label}</dt> <dt>{label}</dt>
<dd>{value == null ? 'Unavailable' : String(value)}</dd> <dd>{displayValue(value)}</dd>
</div> </div>
))} ))}
</dl> </dl>
@@ -156,28 +157,28 @@ function SnapshotView({ snapshot }: { snapshot: AutomationSnapshot }) {
const tasks = snapshot.tasks; const tasks = snapshot.tasks;
return ( return (
<> <>
{snapshot.observedAt ? <p>Observed {snapshot.observedAt}</p> : null} {snapshot.observedAt ? <p>{snapshot.observedAt}</p> : null}
<p>Read-only aggregate view; task details and operational controls are excluded.</p> <p></p>
<div className="automation-grid"> <div className="automation-grid">
<Section label="Automation status"> <Section label="自动化状态">
<Fields <Fields
values={[ values={[
['State', status.state], ['状态', status.state],
['Scheduler', status.scheduler], ['调度器', status.scheduler],
['Workers', status.workers], ['工作进程', status.workers],
]} ]}
/> />
</Section> </Section>
<Section label="Task counts"> <Section label="任务计数">
<Fields <Fields
values={[ values={[
['Total', tasks.total], ['总计', tasks.total],
['Enabled', tasks.enabled], ['已启用', tasks.enabled],
['Disabled', tasks.disabled], ['已禁用', tasks.disabled],
['Running', tasks.running], ['运行中', tasks.running],
['Queued', tasks.queued], ['排队中', tasks.queued],
['Succeeded', tasks.succeeded], ['成功', tasks.succeeded],
['Failed', tasks.failed], ['失败', tasks.failed],
]} ]}
/> />
</Section> </Section>
@@ -238,15 +239,14 @@ export function AutomationModule({ instance, dataSource, refreshSignal }: Automa
if (instance.authentication !== 'authenticated') if (instance.authentication !== 'authenticated')
return ( return (
<div className="state-panel state-error" role="alert"> <div className="state-panel state-error" role="alert">
Authentication is required before Automation data can be read for this instance.
</div> </div>
); );
if (!dataSource) if (!dataSource)
return ( return (
<div className="state-panel" role="status" aria-label="Automation unavailable"> <div className="state-panel" role="status" aria-label="自动化不可用">
No safe Automation read data source is available. This console will not invent or call an
uncontracted production endpoint.
</div> </div>
); );
@@ -256,17 +256,17 @@ export function AutomationModule({ instance, dataSource, refreshSignal }: Automa
: undefined; : undefined;
if ((state.kind === 'idle' || state.kind === 'loading') && !retained) if ((state.kind === 'idle' || state.kind === 'loading') && !retained)
return ( return (
<p role="status" aria-label="Automation loading status"> <p role="status" aria-label="自动化加载状态">
Loading Automation
</p> </p>
); );
if (state.kind === 'error' && !retained) if (state.kind === 'error' && !retained)
return ( return (
<div className="state-panel state-error" role="alert"> <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)}> <button type="button" onClick={() => setRetry((value) => value + 1)}>
Retry loading Automation
</button> </button>
</div> </div>
); );
@@ -276,18 +276,18 @@ export function AutomationModule({ instance, dataSource, refreshSignal }: Automa
if (!snapshot) return null; if (!snapshot) return null;
return ( return (
<div className="automation-module"> <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' ? ( {state.kind === 'error' ? (
<div className="state-panel state-error" role="alert"> <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)}> <button type="button" onClick={() => setRetry((value) => value + 1)}>
Retry loading Automation
</button> </button>
</div> </div>
) : null} ) : null}
{instance.freshness !== 'fresh' ? ( {instance.freshness !== 'fresh' ? (
<p className="state-panel" role="status" aria-label="Automation freshness"> <p className="state-panel" role="status" aria-label="自动化数据时效性">
Automation data is {instance.freshness}; verify freshness before relying on these values. 使
</p> </p>
) : null} ) : null}
<SnapshotView snapshot={snapshot} /> <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 () => { it('loads only through the injected source and renders aggregate call and device status', async () => {
const pending = deferredSource(); const pending = deferredSource();
render(<CallsModule instance={owner} dataSource={pending.source} />); 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)); expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
pending.resolve(snapshot); pending.resolve(snapshot);
const calls = await screen.findByRole('region', { name: 'Call status' }); const calls = await screen.findByRole('region', { name: '通话状态' });
const devices = screen.getByRole('region', { name: 'Device status' }); const devices = screen.getByRole('region', { name: '设备状态' });
expect(within(calls).getByText('active')).toBeTruthy(); expect(within(calls).getByText('活跃')).toBeTruthy();
expect(within(calls).getByText('2')).toBeTruthy(); expect(within(calls).getByText('2')).toBeTruthy();
expect(within(devices).getByText('available')).toBeTruthy(); expect(within(devices).getByText('可用')).toBeTruthy();
expect(screen.getByText(/Observed 2026-07-17T12:30:00Z/)).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 () => { 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' }, devices: { ...snapshot.devices, phoneNumber: '+1-555-0102', audioUrl: 'secret-audio-url' },
} as CallsSnapshot; } as CallsSnapshot;
render(<CallsModule instance={owner} dataSource={{ load: async () => unsafe }} />); 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(document.body.textContent).not.toMatch(/555|private|secret-audio/i);
expect(screen.queryByRole('button')).toBeNull(); expect(screen.queryByRole('button')).toBeNull();
}); });
@@ -75,12 +75,12 @@ describe('isolated Calls read module', () => {
dataSource={{ load }} dataSource={{ load }}
/>, />,
); );
expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i); expect(screen.getByRole('alert').textContent).toMatch(/需要先完成认证/i);
expect(load).not.toHaveBeenCalled(); expect(load).not.toHaveBeenCalled();
rerender(<CallsModule instance={owner} />); rerender(<CallsModule instance={owner} />);
expect(screen.getByRole('status', { name: 'Calls unavailable' }).textContent).toMatch( expect(screen.getByRole('status', { name: '通话不可用' }).textContent).toMatch(
/no safe calls read data source.*will not invent.*production endpoint/i, /没有可用的安全通话只读数据源.*不会臆造.*生产端点/i,
); );
}); });
@@ -95,16 +95,16 @@ describe('isolated Calls read module', () => {
const { rerender } = render( const { rerender } = render(
<CallsModule instance={owner} dataSource={source} refreshSignal={0} />, <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} />); rerender(<CallsModule instance={owner} dataSource={source} refreshSignal={1} />);
const alert = await screen.findByRole('alert'); 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(alert.textContent).not.toMatch(/secret endpoint|telephone/i);
expect(screen.getByText('available')).toBeTruthy(); expect(screen.getByText('可用')).toBeTruthy();
await user.click(screen.getByRole('button', { name: 'Retry loading Calls' })); await user.click(screen.getByRole('button', { name: '重试加载通话' }));
expect( expect(
await within(screen.getByRole('region', { name: 'Call status' })).findByText('3'), await within(screen.getByRole('region', { name: '通话状态' })).findByText('3'),
).toBeTruthy(); ).toBeTruthy();
}); });
+30 -30
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { InstanceContext } from '../app-shell.js'; import type { InstanceContext } from '../app-shell.js';
import { displayValue } from '../ui/locale.js';
/** Bounded values permitted in aggregate call/device status. */ /** Bounded values permitted in aggregate call/device status. */
export type CallsStatusValue = string | number | boolean | null; export type CallsStatusValue = string | number | boolean | null;
@@ -49,23 +50,23 @@ type ReadState =
| { kind: 'ready'; snapshot: OwnedSnapshot } | { kind: 'ready'; snapshot: OwnedSnapshot }
| { kind: 'error'; snapshot?: OwnedSnapshot }; | { kind: 'error'; snapshot?: OwnedSnapshot };
const SAFE_LOAD_ERROR = 'Calls data could not be loaded.'; const SAFE_LOAD_ERROR = '无法加载通话数据。';
const CALL_FIELDS = [ const CALL_FIELDS = [
['State', 'state'], ['状态', 'state'],
['Total', 'total'], ['总计', 'total'],
['Active', 'active'], ['活动中', 'active'],
['Ringing', 'ringing'], ['响铃中', 'ringing'],
['Held', 'held'], ['保持中', 'held'],
['Failed', 'failed'], ['失败', 'failed'],
] as const; ] as const;
const DEVICE_FIELDS = [ const DEVICE_FIELDS = [
['State', 'state'], ['状态', 'state'],
['Total', 'total'], ['总计', 'total'],
['Online', 'online'], ['在线', 'online'],
['Offline', 'offline'], ['离线', 'offline'],
['Busy', 'busy'], ['忙碌', 'busy'],
] as const; ] as const;
function Section({ label, children }: { label: string; children: ReactNode }) { function Section({ label, children }: { label: string; children: ReactNode }) {
@@ -86,13 +87,13 @@ function AggregateFields({
}) { }) {
const safeValues = values as Readonly<Record<string, CallsStatusValue | undefined>>; const safeValues = values as Readonly<Record<string, CallsStatusValue | undefined>>;
const supplied = fields.filter(([, key]) => safeValues[key] !== 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 ( return (
<dl> <dl>
{supplied.map(([label, key]) => ( {supplied.map(([label, key]) => (
<div key={key}> <div key={key}>
<dt>{label}</dt> <dt>{label}</dt>
<dd>{safeValues[key] == null ? 'Unavailable' : String(safeValues[key])}</dd> <dd>{displayValue(safeValues[key])}</dd>
</div> </div>
))} ))}
</dl> </dl>
@@ -102,12 +103,12 @@ function AggregateFields({
function SnapshotView({ snapshot }: { snapshot: CallsSnapshot }) { function SnapshotView({ snapshot }: { snapshot: CallsSnapshot }) {
return ( return (
<> <>
{snapshot.observedAt ? <p>Observed {snapshot.observedAt}</p> : null} {snapshot.observedAt ? <p>{snapshot.observedAt}</p> : null}
<div className="calls-grid"> <div className="calls-grid">
<Section label="Call status"> <Section label="通话状态">
<AggregateFields values={snapshot.calls} fields={CALL_FIELDS} /> <AggregateFields values={snapshot.calls} fields={CALL_FIELDS} />
</Section> </Section>
<Section label="Device status"> <Section label="设备状态">
<AggregateFields values={snapshot.devices} fields={DEVICE_FIELDS} /> <AggregateFields values={snapshot.devices} fields={DEVICE_FIELDS} />
</Section> </Section>
</div> </div>
@@ -162,15 +163,14 @@ export function CallsModule({ instance, dataSource, refreshSignal }: CallsModule
if (instance.authentication !== 'authenticated') if (instance.authentication !== 'authenticated')
return ( return (
<div className="state-panel state-error" role="alert"> <div className="state-panel state-error" role="alert">
Authentication is required before Calls data can be read for this instance.
</div> </div>
); );
if (!dataSource) if (!dataSource)
return ( return (
<div className="state-panel" role="status" aria-label="Calls unavailable"> <div className="state-panel" role="status" aria-label="通话不可用">
No safe Calls read data source is available. This console will not invent or call an
uncontracted production endpoint.
</div> </div>
); );
@@ -179,17 +179,17 @@ export function CallsModule({ instance, dataSource, refreshSignal }: CallsModule
if ((state.kind === 'idle' || state.kind === 'loading') && !snapshot) if ((state.kind === 'idle' || state.kind === 'loading') && !snapshot)
return ( return (
<p role="status" aria-label="Calls loading status"> <p role="status" aria-label="通话加载状态">
Loading Calls
</p> </p>
); );
if (state.kind === 'error' && !snapshot) if (state.kind === 'error' && !snapshot)
return ( return (
<div className="state-panel state-error" role="alert"> <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)}> <button type="button" onClick={() => setRetry((value) => value + 1)}>
Retry loading Calls
</button> </button>
</div> </div>
); );
@@ -198,18 +198,18 @@ export function CallsModule({ instance, dataSource, refreshSignal }: CallsModule
return ( return (
<div className="calls-module"> <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' ? ( {state.kind === 'error' ? (
<div className="state-panel state-error" role="alert"> <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)}> <button type="button" onClick={() => setRetry((value) => value + 1)}>
Retry loading Calls
</button> </button>
</div> </div>
) : null} ) : null}
{instance.freshness !== 'fresh' ? ( {instance.freshness !== 'fresh' ? (
<p className="state-panel" role="status" aria-label="Calls freshness"> <p className="state-panel" role="status" aria-label="通话数据时效性">
Calls data is {instance.freshness}; verify freshness before relying on these values. 使
</p> </p>
) : null} ) : null}
<SnapshotView snapshot={snapshot.value} /> <SnapshotView snapshot={snapshot.value} />
+20 -19
View File
@@ -23,7 +23,7 @@ const owner: InstanceContext = {
const snapshot: CellularSnapshot = { const snapshot: CellularSnapshot = {
observedAt: '2026-07-17T12:00:00Z', observedAt: '2026-07-17T12:00:00Z',
networkRegistration: { state: 'registered', roaming: false }, networkRegistration: { state: 'online', roaming: false },
signal: { rssi: '-71 dBm', quality: 82 }, signal: { rssi: '-71 dBm', quality: 82 },
cellsLocation: { cell: '12345', area: 19, latitude: null }, cellsLocation: { cell: '12345', area: 19, latitude: null },
operators: { current: 'Example Mobile', available: 3 }, operators: { current: 'Example Mobile', available: 3 },
@@ -54,27 +54,30 @@ describe('Phase 6.2 Cellular read module', () => {
const pending = deferredSource(); const pending = deferredSource();
render(<CellularModule instance={owner} dataSource={pending.source} />); 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)); expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
pending.resolve(snapshot); 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(await screen.findByRole('heading', { name: heading })).toBeTruthy();
} }
expect( expect(within(screen.getByRole('region', { name: '信号' })).getByText('-71 dBm')).toBeTruthy();
within(screen.getByRole('region', { name: 'Signal' })).getByText('-71 dBm'), const registration = screen.getByRole('region', { name: '网络注册' });
).toBeTruthy(); expect(within(registration).getByText('在线')).toBeTruthy();
expect(screen.getByText('Unavailable')).toBeTruthy(); expect(within(registration).getByText('否')).toBeTruthy();
expect(screen.getByText(/Observed 2026-07-17T12:00:00Z/)).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.queryByRole('button', { name: /register|operator|network/i })).toBeNull();
expect(screen.getByText(/automatic network registration is available only/i)).toBeTruthy(); expect(screen.getByText(/自动网络注册仅可通过/i)).toBeTruthy();
expect(screen.getByText(/audited R2 prepare, confirm, and execute flow/i)).toBeTruthy(); expect(screen.getByText(/经审计的 R2 准备、确认和执行流程/i)).toBeTruthy();
}); });
it('is honest when no read source is injected', () => { it('is honest when no read source is injected', () => {
render(<CellularModule instance={owner} />); render(<CellularModule instance={owner} />);
expect(screen.getByRole('status', { name: 'Cellular unavailable' }).textContent).toMatch( expect(screen.getByRole('status', { name: '蜂窝网络不可用' }).textContent).toMatch(
/no safe cellular read data source.*will not invent.*production endpoint/i, /没有可用的安全蜂窝网络只读数据源.*不会臆造.*生产端点/i,
); );
}); });
@@ -95,7 +98,7 @@ describe('Phase 6.2 Cellular read module', () => {
dataSource={{ load }} 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(screen.queryByText('-71 dBm')).toBeNull();
expect(load).toHaveBeenCalledTimes(1); expect(load).toHaveBeenCalledTimes(1);
}); });
@@ -114,13 +117,11 @@ describe('Phase 6.2 Cellular read module', () => {
expect(await screen.findByText('-71 dBm')).toBeTruthy(); expect(await screen.findByText('-71 dBm')).toBeTruthy();
rerender(<CellularModule instance={owner} dataSource={source} refreshSignal={1} />); rerender(<CellularModule instance={owner} dataSource={source} refreshSignal={1} />);
expect( expect(await screen.findByText(/刷新失败,正在显示上次已知的蜂窝网络数据/i)).toBeTruthy();
await screen.findByText(/Refresh failed; showing the last known cellular data/i),
).toBeTruthy();
expect(screen.getByText('-71 dBm')).toBeTruthy(); expect(screen.getByText('-71 dBm')).toBeTruthy();
expect(screen.queryByText(/secret upstream details/i)).toBeNull(); 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(await screen.findByText('-65 dBm')).toBeTruthy();
expect(load).toHaveBeenCalledTimes(3); expect(load).toHaveBeenCalledTimes(3);
}); });
@@ -134,9 +135,9 @@ describe('Phase 6.2 Cellular read module', () => {
render(<CellularModule instance={owner} dataSource={{ load }} />); render(<CellularModule instance={owner} dataSource={{ load }} />);
const alert = await screen.findByRole('alert'); 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); 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(); expect(await screen.findByText('-71 dBm')).toBeTruthy();
}); });
+41 -40
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { InstanceContext } from '../app-shell.js'; import type { InstanceContext } from '../app-shell.js';
import { displayValue } from '../ui/locale.js';
/** Cellular data is deliberately limited to display-safe primitive values. */ /** Cellular data is deliberately limited to display-safe primitive values. */
export type CellularFieldValue = string | number | boolean | null; export type CellularFieldValue = string | number | boolean | null;
@@ -59,42 +60,42 @@ type ReadState =
const SECTIONS = [ const SECTIONS = [
[ [
'networkRegistration', 'networkRegistration',
'Network registration', '网络注册',
[ [
['State', 'state'], ['状态', 'state', true],
['Mode', 'mode'], ['模式', 'mode', true],
['Operator', 'operator'], ['运营商', 'operator'],
['Roaming', 'roaming'], ['漫游', 'roaming', true],
], ],
], ],
[ [
'signal', 'signal',
'Signal', '信号',
[ [
['RSSI', 'rssi'], ['RSSI', 'rssi'],
['RSRP', 'rsrp'], ['RSRP', 'rsrp'],
['RSRQ', 'rsrq'], ['RSRQ', 'rsrq'],
['SINR', 'sinr'], ['SINR', 'sinr'],
['Quality', 'quality'], ['质量', 'quality', true],
], ],
], ],
[ [
'cellsLocation', 'cellsLocation',
'Cells / location', '小区与位置',
[ [
['Cell', 'cell'], ['小区', 'cell'],
['Area', 'area'], ['区域', 'area'],
['Technology', 'technology'], ['技术', 'technology', true],
['Latitude', 'latitude'], ['纬度', 'latitude'],
['Longitude', 'longitude'], ['经度', 'longitude'],
], ],
], ],
[ [
'operators', 'operators',
'Operators', '运营商',
[ [
['Current', 'current'], ['当前', 'current'],
['Available', 'available'], ['可用', 'available'],
], ],
], ],
] as const; ] as const;
@@ -106,7 +107,7 @@ function StructuredSection({
}: { }: {
label: string; label: string;
values: object; values: object;
fields: readonly (readonly [string, string])[]; fields: readonly (readonly [string, string, boolean?])[];
}) { }) {
const safeValues = values as Readonly<Record<string, CellularFieldValue | undefined>>; const safeValues = values as Readonly<Record<string, CellularFieldValue | undefined>>;
const entries = fields.filter(([, key]) => safeValues[key] !== undefined); const entries = fields.filter(([, key]) => safeValues[key] !== undefined);
@@ -115,18 +116,20 @@ function StructuredSection({
<h2>{label}</h2> <h2>{label}</h2>
{entries.length ? ( {entries.length ? (
<dl> <dl>
{entries.map(([labelText, fieldKey]) => { {entries.map(([labelText, fieldKey, localized]) => {
const value = safeValues[fieldKey]; const value = safeValues[fieldKey];
return ( return (
<div key={fieldKey}> <div key={fieldKey}>
<dt>{labelText}</dt> <dt>{labelText}</dt>
<dd>{value == null ? 'Unavailable' : String(value)}</dd> <dd>
{localized ? displayValue(value) : value == null ? '不可用' : String(value)}
</dd>
</div> </div>
); );
})} })}
</dl> </dl>
) : ( ) : (
<p>No {label.toLocaleLowerCase()} data was supplied.</p> <p>{label}</p>
)} )}
</section> </section>
); );
@@ -169,7 +172,7 @@ export function CellularModule({ instance, dataSource, refreshSignal }: Cellular
: undefined; : undefined;
return { return {
kind: 'error', kind: 'error',
message: 'Cellular data could not be loaded.', message: '无法加载蜂窝网络数据。',
...(snapshot ? { snapshot } : {}), ...(snapshot ? { snapshot } : {}),
}; };
}); });
@@ -182,16 +185,15 @@ export function CellularModule({ instance, dataSource, refreshSignal }: Cellular
if (instance.authentication !== 'authenticated') { if (instance.authentication !== 'authenticated') {
return ( return (
<div className="state-panel state-error" role="alert"> <div className="state-panel state-error" role="alert">
Authentication is required before cellular data can be read for this instance.
</div> </div>
); );
} }
if (!dataSource) { if (!dataSource) {
return ( return (
<div className="state-panel" role="status" aria-label="Cellular unavailable"> <div className="state-panel" role="status" aria-label="蜂窝网络不可用">
No safe cellular read data source is available. This console will not invent or call an
uncontracted production endpoint.
</div> </div>
); );
} }
@@ -201,8 +203,8 @@ export function CellularModule({ instance, dataSource, refreshSignal }: Cellular
if ((state.kind === 'idle' || state.kind === 'loading') && !ownedSnapshot) { if ((state.kind === 'idle' || state.kind === 'loading') && !ownedSnapshot) {
return ( return (
<p role="status" aria-label="Cellular loading status"> <p role="status" aria-label="蜂窝网络加载状态">
Loading cellular data
</p> </p>
); );
} }
@@ -210,9 +212,9 @@ export function CellularModule({ instance, dataSource, refreshSignal }: Cellular
if (state.kind === 'error' && !ownedSnapshot) { if (state.kind === 'error' && !ownedSnapshot) {
return ( return (
<div className="state-panel state-error" role="alert"> <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)}> <button type="button" onClick={() => setRetry((value) => value + 1)}>
Retry loading cellular data
</button> </button>
</div> </div>
); );
@@ -223,32 +225,31 @@ export function CellularModule({ instance, dataSource, refreshSignal }: Cellular
return ( return (
<div className="cellular-module"> <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' ? ( {state.kind === 'error' ? (
<div className="state-panel state-error" role="alert"> <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)}> <button type="button" onClick={() => setRetry((value) => value + 1)}>
Retry loading cellular data
</button> </button>
</div> </div>
) : null} ) : null}
{instance.freshness !== 'fresh' ? ( {instance.freshness !== 'fresh' ? (
<p className="state-panel" role="status" aria-label="Cellular freshness"> <p className="state-panel" role="status" aria-label="蜂窝网络数据新鲜度">
Cellular data is {instance.freshness}; verify freshness before relying on these values. 使
</p> </p>
) : null} ) : null}
{snapshot.observedAt ? <p>Observed {snapshot.observedAt}</p> : null} {snapshot.observedAt ? <p>{snapshot.observedAt}</p> : null}
<div className="cellular-grid"> <div className="cellular-grid">
{SECTIONS.map(([key, label, fields]) => ( {SECTIONS.map(([key, label, fields]) => (
<StructuredSection key={key} label={label} values={snapshot[key]} fields={fields} /> <StructuredSection key={key} label={label} values={snapshot[key]} fields={fields} />
))} ))}
</div> </div>
<section className="state-panel" aria-label="Network registration operations"> <section className="state-panel" aria-label="网络注册操作">
<h2>Network registration operations</h2> <h2></h2>
<p> <p>
Automatic network registration is available only through the audited R2 prepare, confirm, R2
and execute flow. Manual registration and monitoring controls remain unavailable. This
read-only panel does not bypass those gates.
</p> </p>
</section> </section>
</div> </div>
@@ -26,8 +26,8 @@ const snapshot: DeviceNetworkSnapshot = {
wlan: { wlan: {
status: { status: {
enabled: true, enabled: true,
radioState: 'on', radioState: 'enabled',
connectionState: 'connected', connectionState: 'active',
activeProfile: 'office', activeProfile: 'office',
ssid: 'Operations Wi-Fi', ssid: 'Operations Wi-Fi',
}, },
@@ -101,22 +101,30 @@ describe('Phase 6.3 Device Network read module', () => {
const pending = deferredSource(); const pending = deferredSource();
render(<DeviceNetworkModule instance={owner} dataSource={pending.source} />); 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)); expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
pending.resolve(snapshot); pending.resolve(snapshot);
expect(await screen.findByRole('heading', { name: 'WLAN status' })).toBeTruthy(); expect(await screen.findByRole('heading', { name: 'WLAN 状态' })).toBeTruthy();
expect(screen.getByRole('heading', { name: 'WLAN profiles' })).toBeTruthy(); expect(screen.getByRole('heading', { name: 'WLAN 配置文件' })).toBeTruthy();
expect(screen.getByRole('heading', { name: 'Interfaces and addresses' })).toBeTruthy(); expect(screen.getByRole('heading', { name: '接口与地址' })).toBeTruthy();
expect(screen.getByRole('heading', { name: 'DDNS status' })).toBeTruthy(); expect(screen.getByRole('heading', { name: 'DDNS 状态' })).toBeTruthy();
expect(screen.getByRole('heading', { name: 'DDNS configuration' })).toBeTruthy(); expect(screen.getByRole('heading', { name: 'DDNS 配置' })).toBeTruthy();
expect(screen.getByRole('heading', { name: 'DDNS log summary' })).toBeTruthy(); expect(screen.getByRole('heading', { name: 'DDNS 日志摘要' })).toBeTruthy();
expect( expect(
within(screen.getByRole('region', { name: 'WLAN profiles' })).getByText('WPA3'), within(screen.getByRole('region', { name: 'WLAN 配置文件' })).getByText('WPA3'),
).toBeTruthy(); ).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('192.0.2.10/24')).toBeTruthy();
expect(screen.getByText('gateway.example.test')).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 () => { 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(document.body.textContent).not.toContain('private-password');
expect(screen.queryByText(/password/i)).toBeNull(); expect(screen.queryByText(/password/i)).toBeNull();
expect(screen.queryByRole('button')).toBeNull(); expect(screen.queryByRole('button')).toBeNull();
expect(screen.getByText(/R1.*unavailable.*executable backend support/i)).toBeTruthy(); expect(screen.getByText(/可执行后端支持.*R1.*不可用/i)).toBeTruthy();
expect(screen.getByText(/R2.*unavailable.*executable backend support/i)).toBeTruthy(); expect(screen.getByText(/可执行后端支持.*R2.*不可用/i)).toBeTruthy();
}); });
it('is honest when no source is injected and does not invent an endpoint', () => { it('is honest when no source is injected and does not invent an endpoint', () => {
render(<DeviceNetworkModule instance={owner} />); render(<DeviceNetworkModule instance={owner} />);
expect(screen.getByRole('status', { name: 'Device Network unavailable' }).textContent).toMatch( expect(screen.getByRole('status', { name: '设备网络不可用' }).textContent).toMatch(
/no safe device network read data source.*uncontracted production endpoint/i, /没有可用的安全设备网络只读数据源.*未签订契约的生产端点/i,
); );
}); });
@@ -155,7 +163,7 @@ describe('Phase 6.3 Device Network read module', () => {
dataSource={{ load }} 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(screen.queryByText('gateway.example.test')).toBeNull();
expect(load).toHaveBeenCalledTimes(1); expect(load).toHaveBeenCalledTimes(1);
}); });
@@ -169,9 +177,9 @@ describe('Phase 6.3 Device Network read module', () => {
render(<DeviceNetworkModule instance={owner} dataSource={{ load }} />); render(<DeviceNetworkModule instance={owner} dataSource={{ load }} />);
const alert = await screen.findByRole('alert'); 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'); 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(await screen.findByText('gateway.example.test')).toBeTruthy();
expect(load).toHaveBeenCalledTimes(2); expect(load).toHaveBeenCalledTimes(2);
}); });
@@ -193,9 +201,9 @@ describe('Phase 6.3 Device Network read module', () => {
expect(await screen.findByText('gateway.example.test')).toBeTruthy(); expect(await screen.findByText('gateway.example.test')).toBeTruthy();
rerender(<DeviceNetworkModule instance={owner} dataSource={source} refreshSignal={1} />); 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(); 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(); expect(await screen.findByText('new.example.test')).toBeTruthy();
}); });
@@ -250,9 +258,9 @@ describe('Phase 6.3 Device Network read module', () => {
dataSource={{ load: async () => snapshot }} dataSource={{ load: async () => snapshot }}
/>, />,
); );
expect( expect((await screen.findByRole('status', { name: '设备网络数据新鲜度' })).textContent).toMatch(
(await screen.findByRole('status', { name: 'Device Network freshness' })).textContent, /可能已过期/i,
).toMatch(/stale/i); );
expect(screen.getByText('gateway.example.test')).toBeTruthy(); expect(screen.getByText('gateway.example.test')).toBeTruthy();
}); });
}); });
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { InstanceContext } from '../app-shell.js'; 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. */ /** Only these bounded primitives may cross the injected read boundary into the UI. */
export type DeviceNetworkValue = string | number | boolean | null; export type DeviceNetworkValue = string | number | boolean | null;
@@ -90,23 +91,27 @@ type ReadState =
| { kind: 'ready'; ownerId: string; snapshot: DeviceNetworkSnapshot } | { kind: 'ready'; ownerId: string; snapshot: DeviceNetworkSnapshot }
| { kind: 'error'; 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 { function displayRaw(value: DeviceNetworkValue | undefined): string {
return value == null ? 'Unavailable' : String(value); return value == null ? '不可用' : String(value);
} }
function Fields({ function Fields({
values, values,
}: { }: {
values: readonly (readonly [label: string, value: DeviceNetworkValue | undefined])[]; values: readonly (readonly [
label: string,
value: DeviceNetworkValue | undefined,
localized?: boolean,
])[];
}) { }) {
return ( return (
<dl> <dl>
{values.map(([label, value]) => ( {values.map(([label, value, localized]) => (
<div key={label}> <div key={label}>
<dt>{label}</dt> <dt>{label}</dt>
<dd>{display(value)}</dd> <dd>{localized ? displayValue(value) : displayRaw(value)}</dd>
</div> </div>
))} ))}
</dl> </dl>
@@ -129,115 +134,115 @@ function SnapshotView({ snapshot }: { snapshot: DeviceNetworkSnapshot }) {
const logs = snapshot.ddns.logSummary; const logs = snapshot.ddns.logSummary;
return ( return (
<> <>
{snapshot.observedAt ? <p>Observed {snapshot.observedAt}</p> : null} {snapshot.observedAt ? <p>{snapshot.observedAt}</p> : null}
<div className="device-network-grid"> <div className="device-network-grid">
<Section label="WLAN status"> <Section label="WLAN 状态">
<Fields <Fields
values={[ values={[
['Enabled', status.enabled], ['已启用', status.enabled, true],
['Radio state', status.radioState], ['无线电状态', status.radioState, true],
['Connection state', status.connectionState], ['连接状态', status.connectionState, true],
['Active profile', status.activeProfile], ['活动配置文件', status.activeProfile],
['SSID', status.ssid], ['SSID', status.ssid],
]} ]}
/> />
</Section> </Section>
<Section label="WLAN profiles"> <Section label="WLAN 配置文件">
{snapshot.wlan.profiles.length ? ( {snapshot.wlan.profiles.length ? (
snapshot.wlan.profiles.map((profile, index) => ( snapshot.wlan.profiles.map((profile, index) => (
<article key={`${profile.name ?? 'profile'}-${index}`}> <article key={`${profile.name ?? 'profile'}-${index}`}>
<Fields <Fields
values={[ values={[
['Name', profile.name], ['名称', profile.name],
['SSID', profile.ssid], ['SSID', profile.ssid],
['Security', profile.security], ['安全性', profile.security, true],
['Enabled', profile.enabled], ['已启用', profile.enabled, true],
['Priority', profile.priority], ['优先级', profile.priority],
]} ]}
/> />
</article> </article>
)) ))
) : ( ) : (
<p>No WLAN profiles were supplied.</p> <p> WLAN </p>
)} )}
</Section> </Section>
<Section label="Interfaces and addresses"> <Section label="接口与地址">
{snapshot.interfaces.length ? ( {snapshot.interfaces.length ? (
snapshot.interfaces.map((networkInterface, index) => ( snapshot.interfaces.map((networkInterface, index) => (
<article key={`${networkInterface.name ?? 'interface'}-${index}`}> <article key={`${networkInterface.name ?? 'interface'}-${index}`}>
<Fields <Fields
values={[ values={[
['Name', networkInterface.name], ['名称', networkInterface.name],
['Kind', networkInterface.kind], ['类型', networkInterface.kind, true],
['State', networkInterface.state], ['状态', networkInterface.state, true],
['MAC address', networkInterface.macAddress], ['MAC 地址', networkInterface.macAddress],
['MTU', networkInterface.mtu], ['MTU', networkInterface.mtu],
]} ]}
/> />
<h3>Addresses</h3> <h3></h3>
{networkInterface.addresses.length ? ( {networkInterface.addresses.length ? (
<ul> <ul>
{networkInterface.addresses.map((address, addressIndex) => ( {networkInterface.addresses.map((address, addressIndex) => (
<li key={`${address.address ?? 'address'}-${addressIndex}`}> <li key={`${address.address ?? 'address'}-${addressIndex}`}>
<Fields <Fields
values={[ values={[
['Family', address.family], ['地址族', address.family, true],
[ [
'Address', '地址',
address.address == null address.address == null
? null ? null
: address.prefixLength == null : address.prefixLength == null
? address.address ? address.address
: `${address.address}/${address.prefixLength}`, : `${address.address}/${address.prefixLength}`,
], ],
['Scope', address.scope], ['作用域', address.scope, true],
]} ]}
/> />
</li> </li>
))} ))}
</ul> </ul>
) : ( ) : (
<p>No addresses were supplied.</p> <p></p>
)} )}
</article> </article>
)) ))
) : ( ) : (
<p>No network interfaces were supplied.</p> <p></p>
)} )}
</Section> </Section>
<Section label="DDNS status"> <Section label="DDNS 状态">
<Fields <Fields
values={[ values={[
['Enabled', ddnsStatus.enabled], ['已启用', ddnsStatus.enabled, true],
['State', ddnsStatus.state], ['状态', ddnsStatus.state, true],
['Last update', ddnsStatus.lastUpdateAt], ['最近更新', ddnsStatus.lastUpdateAt],
]} ]}
/> />
</Section> </Section>
<Section label="DDNS configuration"> <Section label="DDNS 配置">
<Fields <Fields
values={[ values={[
['Provider', config.provider], ['服务商', config.provider],
['Hostname', config.hostname], ['主机名', config.hostname],
['Update interval (seconds)', config.updateIntervalSeconds], ['更新间隔(秒)', config.updateIntervalSeconds],
]} ]}
/> />
</Section> </Section>
<Section label="DDNS log summary"> <Section label="DDNS 日志摘要">
<Fields <Fields
values={[ values={[
['Total entries', logs.totalEntries], ['条目总数', logs.totalEntries],
['Successful updates', logs.successfulUpdates], ['成功更新', logs.successfulUpdates],
['Failed updates', logs.failedUpdates], ['失败更新', logs.failedUpdates],
['Last event', logs.lastEventAt], ['最近事件', logs.lastEventAt],
]} ]}
/> />
</Section> </Section>
</div> </div>
<section className="state-panel" aria-label="Device Network actions"> <section className="state-panel" aria-label="设备网络操作">
<h2>Device Network actions</h2> <h2></h2>
<p>R1 configuration actions are unavailable until executable backend support exists.</p> <p>R1 </p>
<p>R2 operational actions are unavailable until executable backend support exists.</p> <p>R2 </p>
</section> </section>
</> </>
); );
@@ -294,16 +299,15 @@ export function DeviceNetworkModule({
if (instance.authentication !== 'authenticated') { if (instance.authentication !== 'authenticated') {
return ( return (
<div className="state-panel state-error" role="alert"> <div className="state-panel state-error" role="alert">
Authentication is required before Device Network data can be read for this instance.
</div> </div>
); );
} }
if (!dataSource) if (!dataSource)
return ( return (
<div className="state-panel" role="status" aria-label="Device Network unavailable"> <div className="state-panel" role="status" aria-label="设备网络不可用">
No safe Device Network read data source is available. This console will not invent or call
an uncontracted production endpoint.
</div> </div>
); );
@@ -314,17 +318,17 @@ export function DeviceNetworkModule({
if ((state.kind === 'idle' || state.kind === 'loading') && !retainedSnapshot) if ((state.kind === 'idle' || state.kind === 'loading') && !retainedSnapshot)
return ( return (
<p role="status" aria-label="Device Network loading status"> <p role="status" aria-label="设备网络加载状态">
Loading Device Network
</p> </p>
); );
if (state.kind === 'error' && !retainedSnapshot) if (state.kind === 'error' && !retainedSnapshot)
return ( return (
<div className="state-panel state-error" role="alert"> <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)}> <button type="button" onClick={() => setRetry((value) => value + 1)}>
Retry loading Device Network
</button> </button>
</div> </div>
); );
@@ -335,19 +339,18 @@ export function DeviceNetworkModule({
return ( return (
<div className="device-network-module"> <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' ? ( {state.kind === 'error' ? (
<div className="state-panel state-error" role="alert"> <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)}> <button type="button" onClick={() => setRetry((value) => value + 1)}>
Retry loading Device Network
</button> </button>
</div> </div>
) : null} ) : null}
{instance.freshness !== 'fresh' ? ( {instance.freshness !== 'fresh' ? (
<p className="state-panel" role="status" aria-label="Device Network freshness"> <p className="state-panel" role="status" aria-label="设备网络数据新鲜度">
Device Network data is {instance.freshness}; verify freshness before relying on these 使
values.
</p> </p>
) : null} ) : null}
<SnapshotView snapshot={currentSnapshot} /> <SnapshotView snapshot={currentSnapshot} />
+21 -23
View File
@@ -36,19 +36,19 @@ describe('isolated safe eSIM read module', () => {
render(<EsimModule instance={owner} dataSource={pending.source} />); render(<EsimModule instance={owner} dataSource={pending.source} />);
expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal)); expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
pending.resolve(snapshot); 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('4')).toBeTruthy();
expect(within(section).getByText('2')).toBeTruthy(); expect(within(section).getByText('2')).toBeTruthy();
expect(within(section).getByText('available')).toBeTruthy(); expect(within(section).getByText('可用')).toBeTruthy();
expect(within(section).getByText('idle')).toBeTruthy(); expect(within(section).getByText('空闲')).toBeTruthy();
expect(within(section).getByText('Provisioned')).toBeTruthy(); expect(within(section).getByText('已配置')).toBeTruthy();
expect(screen.queryByRole('button')).toBeNull(); expect(screen.queryByRole('button')).toBeNull();
}); });
it('has no endpoint fallback and requires the exact authenticated owner', async () => { it('has no endpoint fallback and requires the exact authenticated owner', async () => {
const load = vi.fn<EsimDataSource['load']>().mockResolvedValue(snapshot); const load = vi.fn<EsimDataSource['load']>().mockResolvedValue(snapshot);
const { rerender } = render(<EsimModule instance={owner} dataSource={{ load }} />); const { rerender } = render(<EsimModule instance={owner} dataSource={{ load }} />);
expect(await screen.findByText('Provisioned')).toBeTruthy(); expect(await screen.findByText('已配置')).toBeTruthy();
rerender( rerender(
<EsimModule <EsimModule
instance={{ instance={{
@@ -60,13 +60,13 @@ describe('isolated safe eSIM read module', () => {
dataSource={{ load }} dataSource={{ load }}
/>, />,
); );
expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i); expect(screen.getByRole('alert').textContent).toMatch(/需要先完成认证/i);
expect(screen.queryByText('Provisioned')).toBeNull(); expect(screen.queryByText('已配置')).toBeNull();
expect(load).toHaveBeenCalledTimes(1); expect(load).toHaveBeenCalledTimes(1);
rerender(<EsimModule instance={owner} />); rerender(<EsimModule instance={owner} />);
expect(screen.getByRole('status', { name: 'eSIM unavailable' }).textContent).toMatch( expect(screen.getByRole('status', { name: 'eSIM 不可用' }).textContent).toMatch(
/no safe esim read data source.*will not invent or call.*endpoint/i, /没有可用的安全 eSIM 只读数据源.*不会臆造或调用.*端点/i,
); );
}); });
@@ -91,7 +91,7 @@ describe('isolated safe eSIM read module', () => {
providerData: { secret: 'raw-provider-secret' }, providerData: { secret: 'raw-provider-secret' },
} as unknown as EsimSnapshot; } as unknown as EsimSnapshot;
render(<EsimModule instance={owner} dataSource={{ load: async () => hostile }} />); render(<EsimModule instance={owner} dataSource={{ load: async () => hostile }} />);
expect(await screen.findByText('Enabled')).toBeTruthy(); expect(await screen.findByText('已启用')).toBeTruthy();
for (const forbidden of [ for (const forbidden of [
'EID-secret', 'EID-secret',
'ICCID-secret', 'ICCID-secret',
@@ -109,7 +109,7 @@ describe('isolated safe eSIM read module', () => {
expect(document.body.textContent).not.toContain(forbidden); expect(document.body.textContent).not.toContain(forbidden);
expect(document.querySelector('img')).toBeNull(); expect(document.querySelector('img')).toBeNull();
expect(document.querySelector('script')).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 () => { 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( const { rerender } = render(
<EsimModule instance={owner} dataSource={source} refreshSignal={0} />, <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} />); rerender(<EsimModule instance={owner} dataSource={source} refreshSignal={1} />);
expect( expect(await screen.findByText(/刷新失败,正在显示上次已知的 eSIM 摘要/i)).toBeTruthy();
await screen.findByText(/Refresh failed; showing the last known eSIM summary/i),
).toBeTruthy();
expect(document.body.textContent).not.toContain('EID-secret'); expect(document.body.textContent).not.toContain('EID-secret');
expect(screen.getByText('4')).toBeTruthy(); 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(); expect(await screen.findByText('5')).toBeTruthy();
}); });
@@ -146,7 +144,7 @@ describe('isolated safe eSIM read module', () => {
/>, />,
); );
const alert = await screen.findByRole('alert'); 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'); expect(alert.textContent).not.toContain('private code');
}); });
@@ -163,8 +161,8 @@ describe('isolated safe eSIM read module', () => {
expect(alphaSignal?.aborted).toBe(true); expect(alphaSignal?.aborted).toBe(true);
alpha.resolve({ ...snapshot, labels: ['Error'] }); alpha.resolve({ ...snapshot, labels: ['Error'] });
bravo.resolve({ ...snapshot, labels: ['Pending'] }); bravo.resolve({ ...snapshot, labels: ['Pending'] });
expect(await screen.findByText('Pending')).toBeTruthy(); expect(await screen.findByText('等待中')).toBeTruthy();
expect(screen.queryByText('Error')).toBeNull(); expect(screen.queryByText('错误')).toBeNull();
}); });
it('caps labels at eight and defensively sanitizes unknown and null payloads', async () => { 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( const { rerender } = render(
<EsimModule instance={owner} dataSource={{ load: async () => ({ labels }) }} />, <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); expect(within(summary).getAllByRole('listitem')).toHaveLength(8);
rerender( rerender(
<EsimModule instance={owner} dataSource={{ load: async () => null }} refreshSignal={1} />, <EsimModule instance={owner} dataSource={{ load: async () => null }} refreshSignal={1} />,
); );
expect(await screen.findByText('No safe labels were supplied.')).toBeTruthy(); expect(await screen.findByText('未提供安全标签。')).toBeTruthy();
expect(screen.getAllByText('Unavailable')).toHaveLength(4); expect(screen.getAllByText('不可用')).toHaveLength(4);
}); });
it('turns a synchronous source throw into the fixed initial-load error', async () => { 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'); 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'); expect(alert.textContent).not.toContain('private eSIM identifier');
}); });
}); });
+32 -24
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { InstanceContext } from '../app-shell.js'; import type { InstanceContext } from '../app-shell.js';
import { displayValue } from '../ui/locale.js';
/** Bounded status vocabularies are the only strings this module may present. */ /** Bounded status vocabularies are the only strings this module may present. */
export type EsimLpacStatus = 'available' | 'unavailable' | 'degraded' | 'unknown'; export type EsimLpacStatus = 'available' | 'unavailable' | 'degraded' | 'unknown';
@@ -53,7 +54,7 @@ const SAFE_LABELS = new Set<EsimSafeLabel>([
'Pending', 'Pending',
'Error', 'Error',
]); ]);
const SAFE_LOAD_ERROR = 'eSIM data could not be loaded.'; const SAFE_LOAD_ERROR = '无法加载 eSIM 数据。';
const MAX_SAFE_LABELS = 8; const MAX_SAFE_LABELS = 8;
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> { 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 { 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 }) { function SnapshotView({ snapshot }: { snapshot: SafeSnapshot }) {
return ( return (
<section className="esim-card" aria-label="eSIM safe summary"> <section className="esim-card" aria-label="eSIM 安全摘要">
<h2>eSIM safe summary</h2> <h2>eSIM </h2>
<dl> <dl>
<div> <div>
<dt>Profile count</dt> <dt></dt>
<dd>{display(snapshot.profileCount)}</dd> <dd>{display(snapshot.profileCount)}</dd>
</div> </div>
<div> <div>
<dt>Enabled profile count</dt> <dt></dt>
<dd>{display(snapshot.enabledProfileCount)}</dd> <dd>{display(snapshot.enabledProfileCount)}</dd>
</div> </div>
<div> <div>
<dt>lpac status</dt> <dt>LPAC </dt>
<dd>{display(snapshot.lpacStatus)}</dd> <dd>{display(snapshot.lpacStatus)}</dd>
</div> </div>
<div> <div>
<dt>Work mode</dt> <dt></dt>
<dd>{display(snapshot.workMode)}</dd> <dd>{display(snapshot.workMode)}</dd>
</div> </div>
</dl> </dl>
<h3>Safe labels</h3> <h3></h3>
{snapshot.labels.length ? ( {snapshot.labels.length ? (
<ul> <ul>
{snapshot.labels.map((label, index) => ( {snapshot.labels.map((label, index) => (
<li key={`${label}-${index}`}>{label}</li> <li key={`${label}-${index}`}>{displayValue(SAFE_LABEL_DISPLAY_VALUES[label])}</li>
))} ))}
</ul> </ul>
) : ( ) : (
<p>No safe labels were supplied.</p> <p></p>
)} )}
</section> </section>
); );
@@ -187,16 +196,15 @@ export function EsimModule({ instance, dataSource, refreshSignal }: EsimModulePr
if (instance.authentication !== 'authenticated') { if (instance.authentication !== 'authenticated') {
return ( return (
<div className="state-panel state-error" role="alert"> <div className="state-panel state-error" role="alert">
Authentication is required before eSIM data can be read for this instance. eSIM
</div> </div>
); );
} }
if (!dataSource) { if (!dataSource) {
return ( return (
<div className="state-panel" role="status" aria-label="eSIM unavailable"> <div className="state-panel" role="status" aria-label="eSIM 不可用">
No safe eSIM read data source is available. This console will not invent or call an eSIM
uncontracted production endpoint.
</div> </div>
); );
} }
@@ -208,8 +216,8 @@ export function EsimModule({ instance, dataSource, refreshSignal }: EsimModulePr
if ((state.kind === 'idle' || state.kind === 'loading') && !retained) { if ((state.kind === 'idle' || state.kind === 'loading') && !retained) {
return ( return (
<p role="status" aria-label="eSIM loading status"> <p role="status" aria-label="eSIM 加载状态">
Loading eSIM summary eSIM
</p> </p>
); );
} }
@@ -217,9 +225,9 @@ export function EsimModule({ instance, dataSource, refreshSignal }: EsimModulePr
if (state.kind === 'error' && !retained) { if (state.kind === 'error' && !retained) {
return ( return (
<div className="state-panel state-error" role="alert"> <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)}> <button type="button" onClick={() => setRetry((value) => value + 1)}>
Retry loading eSIM data eSIM
</button> </button>
</div> </div>
); );
@@ -231,18 +239,18 @@ export function EsimModule({ instance, dataSource, refreshSignal }: EsimModulePr
return ( return (
<div className="esim-module"> <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' ? ( {state.kind === 'error' ? (
<div className="state-panel state-error" role="alert"> <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)}> <button type="button" onClick={() => setRetry((value) => value + 1)}>
Retry loading eSIM data eSIM
</button> </button>
</div> </div>
) : null} ) : null}
{instance.freshness !== 'fresh' ? ( {instance.freshness !== 'fresh' ? (
<p className="state-panel" role="status" aria-label="eSIM freshness"> <p className="state-panel" role="status" aria-label="eSIM 数据时效性">
eSIM data is {instance.freshness}; verify freshness before relying on these values. eSIM 使
</p> </p>
) : null} ) : null}
<SnapshotView snapshot={snapshot} /> <SnapshotView snapshot={snapshot} />
+24 -26
View File
@@ -33,13 +33,13 @@ describe('Instance CRUD form', () => {
const source = dataSource(); const source = dataSource();
render(<InstanceEditor mode="create" dataSource={source} />); render(<InstanceEditor mode="create" dataSource={source} />);
await user.type(screen.getByLabelText('Name'), 'Lab modem'); await user.type(screen.getByLabelText('名称'), 'Lab modem');
await user.type(screen.getByLabelText('Origin'), 'https://lab.example/admin'); await user.type(screen.getByLabelText('源地址'), 'https://lab.example/admin');
await user.type(screen.getByLabelText('Tags'), 'lab, west, lab'); await user.type(screen.getByLabelText('标签'), 'lab, west, lab');
await user.selectOptions(screen.getByLabelText('Authentication method'), 'password'); await user.selectOptions(screen.getByLabelText('认证方式'), 'password');
await user.type(screen.getByLabelText('Password'), 'do-not-render'); await user.type(screen.getByLabelText('密码'), 'do-not-render');
expect(document.body.textContent).not.toContain('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({ expect(source.create).toHaveBeenCalledWith({
name: 'Lab modem', name: 'Lab modem',
@@ -58,17 +58,17 @@ describe('Instance CRUD form', () => {
expect(await screen.findByDisplayValue('Owner modem')).toBeTruthy(); expect(await screen.findByDisplayValue('Owner modem')).toBeTruthy();
expect(source.get).toHaveBeenCalledWith('owner'); expect(source.get).toHaveBeenCalledWith('owner');
await user.clear(screen.getByLabelText('Name')); await user.clear(screen.getByLabelText('名称'));
await user.type(screen.getByLabelText('Name'), 'Renamed'); await user.type(screen.getByLabelText('名称'), 'Renamed');
await user.click(screen.getByRole('button', { name: 'Save changes' })); await user.click(screen.getByRole('button', { name: '保存更改' }));
expect(source.update).toHaveBeenLastCalledWith( expect(source.update).toHaveBeenLastCalledWith(
'owner', 'owner',
3, 3,
expect.objectContaining({ password: { action: 'preserve' } }), expect.objectContaining({ password: { action: 'preserve' } }),
); );
await user.selectOptions(screen.getByLabelText('Password action'), 'clear'); await user.selectOptions(screen.getByLabelText('密码操作'), 'clear');
await user.click(screen.getByRole('button', { name: 'Save changes' })); await user.click(screen.getByRole('button', { name: '保存更改' }));
expect(source.update).toHaveBeenLastCalledWith( expect(source.update).toHaveBeenLastCalledWith(
'owner', 'owner',
4, 4,
@@ -76,7 +76,7 @@ describe('Instance CRUD form', () => {
); );
rerender(<InstanceEditor mode="edit" instanceId="intruder" dataSource={source} />); 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(); expect(screen.queryByDisplayValue('Owner modem')).toBeNull();
}); });
@@ -84,19 +84,17 @@ describe('Instance CRUD form', () => {
const user = userEvent.setup(); const user = userEvent.setup();
const source = dataSource({ const source = dataSource({
update: vi.fn(async () => { update: vi.fn(async () => {
throw new Error('Could not save this instance.'); throw new Error('无法保存此实例。');
}), }),
}); });
render(<InstanceEditor mode="edit" instanceId="owner" dataSource={source} />); render(<InstanceEditor mode="edit" instanceId="owner" dataSource={source} />);
await screen.findByDisplayValue('Owner modem'); await screen.findByDisplayValue('Owner modem');
await user.click(screen.getByRole('button', { name: 'Test connection' })); await user.click(screen.getByRole('button', { name: '测试连接' }));
expect((await screen.findByRole('status')).textContent).toMatch(/reachable and authenticated/i); expect((await screen.findByRole('status')).textContent).toMatch(/连接可达且已认证/i);
await user.click(screen.getByRole('button', { name: 'Save changes' })); await user.click(screen.getByRole('button', { name: '保存更改' }));
expect((await screen.findByRole('alert')).textContent).toContain( expect((await screen.findByRole('alert')).textContent).toContain('实例操作失败,请稍后重试。');
'Could not save this instance.', await user.type(screen.getByLabelText('名称'), ' still here');
);
await user.type(screen.getByLabelText('Name'), ' still here');
expect(screen.getByRole('alert')).toBeTruthy(); expect(screen.getByRole('alert')).toBeTruthy();
}); });
@@ -105,12 +103,12 @@ describe('Instance CRUD form', () => {
const source = dataSource(); const source = dataSource();
render(<InstanceEditor mode="edit" instanceId="owner" dataSource={source} />); render(<InstanceEditor mode="edit" instanceId="owner" dataSource={source} />);
await screen.findByDisplayValue('Owner modem'); await screen.findByDisplayValue('Owner modem');
await user.click(screen.getByRole('button', { name: 'Delete instance' })); await user.click(screen.getByRole('button', { name: '删除实例' }));
expect( expect((screen.getByRole('button', { name: '确认删除' }) as HTMLButtonElement).disabled).toBe(
(screen.getByRole('button', { name: 'Confirm deletion' }) as HTMLButtonElement).disabled, true,
).toBe(true); );
await user.type(screen.getByLabelText('Type owner to confirm'), 'owner'); await user.type(screen.getByLabelText('输入 owner 以确认'), 'owner');
await user.click(screen.getByRole('button', { name: 'Confirm deletion' })); await user.click(screen.getByRole('button', { name: '确认删除' }));
expect(source.delete).toHaveBeenCalledWith('owner', 3); expect(source.delete).toHaveBeenCalledWith('owner', 3);
}); });
}); });
+39 -40
View File
@@ -16,8 +16,8 @@ export interface InstanceEditorProps {
} }
type PasswordAction = 'preserve' | 'set' | 'clear'; type PasswordAction = 'preserve' | 'set' | 'clear';
function message(error: unknown): string { function message(): string {
return error instanceof Error && error.message ? error.message : 'The instance operation failed.'; return '实例操作失败,请稍后重试。';
} }
export function InstanceEditor({ export function InstanceEditor({
@@ -43,7 +43,7 @@ export function InstanceEditor({
useEffect(() => { useEffect(() => {
if (mode !== 'edit') return; if (mode !== 'edit') return;
if (!instanceId) { if (!instanceId) {
setError('The instance route is missing an owner.'); setError('实例路由缺少所有者。');
return; return;
} }
let active = true; let active = true;
@@ -53,8 +53,7 @@ export function InstanceEditor({
.get(instanceId) .get(instanceId)
.then((loaded) => { .then((loaded) => {
if (!active) return; if (!active) return;
if (loaded.id !== instanceId) if (loaded.id !== instanceId) throw new Error('返回的实例与当前路由不匹配。');
throw new Error('The returned instance does not match this route.');
setOwner(loaded); setOwner(loaded);
setName(loaded.name); setName(loaded.name);
setOrigin(loaded.origin); setOrigin(loaded.origin);
@@ -63,7 +62,7 @@ export function InstanceEditor({
setPasswordAction('preserve'); setPasswordAction('preserve');
setPassword(''); setPassword('');
}) })
.catch((cause: unknown) => active && setError(message(cause))); .catch(() => active && setError(message()));
return () => { return () => {
active = false; active = false;
}; };
@@ -88,14 +87,14 @@ export function InstanceEditor({
? await dataSource.create(input) ? await dataSource.create(input)
: await dataSource.update(owner!.id, owner!.revision, input); : await dataSource.update(owner!.id, owner!.revision, input);
if (mode === 'edit' && saved.id !== instanceId) if (mode === 'edit' && saved.id !== instanceId)
throw new Error('The returned instance does not match this route.'); throw new Error('返回的实例与当前路由不匹配。');
setOwner(saved); setOwner(saved);
setPassword(''); setPassword('');
setPasswordAction('preserve'); setPasswordAction('preserve');
setStatus(mode === 'create' ? 'Instance added.' : 'Changes saved.'); setStatus(mode === 'create' ? '实例已添加。' : '更改已保存。');
setError(undefined); setError(undefined);
} catch (cause) { } catch {
setError(message(cause)); setError(message());
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -109,13 +108,13 @@ export function InstanceEditor({
setStatus( setStatus(
result.reachable result.reachable
? result.authenticated ? result.authenticated
? 'Connection is reachable and authenticated.' ? '连接可达且已认证。'
: 'Connection is reachable; authentication is required.' : '连接可达,但需要认证。'
: 'Connection is not reachable.', : '连接不可达。',
); );
setError(undefined); setError(undefined);
} catch (cause) { } catch {
setError(message(cause)); setError(message());
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -126,11 +125,11 @@ export function InstanceEditor({
setBusy(true); setBusy(true);
try { try {
await dataSource.delete(owner.id, owner.revision); await dataSource.delete(owner.id, owner.revision);
setStatus('Instance deletion accepted.'); setStatus('实例删除请求已接受。');
setError(undefined); setError(undefined);
setConfirming(false); setConfirming(false);
} catch (cause) { } catch {
setError(message(cause)); setError(message());
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -142,12 +141,12 @@ export function InstanceEditor({
{error} {error}
</p> </p>
) : ( ) : (
<p role="status">Loading instance</p> <p role="status"></p>
); );
return ( return (
<section className="instance-editor"> <section className="instance-editor">
<h1>{mode === 'create' ? 'Add instance' : 'Instance settings'}</h1> <h1>{mode === 'create' ? '添加实例' : '实例设置'}</h1>
{error ? ( {error ? (
<p role="alert" className="state-panel state-error"> <p role="alert" className="state-panel state-error">
{error} {error}
@@ -160,11 +159,11 @@ export function InstanceEditor({
) : null} ) : null}
<form onSubmit={submit}> <form onSubmit={submit}>
<label> <label>
Name
<input required value={name} onChange={(event) => setName(event.target.value)} /> <input required value={name} onChange={(event) => setName(event.target.value)} />
</label> </label>
<label> <label>
Origin
<input <input
required required
type="url" type="url"
@@ -173,29 +172,29 @@ export function InstanceEditor({
/> />
</label> </label>
<label> <label>
Tags
<input <input
value={tags} value={tags}
onChange={(event) => setTags(event.target.value)} onChange={(event) => setTags(event.target.value)}
aria-describedby="tags-help" aria-describedby="tags-help"
/> />
</label> </label>
<small id="tags-help">Comma-separated tags</small> <small id="tags-help">使</small>
<label> <label>
Authentication method
<select <select
value={authMethod} value={authMethod}
onChange={(event) => setAuthMethod(event.target.value as 'none' | 'password')} onChange={(event) => setAuthMethod(event.target.value as 'none' | 'password')}
> >
<option value="none">None</option> <option value="none"></option>
<option value="password">Password</option> <option value="password"></option>
</select> </select>
</label> </label>
{authMethod === 'password' ? ( {authMethod === 'password' ? (
<> <>
{mode === 'edit' ? ( {mode === 'edit' ? (
<label> <label>
Password action
<select <select
value={passwordAction} value={passwordAction}
onChange={(event) => { onChange={(event) => {
@@ -203,15 +202,15 @@ export function InstanceEditor({
setPassword(''); setPassword('');
}} }}
> >
<option value="preserve">Keep saved password</option> <option value="preserve"></option>
<option value="set">Set new password</option> <option value="set"></option>
<option value="clear">Clear saved password</option> <option value="clear"></option>
</select> </select>
</label> </label>
) : null} ) : null}
{mode === 'create' || passwordAction === 'set' ? ( {mode === 'create' || passwordAction === 'set' ? (
<label> <label>
Password
<input <input
required required
type="password" type="password"
@@ -225,27 +224,27 @@ export function InstanceEditor({
) : null} ) : null}
<div className="form-actions"> <div className="form-actions">
<button disabled={busy} type="submit"> <button disabled={busy} type="submit">
{mode === 'create' ? 'Add instance' : 'Save changes'} {mode === 'create' ? '添加实例' : '保存更改'}
</button> </button>
{mode === 'edit' ? ( {mode === 'edit' ? (
<button disabled={busy} type="button" onClick={() => void testConnection()}> <button disabled={busy} type="button" onClick={() => void testConnection()}>
Test connection
</button> </button>
) : null} ) : null}
</div> </div>
</form> </form>
{mode === 'edit' ? ( {mode === 'edit' ? (
<section className="danger-zone" aria-labelledby="danger-heading"> <section className="danger-zone" aria-labelledby="danger-heading">
<h2 id="danger-heading">Danger zone</h2> <h2 id="danger-heading"></h2>
{!confirming ? ( {!confirming ? (
<button type="button" onClick={() => setConfirming(true)}> <button type="button" onClick={() => setConfirming(true)}>
Delete instance
</button> </button>
) : ( ) : (
<div> <div>
<p>Deletion cannot be undone.</p> <p></p>
<label> <label>
Type {owner!.id} to confirm {owner!.id}
<input <input
value={confirmation} value={confirmation}
onChange={(event) => setConfirmation(event.target.value)} onChange={(event) => setConfirmation(event.target.value)}
@@ -256,7 +255,7 @@ export function InstanceEditor({
type="button" type="button"
onClick={() => void remove()} onClick={() => void remove()}
> >
Confirm deletion
</button> </button>
<button <button
type="button" type="button"
@@ -265,7 +264,7 @@ export function InstanceEditor({
setConfirmation(''); setConfirmation('');
}} }}
> >
Cancel
</button> </button>
</div> </div>
)} )}
+15 -15
View File
@@ -22,9 +22,9 @@ const owner: InstanceContext = {
const capabilities: InstanceCapabilityMap = { const capabilities: InstanceCapabilityMap = {
overview: { state: 'supported' }, overview: { state: 'supported' },
messages: { state: 'degraded', explanation: 'Message history is read-only.' }, messages: { state: 'degraded', explanation: '消息历史记录为只读。' },
calls: { state: 'unsupported', explanation: 'This modem has no voice support.' }, calls: { state: 'unsupported', explanation: '此调制解调器不支持语音功能。' },
esim: { state: 'unknown', explanation: 'Capability discovery did not report eSIM.' }, esim: { state: 'unknown', explanation: '能力探测结果未包含 eSIM' },
}; };
describe('InstanceDetail', () => { 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', '/instances/owner/overview',
); );
expect(screen.queryByRole('link', { name: 'Messages' })).toBeNull(); expect(screen.queryByRole('link', { name: '消息' })).toBeNull();
expect(screen.getByText('Message history is read-only.')).toBeTruthy(); expect(screen.getByText('消息历史记录为只读。')).toBeTruthy();
expect(screen.getByText('This modem has no voice support.')).toBeTruthy(); expect(screen.getByText('此调制解调器不支持语音功能。')).toBeTruthy();
expect(screen.getByText('Capability discovery did not report eSIM.')).toBeTruthy(); expect(screen.getByText('能力探测结果未包含 eSIM')).toBeTruthy();
expect(screen.getAllByText('Capability state is unknown.').length).toBeGreaterThan(0); expect(screen.getAllByText('能力状态未知。').length).toBeGreaterThan(0);
}); });
it('never renders or loads context when the direct-route owner does not match', () => { 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.queryByText('Owner modem')).toBeNull();
expect(screen.queryByRole('navigation', { name: 'Instance modules' })).toBeNull(); expect(screen.queryByRole('navigation', { name: '实例模块' })).toBeNull();
expect(screen.getByText(/Instance context is unavailable/i)).toBeTruthy(); expect(screen.getByText(/此路由缺少实例上下文/i)).toBeTruthy();
expect(load).not.toHaveBeenCalled(); expect(load).not.toHaveBeenCalled();
}); });
@@ -98,7 +98,7 @@ describe('InstanceDetail', () => {
expect(pending.get('owner')?.signal.aborted).toBe(true); expect(pending.get('owner')?.signal.aborted).toBe(true);
pending.get('second')?.resolve({ overview: { state: 'supported' } }); pending.get('second')?.resolve({ overview: { state: 'supported' } });
expect(await screen.findByRole('link', { name: 'Overview' })).toBeTruthy(); expect(await screen.findByRole('link', { name: '概览' })).toBeTruthy();
pending pending
.get('owner') .get('owner')
?.resolve({ overview: { state: 'unsupported', explanation: 'Stale owner result' } }); ?.resolve({ overview: { state: 'unsupported', explanation: 'Stale owner result' } });
@@ -107,9 +107,9 @@ describe('InstanceDetail', () => {
}); });
it.each<[InstanceModule, string]>([ it.each<[InstanceModule, string]>([
['cellular', 'Cellular'], ['cellular', '蜂窝网络'],
['device-network', 'Device Network'], ['device-network', '设备网络'],
['automation', 'Automation'], ['automation', '自动化'],
['ota', 'OTA'], ['ota', 'OTA'],
])('uses the existing module contract for %s', (module, label) => { ])('uses the existing module contract for %s', (module, label) => {
render( render(
+25 -29
View File
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react';
import type { InstanceContext, InstanceModule } from '../app-shell.js'; import type { InstanceContext, InstanceModule } from '../app-shell.js';
import { canonicalHttpOrigin } from '../fleet/fleet-page.js'; import { canonicalHttpOrigin } from '../fleet/fleet-page.js';
import { displayStatus } from '../ui/locale.js';
export type CapabilityState = 'supported' | 'degraded' | 'unsupported' | 'unknown'; export type CapabilityState = 'supported' | 'degraded' | 'unsupported' | 'unknown';
@@ -27,23 +28,23 @@ export interface InstanceDetailProps {
} }
export const INSTANCE_MODULE_LABELS: Readonly<Record<InstanceModule, string>> = { export const INSTANCE_MODULE_LABELS: Readonly<Record<InstanceModule, string>> = {
overview: 'Overview', overview: '概览',
cellular: 'Cellular', cellular: '蜂窝网络',
'device-network': 'Device Network', 'device-network': '设备网络',
messages: 'Messages', messages: '消息',
calls: 'Calls', calls: '通话',
esim: 'eSIM', esim: 'eSIM',
notifications: 'Notifications', notifications: '通知',
automation: 'Automation', automation: '自动化',
ota: 'OTA', ota: 'OTA',
}; };
const MODULES = Object.keys(INSTANCE_MODULE_LABELS) as readonly InstanceModule[]; const MODULES = Object.keys(INSTANCE_MODULE_LABELS) as readonly InstanceModule[];
const DEFAULT_EXPLANATIONS: Readonly<Record<Exclude<CapabilityState, 'supported'>, string>> = { const DEFAULT_EXPLANATIONS: Readonly<Record<Exclude<CapabilityState, 'supported'>, string>> = {
degraded: 'This module is available with limited functionality.', degraded: '此模块可用,但功能受限。',
unsupported: 'This instance does not support this module.', unsupported: '此实例不支持该模块。',
unknown: 'Capability state is unknown.', unknown: '能力状态未知。',
}; };
function capabilityFor(map: InstanceCapabilityMap, module: InstanceModule): InstanceCapability { function capabilityFor(map: InstanceCapabilityMap, module: InstanceModule): InstanceCapability {
@@ -99,7 +100,7 @@ export function InstanceDetail({
(error: unknown) => { (error: unknown) => {
void error; void error;
if (!controller.signal.aborted && requestRef.current === request) { if (!controller.signal.aborted && requestRef.current === request) {
setLoadError('Capability discovery failed.'); setLoadError('能力探测失败。');
setLoading(false); setLoading(false);
} }
}, },
@@ -111,7 +112,7 @@ export function InstanceDetail({
return ( return (
<section> <section>
<h1>{INSTANCE_MODULE_LABELS[module]}</h1> <h1>{INSTANCE_MODULE_LABELS[module]}</h1>
<p>Instance context is unavailable for this route.</p> <p></p>
</section> </section>
); );
} }
@@ -122,29 +123,29 @@ export function InstanceDetail({
return ( return (
<section className="instance-detail"> <section className="instance-detail">
<aside className="instance-context" aria-label="Current instance"> <aside className="instance-context" aria-label="当前实例">
<strong>{instance.name}</strong> <strong>{instance.name}</strong>
<code>{instance.id}</code> <code>{instance.id}</code>
<dl> <dl>
<div> <div>
<dt>Status</dt> <dt></dt>
<dd>{instance.status}</dd> <dd>{displayStatus(instance.status)}</dd>
</div> </div>
<div> <div>
<dt>Authentication</dt> <dt></dt>
<dd>{instance.authentication}</dd> <dd>{displayStatus(instance.authentication)}</dd>
</div> </div>
<div> <div>
<dt>Freshness</dt> <dt></dt>
<dd>{instance.freshness}</dd> <dd>{displayStatus(instance.freshness)}</dd>
</div> </div>
</dl> </dl>
{origin ? ( {origin ? (
<a href={origin} target="_blank" rel="noopener noreferrer"> <a href={origin} target="_blank" rel="noopener noreferrer">
Open original site
</a> </a>
) : null} ) : null}
<nav aria-label="Instance modules"> <nav aria-label="实例模块">
<ul> <ul>
{MODULES.map((item) => { {MODULES.map((item) => {
const capability = capabilityFor(map, item); const capability = capabilityFor(map, item);
@@ -172,19 +173,14 @@ export function InstanceDetail({
</aside> </aside>
<div className="instance-module-detail"> <div className="instance-module-detail">
<h1>{INSTANCE_MODULE_LABELS[module]}</h1> <h1>{INSTANCE_MODULE_LABELS[module]}</h1>
{loading ? <p role="status">Loading capabilities</p> : null} {loading ? <p role="status"></p> : null}
{loadError ? <p role="alert">Capabilities unavailable: {loadError}</p> : null} {loadError ? <p role="alert">{loadError}</p> : null}
{!loading && canRender(activeCapability) ? ( {!loading && canRender(activeCapability) ? (
<> <>
{activeCapability.state === 'degraded' ? ( {activeCapability.state === 'degraded' ? (
<p data-capability-state="degraded">{explanation(activeCapability)}</p> <p data-capability-state="degraded">{explanation(activeCapability)}</p>
) : null} ) : null}
{moduleContent ?? ( {moduleContent ?? <p>{INSTANCE_MODULE_LABELS[module]}</p>}
<p>
Inspect {INSTANCE_MODULE_LABELS[module].toLowerCase()} data and available
operations.
</p>
)}
</> </>
) : null} ) : null}
{!loading && !canRender(activeCapability) ? ( {!loading && !canRender(activeCapability) ? (
+17 -17
View File
@@ -81,17 +81,19 @@ describe('Messages isolated read module', () => {
const pending = deferredSource(); const pending = deferredSource();
render(<MessagesModule instance={owner} dataSource={pending.source} />); 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)); expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
pending.resolve(snapshot); 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('42')).toBeTruthy();
expect(within(sms).getByText('2026-07-17T11:55:00Z')).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('modem-1')).toBeTruthy();
expect(within(devices).getByText('Primary modem')).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 () => { 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.queryByText(/recipient|phone number|password/i)).toBeNull();
expect(screen.queryByRole('button')).toBeNull(); expect(screen.queryByRole('button')).toBeNull();
expect(screen.getByText(/aggregate metadata only/i)).toBeTruthy(); expect(screen.getByText(/仅显示汇总元数据/i)).toBeTruthy();
expect( expect(screen.getByText(/此只读模块不支持发送、删除或修改消息/)).toBeTruthy();
screen.getByText(/sending, deleting, and changing messages are unavailable/i),
).toBeTruthy();
}); });
it('does not invent an endpoint when no source is injected', () => { it('does not invent an endpoint when no source is injected', () => {
render(<MessagesModule instance={owner} />); render(<MessagesModule instance={owner} />);
expect(screen.getByRole('status', { name: 'Messages unavailable' }).textContent).toMatch( expect(screen.getByRole('status', { name: '消息不可用' }).textContent).toMatch(
/no safe messages read data source.*uncontracted production endpoint/i, /没有可用的安全消息只读数据源.*未签订契约的生产端点/i,
); );
}); });
@@ -135,7 +135,7 @@ describe('Messages isolated read module', () => {
dataSource={{ load }} 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(screen.queryByText('Primary modem')).toBeNull();
expect(load).toHaveBeenCalledTimes(1); expect(load).toHaveBeenCalledTimes(1);
}); });
@@ -149,9 +149,9 @@ describe('Messages isolated read module', () => {
render(<MessagesModule instance={owner} dataSource={{ load }} />); render(<MessagesModule instance={owner} dataSource={{ load }} />);
const alert = await screen.findByRole('alert'); 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'); 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(await screen.findByText('Primary modem')).toBeTruthy();
expect(load).toHaveBeenCalledTimes(2); expect(load).toHaveBeenCalledTimes(2);
}); });
@@ -171,9 +171,9 @@ describe('Messages isolated read module', () => {
rerender(<MessagesModule instance={owner} dataSource={source} refreshSignal={1} />); rerender(<MessagesModule instance={owner} dataSource={source} refreshSignal={1} />);
expect(screen.getByText('Primary modem')).toBeTruthy(); 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(); 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(); expect(await screen.findByText('Replacement modem')).toBeTruthy();
}); });
@@ -207,8 +207,8 @@ describe('Messages isolated read module', () => {
dataSource={{ load: async () => snapshot }} dataSource={{ load: async () => snapshot }}
/>, />,
); );
expect((await screen.findByRole('status', { name: 'Messages freshness' })).textContent).toMatch( expect((await screen.findByRole('status', { name: '消息数据新鲜度' })).textContent).toMatch(
/stale/i, /可能已过期/i,
); );
expect(screen.getByText('Primary modem')).toBeTruthy(); expect(screen.getByText('Primary modem')).toBeTruthy();
}); });
+41 -37
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { InstanceContext } from '../app-shell.js'; import type { InstanceContext } from '../app-shell.js';
import { displayValue } from '../ui/locale.js';
/** Bounded primitives permitted at the Messages presentation boundary. */ /** Bounded primitives permitted at the Messages presentation boundary. */
export type MessageMetadataValue = string | number | boolean | null; export type MessageMetadataValue = string | number | boolean | null;
@@ -47,33 +48,37 @@ type ReadState =
| { kind: 'ready'; ownerId: string; snapshot: MessagesSnapshot } | { kind: 'ready'; ownerId: string; snapshot: MessagesSnapshot }
| { kind: 'error'; 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 = [ const AGGREGATE_FIELDS = [
['Total', 'total'], ['总计', 'total'],
['Inbound', 'inbound'], ['接收', 'inbound'],
['Outbound', 'outbound'], ['发送', 'outbound'],
['Unread', 'unread'], ['未读', 'unread'],
['Failed', 'failed'], ['失败', 'failed'],
['Queued', 'queued'], ['排队中', 'queued'],
['Last activity', 'lastActivityAt'], ['最近活动', 'lastActivityAt'],
] as const; ] as const;
function display(value: MessageMetadataValue | undefined): string { function displayRaw(value: MessageMetadataValue | undefined): string {
return value == null ? 'Unavailable' : String(value); return value == null ? '不可用' : String(value);
} }
function Fields({ function Fields({
values, values,
}: { }: {
values: readonly (readonly [label: string, value: MessageMetadataValue | undefined])[]; values: readonly (readonly [
label: string,
value: MessageMetadataValue | undefined,
localized?: boolean,
])[];
}) { }) {
return ( return (
<dl> <dl>
{values.map(([label, value]) => ( {values.map(([label, value, localized]) => (
<div key={label}> <div key={label}>
<dt>{label}</dt> <dt>{label}</dt>
<dd>{display(value)}</dd> <dd>{localized ? displayValue(value) : displayRaw(value)}</dd>
</div> </div>
))} ))}
</dl> </dl>
@@ -99,34 +104,34 @@ function aggregateFields(
function SnapshotView({ snapshot }: { snapshot: MessagesSnapshot }) { function SnapshotView({ snapshot }: { snapshot: MessagesSnapshot }) {
return ( return (
<> <>
{snapshot.observedAt ? <p>Observed {snapshot.observedAt}</p> : null} {snapshot.observedAt ? <p>{snapshot.observedAt}</p> : null}
<p>Aggregate metadata only; sensitive payload and addressing details are excluded.</p> <p></p>
<div className="messages-grid"> <div className="messages-grid">
<Section label="SMS aggregate"> <Section label="短信汇总">
<Fields values={aggregateFields(snapshot.sms)} /> <Fields values={aggregateFields(snapshot.sms)} />
</Section> </Section>
<Section label="Device message aggregates"> <Section label="设备消息汇总">
{snapshot.devices.length ? ( {snapshot.devices.length ? (
snapshot.devices.map((device, index) => ( snapshot.devices.map((device, index) => (
<article key={`${device.deviceId ?? device.label ?? 'device'}-${index}`}> <article key={`${device.deviceId ?? device.label ?? 'device'}-${index}`}>
<Fields <Fields
values={[ values={[
['Device ID', device.deviceId], ['设备 ID', device.deviceId],
['Label', device.label], ['标签', device.label],
['State', device.state], ['状态', device.state, true],
...aggregateFields(device), ...aggregateFields(device),
]} ]}
/> />
</article> </article>
)) ))
) : ( ) : (
<p>No device message aggregates were supplied.</p> <p></p>
)} )}
</Section> </Section>
</div> </div>
<section className="state-panel" aria-label="Messages actions"> <section className="state-panel" aria-label="消息操作">
<h2>Messages actions</h2> <h2></h2>
<p>Sending, deleting, and changing messages are unavailable in this read-only module.</p> <p></p>
</section> </section>
</> </>
); );
@@ -183,16 +188,15 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
if (instance.authentication !== 'authenticated') { if (instance.authentication !== 'authenticated') {
return ( return (
<div className="state-panel state-error" role="alert"> <div className="state-panel state-error" role="alert">
Authentication is required before Messages data can be read for this instance.
</div> </div>
); );
} }
if (!dataSource) { if (!dataSource) {
return ( return (
<div className="state-panel" role="status" aria-label="Messages unavailable"> <div className="state-panel" role="status" aria-label="消息不可用">
No safe Messages read data source is available. This console will not invent or call an
uncontracted production endpoint.
</div> </div>
); );
} }
@@ -204,8 +208,8 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
if ((state.kind === 'idle' || state.kind === 'loading') && !retainedSnapshot) { if ((state.kind === 'idle' || state.kind === 'loading') && !retainedSnapshot) {
return ( return (
<p role="status" aria-label="Messages loading status"> <p role="status" aria-label="消息加载状态">
Loading Messages
</p> </p>
); );
} }
@@ -213,9 +217,9 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
if (state.kind === 'error' && !retainedSnapshot) { if (state.kind === 'error' && !retainedSnapshot) {
return ( return (
<div className="state-panel state-error" role="alert"> <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)}> <button type="button" onClick={() => setRetry((value) => value + 1)}>
Retry loading Messages
</button> </button>
</div> </div>
); );
@@ -227,18 +231,18 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
return ( return (
<div className="messages-module"> <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' ? ( {state.kind === 'error' ? (
<div className="state-panel state-error" role="alert"> <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)}> <button type="button" onClick={() => setRetry((value) => value + 1)}>
Retry loading Messages
</button> </button>
</div> </div>
) : null} ) : null}
{instance.freshness !== 'fresh' ? ( {instance.freshness !== 'fresh' ? (
<p className="state-panel" role="status" aria-label="Messages freshness"> <p className="state-panel" role="status" aria-label="消息数据新鲜度">
Messages data is {instance.freshness}; verify freshness before relying on these values. 使
</p> </p>
) : null} ) : null}
<SnapshotView snapshot={currentSnapshot} /> <SnapshotView snapshot={currentSnapshot} />
@@ -82,25 +82,21 @@ describe('Notifications isolated safe read module', () => {
const pending = deferredSource(); const pending = deferredSource();
render(<NotificationsModule instance={owner} dataSource={pending.source} />); 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)); expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
pending.resolve(snapshot); pending.resolve(snapshot);
const channels = await screen.findByRole('region', { name: 'Channel aggregate' }); const channels = await screen.findByRole('region', { name: '渠道汇总' });
expect(within(channels).getByText('healthy')).toBeTruthy(); expect(within(channels).getByText('正常', { selector: 'dd' })).toBeTruthy();
expect(within(channels).getByText('5')).toBeTruthy(); expect(within(channels).getByText('5')).toBeTruthy();
expect( expect(within(screen.getByRole('region', { name: '队列汇总' })).getByText('18')).toBeTruthy();
within(screen.getByRole('region', { name: 'Queue aggregate' })).getByText('18'), expect(within(screen.getByRole('region', { name: '日志汇总' })).getByText('30')).toBeTruthy();
).toBeTruthy(); expect(screen.getByText(/观测时间:2026-07-17T12:00:00Z/)).toBeTruthy();
expect(
within(screen.getByRole('region', { name: 'Log aggregate' })).getByText('30'),
).toBeTruthy();
expect(screen.getByText(/Observed 2026-07-17T12:00:00Z/)).toBeTruthy();
}); });
it('never exposes polymorphic config, endpoints, destinations, tokens, phone numbers, payloads, raw logs, or actions', async () => { it('never exposes polymorphic config, endpoints, destinations, tokens, phone numbers, payloads, raw logs, or actions', async () => {
render(<NotificationsModule instance={owner} dataSource={{ load: async () => snapshot }} />); 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 ?? ''; const text = document.body.textContent ?? '';
for (const secret of [ 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 () => { it('fails closed without an injected source or authenticated owner', async () => {
const load = vi.fn<NotificationsDataSource['load']>().mockResolvedValue(snapshot); const load = vi.fn<NotificationsDataSource['load']>().mockResolvedValue(snapshot);
const { rerender } = render(<NotificationsModule instance={owner} />); const { rerender } = render(<NotificationsModule instance={owner} />);
expect(screen.getByRole('status', { name: 'Notifications unavailable' }).textContent).toMatch( expect(screen.getByRole('status', { name: '通知不可用' }).textContent).toMatch(
/no safe notifications read data source.*uncontracted production endpoint/i, /没有可用的安全通知只读数据源.*未签订契约的生产端点/i,
); );
rerender( rerender(
@@ -134,7 +130,7 @@ describe('Notifications isolated safe read module', () => {
dataSource={{ load }} dataSource={{ load }}
/>, />,
); );
expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i); expect(screen.getByRole('alert').textContent).toMatch(/需要先完成认证/i);
expect(load).not.toHaveBeenCalled(); expect(load).not.toHaveBeenCalled();
}); });
@@ -160,8 +156,8 @@ describe('Notifications isolated safe read module', () => {
...snapshot, ...snapshot,
channels: { ...snapshot.channels, status: 'degraded', total: 9 }, channels: { ...snapshot.channels, status: 'degraded', total: 9 },
}); });
expect(await screen.findByText('degraded')).toBeTruthy(); expect(await screen.findByText('功能受限', { selector: 'dd' })).toBeTruthy();
expect(screen.queryByText('healthy')).toBeNull(); expect(screen.queryByText('正常', { selector: 'dd' })).toBeNull();
}); });
it('uses fixed errors and retains only same-owner last-good data across refresh failure and retry', async () => { 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( const { rerender } = render(
<NotificationsModule instance={owner} dataSource={source} refreshSignal={0} />, <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} />); rerender(<NotificationsModule instance={owner} dataSource={source} refreshSignal={1} />);
const alert = await screen.findByRole('alert'); 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(alert.textContent).not.toContain('secret endpoint');
expect(screen.getByText('healthy')).toBeTruthy(); expect(screen.getByText('正常', { selector: 'dd' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: 'Retry loading Notifications' })); await user.click(screen.getByRole('button', { name: '重试加载通知数据' }));
expect( expect(
await within(screen.getByRole('region', { name: 'Channel aggregate' })).findByText( await within(screen.getByRole('region', { name: '渠道汇总' })).findByText('可用'),
'available',
),
).toBeTruthy(); ).toBeTruthy();
}); });
@@ -204,15 +198,13 @@ describe('Notifications isolated safe read module', () => {
logs: { status: { token: 'nested-secret' }, total: Number.MAX_SAFE_INTEGER + 1, error: 3 }, logs: { status: { token: 'nested-secret' }, total: Number.MAX_SAFE_INTEGER + 1, error: 3 },
}; };
render(<NotificationsModule instance={owner} dataSource={{ load: async () => unsafe }} />); 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(logs).getByText('3')).toBeTruthy();
expect( expect(within(screen.getByRole('region', { name: '渠道汇总' })).getByText('2')).toBeTruthy();
within(screen.getByRole('region', { name: 'Channel aggregate' })).getByText('2'),
).toBeTruthy();
expect(document.body.textContent).not.toMatch( expect(document.body.textContent).not.toMatch(
/api-token|channel-secret|private payload|nested-secret|Infinity|1\.5|-1/, /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; unsafe.channels.disabled = 99;
expect(screen.queryByText('99')).toBeNull(); expect(screen.queryByText('99')).toBeNull();
}); });
@@ -225,7 +217,7 @@ describe('Notifications isolated safe read module', () => {
/>, />,
); );
const alert = await screen.findByRole('alert'); 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'); expect(alert.textContent).not.toContain('private token');
}); });
@@ -241,7 +233,7 @@ describe('Notifications isolated safe read module', () => {
/>, />,
); );
const alert = await screen.findByRole('alert'); 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'); 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 { useEffect, useRef, useState, type ReactNode } from 'react';
import type { InstanceContext } from '../app-shell.js'; import type { InstanceContext } from '../app-shell.js';
import { displayValue } from '../ui/locale.js';
/** Values permitted at the Notifications presentation boundary. */ /** Values permitted at the Notifications presentation boundary. */
export type NotificationStatus = export type NotificationStatus =
@@ -73,7 +74,7 @@ type ReadState =
| { kind: 'ready'; snapshot: OwnedSnapshot } | { kind: 'ready'; snapshot: OwnedSnapshot }
| { kind: 'error'; 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>([ const NOTIFICATION_STATUSES = new Set<NotificationStatus>([
'healthy', 'healthy',
'degraded', 'degraded',
@@ -139,30 +140,30 @@ export function sanitizeNotificationsSnapshot(value: unknown): NotificationsSnap
} }
const CHANNEL_FIELDS = [ const CHANNEL_FIELDS = [
['Status', 'status'], ['状态', 'status'],
['Total', 'total'], ['总计', 'total'],
['Enabled', 'enabled'], ['已启用', 'enabled'],
['Disabled', 'disabled'], ['已禁用', 'disabled'],
['Healthy', 'healthy'], ['正常', 'healthy'],
['Degraded', 'degraded'], ['功能受限', 'degraded'],
['Failed', 'failed'], ['失败', 'failed'],
] as const; ] as const;
const QUEUE_FIELDS = [ const QUEUE_FIELDS = [
['Status', 'status'], ['状态', 'status'],
['Total', 'total'], ['总计', 'total'],
['Pending', 'pending'], ['等待中', 'pending'],
['Processing', 'processing'], ['处理中', 'processing'],
['Delivered', 'delivered'], ['已送达', 'delivered'],
['Failed', 'failed'], ['失败', 'failed'],
] as const; ] as const;
const LOG_FIELDS = [ const LOG_FIELDS = [
['Status', 'status'], ['状态', 'status'],
['Total', 'total'], ['总计', 'total'],
['Info', 'info'], ['信息', 'info'],
['Warning', 'warning'], ['警告', 'warning'],
['Error', 'error'], ['错误', 'error'],
] as const; ] as const;
function Section({ label, children }: { label: string; children: ReactNode }) { 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. // Constant-key projection is the security boundary: never enumerate or spread source objects.
const supplied = fields.filter(([, key]) => aggregate[key] !== undefined); 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 ( return (
<dl> <dl>
@@ -192,7 +193,7 @@ function AggregateFields({
return ( return (
<div key={key}> <div key={key}>
<dt>{label}</dt> <dt>{label}</dt>
<dd>{value == null ? 'Unavailable' : String(value)}</dd> <dd>{displayValue(value)}</dd>
</div> </div>
); );
})} })}
@@ -203,16 +204,16 @@ function AggregateFields({
function SnapshotView({ snapshot }: { snapshot: NotificationsSnapshot }) { function SnapshotView({ snapshot }: { snapshot: NotificationsSnapshot }) {
return ( return (
<> <>
{snapshot.observedAt ? <p>Observed {snapshot.observedAt}</p> : null} {snapshot.observedAt ? <p>{snapshot.observedAt}</p> : null}
<p>Aggregate counts and status only; notification details and contents are excluded.</p> <p></p>
<div className="notifications-grid"> <div className="notifications-grid">
<Section label="Channel aggregate"> <Section label="渠道汇总">
<AggregateFields aggregate={snapshot.channels} fields={CHANNEL_FIELDS} /> <AggregateFields aggregate={snapshot.channels} fields={CHANNEL_FIELDS} />
</Section> </Section>
<Section label="Queue aggregate"> <Section label="队列汇总">
<AggregateFields aggregate={snapshot.queue} fields={QUEUE_FIELDS} /> <AggregateFields aggregate={snapshot.queue} fields={QUEUE_FIELDS} />
</Section> </Section>
<Section label="Log aggregate"> <Section label="日志汇总">
<AggregateFields aggregate={snapshot.logs} fields={LOG_FIELDS} /> <AggregateFields aggregate={snapshot.logs} fields={LOG_FIELDS} />
</Section> </Section>
</div> </div>
@@ -282,16 +283,15 @@ export function NotificationsModule({
if (instance.authentication !== 'authenticated') { if (instance.authentication !== 'authenticated') {
return ( return (
<div className="state-panel state-error" role="alert"> <div className="state-panel state-error" role="alert">
Authentication is required before Notifications data can be read for this instance.
</div> </div>
); );
} }
if (!dataSource) { if (!dataSource) {
return ( return (
<div className="state-panel" role="status" aria-label="Notifications unavailable"> <div className="state-panel" role="status" aria-label="通知不可用">
No safe Notifications read data source is available. This console will not invent or call an
uncontracted production endpoint.
</div> </div>
); );
} }
@@ -301,8 +301,8 @@ export function NotificationsModule({
if ((state.kind === 'idle' || state.kind === 'loading') && !snapshot) { if ((state.kind === 'idle' || state.kind === 'loading') && !snapshot) {
return ( return (
<p role="status" aria-label="Notifications loading status"> <p role="status" aria-label="通知加载状态">
Loading Notifications
</p> </p>
); );
} }
@@ -310,9 +310,9 @@ export function NotificationsModule({
if (state.kind === 'error' && !snapshot) { if (state.kind === 'error' && !snapshot) {
return ( return (
<div className="state-panel state-error" role="alert"> <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)}> <button type="button" onClick={() => setRetry((value) => value + 1)}>
Retry loading Notifications
</button> </button>
</div> </div>
); );
@@ -322,19 +322,18 @@ export function NotificationsModule({
return ( return (
<div className="notifications-module"> <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' ? ( {state.kind === 'error' ? (
<div className="state-panel state-error" role="alert"> <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)}> <button type="button" onClick={() => setRetry((value) => value + 1)}>
Retry loading Notifications
</button> </button>
</div> </div>
) : null} ) : null}
{instance.freshness !== 'fresh' ? ( {instance.freshness !== 'fresh' ? (
<p className="state-panel" role="status" aria-label="Notifications freshness"> <p className="state-panel" role="status" aria-label="通知数据时效性">
Notifications data is {instance.freshness}; verify freshness before relying on these 使
values.
</p> </p>
) : null} ) : null}
<SnapshotView snapshot={snapshot.value} /> <SnapshotView snapshot={snapshot.value} />
+17 -17
View File
@@ -42,15 +42,15 @@ describe('isolated safe OTA read module', () => {
const pending = deferredSource(); const pending = deferredSource();
render(<OtaModule instance={owner} dataSource={pending.source} />); 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)); expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
pending.resolve(snapshot); 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('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('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 () => { 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; } as unknown as OtaSnapshot;
render(<OtaModule instance={owner} dataSource={{ load: async () => unsafe }} />); render(<OtaModule instance={owner} dataSource={{ load: async () => unsafe }} />);
const summary = await screen.findByRole('region', { name: 'OTA safe summary' }); const summary = await screen.findByRole('region', { name: 'OTA 安全摘要' });
expect(within(summary).getAllByText('Unavailable')).toHaveLength(2); expect(within(summary).getAllByText('不可用')).toHaveLength(2);
expect(document.body.textContent).not.toMatch( expect(document.body.textContent).not.toMatch(
/private\.example|admin\/ota|api\/proxy|private release notes|private raw OTA log|var\/lib|tmp\/private/i, /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 () => { it('offers no endpoint or apply, cancel, or upload operations', async () => {
render(<OtaModule instance={owner} dataSource={{ load: async () => snapshot }} />); render(<OtaModule instance={owner} dataSource={{ load: async () => snapshot }} />);
expect(await screen.findByText('v2.4.1-build.7')).toBeTruthy(); 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.queryByText(/endpoint/i)).toBeNull();
expect(screen.queryByRole('button', { name: /apply|cancel|upload/i })).toBeNull(); expect(screen.queryByRole('button', { name: /apply|cancel|upload/i })).toBeNull();
expect(screen.queryByRole('textbox')).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', () => { it('fails closed without an injected source or authenticated owner', () => {
const load = vi.fn<OtaDataSource['load']>().mockResolvedValue(snapshot); const load = vi.fn<OtaDataSource['load']>().mockResolvedValue(snapshot);
const { rerender } = render(<OtaModule instance={owner} />); const { rerender } = render(<OtaModule instance={owner} />);
expect(screen.getByRole('status', { name: 'OTA unavailable' }).textContent).toMatch( expect(screen.getByRole('status', { name: 'OTA 不可用' }).textContent).toMatch(
/no safe OTA read data source.*will not invent.*production endpoint/i, /没有可用的安全 OTA 只读数据源.*不会臆造.*生产端点/i,
); );
rerender( rerender(
<OtaModule instance={{ ...owner, authentication: 'auth-required' }} dataSource={{ load }} />, <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(); expect(load).not.toHaveBeenCalled();
}); });
@@ -132,11 +132,11 @@ describe('isolated safe OTA read module', () => {
rerender(<OtaModule instance={owner} dataSource={source} refreshSignal={1} />); rerender(<OtaModule instance={owner} dataSource={source} refreshSignal={1} />);
const alert = await screen.findByRole('alert'); 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(alert.textContent).not.toMatch(/secret URL|var\/private/i);
expect(screen.getByText('v2.4.1-build.7')).toBeTruthy(); 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(); expect(await screen.findByText('v2.4.2')).toBeTruthy();
}); });
@@ -148,7 +148,7 @@ describe('isolated safe OTA read module', () => {
/>, />,
); );
const alert = await screen.findByRole('alert'); 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'); expect(alert.textContent).not.toContain('private release URL');
}); });
@@ -160,13 +160,13 @@ describe('isolated safe OTA read module', () => {
dataSource={{ load: async () => ({ currentVersion: opaque }) }} dataSource={{ load: async () => ({ currentVersion: opaque }) }}
/>, />,
); );
let summary = await screen.findByRole('region', { name: 'OTA safe summary' }); let summary = await screen.findByRole('region', { name: 'OTA 安全摘要' });
expect(within(summary).getAllByText('Unavailable')).toHaveLength(4); expect(within(summary).getAllByText('不可用')).toHaveLength(4);
expect(document.body.textContent).not.toContain(opaque); expect(document.body.textContent).not.toContain(opaque);
rerender( rerender(
<OtaModule instance={owner} dataSource={{ load: async () => null }} refreshSignal={1} />, <OtaModule instance={owner} dataSource={{ load: async () => null }} refreshSignal={1} />,
); );
summary = await screen.findByRole('region', { name: 'OTA safe summary' }); summary = await screen.findByRole('region', { name: 'OTA 安全摘要' });
expect(within(summary).getAllByText('Unavailable')).toHaveLength(4); expect(within(summary).getAllByText('不可用')).toHaveLength(4);
}); });
}); });
+24 -32
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { InstanceContext } from '../app-shell.js'; import type { InstanceContext } from '../app-shell.js';
import { displayValue } from '../ui/locale.js';
export type OtaStatus = export type OtaStatus =
| 'idle' | 'idle'
@@ -52,7 +53,7 @@ type ReadState =
| { kind: 'ready'; ownerId: string; snapshot: SafeOtaSnapshot } | { kind: 'ready'; ownerId: string; snapshot: SafeOtaSnapshot }
| { kind: 'error'; 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>([ const OTA_STATUSES = new Set<OtaStatus>([
'idle', 'idle',
'checking', 'checking',
@@ -107,40 +108,32 @@ function sanitizeSnapshot(value: unknown): SafeOtaSnapshot {
} }
function display(value: string | undefined): string { function display(value: string | undefined): string {
return value ?? 'Unavailable'; return displayValue(value);
} }
function SnapshotView({ snapshot }: { snapshot: SafeOtaSnapshot }) { function SnapshotView({ snapshot }: { snapshot: SafeOtaSnapshot }) {
return ( return (
<section className="ota-card" aria-label="OTA safe summary"> <section className="ota-card" aria-label="OTA 安全摘要">
<h2>OTA safe summary</h2> <h2>OTA </h2>
<p>Read-only OTA metadata: update operations and source-specific details are excluded.</p> <p> OTA </p>
<dl> <dl>
<div> <div>
<dt>Current version</dt> <dt></dt>
<dd>{display(snapshot.currentVersion)}</dd> <dd>{display(snapshot.currentVersion)}</dd>
</div> </div>
<div> <div>
<dt>Status</dt> <dt></dt>
<dd>{display(snapshot.status)}</dd> <dd>{display(snapshot.status)}</dd>
</div> </div>
<div> <div>
<dt>Progress</dt> <dt></dt>
<dd> <dd>
{snapshot.progressPercent === undefined {snapshot.progressPercent === undefined ? '不可用' : `${snapshot.progressPercent}%`}
? 'Unavailable'
: `${snapshot.progressPercent}%`}
</dd> </dd>
</div> </div>
<div> <div>
<dt>Update availability</dt> <dt></dt>
<dd> <dd>{displayValue(snapshot.updateAvailable)}</dd>
{snapshot.updateAvailable === undefined
? 'Unavailable'
: snapshot.updateAvailable
? 'Available'
: 'No update available'}
</dd>
</div> </div>
</dl> </dl>
</section> </section>
@@ -204,16 +197,15 @@ export function OtaModule({ instance, dataSource, refreshSignal }: OtaModuleProp
if (instance.authentication !== 'authenticated') { if (instance.authentication !== 'authenticated') {
return ( return (
<div className="state-panel state-error" role="alert"> <div className="state-panel state-error" role="alert">
Authentication is required before OTA data can be read for this instance. OTA
</div> </div>
); );
} }
if (!dataSource) { if (!dataSource) {
return ( return (
<div className="state-panel" role="status" aria-label="OTA unavailable"> <div className="state-panel" role="status" aria-label="OTA 不可用">
No safe OTA read data source is available. This console will not invent or call an OTA
uncontracted production endpoint.
</div> </div>
); );
} }
@@ -225,8 +217,8 @@ export function OtaModule({ instance, dataSource, refreshSignal }: OtaModuleProp
if ((state.kind === 'idle' || state.kind === 'loading') && !retained) { if ((state.kind === 'idle' || state.kind === 'loading') && !retained) {
return ( return (
<p role="status" aria-label="OTA loading status"> <p role="status" aria-label="OTA 加载状态">
Loading OTA data OTA
</p> </p>
); );
} }
@@ -234,9 +226,9 @@ export function OtaModule({ instance, dataSource, refreshSignal }: OtaModuleProp
if (state.kind === 'error' && !retained) { if (state.kind === 'error' && !retained) {
return ( return (
<div className="state-panel state-error" role="alert"> <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)}> <button type="button" onClick={() => setRetry((value) => value + 1)}>
Retry loading OTA data OTA
</button> </button>
</div> </div>
); );
@@ -248,18 +240,18 @@ export function OtaModule({ instance, dataSource, refreshSignal }: OtaModuleProp
return ( return (
<div className="ota-module"> <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' ? ( {state.kind === 'error' ? (
<div className="state-panel state-error" role="alert"> <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)}> <button type="button" onClick={() => setRetry((value) => value + 1)}>
Retry loading OTA data OTA
</button> </button>
</div> </div>
) : null} ) : null}
{instance.freshness !== 'fresh' ? ( {instance.freshness !== 'fresh' ? (
<p className="state-panel" role="status" aria-label="OTA freshness"> <p className="state-panel" role="status" aria-label="OTA 数据时效性">
OTA data is {instance.freshness}; verify freshness before relying on these values. OTA 使
</p> </p>
) : null} ) : null}
<SnapshotView snapshot={snapshot} /> <SnapshotView snapshot={snapshot} />
+43 -20
View File
@@ -21,7 +21,7 @@ const owner: InstanceContext = {
authentication: 'authenticated', authentication: 'authenticated',
freshness: 'fresh', freshness: 'fresh',
}; };
const overviewCapability: InstanceCapabilityMap = { const overviewCapabilities: InstanceCapabilityMap = {
overview: { state: 'supported' }, overview: { state: 'supported' },
}; };
const snapshot: OverviewSnapshot = { const snapshot: OverviewSnapshot = {
@@ -61,26 +61,51 @@ describe('Phase 6.1 Overview / System read slice', () => {
<AppShell <AppShell
pathname="/instances/alpha/overview" pathname="/instances/alpha/overview"
instance={owner} instance={owner}
capabilities={overviewCapability} capabilities={overviewCapabilities}
overviewDataSource={pending.source} overviewDataSource={pending.source}
/>, />,
); );
expect(screen.getByRole('status', { name: 'Overview loading status' }).textContent).toContain( expect(screen.getByRole('status', { name: '概览加载状态' }).textContent).toContain(
'Loading overview', '正在加载概览',
); );
expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal)); expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
pending.resolve(snapshot); 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(await screen.findByRole('heading', { name: heading })).toBeTruthy();
} }
expect( expect(within(screen.getByRole('region', { name: '设备' })).getByText('SIMBox 8')).toBeTruthy();
within(screen.getByRole('region', { name: 'Device' })).getByText('SIMBox 8'), expect(screen.getByText(/观测时间:2026-07-17T10:00:00Z/)).toBeTruthy();
).toBeTruthy();
expect(screen.getByText(/Observed 2026-07-17T10:00:00Z/)).toBeTruthy();
expect(screen.queryByRole('button', { name: /restart/i })).toBeNull(); 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', () => { 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 <AppShell
pathname="/instances/alpha/overview" pathname="/instances/alpha/overview"
instance={owner} instance={owner}
capabilities={overviewCapability} capabilities={overviewCapabilities}
/>, />,
); );
expect(screen.getByRole('status', { name: 'Overview unavailable' }).textContent).toMatch( expect(screen.getByRole('status', { name: '概览不可用' }).textContent).toMatch(
/no safe overview read data source/i, /没有可用的安全概览只读数据源/i,
); );
expect(screen.queryByRole('button', { name: /restart/i })).toBeNull(); expect(screen.queryByRole('button', { name: /restart/i })).toBeNull();
}); });
@@ -114,7 +139,7 @@ describe('Phase 6.1 Overview / System read slice', () => {
dataSource={{ load }} 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(screen.queryByText('SIMBox 8')).toBeNull();
expect(load).toHaveBeenCalledTimes(1); expect(load).toHaveBeenCalledTimes(1);
}); });
@@ -126,8 +151,8 @@ describe('Phase 6.1 Overview / System read slice', () => {
dataSource={{ load: async () => snapshot }} dataSource={{ load: async () => snapshot }}
/>, />,
); );
expect((await screen.findByRole('status', { name: 'Overview freshness' })).textContent).toMatch( expect((await screen.findByRole('status', { name: '概览数据新鲜度' })).textContent).toMatch(
/stale/i, /可能已过期/i,
); );
expect(screen.getByText('SIMBox 8')).toBeTruthy(); expect(screen.getByText('SIMBox 8')).toBeTruthy();
}); });
@@ -140,10 +165,8 @@ describe('Phase 6.1 Overview / System read slice', () => {
.mockResolvedValueOnce(snapshot); .mockResolvedValueOnce(snapshot);
render(<OverviewSystemPage instance={owner} dataSource={{ load }} />); render(<OverviewSystemPage instance={owner} dataSource={{ load }} />);
expect((await screen.findByRole('alert')).textContent).toContain( expect((await screen.findByRole('alert')).textContent).toContain('无法加载概览数据。');
'Overview data could not be loaded.', await user.click(screen.getByRole('button', { name: '重试加载概览' }));
);
await user.click(screen.getByRole('button', { name: 'Retry loading overview' }));
expect(await screen.findByText('SIMBox 8')).toBeTruthy(); expect(await screen.findByText('SIMBox 8')).toBeTruthy();
expect(load).toHaveBeenCalledTimes(2); expect(load).toHaveBeenCalledTimes(2);
}); });
+63 -28
View File
@@ -34,14 +34,53 @@ type ReadState =
| { kind: 'error'; message: string; snapshot?: OverviewSnapshot }; | { kind: 'error'; message: string; snapshot?: OverviewSnapshot };
const SECTIONS = [ const SECTIONS = [
['device', 'Device'], ['device', '设备'],
['sim', 'SIM'], ['sim', 'SIM'],
['network', 'Network'], ['network', '网络'],
['stats', 'Statistics'], ['stats', '统计'],
['cpu', 'CPU'], ['cpu', 'CPU'],
['connectivity', 'Connectivity'], ['connectivity', '连接状态'],
] as const; ] 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 }) { function StructuredSection({ label, values }: { label: string; values: OverviewSection }) {
const entries = Object.entries(values); const entries = Object.entries(values);
return ( return (
@@ -51,13 +90,13 @@ function StructuredSection({ label, values }: { label: string; values: OverviewS
<dl> <dl>
{entries.map(([key, value]) => ( {entries.map(([key, value]) => (
<div key={key}> <div key={key}>
<dt>{key}</dt> <dt>{displayFieldLabel(key)}</dt>
<dd>{value == null ? 'Unavailable' : String(value)}</dd> <dd>{displayFieldValue(value)}</dd>
</div> </div>
))} ))}
</dl> </dl>
) : ( ) : (
<p>No {label.toLocaleLowerCase()} data was supplied.</p> <p>{label}</p>
)} )}
</section> </section>
); );
@@ -103,7 +142,7 @@ export function OverviewSystemPage({
if (request === requestOwner.current && !controller.signal.aborted) if (request === requestOwner.current && !controller.signal.aborted)
setState((current) => ({ setState((current) => ({
kind: 'error', kind: 'error',
message: 'Overview data could not be loaded.', message: '无法加载概览数据。',
...(current.kind === 'loading' && current.snapshot ...(current.kind === 'loading' && current.snapshot
? { snapshot: current.snapshot } ? { snapshot: current.snapshot }
: {}), : {}),
@@ -116,15 +155,14 @@ export function OverviewSystemPage({
if (instance.authentication === 'auth-required') if (instance.authentication === 'auth-required')
return ( return (
<div className="state-panel state-error" role="alert"> <div className="state-panel state-error" role="alert">
Authentication is required before overview data can be read for this instance.
</div> </div>
); );
if (!dataSource) if (!dataSource)
return ( return (
<div className="state-panel" role="status" aria-label="Overview unavailable"> <div className="state-panel" role="status" aria-label="概览不可用">
No safe overview read data source is available. This console will not invent or call an
uncontracted production endpoint.
</div> </div>
); );
@@ -133,17 +171,17 @@ export function OverviewSystemPage({
if ((state.kind === 'loading' || state.kind === 'idle') && !retainedSnapshot) if ((state.kind === 'loading' || state.kind === 'idle') && !retainedSnapshot)
return ( return (
<p role="status" aria-label="Overview loading status"> <p role="status" aria-label="概览加载状态">
Loading overview
</p> </p>
); );
if (state.kind === 'error' && !state.snapshot) if (state.kind === 'error' && !state.snapshot)
return ( return (
<div className="state-panel state-error" role="alert"> <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)}> <button type="button" onClick={() => setRetry((value) => value + 1)}>
Retry loading overview
</button> </button>
</div> </div>
); );
@@ -153,32 +191,29 @@ export function OverviewSystemPage({
return ( return (
<div className="overview-system"> <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' ? ( {state.kind === 'error' ? (
<div className="state-panel state-error" role="alert"> <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)}> <button type="button" onClick={() => setRetry((value) => value + 1)}>
Retry loading overview
</button> </button>
</div> </div>
) : null} ) : null}
{instance.freshness !== 'fresh' ? ( {instance.freshness !== 'fresh' ? (
<p className="state-panel" role="status" aria-label="Overview freshness"> <p className="state-panel" role="status" aria-label="概览数据新鲜度">
Overview data is {instance.freshness}; verify freshness before relying on these values. 使
</p> </p>
) : null} ) : null}
{snapshot.observedAt ? <p>Observed {snapshot.observedAt}</p> : null} {snapshot.observedAt ? <p>{snapshot.observedAt}</p> : null}
<div className="overview-grid"> <div className="overview-grid">
{SECTIONS.map(([key, label]) => ( {SECTIONS.map(([key, label]) => (
<StructuredSection key={key} label={label} values={snapshot[key]} /> <StructuredSection key={key} label={label} values={snapshot[key]} />
))} ))}
</div> </div>
<section className="state-panel" aria-label="System actions"> <section className="state-panel" aria-label="系统操作">
<h2>System actions</h2> <h2></h2>
<p> <p> R3 </p>
Restart is unavailable because the backend R3 restart operation is not implemented. No
action is offered.
</p>
</section> </section>
</div> </div>
); );
+15 -15
View File
@@ -55,8 +55,8 @@ function deferredSource() {
describe('Phase 7 Jobs workspace', () => { describe('Phase 7 Jobs workspace', () => {
it('is explicitly runtime-unavailable without an injected source and offers no mutation controls', () => { it('is explicitly runtime-unavailable without an injected source and offers no mutation controls', () => {
render(<JobsPage />); render(<JobsPage />);
expect(screen.getByRole('status', { name: 'Jobs unavailable' }).textContent).toMatch( expect(screen.getByRole('status', { name: '任务不可用' }).textContent).toMatch(
/no jobs data source was provided/i, /未提供任务数据源/,
); );
expect(screen.queryByRole('button', { name: /cancel|retry/i })).toBeNull(); expect(screen.queryByRole('button', { name: /cancel|retry/i })).toBeNull();
expect(screen.queryByRole('table')).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 () => { it('loads through the injected source and renders sanitized, read-only job data', async () => {
const pending = deferredSource(); const pending = deferredSource();
render(<JobsPage dataSource={pending.source} />); render(<JobsPage dataSource={pending.source} />);
expect(screen.getByRole('status', { name: 'Jobs loading status' })).toBeTruthy(); expect(screen.getByRole('status', { name: '任务加载状态' })).toBeTruthy();
expect(pending.load).toHaveBeenCalledWith( expect(pending.load).toHaveBeenCalledWith(
{ page: 1, pageSize: 25, sort: 'createdAt', direction: 'desc' }, { page: 1, pageSize: 25, sort: 'createdAt', direction: 'desc' },
expect.any(AbortSignal), expect.any(AbortSignal),
@@ -75,20 +75,20 @@ describe('Phase 7 Jobs workspace', () => {
page: { page: 1, pageSize: 25, total: 1 }, page: { page: 1, pageSize: 25, total: 1 },
confirmationToken: 'never-show', confirmationToken: 'never-show',
}); });
const table = await screen.findByRole('table', { name: 'Jobs' }); const table = await screen.findByRole('table', { name: '任务' });
const columnHeaders = within(table).getAllByRole('columnheader'); const columnHeaders = within(table).getAllByRole('columnheader');
expect(columnHeaders).toHaveLength(7); expect(columnHeaders).toHaveLength(7);
for (const header of columnHeaders) expect(header.getAttribute('scope')).toBe('col'); for (const header of columnHeaders) expect(header.getAttribute('scope')).toBe('col');
expect( expect(
within(table).getByRole('columnheader', { name: 'Created' }).getAttribute('aria-sort'), within(table).getByRole('columnheader', { name: '创建时间' }).getAttribute('aria-sort'),
).toBe('descending'); ).toBe('descending');
expect( expect(
within(table).getByRole('columnheader', { name: 'Operation' }).getAttribute('aria-sort'), within(table).getByRole('columnheader', { name: '操作' }).getAttribute('aria-sort'),
).toBeNull(); ).toBeNull();
for (const text of [ for (const text of [
'job-1', 'job-1',
'message.send', 'message.send',
'Failed', '失败',
'job-root', 'job-root',
'alpha', 'alpha',
'Delivery failed', 'Delivery failed',
@@ -148,14 +148,14 @@ describe('Phase 7 Jobs workspace', () => {
.fn<JobsDataSource['load']>() .fn<JobsDataSource['load']>()
.mockResolvedValue({ items: [], page: { page: 1, pageSize: 25, total: 80 } }); .mockResolvedValue({ items: [], page: { page: 1, pageSize: 25, total: 80 } });
render(<JobsPage dataSource={{ load }} />); render(<JobsPage dataSource={{ load }} />);
await screen.findByText('No jobs match the current query.'); await screen.findByText('没有任务符合当前查询。');
await user.selectOptions(screen.getByRole('combobox', { name: 'Status' }), 'failed'); await user.selectOptions(screen.getByRole('combobox', { name: '状态' }), 'failed');
await user.type(screen.getByRole('textbox', { name: 'Operation' }), 'call.start'); await user.type(screen.getByRole('textbox', { name: '操作' }), 'call.start');
await user.type(screen.getByRole('textbox', { name: 'Root job' }), 'root-1'); await user.type(screen.getByRole('textbox', { name: '根任务' }), 'root-1');
await user.type(screen.getByRole('textbox', { name: 'Instance' }), 'alpha'); await user.type(screen.getByRole('textbox', { name: '实例' }), 'alpha');
await user.click(screen.getByRole('button', { name: /sort by operation/i })); await user.click(screen.getByRole('button', { name: /按操作排序/ }));
await user.click(screen.getByRole('button', { name: 'Next page' })); await user.click(screen.getByRole('button', { name: '下一页' }));
await user.click(screen.getByRole('button', { name: 'Refresh jobs' })); await user.click(screen.getByRole('button', { name: '刷新任务' }));
expect(load.mock.calls.at(-1)?.[0] as JobPageQuery).toEqual({ expect(load.mock.calls.at(-1)?.[0] as JobPageQuery).toEqual({
status: 'failed', status: 'failed',
operationId: 'call.start', operationId: 'call.start',
+53 -46
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { displayStatus } from '../ui/locale.js';
import { import {
ATTEMPT_STATUSES, ATTEMPT_STATUSES,
@@ -182,10 +183,15 @@ export function sanitizeJobPage(value: unknown): SafeJobPage | null {
} }
function statusLabel(value: string): string { function statusLabel(value: string): string {
return value const labels: Readonly<Record<string, string>> = {
.split('-') queued: '排队中',
.map((part) => part[0]?.toUpperCase() + part.slice(1)) running: '运行中',
.join(' '); cancelling: '取消中',
cancelled: '已取消',
succeeded: '成功',
failed: '失败',
};
return labels[value] ?? displayStatus(value);
} }
function safeLoadError(value: unknown): SafeProblem | null { function safeLoadError(value: unknown): SafeProblem | null {
return parseProblem(value) ?? null; return parseProblem(value) ?? null;
@@ -250,9 +256,9 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
if (!dataSource) { if (!dataSource) {
return ( return (
<section aria-labelledby="jobs-title"> <section aria-labelledby="jobs-title">
<h1 id="jobs-title">Jobs</h1> <h1 id="jobs-title"></h1>
<p role="status" aria-label="Jobs unavailable"> <p role="status" aria-label="任务不可用">
Jobs are runtime-unavailable because no jobs data source was provided.
</p> </p>
</section> </section>
); );
@@ -276,20 +282,20 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
return ( return (
<section aria-labelledby="jobs-title"> <section aria-labelledby="jobs-title">
<header> <header>
<h1 id="jobs-title">Jobs</h1> <h1 id="jobs-title"></h1>
<p>Read-only operation history.</p> <p></p>
</header> </header>
<div className="jobs-toolbar"> <div className="jobs-toolbar">
<label> <label>
Status
<select <select
aria-label="Status" aria-label="状态"
value={status} value={status}
onChange={(event) => onChange={(event) =>
changeFilter(() => setStatus(event.currentTarget.value as JobStatus | '')) changeFilter(() => setStatus(event.currentTarget.value as JobStatus | ''))
} }
> >
<option value="">All statuses</option> <option value=""></option>
{JOB_STATUSES.map((value) => ( {JOB_STATUSES.map((value) => (
<option key={value} value={value}> <option key={value} value={value}>
{statusLabel(value)} {statusLabel(value)}
@@ -298,57 +304,58 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
</select> </select>
</label> </label>
<label> <label>
Operation
<input <input
aria-label="Operation" aria-label="操作"
value={operation} value={operation}
onChange={(event) => changeFilter(() => setOperation(event.currentTarget.value))} onChange={(event) => changeFilter(() => setOperation(event.currentTarget.value))}
/> />
</label> </label>
<label> <label>
Root job
<input <input
aria-label="Root job" aria-label="根任务"
value={rootJob} value={rootJob}
onChange={(event) => changeFilter(() => setRootJob(event.currentTarget.value))} onChange={(event) => changeFilter(() => setRootJob(event.currentTarget.value))}
/> />
</label> </label>
<label> <label>
Instance
<input <input
aria-label="Instance" aria-label="实例"
value={instance} value={instance}
onChange={(event) => changeFilter(() => setInstance(event.currentTarget.value))} onChange={(event) => changeFilter(() => setInstance(event.currentTarget.value))}
/> />
</label> </label>
<button <button
type="button" type="button"
aria-label="Refresh jobs" aria-label="刷新任务"
onClick={() => setManualRefresh((value) => value + 1)} onClick={() => setManualRefresh((value) => value + 1)}
> >
Refresh
</button> </button>
</div> </div>
{loading ? ( {loading ? (
<p role="status" aria-label="Jobs loading status"> <p role="status" aria-label="任务加载状态">
Loading jobs
</p> </p>
) : null} ) : null}
{failed ? ( {failed ? (
<div role="alert"> <div role="alert">
<p> <p>
Jobs could not be loaded.
{failed !== true ? ` ${failed.code}: ${failed.title} (${failed.status})` : ''} {failed !== true ? ` ${failed.code}: ${failed.title} (${failed.status})` : ''}
</p> </p>
<button type="button" onClick={() => setManualRefresh((value) => value + 1)}>
</button>
</div> </div>
) : null} ) : null}
{!loading && !failed && result?.items.length === 0 ? ( {!loading && !failed && result?.items.length === 0 ? <p></p> : null}
<p>No jobs match the current query.</p>
) : null}
{!loading && !failed && result ? ( {!loading && !failed && result ? (
<> <>
<div className="table-scroll" role="region" aria-label="Jobs table" tabIndex={0}> <div className="table-scroll" role="region" aria-label="任务表格" tabIndex={0}>
<table className="dense-table" aria-label="Jobs"> <table className="dense-table" aria-label="任务">
<thead> <thead>
<tr> <tr>
<th <th
@@ -363,13 +370,13 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
> >
<button <button
type="button" type="button"
aria-label="Sort by created time" aria-label="按创建时间排序"
onClick={() => changeSort('createdAt')} onClick={() => changeSort('createdAt')}
> >
Created
</button> </button>
</th> </th>
<th scope="col">Job</th> <th scope="col"></th>
<th <th
scope="col" scope="col"
aria-sort={ aria-sort={
@@ -382,10 +389,10 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
> >
<button <button
type="button" type="button"
aria-label="Sort by operation" aria-label="按操作排序"
onClick={() => changeSort('operationId')} onClick={() => changeSort('operationId')}
> >
Operation
</button> </button>
</th> </th>
<th <th
@@ -400,15 +407,15 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
> >
<button <button
type="button" type="button"
aria-label="Sort by status" aria-label="按状态排序"
onClick={() => changeSort('status')} onClick={() => changeSort('status')}
> >
Status
</button> </button>
</th> </th>
<th scope="col">Root job</th> <th scope="col"></th>
<th scope="col">Items</th> <th scope="col"></th>
<th scope="col">Attempts</th> <th scope="col"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -425,8 +432,8 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
{item.status === 'queued' || {item.status === 'queued' ||
item.status === 'running' || item.status === 'running' ||
item.status === 'cancelling' item.status === 'cancelling'
? 'No terminal item results while this job is active.' ? '任务活动期间暂无最终项目结果。'
: 'No terminal item results.'} : '暂无最终项目结果。'}
</span> </span>
) : null} ) : null}
{item.items.map((entry) => ( {item.items.map((entry) => (
@@ -454,25 +461,25 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
</tbody> </tbody>
</table> </table>
</div> </div>
<nav aria-label="Jobs pagination"> <nav aria-label="任务分页">
<button <button
type="button" type="button"
aria-label="Previous page" aria-label="上一页"
disabled={page <= 1} disabled={page <= 1}
onClick={() => setPage((value) => value - 1)} onClick={() => setPage((value) => value - 1)}
> >
Previous
</button> </button>
<span> <span>
Page {page} of {pageCount} {page} / {pageCount}
</span> </span>
<button <button
type="button" type="button"
aria-label="Next page" aria-label="下一页"
disabled={page >= pageCount} disabled={page >= pageCount}
onClick={() => setPage((value) => value + 1)} onClick={() => setPage((value) => value + 1)}
> >
Next
</button> </button>
</nav> </nav>
</> </>
@@ -49,7 +49,7 @@ const snapshot: FleetSnapshot = {
authenticated: true, authenticated: true,
latencyMs: 12, latencyMs: 12,
summary: { summary: {
capabilities: ['messages', 'calls', 7], capabilities: ['messages', 'calls', 'vendor-capability', 7],
freshness: 'fresh', freshness: 'fresh',
password: 'never-render', password: 'never-render',
credentialConfigured: true, credentialConfigured: true,
@@ -79,11 +79,11 @@ describe('Settings instances page', () => {
it('is explicitly unavailable without an injected fleet source', () => { it('is explicitly unavailable without an injected fleet source', () => {
render(<InstanceSettingsPage />); render(<InstanceSettingsPage />);
expect(screen.getByRole('heading', { name: 'Instances' })).toBeTruthy(); expect(screen.getByRole('heading', { name: '实例' })).toBeTruthy();
expect(screen.getByRole('status', { name: 'Instances unavailable' }).textContent).toMatch( expect(screen.getByRole('status', { name: '实例不可用' }).textContent).toMatch(
/no fleet data source/i, /未提供实例总览数据源/i,
); );
expect(screen.getByRole('link', { name: 'Add instance' }).getAttribute('href')).toBe( expect(screen.getByRole('link', { name: '添加实例' }).getAttribute('href')).toBe(
'/instances/new', '/instances/new',
); );
}); });
@@ -91,24 +91,25 @@ describe('Settings instances page', () => {
it('loads and renders an accessible, safe settings list using only fleet fields', async () => { it('loads and renders an accessible, safe settings list using only fleet fields', async () => {
const pending = deferredSource(); const pending = deferredSource();
render(<InstanceSettingsPage dataSource={pending.source} />); 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)); expect(pending.load).toHaveBeenCalledWith(expect.any(AbortSignal));
pending.resolve(snapshot); 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' }); const west = within(list).getByRole('listitem', { name: 'West modem' });
expect(within(west).getByRole('link', { name: 'West modem' }).getAttribute('href')).toBe( expect(within(west).getByRole('link', { name: 'West modem' }).getAttribute('href')).toBe(
'/settings/instances/west%2Fone', '/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('href')).toBe('https://west.example:8443');
expect(origin.getAttribute('rel')).toBe('noopener noreferrer'); 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).getByText(value)).toBeTruthy();
expect(within(west).queryByText('messages, calls')).toBeNull();
const offline = within(list).getByRole('listitem', { name: 'offline' }); const offline = within(list).getByRole('listitem', { name: 'offline' });
expect(within(offline).getByText('Invalid origin')).toBeTruthy(); expect(within(offline).getByText('源地址无效')).toBeTruthy();
expect(within(offline).getByText('Offline')).toBeTruthy(); expect(within(offline).getByText('离线')).toBeTruthy();
expect(within(offline).queryByText(/authentication|freshness|capabilities/i)).toBeNull(); expect(within(offline).queryByText(/authentication|freshness|capabilities/i)).toBeNull();
expect(document.body.textContent).not.toMatch( expect(document.body.textContent).not.toMatch(
@@ -124,10 +125,10 @@ describe('Settings instances page', () => {
.mockResolvedValueOnce({ instances: [], statuses: new Map() }); .mockResolvedValueOnce({ instances: [], statuses: new Map() });
render(<InstanceSettingsPage dataSource={{ load }} />); render(<InstanceSettingsPage dataSource={{ load }} />);
const alert = await screen.findByRole('alert'); 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'); expect(document.body.textContent).not.toContain('top-secret');
await user.click(screen.getByRole('button', { name: 'Retry loading instances' })); await user.click(screen.getByRole('button', { name: '重试加载实例' }));
expect(await screen.findByText('No instances are configured.')).toBeTruthy(); expect(await screen.findByText('尚未配置实例。')).toBeTruthy();
expect(load).toHaveBeenCalledTimes(2); expect(load).toHaveBeenCalledTimes(2);
}); });
@@ -114,19 +114,39 @@ export function sanitizeFleetSnapshot(value: unknown): SafeSnapshot | null {
function statusLabel(status: SafeStatus | undefined): string | null { function statusLabel(status: SafeStatus | undefined): string | null {
if (!status) return null; if (!status) return null;
if (!status.reachable) return 'Offline'; if (!status.reachable) return '离线';
if (status.authenticated === false) return 'Authentication required'; if (status.authenticated === false) return '需要认证';
return 'Online'; return '在线';
} }
function authLabel(status: SafeStatus | undefined): string | null { function authLabel(status: SafeStatus | undefined): string | null {
if (status?.authenticated === true) return 'Authenticated'; if (status?.authenticated === true) return '已认证';
if (status?.authenticated === false) return 'Authentication required'; if (status?.authenticated === false) return '需要认证';
return null; return null;
} }
function titleCase(value: string): string { function freshnessLabel(value: string): string {
return value[0]!.toUpperCase() + value.slice(1); 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({ export function InstanceSettingsPage({
@@ -179,37 +199,35 @@ export function InstanceSettingsPage({
<section aria-labelledby="settings-instances-title"> <section aria-labelledby="settings-instances-title">
<header> <header>
<div> <div>
<h1 id="settings-instances-title">Instances</h1> <h1 id="settings-instances-title"></h1>
<p>Configure the SimAdmin instances available to this workspace.</p> <p> SimAdmin </p>
</div> </div>
<a href="/instances/new">Add instance</a> <a href="/instances/new"></a>
</header> </header>
{unavailable ? ( {unavailable ? (
<p role="status" aria-label="Instances unavailable"> <p role="status" aria-label="实例不可用">
Instances are runtime-unavailable because no fleet data source was provided.
</p> </p>
) : null} ) : null}
{loading ? ( {loading ? (
<p role="status" aria-label="Instances loading status"> <p role="status" aria-label="实例加载状态">
Loading instances
</p> </p>
) : null} ) : null}
{failed ? ( {failed ? (
<div role="alert"> <div role="alert">
<p>Instances could not be loaded.</p> <p></p>
{dataSource ? ( {dataSource ? (
<button type="button" onClick={() => setAttempt((value) => value + 1)}> <button type="button" onClick={() => setAttempt((value) => value + 1)}>
Retry loading instances
</button> </button>
) : null} ) : null}
</div> </div>
) : null} ) : null}
{!loading && !failed && snapshot?.instances.length === 0 ? ( {!loading && !failed && snapshot?.instances.length === 0 ? <p></p> : null}
<p>No instances are configured.</p>
) : null}
{!loading && !failed && snapshot && snapshot.instances.length > 0 ? ( {!loading && !failed && snapshot && snapshot.instances.length > 0 ? (
<ul aria-label="Configured instances"> <ul aria-label="已配置实例">
{snapshot.instances.map((instance) => { {snapshot.instances.map((instance) => {
const displayName = instance.name ?? instance.id; const displayName = instance.name ?? instance.id;
const origin = canonicalHttpOrigin(instance.url); const origin = canonicalHttpOrigin(instance.url);
@@ -226,44 +244,44 @@ export function InstanceSettingsPage({
{instance.name ? <p>{instance.id}</p> : null} {instance.name ? <p>{instance.id}</p> : null}
<dl> <dl>
<div> <div>
<dt>Origin</dt> <dt></dt>
<dd> <dd>
{origin ? ( {origin ? (
<a <a
href={origin} href={origin}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label={`Open ${displayName} origin`} aria-label={`打开 ${displayName} 的源站`}
> >
{origin} {origin}
</a> </a>
) : ( ) : (
'Invalid origin' '源地址无效'
)} )}
</dd> </dd>
</div> </div>
{state ? ( {state ? (
<div> <div>
<dt>Status</dt> <dt></dt>
<dd>{state}</dd> <dd>{state}</dd>
</div> </div>
) : null} ) : null}
{authentication ? ( {authentication ? (
<div> <div>
<dt>Authentication</dt> <dt></dt>
<dd>{authentication}</dd> <dd>{authentication}</dd>
</div> </div>
) : null} ) : null}
{status?.freshness ? ( {status?.freshness ? (
<div> <div>
<dt>Freshness</dt> <dt></dt>
<dd>{titleCase(status.freshness)}</dd> <dd>{freshnessLabel(status.freshness)}</dd>
</div> </div>
) : null} ) : null}
{status?.capabilities?.length ? ( {status?.capabilities?.length ? (
<div> <div>
<dt>Capabilities</dt> <dt></dt>
<dd>{status.capabilities.join(', ')}</dd> <dd>{status.capabilities.map(capabilityLabel).join(', ')}</dd>
</div> </div>
) : null} ) : null}
</dl> </dl>
+439 -122
View File
@@ -1,40 +1,52 @@
:root { :root {
font-family: Inter, ui-sans-serif, system-ui, sans-serif; font-family: 'PingFang SC', 'Microsoft YaHei', ui-rounded, system-ui, sans-serif;
color: #e8edf5; color: #725d42;
background: #0b1018; background: #f8f8f0;
--surface: #121a26; --mint: #19c8b9;
--surface-raised: #192434; --mint-dark: #10a99d;
--border: #2b394d; --brown: #794f27;
--muted: #a9b6c8; --text: #725d42;
--accent: #75a7ff; --muted: #9f927d;
--danger: #ff8b91; --canvas: #f8f8f0;
--radius: 0.5rem; --surface: rgb(247, 243, 223);
--surface-raised: #fffdf5;
--border: #ded3bd;
--danger: #c95e55;
--warning: #f5c31c;
--radius: 16px;
--space: clamp(0.75rem, 2vw, 1.5rem); --space: clamp(0.75rem, 2vw, 1.5rem);
font-synthesis: none;
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
html {
background: var(--canvas);
}
body { body {
margin: 0; margin: 0;
min-width: 20rem; 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 { a {
color: var(--accent); color: #187f77;
text-underline-offset: 3px;
} }
button, button,
input, input,
select { select {
font: inherit; font: inherit;
}
button,
input,
select {
color: inherit; color: inherit;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 0.35rem;
padding: 0.5rem 0.65rem;
} }
button, button,
input, input,
@@ -42,44 +54,156 @@ select,
a { a {
outline-offset: 3px; 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 { .skip-link {
position: fixed; position: fixed;
top: -5rem; top: -6rem;
left: 1rem; left: 1rem;
z-index: 10; z-index: 100;
background: white; color: #fff;
color: black; background: var(--brown);
padding: 0.75rem; padding: 0.75rem 1rem;
border-radius: 12px;
} }
.skip-link:focus { .skip-link:focus {
top: 1rem; top: 1rem;
} }
.app-topbar { .app-topbar {
min-height: 3.5rem; min-height: 4.5rem;
padding: 0 var(--space); padding: 0.75rem var(--space);
border-bottom: 1px solid var(--border);
display: flex; display: flex;
align-items: center; 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 { .product-name {
margin-right: auto; margin-right: auto;
font-weight: 750; color: var(--brown);
color: inherit;
text-decoration: none; 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); 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 { .app-layout {
display: grid; display: grid;
grid-template-columns: 11rem minmax(0, 1fr); grid-template-columns: 13rem minmax(0, 1fr);
min-height: calc(100vh - 3.5rem); gap: var(--space);
} min-height: calc(100vh - 4.5rem);
.global-navigation,
.instance-context {
padding: var(--space); 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, .global-navigation ul,
.instance-context ul { .instance-context ul {
@@ -92,64 +216,117 @@ a {
.global-navigation a, .global-navigation a,
.instance-context nav a { .instance-context nav a {
display: block; display: block;
padding: 0.55rem; padding: 0.7rem 0.8rem;
border-radius: 0.35rem; border-radius: 12px;
color: var(--text);
text-decoration: none; text-decoration: none;
font-weight: 700;
} }
a[aria-current='page'] { a[aria-current='page'] {
background: var(--surface-raised); color: var(--brown);
color: white; background: #fff;
} box-shadow: 0 3px 0 #d4c9b4;
.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;
} }
main { main {
min-width: 0; 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, main > section > header,
.fleet-toolbar { .fleet-heading {
display: flex; display: flex;
align-items: end; align-items: end;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
padding-bottom: 1rem;
border-bottom: 1px dashed var(--border);
} }
.fleet-toolbar { .fleet-heading p,
justify-content: flex-start; main > section > header p {
padding: 1rem 0; margin: 0.25rem 0 0;
color: var(--muted);
} }
.fleet-toolbar label { .fleet-actions,
display: grid; .form-actions,
gap: 0.3rem; .pagination {
min-width: min(100%, 14rem); display: flex;
} align-items: center;
.fleet-toolbar label:first-child { gap: 0.75rem;
flex: 1; flex-wrap: wrap;
} }
.selection-summary, .selection-summary,
small { small {
color: var(--muted); 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 { .table-scroll {
max-width: 100%;
overflow-x: auto; overflow-x: auto;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
background: #fffdf5;
box-shadow: 0 5px 16px rgba(121, 79, 39, 0.07);
} }
table { table {
width: 100%; width: 100%;
@@ -157,19 +334,56 @@ table {
white-space: nowrap; white-space: nowrap;
} }
caption { caption {
padding: 0.8rem;
text-align: left; text-align: left;
padding: 0.75rem; font-weight: 800;
font-weight: 700; color: var(--brown);
} }
th, th,
td { td {
padding: 0.6rem 0.75rem; padding: 0.65rem 0.75rem;
border-top: 1px solid var(--border); border-top: 1px solid #e9dfca;
text-align: left; 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, tbody tr:hover,
tr[aria-selected='true'] { tr[aria-selected='true'] {
background: var(--surface); background: #f2f8e9;
} }
tbody th small { tbody th small {
display: block; display: block;
@@ -180,25 +394,84 @@ tbody th small {
} }
.status { .status {
display: inline-block; display: inline-block;
padding: 0.2rem 0.45rem; padding: 0.25rem 0.55rem;
border-radius: 999px; border-radius: 999px;
background: var(--surface-raised); background: #eee5d2;
font-size: 0.78rem;
font-weight: 800;
} }
.status-online { .status-online {
color: #75e6a3; color: #087b70;
background: #d8f5ec;
} }
.status-offline, .status-offline,
.status-auth { .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; padding: 1rem;
border: 1px dashed var(--border); border-radius: 18px;
border-radius: var(--radius); background: var(--surface);
} }
.state-error { .instance-context code {
color: var(--danger); display: block;
border-color: currentColor; 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 { .instance-editor {
max-width: 48rem; max-width: 48rem;
@@ -211,80 +484,124 @@ tbody th small {
} }
.instance-editor form { .instance-editor form {
gap: 1rem; gap: 1rem;
} padding: 1rem;
.form-actions { border-radius: var(--radius);
display: flex; background: var(--surface);
gap: 0.75rem;
flex-wrap: wrap;
} }
.danger-zone { .danger-zone {
margin-top: 2rem; margin-top: 2rem;
padding: 1rem; padding: 1rem;
border: 1px solid var(--danger); border: 1px solid #db938d;
border-radius: var(--radius); border-radius: var(--radius);
background: #fff4ed;
} }
.danger-zone div { .danger-zone div {
display: grid; display: grid;
gap: 0.75rem; 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) { @media (max-width: 48rem) {
.app-topbar { .app-topbar {
position: static;
flex-wrap: wrap; flex-wrap: wrap;
padding-block: 0.6rem; }
.product-name {
width: 100%;
} }
.app-layout { .app-layout {
display: block; display: block;
padding: 0.65rem;
} }
.global-navigation { .global-navigation {
border-right: 0; position: static;
border-bottom: 1px solid var(--border); padding: 0.55rem;
margin-bottom: 0.65rem;
overflow-x: auto; overflow-x: auto;
} }
.global-navigation::before {
display: none;
}
.global-navigation ul { .global-navigation ul {
display: flex; display: flex;
min-width: max-content;
}
main {
padding: 0.8rem;
border-radius: 16px;
}
.instance-detail {
display: block;
} }
.instance-context { .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%; width: 100%;
} }
.table-scroll { .table-scroll {
overflow: visible; overflow-x: auto;
border: 0; border: 1px solid var(--border);
box-shadow: none;
background: var(--surface-raised);
} }
.dense-table, .dense-table,
.dense-table tbody, .dense-table tbody {
.dense-table tr, min-width: 48rem;
}
.dense-table th, .dense-table th,
.dense-table td { .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%; width: 100%;
}
button {
max-width: 100%;
white-space: normal; 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) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,
*::after { *::after {
scroll-behavior: auto !important; 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;
}