feat(connections): explain API vs Unified Agent above the ledger

New users landing on an empty monitored-systems ledger had no way to
know when they should connect via a platform API vs install the Pulse
Unified Agent, and no signal that the agent unlocks Assistant command
execution and Patrol remediation on the host.

- ConnectionsExplainer: dismissible (localStorage) callout above the
  table, two-mode grid with lucide glyphs (Cloud for API, Cpu for
  agent), short copy tying each mode to concrete capabilities.
- Mount in InfrastructureWorkspace only in ledger mode (hidden during
  add/edit flows so it doesn't compete for attention).
- Rename the agent type label from "Pulse agent" to "Pulse Unified
  Agent" in CONNECTION_TYPE_LABELS. Inline prose elsewhere (tooltips,
  install guides) keeps the conversational "Pulse agent" form — the
  formal branded name only lives where the ledger treats it as a
  type.
This commit is contained in:
rcourtman
2026-04-20 09:27:02 +01:00
parent cdeb257754
commit c0de1a4021
4 changed files with 146 additions and 9 deletions
@@ -0,0 +1,94 @@
import { Component, Show, createSignal } from 'solid-js';
import { Cloud, Cpu, X } from 'lucide-solid';
const DISMISS_KEY = 'pulse.infrastructure.explainer.dismissed.v1';
const readDismissed = (): boolean => {
if (typeof window === 'undefined') return false;
try {
return window.localStorage.getItem(DISMISS_KEY) === '1';
} catch {
return false;
}
};
const persistDismissed = () => {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(DISMISS_KEY, '1');
} catch {
// Ignore storage failures (private mode, quota) — the in-memory signal is enough for this session.
}
};
export const ConnectionsExplainer: Component = () => {
const [dismissed, setDismissed] = createSignal(readDismissed());
const handleDismiss = () => {
persistDismissed();
setDismissed(true);
};
return (
<Show when={!dismissed()}>
<section
aria-label="How to connect infrastructure"
class="relative rounded-md border border-border bg-surface-alt px-4 py-4"
>
<button
type="button"
onClick={handleDismiss}
aria-label="Dismiss"
class="absolute right-2 top-2 inline-flex items-center justify-center rounded-md p-1 text-muted transition-colors hover:bg-surface-hover hover:text-base-content"
>
<X class="h-4 w-4" aria-hidden="true" />
</button>
<div class="mb-3 pr-8">
<h3 class="text-sm font-semibold text-base-content">
Two ways to connect infrastructure
</h3>
<p class="text-xs text-muted">
Pick whichever fits the target. You can mix both on the same host.
</p>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="flex gap-3">
<div
aria-hidden="true"
class="mt-0.5 flex h-8 w-8 flex-none items-center justify-center rounded-md border border-border bg-surface text-muted"
>
<Cloud class="h-4 w-4" />
</div>
<div class="min-w-0 space-y-1">
<div class="text-sm font-medium text-base-content">Platform API</div>
<p class="text-xs text-muted">
Pulse polls the platform's own API (Proxmox VE / PBS / PMG, VMware,
TrueNAS). Fastest to set up; coverage matches what the platform exposes.
</p>
</div>
</div>
<div class="flex gap-3">
<div
aria-hidden="true"
class="mt-0.5 flex h-8 w-8 flex-none items-center justify-center rounded-md border border-border bg-surface text-muted"
>
<Cpu class="h-4 w-4" />
</div>
<div class="min-w-0 space-y-1">
<div class="text-sm font-medium text-base-content">Pulse Unified Agent</div>
<p class="text-xs text-muted">
Installs on the host itself. Use when there's no API (bare-metal Linux,
Unraid), when you want data the API can't surface (CPU/disk temps, SMART,
power, Ceph/RAID), or to let Assistant and Patrol run commands and fixes
on the host.
</p>
</div>
</div>
</div>
</section>
</Show>
);
};
@@ -4,6 +4,7 @@ import { presentationPolicyIsReadOnly } from '@/stores/sessionPresentationPolicy
import { copyToClipboard } from '@/utils/clipboard';
import { notificationStore } from '@/stores/notifications';
import { AgentProfilesPanel } from './AgentProfilesPanel';
import { ConnectionsExplainer } from './ConnectionsExplainer';
import { ConnectionsTable, type ConnectionsTableHeaderAction } from './ConnectionsTable';
import { ConnectionEditor } from './ConnectionEditor/ConnectionEditor';
import { NodeCredentialSlot } from './ConnectionEditor/CredentialSlots/NodeCredentialSlot';
@@ -197,14 +198,17 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
<div class="space-y-8">
<Switch
fallback={
<ConnectionsTable
rows={rows}
headerActions={headerActions()}
actions={readOnly() ? undefined : rowActions}
onEdit={readOnly() ? undefined : handleEditConnection}
agentUninstallCommands={agentUninstallCommands()}
onCopyText={(text) => void handleCopy(text)}
/>
<>
<ConnectionsExplainer />
<ConnectionsTable
rows={rows}
headerActions={headerActions()}
actions={readOnly() ? undefined : rowActions}
onEdit={readOnly() ? undefined : handleEditConnection}
agentUninstallCommands={agentUninstallCommands()}
onCopyText={(text) => void handleCopy(text)}
/>
</>
}
>
<Match when={mode() === 'edit' && editingConnection()}>
@@ -0,0 +1,39 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { ConnectionsExplainer } from '../ConnectionsExplainer';
const DISMISS_KEY = 'pulse.infrastructure.explainer.dismissed.v1';
describe('ConnectionsExplainer', () => {
beforeEach(() => {
window.localStorage.clear();
});
afterEach(() => {
cleanup();
window.localStorage.clear();
});
it('names both ingestion modes with their branded labels', () => {
render(() => <ConnectionsExplainer />);
expect(screen.getByText('Platform API')).toBeInTheDocument();
expect(screen.getByText('Pulse Unified Agent')).toBeInTheDocument();
});
it('hides itself and persists dismissal to localStorage when closed', () => {
const { container } = render(() => <ConnectionsExplainer />);
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
expect(container.querySelector('section')).toBeNull();
expect(window.localStorage.getItem(DISMISS_KEY)).toBe('1');
});
it('stays dismissed across remounts once localStorage records it', () => {
window.localStorage.setItem(DISMISS_KEY, '1');
const { container } = render(() => <ConnectionsExplainer />);
expect(container.querySelector('section')).toBeNull();
});
});
@@ -15,7 +15,7 @@ export const CONNECTION_TYPE_LABELS: Record<ConnectionType, string> = {
pmg: 'Proxmox Mail Gateway',
vmware: 'VMware vCenter',
truenas: 'TrueNAS',
agent: 'Pulse agent',
agent: 'Pulse Unified Agent',
docker: 'Docker',
kubernetes: 'Kubernetes',
};