From cba8cd2d498f400b970f308ecb6c2d1c1f9f93dc Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 20 Apr 2026 08:38:13 +0100 Subject: [PATCH] refactor(connections): drop detail page, render actions inline on ledger rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail panel never earned its keep — it showed the same status, host, and last-seen already on the row, and gated Edit / Pause / Remove behind a navigation hop. Collapse it into row-level actions so the ledger is the single surface for managing a monitored system: - New useConnectionRowActions hook: per-id pending / error / confirming state so pause + two-click remove coexist across rows without component-per-row signal proliferation. Confirm window bumped to 6s. - ConnectionsTable: inline Edit / Pause / Remove cluster per row, red alert row for action errors, expansion row with agent uninstall commands (Linux + Windows, copy-to-clipboard) during remove-confirm. - InfrastructureWorkspace: 3-mode shape (ledger / add / edit), no detail selection state, pulls uninstall commands from the operations context and wires clipboard + notifications. - Deleted ConnectionDetailPanel + its test; subsystem contracts (agent-lifecycle, frontend-primitives, api-contracts, registry) updated to point at useConnectionRowActions and ConnectionsTable as the canonical drill-in surface. All 30 Settings tests pass; type-check clean. --- .../v6/internal/subsystems/agent-lifecycle.md | 33 +- .../v6/internal/subsystems/api-contracts.md | 4 +- .../subsystems/frontend-primitives.md | 10 +- .../v6/internal/subsystems/registry.json | 4 +- .../Settings/ConnectionDetailPanel.tsx | 284 ------------------ .../components/Settings/ConnectionsTable.tsx | 269 +++++++++++++---- .../Settings/InfrastructureWorkspace.tsx | 77 ++--- .../__tests__/ConnectionDetailPanel.test.tsx | 205 ------------- .../__tests__/ConnectionsTable.test.tsx | 270 +++++++++++++---- .../InfrastructureWorkspace.test.tsx | 55 ++-- .../Settings/connectionsTableModel.ts | 11 +- .../Settings/useConnectionRowActions.ts | 108 +++++++ .../Settings/useConnectionsLedger.ts | 17 +- 13 files changed, 632 insertions(+), 715 deletions(-) delete mode 100644 frontend-modern/src/components/Settings/ConnectionDetailPanel.tsx delete mode 100644 frontend-modern/src/components/Settings/__tests__/ConnectionDetailPanel.test.tsx create mode 100644 frontend-modern/src/components/Settings/useConnectionRowActions.ts diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index f13fd5280..b8e00c78e 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -37,7 +37,7 @@ management, and fleet control surfaces. 13. `frontend-modern/src/components/Settings/ConnectionsTable.tsx` 14. `frontend-modern/src/components/Settings/connectionsTableModel.ts` 15. `frontend-modern/src/components/Settings/useConnectionsLedger.ts` -16. `frontend-modern/src/components/Settings/ConnectionDetailPanel.tsx` +16. `frontend-modern/src/components/Settings/useConnectionRowActions.ts` 17. `frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx` 18. `frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx` 19. `frontend-modern/src/components/Settings/ConnectionEditor/useConnectionEditor.ts` @@ -192,7 +192,7 @@ an add-only capacity posture. 4. Keep legacy Unified Agent compatibility names explicitly secondary when touching shared `internal/api/` runtime helpers: the legacy host-route family and `host-agent:*` scope names may remain as ingress or migration aliases, but they must not retake primary ownership in router state, live runtime scope checks, handler commentary, or operator-facing guidance. 5. Add or change the unified agent CLI entrypoint, version/help exit semantics, or startup argument/error routing through `cmd/pulse-agent/main.go`. 6. Add or change installer flags, persisted service arguments, or upgrade-safe re-entry behavior through `scripts/install.sh` and `scripts/install.ps1`. -7. Add or change profile management, the extracted agent profiles runtime owner, the top-level infrastructure ledger, the pure unified-agent inventory/install model, the connections-ledger workspace shell, the unified ConnectionEditor and its per-type credential slots, route model, shared install section owner, the shared direct-node/discovery infrastructure settings owners plus their model, shared frontend install-command assembly, Proxmox setup/install API transport, TrueNAS platform-connection management, VMware platform-connection management, the shared monitored-system admission preview shell for those platform connections, setup-completion install handoff transport, deploy-fallback manual install transport, and fleet-control presentation through `frontend-modern/src/api/agentProfiles.ts`, `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`, `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`, `frontend-modern/src/components/Settings/ConnectionsTable.tsx`, `frontend-modern/src/components/Settings/connectionsTableModel.ts`, `frontend-modern/src/components/Settings/useConnectionsLedger.ts`, `frontend-modern/src/components/Settings/ConnectionDetailPanel.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/useConnectionEditor.ts`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/TrueNASCredentialSlot.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/VMwareCredentialSlot.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`, `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`, `frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx`, `frontend-modern/src/components/Settings/platformConnectionsModel.ts`, `frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts`, `frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts`, `frontend-modern/src/components/Settings/proxmoxSettingsModel.ts`, `frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx`, `frontend-modern/src/components/Settings/SettingsSectionNav.tsx`, `frontend-modern/src/components/Settings/infrastructureSettingsModel.ts`, `frontend-modern/src/components/Settings/useInfrastructureConfiguredNodesState.ts`, `frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts`, `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`, `frontend-modern/src/components/Settings/nodeModalModel.ts`, `frontend-modern/src/components/Settings/useNodeModalState.ts`, `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`, and `frontend-modern/src/utils/agentInstallCommand.ts`. Phase 9 retired the legacy reporting/inventory surface (InfrastructureOperationsController, InfrastructureInventorySection, InfrastructureActiveRowDetails, InfrastructureIgnoredRowDetails, InfrastructureStopMonitoringDialog, useInfrastructureReportingState) and the per-type shells (PlatformConnectionsWorkspace, ProxmoxSettingsPanel, ProxmoxDirectWorkspace, ProxmoxConfiguredNodesTable, ProxmoxDirectConnectionsCard, ProxmoxDiscoveryResultsCard, ProxmoxDeleteNodeDialog, ProxmoxNodeModalStack, NodeModal shell, TrueNASSettingsPanel, VMwareSettingsPanel, useProxmoxDirectWorkspaceState); lifecycle extensions must route through the unified aggregator ledger plus ConnectionEditor credential slots rather than reintroducing those retired surfaces. +7. Add or change profile management, the extracted agent profiles runtime owner, the top-level infrastructure ledger, the pure unified-agent inventory/install model, the connections-ledger workspace shell, the unified ConnectionEditor and its per-type credential slots, route model, shared install section owner, the shared direct-node/discovery infrastructure settings owners plus their model, shared frontend install-command assembly, Proxmox setup/install API transport, TrueNAS platform-connection management, VMware platform-connection management, the shared monitored-system admission preview shell for those platform connections, setup-completion install handoff transport, deploy-fallback manual install transport, and fleet-control presentation through `frontend-modern/src/api/agentProfiles.ts`, `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`, `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`, `frontend-modern/src/components/Settings/ConnectionsTable.tsx`, `frontend-modern/src/components/Settings/connectionsTableModel.ts`, `frontend-modern/src/components/Settings/useConnectionsLedger.ts`, `frontend-modern/src/components/Settings/useConnectionRowActions.ts`, `frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/useConnectionEditor.ts`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/TrueNASCredentialSlot.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/VMwareCredentialSlot.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`, `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`, `frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx`, `frontend-modern/src/components/Settings/platformConnectionsModel.ts`, `frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts`, `frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts`, `frontend-modern/src/components/Settings/proxmoxSettingsModel.ts`, `frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx`, `frontend-modern/src/components/Settings/SettingsSectionNav.tsx`, `frontend-modern/src/components/Settings/infrastructureSettingsModel.ts`, `frontend-modern/src/components/Settings/useInfrastructureConfiguredNodesState.ts`, `frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts`, `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`, `frontend-modern/src/components/Settings/nodeModalModel.ts`, `frontend-modern/src/components/Settings/useNodeModalState.ts`, `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`, and `frontend-modern/src/utils/agentInstallCommand.ts`. Phase 9 retired the legacy reporting/inventory surface (InfrastructureOperationsController, InfrastructureInventorySection, InfrastructureActiveRowDetails, InfrastructureIgnoredRowDetails, InfrastructureStopMonitoringDialog, useInfrastructureReportingState) and the per-type shells (PlatformConnectionsWorkspace, ProxmoxSettingsPanel, ProxmoxDirectWorkspace, ProxmoxConfiguredNodesTable, ProxmoxDirectConnectionsCard, ProxmoxDiscoveryResultsCard, ProxmoxDeleteNodeDialog, ProxmoxNodeModalStack, NodeModal shell, TrueNASSettingsPanel, VMwareSettingsPanel, useProxmoxDirectWorkspaceState); lifecycle extensions must route through the unified aggregator ledger plus ConnectionEditor credential slots rather than reintroducing those retired surfaces. Those lifecycle-owned settings hooks may consume websocket state only through `frontend-modern/src/contexts/appRuntime.ts`; they must not import `frontend-modern/src/App.tsx` or recreate root-shell providers. Public demo and other read-only settings posture must stay reporting-first on that same lifecycle-owned workspace boundary: infrastructure workspace @@ -341,16 +341,20 @@ an add-only capacity posture. must not reconstruct per-type health, scope, or last-seen columns from any retired reporting-local state for configured connection rows; the aggregator is the only configured-connections source of - truth. Drill-in - from a unified row opens - `frontend-modern/src/components/Settings/ConnectionDetailPanel.tsx` - inline as a workspace mode (alongside add and edit) with a - "Back to systems" header; the panel renders the aggregator fields - directly (type, address, state, state reason, enabled flag, surfaces - with active scope, last seen, last error) rather than the legacy - `InfrastructureActiveRowDetails` surface-breakdown drawer, and it - must not be re-wrapped in a floating `Dialog` drawer — the detail - view is a workspace mode, not an overlay. + truth. Per-row Edit, Pause/Resume, and Remove actions live directly + on the `ConnectionsTable` row — wired through + `frontend-modern/src/components/Settings/useConnectionRowActions.ts` + which owns pause/remove API dispatch, two-click remove confirm, and + per-id action error presentation. Last-error detail is rendered + inline on the row when `connection.lastError` is non-null, not + hidden behind a click-through. Remove-confirm on an agent row + reveals the Linux + Windows uninstall commands as an inline + expansion so the operator can copy and run them before the final + confirm; that expansion replaces the legacy + `InfrastructureActiveRowDetails` surface-breakdown drawer. The + ledger must never reintroduce a separate detail page or `Dialog` + drawer for viewing a connection's aggregator fields — everything is + on the row. The TrueNAS and VMware credential slots carry per-surface Monitor* scope the same way the PVE/PBS/PMG credential slot already does: `TrueNASSettingsPanelState`/`VMwareSettingsPanelState` read and @@ -727,8 +731,9 @@ instead of adopting execution-history persistence as a side effect. The connected-infrastructure reporting workspace also now treats API-backed platform surfaces as platform-connection-managed capabilities, not host-managed agent extensions. `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, -`frontend-modern/src/components/Settings/useConnectionsLedger.ts`, and -`frontend-modern/src/components/Settings/ConnectionDetailPanel.tsx` must keep +`frontend-modern/src/components/Settings/useConnectionsLedger.ts`, +`frontend-modern/src/components/Settings/useConnectionRowActions.ts`, and +`frontend-modern/src/components/Settings/ConnectionsTable.tsx` must keep Proxmox, PBS, PMG, and TrueNAS on the shared Infrastructure API-backed path, while only machine-installed agent, Docker, and Kubernetes surfaces participate in host stop-monitoring scope, uninstall commands, and upgrade diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 4809fd6eb..089537e91 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -456,7 +456,7 @@ the canonical monitored-system blocked payload. `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx` together with `frontend-modern/src/components/Settings/useConnectionsLedger.ts` and - `frontend-modern/src/components/Settings/ConnectionDetailPanel.tsx` + `frontend-modern/src/components/Settings/ConnectionsTable.tsx` must preserve the transport distinction between machine-managed surfaces (`agent`, `docker`, `kubernetes`) and platform-connections-managed surfaces (`proxmox`, `pbs`, `pmg`, `truenas`) instead of collapsing them @@ -2909,7 +2909,7 @@ connected-infrastructure distinction between machine-managed and platform-connections-managed reporting. `frontend-modern/src/types/api.ts`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/useConnectionsLedger.ts`, and -`frontend-modern/src/components/Settings/ConnectionDetailPanel.tsx` +`frontend-modern/src/components/Settings/ConnectionsTable.tsx` must treat `truenas` as a canonical connected-infrastructure surface kind alongside `proxmox`, `pbs`, and `pmg`, and the settings reporting/install surfaces must keep those platform-managed rows navigable back to platform diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index ac7abadd1..3701d06cd 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -259,10 +259,12 @@ work extends shared components instead of creating new local variants. the aggregator. `InfrastructureWorkspace.tsx` composes the default table rows from `frontend-modern/src/components/Settings/useConnectionsLedger.ts` - (polling `GET /api/connections`) and the unified row click opens - `frontend-modern/src/components/Settings/ConnectionDetailPanel.tsx` - inline as a workspace mode, which reads the aggregator fields - directly and must not be re-wrapped as a floating `Dialog` drawer. Phase 9 retired the + (polling `GET /api/connections`) and renders per-row Edit, Pause, and + Remove actions inline via + `frontend-modern/src/components/Settings/useConnectionRowActions.ts`; + last-error detail and agent uninstall commands surface on the row + itself rather than behind a separate detail page or `Dialog` drawer. + Phase 9 retired the parallel reporting/inventory surface entirely: `useInfrastructureReportingState`, `InfrastructureOperationsController`, `InfrastructureInventorySection`, `InfrastructureActiveRowDetails`, diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index c0173b628..a5e271c0d 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -586,7 +586,6 @@ "frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx", "frontend-modern/src/components/Settings/AgentProfilesPanel.tsx", "frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx", - "frontend-modern/src/components/Settings/ConnectionDetailPanel.tsx", "frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx", "frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx", "frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx", @@ -611,6 +610,7 @@ "frontend-modern/src/components/Settings/proxmoxSettingsModel.ts", "frontend-modern/src/components/Settings/SettingsSectionNav.tsx", "frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts", + "frontend-modern/src/components/Settings/useConnectionRowActions.ts", "frontend-modern/src/components/Settings/useConnectionsLedger.ts", "frontend-modern/src/components/Settings/useInfrastructureConfiguredNodesState.ts", "frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts", @@ -883,7 +883,6 @@ "match_prefixes": [], "match_files": [ "frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx", - "frontend-modern/src/components/Settings/ConnectionDetailPanel.tsx", "frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx", "frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx", "frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx", @@ -897,6 +896,7 @@ "frontend-modern/src/components/Settings/platformConnectionsModel.ts", "frontend-modern/src/components/Settings/proxmoxSettingsModel.ts", "frontend-modern/src/components/Settings/SettingsSectionNav.tsx", + "frontend-modern/src/components/Settings/useConnectionRowActions.ts", "frontend-modern/src/components/Settings/useConnectionsLedger.ts", "frontend-modern/src/components/Settings/useInfrastructureConfiguredNodesState.ts", "frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts", diff --git a/frontend-modern/src/components/Settings/ConnectionDetailPanel.tsx b/frontend-modern/src/components/Settings/ConnectionDetailPanel.tsx deleted file mode 100644 index f74e7a1a6..000000000 --- a/frontend-modern/src/components/Settings/ConnectionDetailPanel.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import { Component, For, Show, createEffect, createSignal, onCleanup } from 'solid-js'; -import { ConnectionsAPI, type Connection } from '@/api/connections'; -import { CONNECTION_TYPE_LABELS, surfaceLabel } from './useConnectionsLedger'; - -interface ConnectionDetailPanelProps { - connection: () => Connection | undefined; - onMutated?: () => void; - onEdit?: (connection: Connection) => void; - onRemoved?: () => void; -} - -const EDITABLE_CONNECTION_TYPES: readonly Connection['type'][] = [ - 'pve', - 'pbs', - 'pmg', - 'vmware', - 'truenas', -]; - -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); - if (Number.isNaN(ts)) return value; - return new Date(ts).toLocaleString(); -}; - -const formatErrorAt = (value: string): string => { - const ts = Date.parse(value); - if (Number.isNaN(ts)) return value; - return new Date(ts).toLocaleString(); -}; - -export const ConnectionDetailPanel: 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. - 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.onRemoved?.(); - } catch (err) { - setActionError(errorMessage(err)); - } finally { - setPendingAction(null); - } - }; - - return ( - - {(accessor) => { - const connection = accessor(); - const typeLabel = CONNECTION_TYPE_LABELS[connection.type] ?? connection.type; - const activeScopeKeys = Object.keys(connection.scope ?? {}).filter( - (key) => connection.scope?.[key], - ); - const inactiveScopeKeys = (connection.surfaces ?? []).filter( - (key) => !activeScopeKeys.includes(key), - ); - const canPause = connection.capabilities.supportsPause; - const canRemove = connection.type !== 'docker' && connection.type !== 'kubernetes'; - const canEdit = - Boolean(props.onEdit) && EDITABLE_CONNECTION_TYPES.includes(connection.type); - const pauseLabel = connection.enabled ? 'Pause' : 'Resume'; - const pauseBusy = () => pendingAction() === 'pause'; - const removeBusy = () => pendingAction() === 'remove'; - const anyBusy = () => pendingAction() !== null; - return ( -
-
-
-
-
Type
-
{typeLabel}
-
- -
-
Address
-
{connection.address || '—'}
-
- -
-
State
-
-
{connection.state}
- -
{connection.stateReason}
-
-
-
- -
-
Enabled
-
- {connection.enabled ? 'Yes' : 'No (paused)'} -
-
- -
-
- Surfaces collected -
-
- 0} - fallback={ - - No surfaces currently enabled for collection. - - } - > - - {(key) => ( - - {surfaceLabel(key)} - - )} - - -
- 0}> -
- - {(key) => ( - - {surfaceLabel(key)} (paused) - - )} - -
-
-
- -
-
- Last activity -
-
{formatLastSeen(connection.lastSeen)}
-
- -
-
Source
-
{connection.source}
-
- - - {(errorAccessor) => { - const err = errorAccessor(); - return ( -
-
- Last error -
-
-
- {err.message} -
-
{formatErrorAt(err.at)}
-
-
- ); - }} -
-
-
- - -
- - - -
- - - - - - - - - -
- -

- 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/ConnectionsTable.tsx b/frontend-modern/src/components/Settings/ConnectionsTable.tsx index eeafc03c4..a19b6677d 100644 --- a/frontend-modern/src/components/Settings/ConnectionsTable.tsx +++ b/frontend-modern/src/components/Settings/ConnectionsTable.tsx @@ -8,7 +8,9 @@ import { TableHeader, TableRow, } from '@/components/shared/Table'; +import type { Connection } from '@/api/connections'; import type { InfrastructureSystemRow } from './connectionsTableModel'; +import type { ConnectionRowActions } from './useConnectionRowActions'; export interface ConnectionsTableHeaderAction { label: string; @@ -16,13 +18,38 @@ export interface ConnectionsTableHeaderAction { tone?: 'primary' | 'secondary'; } +export interface AgentUninstallCommands { + linux: string; + windows: string; +} + interface ConnectionsTableProps { rows: Accessor; headerActions?: readonly ConnectionsTableHeaderAction[]; - onManageRow?: (row: InfrastructureSystemRow) => void; + actions?: ConnectionRowActions; + onEdit?: (connection: Connection) => void; + agentUninstallCommands?: AgentUninstallCommands; + onCopyText?: (text: string) => void; } +const actionColumnClass = + 'w-[26%] px-4 py-2 text-right text-xs font-semibold uppercase tracking-wide text-muted whitespace-nowrap 2xl:w-[18%]'; + +const inlineButtonClass = + 'inline-flex items-center rounded-md border border-border px-2.5 py-1 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60'; + +const removeButtonClass = + 'inline-flex items-center rounded-md border border-rose-300 px-2.5 py-1 text-xs 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'; + +const removeConfirmClass = + 'inline-flex items-center rounded-md bg-rose-600 px-2.5 py-1 text-xs font-medium text-white transition-colors hover:bg-rose-700 disabled:cursor-not-allowed disabled:opacity-60'; + export const ConnectionsTable: Component = (props) => { + const hasActions = () => + Boolean(props.actions) || Boolean(props.onEdit); + + const colSpan = () => (hasActions() ? 6 : 5); + return (
@@ -60,89 +87,205 @@ export const ConnectionsTable: Component = (props) => { - + System - + Coverage - - {(row) => ( - - -
-
{row.name}
- -
{row.host}
-
- -
{row.subtitle}
-
-
{row.collectionLabel}
-
-
+ {(row) => { + const pauseLabel = () => (row.enabled ? 'Pause' : 'Resume'); + const isPauseBusy = () => props.actions?.pendingAction(row.id) === 'pause'; + const isRemoveBusy = () => props.actions?.pendingAction(row.id) === 'remove'; + const anyBusy = () => props.actions?.pendingAction(row.id) !== null; + const isConfirmingRemove = () => + Boolean(props.actions?.confirmingRemove(row.id)); + const rowError = () => props.actions?.actionError(row.id) ?? null; - -
- - {(label) => ( - - {label} + return ( + <> + + +
+
{row.name}
+ +
{row.host}
+
+ +
{row.subtitle}
+
+ +
+ {row.lastErrorMessage} +
+
+
{row.collectionLabel}
+
+
+ + +
+ + {(label) => ( + + {label} + + )} + +
+
+ + + + +
+ + {row.statusLabel} - )} - -
-
+
{row.lastActivityText}
+
+
- + - -
- - {row.statusLabel} - -
{row.lastActivityText}
-
-
+ + +
+ + + + + + + + + +
+
+
+
- + + + + + + + - - - - - - - )} + + + +
+

+ Removing forgets this agent from the ledger; history is retained. + To fully detach, run the uninstall command on the host: +

+
+ + Linux / macOS / FreeBSD + +
+ + + +
+                                  {props.agentUninstallCommands!.linux}
+                                
+
+
+
+ + Windows (PowerShell as Administrator) + +
+ + + +
+                                  {props.agentUninstallCommands!.windows}
+                                
+
+
+
+
+
+
+ + ); + }}
diff --git a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx index a775907b7..55a5d0d7d 100644 --- a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx @@ -1,10 +1,10 @@ import { Component, Match, Show, Switch, createEffect, createMemo, createSignal } from 'solid-js'; import { useLocation, useNavigate, useSearchParams } from '@solidjs/router'; import { presentationPolicyIsReadOnly } from '@/stores/sessionPresentationPolicy'; +import { copyToClipboard } from '@/utils/clipboard'; +import { notificationStore } from '@/stores/notifications'; import { AgentProfilesPanel } from './AgentProfilesPanel'; -import { ConnectionDetailPanel } from './ConnectionDetailPanel'; import { ConnectionsTable, type ConnectionsTableHeaderAction } from './ConnectionsTable'; -import type { InfrastructureSystemRow, SystemManageAction } from './connectionsTableModel'; import { ConnectionEditor } from './ConnectionEditor/ConnectionEditor'; import { NodeCredentialSlot } from './ConnectionEditor/CredentialSlots/NodeCredentialSlot'; import { TrueNASCredentialSlot } from './ConnectionEditor/CredentialSlots/TrueNASCredentialSlot'; @@ -23,7 +23,11 @@ import { } from './infrastructureWorkspaceModel'; import type { InfrastructurePlatformSettingsProps } from './proxmoxSettingsModel'; import { useConnectionsLedger } from './useConnectionsLedger'; -import { InfrastructureOperationsStateProvider } from './useInfrastructureOperationsState'; +import { useConnectionRowActions } from './useConnectionRowActions'; +import { + InfrastructureOperationsStateProvider, + useInfrastructureOperationsContext, +} from './useInfrastructureOperationsState'; export type InfrastructureWorkspaceProps = InfrastructurePlatformSettingsProps; @@ -41,17 +45,14 @@ const InfrastructureWorkspaceContent: Component = const location = useLocation(); const [, setSearchParams] = useSearchParams(); const ledger = useConnectionsLedger(); + const operations = useInfrastructureOperationsContext(); + const rowActions = useConnectionRowActions({ onMutated: () => ledger.reload() }); const [addMode, setAddMode] = createSignal(false); const [initialAddType, setInitialAddType] = createSignal(null); const [showAgentProfiles, setShowAgentProfiles] = createSignal(false); - const [selectedConnectionId, setSelectedConnectionId] = createSignal(null); const [editingConnection, setEditingConnection] = createSignal(null); const readOnly = createMemo(() => presentationPolicyIsReadOnly()); - const selectedConnection = createMemo(() => { - const id = selectedConnectionId(); - return id ? ledger.findById(id) : undefined; - }); const findEditableNode = (connection: Connection): NodeConfigWithStatus | null => { const accessor = @@ -118,7 +119,7 @@ const InfrastructureWorkspaceContent: Component = } }); - const rows = createMemo(() => ledger.rows()); + const rows = createMemo(() => ledger.rows()); const headerActions = createMemo(() => readOnly() @@ -136,9 +137,17 @@ const InfrastructureWorkspaceContent: Component = ], ); - const handleManageAction = (action: SystemManageAction) => { - if (action.kind === 'connection') { - setSelectedConnectionId(action.connectionId); + const agentUninstallCommands = createMemo(() => ({ + linux: operations.getUninstallCommand(), + windows: operations.getWindowsUninstallCommand(), + })); + + const handleCopy = async (text: string) => { + const ok = await copyToClipboard(text); + if (ok) { + notificationStore.success('Copied to clipboard'); + } else { + notificationStore.error('Copy failed'); } }; @@ -153,7 +162,6 @@ const InfrastructureWorkspaceContent: Component = }; const handleEditConnection = (connection: Connection) => { - setSelectedConnectionId(null); setEditingConnection(connection); }; @@ -179,15 +187,12 @@ const InfrastructureWorkspaceContent: Component = ); }; - const mode = createMemo<'ledger' | 'add' | 'edit' | 'detail'>(() => { + const mode = createMemo<'ledger' | 'add' | 'edit'>(() => { if (editingConnection()) return 'edit'; if (addMode()) return 'add'; - if (selectedConnection()) return 'detail'; return 'ledger'; }); - const exitDetailMode = () => setSelectedConnectionId(null); - return (
= handleManageAction(row.manage)} + actions={readOnly() ? undefined : rowActions} + onEdit={readOnly() ? undefined : handleEditConnection} + agentUninstallCommands={agentUninstallCommands()} + onCopyText={(text) => void handleCopy(text)} /> } > @@ -300,39 +308,6 @@ const InfrastructureWorkspaceContent: Component = }} - - {(accessor) => { - const connection = accessor(); - const handleEditFromDetail = () => handleEditConnection(connection); - return ( -
-
-
-
- {connection.name || connection.address || connection.id} -
-
{connection.address}
-
- -
- - ledger.reload()} - onEdit={handleEditFromDetail} - onRemoved={exitDetailMode} - /> -
- ); - }} -
-
diff --git a/frontend-modern/src/components/Settings/__tests__/ConnectionDetailPanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/ConnectionDetailPanel.test.tsx deleted file mode 100644 index dafaba106..000000000 --- a/frontend-modern/src/components/Settings/__tests__/ConnectionDetailPanel.test.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ConnectionDetailPanel } from '../ConnectionDetailPanel'; -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('ConnectionDetailPanel', () => { - beforeEach(() => { - setEnabled.mockReset(); - remove.mockReset(); - }); - afterEach(() => cleanup()); - - it('hides pause for agent connections but still allows remove', () => { - render(() => ( - agentConnection()} - 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('shows Edit for pve connections and invokes onEdit with the connection', () => { - const onEdit = vi.fn(); - render(() => ( - pveConnection()} - onMutated={() => {}} - onEdit={onEdit} - /> - )); - - const editButton = screen.getByRole('button', { name: 'Edit' }); - fireEvent.click(editButton); - expect(onEdit).toHaveBeenCalledTimes(1); - expect(onEdit.mock.calls[0][0]).toMatchObject({ id: 'pve:tower', type: 'pve' }); - }); - - it('shows Edit for vmware connections', () => { - const onEdit = vi.fn(); - render(() => ( - - pveConnection({ id: 'vmware:abc', type: 'vmware', name: 'vcsa' }) - } - onMutated={() => {}} - onEdit={onEdit} - /> - )); - - const editButton = screen.getByRole('button', { name: 'Edit' }); - fireEvent.click(editButton); - expect(onEdit).toHaveBeenCalledTimes(1); - expect(onEdit.mock.calls[0][0]).toMatchObject({ id: 'vmware:abc', type: 'vmware' }); - }); - - it('shows Edit for truenas connections', () => { - const onEdit = vi.fn(); - render(() => ( - - pveConnection({ id: 'truenas:xyz', type: 'truenas', name: 'tower' }) - } - onMutated={() => {}} - onEdit={onEdit} - /> - )); - - const editButton = screen.getByRole('button', { name: 'Edit' }); - fireEvent.click(editButton); - expect(onEdit).toHaveBeenCalledTimes(1); - expect(onEdit.mock.calls[0][0]).toMatchObject({ id: 'truenas:xyz', type: 'truenas' }); - }); - - it('omits Edit for agent connections (edit is not yet supported)', () => { - render(() => ( - agentConnection()} - onMutated={() => {}} - onEdit={() => {}} - /> - )); - - expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull(); - }); - - it('toggles pause via ConnectionsAPI and calls onMutated on success', async () => { - setEnabled.mockResolvedValueOnce(undefined); - const onMutated = vi.fn(); - - render(() => ( - pveConnection()} - 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()} - 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 + onRemoved + onMutated', async () => { - remove.mockResolvedValueOnce(undefined); - const onMutated = vi.fn(); - const onRemoved = vi.fn(); - - render(() => ( - pveConnection()} - onMutated={onMutated} - onRemoved={onRemoved} - /> - )); - - 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(onRemoved).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/frontend-modern/src/components/Settings/__tests__/ConnectionsTable.test.tsx b/frontend-modern/src/components/Settings/__tests__/ConnectionsTable.test.tsx index 928b796f7..b027bc890 100644 --- a/frontend-modern/src/components/Settings/__tests__/ConnectionsTable.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ConnectionsTable.test.tsx @@ -2,19 +2,56 @@ import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ConnectionsTable } from '../ConnectionsTable'; import type { InfrastructureSystemRow } from '../connectionsTableModel'; +import type { Connection } from '@/api/connections'; +import type { ConnectionRowActions } from '../useConnectionRowActions'; -const row = (overrides: Partial = {}): InfrastructureSystemRow => ({ - id: 'row-1', +const connectionFixture = (overrides: Partial = {}): Connection => ({ + id: 'pve:tower', + type: 'pve', name: 'tower', - subtitle: undefined, - host: '10.0.0.1', - coverageLabels: ['Host telemetry'], - collectionLabel: 'Agent', - statusLabel: 'online', - statusClassName: 'bg-green-100 text-green-800', - lastActivityText: '5s ago', - manageLabel: 'View details', - manage: { kind: 'connection', connectionId: 'row-1' }, + address: 'https://tower.local:8006', + state: 'active', + stateReason: '', + enabled: true, + surfaces: ['vms'], + scope: { vms: true }, + lastSeen: null, + lastError: null, + source: 'manual', + capabilities: { supportsPause: true, supportsScope: true, supportsTest: true }, + ...overrides, +}); + +const row = (overrides: Partial = {}): InfrastructureSystemRow => { + const connection = overrides.connection ?? connectionFixture(); + return { + id: overrides.id ?? connection.id, + name: overrides.name ?? 'tower', + subtitle: undefined, + host: '10.0.0.1', + coverageLabels: ['Host telemetry'], + collectionLabel: 'Agent', + statusLabel: 'online', + statusClassName: 'bg-green-100 text-green-800', + lastActivityText: '5s ago', + lastErrorMessage: undefined, + enabled: connection.enabled, + canEdit: true, + canPause: connection.capabilities.supportsPause, + canRemove: connection.type !== 'docker' && connection.type !== 'kubernetes', + isAgent: connection.type === 'agent', + connection, + ...overrides, + }; +}; + +const makeActions = (overrides: Partial = {}): ConnectionRowActions => ({ + pendingAction: () => null, + actionError: () => null, + confirmingRemove: () => false, + togglePause: vi.fn(), + requestRemove: vi.fn(), + cancelRemove: vi.fn(), ...overrides, }); @@ -22,90 +59,191 @@ describe('ConnectionsTable', () => { afterEach(() => cleanup()); it('renders an empty-state hint when no rows exist', () => { - render(() => ( - []} /> - ) as any); + render(() => []} />); expect(screen.getByText(/No monitored systems yet/i)).toBeInTheDocument(); expect(screen.queryByRole('table')).toBeNull(); }); - it('renders one row per top-level monitored system with coverage, collection, and status labels', () => { + it('renders one row per monitored system with coverage, collection, and status labels', () => { render(() => ( [ row(), row({ - id: 'row-2', - name: 'pbs-docker', - subtitle: 'Ignored by Pulse', + id: 'truenas:nas', + name: 'nas', + subtitle: 'TrueNAS', host: undefined, - coverageLabels: ['PBS data'], + coverageLabels: ['Datasets'], collectionLabel: 'API', - statusLabel: 'Ignored', - manageLabel: 'Review ignored', - manage: { kind: 'connection', connectionId: 'row-2' }, + statusLabel: 'Paused', + connection: connectionFixture({ id: 'truenas:nas', type: 'truenas', name: 'nas' }), }), ]} /> - ) as any); + )); expect(screen.getByRole('table')).toBeInTheDocument(); expect(screen.getByText('tower')).toBeInTheDocument(); - expect(screen.getByText('pbs-docker')).toBeInTheDocument(); - expect(screen.getByText('Ignored by Pulse')).toBeInTheDocument(); - expect(screen.getByText('Host telemetry')).toBeInTheDocument(); - expect(screen.getAllByText('API')).toHaveLength(2); - expect(screen.getAllByText('Agent')).toHaveLength(2); - expect(screen.getByText('Ignored')).toBeInTheDocument(); + expect(screen.getByText('nas')).toBeInTheDocument(); + expect(screen.getByText('TrueNAS')).toBeInTheDocument(); + expect(screen.getByText('Datasets')).toBeInTheDocument(); + expect(screen.getByText('Paused')).toBeInTheDocument(); expect(screen.getByText('online')).toBeInTheDocument(); }); - it('uses the responsive ledger layout without the legacy fixed minimum width', () => { - render(() => ( - [row()]} onManageRow={vi.fn()} /> - ) as any); - - const table = screen.getByRole('table'); - expect(table).toHaveClass('table-fixed'); - expect(table.className).toContain('!whitespace-normal'); - expect(table.className).not.toContain('min-w-[1040px]'); - expect(screen.getAllByText('Agent')).toHaveLength(2); - expect(screen.getAllByText('5s ago')).toHaveLength(2); - }); - - it('surfaces configured header actions when provided', () => { - const onAddSystem = vi.fn(); + it('surfaces configured header actions', () => { + const onAdd = vi.fn(); render(() => ( []} - headerActions={[ - { - label: 'Add infrastructure', - onSelect: onAddSystem, - tone: 'primary', - }, - ]} + headerActions={[{ label: 'Add connection', onSelect: onAdd, tone: 'primary' }]} /> - ) as any); + )); - const button = screen.getByRole('button', { name: /Add infrastructure/i }); - fireEvent.click(button); - expect(onAddSystem).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: /Add connection/i })); + expect(onAdd).toHaveBeenCalledTimes(1); }); - it('routes per-row manage actions through the provided callback', () => { - const onManageRow = vi.fn(); + it('shows lastErrorMessage inline on the row when present', () => { render(() => ( - [row()]} onManageRow={onManageRow} /> - ) as any); + [row({ lastErrorMessage: 'certificate expired' })]} + /> + )); - fireEvent.click(screen.getByRole('button', { name: 'View details' })); - expect(onManageRow).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'row-1', - manage: { kind: 'connection', connectionId: 'row-1' }, - }), - ); + expect(screen.getByText('certificate expired')).toBeInTheDocument(); + }); + + it('renders Edit / Pause / Remove buttons when actions and onEdit are provided', () => { + const onEdit = vi.fn(); + const actions = makeActions(); + render(() => ( + [row()]} actions={actions} onEdit={onEdit} /> + )); + + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument(); + }); + + it('invokes onEdit with the underlying Connection', () => { + const onEdit = vi.fn(); + const connection = connectionFixture({ id: 'pve:zeus', name: 'zeus' }); + render(() => ( + [row({ connection })]} + actions={makeActions()} + onEdit={onEdit} + /> + )); + + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + expect(onEdit).toHaveBeenCalledTimes(1); + expect(onEdit).toHaveBeenCalledWith(connection); + }); + + it('invokes togglePause with the Connection when Pause is clicked', () => { + const togglePause = vi.fn(); + const actions = makeActions({ togglePause }); + const connection = connectionFixture(); + render(() => ( + [row({ connection })]} + actions={actions} + onEdit={vi.fn()} + /> + )); + + fireEvent.click(screen.getByRole('button', { name: 'Pause' })); + expect(togglePause).toHaveBeenCalledWith(connection); + }); + + it('labels the pause button Resume when the connection is already paused', () => { + const connection = connectionFixture({ enabled: false }); + render(() => ( + [row({ connection, enabled: false })]} + actions={makeActions()} + /> + )); + + expect(screen.getByRole('button', { name: 'Resume' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Pause' })).toBeNull(); + }); + + it('surfaces the row-specific actionError inside an alert', () => { + const actions = makeActions({ actionError: () => 'permission denied' }); + render(() => ( + [row()]} actions={actions} onEdit={vi.fn()} /> + )); + + expect(screen.getByRole('alert')).toHaveTextContent('permission denied'); + }); + + it('swaps the remove button into a confirming state when confirmingRemove is true', () => { + const actions = makeActions({ confirmingRemove: () => true }); + render(() => ( + [row()]} actions={actions} onEdit={vi.fn()} /> + )); + + expect(screen.getByRole('button', { name: /Click again to confirm/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Remove' })).toBeNull(); + }); + + it('reveals the agent uninstall commands while confirming removal of an agent row', () => { + const actions = makeActions({ confirmingRemove: () => true }); + const agentConnection = connectionFixture({ + id: 'agent:host-1', + type: 'agent', + name: 'host-1', + capabilities: { supportsPause: false, supportsScope: false, supportsTest: false }, + }); + render(() => ( + [ + row({ + connection: agentConnection, + isAgent: true, + canPause: false, + }), + ]} + actions={actions} + onEdit={vi.fn()} + agentUninstallCommands={{ + linux: 'curl -fsSL http://pulse/install.sh | bash -s -- --uninstall', + windows: '$env:PULSE_URL="http://pulse"; $env:PULSE_UNINSTALL="true"; iwr /install.ps1 | iex', + }} + /> + )); + + expect(screen.getByText(/Removing forgets this agent/i)).toBeInTheDocument(); + expect( + screen.getByText(/curl -fsSL http:\/\/pulse\/install\.sh \| bash -s -- --uninstall/), + ).toBeInTheDocument(); + expect( + screen.getByText(/\$env:PULSE_UNINSTALL="true"/), + ).toBeInTheDocument(); + }); + + it('does not render action buttons when the row model forbids them', () => { + render(() => ( + [ + row({ + canEdit: false, + canPause: false, + canRemove: false, + }), + ]} + actions={makeActions()} + onEdit={vi.fn()} + /> + )); + + expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Pause' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Remove' })).toBeNull(); }); }); diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx index ba98f83af..8fcb8e1f7 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx @@ -31,6 +31,11 @@ vi.mock('@/stores/sessionPresentationPolicy', () => ({ vi.mock('../useInfrastructureOperationsState', () => ({ InfrastructureOperationsStateProvider: (props: { children: unknown }) => <>{props.children}, + useInfrastructureOperationsContext: () => ({ + getUninstallCommand: () => 'curl -fsSL http://pulse/install.sh | bash -s -- --uninstall', + getWindowsUninstallCommand: () => + '$env:PULSE_URL="http://pulse"; $env:PULSE_UNINSTALL="true"; iwr /install.ps1 | iex', + }), })); vi.mock('../useConnectionsLedger', () => ({ @@ -47,8 +52,13 @@ vi.mock('../useConnectionsLedger', () => ({ statusLabel: connection.state === 'paused' ? 'Paused' : 'Active', statusClassName: '', lastActivityText: '1m ago', - manageLabel: 'View details', - manage: { kind: 'connection' as const, connectionId: connection.id }, + lastErrorMessage: connection.lastError?.message, + enabled: connection.enabled, + canEdit: ['pve', 'pbs', 'pmg', 'vmware', 'truenas'].includes(connection.type), + canPause: connection.capabilities.supportsPause, + canRemove: connection.type !== 'docker' && connection.type !== 'kubernetes', + isAgent: connection.type === 'agent', + connection, })), findById: (id: string) => connectionState.connections.find((connection) => connection.id === id), reload: vi.fn(), @@ -81,18 +91,6 @@ vi.mock('../AgentProfilesPanel', () => ({ AgentProfilesPanel: () =>
profiles
, })); -vi.mock('../ConnectionDetailPanel', () => ({ - ConnectionDetailPanel: (props: { connection: () => Connection | undefined }) => ( - <> - {props.connection() ? ( -
-
{props.connection()!.name || props.connection()!.id}
-
- ) : null} - - ), -})); - vi.mock('@/api/connections', async () => { const actual = await vi.importActual('@/api/connections'); return { @@ -298,18 +296,37 @@ describe('InfrastructureWorkspace', () => { expect(screen.getByTestId('agent-profiles')).toBeInTheDocument(); }); - it('opens the inline connection detail panel when a ledger row is viewed', async () => { + it('exposes Edit / Pause / Remove on each ledger row directly', async () => { renderWorkspace(); await waitFor(() => expect(screen.getByText('zeus')).toBeInTheDocument()); - fireEvent.click(screen.getByRole('button', { name: 'View details' })); + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'View details' })).toBeNull(); + }); - const panel = screen.getByTestId('connection-detail-panel'); - expect(panel).toBeInTheDocument(); - expect(within(panel).getByText('zeus')).toBeInTheDocument(); + it('opens the inline edit flow when Edit is clicked on a pve row', async () => { + renderWorkspace({ + pveNodes: () => [{ name: 'zeus', host: 'https://10.0.0.1:8006' } as any], + }); + + await waitFor(() => expect(screen.getByText('zeus')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + + await waitFor(() => expect(screen.getByTestId('proxmox-section')).toBeInTheDocument()); expect(screen.getByRole('button', { name: /Back to systems/i })).toBeInTheDocument(); }); + // `within` was previously used for the detail panel test; retain a smoke reference so the + // import stays live until the broader test harness evolves. + it('isolates row content by cell', async () => { + renderWorkspace(); + await waitFor(() => expect(screen.getByText('zeus')).toBeInTheDocument()); + const table = screen.getByRole('table'); + expect(within(table).getByText('zeus')).toBeInTheDocument(); + }); + it('redirects legacy install deep links and pre-selects the agent install slot', async () => { routeState.pathname = '/settings/infrastructure/install'; renderWorkspace(); diff --git a/frontend-modern/src/components/Settings/connectionsTableModel.ts b/frontend-modern/src/components/Settings/connectionsTableModel.ts index 681383975..7883a24f2 100644 --- a/frontend-modern/src/components/Settings/connectionsTableModel.ts +++ b/frontend-modern/src/components/Settings/connectionsTableModel.ts @@ -1,4 +1,4 @@ -export type SystemManageAction = { kind: 'connection'; connectionId: string }; +import type { Connection } from '@/api/connections'; export interface InfrastructureSystemRow { id: string; @@ -10,6 +10,11 @@ export interface InfrastructureSystemRow { statusLabel: string; statusClassName: string; lastActivityText: string; - manageLabel: string; - manage: SystemManageAction; + lastErrorMessage?: string; + enabled: boolean; + canEdit: boolean; + canPause: boolean; + canRemove: boolean; + isAgent: boolean; + connection: Connection; } diff --git a/frontend-modern/src/components/Settings/useConnectionRowActions.ts b/frontend-modern/src/components/Settings/useConnectionRowActions.ts new file mode 100644 index 000000000..4bfba2d12 --- /dev/null +++ b/frontend-modern/src/components/Settings/useConnectionRowActions.ts @@ -0,0 +1,108 @@ +import { createSignal, onCleanup } from 'solid-js'; +import { ConnectionsAPI, type Connection } from '@/api/connections'; + +const REMOVE_CONFIRM_TIMEOUT_MS = 6000; + +type PendingAction = 'pause' | 'remove'; + +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.'; +}; + +export interface ConnectionRowActionsOptions { + onMutated?: () => void; +} + +export interface ConnectionRowActions { + pendingAction: (id: string) => PendingAction | null; + actionError: (id: string) => string | null; + confirmingRemove: (id: string) => boolean; + togglePause: (connection: Connection) => Promise; + requestRemove: (connection: Connection) => Promise; + cancelRemove: (id: string) => void; +} + +export const useConnectionRowActions = ( + options: ConnectionRowActionsOptions = {}, +): ConnectionRowActions => { + const [pending, setPending] = createSignal>({}); + const [errors, setErrors] = createSignal>({}); + const [confirming, setConfirming] = createSignal>({}); + const timers = new Map(); + + const clearTimer = (id: string) => { + const handle = timers.get(id); + if (handle !== undefined) { + window.clearTimeout(handle); + timers.delete(id); + } + }; + + onCleanup(() => { + timers.forEach((handle) => window.clearTimeout(handle)); + timers.clear(); + }); + + const setPendingFor = (id: string, value: PendingAction | null) => + setPending((prev) => ({ ...prev, [id]: value })); + const setErrorFor = (id: string, value: string | null) => + setErrors((prev) => ({ ...prev, [id]: value })); + const setConfirmingFor = (id: string, value: boolean) => + setConfirming((prev) => ({ ...prev, [id]: value })); + + const togglePause = async (connection: Connection) => { + const id = connection.id; + setErrorFor(id, null); + setPendingFor(id, 'pause'); + try { + await ConnectionsAPI.setEnabled(id, !connection.enabled); + options.onMutated?.(); + } catch (err) { + setErrorFor(id, errorMessage(err)); + } finally { + setPendingFor(id, null); + } + }; + + const cancelRemove = (id: string) => { + clearTimer(id); + setConfirmingFor(id, false); + }; + + const requestRemove = async (connection: Connection) => { + const id = connection.id; + setErrorFor(id, null); + if (!confirming()[id]) { + setConfirmingFor(id, true); + clearTimer(id); + const handle = window.setTimeout(() => { + setConfirmingFor(id, false); + timers.delete(id); + }, REMOVE_CONFIRM_TIMEOUT_MS); + timers.set(id, handle); + return; + } + clearTimer(id); + setConfirmingFor(id, false); + setPendingFor(id, 'remove'); + try { + await ConnectionsAPI.remove(id); + options.onMutated?.(); + } catch (err) { + setErrorFor(id, errorMessage(err)); + } finally { + setPendingFor(id, null); + } + }; + + return { + pendingAction: (id) => pending()[id] ?? null, + actionError: (id) => errors()[id] ?? null, + confirmingRemove: (id) => Boolean(confirming()[id]), + togglePause, + requestRemove, + cancelRemove, + }; +}; diff --git a/frontend-modern/src/components/Settings/useConnectionsLedger.ts b/frontend-modern/src/components/Settings/useConnectionsLedger.ts index c17a182d1..5f6ac606f 100644 --- a/frontend-modern/src/components/Settings/useConnectionsLedger.ts +++ b/frontend-modern/src/components/Settings/useConnectionsLedger.ts @@ -108,6 +108,14 @@ const subtitleFor = (connection: Connection): string | undefined => { return CONNECTION_TYPE_LABELS[connection.type] ?? connection.type; }; +const EDITABLE_CONNECTION_TYPES: readonly ConnectionType[] = [ + 'pve', + 'pbs', + 'pmg', + 'vmware', + 'truenas', +]; + export const connectionToRow = (connection: Connection): InfrastructureSystemRow => { const presentation = STATE_PRESENTATION[connection.state] ?? STATE_PRESENTATION.pending; const activeScopeKeys = Object.keys(connection.scope ?? {}).filter( @@ -127,8 +135,13 @@ export const connectionToRow = (connection: Connection): InfrastructureSystemRow statusLabel: presentation.label, statusClassName: presentation.badgeClass, lastActivityText: lastActivityText(connection), - manageLabel: 'View details', - manage: { kind: 'connection', connectionId: connection.id }, + lastErrorMessage: connection.lastError?.message, + enabled: connection.enabled, + canEdit: EDITABLE_CONNECTION_TYPES.includes(connection.type), + canPause: connection.capabilities.supportsPause, + canRemove: connection.type !== 'docker' && connection.type !== 'kubernetes', + isAgent: connection.type === 'agent', + connection, }; };