feat(web): redesign fleet cards and instance workspace
This commit is contained in:
@@ -87,10 +87,8 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
'/instances/alpha/overview',
|
||||
);
|
||||
expect(
|
||||
within(screen.getByRole('row', { name: /Bravo/ }))
|
||||
.getByRole('link', { name: '打开 Bravo 的源站' })
|
||||
.getAttribute('href'),
|
||||
).toBe('http://bravo.example:8080');
|
||||
within(screen.getByRole('row', { name: /Bravo/ })).getByText('http://bravo.example:8080'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('版本 0.1.0')).toBeTruthy();
|
||||
expect(
|
||||
consoleError.mock.calls.some((call) =>
|
||||
@@ -159,7 +157,7 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
expect(within(card).getByText('13800138000')).toBeTruthy();
|
||||
expect(await within(card).findByText('收到')).toBeTruthy();
|
||||
expect(within(card).getByText('13900139000')).toBeTruthy();
|
||||
expect(within(card).getByText(/这是一条用于聚合页展示/)).toBeTruthy();
|
||||
expect(within(card).queryByText(/这是一条用于聚合页展示/)).toBeNull();
|
||||
expect(within(card).getByText(/2026/)).toBeTruthy();
|
||||
expect(within(card).queryByText('能力未知')).toBeNull();
|
||||
expect(
|
||||
@@ -248,6 +246,37 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the route owner without waiting for optional Fleet resource enrichment', async () => {
|
||||
const instanceDataSource: InstanceDataSource = {
|
||||
get: vi.fn().mockResolvedValue({
|
||||
id: 'bravo',
|
||||
name: 'Bravo',
|
||||
origin: 'http://bravo.example:8080',
|
||||
tags: [],
|
||||
revision: 1,
|
||||
credentialConfigured: false,
|
||||
}),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
testConnection: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
const fleetDataSource = source(() => new Promise<FleetSnapshot>(() => undefined));
|
||||
|
||||
render(
|
||||
<AppShell
|
||||
pathname="/instances/bravo/overview"
|
||||
instanceDataSource={instanceDataSource}
|
||||
fleetDataSource={fleetDataSource}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Bravo' })).toBeTruthy();
|
||||
expect(screen.getByRole('status', { name: '资源摘要状态' }).textContent).toContain(
|
||||
'正在加载资源摘要',
|
||||
);
|
||||
});
|
||||
|
||||
it('supports accessible search, status filtering, sorting, and visible selection', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppShell pathname="/fleet" fleetDataSource={source(async () => snapshot)} />);
|
||||
@@ -355,9 +384,8 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
);
|
||||
expect(screen.getByRole('heading', { name: '短信管理' })).toBeTruthy();
|
||||
expect(screen.getByText('Owner modem')).toBeTruthy();
|
||||
expect(screen.getByRole('link', { name: '打开源站' }).getAttribute('href')).toBe(
|
||||
'https://owner.example',
|
||||
);
|
||||
expect(screen.getByText('源站已由控制平面托管')).toBeTruthy();
|
||||
expect(screen.queryByRole('link', { name: '打开源站' })).toBeNull();
|
||||
|
||||
rerender(<AppShell pathname="/instances/someone-else/messages" instance={instance} />);
|
||||
expect(screen.queryByText('Owner modem')).toBeNull();
|
||||
|
||||
@@ -78,6 +78,13 @@ export interface InstanceContext {
|
||||
status: 'online' | 'offline' | 'auth-required' | 'degraded' | 'unknown';
|
||||
authentication: 'authenticated' | 'auth-required' | 'unknown';
|
||||
freshness: 'fresh' | 'stale' | 'expired' | 'unknown';
|
||||
resources?: Readonly<{
|
||||
cpuPercent?: number;
|
||||
memoryPercent?: number;
|
||||
maxTemperatureCelsius?: number;
|
||||
phoneNumbers?: readonly string[];
|
||||
}>;
|
||||
resourceSummaryState?: 'loading' | 'ready' | 'unavailable';
|
||||
}
|
||||
export interface AppShellProps {
|
||||
pathname: string;
|
||||
@@ -440,8 +447,13 @@ export function AppShell({
|
||||
setInstanceLoading(false);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
let active = true;
|
||||
setInstanceLoading(true);
|
||||
const fleetLoad = (fleetDataSource ?? defaultFleetDataSource).load(controller.signal).then(
|
||||
(fleet) => ({ ok: true as const, fleet }),
|
||||
() => ({ ok: false as const }),
|
||||
);
|
||||
void resolvedInstanceDataSource.get(routeInstanceId).then(
|
||||
(owner) => {
|
||||
if (!active) return;
|
||||
@@ -457,8 +469,50 @@ export function AppShell({
|
||||
status: 'unknown',
|
||||
authentication: 'unknown',
|
||||
freshness: 'unknown',
|
||||
resourceSummaryState: 'loading',
|
||||
});
|
||||
setInstanceLoading(false);
|
||||
|
||||
void fleetLoad.then((result) => {
|
||||
if (!active) return;
|
||||
if (!result.ok) {
|
||||
setLoadedInstance((current) =>
|
||||
current?.id === routeInstanceId
|
||||
? { ...current, resourceSummaryState: 'unavailable' }
|
||||
: current,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const fleet = result.fleet;
|
||||
const fleetOwner = fleet.instances.find((candidate) => candidate.id === routeInstanceId);
|
||||
const status = fleet.statuses.get(routeInstanceId);
|
||||
const routeStatus = !status
|
||||
? 'unknown'
|
||||
: !status.reachable
|
||||
? 'offline'
|
||||
: status.authenticated === false
|
||||
? 'auth-required'
|
||||
: 'online';
|
||||
const freshness = status?.summary?.freshness;
|
||||
setLoadedInstance({
|
||||
id: owner.id,
|
||||
name: owner.name,
|
||||
origin: fleetOwner?.url ?? owner.origin,
|
||||
status: routeStatus,
|
||||
authentication:
|
||||
status?.authenticated === true
|
||||
? 'authenticated'
|
||||
: status?.authenticated === false
|
||||
? 'auth-required'
|
||||
: 'unknown',
|
||||
freshness:
|
||||
freshness === 'fresh' || freshness === 'stale' || freshness === 'expired'
|
||||
? freshness
|
||||
: 'unknown',
|
||||
...(status?.summary?.resources ? { resources: status.summary.resources } : {}),
|
||||
resourceSummaryState: status ? 'ready' : 'unavailable',
|
||||
});
|
||||
});
|
||||
},
|
||||
() => {
|
||||
if (!active) return;
|
||||
@@ -468,8 +522,15 @@ export function AppShell({
|
||||
);
|
||||
return () => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [instance, resolvedInstanceDataSource, routeInstanceId]);
|
||||
}, [
|
||||
defaultFleetDataSource,
|
||||
fleetDataSource,
|
||||
instance,
|
||||
resolvedInstanceDataSource,
|
||||
routeInstanceId,
|
||||
]);
|
||||
|
||||
const routeInstance = instance?.id === routeInstanceId ? instance : loadedInstance;
|
||||
const refresh = useControlPlaneEvents(
|
||||
|
||||
@@ -18,11 +18,9 @@ const snapshot: FleetSnapshot = {
|
||||
'alpha',
|
||||
{
|
||||
instanceId: 'alpha',
|
||||
connectivity: 'online',
|
||||
authentication: 'authenticated',
|
||||
freshness: 'fresh',
|
||||
capabilities: ['overview', 'messages'],
|
||||
anomalies: [],
|
||||
reachable: true,
|
||||
authenticated: true,
|
||||
summary: { freshness: 'fresh', resources: { cpuPercent: 24, memoryPercent: 51 } },
|
||||
},
|
||||
],
|
||||
]),
|
||||
@@ -41,5 +39,16 @@ describe('FleetPage card navigation', () => {
|
||||
expect(within(card).getByRole('link', { name: '编辑 Alpha modem' })).toBeTruthy();
|
||||
expect(within(card).getByRole('button', { name: '删除 Alpha modem' })).toBeTruthy();
|
||||
expect(within(card).queryByRole('link', { name: /查看.*短信/ })).toBeNull();
|
||||
expect(screen.getByRole('region', { name: '实例状态摘要' }).textContent).toMatch(
|
||||
/实例总数\s*1.*在线\s*1.*需处理\s*0/s,
|
||||
);
|
||||
expect(within(card).getByRole('meter', { name: 'CPU 使用率' }).getAttribute('value')).toBe(
|
||||
'24',
|
||||
);
|
||||
expect(within(card).getByRole('meter', { name: '内存使用率' }).getAttribute('value')).toBe(
|
||||
'51',
|
||||
);
|
||||
expect(within(card).getByText('进入仪表盘')).toBeTruthy();
|
||||
expect(within(card).getByRole('group', { name: '实例管理操作' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,8 +82,7 @@ const messageDirection = (value: string): string =>
|
||||
: value === 'outgoing' || value === 'sent'
|
||||
? '发送'
|
||||
: '未知方向';
|
||||
const messageExcerpt = (value: string): string =>
|
||||
value.length > 36 ? `${value.slice(0, 36)}…` : value;
|
||||
|
||||
const messageTime = (value: string): string => {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN');
|
||||
@@ -198,9 +197,22 @@ export function FleetPage({
|
||||
[auth, capability, filter, page, query, selectedIds, snapshot, sort, tag, version],
|
||||
);
|
||||
|
||||
const fleetSummary = useMemo(() => {
|
||||
const statuses = [...(snapshot?.statuses.values() ?? [])];
|
||||
const online = statuses.filter(
|
||||
(status) => status.reachable && status.authenticated !== false,
|
||||
).length;
|
||||
return {
|
||||
total: snapshot?.instances.length ?? 0,
|
||||
online,
|
||||
attention: (snapshot?.instances.length ?? 0) - online,
|
||||
};
|
||||
}, [snapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectAllRef.current)
|
||||
if (selectAllRef.current) {
|
||||
selectAllRef.current.indeterminate = model.visibleSelection.indeterminate;
|
||||
}
|
||||
}, [model.visibleSelection.indeterminate]);
|
||||
useEffect(() => {
|
||||
if (page !== model.page) setPage(model.page);
|
||||
@@ -332,6 +344,22 @@ export function FleetPage({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{snapshot ? (
|
||||
<section className="fleet-status-summary" aria-label="实例状态摘要">
|
||||
<div>
|
||||
<span>实例总数</span>
|
||||
<strong>{fleetSummary.total}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>在线</span>
|
||||
<strong>{fleetSummary.online}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>需处理</span>
|
||||
<strong>{fleetSummary.attention}</strong>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{batchOpen ? (
|
||||
<div className="batch-entry" role="region" aria-label="批量操作入口">
|
||||
请为已选择的 {model.selectedIds.length} 项选择操作。
|
||||
@@ -465,6 +493,7 @@ export function FleetPage({
|
||||
>
|
||||
<h2>{row.displayName}</h2>
|
||||
<code>{row.id}</code>
|
||||
<span className="fleet-card-entry-label">进入仪表盘</span>
|
||||
</a>
|
||||
<span className={`status status-${row.statusKind}`}>
|
||||
{STATUS_LABELS[row.statusKind]}
|
||||
@@ -479,11 +508,39 @@ export function FleetPage({
|
||||
</div>
|
||||
<div>
|
||||
<dt>CPU</dt>
|
||||
<dd>{percent(row.status?.summary?.resources?.cpuPercent)}</dd>
|
||||
<dd>
|
||||
{row.status?.summary?.resources?.cpuPercent === undefined ? (
|
||||
'暂未获取'
|
||||
) : (
|
||||
<>
|
||||
<meter
|
||||
min="0"
|
||||
max="100"
|
||||
value={row.status.summary.resources.cpuPercent}
|
||||
aria-label="CPU 使用率"
|
||||
/>
|
||||
{percent(row.status.summary.resources.cpuPercent)}
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>内存</dt>
|
||||
<dd>{percent(row.status?.summary?.resources?.memoryPercent)}</dd>
|
||||
<dd>
|
||||
{row.status?.summary?.resources?.memoryPercent === undefined ? (
|
||||
'暂未获取'
|
||||
) : (
|
||||
<>
|
||||
<meter
|
||||
min="0"
|
||||
max="100"
|
||||
value={row.status.summary.resources.memoryPercent}
|
||||
aria-label="内存使用率"
|
||||
/>
|
||||
{percent(row.status.summary.resources.memoryPercent)}
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>最高温度</dt>
|
||||
@@ -511,10 +568,6 @@ export function FleetPage({
|
||||
<dt>号码</dt>
|
||||
<dd>{messageStates.get(row.id)!.latest!.phoneNumber}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>内容</dt>
|
||||
<dd>{messageExcerpt(messageStates.get(row.id)!.latest!.content)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>时间</dt>
|
||||
<dd>{messageTime(messageStates.get(row.id)!.latest!.timestamp)}</dd>
|
||||
@@ -524,7 +577,11 @@ export function FleetPage({
|
||||
<p className="fleet-card-sms-empty">暂无短信</p>
|
||||
)}
|
||||
</section>
|
||||
<div className="fleet-card-actions fleet-card-admin-actions">
|
||||
<div
|
||||
className="fleet-card-actions fleet-card-admin-actions"
|
||||
role="group"
|
||||
aria-label="实例管理操作"
|
||||
>
|
||||
<a
|
||||
href={`/settings/instances/${encodeURIComponent(row.id)}`}
|
||||
aria-label={`编辑 ${row.displayName}`}
|
||||
@@ -665,22 +722,7 @@ export function FleetPage({
|
||||
tags: <td>{row.tags.join(', ') || '—'}</td>,
|
||||
freshness: <td>{FRESHNESS_LABELS[row.freshness] ?? row.freshness}</td>,
|
||||
anomalies: <td>{row.anomalies.join(', ') || '—'}</td>,
|
||||
origin: (
|
||||
<td>
|
||||
{origin ? (
|
||||
<a
|
||||
href={origin}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`打开 ${row.displayName} 的源站`}
|
||||
>
|
||||
{origin}
|
||||
</a>
|
||||
) : (
|
||||
<span>源地址无效</span>
|
||||
)}
|
||||
</td>
|
||||
),
|
||||
origin: <td>{origin ? <span>{origin}</span> : <span>源地址无效</span>}</td>,
|
||||
};
|
||||
return (
|
||||
<tr key={row.id} aria-selected={row.selected}>
|
||||
|
||||
@@ -46,14 +46,20 @@ describe('InstanceDetail', () => {
|
||||
const links = navigation.getAllByRole('link');
|
||||
expect(links).toHaveLength(2);
|
||||
expect(links.map((link) => [link.textContent, link.getAttribute('href')])).toEqual([
|
||||
['实例仪表盘', '/instances/owner/overview'],
|
||||
['短信管理', '/instances/owner/messages'],
|
||||
['仪表盘', '/instances/owner/overview'],
|
||||
['短信', '/instances/owner/messages'],
|
||||
]);
|
||||
expect(navigation.queryByText(/蜂窝网络|设备网络|通话|eSIM|通知|自动化|OTA/)).toBeNull();
|
||||
expect(screen.getByRole('heading', { name: '实例仪表盘' })).toBeTruthy();
|
||||
expect(screen.queryByText('能力状态未知。')).toBeNull();
|
||||
expect(screen.queryByText('此调制解调器不支持语音功能。')).toBeNull();
|
||||
expect(screen.queryByText('能力探测结果未包含 eSIM。')).toBeNull();
|
||||
expect(screen.getByRole('link', { name: '返回实例总览' }).getAttribute('href')).toBe('/fleet');
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Owner modem' })).toBeTruthy();
|
||||
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
|
||||
expect(screen.getByText('已认证')).toBeTruthy();
|
||||
expect(screen.getByText('数据最新')).toBeTruthy();
|
||||
expect(links.map((link) => link.textContent)).toEqual(['仪表盘', '短信']);
|
||||
});
|
||||
|
||||
it('uses the business titles for both primary pages', () => {
|
||||
@@ -106,7 +112,7 @@ describe('InstanceDetail', () => {
|
||||
<InstanceDetail instanceId="owner" module="overview" instance={owner} capabilities={{}} />,
|
||||
);
|
||||
expect(screen.getByText('状态')).toBeTruthy();
|
||||
expect(screen.getByText('在线')).toBeTruthy();
|
||||
expect(screen.getAllByText('在线')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('never renders or loads context when the direct-route owner does not match', () => {
|
||||
@@ -159,7 +165,7 @@ describe('InstanceDetail', () => {
|
||||
expect(pending.get('owner')?.signal.aborted).toBe(true);
|
||||
|
||||
pending.get('second')?.resolve({ overview: { state: 'supported' } });
|
||||
expect(await instanceNavigation().findByRole('link', { name: '实例仪表盘' })).toBeTruthy();
|
||||
expect(await instanceNavigation().findByRole('link', { name: '仪表盘' })).toBeTruthy();
|
||||
pending
|
||||
.get('owner')
|
||||
?.resolve({ overview: { state: 'unsupported', explanation: 'Stale owner result' } });
|
||||
|
||||
@@ -40,6 +40,16 @@ export const INSTANCE_MODULE_LABELS: Readonly<Record<InstanceModule, string>> =
|
||||
};
|
||||
|
||||
const PRIMARY_MODULES = ['overview', 'messages'] as const satisfies readonly InstanceModule[];
|
||||
const PRIMARY_LABELS: Readonly<Record<(typeof PRIMARY_MODULES)[number], string>> = {
|
||||
overview: '仪表盘',
|
||||
messages: '短信',
|
||||
};
|
||||
const AUTH_LABELS = { authenticated: '已认证', 'auth-required': '需要认证' } as const;
|
||||
const FRESHNESS_LABELS = {
|
||||
fresh: '数据最新',
|
||||
stale: '数据可能过期',
|
||||
expired: '数据已过期',
|
||||
} as const;
|
||||
|
||||
const DEFAULT_EXPLANATIONS: Readonly<Record<Exclude<CapabilityState, 'supported'>, string>> = {
|
||||
degraded: '此模块可用,但功能受限。',
|
||||
@@ -126,23 +136,37 @@ export function InstanceDetail({
|
||||
|
||||
return (
|
||||
<section className="instance-detail">
|
||||
<aside className="instance-context" aria-label="当前实例">
|
||||
<strong>{instance.name}</strong>
|
||||
<code>{instance.id}</code>
|
||||
<header className="instance-context" aria-label="当前实例">
|
||||
<a className="instance-breadcrumb" href="/fleet">
|
||||
返回实例总览
|
||||
</a>
|
||||
<div className="instance-identity">
|
||||
<div>
|
||||
<h1>{instance.name}</h1>
|
||||
<code>{instance.id}</code>
|
||||
</div>
|
||||
<div className="instance-context-badges">
|
||||
{instance.status !== 'unknown' ? <span>{displayStatus(instance.status)}</span> : null}
|
||||
{instance.authentication !== 'unknown' ? (
|
||||
<span>{AUTH_LABELS[instance.authentication]}</span>
|
||||
) : null}
|
||||
{instance.freshness !== 'unknown' ? (
|
||||
<span>{FRESHNESS_LABELS[instance.freshness]}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{instance.status !== 'unknown' ? (
|
||||
<dl>
|
||||
<dl className="visually-hidden">
|
||||
<div>
|
||||
<dt>状态</dt>
|
||||
<dd>{displayStatus(instance.status)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : null}
|
||||
{origin ? (
|
||||
<a href={origin} target="_blank" rel="noopener noreferrer">
|
||||
打开源站
|
||||
</a>
|
||||
) : null}
|
||||
<a href={`/settings/instances/${encodeURIComponent(instanceId)}`}>编辑实例</a>
|
||||
<div className="instance-header-actions">
|
||||
{origin ? <span title={origin}>源站已由控制平面托管</span> : null}
|
||||
<a href={`/settings/instances/${encodeURIComponent(instanceId)}`}>编辑实例</a>
|
||||
</div>
|
||||
<nav aria-label="实例模块">
|
||||
<ul>
|
||||
{PRIMARY_MODULES.map((item) => {
|
||||
@@ -152,16 +176,16 @@ export function InstanceDetail({
|
||||
href={`/instances/${encodeURIComponent(instanceId)}/${item}`}
|
||||
aria-current={module === item ? 'page' : undefined}
|
||||
>
|
||||
{INSTANCE_MODULE_LABELS[item]}
|
||||
{PRIMARY_LABELS[item]}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
</header>
|
||||
<div className="instance-module-detail">
|
||||
<h1>{INSTANCE_MODULE_LABELS[module]}</h1>
|
||||
<h2>{INSTANCE_MODULE_LABELS[module]}</h2>
|
||||
{loading ? <p role="status">正在加载能力…</p> : null}
|
||||
{loadError ? <p role="alert">能力不可用:{loadError}</p> : null}
|
||||
{!loading && canOpen(module, activeCapability) ? (
|
||||
|
||||
@@ -20,6 +20,12 @@ const owner: InstanceContext = {
|
||||
status: 'online',
|
||||
authentication: 'authenticated',
|
||||
freshness: 'fresh',
|
||||
resources: {
|
||||
cpuPercent: 18.4,
|
||||
memoryPercent: 63.2,
|
||||
maxTemperatureCelsius: 46.7,
|
||||
phoneNumbers: ['13800138000'],
|
||||
},
|
||||
};
|
||||
const overviewCapabilities: InstanceCapabilityMap = {
|
||||
overview: { state: 'supported' },
|
||||
@@ -108,7 +114,7 @@ describe('Phase 6.1 Overview / System read slice', () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('is honest when no safe read source is injected and never attempts a production endpoint', () => {
|
||||
it('renders the route-owned Fleet resource summary without inventing another endpoint', () => {
|
||||
render(
|
||||
<AppShell
|
||||
pathname="/instances/alpha/overview"
|
||||
@@ -116,9 +122,17 @@ describe('Phase 6.1 Overview / System read slice', () => {
|
||||
capabilities={overviewCapabilities}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('status', { name: '概览不可用' }).textContent).toMatch(
|
||||
/没有可用的安全概览只读数据源/i,
|
||||
const operations = screen.getByRole('region', { name: '运行状态与资源' });
|
||||
expect(within(operations).getByText('在线')).toBeTruthy();
|
||||
expect(within(operations).getByRole('meter', { name: 'CPU使用率' }).getAttribute('value')).toBe(
|
||||
'18.4',
|
||||
);
|
||||
expect(
|
||||
within(operations).getByRole('meter', { name: '内存使用率' }).getAttribute('value'),
|
||||
).toBe('63.2');
|
||||
expect(within(operations).getByText('46.7 °C')).toBeTruthy();
|
||||
expect(within(operations).getByText('13800138000')).toBeTruthy();
|
||||
expect(screen.queryByText(/没有可用的安全概览只读数据源/i)).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /restart/i })).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -102,6 +102,66 @@ function StructuredSection({ label, values }: { label: string; values: OverviewS
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceSummary({ instance }: { instance: InstanceContext }) {
|
||||
const resources = instance.resources;
|
||||
const metric = (label: string, value: number | undefined) => (
|
||||
<article className="overview-resource-card">
|
||||
<span>{label}</span>
|
||||
{value === undefined ? (
|
||||
<strong>暂未获取</strong>
|
||||
) : (
|
||||
<>
|
||||
<strong>{value.toFixed(1)}%</strong>
|
||||
<meter min="0" max="100" value={value} aria-label={`${label}使用率`} />
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
return (
|
||||
<section className="overview-operations" aria-label="运行状态与资源">
|
||||
<h2>运行状态</h2>
|
||||
{instance.resourceSummaryState === 'loading' ? (
|
||||
<p role="status" aria-label="资源摘要状态">
|
||||
正在加载资源摘要…
|
||||
</p>
|
||||
) : null}
|
||||
{instance.resourceSummaryState === 'unavailable' ? (
|
||||
<p role="status" aria-label="资源摘要状态">
|
||||
资源摘要暂不可用,实例基本信息仍可使用。
|
||||
</p>
|
||||
) : null}
|
||||
<div className="overview-resource-grid">
|
||||
<article className="overview-resource-card">
|
||||
<span>连接状态</span>
|
||||
<strong>
|
||||
{instance.status === 'online'
|
||||
? '在线'
|
||||
: instance.status === 'offline'
|
||||
? '离线'
|
||||
: instance.status === 'auth-required'
|
||||
? '需要认证'
|
||||
: '未知'}
|
||||
</strong>
|
||||
</article>
|
||||
{metric('CPU', resources?.cpuPercent)}
|
||||
{metric('内存', resources?.memoryPercent)}
|
||||
<article className="overview-resource-card">
|
||||
<span>最高温度</span>
|
||||
<strong>
|
||||
{resources?.maxTemperatureCelsius === undefined
|
||||
? '暂未获取'
|
||||
: `${resources.maxTemperatureCelsius.toFixed(1)} °C`}
|
||||
</strong>
|
||||
</article>
|
||||
<article className="overview-resource-card overview-resource-phone">
|
||||
<span>手机号</span>
|
||||
<strong>{resources?.phoneNumbers?.join('、') || '暂未获取'}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewSystemPage({
|
||||
instance,
|
||||
dataSource,
|
||||
@@ -159,12 +219,7 @@ export function OverviewSystemPage({
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!dataSource)
|
||||
return (
|
||||
<div className="state-panel" role="status" aria-label="概览不可用">
|
||||
没有可用的安全概览只读数据源。此控制台不会臆造或调用未签订契约的生产端点。
|
||||
</div>
|
||||
);
|
||||
if (!dataSource) return <ResourceSummary instance={instance} />;
|
||||
|
||||
const retainedSnapshot =
|
||||
state.kind === 'loading' || state.kind === 'error' ? state.snapshot : undefined;
|
||||
@@ -191,6 +246,7 @@ export function OverviewSystemPage({
|
||||
|
||||
return (
|
||||
<div className="overview-system">
|
||||
<ResourceSummary instance={instance} />
|
||||
{state.kind === 'loading' ? <p role="status">正在刷新概览…</p> : null}
|
||||
{state.kind === 'error' ? (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
@@ -211,10 +267,10 @@ export function OverviewSystemPage({
|
||||
<StructuredSection key={key} label={label} values={snapshot[key]} />
|
||||
))}
|
||||
</div>
|
||||
<section className="state-panel" aria-label="系统操作">
|
||||
<h2>系统操作</h2>
|
||||
<details className="system-operation-note">
|
||||
<summary>系统操作说明</summary>
|
||||
<p>重启不可用,因为后端尚未实现 R3 重启操作,因此不提供任何操作。</p>
|
||||
</section>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+149
-5
@@ -283,6 +283,33 @@ small {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.fleet-status-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
margin: 1rem 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--border);
|
||||
}
|
||||
.fleet-status-summary div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.fleet-card-entry-label {
|
||||
display: block;
|
||||
margin-top: 0.55rem;
|
||||
font-weight: 800;
|
||||
color: #187f77;
|
||||
}
|
||||
.fleet-card meter {
|
||||
width: min(7rem, 45%);
|
||||
margin-right: 0.5rem;
|
||||
accent-color: var(--mint-dark);
|
||||
}
|
||||
.fleet-card {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
@@ -385,6 +412,8 @@ small {
|
||||
border-color: #db938d;
|
||||
}
|
||||
.card-delete-confirmation {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
margin-top: 1rem;
|
||||
@@ -628,14 +657,76 @@ tbody th small {
|
||||
}
|
||||
.instance-detail {
|
||||
display: grid;
|
||||
grid-template-columns: 13rem minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
.instance-context {
|
||||
min-width: 0;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.8rem 1.25rem;
|
||||
padding: 1.15rem 1.25rem 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
background: var(--surface);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: 0 8px 24px rgba(121, 79, 39, 0.08);
|
||||
}
|
||||
.instance-breadcrumb {
|
||||
grid-column: 1 / -1;
|
||||
width: fit-content;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
}
|
||||
.instance-identity {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.instance-identity h1 {
|
||||
margin: 0;
|
||||
}
|
||||
.instance-context-badges,
|
||||
.instance-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.instance-context-badges span {
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
color: #187f77;
|
||||
background: #d8f5ec;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.instance-header-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.instance-header-actions span {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.instance-context nav {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 0.2rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.instance-context nav ul {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.instance-context nav a {
|
||||
border-radius: 0;
|
||||
border-bottom: 3px solid transparent;
|
||||
}
|
||||
.instance-context nav a[aria-current='page'] {
|
||||
border-bottom-color: var(--mint-dark);
|
||||
box-shadow: none;
|
||||
}
|
||||
.instance-context code {
|
||||
display: block;
|
||||
@@ -836,6 +927,47 @@ dd {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.overview-operations {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
.overview-operations h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.overview-resource-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.overview-resource-card {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.overview-resource-card span {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.overview-resource-card strong {
|
||||
color: var(--brown);
|
||||
font-size: 1.1rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.overview-resource-card meter {
|
||||
width: 100%;
|
||||
accent-color: var(--mint-dark);
|
||||
}
|
||||
.system-operation-note {
|
||||
color: var(--muted);
|
||||
}
|
||||
.overview-card,
|
||||
.cellular-card,
|
||||
.device-network-card,
|
||||
@@ -917,8 +1049,17 @@ main ul[aria-label='已配置实例'] {
|
||||
display: block;
|
||||
}
|
||||
.instance-context {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.instance-breadcrumb,
|
||||
.instance-context nav {
|
||||
grid-column: 1;
|
||||
}
|
||||
.instance-header-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.messages-workbench {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
@@ -931,8 +1072,8 @@ main ul[aria-label='已配置实例'] {
|
||||
max-height: none;
|
||||
}
|
||||
.instance-context ul {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
overflow: visible;
|
||||
}
|
||||
.instance-context li {
|
||||
@@ -974,6 +1115,9 @@ main ul[aria-label='已配置实例'] {
|
||||
.fleet-card-section {
|
||||
margin-top: 0.55rem;
|
||||
}
|
||||
.fleet-status-summary {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border);
|
||||
|
||||
Reference in New Issue
Block a user