mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 19:23:31 +00:00
Keep recovery state live during refresh
This commit is contained in:
@@ -151,6 +151,13 @@ work extends shared components instead of creating new local variants.
|
||||
10. Keep contextual row focus on the shared summary primitive. Summary surfaces and same-route table drill-ins must reuse `frontend-modern/src/components/shared/contextualFocus.ts` for interactive-series filtering, focused-name lookup, active-series derivation, and local scroll preservation instead of rebuilding page-local `Set` filters, focused-label scans, or ad hoc scroll restoration in each surface.
|
||||
11. Keep summary-to-table coordination deliberate and reversible. Shared summary hover may highlight the matching table row when it is already visible, but transient chart hover must not auto-filter tables, auto-scroll the page, or reshuffle table ordering. When the active row is off-screen, page owners must route through `frontend-modern/src/components/shared/summaryTableFocus.ts` and surface a lightweight `Jump to row` affordance that reveals and scrolls only on explicit user action.
|
||||
12. Keep summary-linked table row emphasis on the shared primitive contract. Workloads, infrastructure, and storage rows that mirror the active summary entity must expose that state through `data-summary-row-active` and let the shared presentation in `frontend-modern/src/index.css` render the row emphasis, rather than carrying page-local sky or blue fill classes inside each row renderer.
|
||||
13. Keep retained-value data loading honest at the ownership boundary. Helpers
|
||||
that prevent a feature surface from falling through the app-level Suspense
|
||||
boundary during in-flight refresh belong under that feature's
|
||||
`frontend-modern/src/features/` owner until multiple surfaces truly share
|
||||
the behavior; do not promote them into `components/shared/` or a generic
|
||||
global hook namespace just because more than one local hook calls them
|
||||
inside the same feature area.
|
||||
|
||||
## Forbidden Paths
|
||||
|
||||
@@ -230,7 +237,7 @@ connections` visible as the API-backed alternative for Proxmox and
|
||||
`frontend-modern/src/routing/resourceLinks.ts` instead of freezing raw
|
||||
route strings or provider-local link builders inside feature panels.
|
||||
15. Keep shared summary-card emphasis coherent. When shared summary primitives enter an `inactive` state, `SummaryMetricCard`, `InteractiveSparkline`, and `DensityMap` must all demote background context together so storage, infrastructure, and workloads read as one interaction model instead of mixing page-local opacity, sticky-shell, or highlight rules.
|
||||
16. Keep density-map summaries overview-first. When a shared summary density map receives row focus or chart-hover emphasis, `frontend-modern/src/components/shared/DensityMap.tsx`, `frontend-modern/src/components/shared/useDensityMapState.ts`, and `frontend-modern/src/components/shared/densityMapModel.ts` must preserve the multi-entity overview rows and keep focused-entity detail in the hover tooltip instead of swapping the card into a single-series chart, dimming the rest of the map into unusable background noise, duplicating cursor-value tooltip copy, or adding persistent card chrome that steals heatmap space. The card body must stay overview-first; the tooltip may carry the active entity identity, current value, peak, and mini sparkline, shared tooltip shells must follow semantic surface tokens instead of forcing a dark palette in light mode, the tooltip header must let long entity names consume the available width before truncating rather than clipping against an arbitrary fixed label cap, numeric metric readouts such as `16.9 MB/s` or `37.4 MB/s` must stay single-line instead of wrapping the unit onto a second row, and the mini sparkline must keep its own dedicated rail so longer readouts do not squeeze it out of the tooltip.
|
||||
16. Keep density-map summaries overview-first. When a shared summary density map receives row focus or chart-hover emphasis, `frontend-modern/src/components/shared/DensityMap.tsx`, `frontend-modern/src/components/shared/useDensityMapState.ts`, and `frontend-modern/src/components/shared/densityMapModel.ts` must preserve the multi-entity overview rows and keep focused-entity detail in the hover tooltip instead of swapping the card into a single-series chart, dimming the rest of the map into unusable background noise, duplicating cursor-value tooltip copy, or adding persistent card chrome that steals heatmap space. The card body must stay overview-first; the tooltip may carry the active entity identity, current value, and peak, shared tooltip shells must follow semantic surface tokens instead of forcing a dark palette in light mode, the tooltip header must let long entity names consume the available width before truncating rather than clipping against an arbitrary fixed label cap, numeric metric readouts such as `16.9 MB/s` or `37.4 MB/s` must stay single-line instead of wrapping the unit onto a second row, and density-map detail that cannot fit cleanly inside the canonical tooltip shell must be omitted rather than introducing tooltip-specific chrome or a secondary chart inside the hover surface.
|
||||
17. Keep sparkline scrubbing source-local and sibling-sync timestamp-based. The chart a user is actively scrubbing in `frontend-modern/src/components/shared/InteractiveSparkline.tsx` and `frontend-modern/src/components/shared/useInteractiveSparklineState.ts` must keep its dashed hover cursor on the real local mouse `x`, while sibling cards may map the shared hover timestamp onto their own timelines. Shared cursor sync must not snap the source chart back onto the nearest sample timestamp, the rendered SVG/canvas hover cursor must bind to the actual numeric cursor coordinate rather than a boolean guard state, the time cursor must span the chart viewport instead of collapsing to the series height, and the hover tooltip must track the pointer instead of anchoring to the chart top edge while following the active theme rather than a hardcoded dark shell.
|
||||
18. Keep shared contextual focus canonical after adoption. Once a summary or table surface enters route-backed contextual focus, future additions must extend `frontend-modern/src/components/shared/contextualFocus.ts` and its guardrail tests rather than forking another helper for workload IDs, resource IDs, or scroll-preserving same-route selection.
|
||||
19. Keep shared infrastructure/resource selectors on the canonical agent-facet
|
||||
|
||||
@@ -4079,6 +4079,7 @@
|
||||
],
|
||||
"match_files": [
|
||||
"frontend-modern/src/components/Recovery/DashboardRecoveryStatusPanel.tsx",
|
||||
"frontend-modern/src/features/recovery/createNonSuspendingQuery.ts",
|
||||
"frontend-modern/src/features/recovery/useRecoverySurfaceState.ts",
|
||||
"frontend-modern/src/hooks/useDashboardRecovery.ts",
|
||||
"frontend-modern/src/hooks/useRecoveryPoints.ts",
|
||||
|
||||
@@ -42,16 +42,19 @@ querying, and the operator-facing storage health presentation layer.
|
||||
16. `frontend-modern/src/utils/storageSources.ts`
|
||||
17. `frontend-modern/src/hooks/useRecoveryPoints.ts`
|
||||
18. `frontend-modern/src/hooks/useRecoveryRollups.ts`
|
||||
19. `frontend-modern/src/pages/RecoveryRoute.tsx`
|
||||
20. `frontend-modern/src/routing/resourceLinks.ts`
|
||||
21. `frontend-modern/src/pages/Dashboard.tsx`
|
||||
22. `frontend-modern/src/features/dashboardOverview/dashboardWidgets.ts`
|
||||
23. `frontend-modern/src/components/Recovery/DashboardRecoveryStatusPanel.tsx`
|
||||
24. `frontend-modern/src/components/Storage/DashboardStoragePanel.tsx`
|
||||
25. `frontend-modern/src/types/recovery.ts`
|
||||
26. `frontend-modern/src/utils/recoverySummaryPresentation.ts`
|
||||
27. `frontend-modern/src/utils/recoveryTablePresentation.ts`
|
||||
28. `frontend-modern/src/utils/textPresentation.ts`
|
||||
19. `frontend-modern/src/hooks/useRecoveryPointsFacets.ts`
|
||||
20. `frontend-modern/src/hooks/useRecoveryPointsSeries.ts`
|
||||
21. `frontend-modern/src/features/recovery/createNonSuspendingQuery.ts`
|
||||
22. `frontend-modern/src/pages/RecoveryRoute.tsx`
|
||||
23. `frontend-modern/src/routing/resourceLinks.ts`
|
||||
24. `frontend-modern/src/pages/Dashboard.tsx`
|
||||
25. `frontend-modern/src/features/dashboardOverview/dashboardWidgets.ts`
|
||||
26. `frontend-modern/src/components/Recovery/DashboardRecoveryStatusPanel.tsx`
|
||||
27. `frontend-modern/src/components/Storage/DashboardStoragePanel.tsx`
|
||||
28. `frontend-modern/src/types/recovery.ts`
|
||||
29. `frontend-modern/src/utils/recoverySummaryPresentation.ts`
|
||||
30. `frontend-modern/src/utils/recoveryTablePresentation.ts`
|
||||
31. `frontend-modern/src/utils/textPresentation.ts`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
@@ -208,6 +211,14 @@ querying, and the operator-facing storage health presentation layer.
|
||||
presentation owned by `frontend-modern/src/index.css` render the emphasis,
|
||||
rather than carrying storage-local sky fill classes that drift from the
|
||||
rest of the product or obscure inline capacity bars.
|
||||
17. Keep recovery transport refreshes inside the recovery-owned feature state.
|
||||
`frontend-modern/src/features/recovery/useRecoverySurfaceState.ts` and the
|
||||
recovery data hooks may retain the last fulfilled rollups, points, facets,
|
||||
and series while the next request is in flight through
|
||||
`frontend-modern/src/features/recovery/createNonSuspendingQuery.ts`, but
|
||||
that retained-value behavior must stay route-owned and filter-owned through
|
||||
the canonical recovery state model instead of recreating page-local
|
||||
suspense escape hatches in `Recovery.tsx` or the recovery sections.
|
||||
|
||||
## Current State
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import { useBreakpoint } from '@/hooks/useBreakpoint';
|
||||
import { useColumnVisibility } from '@/hooks/useColumnVisibility';
|
||||
import type { ColumnDef } from '@/hooks/useColumnVisibility';
|
||||
import { useKioskMode } from '@/hooks/useKioskMode';
|
||||
import { useRecoveryRollups } from '@/hooks/useRecoveryRollups';
|
||||
import type { ProtectionRollup, RecoveryOutcome, RecoveryPoint } from '@/types/recovery';
|
||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import {
|
||||
@@ -27,13 +26,11 @@ import { normalizeRecoveryOutcome as normalizeOutcome } from '@/utils/recoveryOu
|
||||
import {
|
||||
getRecoveryPointItemLabel,
|
||||
getRecoveryPointTimestampMs,
|
||||
getRecoveryRollupItemSecondaryLabel,
|
||||
getRecoveryRollupItemLabel,
|
||||
} from '@/utils/recoveryRecordPresentation';
|
||||
import {
|
||||
getRecoveryRollupItemTypeKey,
|
||||
getRecoveryItemTypePresentation,
|
||||
normalizeRecoveryItemTypeQueryValue,
|
||||
} from '@/utils/recoveryItemTypePresentation';
|
||||
import {
|
||||
getRecoveryArtifactColumnLabel,
|
||||
@@ -43,7 +40,6 @@ import {
|
||||
} from '@/utils/recoveryTablePresentation';
|
||||
import { getRecoveryTimelineLabelEvery } from '@/utils/recoveryTimelineChartPresentation';
|
||||
import { getRecoveryRollupPlatforms } from '@/utils/recoveryPlatformModel';
|
||||
import { getSourcePlatformLabel } from '@/utils/sourcePlatforms';
|
||||
import { createVisibleCanonicalTypeColumn } from '@/utils/typeColumnDefinition';
|
||||
|
||||
const MOBILE_RECOVERY_COLUMNS = new Set(['time', 'item', 'outcome']);
|
||||
@@ -65,6 +61,7 @@ const Recovery: Component = () => {
|
||||
currentPage,
|
||||
facets,
|
||||
historyOutcomeFilter,
|
||||
historyItemOptions,
|
||||
itemTypeFilter,
|
||||
itemTypeOptions,
|
||||
modeFilter,
|
||||
@@ -84,6 +81,7 @@ const Recovery: Component = () => {
|
||||
rollups,
|
||||
scopeFilter,
|
||||
selectedDateKey,
|
||||
selectedHistoryItemLabel,
|
||||
setChartRangeDays,
|
||||
setClusterFilter,
|
||||
setCurrentPage,
|
||||
@@ -110,16 +108,6 @@ const Recovery: Component = () => {
|
||||
workspaceView,
|
||||
} = useRecoverySurfaceState();
|
||||
|
||||
const rollupCatalogWindow = createMemo(() => {
|
||||
const days = chartRangeDays();
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 999);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - (days - 1));
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return { from: start.toISOString(), to: end.toISOString() };
|
||||
});
|
||||
|
||||
const baseRollups = createMemo<ProtectionRollup[]>(() => {
|
||||
const query = queryFilter().trim().toLowerCase();
|
||||
const platform = platformFilter() === 'all' ? '' : platformFilter();
|
||||
@@ -213,93 +201,6 @@ const Recovery: Component = () => {
|
||||
});
|
||||
});
|
||||
|
||||
const selectedRollup = createMemo<ProtectionRollup | null>(() => {
|
||||
const selected = rollupId().trim();
|
||||
if (!selected) return null;
|
||||
return rollups().find((rollup) => rollup.rollupId === selected) || null;
|
||||
});
|
||||
|
||||
const recoveryHistoryItemCatalog = useRecoveryRollups(() => {
|
||||
if (!rollupId().trim()) return null;
|
||||
const window = rollupCatalogWindow();
|
||||
const vf = verificationFilter();
|
||||
return {
|
||||
platform: platformFilter() === 'all' ? null : platformFilter(),
|
||||
itemType: itemTypeFilter() === 'all' ? null : itemTypeFilter(),
|
||||
mode: modeFilter() === 'all' ? null : modeFilter(),
|
||||
outcome: historyOutcomeFilter() === 'all' ? null : historyOutcomeFilter(),
|
||||
q: queryFilter().trim() || null,
|
||||
cluster: clusterFilter() === 'all' ? null : clusterFilter(),
|
||||
node: nodeFilter() === 'all' ? null : nodeFilter(),
|
||||
namespace: namespaceFilter() === 'all' ? null : namespaceFilter(),
|
||||
scope: scopeFilter() === 'workload' ? 'workload' : null,
|
||||
verification: vf === 'all' ? null : vf,
|
||||
from: window.from,
|
||||
to: window.to,
|
||||
};
|
||||
});
|
||||
|
||||
const selectableRollups = createMemo<ProtectionRollup[]>(() => {
|
||||
const selected = rollupId().trim();
|
||||
if (!selected) return rollups();
|
||||
const catalog = recoveryHistoryItemCatalog.rollups() || [];
|
||||
if (catalog.length > 0) return catalog;
|
||||
const focused = selectedRollup();
|
||||
return focused ? [focused] : [];
|
||||
});
|
||||
|
||||
const selectedHistoryRollup = createMemo<ProtectionRollup | null>(() => {
|
||||
const selected = rollupId().trim();
|
||||
if (!selected) return null;
|
||||
return (
|
||||
selectableRollups().find((rollup) => rollup.rollupId === selected) || selectedRollup() || null
|
||||
);
|
||||
});
|
||||
|
||||
const selectedHistoryItemLabel = createMemo(() => {
|
||||
const rollup = selectedHistoryRollup();
|
||||
return rollup ? getRecoveryRollupItemLabel(rollup, resourcesById()) : null;
|
||||
});
|
||||
|
||||
const historyItemOptions = createMemo(() => {
|
||||
const resourceIndex = resourcesById();
|
||||
const options = selectableRollups().map((rollup) => {
|
||||
const itemTypeLabel =
|
||||
getRecoveryItemTypePresentation(getRecoveryRollupItemTypeKey(rollup))?.label || null;
|
||||
const platformLabels = getRecoveryRollupPlatforms(rollup)
|
||||
.map((platform) => getSourcePlatformLabel(String(platform || '').trim()))
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
return {
|
||||
rollupId: rollup.rollupId,
|
||||
label: getRecoveryRollupItemLabel(rollup, resourceIndex),
|
||||
secondaryLabel: getRecoveryRollupItemSecondaryLabel(rollup),
|
||||
contextLabel: [itemTypeLabel, platformLabels.join(', ')].filter(Boolean).join(' · '),
|
||||
};
|
||||
});
|
||||
|
||||
const selected = selectedHistoryRollup();
|
||||
if (!selected) return options;
|
||||
if (options.some((option) => option.rollupId === selected.rollupId)) return options;
|
||||
|
||||
const itemTypeLabel =
|
||||
getRecoveryItemTypePresentation(getRecoveryRollupItemTypeKey(selected))?.label || null;
|
||||
const platformLabels = getRecoveryRollupPlatforms(selected)
|
||||
.map((platform) => getSourcePlatformLabel(String(platform || '').trim()))
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
|
||||
return [
|
||||
{
|
||||
rollupId: selected.rollupId,
|
||||
label: getRecoveryRollupItemLabel(selected, resourceIndex),
|
||||
secondaryLabel: getRecoveryRollupItemSecondaryLabel(selected),
|
||||
contextLabel: [itemTypeLabel, platformLabels.join(', ')].filter(Boolean).join(' · '),
|
||||
},
|
||||
...options,
|
||||
];
|
||||
});
|
||||
|
||||
const filteredPoints = createMemo<RecoveryPoint[]>(() => {
|
||||
const points = recoveryPoints.points() || [];
|
||||
const selected = selectedDateKey();
|
||||
|
||||
@@ -16,7 +16,10 @@ import type { ColumnDef } from '@/hooks/useColumnVisibility';
|
||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import type { RecoveryOutcome } from '@/types/recovery';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { getRecoveryFilterPanelClearClass } from '@/utils/recoveryActionPresentation';
|
||||
import {
|
||||
getRecoveryBreadcrumbLinkClass,
|
||||
getRecoveryFilterPanelClearClass,
|
||||
} from '@/utils/recoveryActionPresentation';
|
||||
import {
|
||||
getRecoveryArtifactModePresentation,
|
||||
type RecoveryArtifactMode,
|
||||
@@ -145,6 +148,25 @@ export const RecoveryHistorySection: Component<RecoveryHistorySectionProps> = (p
|
||||
<Show when={!props.kioskMode}>
|
||||
<Card padding="none" tone="card" class="overflow-hidden border-border-subtle bg-surface">
|
||||
<div class="px-4 py-3 sm:px-5">
|
||||
<Show when={props.hasFocusedRollup() && props.selectedHistoryItemLabel()}>
|
||||
<div class="mb-3 flex min-w-0 flex-wrap items-center gap-2 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
class={getRecoveryBreadcrumbLinkClass()}
|
||||
onClick={() => {
|
||||
props.setRollupId('');
|
||||
props.setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
All protected items
|
||||
</button>
|
||||
<span class="text-muted">/</span>
|
||||
<span class="min-w-0 truncate font-medium text-base-content">
|
||||
{props.selectedHistoryItemLabel()}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<PageControls
|
||||
role="group"
|
||||
aria-label="Recovery events controls"
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { type Accessor, createEffect, createSignal, onCleanup } from 'solid-js';
|
||||
|
||||
interface CreateNonSuspendingQueryOptions<T, K> {
|
||||
source: Accessor<K | null>;
|
||||
fetcher: (key: K) => Promise<T>;
|
||||
initialValue: T;
|
||||
pollMs?: number;
|
||||
}
|
||||
|
||||
interface QueryRunOptions {
|
||||
background?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep query-backed surfaces out of the app-level Suspense boundary by
|
||||
* retaining the last fulfilled value while the next request is in flight.
|
||||
*/
|
||||
export function createNonSuspendingQuery<T, K>(options: CreateNonSuspendingQueryOptions<T, K>) {
|
||||
const [value, setValue] = createSignal<T>(options.initialValue);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [error, setError] = createSignal<unknown>(null);
|
||||
|
||||
let latestRequestId = 0;
|
||||
|
||||
const reset = () => {
|
||||
latestRequestId += 1;
|
||||
setValue(() => options.initialValue);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return options.initialValue;
|
||||
};
|
||||
|
||||
const run = async (key: K, runOptions: QueryRunOptions = {}): Promise<T> => {
|
||||
const requestId = ++latestRequestId;
|
||||
if (!runOptions.background) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const nextValue = await options.fetcher(key);
|
||||
if (requestId !== latestRequestId) {
|
||||
return value();
|
||||
}
|
||||
setValue(() => nextValue);
|
||||
setError(null);
|
||||
return nextValue;
|
||||
} catch (nextError) {
|
||||
if (requestId === latestRequestId) {
|
||||
setError(nextError);
|
||||
}
|
||||
return value();
|
||||
} finally {
|
||||
if (requestId === latestRequestId && !runOptions.background) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const key = options.source();
|
||||
if (key === null) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
void run(key);
|
||||
});
|
||||
|
||||
const refetch = async (runOptions: QueryRunOptions = {}): Promise<T> => {
|
||||
const key = options.source();
|
||||
if (key === null) {
|
||||
return reset();
|
||||
}
|
||||
return run(key, runOptions);
|
||||
};
|
||||
|
||||
if (typeof options.pollMs === 'number' && options.pollMs > 0) {
|
||||
const interval = setInterval(() => {
|
||||
void refetch({ background: true });
|
||||
}, options.pollMs);
|
||||
onCleanup(() => clearInterval(interval));
|
||||
}
|
||||
|
||||
return {
|
||||
error,
|
||||
loading,
|
||||
refetch,
|
||||
value,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, createSignal, untrack } from 'solid-js';
|
||||
import { createEffect, createMemo, createSignal, onCleanup, untrack } from 'solid-js';
|
||||
import { useLocation, useNavigate } from '@solidjs/router';
|
||||
|
||||
import { useStorageRecoveryResources } from '@/hooks/useUnifiedResources';
|
||||
@@ -9,13 +9,20 @@ import { useRecoveryPointsSeries } from '@/hooks/useRecoveryPointsSeries';
|
||||
import { buildRecoveryPath, parseRecoveryLinkSearch } from '@/routing/resourceLinks';
|
||||
import type { ProtectionRollup, RecoveryOutcome } from '@/types/recovery';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { createRouteStateNavigateScheduler } from '@/utils/routeStateNavigation';
|
||||
import {
|
||||
getSourcePlatformLabel,
|
||||
normalizeSourcePlatformKey,
|
||||
normalizeSourcePlatformQueryValue,
|
||||
} from '@/utils/sourcePlatforms';
|
||||
import { buildSourcePlatformOptions } from '@/utils/sourcePlatformOptions';
|
||||
import { getRecoveryPointPlatform, getRecoveryRollupPlatforms } from '@/utils/recoveryPlatformModel';
|
||||
import {
|
||||
getRecoveryPointPlatform,
|
||||
getRecoveryRollupPlatforms,
|
||||
} from '@/utils/recoveryPlatformModel';
|
||||
import {
|
||||
getRecoveryRollupItemLabel,
|
||||
getRecoveryRollupItemSecondaryLabel,
|
||||
normalizeRecoveryModeQueryValue,
|
||||
} from '@/utils/recoveryRecordPresentation';
|
||||
import {
|
||||
@@ -24,9 +31,7 @@ import {
|
||||
getRecoveryItemTypePresentation,
|
||||
normalizeRecoveryItemTypeQueryValue,
|
||||
} from '@/utils/recoveryItemTypePresentation';
|
||||
import {
|
||||
normalizeRecoveryOutcome as normalizeOutcome,
|
||||
} from '@/utils/recoveryOutcomePresentation';
|
||||
import { normalizeRecoveryOutcome as normalizeOutcome } from '@/utils/recoveryOutcomePresentation';
|
||||
import type { RecoveryArtifactMode } from '@/utils/recoveryArtifactModePresentation';
|
||||
import { parseRecoveryDateKey } from '@/utils/recoveryDatePresentation';
|
||||
|
||||
@@ -75,6 +80,10 @@ const normalizeRecoveryItemTypeSelection = (value: string | null | undefined): s
|
||||
export function useRecoverySurfaceState() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const routeStateNavigate = createRouteStateNavigateScheduler(
|
||||
navigate,
|
||||
() => `${untrack(() => location.pathname)}${untrack(() => location.search || '')}`,
|
||||
);
|
||||
|
||||
const storageRecoveryResources = useStorageRecoveryResources();
|
||||
|
||||
@@ -96,6 +105,7 @@ export function useRecoverySurfaceState() {
|
||||
const [chartRangeDays, setChartRangeDays] = createSignal<7 | 30 | 90 | 365>(30);
|
||||
const [selectedDateKey, setSelectedDateKey] = createSignal<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = createSignal(1);
|
||||
const [hydratedLocationKey, setHydratedLocationKey] = createSignal('');
|
||||
|
||||
const tzOffsetMinutes = createMemo(() => -new Date().getTimezoneOffset());
|
||||
|
||||
@@ -143,6 +153,100 @@ export function useRecoverySurfaceState() {
|
||||
});
|
||||
|
||||
const rollups = createMemo<ProtectionRollup[]>(() => recoveryRollups.rollups() || []);
|
||||
const selectedRollup = createMemo<ProtectionRollup | null>(() => {
|
||||
const selected = rollupId().trim();
|
||||
if (!selected) return null;
|
||||
return rollups().find((rollup) => rollup.rollupId === selected) || null;
|
||||
});
|
||||
|
||||
const recoveryHistoryItemCatalog = useRecoveryRollups(() => {
|
||||
if (!rollupId().trim()) return null;
|
||||
const window = chartWindow();
|
||||
const vf = verificationFilter();
|
||||
return {
|
||||
platform: platformFilter() === 'all' ? null : platformFilter(),
|
||||
itemType: itemTypeFilter() === 'all' ? null : itemTypeFilter(),
|
||||
mode: modeFilter() === 'all' ? null : modeFilter(),
|
||||
outcome: historyOutcomeFilter() === 'all' ? null : historyOutcomeFilter(),
|
||||
q: queryFilter().trim() || null,
|
||||
cluster: clusterFilter() === 'all' ? null : clusterFilter(),
|
||||
node: nodeFilter() === 'all' ? null : nodeFilter(),
|
||||
namespace: namespaceFilter() === 'all' ? null : namespaceFilter(),
|
||||
scope: scopeFilter() === 'workload' ? 'workload' : null,
|
||||
verification: vf === 'all' ? null : vf,
|
||||
from: window.from,
|
||||
to: window.to,
|
||||
};
|
||||
});
|
||||
|
||||
const selectableRollups = createMemo<ProtectionRollup[]>(() => {
|
||||
const selected = rollupId().trim();
|
||||
if (!selected) return rollups();
|
||||
const catalog = recoveryHistoryItemCatalog.rollups() || [];
|
||||
if (catalog.length > 0) return catalog;
|
||||
const focused = selectedRollup();
|
||||
return focused ? [focused] : [];
|
||||
});
|
||||
|
||||
const selectedHistoryRollup = createMemo<ProtectionRollup | null>(() => {
|
||||
const selected = rollupId().trim();
|
||||
if (!selected) return null;
|
||||
return (
|
||||
selectableRollups().find((rollup) => rollup.rollupId === selected) || selectedRollup() || null
|
||||
);
|
||||
});
|
||||
|
||||
const resourcesById = createMemo(() => {
|
||||
const map = new Map<string, Resource>();
|
||||
for (const resource of storageRecoveryResources.resources() || []) {
|
||||
if (resource?.id) map.set(resource.id, resource);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const selectedHistoryItemLabel = createMemo(() => {
|
||||
const rollup = selectedHistoryRollup();
|
||||
return rollup ? getRecoveryRollupItemLabel(rollup, resourcesById()) : null;
|
||||
});
|
||||
|
||||
const historyItemOptions = createMemo(() => {
|
||||
const resourceIndex = resourcesById();
|
||||
const options = selectableRollups().map((rollup) => {
|
||||
const itemTypeLabel =
|
||||
getRecoveryItemTypePresentation(getRecoveryRollupItemTypeKey(rollup))?.label || null;
|
||||
const platformLabels = getRecoveryRollupPlatforms(rollup)
|
||||
.map((platform) => getSourcePlatformLabel(String(platform || '').trim()))
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
return {
|
||||
rollupId: rollup.rollupId,
|
||||
label: getRecoveryRollupItemLabel(rollup, resourceIndex),
|
||||
secondaryLabel: getRecoveryRollupItemSecondaryLabel(rollup),
|
||||
contextLabel: [itemTypeLabel, platformLabels.join(', ')].filter(Boolean).join(' · '),
|
||||
};
|
||||
});
|
||||
|
||||
const selected = selectedHistoryRollup();
|
||||
if (!selected) return options;
|
||||
if (options.some((option) => option.rollupId === selected.rollupId)) return options;
|
||||
|
||||
const itemTypeLabel =
|
||||
getRecoveryItemTypePresentation(getRecoveryRollupItemTypeKey(selected))?.label || null;
|
||||
const platformLabels = getRecoveryRollupPlatforms(selected)
|
||||
.map((platform) => getSourcePlatformLabel(String(platform || '').trim()))
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
|
||||
return [
|
||||
{
|
||||
rollupId: selected.rollupId,
|
||||
label: getRecoveryRollupItemLabel(selected, resourceIndex),
|
||||
secondaryLabel: getRecoveryRollupItemSecondaryLabel(selected),
|
||||
contextLabel: [itemTypeLabel, platformLabels.join(', ')].filter(Boolean).join(' · '),
|
||||
},
|
||||
...options,
|
||||
];
|
||||
});
|
||||
|
||||
const recoveryPoints = useRecoveryPoints(() => {
|
||||
const rid = rollupId().trim();
|
||||
@@ -210,15 +314,8 @@ export function useRecoverySurfaceState() {
|
||||
};
|
||||
});
|
||||
|
||||
const resourcesById = createMemo(() => {
|
||||
const map = new Map<string, Resource>();
|
||||
for (const resource of storageRecoveryResources.resources() || []) {
|
||||
if (resource?.id) map.set(resource.id, resource);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const currentPath = `${location.pathname}${location.search || ''}`;
|
||||
const parsed = parseRecoveryLinkSearch(location.search);
|
||||
|
||||
const nextRollup = normalizeRecoveryRouteValue(parsed.rollupId);
|
||||
@@ -277,6 +374,10 @@ export function useRecoverySurfaceState() {
|
||||
setHistoryOutcomeFilter('all');
|
||||
}
|
||||
}
|
||||
|
||||
if (hydratedLocationKey() !== currentPath) {
|
||||
setHydratedLocationKey(currentPath);
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
@@ -299,10 +400,11 @@ export function useRecoverySurfaceState() {
|
||||
|
||||
createEffect(() => {
|
||||
const rid = rollupId().trim();
|
||||
const defaultView: RecoveryWorkspaceView =
|
||||
rid || selectedDateKey() ? 'events' : 'inventory';
|
||||
const defaultView: RecoveryWorkspaceView = rid || selectedDateKey() ? 'events' : 'inventory';
|
||||
const status = historyOutcomeFilter() !== 'all' ? historyOutcomeFilter() : null;
|
||||
const verification = verificationFilter() !== 'all' ? verificationFilter() : null;
|
||||
const currentPath = `${location.pathname}${location.search || ''}`;
|
||||
if (hydratedLocationKey() !== currentPath) return;
|
||||
|
||||
const nextPath = buildRecoveryPath({
|
||||
rollupId: rid || null,
|
||||
@@ -322,12 +424,15 @@ export function useRecoverySurfaceState() {
|
||||
namespace: namespaceFilter() !== 'all' ? namespaceFilter() : null,
|
||||
});
|
||||
|
||||
const currentPath = `${location.pathname}${location.search || ''}`;
|
||||
if (nextPath !== currentPath) {
|
||||
navigate(nextPath, { replace: true });
|
||||
routeStateNavigate.schedule(nextPath);
|
||||
}
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
routeStateNavigate.cleanup();
|
||||
});
|
||||
|
||||
const facets = createMemo(() => recoveryFacets.facets() || {});
|
||||
|
||||
const platformOptions = createMemo(() => {
|
||||
@@ -429,6 +534,7 @@ export function useRecoverySurfaceState() {
|
||||
currentPage,
|
||||
facets,
|
||||
historyOutcomeFilter,
|
||||
historyItemOptions,
|
||||
itemTypeFilter,
|
||||
itemTypeOptions,
|
||||
modeFilter,
|
||||
@@ -449,6 +555,7 @@ export function useRecoverySurfaceState() {
|
||||
rollups,
|
||||
scopeFilter,
|
||||
selectedDateKey,
|
||||
selectedHistoryItemLabel,
|
||||
setChartRangeDays,
|
||||
setClusterFilter,
|
||||
setCurrentPage,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Accessor, createMemo, createResource, onCleanup } from 'solid-js';
|
||||
import { Accessor, createMemo } from 'solid-js';
|
||||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
import type {
|
||||
RecoveryPoint,
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
RecoveryPointsTransportResponse,
|
||||
} from '@/types/recovery';
|
||||
import { normalizeRecoveryPointsResponse } from '@/utils/recoveryPlatformModel';
|
||||
import { createNonSuspendingQuery } from '@/features/recovery/createNonSuspendingQuery';
|
||||
|
||||
const RECOVERY_POINTS_URL = '/api/recovery/points';
|
||||
const DEFAULT_LIMIT = 200;
|
||||
@@ -124,28 +125,41 @@ async function fetchRecoveryPointsResponse(
|
||||
}
|
||||
|
||||
export function useRecoveryPoints(query?: Accessor<RecoveryPointsQuery | null | undefined>) {
|
||||
const [response, { refetch }] = createResource<RecoveryPointsResponse, string | null>(
|
||||
() => {
|
||||
if (!query) return '__all__';
|
||||
const q = query();
|
||||
if (!q) return null;
|
||||
return serializeQuery(q);
|
||||
const source = createMemo<string | null>(() => {
|
||||
if (!query) return '__all__';
|
||||
const q = query();
|
||||
if (!q) return null;
|
||||
return serializeQuery(q);
|
||||
});
|
||||
|
||||
const state = createNonSuspendingQuery<RecoveryPointsResponse, string>({
|
||||
source,
|
||||
fetcher: async (key) => fetchRecoveryPointsResponse(parseSerializedQuery(key)),
|
||||
initialValue: {
|
||||
data: [],
|
||||
meta: { page: 1, limit: DEFAULT_LIMIT, total: 0, totalPages: 1 },
|
||||
},
|
||||
async (key) => fetchRecoveryPointsResponse(parseSerializedQuery(key)),
|
||||
);
|
||||
pollMs: REFRESH_MS,
|
||||
});
|
||||
|
||||
const points = createMemo<RecoveryPoint[]>(() => response()?.data || []);
|
||||
const points = createMemo<RecoveryPoint[]>(() => state.value().data || []);
|
||||
const meta = createMemo(
|
||||
() => response()?.meta || { page: 1, limit: DEFAULT_LIMIT, total: 0, totalPages: 1 },
|
||||
() => state.value().meta || { page: 1, limit: DEFAULT_LIMIT, total: 0, totalPages: 1 },
|
||||
);
|
||||
|
||||
const interval = setInterval(() => void refetch(), REFRESH_MS);
|
||||
onCleanup(() => clearInterval(interval));
|
||||
const response = {
|
||||
get error() {
|
||||
return state.error();
|
||||
},
|
||||
get loading() {
|
||||
return state.loading();
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
response,
|
||||
points,
|
||||
meta,
|
||||
refetch,
|
||||
refetch: state.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Accessor, createMemo, createResource, onCleanup } from 'solid-js';
|
||||
import { Accessor, createMemo } from 'solid-js';
|
||||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
import type { RecoveryPointsFacets, RecoveryPointsFacetsResponse } from '@/types/recovery';
|
||||
import { createNonSuspendingQuery } from '@/features/recovery/createNonSuspendingQuery';
|
||||
|
||||
const RECOVERY_FACETS_URL = '/api/recovery/facets';
|
||||
const REFRESH_MS = 30_000;
|
||||
@@ -116,24 +117,34 @@ async function fetchFacets(
|
||||
}
|
||||
|
||||
export function useRecoveryPointsFacets(query?: Accessor<RecoveryFacetsQuery | null | undefined>) {
|
||||
const [response, { refetch }] = createResource<RecoveryPointsFacetsResponse, string | null>(
|
||||
() => {
|
||||
if (!query) return '__all__';
|
||||
const q = query();
|
||||
if (!q) return null;
|
||||
return serializeQuery(q);
|
||||
const source = createMemo<string | null>(() => {
|
||||
if (!query) return '__all__';
|
||||
const q = query();
|
||||
if (!q) return null;
|
||||
return serializeQuery(q);
|
||||
});
|
||||
|
||||
const state = createNonSuspendingQuery<RecoveryPointsFacetsResponse, string>({
|
||||
source,
|
||||
fetcher: async (key) => fetchFacets(parseSerializedQuery(key)),
|
||||
initialValue: { data: {} },
|
||||
pollMs: REFRESH_MS,
|
||||
});
|
||||
|
||||
const facets = createMemo<RecoveryPointsFacets>(() => state.value().data || {});
|
||||
|
||||
const response = {
|
||||
get error() {
|
||||
return state.error();
|
||||
},
|
||||
async (key) => fetchFacets(parseSerializedQuery(key)),
|
||||
);
|
||||
|
||||
const facets = createMemo<RecoveryPointsFacets>(() => response()?.data || {});
|
||||
|
||||
const interval = setInterval(() => void refetch(), REFRESH_MS);
|
||||
onCleanup(() => clearInterval(interval));
|
||||
get loading() {
|
||||
return state.loading();
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
response,
|
||||
facets,
|
||||
refetch,
|
||||
refetch: state.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Accessor, createMemo, createResource, onCleanup } from 'solid-js';
|
||||
import { Accessor, createMemo } from 'solid-js';
|
||||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
import type { RecoveryPointsSeriesBucket, RecoveryPointsSeriesResponse } from '@/types/recovery';
|
||||
import { createNonSuspendingQuery } from '@/features/recovery/createNonSuspendingQuery';
|
||||
|
||||
const RECOVERY_SERIES_URL = '/api/recovery/series';
|
||||
const REFRESH_MS = 30_000;
|
||||
@@ -102,24 +103,34 @@ async function fetchSeries(
|
||||
}
|
||||
|
||||
export function useRecoveryPointsSeries(query?: Accessor<RecoverySeriesQuery | null | undefined>) {
|
||||
const [response, { refetch }] = createResource<RecoveryPointsSeriesResponse, string | null>(
|
||||
() => {
|
||||
if (!query) return '__all__';
|
||||
const q = query();
|
||||
if (!q) return null;
|
||||
return serializeQuery(q);
|
||||
const source = createMemo<string | null>(() => {
|
||||
if (!query) return '__all__';
|
||||
const q = query();
|
||||
if (!q) return null;
|
||||
return serializeQuery(q);
|
||||
});
|
||||
|
||||
const state = createNonSuspendingQuery<RecoveryPointsSeriesResponse, string>({
|
||||
source,
|
||||
fetcher: async (key) => fetchSeries(parseSerializedQuery(key)),
|
||||
initialValue: { data: [] },
|
||||
pollMs: REFRESH_MS,
|
||||
});
|
||||
|
||||
const series = createMemo<RecoveryPointsSeriesBucket[]>(() => state.value().data || []);
|
||||
|
||||
const response = {
|
||||
get error() {
|
||||
return state.error();
|
||||
},
|
||||
async (key) => fetchSeries(parseSerializedQuery(key)),
|
||||
);
|
||||
|
||||
const series = createMemo<RecoveryPointsSeriesBucket[]>(() => response()?.data || []);
|
||||
|
||||
const interval = setInterval(() => void refetch(), REFRESH_MS);
|
||||
onCleanup(() => clearInterval(interval));
|
||||
get loading() {
|
||||
return state.loading();
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
response,
|
||||
series,
|
||||
refetch,
|
||||
refetch: state.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createResource, onCleanup } from 'solid-js';
|
||||
import { createMemo, type Accessor } from 'solid-js';
|
||||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
import type { ProtectionRollup, RecoveryRollupsTransportResponse } from '@/types/recovery';
|
||||
import { normalizeRecoveryRollupsResponse } from '@/utils/recoveryPlatformModel';
|
||||
import { createNonSuspendingQuery } from '@/features/recovery/createNonSuspendingQuery';
|
||||
|
||||
const RECOVERY_ROLLUPS_URL = '/api/recovery/rollups';
|
||||
const PAGE_LIMIT = 500;
|
||||
@@ -105,21 +106,35 @@ async function fetchRecoveryRollups(
|
||||
}
|
||||
|
||||
export function useRecoveryRollups(query?: () => RecoveryRollupsQuery | null | undefined) {
|
||||
const [rollups, { refetch }] = createResource<ProtectionRollup[], string | null>(
|
||||
() => {
|
||||
if (!query) return '__all__';
|
||||
const q = query();
|
||||
if (!q) return null;
|
||||
return serializeQuery(q);
|
||||
},
|
||||
async (key) => fetchRecoveryRollups(parseSerializedQuery(key)),
|
||||
);
|
||||
const source = createMemo<string | null>(() => {
|
||||
if (!query) return '__all__';
|
||||
const q = query();
|
||||
if (!q) return null;
|
||||
return serializeQuery(q);
|
||||
});
|
||||
|
||||
const interval = setInterval(() => void refetch(), REFRESH_MS);
|
||||
onCleanup(() => clearInterval(interval));
|
||||
const state = createNonSuspendingQuery<ProtectionRollup[], string>({
|
||||
source,
|
||||
fetcher: async (key) => fetchRecoveryRollups(parseSerializedQuery(key)),
|
||||
initialValue: [],
|
||||
pollMs: REFRESH_MS,
|
||||
});
|
||||
|
||||
const rollups = (() => state.value()) as Accessor<ProtectionRollup[]> & {
|
||||
readonly error: unknown;
|
||||
readonly loading: boolean;
|
||||
};
|
||||
Object.defineProperties(rollups, {
|
||||
error: {
|
||||
get: () => state.error(),
|
||||
},
|
||||
loading: {
|
||||
get: () => state.loading(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
rollups,
|
||||
refetch,
|
||||
refetch: state.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ const { findingsPanelState, runHistoryState, intelligenceState } = vi.hoisted(()
|
||||
},
|
||||
intelligenceState: {
|
||||
findings: [] as Array<Record<string, unknown>>,
|
||||
circuitBreakerStatus: null as { state: string; consecutive_failures: number } | null,
|
||||
summary: null as {
|
||||
timestamp: string;
|
||||
overall_health: {
|
||||
@@ -172,6 +173,9 @@ vi.mock('@/stores/aiIntelligence', () => {
|
||||
get intelligenceSummary() {
|
||||
return intelligenceState.summary;
|
||||
},
|
||||
get circuitBreakerStatus() {
|
||||
return intelligenceState.circuitBreakerStatus;
|
||||
},
|
||||
get correlations() {
|
||||
return correlationsState();
|
||||
},
|
||||
@@ -321,6 +325,7 @@ describe('AIIntelligence entitlement gating', () => {
|
||||
findingsPanelState.latestProps = null;
|
||||
runHistoryState.selection = null;
|
||||
intelligenceState.findings = [];
|
||||
intelligenceState.circuitBreakerStatus = null;
|
||||
intelligenceState.summary = null;
|
||||
intelligenceState.correlations = null;
|
||||
setCorrelationsState(null);
|
||||
@@ -844,9 +849,6 @@ describe('AIIntelligence entitlement gating', () => {
|
||||
expect(screen.getAllByText('Coverage incomplete').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('No recent full patrol')).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Last activity/i)).toHaveLength(1);
|
||||
expect(screen.getAllByText('Active findings')).toHaveLength(1);
|
||||
expect(screen.getAllByText('Warnings')).toHaveLength(1);
|
||||
expect(screen.getAllByText('Critical')).toHaveLength(1);
|
||||
expect(findingsPanelState.latestProps).not.toBeNull();
|
||||
});
|
||||
|
||||
@@ -854,6 +856,9 @@ describe('AIIntelligence entitlement gating', () => {
|
||||
expect(screen.queryByText(/Last patrol/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Last:/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Partial verification')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Active findings')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Warnings')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Critical')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
'Patrol coverage is incomplete: recent activity was limited to scoped runs and ended with errors, so overall health is not fully verified.',
|
||||
@@ -1011,8 +1016,8 @@ describe('AIIntelligence entitlement gating', () => {
|
||||
'Patrol has an active runtime issue: Insufficient API credits. Recent coverage is also incomplete, so the rest of your infrastructure is not fully verified.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Infrastructure findings')).toBeInTheDocument();
|
||||
expect(screen.getByText('Runtime issues')).toBeInTheDocument();
|
||||
expect(screen.getByText('Activity')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Assessment C · 60\/100/)).toBeInTheDocument();
|
||||
@@ -1024,7 +1029,9 @@ describe('AIIntelligence entitlement gating', () => {
|
||||
expect(assessmentShell).not.toBeNull();
|
||||
expect(assessmentShell!.className).toContain('bg-surface');
|
||||
expect(assessmentShell!.className).not.toContain('bg-amber-50');
|
||||
expect(screen.queryByText('Infrastructure findings')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Warnings')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('patrol-status-bar')).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Findings' }).textContent).toBe('Findings 1');
|
||||
|
||||
|
||||
@@ -5138,4 +5138,5 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(alertWebhookPresentationSource).toContain('export function getAlertWebhookTestLabel');
|
||||
expect(alertWebhookPresentationSource).toContain('export function getAlertWebhookSubmitLabel');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user