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:
chick
2026-09-07 00:45:28 +08:00
parent 635527dd26
commit 864e1bd90c
16 changed files with 1596 additions and 90 deletions
+427 -68
View File
@@ -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>
);
}