feat(fleet): card-only UI with progressive load and restart ops
Drop the redundant advanced table so fleet stays resource-first cards. Keep multi-select batch service/system restart via prepare→execute, card restarts, overview system ops, and progressive fleet loading.
This commit is contained in:
@@ -22,7 +22,7 @@ describe('Fleet API data source', () => {
|
||||
name: 'Alpha',
|
||||
origin: 'https://alpha.example/admin',
|
||||
tags: ['lab'],
|
||||
revision: 1,
|
||||
revision: 4,
|
||||
capabilityStatus: 'unknown',
|
||||
freshness: 'unknown',
|
||||
credentialConfigured: false,
|
||||
@@ -36,7 +36,7 @@ describe('Fleet API data source', () => {
|
||||
);
|
||||
const snapshot = await createFleetApiDataSource(fetcher as typeof fetch).load();
|
||||
expect(snapshot.instances).toEqual([
|
||||
{ id: 'alpha', name: 'Alpha', url: 'https://alpha.example/admin', tags: ['lab'] },
|
||||
{ id: 'alpha', name: 'Alpha', url: 'https://alpha.example/admin', tags: ['lab'], revision: 4 },
|
||||
]);
|
||||
expect(snapshot.statuses.get('alpha')?.summary?.resources).toEqual({
|
||||
cpuPercent: 23.4,
|
||||
@@ -54,6 +54,54 @@ describe('Fleet API data source', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('emits a partial fleet snapshot before resource enrichment completes', async () => {
|
||||
let resolveResources!: (value: Response) => void;
|
||||
const resourcesPromise = new Promise<Response>((resolve) => {
|
||||
resolveResources = resolve;
|
||||
});
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
|
||||
if (String(input).endsWith('/resources')) return resourcesPromise;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
id: 'alpha',
|
||||
name: 'Alpha',
|
||||
origin: 'https://alpha.example/admin',
|
||||
tags: [],
|
||||
revision: 1,
|
||||
capabilityStatus: 'unknown',
|
||||
freshness: 'unknown',
|
||||
credentialConfigured: false,
|
||||
},
|
||||
],
|
||||
page: { page: 1, pageSize: 20, total: 1 },
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
const partials: unknown[] = [];
|
||||
const pending = createFleetApiDataSource(fetcher as typeof fetch).load(undefined, (snapshot) => {
|
||||
partials.push({
|
||||
resources: snapshot.statuses.get('alpha')?.summary?.resources,
|
||||
freshness: snapshot.statuses.get('alpha')?.summary?.freshness,
|
||||
});
|
||||
});
|
||||
await vi.waitFor(() => expect(partials.length).toBe(1));
|
||||
expect(partials[0]).toEqual({ resources: undefined, freshness: 'unknown' });
|
||||
resolveResources(
|
||||
new Response(JSON.stringify({ cpuPercent: 11, memoryPercent: 22 }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
await pending;
|
||||
expect(partials.at(-1)).toEqual({
|
||||
resources: { cpuPercent: 11, memoryPercent: 22 },
|
||||
freshness: 'fresh',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects legacy or malformed envelopes instead of rendering a false empty fleet', async () => {
|
||||
const fetcher = vi.fn(async () => new Response(JSON.stringify({ data: [] }), { status: 200 }));
|
||||
await expect(createFleetApiDataSource(fetcher as typeof fetch).load()).rejects.toThrow(
|
||||
|
||||
@@ -1,88 +1,142 @@
|
||||
import type { FleetDataSource, FleetSnapshot } from './fleet-page.js';
|
||||
import type { FleetInstance, FleetStatus } from './fleet-table-view-model.js';
|
||||
|
||||
interface InstancePage {
|
||||
readonly items?: readonly unknown[];
|
||||
}
|
||||
const integer = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value);
|
||||
const finite = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value);
|
||||
const string = (value: unknown): value is string => typeof value === 'string' && value.length > 0;
|
||||
const record = (value: unknown): Record<string, unknown> | undefined =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
function parseInstance(value: unknown): FleetInstance {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
||||
throw new Error('Fleet response is invalid.');
|
||||
const item = value as Record<string, unknown>;
|
||||
function parseInstance(value: unknown): FleetInstance | undefined {
|
||||
const item = record(value);
|
||||
if (
|
||||
typeof item.id !== 'string' ||
|
||||
typeof item.name !== 'string' ||
|
||||
typeof item.origin !== 'string' ||
|
||||
!item ||
|
||||
!string(item.id) ||
|
||||
!string(item.name) ||
|
||||
!string(item.origin) ||
|
||||
!Array.isArray(item.tags) ||
|
||||
!item.tags.every((tag) => typeof tag === 'string')
|
||||
)
|
||||
throw new Error('Fleet response is invalid.');
|
||||
return { id: item.id, name: item.name, url: item.origin, tags: item.tags };
|
||||
return undefined;
|
||||
const revision =
|
||||
integer(item.revision) && item.revision > 0 ? (item.revision as number) : undefined;
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
url: item.origin,
|
||||
tags: item.tags as string[],
|
||||
...(revision === undefined ? {} : { revision }),
|
||||
};
|
||||
}
|
||||
|
||||
type Resources = NonNullable<NonNullable<FleetStatus['summary']>['resources']>;
|
||||
function parseResources(value: unknown): Resources {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
const item = value as Record<string, unknown>;
|
||||
const number = (field: string): number | undefined => {
|
||||
const fieldValue = item[field];
|
||||
return typeof fieldValue === 'number' && Number.isFinite(fieldValue) ? fieldValue : undefined;
|
||||
};
|
||||
const cpuPercent = number('cpuPercent');
|
||||
const memoryPercent = number('memoryPercent');
|
||||
const maxTemperatureCelsius = number('maxTemperatureCelsius');
|
||||
const phoneNumbers = Array.isArray(item.phoneNumbers)
|
||||
? item.phoneNumbers.filter((phone): phone is string => typeof phone === 'string')
|
||||
: undefined;
|
||||
function parseResources(value: unknown): NonNullable<FleetStatus['summary']> {
|
||||
const body = record(value) ?? {};
|
||||
const resources: {
|
||||
cpuPercent?: number;
|
||||
memoryPercent?: number;
|
||||
maxTemperatureCelsius?: number;
|
||||
phoneNumbers?: string[];
|
||||
} = {};
|
||||
if (finite(body.cpuPercent)) resources.cpuPercent = body.cpuPercent;
|
||||
if (finite(body.memoryPercent)) resources.memoryPercent = body.memoryPercent;
|
||||
if (finite(body.maxTemperatureCelsius))
|
||||
resources.maxTemperatureCelsius = body.maxTemperatureCelsius;
|
||||
if (
|
||||
Array.isArray(body.phoneNumbers) &&
|
||||
body.phoneNumbers.every((item) => typeof item === 'string' && item.length > 0)
|
||||
)
|
||||
resources.phoneNumbers = body.phoneNumbers as string[];
|
||||
return {
|
||||
...(cpuPercent === undefined ? {} : { cpuPercent }),
|
||||
...(memoryPercent === undefined ? {} : { memoryPercent }),
|
||||
...(maxTemperatureCelsius === undefined ? {} : { maxTemperatureCelsius }),
|
||||
...(phoneNumbers?.length ? { phoneNumbers } : {}),
|
||||
freshness: 'fresh',
|
||||
...(Object.keys(resources).length > 0 ? { resources } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function readJson(response: Response): Promise<unknown> {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function skeletonStatuses(instances: readonly FleetInstance[]): Map<string, FleetStatus> {
|
||||
return new Map(
|
||||
instances.map((instance) => [
|
||||
instance.id,
|
||||
{
|
||||
reachable: true,
|
||||
authenticated: true,
|
||||
summary: { freshness: 'unknown' },
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDataSource {
|
||||
return {
|
||||
async load(signal): Promise<FleetSnapshot> {
|
||||
const response = await fetcher('/api/v1/instances', {
|
||||
async load(signal, onPartial) {
|
||||
const listResponse = await fetcher('/api/v1/instances', {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { accept: 'application/json' },
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Fleet request failed (${response.status}).`);
|
||||
const body = (await response.json()) as InstancePage;
|
||||
if (!Array.isArray(body.items)) throw new Error('Fleet response is invalid.');
|
||||
const instances = body.items.map(parseInstance);
|
||||
const entries = await Promise.all(
|
||||
instances.map(async (instance): Promise<readonly [string, FleetStatus]> => {
|
||||
try {
|
||||
const resourceResponse = await fetcher(
|
||||
`/api/v1/instances/${encodeURIComponent(instance.id)}/resources`,
|
||||
{
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { accept: 'application/json' },
|
||||
...(signal ? { signal } : {}),
|
||||
},
|
||||
);
|
||||
if (!resourceResponse.ok)
|
||||
return [instance.id, { reachable: false, summary: { resources: {} } }];
|
||||
return [
|
||||
instance.id,
|
||||
{
|
||||
const listBody = record(await readJson(listResponse));
|
||||
if (
|
||||
!listResponse.ok ||
|
||||
!listBody ||
|
||||
!Array.isArray(listBody.items) ||
|
||||
!record(listBody.page)
|
||||
)
|
||||
throw new Error('Fleet response is invalid.');
|
||||
const instances = listBody.items.map(parseInstance);
|
||||
if (instances.some((item) => !item)) throw new Error('Fleet response is invalid.');
|
||||
const readyInstances = instances as FleetInstance[];
|
||||
const partial: FleetSnapshot = {
|
||||
instances: readyInstances,
|
||||
statuses: skeletonStatuses(readyInstances),
|
||||
};
|
||||
onPartial?.(partial);
|
||||
|
||||
const statuses = new Map(
|
||||
await Promise.all(
|
||||
readyInstances.map(async (instance) => {
|
||||
try {
|
||||
const response = await fetcher(
|
||||
`/api/v1/instances/${encodeURIComponent(instance.id)}/resources`,
|
||||
{
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { accept: 'application/json' },
|
||||
...(signal ? { signal } : {}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error('resource unavailable');
|
||||
const status: FleetStatus = {
|
||||
reachable: true,
|
||||
authenticated: true,
|
||||
summary: { resources: parseResources(await resourceResponse.json()) },
|
||||
},
|
||||
];
|
||||
} catch {
|
||||
return [instance.id, { reachable: false, summary: { resources: {} } }];
|
||||
}
|
||||
}),
|
||||
summary: parseResources(await readJson(response)),
|
||||
};
|
||||
return [instance.id, status] as const;
|
||||
} catch {
|
||||
const status: FleetStatus = {
|
||||
reachable: true,
|
||||
authenticated: true,
|
||||
summary: { freshness: 'unknown' },
|
||||
};
|
||||
return [instance.id, status] as const;
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
return { instances, statuses: new Map(entries) };
|
||||
const complete: FleetSnapshot = { instances: readyInstances, statuses };
|
||||
onPartial?.(complete);
|
||||
return complete;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FleetPage, type FleetSnapshot } from './fleet-page.js';
|
||||
|
||||
@@ -11,13 +11,13 @@ const snapshot: FleetSnapshot = {
|
||||
name: 'Alpha modem',
|
||||
url: 'http://192.168.1.2',
|
||||
tags: [],
|
||||
revision: 3,
|
||||
},
|
||||
],
|
||||
statuses: new Map([
|
||||
[
|
||||
'alpha',
|
||||
{
|
||||
instanceId: 'alpha',
|
||||
reachable: true,
|
||||
authenticated: true,
|
||||
summary: { freshness: 'fresh', resources: { cpuPercent: 24, memoryPercent: 51 } },
|
||||
@@ -52,3 +52,65 @@ describe('FleetPage card navigation', () => {
|
||||
expect(within(card).getByRole('group', { name: '实例管理操作' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FleetPage batch and restart actions', () => {
|
||||
it('exposes card restart controls and batch restart entry for selected instances', async () => {
|
||||
const prepare = vi.fn(async () => ({
|
||||
id: 'prep-1',
|
||||
status: 'prepared',
|
||||
operationId: 'postServiceRestart',
|
||||
risk: 'R3',
|
||||
expiresAt: '2030-01-01T00:00:00.000Z',
|
||||
confirmationPrompt: 'Confirm restart',
|
||||
targetCount: 1,
|
||||
}));
|
||||
const execute = vi.fn(async () => ({
|
||||
id: 'job-1',
|
||||
operationId: 'postServiceRestart',
|
||||
status: 'succeeded',
|
||||
rootJobId: 'job-1',
|
||||
items: [{ id: 'item-1', targetId: 'alpha', state: 'succeeded' }],
|
||||
attempts: [],
|
||||
createdAt: '2029-01-01T00:00:00.000Z',
|
||||
}));
|
||||
const list = vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
operationId: 'postServiceRestart',
|
||||
title: 'Restart Service',
|
||||
risk: 'R3',
|
||||
capability: 'job',
|
||||
batchable: false,
|
||||
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
|
||||
},
|
||||
{
|
||||
operationId: 'postSystemReboot',
|
||||
title: 'Reboot System',
|
||||
risk: 'R3',
|
||||
capability: 'job',
|
||||
batchable: false,
|
||||
parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1',
|
||||
},
|
||||
],
|
||||
page: { page: 1, pageSize: 100, total: 2 },
|
||||
}));
|
||||
vi.stubGlobal('confirm', () => true);
|
||||
render(
|
||||
<FleetPage
|
||||
initialData={snapshot}
|
||||
operationClient={{ list, prepare, execute } as never}
|
||||
/>,
|
||||
);
|
||||
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
||||
expect(within(card).getByRole('button', { name: '重启服务 Alpha modem' })).toBeTruthy();
|
||||
expect(within(card).getByRole('button', { name: '系统重启 Alpha modem' })).toBeTruthy();
|
||||
fireEvent.click(within(card).getByRole('checkbox', { name: '选择 Alpha modem' }));
|
||||
expect(screen.getByText(/已选择 1 项/).textContent).toMatch(/已选择 1 项/);
|
||||
fireEvent.click(screen.getByRole('button', { name: '批量操作' }));
|
||||
const batch = await screen.findByRole('region', { name: '批量操作入口' });
|
||||
expect(batch.textContent).toMatch(/已选择 1 项/);
|
||||
fireEvent.click(screen.getByRole('button', { name: '批量重启服务' }));
|
||||
await vi.waitFor(() => expect(prepare).toHaveBeenCalled());
|
||||
expect(execute).toHaveBeenCalledWith('prep-1');
|
||||
});
|
||||
});
|
||||
|
||||
+344
-183
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
buildFleetTableViewModel,
|
||||
@@ -15,13 +15,21 @@ import type {
|
||||
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): Promise<FleetSnapshot>;
|
||||
load(
|
||||
signal?: AbortSignal,
|
||||
onPartial?: (snapshot: FleetSnapshot) => void,
|
||||
): Promise<FleetSnapshot>;
|
||||
}
|
||||
export interface FleetPageProps {
|
||||
readonly dataSource?: FleetDataSource;
|
||||
@@ -29,6 +37,7 @@ export interface FleetPageProps {
|
||||
readonly instanceDataSource?: InstanceDataSource;
|
||||
readonly initialData?: FleetSnapshot;
|
||||
readonly refreshSignal?: number;
|
||||
readonly operationClient?: OperationClient;
|
||||
}
|
||||
type FleetMessageState = FleetMessageLoadState;
|
||||
|
||||
@@ -39,7 +48,7 @@ const STATUS_LABELS: Readonly<Record<string, string>> = {
|
||||
offline: '离线',
|
||||
unknown: '未知',
|
||||
};
|
||||
const COLUMN_LABELS: Readonly<Record<FleetSortColumn | 'origin', string>> = {
|
||||
const SORT_COLUMN_LABELS: Readonly<Record<FleetSortColumn, string>> = {
|
||||
name: '名称',
|
||||
status: '状态',
|
||||
latency: '延迟',
|
||||
@@ -48,9 +57,7 @@ const COLUMN_LABELS: Readonly<Record<FleetSortColumn | 'origin', string>> = {
|
||||
tags: '标签',
|
||||
freshness: '数据新鲜度',
|
||||
anomalies: '异常',
|
||||
origin: '源地址',
|
||||
};
|
||||
const ALL_COLUMNS = Object.keys(COLUMN_LABELS) as readonly (FleetSortColumn | 'origin')[];
|
||||
const CAPABILITY_LABELS: Readonly<Record<string, string>> = {
|
||||
overview: '概览',
|
||||
cellular: '蜂窝网络',
|
||||
@@ -62,11 +69,6 @@ const CAPABILITY_LABELS: Readonly<Record<string, string>> = {
|
||||
automation: '自动化',
|
||||
ota: 'OTA',
|
||||
};
|
||||
const FRESHNESS_LABELS: Readonly<Record<string, string>> = {
|
||||
fresh: '最新',
|
||||
stale: '可能过期',
|
||||
unknown: '未知',
|
||||
};
|
||||
|
||||
function capabilityLabel(value: string): string {
|
||||
return CAPABILITY_LABELS[value] ?? value;
|
||||
@@ -100,12 +102,35 @@ export function canonicalHttpOrigin(value: string): string | 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);
|
||||
@@ -122,9 +147,15 @@ export function FleetPage({
|
||||
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 [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('');
|
||||
@@ -132,11 +163,14 @@ export function FleetPage({
|
||||
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(),
|
||||
);
|
||||
const resolvedOperationClient = useMemo(
|
||||
() => operationClient ?? createOperationClient(),
|
||||
[operationClient],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData && refreshSignal === 0) {
|
||||
@@ -149,18 +183,27 @@ export function FleetPage({
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
setSnapshot(null);
|
||||
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().then(
|
||||
(data) => {
|
||||
if (active) setSnapshot(data);
|
||||
},
|
||||
() => {
|
||||
if (active) setError('实例加载失败。');
|
||||
},
|
||||
);
|
||||
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]);
|
||||
|
||||
@@ -209,11 +252,6 @@ export function FleetPage({
|
||||
};
|
||||
}, [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]);
|
||||
@@ -222,13 +260,6 @@ export function FleetPage({
|
||||
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);
|
||||
@@ -247,14 +278,6 @@ export function FleetPage({
|
||||
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;
|
||||
@@ -297,6 +320,155 @@ export function FleetPage({
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -362,7 +534,32 @@ export function FleetPage({
|
||||
) : null}
|
||||
{batchOpen ? (
|
||||
<div className="batch-entry" role="region" aria-label="批量操作入口">
|
||||
请为已选择的 {model.selectedIds.length} 项选择操作。
|
||||
<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">
|
||||
@@ -393,7 +590,7 @@ export function FleetPage({
|
||||
</label>
|
||||
</div>
|
||||
<details className="fleet-advanced-filters">
|
||||
<summary>更多筛选与列设置</summary>
|
||||
<summary>更多筛选</summary>
|
||||
<div className="fleet-advanced-filter-grid">
|
||||
<label>
|
||||
<span>认证</span>
|
||||
@@ -419,30 +616,44 @@ export function FleetPage({
|
||||
)}
|
||||
{selectFilter('版本', version, setVersion, model.facets.versions, '全部版本')}
|
||||
{selectFilter('标签', tag, setTag, model.facets.tags, '全部标签')}
|
||||
<div className="column-picker">
|
||||
<label>
|
||||
<span>排序</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">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={columnsOpen}
|
||||
onClick={() => setColumnsOpen((open) => !open)}
|
||||
onClick={() => toggleVisible(true)}
|
||||
disabled={model.rows.length === 0}
|
||||
>
|
||||
选择列
|
||||
全选本页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleVisible(false)}
|
||||
disabled={model.selectedIds.length === 0}
|
||||
>
|
||||
清空选择
|
||||
</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>
|
||||
@@ -450,7 +661,7 @@ export function FleetPage({
|
||||
|
||||
{!snapshot && !error ? (
|
||||
<p role="status" aria-label="实例加载状态">
|
||||
正在加载实例…
|
||||
正在加载实例列表…
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
@@ -481,11 +692,19 @@ export function FleetPage({
|
||||
<div className="fleet-card-grid">
|
||||
{model.rows.map((row) => (
|
||||
<article
|
||||
className="fleet-card"
|
||||
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`}
|
||||
@@ -499,6 +718,19 @@ export function FleetPage({
|
||||
{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>
|
||||
@@ -582,6 +814,23 @@ export function FleetPage({
|
||||
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}`}
|
||||
@@ -597,6 +846,14 @@ export function FleetPage({
|
||||
删除
|
||||
</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>
|
||||
@@ -650,123 +907,27 @@ export function FleetPage({
|
||||
))}
|
||||
</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>
|
||||
<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>
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface FleetInstance {
|
||||
readonly url: string;
|
||||
readonly description?: string;
|
||||
readonly tags?: readonly string[];
|
||||
/** Optional config revision for prepare/execute CAS when available from the control plane. */
|
||||
readonly revision?: number;
|
||||
}
|
||||
|
||||
export interface FleetStatus {
|
||||
|
||||
Reference in New Issue
Block a user