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