Adopt Portainer/Beszel-style fleet controls: prominent search, status facet chips with counts, active filter chips, result count, and primary-row sort/select. Keep advanced filters collapsed for secondary facets. Tests and production dist rebuild for gateway 8788.
1095 lines
37 KiB
TypeScript
1095 lines
37 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
|
||
import {
|
||
buildFleetTableViewModel,
|
||
fleetStatusKind,
|
||
type FleetAuthFilter,
|
||
type FleetFilter,
|
||
type FleetInstance,
|
||
type FleetSortColumn,
|
||
type FleetStatus,
|
||
type SortDirection,
|
||
} from './fleet-table-view-model.js';
|
||
import type { InstanceDataSource } from '../instances/instance-crud.js';
|
||
import type {
|
||
FleetMessageLoadState,
|
||
FleetMessagesDataSource,
|
||
} from './fleet-messages-api-data-source.js';
|
||
import { loadFleetMessageSummaries } from './fleet-messages-api-data-source.js';
|
||
import {
|
||
createOperationClient,
|
||
type OperationClient,
|
||
} from '../operations/operation-client.js';
|
||
import { safeUiError } from '../ui/locale.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 instanceDataSource?: InstanceDataSource;
|
||
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 SORT_COLUMN_LABELS: Readonly<Record<FleetSortColumn, string>> = {
|
||
name: '名称',
|
||
status: '状态',
|
||
latency: '延迟',
|
||
version: '版本',
|
||
capabilities: '能力',
|
||
tags: '标签',
|
||
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 percent = (value: number | undefined): string =>
|
||
value === undefined ? '暂未获取' : `${value.toFixed(1)}%`;
|
||
const temperature = (value: number | undefined): string =>
|
||
value === undefined ? '暂未获取' : `${value.toFixed(1)} °C`;
|
||
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');
|
||
};
|
||
|
||
export function canonicalHttpOrigin(value: string): string | null {
|
||
try {
|
||
const url = new URL(value);
|
||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||
url.username = '';
|
||
url.password = '';
|
||
return url.origin;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
type BatchActionKind = 'service-restart' | 'system-reboot';
|
||
type BatchProgress = Readonly<{
|
||
action: BatchActionKind;
|
||
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 SYSTEM_REBOOT = {
|
||
operationId: 'postSystemReboot',
|
||
parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1',
|
||
title: '系统重启',
|
||
delaySeconds: 3,
|
||
} as const;
|
||
|
||
export function FleetPage({
|
||
dataSource,
|
||
messagesDataSource,
|
||
instanceDataSource,
|
||
initialData,
|
||
refreshSignal = 0,
|
||
operationClient,
|
||
}: FleetPageProps) {
|
||
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 [capability, setCapability] = useState('');
|
||
const [version, setVersion] = useState('');
|
||
const [tag, setTag] = useState('');
|
||
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 [batchOpen, setBatchOpen] = 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 [deleteId, setDeleteId] = useState<string>();
|
||
const [deleteOwner, setDeleteOwner] = useState<Readonly<{ id: string; revision: number }>>();
|
||
const [deleteConfirmation, setDeleteConfirmation] = useState('');
|
||
const [deleteBusy, setDeleteBusy] = useState(false);
|
||
const deleteRequestRef = useRef(0);
|
||
const [deleteStatus, setDeleteStatus] =
|
||
useState<Readonly<{ id: string; kind: 'success' | 'error'; message: string }>>();
|
||
const messagesOwner = useRef(0);
|
||
const [messageStates, setMessageStates] = useState<ReadonlyMap<string, FleetMessageState>>(
|
||
new Map(),
|
||
);
|
||
const resolvedOperationClient = useMemo(
|
||
() => operationClient ?? createOperationClient(),
|
||
[operationClient],
|
||
);
|
||
|
||
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]);
|
||
|
||
const model = useMemo(
|
||
() =>
|
||
buildFleetTableViewModel(snapshot?.instances ?? [], snapshot?.statuses ?? new Map(), {
|
||
query,
|
||
filter,
|
||
auth,
|
||
...(capability ? { capability } : {}),
|
||
...(version ? { version } : {}),
|
||
...(tag ? { tag } : {}),
|
||
sort,
|
||
selectedIds,
|
||
page,
|
||
}),
|
||
[auth, capability, filter, 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;
|
||
return {
|
||
total: snapshot?.instances.length ?? 0,
|
||
online,
|
||
attention: (snapshot?.instances.length ?? 0) - online,
|
||
};
|
||
}, [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 (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);
|
||
},
|
||
});
|
||
}
|
||
return chips;
|
||
}, [auth, capability, filter, query, tag, version]);
|
||
|
||
const advancedFilterCount = useMemo(
|
||
() =>
|
||
(auth !== 'all' ? 1 : 0) +
|
||
(capability ? 1 : 0) +
|
||
(version ? 1 : 0) +
|
||
(tag ? 1 : 0),
|
||
[auth, capability, 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');
|
||
setCapability('');
|
||
setVersion('');
|
||
setTag('');
|
||
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;
|
||
});
|
||
}
|
||
|
||
async function beginDelete(id: string): Promise<void> {
|
||
const request = ++deleteRequestRef.current;
|
||
setDeleteId(id);
|
||
setDeleteOwner(undefined);
|
||
setDeleteConfirmation('');
|
||
setDeleteStatus(undefined);
|
||
if (!instanceDataSource) {
|
||
setDeleteStatus({ id, kind: 'error', message: '无法准备删除,请稍后重试。' });
|
||
return;
|
||
}
|
||
setDeleteBusy(true);
|
||
try {
|
||
const current = await instanceDataSource.get(id);
|
||
if (request !== deleteRequestRef.current) return;
|
||
if (current.id !== id) throw new Error('owner mismatch');
|
||
setDeleteOwner({ id: current.id, revision: current.revision });
|
||
} catch {
|
||
if (request === deleteRequestRef.current) {
|
||
setDeleteStatus({ id, kind: 'error', message: '无法准备删除,请稍后重试。' });
|
||
}
|
||
} finally {
|
||
if (request === deleteRequestRef.current) setDeleteBusy(false);
|
||
}
|
||
}
|
||
|
||
async function remove(id: string): Promise<void> {
|
||
if (!instanceDataSource || deleteOwner?.id !== id || deleteConfirmation !== id) return;
|
||
setDeleteBusy(true);
|
||
setDeleteStatus(undefined);
|
||
try {
|
||
await instanceDataSource.delete(id, deleteOwner.revision);
|
||
setDeleteStatus({ id, kind: 'success', message: '删除请求已提交。' });
|
||
setDeleteId(undefined);
|
||
setDeleteConfirmation('');
|
||
} catch {
|
||
setDeleteStatus({ id, kind: 'error', message: '删除请求失败,请稍后重试。' });
|
||
} finally {
|
||
setDeleteBusy(false);
|
||
}
|
||
}
|
||
|
||
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: BatchActionKind,
|
||
targets: ReadonlyArray<{ instanceId: string; revision: number }>,
|
||
): Promise<{ succeeded: number; failed: number }> {
|
||
const catalog = await resolvedOperationClient.list({ pageSize: 100 });
|
||
const op = kind === 'service-restart' ? SERVICE_RESTART : SYSTEM_REBOOT;
|
||
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: BatchActionKind): Promise<void> {
|
||
const instance = snapshot?.instances.find((item) => item.id === id);
|
||
if (!instance?.revision || instance.revision < 1) {
|
||
setCardAction({ id, busy: false, error: '缺少配置版本,请刷新后重试。' });
|
||
return;
|
||
}
|
||
const title = kind === 'service-restart' ? SERVICE_RESTART.title : SYSTEM_REBOOT.title;
|
||
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 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>
|
||
);
|
||
|
||
return (
|
||
<section className="fleet-panel" aria-labelledby="fleet-title">
|
||
<div className="fleet-heading">
|
||
<div>
|
||
<h1 id="fleet-title">实例舰队</h1>
|
||
<p>资源优先总览,查找并管理全部 SimAdmin 实例。</p>
|
||
</div>
|
||
<div className="fleet-actions">
|
||
<a className="primary-action" href="/instances/new">
|
||
添加实例
|
||
</a>
|
||
<span className="selection-summary" aria-live="polite">
|
||
已选择 {model.selectedIds.length} 项
|
||
</span>
|
||
<button
|
||
type="button"
|
||
disabled={model.selectedIds.length === 0}
|
||
onClick={() => setBatchOpen((open) => !open)}
|
||
>
|
||
批量操作
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{snapshot ? (
|
||
<section className="fleet-status-summary" aria-label="实例状态摘要">
|
||
<div>
|
||
<span>实例总数</span>
|
||
<strong>{fleetSummary.total}</strong>
|
||
</div>
|
||
<div>
|
||
<span>在线</span>
|
||
<strong>{fleetSummary.online}</strong>
|
||
</div>
|
||
<div>
|
||
<span>需处理</span>
|
||
<strong>{fleetSummary.attention}</strong>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
{batchOpen ? (
|
||
<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
|
||
type="button"
|
||
disabled={batchBusy || model.selectedIds.length === 0}
|
||
onClick={() => void runBatch('service-restart')}
|
||
>
|
||
批量重启服务
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="danger-button"
|
||
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>
|
||
<span className="fleet-search-glyph" aria-hidden="true">
|
||
⌕
|
||
</span>
|
||
<input
|
||
type="search"
|
||
value={query}
|
||
onChange={(event) => resetPage(() => setQuery(event.currentTarget.value))}
|
||
placeholder="搜索名称、ID、标签、源地址…"
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
/>
|
||
{query ? (
|
||
<button
|
||
type="button"
|
||
className="fleet-search-clear"
|
||
aria-label="清除搜索"
|
||
onClick={() => resetPage(() => setQuery(''))}
|
||
>
|
||
清除
|
||
</button>
|
||
) : null}
|
||
</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>
|
||
<div className="fleet-toolbar-meta">
|
||
<p className="fleet-result-count" aria-live="polite">
|
||
显示 <strong>{model.filteredCount}</strong> / {model.totalCount}
|
||
{model.pageCount > 1 ? ` · 第 ${model.page}/${model.pageCount} 页` : null}
|
||
</p>
|
||
<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>
|
||
<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>
|
||
</div>
|
||
</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">
|
||
<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>
|
||
{selectFilter(
|
||
'能力',
|
||
capability,
|
||
setCapability,
|
||
model.facets.capabilities,
|
||
'全部能力',
|
||
capabilityLabel,
|
||
)}
|
||
{selectFilter('版本', version, setVersion, model.facets.versions, '全部版本')}
|
||
{selectFilter('标签', tag, setTag, model.facets.tags, '全部标签')}
|
||
</div>
|
||
</details>
|
||
</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) => (
|
||
<article
|
||
className={`fleet-card${row.selected ? ' is-selected' : ''}`}
|
||
aria-label={`${row.displayName} 实例概览`}
|
||
key={row.id}
|
||
>
|
||
<header>
|
||
<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>
|
||
<a
|
||
className="fleet-card-entry"
|
||
href={`/instances/${encodeURIComponent(row.id)}/overview`}
|
||
aria-label={`打开 ${row.displayName} 实例仪表盘`}
|
||
>
|
||
<h2>{row.displayName}</h2>
|
||
<code>{row.id}</code>
|
||
<span className="fleet-card-entry-label">进入仪表盘</span>
|
||
</a>
|
||
<span className={`status status-${row.statusKind}`}>
|
||
{STATUS_LABELS[row.statusKind]}
|
||
</span>
|
||
</header>
|
||
{(() => {
|
||
const origin = canonicalHttpOrigin(row.instance.url);
|
||
return (
|
||
<p className="fleet-card-origin" aria-label={`${row.displayName} 源地址`}>
|
||
{origin ?? '源地址无效'}
|
||
</p>
|
||
);
|
||
})()}
|
||
{row.anomalies.length > 0 ? (
|
||
<p className="fleet-card-anomalies" aria-label={`${row.displayName} 异常`}>
|
||
{row.anomalies.join(', ')}
|
||
</p>
|
||
) : null}
|
||
<dl className="fleet-card-metrics">
|
||
<div className="fleet-card-phone">
|
||
<dt>手机号</dt>
|
||
<dd>
|
||
{row.status?.summary?.resources?.phoneNumbers?.join('、') || '暂未获取'}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>CPU</dt>
|
||
<dd>
|
||
{row.status?.summary?.resources?.cpuPercent === undefined ? (
|
||
'暂未获取'
|
||
) : (
|
||
<>
|
||
<meter
|
||
min="0"
|
||
max="100"
|
||
value={row.status.summary.resources.cpuPercent}
|
||
aria-label="CPU 使用率"
|
||
/>
|
||
{percent(row.status.summary.resources.cpuPercent)}
|
||
</>
|
||
)}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>内存</dt>
|
||
<dd>
|
||
{row.status?.summary?.resources?.memoryPercent === undefined ? (
|
||
'暂未获取'
|
||
) : (
|
||
<>
|
||
<meter
|
||
min="0"
|
||
max="100"
|
||
value={row.status.summary.resources.memoryPercent}
|
||
aria-label="内存使用率"
|
||
/>
|
||
{percent(row.status.summary.resources.memoryPercent)}
|
||
</>
|
||
)}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>最高温度</dt>
|
||
<dd>{temperature(row.status?.summary?.resources?.maxTemperatureCelsius)}</dd>
|
||
</div>
|
||
</dl>
|
||
{row.capabilities.length > 0 ? (
|
||
<div className="capability-tags" aria-label="能力">
|
||
{row.capabilities.map((item) => (
|
||
<span key={item}>{capabilityLabel(item)}</span>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
<section className="fleet-card-sms" aria-label="最近短信">
|
||
<h3>最近短信</h3>
|
||
{messageStates.get(row.id)?.unavailable ? (
|
||
<p className="fleet-card-sms-empty">短信暂不可用</p>
|
||
) : messageStates.get(row.id)?.latest ? (
|
||
<dl>
|
||
<div>
|
||
<dt>方向</dt>
|
||
<dd>{messageDirection(messageStates.get(row.id)!.latest!.direction)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>号码</dt>
|
||
<dd>{messageStates.get(row.id)!.latest!.phoneNumber}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>时间</dt>
|
||
<dd>{messageTime(messageStates.get(row.id)!.latest!.timestamp)}</dd>
|
||
</div>
|
||
</dl>
|
||
) : (
|
||
<p className="fleet-card-sms-empty">暂无短信</p>
|
||
)}
|
||
</section>
|
||
<div
|
||
className="fleet-card-actions fleet-card-admin-actions"
|
||
role="group"
|
||
aria-label="实例管理操作"
|
||
>
|
||
<button
|
||
type="button"
|
||
aria-label={`重启服务 ${row.displayName}`}
|
||
disabled={cardAction?.id === row.id && cardAction.busy}
|
||
onClick={() => void runCardAction(row.id, 'service-restart')}
|
||
>
|
||
重启服务
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="danger-button"
|
||
aria-label={`系统重启 ${row.displayName}`}
|
||
disabled={cardAction?.id === row.id && cardAction.busy}
|
||
onClick={() => void runCardAction(row.id, 'system-reboot')}
|
||
>
|
||
系统重启
|
||
</button>
|
||
<a
|
||
href={`/settings/instances/${encodeURIComponent(row.id)}`}
|
||
aria-label={`编辑 ${row.displayName}`}
|
||
>
|
||
编辑
|
||
</a>
|
||
<button
|
||
type="button"
|
||
className="danger-button"
|
||
aria-label={`删除 ${row.displayName}`}
|
||
onClick={() => void beginDelete(row.id)}
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
{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}
|
||
{deleteId === row.id ? (
|
||
<div className="card-delete-confirmation">
|
||
<p>删除后无法撤销。</p>
|
||
<label>
|
||
输入 {row.id} 以确认删除
|
||
<input
|
||
value={deleteConfirmation}
|
||
onChange={(event) => setDeleteConfirmation(event.currentTarget.value)}
|
||
/>
|
||
</label>
|
||
<div className="fleet-card-actions">
|
||
<button
|
||
type="button"
|
||
className="danger-button"
|
||
disabled={
|
||
deleteBusy ||
|
||
deleteOwner?.id !== row.id ||
|
||
deleteConfirmation !== row.id
|
||
}
|
||
aria-label={`确认删除 ${row.displayName}`}
|
||
onClick={() => void remove(row.id)}
|
||
>
|
||
确认删除
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setDeleteId(undefined);
|
||
setDeleteConfirmation('');
|
||
setDeleteStatus(undefined);
|
||
}}
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{deleteStatus?.id === row.id ? (
|
||
<p
|
||
role={deleteStatus.kind === 'error' ? 'alert' : 'status'}
|
||
className={
|
||
deleteStatus.kind === 'error'
|
||
? 'card-operation-error'
|
||
: 'card-operation-success'
|
||
}
|
||
>
|
||
{deleteStatus.message}
|
||
</p>
|
||
) : null}
|
||
</article>
|
||
))}
|
||
</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}
|
||
</section>
|
||
);
|
||
}
|