mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-24 20:22:53 +00:00
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:
@@ -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 type { Connection } from '@/api/connections';
|
||||
import { ConnectionsAPI, type Connection } from '@/api/connections';
|
||||
import { CONNECTION_TYPE_LABELS, surfaceLabel } from './useConnectionsLedger';
|
||||
|
||||
interface ConnectionDetailDrawerProps {
|
||||
connection: () => Connection | undefined;
|
||||
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 => {
|
||||
if (!value) return 'No activity yet';
|
||||
const ts = Date.parse(value);
|
||||
@@ -22,6 +31,68 @@ const formatErrorAt = (value: string): string => {
|
||||
};
|
||||
|
||||
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 (
|
||||
<Dialog
|
||||
isOpen={Boolean(props.connection())}
|
||||
@@ -40,6 +111,13 @@ export const ConnectionDetailDrawer: Component<ConnectionDetailDrawerProps> = (p
|
||||
const inactiveScopeKeys = (connection.surfaces ?? []).filter(
|
||||
(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 (
|
||||
<div class="flex h-full flex-col">
|
||||
<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>
|
||||
</dl>
|
||||
</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>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -224,6 +224,7 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
|
||||
<ConnectionDetailDrawer
|
||||
connection={selectedConnection}
|
||||
onClose={() => setSelectedConnectionId(null)}
|
||||
onMutated={() => ledger.reload()}
|
||||
/>
|
||||
</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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user