@skyhook-io/radar-app 1.8.7 → 1.8.9

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.
Files changed (56) hide show
  1. package/package.json +4 -4
  2. package/src/App.tsx +69 -61
  3. package/src/RadarApp.tsx +15 -1
  4. package/src/api/apiResources.ts +1 -1
  5. package/src/api/client.argoResourceSync.test.ts +69 -0
  6. package/src/api/client.rightsizing.test.ts +32 -0
  7. package/src/api/client.ts +1222 -244
  8. package/src/api/timelineSource.ts +4 -2
  9. package/src/components/applications/ApplicationsView.tsx +613 -219
  10. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  11. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  12. package/src/components/cost/CostTrendChart.tsx +103 -72
  13. package/src/components/cost/CostView.test.ts +12 -0
  14. package/src/components/cost/CostView.tsx +494 -229
  15. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  16. package/src/components/cost/CostViewTabs.tsx +40 -0
  17. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  18. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  19. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  20. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  21. package/src/components/cost/cloud-console.test.ts +39 -0
  22. package/src/components/cost/cloud-console.ts +81 -0
  23. package/src/components/cost/errors.ts +8 -0
  24. package/src/components/cost/format.test.ts +27 -0
  25. package/src/components/cost/format.ts +46 -0
  26. package/src/components/cost/kinds.ts +5 -0
  27. package/src/components/diagnose/AISettings.tsx +7 -12
  28. package/src/components/diagnose/DiagnoseContext.tsx +1 -0
  29. package/src/components/diagnose/DiagnoseSurface.tsx +3 -0
  30. package/src/components/diagnose/InvestigationView.tsx +16 -3
  31. package/src/components/diagnose/parts.tsx +140 -73
  32. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  33. package/src/components/gitops/GitOpsView.tsx +81 -14
  34. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  35. package/src/components/helm/HelmCompareRoute.tsx +1 -2
  36. package/src/components/helm/ManifestDiffViewer.tsx +1 -31
  37. package/src/components/helm/ValuesDiffPreview.tsx +2 -3
  38. package/src/components/home/CostCard.tsx +21 -36
  39. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  40. package/src/components/resource/RightsizingStrip.tsx +319 -123
  41. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  42. package/src/components/rightsizing/copy.test.ts +56 -0
  43. package/src/components/rightsizing/model.test.ts +227 -0
  44. package/src/components/rightsizing/model.ts +158 -0
  45. package/src/components/rightsizing/presentation.test.ts +104 -0
  46. package/src/components/rightsizing/presentation.ts +94 -0
  47. package/src/components/settings/MyPermissionsDialog.tsx +66 -116
  48. package/src/components/settings/SettingsDialog.tsx +1268 -318
  49. package/src/components/timeline/TimelineList.tsx +35 -8
  50. package/src/components/timeline/TimelineView.tsx +156 -26
  51. package/src/components/timeline/TimelineView.urlparams.test.ts +43 -2
  52. package/src/components/workload/WorkloadView.tsx +711 -328
  53. package/src/context/DiagnoseCustomization.tsx +36 -2
  54. package/src/index.css +5 -1
  55. package/src/index.ts +4 -1
  56. package/src/main.tsx +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.8.7",
3
+ "version": "1.8.9",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -63,16 +63,16 @@
63
63
  "@types/react": "^19.2.17",
64
64
  "@types/react-dom": "^19.2.3",
65
65
  "@vitejs/plugin-react": "^6.0.2",
66
- "@xyflow/react": "^12.10.2",
66
+ "@xyflow/react": "^12.11.2",
67
67
  "clsx": "^2.1.1",
68
68
  "elkjs": "^0.11.1",
69
69
  "eslint": "^10.6.0",
70
70
  "eslint-plugin-react-hooks": "^7.1.1",
71
71
  "eslint-plugin-react-refresh": "^0.5.3",
72
72
  "globals": "^17.7.0",
73
- "lucide-react": "^1.23.0",
73
+ "lucide-react": "^1.24.0",
74
74
  "postcss": "^8.5.16",
75
- "prettier": "^3.9.4",
75
+ "prettier": "^3.9.5",
76
76
  "react": "^19.2.7",
77
77
  "react-dom": "^19.2.7",
78
78
  "react-router-dom": "^7.18.1",
package/src/App.tsx CHANGED
@@ -9,9 +9,9 @@ import { DebugOverlay } from './components/DebugOverlay'
9
9
  import { GlobalDiagnoseButton } from './components/diagnose/LocalDiagnoseAction'
10
10
  import { useDiagnoseLayout } from './components/diagnose/DiagnoseContext'
11
11
  import { DiagnoseSurface } from './components/diagnose/DiagnoseSurface'
12
- import { TopologyGraph, TopologySearch, TopologyFilterSidebar, TopologyControls, FreshnessControl, gitOpsRouteForKind, gitOpsRouteForResource, ScopePill, PaneLoader } from '@skyhook-io/k8s-ui'
12
+ import { TopologyGraph, TopologySearch, TopologyBreadcrumb, TopologyFilterSidebar, TopologyControls, FreshnessControl, gitOpsRouteForKind, gitOpsRouteForResource, ScopePill, PaneLoader } from '@skyhook-io/k8s-ui'
13
13
  import { initNavigationMap } from '@skyhook-io/k8s-ui/utils/navigation'
14
- import { useAPIResources, CORE_RESOURCES } from './api/apiResources'
14
+ import { useAPIResources, findAPIResourceForRoute } from './api/apiResources'
15
15
  import { TimelineView } from './components/timeline/TimelineView'
16
16
  import { ResourcesView } from './components/resources/ResourcesView'
17
17
  import { serializeColumnFilters } from './components/resources/resource-utils'
@@ -61,8 +61,7 @@ import { useTheme } from './context/ThemeContext'
61
61
  import { Tooltip } from './components/ui/Tooltip'
62
62
  import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
63
63
  import { SettingsDialog } from './components/settings/SettingsDialog'
64
- import { MyPermissionsDialog } from './components/settings/MyPermissionsDialog'
65
- import type { TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
64
+ import type { APIResource, TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
66
65
  import { kindToPlural, pluralToKind, openExternal, apiVersionToGroup, relatedResourcePath, searchHitToSelectedResource } from './utils/navigation'
67
66
  import { type OmnibarHandle } from './components/ui/Omnibar'
68
67
  import { RadarOmnibar } from './components/ui/RadarOmnibar'
@@ -151,9 +150,13 @@ function getViewFromPath(pathname: string): ExtendedMainView {
151
150
  function namespaceFilterDisabled(
152
151
  view: ExtendedMainView,
153
152
  pathname: string,
154
- apiResources?: { name: string; kind: string; namespaced: boolean }[],
153
+ search = '',
154
+ apiResources?: APIResource[],
155
155
  ): { disabled: boolean; tooltip?: string } {
156
- if (view === 'cost') {
156
+ if (
157
+ view === 'cost' &&
158
+ !pathname.startsWith('/cost/rightsizing')
159
+ ) {
157
160
  return {
158
161
  disabled: true,
159
162
  tooltip: 'Cost is reported per namespace across the whole cluster — the namespace filter doesn’t apply here.',
@@ -168,7 +171,8 @@ function namespaceFilterDisabled(
168
171
  }
169
172
  if (view === 'resources') {
170
173
  const kindSlug = segments[1]
171
- const match = kindSlug ? apiResources?.find(r => r.name === kindSlug) : undefined
174
+ const group = new URLSearchParams(search).get('apiGroup') || ''
175
+ const match = kindSlug ? findAPIResourceForRoute(apiResources, kindSlug, group) : undefined
172
176
  if (match && !match.namespaced) {
173
177
  return {
174
178
  disabled: true,
@@ -183,7 +187,7 @@ function namespaceFilterDisabled(
183
187
  // correct regardless of which component renders it. A detail drawer that opens
184
188
  // over a list (?resource=…) is deliberately NOT titled — it's the same page, so
185
189
  // it keeps the list's title.
186
- function radarPageTitle(pathname: string, search = '', apiResources?: { name: string; kind: string; group?: string }[]): string | null {
190
+ function radarPageTitle(pathname: string, search = '', apiResources?: APIResource[]): string | null {
187
191
  const decode = (s: string) => {
188
192
  try {
189
193
  return decodeURIComponent(s)
@@ -194,7 +198,7 @@ function radarPageTitle(pathname: string, search = '', apiResources?: { name: st
194
198
  const capitalize = (text: string) =>
195
199
  text ? text.charAt(0).toUpperCase() + text.slice(1) : text
196
200
  const pluralKindTitle = (kind: string, resourceName: string) =>
197
- kind.toLowerCase() === resourceName.toLowerCase() ? kind : englishPlural(kind)
201
+ kind.toLowerCase() === resourceName.toLowerCase() || /Metrics$/.test(kind) ? kind : englishPlural(kind)
198
202
  const pathSegments = pathname.replace(/^\//, '').split('/').filter(Boolean)
199
203
  const view = getViewFromPath(pathname)
200
204
 
@@ -205,7 +209,8 @@ function radarPageTitle(pathname: string, search = '', apiResources?: { name: st
205
209
  if (view === 'resources') {
206
210
  const resourceName = decode(pathSegments[1] ?? '')
207
211
  if (!resourceName) return 'Resources'
208
- const match = apiResources?.find((r) => r.name === resourceName)
212
+ const group = new URLSearchParams(search).get('apiGroup') || ''
213
+ const match = findAPIResourceForRoute(apiResources, resourceName, group)
209
214
  return pluralKindTitle(match?.kind ?? pluralToKind(resourceName), resourceName)
210
215
  }
211
216
  // GitOps detail is /gitops/detail/<kind>/<ns>/<name> → the resource name;
@@ -385,10 +390,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
385
390
 
386
391
  // View-aware namespace scope: disabled on cluster-scoped surfaces so the
387
392
  // chip isn't a dead control next to the cluster switcher.
388
- // Fall back to the static core-resource list before discovery loads, so the
389
- // chip disables immediately on a cluster-scoped core kind (Nodes, PVs, …)
390
- // instead of flickering enabled until /api-resources resolves.
391
- const namespaceFilter = namespaceFilterDisabled(mainView, location.pathname, navApiResources ?? CORE_RESOURCES)
393
+ const namespaceFilter = namespaceFilterDisabled(mainView, location.pathname, location.search, navApiResources)
392
394
 
393
395
  // One URL-derived tab title for every view (see radarPageTitle). Driving it
394
396
  // from the URL — not the mounted component. Off unless the host opts in
@@ -485,6 +487,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
485
487
  const [filterSidebarCollapsed, setFilterSidebarCollapsed] = useState(false)
486
488
  // Topology node-search → canvas focus request (nonce lets the same node re-focus)
487
489
  const [topologyFocus, setTopologyFocus] = useState<{ id: string; nonce: number } | null>(null)
490
+ // The topology pane element — the search overlay portals into it so its
491
+ // backdrop dims only the pane (not the app) and stays clickable. Callback-ref
492
+ // state so it updates once the pane mounts.
493
+ const [topologyPane, setTopologyPane] = useState<HTMLDivElement | null>(null)
488
494
  // Track CRD kinds that have been auto-added to visibleKinds so we don't override user toggles
489
495
  const seededCRDKindsRef = useRef<Set<string>>(new Set())
490
496
 
@@ -501,7 +507,6 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
501
507
 
502
508
  // Settings dialog state
503
509
  const [showSettings, setShowSettings] = useState(false)
504
- const [showMyPermissions, setShowMyPermissions] = useState(false)
505
510
 
506
511
  // Listen for "open-settings" DOM event (used by MCPSetupDialog etc.)
507
512
  useEffect(() => {
@@ -2057,7 +2062,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2057
2062
  }}
2058
2063
  />
2059
2064
 
2060
- <div className="flex-1 relative">
2065
+ <div ref={setTopologyPane} className="flex-1 relative">
2061
2066
  <TopologyGraph
2062
2067
  topology={topologyWithAudit}
2063
2068
  viewMode={topologyMode}
@@ -2068,48 +2073,55 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2068
2073
  paused={topologyPaused}
2069
2074
  onTogglePause={handleTogglePause}
2070
2075
  onMaximizeNamespace={(ns) => setActiveNamespace.mutate({ namespaces: [ns] })}
2071
- namespaceBreadcrumb={namespaces.length === 1 ? namespaces[0] : undefined}
2072
- onClearNamespace={namespaces.length >= 1 ? () => setActiveNamespace.mutate({ namespaces: [] }) : undefined}
2073
2076
  namespacesKey={namespaces.join(',')}
2074
2077
  focusNodeId={topologyFocus?.id}
2075
2078
  focusNonce={topologyFocus?.nonce}
2076
- />
2077
-
2078
- {/* Topology node search overlay - top left */}
2079
- <TopologySearch
2080
- nodes={filteredTopology?.nodes ?? []}
2081
- allNodes={topology?.nodes}
2082
- viewModeLabel={topologyMode === 'fleet' ? 'Fleet' : topologyMode === 'traffic' ? 'Network Flow' : 'Resources'}
2083
- onNodeSelect={handleNodeClick}
2084
- onZoomToNode={(id) => setTopologyFocus((prev) => ({ id, nonce: (prev?.nonce ?? 0) + 1 }))}
2085
- // Stack below the namespace breadcrumb (shown only for a single
2086
- // namespace) so the two don't overlap in the top-left corner.
2087
- triggerClassName={namespaces.length === 1 ? 'top-12 left-3' : 'top-3 left-3'}
2088
- />
2089
-
2090
- {/* Topology controls overlay - top right */}
2091
- <TopologyControls
2092
- viewMode={topologyMode}
2093
- onViewModeChange={(mode) => {
2094
- setTopologyMode(mode)
2095
- // Fleet mode: namespace grouping for structure, but expanded (not collapsed chips)
2096
- if (mode === 'fleet') setGroupingMode('namespace')
2097
- }}
2098
- groupingMode={groupingMode}
2099
- onGroupingModeChange={setGroupingMode}
2100
- showNoGrouping={hasNamespaceFilter}
2101
- showPolicyEffect={showPolicyEffect}
2102
- onShowPolicyEffectChange={setShowPolicyEffect}
2103
- showFleetMode={displayedTopology?.nodes?.some(n => FLEET_MODE_KINDS.has(n.kind as NodeKind)) ?? false}
2104
- onNavigateToTraffic={() => setMainView('traffic')}
2105
- leadingSlot={
2106
- <FreshnessControl
2107
- mode="auto"
2108
- paused={topologyPaused}
2109
- connectionState={connection.state}
2079
+ >
2080
+ {/* Overlay row: left column (namespace breadcrumb over search)
2081
+ + controls. items-start pins the controls to the top even
2082
+ when the breadcrumb grows the left column; w-full so
2083
+ justify-between spans the canvas. */}
2084
+ <div className="flex w-full items-start justify-between gap-2">
2085
+ <div className="flex flex-col items-start gap-2">
2086
+ {namespaces.length === 1 && (
2087
+ <TopologyBreadcrumb
2088
+ namespace={namespaces[0]}
2089
+ onClear={() => setActiveNamespace.mutate({ namespaces: [] })}
2090
+ />
2091
+ )}
2092
+ <TopologySearch
2093
+ nodes={filteredTopology?.nodes ?? []}
2094
+ allNodes={topology?.nodes}
2095
+ viewModeLabel={topologyMode === 'fleet' ? 'Fleet' : topologyMode === 'traffic' ? 'Network Flow' : 'Resources'}
2096
+ onNodeSelect={handleNodeClick}
2097
+ onZoomToNode={(id) => setTopologyFocus((prev) => ({ id, nonce: (prev?.nonce ?? 0) + 1 }))}
2098
+ overlayContainer={topologyPane}
2099
+ />
2100
+ </div>
2101
+ <TopologyControls
2102
+ viewMode={topologyMode}
2103
+ onViewModeChange={(mode) => {
2104
+ setTopologyMode(mode)
2105
+ // Fleet mode: namespace grouping for structure, but expanded (not collapsed chips)
2106
+ if (mode === 'fleet') setGroupingMode('namespace')
2107
+ }}
2108
+ groupingMode={groupingMode}
2109
+ onGroupingModeChange={setGroupingMode}
2110
+ showNoGrouping={hasNamespaceFilter}
2111
+ showPolicyEffect={showPolicyEffect}
2112
+ onShowPolicyEffectChange={setShowPolicyEffect}
2113
+ showFleetMode={displayedTopology?.nodes?.some(n => FLEET_MODE_KINDS.has(n.kind as NodeKind)) ?? false}
2114
+ onNavigateToTraffic={() => setMainView('traffic')}
2115
+ leadingSlot={
2116
+ <FreshnessControl
2117
+ mode="auto"
2118
+ paused={topologyPaused}
2119
+ connectionState={connection.state}
2120
+ />
2121
+ }
2110
2122
  />
2111
- }
2112
- />
2123
+ </div>
2124
+ </TopologyGraph>
2113
2125
  </div>
2114
2126
  </>
2115
2127
  )}
@@ -2171,6 +2183,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2171
2183
  navigateToResource(resource)
2172
2184
  }}
2173
2185
  onClearNamespaces={clearAllNamespaces}
2186
+ onOpenSettings={() => setShowSettings(true)}
2174
2187
  />
2175
2188
  )}
2176
2189
 
@@ -2202,7 +2215,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2202
2215
 
2203
2216
  {/* Cost detail view */}
2204
2217
  {mainView === 'cost' && (
2205
- <CostView onBack={() => setMainView('home')} />
2218
+ <CostView namespaces={namespaces} onBack={() => setMainView('home')} onOpenResource={navigateToResource} />
2206
2219
  )}
2207
2220
 
2208
2221
  {/* Takeover splash. When the host claims the current view via
@@ -2398,16 +2411,11 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2398
2411
  {/* Diagnostics overlay */}
2399
2412
  {diagnosticsOverlay.shouldRender && <DiagnosticsOverlay isOpen={diagnosticsOverlay.isOpen} onClose={() => setShowDiagnostics(false)} />}
2400
2413
 
2401
- {/* Settings dialog */}
2414
+ {/* Settings dialog — My permissions is rendered inline in its own section */}
2402
2415
  <SettingsDialog
2403
2416
  open={showSettings}
2404
2417
  onClose={() => setShowSettings(false)}
2405
- onShowMyPermissions={() => {
2406
- setShowSettings(false)
2407
- setShowMyPermissions(true)
2408
- }}
2409
2418
  />
2410
- <MyPermissionsDialog open={showMyPermissions} onClose={() => setShowMyPermissions(false)} />
2411
2419
 
2412
2420
  {/* Debug overlay — dev mode, standalone only. Embedded hosts (Radar Hub)
2413
2421
  own their own dev tooling; ours would collide with theirs bottom-right. */}
package/src/RadarApp.tsx CHANGED
@@ -40,7 +40,10 @@ import type { ClusterLoadState } from "./types/clusterLoadState";
40
40
  import { TimelineSourceProvider } from "./context/TimelineSource";
41
41
  import type { TimelineSourceConfig } from "./api/timelineSource";
42
42
  import { DiagnoseCustomizationProvider } from "./context/DiagnoseCustomization";
43
- import type { RenderDiagnoseAction } from "./context/DiagnoseCustomization";
43
+ import type {
44
+ RenderDiagnoseAction,
45
+ DiagnoseConsentCopy,
46
+ } from "./context/DiagnoseCustomization";
44
47
  import { defaultDiagnoseAction } from "./components/diagnose/LocalDiagnoseAction";
45
48
  import { DiagnoseProvider } from "./components/diagnose/DiagnoseContext";
46
49
 
@@ -110,6 +113,15 @@ export interface RadarAppProps {
110
113
  * agent-free. See ./context/DiagnoseCustomization for the render-prop shape.
111
114
  */
112
115
  renderDiagnoseAction?: RenderDiagnoseAction;
116
+ /**
117
+ * Replaces the first-run consent card's trust copy. REQUIRED of any host whose
118
+ * backend runs the agent somewhere other than the user's own machine — the
119
+ * default copy states the agent runs locally, under the user's own model
120
+ * account, with transcripts kept on their disk, and none of that is true of a
121
+ * hosted runner. Radar keeps the card's chrome and the Approve/Cancel flow; a
122
+ * host only supplies the claims. See ./context/DiagnoseCustomization.
123
+ */
124
+ diagnoseConsent?: DiagnoseConsentCopy;
113
125
  /**
114
126
  * Initial route for `router: 'memory'` (ignored for 'browser'). Lets a host
115
127
  * deep-link a specific view (e.g. '/topology') without owning the URL bar —
@@ -184,6 +196,7 @@ export function RadarApp({
184
196
  manageDocumentTitle = false,
185
197
  documentTitleSuffix,
186
198
  renderDiagnoseAction,
199
+ diagnoseConsent,
187
200
  initialPath,
188
201
  onClusterLoadStateChange,
189
202
  timelineSource,
@@ -213,6 +226,7 @@ export function RadarApp({
213
226
  <TimelineSourceProvider config={timelineSource}>
214
227
  <DiagnoseCustomizationProvider
215
228
  value={renderDiagnoseAction ?? defaultDiagnoseAction}
229
+ consentCopy={diagnoseConsent}
216
230
  >
217
231
  <DiagnoseProvider>
218
232
  <App
@@ -3,7 +3,7 @@ import type { APIResource } from '../types'
3
3
  import { apiUrl, getAuthHeaders, getCredentialsMode } from './config'
4
4
 
5
5
  // Re-export pure functions from package
6
- export { categorizeResources, CORE_RESOURCES, formatGroupName, shortenGroupName, getKindLabel, getKindPlural } from '@skyhook-io/k8s-ui'
6
+ export { categorizeResources, CORE_RESOURCES, findAPIResourceForRoute, formatGroupName, shortenGroupName, getKindLabel, getKindPlural } from '@skyhook-io/k8s-ui'
7
7
  export type { ResourceCategory } from '@skyhook-io/k8s-ui'
8
8
 
9
9
  async function fetchJSON<T>(path: string): Promise<T> {
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildArgoResourceSyncVars } from './client'
3
+
4
+ const options = {
5
+ revision: 'should-not-survive',
6
+ prune: true,
7
+ dryRun: false,
8
+ force: true,
9
+ applyOnly: true,
10
+ syncOptions: ['ServerSideApply=true'],
11
+ }
12
+
13
+ describe('buildArgoResourceSyncVars', () => {
14
+ it('preserves the complete Argo status ref and forces resource-safe options', () => {
15
+ expect(
16
+ buildArgoResourceSyncVars(
17
+ 'argocd',
18
+ 'guestbook',
19
+ {
20
+ group: 'apps',
21
+ kind: 'Deployment',
22
+ namespace: 'guestbook',
23
+ name: 'guestbook-ui',
24
+ },
25
+ options,
26
+ ),
27
+ ).toEqual({
28
+ namespace: 'argocd',
29
+ name: 'guestbook',
30
+ resources: [
31
+ {
32
+ group: 'apps',
33
+ kind: 'Deployment',
34
+ namespace: 'guestbook',
35
+ name: 'guestbook-ui',
36
+ },
37
+ ],
38
+ revision: undefined,
39
+ prune: false,
40
+ dryRun: false,
41
+ force: true,
42
+ applyOnly: false,
43
+ syncOptions: ['ServerSideApply=true'],
44
+ })
45
+ })
46
+
47
+ it('preserves an empty core API group', () => {
48
+ const variables = buildArgoResourceSyncVars(
49
+ 'argocd',
50
+ 'guestbook',
51
+ {
52
+ group: '',
53
+ kind: 'Service',
54
+ namespace: 'guestbook',
55
+ name: 'guestbook-ui',
56
+ },
57
+ options,
58
+ )
59
+
60
+ expect(variables.resources).toEqual([
61
+ {
62
+ group: '',
63
+ kind: 'Service',
64
+ namespace: 'guestbook',
65
+ name: 'guestbook-ui',
66
+ },
67
+ ])
68
+ })
69
+ })
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { skipToken } from '@tanstack/react-query'
3
+ import { getRightsizingScanCacheConfig } from './client'
4
+
5
+ describe('rightsizing scan cache config', () => {
6
+ it('is manual-only and retained briefly', () => {
7
+ const config = getRightsizingScanCacheConfig(['staging', 'default'], 'cluster-a')
8
+
9
+ expect(config.queryFn).toBe(skipToken)
10
+ expect(config.gcTime).toBe(5 * 60 * 1000)
11
+ })
12
+
13
+ it('normalizes namespace order and isolates cluster and namespace scopes', () => {
14
+ const config = getRightsizingScanCacheConfig(['staging', 'default'], 'cluster-a')
15
+
16
+ expect(config.namespaceKey).toBe('default,staging')
17
+ expect(config.queryKey).toEqual([
18
+ 'prometheus-rightsizing-scan',
19
+ 'cluster-a',
20
+ 'default,staging',
21
+ ])
22
+ expect(getRightsizingScanCacheConfig(['default', 'staging'], 'cluster-a').queryKey).toEqual(
23
+ config.queryKey,
24
+ )
25
+ expect(getRightsizingScanCacheConfig(['default'], 'cluster-a').queryKey).not.toEqual(
26
+ config.queryKey,
27
+ )
28
+ expect(getRightsizingScanCacheConfig(['staging', 'default'], 'cluster-b').queryKey).not.toEqual(
29
+ config.queryKey,
30
+ )
31
+ })
32
+ })