@skyhook-io/radar-app 1.6.1 → 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.1",
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
@@ -542,9 +542,11 @@ function AppInner() {
542
542
  description: 'Show keyboard shortcuts',
543
543
  category: 'General' as const,
544
544
  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) },
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.
549
+ handler: () => setShowHelp(prev => !prev),
548
550
  },
549
551
  {
550
552
  id: 'command-palette',
@@ -1608,21 +1610,7 @@ function AppInner() {
1608
1610
  console.debug('[filters] App.onNavigateToResourceKind: navigating to', targetURL)
1609
1611
  navigate({ pathname: `/resources/${kind}`, search: newParams.toString() })
1610
1612
  }}
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
- }}
1613
+ onNavigateToResource={navigateFromIssue}
1626
1614
  />
1627
1615
  )}
1628
1616
 
@@ -1972,8 +1960,9 @@ function AppInner() {
1972
1960
  />
1973
1961
  <MyPermissionsDialog open={showMyPermissions} onClose={() => setShowMyPermissions(false)} />
1974
1962
 
1975
- {/* Debug overlay - only in dev mode */}
1976
- {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 />}
1977
1966
  </div>
1978
1967
  </div>
1979
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
 
@@ -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
  })
@@ -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
-
@@ -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 {
@@ -26,6 +33,10 @@ interface HomeViewProps {
26
33
 
27
34
  export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToResourceKind, onNavigateToResource }: HomeViewProps) {
28
35
  const { data, isLoading, error } = useDashboard(namespaces)
36
+ const { data: issuesData, isLoading: issuesLoading, isFetching: issuesFetching, error: issuesError } = useIssues(namespaces)
37
+ const issues = issuesData?.issues ?? []
38
+ const issueCount = issuesData?.total_matched ?? issuesData?.total ?? issues.length
39
+ const hasCriticalIssues = issues.some((issue) => issue.severity === 'critical')
29
40
 
30
41
  // SSE is cluster-wide on small/medium clusters; the picker only narrows the
31
42
  // dashboard summary, so re-apply the filter here or the legend disagrees.
@@ -73,8 +84,6 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
73
84
  )
74
85
  }
75
86
 
76
- const hasProblems = data.problems && data.problems.length > 0
77
-
78
87
  const stillLoading = data.deferredLoading || (data.partialData && data.partialData.length > 0)
79
88
 
80
89
  return (
@@ -98,19 +107,17 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
98
107
  metrics={data.metrics}
99
108
  metricsServerAvailable={data.metricsServerAvailable}
100
109
  topCRDs={crdsData?.topCRDs}
101
- problems={data.problems ?? []}
110
+ issueCount={issueCount}
111
+ hasCriticalIssues={hasCriticalIssues}
102
112
  nodeVersionSkew={data.nodeVersionSkew}
103
113
  onNavigateToKind={onNavigateToResourceKind}
104
114
  onNavigateToView={() => onNavigateToView('resources')}
105
115
  onWarningEventsClick={() => onNavigateToView('timeline', { view: 'list', filter: 'warnings', time: 'all' })}
106
- onUnhealthyClick={() => onNavigateToView('timeline', { view: 'list', filter: 'unhealthy', time: 'all' })}
116
+ onIssuesClick={() => onNavigateToView('issues')}
107
117
  />
108
118
 
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
- )}>
119
+ {/* Row 2: Main content columns - teasers left, issues right */}
120
+ <div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_420px]">
114
121
  {/* Left column: teaser cards */}
115
122
  <div className="flex flex-col gap-6 auto-rows-min">
116
123
  {/* Live band — Topology + Timeline always render, so a fixed 2-up never strands.
@@ -190,14 +197,18 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
190
197
  )}
191
198
  </div>
192
199
 
193
- {/* Right column: problems panel */}
194
- {hasProblems && (
195
- <ProblemsPanel
196
- problems={data.problems}
197
- onNavigateToIssues={() => onNavigateToView('issues')}
198
- onResourceClick={onNavigateToResource}
199
- />
200
- )}
200
+ <ProblemsPanel
201
+ issues={issues}
202
+ issueCount={issueCount}
203
+ visibility={issuesData?.visibility}
204
+ hasData={!!issuesData}
205
+ isLoading={issuesLoading && !issuesData}
206
+ isRefreshing={issuesFetching && !!issuesData}
207
+ error={issuesError}
208
+ totalReturned={issues.length}
209
+ onNavigateToIssues={() => onNavigateToView('issues')}
210
+ onResourceClick={onNavigateToResource}
211
+ />
201
212
  </div>
202
213
  </div>
203
214
  </div>
@@ -216,19 +227,56 @@ function BandItem({ children }: { children: ReactNode }) {
216
227
  // ============================================================================
217
228
 
218
229
  interface ProblemsPanelProps {
219
- problems: DashboardResponse['problems']
230
+ issues: Issue[]
231
+ issueCount: number
232
+ visibility?: IssuesResponse['visibility']
233
+ hasData: boolean
234
+ isLoading: boolean
235
+ isRefreshing: boolean
236
+ error: unknown
237
+ totalReturned: number
220
238
  onNavigateToIssues: () => void
221
239
  onResourceClick: (resource: SelectedResource) => void
222
240
  }
223
241
 
224
242
 
225
- function ProblemsPanel({ problems, onNavigateToIssues, onResourceClick }: ProblemsPanelProps) {
243
+ function ProblemsPanel({
244
+ issues,
245
+ issueCount,
246
+ visibility,
247
+ hasData,
248
+ isLoading,
249
+ isRefreshing,
250
+ error,
251
+ totalReturned,
252
+ onNavigateToIssues,
253
+ onResourceClick,
254
+ }: ProblemsPanelProps) {
255
+ const hasCriticalIssues = issues.some((issue) => issue.severity === 'critical')
256
+ const hasIssues = issueCount > 0
257
+ const hasHardError = !!error && !hasData
258
+ const hasLimitedVisibility = !!visibility?.impact
259
+ const isTruncated = issueCount > totalReturned
260
+ const titleClass = hasCriticalIssues
261
+ ? 'text-red-500'
262
+ : hasIssues || hasHardError || hasLimitedVisibility
263
+ ? 'text-amber-500'
264
+ : 'text-theme-text-secondary'
265
+ const countClass = hasHardError
266
+ ? 'status-unknown'
267
+ : hasCriticalIssues
268
+ ? 'status-unhealthy'
269
+ : hasIssues || hasLimitedVisibility
270
+ ? 'status-degraded'
271
+ : 'status-healthy'
272
+ const countLabel = isLoading ? '...' : hasHardError ? 'error' : String(issueCount)
273
+
226
274
  return (
227
275
  <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
276
  <div className="flex items-center justify-between px-5 py-3 border-b border-theme-border/50 shrink-0">
229
277
  <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>
278
+ <AlertTriangle className={clsx('w-4 h-4', titleClass)} />
279
+ <span className={clsx('text-xs font-semibold uppercase tracking-wider', titleClass)}>Active Issues</span>
232
280
  </div>
233
281
  <div className="flex items-center gap-2">
234
282
  <button
@@ -238,41 +286,141 @@ function ProblemsPanel({ problems, onNavigateToIssues, onResourceClick }: Proble
238
286
  >
239
287
  View all
240
288
  </button>
241
- <span className="badge status-unhealthy rounded-full">{problems.length}</span>
289
+ {isRefreshing && !isLoading && (
290
+ <Loader2 className="h-3.5 w-3.5 animate-spin text-theme-text-tertiary" aria-label="Refreshing issues" />
291
+ )}
292
+ <span className={clsx('badge rounded-full', countClass)}>{countLabel}</span>
242
293
  </div>
243
294
  </div>
244
295
  <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
- )}
296
+ {isLoading ? (
297
+ <ProblemsPanelState
298
+ icon={<Loader2 className="h-5 w-5 animate-spin text-theme-text-tertiary" />}
299
+ title="Loading issues"
300
+ body="Checking live cluster issues for the selected scope."
301
+ />
302
+ ) : hasHardError ? (
303
+ <ProblemsPanelState
304
+ icon={<AlertTriangle className="h-5 w-5 text-amber-500" />}
305
+ title="Issues unavailable"
306
+ body={formatIssueError(error)}
307
+ />
308
+ ) : (
309
+ <>
310
+ {!!error && (
311
+ <ProblemsPanelNotice tone="warning">
312
+ Issue refresh failed. Showing the last successful result.
313
+ </ProblemsPanelNotice>
314
+ )}
315
+ {visibility?.impact && (
316
+ <ProblemsPanelNotice tone="warning">
317
+ Limited visibility - {visibility.impact} Results may be incomplete.
318
+ </ProblemsPanelNotice>
319
+ )}
320
+ {isTruncated && (
321
+ <ProblemsPanelNotice tone="neutral">
322
+ Showing {totalReturned} of {issueCount} issues. Narrow by namespace to see the rest.
323
+ </ProblemsPanelNotice>
324
+ )}
325
+
326
+ {issues.length === 0 ? (
327
+ <ProblemsPanelState
328
+ icon={
329
+ hasLimitedVisibility
330
+ ? <AlertTriangle className="h-5 w-5 text-amber-500" />
331
+ : <CheckCircle className="h-5 w-5 text-green-500" />
332
+ }
333
+ title={hasLimitedVisibility ? 'No visible active issues' : 'No active issues'}
334
+ body={
335
+ hasLimitedVisibility
336
+ ? 'Radar could not read every workload source in this scope.'
337
+ : 'No critical or warning issues across the selected scope.'
338
+ }
339
+ />
340
+ ) : (
341
+ <div className="divide-y divide-theme-border">
342
+ {issues.map((issue) => {
343
+ const ref = subjectRef(issue)
344
+ const age = issue.first_seen
345
+ ? issue.issue_timing === 'started_at_resource_creation'
346
+ ? 'since deploy'
347
+ : formatCompactAge(issue.first_seen)
348
+ : ''
349
+
350
+ return (
351
+ <button
352
+ key={issue.id}
353
+ className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-theme-hover transition-colors text-left"
354
+ onClick={() => onResourceClick({
355
+ kind: ref.kind,
356
+ namespace: ref.namespace ?? '',
357
+ name: ref.name,
358
+ group: ref.group ?? '',
359
+ })}
360
+ >
361
+ <StatusDot tone={issue.severity === 'critical' ? 'unhealthy' : 'degraded'} className="shrink-0" />
362
+ <div className="min-w-0 flex-1">
363
+ <div className="flex items-center gap-1.5">
364
+ <span className="text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 py-0.5 rounded">{issue.kind}</span>
365
+ <span className="text-xs text-theme-text-primary truncate font-medium">{issue.name}</span>
366
+ {age && <span className="text-[10px] text-theme-text-tertiary ml-auto shrink-0">{age}</span>}
367
+ </div>
368
+ <div className="flex items-center gap-1.5 mt-0.5">
369
+ <span className="text-[11px] text-theme-text-secondary truncate">{categoryLabel(issue.category)}</span>
370
+ <span className="text-[10px] text-theme-text-tertiary shrink-0">{groupLabel(issue.category_group)}</span>
371
+ {issue.namespace && <span className="text-[10px] text-theme-text-tertiary shrink-0">{issue.namespace}</span>}
372
+ </div>
373
+ {(issue.reason || issue.message) && (
374
+ <div className="text-[10px] text-theme-text-tertiary truncate mt-0.5">
375
+ {issue.reason}
376
+ {issue.reason && issue.message ? ' - ' : ''}
377
+ {issue.message}
378
+ </div>
379
+ )}
380
+ </div>
381
+ </button>
382
+ )
383
+ })}
271
384
  </div>
272
- </button>
273
- ))}
274
- </div>
385
+ )}
386
+ </>
387
+ )}
275
388
  </div>
276
389
  </div>
277
390
  )
278
391
  }
392
+
393
+ function ProblemsPanelState({ icon, title, body }: { icon: ReactNode; title: string; body: string }) {
394
+ return (
395
+ <div className="flex min-h-[220px] flex-col items-center justify-center gap-2 px-6 py-10 text-center">
396
+ <div className="flex h-9 w-9 items-center justify-center rounded-full bg-theme-elevated">
397
+ {icon}
398
+ </div>
399
+ <p className="text-sm font-medium text-theme-text-primary">{title}</p>
400
+ <p className="max-w-[280px] text-xs leading-5 text-theme-text-secondary">{body}</p>
401
+ </div>
402
+ )
403
+ }
404
+
405
+ function ProblemsPanelNotice({ tone, children }: { tone: 'warning' | 'neutral'; children: ReactNode }) {
406
+ return (
407
+ <div className="px-3 pt-3">
408
+ <div
409
+ className={clsx(
410
+ 'rounded-lg border px-3 py-2 text-xs leading-5',
411
+ tone === 'warning'
412
+ ? 'border-amber-500/20 bg-amber-500/10 text-theme-text-secondary'
413
+ : 'border-theme-border bg-theme-elevated text-theme-text-secondary',
414
+ )}
415
+ >
416
+ {children}
417
+ </div>
418
+ </div>
419
+ )
420
+ }
421
+
422
+ function formatIssueError(error: unknown): string {
423
+ return error instanceof Error && error.message
424
+ ? error.message
425
+ : 'Failed to load active issues.'
426
+ }
@@ -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}
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
+ }