@skyhook-io/radar-app 1.8.3 → 1.8.6

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 +5 -5
  2. package/src/App.tsx +256 -94
  3. package/src/RadarApp.tsx +4 -1
  4. package/src/api/client.metrics.test.ts +106 -0
  5. package/src/api/client.ts +147 -20
  6. package/src/components/ConnectionErrorView.tsx +1 -1
  7. package/src/components/ContextSwitcher.tsx +5 -1
  8. package/src/components/NamespaceSwitcher.tsx +21 -300
  9. package/src/components/applications/ApplicationsView.tsx +22 -7
  10. package/src/components/audit/AuditView.tsx +11 -2
  11. package/src/components/cost/CostView.tsx +12 -2
  12. package/src/components/gitops/GitOpsView.tsx +22 -7
  13. package/src/components/helm/HelmCompareRoute.tsx +1342 -0
  14. package/src/components/helm/HelmReleaseDrawer.tsx +189 -352
  15. package/src/components/helm/HelmView.tsx +79 -62
  16. package/src/components/helm/ManifestDiffViewer.tsx +4 -4
  17. package/src/components/home/ClusterHealthCard.tsx +6 -1
  18. package/src/components/home/HomeView.tsx +20 -7
  19. package/src/components/home/mcpToolCatalog.ts +1 -1
  20. package/src/components/issues/IssuesPane.tsx +29 -18
  21. package/src/components/resources/ResourceDetailDrawer.tsx +8 -3
  22. package/src/components/resources/ResourcesView.tsx +3 -0
  23. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  24. package/src/components/resources/renderers/PodRenderer.tsx +10 -4
  25. package/src/components/timeline/TimelineView.tsx +26 -2
  26. package/src/components/traffic/TrafficView.tsx +17 -10
  27. package/src/components/ui/Markdown.tsx +2 -2
  28. package/src/components/ui/Omnibar.tsx +1 -1
  29. package/src/components/workload/WorkloadView.tsx +5 -1
  30. package/src/filter/FilterLocationBridge.tsx +30 -0
  31. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  32. package/src/index.ts +15 -0
@@ -1,320 +1,41 @@
1
- import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
2
- import { createPortal } from 'react-dom'
3
- import { ChevronDown, Globe, Search, AlertTriangle, X } from 'lucide-react'
1
+ import { forwardRef } from 'react'
2
+ import { NamespacePicker, type NamespacePickerHandle } from '@skyhook-io/k8s-ui'
4
3
  import { useNamespaceScope, useSetActiveNamespace } from '../api/client'
5
- import { Tooltip } from './ui/Tooltip'
6
4
 
7
- export interface NamespaceSwitcherHandle {
8
- open: () => void
9
- }
5
+ export type NamespaceSwitcherHandle = NamespacePickerHandle
10
6
 
11
7
  interface NamespaceSwitcherProps {
12
8
  className?: string
13
9
  disabled?: boolean
14
10
  disabledTooltip?: string
11
+ variant?: 'chip' | 'segment'
12
+ label?: string
15
13
  }
16
14
 
17
15
  /**
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.
21
- *
22
- * Three states reflect what the backend reports:
23
- * - cluster-wide: empty trigger label "All namespaces", picker lets the
24
- * user narrow the view; otherwise informational.
25
- * - namespace: label shows the namespace count (or single name); picker
26
- * offers other accessible namespaces and a clear-all reset.
27
- * - restricted: user can't list namespaces and isn't pinned; picker
28
- * surfaces only the kubeconfig context's namespace + any saved picks.
29
- *
30
- * Selection model: the dropdown keeps a draft Set<string>; toggling rows
31
- * mutates the draft locally; closing the dropdown applies the draft in a
32
- * single mutation. "Clear all" applies immediately and closes; "Select all
33
- * visible" / "Clear visible" mutate the draft only and wait for close.
16
+ * OSS Radar's namespace scope control — a thin data container over the shared
17
+ * presentational NamespacePicker (@skyhook-io/k8s-ui). Wires Radar's own API
18
+ * hooks; Radar Hub supplies its own container over the per-cluster apiBase.
34
19
  */
35
20
  export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSwitcherProps>(function NamespaceSwitcher(
36
- { className = '', disabled = false, disabledTooltip },
21
+ { className, disabled, disabledTooltip, variant, label },
37
22
  ref,
38
23
  ) {
39
24
  const { data: scope, isLoading } = useNamespaceScope()
40
25
  const setActive = useSetActiveNamespace()
41
26
 
42
- const [isOpen, setIsOpen] = useState(false)
43
- const [search, setSearch] = useState('')
44
- const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
45
- const [draft, setDraft] = useState<Set<string>>(() => new Set())
46
-
47
- const triggerRef = useRef<HTMLButtonElement>(null)
48
- const dropdownRef = useRef<HTMLDivElement>(null)
49
-
50
- const scopeActives = useMemo(() => scope?.actives ?? [], [scope?.actives])
51
- const activesKey = useMemo(() => [...scopeActives].sort().join(','), [scopeActives])
52
-
53
- // Sync the draft with the server's view whenever it changes (initial load,
54
- // post-mutation refetch, eviction after RBAC drift).
55
- useEffect(() => {
56
- setDraft(new Set(scopeActives))
57
- }, [activesKey, scopeActives])
58
-
59
- const items = useMemo(() => {
60
- if (!scope) return [] as string[]
61
- return [...(scope.accessibleNamespaces ?? [])].sort((a, b) => a.localeCompare(b))
62
- }, [scope])
63
-
64
- const filteredItems = useMemo(() => {
65
- const q = search.trim().toLowerCase()
66
- if (!q) return items
67
- return items.filter(n => n.toLowerCase().includes(q))
68
- }, [items, search])
69
-
70
- const applySelection = useCallback((next: Set<string>) => {
71
- if (!scope) return
72
- const nextArr = Array.from(next).sort()
73
- if (scope.cacheScoped && nextArr.length !== 1) return
74
- if (nextArr.join(',') === activesKey) return
75
- setActive.mutate({ namespaces: nextArr })
76
- }, [activesKey, scope, setActive])
77
-
78
- const closeAndApply = useCallback(() => {
79
- setIsOpen(false)
80
- setSearch('')
81
- applySelection(draft)
82
- }, [applySelection, draft])
83
-
84
- useImperativeHandle(ref, () => ({
85
- open: () => {
86
- if (disabled || isLoading || setActive.isPending) return
87
- setIsOpen(true)
88
- },
89
- }), [disabled, isLoading, setActive.isPending])
90
-
91
- useEffect(() => {
92
- if (!isOpen) return
93
- const trigger = triggerRef.current
94
- if (!trigger) return
95
- const r = trigger.getBoundingClientRect()
96
- setPos({ top: r.bottom + 4, left: r.left, width: Math.max(r.width, 240) })
97
- }, [isOpen])
98
-
99
- useEffect(() => {
100
- if (!isOpen) return
101
- function onClick(e: MouseEvent) {
102
- if (
103
- !dropdownRef.current?.contains(e.target as Node) &&
104
- !triggerRef.current?.contains(e.target as Node)
105
- ) {
106
- closeAndApply()
107
- }
108
- }
109
- function onKey(e: KeyboardEvent) {
110
- if (e.key === 'Escape') closeAndApply()
111
- }
112
- document.addEventListener('mousedown', onClick)
113
- document.addEventListener('keydown', onKey)
114
- return () => {
115
- document.removeEventListener('mousedown', onClick)
116
- document.removeEventListener('keydown', onKey)
117
- }
118
- }, [isOpen, closeAndApply])
119
-
120
- if (!scope) return null
121
-
122
- const toggle = (ns: string) => {
123
- if (scope.cacheScoped) {
124
- setDraft(new Set([ns]))
125
- return
126
- }
127
- const next = new Set(draft)
128
- if (next.has(ns)) next.delete(ns)
129
- else next.add(ns)
130
- setDraft(next)
131
- }
132
-
133
- const clearAll = () => {
134
- if (scope.cacheScoped) return
135
- setDraft(new Set())
136
- setIsOpen(false)
137
- setSearch('')
138
- applySelection(new Set())
139
- }
140
-
141
- const selectAllVisible = () => {
142
- const next = new Set(draft)
143
- for (const ns of filteredItems) next.add(ns)
144
- setDraft(next)
145
- }
146
-
147
- const clearVisible = () => {
148
- const next = new Set(draft)
149
- for (const ns of filteredItems) next.delete(ns)
150
- setDraft(next)
151
- }
152
-
153
- const activeCount = scopeActives.length
154
- const triggerLabel =
155
- activeCount === 0 ? 'All namespaces' : activeCount === 1 ? scopeActives[0] : `${activeCount} namespaces`
156
- const isClusterWide = activeCount === 0
157
- const restrictedHint = scope.mode === 'restricted'
158
- const cacheScopeLocked = scope.cacheScoped && !scope.namespaceRescope
159
- const isDisabled = disabled || isLoading || setActive.isPending || cacheScopeLocked
160
- const canClearAll = scope.canClearNamespace || activeCount === 0
161
- const tooltipContent = disabled && disabledTooltip
162
- ? disabledTooltip
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
168
- ? 'Limited namespace visibility — only namespaces granted by your RBAC are shown.'
169
- : isClusterWide
170
- ? 'Currently viewing all namespaces. Click to narrow the view.'
171
- : activeCount === 1
172
- ? `View is filtered to namespace ${scopeActives[0]}. Click to switch or reset.`
173
- : `View is filtered to ${activeCount} namespaces. Click to adjust or reset.`
174
-
175
- // Counts used to label the bulk-action buttons; computed against the visible
176
- // (filtered) set so the labels match what the action will affect.
177
- const visibleSelectedCount = filteredItems.reduce((n, ns) => n + (draft.has(ns) ? 1 : 0), 0)
178
- const allVisibleSelected = filteredItems.length > 0 && visibleSelectedCount === filteredItems.length
179
-
180
27
  return (
181
- <>
182
- <Tooltip
183
- content={tooltipContent}
184
- delay={300}
185
- position="bottom"
186
- >
187
- <button
188
- ref={triggerRef}
189
- onClick={() => !isDisabled && (isOpen ? closeAndApply() : setIsOpen(true))}
190
- disabled={isDisabled}
191
- className={`flex items-center gap-1.5 px-2 py-1 rounded text-sm bg-theme-elevated hover:bg-theme-hover text-theme-text-primary disabled:opacity-60 transition-colors ${className}`}
192
- aria-label="Switch active namespaces"
193
- >
194
- {isClusterWide ? (
195
- <Globe className="w-3.5 h-3.5 text-theme-text-tertiary" />
196
- ) : restrictedHint ? (
197
- <AlertTriangle className="w-3.5 h-3.5 text-theme-text-tertiary" />
198
- ) : null}
199
- <span className="font-medium max-w-[180px] truncate">
200
- {setActive.isPending ? 'Switching…' : triggerLabel}
201
- </span>
202
- <ChevronDown className="w-3 h-3 opacity-60" />
203
- </button>
204
- </Tooltip>
205
-
206
- {isOpen &&
207
- createPortal(
208
- <div
209
- ref={dropdownRef}
210
- style={{ position: 'fixed', top: pos.top, left: pos.left, minWidth: pos.width, zIndex: 100 }}
211
- className="bg-theme-surface border border-theme-border rounded-md shadow-theme-lg overflow-hidden"
212
- >
213
- {items.length > 6 && (
214
- <div className="flex items-center gap-2 px-2 py-1.5 border-b border-theme-border">
215
- <Search className="w-3.5 h-3.5 text-theme-text-tertiary" />
216
- <input
217
- autoFocus
218
- value={search}
219
- onChange={e => setSearch(e.target.value)}
220
- placeholder="Filter namespaces"
221
- className="flex-1 bg-transparent text-sm outline-none text-theme-text-primary placeholder:text-theme-text-tertiary"
222
- />
223
- </div>
224
- )}
225
-
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
- )}
257
-
258
- <ul className="max-h-80 overflow-y-auto py-1">
259
- {filteredItems.length === 0 && (
260
- <li className="px-3 py-2 text-xs text-theme-text-tertiary">
261
- {search ? 'No matches.' : 'No namespaces available.'}
262
- </li>
263
- )}
264
-
265
- {filteredItems.map(ns => {
266
- const isChecked = draft.has(ns)
267
- const isContextDefault = ns === scope.kubeconfigNamespace && ns !== ''
268
- return (
269
- <li key={ns}>
270
- <label
271
- className="w-full flex items-center justify-between px-3 py-1.5 text-sm hover:bg-theme-hover text-left text-theme-text-primary cursor-pointer"
272
- >
273
- <span className="flex items-center gap-2 min-w-0">
274
- <input
275
- type={scope.cacheScoped ? 'radio' : 'checkbox'}
276
- name={scope.cacheScoped ? 'namespace-cache-scope' : undefined}
277
- checked={isChecked}
278
- onChange={() => toggle(ns)}
279
- className="shrink-0 accent-current"
280
- />
281
- <span className="truncate">{ns}</span>
282
- {isContextDefault && (
283
- <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary shrink-0">
284
- kubeconfig
285
- </span>
286
- )}
287
- </span>
288
- </label>
289
- </li>
290
- )
291
- })}
292
- </ul>
293
-
294
- <div className="flex items-center justify-between px-3 py-1.5 border-t border-theme-border text-[11px] text-theme-text-tertiary">
295
- <span>
296
- {scope.cacheScoped
297
- ? (draft.size === 1 ? Array.from(draft)[0] : 'Select a namespace')
298
- : draft.size === 0 ? 'All namespaces' : `${draft.size} selected`}
299
- </span>
300
- <button
301
- onClick={closeAndApply}
302
- className="px-2 py-0.5 rounded bg-theme-elevated hover:bg-theme-hover text-theme-text-primary"
303
- >
304
- Done
305
- </button>
306
- </div>
307
-
308
- {!scope.authoritative && (
309
- <div className="px-3 py-2 border-t border-theme-border text-[11px] status-degraded">
310
- Limited list — your RBAC doesn&rsquo;t allow listing all
311
- namespaces. Other namespaces may be accessible but won&rsquo;t
312
- appear here until you switch context.
313
- </div>
314
- )}
315
- </div>,
316
- document.body,
317
- )}
318
- </>
28
+ <NamespacePicker
29
+ ref={ref}
30
+ scope={scope}
31
+ loading={isLoading}
32
+ pending={setActive.isPending}
33
+ onApply={namespaces => setActive.mutate({ namespaces })}
34
+ disabled={disabled}
35
+ disabledTooltip={disabledTooltip}
36
+ className={className}
37
+ variant={variant}
38
+ label={label}
39
+ />
319
40
  )
320
41
  })
@@ -5,6 +5,7 @@ import {
5
5
  ApplicationDetail,
6
6
  CenteredEmpty,
7
7
  PageHeader,
8
+ FreshnessControl,
8
9
  useToast,
9
10
  orderEnvs,
10
11
  matchWorkloadAcrossInstances,
@@ -18,6 +19,7 @@ import {
18
19
  } from '@skyhook-io/k8s-ui'
19
20
  import { Boxes } from 'lucide-react'
20
21
  import { useApplications, useTopology } from '../../api/client'
22
+ import { useConnection } from '../../context/ConnectionContext'
21
23
  import { kindToPlural } from '../../utils/navigation'
22
24
  import { WorkloadView } from '../workload/WorkloadView'
23
25
 
@@ -28,8 +30,18 @@ interface ApplicationsViewProps {
28
30
 
29
31
  export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsViewProps) {
30
32
  const query = useApplications(namespaces)
33
+ const { connection } = useConnection()
31
34
  const apps = useMemo(() => query.data?.applications ?? [], [query.data])
32
35
 
36
+ const freshness = (
37
+ <FreshnessControl
38
+ mode="auto"
39
+ dataUpdatedAt={query.dataUpdatedAt}
40
+ onRefresh={() => query.refetch()}
41
+ connectionState={connection.state}
42
+ />
43
+ )
44
+
33
45
  // Which app is open lives in the URL (?app=<key>) so the detail view is
34
46
  // deep-linkable and the browser back button returns to the list. Opening or
35
47
  // closing an app also clears the per-app params (workload, tab).
@@ -71,7 +83,14 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
71
83
  // the page header from vanishing while loading / on error, the wrapper shows
72
84
  // the same header bar above those states. (Keep title + description in sync
73
85
  // with ApplicationsList's PageHeader.)
74
- if (query.isLoading || query.error) {
86
+ if (query.isLoading) {
87
+ return (
88
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
89
+ <ApplicationsList apps={[]} onSelect={selectApp} headerActions={freshness} loading />
90
+ </div>
91
+ )
92
+ }
93
+ if (query.error) {
75
94
  return (
76
95
  <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
77
96
  <div className="shrink-0 border-b border-theme-border px-4 py-4">
@@ -81,18 +100,14 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
81
100
  description="Deployable software in this cluster — your services, workers, and jobs, grouped by app/release evidence."
82
101
  />
83
102
  </div>
84
- {query.isLoading ? (
85
- <CenteredEmpty icon={Boxes} headline="Loading applications…" />
86
- ) : (
87
- <CenteredEmpty tone="filtered" icon={Boxes} headline="Failed to load applications" body={(query.error as Error).message} />
88
- )}
103
+ <CenteredEmpty tone="filtered" icon={Boxes} headline="Failed to load applications" body={(query.error as Error).message} />
89
104
  </div>
90
105
  )
91
106
  }
92
107
 
93
108
  return (
94
109
  <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
95
- <ApplicationsList apps={apps} onSelect={selectApp} />
110
+ <ApplicationsList apps={apps} onSelect={selectApp} headerActions={freshness} />
96
111
  </div>
97
112
  )
98
113
  }
@@ -1,10 +1,11 @@
1
1
  import { useState, useCallback } from 'react'
2
2
  import { useAudit, useAuditSettings, useUpdateAuditSettings, useCloudRole } from '../../api/client'
3
3
  import type { SelectedResource } from '../../types'
4
- import { ChecksView, PaneLoader, PageHeader, type CheckResourceRef } from '@skyhook-io/k8s-ui'
4
+ import { ChecksView, PaneLoader, PageHeader, FreshnessControl, type CheckResourceRef } from '@skyhook-io/k8s-ui'
5
5
  import { ShieldCheck, Settings } from 'lucide-react'
6
6
  import { AuditSettingsDialog } from './AuditSettingsDialog'
7
7
  import { Tooltip } from '../ui/Tooltip'
8
+ import { useConnection } from '../../context/ConnectionContext'
8
9
 
9
10
  interface AuditViewProps {
10
11
  namespaces: string[]
@@ -18,7 +19,7 @@ interface AuditViewProps {
18
19
  // ~/.radar settings are this cluster's "policy" and the row hide-menu writes to
19
20
  // them.
20
21
  export function AuditView({ namespaces, onNavigateToResource }: AuditViewProps) {
21
- const { data, isLoading, error } = useAudit(namespaces)
22
+ const { data, isLoading, error, dataUpdatedAt, refetch } = useAudit(namespaces)
22
23
  const { data: auditSettings } = useAuditSettings()
23
24
  const updateSettings = useUpdateAuditSettings()
24
25
  // Audit policy is owner-gated (enforced server-side). Withhold the inline
@@ -30,6 +31,8 @@ export function AuditView({ namespaces, onNavigateToResource }: AuditViewProps)
30
31
 
31
32
  const ignoredCount = auditSettings?.ignoredNamespaces?.length ?? 0
32
33
 
34
+ const { connection } = useConnection()
35
+
33
36
  // Inline hide actions — persist to local settings immediately.
34
37
  const hideCheck = useCallback((checkID: string) => {
35
38
  if (!auditSettings) return
@@ -80,6 +83,12 @@ export function AuditView({ namespaces, onNavigateToResource }: AuditViewProps)
80
83
  description="Security, reliability, and efficiency best practices (NSA/CISA, CIS, Polaris, Kubescape), grouped into a remediation queue."
81
84
  actions={
82
85
  <>
86
+ <FreshnessControl
87
+ mode="auto"
88
+ dataUpdatedAt={dataUpdatedAt}
89
+ onRefresh={() => refetch()}
90
+ connectionState={connection.state}
91
+ />
83
92
  {ignoredCount > 0 && (
84
93
  <button onClick={() => setShowSettings(true)} className="text-xs text-theme-text-tertiary hover:text-theme-text-secondary transition-colors">{ignoredCount} {ignoredCount === 1 ? 'namespace' : 'namespaces'} hidden</button>
85
94
  )}
@@ -2,17 +2,19 @@ import { useState, useEffect } from 'react'
2
2
  import { useOpenCostSummary, useOpenCostWorkloads, useOpenCostNodes } from '../../api/client'
3
3
  import type { OpenCostNamespaceCost, OpenCostWorkloadCost, OpenCostNodeCost } from '../../api/client'
4
4
  import { ArrowLeft, ChevronDown, ChevronRight, DollarSign, HelpCircle, Loader2, Server, X } from 'lucide-react'
5
- import { PaneLoader } from '@skyhook-io/k8s-ui'
5
+ import { PaneLoader, FreshnessControl } from '@skyhook-io/k8s-ui'
6
6
  import { CostTrendChart } from './CostTrendChart'
7
7
  import { Tooltip } from '../ui/Tooltip'
8
+ import { useConnection } from '../../context/ConnectionContext'
8
9
 
9
10
  interface CostViewProps {
10
11
  onBack: () => void
11
12
  }
12
13
 
13
14
  export function CostView({ onBack }: CostViewProps) {
14
- const { data, isLoading } = useOpenCostSummary()
15
+ const { data, isLoading, dataUpdatedAt, refetch } = useOpenCostSummary()
15
16
  const { data: nodeData } = useOpenCostNodes()
17
+ const { connection } = useConnection()
16
18
  const [showHelp, setShowHelp] = useState(false)
17
19
 
18
20
  if (isLoading) {
@@ -90,6 +92,14 @@ export function CostView({ onBack }: CostViewProps) {
90
92
  </button>
91
93
  </div>
92
94
  <div className="flex items-center gap-4">
95
+ {/* Tracks the headline $/hr summary (the primary query); its load
96
+ time is the representative freshness signal for the view. */}
97
+ <FreshnessControl
98
+ mode="auto"
99
+ dataUpdatedAt={dataUpdatedAt}
100
+ onRefresh={() => refetch()}
101
+ connectionState={connection.state}
102
+ />
93
103
  {hasEfficiency && (
94
104
  <div className="flex flex-col items-end gap-0.5">
95
105
  <div className="flex items-center gap-2 text-sm">
@@ -8,6 +8,7 @@ import {
8
8
  GitOpsDetailLayout,
9
9
  GitOpsGraphFilterRail,
10
10
  GitOpsTableView as SharedGitOpsTableView,
11
+ FreshnessControl,
11
12
  GitOpsTreeGraph,
12
13
  RollbackDialog,
13
14
  SyncOptionsDialog,
@@ -61,6 +62,7 @@ import {
61
62
  useResource,
62
63
  } from '../../api/client'
63
64
  import { useAPIResources } from '../../api/apiResources'
65
+ import { useConnection } from '../../context/ConnectionContext'
64
66
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
65
67
  import { useRegisterShortcut } from '../../hooks/useKeyboardShortcuts'
66
68
  import { CodeViewer } from '../ui/CodeViewer'
@@ -80,6 +82,11 @@ const GITOPS_KINDS: APIResource[] = [
80
82
 
81
83
  const KIND_BY_NAME = new Map(GITOPS_KINDS.map((k) => [k.name, k]))
82
84
 
85
+ // Rows are the table's primary content; their poll cadence is what the toolbar
86
+ // freshness signal advertises ("Auto-refreshes every 2m"). Single source of
87
+ // truth so the signal can't drift from the actual refetchInterval below.
88
+ const GITOPS_ROWS_REFRESH_INTERVAL_MS = 120_000
89
+
83
90
  interface ResourceCountsResponse {
84
91
  counts: Record<string, number>
85
92
  forbidden?: string[]
@@ -102,6 +109,7 @@ export function GitOpsView({ namespaces, onOpenResource, onClearNamespaces }: Gi
102
109
 
103
110
  function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string[]; onClearNamespaces?: () => void }) {
104
111
  const navigate = useNavigate()
112
+ const { connection } = useConnection()
105
113
  const namespacesParam = namespaces.join(',')
106
114
  const { data: apiResources, isLoading: apiResourcesLoading } = useAPIResources()
107
115
 
@@ -191,7 +199,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
191
199
  },
192
200
  enabled: !apiResourcesLoading,
193
201
  staleTime: 30_000,
194
- refetchInterval: 120_000,
202
+ refetchInterval: GITOPS_ROWS_REFRESH_INTERVAL_MS,
195
203
  })
196
204
 
197
205
  // Row mutations invalidate granular keys (['resource', …], ['gitops-tree', …])
@@ -201,11 +209,11 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
201
209
  // inviting a duplicate request. Radar serves reads from an informer cache that
202
210
  // lags the write by the watch-propagation delay, so refetch once now (covers
203
211
  // an already-current cache) and once shortly after to catch the propagated
204
- // update; refetch() forces a fetch regardless of staleTime.
205
- const refetchTable = () => {
206
- rowsQuery.refetch()
207
- countsQuery.refetch()
208
- }
212
+ // update; refetch() forces a fetch regardless of staleTime. The toolbar's
213
+ // manual refresh reuses refetchTable so rows + counts stay in sync.
214
+ // Return the combined promise so the toolbar's refresh animation waits for the
215
+ // real fetches to settle before showing its success checkmark.
216
+ const refetchTable = () => Promise.all([rowsQuery.refetch(), countsQuery.refetch()])
209
217
  const refetchTableAfterMutation = () => {
210
218
  refetchTable()
211
219
  window.setTimeout(refetchTable, 1200)
@@ -280,7 +288,14 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
280
288
  error={(rowsQuery.error as Error | null) ?? null}
281
289
  counts={countsQuery.data?.counts ?? {}}
282
290
  countsUnavailable={countsQuery.data?.unavailable}
283
- onRefresh={() => rowsQuery.refetch()}
291
+ freshnessSlot={
292
+ <FreshnessControl
293
+ mode="auto"
294
+ dataUpdatedAt={rowsQuery.dataUpdatedAt}
295
+ onRefresh={refetchTable}
296
+ connectionState={connection.state}
297
+ />
298
+ }
284
299
  onRowClick={(row) => {
285
300
  const ns = row.namespace || '_'
286
301
  const params = new URLSearchParams()