mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 22:12:23 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+36
-19
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user