@skyhook-io/radar-app 1.8.1 → 1.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/src/App.tsx +167 -56
  3. package/src/RadarApp.tsx +18 -1
  4. package/src/api/client.ts +173 -6
  5. package/src/components/NamespaceSwitcher.tsx +52 -30
  6. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  7. package/src/components/gitops/GitOpsView.tsx +1 -10
  8. package/src/components/helm/HelmReleaseDrawer.tsx +802 -44
  9. package/src/components/helm/HelmView.tsx +85 -16
  10. package/src/components/helm/ManifestDiffViewer.tsx +15 -4
  11. package/src/components/helm/OwnedResources.tsx +14 -50
  12. package/src/components/helm/RevisionHistory.tsx +50 -2
  13. package/src/components/helm/TrackChartSourceDialog.tsx +141 -0
  14. package/src/components/helm/ValuesViewer.tsx +41 -11
  15. package/src/components/home/TrafficSummary.tsx +2 -2
  16. package/src/components/home/mcpToolCatalog.ts +10 -10
  17. package/src/components/nav/PrimaryNavRail.tsx +1 -1
  18. package/src/components/portforward/PortForwardButton.tsx +69 -25
  19. package/src/components/portforward/PortForwardManager.tsx +18 -4
  20. package/src/components/resources/ResourcesView.tsx +42 -1
  21. package/src/components/resources/renderers/PodRenderer.tsx +7 -2
  22. package/src/components/resources/renderers/ServiceRenderer.tsx +54 -8
  23. package/src/components/ui/ShortcutHelpOverlay.tsx +1 -1
  24. package/src/components/ui/UpdateNotification.tsx +5 -10
  25. package/src/components/ui/command-items.ts +1 -1
  26. package/src/components/workload/WorkloadView.tsx +52 -7
  27. package/src/context/ConnectionContext.tsx +29 -2
  28. package/src/contexts/CapabilitiesContext.tsx +8 -0
  29. package/src/hooks/useDocumentTitle.ts +25 -0
  30. package/src/main.tsx +5 -3
  31. package/src/utils/auditBadges.ts +53 -0
  32. package/src/utils/navigation.ts +5 -3
package/src/api/client.ts CHANGED
@@ -16,8 +16,11 @@ import type {
16
16
  HelmReleaseDetail,
17
17
  HelmValues,
18
18
  ManifestDiff,
19
+ NotesDiff,
20
+ ResourceDiff,
19
21
  UpgradeInfo,
20
22
  BatchUpgradeInfo,
23
+ ValuesDiff,
21
24
  ValuesPreviewResponse,
22
25
  HelmRepository,
23
26
  ChartSearchResult,
@@ -377,6 +380,28 @@ export function useResourceAudit(kind: string, namespace: string, name: string)
377
380
  })
378
381
  }
379
382
 
383
+ // Live Issues that touch ONE resource — its own issues plus, for a workload, its
384
+ // owned pods' issues (server-side owner rollup via issues.RelatedIssues). Backs
385
+ // the "Operational Issues" section in the resource detail. Cluster-scoped
386
+ // resources pass "_" for namespace; namespaced ones also scope the scan via
387
+ // ?namespaces= for a cheap, bounded Compose.
388
+ export function useResourceIssues(kind: string, group: string | undefined, namespace: string, name: string, enabled = true) {
389
+ const clusterScoped = !namespace
390
+ const pathNs = clusterScoped ? '_' : encodeURIComponent(namespace)
391
+ const params = new URLSearchParams()
392
+ if (group) params.set('group', group)
393
+ const path = `/issues/resource/${encodeURIComponent(kind)}/${pathNs}/${encodeURIComponent(name)}`
394
+ const qs = params.toString()
395
+ return useQuery<Issue[]>({
396
+ queryKey: ['issues', 'resource', kind, group ?? '', namespace, name],
397
+ queryFn: () => fetchJSON(`${path}${qs ? `?${qs}` : ''}`),
398
+ // No refetchInterval: a drawer doesn't need to poll; staleTime keeps it fresh
399
+ // on reopen without re-running an uncapped Compose every 30s.
400
+ staleTime: 30000,
401
+ enabled: enabled && !!kind && !!name,
402
+ })
403
+ }
404
+
380
405
  // Audit settings
381
406
  export interface AuditSettings {
382
407
  ignoredNamespaces: string[]
@@ -2358,11 +2383,14 @@ export function useHelmManifest(namespace: string, name: string, revision?: numb
2358
2383
  }
2359
2384
 
2360
2385
  // Get values for a Helm release. `enabled` see useHelmManifest.
2361
- export function useHelmValues(namespace: string, name: string, allValues?: boolean, enabled = true) {
2362
- const params = allValues ? '?all=true' : ''
2386
+ export function useHelmValues(namespace: string, name: string, allValues?: boolean, enabled = true, revision?: number) {
2387
+ const params = new URLSearchParams()
2388
+ if (allValues) params.set('all', 'true')
2389
+ if (revision && revision > 0) params.set('revision', String(revision))
2390
+ const query = params.toString() ? `?${params.toString()}` : ''
2363
2391
  return useQuery<HelmValues>({
2364
- queryKey: ['helm-values', namespace, name, allValues],
2365
- queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}/values${params}`),
2392
+ queryKey: ['helm-values', namespace, name, allValues, revision],
2393
+ queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}/values${query}`),
2366
2394
  enabled: Boolean(namespace && name && enabled),
2367
2395
  staleTime: 60000,
2368
2396
  })
@@ -2385,6 +2413,61 @@ export function useHelmManifestDiff(
2385
2413
  })
2386
2414
  }
2387
2415
 
2416
+ export function useHelmValuesDiff(
2417
+ namespace: string,
2418
+ name: string,
2419
+ revision1: number,
2420
+ revision2: number,
2421
+ allValues = false,
2422
+ enabled = true,
2423
+ ) {
2424
+ return useQuery<ValuesDiff>({
2425
+ queryKey: ['helm-values-diff', namespace, name, revision1, revision2, allValues],
2426
+ queryFn: () => {
2427
+ const params = new URLSearchParams({
2428
+ revision1: String(revision1),
2429
+ revision2: String(revision2),
2430
+ })
2431
+ if (allValues) params.set('all', 'true')
2432
+ return fetchJSON(`/helm/releases/${namespace}/${name}/values/diff?${params.toString()}`)
2433
+ },
2434
+ enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
2435
+ staleTime: 60000,
2436
+ })
2437
+ }
2438
+
2439
+ export function useHelmNotesDiff(
2440
+ namespace: string,
2441
+ name: string,
2442
+ revision1: number,
2443
+ revision2: number,
2444
+ enabled = true,
2445
+ ) {
2446
+ return useQuery<NotesDiff>({
2447
+ queryKey: ['helm-notes-diff', namespace, name, revision1, revision2],
2448
+ queryFn: () =>
2449
+ fetchJSON(`/helm/releases/${namespace}/${name}/notes/diff?revision1=${revision1}&revision2=${revision2}`),
2450
+ enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
2451
+ staleTime: 60000,
2452
+ })
2453
+ }
2454
+
2455
+ export function useHelmResourceDiff(
2456
+ namespace: string,
2457
+ name: string,
2458
+ revision1: number,
2459
+ revision2: number,
2460
+ enabled = true,
2461
+ ) {
2462
+ return useQuery<ResourceDiff>({
2463
+ queryKey: ['helm-resource-diff', namespace, name, revision1, revision2],
2464
+ queryFn: () =>
2465
+ fetchJSON(`/helm/releases/${namespace}/${name}/resources/diff?revision1=${revision1}&revision2=${revision2}`),
2466
+ enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
2467
+ staleTime: 60000,
2468
+ })
2469
+ }
2470
+
2388
2471
  // Check for upgrade availability (lazy - called when drawer opens)
2389
2472
  export function useHelmUpgradeInfo(namespace: string, name: string, enabled = true) {
2390
2473
  return useQuery<UpgradeInfo>({
@@ -2396,6 +2479,19 @@ export function useHelmUpgradeInfo(namespace: string, name: string, enabled = tr
2396
2479
  })
2397
2480
  }
2398
2481
 
2482
+ // Available chart versions for a release (newest-first), for the upgrade dialog's
2483
+ // version picker. Empty when the source can't be resolved — the dialog then falls
2484
+ // back to the latest version from upgrade-info.
2485
+ export function useHelmReleaseVersions(namespace: string, name: string, enabled = true) {
2486
+ return useQuery<string[]>({
2487
+ queryKey: ['helm-release-versions', namespace, name],
2488
+ queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}/versions`),
2489
+ enabled: Boolean(namespace && name && enabled),
2490
+ staleTime: 30000,
2491
+ retry: false,
2492
+ })
2493
+ }
2494
+
2399
2495
  // Batch check for upgrade availability (for list view)
2400
2496
  export function useHelmBatchUpgradeInfo(namespaces: string[] = [], enabled = true) {
2401
2497
  const params = helmNamespaceParams(namespaces)
@@ -2668,6 +2764,54 @@ export function useUpdateRepositorySilent() {
2668
2764
  })
2669
2765
  }
2670
2766
 
2767
+ // Registered OCI chart sources (the OCI analog of `helm repo add`). Used to
2768
+ // track upgrades for the user's own OCI-published charts.
2769
+ export function useHelmOCISources() {
2770
+ return useQuery<string[]>({
2771
+ queryKey: ['helm-oci-sources'],
2772
+ queryFn: () => fetchJSON('/helm/oci-sources'),
2773
+ })
2774
+ }
2775
+
2776
+ async function mutateOCISource(method: 'POST' | 'DELETE', source: string): Promise<string[]> {
2777
+ const response = await apiFetch(`${getApiBase()}/helm/oci-sources`, {
2778
+ method,
2779
+ headers: { 'Content-Type': 'application/json' },
2780
+ body: JSON.stringify({ source }),
2781
+ })
2782
+ if (!response.ok) {
2783
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2784
+ throw new Error(error.error || `HTTP ${response.status}`)
2785
+ }
2786
+ return response.json()
2787
+ }
2788
+
2789
+ // Invalidate the upgrade-info queries so a newly-registered source is probed
2790
+ // immediately and "source not tracked" re-resolves.
2791
+ function invalidateHelmAfterSourceChange(queryClient: ReturnType<typeof useQueryClient>) {
2792
+ queryClient.invalidateQueries({ queryKey: ['helm-oci-sources'] })
2793
+ queryClient.invalidateQueries({ queryKey: ['helm-upgrade-info'] })
2794
+ queryClient.invalidateQueries({ queryKey: ['helm-batch-upgrade-info'] })
2795
+ }
2796
+
2797
+ export function useAddOCISource() {
2798
+ const queryClient = useQueryClient()
2799
+ return useMutation({
2800
+ mutationFn: (source: string) => mutateOCISource('POST', source),
2801
+ meta: { errorMessage: 'Failed to add chart source', successMessage: 'Chart source added' },
2802
+ onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
2803
+ })
2804
+ }
2805
+
2806
+ export function useRemoveOCISource() {
2807
+ const queryClient = useQueryClient()
2808
+ return useMutation({
2809
+ mutationFn: (source: string) => mutateOCISource('DELETE', source),
2810
+ meta: { errorMessage: 'Failed to remove chart source', successMessage: 'Chart source removed' },
2811
+ onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
2812
+ })
2813
+ }
2814
+
2671
2815
  // Search charts across all repositories
2672
2816
  export function useSearchCharts(query: string, allVersions = false, enabled = true) {
2673
2817
  return useQuery<ChartSearchResult>({
@@ -3086,6 +3230,11 @@ export interface NamespaceScope {
3086
3230
  authoritative: boolean
3087
3231
  /** false when clearing would leave no usable namespace fallback. */
3088
3232
  canClearNamespace: boolean
3233
+ /** true when the backend informer cache is pinned to a namespace. */
3234
+ cacheScoped: boolean
3235
+ cacheScopeNamespace?: string
3236
+ /** true when this client may rebuild the local cache for another namespace. */
3237
+ namespaceRescope: boolean
3089
3238
  }
3090
3239
 
3091
3240
  export function useNamespaceScope() {
@@ -3097,6 +3246,7 @@ export function useNamespaceScope() {
3097
3246
  }
3098
3247
 
3099
3248
  const NAMESPACE_SWITCH_TIMEOUT = 5000
3249
+ const NAMESPACE_RESCOPE_TIMEOUT = 120000
3100
3250
 
3101
3251
  export function debugNamespaceLog(label: string, payload?: Record<string, unknown>) {
3102
3252
  if (typeof window === 'undefined') return
@@ -3123,7 +3273,16 @@ export function useSetActiveNamespace() {
3123
3273
  mutationFn: async ({ namespaces }) => {
3124
3274
  debugNamespaceLog('mutation:start', { namespaces })
3125
3275
  const controller = new AbortController()
3126
- const timeoutId = setTimeout(() => controller.abort(), NAMESPACE_SWITCH_TIMEOUT)
3276
+ const currentScope = queryClient.getQueryData<NamespaceScope>(['namespace-scope'])
3277
+ // cacheScoped is a stable per-process property (the server's --namespace-scope
3278
+ // flag). If the scope query is missing/stale we can't yet tell a cheap
3279
+ // view-filter change from a cache-rebuilding rescope, so bias to the long
3280
+ // timeout — only a confirmed non-scoped session gets the fast switch timeout.
3281
+ // Aborting a real rebuild at 5s surfaces a spurious failure while the server
3282
+ // keeps going.
3283
+ const isRescope = currentScope?.cacheScoped !== false
3284
+ const timeoutMs = isRescope ? NAMESPACE_RESCOPE_TIMEOUT : NAMESPACE_SWITCH_TIMEOUT
3285
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
3127
3286
  const startedAt = performance.now()
3128
3287
  try {
3129
3288
  const response = await apiFetch(`${getApiBase()}/cluster/namespace`, {
@@ -3151,7 +3310,9 @@ export function useSetActiveNamespace() {
3151
3310
  error: error instanceof Error ? error.message : String(error),
3152
3311
  })
3153
3312
  if (error instanceof Error && error.name === 'AbortError') {
3154
- throw new Error('Namespace switch timed out. The cluster may be unreachable.', { cause: error })
3313
+ throw new Error(isRescope
3314
+ ? 'Namespace rescope timed out. The cluster may still be loading.'
3315
+ : 'Namespace switch timed out. The cluster may be unreachable.', { cause: error })
3155
3316
  }
3156
3317
  throw error
3157
3318
  }
@@ -3162,7 +3323,13 @@ export function useSetActiveNamespace() {
3162
3323
  mode: scope.mode,
3163
3324
  accessibleCount: scope.accessibleNamespaces.length,
3164
3325
  })
3326
+ if (scope.cacheScoped) {
3327
+ queryClient.removeQueries({ predicate: query => query.queryKey[0] !== 'namespace-scope' })
3328
+ }
3165
3329
  queryClient.setQueryData<NamespaceScope>(['namespace-scope'], scope)
3330
+ if (scope.cacheScoped) {
3331
+ queryClient.invalidateQueries()
3332
+ }
3166
3333
  debugNamespaceLog('mutation:success-after-scope-cache-write')
3167
3334
  },
3168
3335
  onError: () => {
@@ -15,10 +15,9 @@ interface NamespaceSwitcherProps {
15
15
  }
16
16
 
17
17
  /**
18
- * NamespaceSwitcher is a per-user multi-select view filter for the cluster
19
- * view. It does NOT reshape the shared informer cache — picks are saved
20
- * server-side per user and intersected with the user's RBAC-allowed
21
- * namespaces on each read.
18
+ * NamespaceSwitcher is normally a per-user multi-select view filter. When the
19
+ * backend reports cacheScoped=true, it becomes a single-namespace cache scope
20
+ * control; local sessions may rebuild the cache for a different namespace.
22
21
  *
23
22
  * Three states reflect what the backend reports:
24
23
  * - cluster-wide: empty trigger label "All namespaces", picker lets the
@@ -71,6 +70,7 @@ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSw
71
70
  const applySelection = useCallback((next: Set<string>) => {
72
71
  if (!scope) return
73
72
  const nextArr = Array.from(next).sort()
73
+ if (scope.cacheScoped && nextArr.length !== 1) return
74
74
  if (nextArr.join(',') === activesKey) return
75
75
  setActive.mutate({ namespaces: nextArr })
76
76
  }, [activesKey, scope, setActive])
@@ -120,6 +120,10 @@ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSw
120
120
  if (!scope) return null
121
121
 
122
122
  const toggle = (ns: string) => {
123
+ if (scope.cacheScoped) {
124
+ setDraft(new Set([ns]))
125
+ return
126
+ }
123
127
  const next = new Set(draft)
124
128
  if (next.has(ns)) next.delete(ns)
125
129
  else next.add(ns)
@@ -127,6 +131,7 @@ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSw
127
131
  }
128
132
 
129
133
  const clearAll = () => {
134
+ if (scope.cacheScoped) return
130
135
  setDraft(new Set())
131
136
  setIsOpen(false)
132
137
  setSearch('')
@@ -150,11 +155,16 @@ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSw
150
155
  activeCount === 0 ? 'All namespaces' : activeCount === 1 ? scopeActives[0] : `${activeCount} namespaces`
151
156
  const isClusterWide = activeCount === 0
152
157
  const restrictedHint = scope.mode === 'restricted'
153
- const isDisabled = disabled || isLoading || setActive.isPending
158
+ const cacheScopeLocked = scope.cacheScoped && !scope.namespaceRescope
159
+ const isDisabled = disabled || isLoading || setActive.isPending || cacheScopeLocked
154
160
  const canClearAll = scope.canClearNamespace || activeCount === 0
155
161
  const tooltipContent = disabled && disabledTooltip
156
162
  ? disabledTooltip
157
- : restrictedHint
163
+ : scope.cacheScoped
164
+ ? scope.namespaceRescope
165
+ ? `Radar is watching only ${scope.cacheScopeNamespace || triggerLabel} to stay fast on large clusters. Pick another namespace to re-point it (takes a moment; closes open terminals).`
166
+ : `Radar is watching only ${scope.cacheScopeNamespace || triggerLabel} on this cluster.`
167
+ : restrictedHint
158
168
  ? 'Limited namespace visibility — only namespaces granted by your RBAC are shown.'
159
169
  : isClusterWide
160
170
  ? 'Currently viewing all namespaces. Click to narrow the view.'
@@ -213,28 +223,37 @@ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSw
213
223
  </div>
214
224
  )}
215
225
 
216
- <div className="flex items-center justify-between px-2 py-1.5 border-b border-theme-border text-xs text-theme-text-secondary">
217
- <button
218
- onClick={canClearAll ? clearAll : undefined}
219
- disabled={!canClearAll || activeCount === 0}
220
- className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
221
- aria-label="Clear namespace selection"
222
- >
223
- <X className="w-3 h-3" />
224
- Clear all
225
- </button>
226
- <button
227
- onClick={allVisibleSelected ? clearVisible : selectAllVisible}
228
- disabled={filteredItems.length === 0}
229
- className="px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
230
- >
231
- {allVisibleSelected
232
- ? `Clear ${filteredItems.length} visible`
233
- : search.trim()
234
- ? `Select ${filteredItems.length} visible`
235
- : 'Select all'}
236
- </button>
237
- </div>
226
+ {scope.cacheScoped ? (
227
+ <div className="px-3 py-1.5 border-b border-theme-border text-[11px] leading-snug text-theme-text-secondary">
228
+ Radar is watching one namespace to stay fast on large clusters.
229
+ {scope.namespaceRescope
230
+ ? ' Pick another to re-point it takes a moment and closes open terminals.'
231
+ : ' This instance is locked to its startup namespace.'}
232
+ </div>
233
+ ) : (
234
+ <div className="flex items-center justify-between px-2 py-1.5 border-b border-theme-border text-xs text-theme-text-secondary">
235
+ <button
236
+ onClick={canClearAll ? clearAll : undefined}
237
+ disabled={!canClearAll || activeCount === 0}
238
+ className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
239
+ aria-label="Clear namespace selection"
240
+ >
241
+ <X className="w-3 h-3" />
242
+ Clear all
243
+ </button>
244
+ <button
245
+ onClick={allVisibleSelected ? clearVisible : selectAllVisible}
246
+ disabled={filteredItems.length === 0}
247
+ className="px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
248
+ >
249
+ {allVisibleSelected
250
+ ? `Clear ${filteredItems.length} visible`
251
+ : search.trim()
252
+ ? `Select ${filteredItems.length} visible`
253
+ : 'Select all'}
254
+ </button>
255
+ </div>
256
+ )}
238
257
 
239
258
  <ul className="max-h-80 overflow-y-auto py-1">
240
259
  {filteredItems.length === 0 && (
@@ -253,7 +272,8 @@ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSw
253
272
  >
254
273
  <span className="flex items-center gap-2 min-w-0">
255
274
  <input
256
- type="checkbox"
275
+ type={scope.cacheScoped ? 'radio' : 'checkbox'}
276
+ name={scope.cacheScoped ? 'namespace-cache-scope' : undefined}
257
277
  checked={isChecked}
258
278
  onChange={() => toggle(ns)}
259
279
  className="shrink-0 accent-current"
@@ -273,7 +293,9 @@ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSw
273
293
 
274
294
  <div className="flex items-center justify-between px-3 py-1.5 border-t border-theme-border text-[11px] text-theme-text-tertiary">
275
295
  <span>
276
- {draft.size === 0 ? 'All namespaces' : `${draft.size} selected`}
296
+ {scope.cacheScoped
297
+ ? (draft.size === 1 ? Array.from(draft)[0] : 'Select a namespace')
298
+ : draft.size === 0 ? 'All namespaces' : `${draft.size} selected`}
277
299
  </span>
278
300
  <button
279
301
  onClick={closeAndApply}