@skyhook-io/radar-app 1.9.1 → 1.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/App.tsx +42 -7
- package/src/api/apiResources.test.ts +11 -0
- package/src/api/apiResources.ts +51 -12
- package/src/api/client.capacity.test.ts +92 -0
- package/src/api/client.ts +2884 -2075
- package/src/api/diagnose.ts +5 -0
- package/src/components/ConnectionErrorView.test.tsx +88 -0
- package/src/components/ConnectionErrorView.tsx +128 -22
- package/src/components/capacity/CapacityActivity.tsx +787 -0
- package/src/components/capacity/CapacityDemand.tsx +961 -0
- package/src/components/capacity/CapacityOverview.tsx +1529 -0
- package/src/components/capacity/CapacityPoolDetail.tsx +1626 -0
- package/src/components/capacity/CapacityView.test.tsx +2287 -0
- package/src/components/capacity/CapacityView.tsx +85 -0
- package/src/components/capacity/ClusterSchedulingCard.tsx +603 -0
- package/src/components/capacity/DemandNomination.test.tsx +151 -0
- package/src/components/capacity/certaintyGlyph.test.tsx +191 -0
- package/src/components/capacity/coverageCertainty.test.ts +162 -0
- package/src/components/capacity/podDemandGate.test.ts +47 -0
- package/src/components/capacity/podDemandGate.ts +22 -0
- package/src/components/capacity/schedulingBar.test.ts +244 -0
- package/src/components/capacity/shared.tsx +1841 -0
- package/src/components/diagnose/AgentSetupNotice.tsx +117 -0
- package/src/components/diagnose/DiagnoseContext.tsx +51 -10
- package/src/components/diagnose/DiagnoseSurface.tsx +20 -7
- package/src/components/diagnose/LocalDiagnoseAction.tsx +50 -27
- package/src/components/diagnose/agentCatalog.ts +30 -0
- package/src/components/home/CapacityCard.test.tsx +150 -0
- package/src/components/home/CapacityCard.tsx +125 -0
- package/src/components/home/HomeView.tsx +15 -1
- package/src/components/issues/IssuesPane.test.ts +142 -0
- package/src/components/issues/IssuesPane.tsx +142 -38
- package/src/components/nav/PrimaryNavRail.test.tsx +20 -0
- package/src/components/nav/PrimaryNavRail.tsx +191 -103
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +29 -1
- package/src/components/resources/renderers/PodRenderer.tsx +32 -3
- package/src/components/ui/command-items.ts +222 -98
- package/src/components/workload/WorkloadView.tsx +16 -83
- package/src/context/ConnectionContext.test.ts +39 -0
- package/src/context/ConnectionContext.tsx +155 -51
- package/src/utils/shell-safe.test.ts +55 -0
- package/src/utils/shell-safe.ts +21 -0
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -22,6 +22,7 @@ import { HelmView } from './components/helm/HelmView'
|
|
|
22
22
|
import { HelmCompareRoute } from './components/helm/HelmCompareRoute'
|
|
23
23
|
import { TrafficView } from './components/traffic/TrafficView'
|
|
24
24
|
import { CostView } from './components/cost/CostView'
|
|
25
|
+
import { CapacityView } from './components/capacity/CapacityView'
|
|
25
26
|
import { AuditView } from './components/audit/AuditView'
|
|
26
27
|
import { IssuesPane } from './components/issues/IssuesPane'
|
|
27
28
|
import { GitOpsView } from './components/gitops/GitOpsView'
|
|
@@ -56,7 +57,7 @@ import { useAnimatedUnmount } from './hooks/useAnimatedUnmount'
|
|
|
56
57
|
import { useDocumentTitle } from './hooks/useDocumentTitle'
|
|
57
58
|
import type { ClusterLoadState } from './types/clusterLoadState'
|
|
58
59
|
import { useClusterLoadState } from './hooks/useClusterLoadState'
|
|
59
|
-
import { Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, SquareTerminal, ShieldCheck, GitBranch, HelpCircle, Loader2, RefreshCw } from 'lucide-react'
|
|
60
|
+
import { Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, SquareTerminal, ShieldCheck, GitBranch, Gauge, HelpCircle, Loader2, RefreshCw } from 'lucide-react'
|
|
60
61
|
import { useTheme } from './context/ThemeContext'
|
|
61
62
|
import { Tooltip } from './components/ui/Tooltip'
|
|
62
63
|
import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
|
|
@@ -114,7 +115,7 @@ const FLEET_MODE_KINDS = new Set<NodeKind>([
|
|
|
114
115
|
|
|
115
116
|
// Convert API resource name back to topology node ID prefix
|
|
116
117
|
// Extended MainView type that includes traffic and cost
|
|
117
|
-
type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'checks' | 'gitops' | 'compare' | 'helmCompare' | 'issues' | 'applications'
|
|
118
|
+
type ExtendedMainView = MainView | 'traffic' | 'cost' | 'capacity' | 'workload' | 'checks' | 'gitops' | 'compare' | 'helmCompare' | 'issues' | 'applications'
|
|
118
119
|
|
|
119
120
|
// Extract view from URL path
|
|
120
121
|
function getViewFromPath(pathname: string): ExtendedMainView {
|
|
@@ -127,6 +128,7 @@ function getViewFromPath(pathname: string): ExtendedMainView {
|
|
|
127
128
|
if (path === 'helm') return 'helm'
|
|
128
129
|
if (path === 'traffic') return 'traffic'
|
|
129
130
|
if (path === 'cost') return 'cost'
|
|
131
|
+
if (path === 'capacity') return 'capacity'
|
|
130
132
|
if (path === 'workload') return 'workload'
|
|
131
133
|
if (path === 'checks' || path === 'audit') return 'checks' // /audit = legacy → checks
|
|
132
134
|
if (path === 'gitops') return 'gitops'
|
|
@@ -162,6 +164,12 @@ function namespaceFilterDisabled(
|
|
|
162
164
|
tooltip: 'Cost is reported per namespace across the whole cluster — the namespace filter doesn’t apply here.',
|
|
163
165
|
}
|
|
164
166
|
}
|
|
167
|
+
if (view === 'capacity') {
|
|
168
|
+
return {
|
|
169
|
+
disabled: true,
|
|
170
|
+
tooltip: 'Capacity is reported across the cluster — the namespace filter doesn’t apply here.',
|
|
171
|
+
}
|
|
172
|
+
}
|
|
165
173
|
const segments = pathname.replace(/^\//, '').split('/')
|
|
166
174
|
if (view === 'gitops' && segments[1] === 'detail') {
|
|
167
175
|
return {
|
|
@@ -226,6 +234,12 @@ function radarPageTitle(pathname: string, search = '', apiResources?: APIResourc
|
|
|
226
234
|
}
|
|
227
235
|
|
|
228
236
|
// The landing view reads "Overview" rather than "Home" in the tab.
|
|
237
|
+
if (view === 'capacity') {
|
|
238
|
+
if (pathSegments[1] === 'pools') return decode(pathSegments[2] ?? '') || 'Capacity'
|
|
239
|
+
if (pathSegments[1] === 'demand') return 'Capacity Demand'
|
|
240
|
+
if (pathSegments[1] === 'activity') return 'Capacity Activity'
|
|
241
|
+
}
|
|
242
|
+
|
|
229
243
|
if (view === 'home') return 'Overview'
|
|
230
244
|
// Every other view's label is its id capitalized — getViewFromPath has already
|
|
231
245
|
// normalized aliases (e.g. /audit → 'checks'), so no lookup table is needed.
|
|
@@ -785,7 +799,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
785
799
|
const VIEW_SHORTCUT_KEYS: Record<ExtendedMainView, string> = {
|
|
786
800
|
home: 'g h', resources: 'g r', issues: 'g i', topology: 'g t',
|
|
787
801
|
applications: 'g a', timeline: 'g l', traffic: 'g f', helm: 'g m',
|
|
788
|
-
gitops: 'g o', checks: 'g u', cost: 'g c',
|
|
802
|
+
gitops: 'g o', checks: 'g u', cost: 'g c', capacity: 'g p',
|
|
789
803
|
// Non-rail views (reachable via deep links / actions, not the rail) get no
|
|
790
804
|
// dedicated mnemonic — listed for exhaustiveness so the type stays total.
|
|
791
805
|
workload: '', compare: '', helmCompare: '',
|
|
@@ -965,9 +979,11 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
965
979
|
const fastInvalidationRef = useRef<{
|
|
966
980
|
changedKinds: Set<string> // every changed kind (any op) → detail drawer
|
|
967
981
|
structuralKinds: Set<string> // add/delete kinds → list membership + counts + dashboard
|
|
982
|
+
environmentNamespaces: Set<string>
|
|
983
|
+
environmentPods: Map<string, Set<string>>
|
|
968
984
|
secretsChanged: boolean
|
|
969
985
|
timer: number | null
|
|
970
|
-
}>({ changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null })
|
|
986
|
+
}>({ changedKinds: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null })
|
|
971
987
|
const slowInvalidationRef = useRef<{
|
|
972
988
|
updatedKinds: Set<string> // update-only churn → throttled list + dashboard
|
|
973
989
|
timer: number | null
|
|
@@ -1002,6 +1018,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1002
1018
|
fast.changedKinds.add(kind)
|
|
1003
1019
|
if (structural) fast.structuralKinds.add(kind)
|
|
1004
1020
|
if (kind === 'secrets') fast.secretsChanged = true
|
|
1021
|
+
if ((kind === 'configmaps' || kind === 'secrets') && event.namespace) fast.environmentNamespaces.add(event.namespace)
|
|
1022
|
+
if (kind === 'pods' && event.namespace && event.name) {
|
|
1023
|
+
const names = fast.environmentPods.get(event.namespace) ?? new Set<string>()
|
|
1024
|
+
names.add(event.name)
|
|
1025
|
+
fast.environmentPods.set(event.namespace, names)
|
|
1026
|
+
}
|
|
1005
1027
|
|
|
1006
1028
|
const slow = slowInvalidationRef.current
|
|
1007
1029
|
if (!structural) slow.updatedKinds.add(kind)
|
|
@@ -1023,11 +1045,19 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1023
1045
|
if (f.secretsChanged) {
|
|
1024
1046
|
queryClient.invalidateQueries({ queryKey: ['secret-cert-expiry'] })
|
|
1025
1047
|
}
|
|
1048
|
+
for (const namespace of f.environmentNamespaces) {
|
|
1049
|
+
queryClient.invalidateQueries({ queryKey: ['pod-environment', namespace] })
|
|
1050
|
+
}
|
|
1051
|
+
for (const [namespace, names] of f.environmentPods) {
|
|
1052
|
+
for (const name of names) {
|
|
1053
|
+
queryClient.invalidateQueries({ queryKey: ['pod-environment', namespace, name] })
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1026
1056
|
// GitOps behavior unchanged from before — refreshes every batch when a
|
|
1027
1057
|
// GitOps view is mounted (Phase 2 will make this relevance-aware).
|
|
1028
1058
|
queryClient.invalidateQueries({ queryKey: ['gitops-tree'] })
|
|
1029
1059
|
queryClient.invalidateQueries({ queryKey: ['gitops-insights'] })
|
|
1030
|
-
fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null }
|
|
1060
|
+
fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null }
|
|
1031
1061
|
}, 3000)
|
|
1032
1062
|
}
|
|
1033
1063
|
|
|
@@ -1053,7 +1083,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1053
1083
|
if (fastInvalidationRef.current.timer !== null) clearTimeout(fastInvalidationRef.current.timer)
|
|
1054
1084
|
if (slowInvalidationRef.current.timer !== null) clearTimeout(slowInvalidationRef.current.timer)
|
|
1055
1085
|
if (timelineInvalidationRef.current.timer !== null) clearTimeout(timelineInvalidationRef.current.timer)
|
|
1056
|
-
fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null }
|
|
1086
|
+
fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null }
|
|
1057
1087
|
slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
|
|
1058
1088
|
timelineInvalidationRef.current = { timer: null }
|
|
1059
1089
|
}, [])
|
|
@@ -1076,7 +1106,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1076
1106
|
if (fastInvalidationRef.current.timer !== null) clearTimeout(fastInvalidationRef.current.timer)
|
|
1077
1107
|
if (slowInvalidationRef.current.timer !== null) clearTimeout(slowInvalidationRef.current.timer)
|
|
1078
1108
|
if (timelineInvalidationRef.current.timer !== null) clearTimeout(timelineInvalidationRef.current.timer)
|
|
1079
|
-
fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null }
|
|
1109
|
+
fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null }
|
|
1080
1110
|
slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
|
|
1081
1111
|
timelineInvalidationRef.current = { timer: null }
|
|
1082
1112
|
|
|
@@ -1729,6 +1759,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1729
1759
|
{ view: 'home' as const, icon: Home, label: 'Home' },
|
|
1730
1760
|
{ view: 'topology' as const, icon: Network, label: 'Topology' },
|
|
1731
1761
|
{ view: 'resources' as const, icon: List, label: 'Resources' },
|
|
1762
|
+
{ view: 'capacity' as const, icon: Gauge, label: 'Capacity' },
|
|
1732
1763
|
{ view: 'timeline' as const, icon: Clock, label: 'Timeline' },
|
|
1733
1764
|
{ view: 'helm' as const, icon: Package, label: 'Helm' },
|
|
1734
1765
|
{ view: 'gitops' as const, icon: GitBranch, label: 'GitOps' },
|
|
@@ -2238,6 +2269,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
2238
2269
|
<CostView namespaces={namespaces} onBack={() => setMainView('home')} onOpenResource={navigateToResource} />
|
|
2239
2270
|
)}
|
|
2240
2271
|
|
|
2272
|
+
{mainView === 'capacity' && (
|
|
2273
|
+
<CapacityView onOpenResource={navigateToResource} />
|
|
2274
|
+
)}
|
|
2275
|
+
|
|
2241
2276
|
{/* Takeover splash. When the host claims the current view via
|
|
2242
2277
|
fleetTakeoverHref, the redirect effect above is mid-flight — render a
|
|
2243
2278
|
brief splash instead of the inline view (which would flash + fire its
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { hasKarpenterNodePools } from './apiResources'
|
|
3
|
+
|
|
4
|
+
describe('hasKarpenterNodePools', () => {
|
|
5
|
+
it('uses exact API discovery rather than top-CRD counts or kind name alone', () => {
|
|
6
|
+
expect(hasKarpenterNodePools([{ name: 'nodepools', kind: 'NodePool', group: 'karpenter.sh', verbs: ['get', 'list'] } as any])).toBe(true)
|
|
7
|
+
expect(hasKarpenterNodePools([{ name: 'nodepools', kind: 'NodePool', group: 'example.io', verbs: ['list'] } as any])).toBe(false)
|
|
8
|
+
expect(hasKarpenterNodePools([{ name: 'nodepools', kind: 'NodePool', group: 'karpenter.sh', verbs: ['get'] } as any])).toBe(false)
|
|
9
|
+
expect(hasKarpenterNodePools(undefined)).toBe(false)
|
|
10
|
+
})
|
|
11
|
+
})
|
package/src/api/apiResources.ts
CHANGED
|
@@ -1,28 +1,67 @@
|
|
|
1
|
-
import { useQuery } from
|
|
2
|
-
import type { APIResource } from
|
|
3
|
-
import { apiUrl, getAuthHeaders, getCredentialsMode } from
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import type { APIResource } from "../types";
|
|
3
|
+
import { apiUrl, getAuthHeaders, getCredentialsMode } from "./config";
|
|
4
4
|
|
|
5
5
|
// Re-export pure functions from package
|
|
6
|
-
export {
|
|
7
|
-
|
|
6
|
+
export {
|
|
7
|
+
categorizeResources,
|
|
8
|
+
CORE_RESOURCES,
|
|
9
|
+
findAPIResourceForRoute,
|
|
10
|
+
formatGroupName,
|
|
11
|
+
shortenGroupName,
|
|
12
|
+
getKindLabel,
|
|
13
|
+
getKindPlural,
|
|
14
|
+
} from "@skyhook-io/k8s-ui";
|
|
15
|
+
export type { ResourceCategory } from "@skyhook-io/k8s-ui";
|
|
8
16
|
|
|
9
17
|
async function fetchJSON<T>(path: string): Promise<T> {
|
|
10
18
|
const response = await fetch(apiUrl(path), {
|
|
11
19
|
credentials: getCredentialsMode(),
|
|
12
20
|
headers: getAuthHeaders(),
|
|
13
|
-
})
|
|
21
|
+
});
|
|
14
22
|
if (!response.ok) {
|
|
15
|
-
const error = await response
|
|
16
|
-
|
|
23
|
+
const error = await response
|
|
24
|
+
.json()
|
|
25
|
+
.catch(() => ({ error: "Unknown error" }));
|
|
26
|
+
throw new Error(error.error || `HTTP ${response.status}`);
|
|
17
27
|
}
|
|
18
|
-
return response.json()
|
|
28
|
+
return response.json();
|
|
19
29
|
}
|
|
20
30
|
|
|
21
31
|
// Fetch all API resources from the cluster
|
|
22
32
|
export function useAPIResources() {
|
|
23
33
|
return useQuery<APIResource[]>({
|
|
24
|
-
queryKey: [
|
|
25
|
-
queryFn: () => fetchJSON(
|
|
34
|
+
queryKey: ["api-resources"],
|
|
35
|
+
queryFn: () => fetchJSON("/api-resources"),
|
|
26
36
|
staleTime: 5 * 60 * 1000, // 5 minutes - resources don't change often
|
|
27
|
-
})
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function hasKarpenterNodePools(
|
|
41
|
+
resources: APIResource[] | undefined,
|
|
42
|
+
): boolean {
|
|
43
|
+
return (
|
|
44
|
+
resources?.some(
|
|
45
|
+
(resource) =>
|
|
46
|
+
resource.name === "nodepools" &&
|
|
47
|
+
resource.group === "karpenter.sh" &&
|
|
48
|
+
resource.verbs?.includes("list"),
|
|
49
|
+
) ?? false
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Whether the Capacity screens are reachable for this caller — the gate on
|
|
54
|
+
// links and entry points into them. The per-request capability is authoritative
|
|
55
|
+
// once it has resolved discovery + RBAC: available opens, denied/not_detected
|
|
56
|
+
// closes (an RBAC-denied user would otherwise land on a 403 page). While the
|
|
57
|
+
// capability is still syncing pre-discovery (cluster connecting), fall back to
|
|
58
|
+
// the discovery signal so entry points don't flicker.
|
|
59
|
+
export function karpenterCapacityAvailable(
|
|
60
|
+
capability: { state: string } | undefined,
|
|
61
|
+
apiResources: APIResource[] | undefined,
|
|
62
|
+
): boolean {
|
|
63
|
+
if (capability?.state === "available") return true;
|
|
64
|
+
if (capability?.state === "denied" || capability?.state === "not_detected")
|
|
65
|
+
return false;
|
|
66
|
+
return hasKarpenterNodePools(apiResources);
|
|
28
67
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
ApiError,
|
|
4
|
+
capacityRefetchInterval,
|
|
5
|
+
isCapacityCursorInvalidError,
|
|
6
|
+
shouldRetryCapacityQuery,
|
|
7
|
+
} from "./client";
|
|
8
|
+
|
|
9
|
+
describe("isCapacityCursorInvalidError", () => {
|
|
10
|
+
it("uses the structured capacity error code", () => {
|
|
11
|
+
expect(
|
|
12
|
+
isCapacityCursorInvalidError(
|
|
13
|
+
new ApiError("cursor expired", 400, {
|
|
14
|
+
error_code: "capacity_cursor_invalid",
|
|
15
|
+
}),
|
|
16
|
+
),
|
|
17
|
+
).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("does not infer cursor failures from status or message text", () => {
|
|
21
|
+
expect(
|
|
22
|
+
isCapacityCursorInvalidError(new ApiError("cursor expired", 400)),
|
|
23
|
+
).toBe(false);
|
|
24
|
+
expect(
|
|
25
|
+
isCapacityCursorInvalidError(
|
|
26
|
+
new ApiError("unrelated", 400, { code: "another_error" }),
|
|
27
|
+
),
|
|
28
|
+
).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("shouldRetryCapacityQuery", () => {
|
|
33
|
+
it("does not retry stable client or authorization failures", () => {
|
|
34
|
+
expect(
|
|
35
|
+
shouldRetryCapacityQuery(0, new ApiError("invalid since", 400)),
|
|
36
|
+
).toBe(false);
|
|
37
|
+
expect(shouldRetryCapacityQuery(0, new ApiError("forbidden", 403))).toBe(
|
|
38
|
+
false,
|
|
39
|
+
);
|
|
40
|
+
expect(shouldRetryCapacityQuery(0, new ApiError("not found", 404))).toBe(
|
|
41
|
+
false,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("bounds retries for transient failures", () => {
|
|
46
|
+
expect(shouldRetryCapacityQuery(0, new ApiError("unavailable", 503))).toBe(
|
|
47
|
+
true,
|
|
48
|
+
);
|
|
49
|
+
expect(shouldRetryCapacityQuery(3, new ApiError("unavailable", 503))).toBe(
|
|
50
|
+
false,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("capacityRefetchInterval", () => {
|
|
56
|
+
const withData = (data?: { state?: string }) => ({ state: { data } });
|
|
57
|
+
|
|
58
|
+
it("suspends polling while the user holds a page cursor", () => {
|
|
59
|
+
// A background refetch on page 2 races membership changes and yanks the
|
|
60
|
+
// user back to page 1.
|
|
61
|
+
expect(
|
|
62
|
+
capacityRefetchInterval(undefined, true, "cursor-abc")(withData()),
|
|
63
|
+
).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("polls fast while the server reports syncing", () => {
|
|
67
|
+
expect(
|
|
68
|
+
capacityRefetchInterval(undefined, true)(withData({ state: "syncing" })),
|
|
69
|
+
).toBe(2_500);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("arms the normal cadence for a settled, enabled query", () => {
|
|
73
|
+
expect(
|
|
74
|
+
capacityRefetchInterval(
|
|
75
|
+
undefined,
|
|
76
|
+
true,
|
|
77
|
+
)(withData({ state: "available" })),
|
|
78
|
+
).toBe(30_000);
|
|
79
|
+
expect(
|
|
80
|
+
capacityRefetchInterval(
|
|
81
|
+
{ refetchInterval: 5_000 },
|
|
82
|
+
true,
|
|
83
|
+
)(withData({ state: "available" })),
|
|
84
|
+
).toBe(5_000);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("does not poll a disabled query, even while syncing", () => {
|
|
88
|
+
expect(
|
|
89
|
+
capacityRefetchInterval(undefined, false)(withData({ state: "syncing" })),
|
|
90
|
+
).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|