diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index c9ef4c7ae..4a111a893 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -94,6 +94,15 @@ The subsystem registry now also requires explicit proof-policy coverage for all
shared runtime files, and shared-component guardrails fail if raw table
composition is reintroduced in new shared components outside the canonical
allowlist.
+Top-level route files are now also expected to stay thin when a feature owns
+the real product surface. `frontend-modern/src/pages/Infrastructure.tsx` now
+acts only as the route boundary, while
+`frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx`
+and `frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts`
+own the actual infrastructure page shell and state contract. Future feature
+surfaces under `frontend-modern/src/features/` should follow that same pattern
+instead of letting page files accumulate route sync, filter, and modal
+orchestration inline.
Infrastructure summary and detail surfaces now also use the shared normalized
identity lookup helper from `frontend-modern/src/utils/resourceIdentity.ts`
so dotted hostnames and alias variants stay consistent between the shared
diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json
index c400c27a9..7c25beb43 100644
--- a/docs/release-control/v6/internal/subsystems/registry.json
+++ b/docs/release-control/v6/internal/subsystems/registry.json
@@ -2499,6 +2499,8 @@
"frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx",
"frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts",
+ "frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx",
+ "frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts",
"frontend-modern/src/hooks/useDashboardTrends.ts",
"frontend-modern/src/hooks/useUnifiedResources.ts",
"frontend-modern/src/pages/Infrastructure.tsx",
@@ -2568,6 +2570,8 @@
"frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx",
"frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts",
+ "frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx",
+ "frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts",
"frontend-modern/src/hooks/useDashboardTrends.ts",
"frontend-modern/src/hooks/useUnifiedResources.ts",
"frontend-modern/src/pages/Infrastructure.tsx",
diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md
index 518e9e7dd..913c4f186 100644
--- a/docs/release-control/v6/internal/subsystems/unified-resources.md
+++ b/docs/release-control/v6/internal/subsystems/unified-resources.md
@@ -46,6 +46,8 @@ cross-source deduplication.
24. `frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx`
25. `frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx`
26. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts`
+27. `frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx`
+28. `frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts`
## Shared Boundaries
@@ -708,10 +710,16 @@ strings, but it must not widen the canonical unified-resource source/status
contracts that feed the infrastructure table and workload links.
The same source-filter boundary now also applies to infrastructure filter UI
-options: `frontend-modern/src/pages/Infrastructure.tsx` may render friendly
-string keys, but membership checks against available sources must normalize
-through the shared `frontend-modern/src/utils/sourcePlatforms.ts` helper
-before consulting `KnownSourcePlatform` sets.
+options: `frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx`
+and `frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts`
+may render friendly string keys, but membership checks against available
+sources must normalize through the shared
+`frontend-modern/src/utils/sourcePlatforms.ts` helper before consulting
+`KnownSourcePlatform` sets.
+The route file `frontend-modern/src/pages/Infrastructure.tsx` is now only the
+navigation boundary for that surface; canonical infrastructure filter, search,
+deep-link, and expansion state now live behind the dedicated infrastructure
+feature owner instead of accumulating in the page shell itself.
Shared unified-resource consumers now also normalize org scope through
`frontend-modern/src/utils/orgScope.ts` before building cache keys or
multi-tenant resource fetch state, so the canonical resource hooks do not
diff --git a/frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx b/frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx
new file mode 100644
index 000000000..78e486625
--- /dev/null
+++ b/frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx
@@ -0,0 +1,338 @@
+import { For, Show } from 'solid-js';
+import { EmptyState } from '@/components/shared/EmptyState';
+import { Card } from '@/components/shared/Card';
+import { FilterSegmentedControl, LabeledFilterSelect } from '@/components/shared/FilterToolbar';
+import { PageControls } from '@/components/shared/PageControls';
+import { SearchInput } from '@/components/shared/SearchInput';
+import { UnifiedResourceTable } from '@/components/Infrastructure/UnifiedResourceTable';
+import { InfrastructureSummary } from '@/components/Infrastructure/InfrastructureSummary';
+import ServerIcon from 'lucide-solid/icons/server';
+import RefreshCwIcon from 'lucide-solid/icons/refresh-cw';
+import SettingsIcon from 'lucide-solid/icons/settings';
+import { ScrollToTopButton } from '@/components/shared/ScrollToTopButton';
+import { STORAGE_KEYS } from '@/utils/localStorage';
+import { AgentDeployModal } from '@/components/Infrastructure/AgentDeployModal';
+import { DEFAULT_INFRASTRUCTURE_SOURCE_OPTIONS } from '@/utils/sourcePlatformOptions';
+import { normalizeSourcePlatformKey } from '@/utils/sourcePlatforms';
+import {
+ getInfrastructureEmptyState,
+ getInfrastructureFilterEmptyState,
+ getInfrastructureLoadFailureState,
+} from '@/utils/infrastructureEmptyStatePresentation';
+import { useInfrastructurePageState, type GroupingMode } from './useInfrastructurePageState';
+
+export function InfrastructurePageSurface() {
+ const sourceOptions = DEFAULT_INFRASTRUCTURE_SOURCE_OPTIONS;
+ const {
+ loading,
+ error,
+ refetch,
+ initialLoadComplete,
+ showNoResources,
+ selectedSource,
+ setSelectedSource,
+ selectedStatus,
+ setSelectedStatus,
+ searchQuery,
+ setSearchQuery,
+ infrastructureSummaryRange,
+ setInfrastructureSummaryRange,
+ summaryCollapsed,
+ setSummaryCollapsed,
+ groupingMode,
+ setGroupingMode,
+ expandedResourceId,
+ setExpandedResourceId,
+ hoveredResourceId,
+ setHoveredResourceId,
+ highlightedResourceId,
+ isMobile,
+ deployCluster,
+ setDeployCluster,
+ filtersOpen,
+ setFiltersOpen,
+ activeFilterCount,
+ kioskMode,
+ availableSources,
+ statusOptions,
+ hasActiveFilters,
+ clearFilters,
+ filteredResources,
+ hasFilteredResources,
+ handleNavigateToSettings,
+ } = useInfrastructurePageState();
+
+ const infrastructureEmptyState = () => getInfrastructureEmptyState();
+ const infrastructureFilterEmptyState = () => getInfrastructureFilterEmptyState();
+ const infrastructureLoadFailureState = () => getInfrastructureLoadFailureState();
+
+ return (
+
+ }
+ >
+
+ }
+ title={infrastructureLoadFailureState().title}
+ description={infrastructureLoadFailureState().description}
+ actions={
+ refetch()}
+ class="inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium text-base-content shadow-sm hover:"
+ >
+
+ {infrastructureLoadFailureState().actionLabel}
+
+ }
+ />
+
+ }
+ >
+
+ }
+ title={infrastructureEmptyState().title}
+ description={infrastructureEmptyState().description}
+ actions={
+
+
+ {infrastructureEmptyState().actionLabel}
+
+ }
+ />
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+ }
+ mobileFilters={{
+ enabled: isMobile(),
+ onToggle: () => setFiltersOpen((o) => !o),
+ count: activeFilterCount(),
+ }}
+ resetAction={{
+ show: hasActiveFilters(),
+ onClick: clearFilters,
+ label: 'Clear',
+ class: 'ml-auto text-base-content',
+ }}
+ showFilters={!isMobile() || filtersOpen()}
+ toolbarClass="lg:flex-nowrap"
+ >
+ setSelectedSource(e.currentTarget.value)}
+ selectClass="min-w-[8rem]"
+ >
+ All
+ {
+ const normalized = normalizeSourcePlatformKey(source.key);
+ return normalized ? availableSources().has(normalized) : false;
+ })}
+ >
+ {(source) => {source.label} }
+
+
+
+ setSelectedStatus(e.currentTarget.value)}
+ selectClass="min-w-[7rem]"
+ >
+ All
+
+ {(status) => {status.label} }
+
+
+
+ setGroupingMode(value as GroupingMode)}
+ aria-label="Group By"
+ options={[
+ {
+ value: 'grouped',
+ title: 'Group by cluster',
+ label: (
+ <>
+
+
+
+ Grouped
+ >
+ ),
+ },
+ {
+ value: 'flat',
+ title: 'Flat list view',
+ label: (
+ <>
+
+
+
+
+
+
+
+
+ List
+ >
+ ),
+ },
+ ]}
+ />
+
+ setSummaryCollapsed((c) => !c)}
+ aria-label="Charts"
+ options={[
+ {
+ value: 'shown',
+ title: summaryCollapsed() ? 'Show charts' : 'Hide charts',
+ label: (
+ <>
+
+
+
+ Charts
+ >
+ ),
+ },
+ ]}
+ />
+
+
+
+
+
+ }
+ title={infrastructureFilterEmptyState().title}
+ description={infrastructureFilterEmptyState().description}
+ actions={
+
+
+ {infrastructureFilterEmptyState().actionLabel}
+
+
+ }
+ />
+
+ }
+ >
+ setDeployCluster({ id, name })}
+ />
+
+
+
+
+
+
+ {(cluster) => (
+ setDeployCluster(null)}
+ />
+ )}
+
+
+
+ );
+}
+
+export default InfrastructurePageSurface;
diff --git a/frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts b/frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts
new file mode 100644
index 000000000..5b561229a
--- /dev/null
+++ b/frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts
@@ -0,0 +1,281 @@
+import { createEffect, createMemo, createSignal, onCleanup, untrack } from 'solid-js';
+import { useLocation, useNavigate } from '@solidjs/router';
+import type { TimeRange } from '@/api/charts';
+import { useUnifiedResources } from '@/hooks/useUnifiedResources';
+import { usePersistentSignal } from '@/hooks/usePersistentSignal';
+import { useBreakpoint } from '@/hooks/useBreakpoint';
+import { STORAGE_KEYS } from '@/utils/localStorage';
+import { useKioskMode } from '@/hooks/useKioskMode';
+import { isSummaryTimeRange } from '@/components/shared/summaryTimeRange';
+import {
+ tokenizeSearch,
+ filterResources,
+ collectAvailableSources,
+ collectAvailableStatuses,
+ buildStatusOptions,
+} from '@/components/Infrastructure/infrastructureSelectors';
+import { normalizeSourcePlatformKey } from '@/utils/sourcePlatforms';
+import {
+ buildInfrastructurePath,
+ INFRASTRUCTURE_PATH,
+ INFRASTRUCTURE_QUERY_PARAMS,
+ parseInfrastructureLinkSearch,
+} from '@/routing/resourceLinks';
+import { areSearchParamsEquivalent } from '@/utils/searchParams';
+
+export type GroupingMode = 'grouped' | 'flat';
+
+type DeployCluster = {
+ id: string;
+ name: string;
+};
+
+export function useInfrastructurePageState() {
+ const { resources, loading, error, refetch } = useUnifiedResources();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const kioskMode = useKioskMode();
+ const { isMobile } = useBreakpoint();
+
+ const [initialLoadComplete, setInitialLoadComplete] = createSignal(false);
+ const [selectedSource, setSelectedSource] = createSignal('');
+ const [selectedStatus, setSelectedStatus] = createSignal('');
+ const [searchQuery, setSearchQuery] = createSignal('');
+ const [infrastructureSummaryRange, setInfrastructureSummaryRange] =
+ usePersistentSignal(STORAGE_KEYS.INFRASTRUCTURE_SUMMARY_RANGE, '1h', {
+ deserialize: (raw) => (isSummaryTimeRange(raw) ? raw : '1h'),
+ });
+ const [summaryCollapsed, setSummaryCollapsed] = usePersistentSignal(
+ STORAGE_KEYS.INFRASTRUCTURE_SUMMARY_COLLAPSED,
+ false,
+ { deserialize: (raw) => raw === 'true' },
+ );
+ const [groupingMode, setGroupingMode] = usePersistentSignal(
+ 'infrastructureGroupingMode',
+ 'grouped',
+ { deserialize: (raw) => (raw === 'grouped' || raw === 'flat' ? raw : 'grouped') },
+ );
+ const [expandedResourceId, setExpandedResourceId] = createSignal(null);
+ const [hoveredResourceId, setHoveredResourceId] = createSignal(null);
+ const [highlightedResourceId, setHighlightedResourceId] = createSignal(null);
+ const [handledResourceId, setHandledResourceId] = createSignal(null);
+ const [handledSourceParam, setHandledSourceParam] = createSignal(null);
+ const [handledQueryParam, setHandledQueryParam] = createSignal('');
+ const [deployCluster, setDeployCluster] = createSignal(null);
+ const [filtersOpen, setFiltersOpen] = createSignal(false);
+
+ const hasResources = createMemo(() => resources().length > 0);
+ const showNoResources = createMemo(() => initialLoadComplete() && !hasResources() && !error());
+ const activeFilterCount = createMemo(
+ () => (selectedSource() !== '' ? 1 : 0) + (selectedStatus() !== '' ? 1 : 0),
+ );
+ const availableSources = createMemo(() => collectAvailableSources(resources()));
+ const availableStatuses = createMemo(() => collectAvailableStatuses(resources()));
+ const statusOptions = createMemo(() => buildStatusOptions(availableStatuses()));
+ const hasActiveFilters = createMemo(
+ () => selectedSource() !== '' || selectedStatus() !== '' || searchQuery().trim().length > 0,
+ );
+ const searchTerms = createMemo(() => tokenizeSearch(searchQuery()));
+ const filteredResources = createMemo(() =>
+ filterResources(
+ resources(),
+ selectedSource() !== '' ? new Set([selectedSource()]) : new Set(),
+ selectedStatus() !== '' ? new Set([selectedStatus()]) : new Set(),
+ searchTerms(),
+ ),
+ );
+ const hasFilteredResources = createMemo(() => filteredResources().length > 0);
+
+ let highlightTimer: number | undefined;
+ let pendingUrlSyncHandle: number | null = null;
+ let pendingUrlSyncPath: string | null = null;
+
+ const scheduleUrlSyncNavigate = (nextPath: string) => {
+ pendingUrlSyncPath = nextPath;
+ if (pendingUrlSyncHandle !== null) return;
+ pendingUrlSyncHandle = window.setTimeout(() => {
+ pendingUrlSyncHandle = null;
+ const target = pendingUrlSyncPath;
+ pendingUrlSyncPath = null;
+ if (!target) return;
+ const current = `${untrack(() => location.pathname)}${untrack(() => location.search)}`;
+ if (current === target) return;
+ navigate(target, { replace: true });
+ }, 0);
+ };
+
+ const clearFilters = () => {
+ setSelectedSource('');
+ setSelectedStatus('');
+ setSearchQuery('');
+ };
+
+ const handleNavigateToSettings = () => navigate('/settings');
+
+ createEffect(() => {
+ if (!loading() && !initialLoadComplete()) {
+ setInitialLoadComplete(true);
+ }
+ });
+
+ createEffect(() => {
+ const { resource: resourceId } = parseInfrastructureLinkSearch(location.search);
+ if (!resourceId || resourceId === handledResourceId()) return;
+ const matching = resources().some((resource) => resource.id === resourceId);
+ if (!matching) return;
+ setExpandedResourceId(resourceId);
+ setHighlightedResourceId(resourceId);
+ setHandledResourceId(resourceId);
+ if (highlightTimer) {
+ window.clearTimeout(highlightTimer);
+ }
+ highlightTimer = window.setTimeout(() => {
+ setHighlightedResourceId(null);
+ }, 2000);
+ });
+
+ createEffect(() => {
+ const { resource: resourceId } = parseInfrastructureLinkSearch(location.search);
+ if (resourceId) return;
+ if (handledResourceId() === null) return;
+
+ if (expandedResourceId() !== null) {
+ setExpandedResourceId(null);
+ }
+ if (highlightedResourceId() !== null) {
+ setHighlightedResourceId(null);
+ }
+ setHandledResourceId(null);
+ });
+
+ createEffect(() => {
+ const { source: sourceParam } = parseInfrastructureLinkSearch(location.search);
+ if (!sourceParam) {
+ const previous = (handledSourceParam() ?? '').trim();
+ if (previous) {
+ if (selectedSource() !== '') setSelectedSource('');
+ setHandledSourceParam('');
+ } else if (handledSourceParam() === null) {
+ setHandledSourceParam('');
+ }
+ return;
+ }
+ if (sourceParam === handledSourceParam()) return;
+ const normalized = normalizeSourcePlatformKey(sourceParam) ?? '';
+ setSelectedSource(normalized);
+ setHandledSourceParam(sourceParam);
+ });
+
+ createEffect(() => {
+ const { query: nextSearch } = parseInfrastructureLinkSearch(location.search);
+ const normalized = nextSearch ?? '';
+ if (normalized !== handledQueryParam()) {
+ if (normalized !== untrack(searchQuery)) {
+ setSearchQuery(normalized);
+ }
+ setHandledQueryParam(normalized);
+ }
+ });
+
+ createEffect(() => {
+ if (location.pathname !== INFRASTRUCTURE_PATH) return;
+
+ const parsed = parseInfrastructureLinkSearch(location.search);
+ const urlSource = parsed.source ?? '';
+ const urlQuery = parsed.query ?? '';
+ const urlResource = parsed.resource ?? '';
+ if ((handledSourceParam() ?? '') !== urlSource) return;
+ if (handledQueryParam() !== urlQuery) return;
+ if (urlResource && handledResourceId() !== urlResource && !initialLoadComplete()) return;
+
+ const nextSource = selectedSource();
+ const nextQuery = searchQuery().trim();
+ const currentLinkedResource = parsed.resource;
+ const selectedResourceId = expandedResourceId();
+ const shouldPreserveIncomingResource =
+ !selectedResourceId && Boolean(currentLinkedResource) && !initialLoadComplete();
+ const nextResource = shouldPreserveIncomingResource
+ ? currentLinkedResource
+ : (selectedResourceId ?? '');
+
+ const managedPath = buildInfrastructurePath({
+ source: nextSource || null,
+ query: nextQuery || null,
+ resource: nextResource || null,
+ });
+ const managedUrl = new URL(managedPath, 'http://pulse.local');
+ const currentParams = new URLSearchParams(location.search);
+ const nextParams = new URLSearchParams(location.search);
+ nextParams.delete(INFRASTRUCTURE_QUERY_PARAMS.source);
+ nextParams.delete(INFRASTRUCTURE_QUERY_PARAMS.query);
+ nextParams.delete(INFRASTRUCTURE_QUERY_PARAMS.resource);
+ managedUrl.searchParams.forEach((value, key) => {
+ nextParams.set(key, value);
+ });
+
+ if (!areSearchParamsEquivalent(currentParams, nextParams)) {
+ const nextSearch = nextParams.toString();
+ const nextPath = nextSearch ? `${INFRASTRUCTURE_PATH}?${nextSearch}` : INFRASTRUCTURE_PATH;
+ scheduleUrlSyncNavigate(nextPath);
+ }
+ });
+
+ createEffect(() => {
+ const hoveredId = hoveredResourceId();
+ if (!hoveredId) return;
+ const exists = filteredResources().some((resource) => resource.id === hoveredId);
+ if (!exists) {
+ setHoveredResourceId(null);
+ }
+ });
+
+ onCleanup(() => {
+ if (pendingUrlSyncHandle !== null) {
+ window.clearTimeout(pendingUrlSyncHandle);
+ pendingUrlSyncHandle = null;
+ pendingUrlSyncPath = null;
+ }
+ if (highlightTimer) {
+ window.clearTimeout(highlightTimer);
+ }
+ });
+
+ return {
+ loading,
+ error,
+ refetch,
+ initialLoadComplete,
+ showNoResources,
+ selectedSource,
+ setSelectedSource,
+ selectedStatus,
+ setSelectedStatus,
+ searchQuery,
+ setSearchQuery,
+ infrastructureSummaryRange,
+ setInfrastructureSummaryRange,
+ summaryCollapsed,
+ setSummaryCollapsed,
+ groupingMode,
+ setGroupingMode,
+ expandedResourceId,
+ setExpandedResourceId,
+ hoveredResourceId,
+ setHoveredResourceId,
+ highlightedResourceId,
+ isMobile,
+ deployCluster,
+ setDeployCluster,
+ filtersOpen,
+ setFiltersOpen,
+ activeFilterCount,
+ kioskMode,
+ availableSources,
+ statusOptions,
+ hasActiveFilters,
+ clearFilters,
+ filteredResources,
+ hasFilteredResources,
+ handleNavigateToSettings,
+ };
+}
diff --git a/frontend-modern/src/pages/Infrastructure.tsx b/frontend-modern/src/pages/Infrastructure.tsx
index 7c3457700..9d24808f2 100644
--- a/frontend-modern/src/pages/Infrastructure.tsx
+++ b/frontend-modern/src/pages/Infrastructure.tsx
@@ -1,547 +1,7 @@
-import { For, Show, createEffect, createMemo, createSignal, onCleanup, untrack } from 'solid-js';
-import { useLocation, useNavigate } from '@solidjs/router';
-import { EmptyState } from '@/components/shared/EmptyState';
-import { Card } from '@/components/shared/Card';
-import {
- FilterSegmentedControl,
- LabeledFilterSelect,
-} from '@/components/shared/FilterToolbar';
-import { PageControls } from '@/components/shared/PageControls';
-import { SearchInput } from '@/components/shared/SearchInput';
-import { useUnifiedResources } from '@/hooks/useUnifiedResources';
-import { UnifiedResourceTable } from '@/components/Infrastructure/UnifiedResourceTable';
-import { InfrastructureSummary } from '@/components/Infrastructure/InfrastructureSummary';
-import { usePersistentSignal } from '@/hooks/usePersistentSignal';
-import type { TimeRange } from '@/api/charts';
-import ServerIcon from 'lucide-solid/icons/server';
-import RefreshCwIcon from 'lucide-solid/icons/refresh-cw';
-import SettingsIcon from 'lucide-solid/icons/settings';
-import { useBreakpoint } from '@/hooks/useBreakpoint';
-import { ScrollToTopButton } from '@/components/shared/ScrollToTopButton';
-import { STORAGE_KEYS } from '@/utils/localStorage';
-import { useKioskMode } from '@/hooks/useKioskMode';
-import { AgentDeployModal } from '@/components/Infrastructure/AgentDeployModal';
-import { isSummaryTimeRange } from '@/components/shared/summaryTimeRange';
-import {
- tokenizeSearch,
- filterResources,
- collectAvailableSources,
- collectAvailableStatuses,
- buildStatusOptions,
-} from '@/components/Infrastructure/infrastructureSelectors';
-import { DEFAULT_INFRASTRUCTURE_SOURCE_OPTIONS } from '@/utils/sourcePlatformOptions';
-import { normalizeSourcePlatformKey } from '@/utils/sourcePlatforms';
-import {
- buildInfrastructurePath,
- INFRASTRUCTURE_PATH,
- INFRASTRUCTURE_QUERY_PARAMS,
- parseInfrastructureLinkSearch,
-} from '@/routing/resourceLinks';
-import { areSearchParamsEquivalent } from '@/utils/searchParams';
-import {
- getInfrastructureEmptyState,
- getInfrastructureFilterEmptyState,
- getInfrastructureLoadFailureState,
-} from '@/utils/infrastructureEmptyStatePresentation';
+import { InfrastructurePageSurface } from '@/features/infrastructure/InfrastructurePageSurface';
export function Infrastructure() {
- const { resources, loading, error, refetch } = useUnifiedResources();
- const location = useLocation();
- const navigate = useNavigate();
-
- const kioskMode = useKioskMode();
-
- // Track if we've completed initial load to prevent flash of empty state
- const [initialLoadComplete, setInitialLoadComplete] = createSignal(false);
- createEffect(() => {
- if (!loading() && !initialLoadComplete()) {
- setInitialLoadComplete(true);
- }
- });
-
- const hasResources = createMemo(() => resources().length > 0);
- // Only show "no resources" after initial load completes with zero results
- const showNoResources = createMemo(() => initialLoadComplete() && !hasResources() && !error());
- const infrastructureEmptyState = createMemo(() => getInfrastructureEmptyState());
- const infrastructureFilterEmptyState = createMemo(() => getInfrastructureFilterEmptyState());
- const infrastructureLoadFailureState = createMemo(() => getInfrastructureLoadFailureState());
- const [selectedSource, setSelectedSource] = createSignal('');
- const [selectedStatus, setSelectedStatus] = createSignal('');
- const [searchQuery, setSearchQuery] = createSignal('');
- const [infrastructureSummaryRange, setInfrastructureSummaryRange] =
- usePersistentSignal(STORAGE_KEYS.INFRASTRUCTURE_SUMMARY_RANGE, '1h', {
- deserialize: (raw) => (isSummaryTimeRange(raw) ? raw : '1h'),
- });
- const [summaryCollapsed, setSummaryCollapsed] = usePersistentSignal(
- STORAGE_KEYS.INFRASTRUCTURE_SUMMARY_COLLAPSED,
- false,
- { deserialize: (raw) => raw === 'true' },
- );
- type GroupingMode = 'grouped' | 'flat';
- const [groupingMode, setGroupingMode] = usePersistentSignal(
- 'infrastructureGroupingMode',
- 'grouped',
- { deserialize: (raw) => (raw === 'grouped' || raw === 'flat' ? raw : 'grouped') },
- );
- const [expandedResourceId, setExpandedResourceId] = createSignal(null);
- const [hoveredResourceId, setHoveredResourceId] = createSignal(null);
- const [highlightedResourceId, setHighlightedResourceId] = createSignal(null);
- const [handledResourceId, setHandledResourceId] = createSignal(null);
- const [handledSourceParam, setHandledSourceParam] = createSignal(null);
- const [handledQueryParam, setHandledQueryParam] = createSignal('');
- const { isMobile } = useBreakpoint();
- const [deployCluster, setDeployCluster] = createSignal<{ id: string; name: string } | null>(null);
- const [filtersOpen, setFiltersOpen] = createSignal(false);
- const activeFilterCount = createMemo(
- () => (selectedSource() !== '' ? 1 : 0) + (selectedStatus() !== '' ? 1 : 0),
- );
- let highlightTimer: number | undefined;
-
- // URL sync can require multiple reactive updates (normalizing source values,
- // preserving deep-links). Navigating synchronously
- // for each intermediate state can trigger Solid Router's redirect protection.
- // Coalesce URL sync into a single replace-navigation per tick.
- let pendingUrlSyncHandle: number | null = null;
- let pendingUrlSyncPath: string | null = null;
- const scheduleUrlSyncNavigate = (nextPath: string) => {
- pendingUrlSyncPath = nextPath;
- if (pendingUrlSyncHandle !== null) return;
- pendingUrlSyncHandle = window.setTimeout(() => {
- pendingUrlSyncHandle = null;
- const target = pendingUrlSyncPath;
- pendingUrlSyncPath = null;
- if (!target) return;
- const current = `${untrack(() => location.pathname)}${untrack(() => location.search)}`;
- if (current === target) return;
- navigate(target, { replace: true });
- }, 0);
- };
- const sourceOptions = DEFAULT_INFRASTRUCTURE_SOURCE_OPTIONS;
-
- createEffect(() => {
- const { resource: resourceId } = parseInfrastructureLinkSearch(location.search);
- if (!resourceId || resourceId === handledResourceId()) return;
- const matching = resources().some((resource) => resource.id === resourceId);
- if (!matching) return;
- setExpandedResourceId(resourceId);
- setHighlightedResourceId(resourceId);
- setHandledResourceId(resourceId);
- if (highlightTimer) {
- window.clearTimeout(highlightTimer);
- }
- highlightTimer = window.setTimeout(() => {
- setHighlightedResourceId(null);
- }, 2000);
- });
-
- createEffect(() => {
- const { resource: resourceId } = parseInfrastructureLinkSearch(location.search);
- if (resourceId) return;
-
- // Only treat "missing resource param" as a close signal if we've previously
- // handled a resource deep-link or written one into the URL. Otherwise, this
- // can fight user-driven opens before the URL-sync effect runs.
- if (handledResourceId() === null) return;
-
- if (expandedResourceId() !== null) {
- setExpandedResourceId(null);
- }
- if (highlightedResourceId() !== null) {
- setHighlightedResourceId(null);
- }
- setHandledResourceId(null);
- });
-
- createEffect(() => {
- const { source: sourceParam } = parseInfrastructureLinkSearch(location.search);
- if (!sourceParam) {
- const previous = (handledSourceParam() ?? '').trim();
- if (previous) {
- if (selectedSource() !== '') setSelectedSource('');
- setHandledSourceParam('');
- } else if (handledSourceParam() === null) {
- setHandledSourceParam('');
- }
- return;
- }
- if (sourceParam === handledSourceParam()) return;
- const normalized = normalizeSourcePlatformKey(sourceParam) ?? '';
- setSelectedSource(normalized);
- setHandledSourceParam(sourceParam);
- });
-
- createEffect(() => {
- const { query: nextSearch } = parseInfrastructureLinkSearch(location.search);
- const normalized = nextSearch ?? '';
- if (normalized !== handledQueryParam()) {
- if (normalized !== untrack(searchQuery)) {
- setSearchQuery(normalized);
- }
- setHandledQueryParam(normalized);
- }
- });
-
- createEffect(() => {
- if (location.pathname !== INFRASTRUCTURE_PATH) return;
-
- // Avoid oscillation: only write managed params after we've processed the current URL.
- const parsed = parseInfrastructureLinkSearch(location.search);
- const urlSource = parsed.source ?? '';
- const urlQuery = parsed.query ?? '';
- const urlResource = parsed.resource ?? '';
- if ((handledSourceParam() ?? '') !== urlSource) return;
- if (handledQueryParam() !== urlQuery) return;
- if (urlResource && handledResourceId() !== urlResource && !initialLoadComplete()) return;
-
- const nextSource = selectedSource();
- const nextQuery = searchQuery().trim();
- const currentLinkedResource = parseInfrastructureLinkSearch(location.search).resource;
- const selectedResourceId = expandedResourceId();
- const shouldPreserveIncomingResource =
- !selectedResourceId && Boolean(currentLinkedResource) && !initialLoadComplete();
- const nextResource = shouldPreserveIncomingResource
- ? currentLinkedResource
- : (selectedResourceId ?? '');
-
- const managedPath = buildInfrastructurePath({
- source: nextSource || null,
- query: nextQuery || null,
- resource: nextResource || null,
- });
- const managedUrl = new URL(managedPath, 'http://pulse.local');
- const currentParams = new URLSearchParams(location.search);
- const nextParams = new URLSearchParams(location.search);
- nextParams.delete(INFRASTRUCTURE_QUERY_PARAMS.source);
- nextParams.delete(INFRASTRUCTURE_QUERY_PARAMS.query);
- nextParams.delete(INFRASTRUCTURE_QUERY_PARAMS.resource);
- managedUrl.searchParams.forEach((value, key) => {
- nextParams.set(key, value);
- });
-
- if (!areSearchParamsEquivalent(currentParams, nextParams)) {
- const nextSearch = nextParams.toString();
- const nextPath = nextSearch ? `${INFRASTRUCTURE_PATH}?${nextSearch}` : INFRASTRUCTURE_PATH;
- scheduleUrlSyncNavigate(nextPath);
- }
- });
-
- onCleanup(() => {
- if (pendingUrlSyncHandle !== null) {
- window.clearTimeout(pendingUrlSyncHandle);
- pendingUrlSyncHandle = null;
- pendingUrlSyncPath = null;
- }
- if (highlightTimer) {
- window.clearTimeout(highlightTimer);
- }
- });
-
- const availableSources = createMemo(() => collectAvailableSources(resources()));
-
- const availableStatuses = createMemo(() => collectAvailableStatuses(resources()));
-
- const statusOptions = createMemo(() => buildStatusOptions(availableStatuses()));
-
- const hasActiveFilters = createMemo(
- () => selectedSource() !== '' || selectedStatus() !== '' || searchQuery().trim().length > 0,
- );
-
- const clearFilters = () => {
- setSelectedSource('');
- setSelectedStatus('');
- setSearchQuery('');
- };
-
- const searchTerms = createMemo(() => tokenizeSearch(searchQuery()));
-
- const filteredResources = createMemo(() =>
- filterResources(
- resources(),
- selectedSource() !== '' ? new Set([selectedSource()]) : new Set(),
- selectedStatus() !== '' ? new Set([selectedStatus()]) : new Set(),
- searchTerms(),
- ),
- );
-
- const hasFilteredResources = createMemo(() => filteredResources().length > 0);
-
- createEffect(() => {
- const hoveredId = hoveredResourceId();
- if (!hoveredId) return;
- const exists = filteredResources().some((resource) => resource.id === hoveredId);
- if (!exists) {
- setHoveredResourceId(null);
- }
- });
-
- return (
-
- }
- >
-
- }
- title={infrastructureLoadFailureState().title}
- description={infrastructureLoadFailureState().description}
- actions={
- refetch()}
- class="inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium text-base-content shadow-sm hover:"
- >
-
- {infrastructureLoadFailureState().actionLabel}
-
- }
- />
-
- }
- >
-
- }
- title={infrastructureEmptyState().title}
- description={infrastructureEmptyState().description}
- actions={
- navigate('/settings')}
- class="inline-flex items-center gap-2 rounded-md border border-border bg-surface px-3 py-1.5 text-xs font-medium text-base-content shadow-sm hover:bg-slate-50"
- >
-
- {infrastructureEmptyState().actionLabel}
-
- }
- />
-
- }
- >
-
-
-
-
-
-
-
-
-
-
- }
- mobileFilters={{
- enabled: isMobile(),
- onToggle: () => setFiltersOpen((o) => !o),
- count: activeFilterCount(),
- }}
- resetAction={{
- show: hasActiveFilters(),
- onClick: clearFilters,
- label: 'Clear',
- class: 'ml-auto text-base-content',
- }}
- showFilters={!isMobile() || filtersOpen()}
- toolbarClass="lg:flex-nowrap"
- >
- setSelectedSource(e.currentTarget.value)}
- selectClass="min-w-[8rem]"
- >
- All
- {
- const normalized = normalizeSourcePlatformKey(source.key);
- return normalized ? availableSources().has(normalized) : false;
- })}
- >
- {(source) => {source.label} }
-
-
-
- setSelectedStatus(e.currentTarget.value)}
- selectClass="min-w-[7rem]"
- >
- All
-
- {(status) => {status.label} }
-
-
-
- setGroupingMode(value as GroupingMode)}
- aria-label="Group By"
- options={[
- {
- value: 'grouped',
- title: 'Group by cluster',
- label: (
- <>
-
-
-
- Grouped
- >
- ),
- },
- {
- value: 'flat',
- title: 'Flat list view',
- label: (
- <>
-
-
-
-
-
-
-
-
- List
- >
- ),
- },
- ]}
- />
-
- setSummaryCollapsed((c) => !c)}
- aria-label="Charts"
- options={[
- {
- value: 'shown',
- title: summaryCollapsed() ? 'Show charts' : 'Hide charts',
- label: (
- <>
-
-
-
- Charts
- >
- ),
- },
- ]}
- />
-
-
-
-
-
-
- }
- title={infrastructureFilterEmptyState().title}
- description={infrastructureFilterEmptyState().description}
- actions={
-
-
- {infrastructureFilterEmptyState().actionLabel}
-
-
- }
- />
-
- }
- >
- setDeployCluster({ id, name })}
- />
-
-
-
-
-
-
- {(cluster) => (
- setDeployCluster(null)}
- />
- )}
-
-
-
- );
+ return ;
}
export default Infrastructure;
diff --git a/frontend-modern/src/pages/__tests__/Infrastructure.pbs-pmg.test.tsx b/frontend-modern/src/pages/__tests__/Infrastructure.pbs-pmg.test.tsx
index eb78a6f5a..a8900c604 100644
--- a/frontend-modern/src/pages/__tests__/Infrastructure.pbs-pmg.test.tsx
+++ b/frontend-modern/src/pages/__tests__/Infrastructure.pbs-pmg.test.tsx
@@ -99,6 +99,7 @@ describe('Infrastructure PBS/PMG integration', () => {
it('renders native PBS and PMG resources in infrastructure view', async () => {
const { getByTestId, getByText } = render(() => );
+ expect(getByTestId('infrastructure-page')).toBeInTheDocument();
expect(getByTestId('infra-summary')).toBeInTheDocument();
expect(getByTestId('infra-table')).toHaveTextContent('pbs-main,pmg-main');
expect(getByText('PBS')).toBeInTheDocument();
diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts
index 20c380e5f..1a9e72043 100644
--- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts
+++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts
@@ -23,7 +23,7 @@ import updatesPresentationSource from '@/utils/updatesPresentation.ts?raw';
import environmentLockBadgeSource from '@/components/shared/EnvironmentLockBadge.tsx?raw';
import environmentLockPresentationSource from '@/utils/environmentLockPresentation.ts?raw';
import dockerRuntimeSettingsCardSource from '@/components/Settings/DockerRuntimeSettingsCard.tsx?raw';
-import infrastructurePageSource from '@/pages/Infrastructure.tsx?raw';
+import infrastructurePageShellSource from '@/pages/Infrastructure.tsx?raw';
import discoveryTargetSource from '@/utils/discoveryTarget.ts?raw';
import infrastructureEmptyStatePresentationSource from '@/utils/infrastructureEmptyStatePresentation.ts?raw';
import recoverySummarySource from '@/components/Recovery/RecoverySummary.tsx?raw';
@@ -309,6 +309,8 @@ import remediationStatusSource from '@/components/patrol/RemediationStatus.tsx?r
import remediationPresentationSource from '@/utils/remediationPresentation.ts?raw';
import aiChatPresentationSource from '@/utils/aiChatPresentation.ts?raw';
import infrastructureDetailsDrawerSource from '@/components/shared/InfrastructureDetailsDrawer.tsx?raw';
+import infrastructurePageSurfaceSource from '@/features/infrastructure/InfrastructurePageSurface.tsx?raw';
+import infrastructurePageStateSource from '@/features/infrastructure/useInfrastructurePageState.ts?raw';
const aiSettingsSource = [
aiSettingsShellSource,
@@ -322,6 +324,12 @@ const resourceDetailDrawerSource = [
resourceDetailDrawerStateSource,
].join('\n');
+const infrastructurePageSource = [
+ infrastructurePageShellSource,
+ infrastructurePageSurfaceSource,
+ infrastructurePageStateSource,
+].join('\n');
+
describe('frontend resource type boundaries', () => {
it('keeps the shared compatibility adapter narrow and explicit', () => {
expect(resourceTypeCompatSource).toContain('export const canonicalizeFrontendResourceType');