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.
493 lines
16 KiB
TypeScript
493 lines
16 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
|
||
import type { InstanceContext } from '../app-shell.js';
|
||
import { createOperationClient, type OperationClient } from '../operations/operation-client.js';
|
||
import { safeUiError } from '../ui/locale.js';
|
||
|
||
export type OverviewFieldValue = string | number | boolean | null;
|
||
export type OverviewSection = Readonly<Record<string, OverviewFieldValue>>;
|
||
|
||
export interface OverviewSnapshot {
|
||
readonly observedAt?: string | undefined;
|
||
readonly device: OverviewSection;
|
||
readonly sim: OverviewSection;
|
||
readonly network: OverviewSection;
|
||
readonly stats: OverviewSection;
|
||
readonly cpu: OverviewSection;
|
||
readonly connectivity: OverviewSection;
|
||
}
|
||
|
||
export interface OverviewDataSource {
|
||
/** Implementations are injected by the application owner; this component has no production endpoint. */
|
||
load(instanceId: string, signal: AbortSignal): Promise<OverviewSnapshot>;
|
||
}
|
||
|
||
export interface OverviewSystemPageProps {
|
||
readonly instance: InstanceContext;
|
||
readonly dataSource?: OverviewDataSource | undefined;
|
||
/** Change this value when an external owner has requested a refresh. */
|
||
readonly refreshSignal?: unknown | undefined;
|
||
readonly operationClient?: OperationClient | undefined;
|
||
readonly revision?: number | undefined;
|
||
}
|
||
|
||
type ReadState =
|
||
| { kind: 'idle' }
|
||
| { kind: 'loading'; snapshot?: OverviewSnapshot }
|
||
| { kind: 'ready'; snapshot: OverviewSnapshot }
|
||
| { kind: 'error'; message: string; snapshot?: OverviewSnapshot };
|
||
|
||
const SECTIONS = [
|
||
['device', '设备'],
|
||
['sim', 'SIM'],
|
||
['network', '网络'],
|
||
['stats', '统计'],
|
||
['cpu', 'CPU'],
|
||
['connectivity', '连接状态'],
|
||
] as const;
|
||
|
||
const FIELD_LABELS: Readonly<Record<string, string>> = {
|
||
Model: '型号',
|
||
Manufacturer: '制造商',
|
||
IMEI: 'IMEI',
|
||
Version: '固件版本',
|
||
Uptime: '运行时间',
|
||
Slots: '卡槽数',
|
||
Active: '活跃数',
|
||
Operator: '运营商',
|
||
Technology: '接入制式',
|
||
Signal: '信号强度',
|
||
MCC: 'MCC',
|
||
MNC: 'MNC',
|
||
Registration: '注册状态',
|
||
IPv4: 'IPv4 地址',
|
||
IPv6: 'IPv6 地址',
|
||
Download: '下行速率',
|
||
Upload: '上行速率',
|
||
'Messages today': '今日消息数',
|
||
Calls: '通话数',
|
||
Usage: '使用率',
|
||
Temperature: '温度',
|
||
State: '状态',
|
||
Latency: '延迟',
|
||
// Device payloads arrive with their native snake_case field names.
|
||
model: '型号',
|
||
manufacturer: '制造商',
|
||
brand: '品牌',
|
||
imei: 'IMEI',
|
||
meid: 'MEID',
|
||
iccid: 'ICCID',
|
||
imsi: 'IMSI',
|
||
phone_number: '号码',
|
||
firmware_version: '固件版本',
|
||
os_version: '系统版本',
|
||
version: '版本',
|
||
baseband_version: '基带版本',
|
||
uptime: '运行时间',
|
||
uptime_seconds: '运行时间(秒)',
|
||
slots: '卡槽数',
|
||
active_slots: '活跃数',
|
||
active: '已激活',
|
||
enabled: '已启用',
|
||
operator: '运营商',
|
||
carrier: '运营商',
|
||
network_type: '接入制式',
|
||
technology: '技术',
|
||
radio_mode: '网络模式',
|
||
signal: '信号强度',
|
||
signal_dbm: '信号强度(dBm)',
|
||
signal_percent: '信号质量',
|
||
rsrp: 'RSRP',
|
||
rsrq: 'RSRQ',
|
||
sinr: 'SINR',
|
||
mcc: 'MCC',
|
||
mnc: 'MNC',
|
||
registration: '注册状态',
|
||
registration_state: '注册状态',
|
||
roaming: '漫游',
|
||
airplane_mode: '飞行模式',
|
||
ipv4: 'IPv4 地址',
|
||
ipv6: 'IPv6 地址',
|
||
ip_address: 'IP 地址',
|
||
gateway: '网关',
|
||
dns: 'DNS',
|
||
download: '下行速率',
|
||
upload: '上行速率',
|
||
download_bytes: '下行流量',
|
||
upload_bytes: '上行流量',
|
||
messages_today: '今日消息数',
|
||
calls: '通话数',
|
||
usage: '使用率',
|
||
temperature: '温度',
|
||
max_temperature_c: '最高温度',
|
||
cpu_percent: 'CPU 使用率',
|
||
cpu_load: 'CPU 负载',
|
||
memory_percent: '内存使用率',
|
||
memory_total_mb: '内存总量',
|
||
memory_available_mb: '可用内存',
|
||
battery_level: '电量',
|
||
battery_percent: '电量',
|
||
latency: '延迟',
|
||
connected: '连接状态',
|
||
interface: '网络接口',
|
||
ssid: '无线网络',
|
||
apn: 'APN',
|
||
};
|
||
|
||
const ENUM_LABELS: Readonly<Record<string, string>> = {
|
||
connected: '已连接',
|
||
not_registered: '未注册',
|
||
attached: '已附着',
|
||
detached: '已分离',
|
||
enabled: '已启用',
|
||
disabled: '已停用',
|
||
true: '是',
|
||
false: '否',
|
||
lte: 'LTE',
|
||
nr: 'NR',
|
||
nr5g: '5G NR',
|
||
gsm: 'GSM',
|
||
wcdma: 'WCDMA',
|
||
td_scdma: 'TD-SCDMA',
|
||
evdo: 'EVDO',
|
||
disconnected: '未连接',
|
||
connecting: '正在连接',
|
||
registered: '已注册',
|
||
unregistered: '未注册',
|
||
searching: '正在搜索',
|
||
roaming: '漫游中',
|
||
online: '在线',
|
||
offline: '离线',
|
||
unknown: '未知',
|
||
};
|
||
|
||
function displayFieldLabel(key: string): string {
|
||
return FIELD_LABELS[key] ?? key;
|
||
}
|
||
|
||
function displayFieldValue(value: OverviewFieldValue): string {
|
||
if (value == null) return '不可用';
|
||
if (typeof value === 'boolean') return value ? '是' : '否';
|
||
if (typeof value === 'string') return ENUM_LABELS[value.toLocaleLowerCase()] ?? value;
|
||
return String(value);
|
||
}
|
||
|
||
function StructuredSection({ label, values }: { label: string; values: OverviewSection }) {
|
||
const entries = Object.entries(values);
|
||
return (
|
||
<section className="overview-card" aria-label={label}>
|
||
<h2>{label}</h2>
|
||
{entries.length ? (
|
||
<dl>
|
||
{entries.map(([key, value]) => (
|
||
<div key={key}>
|
||
<dt>{displayFieldLabel(key)}</dt>
|
||
<dd>{displayFieldValue(value)}</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
) : (
|
||
<p>未提供{label}数据。</p>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function ResourceSummary({ instance }: { instance: InstanceContext }) {
|
||
const resources = instance.resources;
|
||
const metric = (label: string, value: number | undefined) => (
|
||
<article className="overview-resource-card">
|
||
<span>{label}</span>
|
||
{value === undefined ? (
|
||
<strong>暂未获取</strong>
|
||
) : (
|
||
<>
|
||
<strong>{value.toFixed(1)}%</strong>
|
||
<meter min="0" max="100" value={value} aria-label={`${label}使用率`} />
|
||
</>
|
||
)}
|
||
</article>
|
||
);
|
||
return (
|
||
<section className="overview-operations" aria-label="运行状态与资源">
|
||
<h2>运行状态</h2>
|
||
{instance.resourceSummaryState === 'loading' ? (
|
||
<p role="status" aria-label="资源摘要状态">
|
||
正在加载资源摘要…
|
||
</p>
|
||
) : null}
|
||
{instance.resourceSummaryState === 'unavailable' ? (
|
||
<p role="status" aria-label="资源摘要状态">
|
||
资源摘要暂不可用,实例基本信息仍可使用。
|
||
</p>
|
||
) : null}
|
||
<div className="overview-resource-grid">
|
||
<article className="overview-resource-card">
|
||
<span>连接状态</span>
|
||
<strong>
|
||
{instance.status === 'online'
|
||
? '在线'
|
||
: instance.status === 'offline'
|
||
? '离线'
|
||
: instance.status === 'auth-required'
|
||
? '需要认证'
|
||
: '未知'}
|
||
</strong>
|
||
</article>
|
||
{metric('CPU', resources?.cpuPercent)}
|
||
{metric('内存', resources?.memoryPercent)}
|
||
<article className="overview-resource-card">
|
||
<span>最高温度</span>
|
||
<strong>
|
||
{resources?.maxTemperatureCelsius === undefined
|
||
? '暂未获取'
|
||
: `${resources.maxTemperatureCelsius.toFixed(1)} °C`}
|
||
</strong>
|
||
</article>
|
||
<article className="overview-resource-card overview-resource-phone">
|
||
<span>手机号</span>
|
||
<strong>{resources?.phoneNumbers?.join('、') || '暂未获取'}</strong>
|
||
</article>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
export function OverviewSystemPage({
|
||
instance,
|
||
dataSource,
|
||
refreshSignal,
|
||
operationClient,
|
||
revision,
|
||
}: OverviewSystemPageProps) {
|
||
const requestOwner = useRef(0);
|
||
const [retry, setRetry] = useState(0);
|
||
const [state, setState] = useState<ReadState>({ kind: 'idle' });
|
||
const [actionState, setActionState] = useState<{
|
||
busy: boolean;
|
||
message?: string;
|
||
error?: string;
|
||
}>({ busy: false });
|
||
const client = useMemo(() => operationClient ?? createOperationClient(), [operationClient]);
|
||
const effectiveRevision = revision ?? instance.revision;
|
||
|
||
async function runOverviewAction(kind: 'service' | 'system'): Promise<void> {
|
||
const targetRevision = effectiveRevision;
|
||
if (!targetRevision || targetRevision < 1) {
|
||
setActionState({ busy: false, error: '缺少配置版本,请刷新后重试。' });
|
||
return;
|
||
}
|
||
const op =
|
||
kind === 'service'
|
||
? {
|
||
operationId: 'postServiceRestart',
|
||
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
|
||
title: '重启服务',
|
||
fields: [] as const,
|
||
}
|
||
: {
|
||
operationId: 'postSystemReboot',
|
||
parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1',
|
||
title: '系统重启',
|
||
fields: [{ fieldId: 'delay_seconds', kind: 'number' as const, value: 3 }],
|
||
};
|
||
if (
|
||
typeof window !== 'undefined' &&
|
||
!window.confirm(`将执行「${op.title}」。此操作为高风险,确认继续?`)
|
||
)
|
||
return;
|
||
setActionState({ busy: true, message: `正在${op.title}…` });
|
||
try {
|
||
// Avoid pageSize=100 first-page truncation for late-sorted operation ids.
|
||
await client.list({ search: op.operationId, pageSize: 100 });
|
||
const prepared = await client.prepare({
|
||
operationId: op.operationId,
|
||
targets: [{ instanceId: instance.id, revision: targetRevision }],
|
||
parameters: {
|
||
parameterSchemaId: op.parameterSchemaId,
|
||
fields: [...op.fields],
|
||
},
|
||
});
|
||
const confirmed =
|
||
typeof window === 'undefined'
|
||
? true
|
||
: window.confirm(`${prepared.confirmationPrompt}
|
||
风险等级 ${prepared.risk}。确认继续?`);
|
||
if (!confirmed) {
|
||
setActionState({ busy: false, message: '已取消操作。' });
|
||
return;
|
||
}
|
||
const job = await client.execute(prepared.id);
|
||
setActionState({
|
||
busy: false,
|
||
message:
|
||
job.status === 'succeeded' ? `${op.title}已提交成功。` : `${op.title}结果未知或失败。`,
|
||
...(job.status === 'succeeded' ? {} : { error: `${op.title}结果未知或失败。` }),
|
||
});
|
||
} catch (error) {
|
||
setActionState({
|
||
busy: false,
|
||
error: safeUiError(error, `${op.title}失败,请稍后重试。`),
|
||
});
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
const request = ++requestOwner.current;
|
||
const controller = new AbortController();
|
||
|
||
if (instance.authentication === 'auth-required') {
|
||
setState({ kind: 'idle' });
|
||
return () => controller.abort();
|
||
}
|
||
if (!dataSource) {
|
||
setState({ kind: 'idle' });
|
||
return () => controller.abort();
|
||
}
|
||
|
||
setState((current) => ({
|
||
kind: 'loading',
|
||
...(current.kind === 'ready' || current.kind === 'error'
|
||
? current.snapshot
|
||
? { snapshot: current.snapshot }
|
||
: {}
|
||
: {}),
|
||
}));
|
||
void dataSource.load(instance.id, controller.signal).then(
|
||
(snapshot) => {
|
||
if (request === requestOwner.current && !controller.signal.aborted)
|
||
setState({ kind: 'ready', snapshot });
|
||
},
|
||
(reason: unknown) => {
|
||
void reason;
|
||
if (request === requestOwner.current && !controller.signal.aborted)
|
||
setState((current) => ({
|
||
kind: 'error',
|
||
message: '无法加载概览数据。',
|
||
...(current.kind === 'loading' && current.snapshot
|
||
? { snapshot: current.snapshot }
|
||
: {}),
|
||
}));
|
||
},
|
||
);
|
||
return () => controller.abort();
|
||
}, [dataSource, instance.authentication, instance.id, refreshSignal, retry]);
|
||
|
||
const systemOperations = (
|
||
<section className="overview-operations" aria-label="系统操作">
|
||
<h2>系统操作</h2>
|
||
<p>服务重启与系统重启均为高风险操作,执行前会再次确认。</p>
|
||
<div className="fleet-card-actions" role="group" aria-label="重启操作">
|
||
<button
|
||
type="button"
|
||
disabled={actionState.busy || !effectiveRevision}
|
||
onClick={() => void runOverviewAction('service')}
|
||
>
|
||
重启服务
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="danger-button"
|
||
disabled={actionState.busy || !effectiveRevision}
|
||
onClick={() => void runOverviewAction('system')}
|
||
>
|
||
系统重启
|
||
</button>
|
||
</div>
|
||
{!effectiveRevision ? (
|
||
<p role="status">缺少配置版本,暂不可执行重启。请从实例列表进入并刷新后重试。</p>
|
||
) : null}
|
||
{actionState.error ? (
|
||
<p role="alert" className="card-operation-error">
|
||
{actionState.error}
|
||
</p>
|
||
) : null}
|
||
{actionState.message ? (
|
||
<p role="status" className="card-operation-success">
|
||
{actionState.message}
|
||
</p>
|
||
) : null}
|
||
</section>
|
||
);
|
||
|
||
if (instance.authentication === 'auth-required')
|
||
return (
|
||
<div className="overview-system">
|
||
<div className="state-panel state-error" role="alert">
|
||
需要先完成认证,才能读取概览数据。
|
||
</div>
|
||
{systemOperations}
|
||
</div>
|
||
);
|
||
|
||
if (!dataSource)
|
||
return (
|
||
<div className="overview-system">
|
||
<ResourceSummary instance={instance} />
|
||
<p role="status" aria-label="概览加载状态">
|
||
未提供安全概览只读数据源,但系统操作仍可使用。
|
||
</p>
|
||
{systemOperations}
|
||
</div>
|
||
);
|
||
|
||
const retainedSnapshot =
|
||
state.kind === 'loading' || state.kind === 'error' ? state.snapshot : undefined;
|
||
|
||
const snapshot = state.kind === 'ready' ? state.snapshot : retainedSnapshot;
|
||
|
||
if ((state.kind === 'loading' || state.kind === 'idle') && !retainedSnapshot)
|
||
return (
|
||
<div className="overview-system">
|
||
<ResourceSummary instance={instance} />
|
||
<p role="status" aria-label="概览加载状态">
|
||
正在加载概览…
|
||
</p>
|
||
{systemOperations}
|
||
</div>
|
||
);
|
||
|
||
if (state.kind === 'error' && !state.snapshot)
|
||
return (
|
||
<div className="overview-system">
|
||
<ResourceSummary instance={instance} />
|
||
<div className="state-panel state-error" role="alert">
|
||
<p>无法加载概览: {state.message}</p>
|
||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||
重试加载概览
|
||
</button>
|
||
</div>
|
||
{systemOperations}
|
||
</div>
|
||
);
|
||
|
||
if (!snapshot) return <div className="overview-system">{systemOperations}</div>;
|
||
|
||
return (
|
||
<div className="overview-system">
|
||
<ResourceSummary instance={instance} />
|
||
{state.kind === 'loading' ? <p role="status">正在刷新概览…</p> : null}
|
||
{state.kind === 'error' ? (
|
||
<div className="state-panel state-error" role="alert">
|
||
<p>刷新失败,正在显示上次已知的概览数据。</p>
|
||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||
重试加载概览
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
{instance.freshness !== 'fresh' ? (
|
||
<p className="state-panel" role="status" aria-label="概览数据新鲜度">
|
||
概览数据可能已过期;使用这些值前请先核实数据新鲜度。
|
||
</p>
|
||
) : null}
|
||
{snapshot.observedAt ? <p>观测时间:{snapshot.observedAt}</p> : null}
|
||
<div className="overview-grid">
|
||
{SECTIONS.map(([key, label]) => (
|
||
<StructuredSection key={key} label={label} values={snapshot[key]} />
|
||
))}
|
||
</div>
|
||
{systemOperations}
|
||
</div>
|
||
);
|
||
}
|