diff --git a/apps/web/src/app-shell.integration.test.tsx b/apps/web/src/app-shell.integration.test.tsx index 5f32a13..3b16286 100644 --- a/apps/web/src/app-shell.integration.test.tsx +++ b/apps/web/src/app-shell.integration.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { cleanup, render, screen, within } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -7,6 +7,7 @@ import { AppShell, type InstanceContext } from './app-shell.js'; import type { AuditDataSource } from './audit/audit-page.js'; import type { EventStreamClient } from './events/event-stream-client.js'; import { type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js'; +import type { InstanceDataSource } from './instances/instance-api-data-source.js'; import type { JobsDataSource } from './jobs/jobs-page.js'; afterEach(() => { @@ -91,6 +92,82 @@ describe('React AppShell and Fleet vertical slice', () => { ).toBe(false); }); + it('makes cards, add, detail, edit, and safe deletion available from the overview', async () => { + const user = userEvent.setup(); + const remove = vi.fn().mockResolvedValue(undefined); + const instanceDataSource: InstanceDataSource = { + get: vi.fn().mockResolvedValue({ + id: 'bravo', + name: 'Bravo', + origin: 'http://bravo.example:8080', + tags: ['west'], + revision: 7, + credentialConfigured: true, + }), + create: vi.fn(), + update: vi.fn(), + testConnection: vi.fn(), + delete: remove, + }; + + render( + snapshot)} + instanceDataSource={instanceDataSource} + />, + ); + + expect(await screen.findByRole('region', { name: '实例卡片' })).toBeTruthy(); + expect(screen.getByRole('link', { name: '添加实例' }).getAttribute('href')).toBe( + '/instances/new', + ); + const card = screen.getByRole('article', { name: 'Bravo 实例概览' }); + expect(within(card).getByText('40 ms')).toBeTruthy(); + expect(within(card).getByText('2.0')).toBeTruthy(); + expect(within(card).getByRole('link', { name: '管理 Bravo' }).getAttribute('href')).toBe( + '/instances/bravo/overview', + ); + expect(within(card).getByRole('link', { name: '编辑 Bravo' }).getAttribute('href')).toBe( + '/settings/instances/bravo', + ); + + await user.click(within(card).getByRole('button', { name: '删除 Bravo' })); + expect(within(card).getByText('删除后无法撤销。')).toBeTruthy(); + await user.type(within(card).getByRole('textbox', { name: '输入 bravo 以确认删除' }), 'bravo'); + await user.click(within(card).getByRole('button', { name: '确认删除 Bravo' })); + expect(remove).toHaveBeenCalledWith('bravo', 7); + expect(within(card).getByRole('status').textContent).toContain('删除请求已提交'); + }); + + it('loads route-owned instance context so overview-card navigation opens detail management', async () => { + const instanceDataSource: InstanceDataSource = { + get: vi.fn().mockResolvedValue({ + id: 'bravo', + name: 'Bravo', + origin: 'http://bravo.example:8080', + tags: ['west'], + revision: 7, + credentialConfigured: true, + }), + create: vi.fn(), + update: vi.fn(), + testConnection: vi.fn(), + delete: vi.fn(), + }; + + render( + , + ); + + expect(screen.getByRole('status').textContent).toContain('正在加载实例'); + expect(await screen.findByText('Bravo')).toBeTruthy(); + expect(screen.getByRole('heading', { name: '概览' })).toBeTruthy(); + expect(screen.getByRole('link', { name: '编辑实例' }).getAttribute('href')).toBe( + '/settings/instances/bravo', + ); + }); + it('supports accessible search, status filtering, sorting, and visible selection', async () => { const user = userEvent.setup(); render( snapshot)} />); @@ -164,7 +241,6 @@ describe('React AppShell and Fleet vertical slice', () => { }); it('paginates fleet rows and selects only the current page', async () => { - const user = userEvent.setup(); const many: FleetSnapshot = { instances: Array.from({ length: 11 }, (_, index) => ({ id: `instance-${String(index + 1).padStart(2, '0')}`, @@ -178,9 +254,9 @@ describe('React AppShell and Fleet vertical slice', () => { expect(screen.queryByRole('row', { name: /Instance 11/ })).toBeNull(); expect(screen.getByText('第 1 页,共 2 页')).toBeTruthy(); - await user.click(screen.getByRole('checkbox', { name: '选择当前页全部实例' })); + fireEvent.click(screen.getByRole('checkbox', { name: '选择当前页全部实例' })); expect(screen.getByText('已选择 10 项')).toBeTruthy(); - await user.click(screen.getByRole('button', { name: '下一页' })); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); expect(await screen.findByRole('row', { name: /Instance 11/ })).toBeTruthy(); expect(screen.getByText('第 2 页,共 2 页')).toBeTruthy(); }); @@ -205,7 +281,7 @@ describe('React AppShell and Fleet vertical slice', () => { rerender(); expect(screen.queryByText('Owner modem')).toBeNull(); - expect(screen.getByText(/此路由缺少实例上下文/i)).toBeTruthy(); + expect(screen.getByRole('status').textContent).toContain('正在加载实例'); }); it.each([ diff --git a/apps/web/src/app-shell.tsx b/apps/web/src/app-shell.tsx index 8d7bc67..b5c56f7 100644 --- a/apps/web/src/app-shell.tsx +++ b/apps/web/src/app-shell.tsx @@ -1,7 +1,7 @@ import { AuditPage, type AuditDataSource } from './audit/audit-page.js'; import { createAuditApiDataSource } from './audit/audit-api-data-source.js'; import type { ReactNode } from 'react'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useControlPlaneEvents } from './events/use-control-plane-events.js'; import { createEventStreamClient, type EventStreamClient } from './events/event-stream-client.js'; @@ -18,6 +18,7 @@ import { import { EsimModule, type EsimDataSource } from './instances/esim-module.js'; import { OverviewSystemPage, type OverviewDataSource } from './instances/overview-system.js'; import { InstanceEditor, type InstanceDataSource } from './instances/instance-crud.js'; +import { createInstanceApiDataSource } from './instances/instance-api-data-source.js'; import { JobsPage, type JobsDataSource } from './jobs/jobs-page.js'; import { createJobsApiDataSource } from './jobs/jobs-api-data-source.js'; import { MessagesModule, type MessagesDataSource } from './instances/messages-module.js'; @@ -198,6 +199,7 @@ function Page({ return ( @@ -407,6 +409,7 @@ export function AppShell({ }: AppShellProps) { const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []); const defaultFleetDataSource = useMemo(() => createFleetApiDataSource(), []); + const defaultInstanceDataSource = useMemo(() => createInstanceApiDataSource(), []); const resolvedJobsDataSource = useMemo( () => jobsDataSource ?? createJobsApiDataSource(), [jobsDataSource], @@ -418,6 +421,50 @@ export function AppShell({ const resolved = resolveRoute(pathname); const route = resolved.kind === 'redirect' ? resolveRoute(resolved.to ?? '/fleet') : resolved; const routeInstanceId = route.params?.instanceId; + const resolvedInstanceDataSource = instanceDataSource ?? defaultInstanceDataSource; + const [loadedInstance, setLoadedInstance] = useState(); + const [instanceLoading, setInstanceLoading] = useState(false); + const [instanceLoadFailed, setInstanceLoadFailed] = useState(false); + + useEffect(() => { + setLoadedInstance(undefined); + setInstanceLoadFailed(false); + if (!routeInstanceId || instance?.id === routeInstanceId) { + setInstanceLoading(false); + return; + } + let active = true; + setInstanceLoading(true); + void resolvedInstanceDataSource.get(routeInstanceId).then( + (owner) => { + if (!active) return; + if (owner.id !== routeInstanceId) { + setInstanceLoadFailed(true); + setInstanceLoading(false); + return; + } + setLoadedInstance({ + id: owner.id, + name: owner.name, + origin: owner.origin, + status: 'unknown', + authentication: 'unknown', + freshness: 'unknown', + }); + setInstanceLoading(false); + }, + () => { + if (!active) return; + setInstanceLoadFailed(true); + setInstanceLoading(false); + }, + ); + return () => { + active = false; + }; + }, [instance, resolvedInstanceDataSource, routeInstanceId]); + + const routeInstance = instance?.id === routeInstanceId ? instance : loadedInstance; const refresh = useControlPlaneEvents( routeInstanceId, eventStreamClient ?? defaultEventStreamClient, @@ -455,28 +502,36 @@ export function AppShell({
- + {routeInstanceId && instanceLoading ? ( +

正在加载实例…

+ ) : routeInstanceId && instanceLoadFailed ? ( +

+ 无法加载此实例,请返回总览后重试。 +

+ ) : ( + + )}
diff --git a/apps/web/src/fleet/fleet-page.tsx b/apps/web/src/fleet/fleet-page.tsx index f014b6a..5117e41 100644 --- a/apps/web/src/fleet/fleet-page.tsx +++ b/apps/web/src/fleet/fleet-page.tsx @@ -9,6 +9,7 @@ import { type FleetStatus, type SortDirection, } from './fleet-table-view-model.js'; +import type { InstanceDataSource } from '../instances/instance-crud.js'; export interface FleetSnapshot { readonly instances: readonly FleetInstance[]; @@ -19,6 +20,7 @@ export interface FleetDataSource { } export interface FleetPageProps { readonly dataSource?: FleetDataSource; + readonly instanceDataSource?: InstanceDataSource; readonly initialData?: FleetSnapshot; readonly refreshSignal?: number; } @@ -75,7 +77,12 @@ export function canonicalHttpOrigin(value: string): string | null { } } -export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetPageProps) { +export function FleetPage({ + dataSource, + instanceDataSource, + initialData, + refreshSignal = 0, +}: FleetPageProps) { const [snapshot, setSnapshot] = useState(initialData ?? null); const [error, setError] = useState(null); const [attempt, setAttempt] = useState(0); @@ -94,6 +101,13 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP const [shownColumns, setShownColumns] = useState>(new Set(ALL_COLUMNS)); const [columnsOpen, setColumnsOpen] = useState(false); const [batchOpen, setBatchOpen] = useState(false); + const [deleteId, setDeleteId] = useState(); + const [deleteOwner, setDeleteOwner] = useState>(); + const [deleteConfirmation, setDeleteConfirmation] = useState(''); + const [deleteBusy, setDeleteBusy] = useState(false); + const deleteRequestRef = useRef(0); + const [deleteStatus, setDeleteStatus] = + useState>(); const selectAllRef = useRef(null); useEffect(() => { @@ -184,6 +198,47 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP }); } + async function beginDelete(id: string): Promise { + const request = ++deleteRequestRef.current; + setDeleteId(id); + setDeleteOwner(undefined); + setDeleteConfirmation(''); + setDeleteStatus(undefined); + if (!instanceDataSource) { + setDeleteStatus({ id, kind: 'error', message: '无法准备删除,请稍后重试。' }); + return; + } + setDeleteBusy(true); + try { + const current = await instanceDataSource.get(id); + if (request !== deleteRequestRef.current) return; + if (current.id !== id) throw new Error('owner mismatch'); + setDeleteOwner({ id: current.id, revision: current.revision }); + } catch { + if (request === deleteRequestRef.current) { + setDeleteStatus({ id, kind: 'error', message: '无法准备删除,请稍后重试。' }); + } + } finally { + if (request === deleteRequestRef.current) setDeleteBusy(false); + } + } + + async function remove(id: string): Promise { + if (!instanceDataSource || deleteOwner?.id !== id || deleteConfirmation !== id) return; + setDeleteBusy(true); + setDeleteStatus(undefined); + try { + await instanceDataSource.delete(id, deleteOwner.revision); + setDeleteStatus({ id, kind: 'success', message: '删除请求已提交。' }); + setDeleteId(undefined); + setDeleteConfirmation(''); + } catch { + setDeleteStatus({ id, kind: 'error', message: '删除请求失败,请稍后重试。' }); + } finally { + setDeleteBusy(false); + } + } + const selectFilter = ( label: string, value: string, @@ -216,6 +271,9 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP

查找、对比并管理所有 SimAdmin 实例。

+ + 添加实例 + 已选择 {model.selectedIds.length} 项 @@ -234,79 +292,86 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
) : null}
- - - - {selectFilter( - '能力', - capability, - setCapability, - model.facets.capabilities, - '全部能力', - capabilityLabel, - )} - {selectFilter('版本', version, setVersion, model.facets.versions, '全部版本')} - {selectFilter('标签', tag, setTag, model.facets.tags, '全部标签')} -
- - {columnsOpen ? ( -
- 显示的列 - {ALL_COLUMNS.map((column) => ( - - ))} -
- ) : null} +
+ +
+
+ 更多筛选与列设置 +
+ + {selectFilter( + '能力', + capability, + setCapability, + model.facets.capabilities, + '全部能力', + capabilityLabel, + )} + {selectFilter('版本', version, setVersion, model.facets.versions, '全部版本')} + {selectFilter('标签', tag, setTag, model.facets.tags, '全部标签')} +
+ + {columnsOpen ? ( +
+ 显示的列 + {ALL_COLUMNS.map((column) => ( + + ))} +
+ ) : null} +
+
+
{!snapshot && !error ? ( @@ -324,142 +389,267 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP ) : null} {snapshot && model.emptyReason ? (
- {model.emptyReason === 'config' ?

尚未配置实例。

: null} + {model.emptyReason === 'config' ? ( + <> +

尚未配置实例。

+ + 添加实例 + + + ) : null} {model.emptyReason === 'search' ?

没有实例符合搜索与筛选条件。

: null} {model.emptyReason === 'filter' ?

没有实例符合当前筛选条件。

: null}
) : null} {snapshot && model.rows.length > 0 ? ( <> -
- - - - - - {ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => ( - + ))} + + + + {model.rows.map((row) => { + const origin = canonicalHttpOrigin(row.instance.url); + const cells: Readonly> = { + name: ( + + ), + status: ( + + ), + latency: , + version: , + capabilities: ( + + ), + tags: , + freshness: , + anomalies: , + origin: ( + + ), + }; + return ( + + + {ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => ( + {cells[column]} + ))} + + ); + })} + +
实例列表
- - +
+ {model.rows.map((row) => ( +
+
+
+

{row.displayName}

+ {row.id} +
+ + {STATUS_LABELS[row.statusKind]} + +
+
+
+
延迟
+
{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}
+
+
+
版本
+
{row.version ?? '—'}
+
+
+
新鲜度
+
{FRESHNESS_LABELS[row.freshness] ?? row.freshness}
+
+
+
+ {row.capabilities.length > 0 ? ( + row.capabilities.map((item) => ( + {capabilityLabel(item)} + )) + ) : ( + 能力未知 + )} +
+ + {deleteId === row.id ? ( +
+

删除后无法撤销。

+ +
- )} + +
+
+ ) : null} + {deleteStatus?.id === row.id ? ( +

+ {deleteStatus.message} +

+ ) : null} +
+ ))} +
+ +
+

高级详细列表

+
+ + + + + - ))} - - - - {model.rows.map((row) => { - const origin = canonicalHttpOrigin(row.instance.url); - const cells: Readonly> = { - name: ( - - ), - status: ( - - ), - latency: , - version: , - capabilities: ( - - ), - tags: , - freshness: , - anomalies: , - origin: ( - - - {ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => ( - {cells[column]} - ))} - - ); - })} - -
实例列表
+
- - {row.displayName} - - {row.id} - - - {STATUS_LABELS[row.statusKind]} - - {row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}{row.version ?? '—'}{row.capabilities.map(capabilityLabel).join(', ') || '—'}{row.tags.join(', ') || '—'}{FRESHNESS_LABELS[row.freshness] ?? row.freshness}{row.anomalies.join(', ') || '—'} - {origin ? ( - - {origin} - + {ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => ( + + {column === 'origin' ? ( + COLUMN_LABELS[column] ) : ( - 源地址无效 + )} - - ), - }; - return ( -
- -
-
- +
+ + {row.displayName} + + {row.id} + + + {STATUS_LABELS[row.statusKind]} + + {row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}{row.version ?? '—'}{row.capabilities.map(capabilityLabel).join(', ') || '—'}{row.tags.join(', ') || '—'}{FRESHNESS_LABELS[row.freshness] ?? row.freshness}{row.anomalies.join(', ') || '—'} + {origin ? ( + + {origin} + + ) : ( + 源地址无效 + )} +
+ +
+
+ + ) : null} diff --git a/apps/web/src/instances/instance-detail.tsx b/apps/web/src/instances/instance-detail.tsx index 038893a..f1a2c72 100644 --- a/apps/web/src/instances/instance-detail.tsx +++ b/apps/web/src/instances/instance-detail.tsx @@ -145,6 +145,7 @@ export function InstanceDetail({ 打开源站 ) : null} + 编辑实例