@skyhook-io/k8s-ui 1.8.0 → 1.8.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -820,9 +820,9 @@ function FindingLine({
820
820
  </button>
821
821
  ) : resourceHref ? (
822
822
  // Opens in a new tab to match the ExternalLink glyph in `body`. For the
823
- // Hub fleet view this href crosses the HubCluster SPA boundary (a full
823
+ // Hub fleet view this href crosses the Hub to Cluster route-tree boundary (a full
824
824
  // document nav); a new tab keeps the Checks queue intact and avoids the
825
- // cross-tree browser-Back dead-end (SKY-931).
825
+ // cross-tree browser-Back dead-end.
826
826
  <a href={resourceHref(r)} target="_blank" rel="noreferrer" className={cls}>
827
827
  {body}
828
828
  </a>
@@ -102,7 +102,7 @@ export interface FleetClusterStamp {
102
102
  name: string
103
103
  }
104
104
  export type FleetDestinationMatch = 'in_cluster' | 'exact' | 'inferred' | 'unmatched'
105
- // FleetDestinationConfidence is a coarse signal the SPA uses to style
105
+ // FleetDestinationConfidence is a coarse signal the frontend uses to style
106
106
  // the destination chip. `high` = URL-equality match (either direct or
107
107
  // via Argo cluster-secret), `medium` = name-equality match (more
108
108
  // fragile, more likely to be a false positive when two clusters share
@@ -116,7 +116,7 @@ export interface FleetDestinationStamp {
116
116
  // Confidence + reason are populated for non-unmatched rows. They power
117
117
  // the chip's visual treatment (a checkmark on high-confidence matches)
118
118
  // and its title= tooltip respectively. Both come from the hub —
119
- // adding new values shouldn't break the SPA (unknown confidence
119
+ // adding new values shouldn't break the frontend (unknown confidence
120
120
  // falls back to no special styling).
121
121
  confidence?: FleetDestinationConfidence
122
122
  reason?: string
@@ -184,7 +184,7 @@ export interface GitOpsTableViewProps {
184
184
  onRefresh?: () => void
185
185
  // Row click — caller routes to its own detail page. When the host also
186
186
  // passes `rowHrefFor`, the callback receives the MouseEvent so it can
187
- // `preventDefault()` for SPA-local nav (e.g. react-router) or skip the
187
+ // `preventDefault()` for same-tree nav (e.g. react-router) or skip the
188
188
  // preventDefault to let the anchor's default full-page navigation run
189
189
  // (required for cross-router-boundary links).
190
190
  onRowClick: (row: GitOpsRow, event?: ReactMouseEvent) => void
@@ -217,8 +217,11 @@ export interface GitOpsTableViewProps {
217
217
  searchHotkey?: boolean
218
218
  // emptyStateTitle / emptyStateBody override the "No GitOps resources
219
219
  // detected" copy. Hub passes "No GitOps resources across the fleet".
220
- emptyStateTitle?: string
221
- emptyStateBody?: string
220
+ // ReactNode (not just string) so a host can split the body across lines
221
+ // (e.g. a `<br/>` between "what's wrong" and "what to do") or emphasize
222
+ // part of it. Strings still work unchanged.
223
+ emptyStateTitle?: ReactNode
224
+ emptyStateBody?: ReactNode
222
225
  /**
223
226
  * Which side the filter rail sits on. Default 'left' (OSS Radar, which
224
227
  * has no app sidebar). A host with its own left navigation rail (the
@@ -483,7 +486,7 @@ export function GitOpsTableView({
483
486
  // be hidden behind a "nothing here" screen.
484
487
  if (totalGitOps === 0 && allRowsInput.length === 0 && !loading && !hasGlobalNamespaceFilter) {
485
488
  return (
486
- <div className="flex h-full min-h-0 flex-1 items-center justify-center bg-theme-base p-4">
489
+ <div className="flex h-full min-h-0 flex-1 items-start justify-center bg-theme-base px-4 pb-4 pt-[22vh]">
487
490
  <div className="rounded-lg border border-theme-border bg-theme-surface p-8 text-center">
488
491
  <GitBranch className="mx-auto h-8 w-8 text-theme-text-tertiary" />
489
492
  <h2 className="mt-3 text-base font-semibold text-theme-text-primary">
@@ -80,7 +80,7 @@ describe('getGitOpsTool', () => {
80
80
  expect(getGitOpsTool('appprojects', 'argoproj.io')).toBe('argo')
81
81
  })
82
82
  // Argo kinds without an explicit group still route to 'argo'.
83
- // Defends against a normalizer that drops `group` from the SPA payload.
83
+ // Defends against a normalizer that drops `group` from the frontend payload.
84
84
  test('Argo kinds without group still route to argo', () => {
85
85
  expect(getGitOpsTool('applications', undefined)).toBe('argo')
86
86
  expect(getGitOpsTool('applicationsets', '')).toBe('argo')
@@ -1127,6 +1127,10 @@ export function cronToHuman(cron: string): string {
1127
1127
  if (minute === '0' && hour === '0' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
1128
1128
  return 'Daily at midnight'
1129
1129
  }
1130
+ if (minute === '0' && hour.startsWith('*/') && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
1131
+ const interval = hour.slice(2)
1132
+ return interval === '1' ? 'Every hour' : `Every ${interval} hours`
1133
+ }
1130
1134
  if (minute === '0' && hour !== '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
1131
1135
  return `Daily at ${hour}:00`
1132
1136
  }
@@ -53,6 +53,11 @@ export interface TimelineListProps {
53
53
  onResourceClick?: NavigateToResource
54
54
  initialFilter?: ActivityTypeFilter
55
55
  initialTimeRange?: TimeRange
56
+ // Controlled "show deleted" toggle. When omitted the component manages it
57
+ // internally; the host passes it to share one toggle across list + swimlane
58
+ // and to drive server-side delete filtering.
59
+ showDeleted?: boolean
60
+ onShowDeletedChange?: (showDeleted: boolean) => void
56
61
  }
57
62
 
58
63
  const TIME_RANGES: { value: TimeRange; label: string }[] = [
@@ -80,11 +85,14 @@ const RESOURCE_KINDS = [
80
85
  'StatefulSet',
81
86
  ]
82
87
 
83
- export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasLimitedAccess, namespaces, onViewChange, currentView = 'list', onResourceClick, initialFilter, initialTimeRange }: TimelineListProps) {
88
+ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasLimitedAccess, namespaces, onViewChange, currentView = 'list', onResourceClick, initialFilter, initialTimeRange, showDeleted: showDeletedProp, onShowDeletedChange }: TimelineListProps) {
84
89
  const [searchTerm, setSearchTerm] = useState('')
85
90
  const [activityTypeFilter, setActivityTypeFilter] = useState<ActivityTypeFilter>(initialFilter ?? 'all')
86
91
  const [timeRange, setTimeRange] = useState<TimeRange>(initialTimeRange ?? '1h')
87
92
  const [kindFilter, setKindFilter] = useState<string>('')
93
+ const [showDeletedInternal, setShowDeletedInternal] = useState(true)
94
+ const showDeleted = showDeletedProp ?? showDeletedInternal
95
+ const setShowDeleted = onShowDeletedChange ?? setShowDeletedInternal
88
96
  const [expandedItem, setExpandedItem] = useState<string | null>(null)
89
97
 
90
98
  useEffect(() => {
@@ -111,6 +119,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
111
119
  const isUnhealthyChange = isChangeEvent(item) && (item.healthState === 'unhealthy' || item.healthState === 'degraded')
112
120
  if (!isUnhealthyChange) return false
113
121
  }
122
+ if (!showDeleted && item.eventType === 'delete') return false
114
123
 
115
124
  // Filter by search term
116
125
  if (searchTerm) {
@@ -129,7 +138,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
129
138
 
130
139
  return true
131
140
  })
132
- }, [events, activityTypeFilter, searchTerm])
141
+ }, [events, activityTypeFilter, searchTerm, showDeleted])
133
142
 
134
143
  // Aggregated event group type
135
144
  type AggregatedItem = {
@@ -245,12 +254,13 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
245
254
 
246
255
  // Count stats
247
256
  const stats = useMemo(() => {
248
- if (!events) return { total: 0, changes: 0, warnings: 0, unhealthy: 0 }
257
+ if (!events) return { total: 0, changes: 0, warnings: 0, unhealthy: 0, deleted: 0 }
249
258
  return {
250
259
  total: events.length,
251
260
  changes: events.filter((e) => isChangeEvent(e)).length,
252
261
  warnings: events.filter((e) => e.eventType === 'Warning').length,
253
262
  unhealthy: events.filter((e) => isChangeEvent(e) && (e.healthState === 'unhealthy' || e.healthState === 'degraded')).length,
263
+ deleted: events.filter((e) => e.eventType === 'delete').length,
254
264
  }
255
265
  }, [events])
256
266
 
@@ -306,6 +316,24 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
306
316
  />
307
317
  </div>
308
318
 
319
+ <button
320
+ type="button"
321
+ onClick={() => setShowDeleted(!showDeleted)}
322
+ title="Show or hide resources that were deleted, including Pods that no longer exist"
323
+ className={clsx(
324
+ 'px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-2 bg-theme-elevated',
325
+ showDeleted ? 'text-theme-text-primary' : 'text-theme-text-secondary hover:text-theme-text-primary'
326
+ )}
327
+ >
328
+ <Trash2 className="w-3 h-3" />
329
+ Deleted
330
+ {stats.deleted > 0 && (
331
+ <span className="text-xs px-1.5 rounded bg-theme-hover/50">
332
+ {stats.deleted}
333
+ </span>
334
+ )}
335
+ </button>
336
+
309
337
  {/* Kind filter */}
310
338
  <select
311
339
  value={kindFilter}
@@ -21,6 +21,7 @@ import {
21
21
  Timer,
22
22
  RotateCcw,
23
23
  Shield,
24
+ Trash2,
24
25
  } from 'lucide-react'
25
26
  import type { TimelineEvent, Topology } from '../../types'
26
27
  import type { NavigateToResource } from '../../utils/navigation'
@@ -54,9 +55,14 @@ export interface TimelineSwimlanesProps {
54
55
  // RBAC capability flag (was a radar/web context); host passes it. Default false.
55
56
  hasLimitedAccess?: boolean
56
57
  // GitOps lane labels deep-link to a controller path; the host decides how to
57
- // navigate (radar router push, or cross-SPA href). When omitted, GitOps lanes
58
+ // navigate (radar router push, or cross-route-tree href). When omitted, GitOps lanes
58
59
  // fall back to onResourceClick (the resource drawer).
59
60
  onNavigatePath?: (path: string) => void
61
+ // Controlled "show deleted" toggle. When omitted the component manages it
62
+ // internally; the host passes it to share one toggle with the list view and
63
+ // to drive server-side delete filtering on the underlying fetch.
64
+ showDeleted?: boolean
65
+ onShowDeletedChange?: (showDeleted: boolean) => void
60
66
  }
61
67
 
62
68
  interface ResourceLane extends BaseResourceLane {
@@ -180,7 +186,7 @@ function calculateInterestingnessWithBreakdown(lane: ResourceLane): ScoreBreakdo
180
186
  return breakdown
181
187
  }
182
188
 
183
- export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode, onViewModeChange, topology, namespaces, hasLimitedAccess = false, onNavigatePath }: TimelineSwimlanesProps) {
189
+ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode, onViewModeChange, topology, namespaces, hasLimitedAccess = false, onNavigatePath, showDeleted: showDeletedProp, onShowDeletedChange }: TimelineSwimlanesProps) {
184
190
  // Timeline lane labels for GitOps CRs (Application/Kustomization/HelmRelease)
185
191
  // deep-link to GitOps detail rather than the resource drawer — the lane is
186
192
  // already telling the user "this controller had changes/events"; the GitOps
@@ -203,6 +209,9 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
203
209
  const [expandedLanes, setExpandedLanes] = useState<Set<string>>(new Set())
204
210
  const [hasAutoZoomed, setHasAutoZoomed] = useState(false)
205
211
  const [groupByApp, setGroupByApp] = useState(true) // Group by app.kubernetes.io/name label
212
+ const [showDeletedInternal, setShowDeletedInternal] = useState(true)
213
+ const showDeleted = showDeletedProp ?? showDeletedInternal
214
+ const setShowDeleted = onShowDeletedChange ?? setShowDeletedInternal
206
215
 
207
216
  // Stable lane ordering - use ref to avoid render loop (lanes depends on order, order depends on lanes)
208
217
  const laneOrderRef = useRef<Map<string, number>>(new Map())
@@ -253,17 +262,18 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
253
262
 
254
263
  // Filter events by search term
255
264
  const filteredEvents = useMemo(() => {
256
- if (!searchTerm) return events
265
+ const baseEvents = showDeleted ? events : events.filter(e => e.eventType !== 'delete')
266
+ if (!searchTerm) return baseEvents
257
267
 
258
268
  const term = searchTerm.toLowerCase()
259
- return events.filter(e =>
269
+ return baseEvents.filter(e =>
260
270
  e.name.toLowerCase().includes(term) ||
261
271
  e.kind.toLowerCase().includes(term) ||
262
272
  e.namespace?.toLowerCase().includes(term) ||
263
273
  e.reason?.toLowerCase().includes(term) ||
264
274
  e.message?.toLowerCase().includes(term)
265
275
  )
266
- }, [events, searchTerm])
276
+ }, [events, searchTerm, showDeleted])
267
277
 
268
278
  // Build hierarchical lanes using owner references + topology edges
269
279
  // Uses the shared utility from utils/resource-hierarchy.ts
@@ -514,6 +524,18 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
514
524
  <span className="border-b border-dotted border-theme-text-tertiary">Group by app</span>
515
525
  </label>
516
526
  </Tooltip>
527
+ <Tooltip content="Show resources Radar observed being deleted, including Pods that no longer exist" position="bottom">
528
+ <label className="flex items-center gap-1.5 text-xs text-theme-text-secondary hover:text-theme-text-primary">
529
+ <input
530
+ type="checkbox"
531
+ checked={showDeleted}
532
+ onChange={(e) => setShowDeleted(e.target.checked)}
533
+ className="w-3.5 h-3.5 rounded border-theme-border-light bg-theme-elevated text-accent focus:ring-accent focus:ring-offset-0"
534
+ />
535
+ <Trash2 className="w-3.5 h-3.5" />
536
+ <span className="border-b border-dotted border-theme-text-tertiary">Deleted</span>
537
+ </label>
538
+ </Tooltip>
517
539
  {/* View toggle */}
518
540
  {onViewModeChange && (
519
541
  <div className="flex items-center gap-1 bg-theme-elevated rounded-lg p-1">
@@ -38,6 +38,8 @@ export interface SearchPillInputProps {
38
38
  rightSlot?: React.ReactNode
39
39
  /** Applied to the input container (host owns the box chrome: height, bg, border). */
40
40
  className?: string
41
+ /** Applied to the `<input>` itself (host owns text size: e.g. a hero variant). */
42
+ inputClassName?: string
41
43
  'aria-label'?: string
42
44
  /** Fires when the modifier autocomplete opens/closes, so the host can suppress
43
45
  its own results dropdown while a modifier is being completed. */
@@ -101,6 +103,7 @@ export function SearchPillInput({
101
103
  leftSlot,
102
104
  rightSlot,
103
105
  className,
106
+ inputClassName,
104
107
  onSuggestingChange,
105
108
  ...rest
106
109
  }: SearchPillInputProps) {
@@ -110,6 +113,20 @@ export function SearchPillInput({
110
113
  const [sel, setSel] = useState(0)
111
114
  const [dismissed, setDismissed] = useState(false)
112
115
  const [anchor, setAnchor] = useState<{ left: number; bottom: number } | null>(null)
116
+ // Collapse a long pill list to a few + "+N more" so they don't flood the
117
+ // field (e.g. a broad namespace scope seeded as ns: pills). Expanding wraps
118
+ // them so every pill stays removable.
119
+ const [pillsExpanded, setPillsExpanded] = useState(false)
120
+ const MAX_VISIBLE_PILLS = 3
121
+ // Reset the expand toggle once there's nothing to collapse (pills cleared or
122
+ // trimmed) — otherwise a later seed of many pills would render expanded,
123
+ // re-flooding the field instead of collapsing.
124
+ useEffect(() => {
125
+ if (pills.length <= MAX_VISIBLE_PILLS + 1) setPillsExpanded(false)
126
+ }, [pills.length])
127
+ const collapsePills = !pillsExpanded && pills.length > MAX_VISIBLE_PILLS + 1
128
+ const shownPills = collapsePills ? pills.slice(0, MAX_VISIBLE_PILLS) : pills
129
+ const hiddenPillCount = pills.length - shownPills.length
113
130
 
114
131
  const mod = useMemo(() => activeModifier(text, aliases), [text, aliases])
115
132
 
@@ -181,12 +198,12 @@ export function SearchPillInput({
181
198
  }, [suggesting, filtered, sel, mod, text, pills, commitPill, onChange, onKeyDown])
182
199
 
183
200
  return (
184
- <div ref={containerRef} className={clsx('flex items-center gap-1.5', className)} onClick={() => inputRef.current?.focus()}>
201
+ <div ref={containerRef} className={clsx('flex items-center gap-1.5', pillsExpanded && 'flex-wrap', className)} onClick={() => inputRef.current?.focus()}>
185
202
  {leftSlot}
186
- {pills.map((p, i) => (
203
+ {shownPills.map((p, i) => (
187
204
  <span key={`${p.key}:${p.value}:${i}`} className="inline-flex items-center gap-1 shrink-0 rounded-md bg-theme-elevated border border-theme-border-light pl-1.5 pr-1 py-0.5 text-xs whitespace-nowrap">
188
205
  <span className="text-theme-text-tertiary">{p.key}:</span>
189
- <span className="text-theme-text-primary font-medium">{p.value}</span>
206
+ <span className="max-w-[16ch] truncate font-medium text-theme-text-primary" title={p.value}>{p.value}</span>
190
207
  <button
191
208
  type="button"
192
209
  tabIndex={-1}
@@ -198,6 +215,27 @@ export function SearchPillInput({
198
215
  </button>
199
216
  </span>
200
217
  ))}
218
+ {collapsePills && hiddenPillCount > 0 && (
219
+ <button
220
+ type="button"
221
+ tabIndex={-1}
222
+ onMouseDown={(e) => { e.preventDefault(); setPillsExpanded(true) }}
223
+ className="shrink-0 rounded-md bg-theme-elevated border border-theme-border-light px-1.5 py-0.5 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary whitespace-nowrap"
224
+ title="Show all filters"
225
+ >
226
+ +{hiddenPillCount} more
227
+ </button>
228
+ )}
229
+ {pillsExpanded && pills.length > MAX_VISIBLE_PILLS + 1 && (
230
+ <button
231
+ type="button"
232
+ tabIndex={-1}
233
+ onMouseDown={(e) => { e.preventDefault(); setPillsExpanded(false) }}
234
+ className="shrink-0 rounded-md px-1.5 py-0.5 text-xs text-theme-text-tertiary hover:text-theme-text-primary whitespace-nowrap"
235
+ >
236
+ less
237
+ </button>
238
+ )}
201
239
  <input
202
240
  ref={inputRef}
203
241
  type="text"
@@ -207,7 +245,10 @@ export function SearchPillInput({
207
245
  onFocus={onFocus}
208
246
  placeholder={pills.length ? '' : placeholder}
209
247
  aria-label={rest['aria-label']}
210
- className="flex-1 min-w-[80px] bg-transparent text-sm text-theme-text-primary placeholder-theme-text-tertiary outline-none"
248
+ className={clsx(
249
+ 'flex-1 min-w-[80px] bg-transparent text-theme-text-primary placeholder-theme-text-tertiary outline-none',
250
+ inputClassName ?? 'text-sm',
251
+ )}
211
252
  />
212
253
  {rightSlot}
213
254
  {suggesting && anchor && mod && createPortal(