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.
2076 lines
81 KiB
TypeScript
2076 lines
81 KiB
TypeScript
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>
|
||
);
|
||
}
|