775 lines
28 KiB
TypeScript
775 lines
28 KiB
TypeScript
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import {
|
|
buildFleetTableViewModel,
|
|
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';
|
|
|
|
export interface FleetSnapshot {
|
|
readonly instances: readonly FleetInstance[];
|
|
readonly statuses: ReadonlyMap<string, FleetStatus>;
|
|
}
|
|
export interface FleetDataSource {
|
|
load(signal?: AbortSignal): Promise<FleetSnapshot>;
|
|
}
|
|
export interface FleetPageProps {
|
|
readonly dataSource?: FleetDataSource;
|
|
readonly messagesDataSource?: FleetMessagesDataSource;
|
|
readonly instanceDataSource?: InstanceDataSource;
|
|
readonly initialData?: FleetSnapshot;
|
|
readonly refreshSignal?: number;
|
|
}
|
|
type FleetMessageState = FleetMessageLoadState;
|
|
|
|
const EMPTY_SNAPSHOT: FleetSnapshot = { instances: [], statuses: new Map() };
|
|
const STATUS_LABELS: Readonly<Record<string, string>> = {
|
|
online: '在线',
|
|
auth: '需要认证',
|
|
offline: '离线',
|
|
unknown: '未知',
|
|
};
|
|
const COLUMN_LABELS: Readonly<Record<FleetSortColumn | 'origin', string>> = {
|
|
name: '名称',
|
|
status: '状态',
|
|
latency: '延迟',
|
|
version: '版本',
|
|
capabilities: '能力',
|
|
tags: '标签',
|
|
freshness: '数据新鲜度',
|
|
anomalies: '异常',
|
|
origin: '源地址',
|
|
};
|
|
const ALL_COLUMNS = Object.keys(COLUMN_LABELS) as readonly (FleetSortColumn | 'origin')[];
|
|
const CAPABILITY_LABELS: Readonly<Record<string, string>> = {
|
|
overview: '概览',
|
|
cellular: '蜂窝网络',
|
|
'device-network': '设备网络',
|
|
messages: '消息',
|
|
calls: '通话',
|
|
esim: 'eSIM',
|
|
notifications: '通知',
|
|
automation: '自动化',
|
|
ota: 'OTA',
|
|
};
|
|
const FRESHNESS_LABELS: Readonly<Record<string, string>> = {
|
|
fresh: '最新',
|
|
stale: '可能过期',
|
|
unknown: '未知',
|
|
};
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
export function FleetPage({
|
|
dataSource,
|
|
messagesDataSource,
|
|
instanceDataSource,
|
|
initialData,
|
|
refreshSignal = 0,
|
|
}: 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 [shownColumns, setShownColumns] = useState<ReadonlySet<string>>(new Set(ALL_COLUMNS));
|
|
const [columnsOpen, setColumnsOpen] = useState(false);
|
|
const [batchOpen, setBatchOpen] = useState(false);
|
|
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 selectAllRef = useRef<HTMLInputElement>(null);
|
|
const messagesOwner = useRef(0);
|
|
const [messageStates, setMessageStates] = useState<ReadonlyMap<string, FleetMessageState>>(
|
|
new Map(),
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (initialData && refreshSignal === 0) {
|
|
setSnapshot(initialData);
|
|
setError(null);
|
|
return;
|
|
}
|
|
if (!dataSource) {
|
|
setSnapshot(EMPTY_SNAPSHOT);
|
|
return;
|
|
}
|
|
let active = true;
|
|
setSnapshot(null);
|
|
setError(null);
|
|
void dataSource.load().then(
|
|
(data) => {
|
|
if (active) setSnapshot(data);
|
|
},
|
|
() => {
|
|
if (active) setError('实例加载失败。');
|
|
},
|
|
);
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [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]);
|
|
|
|
useEffect(() => {
|
|
if (selectAllRef.current) {
|
|
selectAllRef.current.indeterminate = model.visibleSelection.indeterminate;
|
|
}
|
|
}, [model.visibleSelection.indeterminate]);
|
|
useEffect(() => {
|
|
if (page !== model.page) setPage(model.page);
|
|
}, [model.page, page]);
|
|
|
|
function resetPage(action: () => void): void {
|
|
action();
|
|
setPage(1);
|
|
}
|
|
function changeSort(column: FleetSortColumn): void {
|
|
setSort((current) => ({
|
|
column,
|
|
direction: current.column === column && current.direction === 'asc' ? 'desc' : 'asc',
|
|
}));
|
|
setPage(1);
|
|
}
|
|
function toggleOne(id: string, checked: boolean): void {
|
|
setSelectedIds((current) => {
|
|
const next = new Set(current);
|
|
if (checked) next.add(id);
|
|
else next.delete(id);
|
|
return next;
|
|
});
|
|
}
|
|
function toggleVisible(checked: boolean): void {
|
|
setSelectedIds((current) => {
|
|
const next = new Set(current);
|
|
for (const row of model.rows) {
|
|
if (checked) next.add(row.id);
|
|
else next.delete(row.id);
|
|
}
|
|
return next;
|
|
});
|
|
}
|
|
function toggleColumn(column: string): void {
|
|
setShownColumns((current) => {
|
|
const next = new Set(current);
|
|
if (next.has(column)) next.delete(column);
|
|
else next.add(column);
|
|
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);
|
|
}
|
|
}
|
|
|
|
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="批量操作入口">
|
|
请为已选择的 {model.selectedIds.length} 项选择操作。
|
|
</div>
|
|
) : null}
|
|
<div className="fleet-toolbar">
|
|
<div className="fleet-toolbar-primary">
|
|
<label>
|
|
<span>搜索实例</span>
|
|
<input
|
|
type="search"
|
|
value={query}
|
|
onChange={(event) => resetPage(() => setQuery(event.currentTarget.value))}
|
|
placeholder="名称、ID、标签、源地址…"
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span>状态</span>
|
|
<select
|
|
value={filter}
|
|
onChange={(event) =>
|
|
resetPage(() => setFilter(event.currentTarget.value as FleetFilter))
|
|
}
|
|
>
|
|
<option value="all">全部状态</option>
|
|
<option value="online">在线</option>
|
|
<option value="auth">需要认证</option>
|
|
<option value="offline">离线</option>
|
|
<option value="unknown">未知</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<details className="fleet-advanced-filters">
|
|
<summary>更多筛选与列设置</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 className="column-picker">
|
|
<button
|
|
type="button"
|
|
aria-expanded={columnsOpen}
|
|
onClick={() => setColumnsOpen((open) => !open)}
|
|
>
|
|
选择列
|
|
</button>
|
|
{columnsOpen ? (
|
|
<fieldset>
|
|
<legend>显示的列</legend>
|
|
{ALL_COLUMNS.map((column) => (
|
|
<label key={column}>
|
|
<input
|
|
type="checkbox"
|
|
aria-label={`显示${COLUMN_LABELS[column]}列`}
|
|
checked={shownColumns.has(column)}
|
|
onChange={() => toggleColumn(column)}
|
|
/>
|
|
{COLUMN_LABELS[column]}
|
|
</label>
|
|
))}
|
|
</fieldset>
|
|
) : null}
|
|
</div>
|
|
</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"
|
|
aria-label={`${row.displayName} 实例概览`}
|
|
key={row.id}
|
|
>
|
|
<header>
|
|
<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>
|
|
<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="实例管理操作"
|
|
>
|
|
<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>
|
|
{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>
|
|
<section className="fleet-details" aria-labelledby="fleet-details-title">
|
|
<h2 id="fleet-details-title">高级详细列表</h2>
|
|
<div className="table-scroll" tabIndex={0}>
|
|
<table className="dense-table">
|
|
<caption>实例列表</caption>
|
|
<thead>
|
|
<tr>
|
|
<th scope="col" className="select-column">
|
|
<label className="touch-target">
|
|
<input
|
|
ref={selectAllRef}
|
|
type="checkbox"
|
|
aria-label="选择当前页全部实例"
|
|
checked={model.visibleSelection.checked}
|
|
onChange={(event) => toggleVisible(event.currentTarget.checked)}
|
|
/>
|
|
</label>
|
|
</th>
|
|
{ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => (
|
|
<th
|
|
key={column}
|
|
scope="col"
|
|
aria-sort={
|
|
column !== 'origin' && sort.column === column
|
|
? sort.direction === 'asc'
|
|
? 'ascending'
|
|
: 'descending'
|
|
: undefined
|
|
}
|
|
>
|
|
{column === 'origin' ? (
|
|
COLUMN_LABELS[column]
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => changeSort(column)}
|
|
aria-label={`按${COLUMN_LABELS[column]}排序`}
|
|
>
|
|
{COLUMN_LABELS[column]}
|
|
</button>
|
|
)}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{model.rows.map((row) => {
|
|
const origin = canonicalHttpOrigin(row.instance.url);
|
|
const cells: Readonly<Record<string, React.ReactNode>> = {
|
|
name: (
|
|
<th scope="row">
|
|
<a href={`/instances/${encodeURIComponent(row.id)}/overview`}>
|
|
{row.displayName}
|
|
</a>
|
|
<small>{row.id}</small>
|
|
</th>
|
|
),
|
|
status: (
|
|
<td>
|
|
<span className={`status status-${row.statusKind}`}>
|
|
{STATUS_LABELS[row.statusKind]}
|
|
</span>
|
|
</td>
|
|
),
|
|
latency: <td>{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}</td>,
|
|
version: <td>{row.version ?? '—'}</td>,
|
|
capabilities: (
|
|
<td>{row.capabilities.map(capabilityLabel).join(', ') || '—'}</td>
|
|
),
|
|
tags: <td>{row.tags.join(', ') || '—'}</td>,
|
|
freshness: <td>{FRESHNESS_LABELS[row.freshness] ?? row.freshness}</td>,
|
|
anomalies: <td>{row.anomalies.join(', ') || '—'}</td>,
|
|
origin: <td>{origin ? <span>{origin}</span> : <span>源地址无效</span>}</td>,
|
|
};
|
|
return (
|
|
<tr key={row.id} aria-selected={row.selected}>
|
|
<td>
|
|
<label className="touch-target">
|
|
<input
|
|
type="checkbox"
|
|
aria-label={`选择 ${row.displayName}`}
|
|
checked={row.selected}
|
|
onChange={(event) => toggleOne(row.id, event.currentTarget.checked)}
|
|
/>
|
|
</label>
|
|
</td>
|
|
{ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => (
|
|
<Fragment key={column}>{cells[column]}</Fragment>
|
|
))}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<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>
|
|
</section>
|
|
</>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|