feat(web): rebuild the console around the fused control plane
Render the Hub feature set in the existing Animal Island styling: fleet messages, notification centre, log centre, organisation panel, per-instance device module panels and the settings backup, connection and maintenance surfaces.
This commit is contained in:
@@ -17,6 +17,13 @@ import type {
|
||||
} 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 {
|
||||
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';
|
||||
@@ -31,6 +38,10 @@ export interface FleetDataSource {
|
||||
export interface FleetPageProps {
|
||||
readonly dataSource?: FleetDataSource;
|
||||
readonly messagesDataSource?: FleetMessagesDataSource;
|
||||
readonly organizationDataSource?: OrganizationDataSource;
|
||||
/** 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;
|
||||
@@ -57,6 +68,7 @@ const SORT_COLUMN_LABELS: Readonly<Record<FleetSortColumn, string>> = {
|
||||
version: '版本',
|
||||
capabilities: '能力',
|
||||
tags: '标签',
|
||||
group: '分组',
|
||||
freshness: '数据新鲜度',
|
||||
anomalies: '异常',
|
||||
};
|
||||
@@ -102,8 +114,30 @@ function initials(value: string): string {
|
||||
|
||||
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'
|
||||
? '收到'
|
||||
@@ -116,6 +150,53 @@ const messageTime = (value: string): string => {
|
||||
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)} 天前`;
|
||||
}
|
||||
|
||||
const PRIVATE_NETWORKS = [
|
||||
/^10\./u,
|
||||
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./u,
|
||||
/^127\./u,
|
||||
/^169\.254\./u,
|
||||
/^172\.(1[6-9]|2\d|3[01])\./u,
|
||||
/^192\.168\./u,
|
||||
/^(?:fe80|fc|fd)/u,
|
||||
];
|
||||
|
||||
/**
|
||||
* How the control plane reaches the node. Hub labels the same idea 接入方式, and the answer is
|
||||
* already implied by the origin, so it is derived rather than stored.
|
||||
*/
|
||||
export function accessMethod(
|
||||
value: string,
|
||||
): Readonly<{ label: string; kind: 'local' | 'lan' | 'wan' }> | null {
|
||||
const origin = canonicalHttpOrigin(value);
|
||||
if (!origin) return null;
|
||||
let host: string | null;
|
||||
try {
|
||||
host = new URL(origin).hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!host) return null;
|
||||
const bare = host.replace(/^\[(.*)\]$/u, '$1').toLowerCase();
|
||||
if (bare === 'localhost' || bare === '::1' || /^127\./u.test(bare))
|
||||
return { label: '本机直连', kind: 'local' };
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/u.test(bare) || /^[0-9a-f:]+$/u.test(bare))
|
||||
return PRIVATE_NETWORKS.some((pattern) => pattern.test(bare))
|
||||
? { label: '局域网接入', kind: 'lan' }
|
||||
: { label: '公网地址', kind: 'wan' };
|
||||
return { label: '域名接入', kind: 'wan' };
|
||||
}
|
||||
|
||||
export function canonicalHttpOrigin(value: string): string | null {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
@@ -153,10 +234,14 @@ const SYSTEM_REBOOT = {
|
||||
export function FleetPage({
|
||||
dataSource,
|
||||
messagesDataSource,
|
||||
organizationDataSource,
|
||||
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);
|
||||
@@ -166,6 +251,10 @@ export function FleetPage({
|
||||
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 [page, setPage] = useState(1);
|
||||
const [sort, setSort] = useState<{ column: FleetSortColumn; direction: SortDirection }>({
|
||||
column: 'name',
|
||||
@@ -195,6 +284,32 @@ export function FleetPage({
|
||||
() => operationClient ?? createOperationClient(),
|
||||
[operationClient],
|
||||
);
|
||||
const resolvedOrganizationDataSource = useMemo(
|
||||
() => organizationDataSource ?? createOrganizationApiDataSource(),
|
||||
[organizationDataSource],
|
||||
);
|
||||
const groups = controlledGroups ?? ownGroups;
|
||||
const groupNames = useMemo(() => new Map(groups.map((item) => [item.id, item.name])), [groups]);
|
||||
|
||||
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(() => {
|
||||
if (initialData && refreshSignal === 0) {
|
||||
@@ -291,11 +406,11 @@ export function FleetPage({
|
||||
}
|
||||
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
|
||||
const items = Array.from(
|
||||
event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not(:disabled)'),
|
||||
event.currentTarget.querySelectorAll<HTMLElement>('[role="menuitem"]:not(:disabled)'),
|
||||
);
|
||||
if (!items.length) return;
|
||||
event.preventDefault();
|
||||
const currentIndex = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||
const currentIndex = items.indexOf(document.activeElement as HTMLElement);
|
||||
const nextIndex =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
@@ -316,11 +431,26 @@ export function FleetPage({
|
||||
...(capability ? { capability } : {}),
|
||||
...(version ? { version } : {}),
|
||||
...(tag ? { tag } : {}),
|
||||
...(group ? { group } : {}),
|
||||
groupNames,
|
||||
sort,
|
||||
selectedIds,
|
||||
page,
|
||||
}),
|
||||
[auth, capability, filter, page, query, selectedIds, snapshot, sort, tag, version],
|
||||
[
|
||||
auth,
|
||||
capability,
|
||||
filter,
|
||||
group,
|
||||
groupNames,
|
||||
page,
|
||||
query,
|
||||
selectedIds,
|
||||
snapshot,
|
||||
sort,
|
||||
tag,
|
||||
version,
|
||||
],
|
||||
);
|
||||
|
||||
const fleetSummary = useMemo(() => {
|
||||
@@ -328,13 +458,80 @@ export function FleetPage({
|
||||
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],
|
||||
);
|
||||
|
||||
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,
|
||||
@@ -415,12 +612,27 @@ export function FleetPage({
|
||||
},
|
||||
});
|
||||
}
|
||||
if (group) {
|
||||
chips.push({
|
||||
key: 'group',
|
||||
label: `分组:${groupNames.get(group) ?? group}`,
|
||||
onClear: () => {
|
||||
setGroup('');
|
||||
setPage(1);
|
||||
},
|
||||
});
|
||||
}
|
||||
return chips;
|
||||
}, [auth, capability, filter, query, tag, version]);
|
||||
}, [auth, capability, filter, group, groupNames, query, tag, version]);
|
||||
|
||||
const advancedFilterCount = useMemo(
|
||||
() => (auth !== 'all' ? 1 : 0) + (capability ? 1 : 0) + (version ? 1 : 0) + (tag ? 1 : 0),
|
||||
[auth, capability, tag, version],
|
||||
() =>
|
||||
(auth !== 'all' ? 1 : 0) +
|
||||
(capability ? 1 : 0) +
|
||||
(version ? 1 : 0) +
|
||||
(tag ? 1 : 0) +
|
||||
(group ? 1 : 0),
|
||||
[auth, capability, group, tag, version],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -439,6 +651,7 @@ export function FleetPage({
|
||||
setCapability('');
|
||||
setVersion('');
|
||||
setTag('');
|
||||
setGroup('');
|
||||
setPage(1);
|
||||
}
|
||||
function toggleOne(id: string, checked: boolean): void {
|
||||
@@ -718,6 +931,13 @@ export function FleetPage({
|
||||
</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" />
|
||||
@@ -727,6 +947,34 @@ export function FleetPage({
|
||||
</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>
|
||||
</section>
|
||||
{selectionMode ? (
|
||||
<div className="batch-entry" role="region" aria-label="批量操作入口">
|
||||
<div className="batch-entry-copy">
|
||||
@@ -858,6 +1106,16 @@ export function FleetPage({
|
||||
)}
|
||||
{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>
|
||||
@@ -868,23 +1126,32 @@ export function FleetPage({
|
||||
<div className="fleet-group-tabs" role="group" aria-label="节点分组">
|
||||
<button
|
||||
type="button"
|
||||
className={tag === '' ? 'is-active' : undefined}
|
||||
aria-pressed={tag === ''}
|
||||
onClick={() => resetPage(() => setTag(''))}
|
||||
className={group === '' ? 'is-active' : undefined}
|
||||
aria-pressed={group === ''}
|
||||
onClick={() => resetPage(() => setGroup(''))}
|
||||
>
|
||||
全部节点
|
||||
</button>
|
||||
{model.facets.tags.map((item) => (
|
||||
{groups.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
className={tag === item ? 'is-active' : undefined}
|
||||
aria-pressed={tag === item}
|
||||
key={item}
|
||||
onClick={() => resetPage(() => setTag(item))}
|
||||
className={group === item.id ? 'is-active' : undefined}
|
||||
aria-pressed={group === item.id}
|
||||
key={item.id}
|
||||
onClick={() => resetPage(() => setGroup(item.id))}
|
||||
>
|
||||
{item}
|
||||
{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>
|
||||
</div>
|
||||
<div className="fleet-results-header">
|
||||
<div>
|
||||
@@ -972,7 +1239,8 @@ export function FleetPage({
|
||||
{model.rows.map((row) => {
|
||||
const phoneNumbers = row.status?.summary?.resources?.phoneNumbers ?? [];
|
||||
const latestMessage = messageStates.get(row.id)?.latest;
|
||||
const phoneNumbersRevealed = revealedPhoneIds.has(row.id);
|
||||
// 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
|
||||
@@ -1064,6 +1332,17 @@ export function FleetPage({
|
||||
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"
|
||||
@@ -1101,6 +1380,14 @@ export function FleetPage({
|
||||
<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 ? (
|
||||
@@ -1125,9 +1412,39 @@ export function FleetPage({
|
||||
);
|
||||
})()}
|
||||
</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"
|
||||
@@ -1183,6 +1500,13 @@ export function FleetPage({
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
<Icon name="power" />
|
||||
运行时长
|
||||
</dt>
|
||||
<dd>{formatUptime(row.status?.summary?.resources?.uptimeSeconds)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
<Icon name="temperature" />
|
||||
@@ -1193,6 +1517,65 @@ export function FleetPage({
|
||||
</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">
|
||||
@@ -1318,6 +1701,17 @@ export function FleetPage({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{organizationOpen ? (
|
||||
<FleetOrganizationPanel
|
||||
dataSource={resolvedOrganizationDataSource}
|
||||
onClose={() => setOrganizationOpen(false)}
|
||||
onChanged={() => {
|
||||
setAttempt((value) => value + 1);
|
||||
setOrganizationRevision((value) => value + 1);
|
||||
onGroupsChanged?.();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user