refactor(connections): drop detail page, render actions inline on ledger rows

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.
This commit is contained in:
rcourtman
2026-04-20 08:38:13 +01:00
parent e6352afbef
commit cba8cd2d49
13 changed files with 632 additions and 715 deletions
@@ -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
@@ -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
@@ -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`,
@@ -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",
@@ -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<ConnectionDetailPanelProps> = (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.
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 (
<Show when={props.connection()}>
{(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 (
<div class="rounded-lg border border-border bg-surface">
<div class="px-5 py-4">
<dl class="grid gap-4 text-sm sm:grid-cols-2">
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-muted">Type</dt>
<dd class="mt-1 text-base-content">{typeLabel}</dd>
</div>
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-muted">Address</dt>
<dd class="mt-1 break-all text-base-content">{connection.address || '—'}</dd>
</div>
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-muted">State</dt>
<dd class="mt-1 text-base-content">
<div class="font-medium capitalize">{connection.state}</div>
<Show when={connection.stateReason}>
<div class="mt-0.5 text-xs text-muted">{connection.stateReason}</div>
</Show>
</dd>
</div>
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-muted">Enabled</dt>
<dd class="mt-1 text-base-content">
{connection.enabled ? 'Yes' : 'No (paused)'}
</dd>
</div>
<div class="sm:col-span-2">
<dt class="text-xs font-semibold uppercase tracking-wide text-muted">
Surfaces collected
</dt>
<dd class="mt-1 flex flex-wrap gap-1.5">
<Show
when={activeScopeKeys.length > 0}
fallback={
<span class="text-xs text-muted">
No surfaces currently enabled for collection.
</span>
}
>
<For each={activeScopeKeys}>
{(key) => (
<span class="inline-flex items-center rounded-full border border-border bg-surface px-2 py-0.5 text-xs font-medium text-base-content">
{surfaceLabel(key)}
</span>
)}
</For>
</Show>
</dd>
<Show when={inactiveScopeKeys.length > 0}>
<dd class="mt-2 flex flex-wrap gap-1.5">
<For each={inactiveScopeKeys}>
{(key) => (
<span class="inline-flex items-center rounded-full border border-dashed border-border bg-surface-alt px-2 py-0.5 text-xs font-medium text-muted">
{surfaceLabel(key)} (paused)
</span>
)}
</For>
</dd>
</Show>
</div>
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-muted">
Last activity
</dt>
<dd class="mt-1 text-base-content">{formatLastSeen(connection.lastSeen)}</dd>
</div>
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-muted">Source</dt>
<dd class="mt-1 text-base-content capitalize">{connection.source}</dd>
</div>
<Show when={connection.lastError}>
{(errorAccessor) => {
const err = errorAccessor();
return (
<div class="sm:col-span-2">
<dt class="text-xs font-semibold uppercase tracking-wide text-muted">
Last error
</dt>
<dd class="mt-1 space-y-1">
<div class="break-words text-rose-700 dark:text-rose-300">
{err.message}
</div>
<div class="text-xs text-muted">{formatErrorAt(err.at)}</div>
</dd>
</div>
);
}}
</Show>
</dl>
</div>
<Show when={canEdit || 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={canEdit}>
<button
type="button"
disabled={anyBusy()}
onClick={() => props.onEdit?.(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"
>
Edit
</button>
</Show>
<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>
);
}}
</Show>
);
};
@@ -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<readonly InfrastructureSystemRow[]>;
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<ConnectionsTableProps> = (props) => {
const hasActions = () =>
Boolean(props.actions) || Boolean(props.onEdit);
const colSpan = () => (hasActions() ? 6 : 5);
return (
<Card padding="none" tone="card" class="rounded-md">
<div class="flex flex-col gap-3 border-b border-border px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
@@ -60,89 +87,205 @@ export const ConnectionsTable: Component<ConnectionsTableProps> = (props) => {
<Table class="w-full table-fixed divide-y divide-border text-sm !whitespace-normal">
<TableHeader class="bg-surface-alt">
<TableRow>
<TableHead class="w-[30%] py-2 pl-4 pr-3 text-left text-xs font-semibold uppercase tracking-wide text-muted whitespace-nowrap 2xl:w-[20%]">
<TableHead class="w-[26%] py-2 pl-4 pr-3 text-left text-xs font-semibold uppercase tracking-wide text-muted whitespace-nowrap 2xl:w-[18%]">
System
</TableHead>
<TableHead class="w-[36%] px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted whitespace-nowrap 2xl:w-[30%]">
<TableHead class="w-[28%] px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted whitespace-nowrap 2xl:w-[26%]">
Coverage
</TableHead>
<TableHead class="hidden w-[14%] px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted whitespace-nowrap 2xl:table-cell">
<TableHead class="hidden w-[12%] px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted whitespace-nowrap 2xl:table-cell">
Collection
</TableHead>
<TableHead class="w-[16%] px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted whitespace-nowrap 2xl:w-[10%]">
<TableHead class="w-[14%] px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted whitespace-nowrap 2xl:w-[10%]">
Status
</TableHead>
<TableHead class="hidden w-[14%] px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted whitespace-nowrap 2xl:table-cell">
<TableHead class="hidden w-[12%] px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted whitespace-nowrap 2xl:table-cell">
Last activity
</TableHead>
<Show when={props.onManageRow}>
<TableHead class="w-[18%] px-4 py-2 text-right text-xs font-semibold uppercase tracking-wide text-muted whitespace-nowrap 2xl:w-[12%]">
Manage
</TableHead>
<Show when={hasActions()}>
<TableHead class={actionColumnClass}>Actions</TableHead>
</Show>
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border bg-surface">
<For each={props.rows()}>
{(row) => (
<TableRow class="even:bg-surface-alt">
<TableCell class="py-3 pl-4 pr-3 align-top">
<div class="min-w-0 space-y-1">
<div class="break-words font-medium text-base-content">{row.name}</div>
<Show when={row.host}>
<div class="break-words text-xs text-muted">{row.host}</div>
</Show>
<Show when={row.subtitle}>
<div class="break-words text-xs text-muted">{row.subtitle}</div>
</Show>
<div class="text-xs text-muted 2xl:hidden">{row.collectionLabel}</div>
</div>
</TableCell>
{(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;
<TableCell class="px-3 py-3 align-top">
<div class="flex flex-wrap gap-1.5">
<For each={row.coverageLabels}>
{(label) => (
<span class="inline-flex items-center rounded-full border border-border bg-surface px-2 py-0.5 text-xs font-medium text-base-content whitespace-nowrap">
{label}
return (
<>
<TableRow class="even:bg-surface-alt">
<TableCell class="py-3 pl-4 pr-3 align-top">
<div class="min-w-0 space-y-1">
<div class="break-words font-medium text-base-content">{row.name}</div>
<Show when={row.host}>
<div class="break-words text-xs text-muted">{row.host}</div>
</Show>
<Show when={row.subtitle}>
<div class="break-words text-xs text-muted">{row.subtitle}</div>
</Show>
<Show when={row.lastErrorMessage}>
<div class="break-words text-xs text-rose-700 dark:text-rose-300">
{row.lastErrorMessage}
</div>
</Show>
<div class="text-xs text-muted 2xl:hidden">{row.collectionLabel}</div>
</div>
</TableCell>
<TableCell class="px-3 py-3 align-top">
<div class="flex flex-wrap gap-1.5">
<For each={row.coverageLabels}>
{(label) => (
<span class="inline-flex items-center rounded-full border border-border bg-surface px-2 py-0.5 text-xs font-medium text-base-content whitespace-nowrap">
{label}
</span>
)}
</For>
</div>
</TableCell>
<TableCell class="hidden px-3 py-3 align-top whitespace-nowrap text-base-content 2xl:table-cell">
{row.collectionLabel}
</TableCell>
<TableCell class="px-3 py-3 align-top">
<div class="space-y-1">
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap ${row.statusClassName}`}
>
{row.statusLabel}
</span>
)}
</For>
</div>
</TableCell>
<div class="text-xs text-muted 2xl:hidden">{row.lastActivityText}</div>
</div>
</TableCell>
<TableCell class="hidden px-3 py-3 align-top whitespace-nowrap text-base-content 2xl:table-cell">
{row.collectionLabel}
</TableCell>
<TableCell class="hidden px-3 py-3 align-top whitespace-nowrap text-muted 2xl:table-cell">
{row.lastActivityText}
</TableCell>
<TableCell class="px-3 py-3 align-top">
<div class="space-y-1">
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap ${row.statusClassName}`}
>
{row.statusLabel}
</span>
<div class="text-xs text-muted 2xl:hidden">{row.lastActivityText}</div>
</div>
</TableCell>
<Show when={hasActions()}>
<TableCell class="px-4 py-3 align-top text-right">
<div class="flex flex-wrap items-center justify-end gap-1.5">
<Show when={row.canEdit && props.onEdit}>
<button
type="button"
disabled={anyBusy()}
onClick={() => props.onEdit?.(row.connection)}
class={inlineButtonClass}
>
Edit
</button>
</Show>
<Show when={row.canPause && props.actions}>
<button
type="button"
disabled={anyBusy()}
onClick={() => void props.actions?.togglePause(row.connection)}
class={inlineButtonClass}
>
{isPauseBusy() ? 'Working…' : pauseLabel()}
</button>
</Show>
<Show when={row.canRemove && props.actions}>
<button
type="button"
disabled={anyBusy()}
onClick={() => void props.actions?.requestRemove(row.connection)}
class={
isConfirmingRemove() ? removeConfirmClass : removeButtonClass
}
>
{isRemoveBusy()
? 'Removing…'
: isConfirmingRemove()
? 'Click again to confirm'
: 'Remove'}
</button>
</Show>
</div>
</TableCell>
</Show>
</TableRow>
<TableCell class="hidden px-3 py-3 align-top whitespace-nowrap text-muted 2xl:table-cell">
{row.lastActivityText}
</TableCell>
<Show when={rowError()}>
<TableRow>
<TableCell colspan={colSpan()} class="bg-surface px-4 pb-3 pt-0">
<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"
>
{rowError()}
</div>
</TableCell>
</TableRow>
</Show>
<Show when={props.onManageRow}>
<TableCell class="px-4 py-3 align-top text-right">
<button
type="button"
onClick={() => props.onManageRow?.(row)}
class="inline-flex min-h-10 w-full items-center justify-center rounded-md border border-border px-3 py-2 text-sm font-medium text-base-content whitespace-nowrap transition-colors hover:bg-surface-hover sm:min-h-9 2xl:w-auto"
>
{row.manageLabel}
</button>
</TableCell>
</Show>
</TableRow>
)}
<Show when={row.isAgent && isConfirmingRemove() && props.agentUninstallCommands}>
<TableRow>
<TableCell colspan={colSpan()} class="bg-surface-alt px-4 pb-4 pt-1">
<div class="space-y-3">
<p class="text-xs text-muted">
Removing forgets this agent from the ledger; history is retained.
To fully detach, run the uninstall command on the host:
</p>
<div class="space-y-1">
<span class="text-xs font-medium text-muted">
Linux / macOS / FreeBSD
</span>
<div class="relative">
<Show when={props.onCopyText}>
<button
type="button"
onClick={() =>
props.onCopyText?.(props.agentUninstallCommands!.linux)
}
class="absolute right-2 top-2 inline-flex items-center justify-center rounded-md bg-surface-hover px-2 py-1 text-xs font-medium text-slate-400 transition-colors hover:bg-slate-700 hover:text-slate-200"
title="Copy command"
>
Copy
</button>
</Show>
<pre class="overflow-x-auto rounded-md bg-slate-950 p-3 pr-16 font-mono text-xs text-red-400">
<code>{props.agentUninstallCommands!.linux}</code>
</pre>
</div>
</div>
<div class="space-y-1">
<span class="text-xs font-medium text-muted">
Windows (PowerShell as Administrator)
</span>
<div class="relative">
<Show when={props.onCopyText}>
<button
type="button"
onClick={() =>
props.onCopyText?.(props.agentUninstallCommands!.windows)
}
class="absolute right-2 top-2 inline-flex items-center justify-center rounded-md bg-surface-hover px-2 py-1 text-xs font-medium text-slate-400 transition-colors hover:bg-slate-700 hover:text-slate-200"
title="Copy command"
>
Copy
</button>
</Show>
<pre class="overflow-x-auto rounded-md bg-slate-950 p-3 pr-16 font-mono text-xs text-red-400">
<code>{props.agentUninstallCommands!.windows}</code>
</pre>
</div>
</div>
</div>
</TableCell>
</TableRow>
</Show>
</>
);
}}
</For>
</TableBody>
</Table>
@@ -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<InfrastructureWorkspaceProps> =
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<ConnectionType | null>(null);
const [showAgentProfiles, setShowAgentProfiles] = createSignal(false);
const [selectedConnectionId, setSelectedConnectionId] = createSignal<string | null>(null);
const [editingConnection, setEditingConnection] = createSignal<Connection | null>(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<InfrastructureWorkspaceProps> =
}
});
const rows = createMemo<InfrastructureSystemRow[]>(() => ledger.rows());
const rows = createMemo(() => ledger.rows());
const headerActions = createMemo<ConnectionsTableHeaderAction[]>(() =>
readOnly()
@@ -136,9 +137,17 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
],
);
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<InfrastructureWorkspaceProps> =
};
const handleEditConnection = (connection: Connection) => {
setSelectedConnectionId(null);
setEditingConnection(connection);
};
@@ -179,15 +187,12 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
);
};
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 (
<div class="space-y-8">
<Switch
@@ -195,7 +200,10 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
<ConnectionsTable
rows={rows}
headerActions={headerActions()}
onManageRow={(row) => 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<InfrastructureWorkspaceProps> =
}}
</Match>
<Match when={mode() === 'detail' && selectedConnection()}>
{(accessor) => {
const connection = accessor();
const handleEditFromDetail = () => handleEditConnection(connection);
return (
<div class="space-y-4">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-base font-semibold text-base-content">
{connection.name || connection.address || connection.id}
</div>
<div class="mt-0.5 text-xs text-muted">{connection.address}</div>
</div>
<button
type="button"
onClick={exitDetailMode}
class="inline-flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover"
>
Back to systems
</button>
</div>
<ConnectionDetailPanel
connection={selectedConnection}
onMutated={() => ledger.reload()}
onEdit={handleEditFromDetail}
onRemoved={exitDetailMode}
/>
</div>
);
}}
</Match>
<Match when={mode() === 'add'}>
<div class="space-y-4">
<div class="flex items-center justify-between gap-3">
@@ -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<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('ConnectionDetailPanel', () => {
beforeEach(() => {
setEnabled.mockReset();
remove.mockReset();
});
afterEach(() => cleanup());
it('hides pause for agent connections but still allows remove', () => {
render(() => (
<ConnectionDetailPanel
connection={() => 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(() => (
<ConnectionDetailPanel
connection={() => 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(() => (
<ConnectionDetailPanel
connection={() =>
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(() => (
<ConnectionDetailPanel
connection={() =>
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(() => (
<ConnectionDetailPanel
connection={() => 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(() => (
<ConnectionDetailPanel
connection={() => 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(() => (
<ConnectionDetailPanel
connection={() => 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(() => (
<ConnectionDetailPanel
connection={() => 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);
});
});
});
@@ -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> = {}): InfrastructureSystemRow => ({
id: 'row-1',
const connectionFixture = (overrides: Partial<Connection> = {}): 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> = {}): 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> = {}): 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(() => (
<ConnectionsTable rows={() => []} />
) as any);
render(() => <ConnectionsTable rows={() => []} />);
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(() => (
<ConnectionsTable
rows={() => [
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(() => (
<ConnectionsTable rows={() => [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(() => (
<ConnectionsTable
rows={() => []}
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(() => (
<ConnectionsTable rows={() => [row()]} onManageRow={onManageRow} />
) as any);
<ConnectionsTable
rows={() => [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(() => (
<ConnectionsTable rows={() => [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(() => (
<ConnectionsTable
rows={() => [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(() => (
<ConnectionsTable
rows={() => [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(() => (
<ConnectionsTable
rows={() => [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(() => (
<ConnectionsTable rows={() => [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(() => (
<ConnectionsTable rows={() => [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(() => (
<ConnectionsTable
rows={() => [
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(() => (
<ConnectionsTable
rows={() => [
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();
});
});
@@ -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: () => <div data-testid="agent-profiles">profiles</div>,
}));
vi.mock('../ConnectionDetailPanel', () => ({
ConnectionDetailPanel: (props: { connection: () => Connection | undefined }) => (
<>
{props.connection() ? (
<div data-testid="connection-detail-panel">
<div>{props.connection()!.name || props.connection()!.id}</div>
</div>
) : null}
</>
),
}));
vi.mock('@/api/connections', async () => {
const actual = await vi.importActual<typeof import('@/api/connections')>('@/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();
@@ -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;
}
@@ -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<void>;
requestRemove: (connection: Connection) => Promise<void>;
cancelRemove: (id: string) => void;
}
export const useConnectionRowActions = (
options: ConnectionRowActionsOptions = {},
): ConnectionRowActions => {
const [pending, setPending] = createSignal<Record<string, PendingAction | null>>({});
const [errors, setErrors] = createSignal<Record<string, string | null>>({});
const [confirming, setConfirming] = createSignal<Record<string, boolean>>({});
const timers = new Map<string, number>();
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,
};
};
@@ -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,
};
};