mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-25 04:33:03 +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 { 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user