diff --git a/frontend-modern/src/components/Settings/ConnectionDetailDrawer.tsx b/frontend-modern/src/components/Settings/ConnectionDetailDrawer.tsx index 18b0e7d52..209550d3c 100644 --- a/frontend-modern/src/components/Settings/ConnectionDetailDrawer.tsx +++ b/frontend-modern/src/components/Settings/ConnectionDetailDrawer.tsx @@ -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 = (props) => { + const [pendingAction, setPendingAction] = createSignal<'pause' | 'remove' | null>(null); + const [actionError, setActionError] = createSignal(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 ( = (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 (
@@ -155,6 +233,55 @@ export const ConnectionDetailDrawer: Component = (p
+ + +
+ + + +
+ + + + + + +
+ +

+ Removing stops recording this agent. Run the uninstall command on the host to + fully detach; history is retained. +

+
+
+
); }} diff --git a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx index a188b1a4c..7a8b975d2 100644 --- a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx @@ -224,6 +224,7 @@ const InfrastructureWorkspaceContent: Component = setSelectedConnectionId(null)} + onMutated={() => ledger.reload()} /> ); diff --git a/frontend-modern/src/components/Settings/__tests__/ConnectionDetailDrawer.test.tsx b/frontend-modern/src/components/Settings/__tests__/ConnectionDetailDrawer.test.tsx new file mode 100644 index 000000000..d339318d1 --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/ConnectionDetailDrawer.test.tsx @@ -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>(); +const remove = vi.fn<(connectionId: string) => Promise>(); + +vi.mock('@/api/connections', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ConnectionsAPI: { + ...actual.ConnectionsAPI, + setEnabled: (...args: Parameters) => + setEnabled(...args), + remove: (...args: Parameters) => remove(...args), + }, + }; +}); + +const pveConnection = (overrides: Partial = {}): 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 => ({ + 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(() => ( + 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(() => ( + 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(() => ( + 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(() => ( + 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); + }); + }); +});