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.
258 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|