feat(ui): simplify fleet cards by moving edit/delete into detail

Hide instance IDs and config/delete controls on overview cards; keep
service/system restart only, with edit still available from detail.
This commit is contained in:
chick
2026-07-22 00:20:32 +08:00
parent 3c7018a792
commit bbd94b5672
6 changed files with 21 additions and 161 deletions
+9 -30
View File
@@ -100,24 +100,7 @@ describe('React AppShell and Fleet vertical slice', () => {
).toBe(false); ).toBe(false);
}); });
it('makes cards, add, detail, edit, and safe deletion available from the overview', async () => { it('keeps overview cards compact: dashboard entry and ops only; edit/delete live in detail', async () => {
const user = userEvent.setup();
const remove = vi.fn<InstanceDataSource['delete']>().mockResolvedValue(undefined);
const instanceDataSource: InstanceDataSource = {
get: vi.fn().mockResolvedValue({
id: 'bravo',
name: 'Bravo',
origin: 'http://bravo.example:8080',
tags: ['west'],
revision: 7,
credentialConfigured: true,
}),
create: vi.fn(),
update: vi.fn(),
testConnection: vi.fn(),
delete: remove,
};
const fleetMessagesDataSource: FleetMessagesDataSource = { const fleetMessagesDataSource: FleetMessagesDataSource = {
load: vi.fn(async (instanceId) => load: vi.fn(async (instanceId) =>
instanceId === 'bravo' instanceId === 'bravo'
@@ -139,7 +122,6 @@ describe('React AppShell and Fleet vertical slice', () => {
pathname="/fleet" pathname="/fleet"
fleetDataSource={source(async () => snapshot)} fleetDataSource={source(async () => snapshot)}
fleetMessagesDataSource={fleetMessagesDataSource} fleetMessagesDataSource={fleetMessagesDataSource}
instanceDataSource={instanceDataSource}
/>, />,
); );
@@ -167,16 +149,12 @@ describe('React AppShell and Fleet vertical slice', () => {
within(card).getByRole('link', { name: '打开 Bravo 实例仪表盘' }).getAttribute('href'), within(card).getByRole('link', { name: '打开 Bravo 实例仪表盘' }).getAttribute('href'),
).toBe('/instances/bravo/overview'); ).toBe('/instances/bravo/overview');
expect(within(card).queryByRole('link', { name: /查看.*短信/ })).toBeNull(); expect(within(card).queryByRole('link', { name: /查看.*短信/ })).toBeNull();
expect(within(card).getByRole('link', { name: '编辑 Bravo' }).getAttribute('href')).toBe( expect(within(card).queryByRole('link', { name: /编辑/ })).toBeNull();
'/settings/instances/bravo', expect(within(card).queryByRole('button', { name: /删除/ })).toBeNull();
); expect(within(card).queryByText('bravo')).toBeNull();
expect(within(card).getByRole('group', { name: '实例运维操作' })).toBeTruthy();
await user.click(within(card).getByRole('button', { name: '删除 Bravo' })); expect(within(card).getByRole('button', { name: '重启服务 Bravo' })).toBeTruthy();
expect(within(card).getByText('删除后无法撤销。')).toBeTruthy(); expect(within(card).getByRole('button', { name: '系统重启 Bravo' })).toBeTruthy();
await user.type(within(card).getByRole('textbox', { name: '输入 bravo 以确认删除' }), 'bravo');
await user.click(within(card).getByRole('button', { name: '确认删除 Bravo' }));
expect(remove).toHaveBeenCalledWith('bravo', 7);
expect(within(card).getByRole('status').textContent).toContain('删除请求已提交');
}); });
it('keeps message read failures separate from instance reachability and reports each once', async () => { it('keeps message read failures separate from instance reachability and reports each once', async () => {
@@ -302,8 +280,9 @@ describe('React AppShell and Fleet vertical slice', () => {
cards = screen.getAllByRole('article'); cards = screen.getAllByRole('article');
expect(cards[0]?.textContent).toContain('Bravo'); expect(cards[0]?.textContent).toContain('Bravo');
await user.selectOptions(screen.getByRole('combobox', { name: '状态' }), 'auth'); await user.click(screen.getByRole('button', { name: /需认证/ }));
expect(screen.queryByRole('article', { name: /Alpha 实例概览/ })).toBeNull(); expect(screen.queryByRole('article', { name: /Alpha 实例概览/ })).toBeNull();
expect(screen.getByRole('article', { name: /Bravo 实例概览/ })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '全选本页' })); await user.click(screen.getByRole('button', { name: '全选本页' }));
expect((screen.getByRole('checkbox', { name: '选择 Bravo' }) as HTMLInputElement).checked).toBe( expect((screen.getByRole('checkbox', { name: '选择 Bravo' }) as HTMLInputElement).checked).toBe(
true, true,
-1
View File
@@ -218,7 +218,6 @@ function Page({
<FleetPage <FleetPage
{...(fleetDataSource ? { dataSource: fleetDataSource } : {})} {...(fleetDataSource ? { dataSource: fleetDataSource } : {})}
{...(fleetMessagesDataSource ? { messagesDataSource: fleetMessagesDataSource } : {})} {...(fleetMessagesDataSource ? { messagesDataSource: fleetMessagesDataSource } : {})}
{...(instanceDataSource ? { instanceDataSource } : {})}
{...(fleetData ? { initialData: fleetData } : {})} {...(fleetData ? { initialData: fleetData } : {})}
refreshSignal={fleetRefreshSignal} refreshSignal={fleetRefreshSignal}
/> />
+7 -4
View File
@@ -29,15 +29,16 @@ const snapshot: FleetSnapshot = {
afterEach(cleanup); afterEach(cleanup);
describe('FleetPage card navigation', () => { describe('FleetPage card navigation', () => {
it('makes the whole instance card open its dashboard while admin controls stay separately interactive', () => { it('makes the whole instance card open its dashboard while ops stay separate and config stays out of the card', () => {
render(<FleetPage initialData={snapshot} />); render(<FleetPage initialData={snapshot} />);
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' }); const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
const entry = within(card).getByRole('link', { name: '打开 Alpha modem 实例仪表盘' }); const entry = within(card).getByRole('link', { name: '打开 Alpha modem 实例仪表盘' });
expect(entry.getAttribute('href')).toBe('/instances/alpha/overview'); expect(entry.getAttribute('href')).toBe('/instances/alpha/overview');
expect(entry.classList.contains('fleet-card-entry')).toBe(true); expect(entry.classList.contains('fleet-card-entry')).toBe(true);
expect(within(card).getByRole('link', { name: '编辑 Alpha modem' })).toBeTruthy(); expect(within(card).queryByRole('link', { name: /编辑/ })).toBeNull();
expect(within(card).getByRole('button', { name: '删除 Alpha modem' })).toBeTruthy(); expect(within(card).queryByRole('button', { name: /删除/ })).toBeNull();
expect(within(card).queryByText('alpha')).toBeNull();
expect(within(card).queryByRole('link', { name: /查看.*短信/ })).toBeNull(); expect(within(card).queryByRole('link', { name: /查看.*短信/ })).toBeNull();
expect(screen.getByRole('region', { name: '实例状态摘要' }).textContent).toMatch( expect(screen.getByRole('region', { name: '实例状态摘要' }).textContent).toMatch(
/\s*1.*线\s*1.*\s*0/s, /\s*1.*线\s*1.*\s*0/s,
@@ -49,7 +50,9 @@ describe('FleetPage card navigation', () => {
'51', '51',
); );
expect(within(card).getByText('进入仪表盘')).toBeTruthy(); expect(within(card).getByText('进入仪表盘')).toBeTruthy();
expect(within(card).getByRole('group', { name: '实例管理操作' })).toBeTruthy(); expect(within(card).getByRole('group', { name: '实例运维操作' })).toBeTruthy();
expect(within(card).getByRole('button', { name: '重启服务 Alpha modem' })).toBeTruthy();
expect(within(card).getByRole('button', { name: '系统重启 Alpha modem' })).toBeTruthy();
}); });
}); });
+1 -116
View File
@@ -10,7 +10,6 @@ import {
type FleetStatus, type FleetStatus,
type SortDirection, type SortDirection,
} from './fleet-table-view-model.js'; } from './fleet-table-view-model.js';
import type { InstanceDataSource } from '../instances/instance-crud.js';
import type { import type {
FleetMessageLoadState, FleetMessageLoadState,
FleetMessagesDataSource, FleetMessagesDataSource,
@@ -35,7 +34,6 @@ export interface FleetDataSource {
export interface FleetPageProps { export interface FleetPageProps {
readonly dataSource?: FleetDataSource; readonly dataSource?: FleetDataSource;
readonly messagesDataSource?: FleetMessagesDataSource; readonly messagesDataSource?: FleetMessagesDataSource;
readonly instanceDataSource?: InstanceDataSource;
readonly initialData?: FleetSnapshot; readonly initialData?: FleetSnapshot;
readonly refreshSignal?: number; readonly refreshSignal?: number;
readonly operationClient?: OperationClient; readonly operationClient?: OperationClient;
@@ -128,7 +126,6 @@ const SYSTEM_REBOOT = {
export function FleetPage({ export function FleetPage({
dataSource, dataSource,
messagesDataSource, messagesDataSource,
instanceDataSource,
initialData, initialData,
refreshSignal = 0, refreshSignal = 0,
operationClient, operationClient,
@@ -157,13 +154,6 @@ export function FleetPage({
message?: string; message?: string;
error?: string; error?: string;
} | null>(null); } | null>(null);
const [deleteId, setDeleteId] = useState<string>();
const [deleteOwner, setDeleteOwner] = useState<Readonly<{ id: string; revision: number }>>();
const [deleteConfirmation, setDeleteConfirmation] = useState('');
const [deleteBusy, setDeleteBusy] = useState(false);
const deleteRequestRef = useRef(0);
const [deleteStatus, setDeleteStatus] =
useState<Readonly<{ id: string; kind: 'success' | 'error'; message: string }>>();
const messagesOwner = useRef(0); const messagesOwner = useRef(0);
const [messageStates, setMessageStates] = useState<ReadonlyMap<string, FleetMessageState>>( const [messageStates, setMessageStates] = useState<ReadonlyMap<string, FleetMessageState>>(
new Map(), new Map(),
@@ -382,47 +372,6 @@ export function FleetPage({
}); });
} }
async function beginDelete(id: string): Promise<void> {
const request = ++deleteRequestRef.current;
setDeleteId(id);
setDeleteOwner(undefined);
setDeleteConfirmation('');
setDeleteStatus(undefined);
if (!instanceDataSource) {
setDeleteStatus({ id, kind: 'error', message: '无法准备删除,请稍后重试。' });
return;
}
setDeleteBusy(true);
try {
const current = await instanceDataSource.get(id);
if (request !== deleteRequestRef.current) return;
if (current.id !== id) throw new Error('owner mismatch');
setDeleteOwner({ id: current.id, revision: current.revision });
} catch {
if (request === deleteRequestRef.current) {
setDeleteStatus({ id, kind: 'error', message: '无法准备删除,请稍后重试。' });
}
} finally {
if (request === deleteRequestRef.current) setDeleteBusy(false);
}
}
async function remove(id: string): Promise<void> {
if (!instanceDataSource || deleteOwner?.id !== id || deleteConfirmation !== id) return;
setDeleteBusy(true);
setDeleteStatus(undefined);
try {
await instanceDataSource.delete(id, deleteOwner.revision);
setDeleteStatus({ id, kind: 'success', message: '删除请求已提交。' });
setDeleteId(undefined);
setDeleteConfirmation('');
} catch {
setDeleteStatus({ id, kind: 'error', message: '删除请求失败,请稍后重试。' });
} finally {
setDeleteBusy(false);
}
}
function selectedTargets(): Array<{ instanceId: string; revision: number }> { function selectedTargets(): Array<{ instanceId: string; revision: number }> {
const instances = snapshot?.instances ?? []; const instances = snapshot?.instances ?? [];
return model.selectedIds.flatMap((id) => { return model.selectedIds.flatMap((id) => {
@@ -870,7 +819,6 @@ export function FleetPage({
aria-label={`打开 ${row.displayName} 实例仪表盘`} aria-label={`打开 ${row.displayName} 实例仪表盘`}
> >
<h2>{row.displayName}</h2> <h2>{row.displayName}</h2>
<code>{row.id}</code>
<span className="fleet-card-entry-label"></span> <span className="fleet-card-entry-label"></span>
</a> </a>
<span className={`status status-${row.statusKind}`}> <span className={`status status-${row.statusKind}`}>
@@ -971,7 +919,7 @@ export function FleetPage({
<div <div
className="fleet-card-actions fleet-card-admin-actions" className="fleet-card-actions fleet-card-admin-actions"
role="group" role="group"
aria-label="实例管理操作" aria-label="实例运维操作"
> >
<button <button
type="button" type="button"
@@ -990,20 +938,6 @@ export function FleetPage({
> >
</button> </button>
<a
href={`/settings/instances/${encodeURIComponent(row.id)}`}
aria-label={`编辑 ${row.displayName}`}
>
</a>
<button
type="button"
className="danger-button"
aria-label={`删除 ${row.displayName}`}
onClick={() => void beginDelete(row.id)}
>
</button>
</div> </div>
{cardAction?.id === row.id ? ( {cardAction?.id === row.id ? (
<p <p
@@ -1013,55 +947,6 @@ export function FleetPage({
{cardAction.error ?? cardAction.message} {cardAction.error ?? cardAction.message}
</p> </p>
) : null} ) : null}
{deleteId === row.id ? (
<div className="card-delete-confirmation">
<p></p>
<label>
{row.id}
<input
value={deleteConfirmation}
onChange={(event) => setDeleteConfirmation(event.currentTarget.value)}
/>
</label>
<div className="fleet-card-actions">
<button
type="button"
className="danger-button"
disabled={
deleteBusy ||
deleteOwner?.id !== row.id ||
deleteConfirmation !== row.id
}
aria-label={`确认删除 ${row.displayName}`}
onClick={() => void remove(row.id)}
>
</button>
<button
type="button"
onClick={() => {
setDeleteId(undefined);
setDeleteConfirmation('');
setDeleteStatus(undefined);
}}
>
</button>
</div>
</div>
) : null}
{deleteStatus?.id === row.id ? (
<p
role={deleteStatus.kind === 'error' ? 'alert' : 'status'}
className={
deleteStatus.kind === 'error'
? 'card-operation-error'
: 'card-operation-success'
}
>
{deleteStatus.message}
</p>
) : null}
</article> </article>
))} ))}
</div> </div>
@@ -60,6 +60,9 @@ describe('InstanceDetail', () => {
expect(screen.getByText('已认证')).toBeTruthy(); expect(screen.getByText('已认证')).toBeTruthy();
expect(screen.getByText('数据最新')).toBeTruthy(); expect(screen.getByText('数据最新')).toBeTruthy();
expect(links.map((link) => link.textContent)).toEqual(['仪表盘', '短信']); expect(links.map((link) => link.textContent)).toEqual(['仪表盘', '短信']);
expect(screen.getByRole('link', { name: '编辑实例' }).getAttribute('href')).toBe(
'/settings/instances/owner',
);
}); });
it('uses the business titles for both primary pages', () => { it('uses the business titles for both primary pages', () => {
+1 -10
View File
@@ -412,15 +412,10 @@ small {
gap: 0.75rem; gap: 0.75rem;
} }
.fleet-card h2 { .fleet-card h2 {
margin: 0 0 0.2rem; margin: 0;
font-size: 1.18rem; font-size: 1.18rem;
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
.fleet-card code {
color: var(--muted);
overflow-wrap: anywhere;
font-size: 0.82rem;
}
.fleet-card-metrics { .fleet-card-metrics {
margin: 1rem 0; margin: 1rem 0;
display: grid; display: grid;
@@ -950,10 +945,6 @@ tbody th small {
text-decoration: underline; text-decoration: underline;
text-underline-offset: 0.2em; text-underline-offset: 0.2em;
} }
.fleet-card-entry code {
display: block;
overflow-wrap: anywhere;
}
.fleet-card-admin-actions { .fleet-card-admin-actions {
position: relative; position: relative;
z-index: 1; z-index: 1;