@skyhook-io/radar-app 1.6.1 → 1.7.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
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",
@@ -75,6 +75,7 @@
75
75
  "vite": "^8.0.12"
76
76
  },
77
77
  "sideEffects": [
78
- "*.css"
78
+ "*.css",
79
+ "./src/monaco-setup.ts"
79
80
  ]
80
81
  }
package/src/App.tsx CHANGED
@@ -29,6 +29,7 @@ import { DURATION_DOCK } from '@skyhook-io/k8s-ui/utils/animation'
29
29
  import { ContextSwitcher } from './components/ContextSwitcher'
30
30
  import { NamespaceSwitcher, type NamespaceSwitcherHandle } from './components/NamespaceSwitcher'
31
31
  import { useNavCustomization } from './context/NavCustomization'
32
+ import type { FleetTakeoverTarget } from './context/NavCustomization'
32
33
  import { PrimaryNavRail } from './components/nav/PrimaryNavRail'
33
34
  import { useNavRailPinned } from './hooks/useNavRailPinned'
34
35
  import { useMediaQuery } from './hooks/useMediaQuery'
@@ -276,21 +277,46 @@ function AppInner() {
276
277
  navigate({ pathname: path, search: newParams.toString() })
277
278
  }, [navigate, searchParams])
278
279
 
279
- // Cloud (embedded) makes the host's fleet Checks queue the one canonical
280
- // surface — owned by the host's left rail — so Radar drops its own Audit
281
- // pill (see the nav below) and any route to /audit redirects to the fleet
282
- // Checks queue scoped to this cluster. Entry points that still land on
283
- // /auditthe Home "Cluster Audit" card, ⌘K, WorkloadView's "view all"
284
- // findings, bookmarks/deep links — all funnel through here. `replace` (not
285
- // assign) keeps the transient /audit URL out of history so Back doesn't
286
- // bounce off the redirect. Standalone OSS (no clusterChecksHref) is
287
- // unaffected and renders the in-app audit view as before.
280
+ // Cloud (embedded) takes over the "fleet-shaped" per-cluster views with its
281
+ // own fleet pages scoped to this cluster — owned by the host's left rail — so
282
+ // Radar drops the matching pills (see the nav below) and any route into one
283
+ // of these views redirects to the host. Entry points that still land on the
284
+ // view — Home cards, ⌘K, WorkloadView's "view all" findings, bookmarks/deep
285
+ // links — all funnel through here. The view name doubles as the takeover
286
+ // target ('issues' | 'gitops' | 'checks'; 'audit' normalizes to 'checks' via
287
+ // getViewFromPath). `replace` (not assign) keeps the transient /<view> URL
288
+ // out of history so Back doesn't bounce off the redirect. Standalone OSS (no
289
+ // fleetTakeoverHref) is unaffected and renders the in-app view as before.
290
+ // Resolve every takeover target's host URL ONCE per render so the redirect
291
+ // effect, nav-pill filtering, inline-view gating, and the cert click handler
292
+ // all close over the SAME value — host callbacks aren't guaranteed idempotent
293
+ // (scope / flags / signed URLs can shift between calls). undefined = not taken
294
+ // over → Radar renders the view itself. `clusterChecksHref` is the deprecated
295
+ // pre-1.7 hook, folded into the 'checks' target for back-compat.
296
+ const fleetTakeoverHref = navCustomization.fleetTakeoverHref
288
297
  const clusterChecksHref = navCustomization.clusterChecksHref
298
+ const takeover: Record<FleetTakeoverTarget, string | undefined> = {
299
+ issues: fleetTakeoverHref?.('issues'),
300
+ gitops: fleetTakeoverHref?.('gitops'),
301
+ checks: fleetTakeoverHref?.('checks') ?? clusterChecksHref?.(),
302
+ certs: fleetTakeoverHref?.('certs'),
303
+ }
304
+ // Has the host claimed this view? View-shaped targets only ('certs' has no
305
+ // Radar view — only its Home card consults `takeover`). Used to drop the nav
306
+ // pill and gate the inline view render in favor of the "Opening…" splash.
307
+ const isViewTakenOver = (view: ExtendedMainView): boolean =>
308
+ (view === 'issues' || view === 'gitops' || view === 'checks') && !!takeover[view]
309
+ // The host's URL for the CURRENT view, if taken over. Drives the redirect
310
+ // effect and the "Opening…" splash.
311
+ const viewTakeoverHref =
312
+ mainView === 'issues' || mainView === 'gitops' || mainView === 'checks'
313
+ ? takeover[mainView]
314
+ : undefined
289
315
  useEffect(() => {
290
- if (clusterChecksHref && mainView === 'checks') {
291
- window.location.replace(clusterChecksHref())
316
+ if (viewTakeoverHref) {
317
+ window.location.replace(viewTakeoverHref)
292
318
  }
293
- }, [clusterChecksHref, mainView])
319
+ }, [viewTakeoverHref])
294
320
 
295
321
  const [namespaces, setNamespaces] = useState<string[]>(getInitialState().namespaces)
296
322
  // For large clusters: force SSE to reconnect with namespace filter
@@ -542,9 +568,11 @@ function AppInner() {
542
568
  description: 'Show keyboard shortcuts',
543
569
  category: 'General' as const,
544
570
  scope: 'global' as const,
545
- // Chromeless embeds (Radar Hub) own their own help surface don't open a
546
- // competing Radar overlay.
547
- handler: () => { if (!chromeless) setShowHelp(prev => !prev) },
571
+ // Radar owns the shortcut registry even in a chromeless embed, so its `?`
572
+ // overlay is the one that actually lists the working shortcuts. The host
573
+ // (Radar Hub) drives it from its own chrome by dispatching a `?` keydown —
574
+ // it has no registry of its own to populate a competing overlay with.
575
+ handler: () => setShowHelp(prev => !prev),
548
576
  },
549
577
  {
550
578
  id: 'command-palette',
@@ -1316,15 +1344,14 @@ function AppInner() {
1316
1344
  // command palette (⌘K). Remove this comment to restore it.
1317
1345
  { view: 'checks' as const, icon: ShieldCheck, label: 'Checks' },
1318
1346
  ] as const)
1319
- // In Cloud, Checks is a fleet-scoped feature owned by the host's
1320
- // left rail; the per-cluster view is just that fleet queue filtered
1321
- // to this cluster, so duplicating it as a peer pill here would be a
1322
- // second "Checks" that teleports out of the cluster shell. Drop the
1323
- // Audit tab when embedded — cluster-scoped access stays available
1324
- // via the Home "Cluster Audit" card ( /audit, redirected to the
1325
- // scoped fleet Checks by the clusterChecksHref effect above), ⌘K,
1326
- // and bookmarks. Standalone OSS keeps the Audit tab.
1327
- .filter(({ view }) => !(view === 'checks' && clusterChecksHref))
1347
+ // In Cloud, fleet-shaped views (Checks, Issues, GitOps) are owned by
1348
+ // the host's left rail; the per-cluster view is just that fleet page
1349
+ // filtered to this cluster, so duplicating it as a peer pill here
1350
+ // would be a second copy that teleports out of the cluster shell.
1351
+ // Drop any pill the host took over — cluster-scoped access stays
1352
+ // available via the Home cards (redirected by the takeover effect
1353
+ // above), ⌘K, and bookmarks. Standalone OSS keeps every pill.
1354
+ .filter(({ view }) => !isViewTakenOver(view))
1328
1355
  .map(({ view, icon: Icon, label }) => (
1329
1356
  <Tooltip key={view} content={label} delay={100} position="bottom">
1330
1357
  <button
@@ -1380,8 +1407,6 @@ function AppInner() {
1380
1407
  <div className="flex items-center gap-3 shrink-0">
1381
1408
  <NamespaceSwitcher
1382
1409
  ref={namespaceSwitcherRef}
1383
- disabled={mainView === 'helm'}
1384
- disabledTooltip="Helm view always shows all namespaces"
1385
1410
  />
1386
1411
 
1387
1412
 
@@ -1608,21 +1633,16 @@ function AppInner() {
1608
1633
  console.debug('[filters] App.onNavigateToResourceKind: navigating to', targetURL)
1609
1634
  navigate({ pathname: `/resources/${kind}`, search: newParams.toString() })
1610
1635
  }}
1611
- onNavigateToResource={(resource) => {
1612
- // Switch to resources view and open the resource detail drawer
1613
- setSelectedResource(resource)
1614
- const newParams = new URLSearchParams(searchParams)
1615
- newParams.delete('kind') // kind is now in the path
1616
- newParams.delete('mode')
1617
- newParams.delete('group')
1618
- newParams.delete('resource')
1619
- if (resource.group) {
1620
- newParams.set('apiGroup', resource.group)
1621
- } else {
1622
- newParams.delete('apiGroup')
1623
- }
1624
- navigate({ pathname: `/resources/${resource.kind}`, search: newParams.toString() })
1625
- }}
1636
+ onNavigateToResource={navigateFromIssue}
1637
+ // Certs has no Radar view, so it can't ride the view-redirect effect
1638
+ // above — wire the Certificate Health card straight to the host's
1639
+ // fleet Certs page (scoped to this cluster) when claimed. `assign`
1640
+ // (not replace): the user is navigating forward from a card, so this
1641
+ // belongs in history. Omitted → the card falls back to Radar's own
1642
+ // TLS-secrets resource list.
1643
+ onNavigateToCerts={
1644
+ takeover.certs ? () => window.location.assign(takeover.certs!) : undefined
1645
+ }
1626
1646
  />
1627
1647
  )}
1628
1648
 
@@ -1758,10 +1778,9 @@ function AppInner() {
1758
1778
  />
1759
1779
  )}
1760
1780
 
1761
- {/* Helm view - always show all namespaces since releases span multiple ns */}
1762
1781
  {mainView === 'helm' && (
1763
1782
  <HelmView
1764
- namespace=""
1783
+ namespaces={namespaces}
1765
1784
  selectedRelease={selectedHelmRelease}
1766
1785
  onReleaseClick={(ns, name, storageNamespace) => {
1767
1786
  setSelectedHelmRelease({ namespace: ns, name, storageNamespace })
@@ -1777,8 +1796,9 @@ function AppInner() {
1777
1796
  />
1778
1797
  )}
1779
1798
 
1780
- {/* GitOps view */}
1781
- {mainView === 'gitops' && (
1799
+ {/* GitOps view (inline only when the host hasn't taken it over — see
1800
+ the takeover splash below). */}
1801
+ {mainView === 'gitops' && !isViewTakenOver('gitops') && (
1782
1802
  <GitOpsView
1783
1803
  namespaces={namespaces}
1784
1804
  onOpenResource={(resource) => {
@@ -1808,17 +1828,21 @@ function AppInner() {
1808
1828
  <CostView onBack={() => setMainView('home')} />
1809
1829
  )}
1810
1830
 
1811
- {/* Best practices detail view. In Cloud this redirects to the host's
1812
- fleet Checks queue (clusterChecksHref effect above) — render a brief
1813
- splash instead of the single-cluster view while the cross-document
1814
- nav lands. */}
1815
- {mainView === 'checks' && clusterChecksHref && (
1831
+ {/* Takeover splash. When the host claims the current view via
1832
+ fleetTakeoverHref, the redirect effect above is mid-flight — render a
1833
+ brief splash instead of the inline view (which would flash + fire its
1834
+ own fetches) while the cross-document nav lands. Covers checks /
1835
+ issues / gitops with one block since only one view is active. */}
1836
+ {viewTakeoverHref && (
1816
1837
  <div className="flex-1 flex flex-col items-center justify-center gap-3 bg-theme-base">
1817
1838
  <img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
1818
- <p className="text-sm text-theme-text-secondary">Opening Checks…</p>
1839
+ <p className="text-sm text-theme-text-secondary">Opening…</p>
1819
1840
  </div>
1820
1841
  )}
1821
- {mainView === 'checks' && !clusterChecksHref && (
1842
+
1843
+ {/* Best practices detail view (inline only when the host hasn't taken
1844
+ Checks over — standalone OSS, or Cloud without a checks takeover). */}
1845
+ {mainView === 'checks' && !isViewTakenOver('checks') && (
1822
1846
  <AuditView
1823
1847
  namespaces={namespaces}
1824
1848
  onNavigateToResource={navigateToResourceList}
@@ -1828,8 +1852,9 @@ function AppInner() {
1828
1852
  {/* Issues — per-cluster live triage queue (hidden route: not yet in the
1829
1853
  nav `views` list; reachable at /issues). Same shared <IssuesView> the
1830
1854
  Hub fleet uses; a GitOps reconciler subject routes to its detail page,
1831
- other resources open the standard resource view. */}
1832
- {mainView === 'issues' && (
1855
+ other resources open the standard resource view. Inline only when the
1856
+ host hasn't taken it over. */}
1857
+ {mainView === 'issues' && !isViewTakenOver('issues') && (
1833
1858
  <IssuesPane
1834
1859
  namespaces={namespaces}
1835
1860
  onNavigateToResource={navigateFromIssue}
@@ -1972,8 +1997,9 @@ function AppInner() {
1972
1997
  />
1973
1998
  <MyPermissionsDialog open={showMyPermissions} onClose={() => setShowMyPermissions(false)} />
1974
1999
 
1975
- {/* Debug overlay - only in dev mode */}
1976
- {import.meta.env.DEV && <DebugOverlay />}
2000
+ {/* Debug overlay dev mode, standalone only. Embedded hosts (Radar Hub)
2001
+ own their own dev tooling; ours would collide with theirs bottom-right. */}
2002
+ {import.meta.env.DEV && showNavRail && <DebugOverlay />}
1977
2003
  </div>
1978
2004
  </div>
1979
2005
  </PortForwardProvider>
package/src/api/client.ts CHANGED
@@ -122,7 +122,7 @@ export interface DashboardProblem {
122
122
  namespace: string
123
123
  name: string
124
124
  group?: string
125
- severity: 'critical' | 'high' | 'medium'
125
+ severity: 'critical' | 'high' | 'medium' | 'warning' | 'info'
126
126
  reason: string
127
127
  message: string
128
128
  age: string
@@ -341,8 +341,9 @@ export function useAudit(namespaces: string[] = []) {
341
341
 
342
342
  // Live cluster Issues — the grouped triage queue (radar's /api/issues =
343
343
  // internal/issues.Compose+Classify+Group). Single-cluster here; the Hub fleet
344
- // view fans the same shape across clusters. keepPreviousData semantics via
345
- // placeholderData so the queue doesn't flash empty on the 30s refresh.
344
+ // view fans the same shape across clusters. Do not carry old data across query
345
+ // keys: issues are scope-sensitive, so namespace changes must not show the
346
+ // previous scope's rows while the new scope fetches.
346
347
  // total = rows returned (after the cap); total_matched = rows that matched
347
348
  // before the cap. total_matched > total means the queue was truncated — surface
348
349
  // that honestly rather than presenting a capped list as if it were complete.
@@ -365,7 +366,6 @@ export function useIssues(namespaces: string[] = []) {
365
366
  queryFn: () => fetchJSON(`/issues${params}`),
366
367
  staleTime: 30000,
367
368
  refetchInterval: 30000,
368
- placeholderData: (prev) => prev,
369
369
  })
370
370
  }
371
371
 
@@ -1136,6 +1136,7 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
1136
1136
  const p = new URLSearchParams()
1137
1137
  p.set('namespace', namespace)
1138
1138
  p.set('kind', singularKind)
1139
+ p.set('name', name)
1139
1140
  p.set('include_managed', 'true')
1140
1141
  p.set('since', since)
1141
1142
  return p
@@ -1152,8 +1153,7 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
1152
1153
  const params = baseParams()
1153
1154
  params.set('sources', 'k8s_event')
1154
1155
  params.set('limit', '500')
1155
- const events = await fetchJSON<TimelineEvent[]>(`/changes?${params.toString()}`)
1156
- return events.filter(e => e.name === name)
1156
+ return fetchJSON<TimelineEvent[]>(`/changes?${params.toString()}`)
1157
1157
  },
1158
1158
  enabled,
1159
1159
  refetchInterval: 15000,
@@ -1167,8 +1167,7 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
1167
1167
  const params = baseParams()
1168
1168
  params.set('sources', 'informer,historical')
1169
1169
  params.set('limit', '50')
1170
- const events = await fetchJSON<TimelineEvent[]>(`/changes?${params.toString()}`)
1171
- return events.filter(e => e.name === name)
1170
+ return fetchJSON<TimelineEvent[]>(`/changes?${params.toString()}`)
1172
1171
  },
1173
1172
  enabled,
1174
1173
  refetchInterval: 15000,
@@ -1312,21 +1311,28 @@ export interface TopNodeMetrics {
1312
1311
  memoryAllocatable: number // bytes
1313
1312
  }
1314
1313
 
1315
- // Fetch bulk metrics for all pods (for CPU/Memory columns in resource table)
1316
- export function useTopPodMetrics() {
1314
+ // Fetch bulk metrics for pods (for CPU/Memory columns in resource table)
1315
+ export function useTopPodMetrics(options?: { enabled?: boolean; namespaces?: string[] }) {
1316
+ const namespacesParam = options?.namespaces?.join(',') ?? ''
1317
+ const params = new URLSearchParams()
1318
+ if (namespacesParam) params.set('namespaces', namespacesParam)
1319
+ const queryString = params.toString()
1320
+
1317
1321
  return useQuery<TopPodMetrics[]>({
1318
- queryKey: ['top-pod-metrics'],
1319
- queryFn: () => fetchJSON('/metrics/top/pods'),
1322
+ queryKey: ['top-pod-metrics', namespacesParam],
1323
+ queryFn: () => fetchJSON(`/metrics/top/pods${queryString ? `?${queryString}` : ''}`),
1324
+ enabled: options?.enabled ?? true,
1320
1325
  staleTime: 25000,
1321
1326
  refetchInterval: 30000,
1322
1327
  })
1323
1328
  }
1324
1329
 
1325
1330
  // Fetch bulk metrics for all nodes (for CPU/Memory columns in resource table)
1326
- export function useTopNodeMetrics() {
1331
+ export function useTopNodeMetrics(options?: { enabled?: boolean }) {
1327
1332
  return useQuery<TopNodeMetrics[]>({
1328
1333
  queryKey: ['top-node-metrics'],
1329
1334
  queryFn: () => fetchJSON('/metrics/top/nodes'),
1335
+ enabled: options?.enabled ?? true,
1330
1336
  staleTime: 25000,
1331
1337
  refetchInterval: 30000,
1332
1338
  })
@@ -2305,11 +2311,15 @@ export function useDrainNode() {
2305
2311
  // Helm API hooks
2306
2312
  // ============================================================================
2307
2313
 
2314
+ function helmNamespaceParams(namespaces: string[] = []) {
2315
+ return namespaces.length > 0 ? `?namespaces=${namespaces.join(',')}` : ''
2316
+ }
2317
+
2308
2318
  // List all Helm releases
2309
- export function useHelmReleases(namespace?: string) {
2310
- const params = namespace ? `?namespace=${namespace}` : ''
2319
+ export function useHelmReleases(namespaces: string[] = []) {
2320
+ const params = helmNamespaceParams(namespaces)
2311
2321
  return useQuery<HelmRelease[]>({
2312
- queryKey: ['helm-releases', namespace],
2322
+ queryKey: ['helm-releases', namespaces],
2313
2323
  queryFn: () => fetchJSON(`/helm/releases${params}`),
2314
2324
  staleTime: 30000, // 30 seconds
2315
2325
  })
@@ -2387,10 +2397,10 @@ export function useHelmUpgradeInfo(namespace: string, name: string, enabled = tr
2387
2397
  }
2388
2398
 
2389
2399
  // Batch check for upgrade availability (for list view)
2390
- export function useHelmBatchUpgradeInfo(namespace?: string, enabled = true) {
2391
- const params = namespace ? `?namespace=${namespace}` : ''
2400
+ export function useHelmBatchUpgradeInfo(namespaces: string[] = [], enabled = true) {
2401
+ const params = helmNamespaceParams(namespaces)
2392
2402
  return useQuery<BatchUpgradeInfo>({
2393
- queryKey: ['helm-batch-upgrade-info', namespace],
2403
+ queryKey: ['helm-batch-upgrade-info', namespaces],
2394
2404
  queryFn: () => fetchJSON(`/helm/upgrade-check${params}`),
2395
2405
  enabled,
2396
2406
  staleTime: 30000, // 30 seconds - keep in sync with release list
@@ -83,6 +83,7 @@ const KIND_BY_NAME = new Map(GITOPS_KINDS.map((k) => [k.name, k]))
83
83
  interface ResourceCountsResponse {
84
84
  counts: Record<string, number>
85
85
  forbidden?: string[]
86
+ unavailable?: string[]
86
87
  }
87
88
 
88
89
  interface GitOpsViewProps {
@@ -136,9 +137,15 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
136
137
  initNavigationMap([...(apiResources ?? []), ...GITOPS_KINDS])
137
138
  }, [apiResources])
138
139
 
139
- // Counts come from radar's /api/resource-counts, kind-filtered to the
140
- // GitOps set. The extracted GitOpsTableView reads them for the
141
- // Scope-section mode tabs + the empty-state check.
140
+ const hasGitOpsRowResource = useMemo(() => (
141
+ hasAPIResource(apiResources, 'applications', 'argoproj.io') ||
142
+ hasAPIResource(apiResources, 'kustomizations', 'kustomize.toolkit.fluxcd.io') ||
143
+ hasAPIResource(apiResources, 'helmreleases', 'helm.toolkit.fluxcd.io')
144
+ ), [apiResources])
145
+
146
+ // Counts come from radar's /api/resource-counts. The extracted
147
+ // GitOpsTableView reads only the GitOps keys for mode tabs + empty-state
148
+ // checks.
142
149
  const countsQuery = useQuery({
143
150
  queryKey: ['gitops-resource-counts', namespacesParam],
144
151
  queryFn: async () => {
@@ -217,6 +224,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
217
224
  const [coldRetrying, setColdRetrying] = useState(false)
218
225
  useEffect(() => { coldRetriesRef.current = 0; setColdRetrying(false) }, [apiResources, namespacesParam])
219
226
  useEffect(() => {
227
+ if (!hasGitOpsRowResource || rowsQuery.error) { setColdRetrying(false); return }
220
228
  if (apiResourcesLoading || rowsQuery.isFetching) return
221
229
  if ((rowsQuery.data?.length ?? 0) > 0) { setColdRetrying(false); return }
222
230
  if (coldRetriesRef.current >= 4) { setColdRetrying(false); return }
@@ -224,7 +232,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
224
232
  const t = window.setTimeout(() => { coldRetriesRef.current += 1; refetchTable() }, 2000)
225
233
  return () => window.clearTimeout(t)
226
234
  // eslint-disable-next-line react-hooks/exhaustive-deps
227
- }, [rowsQuery.data, rowsQuery.isFetching, apiResourcesLoading])
235
+ }, [rowsQuery.data, rowsQuery.isFetching, rowsQuery.error, apiResourcesLoading, hasGitOpsRowResource])
228
236
 
229
237
  const handleRowAction = (row: GitOpsRow, action: GitOpsRowAction) => {
230
238
  const { kindName: kind, namespace, name, id } = row
@@ -271,6 +279,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
271
279
  loading={apiResourcesLoading || countsQuery.isLoading || rowsQuery.isLoading || coldRetrying}
272
280
  error={(rowsQuery.error as Error | null) ?? null}
273
281
  counts={countsQuery.data?.counts ?? {}}
282
+ countsUnavailable={countsQuery.data?.unavailable}
274
283
  onRefresh={() => rowsQuery.refetch()}
275
284
  onRowClick={(row) => {
276
285
  const ns = row.namespace || '_'
@@ -958,5 +967,3 @@ function TopologyCounts({ tree }: { tree: GitOpsResourceTree }) {
958
967
  </div>
959
968
  )
960
969
  }
961
-
962
-
@@ -15,23 +15,23 @@ import { InstallWizard } from './InstallWizard'
15
15
  type ViewTab = 'releases' | 'charts'
16
16
 
17
17
  interface HelmViewProps {
18
- namespace: string
18
+ namespaces: string[]
19
19
  selectedRelease?: SelectedHelmRelease | null
20
20
  onReleaseClick?: (namespace: string, name: string, storageNamespace?: string) => void
21
21
  }
22
22
 
23
- export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmViewProps) {
23
+ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmViewProps) {
24
24
  const [activeTab, setActiveTab] = useState<ViewTab>('releases')
25
25
  const [searchTerm, setSearchTerm] = useState('')
26
26
  const [selectedChart, setSelectedChart] = useState<{ repo: string; chart: string; version: string; source: ChartSource } | null>(null)
27
27
 
28
- const { data: releases, isLoading, error: releasesError, refetch: refetchReleases } = useHelmReleases(namespace || undefined)
28
+ const { data: releases, isLoading, error: releasesError, refetch: refetchReleases } = useHelmReleases(namespaces)
29
29
  const isForbidden = isForbiddenError(releasesError)
30
30
  const releasesErrorMessage = releasesError instanceof Error ? releasesError.message : 'Failed to load Helm releases'
31
31
 
32
32
  // Lazy load upgrade info after releases are loaded
33
33
  const { data: upgradeInfo, isLoading: upgradeLoading, error: upgradeError, refetch: refetchUpgradeInfo } = useHelmBatchUpgradeInfo(
34
- namespace || undefined,
34
+ namespaces,
35
35
  Boolean(releases && releases.length > 0)
36
36
  )
37
37
  const upgradeErrorMessage = upgradeError instanceof Error ? upgradeError.message : 'Upgrade checks failed'
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'react'
2
- import type { DashboardResponse, DashboardMetrics, DashboardCRDCount, DashboardProblem } from '../../api/client'
2
+ import type { DashboardResponse, DashboardMetrics, DashboardCRDCount } from '../../api/client'
3
3
  import { HealthRing } from './HealthRing'
4
4
  import {
5
5
  AlertTriangle, CheckCircle, XCircle,
@@ -23,12 +23,13 @@ interface ClusterHealthCardProps {
23
23
  metrics: DashboardMetrics | null
24
24
  metricsServerAvailable: boolean
25
25
  topCRDs?: DashboardCRDCount[] // Loaded lazily, may be undefined
26
- problems: DashboardProblem[]
26
+ issueCount: number
27
+ hasCriticalIssues: boolean
27
28
  nodeVersionSkew: DashboardResponse['nodeVersionSkew']
28
29
  onNavigateToKind: (kind: string, group?: string) => void
29
30
  onNavigateToView: () => void
30
31
  onWarningEventsClick?: () => void
31
- onUnhealthyClick?: () => void
32
+ onIssuesClick?: () => void
32
33
  }
33
34
 
34
35
  function getMetricsInstallHint(platform: string): string {
@@ -113,12 +114,13 @@ export function ClusterHealthCard({
113
114
  metrics,
114
115
  metricsServerAvailable,
115
116
  topCRDs: _topCRDs,
116
- problems,
117
+ issueCount,
118
+ hasCriticalIssues,
117
119
  nodeVersionSkew,
118
120
  onNavigateToKind,
119
121
  onNavigateToView,
120
122
  onWarningEventsClick,
121
- onUnhealthyClick,
123
+ onIssuesClick,
122
124
  }: ClusterHealthCardProps) {
123
125
  void _topCRDs // Reserved for future CRD display
124
126
 
@@ -450,14 +452,14 @@ export function ClusterHealthCard({
450
452
  <span><span className="font-mono">{health.warningEvents}</span> Warning Events</span>
451
453
  </button>
452
454
  )}
453
- {problems.length > 0 && (
455
+ {issueCount > 0 && (
454
456
  <button
455
- onClick={onUnhealthyClick}
456
- title="View timeline of unhealthy/degraded workload events"
457
- className="badge status-unhealthy w-fit gap-1.5 hover:opacity-80 transition-opacity"
457
+ onClick={onIssuesClick}
458
+ title="View grouped live operational issues"
459
+ className={clsx('badge w-fit gap-1.5 hover:opacity-80 transition-opacity', hasCriticalIssues ? 'status-unhealthy' : 'status-degraded')}
458
460
  >
459
461
  <AlertTriangle className="w-3.5 h-3.5 shrink-0" />
460
- <span>View unhealthy workload events</span>
462
+ <span>{pluralize(issueCount, 'Active Issue')}</span>
461
463
  </button>
462
464
  )}
463
465
  </div>
@@ -1,8 +1,6 @@
1
1
  import { useMemo, type ReactNode } from 'react'
2
- import { useDashboard, useDashboardCRDs, useDashboardHelm } from '../../api/client'
3
- import type { DashboardResponse } from '../../api/client'
2
+ import { useDashboard, useDashboardCRDs, useDashboardHelm, useIssues, type IssuesResponse } from '../../api/client'
4
3
  import type { ExtendedMainView, Topology, SelectedResource } from '../../types'
5
- import { kindToPlural } from '../../utils/navigation'
6
4
  import { TopologyPreview } from './TopologyPreview'
7
5
  import { HelmSummary } from './HelmSummary'
8
6
  import { ActivitySummary } from './ActivitySummary'
@@ -11,9 +9,18 @@ import { CertificateHealthCard } from './CertificateHealthCard'
11
9
  import { NetworkPolicyCoverageCard } from './NetworkPolicyCoverageCard'
12
10
  import { CostCard } from './CostCard'
13
11
  import { GitOpsControllersCard } from './GitOpsControllersCard'
14
- import { AuditCard, PaneLoader, StatusDot, mapHealthToTone } from '@skyhook-io/k8s-ui'
12
+ import {
13
+ AuditCard,
14
+ PaneLoader,
15
+ StatusDot,
16
+ categoryLabel,
17
+ groupLabel,
18
+ subjectRef,
19
+ type Issue,
20
+ } from '@skyhook-io/k8s-ui'
21
+ import { formatCompactAge } from '@skyhook-io/k8s-ui/utils/format'
15
22
  import { ClusterHealthCard } from './ClusterHealthCard'
16
- import { AlertTriangle, Loader2, Shield } from 'lucide-react'
23
+ import { AlertTriangle, CheckCircle, Loader2, Shield } from 'lucide-react'
17
24
  import { clsx } from 'clsx'
18
25
 
19
26
  interface HomeViewProps {
@@ -22,10 +29,21 @@ interface HomeViewProps {
22
29
  onNavigateToView: (view: ExtendedMainView, params?: Record<string, string>) => void
23
30
  onNavigateToResourceKind: (kind: string, group?: string, filters?: Record<string, string[]>) => void
24
31
  onNavigateToResource: (resource: SelectedResource) => void
32
+ /**
33
+ * Optional override for the Certificate Health card's click. When an embedded
34
+ * host (Radar Cloud) takes Certs over with its own fleet page, it passes this
35
+ * to route there instead of Radar's TLS-secrets resource list. Omitted in
36
+ * standalone OSS → the card drills into secrets as before.
37
+ */
38
+ onNavigateToCerts?: () => void
25
39
  }
26
40
 
27
- export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToResourceKind, onNavigateToResource }: HomeViewProps) {
41
+ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToResourceKind, onNavigateToResource, onNavigateToCerts }: HomeViewProps) {
28
42
  const { data, isLoading, error } = useDashboard(namespaces)
43
+ const { data: issuesData, isLoading: issuesLoading, isFetching: issuesFetching, error: issuesError } = useIssues(namespaces)
44
+ const issues = issuesData?.issues ?? []
45
+ const issueCount = issuesData?.total_matched ?? issuesData?.total ?? issues.length
46
+ const hasCriticalIssues = issues.some((issue) => issue.severity === 'critical')
29
47
 
30
48
  // SSE is cluster-wide on small/medium clusters; the picker only narrows the
31
49
  // dashboard summary, so re-apply the filter here or the legend disagrees.
@@ -73,8 +91,6 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
73
91
  )
74
92
  }
75
93
 
76
- const hasProblems = data.problems && data.problems.length > 0
77
-
78
94
  const stillLoading = data.deferredLoading || (data.partialData && data.partialData.length > 0)
79
95
 
80
96
  return (
@@ -98,19 +114,17 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
98
114
  metrics={data.metrics}
99
115
  metricsServerAvailable={data.metricsServerAvailable}
100
116
  topCRDs={crdsData?.topCRDs}
101
- problems={data.problems ?? []}
117
+ issueCount={issueCount}
118
+ hasCriticalIssues={hasCriticalIssues}
102
119
  nodeVersionSkew={data.nodeVersionSkew}
103
120
  onNavigateToKind={onNavigateToResourceKind}
104
121
  onNavigateToView={() => onNavigateToView('resources')}
105
122
  onWarningEventsClick={() => onNavigateToView('timeline', { view: 'list', filter: 'warnings', time: 'all' })}
106
- onUnhealthyClick={() => onNavigateToView('timeline', { view: 'list', filter: 'unhealthy', time: 'all' })}
123
+ onIssuesClick={() => onNavigateToView('issues')}
107
124
  />
108
125
 
109
- {/* Row 2: Main content columns teasers left, problems right (if any) */}
110
- <div className={clsx(
111
- 'grid gap-6',
112
- hasProblems ? 'grid-cols-1 lg:grid-cols-[1fr_420px]' : 'grid-cols-1'
113
- )}>
126
+ {/* Row 2: Main content columns - teasers left, issues right */}
127
+ <div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_420px]">
114
128
  {/* Left column: teaser cards */}
115
129
  <div className="flex flex-col gap-6 auto-rows-min">
116
130
  {/* Live band — Topology + Timeline always render, so a fixed 2-up never strands.
@@ -158,7 +172,7 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
158
172
  <BandItem>
159
173
  <CertificateHealthCard
160
174
  data={data.certificateHealth}
161
- onNavigate={() => onNavigateToResourceKind('secrets', undefined, { type: ['TLS'] })}
175
+ onNavigate={onNavigateToCerts ?? (() => onNavigateToResourceKind('secrets', undefined, { type: ['TLS'] }))}
162
176
  />
163
177
  </BandItem>
164
178
  )}
@@ -190,14 +204,18 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
190
204
  )}
191
205
  </div>
192
206
 
193
- {/* Right column: problems panel */}
194
- {hasProblems && (
195
- <ProblemsPanel
196
- problems={data.problems}
197
- onNavigateToIssues={() => onNavigateToView('issues')}
198
- onResourceClick={onNavigateToResource}
199
- />
200
- )}
207
+ <ProblemsPanel
208
+ issues={issues}
209
+ issueCount={issueCount}
210
+ visibility={issuesData?.visibility}
211
+ hasData={!!issuesData}
212
+ isLoading={issuesLoading && !issuesData}
213
+ isRefreshing={issuesFetching && !!issuesData}
214
+ error={issuesError}
215
+ totalReturned={issues.length}
216
+ onNavigateToIssues={() => onNavigateToView('issues')}
217
+ onResourceClick={onNavigateToResource}
218
+ />
201
219
  </div>
202
220
  </div>
203
221
  </div>
@@ -216,19 +234,56 @@ function BandItem({ children }: { children: ReactNode }) {
216
234
  // ============================================================================
217
235
 
218
236
  interface ProblemsPanelProps {
219
- problems: DashboardResponse['problems']
237
+ issues: Issue[]
238
+ issueCount: number
239
+ visibility?: IssuesResponse['visibility']
240
+ hasData: boolean
241
+ isLoading: boolean
242
+ isRefreshing: boolean
243
+ error: unknown
244
+ totalReturned: number
220
245
  onNavigateToIssues: () => void
221
246
  onResourceClick: (resource: SelectedResource) => void
222
247
  }
223
248
 
224
249
 
225
- function ProblemsPanel({ problems, onNavigateToIssues, onResourceClick }: ProblemsPanelProps) {
250
+ function ProblemsPanel({
251
+ issues,
252
+ issueCount,
253
+ visibility,
254
+ hasData,
255
+ isLoading,
256
+ isRefreshing,
257
+ error,
258
+ totalReturned,
259
+ onNavigateToIssues,
260
+ onResourceClick,
261
+ }: ProblemsPanelProps) {
262
+ const hasCriticalIssues = issues.some((issue) => issue.severity === 'critical')
263
+ const hasIssues = issueCount > 0
264
+ const hasHardError = !!error && !hasData
265
+ const hasLimitedVisibility = !!visibility?.impact
266
+ const isTruncated = issueCount > totalReturned
267
+ const titleClass = hasCriticalIssues
268
+ ? 'text-red-500'
269
+ : hasIssues || hasHardError || hasLimitedVisibility
270
+ ? 'text-amber-500'
271
+ : 'text-theme-text-secondary'
272
+ const countClass = hasHardError
273
+ ? 'status-unknown'
274
+ : hasCriticalIssues
275
+ ? 'status-unhealthy'
276
+ : hasIssues || hasLimitedVisibility
277
+ ? 'status-degraded'
278
+ : 'status-healthy'
279
+ const countLabel = isLoading ? '...' : hasHardError ? 'error' : String(issueCount)
280
+
226
281
  return (
227
282
  <div className="rounded-xl bg-theme-surface shadow-theme-sm flex flex-col lg:max-h-[calc(100vh-280px)] lg:sticky lg:top-0">
228
283
  <div className="flex items-center justify-between px-5 py-3 border-b border-theme-border/50 shrink-0">
229
284
  <div className="flex items-center gap-2">
230
- <AlertTriangle className="w-4 h-4 text-red-500" />
231
- <span className="text-xs font-semibold uppercase tracking-wider text-red-500">Active Issues</span>
285
+ <AlertTriangle className={clsx('w-4 h-4', titleClass)} />
286
+ <span className={clsx('text-xs font-semibold uppercase tracking-wider', titleClass)}>Active Issues</span>
232
287
  </div>
233
288
  <div className="flex items-center gap-2">
234
289
  <button
@@ -238,41 +293,141 @@ function ProblemsPanel({ problems, onNavigateToIssues, onResourceClick }: Proble
238
293
  >
239
294
  View all
240
295
  </button>
241
- <span className="badge status-unhealthy rounded-full">{problems.length}</span>
296
+ {isRefreshing && !isLoading && (
297
+ <Loader2 className="h-3.5 w-3.5 animate-spin text-theme-text-tertiary" aria-label="Refreshing issues" />
298
+ )}
299
+ <span className={clsx('badge rounded-full', countClass)}>{countLabel}</span>
242
300
  </div>
243
301
  </div>
244
302
  <div className="overflow-y-auto flex-1 min-h-0">
245
- <div className="divide-y divide-theme-border">
246
- {problems.map((p, i) => (
247
- <button
248
- key={`${p.kind}-${p.namespace}-${p.name}-${i}`}
249
- className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-theme-hover transition-colors text-left"
250
- onClick={() => onResourceClick({
251
- kind: kindToPlural(p.kind),
252
- namespace: p.namespace,
253
- name: p.name,
254
- group: p.group,
255
- })}
256
- >
257
- <StatusDot tone={mapHealthToTone(p.severity)} className="shrink-0" />
258
- <div className="min-w-0 flex-1">
259
- <div className="flex items-center gap-1.5">
260
- <span className="text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 py-0.5 rounded">{p.kind}</span>
261
- <span className="text-xs text-theme-text-primary truncate font-medium">{p.name}</span>
262
- <span className="text-[10px] text-theme-text-tertiary ml-auto shrink-0">{p.duration || p.age}</span>
263
- </div>
264
- <div className="flex items-center gap-1.5 mt-0.5">
265
- <span className="text-[11px] text-theme-text-secondary truncate">{p.reason}</span>
266
- <span className="text-[10px] text-theme-text-tertiary shrink-0">{p.namespace}</span>
267
- </div>
268
- {p.message && (
269
- <div className="text-[10px] text-theme-text-tertiary truncate mt-0.5">{p.message}</div>
270
- )}
303
+ {isLoading ? (
304
+ <ProblemsPanelState
305
+ icon={<Loader2 className="h-5 w-5 animate-spin text-theme-text-tertiary" />}
306
+ title="Loading issues"
307
+ body="Checking live cluster issues for the selected scope."
308
+ />
309
+ ) : hasHardError ? (
310
+ <ProblemsPanelState
311
+ icon={<AlertTriangle className="h-5 w-5 text-amber-500" />}
312
+ title="Issues unavailable"
313
+ body={formatIssueError(error)}
314
+ />
315
+ ) : (
316
+ <>
317
+ {!!error && (
318
+ <ProblemsPanelNotice tone="warning">
319
+ Issue refresh failed. Showing the last successful result.
320
+ </ProblemsPanelNotice>
321
+ )}
322
+ {visibility?.impact && (
323
+ <ProblemsPanelNotice tone="warning">
324
+ Limited visibility - {visibility.impact} Results may be incomplete.
325
+ </ProblemsPanelNotice>
326
+ )}
327
+ {isTruncated && (
328
+ <ProblemsPanelNotice tone="neutral">
329
+ Showing {totalReturned} of {issueCount} issues. Narrow by namespace to see the rest.
330
+ </ProblemsPanelNotice>
331
+ )}
332
+
333
+ {issues.length === 0 ? (
334
+ <ProblemsPanelState
335
+ icon={
336
+ hasLimitedVisibility
337
+ ? <AlertTriangle className="h-5 w-5 text-amber-500" />
338
+ : <CheckCircle className="h-5 w-5 text-green-500" />
339
+ }
340
+ title={hasLimitedVisibility ? 'No visible active issues' : 'No active issues'}
341
+ body={
342
+ hasLimitedVisibility
343
+ ? 'Radar could not read every workload source in this scope.'
344
+ : 'No critical or warning issues across the selected scope.'
345
+ }
346
+ />
347
+ ) : (
348
+ <div className="divide-y divide-theme-border">
349
+ {issues.map((issue) => {
350
+ const ref = subjectRef(issue)
351
+ const age = issue.first_seen
352
+ ? issue.issue_timing === 'started_at_resource_creation'
353
+ ? 'since deploy'
354
+ : formatCompactAge(issue.first_seen)
355
+ : ''
356
+
357
+ return (
358
+ <button
359
+ key={issue.id}
360
+ className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-theme-hover transition-colors text-left"
361
+ onClick={() => onResourceClick({
362
+ kind: ref.kind,
363
+ namespace: ref.namespace ?? '',
364
+ name: ref.name,
365
+ group: ref.group ?? '',
366
+ })}
367
+ >
368
+ <StatusDot tone={issue.severity === 'critical' ? 'unhealthy' : 'degraded'} className="shrink-0" />
369
+ <div className="min-w-0 flex-1">
370
+ <div className="flex items-center gap-1.5">
371
+ <span className="text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 py-0.5 rounded">{issue.kind}</span>
372
+ <span className="text-xs text-theme-text-primary truncate font-medium">{issue.name}</span>
373
+ {age && <span className="text-[10px] text-theme-text-tertiary ml-auto shrink-0">{age}</span>}
374
+ </div>
375
+ <div className="flex items-center gap-1.5 mt-0.5">
376
+ <span className="text-[11px] text-theme-text-secondary truncate">{categoryLabel(issue.category)}</span>
377
+ <span className="text-[10px] text-theme-text-tertiary shrink-0">{groupLabel(issue.category_group)}</span>
378
+ {issue.namespace && <span className="text-[10px] text-theme-text-tertiary shrink-0">{issue.namespace}</span>}
379
+ </div>
380
+ {(issue.reason || issue.message) && (
381
+ <div className="text-[10px] text-theme-text-tertiary truncate mt-0.5">
382
+ {issue.reason}
383
+ {issue.reason && issue.message ? ' - ' : ''}
384
+ {issue.message}
385
+ </div>
386
+ )}
387
+ </div>
388
+ </button>
389
+ )
390
+ })}
271
391
  </div>
272
- </button>
273
- ))}
274
- </div>
392
+ )}
393
+ </>
394
+ )}
275
395
  </div>
276
396
  </div>
277
397
  )
278
398
  }
399
+
400
+ function ProblemsPanelState({ icon, title, body }: { icon: ReactNode; title: string; body: string }) {
401
+ return (
402
+ <div className="flex min-h-[220px] flex-col items-center justify-center gap-2 px-6 py-10 text-center">
403
+ <div className="flex h-9 w-9 items-center justify-center rounded-full bg-theme-elevated">
404
+ {icon}
405
+ </div>
406
+ <p className="text-sm font-medium text-theme-text-primary">{title}</p>
407
+ <p className="max-w-[280px] text-xs leading-5 text-theme-text-secondary">{body}</p>
408
+ </div>
409
+ )
410
+ }
411
+
412
+ function ProblemsPanelNotice({ tone, children }: { tone: 'warning' | 'neutral'; children: ReactNode }) {
413
+ return (
414
+ <div className="px-3 pt-3">
415
+ <div
416
+ className={clsx(
417
+ 'rounded-lg border px-3 py-2 text-xs leading-5',
418
+ tone === 'warning'
419
+ ? 'border-amber-500/20 bg-amber-500/10 text-theme-text-secondary'
420
+ : 'border-theme-border bg-theme-elevated text-theme-text-secondary',
421
+ )}
422
+ >
423
+ {children}
424
+ </div>
425
+ </div>
426
+ )
427
+ }
428
+
429
+ function formatIssueError(error: unknown): string {
430
+ return error instanceof Error && error.message
431
+ ? error.message
432
+ : 'Failed to load active issues.'
433
+ }
@@ -23,6 +23,7 @@ import { getSkeletonYaml } from '../../utils/skeleton-yaml'
23
23
  interface ResourceCountsResponse {
24
24
  counts: Record<string, number>
25
25
  forbidden?: string[]
26
+ unavailable?: string[]
26
27
  }
27
28
 
28
29
  interface ResourcesViewProps {
@@ -36,6 +37,14 @@ interface ResourcesViewProps {
36
37
 
37
38
  type SelectedKindInfo = { name: string; kind: string; group: string } | null
38
39
 
40
+ const LARGE_RESOURCE_LIST_LIMIT = 25000
41
+ const LARGE_RESOURCE_LIST_GUARD_KEYS = new Set([
42
+ 'Pod',
43
+ 'Event',
44
+ 'apps/ReplicaSet',
45
+ 'discovery.k8s.io/EndpointSlice',
46
+ ])
47
+
39
48
  const deniedWorkloadWrites: WorkloadWritePermissions = {
40
49
  deployments: false,
41
50
  daemonSets: false,
@@ -43,6 +52,10 @@ const deniedWorkloadWrites: WorkloadWritePermissions = {
43
52
  rollouts: false,
44
53
  }
45
54
 
55
+ function resourceCountKey(kind: NonNullable<SelectedKindInfo>): string {
56
+ return kind.group ? `${kind.group}/${kind.kind}` : kind.kind
57
+ }
58
+
46
59
  export function ResourcesView({ namespaces, selectedResource, onResourceClick, onResourceClickYaml, onKindChange, onClearNamespaces }: ResourcesViewProps) {
47
60
  const location = useLocation()
48
61
  const navigate = useNavigate()
@@ -93,7 +106,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
93
106
 
94
107
  // Lightweight resource counts for sidebar badges (~2KB instead of ~608MB)
95
108
  const namespacesParam = namespaces.join(',')
96
- const { data: countsData } = useQuery({
109
+ const { data: countsData, isError: countsIsError } = useQuery({
97
110
  queryKey: ['resource-counts', namespacesParam],
98
111
  queryFn: async () => {
99
112
  const params = new URLSearchParams()
@@ -123,6 +136,29 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
123
136
  return match?.isCrd ?? (!!selectedKind.group) // default: has group = likely CRD
124
137
  }, [selectedKind, apiResources])
125
138
 
139
+ const selectedCountKey = selectedKind ? resourceCountKey(selectedKind) : ''
140
+ const selectedCount = selectedCountKey ? countsData?.counts[selectedCountKey] : undefined
141
+ const selectedCountUnavailable = selectedCountKey ? countsData?.unavailable?.includes(selectedCountKey) ?? false : false
142
+ const isSelectedKindGuarded = selectedCountKey !== '' && LARGE_RESOURCE_LIST_GUARD_KEYS.has(selectedCountKey)
143
+ const waitingForGuardCount = isSelectedKindGuarded && !countsData && !countsIsError
144
+ const largeListBlocked = isSelectedKindGuarded && countsData != null && (selectedCountUnavailable || (selectedCount ?? 0) > LARGE_RESOURCE_LIST_LIMIT)
145
+ const selectedKindQueryBlocked = waitingForGuardCount || largeListBlocked
146
+ const podCount = countsData?.counts.Pod
147
+ const podCountUnavailable = countsData?.unavailable?.includes('Pod') ?? false
148
+ const podCountAllowsBulkMetrics = countsData != null && !podCountUnavailable && (podCount ?? 0) <= LARGE_RESOURCE_LIST_LIMIT
149
+ const selectedKindName = selectedKind?.name.toLowerCase() ?? ''
150
+ const topPodMetricsEnabled = selectedKindName === 'pods' && podCountAllowsBulkMetrics
151
+ const topNodeMetricsEnabled = selectedKindName === 'nodes' && namespaces.length === 0 && podCountAllowsBulkMetrics
152
+ const largeListGuard = selectedKind && largeListBlocked
153
+ ? {
154
+ kind: selectedKind.name,
155
+ count: selectedCountUnavailable ? undefined : selectedCount,
156
+ reason: selectedCountUnavailable ? 'count-unavailable' as const : 'too-many' as const,
157
+ limit: LARGE_RESOURCE_LIST_LIMIT,
158
+ namespaces,
159
+ }
160
+ : null
161
+
126
162
  // Fetch full data only for the selected kind
127
163
  const selectedKindQuery = useQuery({
128
164
  queryKey: ['resources', selectedKind?.name, isSelectedCrd ? selectedKind?.group : '', namespaces],
@@ -156,7 +192,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
156
192
  }
157
193
  return res.json()
158
194
  },
159
- enabled: !!selectedKind,
195
+ enabled: !!selectedKind && !selectedKindQueryBlocked,
160
196
  staleTime: 30000,
161
197
  refetchInterval: 120000, // Safety net — SSE k8s_event drives near-real-time invalidation
162
198
  retry: (failureCount: number, error: Error) => {
@@ -169,17 +205,17 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
169
205
  const selectedKindQueryResult: ResourceQueryResult | undefined = useMemo(() => {
170
206
  if (!selectedKind) return undefined
171
207
  return {
172
- data: selectedKindQuery.data as any[] | undefined,
173
- isLoading: selectedKindQuery.isLoading,
174
- error: selectedKindQuery.error,
208
+ data: selectedKindQueryBlocked ? [] : selectedKindQuery.data as any[] | undefined,
209
+ isLoading: waitingForGuardCount || selectedKindQuery.isLoading,
210
+ error: selectedKindQueryBlocked ? undefined : selectedKindQuery.error,
175
211
  refetch: selectedKindQuery.refetch,
176
212
  dataUpdatedAt: selectedKindQuery.dataUpdatedAt,
177
213
  }
178
- }, [selectedKind, selectedKindQuery.data, selectedKindQuery.isLoading, selectedKindQuery.error, selectedKindQuery.refetch, selectedKindQuery.dataUpdatedAt])
214
+ }, [selectedKind, selectedKindQueryBlocked, waitingForGuardCount, selectedKindQuery.data, selectedKindQuery.isLoading, selectedKindQuery.error, selectedKindQuery.refetch, selectedKindQuery.dataUpdatedAt])
179
215
 
180
216
  // Metrics
181
- const { data: topPodMetrics } = useTopPodMetrics()
182
- const { data: topNodeMetrics } = useTopNodeMetrics()
217
+ const { data: topPodMetrics } = useTopPodMetrics({ enabled: topPodMetricsEnabled, namespaces })
218
+ const { data: topNodeMetrics } = useTopNodeMetrics({ enabled: topNodeMetricsEnabled })
183
219
 
184
220
  // Certificate expiry
185
221
  const { data: certExpiry, isError: certExpiryError } = useSecretCertExpiry()
@@ -248,7 +284,9 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
248
284
  // Lightweight counts for sidebar (replaces 233 parallel queries)
249
285
  resourceCounts={countsData?.counts}
250
286
  resourceForbidden={countsData?.forbidden}
287
+ resourceUnavailable={countsData?.unavailable}
251
288
  selectedKindQuery={selectedKindQueryResult}
289
+ largeListGuard={largeListGuard}
252
290
  onSelectedKindChange={setSelectedKind}
253
291
  topPodMetrics={topPodMetrics}
254
292
  topNodeMetrics={topNodeMetrics}
@@ -13,6 +13,14 @@
13
13
  import { createContext, useContext } from 'react';
14
14
  import type { ReactNode } from 'react';
15
15
 
16
+ /**
17
+ * Per-cluster destinations an embedded host can take over with its own
18
+ * fleet-scoped pages. See `fleetTakeoverHref`. 'issues' | 'gitops' | 'checks'
19
+ * are also Radar view names (so route entry redirects too); 'certs' is
20
+ * card-only (Radar has no certs view).
21
+ */
22
+ export type FleetTakeoverTarget = 'issues' | 'gitops' | 'checks' | 'certs';
23
+
16
24
  interface NavCustomizationBase {
17
25
  /** Replaces Radar's Skyhook/radar logo + wordmark. */
18
26
  brandSlot?: ReactNode;
@@ -32,15 +40,32 @@ interface NavCustomizationBase {
32
40
  group?: string;
33
41
  }) => string;
34
42
  /**
35
- * When set, Radar treats the host's fleet Checks page as the one canonical
36
- * Checks surface in Cloud: it removes its own per-cluster Audit tab and
37
- * redirects any route to /audit (the Home "Cluster Audit" card, ⌘K,
38
- * bookmarks) to the URL returned here the host's fleet Checks page scoped
39
- * to this cluster. Navigated via window.location.replace (a cross-document
40
- * hop into the host's router) so the transient /audit URL stays out of
41
- * history. This keeps the per-cluster view and the host's fleet nav from
42
- * presenting two diverging Checks surfaces. Standalone Radar omits this and
43
- * keeps its single-cluster Audit tab.
43
+ * Lets an embedded host (e.g. Radar Cloud) take over selected per-cluster
44
+ * destinations with its OWN fleet pages scoped to this cluster, instead of
45
+ * Radar rendering them inline. Given a semantic target the host returns the
46
+ * URL to navigate to, or `undefined`/omits the hook to let Radar render its
47
+ * own view as usual (standalone OSS does the latter for everything).
48
+ *
49
+ * This is how the Home dashboard's "fleet-shaped" cards reach the host's
50
+ * canonical surfaces rather than a second, diverging per-cluster copy:
51
+ * - 'issues' → the Active Issues panel + cluster-health issues count
52
+ * - 'gitops' → the GitOps controllers card
53
+ * - 'checks' → the Cluster Audit card (and any route to /audit; legacy
54
+ * `clusterChecksHref` folded in here)
55
+ * - 'certs' → the Certificate Health card
56
+ *
57
+ * View-shaped targets (issues / gitops / checks) are also honored for any
58
+ * entry into that view — ⌘K, bookmarks, deep links — via a redirect effect
59
+ * in App.tsx, using window.location.replace so the transient /<view> URL
60
+ * stays out of history. 'certs' has no Radar view, so only the card consults
61
+ * it (window.location.assign — a real forward navigation the user initiated).
62
+ */
63
+ fleetTakeoverHref?: (target: FleetTakeoverTarget) => string | undefined;
64
+ /**
65
+ * @deprecated Superseded by `fleetTakeoverHref('checks')`. Kept so consumers
66
+ * still on the pre-1.7 hook keep working (App.tsx folds it into the 'checks'
67
+ * target) — this makes adding `fleetTakeoverHref` an additive, non-breaking
68
+ * change. Remove in a major release once all consumers have migrated.
44
69
  */
45
70
  clusterChecksHref?: () => string;
46
71
  /**
package/src/index.ts CHANGED
@@ -14,7 +14,7 @@ export {
14
14
  getAuthHeaders,
15
15
  getCredentialsMode,
16
16
  } from './api/config';
17
- export type { NavCustomization } from './context/NavCustomization';
17
+ export type { NavCustomization, FleetTakeoverTarget } from './context/NavCustomization';
18
18
  export { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay';
19
19
 
20
20
  // Shared cluster-switcher primitive — re-exported from @skyhook-io/k8s-ui so
package/src/main.tsx CHANGED
@@ -1,10 +1,15 @@
1
1
  import React from 'react'
2
2
  import ReactDOM from 'react-dom/client'
3
- import './monaco-setup'
3
+ import { configureBundledMonaco } from './monaco-setup'
4
4
  import { RadarApp } from './RadarApp'
5
5
  import { openExternal } from './utils/navigation'
6
6
  import './index.css'
7
7
 
8
+ // Keep this as an explicit call: side-effect-only imports of the Monaco setup can
9
+ // be dropped by production tree-shaking, which makes offline desktop builds fall
10
+ // back to Monaco's CDN loader.
11
+ configureBundledMonaco()
12
+
8
13
  // Intercept external link clicks in the Wails desktop app.
9
14
  // <a target="_blank"> is swallowed by WKWebView/WebView2 — route through openExternal()
10
15
  // which calls the backend /api/desktop/open-url endpoint to open in the system browser.
@@ -3,8 +3,8 @@
3
3
  // over the network, so the YAML editor never loads in airgapped / offline
4
4
  // deployments. Bundling makes the binary fully self-contained.
5
5
  //
6
- // Imported for side effects from main.tsx (Radar's binary entry) only — library
7
- // consumers (e.g. Radar Hub) keep the default CDN loader unless they opt in.
6
+ // Called from main.tsx (Radar's binary entry) only — library consumers
7
+ // (e.g. Radar Hub) keep the default CDN loader unless they opt in.
8
8
  //
9
9
  // Import the editor API + YAML grammar directly rather than the `monaco-editor`
10
10
  // barrel: the barrel pulls in the JSON/CSS/HTML/TypeScript language services,
@@ -15,12 +15,19 @@ import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution'
15
15
  import { loader } from '@monaco-editor/react'
16
16
  import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
17
17
 
18
- // YAML has no dedicated Monaco language worker — the base editor worker covers
19
- // everything we use, so route every label to it.
20
- ;(self as typeof self & { MonacoEnvironment?: { getWorker(): Worker } }).MonacoEnvironment = {
21
- getWorker() {
22
- return new EditorWorker()
23
- },
24
- }
18
+ let configured = false
19
+
20
+ export function configureBundledMonaco() {
21
+ if (configured) return
22
+ configured = true
25
23
 
26
- loader.config({ monaco })
24
+ // YAML has no dedicated Monaco language worker — the base editor worker covers
25
+ // everything we use, so route every label to it.
26
+ ;(globalThis as typeof globalThis & { MonacoEnvironment?: { getWorker(): Worker } }).MonacoEnvironment = {
27
+ getWorker() {
28
+ return new EditorWorker()
29
+ },
30
+ }
31
+
32
+ loader.config({ monaco })
33
+ }