@skyhook-io/k8s-ui 1.8.7 → 1.8.8

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 (36) hide show
  1. package/package.json +3 -3
  2. package/src/components/applications/ApplicationsList.tsx +5 -2
  3. package/src/components/applications/ApplicationsView.tsx +4 -1
  4. package/src/components/cluster-switcher/ClusterSwitcher.tsx +27 -9
  5. package/src/components/gitops/GitOpsTableView.tsx +46 -45
  6. package/src/components/gitops/insights/GitOpsInsightViews.tsx +12 -5
  7. package/src/components/issues/IssuesView.tsx +41 -5
  8. package/src/components/issues/ResourceIssuesSection.tsx +3 -0
  9. package/src/components/issues/diagnostic.ts +22 -0
  10. package/src/components/issues/index.ts +1 -1
  11. package/src/components/issues/issues.test.ts +21 -0
  12. package/src/components/issues/types.ts +18 -0
  13. package/src/components/namespace-switcher/NamespacePicker.tsx +381 -0
  14. package/src/components/namespace-switcher/index.ts +6 -0
  15. package/src/components/resources/ResourcesView.tsx +20 -81
  16. package/src/components/scope-pill/ScopePill.tsx +35 -0
  17. package/src/components/scope-pill/index.ts +2 -0
  18. package/src/components/timeline/TimelineList.tsx +27 -1
  19. package/src/components/topology/TopologyControls.tsx +90 -14
  20. package/src/components/ui/FreshnessControl.tsx +153 -0
  21. package/src/components/ui/SortableTh.tsx +16 -10
  22. package/src/components/ui/Toast.tsx +1 -1
  23. package/src/components/ui/index.ts +2 -0
  24. package/src/components/workload/ResourceDetailDrawer.tsx +215 -32
  25. package/src/components/workload/WorkloadView.tsx +26 -8
  26. package/src/hooks/index.ts +1 -0
  27. package/src/hooks/useKeyboardShortcuts.tsx +23 -2
  28. package/src/hooks/useRefreshAnimation.ts +15 -2
  29. package/src/index.ts +8 -0
  30. package/src/types/core.ts +42 -0
  31. package/src/types/gitops-insights.ts +4 -0
  32. package/src/utils/animation.ts +10 -0
  33. package/src/utils/format-freshness.test.ts +34 -0
  34. package/src/utils/format.ts +32 -0
  35. package/src/utils/resource-hierarchy.test.ts +51 -0
  36. package/src/utils/resource-hierarchy.ts +7 -4
@@ -0,0 +1,381 @@
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'
4
+ import { Tooltip } from '../ui/Tooltip'
5
+
6
+ /**
7
+ * Backend-reported namespace scope. Mirrors Radar's `/cluster/namespace-scope`
8
+ * response shape; the host supplies it however it fetches it (OSS via its API
9
+ * hooks, Radar Hub via the per-cluster apiBase).
10
+ */
11
+ export interface NamespaceScopeView {
12
+ /** Currently-selected namespaces. Empty = cluster-wide ("All namespaces"). */
13
+ actives: string[]
14
+ /** Namespaces the user may pick from. */
15
+ accessibleNamespaces: string[]
16
+ mode?: 'cluster-wide' | 'namespace' | 'restricted' | string
17
+ /** Single-namespace cache-scope control instead of a multi-select filter. */
18
+ cacheScoped?: boolean
19
+ /** Under cacheScoped, whether the user may re-point the watched namespace. */
20
+ namespaceRescope?: boolean
21
+ cacheScopeNamespace?: string
22
+ kubeconfigNamespace?: string
23
+ canClearNamespace?: boolean
24
+ /** false when accessibleNamespaces is a best-effort short list (no list perm). */
25
+ authoritative?: boolean
26
+ }
27
+
28
+ export interface NamespacePickerHandle {
29
+ open: () => void
30
+ }
31
+
32
+ export interface NamespacePickerProps {
33
+ /** null/undefined while loading — the picker renders nothing until it arrives. */
34
+ scope: NamespaceScopeView | null | undefined
35
+ /**
36
+ * Applied when the selection is committed (dropdown close / clear all).
37
+ * The picker only calls this when the selection actually changed and is
38
+ * valid (respects the cacheScoped single-namespace constraint).
39
+ */
40
+ onApply: (namespaces: string[]) => void
41
+ loading?: boolean
42
+ /** A switch/mutation is in flight — trigger shows "Switching…" and is inert. */
43
+ pending?: boolean
44
+ disabled?: boolean
45
+ disabledTooltip?: string
46
+ className?: string
47
+ /**
48
+ * 'chip' (default) renders a self-contained pill. 'segment' renders a
49
+ * borderless label+value cell for embedding in a shared bordered container
50
+ * (the unified cluster+namespace scope control), with an optional muted
51
+ * {@link label} before the value and a shorter value ("All" vs "All namespaces").
52
+ */
53
+ variant?: 'chip' | 'segment'
54
+ /** Muted label shown before the value in the 'segment' variant (e.g. "Namespace"). */
55
+ label?: string
56
+ }
57
+
58
+ /**
59
+ * NamespacePicker is the presentational namespace scope control shared by
60
+ * Radar OSS and Radar Hub (mirrors the ClusterSwitcher pattern — pure UI, data
61
+ * injected via props). It is normally a per-user multi-select view filter. When
62
+ * the scope reports cacheScoped=true, it becomes a single-namespace cache scope
63
+ * control.
64
+ *
65
+ * Three states reflect what the scope reports:
66
+ * - cluster-wide: empty trigger label "All namespaces"; picker lets the user
67
+ * narrow the view.
68
+ * - namespace: label shows the namespace count (or single name); picker
69
+ * offers other accessible namespaces and a clear-all reset.
70
+ * - restricted: user can't list namespaces and isn't pinned; picker
71
+ * surfaces only the kubeconfig context's namespace + any saved picks.
72
+ *
73
+ * Selection model: the dropdown keeps a draft Set<string>; toggling rows
74
+ * mutates the draft locally; closing the dropdown applies the draft in a single
75
+ * onApply. "Clear all" applies immediately and closes.
76
+ */
77
+ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePickerProps>(function NamespacePicker(
78
+ { scope, onApply, loading = false, pending = false, disabled = false, disabledTooltip, className = '', variant = 'chip', label },
79
+ ref,
80
+ ) {
81
+ const [isOpen, setIsOpen] = useState(false)
82
+ const [search, setSearch] = useState('')
83
+ const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
84
+ const [draft, setDraft] = useState<Set<string>>(() => new Set())
85
+
86
+ const triggerRef = useRef<HTMLButtonElement>(null)
87
+ const dropdownRef = useRef<HTMLDivElement>(null)
88
+
89
+ const scopeActives = useMemo(() => scope?.actives ?? [], [scope?.actives])
90
+ const activesKey = useMemo(() => [...scopeActives].sort().join(','), [scopeActives])
91
+
92
+ // Sync the draft with the server's view whenever it changes (initial load,
93
+ // post-mutation refetch, eviction after RBAC drift).
94
+ useEffect(() => {
95
+ setDraft(new Set(scopeActives))
96
+ }, [activesKey, scopeActives])
97
+
98
+ // Fully disable when the host disables the control (e.g. view-awareness
99
+ // navigating to a cluster-scoped surface): the trigger is inert and open()
100
+ // is blocked, but an already-open dropdown would still commit via Done /
101
+ // outside-click / Clear all — so close it (discarding the uncommitted draft)
102
+ // rather than letting a dead view apply a namespace change.
103
+ useEffect(() => {
104
+ if (disabled && isOpen) {
105
+ setIsOpen(false)
106
+ setSearch('')
107
+ // Discard the uncommitted draft so a later re-open reflects the current
108
+ // server actives, not stale toggles from before the control was disabled.
109
+ setDraft(new Set(scopeActives))
110
+ }
111
+ }, [disabled, isOpen, scopeActives])
112
+
113
+ const items = useMemo(() => {
114
+ if (!scope) return [] as string[]
115
+ return [...(scope.accessibleNamespaces ?? [])].sort((a, b) => a.localeCompare(b))
116
+ }, [scope])
117
+
118
+ const filteredItems = useMemo(() => {
119
+ const q = search.trim().toLowerCase()
120
+ if (!q) return items
121
+ return items.filter(n => n.toLowerCase().includes(q))
122
+ }, [items, search])
123
+
124
+ const applySelection = useCallback((next: Set<string>) => {
125
+ if (!scope) return
126
+ const nextArr = Array.from(next).sort()
127
+ if (scope.cacheScoped && nextArr.length !== 1) return
128
+ if (nextArr.join(',') === activesKey) return
129
+ onApply(nextArr)
130
+ }, [activesKey, scope, onApply])
131
+
132
+ const closeAndApply = useCallback(() => {
133
+ setIsOpen(false)
134
+ setSearch('')
135
+ applySelection(draft)
136
+ }, [applySelection, draft])
137
+
138
+ useImperativeHandle(ref, () => ({
139
+ open: () => {
140
+ if (disabled || loading || pending) return
141
+ setIsOpen(true)
142
+ },
143
+ }), [disabled, loading, pending])
144
+
145
+ useEffect(() => {
146
+ if (!isOpen) return
147
+ const trigger = triggerRef.current
148
+ if (!trigger) return
149
+ const r = trigger.getBoundingClientRect()
150
+ setPos({ top: r.bottom + 4, left: r.left, width: Math.max(r.width, 240) })
151
+ }, [isOpen])
152
+
153
+ useEffect(() => {
154
+ if (!isOpen) return
155
+ function onClick(e: MouseEvent) {
156
+ if (
157
+ !dropdownRef.current?.contains(e.target as Node) &&
158
+ !triggerRef.current?.contains(e.target as Node)
159
+ ) {
160
+ closeAndApply()
161
+ }
162
+ }
163
+ function onKey(e: KeyboardEvent) {
164
+ if (e.key === 'Escape') closeAndApply()
165
+ }
166
+ document.addEventListener('mousedown', onClick)
167
+ document.addEventListener('keydown', onKey)
168
+ return () => {
169
+ document.removeEventListener('mousedown', onClick)
170
+ document.removeEventListener('keydown', onKey)
171
+ }
172
+ }, [isOpen, closeAndApply])
173
+
174
+ if (!scope) return null
175
+
176
+ const toggle = (ns: string) => {
177
+ if (scope.cacheScoped) {
178
+ setDraft(new Set([ns]))
179
+ return
180
+ }
181
+ const next = new Set(draft)
182
+ if (next.has(ns)) next.delete(ns)
183
+ else next.add(ns)
184
+ setDraft(next)
185
+ }
186
+
187
+ const clearAll = () => {
188
+ if (scope.cacheScoped) return
189
+ setDraft(new Set())
190
+ setIsOpen(false)
191
+ setSearch('')
192
+ applySelection(new Set())
193
+ }
194
+
195
+ const selectAllVisible = () => {
196
+ const next = new Set(draft)
197
+ for (const ns of filteredItems) next.add(ns)
198
+ setDraft(next)
199
+ }
200
+
201
+ const clearVisible = () => {
202
+ const next = new Set(draft)
203
+ for (const ns of filteredItems) next.delete(ns)
204
+ setDraft(next)
205
+ }
206
+
207
+ const activeCount = scopeActives.length
208
+ const triggerLabel =
209
+ activeCount === 0 ? 'All namespaces' : activeCount === 1 ? scopeActives[0] : `${activeCount} namespaces`
210
+ const isClusterWide = activeCount === 0
211
+ const restrictedHint = scope.mode === 'restricted'
212
+ const cacheScopeLocked = scope.cacheScoped && !scope.namespaceRescope
213
+ const isDisabled = disabled || loading || pending || cacheScopeLocked
214
+ const canClearAll = scope.canClearNamespace || activeCount === 0
215
+ const tooltipContent = disabled && disabledTooltip
216
+ ? disabledTooltip
217
+ : scope.cacheScoped
218
+ ? scope.namespaceRescope
219
+ ? `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).`
220
+ : `Radar is watching only ${scope.cacheScopeNamespace || triggerLabel} on this cluster.`
221
+ : restrictedHint
222
+ ? 'Limited namespace visibility — only namespaces granted by your RBAC are shown.'
223
+ : isClusterWide
224
+ ? 'Currently viewing all namespaces. Click to narrow the view.'
225
+ : activeCount === 1
226
+ ? `View is filtered to namespace ${scopeActives[0]}. Click to switch or reset.`
227
+ : `View is filtered to ${activeCount} namespaces. Click to adjust or reset.`
228
+
229
+ // Counts used to label the bulk-action buttons; computed against the visible
230
+ // (filtered) set so the labels match what the action will affect.
231
+ const visibleSelectedCount = filteredItems.reduce((n, ns) => n + (draft.has(ns) ? 1 : 0), 0)
232
+ const allVisibleSelected = filteredItems.length > 0 && visibleSelectedCount === filteredItems.length
233
+
234
+ return (
235
+ <>
236
+ <Tooltip
237
+ content={tooltipContent}
238
+ delay={300}
239
+ position="bottom"
240
+ >
241
+ <button
242
+ ref={triggerRef}
243
+ onClick={() => !isDisabled && (isOpen ? closeAndApply() : setIsOpen(true))}
244
+ disabled={isDisabled}
245
+ className={
246
+ variant === 'segment'
247
+ ? `flex items-center justify-center gap-1.5 px-3 py-1.5 h-full min-w-[110px] max-w-[200px] text-[13px] text-theme-text-primary hover:bg-theme-hover disabled:opacity-60 transition-colors ${className}`
248
+ : `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}`
249
+ }
250
+ aria-label="Switch active namespaces"
251
+ >
252
+ {label && (
253
+ <span className="shrink-0 font-normal text-theme-text-tertiary">{label}</span>
254
+ )}
255
+ {isClusterWide ? (
256
+ <Globe className="w-3.5 h-3.5 shrink-0 text-theme-text-tertiary" />
257
+ ) : restrictedHint ? (
258
+ <AlertTriangle className="w-3.5 h-3.5 shrink-0 text-theme-text-tertiary" />
259
+ ) : null}
260
+ <span className={`font-medium truncate ${variant === 'segment' ? 'min-w-0' : 'max-w-[180px]'}`}>
261
+ {pending ? 'Switching…' : triggerLabel}
262
+ </span>
263
+ <ChevronDown className="w-3 h-3 shrink-0 opacity-60" />
264
+ </button>
265
+ </Tooltip>
266
+
267
+ {isOpen &&
268
+ createPortal(
269
+ <div
270
+ ref={dropdownRef}
271
+ style={{ position: 'fixed', top: pos.top, left: pos.left, minWidth: pos.width, zIndex: 100 }}
272
+ className="bg-theme-surface border border-theme-border rounded-md shadow-theme-lg overflow-hidden"
273
+ >
274
+ {items.length > 6 && (
275
+ <div className="flex items-center gap-2 px-2 py-1.5 border-b border-theme-border">
276
+ <Search className="w-3.5 h-3.5 text-theme-text-tertiary" />
277
+ <input
278
+ autoFocus
279
+ value={search}
280
+ onChange={e => setSearch(e.target.value)}
281
+ placeholder="Filter namespaces"
282
+ className="flex-1 bg-transparent text-sm outline-none text-theme-text-primary placeholder:text-theme-text-tertiary"
283
+ />
284
+ </div>
285
+ )}
286
+
287
+ {scope.cacheScoped ? (
288
+ <div className="px-3 py-1.5 border-b border-theme-border text-[11px] leading-snug text-theme-text-secondary">
289
+ Radar is watching one namespace to stay fast on large clusters.
290
+ {scope.namespaceRescope
291
+ ? ' Pick another to re-point it — takes a moment and closes open terminals.'
292
+ : ' This instance is locked to its startup namespace.'}
293
+ </div>
294
+ ) : (
295
+ <div className="flex items-center justify-between px-2 py-1.5 border-b border-theme-border text-xs text-theme-text-secondary">
296
+ <button
297
+ onClick={canClearAll ? clearAll : undefined}
298
+ disabled={!canClearAll || activeCount === 0}
299
+ className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
300
+ aria-label="Clear namespace selection"
301
+ >
302
+ <X className="w-3 h-3" />
303
+ Clear all
304
+ </button>
305
+ <button
306
+ onClick={allVisibleSelected ? clearVisible : selectAllVisible}
307
+ disabled={filteredItems.length === 0}
308
+ className="px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
309
+ >
310
+ {allVisibleSelected
311
+ ? `Clear ${filteredItems.length} visible`
312
+ : search.trim()
313
+ ? `Select ${filteredItems.length} visible`
314
+ : 'Select all'}
315
+ </button>
316
+ </div>
317
+ )}
318
+
319
+ <ul className="max-h-80 overflow-y-auto py-1">
320
+ {filteredItems.length === 0 && (
321
+ <li className="px-3 py-2 text-xs text-theme-text-tertiary">
322
+ {search ? 'No matches.' : 'No namespaces available.'}
323
+ </li>
324
+ )}
325
+
326
+ {filteredItems.map(ns => {
327
+ const isChecked = draft.has(ns)
328
+ const isContextDefault = ns === scope.kubeconfigNamespace && ns !== ''
329
+ return (
330
+ <li key={ns}>
331
+ <label
332
+ 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"
333
+ >
334
+ <span className="flex items-center gap-2 min-w-0">
335
+ <input
336
+ type={scope.cacheScoped ? 'radio' : 'checkbox'}
337
+ name={scope.cacheScoped ? 'namespace-cache-scope' : undefined}
338
+ checked={isChecked}
339
+ onChange={() => toggle(ns)}
340
+ className="shrink-0 accent-current"
341
+ />
342
+ <span className="truncate">{ns}</span>
343
+ {isContextDefault && (
344
+ <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary shrink-0">
345
+ kubeconfig
346
+ </span>
347
+ )}
348
+ </span>
349
+ </label>
350
+ </li>
351
+ )
352
+ })}
353
+ </ul>
354
+
355
+ <div className="flex items-center justify-between px-3 py-1.5 border-t border-theme-border text-[11px] text-theme-text-tertiary">
356
+ <span>
357
+ {scope.cacheScoped
358
+ ? (draft.size === 1 ? Array.from(draft)[0] : 'Select a namespace')
359
+ : draft.size === 0 ? 'All namespaces' : `${draft.size} selected`}
360
+ </span>
361
+ <button
362
+ onClick={closeAndApply}
363
+ className="px-2 py-0.5 rounded bg-theme-elevated hover:bg-theme-hover text-theme-text-primary"
364
+ >
365
+ Done
366
+ </button>
367
+ </div>
368
+
369
+ {!scope.authoritative && (
370
+ <div className="px-3 py-2 border-t border-theme-border text-[11px] status-degraded">
371
+ Limited list — your RBAC doesn&rsquo;t allow listing all
372
+ namespaces. Other namespaces may be accessible but won&rsquo;t
373
+ appear here until you switch context.
374
+ </div>
375
+ )}
376
+ </div>,
377
+ document.body,
378
+ )}
379
+ </>
380
+ )
381
+ })
@@ -0,0 +1,6 @@
1
+ export { NamespacePicker } from './NamespacePicker'
2
+ export type {
3
+ NamespacePickerProps,
4
+ NamespacePickerHandle,
5
+ NamespaceScopeView,
6
+ } from './NamespacePicker'
@@ -1,6 +1,5 @@
1
1
  import React, { useState, useMemo, useEffect, useCallback, useRef, useContext, useId } from 'react'
2
2
  import { TableVirtuoso, type TableVirtuosoHandle } from 'react-virtuoso'
3
- import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
4
3
  import { PaneLoader } from '../ui/PaneLoader'
5
4
  import { RestrictedState } from '../ui/RestrictedState'
6
5
  import type { TopPodMetrics, TopNodeMetrics } from '../../types'
@@ -13,7 +12,6 @@ import {
13
12
  ChevronDown,
14
13
  ChevronUp,
15
14
  ArrowUpDown,
16
- Clock,
17
15
  ListFilter,
18
16
  X,
19
17
  Columns3,
@@ -133,6 +131,7 @@ import { SEVERITY_BADGE, EVENT_TYPE_COLORS, SEVERITY_TEXT } from '../../utils/ba
133
131
  import { pluralize } from '../../utils/pluralize'
134
132
  import { getPodGpuCount, getNodeGpuCount } from '../../utils/extended-resources'
135
133
  import { type CustomColumnDef, type CustomColumnSource, customColumnKey, readCustomColumnValue, sanitizeCustomColumnDefs } from '../../utils/custom-columns'
134
+ import { FreshnessControl, type FreshnessConnection } from '../ui/FreshnessControl'
136
135
  import { Tooltip } from '../ui/Tooltip'
137
136
  import { AuditBadgeTooltip, type AuditBadgeMessage } from '../audit/AuditBadgeTooltip'
138
137
  // CRD-specific cell components (extracted)
@@ -1851,6 +1850,9 @@ interface ResourcesViewProps {
1851
1850
  resourceUnavailable?: string[]
1852
1851
  // Single query for the currently selected kind's full data
1853
1852
  selectedKindQuery?: ResourceQueryResult
1853
+ // Cluster/SSE connection health — the list is SSE-invalidated ("Auto-updating"),
1854
+ // so it must degrade to "Reconnecting…" when the stream drops.
1855
+ connectionState?: FreshnessConnection
1854
1856
  largeListGuard?: LargeListGuardState | null
1855
1857
  topPodMetrics?: TopPodMetrics[]
1856
1858
  topNodeMetrics?: TopNodeMetrics[]
@@ -2017,51 +2019,6 @@ function getInitialFiltersFromURL() {
2017
2019
  // Sort state type
2018
2020
  type SortDirection = 'asc' | 'desc' | null
2019
2021
 
2020
- // Coarse "just now / Xm / Xh / Xd" buckets — finer-grained updates
2021
- // add motion in the periphery without aiding any user decision.
2022
- function formatLastUpdatedBucket(elapsedMs: number): string {
2023
- const elapsedSec = Math.max(0, Math.floor(elapsedMs / 1000))
2024
- if (elapsedSec < 60) return 'just now'
2025
- const minutes = Math.floor(elapsedSec / 60)
2026
- if (minutes < 60) return `${minutes}m`
2027
- const hours = Math.floor(minutes / 60)
2028
- if (hours < 24) return `${hours}h`
2029
- return `${Math.floor(hours / 24)}d`
2030
- }
2031
-
2032
- // ms until the displayed bucket would change.
2033
- function msToNextBucket(elapsedMs: number): number {
2034
- const elapsed = Math.max(0, elapsedMs)
2035
- if (elapsed < 60_000) return 60_000 - elapsed
2036
- if (elapsed < 3_600_000) return 60_000 - (elapsed % 60_000)
2037
- if (elapsed < 86_400_000) return 3_600_000 - (elapsed % 3_600_000)
2038
- return 86_400_000 - (elapsed % 86_400_000)
2039
- }
2040
-
2041
- // Isolated subtree so re-renders don't cascade into the parent's
2042
- // virtualized table.
2043
- function LastUpdatedLabel({ lastUpdated }: { lastUpdated: Date }) {
2044
- const [, force] = useState(0)
2045
- useEffect(() => {
2046
- let id: ReturnType<typeof setTimeout>
2047
- function schedule() {
2048
- const delay = Math.max(1000, msToNextBucket(Date.now() - lastUpdated.getTime()))
2049
- id = setTimeout(() => {
2050
- force(t => t + 1)
2051
- schedule()
2052
- }, delay)
2053
- }
2054
- schedule()
2055
- return () => clearTimeout(id)
2056
- }, [lastUpdated])
2057
- return (
2058
- <div className="flex items-center gap-1.5 text-xs text-theme-text-tertiary">
2059
- <Clock className="w-3.5 h-3.5" />
2060
- <span>Updated {formatLastUpdatedBucket(Date.now() - lastUpdated.getTime())}</span>
2061
- </div>
2062
- )
2063
- }
2064
-
2065
2022
  export function ResourcesView({
2066
2023
  namespaces, selectedResource, onResourceClick, onResourceClickYaml, onKindChange,
2067
2024
  apiResources: apiResourcesProp,
@@ -2071,6 +2028,7 @@ export function ResourcesView({
2071
2028
  resourceReasons,
2072
2029
  resourceUnavailable: resourceUnavailableProp,
2073
2030
  selectedKindQuery: selectedKindQueryProp,
2031
+ connectionState,
2074
2032
  largeListGuard,
2075
2033
  topPodMetrics,
2076
2034
  topNodeMetrics,
@@ -2130,7 +2088,6 @@ export function ResourcesView({
2130
2088
  const [regexMode, setRegexMode] = useState(false)
2131
2089
  const [sortColumn, setSortColumn] = useState<string | null>(null)
2132
2090
  const [sortDirection, setSortDirection] = useState<SortDirection>(null)
2133
- const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
2134
2091
  // Filter state
2135
2092
  const [columnFilters, setColumnFilters] = useState<Record<string, string[]>>(initialFilters.columnFilters)
2136
2093
  const [problemFilters, setProblemFilters] = useState<string[]>(initialFilters.problemFilters)
@@ -2951,6 +2908,11 @@ export function ResourcesView({
2951
2908
  params.set('resource', resourceNs ? `${resourceNs}/${resourceName}` : resourceName)
2952
2909
  } else {
2953
2910
  params.delete('resource')
2911
+ // `full` (over-list fullscreen) and `tab` are resource-scoped — when no
2912
+ // resource is selected they're stale; drop them so they can't leak onto a
2913
+ // later selection (e.g. after a kind switch or closing the drawer).
2914
+ params.delete('full')
2915
+ params.delete('tab')
2954
2916
  }
2955
2917
 
2956
2918
  const newPath = `${basePath}/${kindInfo.name}`
@@ -3244,23 +3206,6 @@ export function ResourcesView({
3244
3206
  }, [resources])
3245
3207
  const isLoading = selectedQuery?.isLoading ?? true
3246
3208
  const selectedQueryError = selectedQuery?.error
3247
- const refetchFn = selectedQuery?.refetch
3248
- const dataUpdatedAt = selectedQuery?.dataUpdatedAt
3249
-
3250
- const [refetch, isRefreshAnimating, refreshPhase] = useRefreshAnimation(() => refetchFn?.())
3251
-
3252
- // React Query bumps dataUpdatedAt on no-op refetches (window focus,
3253
- // mount, sibling subscribers); structural sharing returns the same
3254
- // resources reference when data is byte-identical. Skip the timer
3255
- // reset in that case — otherwise opening a filter drawer looks like
3256
- // it triggered a real fetch.
3257
- const lastDataRef = useRef<unknown>(undefined)
3258
- useEffect(() => {
3259
- if (!dataUpdatedAt) return
3260
- if (resources === lastDataRef.current) return
3261
- lastDataRef.current = resources
3262
- setLastUpdated(new Date(dataUpdatedAt))
3263
- }, [dataUpdatedAt, resources])
3264
3209
 
3265
3210
  // Derive counts — prefer lightweight resourceCounts prop over full query data
3266
3211
  const counts = useMemo(() => {
@@ -4288,19 +4233,19 @@ export function ResourcesView({
4288
4233
  </Tooltip>
4289
4234
  )}
4290
4235
 
4291
- {lastUpdated && <LastUpdatedLabel lastUpdated={lastUpdated} />}
4292
4236
  {/* Column picker */}
4293
4237
  <div className="relative" ref={columnPickerRef}>
4238
+ <Tooltip content="Configure columns">
4294
4239
  <button
4295
4240
  onClick={() => setShowColumnPicker(prev => !prev)}
4296
4241
  className={clsx(
4297
4242
  'p-2 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg',
4298
4243
  showColumnPicker && 'bg-theme-elevated text-theme-text-primary'
4299
4244
  )}
4300
- title="Configure columns"
4301
4245
  >
4302
4246
  <Columns3 className="w-4 h-4" />
4303
4247
  </button>
4248
+ </Tooltip>
4304
4249
  {showColumnPicker && (
4305
4250
  <div className="absolute right-0 top-full mt-1 z-50 bg-theme-surface border border-theme-border rounded-lg shadow-lg py-1 min-w-[200px] max-h-[400px] flex flex-col">
4306
4251
  <div className="shrink-0 px-3 py-2 border-b border-theme-border flex items-center justify-between">
@@ -4406,20 +4351,6 @@ export function ResourcesView({
4406
4351
  </div>
4407
4352
  )}
4408
4353
  </div>
4409
- <button
4410
- onClick={refetch}
4411
- disabled={isRefreshAnimating}
4412
- className={clsx(
4413
- 'p-2 hover:bg-theme-elevated rounded-lg disabled:opacity-50 transition-colors duration-500',
4414
- refreshPhase === 'success' ? 'text-emerald-400' : 'text-theme-text-secondary hover:text-theme-text-primary'
4415
- )}
4416
- title="Refresh"
4417
- >
4418
- {refreshPhase === 'success'
4419
- ? <Check className="w-4 h-4 stroke-[2.5]" />
4420
- : <RefreshCw className={clsx('w-4 h-4', refreshPhase === 'spinning' && 'animate-spin')} />
4421
- }
4422
- </button>
4423
4354
  {onCreateResource && (
4424
4355
  <Tooltip content={`Create ${selectedKind.kind || 'resource'}`}>
4425
4356
  <button
@@ -4470,6 +4401,14 @@ export function ResourcesView({
4470
4401
  </button>
4471
4402
  </Tooltip>
4472
4403
  )}
4404
+ {/* Freshness/liveness status — trailing and divided off from the action
4405
+ buttons so it reads as a status, not another control. Resources has
4406
+ no PageHeader, so this toolbar is its home. */}
4407
+ <div className="mx-1 h-5 w-px bg-theme-border/60" />
4408
+ {/* The list is SSE-invalidated (near-real-time), so it reads
4409
+ "Auto-updating" with no refresh button — the stream keeps it
4410
+ current, so a manual refresh would only undercut the claim. */}
4411
+ <FreshnessControl mode="auto" connectionState={connectionState} />
4473
4412
  </div>
4474
4413
 
4475
4414
  {/* Bulk actions bar */}
@@ -0,0 +1,35 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ export interface ScopePillProps {
4
+ /**
5
+ * The scope segments — typically a cluster switcher followed by a namespace
6
+ * picker, each rendered in its `variant="segment"` form (borderless). They're
7
+ * separated by a divider and read as one "what am I looking at" unit.
8
+ */
9
+ children: ReactNode
10
+ className?: string
11
+ }
12
+
13
+ /**
14
+ * ScopePill is the shared bordered shell for the cluster + namespace "scope"
15
+ * control, used by both OSS Radar's header and Radar Hub's cluster top bar so
16
+ * the two stay visually identical. It is purely the container: the segments
17
+ * (ClusterSwitcher / NamespacePicker in segment variant) and their data,
18
+ * view-awareness, and any layout pinning are the host's concern.
19
+ *
20
+ * Deliberately NO `overflow-hidden`: ClusterSwitcher's dropdown renders inline
21
+ * (absolute, not portaled), so clipping this ancestor would hide it. Instead of
22
+ * clipping, the outer corners of the first/last segment's TRIGGER button are
23
+ * rounded (7px = the 8px pill radius minus its 1px border) so each segment's
24
+ * hover/active fill follows the pill's shape instead of poking square corners
25
+ * past it. `>button` targets only the trigger, never the dropdown's buttons.
26
+ */
27
+ export function ScopePill({ children, className = '' }: ScopePillProps) {
28
+ return (
29
+ <div
30
+ className={`flex items-stretch shrink-0 rounded-lg border border-theme-border bg-theme-surface divide-x divide-theme-border [&>*:first-child>button]:rounded-l-[7px] [&>*:last-child>button]:rounded-r-[7px] ${className}`}
31
+ >
32
+ {children}
33
+ </div>
34
+ )
35
+ }
@@ -0,0 +1,2 @@
1
+ export { ScopePill } from './ScopePill'
2
+ export type { ScopePillProps } from './ScopePill'
@@ -99,6 +99,32 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
99
99
  onQueryChange?.({ timeRange, kind: kindFilter || undefined })
100
100
  }, [timeRange, kindFilter, onQueryChange])
101
101
 
102
+ // Kind filter options: seed with common kinds, then accumulate every kind seen
103
+ // in the data so CRDs the cluster actually emits become filterable. The set only
104
+ // grows — selecting a kind narrows the server query to it, so deriving options
105
+ // from the current events alone would collapse the dropdown to that one kind.
106
+ const [seenKinds, setSeenKinds] = useState<Set<string>>(() => new Set(RESOURCE_KINDS))
107
+ useEffect(() => {
108
+ if (!events?.length) return
109
+ setSeenKinds((prev) => {
110
+ let next: Set<string> | null = null
111
+ for (const e of events) {
112
+ if (e.kind && !prev.has(e.kind)) {
113
+ if (!next) next = new Set(prev)
114
+ next.add(e.kind)
115
+ }
116
+ }
117
+ return next ?? prev
118
+ })
119
+ }, [events])
120
+ // Common kinds keep their curated order (most-used first); kinds discovered in
121
+ // the data that aren't in the seed (CRDs) are appended alphabetically.
122
+ const kindOptions = useMemo(() => {
123
+ const seeded = new Set<string>(RESOURCE_KINDS)
124
+ const extra = [...seenKinds].filter((k) => !seeded.has(k)).sort()
125
+ return [...RESOURCE_KINDS, ...extra]
126
+ }, [seenKinds])
127
+
102
128
 
103
129
  const [handleRefresh, isRefreshAnimating] = useRefreshAnimation(onRefresh ?? (() => {}))
104
130
 
@@ -341,7 +367,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
341
367
  className="appearance-none bg-theme-elevated text-theme-text-primary text-sm rounded-lg px-3 py-2 border border-theme-border-light focus:outline-none focus:ring-2 focus:ring-blue-500"
342
368
  >
343
369
  <option value="">All Kinds</option>
344
- {RESOURCE_KINDS.map((kind) => (
370
+ {kindOptions.map((kind) => (
345
371
  <option key={kind} value={kind}>
346
372
  {kind}
347
373
  </option>