Files
multi-simadmin/apps/web/src/instances/instance-detail.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

258 lines
8.2 KiB
TypeScript

import type { ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Tag, Title } from 'animal-island-ui';
import type { InstanceContext, InstanceModule } from '../app-shell.js';
import { canonicalHttpOrigin } from '../fleet/fleet-page.js';
import { displayStatus } from '../ui/locale.js';
export type CapabilityState = 'supported' | 'degraded' | 'unsupported' | 'unknown';
export interface InstanceCapability {
readonly state: CapabilityState;
readonly explanation?: string;
}
export type InstanceCapabilityMap = Readonly<Partial<Record<InstanceModule, InstanceCapability>>>;
export interface CapabilityDataSource {
load(instanceId: string, signal: AbortSignal): Promise<InstanceCapabilityMap>;
}
export interface InstanceDetailProps {
readonly instanceId: string;
readonly module: InstanceModule;
readonly instance?: InstanceContext;
readonly capabilities?: InstanceCapabilityMap;
readonly capabilityDataSource?: CapabilityDataSource;
readonly moduleContent?: ReactNode;
}
export const INSTANCE_MODULE_LABELS: Readonly<Record<InstanceModule, string>> = {
overview: '实例仪表盘',
sim: 'SIM 卡',
cellular: '蜂窝网络',
'device-network': '设备网络',
messages: '短信管理',
calls: '通话',
esim: 'eSIM',
vowifi: 'WiFi 通话',
notifications: '通知',
automation: '自动化',
ota: 'OTA',
configuration: '设备设置',
'device-backup': '设备备份',
};
const MODULE_NAV: readonly InstanceModule[] = [
'overview',
'sim',
'cellular',
'device-network',
'messages',
'calls',
'esim',
'vowifi',
'notifications',
'automation',
'ota',
'configuration',
'device-backup',
] as const satisfies readonly InstanceModule[];
const SHORT_LABELS: Readonly<Partial<Record<InstanceModule, string>>> = {
overview: '仪表盘',
cellular: '蜂窝',
'device-network': '设备网络',
messages: '短信',
notifications: '通知',
configuration: '设置',
'device-backup': '备份',
};
const AUTH_LABELS = { authenticated: '已认证', 'auth-required': '需要认证' } as const;
const FRESHNESS_LABELS = {
fresh: '数据最新',
stale: '数据可能过期',
expired: '数据已过期',
} as const;
const DEFAULT_EXPLANATIONS: Readonly<Record<Exclude<CapabilityState, 'supported'>, string>> = {
degraded: '此模块可用,但功能受限。',
unsupported: '此实例不支持该模块。',
unknown: '能力状态未知。',
};
function canRender(capability: InstanceCapability | undefined): boolean {
if (!capability) return true;
return capability.state === 'supported' || capability.state === 'degraded';
}
/**
* Every tab is backed by a live device probe, so an unknown capability no longer hides a module;
* the module itself reports the sections a device cannot serve.
*/
function canOpen(_module: InstanceModule, capability: InstanceCapability | undefined): boolean {
return capability ? canRender(capability) : true;
}
function explanation(capability: InstanceCapability | undefined): string | null {
if (!capability) return null;
if (capability.state === 'supported') return null;
return capability.explanation?.trim() || DEFAULT_EXPLANATIONS[capability.state];
}
/**
* Owner-fenced instance context and capability navigation for every instance route.
* Capability discovery is injected: this framework deliberately assumes no API or operation IDs.
*/
export function InstanceDetail({
instanceId,
module,
instance,
capabilities,
capabilityDataSource,
moduleContent,
}: InstanceDetailProps) {
const ownsRoute = instance?.id === instanceId;
const [loadedCapabilities, setLoadedCapabilities] = useState<InstanceCapabilityMap | undefined>();
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const requestRef = useRef(0);
useEffect(() => {
const request = ++requestRef.current;
setLoadedCapabilities(undefined);
setLoadError(null);
if (!ownsRoute || capabilities || !capabilityDataSource) {
setLoading(false);
return;
}
const controller = new AbortController();
setLoading(true);
void capabilityDataSource.load(instanceId, controller.signal).then(
(result) => {
if (!controller.signal.aborted && requestRef.current === request) {
setLoadedCapabilities(result);
setLoading(false);
}
},
(error: unknown) => {
void error;
if (!controller.signal.aborted && requestRef.current === request) {
setLoadError('能力探测失败。');
setLoading(false);
}
},
);
return () => controller.abort();
}, [capabilities, capabilityDataSource, instanceId, ownsRoute]);
if (!ownsRoute) {
return (
<section>
<h1>
<Title>{INSTANCE_MODULE_LABELS[module]}</Title>
</h1>
<p>此路由缺少实例上下文。</p>
</section>
);
}
const map = capabilities ?? loadedCapabilities ?? {};
const origin = canonicalHttpOrigin(instance.origin);
const activeCapability = map[module];
return (
<section className="instance-detail">
<header className="instance-context" aria-label="当前实例">
<a className="instance-breadcrumb" href="/fleet">
返回实例总览
</a>
<div className="instance-identity">
<div>
<h1>
<Title color="app-teal">{instance.name}</Title>
</h1>
<code>{instance.id}</code>
</div>
<div className="instance-context-badges">
{instance.status !== 'unknown' ? (
<Tag
size="small"
color={
instance.status === 'online'
? 'app-teal'
: instance.status === 'offline'
? 'app-red'
: 'app-yellow'
}
>
{displayStatus(instance.status)}
</Tag>
) : null}
{instance.authentication !== 'unknown' ? (
<Tag size="small" color="app-yellow">
{AUTH_LABELS[instance.authentication]}
</Tag>
) : null}
{instance.freshness !== 'unknown' ? (
<Tag size="small" color="app-blue">
{FRESHNESS_LABELS[instance.freshness]}
</Tag>
) : null}
</div>
</div>
{instance.status !== 'unknown' ? (
<dl className="visually-hidden">
<div>
<dt>状态</dt>
<dd>{displayStatus(instance.status)}</dd>
</div>
</dl>
) : null}
<div className="instance-header-actions">
{origin ? <span title={origin}>源站已由控制平面托管</span> : null}
<a href={`/settings/instances/${encodeURIComponent(instanceId)}`}>编辑实例</a>
</div>
<nav aria-label="实例模块">
<ul>
{MODULE_NAV.map((item) => {
return (
<li key={item}>
<a
href={`/instances/${encodeURIComponent(instanceId)}/${item}`}
aria-current={module === item ? 'page' : undefined}
>
{SHORT_LABELS[item] ?? INSTANCE_MODULE_LABELS[item]}
</a>
</li>
);
})}
</ul>
</nav>
</header>
<div className="instance-module-detail">
<h2>
<Title size="small" color="app-green">
{INSTANCE_MODULE_LABELS[module]}
</Title>
</h2>
{loading ? <p role="status">正在加载能力…</p> : null}
{loadError ? <p role="alert">能力不可用:{loadError}</p> : null}
{!loading && canOpen(module, activeCapability) ? (
<>
{activeCapability?.state === 'degraded' ? (
<p data-capability-state="degraded">{explanation(activeCapability)}</p>
) : null}
{moduleContent ?? <p>查看{INSTANCE_MODULE_LABELS[module]}数据和可用操作。</p>}
</>
) : null}
{!loading && !canOpen(module, activeCapability) ? (
<p data-capability-state={activeCapability?.state}>{explanation(activeCapability)}</p>
) : null}
</div>
</section>
);
}