Extract infrastructure page surface owner

This commit is contained in:
rcourtman 2026-03-20 18:43:44 +00:00
parent e17dc98cf9
commit eb456319e7
8 changed files with 656 additions and 547 deletions

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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 (
<div data-testid="infrastructure-page" class="space-y-4">
<Show
when={!loading() || initialLoadComplete()}
fallback={
<div class="space-y-3 animate-pulse pointer-events-none select-none">
<div class="hidden lg:block h-[124px] w-full bg-surface-alt rounded-md border border-border"></div>
<Card padding="sm" class="h-[52px] bg-surface-alt"></Card>
<Card padding="none" tone="card" class="h-[600px] overflow-hidden">
<div class="h-8 border-b"></div>
<div class="space-y-4 p-4">
<div class="h-4 w-1/4 rounded bg-surface-hover"></div>
<div class="h-4 w-1/2 rounded bg-surface-hover"></div>
<div class="h-4 w-1/3 rounded bg-surface-hover"></div>
</div>
</Card>
</div>
}
>
<Show
when={!error()}
fallback={
<Card class="p-6">
<EmptyState
icon={<ServerIcon class="w-6 h-6 text-slate-400" />}
title={infrastructureLoadFailureState().title}
description={infrastructureLoadFailureState().description}
actions={
<button
type="button"
onClick={() => 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:"
>
<RefreshCwIcon class="h-3.5 w-3.5" />
{infrastructureLoadFailureState().actionLabel}
</button>
}
/>
</Card>
}
>
<Show
when={!showNoResources()}
fallback={
<Card class="p-6">
<EmptyState
icon={<ServerIcon class="w-6 h-6 text-slate-400" />}
title={infrastructureEmptyState().title}
description={infrastructureEmptyState().description}
actions={
<button
type="button"
onClick={handleNavigateToSettings}
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"
>
<SettingsIcon class="h-3.5 w-3.5" />
{infrastructureEmptyState().actionLabel}
</button>
}
/>
</Card>
}
>
<div class="space-y-3">
<Show when={!summaryCollapsed()}>
<div class="hidden lg:block sticky-shield sticky top-0 z-20 bg-surface">
<InfrastructureSummary
resources={filteredResources()}
timeRange={infrastructureSummaryRange()}
onTimeRangeChange={setInfrastructureSummaryRange}
hoveredResourceId={hoveredResourceId()}
focusedResourceId={expandedResourceId()}
/>
</div>
</Show>
<Show when={!kioskMode()}>
<Card padding="sm" class="mb-4">
<PageControls
search={
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search resources, IDs, IPs, or tags..."
class="w-full"
typeToSearch
history={{
storageKey: STORAGE_KEYS.RESOURCES_SEARCH_HISTORY,
emptyMessage: 'Recent infrastructure searches appear here.',
}}
/>
}
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"
>
<LabeledFilterSelect
id="infra-source-filter"
label="Source"
value={selectedSource()}
onChange={(e) => setSelectedSource(e.currentTarget.value)}
selectClass="min-w-[8rem]"
>
<option value="">All</option>
<For
each={sourceOptions.filter((source) => {
const normalized = normalizeSourcePlatformKey(source.key);
return normalized ? availableSources().has(normalized) : false;
})}
>
{(source) => <option value={source.key}>{source.label}</option>}
</For>
</LabeledFilterSelect>
<LabeledFilterSelect
id="infra-status-filter"
label="Status"
value={selectedStatus()}
onChange={(e) => setSelectedStatus(e.currentTarget.value)}
selectClass="min-w-[7rem]"
>
<option value="">All</option>
<For each={statusOptions()}>
{(status) => <option value={status.key}>{status.label}</option>}
</For>
</LabeledFilterSelect>
<FilterSegmentedControl
value={groupingMode()}
onChange={(value) => setGroupingMode(value as GroupingMode)}
aria-label="Group By"
options={[
{
value: 'grouped',
title: 'Group by cluster',
label: (
<>
<svg
class="w-3 h-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2v11z" />
</svg>
Grouped
</>
),
},
{
value: 'flat',
title: 'Flat list view',
label: (
<>
<svg
class="w-3 h-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
List
</>
),
},
]}
/>
<FilterSegmentedControl
class="hidden lg:inline-flex"
value={summaryCollapsed() ? 'hidden' : 'shown'}
onChange={() => setSummaryCollapsed((c) => !c)}
aria-label="Charts"
options={[
{
value: 'shown',
title: summaryCollapsed() ? 'Show charts' : 'Hide charts',
label: (
<>
<svg
class="w-3 h-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
Charts
</>
),
},
]}
/>
</PageControls>
</Card>
</Show>
<Show
when={hasFilteredResources()}
fallback={
<Card class="p-6">
<EmptyState
icon={<ServerIcon class="w-6 h-6 text-slate-400" />}
title={infrastructureFilterEmptyState().title}
description={infrastructureFilterEmptyState().description}
actions={
<Show when={hasActiveFilters()}>
<button
type="button"
onClick={clearFilters}
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"
>
{infrastructureFilterEmptyState().actionLabel}
</button>
</Show>
}
/>
</Card>
}
>
<UnifiedResourceTable
resources={filteredResources()}
expandedResourceId={expandedResourceId()}
hoveredResourceId={hoveredResourceId()}
highlightedResourceId={highlightedResourceId()}
onExpandedResourceChange={setExpandedResourceId}
onHoverChange={setHoveredResourceId}
groupingMode={groupingMode()}
onDeployCluster={(id, name) => setDeployCluster({ id, name })}
/>
</Show>
</div>
</Show>
</Show>
</Show>
<Show when={deployCluster()}>
{(cluster) => (
<AgentDeployModal
isOpen={true}
clusterId={cluster().id}
clusterName={cluster().name}
onClose={() => setDeployCluster(null)}
/>
)}
</Show>
<ScrollToTopButton />
</div>
);
}
export default InfrastructurePageSurface;

View file

@ -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<TimeRange>(STORAGE_KEYS.INFRASTRUCTURE_SUMMARY_RANGE, '1h', {
deserialize: (raw) => (isSummaryTimeRange(raw) ? raw : '1h'),
});
const [summaryCollapsed, setSummaryCollapsed] = usePersistentSignal<boolean>(
STORAGE_KEYS.INFRASTRUCTURE_SUMMARY_COLLAPSED,
false,
{ deserialize: (raw) => raw === 'true' },
);
const [groupingMode, setGroupingMode] = usePersistentSignal<GroupingMode>(
'infrastructureGroupingMode',
'grouped',
{ deserialize: (raw) => (raw === 'grouped' || raw === 'flat' ? raw : 'grouped') },
);
const [expandedResourceId, setExpandedResourceId] = createSignal<string | null>(null);
const [hoveredResourceId, setHoveredResourceId] = createSignal<string | null>(null);
const [highlightedResourceId, setHighlightedResourceId] = createSignal<string | null>(null);
const [handledResourceId, setHandledResourceId] = createSignal<string | null>(null);
const [handledSourceParam, setHandledSourceParam] = createSignal<string | null>(null);
const [handledQueryParam, setHandledQueryParam] = createSignal('');
const [deployCluster, setDeployCluster] = createSignal<DeployCluster | null>(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,
};
}

View file

@ -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<TimeRange>(STORAGE_KEYS.INFRASTRUCTURE_SUMMARY_RANGE, '1h', {
deserialize: (raw) => (isSummaryTimeRange(raw) ? raw : '1h'),
});
const [summaryCollapsed, setSummaryCollapsed] = usePersistentSignal<boolean>(
STORAGE_KEYS.INFRASTRUCTURE_SUMMARY_COLLAPSED,
false,
{ deserialize: (raw) => raw === 'true' },
);
type GroupingMode = 'grouped' | 'flat';
const [groupingMode, setGroupingMode] = usePersistentSignal<GroupingMode>(
'infrastructureGroupingMode',
'grouped',
{ deserialize: (raw) => (raw === 'grouped' || raw === 'flat' ? raw : 'grouped') },
);
const [expandedResourceId, setExpandedResourceId] = createSignal<string | null>(null);
const [hoveredResourceId, setHoveredResourceId] = createSignal<string | null>(null);
const [highlightedResourceId, setHighlightedResourceId] = createSignal<string | null>(null);
const [handledResourceId, setHandledResourceId] = createSignal<string | null>(null);
const [handledSourceParam, setHandledSourceParam] = createSignal<string | null>(null);
const [handledQueryParam, setHandledQueryParam] = createSignal<string>('');
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 (
<div data-testid="infrastructure-page" class="space-y-4">
<Show
when={!loading() || initialLoadComplete()}
fallback={
<div class="space-y-3 animate-pulse pointer-events-none select-none">
<div class="hidden lg:block h-[124px] w-full bg-surface-alt rounded-md border border-border"></div>
<Card padding="sm" class="h-[52px] bg-surface-alt"></Card>
<Card padding="none" tone="card" class="h-[600px] overflow-hidden">
<div class="h-8 border-b"></div>
<div class="space-y-4 p-4">
<div class="h-4 w-1/4 rounded bg-surface-hover"></div>
<div class="h-4 w-1/2 rounded bg-surface-hover"></div>
<div class="h-4 w-1/3 rounded bg-surface-hover"></div>
</div>
</Card>
</div>
}
>
<Show
when={!error()}
fallback={
<Card class="p-6">
<EmptyState
icon={<ServerIcon class="w-6 h-6 text-slate-400" />}
title={infrastructureLoadFailureState().title}
description={infrastructureLoadFailureState().description}
actions={
<button
type="button"
onClick={() => 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:"
>
<RefreshCwIcon class="h-3.5 w-3.5" />
{infrastructureLoadFailureState().actionLabel}
</button>
}
/>
</Card>
}
>
<Show
when={!showNoResources()}
fallback={
<Card class="p-6">
<EmptyState
icon={<ServerIcon class="w-6 h-6 text-slate-400" />}
title={infrastructureEmptyState().title}
description={infrastructureEmptyState().description}
actions={
<button
type="button"
onClick={() => 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"
>
<SettingsIcon class="h-3.5 w-3.5" />
{infrastructureEmptyState().actionLabel}
</button>
}
/>
</Card>
}
>
<div class="space-y-3">
<Show when={!summaryCollapsed()}>
<div class="hidden lg:block sticky-shield sticky top-0 z-20 bg-surface">
<InfrastructureSummary
resources={filteredResources()}
timeRange={infrastructureSummaryRange()}
onTimeRangeChange={setInfrastructureSummaryRange}
hoveredResourceId={hoveredResourceId()}
focusedResourceId={expandedResourceId()}
/>
</div>
</Show>
<Show when={!kioskMode()}>
<Card padding="sm" class="mb-4">
<PageControls
search={
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search resources, IDs, IPs, or tags..."
class="w-full"
typeToSearch
history={{
storageKey: STORAGE_KEYS.RESOURCES_SEARCH_HISTORY,
emptyMessage: 'Recent infrastructure searches appear here.',
}}
/>
}
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"
>
<LabeledFilterSelect
id="infra-source-filter"
label="Source"
value={selectedSource()}
onChange={(e) => setSelectedSource(e.currentTarget.value)}
selectClass="min-w-[8rem]"
>
<option value="">All</option>
<For
each={sourceOptions.filter((source) => {
const normalized = normalizeSourcePlatformKey(source.key);
return normalized ? availableSources().has(normalized) : false;
})}
>
{(source) => <option value={source.key}>{source.label}</option>}
</For>
</LabeledFilterSelect>
<LabeledFilterSelect
id="infra-status-filter"
label="Status"
value={selectedStatus()}
onChange={(e) => setSelectedStatus(e.currentTarget.value)}
selectClass="min-w-[7rem]"
>
<option value="">All</option>
<For each={statusOptions()}>
{(status) => <option value={status.key}>{status.label}</option>}
</For>
</LabeledFilterSelect>
<FilterSegmentedControl
value={groupingMode()}
onChange={(value) => setGroupingMode(value as GroupingMode)}
aria-label="Group By"
options={[
{
value: 'grouped',
title: 'Group by cluster',
label: (
<>
<svg
class="w-3 h-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2v11z" />
</svg>
Grouped
</>
),
},
{
value: 'flat',
title: 'Flat list view',
label: (
<>
<svg
class="w-3 h-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
List
</>
),
},
]}
/>
<FilterSegmentedControl
class="hidden lg:inline-flex"
value={summaryCollapsed() ? 'hidden' : 'shown'}
onChange={() => setSummaryCollapsed((c) => !c)}
aria-label="Charts"
options={[
{
value: 'shown',
title: summaryCollapsed() ? 'Show charts' : 'Hide charts',
label: (
<>
<svg
class="w-3 h-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
Charts
</>
),
},
]}
/>
</PageControls>
</Card>
</Show>
<Show
when={hasFilteredResources()}
fallback={
<Card class="p-6">
<EmptyState
icon={<ServerIcon class="w-6 h-6 text-slate-400" />}
title={infrastructureFilterEmptyState().title}
description={infrastructureFilterEmptyState().description}
actions={
<Show when={hasActiveFilters()}>
<button
type="button"
onClick={clearFilters}
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"
>
{infrastructureFilterEmptyState().actionLabel}
</button>
</Show>
}
/>
</Card>
}
>
<UnifiedResourceTable
resources={filteredResources()}
expandedResourceId={expandedResourceId()}
hoveredResourceId={hoveredResourceId()}
highlightedResourceId={highlightedResourceId()}
onExpandedResourceChange={setExpandedResourceId}
onHoverChange={setHoveredResourceId}
groupingMode={groupingMode()}
onDeployCluster={(id, name) => setDeployCluster({ id, name })}
/>
</Show>
</div>
</Show>
</Show>
</Show>
<Show when={deployCluster()}>
{(cluster) => (
<AgentDeployModal
isOpen={true}
clusterId={cluster().id}
clusterName={cluster().name}
onClose={() => setDeployCluster(null)}
/>
)}
</Show>
<ScrollToTopButton />
</div>
);
return <InfrastructurePageSurface />;
}
export default Infrastructure;

View file

@ -99,6 +99,7 @@ describe('Infrastructure PBS/PMG integration', () => {
it('renders native PBS and PMG resources in infrastructure view', async () => {
const { getByTestId, getByText } = render(() => <Infrastructure />);
expect(getByTestId('infrastructure-page')).toBeInTheDocument();
expect(getByTestId('infra-summary')).toBeInTheDocument();
expect(getByTestId('infra-table')).toHaveTextContent('pbs-main,pmg-main');
expect(getByText('PBS')).toBeInTheDocument();

View file

@ -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');