import { useEffect, useRef, useState, type ReactNode } from 'react'; import type { InstanceContext } from '../app-shell.js'; import { protectValue } from '../privacy/sensitive-fields.js'; import { useSensitiveReveal } from '../privacy/sensitive-reveal.js'; import { displayValue } from '../ui/locale.js'; import { deviceFieldLabel, deviceValueLabel, normalizeDeviceToken } from './device-field-labels.js'; import { DeviceActions } from './device-action-controls.js'; import type { InstanceModuleReader, InstanceModuleSection, InstanceModuleSnapshot, } from './instance-module-api-data-source.js'; export interface DeviceModuleSectionSpec { /** Section key produced by the control-plane module probe. */ readonly key: string; readonly label: string; /** Optional Chinese labels for device field names. */ readonly fields?: Readonly>; /** Set when the device nests the interesting rows under one key, such as `slots` or `files`. */ readonly listKey?: string; /** Maximum rows rendered when the section is a list. */ readonly limit?: number; /** * When set, the section renders only these device keys. Lets one probe feed two cards, such as * the SIM identity table and the separate PIN/PUK lock panel. */ readonly only?: readonly string[]; /** Device keys this section never renders; they belong to a sibling section instead. */ readonly exclude?: readonly string[]; /** Skip the whole card when the device reported none of this section's fields. */ readonly optional?: boolean; } export interface DeviceModulePanelProps { readonly instance: InstanceContext; readonly module: string; readonly sections: readonly DeviceModuleSectionSpec[]; readonly reader?: InstanceModuleReader; readonly emptyText?: string; readonly refreshSignal?: unknown; } type ReadState = | { kind: 'idle' } | { kind: 'loading'; snapshot?: InstanceModuleSnapshot } | { kind: 'ready'; snapshot: InstanceModuleSnapshot } | { kind: 'error'; message: string; snapshot?: InstanceModuleSnapshot }; const STATE_LABELS: Readonly> = { ok: '正常', empty: '无数据', 'auth-required': '需要认证', unsupported: '设备不支持', failed: '读取失败', }; const COMMON_LABELS: Readonly> = { enabled: '已启用', disabled: '已停用', state: '状态', status: '状态', mode: '模式', name: '名称', label: '标签', provider: '服务商', hostname: '主机名', ssid: '网络名称', security: '安全类型', priority: '优先级', interval: '更新间隔', interval_seconds: '更新间隔(秒)', update_interval_seconds: '更新间隔(秒)', last_update_at: '最近更新', last_event_at: '最近事件', total: '总计', pending: '待处理', processing: '处理中', delivered: '已送达', failed: '失败', level: '级别', message: '内容', created_at: '创建时间', updated_at: '更新时间', size: '大小', size_bytes: '大小(字节)', path: '路径', filename: '文件名', version: '版本', current_version: '当前版本', latest_version: '最新版本', model: '型号', manufacturer: '制造商', imei: 'IMEI', iccid: 'ICCID', imsi: 'IMSI', phone_number: '手机号', operator: '运营商', operator_name: '运营商', roaming: '漫游', auto_renew: '自动续期', components: '组件', retention_days: '保留天数', max_backups: '最大备份数', registered: '已注册', connected: '已连接', available: '可用', supported: '受支持', reason: '原因', detail: '详情', }; function fieldLabel(key: string, fields?: Readonly>): string { return fields?.[key] ?? COMMON_LABELS[key] ?? deviceFieldLabel(key) ?? key.replace(/_/gu, ' '); } function isRecord(value: unknown): value is Readonly> { return typeof value === 'object' && value !== null && !Array.isArray(value); } function scalar(value: unknown, key?: string): string | null { if (typeof value === 'string') { const trimmed = value.length > 200 ? `${value.slice(0, 200)}…` : value; return key === undefined ? trimmed : (deviceValueLabel(key, trimmed) ?? trimmed); } if (typeof value === 'number' && Number.isFinite(value)) return String(value); if (typeof value === 'boolean') return value ? '是' : '否'; if (value === null) return '—'; if (Array.isArray(value)) { const parts = value .slice(0, 8) .map((item) => (isRecord(item) ? scalar(item['name'] ?? item['label']) : scalar(item))) .filter((item): item is string => item !== null); return parts.length ? parts.join('、') : '空列表'; } return null; } /** A nested record of plain values reads better spread across the parent table than dropped. */ function flattenable(value: unknown): value is Readonly> { if (!isRecord(value)) return false; const entries = Object.entries(value); return entries.length > 0 && entries.length <= 12 && entries.every(([, item]) => !isRecord(item)); } function selected(data: unknown, spec: DeviceModuleSectionSpec): readonly [string, unknown][] { if (!isRecord(data)) return []; const only = spec.only ? new Set(spec.only.map(normalizeDeviceToken)) : undefined; const exclude = spec.exclude ? new Set(spec.exclude.map(normalizeDeviceToken)) : undefined; const keeps = (key: string): boolean => { const normalized = normalizeDeviceToken(key); return (!only || only.has(normalized)) && !(exclude && exclude.has(normalized)); }; const picked: [string, unknown][] = []; for (const [key, value] of Object.entries(data)) { if (picked.length >= 40) break; if (!keeps(key)) continue; if (flattenable(value)) { for (const [nestedKey, nestedValue] of Object.entries(value)) { if (picked.length >= 40) break; if (keeps(nestedKey)) picked.push([nestedKey, nestedValue]); } continue; } picked.push([key, value]); } return picked; } function entries(data: unknown, spec: DeviceModuleSectionSpec): readonly [string, string][] { const rows: [string, string][] = []; for (const [key, value] of selected(data, spec)) { const rendered = scalar(value, key); if (rendered !== null) rows.push([key, rendered]); } return rows; } function rows( data: unknown, limit: number, listKey?: string, ): readonly Readonly>[] { const nested = listKey && isRecord(data) ? data[listKey] : undefined; const source = Array.isArray(nested) ? nested : Array.isArray(data) ? data : isRecord(data) ? (data['items'] ?? data['list'] ?? data['profiles']) : undefined; if (!Array.isArray(source)) return []; return source.slice(0, limit).flatMap((item) => (isRecord(item) ? [item] : [])); } function columns(items: readonly Readonly>[]): readonly string[] { const seen: string[] = []; for (const item of items) { for (const key of Object.keys(item)) { if (!seen.includes(key)) seen.push(key); if (seen.length >= 8) return seen; } } return seen; } function tableColumns( items: readonly Readonly>[], spec: DeviceModuleSectionSpec, ): readonly string[] { const all = columns(items); const only = spec.only ? new Set(spec.only.map(normalizeDeviceToken)) : undefined; const exclude = spec.exclude ? new Set(spec.exclude.map(normalizeDeviceToken)) : undefined; const filtered = all.filter((key) => { const normalized = normalizeDeviceToken(key); return (!only || only.has(normalized)) && !(exclude && exclude.has(normalized)); }); // An `only` list that matches nothing means the section genuinely has no data; an `exclude` // list that removes everything falls back so a probe shape change cannot blank the card. return only ? filtered : filtered.length ? filtered : all; } function sectionBody( section: InstanceModuleSection | undefined, spec: DeviceModuleSectionSpec, revealed: boolean, ): ReactNode { if (!section) return

{`未提供${spec.label}数据。`}

; if (section.state !== 'ok') { return (

{section.state === 'auth-required' ? '设备需要重新认证后才能读取该数据。' : section.state === 'unsupported' ? '当前设备固件未提供该接口。' : section.state === 'empty' ? '设备返回成功,但没有可用数据。' : `无法从设备读取${spec.label}。`}

); } const list = rows(section.data, spec.limit ?? 20, spec.listKey); if (list.length) { const headers = tableColumns(list, spec); if (!headers.length) return null; return (
{headers.map((header) => ( ))} {list.map((item, index) => ( {headers.map((header) => ( ))} ))}
{fieldLabel(header, spec.fields)}
{protectValue(header, scalar(item[header], header) ?? '—', revealed)}
); } const fields = entries(section.data, spec); if (!fields.length) return null; return (
{fields.map(([key, value]) => (
{fieldLabel(key, spec.fields)}
{protectValue(key, value, revealed)}
))}
); } /** * Data-driven panel shared by the device tabs that mirror the official Hub: every section is a * read-only probe the control plane already performed, so the console never talks to a device * directly and never invents an endpoint of its own. */ export function DeviceModulePanel({ instance, module, sections, reader, emptyText, refreshSignal, }: DeviceModulePanelProps) { const requestOwner = useRef(0); const [retry, setRetry] = useState(0); // One console-wide switch: the topbar button and this in-place shortcut drive the same state. const { revealed, toggle } = useSensitiveReveal(); const [state, setState] = useState({ kind: 'idle' }); useEffect(() => { const request = ++requestOwner.current; const controller = new AbortController(); if (instance.authentication !== 'authenticated' || !reader) { setState({ kind: 'idle' }); return () => controller.abort(); } setState((current) => ({ kind: 'loading', ...(current.kind !== 'idle' && current.snapshot?.instanceId === instance.id ? { snapshot: current.snapshot } : {}), })); void reader.read(instance.id, module, controller.signal).then( (snapshot) => { if (request === requestOwner.current && !controller.signal.aborted) setState({ kind: 'ready', snapshot }); }, () => { if (request === requestOwner.current && !controller.signal.aborted) setState((current) => ({ kind: 'error', message: `无法加载${sections[0]?.label ?? '设备'}数据。`, ...(current.kind !== 'idle' && current.snapshot?.instanceId === instance.id ? { snapshot: current.snapshot } : {}), })); }, ); return () => controller.abort(); }, [instance.authentication, instance.id, module, reader, refreshSignal, retry, sections]); if (instance.authentication !== 'authenticated' || !reader) { return (
{emptyText ?? '需要先完成设备认证,才能读取该模块的数据。'}
); } const snapshot = state.kind !== 'idle' && state.snapshot?.instanceId === instance.id ? state.snapshot : undefined; if (!snapshot) { if (state.kind === 'error') return (

{state.message}

); return (

正在加载设备数据…

); } return ( <>
{state.kind === 'error' ? (

刷新失败,正在显示上次已知的数据。

) : null} {sections.map((spec, index) => { const section = snapshot.sections.find((candidate) => candidate.key === spec.key); const body = sectionBody(section, spec, revealed); if (body === null && spec.optional) return null; return (

{spec.label} {STATE_LABELS[section?.state ?? 'failed']}

{body ??

{`未提供${spec.label}数据。`}

}
); })} setRetry((value) => value + 1)} />

观测时间:{displayValue(snapshot.observedAt)}

); }