import { useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'; 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, } from './fleet-messages-api-data-source.js'; 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, type OrganizationGroup, } from './organization-api-data-source.js'; import { createOperationClient, type OperationClient } from '../operations/operation-client.js'; import { safeUiError } from '../ui/locale.js'; import { Icon, type IconName } from '../ui/icon.js'; export interface FleetSnapshot { readonly instances: readonly FleetInstance[]; readonly statuses: ReadonlyMap; } export interface FleetDataSource { load(signal?: AbortSignal, onPartial?: (snapshot: FleetSnapshot) => void): Promise; } 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; readonly initialData?: FleetSnapshot; readonly refreshSignal?: number; readonly operationClient?: OperationClient; } type FleetMessageState = FleetMessageLoadState; const EMPTY_SNAPSHOT: FleetSnapshot = { instances: [], statuses: new Map() }; const STATUS_LABELS: Readonly> = { online: '在线', auth: '需要认证', offline: '离线', unknown: '未知', }; const STATUS_COLORS = { online: 'app-teal', auth: 'app-yellow', offline: 'app-red', unknown: 'brown', } as const; const SORT_COLUMN_LABELS: Readonly> = { name: '名称', status: '状态', latency: '延迟', version: '版本', capabilities: '能力', tags: '标签', group: '分组', freshness: '数据新鲜度', anomalies: '异常', }; const CAPABILITY_LABELS: Readonly> = { overview: '概览', cellular: '蜂窝网络', 'device-network': '设备网络', messages: '消息', calls: '通话', esim: 'eSIM', notifications: '通知', automation: '自动化', ota: 'OTA', }; function capabilityLabel(value: string): string { return CAPABILITY_LABELS[value] ?? value; } const CAPABILITY_ICONS: Readonly> = { overview: 'grid', cellular: 'activity', 'device-network': 'wifi', messages: 'message', sms: 'message', calls: 'phone', esim: 'server', notifications: 'alert', automation: 'restart', ota: 'version', }; function initials(value: string): string { const parts = value.trim().split(/\s+/u).filter(Boolean); if (parts.length > 1) return parts .slice(0, 2) .map((item) => item[0]) .join('') .toUpperCase(); return [...(parts[0] ?? 'S')].slice(0, 2).join('').toUpperCase(); } const percent = (value: number | undefined): string => value === undefined ? '--' : `${value.toFixed(1)}%`; const registrationLabel = (value: string | undefined): string => { if (value === 'registered_home' || value === 'registered_roaming') return '已注册'; if (value === 'searching') return '搜网中'; if (value === 'registration_denied') return '受限'; if (value === 'not_registered') return '未注册'; return '状态未知'; }; const booleanStateLabel = (value: boolean | undefined): string => value === undefined ? '未知' : value ? '正常' : '断开'; const temperature = (value: number | undefined): string => value === undefined ? '--' : `${value.toFixed(1)} °C`; /** Device uptime in the compact 天/小时/分 shape the Hub device list uses. */ function formatUptime(seconds: number | undefined): string { if (seconds === undefined) return '--'; const total = Math.max(0, Math.floor(seconds)); const days = Math.floor(total / 86400); const hours = Math.floor((total % 86400) / 3600); const minutes = Math.floor((total % 3600) / 60); if (days > 0) return `${days}天 ${hours}小时`; if (hours > 0) return `${hours}小时 ${minutes}分`; if (minutes > 0) return `${minutes}分`; return `${total}秒`; } 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'); }; /** Heartbeat age in words; the console polls often enough that seconds stay meaningful. */ function heartbeatAge(value: string, now: number): string { const timestamp = Date.parse(value); if (Number.isNaN(timestamp)) return '时间未知'; const seconds = Math.max(0, Math.round((now - timestamp) / 1000)); if (seconds < 60) return `${seconds} 秒前`; if (seconds < 3600) return `${Math.floor(seconds / 60)} 分钟前`; if (seconds < 86400) return `${Math.floor(seconds / 3600)} 小时前`; return `${Math.floor(seconds / 86400)} 天前`; } type BatchActionKind = 'service-restart' | 'system-reboot'; type CardActionKind = BatchActionKind | 'baseband-restart'; type BatchProgress = Readonly<{ action: CardActionKind; total: number; completed: number; succeeded: number; failed: number; message: string; }>; const SERVICE_RESTART = { operationId: 'postServiceRestart', 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', title: '系统重启', delaySeconds: 3, } as const; export function FleetPage({ dataSource, messagesDataSource, organizationDataSource, identityDataSource, groups: controlledGroups, initialData, refreshSignal = 0, operationClient, onGroupsChanged, }: FleetPageProps) { const { revealed: sensitive } = useSensitiveReveal(); 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 [access, setAccess] = useState('all'); const [capability, setCapability] = useState(''); const [version, setVersion] = useState(''); const [tag, setTag] = useState(''); const [group, setGroup] = useState(''); 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', direction: 'asc', }); const [selectedIds, setSelectedIds] = useState>(new Set()); const [revealedPhoneIds, setRevealedPhoneIds] = useState>(new Set()); const [selectionMode, setSelectionMode] = useState(false); const [filtersCollapsed, setFiltersCollapsed] = useState(false); const [batchBusy, setBatchBusy] = useState(false); const [batchProgress, setBatchProgress] = useState(null); const [cardAction, setCardAction] = useState<{ id: string; busy: boolean; message?: string; error?: string; } | null>(null); const [cardMenuId, setCardMenuId] = useState(); 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(), ); const resolvedOperationClient = useMemo( () => operationClient ?? createOperationClient(), [operationClient], ); const resolvedOrganizationDataSource = useMemo( () => 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. if (controlledGroups) return; const controller = new AbortController(); let active = true; void resolvedOrganizationDataSource .listGroups(controller.signal) .then((items) => { if (!active) return; setOwnGroups(items); }) .catch(() => { /* Group filtering degrades to "all nodes" when the registry is unavailable. */ }); return () => { active = false; controller.abort(); }; }, [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); setError(null); return; } if (!dataSource) { setSnapshot(EMPTY_SNAPSHOT); return; } let active = true; const controller = new AbortController(); // Keep the last known instances visible while refreshing so the homepage stays interactive. setSnapshot((current) => current); setError(null); void dataSource .load(controller.signal, (partial) => { if (active) setSnapshot(partial); }) .then( (data) => { if (active) setSnapshot(data); }, (reason: unknown) => { if (!active) return; if (reason instanceof DOMException && reason.name === 'AbortError') return; setError('实例加载失败。'); }, ); return () => { active = false; controller.abort(); }; }, [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]); useEffect(() => { if (!cardMenuId) return; const items = cardMenuPanelRef.current?.querySelectorAll( '[role="menuitem"]:not(:disabled)', ); if (!items?.length) return; const index = cardMenuInitialFocus.current === 'last' ? items.length - 1 : 0; items[index]?.focus(); }, [cardMenuId]); useEffect(() => { if (!cardMenuId) return; const dismissFromOutside = (event: PointerEvent) => { const target = event.target; if (!(target instanceof Node)) return; if ( cardMenuPanelRef.current?.contains(target) || cardMenuTriggerRefs.current.get(cardMenuId)?.contains(target) ) return; setCardMenuId(undefined); }; document.addEventListener('pointerdown', dismissFromOutside); 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); }; const moveCardMenuFocus = (event: KeyboardEvent, id: string) => { if (event.key === 'Escape') { event.preventDefault(); closeCardMenu(id, true); return; } if (event.key === 'Tab') { setCardMenuId(undefined); return; } if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; const items = Array.from( event.currentTarget.querySelectorAll('[role="menuitem"]:not(:disabled)'), ); if (!items.length) return; event.preventDefault(); const currentIndex = items.indexOf(document.activeElement as HTMLElement); const nextIndex = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : event.key === 'ArrowDown' ? (currentIndex + 1) % items.length : (currentIndex - 1 + items.length) % items.length; items[nextIndex]?.focus(); }; const model = useMemo( () => 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, snapshot, sort, tag, version, ], ); const fleetSummary = useMemo(() => { const statuses = [...(snapshot?.statuses.values() ?? [])]; const online = statuses.filter( (status) => status.reachable && status.authenticated !== false, ).length; // Mirrors the Hub health rule: a node counts as usable only when the radio chain is up. const cellular = statuses.filter((status) => { const resources = status.summary?.resources; return ( status.reachable && status.authenticated !== false && resources?.hardwareOnline === true && resources?.controlOnline === true && resources?.simPresent === true && resources?.cellularOnline === true ); }).length; return { total: snapshot?.instances.length ?? 0, online, cellular, attention: (snapshot?.instances.length ?? 0) - online, }; }, [snapshot]); const smsUnavailableCount = useMemo( () => [...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; failed: number; } | null>(null); useEffect(() => { const controller = new AbortController(); let active = true; void (async () => { try { const response = await fetch('/api/v1/notifications/overview', { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' }, signal: controller.signal, }); if (!response.ok) throw new Error('notification health request failed'); const body = (await response.json()) as unknown; const source = body !== null && typeof body === 'object' && !Array.isArray(body) ? (body as Record) : undefined; const wrapped = source?.data !== null && typeof source?.data === 'object' && !Array.isArray(source?.data) ? (source.data as Record) : source; const queue = wrapped?.queue !== null && typeof wrapped?.queue === 'object' ? (wrapped.queue as Record) : undefined; const pending = typeof queue?.pending === 'number' && Number.isSafeInteger(queue.pending) ? queue.pending : 0; const failed = typeof queue?.failed === 'number' && Number.isSafeInteger(queue.failed) ? queue.failed : 0; if (active) setNotificationHealth({ pending, failed }); } catch { if (active && !controller.signal.aborted) setNotificationHealth(null); } })(); return () => { active = false; controller.abort(); }; }, [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 (access !== 'all') { chips.push({ key: 'access', label: `接入:${ access === 'local' ? '本机直连' : access === 'lan' ? '局域网接入' : '公网地址' }`, onClear: () => { setAccess('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); }, }); } if (group) { chips.push({ key: 'group', label: `分组:${groupNames.get(group) ?? group}`, onClear: () => { setGroup(''); setPage(1); }, }); } return chips; }, [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), [access, auth, capability, group, tag, version], ); useEffect(() => { if (page !== model.page) setPage(model.page); }, [model.page, page]); function resetPage(action: () => void): void { action(); setPage(1); } function clearAllFilters(): void { setQuery(''); setFilter('all'); setAuth('all'); setAccess('all'); setCapability(''); setVersion(''); setTag(''); setGroup(''); 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 togglePhonePrivacy(id: string): void { setRevealedPhoneIds((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } function selectedTargets(): Array<{ instanceId: string; revision: number }> { const instances = snapshot?.instances ?? []; return model.selectedIds.flatMap((id) => { const instance = instances.find((item) => item.id === id); if (!instance || !instance.revision || instance.revision < 1) return []; return [{ instanceId: id, revision: instance.revision }]; }); } async function runOperation( kind: CardActionKind, targets: ReadonlyArray<{ instanceId: string; revision: number }>, ): Promise<{ succeeded: number; failed: number }> { 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, pageSize: 100, }); const entry = catalog.items.find((item) => item.operationId === op.operationId); if (!entry || entry.parameterSchemaId !== op.parameterSchemaId) throw new Error('Operation is not available.'); let succeeded = 0; let failed = 0; let completed = 0; for (const target of targets) { try { const prepared = await resolvedOperationClient.prepare({ operationId: op.operationId, targets: [target], parameters: { parameterSchemaId: op.parameterSchemaId, fields: kind === 'system-reboot' ? [{ fieldId: 'delay_seconds', kind: 'number', value: SYSTEM_REBOOT.delaySeconds }] : [], }, }); const confirmed = typeof window === 'undefined' ? true : window.confirm( `${prepared.confirmationPrompt} 风险等级 ${prepared.risk}。确认继续?`, ); if (!confirmed) { failed += 1; } else { const job = await resolvedOperationClient.execute(prepared.id); if (job.status === 'succeeded') succeeded += 1; else failed += 1; } } catch { failed += 1; } completed += 1; setBatchProgress({ action: kind, total: targets.length, completed, succeeded, failed, message: `${op.title}进度 ${completed}/${targets.length}`, }); } return { succeeded, failed }; } async function runBatch(kind: BatchActionKind): Promise { const targets = selectedTargets(); if (targets.length === 0) { setBatchProgress({ action: kind, total: 0, completed: 0, succeeded: 0, failed: 0, message: '所选实例缺少配置版本,请刷新后重试。', }); return; } const title = kind === 'service-restart' ? SERVICE_RESTART.title : SYSTEM_REBOOT.title; if ( typeof window !== 'undefined' && !window.confirm(`将对 ${targets.length} 个实例执行「${title}」。此操作为高风险,确认继续?`) ) return; setBatchBusy(true); setBatchProgress({ action: kind, total: targets.length, completed: 0, succeeded: 0, failed: 0, message: `正在执行${title}…`, }); try { const result = await runOperation(kind, targets); setBatchProgress({ action: kind, total: targets.length, completed: targets.length, succeeded: result.succeeded, failed: result.failed, message: `${title}完成:成功 ${result.succeeded},失败 ${result.failed}。`, }); } catch (error) { setBatchProgress({ action: kind, total: targets.length, completed: 0, succeeded: 0, failed: targets.length, message: safeUiError(error, `${title}失败,请稍后重试。`), }); } finally { setBatchBusy(false); } } 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 : kind === 'baseband-restart' ? BASEBAND_RESTART.title : SYSTEM_REBOOT.title; if ( typeof window !== 'undefined' && !window.confirm(`将对该实例执行「${title}」。此操作为高风险,确认继续?`) ) return; setCardAction({ id, busy: true, message: `正在${title}…` }); try { const result = await runOperation(kind, [{ instanceId: id, revision: instance.revision }]); setCardAction({ id, busy: false, message: result.succeeded === 1 ? `${title}已提交成功。` : `${title}失败,请稍后重试。`, ...(result.failed === 1 ? { error: `${title}失败,请稍后重试。` } : {}), }); } catch (error) { setCardAction({ id, busy: false, error: safeUiError(error, `${title}失败,请稍后重试。`), }); } } const closeContext = (restoreFocus = false): void => { const id = contextId; setContextId(undefined); if (restoreFocus && id) contextTriggerRefs.current.get(id)?.focus(); }; const selectFilter = ( label: string, value: string, setValue: (value: string) => void, choices: readonly string[], allLabel: string, displayChoice: (choice: string) => string = (choice) => choice, ) => ( ); 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 (

节点

添加实例 {selectionMode ? ( 已选择 {model.selectedIds.length} 项 ) : null}
{groups.map((item) => ( ))}

显示 {model.filteredCount} / {model.totalCount} {model.pageCount > 1 ? ` · 第 ${model.page}/${model.pageCount} 页` : null}

{selectionMode ? (
) : 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) => { const phoneNumbers = row.status?.summary?.resources?.phoneNumbers ?? []; const latestMessage = messageStates.get(row.id)?.latest; // The console-wide switch wins; the per-card button still works while it is off. const phoneNumbersRevealed = sensitive || revealedPhoneIds.has(row.id); const hasPrivatePhone = phoneNumbers.length > 0 || Boolean(latestMessage); return (
) : null}
{organizationOpen ? ( setOrganizationOpen(false)} onChanged={() => { setAttempt((value) => value + 1); setOrganizationRevision((value) => value + 1); onGroupsChanged?.(); }} /> ) : null} {identityOpen ? ( setIdentityOpen(false)} onChanged={() => setAttempt((value) => value + 1)} /> ) : null} {contextInstance ? (
closeContext(true)}>
) : null}
); }