mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-13 15:28:38 +00:00
Extract infrastructure page surface owner
This commit is contained in:
parent
e17dc98cf9
commit
eb456319e7
8 changed files with 656 additions and 547 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue