Render the Hub feature set in the existing Animal Island styling: fleet messages, notification centre, log centre, organisation panel, per-instance device module panels and the settings backup, connection and maintenance surfaces.
416 lines
14 KiB
TypeScript
416 lines
14 KiB
TypeScript
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<Record<string, string>>;
|
|
/** 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<Record<InstanceModuleSection['state'], string>> = {
|
|
ok: '正常',
|
|
empty: '无数据',
|
|
'auth-required': '需要认证',
|
|
unsupported: '设备不支持',
|
|
failed: '读取失败',
|
|
};
|
|
|
|
const COMMON_LABELS: Readonly<Record<string, string>> = {
|
|
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<Record<string, string>>): string {
|
|
return fields?.[key] ?? COMMON_LABELS[key] ?? deviceFieldLabel(key) ?? key.replace(/_/gu, ' ');
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
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<Record<string, unknown>> {
|
|
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<Record<string, unknown>>[] {
|
|
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<Record<string, unknown>>[]): 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<Record<string, unknown>>[],
|
|
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 <p>{`未提供${spec.label}数据。`}</p>;
|
|
if (section.state !== 'ok') {
|
|
return (
|
|
<p className="field-note">
|
|
{section.state === 'auth-required'
|
|
? '设备需要重新认证后才能读取该数据。'
|
|
: section.state === 'unsupported'
|
|
? '当前设备固件未提供该接口。'
|
|
: section.state === 'empty'
|
|
? '设备返回成功,但没有可用数据。'
|
|
: `无法从设备读取${spec.label}。`}
|
|
</p>
|
|
);
|
|
}
|
|
const list = rows(section.data, spec.limit ?? 20, spec.listKey);
|
|
if (list.length) {
|
|
const headers = tableColumns(list, spec);
|
|
if (!headers.length) return null;
|
|
return (
|
|
<div className="schedule-table-wrap">
|
|
<table className="dense-table">
|
|
<thead>
|
|
<tr>
|
|
{headers.map((header) => (
|
|
<th key={header} scope="col">
|
|
{fieldLabel(header, spec.fields)}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{list.map((item, index) => (
|
|
<tr key={index}>
|
|
{headers.map((header) => (
|
|
<td key={header}>
|
|
{protectValue(header, scalar(item[header], header) ?? '—', revealed)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
const fields = entries(section.data, spec);
|
|
if (!fields.length) return null;
|
|
return (
|
|
<dl>
|
|
{fields.map(([key, value]) => (
|
|
<div key={key}>
|
|
<dt>{fieldLabel(key, spec.fields)}</dt>
|
|
<dd>{protectValue(key, value, revealed)}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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<ReadState>({ 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 (
|
|
<div className="state-panel state-error" role="alert">
|
|
{emptyText ?? '需要先完成设备认证,才能读取该模块的数据。'}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const snapshot =
|
|
state.kind !== 'idle' && state.snapshot?.instanceId === instance.id
|
|
? state.snapshot
|
|
: undefined;
|
|
|
|
if (!snapshot) {
|
|
if (state.kind === 'error')
|
|
return (
|
|
<div className="state-panel state-error" role="alert">
|
|
<p>{state.message}</p>
|
|
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
|
重试
|
|
</button>
|
|
</div>
|
|
);
|
|
return (
|
|
<p role="status" aria-label="模块加载状态">
|
|
正在加载设备数据…
|
|
</p>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="fleet-card-actions">
|
|
<button
|
|
type="button"
|
|
className="fleet-messages-more"
|
|
aria-pressed={revealed}
|
|
onClick={toggle}
|
|
>
|
|
{revealed ? '隐藏敏感标识' : '显示敏感标识'}
|
|
</button>
|
|
</div>
|
|
<div className="cellular-grid">
|
|
{state.kind === 'error' ? (
|
|
<div className="state-panel state-error" role="alert">
|
|
<p>刷新失败,正在显示上次已知的数据。</p>
|
|
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
|
重试
|
|
</button>
|
|
</div>
|
|
) : 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 (
|
|
<section key={`${spec.key}-${index}`} className="cellular-card" aria-label={spec.label}>
|
|
<h2>
|
|
{spec.label}
|
|
<span className="device-module-state">
|
|
{STATE_LABELS[section?.state ?? 'failed']}
|
|
</span>
|
|
</h2>
|
|
{body ?? <p>{`未提供${spec.label}数据。`}</p>}
|
|
</section>
|
|
);
|
|
})}
|
|
<DeviceActions
|
|
instance={instance}
|
|
module={module}
|
|
onExecuted={() => setRetry((value) => value + 1)}
|
|
/>
|
|
<p className="field-note">观测时间:{displayValue(snapshot.observedAt)}</p>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|