Files
multi-simadmin/apps/web/src/instances/overview-system.tsx
T

221 lines
6.8 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import type { InstanceContext } from '../app-shell.js';
export type OverviewFieldValue = string | number | boolean | null;
export type OverviewSection = Readonly<Record<string, OverviewFieldValue>>;
export interface OverviewSnapshot {
readonly observedAt?: string;
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;
/** Change this value when an external owner has requested a refresh. */
readonly refreshSignal?: unknown;
}
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: '型号',
Uptime: '运行时间',
Slots: '卡槽数',
Active: '活跃数',
Operator: '运营商',
Registration: '注册状态',
'Messages today': '今日消息数',
Calls: '通话数',
Usage: '使用率',
Temperature: '温度',
State: '状态',
Latency: '延迟',
};
const ENUM_LABELS: Readonly<Record<string, string>> = {
connected: '已连接',
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>
);
}
export function OverviewSystemPage({
instance,
dataSource,
refreshSignal,
}: OverviewSystemPageProps) {
const requestOwner = useRef(0);
const [retry, setRetry] = useState(0);
const [state, setState] = useState<ReadState>({ kind: 'idle' });
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]);
if (instance.authentication === 'auth-required')
return (
<div className="state-panel state-error" role="alert">
需要先完成认证,才能读取概览数据。
</div>
);
if (!dataSource)
return (
<div className="state-panel" role="status" aria-label="概览不可用">
没有可用的安全概览只读数据源。此控制台不会臆造或调用未签订契约的生产端点。
</div>
);
const retainedSnapshot =
state.kind === 'loading' || state.kind === 'error' ? state.snapshot : undefined;
if ((state.kind === 'loading' || state.kind === 'idle') && !retainedSnapshot)
return (
<p role="status" aria-label="概览加载状态">
正在加载概览…
</p>
);
if (state.kind === 'error' && !state.snapshot)
return (
<div className="state-panel state-error" role="alert">
<p>无法加载概览: {state.message}</p>
<button type="button" onClick={() => setRetry((value) => value + 1)}>
重试加载概览
</button>
</div>
);
const snapshot = state.kind === 'ready' ? state.snapshot : retainedSnapshot;
if (!snapshot) return null;
return (
<div className="overview-system">
{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>
<section className="state-panel" aria-label="系统操作">
<h2>系统操作</h2>
<p>重启不可用,因为后端尚未实现 R3 重启操作,因此不提供任何操作。</p>
</section>
</div>
);
}