Polish four remaining add-infrastructure friction points

After verifying the previous picker promotion in the browser, four
specific frictions still showed up:

- Picker rendered all 10 platform cards by default, forcing users to
  scroll past tail platforms (PBS, PMG, Docker, Kubernetes, VMware) on
  a typical viewport. Add a popular set (PVE, TrueNAS, Unraid, Linux
  host, Network endpoint) and a 'Show 5 more platforms' disclosure for
  the rest. Search bypasses the split.

- PVE Coverage step had both 'Monitoring coverage' (descriptive
  paragraph) and 'Collection scope' (toggles + their own paragraph),
  describing overlapping ideas in two stacked sections. Hide the
  'Monitoring coverage' header on PVE and fold its framing into the
  Collection scope description so the section reads as one. PBS and
  PMG keep the 'Monitoring coverage' header since they have no
  toggles.

- PVE Authentication tab read 'API Inventory (Recommended)', surfacing
  internal lane-stage vocabulary. Rename to 'Connect via API'.

- Per-platform dialog subtitle concatenated strategy + bestFor +
  coverage. Coverage repeated the green install-path explanation card
  inside the dialog body, putting the telemetry list in two places on
  one screen. Drop coverage from the subtitle; the green card retains
  the detailed list.
This commit is contained in:
rcourtman
2026-05-09 12:56:43 +01:00
parent 7593097e04
commit 0e4481c936
6 changed files with 67 additions and 17 deletions
@@ -51,6 +51,18 @@ const CARD_ICON: Record<InfrastructureSourcePickerItemId, Component<{ class?: st
availability: Activity,
};
// Popular platforms shown by default. The rest are hidden behind 'Show more
// platforms' to keep the picker scannable at a glance and let users scroll
// to fewer cards before deciding. Search bypasses this split — searching
// for any platform surfaces it regardless of popular status.
const POPULAR_PICKER_ITEM_IDS: ReadonlySet<InfrastructureSourcePickerItemId> = new Set([
'pve',
'truenas',
'unraid',
'linux-host',
'availability',
]);
const itemMatchesQuery = (item: InfrastructureSourcePickerItemPresentation, query: string) => {
if (!query) return true;
const searchableText = [
@@ -72,11 +84,24 @@ const itemMatchesQuery = (item: InfrastructureSourcePickerItemPresentation, quer
export const InfrastructureSourcePicker: Component<InfrastructureSourcePickerProps> = (props) => {
const [query, setQuery] = createSignal('');
const [showAllPlatforms, setShowAllPlatforms] = createSignal(false);
const items = () => getInfrastructureSourcePickerItems();
const normalizedQuery = createMemo(() => query().trim().toLowerCase());
const visibleItems = createMemo(() =>
const matchedItems = createMemo(() =>
items().filter((item) => itemMatchesQuery(item, normalizedQuery())),
);
// When the user is searching, show every match regardless of popular
// status. Otherwise gate the long tail behind a 'Show more platforms'
// disclosure so the default scan is short.
const visibleItems = createMemo(() => {
if (normalizedQuery() || showAllPlatforms()) return matchedItems();
return matchedItems().filter((item) => POPULAR_PICKER_ITEM_IDS.has(item.id));
});
const hiddenCount = createMemo(() =>
normalizedQuery() || showAllPlatforms()
? 0
: matchedItems().filter((item) => !POPULAR_PICKER_ITEM_IDS.has(item.id)).length,
);
const heading = createMemo(() =>
normalizedQuery() ? 'Matching choices' : 'Or pick a specific platform',
);
@@ -196,6 +221,15 @@ export const InfrastructureSourcePicker: Component<InfrastructureSourcePickerPro
}}
</For>
</div>
<Show when={hiddenCount() > 0}>
<button
type="button"
onClick={() => setShowAllPlatforms(true)}
class="mt-1 inline-flex items-center text-xs font-medium text-blue-700 hover:text-blue-900 hover:underline dark:text-blue-300 dark:hover:text-blue-100"
>
Show {hiddenCount()} more platform{hiddenCount() === 1 ? '' : 's'}
</button>
</Show>
</Show>
</section>
</div>
@@ -850,13 +850,17 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
const catalogItem = activeCatalogItem();
if (catalogItem) {
const strategy = getInfrastructureSourceStrategyPresentation(catalogItem.sourceStrategy);
return `${strategy.label}. ${trimSentenceTerminal(catalogItem.bestFor)}. ${trimSentenceTerminal(catalogItem.coverage)}.`;
// Subtitle is just strategy + bestFor. The detailed coverage (telemetry
// breakdown) is repeated by the green install-path explanation card
// inside the dialog body, so dropping it here removes a redundant
// sentence from a header the user reads first.
return `${strategy.label}. ${trimSentenceTerminal(catalogItem.bestFor)}.`;
}
const presentation = getInfrastructureOnboardingProductPresentation(
activeAddType() as InfrastructureOnboardingConnectionType,
);
const strategy = getInfrastructureSourceStrategyPresentation(presentation.sourceStrategy);
return `${strategy.label}. ${trimSentenceTerminal(presentation.bestFor)}. ${trimSentenceTerminal(presentation.coverage)}.`;
return `${strategy.label}. ${trimSentenceTerminal(presentation.bestFor)}.`;
});
const editDialogTitle = createMemo(() => {
@@ -55,15 +55,21 @@ export const NodeModalMonitoringSection: Component<NodeModalMonitoringSectionPro
</div>
</div>
<div>
<SectionHeader
title="Monitoring coverage"
size="sm"
class="mb-2"
titleClass="text-base-content"
/>
<p class="text-sm text-muted">{getNodeMonitoringCoverageCopy(modalProps.nodeType)}</p>
</div>
{/* For PVE the Collection scope section below carries both the
coverage description and the per-collector toggles, so the
standalone Monitoring coverage paragraph would be redundant. PBS
and PMG keep the standalone paragraph because they have no toggles. */}
<Show when={modalProps.nodeType !== 'pve'}>
<div>
<SectionHeader
title="Monitoring coverage"
size="sm"
class="mb-2"
titleClass="text-base-content"
/>
<p class="text-sm text-muted">{getNodeMonitoringCoverageCopy(modalProps.nodeType)}</p>
</div>
</Show>
<Show when={modalProps.nodeType === 'pve'}>
<div class="space-y-3">
@@ -73,9 +79,10 @@ export const NodeModalMonitoringSection: Component<NodeModalMonitoringSectionPro
class="mb-1"
titleClass="text-base-content"
/>
<p class="text-xs text-muted">
Control which Proxmox VE resources Pulse ingests. Disable individual collectors to
reduce API load or trim workload noise.
<p class="text-sm text-muted">
Pulse tracks all supported Proxmox VE resources by default — virtual machines,
containers, storage, and backups — so you always get full visibility without extra
configuration. Disable individual collectors below to reduce API load or trim workload noise.
</p>
<label class="flex items-start gap-2 text-sm text-base-content">
@@ -65,7 +65,7 @@ export const NodeModalSetupGuideSection: Component<NodeModalSetupGuideSectionPro
onClick={() => state.updateField('setupMode', 'auto')}
class={setupModeButtonClass(state.formData().setupMode === 'auto')}
>
API Inventory
Connect via API
<span class="ml-1.5 px-1.5 py-0.5 text-[10px] font-semibold bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded">
Recommended
</span>
@@ -16,6 +16,10 @@ describe('InfrastructureSourcePicker', () => {
// alternative.
expect(screen.getByText('Choose how Pulse should connect')).toBeInTheDocument();
expect(screen.getByText('Or pick a specific platform')).toBeInTheDocument();
// VMware lives behind 'Show more platforms' since it is a less common
// homelab choice; expand the long tail to verify the Early support
// badge still renders for it.
fireEvent.click(screen.getByRole('button', { name: /Show \d+ more platforms?/i }));
expect(screen.getByText('Early support')).toBeInTheDocument();
expect(screen.queryByText('Available now')).toBeNull();
expect(screen.getByText('Unraid')).toBeInTheDocument();
@@ -47,7 +47,8 @@ describe('NodeModalSetupGuideSection', () => {
expect(screen.getByText('API inventory')).toBeInTheDocument();
expect(screen.getByText(/Recommended least-privilege path/i)).toBeInTheDocument();
expect(screen.getByText(/Recommended API inventory path/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /API Inventory/i })).toBeInTheDocument();
// Tab renamed from 'API Inventory' (internal term) to 'Connect via API'.
expect(screen.getByRole('button', { name: /Connect via API/i })).toBeInTheDocument();
expect(screen.queryByText('Manual API token')).not.toBeInTheDocument();
});