221 lines
6.8 KiB
TypeScript
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>
|
|
);
|
|
}
|