feat(web): rebuild fleet page with Hub-style identity guard and context actions
- Surface device identity state, carrier details and upstream link health. - Add fleet-level identity monitoring and restart/baseband context actions. - Apply consistent notification scoping across fleet views.
This commit is contained in:
@@ -2,15 +2,20 @@ import { useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEv
|
||||
import { Button, Card, Input, Progress, Tag } from 'animal-island-ui';
|
||||
|
||||
import {
|
||||
accessMethod,
|
||||
buildFleetTableViewModel,
|
||||
canonicalHttpOrigin,
|
||||
fleetStatusKind,
|
||||
type FleetAuthFilter,
|
||||
type FleetAccessFilter,
|
||||
type FleetFilter,
|
||||
type FleetInstance,
|
||||
type FleetSortColumn,
|
||||
type FleetStatus,
|
||||
type SortDirection,
|
||||
} from './fleet-table-view-model.js';
|
||||
|
||||
export { accessMethod, canonicalHttpOrigin };
|
||||
import type {
|
||||
FleetMessageLoadState,
|
||||
FleetMessagesDataSource,
|
||||
@@ -19,6 +24,11 @@ import { loadFleetMessageSummaries } from './fleet-messages-api-data-source.js';
|
||||
import { formatPhoneNumbers } from './phone-privacy.js';
|
||||
import { useSensitiveReveal } from '../privacy/sensitive-reveal.js';
|
||||
import { FleetOrganizationPanel } from './fleet-organization-panel.js';
|
||||
import { FleetIdentityPanel } from './fleet-identity-panel.js';
|
||||
import {
|
||||
createIdentityApiDataSource,
|
||||
type IdentityDataSource,
|
||||
} from './identity-api-data-source.js';
|
||||
import {
|
||||
createOrganizationApiDataSource,
|
||||
type OrganizationDataSource,
|
||||
@@ -39,6 +49,8 @@ export interface FleetPageProps {
|
||||
readonly dataSource?: FleetDataSource;
|
||||
readonly messagesDataSource?: FleetMessagesDataSource;
|
||||
readonly organizationDataSource?: OrganizationDataSource;
|
||||
/** Device identity guard surface; defaults to the control-plane routes. */
|
||||
readonly identityDataSource?: IdentityDataSource;
|
||||
/** Groups owned by the shell; the panel mutates them and asks the shell to reload. */
|
||||
readonly groups?: readonly OrganizationGroup[];
|
||||
readonly onGroupsChanged?: () => void;
|
||||
@@ -161,57 +173,10 @@ function heartbeatAge(value: string, now: number): string {
|
||||
return `${Math.floor(seconds / 86400)} 天前`;
|
||||
}
|
||||
|
||||
const PRIVATE_NETWORKS = [
|
||||
/^10\./u,
|
||||
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./u,
|
||||
/^127\./u,
|
||||
/^169\.254\./u,
|
||||
/^172\.(1[6-9]|2\d|3[01])\./u,
|
||||
/^192\.168\./u,
|
||||
/^(?:fe80|fc|fd)/u,
|
||||
];
|
||||
|
||||
/**
|
||||
* How the control plane reaches the node. Hub labels the same idea 接入方式, and the answer is
|
||||
* already implied by the origin, so it is derived rather than stored.
|
||||
*/
|
||||
export function accessMethod(
|
||||
value: string,
|
||||
): Readonly<{ label: string; kind: 'local' | 'lan' | 'wan' }> | null {
|
||||
const origin = canonicalHttpOrigin(value);
|
||||
if (!origin) return null;
|
||||
let host: string | null;
|
||||
try {
|
||||
host = new URL(origin).hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!host) return null;
|
||||
const bare = host.replace(/^\[(.*)\]$/u, '$1').toLowerCase();
|
||||
if (bare === 'localhost' || bare === '::1' || /^127\./u.test(bare))
|
||||
return { label: '本机直连', kind: 'local' };
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/u.test(bare) || /^[0-9a-f:]+$/u.test(bare))
|
||||
return PRIVATE_NETWORKS.some((pattern) => pattern.test(bare))
|
||||
? { label: '局域网接入', kind: 'lan' }
|
||||
: { label: '公网地址', kind: 'wan' };
|
||||
return { label: '域名接入', kind: 'wan' };
|
||||
}
|
||||
|
||||
export function canonicalHttpOrigin(value: string): string | null {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||
url.username = '';
|
||||
url.password = '';
|
||||
return url.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type BatchActionKind = 'service-restart' | 'system-reboot';
|
||||
type CardActionKind = BatchActionKind | 'baseband-restart';
|
||||
type BatchProgress = Readonly<{
|
||||
action: BatchActionKind;
|
||||
action: CardActionKind;
|
||||
total: number;
|
||||
completed: number;
|
||||
succeeded: number;
|
||||
@@ -224,6 +189,29 @@ const SERVICE_RESTART = {
|
||||
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
|
||||
title: '重启服务',
|
||||
} as const;
|
||||
|
||||
const BASEBAND_RESTART = {
|
||||
operationId: 'postBasebandRestart',
|
||||
parameterSchemaId: 'simadmin.58e2204.postBasebandRestart.parameters.v1',
|
||||
title: '重启基带',
|
||||
} as const;
|
||||
|
||||
const CONTEXT_MODULES: ReadonlyArray<{
|
||||
module: string;
|
||||
label: string;
|
||||
icon: IconName;
|
||||
}> = [
|
||||
{ module: 'overview', label: '状态与资源', icon: 'grid' },
|
||||
{ module: 'cellular', label: '蜂窝面板', icon: 'activity' },
|
||||
{ module: 'sim', label: 'SIM 面板', icon: 'server' },
|
||||
{ module: 'device-network', label: '设备网络', icon: 'wifi' },
|
||||
{ module: 'messages', label: '短信中心', icon: 'message' },
|
||||
{ module: 'calls', label: '通话', icon: 'phone' },
|
||||
{ module: 'esim', label: 'eSIM', icon: 'server' },
|
||||
{ module: 'notifications', label: '通知', icon: 'alert' },
|
||||
{ module: 'automation', label: '自动化', icon: 'restart' },
|
||||
{ module: 'ota', label: 'OTA', icon: 'version' },
|
||||
];
|
||||
const SYSTEM_REBOOT = {
|
||||
operationId: 'postSystemReboot',
|
||||
parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1',
|
||||
@@ -235,6 +223,7 @@ export function FleetPage({
|
||||
dataSource,
|
||||
messagesDataSource,
|
||||
organizationDataSource,
|
||||
identityDataSource,
|
||||
groups: controlledGroups,
|
||||
initialData,
|
||||
refreshSignal = 0,
|
||||
@@ -248,6 +237,7 @@ export function FleetPage({
|
||||
const [query, setQuery] = useState('');
|
||||
const [filter, setFilter] = useState<FleetFilter>('all');
|
||||
const [auth, setAuth] = useState<FleetAuthFilter>('all');
|
||||
const [access, setAccess] = useState<FleetAccessFilter>('all');
|
||||
const [capability, setCapability] = useState('');
|
||||
const [version, setVersion] = useState('');
|
||||
const [tag, setTag] = useState('');
|
||||
@@ -255,6 +245,9 @@ export function FleetPage({
|
||||
const [ownGroups, setOwnGroups] = useState<readonly OrganizationGroup[]>([]);
|
||||
const [organizationRevision, setOrganizationRevision] = useState(0);
|
||||
const [organizationOpen, setOrganizationOpen] = useState(false);
|
||||
const [identityOpen, setIdentityOpen] = useState(false);
|
||||
const [contextId, setContextId] = useState<string>();
|
||||
const contextTriggerRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
const [page, setPage] = useState(1);
|
||||
const [sort, setSort] = useState<{ column: FleetSortColumn; direction: SortDirection }>({
|
||||
column: 'name',
|
||||
@@ -276,6 +269,7 @@ export function FleetPage({
|
||||
const cardMenuInitialFocus = useRef<'first' | 'last'>('first');
|
||||
const cardMenuPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
const cardMenuTriggerRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
const deviceContextPanelRef = useRef<HTMLElement | null>(null);
|
||||
const messagesOwner = useRef(0);
|
||||
const [messageStates, setMessageStates] = useState<ReadonlyMap<string, FleetMessageState>>(
|
||||
new Map(),
|
||||
@@ -288,8 +282,13 @@ export function FleetPage({
|
||||
() => organizationDataSource ?? createOrganizationApiDataSource(),
|
||||
[organizationDataSource],
|
||||
);
|
||||
const resolvedIdentityDataSource = useMemo(
|
||||
() => identityDataSource ?? createIdentityApiDataSource(),
|
||||
[identityDataSource],
|
||||
);
|
||||
const groups = controlledGroups ?? ownGroups;
|
||||
const groupNames = useMemo(() => new Map(groups.map((item) => [item.id, item.name])), [groups]);
|
||||
const [identityImeis, setIdentityImeis] = useState<ReadonlyMap<string, string>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
// The shell owns the shared group registry; standalone usage loads its own copy.
|
||||
@@ -311,6 +310,30 @@ export function FleetPage({
|
||||
};
|
||||
}, [controlledGroups, resolvedOrganizationDataSource, organizationRevision]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let active = true;
|
||||
void resolvedIdentityDataSource
|
||||
.list(controller.signal)
|
||||
.then((items) => {
|
||||
if (!active) return;
|
||||
setIdentityImeis(
|
||||
new Map(
|
||||
items.items
|
||||
.filter((item) => item.imei)
|
||||
.map((item) => [item.instanceId, item.imei] as const),
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
/* IMEI search is enrichment; the fleet list stays usable if identity is unavailable. */
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [resolvedIdentityDataSource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData && refreshSignal === 0) {
|
||||
setSnapshot(initialData);
|
||||
@@ -389,6 +412,18 @@ export function FleetPage({
|
||||
return () => document.removeEventListener('pointerdown', dismissFromOutside);
|
||||
}, [cardMenuId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextId) return;
|
||||
const dismissWithKeyboard = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
closeContext(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', dismissWithKeyboard);
|
||||
return () => document.removeEventListener('keydown', dismissWithKeyboard);
|
||||
}, [contextId]);
|
||||
|
||||
const closeCardMenu = (id: string, restoreFocus = false) => {
|
||||
if (restoreFocus) cardMenuTriggerRefs.current.get(id)?.focus();
|
||||
setCardMenuId(undefined);
|
||||
@@ -424,25 +459,51 @@ export function FleetPage({
|
||||
|
||||
const model = useMemo(
|
||||
() =>
|
||||
buildFleetTableViewModel(snapshot?.instances ?? [], snapshot?.statuses ?? new Map(), {
|
||||
query,
|
||||
filter,
|
||||
auth,
|
||||
...(capability ? { capability } : {}),
|
||||
...(version ? { version } : {}),
|
||||
...(tag ? { tag } : {}),
|
||||
...(group ? { group } : {}),
|
||||
groupNames,
|
||||
sort,
|
||||
selectedIds,
|
||||
page,
|
||||
}),
|
||||
buildFleetTableViewModel(
|
||||
snapshot?.instances ?? [],
|
||||
new Map(
|
||||
[...(snapshot?.statuses ?? new Map())].map(([id, status]) => [
|
||||
id,
|
||||
identityImeis.has(id)
|
||||
? ({
|
||||
...status,
|
||||
...(status.identity
|
||||
? { identity: { ...status.identity, imei: identityImeis.get(id) } }
|
||||
: {
|
||||
identity: {
|
||||
tracked: Boolean(identityImeis.get(id)),
|
||||
status: 'confirmed',
|
||||
reasons: [],
|
||||
imei: identityImeis.get(id),
|
||||
},
|
||||
}),
|
||||
} satisfies FleetStatus)
|
||||
: status,
|
||||
]),
|
||||
),
|
||||
{
|
||||
query,
|
||||
filter,
|
||||
auth,
|
||||
access,
|
||||
...(capability ? { capability } : {}),
|
||||
...(version ? { version } : {}),
|
||||
...(tag ? { tag } : {}),
|
||||
...(group ? { group } : {}),
|
||||
groupNames,
|
||||
sort,
|
||||
selectedIds,
|
||||
page,
|
||||
},
|
||||
),
|
||||
[
|
||||
auth,
|
||||
capability,
|
||||
access,
|
||||
filter,
|
||||
group,
|
||||
groupNames,
|
||||
identityImeis,
|
||||
page,
|
||||
query,
|
||||
selectedIds,
|
||||
@@ -482,6 +543,15 @@ export function FleetPage({
|
||||
() => [...messageStates.values()].filter((state) => state.unavailable).length,
|
||||
[messageStates],
|
||||
);
|
||||
// Nodes the identity guard is holding: their controls are greyed out, so the fleet rail has to
|
||||
// say so instead of letting the operator wonder why a reachable device refuses a command.
|
||||
const identityPendingCount = useMemo(
|
||||
() =>
|
||||
[...(snapshot?.statuses.values() ?? [])].filter(
|
||||
(status) => status.identity?.tracked === true && status.identity.status === 'pending',
|
||||
).length,
|
||||
[snapshot],
|
||||
);
|
||||
|
||||
const [notificationHealth, setNotificationHealth] = useState<{
|
||||
pending: number;
|
||||
@@ -582,6 +652,18 @@ export function FleetPage({
|
||||
},
|
||||
});
|
||||
}
|
||||
if (access !== 'all') {
|
||||
chips.push({
|
||||
key: 'access',
|
||||
label: `接入:${
|
||||
access === 'local' ? '本机直连' : access === 'lan' ? '局域网接入' : '公网地址'
|
||||
}`,
|
||||
onClear: () => {
|
||||
setAccess('all');
|
||||
setPage(1);
|
||||
},
|
||||
});
|
||||
}
|
||||
if (capability) {
|
||||
chips.push({
|
||||
key: 'capability',
|
||||
@@ -623,16 +705,17 @@ export function FleetPage({
|
||||
});
|
||||
}
|
||||
return chips;
|
||||
}, [auth, capability, filter, group, groupNames, query, tag, version]);
|
||||
}, [access, auth, capability, filter, group, groupNames, query, tag, version]);
|
||||
|
||||
const advancedFilterCount = useMemo(
|
||||
() =>
|
||||
(auth !== 'all' ? 1 : 0) +
|
||||
(access !== 'all' ? 1 : 0) +
|
||||
(capability ? 1 : 0) +
|
||||
(version ? 1 : 0) +
|
||||
(tag ? 1 : 0) +
|
||||
(group ? 1 : 0),
|
||||
[auth, capability, group, tag, version],
|
||||
[access, auth, capability, group, tag, version],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -648,6 +731,7 @@ export function FleetPage({
|
||||
setQuery('');
|
||||
setFilter('all');
|
||||
setAuth('all');
|
||||
setAccess('all');
|
||||
setCapability('');
|
||||
setVersion('');
|
||||
setTag('');
|
||||
@@ -692,10 +776,15 @@ export function FleetPage({
|
||||
}
|
||||
|
||||
async function runOperation(
|
||||
kind: BatchActionKind,
|
||||
kind: CardActionKind,
|
||||
targets: ReadonlyArray<{ instanceId: string; revision: number }>,
|
||||
): Promise<{ succeeded: number; failed: number }> {
|
||||
const op = kind === 'service-restart' ? SERVICE_RESTART : SYSTEM_REBOOT;
|
||||
const op =
|
||||
kind === 'service-restart'
|
||||
? SERVICE_RESTART
|
||||
: kind === 'baseband-restart'
|
||||
? BASEBAND_RESTART
|
||||
: SYSTEM_REBOOT;
|
||||
// Full catalog sorts by operationId and truncates at pageSize 100; search for the exact id.
|
||||
const catalog = await resolvedOperationClient.list({
|
||||
search: op.operationId,
|
||||
@@ -802,13 +891,18 @@ export function FleetPage({
|
||||
}
|
||||
}
|
||||
|
||||
async function runCardAction(id: string, kind: BatchActionKind): Promise<void> {
|
||||
async function runCardAction(id: string, kind: CardActionKind): Promise<void> {
|
||||
const instance = snapshot?.instances.find((item) => item.id === id);
|
||||
if (!instance?.revision || instance.revision < 1) {
|
||||
setCardAction({ id, busy: false, error: '缺少配置版本,请刷新后重试。' });
|
||||
return;
|
||||
}
|
||||
const title = kind === 'service-restart' ? SERVICE_RESTART.title : SYSTEM_REBOOT.title;
|
||||
const title =
|
||||
kind === 'service-restart'
|
||||
? SERVICE_RESTART.title
|
||||
: kind === 'baseband-restart'
|
||||
? BASEBAND_RESTART.title
|
||||
: SYSTEM_REBOOT.title;
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
!window.confirm(`将对该实例执行「${title}」。此操作为高风险,确认继续?`)
|
||||
@@ -832,6 +926,12 @@ export function FleetPage({
|
||||
}
|
||||
}
|
||||
|
||||
const closeContext = (restoreFocus = false): void => {
|
||||
const id = contextId;
|
||||
setContextId(undefined);
|
||||
if (restoreFocus && id) contextTriggerRefs.current.get(id)?.focus();
|
||||
};
|
||||
|
||||
const selectFilter = (
|
||||
label: string,
|
||||
value: string,
|
||||
@@ -856,6 +956,16 @@ export function FleetPage({
|
||||
</label>
|
||||
);
|
||||
|
||||
const contextInstance = snapshot?.instances.find((item) => item.id === contextId);
|
||||
const contextStatus = contextId ? snapshot?.statuses.get(contextId) : undefined;
|
||||
const contextRow = model.rows.find((row) => row.id === contextId);
|
||||
const contextResources = contextStatus?.summary?.resources;
|
||||
const contextPhoneNumbers = contextResources?.phoneNumbers ?? [];
|
||||
const contextLatestMessage = contextId ? messageStates.get(contextId)?.latest : undefined;
|
||||
const contextModules = (contextRow?.capabilities ?? [])
|
||||
.map((module) => CONTEXT_MODULES.find((item) => item.module === module))
|
||||
.filter((item): item is (typeof CONTEXT_MODULES)[number] => Boolean(item));
|
||||
|
||||
return (
|
||||
<section className="fleet-panel" aria-labelledby="fleet-title">
|
||||
<div className="fleet-heading">
|
||||
@@ -974,6 +1084,20 @@ export function FleetPage({
|
||||
: '--'}
|
||||
</strong>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="fleet-cross-health-item"
|
||||
aria-label="设备身份待确认节点"
|
||||
onClick={() => setIdentityOpen(true)}
|
||||
>
|
||||
<span>
|
||||
<Icon name="shield" />
|
||||
身份待确认
|
||||
</span>
|
||||
<strong data-tone={identityPendingCount > 0 ? 'attention' : 'ok'}>
|
||||
{identityPendingCount}
|
||||
</strong>
|
||||
</button>
|
||||
</section>
|
||||
{selectionMode ? (
|
||||
<div className="batch-entry" role="region" aria-label="批量操作入口">
|
||||
@@ -1096,6 +1220,20 @@ export function FleetPage({
|
||||
<option value="unknown">未知</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>接入方式</span>
|
||||
<select
|
||||
value={access}
|
||||
onChange={(event) =>
|
||||
resetPage(() => setAccess(event.currentTarget.value as FleetAccessFilter))
|
||||
}
|
||||
>
|
||||
<option value="all">全部接入方式</option>
|
||||
<option value="local">本机直连</option>
|
||||
<option value="lan">局域网接入</option>
|
||||
<option value="wan">公网地址</option>
|
||||
</select>
|
||||
</label>
|
||||
{selectFilter(
|
||||
'能力',
|
||||
capability,
|
||||
@@ -1152,6 +1290,15 @@ export function FleetPage({
|
||||
<Icon name="tag" />
|
||||
管理分组与标签
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="fleet-group-manage"
|
||||
aria-label="设备身份核对"
|
||||
onClick={() => setIdentityOpen(true)}
|
||||
>
|
||||
<Icon name="shield" />
|
||||
设备身份
|
||||
</button>
|
||||
</div>
|
||||
<div className="fleet-results-header">
|
||||
<div>
|
||||
@@ -1282,6 +1429,19 @@ export function FleetPage({
|
||||
<span className="status-dot" aria-hidden="true" />
|
||||
{STATUS_LABELS[row.statusKind]}
|
||||
</Tag>
|
||||
<button
|
||||
ref={(element) => {
|
||||
if (element) contextTriggerRefs.current.set(row.id, element);
|
||||
else contextTriggerRefs.current.delete(row.id);
|
||||
}}
|
||||
type="button"
|
||||
className="fleet-card-context-trigger"
|
||||
aria-label={`打开 ${row.displayName} 设备上下文`}
|
||||
title="设备上下文"
|
||||
onClick={() => setContextId(row.id)}
|
||||
>
|
||||
<Icon name="grid" />
|
||||
</button>
|
||||
{selectionMode ? (
|
||||
<label className="fleet-card-select touch-target">
|
||||
<input
|
||||
@@ -1357,6 +1517,20 @@ export function FleetPage({
|
||||
<Icon name="restart" />
|
||||
<span>重启服务</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
aria-label={`重启基带 ${row.displayName}`}
|
||||
disabled={cardAction?.id === row.id && cardAction.busy}
|
||||
onClick={() => {
|
||||
closeCardMenu(row.id, true);
|
||||
void runCardAction(row.id, 'baseband-restart');
|
||||
}}
|
||||
>
|
||||
<Icon name="restart" />
|
||||
<span>重启基带</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -1453,6 +1627,18 @@ export function FleetPage({
|
||||
{row.anomalies.join(', ')}
|
||||
</p>
|
||||
) : null}
|
||||
{row.identityPending ? (
|
||||
<p
|
||||
className="fleet-card-identity"
|
||||
aria-label={`${row.displayName} 身份待确认`}
|
||||
>
|
||||
<Tag size="small" color="app-red" variant="soft">
|
||||
<Icon name="shield" />
|
||||
身份待确认
|
||||
</Tag>
|
||||
<small>控制操作已暂停,请核对设备身份</small>
|
||||
</p>
|
||||
) : null}
|
||||
{row.tags.length > 0 ? (
|
||||
<div className="fleet-instance-tags" aria-label="实例标签">
|
||||
{row.tags.map((item) => (
|
||||
@@ -1712,6 +1898,179 @@ export function FleetPage({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{identityOpen ? (
|
||||
<FleetIdentityPanel
|
||||
dataSource={resolvedIdentityDataSource}
|
||||
onClose={() => setIdentityOpen(false)}
|
||||
onChanged={() => setAttempt((value) => value + 1)}
|
||||
/>
|
||||
) : null}
|
||||
{contextInstance ? (
|
||||
<div className="drawer-backdrop device-context-backdrop" onClick={() => closeContext(true)}>
|
||||
<aside
|
||||
ref={deviceContextPanelRef}
|
||||
className="schedule-drawer device-context-drawer"
|
||||
role="dialog"
|
||||
aria-label={`${contextRow?.displayName ?? contextInstance.name} 设备上下文`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<p className="device-context-eyebrow">
|
||||
<Icon name="server" />
|
||||
设备上下文
|
||||
</p>
|
||||
<h2>{contextRow?.displayName ?? contextInstance.name}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="关闭设备上下文"
|
||||
onClick={() => closeContext(true)}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="device-context-content">
|
||||
<section className="device-context-section" aria-label="连接与接入">
|
||||
<dl>
|
||||
<div>
|
||||
<dt>状态</dt>
|
||||
<dd>{STATUS_LABELS[fleetStatusKind(contextStatus)]}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>接入方式</dt>
|
||||
<dd>{accessMethod(contextInstance.url)?.label ?? '未知'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>版本</dt>
|
||||
<dd>{contextRow?.version ?? '未知'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>源地址</dt>
|
||||
<dd>
|
||||
{(() => {
|
||||
const origin = canonicalHttpOrigin(contextInstance.url);
|
||||
return origin ? (
|
||||
<a
|
||||
href={origin}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`打开 ${contextRow?.displayName ?? contextInstance.name} 节点入口`}
|
||||
>
|
||||
{origin}
|
||||
</a>
|
||||
) : (
|
||||
'无效'
|
||||
);
|
||||
})()}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="device-context-section" aria-label="设备与 SIM">
|
||||
<dl>
|
||||
<div>
|
||||
<dt>手机号</dt>
|
||||
<dd>
|
||||
{contextPhoneNumbers.length
|
||||
? formatPhoneNumbers(contextPhoneNumbers, sensitive)
|
||||
: '暂未获取'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>运营商</dt>
|
||||
<dd>{contextResources?.carrier ?? '未知'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>接入制式</dt>
|
||||
<dd>{contextResources?.accessTechnology ?? '未知'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>注册状态</dt>
|
||||
<dd>{registrationLabel(contextResources?.cellularRegistration)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="device-context-section" aria-label="系统资源">
|
||||
<dl>
|
||||
<div>
|
||||
<dt>运行时长</dt>
|
||||
<dd>{formatUptime(contextResources?.uptimeSeconds)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>最高温度</dt>
|
||||
<dd>{temperature(contextResources?.maxTemperatureCelsius)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>CPU</dt>
|
||||
<dd>
|
||||
{contextResources?.cpuPercent === undefined ? (
|
||||
'--'
|
||||
) : (
|
||||
<div className="fleet-resource-meter">
|
||||
<Progress
|
||||
percent={contextResources.cpuPercent}
|
||||
size="small"
|
||||
showInfo={false}
|
||||
duration={0}
|
||||
aria-label="CPU 使用率"
|
||||
/>
|
||||
<strong>{percent(contextResources.cpuPercent)}</strong>
|
||||
</div>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>内存</dt>
|
||||
<dd>{percent(contextResources?.memoryPercent)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="device-context-section" aria-label="短信状态">
|
||||
{contextLatestMessage ? (
|
||||
<p>
|
||||
{messageDirection(contextLatestMessage.direction)} ·{' '}
|
||||
{formatPhoneNumbers([contextLatestMessage.phoneNumber], sensitive)}
|
||||
<time dateTime={contextLatestMessage.timestamp}>
|
||||
{messageTime(contextLatestMessage.timestamp)}
|
||||
</time>
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{messageStates.get(contextInstance.id)?.unavailable
|
||||
? '短信暂不可用'
|
||||
: '暂无短信'}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="device-context-actions" aria-label="功能入口">
|
||||
<a
|
||||
className="device-context-primary"
|
||||
href={`/instances/${encodeURIComponent(contextInstance.id)}/overview`}
|
||||
aria-label={`打开 ${contextRow?.displayName ?? contextInstance.name} 实例仪表盘`}
|
||||
>
|
||||
<Icon name="grid" />
|
||||
打开实例仪表盘
|
||||
</a>
|
||||
{contextModules.map((item) => (
|
||||
<a
|
||||
key={item.module}
|
||||
href={`/instances/${encodeURIComponent(contextInstance.id)}/${item.module}`}
|
||||
>
|
||||
<Icon name={item.icon} />
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user