@skyhook-io/radar-app 1.6.0 → 1.6.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
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
@@ -56,7 +56,8 @@ import { SettingsDialog } from './components/settings/SettingsDialog'
56
56
  import { MyPermissionsDialog } from './components/settings/MyPermissionsDialog'
57
57
  import type { TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
58
58
  import { kindToPlural, openExternal, apiVersionToGroup, buildWorkloadPath, searchHitToSelectedResource } from './utils/navigation'
59
- import { Omnibar, type OmnibarHandle } from './components/ui/Omnibar'
59
+ import { type OmnibarHandle } from './components/ui/Omnibar'
60
+ import { RadarOmnibar } from './components/ui/RadarOmnibar'
60
61
  import type { ContextSwitcherHandle } from './components/ContextSwitcher'
61
62
 
62
63
  // All possible node kinds (core + GitOps)
@@ -541,6 +542,10 @@ function AppInner() {
541
542
  description: 'Show keyboard shortcuts',
542
543
  category: 'General' as const,
543
544
  scope: 'global' as const,
545
+ // Radar owns the shortcut registry even in a chromeless embed, so its `?`
546
+ // overlay is the one that actually lists the working shortcuts. The host
547
+ // (Radar Hub) drives it from its own chrome by dispatching a `?` keydown —
548
+ // it has no registry of its own to populate a competing overlay with.
544
549
  handler: () => setShowHelp(prev => !prev),
545
550
  },
546
551
  {
@@ -550,8 +555,10 @@ function AppInner() {
550
555
  category: 'General' as const,
551
556
  scope: 'global' as const,
552
557
  allowInInputs: true,
553
- // Standalone focuses the top-center omnibar; embedded opens the modal.
554
- handler: () => { if (showNavRail) omnibarRef.current?.focus(); else setShowCommandPalette(true) },
558
+ // Standalone focuses the top-center omnibar; embedded opens the modal. In
559
+ // a chromeless embed the HOST owns ⌘K (its own omnibar), so do nothing —
560
+ // otherwise both the host omnibar and Radar's palette fire on one ⌘K.
561
+ handler: () => { if (showNavRail) omnibarRef.current?.focus(); else if (!chromeless) setShowCommandPalette(true) },
555
562
  },
556
563
  {
557
564
  id: 'diagnostics',
@@ -1130,7 +1137,7 @@ function AppInner() {
1130
1137
  // Fleet mode overrides visible kinds to show only CAPI resources + Node
1131
1138
  const effectiveKinds = topologyMode === 'fleet' ? FLEET_MODE_KINDS : visibleKinds
1132
1139
 
1133
- // Filter by namespace (frontend-side) and by visible kinds
1140
+ // Filter by namespace (client-side) and by visible kinds
1134
1141
  const nsSet = namespaces.length > 0 ? new Set(namespaces) : null
1135
1142
  const filteredNodes = displayedTopology.nodes.filter(node =>
1136
1143
  effectiveKinds.has(node.kind) &&
@@ -1352,7 +1359,7 @@ function AppInner() {
1352
1359
  Fills the space the pill bar left; embedded keeps the pills + modal. */}
1353
1360
  {showNavRail && (
1354
1361
  <div className="hidden sm:flex flex-1 justify-center min-w-0 px-3">
1355
- <Omnibar
1362
+ <RadarOmnibar
1356
1363
  ref={omnibarRef}
1357
1364
  onNavigateView={(view) => setMainView(view)}
1358
1365
  onNavigateKind={(kind, group) => {
@@ -1603,21 +1610,7 @@ function AppInner() {
1603
1610
  console.debug('[filters] App.onNavigateToResourceKind: navigating to', targetURL)
1604
1611
  navigate({ pathname: `/resources/${kind}`, search: newParams.toString() })
1605
1612
  }}
1606
- onNavigateToResource={(resource) => {
1607
- // Switch to resources view and open the resource detail drawer
1608
- setSelectedResource(resource)
1609
- const newParams = new URLSearchParams(searchParams)
1610
- newParams.delete('kind') // kind is now in the path
1611
- newParams.delete('mode')
1612
- newParams.delete('group')
1613
- newParams.delete('resource')
1614
- if (resource.group) {
1615
- newParams.set('apiGroup', resource.group)
1616
- } else {
1617
- newParams.delete('apiGroup')
1618
- }
1619
- navigate({ pathname: `/resources/${resource.kind}`, search: newParams.toString() })
1620
- }}
1613
+ onNavigateToResource={navigateFromIssue}
1621
1614
  />
1622
1615
  )}
1623
1616
 
@@ -1851,6 +1844,9 @@ function AppInner() {
1851
1844
  <ResourceDetailDrawer
1852
1845
  resource={drawerResource}
1853
1846
  initialTab={drawerInitialTab}
1847
+ // No Radar header in chromeless embeds (Radar Hub) — anchor the drawer
1848
+ // to the top of the content area instead of leaving a 49px gap.
1849
+ headerHeight={chromeless ? 0 : undefined}
1854
1850
  isOpen={resourceDrawer.isOpen}
1855
1851
  expanded={drawerExpanded}
1856
1852
  onClose={() => { setSelectedResource(null); setDrawerInitialTab('detail'); setDrawerExpanded(false) }}
@@ -1964,8 +1960,9 @@ function AppInner() {
1964
1960
  />
1965
1961
  <MyPermissionsDialog open={showMyPermissions} onClose={() => setShowMyPermissions(false)} />
1966
1962
 
1967
- {/* Debug overlay - only in dev mode */}
1968
- {import.meta.env.DEV && <DebugOverlay />}
1963
+ {/* Debug overlay dev mode, standalone only. Embedded hosts (Radar Hub)
1964
+ own their own dev tooling; ours would collide with theirs bottom-right. */}
1965
+ {import.meta.env.DEV && showNavRail && <DebugOverlay />}
1969
1966
  </div>
1970
1967
  </div>
1971
1968
  </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
 
@@ -717,6 +717,11 @@ export interface SearchHit {
717
717
  name: string
718
718
  matched?: SearchMatchedField[]
719
719
  summaryContext?: SearchSummaryContext
720
+ /** Embedder (Radar Hub) only: the cluster this hit belongs to, for
721
+ * cross-cluster fleet search. Standalone Radar (single-cluster) leaves these
722
+ * unset — the omnibar keys + displays the cluster only when present. */
723
+ cluster?: string
724
+ clusterName?: string
720
725
  }
721
726
 
722
727
  export interface SearchResult {
@@ -817,7 +822,7 @@ export function useAuthMe() {
817
822
  }
818
823
 
819
824
  // Tier ordering for Cloud-role gates. Mirrors radar OSS pkg/auth
820
- // CloudRole.AtLeast — the SPA must agree with the backend on what
825
+ // CloudRole.AtLeast — the frontend must agree with the backend on what
821
826
  // "member-or-higher" means; otherwise we'd hide a button the
822
827
  // backend would happily honor (or vice versa).
823
828
  const CLOUD_ROLE_RANK: Record<string, number> = { viewer: 1, member: 2, owner: 3 }
@@ -1036,6 +1041,7 @@ export interface UseChangesOptions {
1036
1041
  filter?: string // Filter preset name ('default', 'all', 'warnings-only', 'workloads')
1037
1042
  includeK8sEvents?: boolean
1038
1043
  includeManaged?: boolean
1044
+ includeDeleted?: boolean
1039
1045
  limit?: number
1040
1046
  enabled?: boolean
1041
1047
  }
@@ -1060,7 +1066,7 @@ function getTimeRangeDate(range: TimeRange): Date | null {
1060
1066
  }
1061
1067
 
1062
1068
  export function useChanges(options: UseChangesOptions = {}) {
1063
- const { namespaces = [], kind, timeRange = '1h', filter = 'all', includeK8sEvents = true, includeManaged = false, limit = 200, enabled = true } = options
1069
+ const { namespaces = [], kind, timeRange = '1h', filter = 'all', includeK8sEvents = true, includeManaged = false, includeDeleted = true, limit = 200, enabled = true } = options
1064
1070
 
1065
1071
  const params = new URLSearchParams()
1066
1072
  if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
@@ -1068,6 +1074,7 @@ export function useChanges(options: UseChangesOptions = {}) {
1068
1074
  if (filter) params.set('filter', filter)
1069
1075
  if (!includeK8sEvents) params.set('include_k8s_events', 'false')
1070
1076
  if (includeManaged) params.set('include_managed', 'true')
1077
+ if (!includeDeleted) params.set('include_deleted', 'false')
1071
1078
  params.set('limit', String(limit))
1072
1079
 
1073
1080
  const sinceDate = getTimeRangeDate(timeRange)
@@ -1078,7 +1085,7 @@ export function useChanges(options: UseChangesOptions = {}) {
1078
1085
  const queryString = params.toString()
1079
1086
 
1080
1087
  return useQuery<TimelineEvent[]>({
1081
- queryKey: ['changes', namespaces, kind, timeRange, filter, includeK8sEvents, includeManaged, limit],
1088
+ queryKey: ['changes', namespaces, kind, timeRange, filter, includeK8sEvents, includeManaged, includeDeleted, limit],
1082
1089
  queryFn: () => fetchJSON(`/changes${queryString ? `?${queryString}` : ''}`),
1083
1090
  staleTime: 5000, // Consider data stale after 5 seconds to ensure fresh data on navigation
1084
1091
  refetchInterval: 60000, // SSE handles real-time updates; this is a fallback
@@ -1129,6 +1136,7 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
1129
1136
  const p = new URLSearchParams()
1130
1137
  p.set('namespace', namespace)
1131
1138
  p.set('kind', singularKind)
1139
+ p.set('name', name)
1132
1140
  p.set('include_managed', 'true')
1133
1141
  p.set('since', since)
1134
1142
  return p
@@ -1145,8 +1153,7 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
1145
1153
  const params = baseParams()
1146
1154
  params.set('sources', 'k8s_event')
1147
1155
  params.set('limit', '500')
1148
- const events = await fetchJSON<TimelineEvent[]>(`/changes?${params.toString()}`)
1149
- return events.filter(e => e.name === name)
1156
+ return fetchJSON<TimelineEvent[]>(`/changes?${params.toString()}`)
1150
1157
  },
1151
1158
  enabled,
1152
1159
  refetchInterval: 15000,
@@ -1160,8 +1167,7 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
1160
1167
  const params = baseParams()
1161
1168
  params.set('sources', 'informer,historical')
1162
1169
  params.set('limit', '50')
1163
- const events = await fetchJSON<TimelineEvent[]>(`/changes?${params.toString()}`)
1164
- return events.filter(e => e.name === name)
1170
+ return fetchJSON<TimelineEvent[]>(`/changes?${params.toString()}`)
1165
1171
  },
1166
1172
  enabled,
1167
1173
  refetchInterval: 15000,
@@ -1305,21 +1311,28 @@ export interface TopNodeMetrics {
1305
1311
  memoryAllocatable: number // bytes
1306
1312
  }
1307
1313
 
1308
- // Fetch bulk metrics for all pods (for CPU/Memory columns in resource table)
1309
- 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
+
1310
1321
  return useQuery<TopPodMetrics[]>({
1311
- queryKey: ['top-pod-metrics'],
1312
- queryFn: () => fetchJSON('/metrics/top/pods'),
1322
+ queryKey: ['top-pod-metrics', namespacesParam],
1323
+ queryFn: () => fetchJSON(`/metrics/top/pods${queryString ? `?${queryString}` : ''}`),
1324
+ enabled: options?.enabled ?? true,
1313
1325
  staleTime: 25000,
1314
1326
  refetchInterval: 30000,
1315
1327
  })
1316
1328
  }
1317
1329
 
1318
1330
  // Fetch bulk metrics for all nodes (for CPU/Memory columns in resource table)
1319
- export function useTopNodeMetrics() {
1331
+ export function useTopNodeMetrics(options?: { enabled?: boolean }) {
1320
1332
  return useQuery<TopNodeMetrics[]>({
1321
1333
  queryKey: ['top-node-metrics'],
1322
1334
  queryFn: () => fetchJSON('/metrics/top/nodes'),
1335
+ enabled: options?.enabled ?? true,
1323
1336
  staleTime: 25000,
1324
1337
  refetchInterval: 30000,
1325
1338
  })
@@ -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
-
@@ -27,7 +27,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
27
27
 
28
28
  // Repo refresh is gated only by `requireHelmWrite` on the backend
29
29
  // (handleUpdateRepository deliberately skips requireCloudRole — it
30
- // mutates pod-local chart cache, not cluster state). So the SPA gate
30
+ // mutates pod-local chart cache, not cluster state). So the frontend gate
31
31
  // here must NOT include the Cloud role check, or Cloud viewers with
32
32
  // rbac.helm=true would be blocked from a refresh the backend allows.
33
33
  const canHelmWrite = useCanHelmWrite()
@@ -22,7 +22,7 @@ interface RoleGatedPanelProps {
22
22
  *
23
23
  * Bypasses for non-Cloud users (OSS, OIDC, etc.) — `canAtLeast` returns
24
24
  * true when no Cloud role is present. The backend gate has the same
25
- * shape, so the SPA stays in lockstep.
25
+ * shape, so the frontend stays in lockstep.
26
26
  */
27
27
  export function RoleGatedPanel({ min, feature, children }: RoleGatedPanelProps) {
28
28
  const { role, canAtLeast } = useCloudRole()
@@ -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>