import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; import { buildFleetTableViewModel, type FleetAuthFilter, type FleetFilter, type FleetInstance, type FleetSortColumn, type FleetStatus, type SortDirection, } from './fleet-table-view-model.js'; import type { InstanceDataSource } from '../instances/instance-crud.js'; import type { FleetMessageLoadState, FleetMessagesDataSource, } from './fleet-messages-api-data-source.js'; import { loadFleetMessageSummaries } from './fleet-messages-api-data-source.js'; export interface FleetSnapshot { readonly instances: readonly FleetInstance[]; readonly statuses: ReadonlyMap; } export interface FleetDataSource { load(signal?: AbortSignal): Promise; } export interface FleetPageProps { readonly dataSource?: FleetDataSource; readonly messagesDataSource?: FleetMessagesDataSource; readonly instanceDataSource?: InstanceDataSource; readonly initialData?: FleetSnapshot; readonly refreshSignal?: number; } type FleetMessageState = FleetMessageLoadState; const EMPTY_SNAPSHOT: FleetSnapshot = { instances: [], statuses: new Map() }; const STATUS_LABELS: Readonly> = { online: '在线', auth: '需要认证', offline: '离线', unknown: '未知', }; const COLUMN_LABELS: Readonly> = { name: '名称', status: '状态', latency: '延迟', version: '版本', capabilities: '能力', tags: '标签', freshness: '数据新鲜度', anomalies: '异常', origin: '源地址', }; const ALL_COLUMNS = Object.keys(COLUMN_LABELS) as readonly (FleetSortColumn | 'origin')[]; const CAPABILITY_LABELS: Readonly> = { overview: '概览', cellular: '蜂窝网络', 'device-network': '设备网络', messages: '消息', calls: '通话', esim: 'eSIM', notifications: '通知', automation: '自动化', ota: 'OTA', }; const FRESHNESS_LABELS: Readonly> = { fresh: '最新', stale: '可能过期', unknown: '未知', }; function capabilityLabel(value: string): string { return CAPABILITY_LABELS[value] ?? value; } const percent = (value: number | undefined): string => value === undefined ? '暂未获取' : `${value.toFixed(1)}%`; const temperature = (value: number | undefined): string => value === undefined ? '暂未获取' : `${value.toFixed(1)} °C`; const messageDirection = (value: string): string => value === 'incoming' || value === 'received' ? '收到' : value === 'outgoing' || value === 'sent' ? '发送' : '未知方向'; const messageTime = (value: string): string => { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN'); }; 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; } } export function FleetPage({ dataSource, messagesDataSource, instanceDataSource, initialData, refreshSignal = 0, }: FleetPageProps) { const [snapshot, setSnapshot] = useState(initialData ?? null); const [error, setError] = useState(null); const [attempt, setAttempt] = useState(0); const [query, setQuery] = useState(''); const [filter, setFilter] = useState('all'); const [auth, setAuth] = useState('all'); const [capability, setCapability] = useState(''); const [version, setVersion] = useState(''); const [tag, setTag] = useState(''); const [page, setPage] = useState(1); const [sort, setSort] = useState<{ column: FleetSortColumn; direction: SortDirection }>({ column: 'name', direction: 'asc', }); const [selectedIds, setSelectedIds] = useState>(new Set()); 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); const messagesOwner = useRef(0); const [messageStates, setMessageStates] = useState>( new Map(), ); useEffect(() => { if (initialData && refreshSignal === 0) { setSnapshot(initialData); setError(null); return; } if (!dataSource) { setSnapshot(EMPTY_SNAPSHOT); return; } let active = true; setSnapshot(null); setError(null); void dataSource.load().then( (data) => { if (active) setSnapshot(data); }, () => { if (active) setError('实例加载失败。'); }, ); return () => { active = false; }; }, [attempt, dataSource, initialData, refreshSignal]); useEffect(() => { const request = ++messagesOwner.current; const controller = new AbortController(); const instances = snapshot?.instances; setMessageStates(new Map()); if (!messagesDataSource || !instances) return () => controller.abort(); void loadFleetMessageSummaries( messagesDataSource, instances.map((instance) => instance.id), controller.signal, ).then((entries) => { if (request === messagesOwner.current && !controller.signal.aborted) setMessageStates(entries); }); return () => controller.abort(); }, [messagesDataSource, snapshot]); const model = useMemo( () => buildFleetTableViewModel(snapshot?.instances ?? [], snapshot?.statuses ?? new Map(), { query, filter, auth, ...(capability ? { capability } : {}), ...(version ? { version } : {}), ...(tag ? { tag } : {}), sort, selectedIds, page, }), [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) { selectAllRef.current.indeterminate = model.visibleSelection.indeterminate; } }, [model.visibleSelection.indeterminate]); useEffect(() => { if (page !== model.page) setPage(model.page); }, [model.page, page]); function resetPage(action: () => void): void { action(); setPage(1); } function changeSort(column: FleetSortColumn): void { setSort((current) => ({ column, direction: current.column === column && current.direction === 'asc' ? 'desc' : 'asc', })); setPage(1); } function toggleOne(id: string, checked: boolean): void { setSelectedIds((current) => { const next = new Set(current); if (checked) next.add(id); else next.delete(id); return next; }); } function toggleVisible(checked: boolean): void { setSelectedIds((current) => { const next = new Set(current); for (const row of model.rows) { if (checked) next.add(row.id); else next.delete(row.id); } return next; }); } function toggleColumn(column: string): void { setShownColumns((current) => { const next = new Set(current); if (next.has(column)) next.delete(column); else next.add(column); return next; }); } 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, setValue: (value: string) => void, choices: readonly string[], allLabel: string, displayChoice: (choice: string) => string = (choice) => choice, ) => ( ); return (

实例总览

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

添加实例 已选择 {model.selectedIds.length} 项
{snapshot ? (
实例总数 {fleetSummary.total}
在线 {fleetSummary.online}
需处理 {fleetSummary.attention}
) : null} {batchOpen ? (
请为已选择的 {model.selectedIds.length} 项选择操作。
) : 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 ? (

正在加载实例…

) : null} {error ? (

无法加载实例。{error}

) : null} {snapshot && model.emptyReason ? (
{model.emptyReason === 'config' ? ( <>

尚未配置实例。

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

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

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

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

: null}
) : null} {snapshot && model.rows.length > 0 ? ( <>
{model.rows.map((row) => (

{row.displayName}

{row.id} 进入仪表盘
{STATUS_LABELS[row.statusKind]}
手机号
{row.status?.summary?.resources?.phoneNumbers?.join('、') || '暂未获取'}
CPU
{row.status?.summary?.resources?.cpuPercent === undefined ? ( '暂未获取' ) : ( <> {percent(row.status.summary.resources.cpuPercent)} )}
内存
{row.status?.summary?.resources?.memoryPercent === undefined ? ( '暂未获取' ) : ( <> {percent(row.status.summary.resources.memoryPercent)} )}
最高温度
{temperature(row.status?.summary?.resources?.maxTemperatureCelsius)}
{row.capabilities.length > 0 ? (
{row.capabilities.map((item) => ( {capabilityLabel(item)} ))}
) : null}

最近短信

{messageStates.get(row.id)?.unavailable ? (

短信暂不可用

) : messageStates.get(row.id)?.latest ? (
方向
{messageDirection(messageStates.get(row.id)!.latest!.direction)}
号码
{messageStates.get(row.id)!.latest!.phoneNumber}
时间
{messageTime(messageStates.get(row.id)!.latest!.timestamp)}
) : (

暂无短信

)}
编辑
{deleteId === row.id ? (

删除后无法撤销。

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

{deleteStatus.message}

) : null}
))}

高级详细列表

{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]} ))} ); })}
实例列表
{column === 'origin' ? ( COLUMN_LABELS[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} : 源地址无效}
) : null}
); }