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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user