Files
multi-simadmin/apps/web/src/fleet/fleet-page.tsx
T
chick 001659ed91 style(web): unify panel, group and state controls
Standardize panel actions, group filters, segmented modes, workbench tabs, message filters, identity controls and privacy toggles on the shared button scale. Add cascade-aware tests so stale compact sizes and low-contrast states cannot return.
2026-09-08 19:58:10 +08:00

2076 lines
81 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react';
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,
} from './fleet-messages-api-data-source.js';
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,
type OrganizationGroup,
} from './organization-api-data-source.js';
import { createOperationClient, type OperationClient } from '../operations/operation-client.js';
import { safeUiError } from '../ui/locale.js';
import { Icon, type IconName } from '../ui/icon.js';
export interface FleetSnapshot {
readonly instances: readonly FleetInstance[];
readonly statuses: ReadonlyMap<string, FleetStatus>;
}
export interface FleetDataSource {
load(signal?: AbortSignal, onPartial?: (snapshot: FleetSnapshot) => void): Promise<FleetSnapshot>;
}
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;
readonly initialData?: FleetSnapshot;
readonly refreshSignal?: number;
readonly operationClient?: OperationClient;
}
type FleetMessageState = FleetMessageLoadState;
const EMPTY_SNAPSHOT: FleetSnapshot = { instances: [], statuses: new Map() };
const STATUS_LABELS: Readonly<Record<string, string>> = {
online: '在线',
auth: '需要认证',
offline: '离线',
unknown: '未知',
};
const STATUS_COLORS = {
online: 'app-teal',
auth: 'app-yellow',
offline: 'app-red',
unknown: 'brown',
} as const;
const SORT_COLUMN_LABELS: Readonly<Record<FleetSortColumn, string>> = {
name: '名称',
status: '状态',
latency: '延迟',
version: '版本',
capabilities: '能力',
tags: '标签',
group: '分组',
freshness: '数据新鲜度',
anomalies: '异常',
};
const CAPABILITY_LABELS: Readonly<Record<string, string>> = {
overview: '概览',
cellular: '蜂窝网络',
'device-network': '设备网络',
messages: '消息',
calls: '通话',
esim: 'eSIM',
notifications: '通知',
automation: '自动化',
ota: 'OTA',
};
function capabilityLabel(value: string): string {
return CAPABILITY_LABELS[value] ?? value;
}
const CAPABILITY_ICONS: Readonly<Record<string, IconName>> = {
overview: 'grid',
cellular: 'activity',
'device-network': 'wifi',
messages: 'message',
sms: 'message',
calls: 'phone',
esim: 'server',
notifications: 'alert',
automation: 'restart',
ota: 'version',
};
function initials(value: string): string {
const parts = value.trim().split(/\s+/u).filter(Boolean);
if (parts.length > 1)
return parts
.slice(0, 2)
.map((item) => item[0])
.join('')
.toUpperCase();
return [...(parts[0] ?? 'S')].slice(0, 2).join('').toUpperCase();
}
const percent = (value: number | undefined): string =>
value === undefined ? '--' : `${value.toFixed(1)}%`;
const registrationLabel = (value: string | undefined): string => {
if (value === 'registered_home' || value === 'registered_roaming') return '已注册';
if (value === 'searching') return '搜网中';
if (value === 'registration_denied') return '受限';
if (value === 'not_registered') return '未注册';
return '状态未知';
};
const booleanStateLabel = (value: boolean | undefined): string =>
value === undefined ? '未知' : value ? '正常' : '断开';
const temperature = (value: number | undefined): string =>
value === undefined ? '--' : `${value.toFixed(1)} °C`;
/** Device uptime in the compact 天/小时/分 shape the Hub device list uses. */
function formatUptime(seconds: number | undefined): string {
if (seconds === undefined) return '--';
const total = Math.max(0, Math.floor(seconds));
const days = Math.floor(total / 86400);
const hours = Math.floor((total % 86400) / 3600);
const minutes = Math.floor((total % 3600) / 60);
if (days > 0) return `${days}天 ${hours}小时`;
if (hours > 0) return `${hours}小时 ${minutes}分`;
if (minutes > 0) return `${minutes}分`;
return `${total}秒`;
}
const messageDirection = (value: string): string =>
value === 'incoming' || value === 'received'
? '收到'
: value === 'outgoing' || value === 'sent'
? '发送'
: '未知方向';
const messageTime = (value: string): string => {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN');
};
/** Heartbeat age in words; the console polls often enough that seconds stay meaningful. */
function heartbeatAge(value: string, now: number): string {
const timestamp = Date.parse(value);
if (Number.isNaN(timestamp)) return '时间未知';
const seconds = Math.max(0, Math.round((now - timestamp) / 1000));
if (seconds < 60) return `${seconds} 秒前`;
if (seconds < 3600) return `${Math.floor(seconds / 60)} 分钟前`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)} 小时前`;
return `${Math.floor(seconds / 86400)} 天前`;
}
type BatchActionKind = 'service-restart' | 'system-reboot';
type CardActionKind = BatchActionKind | 'baseband-restart';
type BatchProgress = Readonly<{
action: CardActionKind;
total: number;
completed: number;
succeeded: number;
failed: number;
message: string;
}>;
const SERVICE_RESTART = {
operationId: 'postServiceRestart',
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',
title: '系统重启',
delaySeconds: 3,
} as const;
export function FleetPage({
dataSource,
messagesDataSource,
organizationDataSource,
identityDataSource,
groups: controlledGroups,
initialData,
refreshSignal = 0,
operationClient,
onGroupsChanged,
}: FleetPageProps) {
const { revealed: sensitive } = useSensitiveReveal();
const [snapshot, setSnapshot] = useState<FleetSnapshot | null>(initialData ?? null);
const [error, setError] = useState<string | null>(null);
const [attempt, setAttempt] = useState(0);
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('');
const [group, setGroup] = useState('');
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',
direction: 'asc',
});
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
const [revealedPhoneIds, setRevealedPhoneIds] = useState<ReadonlySet<string>>(new Set());
const [selectionMode, setSelectionMode] = useState(false);
const [filtersCollapsed, setFiltersCollapsed] = useState(false);
const [batchBusy, setBatchBusy] = useState(false);
const [batchProgress, setBatchProgress] = useState<BatchProgress | null>(null);
const [cardAction, setCardAction] = useState<{
id: string;
busy: boolean;
message?: string;
error?: string;
} | null>(null);
const [cardMenuId, setCardMenuId] = useState<string>();
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(),
);
const resolvedOperationClient = useMemo(
() => operationClient ?? createOperationClient(),
[operationClient],
);
const resolvedOrganizationDataSource = useMemo(
() => 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.
if (controlledGroups) return;
const controller = new AbortController();
let active = true;
void resolvedOrganizationDataSource
.listGroups(controller.signal)
.then((items) => {
if (!active) return;
setOwnGroups(items);
})
.catch(() => {
/* Group filtering degrades to "all nodes" when the registry is unavailable. */
});
return () => {
active = false;
controller.abort();
};
}, [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);
setError(null);
return;
}
if (!dataSource) {
setSnapshot(EMPTY_SNAPSHOT);
return;
}
let active = true;
const controller = new AbortController();
// Keep the last known instances visible while refreshing so the homepage stays interactive.
setSnapshot((current) => current);
setError(null);
void dataSource
.load(controller.signal, (partial) => {
if (active) setSnapshot(partial);
})
.then(
(data) => {
if (active) setSnapshot(data);
},
(reason: unknown) => {
if (!active) return;
if (reason instanceof DOMException && reason.name === 'AbortError') return;
setError('实例加载失败。');
},
);
return () => {
active = false;
controller.abort();
};
}, [attempt, dataSource, initialData, refreshSignal]);
useEffect(() => {
const request = ++messagesOwner.current;
const controller = new AbortController();
const instances = snapshot?.instances;
setMessageStates(new Map<string, FleetMessageState>());
if (!messagesDataSource || !instances) return () => controller.abort();
void loadFleetMessageSummaries(
messagesDataSource,
instances.map((instance) => instance.id),
controller.signal,
).then((entries) => {
if (request === messagesOwner.current && !controller.signal.aborted)
setMessageStates(entries);
});
return () => controller.abort();
}, [messagesDataSource, snapshot]);
useEffect(() => {
if (!cardMenuId) return;
const items = cardMenuPanelRef.current?.querySelectorAll<HTMLButtonElement>(
'[role="menuitem"]:not(:disabled)',
);
if (!items?.length) return;
const index = cardMenuInitialFocus.current === 'last' ? items.length - 1 : 0;
items[index]?.focus();
}, [cardMenuId]);
useEffect(() => {
if (!cardMenuId) return;
const dismissFromOutside = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (
cardMenuPanelRef.current?.contains(target) ||
cardMenuTriggerRefs.current.get(cardMenuId)?.contains(target)
)
return;
setCardMenuId(undefined);
};
document.addEventListener('pointerdown', dismissFromOutside);
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);
};
const moveCardMenuFocus = (event: KeyboardEvent<HTMLDivElement>, id: string) => {
if (event.key === 'Escape') {
event.preventDefault();
closeCardMenu(id, true);
return;
}
if (event.key === 'Tab') {
setCardMenuId(undefined);
return;
}
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
const items = Array.from(
event.currentTarget.querySelectorAll<HTMLElement>('[role="menuitem"]:not(:disabled)'),
);
if (!items.length) return;
event.preventDefault();
const currentIndex = items.indexOf(document.activeElement as HTMLElement);
const nextIndex =
event.key === 'Home'
? 0
: event.key === 'End'
? items.length - 1
: event.key === 'ArrowDown'
? (currentIndex + 1) % items.length
: (currentIndex - 1 + items.length) % items.length;
items[nextIndex]?.focus();
};
const model = useMemo(
() =>
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,
snapshot,
sort,
tag,
version,
],
);
const fleetSummary = useMemo(() => {
const statuses = [...(snapshot?.statuses.values() ?? [])];
const online = statuses.filter(
(status) => status.reachable && status.authenticated !== false,
).length;
// Mirrors the Hub health rule: a node counts as usable only when the radio chain is up.
const cellular = statuses.filter((status) => {
const resources = status.summary?.resources;
return (
status.reachable &&
status.authenticated !== false &&
resources?.hardwareOnline === true &&
resources?.controlOnline === true &&
resources?.simPresent === true &&
resources?.cellularOnline === true
);
}).length;
return {
total: snapshot?.instances.length ?? 0,
online,
cellular,
attention: (snapshot?.instances.length ?? 0) - online,
};
}, [snapshot]);
const smsUnavailableCount = useMemo(
() => [...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;
failed: number;
} | null>(null);
useEffect(() => {
const controller = new AbortController();
let active = true;
void (async () => {
try {
const response = await fetch('/api/v1/notifications/overview', {
method: 'GET',
credentials: 'same-origin',
headers: { accept: 'application/json' },
signal: controller.signal,
});
if (!response.ok) throw new Error('notification health request failed');
const body = (await response.json()) as unknown;
const source =
body !== null && typeof body === 'object' && !Array.isArray(body)
? (body as Record<string, unknown>)
: undefined;
const wrapped =
source?.data !== null && typeof source?.data === 'object' && !Array.isArray(source?.data)
? (source.data as Record<string, unknown>)
: source;
const queue =
wrapped?.queue !== null && typeof wrapped?.queue === 'object'
? (wrapped.queue as Record<string, unknown>)
: undefined;
const pending =
typeof queue?.pending === 'number' && Number.isSafeInteger(queue.pending)
? queue.pending
: 0;
const failed =
typeof queue?.failed === 'number' && Number.isSafeInteger(queue.failed)
? queue.failed
: 0;
if (active) setNotificationHealth({ pending, failed });
} catch {
if (active && !controller.signal.aborted) setNotificationHealth(null);
}
})();
return () => {
active = false;
controller.abort();
};
}, [snapshot]);
const statusFacetCounts = useMemo(() => {
const counts: Record<FleetFilter, number> = {
all: snapshot?.instances.length ?? 0,
online: 0,
auth: 0,
offline: 0,
unknown: 0,
};
for (const instance of snapshot?.instances ?? []) {
const kind = fleetStatusKind(snapshot?.statuses.get(instance.id));
counts[kind] += 1;
}
return counts;
}, [snapshot]);
const activeFilterChips = useMemo(() => {
const chips: Array<{ key: string; label: string; onClear: () => void }> = [];
const trimmedQuery = query.trim();
if (trimmedQuery) {
chips.push({
key: 'query',
label: `搜索:${trimmedQuery}`,
onClear: () => {
setQuery('');
setPage(1);
},
});
}
if (filter !== 'all') {
chips.push({
key: 'status',
label: `状态:${STATUS_LABELS[filter] ?? filter}`,
onClear: () => {
setFilter('all');
setPage(1);
},
});
}
if (auth !== 'all') {
const authLabel =
auth === 'authenticated' ? '已认证' : auth === 'required' ? '需要认证' : '未知';
chips.push({
key: 'auth',
label: `认证:${authLabel}`,
onClear: () => {
setAuth('all');
setPage(1);
},
});
}
if (access !== 'all') {
chips.push({
key: 'access',
label: `接入:${
access === 'local' ? '本机直连' : access === 'lan' ? '局域网接入' : '公网地址'
}`,
onClear: () => {
setAccess('all');
setPage(1);
},
});
}
if (capability) {
chips.push({
key: 'capability',
label: `能力:${capabilityLabel(capability)}`,
onClear: () => {
setCapability('');
setPage(1);
},
});
}
if (version) {
chips.push({
key: 'version',
label: `版本:${version}`,
onClear: () => {
setVersion('');
setPage(1);
},
});
}
if (tag) {
chips.push({
key: 'tag',
label: `标签:${tag}`,
onClear: () => {
setTag('');
setPage(1);
},
});
}
if (group) {
chips.push({
key: 'group',
label: `分组:${groupNames.get(group) ?? group}`,
onClear: () => {
setGroup('');
setPage(1);
},
});
}
return chips;
}, [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),
[access, auth, capability, group, tag, version],
);
useEffect(() => {
if (page !== model.page) setPage(model.page);
}, [model.page, page]);
function resetPage(action: () => void): void {
action();
setPage(1);
}
function clearAllFilters(): void {
setQuery('');
setFilter('all');
setAuth('all');
setAccess('all');
setCapability('');
setVersion('');
setTag('');
setGroup('');
setPage(1);
}
function toggleOne(id: string, checked: boolean): void {
setSelectedIds((current) => {
const next = new Set(current);
if (checked) next.add(id);
else next.delete(id);
return next;
});
}
function toggleVisible(checked: boolean): void {
setSelectedIds((current) => {
const next = new Set(current);
for (const row of model.rows) {
if (checked) next.add(row.id);
else next.delete(row.id);
}
return next;
});
}
function togglePhonePrivacy(id: string): void {
setRevealedPhoneIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function selectedTargets(): Array<{ instanceId: string; revision: number }> {
const instances = snapshot?.instances ?? [];
return model.selectedIds.flatMap((id) => {
const instance = instances.find((item) => item.id === id);
if (!instance || !instance.revision || instance.revision < 1) return [];
return [{ instanceId: id, revision: instance.revision }];
});
}
async function runOperation(
kind: CardActionKind,
targets: ReadonlyArray<{ instanceId: string; revision: number }>,
): Promise<{ succeeded: number; failed: number }> {
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,
pageSize: 100,
});
const entry = catalog.items.find((item) => item.operationId === op.operationId);
if (!entry || entry.parameterSchemaId !== op.parameterSchemaId)
throw new Error('Operation is not available.');
let succeeded = 0;
let failed = 0;
let completed = 0;
for (const target of targets) {
try {
const prepared = await resolvedOperationClient.prepare({
operationId: op.operationId,
targets: [target],
parameters: {
parameterSchemaId: op.parameterSchemaId,
fields:
kind === 'system-reboot'
? [{ fieldId: 'delay_seconds', kind: 'number', value: SYSTEM_REBOOT.delaySeconds }]
: [],
},
});
const confirmed =
typeof window === 'undefined'
? true
: window.confirm(
`${prepared.confirmationPrompt}
风险等级 ${prepared.risk}。确认继续?`,
);
if (!confirmed) {
failed += 1;
} else {
const job = await resolvedOperationClient.execute(prepared.id);
if (job.status === 'succeeded') succeeded += 1;
else failed += 1;
}
} catch {
failed += 1;
}
completed += 1;
setBatchProgress({
action: kind,
total: targets.length,
completed,
succeeded,
failed,
message: `${op.title}进度 ${completed}/${targets.length}`,
});
}
return { succeeded, failed };
}
async function runBatch(kind: BatchActionKind): Promise<void> {
const targets = selectedTargets();
if (targets.length === 0) {
setBatchProgress({
action: kind,
total: 0,
completed: 0,
succeeded: 0,
failed: 0,
message: '所选实例缺少配置版本,请刷新后重试。',
});
return;
}
const title = kind === 'service-restart' ? SERVICE_RESTART.title : SYSTEM_REBOOT.title;
if (
typeof window !== 'undefined' &&
!window.confirm(`将对 ${targets.length} 个实例执行「${title}」。此操作为高风险,确认继续?`)
)
return;
setBatchBusy(true);
setBatchProgress({
action: kind,
total: targets.length,
completed: 0,
succeeded: 0,
failed: 0,
message: `正在执行${title}…`,
});
try {
const result = await runOperation(kind, targets);
setBatchProgress({
action: kind,
total: targets.length,
completed: targets.length,
succeeded: result.succeeded,
failed: result.failed,
message: `${title}完成:成功 ${result.succeeded},失败 ${result.failed}。`,
});
} catch (error) {
setBatchProgress({
action: kind,
total: targets.length,
completed: 0,
succeeded: 0,
failed: targets.length,
message: safeUiError(error, `${title}失败,请稍后重试。`),
});
} finally {
setBatchBusy(false);
}
}
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
: kind === 'baseband-restart'
? BASEBAND_RESTART.title
: SYSTEM_REBOOT.title;
if (
typeof window !== 'undefined' &&
!window.confirm(`将对该实例执行「${title}」。此操作为高风险,确认继续?`)
)
return;
setCardAction({ id, busy: true, message: `正在${title}…` });
try {
const result = await runOperation(kind, [{ instanceId: id, revision: instance.revision }]);
setCardAction({
id,
busy: false,
message: result.succeeded === 1 ? `${title}已提交成功。` : `${title}失败,请稍后重试。`,
...(result.failed === 1 ? { error: `${title}失败,请稍后重试。` } : {}),
});
} catch (error) {
setCardAction({
id,
busy: false,
error: safeUiError(error, `${title}失败,请稍后重试。`),
});
}
}
const closeContext = (restoreFocus = false): void => {
const id = contextId;
setContextId(undefined);
if (restoreFocus && id) contextTriggerRefs.current.get(id)?.focus();
};
const selectFilter = (
label: string,
value: string,
setValue: (value: string) => void,
choices: readonly string[],
allLabel: string,
displayChoice: (choice: string) => string = (choice) => choice,
) => (
<label>
<span>{label}</span>
<select
value={value}
onChange={(event) => resetPage(() => setValue(event.currentTarget.value))}
>
<option value="">{allLabel}</option>
{choices.map((choice) => (
<option key={choice} value={choice}>
{displayChoice(choice)}
</option>
))}
</select>
</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">
<div>
<h1 id="fleet-title">节点</h1>
</div>
<div className="fleet-actions">
<a className="primary-action" href="/instances/new">
<Icon name="plus" />
<span>添加实例</span>
</a>
{selectionMode ? (
<span className="selection-summary" aria-live="polite">
已选择 {model.selectedIds.length} 项
</span>
) : null}
<Button
htmlType="button"
onClick={() => {
if (selectionMode) setSelectedIds(new Set());
setSelectionMode((value) => !value);
}}
>
{selectionMode ? '退出批量选择' : '批量选择'}
</Button>
</div>
</div>
<div className={`fleet-workspace${filtersCollapsed ? ' is-sidebar-collapsed' : ''}`}>
<aside
className={`fleet-sidebar${filtersCollapsed ? ' is-collapsed' : ''}`}
aria-label="实例总览与筛选"
>
<div className="fleet-sidebar-header">
<div>
<strong>
<Icon name="server" />
节点总览
</strong>
</div>
<Button
htmlType="button"
type="text"
size="small"
className="fleet-sidebar-toggle"
aria-expanded={!filtersCollapsed}
aria-label={filtersCollapsed ? '展开左侧筛选栏' : '收起左侧筛选栏'}
onClick={() => setFiltersCollapsed((value) => !value)}
>
<Icon name={filtersCollapsed ? 'chevron-right' : 'chevron-left'} />
<span className="fleet-sidebar-toggle-label">
{filtersCollapsed ? '展开' : '收起'}
</span>
</Button>
</div>
<div className="fleet-sidebar-content">
{snapshot ? (
<section
className="fleet-status-summary fleet-status-summary-rail"
aria-label="实例状态摘要"
>
<div data-tone="total">
<span>
<Icon name="server" />
实例总数
</span>
<strong>{fleetSummary.total}</strong>
</div>
<div data-tone="online">
<span>
<Icon name="check" />
在线
</span>
<strong>{fleetSummary.online}</strong>
</div>
<div data-tone="cellular">
<span>
<Icon name="wifi" />
蜂窝可用
</span>
<strong>{fleetSummary.cellular}</strong>
</div>
<div data-tone="attention">
<span>
<Icon name="alert" />
需处理
</span>
<strong>{fleetSummary.attention}</strong>
</div>
</section>
) : null}
<section className="fleet-cross-health" aria-label="跨域健康">
<a href="/fleet/messages" className="fleet-cross-health-item">
<span>
<Icon name="message" />
短信异常
</span>
<strong data-tone={smsUnavailableCount > 0 ? 'attention' : 'ok'}>
{smsUnavailableCount}
</strong>
</a>
<a href="/fleet/notifications" className="fleet-cross-health-item">
<span>
<Icon name="alert" />
通知异常
</span>
<strong
data-tone={
notificationHealth && notificationHealth.pending + notificationHealth.failed > 0
? 'attention'
: 'ok'
}
>
{notificationHealth
? notificationHealth.pending + notificationHealth.failed
: '--'}
</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="批量操作入口">
<div className="batch-entry-copy">
<strong>批量操作</strong>
<p>已选择 {model.selectedIds.length} 项。重启将逐个实例安全确认后执行。</p>
</div>
<div className="batch-entry-actions" role="group" aria-label="批量重启操作">
<Button
htmlType="button"
size="small"
disabled={batchBusy || model.selectedIds.length === 0}
onClick={() => void runBatch('service-restart')}
>
批量重启服务
</Button>
<Button
htmlType="button"
size="small"
danger
disabled={batchBusy || model.selectedIds.length === 0}
onClick={() => void runBatch('system-reboot')}
>
批量系统重启
</Button>
</div>
{batchProgress ? (
<p role="status" className="batch-progress">
{batchProgress.message}
</p>
) : null}
</div>
) : null}
<div className="fleet-toolbar" role="search" aria-label="实例搜索与筛选">
<div className="fleet-search-row">
<label className="fleet-search-field">
<span className="visually-hidden">搜索实例</span>
<Input
type="search"
prefix={<Icon className="fleet-search-glyph" name="search" />}
value={query}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
resetPage(() => setQuery(event.currentTarget.value))
}
placeholder="搜索名称、ID、标签、源地址…"
autoComplete="off"
spellCheck={false}
allowClear
clearAriaLabel="清除搜索"
onClear={() => resetPage(() => setQuery(''))}
shadow
/>
</label>
</div>
<div className="fleet-status-chips" role="group" aria-label="按状态筛选">
{(
[
['all', '全部'],
['online', '在线'],
['auth', '需认证'],
['offline', '离线'],
['unknown', '未知'],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
className={`fleet-status-chip${filter === value ? ' is-active' : ''}`}
aria-pressed={filter === value}
onClick={() => resetPage(() => setFilter(value))}
>
<span>{label}</span>
<strong>{statusFacetCounts[value]}</strong>
</button>
))}
</div>
{activeFilterChips.length > 0 ? (
<div className="fleet-active-filters" aria-label="当前筛选条件">
{activeFilterChips.map((chip) => (
<button
key={chip.key}
type="button"
className="fleet-active-chip"
onClick={chip.onClear}
aria-label={`移除筛选 ${chip.label}`}
>
<span>{chip.label}</span>
<span aria-hidden="true">×</span>
</button>
))}
<button type="button" className="fleet-clear-filters" onClick={clearAllFilters}>
清除全部筛选
</button>
</div>
) : null}
<details className="fleet-advanced-filters" open={advancedFilterCount > 0}>
<summary>
更多筛选
{advancedFilterCount > 0 ? (
<span
className="fleet-advanced-count"
aria-label={`已启用 ${advancedFilterCount} 项`}
>
{advancedFilterCount}
</span>
) : null}
</summary>
<div className="fleet-advanced-filter-grid">
<label>
<span>认证</span>
<select
value={auth}
onChange={(event) =>
resetPage(() => setAuth(event.currentTarget.value as FleetAuthFilter))
}
>
<option value="all">全部认证状态</option>
<option value="authenticated">已认证</option>
<option value="required">需要认证</option>
<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,
setCapability,
model.facets.capabilities,
'全部能力',
capabilityLabel,
)}
{selectFilter('版本', version, setVersion, model.facets.versions, '全部版本')}
{selectFilter('标签', tag, setTag, model.facets.tags, '全部标签')}
{groups.length > 0
? selectFilter(
'分组',
group,
setGroup,
groups.map((item) => item.id),
'全部分组',
(choice) => groupNames.get(choice) ?? choice,
)
: null}
</div>
</details>
</div>
</div>
</aside>
<div className="fleet-results-pane">
<div className="fleet-group-tabs" role="group" aria-label="节点分组">
<button
type="button"
className={group === '' ? 'is-active' : undefined}
aria-pressed={group === ''}
onClick={() => resetPage(() => setGroup(''))}
>
全部节点
</button>
{groups.map((item) => (
<button
type="button"
className={group === item.id ? 'is-active' : undefined}
aria-pressed={group === item.id}
key={item.id}
onClick={() => resetPage(() => setGroup(item.id))}
>
{item.name}
<span className="fleet-group-count">{item.deviceCount}</span>
</button>
))}
<button
type="button"
className="fleet-group-manage"
onClick={() => setOrganizationOpen(true)}
>
<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>
<p className="fleet-result-count" aria-live="polite">
显示 <strong>{model.filteredCount}</strong> / {model.totalCount}
{model.pageCount > 1 ? ` · 第 ${model.page}/${model.pageCount} 页` : null}
</p>
</div>
<div className="fleet-toolbar-controls">
<label className="fleet-sort-field">
<span className="visually-hidden">排序</span>
<select
aria-label="排序"
value={`${sort.column}:${sort.direction}`}
onChange={(event) => {
const [column, direction] = event.currentTarget.value.split(':') as [
FleetSortColumn,
SortDirection,
];
resetPage(() => setSort({ column, direction }));
}}
>
{(Object.keys(SORT_COLUMN_LABELS) as FleetSortColumn[]).flatMap((column) => [
<option key={`${column}:asc`} value={`${column}:asc`}>
{SORT_COLUMN_LABELS[column]} · 升序
</option>,
<option key={`${column}:desc`} value={`${column}:desc`}>
{SORT_COLUMN_LABELS[column]} · 降序
</option>,
])}
</select>
</label>
{selectionMode ? (
<div className="fleet-select-all" role="group" aria-label="本页选择">
<button
type="button"
onClick={() => toggleVisible(true)}
disabled={model.rows.length === 0}
>
全选本页
</button>
<button
type="button"
onClick={() => toggleVisible(false)}
disabled={model.selectedIds.length === 0}
>
清空选择
</button>
</div>
) : null}
</div>
</div>
{!snapshot && !error ? (
<p role="status" aria-label="实例加载状态">
正在加载实例列表…
</p>
) : null}
{error ? (
<div className="state-panel state-error" role="alert">
<p>无法加载实例。{error}</p>
<button type="button" onClick={() => setAttempt((value) => value + 1)}>
重试加载实例
</button>
</div>
) : null}
{snapshot && model.emptyReason ? (
<div className="state-panel">
{model.emptyReason === 'config' ? (
<>
<p>尚未配置实例。</p>
<a className="primary-action" href="/instances/new">
添加实例
</a>
</>
) : null}
{model.emptyReason === 'search' ? <p>没有实例符合搜索与筛选条件。</p> : null}
{model.emptyReason === 'filter' ? <p>没有实例符合当前筛选条件。</p> : null}
</div>
) : null}
{snapshot && model.rows.length > 0 ? (
<>
<section className="fleet-card-section" aria-label="实例卡片">
<div className="fleet-card-grid">
{model.rows.map((row) => {
const phoneNumbers = row.status?.summary?.resources?.phoneNumbers ?? [];
const latestMessage = messageStates.get(row.id)?.latest;
// The console-wide switch wins; the per-card button still works while it is off.
const phoneNumbersRevealed = sensitive || revealedPhoneIds.has(row.id);
const hasPrivatePhone = phoneNumbers.length > 0 || Boolean(latestMessage);
return (
<Card
role="article"
className={`fleet-card${row.selected ? ' is-selected' : ''}`}
data-status={row.statusKind}
aria-label={`${row.displayName} 实例概览`}
key={row.id}
>
<div className="fleet-card-glow" aria-hidden="true" />
<header>
<div className="fleet-card-title-group">
<span className="fleet-node-avatar" aria-hidden="true">
{initials(row.displayName)}
</span>
<div>
<a
className="fleet-card-entry"
href={`/instances/${encodeURIComponent(row.id)}/overview`}
aria-label={`打开 ${row.displayName} 实例仪表盘`}
>
<h2>{row.displayName}</h2>
<span className="fleet-card-version">
{row.version ? `SimAdmin ${row.version}` : '版本未知'}
</span>
<span className="fleet-card-entry-label">
进入仪表盘 <Icon name="chevron-right" />
</span>
</a>
</div>
</div>
<div className="fleet-card-header-meta">
<Tag
className={`status status-${row.statusKind}`}
color={STATUS_COLORS[row.statusKind]}
variant="soft"
size="small"
>
<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
type="checkbox"
aria-label={`选择 ${row.displayName}`}
checked={row.selected}
onChange={(event) =>
toggleOne(row.id, event.currentTarget.checked)
}
/>
</label>
) : null}
<div className="fleet-card-menu">
<button
ref={(element) => {
if (element) cardMenuTriggerRefs.current.set(row.id, element);
else cardMenuTriggerRefs.current.delete(row.id);
}}
type="button"
className="fleet-card-menu-trigger"
aria-label={`实例操作 ${row.displayName}`}
aria-haspopup="menu"
aria-expanded={cardMenuId === row.id}
aria-controls={`fleet-card-menu-${row.id}`}
title="实例操作"
onClick={() => {
cardMenuInitialFocus.current = 'first';
setCardMenuId((current) =>
current === row.id ? undefined : row.id,
);
}}
onKeyDown={(event) => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
event.preventDefault();
cardMenuInitialFocus.current =
event.key === 'ArrowUp' ? 'last' : 'first';
setCardMenuId(row.id);
}}
>
<Icon name="more" />
</button>
{cardMenuId === row.id ? (
<div
ref={cardMenuPanelRef}
id={`fleet-card-menu-${row.id}`}
className="fleet-card-menu-panel"
role="menu"
aria-label={`${row.displayName} 操作`}
onKeyDown={(event) => moveCardMenuFocus(event, row.id)}
>
<a
role="menuitem"
tabIndex={-1}
className="fleet-card-menu-item"
aria-label={`打开 ${row.displayName} 短信中心`}
href={`/fleet/messages?device=${encodeURIComponent(row.id)}`}
onClick={() => closeCardMenu(row.id, false)}
>
<Icon name="message" />
<span>短信中心</span>
</a>
<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, 'service-restart');
}}
>
<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"
tabIndex={-1}
className="fleet-card-menu-danger"
aria-label={`系统重启 ${row.displayName}`}
disabled={cardAction?.id === row.id && cardAction.busy}
onClick={() => {
closeCardMenu(row.id, true);
void runCardAction(row.id, 'system-reboot');
}}
>
<Icon name="alert" />
<span>系统重启</span>
</button>
</div>
) : null}
</div>
</div>
</header>
<div className="fleet-card-metadata">
<div className="fleet-card-origin-row">
<Icon name="globe" />
{(() => {
const method = accessMethod(row.instance.url);
return method ? (
<span className="fleet-card-access" data-kind={method.kind}>
{method.label}
</span>
) : null;
})()}
{(() => {
const origin = canonicalHttpOrigin(row.instance.url);
return origin ? (
<a
className="fleet-card-origin fleet-card-origin-link"
href={origin}
target="_blank"
rel="noopener noreferrer"
aria-label={`打开 ${row.displayName} 节点入口`}
title={`在新窗口打开 ${origin}`}
>
<span>{origin}</span>
<Icon className="fleet-origin-arrow" name="arrow-up-right" />
</a>
) : (
<span
className="fleet-card-origin fleet-card-origin-invalid"
aria-label={`${row.displayName} 源地址无效`}
>
源地址无效
</span>
);
})()}
</div>
{(() => {
const checkedAt = row.status?.checkedAt;
return (
<div
className="fleet-card-heartbeat"
data-stale={
checkedAt && row.statusKind === 'online' ? 'false' : 'true'
}
title={checkedAt ? messageTime(checkedAt) : '心跳尚未到达该节点'}
>
<Icon name="history" />
<span>最近上报</span>
{checkedAt ? (
<time dateTime={checkedAt}>
{heartbeatAge(checkedAt, Date.now())}
</time>
) : (
<span className="fleet-resource-unavailable">未上报</span>
)}
</div>
);
})()}
{row.instance.description ? (
<p className="fleet-card-description">{row.instance.description}</p>
) : null}
{row.groupId ? (
<div className="fleet-instance-groups" aria-label="实例分组">
<Tag size="small" color="app-teal" variant="soft">
<Icon name="grid" />
{groupNames.get(row.groupId) ?? row.groupId}
</Tag>
</div>
) : null}
{row.anomalies.length > 0 ? (
<p
className="fleet-card-anomalies"
aria-label={`${row.displayName} 异常`}
>
{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) => (
<Tag key={item} size="small" color="app-yellow" variant="soft">
<Icon name="tag" />
{item}
</Tag>
))}
</div>
) : null}
{row.capabilities.length > 0 ? (
<div className="capability-tags" aria-label="能力">
{row.capabilities.map((item) => (
<Tag key={item} size="small" color="app-teal" variant="soft">
<Icon name={CAPABILITY_ICONS[item] ?? 'grid'} />
{capabilityLabel(item)}
</Tag>
))}
</div>
) : null}
</div>
<dl className="fleet-card-hardware" role="group" aria-label="节点硬件信息">
<div className="fleet-card-phone">
<dt>
<Icon name="phone" />
手机号
</dt>
<dd>
<span className="fleet-card-phone-value">
{phoneNumbers.length > 0
? formatPhoneNumbers(phoneNumbers, phoneNumbersRevealed)
: '暂未获取'}
</span>
{hasPrivatePhone ? (
<button
type="button"
className="fleet-phone-privacy-toggle"
aria-label={`${phoneNumbersRevealed ? '隐藏' : '显示'} ${row.displayName} 手机号`}
aria-pressed={phoneNumbersRevealed}
title={phoneNumbersRevealed ? '隐藏手机号' : '显示手机号'}
onClick={() => togglePhonePrivacy(row.id)}
>
<Icon name={phoneNumbersRevealed ? 'eye-off' : 'eye'} />
</button>
) : null}
</dd>
</div>
<div>
<dt>
<Icon name="power" />
运行时长
</dt>
<dd>{formatUptime(row.status?.summary?.resources?.uptimeSeconds)}</dd>
</div>
<div>
<dt>
<Icon name="temperature" />
最高温度
</dt>
<dd>
{temperature(row.status?.summary?.resources?.maxTemperatureCelsius)}
</dd>
</div>
</dl>
{(() => {
const resources = row.status?.summary?.resources;
const links = [
{ label: '硬件', online: resources?.hardwareOnline },
{ label: '控制', online: resources?.controlOnline },
{ label: 'SIM', online: resources?.simPresent },
{ label: '蜂窝', online: resources?.cellularOnline },
];
return (
<>
<section className="fleet-link-rail" aria-label="链路状态">
{links.map((link) => (
<div
key={link.label}
className="fleet-link-state"
data-state={
link.online === undefined
? 'unknown'
: link.online
? 'online'
: 'offline'
}
>
<span className="fleet-link-dot" aria-hidden="true" />
<span>{link.label}</span>
<strong>{booleanStateLabel(link.online)}</strong>
</div>
))}
</section>
<section className="fleet-cellular" aria-label="蜂窝网络">
<div className="fleet-cellular-heading">
<strong>{resources?.carrier ?? '运营商未知'}</strong>
<span>
{[
resources?.accessTechnology ?? '制式未知',
registrationLabel(resources?.cellularRegistration),
].join(' · ')}
</span>
</div>
<div className="fleet-cellular-signal">
{resources?.signalPercent === undefined ? (
<span className="fleet-resource-unavailable">--</span>
) : (
<div className="fleet-resource-meter">
<Progress
percent={resources.signalPercent}
size="small"
showInfo={false}
duration={0}
aria-label="蜂窝信号"
/>
<strong>{percent(resources.signalPercent)}</strong>
</div>
)}
</div>
</section>
</>
);
})()}
<section className="fleet-card-telemetry" aria-label="资源遥测">
<div className="fleet-resource-row">
<span className="fleet-resource-label">
<Icon name="cpu" />
CPU
</span>
{row.status?.summary?.resources?.cpuPercent === undefined ? (
<span className="fleet-resource-unavailable">--</span>
) : (
<div className="fleet-resource-meter">
<Progress
percent={row.status.summary.resources.cpuPercent}
size="small"
showInfo={false}
duration={0}
aria-label="CPU 使用率"
/>
<strong>{percent(row.status.summary.resources.cpuPercent)}</strong>
</div>
)}
</div>
<div className="fleet-resource-row">
<span className="fleet-resource-label">
<Icon name="memory" />
内存
</span>
{row.status?.summary?.resources?.memoryPercent === undefined ? (
<span className="fleet-resource-unavailable">--</span>
) : (
<div className="fleet-resource-meter">
<Progress
percent={row.status.summary.resources.memoryPercent}
size="small"
showInfo={false}
duration={0}
aria-label="内存使用率"
/>
<strong>
{percent(row.status.summary.resources.memoryPercent)}
</strong>
</div>
)}
</div>
</section>
<section className="fleet-card-footer" aria-label="短信状态">
{messageStates.get(row.id)?.unavailable ? (
<>
<span className="fleet-card-footer-primary">
<Icon name="message" />
短信暂不可用
</span>
<span className="fleet-card-footer-time">--</span>
</>
) : messageStates.get(row.id)?.latest ? (
<>
<span className="fleet-card-footer-primary">
<Icon name="message" />
<span>
{messageDirection(messageStates.get(row.id)!.latest!.direction)}
</span>
<span aria-hidden="true">·</span>
<span>
{formatPhoneNumbers(
[messageStates.get(row.id)!.latest!.phoneNumber],
phoneNumbersRevealed,
)}
</span>
</span>
<time
className="fleet-card-footer-time"
dateTime={messageStates.get(row.id)!.latest!.timestamp}
>
{messageTime(messageStates.get(row.id)!.latest!.timestamp)}
</time>
</>
) : (
<>
<span className="fleet-card-footer-primary">
<Icon name="message" />
暂无短信
</span>
<span className="fleet-card-footer-time">未收到</span>
</>
)}
</section>
{cardAction?.id === row.id ? (
<p
role={cardAction.error ? 'alert' : 'status'}
className={
cardAction.error ? 'card-operation-error' : 'card-operation-success'
}
>
{cardAction.error ?? cardAction.message}
</p>
) : null}
</Card>
);
})}
</div>
</section>
<nav className="pagination" aria-label="实例分页">
<button
type="button"
aria-label="上一页"
disabled={model.page === 1}
onClick={() => setPage((value) => value - 1)}
>
上一页
</button>
<span>
第 {model.page} 页,共 {model.pageCount} 页
</span>
<button
type="button"
aria-label="下一页"
disabled={model.page === model.pageCount}
onClick={() => setPage((value) => value + 1)}
>
下一页
</button>
</nav>
</>
) : null}
</div>
</div>
{organizationOpen ? (
<FleetOrganizationPanel
dataSource={resolvedOrganizationDataSource}
onClose={() => setOrganizationOpen(false)}
onChanged={() => {
setAttempt((value) => value + 1);
setOrganizationRevision((value) => value + 1);
onGroupsChanged?.();
}}
/>
) : 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>
);
}