feat(web): redesign fleet cards and instance workspace

This commit is contained in:
chick
2026-07-19 18:54:26 +08:00
parent f2803896a8
commit 49ee4e6570
9 changed files with 458 additions and 74 deletions
@@ -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' } });
+37 -13
View File
@@ -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();
});
+65 -9
View File
@@ -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>
);
}