@skyhook-io/radar-app 1.8.0 → 1.8.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.
Files changed (32) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +95 -16
  3. package/src/api/client.ts +64 -3
  4. package/src/components/DebugOverlay.tsx +1 -1
  5. package/src/components/applications/ApplicationsView.tsx +1 -1
  6. package/src/components/compare/CompareViewRoute.tsx +13 -5
  7. package/src/components/cost/CostTrendChart.tsx +3 -3
  8. package/src/components/cost/CostView.tsx +1 -1
  9. package/src/components/helm/ChartBrowser.tsx +1 -1
  10. package/src/components/helm/HelmReleaseDrawer.tsx +233 -19
  11. package/src/components/helm/HelmView.tsx +85 -16
  12. package/src/components/helm/InstallWizard.tsx +1 -1
  13. package/src/components/helm/RevisionHistory.tsx +46 -2
  14. package/src/components/helm/TrackChartSourceDialog.tsx +141 -0
  15. package/src/components/home/ActivitySummary.tsx +4 -1
  16. package/src/components/home/TrafficSummary.tsx +2 -2
  17. package/src/components/home/mcpToolCatalog.ts +5 -5
  18. package/src/components/issues/IssuesPane.tsx +2 -2
  19. package/src/components/nav/PrimaryNavRail.tsx +1 -1
  20. package/src/components/portforward/PortForwardManager.tsx +70 -8
  21. package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
  22. package/src/components/resources/ResourcesView.tsx +2 -0
  23. package/src/components/settings/MyPermissionsDialog.tsx +64 -4
  24. package/src/components/shared/LargeClusterNamespacePicker.tsx +1 -1
  25. package/src/components/traffic/TrafficGraph.tsx +29 -20
  26. package/src/components/traffic/TrafficView.tsx +3 -3
  27. package/src/components/ui/DiagnosticsOverlay.tsx +1 -1
  28. package/src/components/ui/ShortcutHelpOverlay.tsx +1 -1
  29. package/src/components/ui/command-items.ts +1 -1
  30. package/src/components/workload/WorkloadView.tsx +17 -17
  31. package/src/context/ConnectionContext.tsx +29 -2
  32. package/src/main.tsx +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.8.0",
3
+ "version": "1.8.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",
@@ -57,7 +57,7 @@
57
57
  "@tailwindcss/typography": "^0.5.20",
58
58
  "@tailwindcss/vite": "^4.3.1",
59
59
  "@tanstack/react-query": "^5.100.14",
60
- "@types/node": "^25.9.3",
60
+ "@types/node": "^26.0.0",
61
61
  "@types/react": "^19.2.17",
62
62
  "@types/react-dom": "^19.2.3",
63
63
  "@vitejs/plugin-react": "^6.0.2",
package/src/App.tsx CHANGED
@@ -138,14 +138,19 @@ function AuthBarrier({ authMode }: { authMode: string }) {
138
138
  src={radarLoadingIcon}
139
139
  alt=""
140
140
  aria-hidden
141
- className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-11 h-11"
141
+ // Integer offset (vw/2 − 22) — matches the Connecting/Opening splashes;
142
+ // avoids sub-pixel jitter from translate(-50%) on odd-width viewports.
143
+ className="absolute w-11 h-11"
144
+ style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
142
145
  />
143
- <p
144
- className="absolute left-1/2 -translate-x-1/2 whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary"
146
+ <div
147
+ className="absolute left-1/2 -translate-x-1/2 text-center"
145
148
  style={{ top: 'calc(50% + 34px)' }}
146
149
  >
147
- Redirecting to login…
148
- </p>
150
+ <p className="whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary">
151
+ Redirecting to login…
152
+ </p>
153
+ </div>
149
154
  </div>
150
155
  </div>
151
156
  )
@@ -171,6 +176,15 @@ function AuthBarrier({ authMode }: { authMode: string }) {
171
176
  )
172
177
  }
173
178
 
179
+ // Identity of the "page" a non-URL-backed peek drawer belongs to. Pathname alone
180
+ // is not enough: Applications keeps the list and an app's detail on the same
181
+ // `/applications` pathname and distinguishes them with `?app=`, so a Back from
182
+ // detail to list would otherwise leave the peek orphaned. Only `app` is included
183
+ // (not the whole query) so filter/tab/namespace churn doesn't close the peek.
184
+ function peekOwnerKey(pathname: string, search: string): string {
185
+ return `${pathname}${new URLSearchParams(search).get('app') ?? ''}`
186
+ }
187
+
174
188
  function AppInner() {
175
189
  const navigate = useNavigate()
176
190
  const location = useLocation()
@@ -410,6 +424,9 @@ function AppInner() {
410
424
  // selected drawer resource. This covers both in-view kind switches and
411
425
  // cross-kind navigations from expanded drawers (for example Node -> View Pods).
412
426
  const prevResourcesKindKeyRef = useRef<string | null>(null)
427
+ // Owner-key (pathname + ?app) a non-URL-backed peek was opened on; see
428
+ // navigateToResource and peekOwnerKey.
429
+ const peekOwnerKeyRef = useRef<string | null>(null)
413
430
  const currentResourceKindSlug = normalizedResourcesKindSlug.toLowerCase()
414
431
  const currentResourceGroup = searchParams.get('apiGroup') ?? ''
415
432
  const selectedResourceKindSlug = selectedResource ? kindToPlural(selectedResource.kind).toLowerCase() : ''
@@ -421,9 +438,28 @@ function AppInner() {
421
438
  const resourcesKindRouteChanged = mainView === 'resources' &&
422
439
  prevResourcesKindKeyRef.current !== null &&
423
440
  prevResourcesKindKeyRef.current !== `${currentResourceGroup}/${currentResourceKindSlug}`
424
- const routeSelectedResource = resourcesKindRouteChanged && selectedResourceRouteMismatch
425
- ? null
426
- : selectedResource
441
+
442
+ // A peek opened outside /resources (topology, GitOps, Applications) carries no
443
+ // URL backing, so the only signal that the page beneath it has navigated is
444
+ // that its owner-key (pathname + ?app) no longer matches where it was opened.
445
+ // Hiding it here, at render time, closes the orphan on Back without adding
446
+ // another clearing effect that would race the `suppressViewClearRef` lifecycle.
447
+ // The /resources case is URL-backed and handled above; an expanded drawer
448
+ // (drawerExpanded) legitimately lives at /workload and must stay open there.
449
+ const peekRouteOrphaned = !!selectedResource && !drawerExpanded && mainView !== 'resources' &&
450
+ peekOwnerKeyRef.current !== null &&
451
+ peekOwnerKeyRef.current !== peekOwnerKey(location.pathname, location.search)
452
+
453
+ // In Applications the inline WorkloadView (?workload) and the peek drawer are
454
+ // mutually exclusive — never two detail surfaces at once. ?workload is the
455
+ // single source of truth: while it's set the peek yields to the inline view.
456
+ // (Opening a child peek from Applications clears ?workload, see onOpenResource.)
457
+ const appsInlineWorkloadActive = mainView === 'applications' && searchParams.has('workload')
458
+
459
+ const routeSelectedResource =
460
+ (resourcesKindRouteChanged && selectedResourceRouteMismatch) || peekRouteOrphaned || appsInlineWorkloadActive
461
+ ? null
462
+ : selectedResource
427
463
 
428
464
  useEffect(() => {
429
465
  if (mainView !== 'resources') {
@@ -458,6 +494,13 @@ function AppInner() {
458
494
 
459
495
  // Navigate to a resource — uses View Transitions cross-fade when drawer is already open
460
496
  const navigateToResource = useCallback((res: SelectedResource, tab: 'detail' | 'yaml' = 'detail') => {
497
+ // Record the page this peek was opened on. Outside /resources the drawer is
498
+ // not URL-backed, so this ref is what lets the render-time gate below close
499
+ // the peek when the page under it changes (e.g. browser Back off a GitOps
500
+ // detail page, or Applications detail → list via ?app). window.location is
501
+ // read (not the `location` closure) so the value is always current
502
+ // regardless of this callback's memoization.
503
+ peekOwnerKeyRef.current = peekOwnerKey(window.location.pathname, window.location.search)
461
504
  const update = () => { setDrawerInitialTab(tab); setSelectedResource(res) }
462
505
  // Skip the cross-fade animation entirely on first open (no
463
506
  // `selectedResource`); otherwise route through
@@ -920,7 +963,7 @@ function AppInner() {
920
963
  name: node.name,
921
964
  group: apiVersionToGroup(node.data.apiVersion as string | undefined),
922
965
  })
923
- }, [navigate])
966
+ }, [navigate, navigateToResource])
924
967
 
925
968
  // Serialize namespaces for stable dependency tracking
926
969
  const namespacesKey = namespaces.join(',')
@@ -1136,6 +1179,11 @@ function AppInner() {
1136
1179
  // Switching to specific namespaces - disable namespace grouping
1137
1180
  setGroupingMode('none')
1138
1181
  }
1182
+ // Intentionally runs ONLY when the namespace selection changes. It reads the
1183
+ // current groupingMode but must not re-run when grouping changes, or it would
1184
+ // immediately revert a manual/fleet grouping choice. namespacesKey is the
1185
+ // manual dependency standing in for the namespaces array.
1186
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1139
1187
  }, [namespacesKey])
1140
1188
 
1141
1189
  // Clear resource selection when changing views or namespaces
@@ -1362,7 +1410,7 @@ function AppInner() {
1362
1410
  // the bar is full, and the view's primary home is Cloud's fleet
1363
1411
  // rail. The view still exists and is reachable via /applications
1364
1412
  // and the view-switching shortcuts. Same treatment as Cost below.
1365
- { view: 'traffic' as const, icon: Activity, label: 'Traffic' },
1413
+ { view: 'traffic' as const, icon: Activity, label: 'Live Traffic' },
1366
1414
  // Cost is intentionally hidden from the pill bar for now — the view still
1367
1415
  // exists and is reachable via /cost, the Home dashboard card, and the
1368
1416
  // command palette (⌘K). Remove this comment to restore it.
@@ -1745,7 +1793,7 @@ function AppInner() {
1745
1793
  <TopologySearch
1746
1794
  nodes={filteredTopology?.nodes ?? []}
1747
1795
  allNodes={topology?.nodes}
1748
- viewModeLabel={topologyMode === 'fleet' ? 'Fleet' : topologyMode === 'traffic' ? 'Traffic' : 'Resources'}
1796
+ viewModeLabel={topologyMode === 'fleet' ? 'Fleet' : topologyMode === 'traffic' ? 'Network Flow' : 'Resources'}
1749
1797
  onNodeSelect={handleNodeClick}
1750
1798
  onZoomToNode={(id) => setTopologyFocus((prev) => ({ id, nonce: (prev?.nonce ?? 0) + 1 }))}
1751
1799
  // Stack below the namespace breadcrumb (shown only for a single
@@ -1767,6 +1815,7 @@ function AppInner() {
1767
1815
  showPolicyEffect={showPolicyEffect}
1768
1816
  onShowPolicyEffectChange={setShowPolicyEffect}
1769
1817
  showFleetMode={displayedTopology?.nodes?.some(n => FLEET_MODE_KINDS.has(n.kind as NodeKind)) ?? false}
1818
+ onNavigateToTraffic={() => setMainView('traffic')}
1770
1819
  />
1771
1820
  </div>
1772
1821
  </>
@@ -1829,7 +1878,10 @@ function AppInner() {
1829
1878
  <GitOpsView
1830
1879
  namespaces={namespaces}
1831
1880
  onOpenResource={(resource) => {
1832
- setSelectedResource(resource)
1881
+ // Route through navigateToResource so the peek records the page it
1882
+ // opened on — that's what lets Back off the GitOps detail page close
1883
+ // the drawer instead of orphaning it on the list.
1884
+ navigateToResource(resource)
1833
1885
  }}
1834
1886
  onClearNamespaces={clearAllNamespaces}
1835
1887
  />
@@ -1840,7 +1892,17 @@ function AppInner() {
1840
1892
  <ApplicationsView
1841
1893
  namespaces={namespaces}
1842
1894
  onOpenResource={(resource) => {
1843
- setSelectedResource(resource)
1895
+ // The peek and the inline WorkloadView are mutually exclusive: drop
1896
+ // the inline workload selection so the app graph (not a second
1897
+ // detail panel) sits behind the peek. Search-only change keeps the
1898
+ // pathname — and thus the peek's owner-path — intact.
1899
+ const params = new URLSearchParams(window.location.search)
1900
+ if (params.has('workload') || params.has('tab')) {
1901
+ params.delete('workload')
1902
+ params.delete('tab')
1903
+ navigate({ pathname: window.location.pathname, search: params.toString() }, { replace: true })
1904
+ }
1905
+ navigateToResource(resource)
1844
1906
  }}
1845
1907
  />
1846
1908
  )}
@@ -1861,9 +1923,26 @@ function AppInner() {
1861
1923
  own fetches) while the cross-document nav lands. Covers checks /
1862
1924
  issues / gitops with one block since only one view is active. */}
1863
1925
  {viewTakeoverHref && (
1864
- <div className="flex-1 flex flex-col items-center justify-center gap-3 bg-theme-base">
1865
- <img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
1866
- <p className="text-sm text-theme-text-secondary">Opening…</p>
1926
+ <div className="flex-1 relative bg-theme-base">
1927
+ {/* Viewport-anchored, 17px — identical to the "Connecting" splash so
1928
+ the mark doesn't move or resize across the takeover hand-off. */}
1929
+ <div className="fixed inset-0 pointer-events-none">
1930
+ <img
1931
+ src={radarLoadingIcon}
1932
+ alt=""
1933
+ aria-hidden
1934
+ className="absolute w-11 h-11"
1935
+ style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
1936
+ />
1937
+ <div
1938
+ className="absolute left-1/2 -translate-x-1/2 text-center"
1939
+ style={{ top: 'calc(50% + 34px)' }}
1940
+ >
1941
+ <p className="whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary">
1942
+ Opening…
1943
+ </p>
1944
+ </div>
1945
+ </div>
1867
1946
  </div>
1868
1947
  )}
1869
1948
 
package/src/api/client.ts CHANGED
@@ -1509,7 +1509,7 @@ export function useAutoPromConnect(): void {
1509
1509
  if (attemptedRef.current === context) return
1510
1510
  let cached: string | null = null
1511
1511
  try { cached = window.localStorage.getItem(promAutoConnectKey(context)) } catch {
1512
- cached = null
1512
+ // keep the null fallback
1513
1513
  }
1514
1514
 
1515
1515
  attemptedRef.current = context
@@ -2396,6 +2396,19 @@ export function useHelmUpgradeInfo(namespace: string, name: string, enabled = tr
2396
2396
  })
2397
2397
  }
2398
2398
 
2399
+ // Available chart versions for a release (newest-first), for the upgrade dialog's
2400
+ // version picker. Empty when the source can't be resolved — the dialog then falls
2401
+ // back to the latest version from upgrade-info.
2402
+ export function useHelmReleaseVersions(namespace: string, name: string, enabled = true) {
2403
+ return useQuery<string[]>({
2404
+ queryKey: ['helm-release-versions', namespace, name],
2405
+ queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}/versions`),
2406
+ enabled: Boolean(namespace && name && enabled),
2407
+ staleTime: 30000,
2408
+ retry: false,
2409
+ })
2410
+ }
2411
+
2399
2412
  // Batch check for upgrade availability (for list view)
2400
2413
  export function useHelmBatchUpgradeInfo(namespaces: string[] = [], enabled = true) {
2401
2414
  const params = helmNamespaceParams(namespaces)
@@ -2668,6 +2681,54 @@ export function useUpdateRepositorySilent() {
2668
2681
  })
2669
2682
  }
2670
2683
 
2684
+ // Registered OCI chart sources (the OCI analog of `helm repo add`). Used to
2685
+ // track upgrades for the user's own OCI-published charts.
2686
+ export function useHelmOCISources() {
2687
+ return useQuery<string[]>({
2688
+ queryKey: ['helm-oci-sources'],
2689
+ queryFn: () => fetchJSON('/helm/oci-sources'),
2690
+ })
2691
+ }
2692
+
2693
+ async function mutateOCISource(method: 'POST' | 'DELETE', source: string): Promise<string[]> {
2694
+ const response = await apiFetch(`${getApiBase()}/helm/oci-sources`, {
2695
+ method,
2696
+ headers: { 'Content-Type': 'application/json' },
2697
+ body: JSON.stringify({ source }),
2698
+ })
2699
+ if (!response.ok) {
2700
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2701
+ throw new Error(error.error || `HTTP ${response.status}`)
2702
+ }
2703
+ return response.json()
2704
+ }
2705
+
2706
+ // Invalidate the upgrade-info queries so a newly-registered source is probed
2707
+ // immediately and "source not tracked" re-resolves.
2708
+ function invalidateHelmAfterSourceChange(queryClient: ReturnType<typeof useQueryClient>) {
2709
+ queryClient.invalidateQueries({ queryKey: ['helm-oci-sources'] })
2710
+ queryClient.invalidateQueries({ queryKey: ['helm-upgrade-info'] })
2711
+ queryClient.invalidateQueries({ queryKey: ['helm-batch-upgrade-info'] })
2712
+ }
2713
+
2714
+ export function useAddOCISource() {
2715
+ const queryClient = useQueryClient()
2716
+ return useMutation({
2717
+ mutationFn: (source: string) => mutateOCISource('POST', source),
2718
+ meta: { errorMessage: 'Failed to add chart source', successMessage: 'Chart source added' },
2719
+ onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
2720
+ })
2721
+ }
2722
+
2723
+ export function useRemoveOCISource() {
2724
+ const queryClient = useQueryClient()
2725
+ return useMutation({
2726
+ mutationFn: (source: string) => mutateOCISource('DELETE', source),
2727
+ meta: { errorMessage: 'Failed to remove chart source', successMessage: 'Chart source removed' },
2728
+ onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
2729
+ })
2730
+ }
2731
+
2671
2732
  // Search charts across all repositories
2672
2733
  export function useSearchCharts(query: string, allVersions = false, enabled = true) {
2673
2734
  return useQuery<ChartSearchResult>({
@@ -3048,7 +3109,7 @@ export function useSwitchContext() {
3048
3109
  } catch (error) {
3049
3110
  clearTimeout(timeoutId)
3050
3111
  if (error instanceof Error && error.name === 'AbortError') {
3051
- throw new Error('Context switch timed out. The cluster may be unreachable.')
3112
+ throw new Error('Context switch timed out. The cluster may be unreachable.', { cause: error })
3052
3113
  }
3053
3114
  throw error
3054
3115
  }
@@ -3151,7 +3212,7 @@ export function useSetActiveNamespace() {
3151
3212
  error: error instanceof Error ? error.message : String(error),
3152
3213
  })
3153
3214
  if (error instanceof Error && error.name === 'AbortError') {
3154
- throw new Error('Namespace switch timed out. The cluster may be unreachable.')
3215
+ throw new Error('Namespace switch timed out. The cluster may be unreachable.', { cause: error })
3155
3216
  }
3156
3217
  throw error
3157
3218
  }
@@ -88,7 +88,7 @@ export function DebugOverlay() {
88
88
  )}
89
89
  </>
90
90
  ) : (
91
- <span className="text-theme-text-tertiary">Loading...</span>
91
+ <span className="text-theme-text-tertiary">Loading…</span>
92
92
  )}
93
93
  </div>
94
94
  </div>
@@ -28,7 +28,7 @@ interface ApplicationsViewProps {
28
28
 
29
29
  export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsViewProps) {
30
30
  const query = useApplications(namespaces)
31
- const apps = query.data?.applications ?? []
31
+ const apps = useMemo(() => query.data?.applications ?? [], [query.data])
32
32
 
33
33
  // Which app is open lives in the URL (?app=<key>) so the detail view is
34
34
  // deep-linkable and the browser back button returns to the list. Opening or
@@ -1,4 +1,4 @@
1
- import { useCallback, useState } from 'react'
1
+ import { useCallback, useMemo, useState } from 'react'
2
2
  import { useNavigate, useSearchParams } from 'react-router-dom'
3
3
  import {
4
4
  ResourceCompareView,
@@ -23,13 +23,21 @@ export function CompareViewRoute() {
23
23
  // reserved for topology grouping mode and gets stripped by App.tsx's URL
24
24
  // sync on every non-topology view.
25
25
  const group = searchParams.get('apiGroup') ?? undefined
26
- const aParsed = parseRef(searchParams.get('a'))
27
- const bParsed = parseRef(searchParams.get('b'))
26
+ const aRaw = searchParams.get('a')
27
+ const bRaw = searchParams.get('b')
28
28
 
29
29
  const [pickerOpen, setPickerOpen] = useState<CompareSide | null>(null)
30
30
 
31
- const a: CompareResourceRef | null = aParsed ? { kind, namespace: aParsed.namespace, name: aParsed.name, group } : null
32
- const b: CompareResourceRef | null = bParsed ? { kind, namespace: bParsed.namespace, name: bParsed.name, group } : null
31
+ // Memoized so the refs keep a stable identity across renders otherwise the
32
+ // useCallbacks below (which depend on a/b) would be rebuilt every render.
33
+ const a: CompareResourceRef | null = useMemo(() => {
34
+ const p = parseRef(aRaw)
35
+ return p ? { kind, namespace: p.namespace, name: p.name, group } : null
36
+ }, [aRaw, kind, group])
37
+ const b: CompareResourceRef | null = useMemo(() => {
38
+ const p = parseRef(bRaw)
39
+ return p ? { kind, namespace: p.namespace, name: p.name, group } : null
40
+ }, [bRaw, kind, group])
33
41
 
34
42
  const aQuery = useResource<unknown>(a?.kind ?? '', a?.namespace ?? '', a?.name ?? '', a?.group)
35
43
  const bQuery = useResource<unknown>(b?.kind ?? '', b?.namespace ?? '', b?.name ?? '', b?.group)
@@ -34,7 +34,7 @@ export function CostTrendChart() {
34
34
  <div className="rounded-lg border border-theme-border bg-theme-surface/50 p-4">
35
35
  <div className="flex items-center justify-center h-[200px] text-theme-text-tertiary">
36
36
  <Loader2 className="w-5 h-5 animate-spin mr-2" />
37
- Loading cost trend...
37
+ Loading cost trend
38
38
  </div>
39
39
  </div>
40
40
  )
@@ -164,7 +164,7 @@ function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] }) {
164
164
  })
165
165
 
166
166
  return { timestamps, stacked, minTs, maxTs, yMax, seriesLookups, toX, toY, yTicks, xTicks, paths }
167
- }, [series])
167
+ }, [series, plotHeight, plotWidth])
168
168
 
169
169
  // Hover data — depends on hoverX + chartData, must be a separate hook (called unconditionally)
170
170
  const hoverData = useMemo(() => {
@@ -193,7 +193,7 @@ function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] }) {
193
193
  })
194
194
 
195
195
  return { ts: closestTs, x: toX(closestTs), total, points }
196
- }, [hoverX, chartData, series])
196
+ }, [hoverX, chartData, series, plotWidth])
197
197
 
198
198
  const handleMouseMove = useCallback((e: React.MouseEvent<SVGRectElement>) => {
199
199
  const svg = svgRef.current
@@ -282,7 +282,7 @@ function WorkloadRows({ namespace }: { namespace: string }) {
282
282
  return (
283
283
  <div className="px-4 py-3 flex items-center gap-2 text-xs text-theme-text-tertiary bg-theme-elevated/30">
284
284
  <Loader2 className="w-3.5 h-3.5 animate-spin" />
285
- Loading workloads...
285
+ Loading workloads
286
286
  </div>
287
287
  )
288
288
  }
@@ -190,7 +190,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
190
190
  </button>
191
191
  <div className="border-t border-theme-border my-1" />
192
192
  {reposLoading ? (
193
- <div className="px-3 py-2 text-sm text-theme-text-tertiary">Loading...</div>
193
+ <div className="px-3 py-2 text-sm text-theme-text-tertiary">Loading…</div>
194
194
  ) : repositories?.length === 0 ? (
195
195
  <div className="px-3 py-2 text-sm text-theme-text-tertiary">No repositories configured</div>
196
196
  ) : (