Files
multi-simadmin/apps/web/src/instances/overview-system.tsx
T
chick a75badeb58 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.
2026-09-05 18:53:04 +08:00

493 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}