feat(web): make instance overview card-first

This commit is contained in:
chick
2026-07-18 23:23:41 +08:00
parent abfd07f8a3
commit 2411801240
5 changed files with 719 additions and 223 deletions
+385 -195
View File
@@ -9,6 +9,7 @@ import {
type FleetStatus,
type SortDirection,
} from './fleet-table-view-model.js';
import type { InstanceDataSource } from '../instances/instance-crud.js';
export interface FleetSnapshot {
readonly instances: readonly FleetInstance[];
@@ -19,6 +20,7 @@ export interface FleetDataSource {
}
export interface FleetPageProps {
readonly dataSource?: FleetDataSource;
readonly instanceDataSource?: InstanceDataSource;
readonly initialData?: FleetSnapshot;
readonly refreshSignal?: number;
}
@@ -75,7 +77,12 @@ export function canonicalHttpOrigin(value: string): string | null {
}
}
export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetPageProps) {
export function FleetPage({
dataSource,
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);
@@ -94,6 +101,13 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
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);
useEffect(() => {
@@ -184,6 +198,47 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
});
}
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,
@@ -216,6 +271,9 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
<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>
@@ -234,79 +292,86 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
</div>
) : null}
<div className="fleet-toolbar">
<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>
<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 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 ? (
@@ -324,142 +389,267 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
) : null}
{snapshot && model.emptyReason ? (
<div className="state-panel">
{model.emptyReason === 'config' ? <p></p> : null}
{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 ? (
<>
<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
}
<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>
<div>
<h2>{row.displayName}</h2>
<code>{row.id}</code>
</div>
<span className={`status status-${row.statusKind}`}>
{STATUS_LABELS[row.statusKind]}
</span>
</header>
<dl className="fleet-card-metrics">
<div>
<dt></dt>
<dd>{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}</dd>
</div>
<div>
<dt></dt>
<dd>{row.version ?? '—'}</dd>
</div>
<div>
<dt></dt>
<dd>{FRESHNESS_LABELS[row.freshness] ?? row.freshness}</dd>
</div>
</dl>
<div className="capability-tags" aria-label="能力">
{row.capabilities.length > 0 ? (
row.capabilities.map((item) => (
<span key={item}>{capabilityLabel(item)}</span>
))
) : (
<span></span>
)}
</div>
<div className="fleet-card-actions">
<a
href={`/instances/${encodeURIComponent(row.id)}/overview`}
aria-label={`管理 ${row.displayName}`}
>
{column === 'origin' ? (
COLUMN_LABELS[column]
) : (
</a>
<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"
onClick={() => changeSort(column)}
aria-label={`${COLUMN_LABELS[column]}排序`}
className="danger-button"
disabled={
deleteBusy ||
deleteOwner?.id !== row.id ||
deleteConfirmation !== row.id
}
aria-label={`确认删除 ${row.displayName}`}
onClick={() => void remove(row.id)}
>
{COLUMN_LABELS[column]}
</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>
))}
</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 ? (
<a
href={origin}
target="_blank"
rel="noopener noreferrer"
aria-label={`打开 ${row.displayName} 的源站`}
>
{origin}
</a>
{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]
) : (
<span></span>
<button
type="button"
onClick={() => changeSort(column)}
aria-label={`${COLUMN_LABELS[column]}排序`}
>
{COLUMN_LABELS[column]}
</button>
)}
</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>
</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 ? (
<a
href={origin}
target="_blank"
rel="noopener noreferrer"
aria-label={`打开 ${row.displayName} 的源站`}
>
{origin}
</a>
) : (
<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>