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:
chick
2026-09-05 18:53:04 +08:00
parent f9186bd851
commit a75badeb58
77 changed files with 20578 additions and 435 deletions
+410 -16
View File
@@ -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>
);
}