mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-28 22:29:49 +00:00
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:
@@ -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',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user