Files
multi-simadmin/apps/web/src/fleet/fleet-page.tsx
T
chick 3c7018a792 feat(ui): refine fleet search/filter toolbar with status chips
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.
2026-07-21 23:31:08 +08:00

1095 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } 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>
);
}