@skyhook-io/radar-app 1.8.3 → 1.8.5

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/src/RadarApp.tsx CHANGED
@@ -25,6 +25,7 @@ import { ThemeProvider } from './context/ThemeContext';
25
25
  import { ToastProvider, showApiError, showApiSuccess } from './components/ui/Toast';
26
26
  import { setApiBase, setBasename } from './api/config';
27
27
  import { NavCustomizationProvider } from './context/NavCustomization';
28
+ import { FilterLocationBridge } from './filter/FilterLocationBridge';
28
29
  import type { NavCustomization } from './context/NavCustomization';
29
30
 
30
31
  // Declare the shape of mutation meta here — inlined rather than in a
@@ -153,7 +154,9 @@ export function RadarApp({
153
154
  <QueryClientProvider client={client}>
154
155
  <ToastProvider>
155
156
  <NavCustomizationProvider value={navSlots}>
156
- <App manageDocumentTitle={manageDocumentTitle} documentTitleSuffix={documentTitleSuffix} />
157
+ <FilterLocationBridge>
158
+ <App manageDocumentTitle={manageDocumentTitle} documentTitleSuffix={documentTitleSuffix} />
159
+ </FilterLocationBridge>
157
160
  </NavCustomizationProvider>
158
161
  </ToastProvider>
159
162
  </QueryClientProvider>
package/src/api/client.ts CHANGED
@@ -17,6 +17,7 @@ import type {
17
17
  HelmValues,
18
18
  ManifestDiff,
19
19
  NotesDiff,
20
+ HooksDiff,
20
21
  ResourceDiff,
21
22
  UpgradeInfo,
22
23
  BatchUpgradeInfo,
@@ -35,6 +36,15 @@ import type { GitOpsOperationResponse } from '../types/gitops'
35
36
  import { getApiBase, getAuthHeaders, getCredentialsMode, getBasename, routePath } from './config'
36
37
  import { pluralToKind } from '../utils/navigation'
37
38
 
39
+ // Auto-refresh cadences (ms) — named constants for each polled hook's
40
+ // refetchInterval below, so the poll rate reads clearly at each call site.
41
+ const DASHBOARD_REFRESH_INTERVAL_MS = 30_000
42
+ const AUDIT_REFRESH_INTERVAL_MS = 60_000
43
+ const ISSUES_REFRESH_INTERVAL_MS = 30_000
44
+ const COST_REFRESH_INTERVAL_MS = 60_000
45
+ const CHANGES_REFRESH_INTERVAL_MS = 60_000
46
+ const APPLICATIONS_REFRESH_INTERVAL_MS = 60_000
47
+
38
48
  // Wrapper around fetch that always includes credentials (for session cookies)
39
49
  // and handles 401 responses globally. Merges caller-provided headers with
40
50
  // auth headers from the config module so library consumers (Radar Hub) can
@@ -326,7 +336,7 @@ export function useDashboard(namespaces: string[] = []) {
326
336
  queryKey: ['dashboard', namespaces],
327
337
  queryFn: () => fetchJSON(`/dashboard${params}`),
328
338
  staleTime: 15000, // 15 seconds
329
- refetchInterval: 30000, // Refresh every 30 seconds
339
+ refetchInterval: DASHBOARD_REFRESH_INTERVAL_MS,
330
340
  })
331
341
  }
332
342
 
@@ -337,7 +347,7 @@ export function useAudit(namespaces: string[] = []) {
337
347
  queryKey: ['audit', namespaces],
338
348
  queryFn: () => fetchJSON(`/audit${params}`),
339
349
  staleTime: 30000,
340
- refetchInterval: 60000,
350
+ refetchInterval: AUDIT_REFRESH_INTERVAL_MS,
341
351
  placeholderData: (prev) => prev,
342
352
  })
343
353
  }
@@ -368,7 +378,7 @@ export function useIssues(namespaces: string[] = []) {
368
378
  queryKey: ['issues', namespaces],
369
379
  queryFn: () => fetchJSON(`/issues${params}`),
370
380
  staleTime: 30000,
371
- refetchInterval: 30000,
381
+ refetchInterval: ISSUES_REFRESH_INTERVAL_MS,
372
382
  })
373
383
  }
374
384
 
@@ -516,7 +526,7 @@ export function useOpenCostSummary() {
516
526
  return useQuery<OpenCostSummary>({
517
527
  queryKey: ['opencost-summary'],
518
528
  queryFn: () => fetchJSON('/opencost/summary'),
519
- refetchInterval: 60000, // Refresh every minute
529
+ refetchInterval: COST_REFRESH_INTERVAL_MS,
520
530
  staleTime: 30000,
521
531
  placeholderData: (prev) => prev, // Keep previous data visible during refetch
522
532
  })
@@ -954,7 +964,7 @@ export function useApplications(namespaces: string[]) {
954
964
  queryKey: ['applications', namespaces],
955
965
  queryFn: () => fetchJSON(`/applications${queryString ? `?${queryString}` : ''}`),
956
966
  staleTime: 30_000,
957
- refetchInterval: 60_000,
967
+ refetchInterval: APPLICATIONS_REFRESH_INTERVAL_MS,
958
968
  })
959
969
  }
960
970
 
@@ -1113,7 +1123,7 @@ export function useChanges(options: UseChangesOptions = {}) {
1113
1123
  queryKey: ['changes', namespaces, kind, timeRange, filter, includeK8sEvents, includeManaged, includeDeleted, limit],
1114
1124
  queryFn: () => fetchJSON(`/changes${queryString ? `?${queryString}` : ''}`),
1115
1125
  staleTime: 5000, // Consider data stale after 5 seconds to ensure fresh data on navigation
1116
- refetchInterval: 60000, // SSE handles real-time updates; this is a fallback
1126
+ refetchInterval: CHANGES_REFRESH_INTERVAL_MS, // SSE handles real-time updates; this is a fallback
1117
1127
  enabled,
1118
1128
  })
1119
1129
  }
@@ -2452,6 +2462,22 @@ export function useHelmNotesDiff(
2452
2462
  })
2453
2463
  }
2454
2464
 
2465
+ export function useHelmHooksDiff(
2466
+ namespace: string,
2467
+ name: string,
2468
+ revision1: number,
2469
+ revision2: number,
2470
+ enabled = true,
2471
+ ) {
2472
+ return useQuery<HooksDiff>({
2473
+ queryKey: ['helm-hooks-diff', namespace, name, revision1, revision2],
2474
+ queryFn: () =>
2475
+ fetchJSON(`/helm/releases/${namespace}/${name}/hooks/diff?revision1=${revision1}&revision2=${revision2}`),
2476
+ enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
2477
+ staleTime: 60000,
2478
+ })
2479
+ }
2480
+
2455
2481
  export function useHelmResourceDiff(
2456
2482
  namespace: string,
2457
2483
  name: string,
@@ -254,7 +254,7 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
254
254
 
255
255
  {connection.error && (
256
256
  <div className="w-full bg-theme-elevated border border-theme-border rounded-lg p-3 mb-6 overflow-auto max-h-32">
257
- <code className="text-xs text-red-400 font-mono whitespace-pre-wrap break-all">
257
+ <code className="text-xs text-red-400 font-mono whitespace-pre-wrap break-words">
258
258
  {connection.error}
259
259
  </code>
260
260
  </div>
@@ -14,6 +14,8 @@ import { parseContextName, type ParsedContextName } from '../utils/context-name'
14
14
 
15
15
  interface ContextSwitcherProps {
16
16
  className?: string
17
+ variant?: 'chip' | 'segment'
18
+ label?: string
17
19
  }
18
20
 
19
21
  export interface ContextSwitcherHandle {
@@ -24,7 +26,7 @@ interface ParsedContext extends ParsedContextName {
24
26
  context: ContextInfo
25
27
  }
26
28
 
27
- export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcherProps>(({ className = '' }, ref) => {
29
+ export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcherProps>(({ className = '', variant, label }, ref) => {
28
30
  const [showConfirm, setShowConfirm] = useState(false)
29
31
  const [pendingSwitch, setPendingSwitch] = useState<ParsedContext | null>(null)
30
32
  const [sessionCounts, setSessionCounts] = useState<SessionCounts | null>(null)
@@ -191,6 +193,8 @@ export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcher
191
193
  <ClusterSwitcher
192
194
  ref={ref}
193
195
  className={className}
196
+ variant={variant}
197
+ label={label}
194
198
  currentId={currentId}
195
199
  currentName={currentRaw}
196
200
  currentSourceLabel={currentSourceLabel}
@@ -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).
@@ -92,7 +104,7 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
92
104
 
93
105
  return (
94
106
  <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
95
- <ApplicationsList apps={apps} onSelect={selectApp} />
107
+ <ApplicationsList apps={apps} onSelect={selectApp} headerActions={freshness} />
96
108
  </div>
97
109
  )
98
110
  }
@@ -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">