Add fleet registry removal with confirm modal and agent_removed WS.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Operators can remove machines from Crucible and dashboard rosters with honest messaging that deletion is registry-only; bulk select, oath ledger entries, and Go/Vitest coverage included.
This commit is contained in:
@@ -243,7 +243,9 @@ describe('api client', () => {
|
||||
.mockResolvedValueOnce(jsonResponse({ agent_id: 'a1', content: 'log' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ agent_id: 'a1', content: 'fresh' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ success: true, agent: mockAgent() }))
|
||||
.mockResolvedValueOnce(jsonResponse({ success: true, sent: 2, failed: 0, action: 'pause' }));
|
||||
.mockResolvedValueOnce(jsonResponse({ success: true, sent: 2, failed: 0, action: 'pause' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ success: true }))
|
||||
.mockResolvedValueOnce(jsonResponse({ success: true, deleted: 2 }));
|
||||
|
||||
await api.getAlerts();
|
||||
expect(lastFetch().url).toBe('/api/v1/alerts');
|
||||
@@ -280,6 +282,14 @@ describe('api client', () => {
|
||||
agent_ids: ['a1', 'a2'],
|
||||
action: 'resume',
|
||||
});
|
||||
|
||||
await api.deleteAgent('gone-1');
|
||||
expect(lastFetch().url).toBe('/api/v1/agents/gone-1');
|
||||
expect(lastFetch().init.method).toBe('DELETE');
|
||||
|
||||
await api.bulkDeleteAgents(['a1', 'a2']);
|
||||
expect(lastFetch().url).toBe('/api/v1/agents/bulk-delete');
|
||||
expect(JSON.parse(lastFetch().init.body as string)).toEqual({ ids: ['a1', 'a2'] });
|
||||
});
|
||||
|
||||
it('createUser POSTs credentials', async () => {
|
||||
|
||||
@@ -50,14 +50,25 @@ describe('CrucibleAgentMeta', () => {
|
||||
expect(await screen.findByText('Saved')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('deletes agent from roster after confirm', async () => {
|
||||
it('deletes agent from roster after confirm modal', async () => {
|
||||
const agent = mockAgent({ id: 'del-1', name: 'Delete Me' });
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
render(<CrucibleAgentMeta agent={agent} />);
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Delete from Roster' }));
|
||||
const requestDeleteConfirm = vi.fn().mockResolvedValue(true);
|
||||
render(<CrucibleAgentMeta agent={agent} requestDeleteConfirm={requestDeleteConfirm} />);
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Remove from fleet' }));
|
||||
await waitFor(() => {
|
||||
expect(requestDeleteConfirm).toHaveBeenCalledWith({ count: 1, agentName: 'Delete Me' });
|
||||
expect(deleteAgentMock).toHaveBeenCalledWith('del-1');
|
||||
});
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('skips delete when operator cancels confirm modal', async () => {
|
||||
const agent = mockAgent({ id: 'del-2', name: 'Keep Me' });
|
||||
const requestDeleteConfirm = vi.fn().mockResolvedValue(false);
|
||||
render(<CrucibleAgentMeta agent={agent} requestDeleteConfirm={requestDeleteConfirm} />);
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Remove from fleet' }));
|
||||
await waitFor(() => {
|
||||
expect(requestDeleteConfirm).toHaveBeenCalled();
|
||||
});
|
||||
expect(deleteAgentMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { Agent } from '../../types';
|
||||
import { HelpTip } from '../HelpTip';
|
||||
|
||||
interface Props {
|
||||
agent: Agent;
|
||||
onUpdated?: (agent: Agent) => void;
|
||||
requestDeleteConfirm?: (req: { count: number; agentName?: string }) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export default function CrucibleAgentMeta({ agent, onUpdated }: Props) {
|
||||
export default function CrucibleAgentMeta({ agent, onUpdated, requestDeleteConfirm }: Props) {
|
||||
const [notesDraft, setNotesDraft] = useState(agent.notes || '');
|
||||
const [tagsDraft, setTagsDraft] = useState((agent.tags || []).join(', '));
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -36,7 +38,12 @@ export default function CrucibleAgentMeta({ agent, onUpdated }: Props) {
|
||||
};
|
||||
|
||||
const deleteFromRoster = async () => {
|
||||
if (!window.confirm('Remove this machine from the fleet roster? This cannot be undone.')) return;
|
||||
const confirmed = requestDeleteConfirm
|
||||
? await requestDeleteConfirm({ count: 1, agentName: agent.name })
|
||||
: window.confirm(
|
||||
'Remove from fleet — does not uninstall agent on host. Remove this machine from the registry?',
|
||||
);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await api.deleteAgent(agent.id);
|
||||
} catch (err) {
|
||||
@@ -111,20 +118,23 @@ export default function CrucibleAgentMeta({ agent, onUpdated }: Props) {
|
||||
className="btn btn-sm"
|
||||
style={{ background: 'rgba(255,100,0,0.15)', border: '1px solid #ff8844', color: '#ffaa66' }}
|
||||
onClick={() => void uninstallAndDelete()}
|
||||
title="Send uninstall command to agent, then remove from roster"
|
||||
title="Send uninstall command to agent, then remove from fleet registry"
|
||||
>
|
||||
Uninstall + Delete
|
||||
Uninstall + Remove
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
style={{ background: 'rgba(255,40,40,0.15)', border: '1px solid #ff4444', color: '#ff6666' }}
|
||||
onClick={() => void deleteFromRoster()}
|
||||
title="Remove this machine from the fleet roster permanently"
|
||||
>
|
||||
Delete from Roster
|
||||
</button>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
style={{ background: 'rgba(255,40,40,0.15)', border: '1px solid #ff4444', color: '#ff6666' }}
|
||||
onClick={() => void deleteFromRoster()}
|
||||
title="Remove from fleet registry only — does not uninstall on host"
|
||||
>
|
||||
Remove from fleet
|
||||
</button>
|
||||
<HelpTip field="crucible_delete_roster" />
|
||||
</span>
|
||||
{msg && <span className="form-hint">{msg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
50
server/web/src/components/Fleet/FleetDeleteConfirmModal.css
Normal file
50
server/web/src/components/Fleet/FleetDeleteConfirmModal.css
Normal file
@@ -0,0 +1,50 @@
|
||||
.fleet-delete-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.fleet-delete-modal {
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border: 1px solid rgba(255, 68, 68, 0.45);
|
||||
box-shadow: 0 0 40px rgba(255, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
.fleet-delete-modal h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.fleet-delete-modal-lead {
|
||||
margin: 0 0 0.75rem;
|
||||
font-family: var(--font-tech, monospace);
|
||||
color: var(--text-primary, #e8f4ff);
|
||||
}
|
||||
|
||||
.fleet-delete-modal-honest {
|
||||
margin: 0 0 1rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.fleet-delete-modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fleet-delete-modal-confirm {
|
||||
background: rgba(255, 40, 40, 0.15);
|
||||
border: 1px solid #ff4444;
|
||||
color: #ff6666;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import FleetDeleteConfirmModal from './FleetDeleteConfirmModal';
|
||||
|
||||
describe('FleetDeleteConfirmModal', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('shows honest fleet-registry-only messaging', () => {
|
||||
render(
|
||||
<FleetDeleteConfirmModal
|
||||
open
|
||||
count={1}
|
||||
agentName="Lab PC"
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('heading', { name: /Remove from fleet/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/does not uninstall the agent on the host/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Remove "Lab PC" from fleet\?/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onConfirm when operator confirms', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<FleetDeleteConfirmModal open count={2} onConfirm={onConfirm} onCancel={onCancel} />,
|
||||
);
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Remove from fleet' }));
|
||||
expect(onConfirm).toHaveBeenCalledOnce();
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onCancel when operator dismisses', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<FleetDeleteConfirmModal open count={1} onConfirm={onConfirm} onCancel={onCancel} />,
|
||||
);
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
61
server/web/src/components/Fleet/FleetDeleteConfirmModal.tsx
Normal file
61
server/web/src/components/Fleet/FleetDeleteConfirmModal.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useModalAmbientDuck } from '../../context/AmbientMusicContext';
|
||||
import { HelpTip } from '../HelpTip';
|
||||
import './FleetDeleteConfirmModal.css';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
count: number;
|
||||
agentName?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function FleetDeleteConfirmModal({
|
||||
open,
|
||||
count,
|
||||
agentName,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
useModalAmbientDuck(open);
|
||||
|
||||
if (!open || count < 1) return null;
|
||||
|
||||
const title =
|
||||
count === 1 && agentName
|
||||
? `Remove "${agentName}" from fleet?`
|
||||
: `Remove ${count} machine${count === 1 ? '' : 's'} from fleet?`;
|
||||
|
||||
return (
|
||||
<div className="fleet-delete-modal-backdrop" role="presentation" onClick={onCancel}>
|
||||
<div
|
||||
className="fleet-delete-modal card"
|
||||
role="dialog"
|
||||
aria-labelledby="fleet-delete-modal-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 id="fleet-delete-modal-title" className="font-display">
|
||||
Remove from fleet
|
||||
<HelpTip field="fl_delete_roster" />
|
||||
</h2>
|
||||
<p className="fleet-delete-modal-lead">{title}</p>
|
||||
<p className="form-hint fleet-delete-modal-honest">
|
||||
Removes the record from the server fleet registry only — does not uninstall the agent on
|
||||
the host. Use Remote Actions → Uninstall if you need to remove the miner binary.
|
||||
</p>
|
||||
<div className="fleet-delete-modal-actions">
|
||||
<button type="button" className="btn btn-outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm fleet-delete-modal-confirm"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Remove from fleet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -125,15 +125,19 @@ export default function FleetToolbar({
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy || !selectedAgents.some((a) => a.status === 'online')} onClick={() => onBulkAction('resume')} title="Fleet health: restore hashing">Resume</button>
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy || !selectedAgents.some((a) => a.status === 'online')} onClick={() => onBulkAction('stop')}>Stop</button>
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy || !selectedAgents.some((a) => a.status === 'online')} onClick={() => onBulkAction('restart_idle')}>Restart idle</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
disabled={bulkBusy}
|
||||
style={{ background: 'rgba(255,40,40,0.15)', border: '1px solid #ff4444', color: '#ff6666' }}
|
||||
onClick={() => onBulkAction('delete')}
|
||||
>
|
||||
🗑 Delete selected
|
||||
</button>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
disabled={bulkBusy}
|
||||
style={{ background: 'rgba(255,40,40,0.15)', border: '1px solid #ff4444', color: '#ff6666' }}
|
||||
onClick={() => onBulkAction('delete')}
|
||||
title="Remove selected machines from fleet registry — does not uninstall on host"
|
||||
>
|
||||
Remove from fleet
|
||||
</button>
|
||||
<HelpTip field="fl_delete_roster" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -164,6 +164,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'agent_removed':
|
||||
case 'agent_deleted': {
|
||||
const { agent_id } = msg.payload as { agent_id: string };
|
||||
setAgents((prev) => prev.filter((a) => a.id !== agent_id));
|
||||
|
||||
@@ -106,6 +106,8 @@ describe('UI_HELP', () => {
|
||||
'crucible_btn_cf_tunnel',
|
||||
'fl_filter_chips',
|
||||
'fl_bulk_actions',
|
||||
'fl_delete_roster',
|
||||
'crucible_delete_roster',
|
||||
'fl_groups',
|
||||
'set_alerts',
|
||||
'set_alert_notifications',
|
||||
|
||||
@@ -218,7 +218,11 @@ export const UI_HELP: Record<string, string> = {
|
||||
fl_filter_chips:
|
||||
'Narrow the roster by name/IP/notes/tags (text search), tag label, subnet prefix, minimum 15m hashrate, or the "needs attention" flag (offline or idle miners below 100 H/s).',
|
||||
fl_bulk_actions:
|
||||
'Actions applied to every checked agent at once: pause or resume mining, stop the miner thread, restart only idle workers, take a screenshot (one agent only), or permanently delete from roster.',
|
||||
'Actions applied to every checked agent at once: pause or resume mining, stop the miner thread, restart only idle workers, take a screenshot (one agent only), or remove from fleet registry (does not uninstall on host).',
|
||||
fl_delete_roster:
|
||||
'Remove from fleet deletes the server registry row and disconnects a live session. It does not uninstall the agent binary on the machine — use Remote Actions → Uninstall for that.',
|
||||
crucible_delete_roster:
|
||||
'Per-machine fleet registry removal from Crucible notes panel or the active-target bar. Offline agents can be removed; online agents are kicked from the hub first.',
|
||||
fl_groups:
|
||||
'Named color-coded subsets of the fleet stored in browser local storage. Click a chip to check-select all members — then apply bulk actions or send Crucible commands to the whole group at once.',
|
||||
|
||||
|
||||
@@ -38,33 +38,41 @@ describe('useFleetBulkActions', () => {
|
||||
describe('bulk delete confirm path', () => {
|
||||
it('calls bulkDeleteAgents when operator confirms', async () => {
|
||||
const agent = mockAgent({ id: 'del-1' });
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
const { result } = renderBulkHook([agent], ['del-1']);
|
||||
const requestDeleteConfirm = vi.fn().mockResolvedValue(true);
|
||||
const { result } = renderHook(() =>
|
||||
useFleetBulkActions({
|
||||
agents: [agent],
|
||||
selectedIds: new Set(['del-1']),
|
||||
requestDeleteConfirm,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleBulkAction('delete');
|
||||
});
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith(
|
||||
'Permanently remove 1 machine(s) from the fleet roster?',
|
||||
);
|
||||
expect(requestDeleteConfirm).toHaveBeenCalledWith({ count: 1 });
|
||||
expect(bulkDeleteMock).toHaveBeenCalledWith(['del-1']);
|
||||
expect(sendBulkMock).not.toHaveBeenCalled();
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('skips bulkDeleteAgents when operator declines', async () => {
|
||||
const agent = mockAgent({ id: 'del-2' });
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
const { result } = renderBulkHook([agent], ['del-2']);
|
||||
const requestDeleteConfirm = vi.fn().mockResolvedValue(false);
|
||||
const { result } = renderHook(() =>
|
||||
useFleetBulkActions({
|
||||
agents: [agent],
|
||||
selectedIds: new Set(['del-2']),
|
||||
requestDeleteConfirm,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleBulkAction('delete');
|
||||
});
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
expect(requestDeleteConfirm).toHaveBeenCalled();
|
||||
expect(bulkDeleteMock).not.toHaveBeenCalled();
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,9 +9,10 @@ interface Options {
|
||||
agents: Agent[];
|
||||
selectedIds: Set<string>;
|
||||
commandResults?: SeqCommandResult[];
|
||||
requestDeleteConfirm?: (req: { count: number; agentName?: string }) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function useFleetBulkActions({ agents, selectedIds, commandResults }: Options) {
|
||||
export function useFleetBulkActions({ agents, selectedIds, commandResults, requestDeleteConfirm }: Options) {
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const screenshotWatchId = useRef<string | null>(null);
|
||||
const screenshotSeqRef = useRef(0);
|
||||
@@ -41,7 +42,12 @@ export function useFleetBulkActions({ agents, selectedIds, commandResults }: Opt
|
||||
if (ids.length === 0) return;
|
||||
|
||||
if (action === 'delete') {
|
||||
if (!window.confirm(`Permanently remove ${ids.length} machine(s) from the fleet roster?`)) return;
|
||||
const confirmed = requestDeleteConfirm
|
||||
? await requestDeleteConfirm({ count: ids.length })
|
||||
: window.confirm(
|
||||
`Remove ${ids.length} machine(s) from the fleet registry? Does not uninstall agents on their hosts.`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
await api.bulkDeleteAgents(ids);
|
||||
@@ -108,7 +114,7 @@ export function useFleetBulkActions({ agents, selectedIds, commandResults }: Opt
|
||||
setBulkBusy(false);
|
||||
}
|
||||
},
|
||||
[agents, commandResults, selectedIds],
|
||||
[agents, commandResults, requestDeleteConfirm, selectedIds],
|
||||
);
|
||||
|
||||
return { bulkBusy, handleBulkAction };
|
||||
|
||||
41
server/web/src/hooks/useFleetDeleteConfirm.test.ts
Normal file
41
server/web/src/hooks/useFleetDeleteConfirm.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useFleetDeleteConfirm } from './useFleetDeleteConfirm';
|
||||
|
||||
describe('useFleetDeleteConfirm', () => {
|
||||
it('resolves true when modal confirms', async () => {
|
||||
const { result } = renderHook(() => useFleetDeleteConfirm());
|
||||
let confirmed: boolean | undefined;
|
||||
act(() => {
|
||||
void result.current.requestDeleteConfirm({ count: 1, agentName: 'Node A' }).then((v) => {
|
||||
confirmed = v;
|
||||
});
|
||||
});
|
||||
expect(result.current.modal.props.open).toBe(true);
|
||||
act(() => {
|
||||
result.current.modal.props.onConfirm();
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(confirmed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves false when modal cancels', async () => {
|
||||
const { result } = renderHook(() => useFleetDeleteConfirm());
|
||||
let confirmed: boolean | undefined;
|
||||
act(() => {
|
||||
void result.current.requestDeleteConfirm({ count: 2 }).then((v) => {
|
||||
confirmed = v;
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
result.current.modal.props.onCancel();
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(confirmed).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
36
server/web/src/hooks/useFleetDeleteConfirm.tsx
Normal file
36
server/web/src/hooks/useFleetDeleteConfirm.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import FleetDeleteConfirmModal from '../components/Fleet/FleetDeleteConfirmModal';
|
||||
|
||||
interface DeleteConfirmRequest {
|
||||
count: number;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
export function useFleetDeleteConfirm() {
|
||||
const [pending, setPending] = useState<
|
||||
(DeleteConfirmRequest & { resolve: (confirmed: boolean) => void }) | null
|
||||
>(null);
|
||||
|
||||
const requestDeleteConfirm = useCallback((req: DeleteConfirmRequest) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
setPending({ ...req, resolve });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const finish = (confirmed: boolean) => {
|
||||
pending?.resolve(confirmed);
|
||||
setPending(null);
|
||||
};
|
||||
|
||||
const modal = (
|
||||
<FleetDeleteConfirmModal
|
||||
open={pending != null}
|
||||
count={pending?.count ?? 0}
|
||||
agentName={pending?.agentName}
|
||||
onConfirm={() => finish(true)}
|
||||
onCancel={() => finish(false)}
|
||||
/>
|
||||
);
|
||||
|
||||
return { requestDeleteConfirm, modal };
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type FleetFilterState,
|
||||
} from '../help/fleetFilters';
|
||||
import { useFleetBulkActions } from '../hooks/useFleetBulkActions';
|
||||
import { useFleetDeleteConfirm } from '../hooks/useFleetDeleteConfirm';
|
||||
import { primaryGroupForAgent } from '../help/fleetGroups';
|
||||
import { useFleetGroups } from '../hooks/useFleetGroups';
|
||||
import { useMatrixRain } from '../context/MatrixRainContext';
|
||||
@@ -374,10 +375,12 @@ export default function CruciblePage() {
|
||||
const [filters, setFilters] = useState<FleetFilterState>(DEFAULT_FLEET_FILTERS);
|
||||
const [showGroupModal, setShowGroupModal] = useState(false);
|
||||
const { groups, addGroup, removeGroup } = useFleetGroups();
|
||||
const { requestDeleteConfirm, modal: deleteConfirmModal } = useFleetDeleteConfirm();
|
||||
const { bulkBusy, handleBulkAction } = useFleetBulkActions({
|
||||
agents,
|
||||
selectedIds,
|
||||
commandResults,
|
||||
requestDeleteConfirm,
|
||||
});
|
||||
|
||||
// Terminal
|
||||
@@ -1491,7 +1494,7 @@ export default function CruciblePage() {
|
||||
</NeonCard>
|
||||
|
||||
{focusedAgent && (
|
||||
<CrucibleAgentMeta agent={focusedAgent} />
|
||||
<CrucibleAgentMeta agent={focusedAgent} requestDeleteConfirm={requestDeleteConfirm} />
|
||||
)}
|
||||
|
||||
{/* ── Focused machine banner ──────────────────────────────────────── */}
|
||||
@@ -1520,9 +1523,34 @@ export default function CruciblePage() {
|
||||
⚠ offline — commands will fail until it reconnects
|
||||
</span>
|
||||
)}
|
||||
<span style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
style={{ background: 'rgba(255,40,40,0.15)', border: '1px solid #ff4444', color: '#ff6666' }}
|
||||
title="Remove from fleet registry — does not uninstall on host"
|
||||
onClick={() => void (async () => {
|
||||
const ok = await requestDeleteConfirm({ count: 1, agentName: focusedAgent.name });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.deleteAgent(focusedAgent.id);
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(focusedAgent.id);
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Remove failed');
|
||||
}
|
||||
})()}
|
||||
>
|
||||
Remove from fleet
|
||||
</button>
|
||||
<HelpTip field="crucible_delete_roster" />
|
||||
</span>
|
||||
<button
|
||||
className="button crucible-btn-muted"
|
||||
style={{ marginLeft: 'auto', fontSize: '0.75rem', padding: '0.2rem 0.6rem' }}
|
||||
style={{ fontSize: '0.75rem', padding: '0.2rem 0.6rem' }}
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
Deselect
|
||||
@@ -1812,6 +1840,7 @@ export default function CruciblePage() {
|
||||
setShowGroupModal(false);
|
||||
}}
|
||||
/>
|
||||
{deleteConfirmModal}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '../components/Fleet/FleetPanels';
|
||||
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
|
||||
import FleetToolbar from '../components/Fleet/FleetToolbar';
|
||||
import { useFleetDeleteConfirm } from '../hooks/useFleetDeleteConfirm';
|
||||
import { SpreadFunnelWidget, AuditLogStrip } from '../components/Fleet/FleetOpsWidgets';
|
||||
import ErrorBoundary from '../components/ErrorBoundary';
|
||||
import { HelpTip } from '../components/HelpTip';
|
||||
@@ -96,6 +97,7 @@ export default function DashboardPage() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const selectedAgents = useMemo(() => agents.filter((a) => selectedIds.has(a.id)), [agents, selectedIds]);
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const { requestDeleteConfirm, modal: deleteConfirmModal } = useFleetDeleteConfirm();
|
||||
const [showMatrix, setShowMatrix] = useState(false);
|
||||
const screenshotWatchId = useRef<string | null>(null);
|
||||
const screenshotSeqRef = useRef(0);
|
||||
@@ -367,7 +369,8 @@ export default function DashboardPage() {
|
||||
if (ids.length === 0) return;
|
||||
|
||||
if (action === 'delete') {
|
||||
if (!window.confirm(`Permanently remove ${ids.length} machine(s) from the fleet roster?`)) return;
|
||||
const confirmed = await requestDeleteConfirm({ count: ids.length });
|
||||
if (!confirmed) return;
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
await api.bulkDeleteAgents(ids);
|
||||
@@ -1058,6 +1061,7 @@ export default function DashboardPage() {
|
||||
<MatrixStreamOverlay active onClose={() => setShowMatrix(false)} />
|
||||
</Suspense>
|
||||
)}
|
||||
{deleteConfirmModal}
|
||||
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
|
||||
⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
|
||||
</footer>
|
||||
|
||||
Reference in New Issue
Block a user