diff --git a/apps/web/src/fleet/fleet-page.test.tsx b/apps/web/src/fleet/fleet-page.test.tsx index d40e018..95c7369 100644 --- a/apps/web/src/fleet/fleet-page.test.tsx +++ b/apps/web/src/fleet/fleet-page.test.tsx @@ -53,6 +53,30 @@ describe('FleetPage card navigation', () => { }); }); +describe('FleetPage search and filter toolbar', () => { + it('filters by status chips and clears active search conditions', () => { + render(); + + const search = screen.getByRole('search', { name: '实例搜索与筛选' }); + expect(within(search).getByPlaceholderText('搜索名称、ID、标签、源地址…')).toBeTruthy(); + expect(within(search).getByText(/显示/).textContent).toMatch(/显示\s*1\s*\/\s*1/); + + fireEvent.click(within(search).getByRole('button', { name: /离线/ })); + expect(screen.queryByRole('article', { name: 'Alpha modem 实例概览' })).toBeNull(); + expect(within(search).getByText(/显示/).textContent).toMatch(/显示\s*0\s*\/\s*1/); + expect(within(search).getByRole('button', { name: /移除筛选 状态:离线/ })).toBeTruthy(); + + fireEvent.change(within(search).getByPlaceholderText('搜索名称、ID、标签、源地址…'), { + target: { value: 'Alpha' }, + }); + expect(within(search).getByRole('button', { name: /移除筛选 搜索:Alpha/ })).toBeTruthy(); + + fireEvent.click(within(search).getByRole('button', { name: '清除全部筛选' })); + expect(screen.getByRole('article', { name: 'Alpha modem 实例概览' })).toBeTruthy(); + expect(within(search).getByText(/显示/).textContent).toMatch(/显示\s*1\s*\/\s*1/); + }); +}); + describe('FleetPage batch and restart actions', () => { it('exposes card restart controls and batch restart entry for selected instances', async () => { const prepare = vi.fn(async () => ({ diff --git a/apps/web/src/fleet/fleet-page.tsx b/apps/web/src/fleet/fleet-page.tsx index b98a2d0..e4abd1d 100644 --- a/apps/web/src/fleet/fleet-page.tsx +++ b/apps/web/src/fleet/fleet-page.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { buildFleetTableViewModel, + fleetStatusKind, type FleetAuthFilter, type FleetFilter, type FleetInstance, @@ -252,6 +253,98 @@ export function FleetPage({ }; }, [snapshot]); + const statusFacetCounts = useMemo(() => { + const counts: Record = { + all: snapshot?.instances.length ?? 0, + online: 0, + auth: 0, + offline: 0, + unknown: 0, + }; + for (const instance of snapshot?.instances ?? []) { + const kind = fleetStatusKind(snapshot?.statuses.get(instance.id)); + counts[kind] += 1; + } + return counts; + }, [snapshot]); + + const activeFilterChips = useMemo(() => { + const chips: Array<{ key: string; label: string; onClear: () => void }> = []; + const trimmedQuery = query.trim(); + if (trimmedQuery) { + chips.push({ + key: 'query', + label: `搜索:${trimmedQuery}`, + onClear: () => { + setQuery(''); + setPage(1); + }, + }); + } + if (filter !== 'all') { + chips.push({ + key: 'status', + label: `状态:${STATUS_LABELS[filter] ?? filter}`, + onClear: () => { + setFilter('all'); + setPage(1); + }, + }); + } + if (auth !== 'all') { + const authLabel = + auth === 'authenticated' ? '已认证' : auth === 'required' ? '需要认证' : '未知'; + chips.push({ + key: 'auth', + label: `认证:${authLabel}`, + onClear: () => { + setAuth('all'); + setPage(1); + }, + }); + } + if (capability) { + chips.push({ + key: 'capability', + label: `能力:${capabilityLabel(capability)}`, + onClear: () => { + setCapability(''); + setPage(1); + }, + }); + } + if (version) { + chips.push({ + key: 'version', + label: `版本:${version}`, + onClear: () => { + setVersion(''); + setPage(1); + }, + }); + } + if (tag) { + chips.push({ + key: 'tag', + label: `标签:${tag}`, + onClear: () => { + setTag(''); + setPage(1); + }, + }); + } + return chips; + }, [auth, capability, filter, query, tag, version]); + + const advancedFilterCount = useMemo( + () => + (auth !== 'all' ? 1 : 0) + + (capability ? 1 : 0) + + (version ? 1 : 0) + + (tag ? 1 : 0), + [auth, capability, tag, version], + ); + useEffect(() => { if (page !== model.page) setPage(model.page); }, [model.page, page]); @@ -260,6 +353,16 @@ export function FleetPage({ action(); setPage(1); } + + function clearAllFilters(): void { + setQuery(''); + setFilter('all'); + setAuth('all'); + setCapability(''); + setVersion(''); + setTag(''); + setPage(1); + } function toggleOne(id: string, checked: boolean): void { setSelectedIds((current) => { const next = new Set(current); @@ -562,35 +665,130 @@ export function FleetPage({ ) : null} ) : null} -
-
-