@skyhook-io/k8s-ui 1.8.7 → 1.8.9

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 (42) hide show
  1. package/package.json +3 -3
  2. package/src/components/applications/ApplicationsList.tsx +5 -2
  3. package/src/components/applications/ApplicationsView.tsx +37 -30
  4. package/src/components/checks/ChecksView.tsx +25 -14
  5. package/src/components/cluster-switcher/ClusterSwitcher.tsx +27 -9
  6. package/src/components/gitops/GitOpsTableView.tsx +163 -88
  7. package/src/components/gitops/insights/GitOpsInsightViews.tsx +12 -5
  8. package/src/components/issues/IssuesView.tsx +41 -5
  9. package/src/components/issues/ResourceIssuesSection.tsx +3 -0
  10. package/src/components/issues/diagnostic.ts +22 -0
  11. package/src/components/issues/index.ts +1 -1
  12. package/src/components/issues/issues.test.ts +21 -0
  13. package/src/components/issues/types.ts +18 -0
  14. package/src/components/namespace-switcher/NamespacePicker.tsx +381 -0
  15. package/src/components/namespace-switcher/index.ts +6 -0
  16. package/src/components/resources/ResourcesView.tsx +20 -81
  17. package/src/components/scope-pill/ScopePill.tsx +35 -0
  18. package/src/components/scope-pill/index.ts +2 -0
  19. package/src/components/timeline/TimelineList.tsx +27 -1
  20. package/src/components/topology/TopologyControls.tsx +90 -14
  21. package/src/components/ui/FreshnessControl.tsx +153 -0
  22. package/src/components/ui/SortableTh.tsx +16 -10
  23. package/src/components/ui/SummaryTile.tsx +9 -1
  24. package/src/components/ui/Toast.tsx +1 -1
  25. package/src/components/ui/index.ts +2 -0
  26. package/src/components/workload/ResourceDetailDrawer.tsx +215 -32
  27. package/src/components/workload/WorkloadView.tsx +26 -8
  28. package/src/filter-state/filter-state-core.test.ts +98 -0
  29. package/src/filter-state/filter-state-core.ts +138 -0
  30. package/src/filter-state/filter-state.tsx +127 -0
  31. package/src/filter-state/index.ts +16 -0
  32. package/src/hooks/index.ts +1 -0
  33. package/src/hooks/useKeyboardShortcuts.tsx +23 -2
  34. package/src/hooks/useRefreshAnimation.ts +15 -2
  35. package/src/index.ts +12 -0
  36. package/src/types/core.ts +42 -0
  37. package/src/types/gitops-insights.ts +4 -0
  38. package/src/utils/animation.ts +10 -0
  39. package/src/utils/format-freshness.test.ts +34 -0
  40. package/src/utils/format.ts +32 -0
  41. package/src/utils/resource-hierarchy.test.ts +51 -0
  42. package/src/utils/resource-hierarchy.ts +7 -4
@@ -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>
@@ -1,4 +1,6 @@
1
- import { FolderTree, ShieldCheck } from 'lucide-react'
1
+ import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
2
+ import { FolderTree, ShieldCheck, ChevronDown, Check } from 'lucide-react'
3
+ import { clsx } from 'clsx'
2
4
  import type { TopologyMode, GroupingMode } from '../../types/core'
3
5
  import { Tooltip } from '../ui/Tooltip'
4
6
 
@@ -18,6 +20,8 @@ interface TopologyControlsProps {
18
20
  * here from the live, observed Traffic view. Omitted by hosts without one.
19
21
  */
20
22
  onNavigateToTraffic?: () => void
23
+ /** Optional leading element (e.g. a freshness/liveness indicator). */
24
+ leadingSlot?: ReactNode
21
25
  }
22
26
 
23
27
  export function TopologyControls({
@@ -30,9 +34,63 @@ export function TopologyControls({
30
34
  onShowPolicyEffectChange,
31
35
  showFleetMode = false,
32
36
  onNavigateToTraffic,
37
+ leadingSlot,
33
38
  }: TopologyControlsProps) {
39
+ const [groupOpen, setGroupOpen] = useState(false)
40
+ const groupRef = useRef<HTMLDivElement>(null)
41
+ const groupTriggerRef = useRef<HTMLButtonElement>(null)
42
+ const groupItemRefs = useRef<(HTMLButtonElement | null)[]>([])
43
+
44
+ const groupOptions: { value: GroupingMode; label: string }[] = [
45
+ ...(showNoGrouping ? [{ value: 'none' as GroupingMode, label: 'No Grouping' }] : []),
46
+ { value: 'namespace', label: 'By Namespace' },
47
+ { value: 'app', label: 'By App Label' },
48
+ ]
49
+ const currentGroupLabel = groupOptions.find((o) => o.value === groupingMode)?.label ?? 'Grouping'
50
+
51
+ const closeGroup = useCallback((restoreFocus = false) => {
52
+ setGroupOpen(false)
53
+ if (restoreFocus) groupTriggerRef.current?.focus()
54
+ }, [])
55
+
56
+ // On open, move focus onto the active option so the menu is keyboard-navigable
57
+ // (parity with the native <select> this replaced). Click-outside closes it.
58
+ useEffect(() => {
59
+ if (!groupOpen) return
60
+ const active = Math.max(0, groupOptions.findIndex((o) => o.value === groupingMode))
61
+ groupItemRefs.current[active]?.focus()
62
+ const onDown = (e: MouseEvent) => {
63
+ if (groupRef.current && !groupRef.current.contains(e.target as Node)) setGroupOpen(false)
64
+ }
65
+ document.addEventListener('mousedown', onDown)
66
+ return () => document.removeEventListener('mousedown', onDown)
67
+ // groupOptions/groupingMode are read once at open; re-running on their
68
+ // identity change would steal focus mid-interaction.
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, [groupOpen])
71
+
72
+ const onGroupMenuKey = (e: KeyboardEvent<HTMLDivElement>) => {
73
+ const items = groupItemRefs.current.filter(Boolean) as HTMLButtonElement[]
74
+ const i = items.indexOf(document.activeElement as HTMLButtonElement)
75
+ if (e.key === 'ArrowDown') { e.preventDefault(); items[(i + 1) % items.length]?.focus() }
76
+ else if (e.key === 'ArrowUp') { e.preventDefault(); items[(i - 1 + items.length) % items.length]?.focus() }
77
+ else if (e.key === 'Home') { e.preventDefault(); items[0]?.focus() }
78
+ else if (e.key === 'End') { e.preventDefault(); items[items.length - 1]?.focus() }
79
+ else if (e.key === 'Escape') { e.preventDefault(); closeGroup(true) }
80
+ }
81
+
34
82
  return (
35
83
  <div className="absolute top-4 right-4 z-10 flex items-center gap-2">
84
+ {/* Freshness/liveness status — backed for legibility over the canvas but
85
+ borderless + divided off, so it reads as a status, not another control. */}
86
+ {leadingSlot && (
87
+ <>
88
+ <div className="flex items-center rounded-lg bg-theme-surface/80 px-2.5 py-1.5 backdrop-blur">
89
+ {leadingSlot}
90
+ </div>
91
+ <div className="h-5 w-px bg-theme-border/70" />
92
+ </>
93
+ )}
36
94
  {/* Policy effect toggle */}
37
95
  {onShowPolicyEffectChange && (
38
96
  <button
@@ -49,20 +107,38 @@ export function TopologyControls({
49
107
  </button>
50
108
  )}
51
109
 
52
- {/* Grouping selector */}
53
- <div className="flex items-center gap-1.5 px-2 py-1.5 bg-theme-surface/90 backdrop-blur border border-theme-border rounded-lg">
54
- <FolderTree className="w-3.5 h-3.5 text-theme-text-secondary" />
55
- <select
56
- value={groupingMode}
57
- onChange={(e) => onGroupingModeChange(e.target.value as GroupingMode)}
58
- className="appearance-none bg-transparent text-theme-text-primary text-xs focus:outline-none"
110
+ {/* Grouping selector — themed dropdown (not a native <select>). */}
111
+ <div ref={groupRef} className="relative">
112
+ <button
113
+ ref={groupTriggerRef}
114
+ type="button"
115
+ onClick={() => setGroupOpen((v) => !v)}
116
+ aria-haspopup="menu"
117
+ aria-expanded={groupOpen}
118
+ className="flex items-center gap-1.5 px-2 py-1.5 bg-theme-surface/90 backdrop-blur border border-theme-border rounded-lg text-xs text-theme-text-primary hover:bg-theme-elevated transition-colors"
59
119
  >
60
- {showNoGrouping && (
61
- <option value="none" className="bg-theme-surface">No Grouping</option>
62
- )}
63
- <option value="namespace" className="bg-theme-surface">By Namespace</option>
64
- <option value="app" className="bg-theme-surface">By App Label</option>
65
- </select>
120
+ <FolderTree className="w-3.5 h-3.5 text-theme-text-secondary" />
121
+ {currentGroupLabel}
122
+ <ChevronDown className="w-3 h-3 text-theme-text-tertiary" />
123
+ </button>
124
+ {groupOpen && (
125
+ <div role="menu" onKeyDown={onGroupMenuKey} className="absolute right-0 top-full mt-1 z-50 min-w-[160px] rounded-lg border border-theme-border bg-theme-surface py-1 shadow-xl">
126
+ {groupOptions.map((o, idx) => (
127
+ <button
128
+ key={o.value}
129
+ ref={(el) => { groupItemRefs.current[idx] = el }}
130
+ type="button"
131
+ role="menuitemradio"
132
+ aria-checked={groupingMode === o.value}
133
+ onClick={() => { onGroupingModeChange(o.value); closeGroup(true) }}
134
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary focus:bg-theme-hover focus:text-theme-text-primary focus:outline-none transition-colors"
135
+ >
136
+ <Check className={clsx('w-3.5 h-3.5 shrink-0', groupingMode === o.value ? 'opacity-100 text-skyhook-500' : 'opacity-0')} />
137
+ <span className="truncate">{o.label}</span>
138
+ </button>
139
+ ))}
140
+ </div>
141
+ )}
66
142
  </div>
67
143
 
68
144
  {/* View mode toggle */}
@@ -0,0 +1,153 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { clsx } from 'clsx'
3
+ import { RefreshCw, Check } from 'lucide-react'
4
+ import { Tooltip } from './Tooltip'
5
+ import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
6
+ import { formatUpdatedAgo, msToNextBucket } from '../../utils/format'
7
+
8
+ export type FreshnessMode = 'auto' | 'snapshot'
9
+ export type FreshnessConnection = 'connected' | 'disconnected' | 'connecting'
10
+
11
+ interface FreshnessControlProps {
12
+ // 'auto' — the view keeps itself current (polls or streams). Reads
13
+ // "Auto-updating". Pass onRefresh on slow polled views for a
14
+ // "check now" hatch; omit it on instant stream-backed views.
15
+ // 'snapshot' — a one-shot load that only changes on demand. Reads "Updated N
16
+ // ago" with a manual refresh button.
17
+ mode: FreshnessMode
18
+ // Epoch ms of the last successful load (React Query dataUpdatedAt). Optional
19
+ // for 'auto' streams that have no per-fetch timestamp (e.g. topology).
20
+ dataUpdatedAt?: number
21
+ // Spins the refresh icon during background refetches (for views with a button).
22
+ isFetching?: boolean
23
+ // Manual refresh. Provide it on polled + snapshot views where forcing a fetch
24
+ // is useful; omit on stream-backed 'auto' views (instant, so a refresh button
25
+ // would just undercut "Auto-updating"). May return a promise — the animation
26
+ // waits for it before showing success.
27
+ onRefresh?: () => void | Promise<unknown>
28
+ // Cluster/SSE connection health. When not connected, freshness must not claim
29
+ // currency — it degrades to "Reconnecting…" instead of a stale age/Auto-updating.
30
+ connectionState?: FreshnessConnection
31
+ // 'auto' streams only: paused (e.g. topology pause toggle). "Auto-updating"
32
+ // while paused would be a lie, so it degrades to "Paused".
33
+ paused?: boolean
34
+ className?: string
35
+ }
36
+
37
+ // The canonical freshness/liveness signal. It answers "does this view stay
38
+ // current on its own?" — "Auto-updating" for anything self-refreshing (poll or
39
+ // stream, mechanism deliberately not exposed), "Updated N ago" + refresh for a
40
+ // manual snapshot. Place it at the right end of a view's header — never a band.
41
+ export function FreshnessControl({
42
+ mode,
43
+ dataUpdatedAt,
44
+ isFetching,
45
+ onRefresh,
46
+ connectionState = 'connected',
47
+ paused = false,
48
+ className,
49
+ }: FreshnessControlProps) {
50
+ const [, force] = useState(0)
51
+ const showAge = typeof dataUpdatedAt === 'number' && dataUpdatedAt > 0
52
+
53
+ // Re-render exactly when the displayed age bucket flips (not every second).
54
+ useEffect(() => {
55
+ if (!showAge) return
56
+ let id: ReturnType<typeof setTimeout>
57
+ function schedule() {
58
+ const delay = Math.max(1000, msToNextBucket(Date.now() - (dataUpdatedAt as number)))
59
+ id = setTimeout(() => {
60
+ force((t) => t + 1)
61
+ schedule()
62
+ }, delay)
63
+ }
64
+ schedule()
65
+ return () => clearTimeout(id)
66
+ }, [showAge, dataUpdatedAt])
67
+
68
+ const [refresh, , phase] = useRefreshAnimation(() => onRefresh?.())
69
+ const spinning = phase === 'spinning' || !!isFetching
70
+
71
+ // Any non-connected state (disconnected OR mid-reconnect) must not claim
72
+ // currency — the signal degrades rather than showing a stale age/Auto-updating.
73
+ const degraded = connectionState !== 'connected'
74
+
75
+ // Show the refresh button whenever the host wired one. Stream-backed 'auto'
76
+ // views (Resources, Topology) deliberately omit onRefresh — they update
77
+ // instantly, so a manual refresh would only undercut "Auto-updating". Slow
78
+ // polled views keep it as a "check now" escape hatch.
79
+ const showRefresh = !!onRefresh
80
+
81
+ const exact = showAge ? `Last updated ${new Date(dataUpdatedAt as number).toLocaleTimeString()}` : null
82
+ const age = showAge ? formatUpdatedAgo(Date.now() - (dataUpdatedAt as number)) : null
83
+
84
+ // Tooltip is only rendered when it ADDS information beyond the visible label.
85
+ let label: string | null
86
+ let tooltip: string | null
87
+ let live = false
88
+ if (degraded) {
89
+ label = 'Reconnecting…'
90
+ tooltip = 'Not connected to the cluster — data may be stale until the connection is restored.'
91
+ } else if (mode === 'auto' && paused) {
92
+ label = 'Paused'
93
+ tooltip = 'Live updates are paused — resume to keep this view current.'
94
+ } else if (mode === 'auto') {
95
+ label = 'Auto-updating'
96
+ tooltip = exact ?? 'Updates automatically as the data changes.'
97
+ live = true
98
+ } else if (age) {
99
+ label = `Updated ${age}`
100
+ tooltip = exact
101
+ } else {
102
+ // Snapshot before its first load — render just the button, no stale text.
103
+ label = null
104
+ tooltip = null
105
+ }
106
+
107
+ // The relative age is the dynamic trust detail. It rides alongside every 'auto'
108
+ // label, and alongside a degraded label in either mode — "Reconnecting… ·
109
+ // updated 8m ago" is exactly when staleness matters most. (In connected
110
+ // 'snapshot' mode the age IS the label, so no suffix.)
111
+ const ageSuffix = age && (mode === 'auto' || degraded) ? age : null
112
+
113
+ const labelNode = label ? (
114
+ <span className="flex items-center gap-1 text-xs text-theme-text-tertiary">
115
+ {(live || (mode === 'auto' && paused)) && !degraded && (
116
+ <span
117
+ className={clsx('w-1.5 h-1.5 rounded-full', paused ? 'bg-amber-400' : 'bg-green-500 animate-pulse')}
118
+ aria-hidden
119
+ />
120
+ )}
121
+ <span className="tabular-nums">{label}</span>
122
+ {ageSuffix && <span className="tabular-nums text-theme-text-quaternary">· updated {ageSuffix}</span>}
123
+ </span>
124
+ ) : null
125
+
126
+ return (
127
+ <div className={clsx('flex items-center gap-1.5 whitespace-nowrap', className)}>
128
+ {/* Only wrap in a tooltip when it adds information beyond the label. */}
129
+ {labelNode && (tooltip
130
+ ? <Tooltip content={tooltip} delay={100} position="bottom">{labelNode}</Tooltip>
131
+ : labelNode)}
132
+ {showRefresh && (
133
+ <Tooltip content="Refresh now" delay={100} position="bottom">
134
+ <button
135
+ type="button"
136
+ onClick={refresh}
137
+ // Only disable during the button's own refresh animation (prevents
138
+ // double-trigger); stay clickable during background refetches.
139
+ disabled={phase === 'spinning'}
140
+ aria-label="Refresh now"
141
+ className="p-1.5 rounded-lg text-theme-text-tertiary hover:text-theme-text-secondary hover:bg-theme-hover transition-colors disabled:opacity-50"
142
+ >
143
+ {phase === 'success' ? (
144
+ <Check className="w-3.5 h-3.5 text-emerald-500" />
145
+ ) : (
146
+ <RefreshCw className={clsx('w-3.5 h-3.5', spinning && 'animate-spin')} />
147
+ )}
148
+ </button>
149
+ </Tooltip>
150
+ )}
151
+ </div>
152
+ )
153
+ }
@@ -1,13 +1,13 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import { clsx } from 'clsx'
3
- import { ChevronUp, ChevronDown } from 'lucide-react'
3
+ import { ChevronUp, ChevronDown, ArrowUpDown } from 'lucide-react'
4
4
 
5
5
  export type SortDir = 'asc' | 'desc'
6
6
 
7
- // Canonical dense-table header-cell styling, shared so the Applications and
8
- // GitOps tables (modeled on the Resources table) read as one table family.
7
+ // Canonical dense-table header-cell styling, shared so the Applications,
8
+ // GitOps, and Helm tables read as one family with the Resources table.
9
9
  export const TH_CLASS =
10
- 'border-b border-theme-border px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary'
10
+ 'border-b border-theme-border px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-theme-text-secondary'
11
11
 
12
12
  // A clickable, sortable column header. Clicking fires onSort(sortKey); the
13
13
  // consumer owns the cycle (toggle dir, or asc→desc→off). One chevron marks the
@@ -42,16 +42,22 @@ export function SortableTh<K extends string>({
42
42
  type="button"
43
43
  onClick={() => onSort(sortKey)}
44
44
  className={clsx(
45
- 'inline-flex items-center gap-1 select-none hover:text-theme-text-primary focus-visible:outline-none focus-visible:text-theme-text-primary',
45
+ // `uppercase` is repeated here on purpose: Tailwind Preflight resets
46
+ // `button { text-transform: none }`, which would otherwise cancel the
47
+ // inherited uppercase from TH_CLASS and render sortable headers in
48
+ // title case while non-sortable <th> cells stay uppercase.
49
+ 'inline-flex items-center gap-1 select-none uppercase hover:text-theme-text-primary focus-visible:outline-none focus-visible:text-theme-text-primary',
46
50
  align === 'right' && 'w-full justify-end',
47
51
  )}
48
52
  >
49
53
  {label}
50
- {active ? (
51
- direction === 'asc' ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />
52
- ) : (
53
- <span className="w-3" />
54
- )}
54
+ <span className="shrink-0 text-theme-text-tertiary">
55
+ {active ? (
56
+ direction === 'asc' ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />
57
+ ) : (
58
+ <ArrowUpDown className="h-3 w-3 opacity-50" />
59
+ )}
60
+ </span>
55
61
  </button>
56
62
  </th>
57
63
  )
@@ -12,12 +12,16 @@ export function SummaryTile({
12
12
  tone = 'neutral',
13
13
  onClick,
14
14
  active = false,
15
+ loading = false,
15
16
  }: {
16
17
  label: string
17
18
  value: number
18
19
  tone?: SummaryTone
19
20
  onClick?: () => void
20
21
  active?: boolean
22
+ /** First fetch in flight — render a pulse instead of the value. A tile
23
+ * that reads "0" while loading is a false zero, not a count. */
24
+ loading?: boolean
21
25
  }) {
22
26
  const toneClass = {
23
27
  neutral: 'text-theme-text-primary',
@@ -33,7 +37,11 @@ export function SummaryTile({
33
37
  error: 'border-red-500',
34
38
  info: 'border-sky-500',
35
39
  }[tone]
36
- const value$ = <div className={`text-sm font-semibold ${toneClass}`}>{value}</div>
40
+ const value$ = loading ? (
41
+ <div className="my-1 h-3.5 w-8 animate-pulse rounded bg-theme-hover" aria-hidden />
42
+ ) : (
43
+ <div className={`text-sm font-semibold ${toneClass}`}>{value}</div>
44
+ )
37
45
  const label$ = <div className="text-xs text-theme-text-tertiary">{label}</div>
38
46
  if (!onClick) {
39
47
  return (
@@ -227,7 +227,7 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
227
227
  {toast.detail}
228
228
  </button>
229
229
  ) : (
230
- <p className={clsx('mt-1 text-xs break-all', isError ? 'text-red-300/80' : isSuccess ? 'text-emerald-300/80' : 'text-theme-text-secondary')}>
230
+ <p className={clsx('mt-1 text-xs break-words', isError ? 'text-red-300/80' : isSuccess ? 'text-emerald-300/80' : 'text-theme-text-secondary')}>
231
231
  {toast.detail}
232
232
  </p>
233
233
  )
@@ -1,4 +1,6 @@
1
1
  export { Tooltip } from './Tooltip'
2
+ export { FreshnessControl } from './FreshnessControl'
3
+ export type { FreshnessMode, FreshnessConnection } from './FreshnessControl'
2
4
  export { PaneLoader } from './PaneLoader'
3
5
  export { ClusterName } from './ClusterName'
4
6
  export { MiddleEllipsis } from './MiddleEllipsis'