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:
chick
2026-07-21 22:56:55 +08:00
parent ebed4c1969
commit 9ea8021cce
20 changed files with 1207 additions and 346 deletions
+33 -29
View File
@@ -82,12 +82,14 @@ describe('React AppShell and Fleet vertical slice', () => {
);
resolve(snapshot);
const alpha = await screen.findByRole('row', { name: /Alpha/ });
expect(within(alpha).getByRole('link', { name: 'Alpha' }).getAttribute('href')).toBe(
'/instances/alpha/overview',
);
const alpha = await screen.findByRole('article', { name: /Alpha 实例概览/ });
expect(
within(screen.getByRole('row', { name: /Bravo/ })).getByText('http://bravo.example:8080'),
within(alpha).getByRole('link', { name: /打开 Alpha 实例仪表盘/ }).getAttribute('href'),
).toBe('/instances/alpha/overview');
expect(
within(screen.getByRole('article', { name: /Bravo 实例概览/ })).getByText(
'http://bravo.example:8080',
),
).toBeTruthy();
expect(screen.getByText('版本 0.1.0')).toBeTruthy();
expect(
@@ -280,18 +282,18 @@ describe('React AppShell and Fleet vertical slice', () => {
it('supports accessible search, status filtering, sorting, and visible selection', async () => {
const user = userEvent.setup();
render(<AppShell pathname="/fleet" fleetDataSource={source(async () => snapshot)} />);
await screen.findByRole('row', { name: /Alpha/ });
await screen.findByRole('article', { name: /Alpha 实例概览/ });
await user.click(screen.getByRole('button', { name: /按延迟排序/i }));
let rows = screen.getAllByRole('row').slice(1);
expect(rows[0]?.textContent).toContain('Alpha');
await user.click(screen.getByRole('button', { name: /按延迟排序/i }));
rows = screen.getAllByRole('row').slice(1);
expect(rows[0]?.textContent).toContain('Bravo');
await user.selectOptions(screen.getByRole('combobox', { name: '排序' }), 'latency:asc');
let cards = screen.getAllByRole('article');
expect(cards[0]?.textContent).toContain('Alpha');
await user.selectOptions(screen.getByRole('combobox', { name: '排序' }), 'latency:desc');
cards = screen.getAllByRole('article');
expect(cards[0]?.textContent).toContain('Bravo');
await user.selectOptions(screen.getByRole('combobox', { name: '状态' }), 'auth');
expect(screen.queryByRole('row', { name: /Alpha/ })).toBeNull();
await user.click(screen.getByRole('checkbox', { name: '选择当前页全部实例' }));
expect(screen.queryByRole('article', { name: /Alpha 实例概览/ })).toBeNull();
await user.click(screen.getByRole('button', { name: '全选本页' }));
expect((screen.getByRole('checkbox', { name: '选择 Bravo' }) as HTMLInputElement).checked).toBe(
true,
);
@@ -314,7 +316,7 @@ describe('React AppShell and Fleet vertical slice', () => {
expect((await screen.findByRole('alert')).textContent).toContain('实例加载失败');
await user.click(screen.getByRole('button', { name: '重试加载实例' }));
expect(await screen.findByRole('row', { name: /Unsafe/ })).toBeTruthy();
expect(await screen.findByRole('article', { name: /Unsafe 实例概览/ })).toBeTruthy();
expect(screen.queryByRole('link', { name: '打开 Unsafe 的源站' })).toBeNull();
first.unmount();
@@ -328,28 +330,30 @@ describe('React AppShell and Fleet vertical slice', () => {
unmount();
});
it('supports metadata filters, column visibility, pagination, and a batch-action entry', async () => {
it('supports metadata filters, card anomalies, and a batch-action entry', async () => {
const user = userEvent.setup();
render(<AppShell pathname="/fleet" fleetDataSource={source(async () => snapshot)} />);
await screen.findByRole('row', { name: /Alpha/ });
await screen.findByRole('article', { name: /Alpha 实例概览/ });
await user.selectOptions(screen.getByRole('combobox', { name: '能力' }), 'sms');
expect(screen.queryByRole('row', { name: /Alpha/ })).toBeNull();
expect(screen.getByRole('row', { name: /Bravo/ }).textContent).toContain('clock drift');
await user.click(screen.getByRole('button', { name: '选择列' }));
await user.click(screen.getByRole('checkbox', { name: '显示异常列' }));
expect(screen.queryByRole('columnheader', { name: '异常' })).toBeNull();
expect(screen.queryByRole('article', { name: /Alpha 实例概览/ })).toBeNull();
expect(screen.getByRole('article', { name: /Bravo 实例概览/ }).textContent).toContain(
'clock drift',
);
expect(screen.queryByRole('heading', { name: '高级详细列表' })).toBeNull();
expect(screen.queryByRole('table')).toBeNull();
await user.click(screen.getByRole('checkbox', { name: '选择 Bravo' }));
expect((screen.getByRole('button', { name: '批量操作' }) as HTMLButtonElement).disabled).toBe(
false,
);
await user.click(screen.getByRole('button', { name: '批量操作' }));
expect(screen.getByText('请为已选择 1 项选择操作。')).toBeTruthy();
expect(screen.getByText('已选择 1 项。重启将逐个实例安全确认后执行。')).toBeTruthy();
expect(screen.getByRole('button', { name: '批量重启服务' })).toBeTruthy();
expect(screen.getByRole('button', { name: '批量系统重启' })).toBeTruthy();
});
it('paginates fleet rows and selects only the current page', async () => {
it('paginates fleet cards and selects only the current page', async () => {
const many: FleetSnapshot = {
instances: Array.from({ length: 11 }, (_, index) => ({
id: `instance-${String(index + 1).padStart(2, '0')}`,
@@ -359,14 +363,14 @@ describe('React AppShell and Fleet vertical slice', () => {
statuses: new Map(),
};
render(<AppShell pathname="/fleet" fleetDataSource={source(async () => many)} />);
await screen.findByRole('row', { name: /Instance 01/ });
await screen.findByRole('article', { name: /Instance 01 实例概览/ });
expect(screen.queryByRole('row', { name: /Instance 11/ })).toBeNull();
expect(screen.queryByRole('article', { name: /Instance 11 实例概览/ })).toBeNull();
expect(screen.getByText('第 1 页,共 2 页')).toBeTruthy();
fireEvent.click(screen.getByRole('checkbox', { name: '选择当前页全部实例' }));
fireEvent.click(screen.getByRole('button', { name: '全选本页' }));
expect(screen.getByText('已选择 10 项')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
expect(await screen.findByRole('row', { name: /Instance 11/ })).toBeTruthy();
expect(await screen.findByRole('article', { name: /Instance 11 实例概览/ })).toBeTruthy();
expect(screen.getByText('第 2 页,共 2 页')).toBeTruthy();
});
+51 -4
View File
@@ -78,6 +78,8 @@ export interface InstanceContext {
status: 'online' | 'offline' | 'auth-required' | 'degraded' | 'unknown';
authentication: 'authenticated' | 'auth-required' | 'unknown';
freshness: 'fresh' | 'stale' | 'expired' | 'unknown';
/** Control-plane config revision for prepare/execute CAS when known. */
revision?: number;
resources?: Readonly<{
cpuPercent?: number;
memoryPercent?: number;
@@ -283,6 +285,7 @@ function Page({
instance={instance}
{...(overviewDataSource ? { dataSource: overviewDataSource } : {})}
refreshSignal={detailRefreshSignal}
{...(instance.revision !== undefined ? { revision: instance.revision } : {})}
/>
),
}
@@ -450,10 +453,51 @@ export function AppShell({
const controller = new AbortController();
let active = true;
setInstanceLoading(true);
const fleetLoad = (fleetDataSource ?? defaultFleetDataSource).load(controller.signal).then(
(fleet) => ({ ok: true as const, fleet }),
() => ({ ok: false as const }),
);
const fleetLoad = (fleetDataSource ?? defaultFleetDataSource)
.load(controller.signal, (partial) => {
if (!active) return;
const fleetOwner = partial.instances.find((candidate) => candidate.id === routeInstanceId);
const status = partial.statuses.get(routeInstanceId);
if (!fleetOwner && !status) return;
setLoadedInstance((current) => {
if (!current || current.id !== routeInstanceId) return current;
const routeStatus = !status
? current.status
: !status.reachable
? 'offline'
: status.authenticated === false
? 'auth-required'
: 'online';
const freshness = status?.summary?.freshness;
const nextRevision = fleetOwner?.revision ?? current.revision;
return {
...current,
origin: fleetOwner?.url ?? current.origin,
status: routeStatus,
authentication:
status?.authenticated === true
? 'authenticated'
: status?.authenticated === false
? 'auth-required'
: current.authentication,
freshness:
freshness === 'fresh' || freshness === 'stale' || freshness === 'expired'
? freshness
: current.freshness,
...(nextRevision === undefined ? {} : { revision: nextRevision }),
...(status?.summary?.resources ? { resources: status.summary.resources } : {}),
resourceSummaryState: status?.summary?.resources
? 'ready'
: current.resourceSummaryState === 'ready'
? 'ready'
: 'loading',
};
});
})
.then(
(fleet) => ({ ok: true as const, fleet }),
() => ({ ok: false as const }),
);
void resolvedInstanceDataSource.get(routeInstanceId).then(
(owner) => {
if (!active) return;
@@ -469,6 +513,7 @@ export function AppShell({
status: 'unknown',
authentication: 'unknown',
freshness: 'unknown',
...(owner.revision === undefined ? {} : { revision: owner.revision }),
resourceSummaryState: 'loading',
});
setInstanceLoading(false);
@@ -494,6 +539,7 @@ export function AppShell({
? 'auth-required'
: 'online';
const freshness = status?.summary?.freshness;
const nextRevision = fleetOwner?.revision ?? owner.revision;
setLoadedInstance({
id: owner.id,
name: owner.name,
@@ -509,6 +555,7 @@ export function AppShell({
freshness === 'fresh' || freshness === 'stale' || freshness === 'expired'
? freshness
: 'unknown',
...(nextRevision === undefined ? {} : { revision: nextRevision }),
...(status?.summary?.resources ? { resources: status.summary.resources } : {}),
resourceSummaryState: status ? 'ready' : 'unavailable',
});
@@ -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(
+115 -61
View File
@@ -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;
},
};
}
+65 -3
View File
@@ -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
View File
@@ -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 {
@@ -83,8 +83,9 @@ describe('Phase 6.1 Overview / System read slice', () => {
}
expect(within(screen.getByRole('region', { name: '设备' })).getByText('SIMBox 8')).toBeTruthy();
expect(screen.getByText(/观测时间:2026-07-17T10:00:00Z/)).toBeTruthy();
expect(screen.queryByRole('button', { name: /restart/i })).toBeNull();
expect(screen.getByText(/重启不可用.*R3/i)).toBeTruthy();
expect(screen.getByRole('button', { name: '重启服务' })).toBeTruthy();
expect(screen.getByRole('button', { name: '系统重启' })).toBeTruthy();
expect(screen.getByText(/服务重启与系统重启均为高风险操作/i)).toBeTruthy();
});
it('translates only known field names and values while preserving unknown safe data', async () => {
@@ -133,7 +134,8 @@ describe('Phase 6.1 Overview / System read slice', () => {
expect(within(operations).getByText('46.7 °C')).toBeTruthy();
expect(within(operations).getByText('13800138000')).toBeTruthy();
expect(screen.queryByText(/没有可用的安全概览只读数据源/i)).toBeNull();
expect(screen.queryByRole('button', { name: /restart/i })).toBeNull();
expect(screen.getByRole('button', { name: '重启服务' })).toBeTruthy();
expect(screen.getByRole('button', { name: '系统重启' })).toBeTruthy();
});
it('shows authentication-required without loading or leaking prior owner data', async () => {
+148 -18
View File
@@ -1,6 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { InstanceContext } from '../app-shell.js';
import {
createOperationClient,
type OperationClient,
} from '../operations/operation-client.js';
import { safeUiError } from '../ui/locale.js';
export type OverviewFieldValue = string | number | boolean | null;
export type OverviewSection = Readonly<Record<string, OverviewFieldValue>>;
@@ -25,6 +30,8 @@ export interface OverviewSystemPageProps {
readonly dataSource?: OverviewDataSource;
/** Change this value when an external owner has requested a refresh. */
readonly refreshSignal?: unknown;
readonly operationClient?: OperationClient;
readonly revision?: number;
}
type ReadState =
@@ -166,10 +173,78 @@ export function OverviewSystemPage({
instance,
dataSource,
refreshSignal,
operationClient,
revision,
}: OverviewSystemPageProps) {
const requestOwner = useRef(0);
const [retry, setRetry] = useState(0);
const [state, setState] = useState<ReadState>({ kind: 'idle' });
const [actionState, setActionState] = useState<{
busy: boolean;
message?: string;
error?: string;
}>({ busy: false });
const client = useMemo(() => operationClient ?? createOperationClient(), [operationClient]);
const effectiveRevision = revision ?? instance.revision;
async function runOverviewAction(kind: 'service' | 'system'): Promise<void> {
const targetRevision = effectiveRevision;
if (!targetRevision || targetRevision < 1) {
setActionState({ busy: false, error: '缺少配置版本,请刷新后重试。' });
return;
}
const op =
kind === 'service'
? {
operationId: 'postServiceRestart',
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
title: '重启服务',
fields: [] as const,
}
: {
operationId: 'postSystemReboot',
parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1',
title: '系统重启',
fields: [{ fieldId: 'delay_seconds', kind: 'number' as const, value: 3 }],
};
if (
typeof window !== 'undefined' &&
!window.confirm(`将执行「${op.title}」。此操作为高风险,确认继续?`)
)
return;
setActionState({ busy: true, message: `正在${op.title}` });
try {
await client.list({ pageSize: 100 });
const prepared = await client.prepare({
operationId: op.operationId,
targets: [{ instanceId: instance.id, revision: targetRevision }],
parameters: {
parameterSchemaId: op.parameterSchemaId,
fields: [...op.fields],
},
});
const confirmed =
typeof window === 'undefined'
? true
: window.confirm(`${prepared.confirmationPrompt}
风险等级 ${prepared.risk}。确认继续?`);
if (!confirmed) {
setActionState({ busy: false, message: '已取消操作。' });
return;
}
const job = await client.execute(prepared.id);
setActionState({
busy: false,
message: job.status === 'succeeded' ? `${op.title}已提交成功。` : `${op.title}结果未知或失败。`,
...(job.status === 'succeeded' ? {} : { error: `${op.title}结果未知或失败。` }),
});
} catch (error) {
setActionState({
busy: false,
error: safeUiError(error, `${op.title}失败,请稍后重试。`),
});
}
}
useEffect(() => {
const request = ++requestOwner.current;
@@ -212,37 +287,95 @@ export function OverviewSystemPage({
return () => controller.abort();
}, [dataSource, instance.authentication, instance.id, refreshSignal, retry]);
const systemOperations = (
<section className="overview-operations" aria-label="系统操作">
<h2></h2>
<p></p>
<div className="fleet-card-actions" role="group" aria-label="重启操作">
<button
type="button"
disabled={actionState.busy || !effectiveRevision}
onClick={() => void runOverviewAction('service')}
>
</button>
<button
type="button"
className="danger-button"
disabled={actionState.busy || !effectiveRevision}
onClick={() => void runOverviewAction('system')}
>
</button>
</div>
{!effectiveRevision ? (
<p role="status"></p>
) : null}
{actionState.error ? (
<p role="alert" className="card-operation-error">
{actionState.error}
</p>
) : null}
{actionState.message ? (
<p role="status" className="card-operation-success">
{actionState.message}
</p>
) : null}
</section>
);
if (instance.authentication === 'auth-required')
return (
<div className="state-panel state-error" role="alert">
<div className="overview-system">
<div className="state-panel state-error" role="alert">
</div>
{systemOperations}
</div>
);
if (!dataSource) return <ResourceSummary instance={instance} />;
if (!dataSource)
return (
<div className="overview-system">
<ResourceSummary instance={instance} />
<p role="status" aria-label="概览加载状态">
使
</p>
{systemOperations}
</div>
);
const retainedSnapshot =
state.kind === 'loading' || state.kind === 'error' ? state.snapshot : undefined;
const snapshot = state.kind === 'ready' ? state.snapshot : retainedSnapshot;
if ((state.kind === 'loading' || state.kind === 'idle') && !retainedSnapshot)
return (
<p role="status" aria-label="概览加载状态">
</p>
<div className="overview-system">
<ResourceSummary instance={instance} />
<p role="status" aria-label="概览加载状态">
</p>
{systemOperations}
</div>
);
if (state.kind === 'error' && !state.snapshot)
return (
<div className="state-panel state-error" role="alert">
<p> {state.message}</p>
<button type="button" onClick={() => setRetry((value) => value + 1)}>
</button>
<div className="overview-system">
<ResourceSummary instance={instance} />
<div className="state-panel state-error" role="alert">
<p> {state.message}</p>
<button type="button" onClick={() => setRetry((value) => value + 1)}>
</button>
</div>
{systemOperations}
</div>
);
const snapshot = state.kind === 'ready' ? state.snapshot : retainedSnapshot;
if (!snapshot) return null;
if (!snapshot) return <div className="overview-system">{systemOperations}</div>;
return (
<div className="overview-system">
@@ -267,10 +400,7 @@ export function OverviewSystemPage({
<StructuredSection key={key} label={label} values={snapshot[key]} />
))}
</div>
<details className="system-operation-note">
<summary></summary>
<p> R3 </p>
</details>
{systemOperations}
</div>
);
}
+113
View File
@@ -1222,6 +1222,94 @@ main ul[aria-label='已配置实例'] {
flex: 0 0 auto;
}
.fleet-card.is-selected {
border-color: color-mix(in srgb, var(--accent, #f2a93b) 55%, var(--border));
box-shadow:
0 0 0 1px color-mix(in srgb, var(--accent, #f2a93b) 35%, transparent),
var(--shadow);
}
.fleet-card header {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.75rem;
align-items: start;
}
.fleet-card-select {
display: grid;
place-items: center;
padding-top: 0.2rem;
}
.fleet-card-select input {
width: 1.1rem;
height: 1.1rem;
}
.fleet-card-metrics {
display: grid;
gap: 0.65rem;
margin: 0.85rem 0 0.5rem;
}
.fleet-card-metrics meter {
width: 100%;
height: 0.55rem;
margin-right: 0.4rem;
}
.fleet-card-admin-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
}
.fleet-card-admin-actions button,
.fleet-card-admin-actions a {
min-height: 2.25rem;
}
.batch-entry {
display: grid;
gap: 0.75rem;
padding: 1rem 1.1rem;
border: 1px solid var(--border);
border-radius: 1rem;
background: color-mix(in srgb, var(--surface-raised, #fff) 92%, var(--accent, #f2a93b));
}
.batch-entry-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.batch-progress {
margin: 0;
font-size: 0.925rem;
}
.fleet-status-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin: 0.75rem 0 1rem;
}
.fleet-status-summary > div {
padding: 0.85rem 1rem;
border: 1px solid var(--border);
border-radius: 0.9rem;
background: var(--surface-raised, #fff);
display: grid;
gap: 0.25rem;
}
.fleet-status-summary strong {
font-size: 1.35rem;
}
@media (max-width: 720px) {
.fleet-status-summary {
grid-template-columns: 1fr;
}
.fleet-card header {
grid-template-columns: auto 1fr;
}
.fleet-card header .status {
grid-column: 2;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
@@ -1232,3 +1320,28 @@ main ul[aria-label='已配置实例'] {
animation-iteration-count: 1 !important;
}
}
.fleet-select-all {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: end;
}
.fleet-select-all button {
min-height: 2.5rem;
}
.fleet-card-section + .pagination {
margin-top: 1rem;
}
.fleet-card-origin {
margin: 0.15rem 0 0;
color: var(--muted, #6b7280);
font-size: 0.85rem;
word-break: break-all;
}
.fleet-card-anomalies {
margin: 0.25rem 0 0;
color: var(--danger, #b45309);
font-size: 0.85rem;
}