feat(web): redesign fleet cards and instance workspace
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user