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={ + + } + /> + + } + > + + } + title={infrastructureEmptyState().title} + description={infrastructureEmptyState().description} + actions={ + + } + /> + + } + > +
+ + + + + + + + } + 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]" + > + + { + const normalized = normalizeSourcePlatformKey(source.key); + return normalized ? availableSources().has(normalized) : false; + })} + > + {(source) => } + + + + setSelectedStatus(e.currentTarget.value)} + selectClass="min-w-[7rem]" + > + + + {(status) => } + + + + 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 + + ), + }, + ]} + /> + + + + + + + } + title={infrastructureFilterEmptyState().title} + description={infrastructureFilterEmptyState().description} + actions={ + + + + } + /> + + } + > + 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={ - - } - /> - - } - > - - } - title={infrastructureEmptyState().title} - description={infrastructureEmptyState().description} - actions={ - - } - /> - - } - > -
- - - - - - - - } - 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]" - > - - { - const normalized = normalizeSourcePlatformKey(source.key); - return normalized ? availableSources().has(normalized) : false; - })} - > - {(source) => } - - - - setSelectedStatus(e.currentTarget.value)} - selectClass="min-w-[7rem]" - > - - - {(status) => } - - - - 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 - - ), - }, - ]} - /> - - - - - - - } - title={infrastructureFilterEmptyState().title} - description={infrastructureFilterEmptyState().description} - actions={ - - - - } - /> - - } - > - 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');