feat(web): rebuild the console around the fused control plane

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.
This commit is contained in:
chick
2026-09-05 18:53:04 +08:00
parent f9186bd851
commit a75badeb58
77 changed files with 20578 additions and 435 deletions
@@ -0,0 +1,415 @@
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>
</>
);
}