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
@@ -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>
);
}