diff --git a/apps/web/src/app-shell.integration.test.tsx b/apps/web/src/app-shell.integration.test.tsx index bd10d89..9800e0c 100644 --- a/apps/web/src/app-shell.integration.test.tsx +++ b/apps/web/src/app-shell.integration.test.tsx @@ -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(() => undefined)); + + render( + , + ); + + 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( 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(); expect(screen.queryByText('Owner modem')).toBeNull(); diff --git a/apps/web/src/app-shell.tsx b/apps/web/src/app-shell.tsx index 9719971..c7d9642 100644 --- a/apps/web/src/app-shell.tsx +++ b/apps/web/src/app-shell.tsx @@ -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( diff --git a/apps/web/src/fleet/fleet-page.test.tsx b/apps/web/src/fleet/fleet-page.test.tsx index f66c6dd..20bb654 100644 --- a/apps/web/src/fleet/fleet-page.test.tsx +++ b/apps/web/src/fleet/fleet-page.test.tsx @@ -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(); }); }); diff --git a/apps/web/src/fleet/fleet-page.tsx b/apps/web/src/fleet/fleet-page.tsx index 41aba78..74fa5c9 100644 --- a/apps/web/src/fleet/fleet-page.tsx +++ b/apps/web/src/fleet/fleet-page.tsx @@ -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({ + {snapshot ? ( +
+
+ 实例总数 + {fleetSummary.total} +
+
+ 在线 + {fleetSummary.online} +
+
+ 需处理 + {fleetSummary.attention} +
+
+ ) : null} {batchOpen ? (
请为已选择的 {model.selectedIds.length} 项选择操作。 @@ -465,6 +493,7 @@ export function FleetPage({ >

{row.displayName}

{row.id} + 进入仪表盘 {STATUS_LABELS[row.statusKind]} @@ -479,11 +508,39 @@ export function FleetPage({
CPU
-
{percent(row.status?.summary?.resources?.cpuPercent)}
+
+ {row.status?.summary?.resources?.cpuPercent === undefined ? ( + '暂未获取' + ) : ( + <> + + {percent(row.status.summary.resources.cpuPercent)} + + )} +
内存
-
{percent(row.status?.summary?.resources?.memoryPercent)}
+
+ {row.status?.summary?.resources?.memoryPercent === undefined ? ( + '暂未获取' + ) : ( + <> + + {percent(row.status.summary.resources.memoryPercent)} + + )} +
最高温度
@@ -511,10 +568,6 @@ export function FleetPage({
号码
{messageStates.get(row.id)!.latest!.phoneNumber}
-
-
内容
-
{messageExcerpt(messageStates.get(row.id)!.latest!.content)}
-
时间
{messageTime(messageStates.get(row.id)!.latest!.timestamp)}
@@ -524,7 +577,11 @@ export function FleetPage({

暂无短信

)} -
+
{row.tags.join(', ') || '—'}, freshness: {FRESHNESS_LABELS[row.freshness] ?? row.freshness}, anomalies: {row.anomalies.join(', ') || '—'}, - origin: ( - - {origin ? ( - - {origin} - - ) : ( - 源地址无效 - )} - - ), + origin: {origin ? {origin} : 源地址无效}, }; return ( diff --git a/apps/web/src/instances/instance-detail.test.tsx b/apps/web/src/instances/instance-detail.test.tsx index c6c3610..71be7c2 100644 --- a/apps/web/src/instances/instance-detail.test.tsx +++ b/apps/web/src/instances/instance-detail.test.tsx @@ -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', () => { , ); 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' } }); diff --git a/apps/web/src/instances/instance-detail.tsx b/apps/web/src/instances/instance-detail.tsx index 7d16280..4bcb44a 100644 --- a/apps/web/src/instances/instance-detail.tsx +++ b/apps/web/src/instances/instance-detail.tsx @@ -40,6 +40,16 @@ export const INSTANCE_MODULE_LABELS: Readonly> = }; const PRIMARY_MODULES = ['overview', 'messages'] as const satisfies readonly InstanceModule[]; +const PRIMARY_LABELS: Readonly> = { + overview: '仪表盘', + messages: '短信', +}; +const AUTH_LABELS = { authenticated: '已认证', 'auth-required': '需要认证' } as const; +const FRESHNESS_LABELS = { + fresh: '数据最新', + stale: '数据可能过期', + expired: '数据已过期', +} as const; const DEFAULT_EXPLANATIONS: Readonly, string>> = { degraded: '此模块可用,但功能受限。', @@ -126,23 +136,37 @@ export function InstanceDetail({ return (
- +
-

{INSTANCE_MODULE_LABELS[module]}

+

{INSTANCE_MODULE_LABELS[module]}

{loading ?

正在加载能力…

: null} {loadError ?

能力不可用:{loadError}

: null} {!loading && canOpen(module, activeCapability) ? ( diff --git a/apps/web/src/instances/overview-system.test.tsx b/apps/web/src/instances/overview-system.test.tsx index 3058c56..0875661 100644 --- a/apps/web/src/instances/overview-system.test.tsx +++ b/apps/web/src/instances/overview-system.test.tsx @@ -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( { 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(); }); diff --git a/apps/web/src/instances/overview-system.tsx b/apps/web/src/instances/overview-system.tsx index eaf9087..55e9c78 100644 --- a/apps/web/src/instances/overview-system.tsx +++ b/apps/web/src/instances/overview-system.tsx @@ -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) => ( +
+ {label} + {value === undefined ? ( + 暂未获取 + ) : ( + <> + {value.toFixed(1)}% + + + )} +
+ ); + return ( +
+

运行状态

+ {instance.resourceSummaryState === 'loading' ? ( +

+ 正在加载资源摘要… +

+ ) : null} + {instance.resourceSummaryState === 'unavailable' ? ( +

+ 资源摘要暂不可用,实例基本信息仍可使用。 +

+ ) : null} +
+
+ 连接状态 + + {instance.status === 'online' + ? '在线' + : instance.status === 'offline' + ? '离线' + : instance.status === 'auth-required' + ? '需要认证' + : '未知'} + +
+ {metric('CPU', resources?.cpuPercent)} + {metric('内存', resources?.memoryPercent)} +
+ 最高温度 + + {resources?.maxTemperatureCelsius === undefined + ? '暂未获取' + : `${resources.maxTemperatureCelsius.toFixed(1)} °C`} + +
+
+ 手机号 + {resources?.phoneNumbers?.join('、') || '暂未获取'} +
+
+
+ ); +} + export function OverviewSystemPage({ instance, dataSource, @@ -159,12 +219,7 @@ export function OverviewSystemPage({
); - if (!dataSource) - return ( -
- 没有可用的安全概览只读数据源。此控制台不会臆造或调用未签订契约的生产端点。 -
- ); + if (!dataSource) return ; const retainedSnapshot = state.kind === 'loading' || state.kind === 'error' ? state.snapshot : undefined; @@ -191,6 +246,7 @@ export function OverviewSystemPage({ return (
+ {state.kind === 'loading' ?

正在刷新概览…

: null} {state.kind === 'error' ? (
@@ -211,10 +267,10 @@ export function OverviewSystemPage({ ))}
-
-

系统操作

+
+ 系统操作说明

重启不可用,因为后端尚未实现 R3 重启操作,因此不提供任何操作。

-
+
); } diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 0e3d7d8..1f13aae 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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);