diff --git a/apps/web/src/fleet/fleet-api-data-source.test.ts b/apps/web/src/fleet/fleet-api-data-source.test.ts index de53830..9f94739 100644 --- a/apps/web/src/fleet/fleet-api-data-source.test.ts +++ b/apps/web/src/fleet/fleet-api-data-source.test.ts @@ -265,4 +265,36 @@ describe('Fleet heartbeat connection state', () => { expect(partials[0]).toMatchObject({ probed: true, reachable: false }); expect(partials.at(-1)).toMatchObject({ probed: true, reachable: false }); }); + + it('carries the identity guard verdict onto the row, in both passes', async () => { + const fetcher = vi.fn(async () => { + const base = overviewWith({ probed: true, reachable: true, authenticated: true }); + const response = await base(); + const body = await response.json(); + body.items[0].identity = { tracked: true, status: 'pending', reasons: ['imei_claimed'] }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); + const partials: unknown[] = []; + const snapshot = await createFleetApiDataSource(fetcher as unknown as typeof fetch).load( + undefined, + (value) => partials.push(value.statuses.get('alpha')), + ); + + expect(partials[0]).toMatchObject({ identity: { status: 'pending' } }); + expect(snapshot.statuses.get('alpha')?.identity).toEqual({ + tracked: true, + status: 'pending', + reasons: ['imei_claimed'], + }); + }); + + it('leaves the row unbadged for a server without the identity guard', async () => { + const snapshot = await createFleetApiDataSource( + overviewWith(undefined) as unknown as typeof fetch, + ).load(); + expect(snapshot.statuses.get('alpha')?.identity).toBeUndefined(); + }); }); diff --git a/apps/web/src/fleet/fleet-api-data-source.ts b/apps/web/src/fleet/fleet-api-data-source.ts index 4b83ba6..bbb5d76 100644 --- a/apps/web/src/fleet/fleet-api-data-source.ts +++ b/apps/web/src/fleet/fleet-api-data-source.ts @@ -1,5 +1,5 @@ import type { FleetDataSource, FleetSnapshot } from './fleet-page.js'; -import type { FleetInstance, FleetStatus } from './fleet-table-view-model.js'; +import type { FleetIdentityState, FleetInstance, FleetStatus } from './fleet-table-view-model.js'; const integer = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value); @@ -126,15 +126,36 @@ function parseConnection(value: unknown): ConnectionView { function skeletonStatuses( instances: readonly FleetInstance[], connections: readonly ConnectionView[], + identities: readonly (FleetIdentityState | undefined)[], ): Map { return new Map( - instances.map((instance, index) => [ - instance.id, - { ...(connections[index] ?? unprobed), summary: { freshness: 'unknown' } }, - ]), + instances.map((instance, index) => { + const identity = identities[index]; + return [ + instance.id, + { + ...(connections[index] ?? unprobed), + ...(identity ? { identity } : {}), + summary: { freshness: 'unknown' }, + }, + ]; + }), ); } +/** Absent on a server that predates the identity guard, which keeps the row unbadged. */ +function parseIdentity(value: unknown): FleetIdentityState | undefined { + const source = record(value); + if (!source || typeof source.tracked !== 'boolean') return undefined; + return { + tracked: source.tracked, + status: source.status === 'pending' ? 'pending' : 'confirmed', + reasons: Array.isArray(source.reasons) + ? source.reasons.filter((item): item is string => string(item)) + : [], + }; +} + export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDataSource { return { async load(signal, onPartial) { @@ -153,9 +174,10 @@ export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDa const connections = overviewBody.items.map((item) => parseConnection(record(item)?.connection), ); + const identities = overviewBody.items.map((item) => parseIdentity(record(item)?.identity)); const partial: FleetSnapshot = { instances: readyInstances, - statuses: skeletonStatuses(readyInstances, connections), + statuses: skeletonStatuses(readyInstances, connections, identities), }; onPartial?.(partial); @@ -163,8 +185,10 @@ export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDa overviewBody.items.map((item, index) => { const instance = readyInstances[index]!; const resources = record(item)?.resources; + const identity = identities[index]; const status: FleetStatus = { ...connections[index]!, + ...(identity ? { identity } : {}), summary: resources ? parseResources(resources) : { freshness: 'unknown' }, }; return [instance.id, status] as const; diff --git a/apps/web/src/fleet/fleet-identity-panel.test.tsx b/apps/web/src/fleet/fleet-identity-panel.test.tsx new file mode 100644 index 0000000..8bac322 --- /dev/null +++ b/apps/web/src/fleet/fleet-identity-panel.test.tsx @@ -0,0 +1,138 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { FleetIdentityPanel } from './fleet-identity-panel.js'; +import type { DeviceIdentity, IdentityDataSource } from './identity-api-data-source.js'; + +afterEach(() => { + cleanup(); +}); + +const pending: DeviceIdentity = { + instanceId: 'node-a', + instanceName: '机房 A', + status: 'pending', + reasons: ['imei_claimed'], + conflicts: [{ reason: 'imei_claimed', instanceIds: ['node-b'], instanceNames: ['机房 B'] }], + imei: '860000000000001', + manufacturer: 'Quectel', + model: 'RM500Q', + revision: 'v1.2', + agent: '', + origin: '', + fingerprint: 'aaaa1111', + confirmedFingerprint: 'bbbb2222', + changes: [{ field: 'model', from: 'RM500Q', to: 'RG500Q' }], + observedAt: '2026-09-05T02:00:00.000Z', + confirmedAt: '2026-09-01T02:00:00.000Z', +}; + +const confirmed: DeviceIdentity = { + ...pending, + instanceId: 'node-b', + instanceName: '机房 B', + status: 'confirmed', + reasons: [], + conflicts: [], + changes: [], +}; + +function source( + items: readonly DeviceIdentity[] = [pending, confirmed], + overrides: Partial = {}, +): IdentityDataSource & Record> { + return { + list: vi.fn().mockResolvedValue({ + items, + tracked: items.length, + pending: items.filter((item) => item.status === 'pending').length, + }), + confirm: vi.fn().mockResolvedValue({ ...pending, status: 'confirmed', reasons: [] }), + refresh: vi.fn().mockResolvedValue({ observed: true, identity: pending }), + ...overrides, + } as unknown as IdentityDataSource & Record>; +} + +describe('FleetIdentityPanel', () => { + it('shows the evidence an operator needs to decide', async () => { + render( {}} onChanged={() => {}} />); + + const dialog = await screen.findByRole('dialog', { name: '设备身份核对' }); + expect(dialog.textContent).toContain('已记录 2 台 · 待确认 1 台'); + expect(screen.getByText('机房 A')).toBeTruthy(); + expect(screen.getByText('同一 IMEI 出现在多台设备记录上')).toBeTruthy(); + expect(screen.getByText('涉及:机房 B')).toBeTruthy(); + expect(screen.getByText('RG500Q')).toBeTruthy(); + expect(screen.getAllByText('型号').length).toBeGreaterThanOrEqual(2); + }); + + it('keeps the IMEI masked until the console-wide reveal is on', async () => { + render( {}} onChanged={() => {}} />); + await screen.findByRole('dialog', { name: '设备身份核对' }); + expect(screen.getAllByText(/0001$/u).length).toBeGreaterThan(0); + expect(screen.queryByText('860000000000001')).toBeNull(); + }); + + it('confirms the disputed node and tells the shell to reload', async () => { + const dataSource = source(); + const onChanged = vi.fn(); + render( {}} onChanged={onChanged} />); + + await screen.findByRole('dialog', { name: '设备身份核对' }); + fireEvent.click(screen.getByRole('button', { name: '确认身份' })); + + await waitFor(() => expect(dataSource.confirm).toHaveBeenCalledWith('node-a')); + await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1)); + // The list reloads after the action so the badge cannot go stale. + expect(dataSource.list).toHaveBeenCalledTimes(2); + }); + + it('re-reads the device and reports a node with nothing to show', async () => { + const dataSource = source([pending], { + refresh: vi.fn().mockResolvedValue({ observed: false }), + }); + render( {}} onChanged={() => {}} />); + + await screen.findByRole('dialog', { name: '设备身份核对' }); + fireEvent.click(screen.getByRole('button', { name: '重新核对' })); + + await waitFor(() => expect(dataSource.refresh).toHaveBeenCalledWith('node-a')); + expect(await screen.findByText('机房 A 未返回身份信息。')).toBeTruthy(); + }); + + it('filters to the nodes that actually need a decision', async () => { + render( {}} onChanged={() => {}} />); + await screen.findByRole('dialog', { name: '设备身份核对' }); + + fireEvent.click(screen.getByRole('button', { name: '只看待确认' })); + + expect(screen.getByText('机房 A')).toBeTruthy(); + expect(screen.queryByText('机房 B')).toBeNull(); + }); + + it('explains the empty state instead of showing a blank drawer', async () => { + const dataSource = source([]); + render( {}} onChanged={() => {}} />); + await screen.findByRole('dialog', { name: '设备身份核对' }); + expect( + screen.getByText( + '尚未记录任何设备身份。打开节点的概览或硬件页后,设备会自动上报 IMEI 与型号。', + ), + ).toBeTruthy(); + }); + + it('surfaces a failed load without throwing', async () => { + const dataSource = source([pending], { list: vi.fn().mockRejectedValue(new Error('down')) }); + render( {}} onChanged={() => {}} />); + expect(await screen.findByText('身份记录暂时无法读取,请重试。')).toBeTruthy(); + }); + + it('closes on escape', async () => { + const onClose = vi.fn(); + render( {}} />); + const dialog = await screen.findByRole('dialog', { name: '设备身份核对' }); + fireEvent.keyDown(dialog, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/fleet/fleet-identity-panel.tsx b/apps/web/src/fleet/fleet-identity-panel.tsx new file mode 100644 index 0000000..31e0fc2 --- /dev/null +++ b/apps/web/src/fleet/fleet-identity-panel.tsx @@ -0,0 +1,289 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'; +import { Tag } from 'animal-island-ui'; + +import { protectValue } from '../privacy/sensitive-fields.js'; +import { useSensitiveReveal } from '../privacy/sensitive-reveal.js'; +import { Icon } from '../ui/icon.js'; +import type { + DeviceIdentity, + IdentityDataSource, + IdentityReason, +} from './identity-api-data-source.js'; + +export interface FleetIdentityPanelProps { + readonly dataSource: IdentityDataSource; + readonly onClose: () => void; + readonly onChanged: () => void; +} + +const REASON_LABELS: Readonly> = { + hardware_swapped: '硬件信息与已确认记录不符', + imei_claimed: '同一 IMEI 出现在多台设备记录上', +}; + +const FIELD_LABELS: Readonly> = { + imei: 'IMEI', + manufacturer: '制造商', + model: '型号', + revision: '版本', +}; + +const timeText = (value: string): string => { + if (!value) return '未知'; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false }); +}; + +/** Only the tail of a fingerprint is worth reading; the head is the same for every node. */ +const tail = (value: string, length = 10): string => + !value ? '—' : value.length <= length ? value : `${value.slice(-length)}`; + +export function FleetIdentityPanel({ dataSource, onClose, onChanged }: FleetIdentityPanelProps) { + const [items, setItems] = useState([]); + const [summary, setSummary] = useState<{ tracked: number; pending: number }>({ + tracked: 0, + pending: 0, + }); + const [loading, setLoading] = useState(true); + const [loadFailed, setLoadFailed] = useState(false); + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [pendingOnly, setPendingOnly] = useState(false); + const closeRef = useRef(null); + const mounted = useRef(true); + const { revealed } = useSensitiveReveal(); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + const reload = useCallback(async (): Promise => { + setLoading(true); + try { + const overview = await dataSource.list(); + if (!mounted.current) return; + setItems(overview.items); + setSummary({ tracked: overview.tracked, pending: overview.pending }); + setLoadFailed(false); + } catch { + if (!mounted.current) return; + setLoadFailed(true); + } finally { + if (mounted.current) setLoading(false); + } + }, [dataSource]); + + useEffect(() => { + void reload(); + }, [reload]); + + useEffect(() => { + closeRef.current?.focus(); + }, []); + + const visible = useMemo( + () => (pendingOnly ? items.filter((item) => item.status === 'pending') : items), + [items, pendingOnly], + ); + + async function run(instanceId: string, operation: () => Promise): Promise { + setBusyId(instanceId); + setError(null); + setNotice(null); + try { + await operation(); + await reload(); + onChanged(); + } catch { + if (mounted.current) setError('操作失败,请稍后重试。'); + } finally { + if (mounted.current) setBusyId(null); + } + } + + function handleKeyDown(event: KeyboardEvent): void { + if (event.key !== 'Escape' || busyId) return; + event.stopPropagation(); + onClose(); + } + + return ( +
{ + if (event.target === event.currentTarget && !busyId) onClose(); + }} + > + +
+ ); +} diff --git a/apps/web/src/fleet/fleet-notifications-page.test.tsx b/apps/web/src/fleet/fleet-notifications-page.test.tsx index cded948..601b64e 100644 --- a/apps/web/src/fleet/fleet-notifications-page.test.tsx +++ b/apps/web/src/fleet/fleet-notifications-page.test.tsx @@ -23,8 +23,8 @@ function snapshot(overrides: Partial = {}): Notifica return { observedAt: '2026-09-03T01:00:00.000Z', devices: [ - { id: 'device-1', name: 'Modem A', state: 'ready', tags: ['office'] }, - { id: 'device-2', name: 'Modem B', state: 'unknown', tags: [] }, + { id: 'device-1', name: 'Modem A', state: 'ready', tags: ['office'], groupId: 'group-lab' }, + { id: 'device-2', name: 'Modem B', state: 'unknown', tags: [], groupId: null }, ], config: { channelCount: 1, @@ -87,7 +87,7 @@ function dataSource( eventType: 'sms', enabled: true, condition: { field: 'content', mode: 'contains', value: '余额' }, - scope: { mode: 'tags', tags: ['office'], match: 'all', instanceIds: [] }, + scope: { mode: 'tags', groups: [], tags: ['office'], match: 'all', instanceIds: [] }, channels: [{ id: 'ch-1', name: '值班 Bark' }], templates: { title: '余额提醒', body: '{{content}}' }, rateLimit: { enabled: true, maxMessages: 20, windowSeconds: 60 }, diff --git a/apps/web/src/fleet/fleet-notifications-page.tsx b/apps/web/src/fleet/fleet-notifications-page.tsx index db8fb3d..028c19b 100644 --- a/apps/web/src/fleet/fleet-notifications-page.tsx +++ b/apps/web/src/fleet/fleet-notifications-page.tsx @@ -487,6 +487,7 @@ function RuleEditor({ const [mode, setMode] = useState(rule?.condition.mode ?? 'all'); const [value, setValue] = useState(rule?.condition.value ?? ''); const [scopeMode, setScopeMode] = useState(rule?.scope.mode ?? 'all'); + const [groups, setGroups] = useState([...(rule?.scope.groups ?? [])]); const [tags, setTags] = useState([...(rule?.scope.tags ?? [])]); const [match, setMatch] = useState(rule?.scope.match ?? 'any'); const [instanceIds, setInstanceIds] = useState([...(rule?.scope.instanceIds ?? [])]); @@ -508,6 +509,12 @@ function RuleEditor({ for (const device of devices) for (const tag of device.tags) set.add(tag); return [...set].sort(); }, [devices, tags]); + const knownGroups = useMemo(() => { + const selected = new Map(); + for (const group of groups) selected.set(group, group); + for (const device of devices) if (device.groupId) selected.set(device.groupId, device.groupId); + return [...selected.keys()].sort(); + }, [devices, groups]); const toggle = (list: string[], update: (next: string[]) => void) => @@ -539,7 +546,7 @@ function RuleEditor({ eventType, enabled, condition: { field, mode, value }, - scope: { mode: scopeMode, tags, match, instanceIds }, + scope: { mode: scopeMode, groups, tags, match, instanceIds }, channelIds, templates: { title, body }, rateLimit: { @@ -636,10 +643,29 @@ function RuleEditor({ 范围 + {scopeMode === 'groups' ? ( +
+ {knownGroups.length === 0 ? ( + + ) : null} + {knownGroups.map((group) => ( + + ))} +
+ ) : null} {scopeMode === 'tags' ? ( <>
@@ -1345,9 +1371,11 @@ export function FleetNotificationsPage({ dataSource }: FleetNotificationsPagePro {rule.scope.mode === 'tags' ? `标签 ${rule.scope.tags.join('、')}` - : rule.scope.mode === 'devices' - ? `指定 ${rule.scope.instanceIds.length} 台` - : '全部设备'} + : rule.scope.mode === 'groups' + ? `分组 ${rule.scope.groups.join('、')}` + : rule.scope.mode === 'devices' + ? `指定 ${rule.scope.instanceIds.length} 台` + : '全部设备'} {rule.channels.map((channel) => channel.name).join('、')} diff --git a/apps/web/src/fleet/fleet-page.test.tsx b/apps/web/src/fleet/fleet-page.test.tsx index 08b02b0..c608842 100644 --- a/apps/web/src/fleet/fleet-page.test.tsx +++ b/apps/web/src/fleet/fleet-page.test.tsx @@ -2,7 +2,8 @@ import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { FleetPage, accessMethod, type FleetSnapshot } from './fleet-page.js'; +import { accessMethod } from './fleet-table-view-model.js'; +import { FleetPage, type FleetSnapshot } from './fleet-page.js'; const snapshot: FleetSnapshot = { instances: [ @@ -202,6 +203,55 @@ describe('FleetPage card navigation', () => { ).toBe(true); }); + it('opens the Hub-style device context drawer and keeps the dashboard one click away', async () => { + const messagesDataSource = { + load: vi.fn(async () => ({ + latest: { + id: 'sms-1', + direction: 'incoming', + phoneNumber: '13900139000', + content: 'status', + timestamp: '2026-07-19T08:30:00.000Z', + }, + })), + }; + render(); + + const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' }); + expect(screen.queryByRole('dialog', { name: 'Alpha modem 设备上下文' })).toBeNull(); + fireEvent.click(within(card).getByRole('button', { name: '打开 Alpha modem 设备上下文' })); + + const drawer = screen.getByRole('dialog', { name: 'Alpha modem 设备上下文' }); + const focusables = Array.from( + drawer.querySelectorAll('a[href], button:not([disabled])'), + ); + fireEvent.focus(focusables[0]!); + const dashboard = within(drawer).getByRole('link', { name: '打开 Alpha modem 实例仪表盘' }); + expect(dashboard.getAttribute('href')).toBe('/instances/alpha/overview'); + expect( + within(drawer).getByRole('link', { name: '打开 Alpha modem 节点入口' }).getAttribute('href'), + ).toBe('http://192.168.1.2'); + expect(within(drawer).getByText('局域网接入')).toBeTruthy(); + expect( + within(drawer).getByRole('progressbar', { name: 'CPU 使用率' }).getAttribute('aria-valuenow'), + ).toBe('24'); + expect(await within(drawer).findByRole('region', { name: '短信状态' })).toBeTruthy(); + + fireEvent.click(within(drawer).getByRole('button', { name: '关闭设备上下文' })); + expect(screen.queryByRole('dialog', { name: 'Alpha modem 设备上下文' })).toBeNull(); + expect(document.activeElement).toBe( + within(card).getByRole('button', { name: '打开 Alpha modem 设备上下文' }), + ); + + fireEvent.click(within(card).getByRole('button', { name: '打开 Alpha modem 设备上下文' })); + fireEvent.click(screen.getByRole('dialog', { name: 'Alpha modem 设备上下文' })); + expect(screen.getByRole('dialog', { name: 'Alpha modem 设备上下文' })).toBeTruthy(); + fireEvent.click( + screen.getByRole('dialog', { name: 'Alpha modem 设备上下文' }).closest('.drawer-backdrop')!, + ); + expect(screen.queryByRole('dialog', { name: 'Alpha modem 设备上下文' })).toBeNull(); + }); + it('shows an explicit fallback when the upstream SimAdmin version is unavailable', () => { const withoutVersion: FleetSnapshot = { ...snapshot, @@ -240,12 +290,17 @@ describe('FleetPage card navigation', () => { const serviceRestart = within(card).getByRole('menuitem', { name: '重启服务 Alpha modem', }); + const basebandRestart = within(card).getByRole('menuitem', { + name: '重启基带 Alpha modem', + }); const systemReboot = within(card).getByRole('menuitem', { name: '系统重启 Alpha modem' }); expect(document.activeElement).toBe(messagesLink); fireEvent.keyDown(messagesLink, { key: 'ArrowDown' }); expect(document.activeElement).toBe(serviceRestart); fireEvent.keyDown(serviceRestart, { key: 'ArrowDown' }); + expect(document.activeElement).toBe(basebandRestart); + fireEvent.keyDown(basebandRestart, { key: 'ArrowDown' }); expect(document.activeElement).toBe(systemReboot); fireEvent.keyDown(systemReboot, { key: 'ArrowDown' }); expect(document.activeElement).toBe(messagesLink); @@ -287,6 +342,32 @@ describe('FleetPage search and filter toolbar', () => { expect(document.querySelector('.fleet-result-count')?.textContent).toMatch(/显示\s*1\s*\/\s*1/); }); + it('filters devices by access method and reports the active condition', () => { + const accessSnapshot: FleetSnapshot = { + instances: [ + ...snapshot.instances, + { + id: 'wan', + name: 'Wan modem', + url: 'https://wan.example', + tags: [], + }, + ], + statuses: new Map([...snapshot.statuses, ['wan', { reachable: true, authenticated: true }]]), + }; + + render(); + + fireEvent.click(screen.getByText('更多筛选')); + fireEvent.change(screen.getByLabelText('接入方式'), { + target: { value: 'lan' }, + }); + + expect(screen.getByRole('article', { name: 'Alpha modem 实例概览' })).toBeTruthy(); + expect(screen.queryByRole('article', { name: 'Wan modem 实例概览' })).toBeNull(); + expect(screen.getByRole('button', { name: '移除筛选 接入:局域网接入' })).toBeTruthy(); + }); + it('filters the matrix from the device group row without moving search out of the sidebar', () => { const groupedSnapshot: FleetSnapshot = { instances: [ @@ -382,6 +463,14 @@ describe('FleetPage batch and restart actions', () => { batchable: false, parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1', }, + { + operationId: 'postBasebandRestart', + title: 'Restart Baseband', + risk: 'R3', + capability: 'job', + batchable: false, + parameterSchemaId: 'simadmin.58e2204.postBasebandRestart.parameters.v1', + }, { operationId: 'postSystemReboot', title: 'Reboot System', @@ -391,7 +480,7 @@ describe('FleetPage batch and restart actions', () => { parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1', }, ], - page: { page: 1, pageSize: 100, total: 2 }, + page: { page: 1, pageSize: 100, total: 3 }, })); vi.stubGlobal('confirm', () => true); render( @@ -406,7 +495,21 @@ describe('FleetPage batch and restart actions', () => { }); expect(messages.getAttribute('href')).toBe('/fleet/messages?device=alpha'); expect(within(card).getByRole('menuitem', { name: '重启服务 Alpha modem' })).toBeTruthy(); + expect(within(card).getByRole('menuitem', { name: '重启基带 Alpha modem' })).toBeTruthy(); expect(within(card).getByRole('menuitem', { name: '系统重启 Alpha modem' })).toBeTruthy(); + fireEvent.click(within(card).getByRole('menuitem', { name: '重启基带 Alpha modem' })); + await vi.waitFor(() => + expect(prepare).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: 'postBasebandRestart', + parameters: expect.objectContaining({ + parameterSchemaId: 'simadmin.58e2204.postBasebandRestart.parameters.v1', + fields: [], + }), + }), + ), + ); + expect(execute).toHaveBeenCalledWith('prep-1'); fireEvent.click(screen.getByRole('button', { name: '批量选择' })); fireEvent.click(within(card).getByRole('checkbox', { name: '选择 Alpha modem' })); expect( @@ -537,4 +640,93 @@ describe('FleetPage heartbeat reporting', () => { expect(row?.querySelector('time')).toBeNull(); expect(row?.textContent).toContain('未上报'); }); + + const withIdentity = (identity: Record | undefined): FleetSnapshot => ({ + instances: snapshot.instances, + statuses: new Map([ + [ + 'alpha', + { + ...snapshot.statuses.get('alpha')!, + ...(identity ? { identity } : {}), + }, + ], + ]), + }); + + it('warns on the card that the identity guard is holding the node', () => { + render( + , + ); + const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' }); + expect(card.querySelector('.fleet-card-identity')?.textContent).toContain('身份待确认'); + expect(card.querySelector('.fleet-card-identity')?.textContent).toContain('控制操作已暂停'); + }); + + it('stays quiet for a confirmed node and for one that never reported identity', () => { + render( + , + ); + expect( + screen + .getByRole('article', { name: 'Alpha modem 实例概览' }) + .querySelector('.fleet-card-identity'), + ).toBeNull(); + cleanup(); + + render(); + expect( + screen + .getByRole('article', { name: 'Alpha modem 实例概览' }) + .querySelector('.fleet-card-identity'), + ).toBeNull(); + }); + + it('opens the identity guard from the node toolbar', async () => { + const identityDataSource = { + list: vi.fn().mockResolvedValue({ items: [], tracked: 0, pending: 0 }), + confirm: vi.fn(), + refresh: vi.fn(), + }; + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '设备身份核对' })); + expect(await screen.findByRole('dialog', { name: '设备身份核对' })).toBeTruthy(); + expect(identityDataSource.list).toHaveBeenCalled(); + }); + + it('counts held nodes in the cross-health rail and opens the guard from there', async () => { + const identityDataSource = { + list: vi.fn().mockResolvedValue({ items: [], tracked: 0, pending: 0 }), + confirm: vi.fn(), + refresh: vi.fn(), + }; + render( + , + ); + + const rail = screen.getByRole('button', { name: '设备身份待确认节点' }); + expect(rail.textContent).toContain('身份待确认'); + expect(rail.querySelector('strong')?.textContent).toBe('1'); + expect(rail.querySelector('strong')?.getAttribute('data-tone')).toBe('attention'); + + fireEvent.click(rail); + expect(await screen.findByRole('dialog', { name: '设备身份核对' })).toBeTruthy(); + }); + + it('shows a clean identity rail when every node matches its record', () => { + render( + , + ); + const rail = screen.getByRole('button', { name: '设备身份待确认节点' }); + expect(rail.querySelector('strong')?.textContent).toBe('0'); + expect(rail.querySelector('strong')?.getAttribute('data-tone')).toBe('ok'); + }); }); diff --git a/apps/web/src/fleet/fleet-page.tsx b/apps/web/src/fleet/fleet-page.tsx index 290b297..27fe156 100644 --- a/apps/web/src/fleet/fleet-page.tsx +++ b/apps/web/src/fleet/fleet-page.tsx @@ -2,15 +2,20 @@ import { useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEv import { Button, Card, Input, Progress, Tag } from 'animal-island-ui'; import { + accessMethod, buildFleetTableViewModel, + canonicalHttpOrigin, fleetStatusKind, type FleetAuthFilter, + type FleetAccessFilter, type FleetFilter, type FleetInstance, type FleetSortColumn, type FleetStatus, type SortDirection, } from './fleet-table-view-model.js'; + +export { accessMethod, canonicalHttpOrigin }; import type { FleetMessageLoadState, FleetMessagesDataSource, @@ -19,6 +24,11 @@ import { loadFleetMessageSummaries } from './fleet-messages-api-data-source.js'; import { formatPhoneNumbers } from './phone-privacy.js'; import { useSensitiveReveal } from '../privacy/sensitive-reveal.js'; import { FleetOrganizationPanel } from './fleet-organization-panel.js'; +import { FleetIdentityPanel } from './fleet-identity-panel.js'; +import { + createIdentityApiDataSource, + type IdentityDataSource, +} from './identity-api-data-source.js'; import { createOrganizationApiDataSource, type OrganizationDataSource, @@ -39,6 +49,8 @@ export interface FleetPageProps { readonly dataSource?: FleetDataSource; readonly messagesDataSource?: FleetMessagesDataSource; readonly organizationDataSource?: OrganizationDataSource; + /** Device identity guard surface; defaults to the control-plane routes. */ + readonly identityDataSource?: IdentityDataSource; /** Groups owned by the shell; the panel mutates them and asks the shell to reload. */ readonly groups?: readonly OrganizationGroup[]; readonly onGroupsChanged?: () => void; @@ -161,57 +173,10 @@ function heartbeatAge(value: string, now: number): string { return `${Math.floor(seconds / 86400)} 天前`; } -const PRIVATE_NETWORKS = [ - /^10\./u, - /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./u, - /^127\./u, - /^169\.254\./u, - /^172\.(1[6-9]|2\d|3[01])\./u, - /^192\.168\./u, - /^(?:fe80|fc|fd)/u, -]; - -/** - * How the control plane reaches the node. Hub labels the same idea 接入方式, and the answer is - * already implied by the origin, so it is derived rather than stored. - */ -export function accessMethod( - value: string, -): Readonly<{ label: string; kind: 'local' | 'lan' | 'wan' }> | null { - const origin = canonicalHttpOrigin(value); - if (!origin) return null; - let host: string | null; - try { - host = new URL(origin).hostname; - } catch { - return null; - } - if (!host) return null; - const bare = host.replace(/^\[(.*)\]$/u, '$1').toLowerCase(); - if (bare === 'localhost' || bare === '::1' || /^127\./u.test(bare)) - return { label: '本机直连', kind: 'local' }; - if (/^\d+\.\d+\.\d+\.\d+$/u.test(bare) || /^[0-9a-f:]+$/u.test(bare)) - return PRIVATE_NETWORKS.some((pattern) => pattern.test(bare)) - ? { label: '局域网接入', kind: 'lan' } - : { label: '公网地址', kind: 'wan' }; - return { label: '域名接入', kind: 'wan' }; -} - -export function canonicalHttpOrigin(value: string): string | null { - try { - const url = new URL(value); - if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; - url.username = ''; - url.password = ''; - return url.origin; - } catch { - return null; - } -} - type BatchActionKind = 'service-restart' | 'system-reboot'; +type CardActionKind = BatchActionKind | 'baseband-restart'; type BatchProgress = Readonly<{ - action: BatchActionKind; + action: CardActionKind; total: number; completed: number; succeeded: number; @@ -224,6 +189,29 @@ const SERVICE_RESTART = { parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1', title: '重启服务', } as const; + +const BASEBAND_RESTART = { + operationId: 'postBasebandRestart', + parameterSchemaId: 'simadmin.58e2204.postBasebandRestart.parameters.v1', + title: '重启基带', +} as const; + +const CONTEXT_MODULES: ReadonlyArray<{ + module: string; + label: string; + icon: IconName; +}> = [ + { module: 'overview', label: '状态与资源', icon: 'grid' }, + { module: 'cellular', label: '蜂窝面板', icon: 'activity' }, + { module: 'sim', label: 'SIM 面板', icon: 'server' }, + { module: 'device-network', label: '设备网络', icon: 'wifi' }, + { module: 'messages', label: '短信中心', icon: 'message' }, + { module: 'calls', label: '通话', icon: 'phone' }, + { module: 'esim', label: 'eSIM', icon: 'server' }, + { module: 'notifications', label: '通知', icon: 'alert' }, + { module: 'automation', label: '自动化', icon: 'restart' }, + { module: 'ota', label: 'OTA', icon: 'version' }, +]; const SYSTEM_REBOOT = { operationId: 'postSystemReboot', parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1', @@ -235,6 +223,7 @@ export function FleetPage({ dataSource, messagesDataSource, organizationDataSource, + identityDataSource, groups: controlledGroups, initialData, refreshSignal = 0, @@ -248,6 +237,7 @@ export function FleetPage({ const [query, setQuery] = useState(''); const [filter, setFilter] = useState('all'); const [auth, setAuth] = useState('all'); + const [access, setAccess] = useState('all'); const [capability, setCapability] = useState(''); const [version, setVersion] = useState(''); const [tag, setTag] = useState(''); @@ -255,6 +245,9 @@ export function FleetPage({ const [ownGroups, setOwnGroups] = useState([]); const [organizationRevision, setOrganizationRevision] = useState(0); const [organizationOpen, setOrganizationOpen] = useState(false); + const [identityOpen, setIdentityOpen] = useState(false); + const [contextId, setContextId] = useState(); + const contextTriggerRefs = useRef(new Map()); const [page, setPage] = useState(1); const [sort, setSort] = useState<{ column: FleetSortColumn; direction: SortDirection }>({ column: 'name', @@ -276,6 +269,7 @@ export function FleetPage({ const cardMenuInitialFocus = useRef<'first' | 'last'>('first'); const cardMenuPanelRef = useRef(null); const cardMenuTriggerRefs = useRef(new Map()); + const deviceContextPanelRef = useRef(null); const messagesOwner = useRef(0); const [messageStates, setMessageStates] = useState>( new Map(), @@ -288,8 +282,13 @@ export function FleetPage({ () => organizationDataSource ?? createOrganizationApiDataSource(), [organizationDataSource], ); + const resolvedIdentityDataSource = useMemo( + () => identityDataSource ?? createIdentityApiDataSource(), + [identityDataSource], + ); const groups = controlledGroups ?? ownGroups; const groupNames = useMemo(() => new Map(groups.map((item) => [item.id, item.name])), [groups]); + const [identityImeis, setIdentityImeis] = useState>(new Map()); useEffect(() => { // The shell owns the shared group registry; standalone usage loads its own copy. @@ -311,6 +310,30 @@ export function FleetPage({ }; }, [controlledGroups, resolvedOrganizationDataSource, organizationRevision]); + useEffect(() => { + const controller = new AbortController(); + let active = true; + void resolvedIdentityDataSource + .list(controller.signal) + .then((items) => { + if (!active) return; + setIdentityImeis( + new Map( + items.items + .filter((item) => item.imei) + .map((item) => [item.instanceId, item.imei] as const), + ), + ); + }) + .catch(() => { + /* IMEI search is enrichment; the fleet list stays usable if identity is unavailable. */ + }); + return () => { + active = false; + controller.abort(); + }; + }, [resolvedIdentityDataSource]); + useEffect(() => { if (initialData && refreshSignal === 0) { setSnapshot(initialData); @@ -389,6 +412,18 @@ export function FleetPage({ return () => document.removeEventListener('pointerdown', dismissFromOutside); }, [cardMenuId]); + useEffect(() => { + if (!contextId) return; + const dismissWithKeyboard = (event: globalThis.KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + closeContext(true); + } + }; + document.addEventListener('keydown', dismissWithKeyboard); + return () => document.removeEventListener('keydown', dismissWithKeyboard); + }, [contextId]); + const closeCardMenu = (id: string, restoreFocus = false) => { if (restoreFocus) cardMenuTriggerRefs.current.get(id)?.focus(); setCardMenuId(undefined); @@ -424,25 +459,51 @@ export function FleetPage({ const model = useMemo( () => - buildFleetTableViewModel(snapshot?.instances ?? [], snapshot?.statuses ?? new Map(), { - query, - filter, - auth, - ...(capability ? { capability } : {}), - ...(version ? { version } : {}), - ...(tag ? { tag } : {}), - ...(group ? { group } : {}), - groupNames, - sort, - selectedIds, - page, - }), + buildFleetTableViewModel( + snapshot?.instances ?? [], + new Map( + [...(snapshot?.statuses ?? new Map())].map(([id, status]) => [ + id, + identityImeis.has(id) + ? ({ + ...status, + ...(status.identity + ? { identity: { ...status.identity, imei: identityImeis.get(id) } } + : { + identity: { + tracked: Boolean(identityImeis.get(id)), + status: 'confirmed', + reasons: [], + imei: identityImeis.get(id), + }, + }), + } satisfies FleetStatus) + : status, + ]), + ), + { + query, + filter, + auth, + access, + ...(capability ? { capability } : {}), + ...(version ? { version } : {}), + ...(tag ? { tag } : {}), + ...(group ? { group } : {}), + groupNames, + sort, + selectedIds, + page, + }, + ), [ auth, capability, + access, filter, group, groupNames, + identityImeis, page, query, selectedIds, @@ -482,6 +543,15 @@ export function FleetPage({ () => [...messageStates.values()].filter((state) => state.unavailable).length, [messageStates], ); + // Nodes the identity guard is holding: their controls are greyed out, so the fleet rail has to + // say so instead of letting the operator wonder why a reachable device refuses a command. + const identityPendingCount = useMemo( + () => + [...(snapshot?.statuses.values() ?? [])].filter( + (status) => status.identity?.tracked === true && status.identity.status === 'pending', + ).length, + [snapshot], + ); const [notificationHealth, setNotificationHealth] = useState<{ pending: number; @@ -582,6 +652,18 @@ export function FleetPage({ }, }); } + if (access !== 'all') { + chips.push({ + key: 'access', + label: `接入:${ + access === 'local' ? '本机直连' : access === 'lan' ? '局域网接入' : '公网地址' + }`, + onClear: () => { + setAccess('all'); + setPage(1); + }, + }); + } if (capability) { chips.push({ key: 'capability', @@ -623,16 +705,17 @@ export function FleetPage({ }); } return chips; - }, [auth, capability, filter, group, groupNames, query, tag, version]); + }, [access, auth, capability, filter, group, groupNames, query, tag, version]); const advancedFilterCount = useMemo( () => (auth !== 'all' ? 1 : 0) + + (access !== 'all' ? 1 : 0) + (capability ? 1 : 0) + (version ? 1 : 0) + (tag ? 1 : 0) + (group ? 1 : 0), - [auth, capability, group, tag, version], + [access, auth, capability, group, tag, version], ); useEffect(() => { @@ -648,6 +731,7 @@ export function FleetPage({ setQuery(''); setFilter('all'); setAuth('all'); + setAccess('all'); setCapability(''); setVersion(''); setTag(''); @@ -692,10 +776,15 @@ export function FleetPage({ } async function runOperation( - kind: BatchActionKind, + kind: CardActionKind, targets: ReadonlyArray<{ instanceId: string; revision: number }>, ): Promise<{ succeeded: number; failed: number }> { - const op = kind === 'service-restart' ? SERVICE_RESTART : SYSTEM_REBOOT; + const op = + kind === 'service-restart' + ? SERVICE_RESTART + : kind === 'baseband-restart' + ? BASEBAND_RESTART + : SYSTEM_REBOOT; // Full catalog sorts by operationId and truncates at pageSize 100; search for the exact id. const catalog = await resolvedOperationClient.list({ search: op.operationId, @@ -802,13 +891,18 @@ export function FleetPage({ } } - async function runCardAction(id: string, kind: BatchActionKind): Promise { + async function runCardAction(id: string, kind: CardActionKind): Promise { const instance = snapshot?.instances.find((item) => item.id === id); if (!instance?.revision || instance.revision < 1) { setCardAction({ id, busy: false, error: '缺少配置版本,请刷新后重试。' }); return; } - const title = kind === 'service-restart' ? SERVICE_RESTART.title : SYSTEM_REBOOT.title; + const title = + kind === 'service-restart' + ? SERVICE_RESTART.title + : kind === 'baseband-restart' + ? BASEBAND_RESTART.title + : SYSTEM_REBOOT.title; if ( typeof window !== 'undefined' && !window.confirm(`将对该实例执行「${title}」。此操作为高风险,确认继续?`) @@ -832,6 +926,12 @@ export function FleetPage({ } } + const closeContext = (restoreFocus = false): void => { + const id = contextId; + setContextId(undefined); + if (restoreFocus && id) contextTriggerRefs.current.get(id)?.focus(); + }; + const selectFilter = ( label: string, value: string, @@ -856,6 +956,16 @@ export function FleetPage({ ); + const contextInstance = snapshot?.instances.find((item) => item.id === contextId); + const contextStatus = contextId ? snapshot?.statuses.get(contextId) : undefined; + const contextRow = model.rows.find((row) => row.id === contextId); + const contextResources = contextStatus?.summary?.resources; + const contextPhoneNumbers = contextResources?.phoneNumbers ?? []; + const contextLatestMessage = contextId ? messageStates.get(contextId)?.latest : undefined; + const contextModules = (contextRow?.capabilities ?? []) + .map((module) => CONTEXT_MODULES.find((item) => item.module === module)) + .filter((item): item is (typeof CONTEXT_MODULES)[number] => Boolean(item)); + return (
@@ -974,6 +1084,20 @@ export function FleetPage({ : '--'} +
{selectionMode ? (
@@ -1096,6 +1220,20 @@ export function FleetPage({ + {selectFilter( '能力', capability, @@ -1152,6 +1290,15 @@ export function FleetPage({ 管理分组与标签 +
@@ -1282,6 +1429,19 @@ export function FleetPage({
+ ) : null} ); } diff --git a/apps/web/src/fleet/fleet-table-view-model.test.ts b/apps/web/src/fleet/fleet-table-view-model.test.ts index ebfe36b..2d0fb65 100644 --- a/apps/web/src/fleet/fleet-table-view-model.test.ts +++ b/apps/web/src/fleet/fleet-table-view-model.test.ts @@ -277,4 +277,74 @@ describe('fleet table view model', () => { }).emptyReason, ).toBe('filter'); }); + + it('marks and searches the nodes the identity guard is holding', () => { + const instances: FleetInstance[] = [ + { id: 'held', name: 'Held', url: 'http://a', tags: [] }, + { id: 'clear', name: 'Clear', url: 'http://b', tags: [] }, + { id: 'blind', name: 'Blind', url: 'http://c', tags: [] }, + ]; + const statuses = new Map([ + [ + 'held', + { + reachable: true, + identity: { tracked: true, status: 'pending', reasons: ['imei_claimed'] }, + }, + ], + ['clear', { reachable: true, identity: { tracked: true, status: 'confirmed', reasons: [] } }], + [ + 'blind', + { reachable: true, identity: { tracked: false, status: 'confirmed', reasons: [] } }, + ], + ]); + + const rows = buildFleetTableViewModel(instances, statuses).rows; + expect(rows.map((row) => [row.id, row.identityPending])).toEqual([ + ['blind', false], + ['clear', false], + ['held', true], + ]); + expect( + buildFleetTableViewModel(instances, statuses, { query: '身份待确认' }).rows.map((r) => r.id), + ).toEqual(['held']); + }); + + it('filters and searches by derived access method and reported IMEI', () => { + const scoped: readonly FleetInstance[] = [ + { id: 'local', name: 'Local', url: 'http://127.0.0.1:3000', tags: [] }, + { id: 'lan', name: 'Lan', url: 'http://192.168.1.3', tags: [] }, + { id: 'wan', name: 'Wan', url: 'https://wan.example', tags: [] }, + ]; + const model = buildFleetTableViewModel(scoped, new Map(), { access: 'local' }); + expect(model.rows.map((row) => row.id)).toEqual(['local']); + expect(buildFleetTableViewModel(instances, statuses, { access: 'lan' }).emptyReason).toBe( + 'filter', + ); + expect( + buildFleetTableViewModel(scoped, new Map(), { query: 'lan' }).rows.map((r) => r.id), + ).toEqual(['lan']); + + const identified = new Map([ + [ + 'alpha', + { + reachable: true, + identity: { + tracked: true, + status: 'confirmed', + reasons: [], + imei: '860000000000001', + }, + }, + ], + ]); + expect( + buildFleetTableViewModel( + [{ id: 'alpha', name: 'Alpha', url: 'http://alpha.example', tags: [] }], + identified, + { query: '860000000000001' }, + ).rows.map((row) => row.id), + ).toEqual(['alpha']); + }); }); diff --git a/apps/web/src/fleet/fleet-table-view-model.ts b/apps/web/src/fleet/fleet-table-view-model.ts index 11b5a14..f2f3ba2 100644 --- a/apps/web/src/fleet/fleet-table-view-model.ts +++ b/apps/web/src/fleet/fleet-table-view-model.ts @@ -1,6 +1,7 @@ export type FleetStatusKind = 'online' | 'auth' | 'offline' | 'unknown'; export type FleetFilter = 'all' | FleetStatusKind; export type FleetAuthFilter = 'all' | 'authenticated' | 'required' | 'unknown'; +export type FleetAccessFilter = 'all' | 'local' | 'lan' | 'wan'; export type FleetSortColumn = | 'name' | 'status' @@ -34,6 +35,8 @@ export interface FleetStatus { /** When the heartbeat last checked this device; null means it has never run. */ readonly checkedAt?: string | null; readonly latencyMs?: number; + /** Device identity guard verdict for this node, from the fleet overview. */ + readonly identity?: FleetIdentityState; readonly summary?: Readonly< Record & { resources?: Readonly<{ @@ -60,6 +63,7 @@ export interface FleetTableOptions { readonly query?: string; readonly filter?: FleetFilter; readonly auth?: FleetAuthFilter; + readonly access?: FleetAccessFilter; readonly capability?: string; readonly version?: string; readonly tag?: string; @@ -74,6 +78,15 @@ export interface FleetTableOptions { export type FleetFreshness = 'fresh' | 'stale' | 'unknown'; +export interface FleetIdentityState { + /** False when the node has never reported hardware identity; nothing to confirm yet. */ + readonly tracked: boolean; + readonly status: 'confirmed' | 'pending'; + readonly reasons: readonly string[]; + /** Filter/search only; display stays masked through the console-wide sensitive-data switch. */ + readonly imei?: string; +} + export interface FleetTableRow { readonly id: string; readonly displayName: string; @@ -88,6 +101,10 @@ export interface FleetTableRow { readonly groupId: string | null; readonly freshness: FleetFreshness; readonly anomalies: readonly string[]; + /** The identity guard is holding this node until an operator confirms the device. */ + readonly identityPending: boolean; + readonly accessKind: 'local' | 'lan' | 'wan' | null; + readonly accessLabel: string | null; } export interface VisibleSelection { @@ -136,6 +153,54 @@ function displayName(instance: FleetInstance): string { return name ? name : instance.id; } +const PRIVATE_NETWORKS = [ + /^10\./u, + /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./u, + /^127\./u, + /^169\.254\./u, + /^172\.(1[6-9]|2\d|3[01])\./u, + /^192\.168\./u, + /^(?:fe80|fc|fd)/u, +]; + +/** + * How the control plane reaches the node. Hub labels the same idea 接入方式, and the answer is + * already implied by the origin, so it is derived rather than stored. + */ +export function accessMethod( + value: string, +): Readonly<{ label: string; kind: 'local' | 'lan' | 'wan' }> | null { + const origin = canonicalHttpOrigin(value); + if (!origin) return null; + let host: string | null; + try { + host = new URL(origin).hostname; + } catch { + return null; + } + if (!host) return null; + const bare = host.replace(/^\[(.*)\]$/u, '$1').toLowerCase(); + if (bare === 'localhost' || bare === '::1' || /^127\./u.test(bare)) + return { label: '本机直连', kind: 'local' }; + if (/^\d+\.\d+\.\d+\.\d+$/u.test(bare) || /^[0-9a-f:]+$/u.test(bare)) + return PRIVATE_NETWORKS.some((pattern) => pattern.test(bare)) + ? { label: '局域网接入', kind: 'lan' } + : { label: '公网地址', kind: 'wan' }; + return { label: '域名接入', kind: 'wan' }; +} + +export function canonicalHttpOrigin(value: string): string | null { + try { + const url = new URL(value); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; + url.username = ''; + url.password = ''; + return url.origin; + } catch { + return null; + } +} + function summaryString(status: FleetStatus | undefined, key: string): string | null { const value = status?.summary?.[key]; return typeof value === 'string' && value.trim() ? value.trim() : null; @@ -150,6 +215,7 @@ function summaryStrings(status: FleetStatus | undefined, key: string): readonly } function metadata(instance: FleetInstance, status: FleetStatus | undefined) { + const access = accessMethod(instance.url); const freshnessValue = summaryString(status, 'freshness'); const freshness: FleetFreshness = freshnessValue === 'fresh' || freshnessValue === 'stale' ? freshnessValue : 'unknown'; @@ -161,6 +227,10 @@ function metadata(instance: FleetInstance, status: FleetStatus | undefined) { typeof instance.groupId === 'string' && instance.groupId.trim() ? instance.groupId : null, freshness, anomalies: summaryStrings(status, 'anomalies'), + identityPending: status?.identity?.tracked === true && status.identity.status === 'pending', + ...(access + ? { accessKind: access.kind, accessLabel: access.label } + : { accessKind: null, accessLabel: null }), }; } @@ -198,8 +268,12 @@ function searchableText( groupName, details.version ?? '', ...details.capabilities, + status?.identity?.imei ?? '', + details.accessLabel ?? '', + details.accessKind ?? '', details.freshness, ...details.anomalies, + details.identityPending ? '身份待确认' : '', status?.summary === undefined ? '' : JSON.stringify(status.summary), ] .join(' ') @@ -292,6 +366,8 @@ export function buildFleetTableViewModel( const details = metadata(instance, status); if (filter !== 'all' && fleetStatusKind(status) !== filter) return false; if (!authenticationMatches(status, auth)) return false; + if (options.access && options.access !== 'all' && details.accessKind !== options.access) + return false; if (options.capability && !details.capabilities.includes(options.capability)) return false; if (options.version && details.version !== options.version) return false; if (options.tag && !details.tags.includes(options.tag)) return false; @@ -323,6 +399,7 @@ export function buildFleetTableViewModel( const visibleSelectedCount = rows.reduce((count, row) => count + Number(row.selected), 0); const hasMetadataFilter = auth !== 'all' || + Boolean(options.access && options.access !== 'all') || Boolean(options.capability || options.version || options.tag || options.group); const emptyReason: EmptyFleetReason | null = instances.length === 0 diff --git a/apps/web/src/fleet/identity-api-data-source.test.ts b/apps/web/src/fleet/identity-api-data-source.test.ts new file mode 100644 index 0000000..327d529 --- /dev/null +++ b/apps/web/src/fleet/identity-api-data-source.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createIdentityApiDataSource } from './identity-api-data-source.js'; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +const identity = { + instanceId: 'node-a', + instanceName: '机房 A', + status: 'pending', + reasons: ['imei_claimed'], + conflicts: [ + { reason: 'imei_claimed', instanceIds: ['node-b'], instanceNames: ['机房 B'] }, + { reason: 'made-up', instanceIds: ['x'], instanceNames: [] }, + ], + imei: '860000000000001', + manufacturer: 'Quectel', + model: 'RM500Q', + revision: '', + agent: '', + origin: '', + fingerprint: 'f'.repeat(64), + confirmedFingerprint: 'c'.repeat(64), + changes: [ + { field: 'model', from: 'RM500Q', to: 'RG500Q' }, + { field: 'unknown', from: 'a', to: 'b' }, + ], + observedAt: '2026-09-05T02:00:00.000Z', + confirmedAt: '2026-09-01T02:00:00.000Z', +}; + +describe('identity API data source', () => { + it('reads the guard view and drops entries it cannot trust', async () => { + const fetcher = vi.fn().mockResolvedValue( + json({ + items: [identity, { status: 'pending' }, null], + summary: { tracked: 5, pending: 2 }, + }), + ); + + const overview = await createIdentityApiDataSource(fetcher).list(); + + expect(fetcher).toHaveBeenCalledWith('/api/v1/fleet/identities', { + method: 'GET', + credentials: 'same-origin', + headers: { accept: 'application/json' }, + }); + expect(overview.tracked).toBe(5); + expect(overview.pending).toBe(2); + expect(overview.items).toHaveLength(1); + const [item] = overview.items; + expect(item?.status).toBe('pending'); + expect(item?.reasons).toEqual(['imei_claimed']); + // Only the reason the server actually explains survives. + expect(item?.conflicts).toEqual([ + { reason: 'imei_claimed', instanceIds: ['node-b'], instanceNames: ['机房 B'] }, + ]); + expect(item?.changes).toEqual([{ field: 'model', from: 'RM500Q', to: 'RG500Q' }]); + }); + + it('degrades to an empty view when the payload is not shaped like a list', async () => { + const fetcher = vi.fn().mockResolvedValue(json({ items: 'nope' })); + await expect(createIdentityApiDataSource(fetcher).list()).resolves.toEqual({ + items: [], + tracked: 0, + pending: 0, + }); + }); + + it('confirms a node through the documented route', async () => { + const fetcher = vi.fn().mockResolvedValue(json({ identity })); + const confirmed = await createIdentityApiDataSource(fetcher).confirm('node a/1'); + expect(fetcher).toHaveBeenCalledWith( + '/api/v1/fleet/identities/node%20a%2F1/confirm', + expect.objectContaining({ method: 'POST', body: '{}' }), + ); + expect(confirmed.instanceId).toBe('node-a'); + }); + + it('reports a refresh that found no identity on the device', async () => { + const fetcher = vi.fn().mockResolvedValue(json({ observed: false })); + await expect(createIdentityApiDataSource(fetcher).refresh('node-a')).resolves.toEqual({ + observed: false, + }); + }); + + it('fails loudly when the control plane refuses', async () => { + const fetcher = vi.fn().mockResolvedValue(json({ code: 'NOT_FOUND' }, 404)); + await expect(createIdentityApiDataSource(fetcher).confirm('ghost')).rejects.toThrow('404'); + }); +}); diff --git a/apps/web/src/fleet/identity-api-data-source.ts b/apps/web/src/fleet/identity-api-data-source.ts new file mode 100644 index 0000000..9c29b8e --- /dev/null +++ b/apps/web/src/fleet/identity-api-data-source.ts @@ -0,0 +1,186 @@ +export type IdentityStatus = 'confirmed' | 'pending'; + +export type IdentityReason = 'hardware_swapped' | 'imei_claimed'; + +export type IdentityField = 'imei' | 'manufacturer' | 'model' | 'revision'; + +export interface IdentityConflict { + readonly reason: IdentityReason; + readonly instanceIds: readonly string[]; + readonly instanceNames: readonly string[]; +} + +export interface IdentityChange { + readonly field: IdentityField; + readonly from: string; + readonly to: string; +} + +export interface DeviceIdentity { + readonly instanceId: string; + readonly instanceName: string; + readonly status: IdentityStatus; + readonly reasons: readonly IdentityReason[]; + readonly conflicts: readonly IdentityConflict[]; + readonly imei: string; + readonly manufacturer: string; + readonly model: string; + readonly revision: string; + readonly agent: string; + readonly origin: string; + readonly fingerprint: string; + readonly confirmedFingerprint: string; + readonly changes: readonly IdentityChange[]; + readonly observedAt: string; + readonly confirmedAt: string; +} + +export interface IdentityOverview { + readonly items: readonly DeviceIdentity[]; + readonly tracked: number; + readonly pending: number; +} + +export interface IdentityDataSource { + list(signal?: AbortSignal): Promise; + confirm(instanceId: string): Promise; + /** One device read; `observed` is false when the node answered without any identity field. */ + refresh(instanceId: string): Promise<{ observed: boolean; identity?: DeviceIdentity }>; +} + +const REASONS: ReadonlySet = new Set(['hardware_swapped', 'imei_claimed']); +const FIELDS: ReadonlySet = new Set(['imei', 'manufacturer', 'model', 'revision']); + +const text = (value: unknown): string => (typeof value === 'string' ? value : ''); + +const boundedText = (value: unknown, maximum: number): string => { + const raw = text(value); + return raw.length > maximum ? raw.slice(0, maximum) : raw; +}; + +function reasons(value: unknown): readonly IdentityReason[] { + if (!Array.isArray(value)) return []; + return value.filter( + (item): item is IdentityReason => typeof item === 'string' && REASONS.has(item), + ); +} + +function conflicts(value: unknown): readonly IdentityConflict[] { + if (!Array.isArray(value)) return []; + const parsed: IdentityConflict[] = []; + for (const entry of value) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + const item = entry as Record; + const reason = text(item.reason); + if (!REASONS.has(reason)) continue; + parsed.push({ + reason: reason as IdentityReason, + instanceIds: Array.isArray(item.instanceIds) + ? item.instanceIds.map(text).filter(Boolean) + : [], + instanceNames: Array.isArray(item.instanceNames) + ? item.instanceNames.map(text).filter(Boolean) + : [], + }); + } + return parsed; +} + +function changes(value: unknown): readonly IdentityChange[] { + if (!Array.isArray(value)) return []; + const parsed: IdentityChange[] = []; + for (const entry of value) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + const item = entry as Record; + const field = text(item.field); + if (!FIELDS.has(field)) continue; + parsed.push({ + field: field as IdentityField, + from: boundedText(item.from, 64), + to: boundedText(item.to, 64), + }); + } + return parsed; +} + +function parseIdentity(value: unknown): DeviceIdentity | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const item = value as Record; + const instanceId = boundedText(item.instanceId, 256); + if (!instanceId) return undefined; + return { + instanceId, + instanceName: boundedText(item.instanceName, 200), + status: item.status === 'pending' ? 'pending' : 'confirmed', + reasons: reasons(item.reasons), + conflicts: conflicts(item.conflicts), + imei: boundedText(item.imei, 64), + manufacturer: boundedText(item.manufacturer, 128), + model: boundedText(item.model, 128), + revision: boundedText(item.revision, 128), + agent: boundedText(item.agent, 128), + origin: boundedText(item.origin, 512), + fingerprint: boundedText(item.fingerprint, 128), + confirmedFingerprint: boundedText(item.confirmedFingerprint, 128), + changes: changes(item.changes), + observedAt: boundedText(item.observedAt, 64), + confirmedAt: boundedText(item.confirmedAt, 64), + }; +} + +async function json(response: Response): Promise { + if (!response.ok) throw new Error(`Identity request failed (${response.status})`); + return (await response.json()) as T; +} + +const HEADERS = { accept: 'application/json' } as const; + +function postJson(): RequestInit { + return { + method: 'POST', + credentials: 'same-origin', + headers: { ...HEADERS, 'content-type': 'application/json' }, + body: '{}', + }; +} + +const path = (instanceId: string, action: string): string => + `/api/v1/fleet/identities/${encodeURIComponent(instanceId)}/${action}`; + +export function createIdentityApiDataSource(fetcher: typeof fetch = fetch): IdentityDataSource { + return { + async list(signal) { + const body = await json( + await fetcher('/api/v1/fleet/identities', { + method: 'GET', + credentials: 'same-origin', + headers: HEADERS, + ...(signal ? { signal } : {}), + }), + ); + const source = (body ?? {}) as Record; + const items = (Array.isArray(source.items) ? source.items : []) + .map(parseIdentity) + .filter((item): item is DeviceIdentity => item !== undefined); + const summary = (source.summary ?? {}) as Record; + const count = (value: unknown): number => + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0; + return { items, tracked: count(summary.tracked), pending: count(summary.pending) }; + }, + async confirm(instanceId) { + const body = await json(await fetcher(path(instanceId, 'confirm'), postJson())); + const identity = parseIdentity((body as Record).identity); + if (!identity) throw new Error('Identity response is invalid.'); + return identity; + }, + async refresh(instanceId) { + const body = await json(await fetcher(path(instanceId, 'refresh'), postJson())); + const source = (body ?? {}) as Record; + const identity = parseIdentity(source.identity); + return { + observed: source.observed === true, + ...(identity ? { identity } : {}), + }; + }, + }; +} diff --git a/apps/web/src/fleet/notification-center-api.test.ts b/apps/web/src/fleet/notification-center-api.test.ts index 4cf3906..3e72110 100644 --- a/apps/web/src/fleet/notification-center-api.test.ts +++ b/apps/web/src/fleet/notification-center-api.test.ts @@ -18,7 +18,15 @@ describe('notification center API data source', () => { const fetcher = vi.fn().mockResolvedValue( json({ observedAt: '2026-09-03T01:00:00.000Z', - devices: [{ id: 'device-1', name: 'Modem A', state: 'ready', tags: ['office'] }], + devices: [ + { + id: 'device-1', + name: 'Modem A', + state: 'ready', + tags: ['office'], + groupId: 'group-lab', + }, + ], config: { channelCount: 2, channelEnabled: 1, @@ -44,7 +52,7 @@ describe('notification center API data source', () => { expect(fetcher).toHaveBeenCalledWith('/api/v1/notifications/overview', expect.anything()); expect(snapshot.devices).toEqual([ - { id: 'device-1', name: 'Modem A', state: 'ready', tags: ['office'] }, + { id: 'device-1', name: 'Modem A', state: 'ready', tags: ['office'], groupId: 'group-lab' }, ]); expect(snapshot.queue.pending).toBe(2); expect(snapshot.queue.total).toBe(5); diff --git a/apps/web/src/fleet/notification-center-api.ts b/apps/web/src/fleet/notification-center-api.ts index e4458d3..49e136f 100644 --- a/apps/web/src/fleet/notification-center-api.ts +++ b/apps/web/src/fleet/notification-center-api.ts @@ -153,11 +153,13 @@ export function createNotificationCenterApiDataSource( ...(draft.condition.mode === 'all' ? {} : { value: draft.condition.value }), }, scope: - draft.scope.mode === 'tags' - ? { mode: 'tags', tags: [...draft.scope.tags], match: draft.scope.match } - : draft.scope.mode === 'devices' - ? { mode: 'devices', instanceIds: [...draft.scope.instanceIds] } - : { mode: 'all' }, + draft.scope.mode === 'groups' + ? { mode: 'groups', groups: [...draft.scope.groups] } + : draft.scope.mode === 'tags' + ? { mode: 'tags', tags: [...draft.scope.tags], match: draft.scope.match } + : draft.scope.mode === 'devices' + ? { mode: 'devices', instanceIds: [...draft.scope.instanceIds] } + : { mode: 'all' }, channelIds: [...draft.channelIds], templates: { title: draft.templates.title, body: draft.templates.body }, ...(draft.rateLimit === undefined ? {} : { rateLimit: draft.rateLimit }), diff --git a/apps/web/src/fleet/notification-center-sanitize.ts b/apps/web/src/fleet/notification-center-sanitize.ts index 828cedb..6300842 100644 --- a/apps/web/src/fleet/notification-center-sanitize.ts +++ b/apps/web/src/fleet/notification-center-sanitize.ts @@ -152,6 +152,7 @@ export function sanitizeNotificationRule(value: unknown): NotificationRule | und }), scope: Object.freeze({ mode: text(scope?.mode, 20) || 'all', + groups: Object.freeze(stringList(scope?.groups)), tags: Object.freeze(stringList(scope?.tags)), match: text(scope?.match, 20) || 'any', instanceIds: Object.freeze(stringList(scope?.instanceIds)), @@ -219,6 +220,7 @@ function deviceList(value: unknown): readonly NotificationDevice[] { name: text(entry.name, 160) || '未命名设备', state: text(entry.state, 40) || 'unavailable', tags: Object.freeze(stringList(entry.tags)), + groupId: typeof entry.groupId === 'string' && entry.groupId ? entry.groupId : null, }), ), ); diff --git a/apps/web/src/fleet/notification-center-types.ts b/apps/web/src/fleet/notification-center-types.ts index 3d7075f..e462072 100644 --- a/apps/web/src/fleet/notification-center-types.ts +++ b/apps/web/src/fleet/notification-center-types.ts @@ -40,6 +40,7 @@ export interface NotificationRule { readonly condition: { readonly field: string; readonly mode: string; readonly value: string }; readonly scope: { readonly mode: string; + readonly groups: readonly string[]; readonly tags: readonly string[]; readonly match: string; readonly instanceIds: readonly string[]; @@ -92,6 +93,7 @@ export interface NotificationDevice { readonly name: string; readonly state: string; readonly tags: readonly string[]; + readonly groupId: string | null; } export interface NotificationCenterSnapshot { @@ -142,6 +144,7 @@ export interface NotificationRuleDraft { readonly condition: { readonly field: string; readonly mode: string; readonly value: string }; readonly scope: { readonly mode: string; + readonly groups: readonly string[]; readonly tags: readonly string[]; readonly match: string; readonly instanceIds: readonly string[];