Wire pause/remove into connection detail drawer

The ledger could show connections but not act on them: pause and
remove buttons existed only as legacy per-type panels. Drawer now
exposes both, dispatching through ConnectionsAPI.setEnabled/remove
that were added in ec28bb331.

- Pause toggle hidden when capabilities.supportsPause is false
  (agents today).
- Remove uses a two-click confirm with a 4s timeout rather than a
  nested modal; clearer UX inside a drawer, cheaper to escape.
- Agent branch adds a footnote that removal stops recording but
  requires running the uninstall command on the host for full
  detach; history is retained. Surfacing the uninstall command
  inline is a follow-up.
- Errors render inline; the drawer does not close on failure so
  the user can retry or copy the message.
- Parent wires onMutated to ledger.reload() so the row state
  reflects the new enabled/presence status on the next tick.

Vitest coverage: agent-hides-pause, pause-succeeds-and-notifies,
pause-error-renders-inline, two-click remove calls API + closes.
This commit is contained in:
rcourtman
2026-04-19 21:02:19 +01:00
parent ec28bb3314
commit cc963391a3
3 changed files with 274 additions and 2 deletions
@@ -1,13 +1,22 @@
import { Component, For, Show } from 'solid-js'; import { Component, For, Show, createEffect, createSignal, onCleanup } from 'solid-js';
import { Dialog } from '@/components/shared/Dialog'; import { Dialog } from '@/components/shared/Dialog';
import type { Connection } from '@/api/connections'; import { ConnectionsAPI, type Connection } from '@/api/connections';
import { CONNECTION_TYPE_LABELS, surfaceLabel } from './useConnectionsLedger'; import { CONNECTION_TYPE_LABELS, surfaceLabel } from './useConnectionsLedger';
interface ConnectionDetailDrawerProps { interface ConnectionDetailDrawerProps {
connection: () => Connection | undefined; connection: () => Connection | undefined;
onClose: () => void; onClose: () => void;
onMutated?: () => void;
} }
const REMOVE_CONFIRM_TIMEOUT_MS = 4000;
const errorMessage = (err: unknown): string => {
if (err instanceof Error && err.message) return err.message;
if (typeof err === 'string' && err.trim()) return err;
return 'Something went wrong.';
};
const formatLastSeen = (value: string | null): string => { const formatLastSeen = (value: string | null): string => {
if (!value) return 'No activity yet'; if (!value) return 'No activity yet';
const ts = Date.parse(value); const ts = Date.parse(value);
@@ -22,6 +31,68 @@ const formatErrorAt = (value: string): string => {
}; };
export const ConnectionDetailDrawer: Component<ConnectionDetailDrawerProps> = (props) => { export const ConnectionDetailDrawer: Component<ConnectionDetailDrawerProps> = (props) => {
const [pendingAction, setPendingAction] = createSignal<'pause' | 'remove' | null>(null);
const [actionError, setActionError] = createSignal<string | null>(null);
const [confirmingRemove, setConfirmingRemove] = createSignal(false);
let confirmTimer: number | undefined;
const clearConfirmTimer = () => {
if (confirmTimer !== undefined) {
window.clearTimeout(confirmTimer);
confirmTimer = undefined;
}
};
// Reset transient action state whenever the selected connection changes
// (including when the drawer closes).
createEffect(() => {
props.connection();
setPendingAction(null);
setActionError(null);
setConfirmingRemove(false);
clearConfirmTimer();
});
onCleanup(clearConfirmTimer);
const handlePauseToggle = async (connection: Connection) => {
setActionError(null);
setPendingAction('pause');
try {
await ConnectionsAPI.setEnabled(connection.id, !connection.enabled);
props.onMutated?.();
} catch (err) {
setActionError(errorMessage(err));
} finally {
setPendingAction(null);
}
};
const handleRemoveClick = async (connection: Connection) => {
setActionError(null);
if (!confirmingRemove()) {
setConfirmingRemove(true);
clearConfirmTimer();
confirmTimer = window.setTimeout(() => {
setConfirmingRemove(false);
confirmTimer = undefined;
}, REMOVE_CONFIRM_TIMEOUT_MS);
return;
}
clearConfirmTimer();
setConfirmingRemove(false);
setPendingAction('remove');
try {
await ConnectionsAPI.remove(connection.id);
props.onMutated?.();
props.onClose();
} catch (err) {
setActionError(errorMessage(err));
} finally {
setPendingAction(null);
}
};
return ( return (
<Dialog <Dialog
isOpen={Boolean(props.connection())} isOpen={Boolean(props.connection())}
@@ -40,6 +111,13 @@ export const ConnectionDetailDrawer: Component<ConnectionDetailDrawerProps> = (p
const inactiveScopeKeys = (connection.surfaces ?? []).filter( const inactiveScopeKeys = (connection.surfaces ?? []).filter(
(key) => !activeScopeKeys.includes(key), (key) => !activeScopeKeys.includes(key),
); );
const canPause = connection.capabilities.supportsPause;
const canRemove =
connection.type !== 'docker' && connection.type !== 'kubernetes';
const pauseLabel = connection.enabled ? 'Pause' : 'Resume';
const pauseBusy = () => pendingAction() === 'pause';
const removeBusy = () => pendingAction() === 'remove';
const anyBusy = () => pendingAction() !== null;
return ( return (
<div class="flex h-full flex-col"> <div class="flex h-full flex-col">
<div class="flex items-start justify-between gap-4 border-b border-border px-5 py-4"> <div class="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
@@ -155,6 +233,55 @@ export const ConnectionDetailDrawer: Component<ConnectionDetailDrawerProps> = (p
</div> </div>
</dl> </dl>
</div> </div>
<Show when={canPause || canRemove}>
<div class="space-y-2 border-t border-border px-5 py-4">
<Show when={actionError()}>
<div
role="alert"
class="rounded-md border border-rose-300 bg-rose-50 px-3 py-2 text-xs text-rose-800 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200"
>
{actionError()}
</div>
</Show>
<div class="flex flex-wrap items-center justify-end gap-2">
<Show when={canPause}>
<button
type="button"
disabled={anyBusy()}
onClick={() => void handlePauseToggle(connection)}
class="inline-flex items-center rounded-md border border-border px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60"
>
{pauseBusy() ? 'Working…' : pauseLabel}
</button>
</Show>
<Show when={canRemove}>
<button
type="button"
disabled={anyBusy()}
onClick={() => void handleRemoveClick(connection)}
class={
confirmingRemove()
? 'inline-flex items-center rounded-md bg-rose-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-rose-700 disabled:cursor-not-allowed disabled:opacity-60'
: 'inline-flex items-center rounded-md border border-rose-300 px-3 py-1.5 text-sm font-medium text-rose-700 transition-colors hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-rose-900 dark:text-rose-300 dark:hover:bg-rose-950'
}
>
{removeBusy()
? 'Removing…'
: confirmingRemove()
? 'Click again to confirm'
: 'Remove'}
</button>
</Show>
</div>
<Show when={connection.type === 'agent'}>
<p class="text-xs text-muted">
Removing stops recording this agent. Run the uninstall command on the host to
fully detach; history is retained.
</p>
</Show>
</div>
</Show>
</div> </div>
); );
}} }}
@@ -224,6 +224,7 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
<ConnectionDetailDrawer <ConnectionDetailDrawer
connection={selectedConnection} connection={selectedConnection}
onClose={() => setSelectedConnectionId(null)} onClose={() => setSelectedConnectionId(null)}
onMutated={() => ledger.reload()}
/> />
</div> </div>
); );
@@ -0,0 +1,144 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ConnectionDetailDrawer } from '../ConnectionDetailDrawer';
import type { Connection } from '@/api/connections';
const setEnabled = vi.fn<(connectionId: string, enabled: boolean) => Promise<void>>();
const remove = vi.fn<(connectionId: string) => Promise<void>>();
vi.mock('@/api/connections', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/api/connections')>();
return {
...actual,
ConnectionsAPI: {
...actual.ConnectionsAPI,
setEnabled: (...args: Parameters<typeof actual.ConnectionsAPI.setEnabled>) =>
setEnabled(...args),
remove: (...args: Parameters<typeof actual.ConnectionsAPI.remove>) => remove(...args),
},
};
});
const pveConnection = (overrides: Partial<Connection> = {}): Connection => ({
id: 'pve:tower',
type: 'pve',
name: 'tower',
address: 'https://tower.local:8006',
state: 'active',
stateReason: '',
enabled: true,
surfaces: ['vms', 'containers'],
scope: { vms: true, containers: true },
lastSeen: null,
lastError: null,
source: 'manual',
capabilities: { supportsPause: true, supportsScope: true, supportsTest: true },
...overrides,
});
const agentConnection = (overrides: Partial<Connection> = {}): Connection => ({
id: 'agent:host-1',
type: 'agent',
name: 'tower.local',
address: 'tower.local',
state: 'active',
stateReason: '',
enabled: true,
surfaces: ['host'],
scope: { host: true },
lastSeen: null,
lastError: null,
source: 'agent',
capabilities: { supportsPause: false, supportsScope: false, supportsTest: false },
...overrides,
});
describe('ConnectionDetailDrawer', () => {
beforeEach(() => {
setEnabled.mockReset();
remove.mockReset();
});
afterEach(() => cleanup());
it('hides pause for agent connections but still allows remove', () => {
render(() => (
<ConnectionDetailDrawer
connection={() => agentConnection()}
onClose={() => {}}
onMutated={() => {}}
/>
));
expect(screen.queryByRole('button', { name: /Pause/i })).toBeNull();
expect(screen.getByRole('button', { name: /Remove/i })).toBeInTheDocument();
expect(screen.getByText(/Removing stops recording this agent/i)).toBeInTheDocument();
});
it('toggles pause via ConnectionsAPI and calls onMutated on success', async () => {
setEnabled.mockResolvedValueOnce(undefined);
const onMutated = vi.fn();
render(() => (
<ConnectionDetailDrawer
connection={() => pveConnection()}
onClose={() => {}}
onMutated={onMutated}
/>
));
fireEvent.click(screen.getByRole('button', { name: 'Pause' }));
await waitFor(() => {
expect(setEnabled).toHaveBeenCalledWith('pve:tower', false);
expect(onMutated).toHaveBeenCalledTimes(1);
});
});
it('shows the returned error inline when pause fails', async () => {
setEnabled.mockRejectedValueOnce(new Error('license limit reached'));
const onMutated = vi.fn();
render(() => (
<ConnectionDetailDrawer
connection={() => pveConnection()}
onClose={() => {}}
onMutated={onMutated}
/>
));
fireEvent.click(screen.getByRole('button', { name: 'Pause' }));
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('license limit reached');
});
expect(onMutated).not.toHaveBeenCalled();
});
it('requires a second click to confirm removal, then calls remove + onClose + onMutated', async () => {
remove.mockResolvedValueOnce(undefined);
const onMutated = vi.fn();
const onClose = vi.fn();
render(() => (
<ConnectionDetailDrawer
connection={() => pveConnection()}
onClose={onClose}
onMutated={onMutated}
/>
));
const removeButton = screen.getByRole('button', { name: 'Remove' });
fireEvent.click(removeButton);
expect(remove).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: /Click again to confirm/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Click again to confirm/i }));
await waitFor(() => {
expect(remove).toHaveBeenCalledWith('pve:tower');
expect(onMutated).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
});
});
});