Keep Agents secondary in platform navigation

Prefer provider and runtime platform pages ahead of Agents for landing, primary navigation, mobile ordering, preloading, and command palette ordering.
This commit is contained in:
rcourtman 2026-05-23 20:45:40 +01:00
parent 3aa00e9bf6
commit 4c422aa2a2
21 changed files with 107 additions and 107 deletions

View file

@ -168,7 +168,12 @@ Kubernetes host that also has the Pulse agent installed remains owned by that
platform page; the agent contributes health, temperature, SMART, command, and
check-in facets there instead of creating a second operational row in Agents.
Standalone Mac, Windows, Linux, Unraid, and other bare-agent machines remain
Agents-page members.
Agents-page members. Agents is a conditional primary destination, but it is
not the provider-estate landing default: authenticated landing, desktop tab
ordering, mobile tab ordering, preload priority, and command-palette ordering
must prefer concrete provider/runtime pages first when those pages have current
resource evidence. Agents may be the first/default destination only in an
agent-only estate.
Current agent-backed runtime lenses:

View file

@ -148,6 +148,11 @@ platform owner. Source-less legacy state may fall back to
`platformType="agent"`, but Proxmox, vSphere, TrueNAS, Kubernetes, and other
provider-owned host rows remain on their owning platform pages even when a
Pulse agent reports supplemental telemetry such as temperature or SMART data.
Agents must not become the authenticated landing page or first primary
navigation destination when provider-platform evidence such as Proxmox,
Docker / Podman, Kubernetes, TrueNAS, or vSphere is present; it is the
standalone Pulse Agent machine surface and may be the fallback only for
agent-only estates.
The same model also sets the current posture for platform breadth: `truenas`
is at the declared support floor summarized below, and `vmware-vsphere` is the
current admitted strategic next-platform direction while its support claim

View file

@ -150,7 +150,11 @@ runtime cost control, and shared AI transport surfaces.
`agent` platform, but the page must not add an agent-specific Assistant
prompt surface, AI launcher, model picker, or browser-owned model context.
Agent rows may seed Assistant or Patrol only through the shared unified
resource handoff contracts.
resource handoff contracts. Agents also stays behind provider/runtime
platform pages in the primary shell order; moving Agents after Proxmox,
Containers, Kubernetes, TrueNAS, and vSphere must not change Patrol or
Assistant utility-tab ordering, launcher visibility, or shared keyboard
handling in `frontend-modern/src/AppLayout.tsx`.
## Forbidden Paths

View file

@ -525,6 +525,11 @@ or other self-hosted uncapped continuity plans.
The presence of an `Agents` tab is not a new commercial usage unit:
hosted and paid surfaces continue to meter the governed monitored-system
grouping result rather than counting primary navigation destinations.
Agents is also not the paid or hosted estate landing default when concrete
provider/runtime platform evidence exists; the authenticated shell order
and post-auth landing fallback must prefer Proxmox, Containers,
Kubernetes, TrueNAS, and vSphere ahead of Agents, with Agents first only in
an agent-only estate.
## Forbidden Paths

View file

@ -1027,7 +1027,14 @@ not a replacement status card, CTA band, or page-local nested card.
Agents page is a platform page, not a legacy Infrastructure page: it must
use the shared platform tab, toolbar, table-card, and kind-aligned column
primitives, and it must not reintroduce the old top-of-page
InfrastructureSummary chart strip.
InfrastructureSummary chart strip. Agents must also remain secondary in
the shell hierarchy when provider/runtime platform evidence exists:
`PRIMARY_INFRASTRUCTURE_NAV_IDS`, desktop primary tabs, mobile primary
priority, app-shell preload order, authenticated landing fallback, and
command-palette ordering must prefer Proxmox, Containers, Kubernetes,
TrueNAS, and vSphere ahead of Agents. Agents may win those first/default
positions only when the current estate has standalone Pulse Agent machines
and no provider/runtime platform evidence.
## Forbidden Paths

View file

@ -622,7 +622,10 @@ shell clickable behind another overlay.
hot path; presentation-only platforms must not be registered. Preload
entries may warm route modules, but they must not trigger additional
unfiltered resource fetches, metrics-history fan-out, or provider scans
before the destination page owns its normal data query.
before the destination page owns its normal data query. The preload order
follows the provider-first shell order as a hot-path hint: Proxmox remains
ahead of Agents when both surfaces are available, while Agents stays in
the preload set so agent-only estates still get a warm first destination.
Platform pages that embed `WorkloadsSurface` reuse the canonical
workloads filter toolbar through the `showFilterToolbar` +
`suppressPlatformFilter` props in `WorkloadsSurfaceProps`. The page

View file

@ -332,17 +332,6 @@ export function AppLayout(props: AppLayoutProps) {
const isVisible = (id: PrimaryTab['id']) =>
primaryInfrastructureNavigationIsVisible(visible, id as PrimaryInfrastructureNavId);
const allPrimaryTabs: PrimaryTab[] = [
{
id: 'agents',
label: 'Agents',
route: ROOT_AGENTS_PATH,
settingsRoute: '/settings/infrastructure',
tooltip: 'Pulse Agent managed machines, OS telemetry, storage, and command eligibility',
enabled: isVisible('agents'),
live: isVisible('agents'),
icon: ServerIcon,
alwaysShow: false,
},
{
id: 'proxmox',
label: 'Proxmox',
@ -398,6 +387,17 @@ export function AppLayout(props: AppLayoutProps) {
icon: CpuIcon,
alwaysShow: false,
},
{
id: 'agents',
label: 'Agents',
route: ROOT_AGENTS_PATH,
settingsRoute: '/settings/infrastructure',
tooltip: 'Standalone Pulse Agent machines, OS telemetry, storage, and command eligibility',
enabled: isVisible('agents'),
live: isVisible('agents'),
icon: ServerIcon,
alwaysShow: false,
},
];
return allPrimaryTabs.filter((tab) => tab.alwaysShow || tab.enabled);

View file

@ -92,7 +92,7 @@ describe('App architecture', () => {
"tooltip: 'VMware vSphere hosts, virtual machines, datastores, and networks'",
);
expect(appLayoutSource).toContain(
"tooltip: 'Pulse Agent managed machines, OS telemetry, storage, and command eligibility'",
"tooltip: 'Standalone Pulse Agent machines, OS telemetry, storage, and command eligibility'",
);
// Governed platform/runtime primary nav: Infrastructure / Workloads /
// Storage / Recovery are not duplicated as equal primary tab

View file

@ -102,6 +102,11 @@ describe('CommandPaletteModal', () => {
expect(screen.getByText('Go to vSphere')).toBeInTheDocument();
expect(screen.getByText('Go to vSphere Networks')).toBeInTheDocument();
expect(screen.getByText('/vmware/networks')).toBeInTheDocument();
const commandLabels = screen.getAllByRole('button').map((button) => button.textContent ?? '');
expect(commandLabels.findIndex((label) => label.includes('Go to Proxmox'))).toBeLessThan(
commandLabels.findIndex((label) => label.includes('Go to Agents')),
);
});
it('navigates to the Kubernetes pods sub-tab', async () => {

View file

@ -15,6 +15,7 @@ window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
const InfrastructureIcon: Component<{ class?: string }> = (props) => (
<span class={props.class}>IN</span>
);
const AgentsIcon: Component<{ class?: string }> = (props) => <span class={props.class}>AG</span>;
const ProxmoxIcon: Component<{ class?: string }> = (props) => <span class={props.class}>PX</span>;
const StorageIcon: Component<{ class?: string }> = (props) => <span class={props.class}>ST</span>;
const AlertsIcon: Component<{ class?: string }> = (props) => <span class={props.class}>AL</span>;
@ -134,6 +135,17 @@ describe('MobileNavBar', () => {
<MobileNavBar
activeTab={() => 'infrastructure'}
primaryTabs={() => [
{
id: 'agents',
label: 'Agents',
route: '/agents/overview',
settingsRoute: '/settings/infrastructure',
tooltip: 'Agents',
enabled: true,
live: true,
icon: AgentsIcon,
alwaysShow: true,
},
{
id: 'proxmox',
label: 'Proxmox',
@ -204,10 +216,11 @@ describe('MobileNavBar', () => {
const buttons = container.querySelectorAll('button[data-tab-id]');
expect(buttons[0]).toHaveAttribute('data-tab-id', 'proxmox');
expect(buttons[1]).toHaveAttribute('data-tab-id', 'infrastructure');
expect(buttons[2]).toHaveAttribute('data-tab-id', 'storage');
expect(buttons[3]).toHaveAttribute('data-tab-id', 'alerts');
expect(buttons[4]).toHaveAttribute('data-tab-id', 'settings');
expect(buttons[1]).toHaveAttribute('data-tab-id', 'agents');
expect(buttons[2]).toHaveAttribute('data-tab-id', 'infrastructure');
expect(buttons[3]).toHaveAttribute('data-tab-id', 'storage');
expect(buttons[4]).toHaveAttribute('data-tab-id', 'alerts');
expect(buttons[5]).toHaveAttribute('data-tab-id', 'settings');
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();

View file

@ -36,17 +36,6 @@ export function buildCommandPaletteCommands(options: {
}): CommandPaletteModalCommand[] {
const commands: CommandPaletteModalCommand[] = [];
if (primaryInfrastructureNavigationIsVisible(options.infrastructureVisibility, 'agents')) {
commands.push({
id: 'nav-agents',
label: 'Go to Agents',
description: options.paths.agentsPath,
shortcut: 'g e',
keywords: ['agents', 'hosts', 'machines', 'linux', 'macos', 'windows', 'unraid'],
action: () => options.navigate(options.paths.agentsPath),
});
}
if (primaryInfrastructureNavigationIsVisible(options.infrastructureVisibility, 'proxmox')) {
commands.push({
id: 'nav-proxmox',
@ -120,6 +109,17 @@ export function buildCommandPaletteCommands(options: {
);
}
if (primaryInfrastructureNavigationIsVisible(options.infrastructureVisibility, 'agents')) {
commands.push({
id: 'nav-agents',
label: 'Go to Agents',
description: options.paths.agentsPath,
shortcut: 'g e',
keywords: ['agents', 'hosts', 'machines', 'linux', 'macos', 'windows', 'unraid'],
action: () => options.navigate(options.paths.agentsPath),
});
}
commands.push(
{
id: 'nav-alerts',

View file

@ -35,12 +35,12 @@ export type MobileNavBarProps = {
};
const MOBILE_NAV_PRIMARY_PRIORITY = [
'agents',
'proxmox',
'docker',
'kubernetes',
'truenas',
'vmware',
'agents',
] as const;
const MOBILE_NAV_UTILITY_PRIORITY = ['alerts', 'ai', 'settings'] as const;

View file

@ -146,13 +146,13 @@ export const AgentsMachinesTable: Component<{
<PlatformTableToolbar
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search agent machines"
searchPlaceholder="Search standalone machines"
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
visible={tableState.visible()}
total={tableState.total()}
rowNoun="machines"
rowNoun="standalone machines"
/>
<Show
@ -161,7 +161,7 @@ export const AgentsMachinesTable: Component<{
<PlatformTableEmptyState
icon={props.emptyIcon}
title="No machines match current filters"
description="Adjust the search or status filter to see more agent-primary machines."
description="Adjust the search or status filter to see more standalone Pulse Agent machines."
/>
}
>

View file

@ -70,8 +70,8 @@ export function AgentsPageSurface() {
fallback={
<PlatformTableEmptyState
icon={agentsIcon()}
title="No Pulse Agent machines"
description="Install the Pulse Agent on Linux, macOS, Windows, Unraid, or another host to populate this platform page."
title="No standalone Pulse Agent machines"
description="Install the Pulse Agent on Linux, macOS, Windows, or Unraid systems that are not already represented by a platform integration."
actions={
<button
type="button"
@ -88,8 +88,8 @@ export function AgentsPageSurface() {
<AgentsMachinesTable
resources={model().resources}
emptyIcon={agentsIcon()}
emptyTitle="No Pulse Agent machines"
emptyDescription="Install the Pulse Agent on Linux, macOS, Windows, Unraid, or another host to populate this platform page."
emptyTitle="No standalone Pulse Agent machines"
emptyDescription="Install the Pulse Agent on Linux, macOS, Windows, or Unraid systems that are not already represented by a platform integration."
/>
</Show>
</Show>

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource';
import { buildAgentsPageFilterModel, buildAgentsPageModel } from '../agentsPageModel';
import { buildAgentsPageModel } from '../agentsPageModel';
const resource = (overrides: Partial<Resource>): Resource =>
({
@ -17,7 +17,7 @@ const resource = (overrides: Partial<Resource>): Resource =>
}) as Resource;
describe('agentsPageModel', () => {
it('projects agent-primary machine resources without admitting provider-owned host rows', () => {
it('projects standalone agent machine resources without admitting provider-owned host rows', () => {
const model = buildAgentsPageModel([
resource({ id: 'mac-mini', platformType: 'agent', type: 'agent', sources: ['agent'] }),
resource({
@ -63,28 +63,4 @@ describe('agentsPageModel', () => {
expect(model.resources.map((item) => item.id)).toEqual(['legacy-agent']);
});
it('filters agent resources by status and local identity search', () => {
const resources = [
resource({
id: 'agent-mac',
displayName: 'Studio Mac',
status: 'online',
identity: { hostname: 'studio-mac.local', ips: ['10.0.0.12'] },
}),
resource({
id: 'agent-win',
displayName: 'Windows Bench',
status: 'offline',
identity: { hostname: 'bench.local', ips: ['10.0.0.20'] },
}),
];
expect(buildAgentsPageFilterModel(resources, 'offline', '').filteredResources).toEqual([
resources[1],
]);
expect(buildAgentsPageFilterModel(resources, '', 'studio 10.0.0.12').filteredResources).toEqual(
[resources[0]],
);
});
});

View file

@ -1,24 +1,10 @@
import type { Resource } from '@/types/resource';
import {
buildStatusOptions,
collectAvailableStatuses,
filterResources,
tokenizeSearch,
} from '@/components/Infrastructure/infrastructureSelectors';
import { isPulseAgentPlatformResource } from '@/utils/agentResources';
export interface AgentsPageModel {
resources: Resource[];
}
export interface AgentsPageFilterModel {
activeFilterCount: number;
statusOptions: Array<{ key: string; label: string }>;
hasActiveFilters: boolean;
filteredResources: Resource[];
hasFilteredResources: boolean;
}
export const isAgentsPageResource = (resource: Resource): boolean =>
isPulseAgentPlatformResource(resource);
@ -27,26 +13,3 @@ export function buildAgentsPageModel(resources: readonly Resource[]): AgentsPage
resources: resources.filter(isAgentsPageResource),
};
}
export function buildAgentsPageFilterModel(
resources: Resource[],
selectedStatus: string,
searchQuery: string,
): AgentsPageFilterModel {
const normalizedStatus = selectedStatus.trim().toLowerCase();
const searchTerms = tokenizeSearch(searchQuery);
const filteredResources = filterResources(
resources,
new Set(),
normalizedStatus ? new Set([normalizedStatus]) : new Set(),
searchTerms,
);
return {
activeFilterCount: (normalizedStatus ? 1 : 0) + (searchTerms.length > 0 ? 1 : 0),
statusOptions: buildStatusOptions(collectAvailableStatuses(resources)),
hasActiveFilters: Boolean(normalizedStatus) || searchTerms.length > 0,
filteredResources,
hasFilteredResources: filteredResources.length > 0,
};
}

View file

@ -170,7 +170,7 @@ describe('infrastructureNavigationModel', () => {
).toBe(false);
});
it('selects the first visible platform using the canonical primary navigation order', () => {
it('selects provider platforms before Agents using the canonical primary navigation order', () => {
expect(
selectFirstVisiblePrimaryInfrastructureNavigationId({
agents: true,
@ -180,7 +180,7 @@ describe('infrastructureNavigationModel', () => {
truenas: false,
vmware: false,
}),
).toBe('agents');
).toBe('proxmox');
expect(
selectFirstVisiblePrimaryInfrastructureNavigationId({
@ -193,6 +193,17 @@ describe('infrastructureNavigationModel', () => {
}),
).toBe('kubernetes');
expect(
selectFirstVisiblePrimaryInfrastructureNavigationId({
agents: true,
proxmox: false,
docker: false,
kubernetes: false,
truenas: false,
vmware: false,
}),
).toBe('agents');
expect(
selectFirstVisiblePrimaryInfrastructureNavigationId({
agents: false,

View file

@ -25,12 +25,12 @@ export type InfrastructureNavigationShortcut = {
};
export const PRIMARY_INFRASTRUCTURE_NAV_IDS: readonly PrimaryInfrastructureNavId[] = [
'agents',
'proxmox',
'docker',
'kubernetes',
'truenas',
'vmware',
'agents',
] as const;
export const PRIMARY_INFRASTRUCTURE_NAV_SCOPE_IDS: Record<

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import agentsMachinesTableSource from '@/features/agents/AgentsMachinesTable.tsx?raw';
import agentsPageModelSource from '@/features/agents/agentsPageModel.ts?raw';
import agentsPageSurfaceSource from '@/features/agents/AgentsPageSurface.tsx?raw';
import dockerHostsTableSource from '@/features/docker/DockerHostsTable.tsx?raw';
import dockerPageSurfaceSource from '@/features/docker/DockerPageSurface.tsx?raw';
@ -221,6 +222,8 @@ describe('platform overview layout guardrails', () => {
expect(agentsPageSurfaceSource).not.toContain('UnifiedResourceTable');
expect(agentsMachinesTableSource).toContain('PlatformResourceDetailTableRow');
expect(agentsMachinesTableSource).not.toContain('ResourceDetailDrawer');
expect(agentsPageModelSource).not.toContain('infrastructureSelectors');
expect(agentsPageModelSource).not.toContain('buildAgentsPageFilterModel');
});
it('keeps TrueNAS overview inventory in tables instead of summary cards', () => {

View file

@ -10,8 +10,8 @@ describe('route preloading', () => {
it('keeps all cold top-level app shell targets in the shared preload set', () => {
expect([...APP_SHELL_ROUTE_PRELOAD_PATHS]).toEqual([
buildAgentsPath(),
buildProxmoxPath(),
buildAgentsPath(),
PATROL_PATH,
'/alerts',
'/settings',

View file

@ -23,8 +23,8 @@ const SETTINGS_PATH = '/settings';
const routePreloadCache = new Map<string, Promise<void>>();
export const APP_SHELL_ROUTE_PRELOAD_PATHS = [
ROOT_AGENTS_PATH,
ROOT_PROXMOX_PATH,
ROOT_AGENTS_PATH,
PATROL_PATH,
ALERTS_PATH,
SETTINGS_PATH,