@skyhook-io/k8s-ui 1.8.0 → 1.8.2

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.
@@ -0,0 +1,75 @@
1
+ import { useCallback, useRef, useState } from 'react'
2
+ import { ConfirmDialog } from '../ui/ConfirmDialog'
3
+ import type { PasteConfirmInfo, PasteConfirmer } from './terminalClipboard'
4
+
5
+ // Session-scoped "Don't ask again" — app-wide for the page session (shared across
6
+ // terminal tabs), reset on reload. Deliberately not persisted: a multi-line paste
7
+ // warning re-appearing once per session is cheap, and it avoids a safety net the
8
+ // user silently disabled long ago and forgot.
9
+ let warningSuppressedThisSession = false
10
+
11
+ /**
12
+ * Owns the themed confirmation shown before a risky multi-line paste runs in a
13
+ * terminal. Returns a `confirmPaste` callback to hand to setupTerminalClipboard
14
+ * and a `pasteDialog` node the host must render. Once the user opts out via
15
+ * "Don't ask again", pastes proceed without prompting for the rest of the session.
16
+ */
17
+ export function useMultilinePasteConfirm(): { confirmPaste: PasteConfirmer; pasteDialog: React.ReactNode } {
18
+ const [pending, setPending] = useState<PasteConfirmInfo | null>(null)
19
+ const [dontAskAgain, setDontAskAgain] = useState(false)
20
+ const resolverRef = useRef<((ok: boolean) => void) | null>(null)
21
+
22
+ const confirmPaste = useCallback<PasteConfirmer>(
23
+ (info) => {
24
+ if (warningSuppressedThisSession) return Promise.resolve(true)
25
+ // A paste arriving while a dialog is still open supersedes it: decline the
26
+ // pending one so its promise resolves (the superseded paste is dropped)
27
+ // rather than leaking, then show the new prompt.
28
+ resolverRef.current?.(false)
29
+ return new Promise<boolean>((resolve) => {
30
+ resolverRef.current = resolve
31
+ setDontAskAgain(false)
32
+ setPending(info)
33
+ })
34
+ },
35
+ [],
36
+ )
37
+
38
+ const settle = useCallback(
39
+ (ok: boolean) => {
40
+ // Only suppress when the user actually proceeds — cancelling shouldn't
41
+ // quietly disable the warning.
42
+ if (ok && dontAskAgain) warningSuppressedThisSession = true
43
+ resolverRef.current?.(ok)
44
+ resolverRef.current = null
45
+ setPending(null)
46
+ },
47
+ [dontAskAgain],
48
+ )
49
+
50
+ const pasteDialog = pending ? (
51
+ <ConfirmDialog
52
+ open
53
+ variant="warning"
54
+ title="Paste multiple lines?"
55
+ message={`This pastes ${pending.lineCount} lines into the shell — they run immediately.`}
56
+ details={pending.text}
57
+ confirmLabel="Paste"
58
+ cancelLabel="Cancel"
59
+ onConfirm={() => settle(true)}
60
+ onClose={() => settle(false)}
61
+ >
62
+ <label className="flex items-center gap-2 text-sm text-theme-text-secondary cursor-pointer">
63
+ <input
64
+ type="checkbox"
65
+ checked={dontAskAgain}
66
+ onChange={(e) => setDontAskAgain(e.target.checked)}
67
+ className="w-4 h-4 rounded border-theme-border bg-theme-base accent-amber-500"
68
+ />
69
+ <span>Don&apos;t ask again this session</span>
70
+ </label>
71
+ </ConfirmDialog>
72
+ ) : null
73
+
74
+ return { confirmPaste, pasteDialog }
75
+ }
@@ -72,12 +72,23 @@ export type GitOpsMode = 'applications' | 'sources' | 'projects' | 'alerts'
72
72
  // the Scope switcher stays hidden until a second category lands. A one-option
73
73
  // switcher is just noise (an always-selected pill that can't be changed).
74
74
  const AVAILABLE_MODES: GitOpsMode[] = ['applications']
75
+ const GITOPS_COUNT_GROUP_PREFIXES = [
76
+ 'argoproj.io/',
77
+ 'source.toolkit.fluxcd.io/',
78
+ 'kustomize.toolkit.fluxcd.io/',
79
+ 'helm.toolkit.fluxcd.io/',
80
+ 'notification.toolkit.fluxcd.io/',
81
+ ]
75
82
  export type GitOpsViewMode = 'table' | 'tiles'
76
83
  // 'urgency' is the curated DEFAULT order (what needs attention first) — not a
77
84
  // column, so no header shows it as active; clicking any header replaces it with
78
85
  // that column's own semantics.
79
86
  export type SortKey = 'urgency' | 'name' | 'health' | 'sync' | 'lastSync' | 'project'
80
87
 
88
+ function isGitOpsCountKey(key: string): boolean {
89
+ return GITOPS_COUNT_GROUP_PREFIXES.some((prefix) => key.startsWith(prefix))
90
+ }
91
+
81
92
  // Row-level actions surfaced from the table's three-dot menu. The set
82
93
  // mirrors what the detail page exposes today; callers wire the mutations
83
94
  // + dialogs and dispatch via `onRowAction`. Argo-only actions (refresh,
@@ -102,7 +113,7 @@ export interface FleetClusterStamp {
102
113
  name: string
103
114
  }
104
115
  export type FleetDestinationMatch = 'in_cluster' | 'exact' | 'inferred' | 'unmatched'
105
- // FleetDestinationConfidence is a coarse signal the SPA uses to style
116
+ // FleetDestinationConfidence is a coarse signal the frontend uses to style
106
117
  // the destination chip. `high` = URL-equality match (either direct or
107
118
  // via Argo cluster-secret), `medium` = name-equality match (more
108
119
  // fragile, more likely to be a false positive when two clusters share
@@ -116,7 +127,7 @@ export interface FleetDestinationStamp {
116
127
  // Confidence + reason are populated for non-unmatched rows. They power
117
128
  // the chip's visual treatment (a checkmark on high-confidence matches)
118
129
  // and its title= tooltip respectively. Both come from the hub —
119
- // adding new values shouldn't break the SPA (unknown confidence
130
+ // adding new values shouldn't break the frontend (unknown confidence
120
131
  // falls back to no special styling).
121
132
  confidence?: FleetDestinationConfidence
122
133
  reason?: string
@@ -180,11 +191,12 @@ export interface GitOpsTableViewProps {
180
191
  // counts keyed "group/Kind" — e.g. "argoproj.io/Application" → 17. Drives
181
192
  // the Scope-section mode tabs and the empty-state check.
182
193
  counts: Record<string, number>
194
+ countsUnavailable?: string[]
183
195
  // Caller refresh — typically invalidates its useQuery + refetches.
184
196
  onRefresh?: () => void
185
197
  // Row click — caller routes to its own detail page. When the host also
186
198
  // passes `rowHrefFor`, the callback receives the MouseEvent so it can
187
- // `preventDefault()` for SPA-local nav (e.g. react-router) or skip the
199
+ // `preventDefault()` for same-tree nav (e.g. react-router) or skip the
188
200
  // preventDefault to let the anchor's default full-page navigation run
189
201
  // (required for cross-router-boundary links).
190
202
  onRowClick: (row: GitOpsRow, event?: ReactMouseEvent) => void
@@ -217,8 +229,11 @@ export interface GitOpsTableViewProps {
217
229
  searchHotkey?: boolean
218
230
  // emptyStateTitle / emptyStateBody override the "No GitOps resources
219
231
  // detected" copy. Hub passes "No GitOps resources across the fleet".
220
- emptyStateTitle?: string
221
- emptyStateBody?: string
232
+ // ReactNode (not just string) so a host can split the body across lines
233
+ // (e.g. a `<br/>` between "what's wrong" and "what to do") or emphasize
234
+ // part of it. Strings still work unchanged.
235
+ emptyStateTitle?: ReactNode
236
+ emptyStateBody?: ReactNode
222
237
  /**
223
238
  * Which side the filter rail sits on. Default 'left' (OSS Radar, which
224
239
  * has no app sidebar). A host with its own left navigation rail (the
@@ -259,6 +274,7 @@ export function GitOpsTableView({
259
274
  loading,
260
275
  error,
261
276
  counts,
277
+ countsUnavailable,
262
278
  onRefresh,
263
279
  onRowClick,
264
280
  rowHrefFor,
@@ -353,7 +369,14 @@ export function GitOpsTableView({
353
369
  projects: counts['argoproj.io/AppProject'] ?? 0,
354
370
  alerts: counts['notification.toolkit.fluxcd.io/Alert'] ?? 0,
355
371
  }
356
- const totalGitOps = Object.values(counts).reduce((sum, n) => sum + n, 0)
372
+ const totalGitOps = Object.entries(counts).reduce(
373
+ (sum, [key, n]) => sum + (isGitOpsCountKey(key) ? n : 0),
374
+ 0,
375
+ )
376
+ const hasUnavailableGitOpsCounts = useMemo(
377
+ () => (countsUnavailable ?? []).some(isGitOpsCountKey),
378
+ [countsUnavailable],
379
+ )
357
380
 
358
381
  const projects = useMemo(
359
382
  () => countValues(allRows.map((row) => row.project).filter(Boolean)),
@@ -481,9 +504,9 @@ export function GitOpsTableView({
481
504
  // Also require zero actual rows: the cold-cache retry can populate `rows`
482
505
  // before the separate counts map catches up, and a populated table must not
483
506
  // be hidden behind a "nothing here" screen.
484
- if (totalGitOps === 0 && allRowsInput.length === 0 && !loading && !hasGlobalNamespaceFilter) {
507
+ if (totalGitOps === 0 && allRowsInput.length === 0 && !loading && !hasGlobalNamespaceFilter && !hasUnavailableGitOpsCounts) {
485
508
  return (
486
- <div className="flex h-full min-h-0 flex-1 items-center justify-center bg-theme-base p-4">
509
+ <div className="flex h-full min-h-0 flex-1 items-start justify-center bg-theme-base px-4 pb-4 pt-[22vh]">
487
510
  <div className="rounded-lg border border-theme-border bg-theme-surface p-8 text-center">
488
511
  <GitBranch className="mx-auto h-8 w-8 text-theme-text-tertiary" />
489
512
  <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')
@@ -34,7 +34,8 @@ export interface ResourcesSidebarProps {
34
34
  onSelectedKindChange: (kind: SelectedKindInfo) => void
35
35
  onKindChange?: () => void
36
36
  apiResources?: APIResource[]
37
- resourceCounts?: Record<string, number>
37
+ resourceCounts?: Record<string, number | null>
38
+ resourceUnavailable?: string[]
38
39
  resourceForbidden?: string[]
39
40
  pinned?: PinnedItem[]
40
41
  togglePin?: (item: PinnedItem) => void
@@ -176,6 +177,7 @@ export function ResourcesSidebar({
176
177
  onKindChange,
177
178
  apiResources,
178
179
  resourceCounts,
180
+ resourceUnavailable,
179
181
  resourceForbidden,
180
182
  pinned = [],
181
183
  togglePin = () => {},
@@ -257,11 +259,11 @@ export function ResourcesSidebar({
257
259
  }))
258
260
  }, [categories])
259
261
 
260
- // null for a key means "not loaded yet" (rendered as a placeholder
261
- // dash in the badge). 0 means "the API replied and confirmed there
262
- // are zero of this kind". Don't conflate them — otherwise the
263
- // sidebar shows a confident "0" for every kind while the count
264
- // payload is still in flight.
262
+ const unavailableKinds = useMemo(() => new Set(resourceUnavailable ?? []), [resourceUnavailable])
263
+
264
+ // null for a key means "count unknown/unavailable" (rendered as a
265
+ // placeholder dash in the badge). 0 means "the API replied and
266
+ // confirmed there are zero of this kind".
265
267
  const counts = useMemo(() => {
266
268
  const results: Record<string, number | null> = {}
267
269
  if (!resourceCounts) {
@@ -273,15 +275,16 @@ export function ResourcesSidebar({
273
275
  }
274
276
  for (const resource of resourcesToCount) {
275
277
  const key = resource.group ? `${resource.group}/${resource.kind}` : resource.kind
276
- const v = resourceCounts[key]
277
- // Treat missing keys in a present resourceCounts payload as
278
- // "the API replied and didn't include this kind" → 0. Treat the
279
- // entire payload being absent as "not loaded" → null (handled
280
- // above).
281
- results[key] = v ?? 0
278
+ if (unavailableKinds.has(key)) {
279
+ results[key] = null
280
+ } else if (key in resourceCounts) {
281
+ results[key] = resourceCounts[key] ?? null
282
+ } else {
283
+ results[key] = 0
284
+ }
282
285
  }
283
286
  return results
284
- }, [resourcesToCount, resourceCounts])
287
+ }, [resourcesToCount, resourceCounts, unavailableKinds])
285
288
 
286
289
  // Track which resource kinds returned 403 Forbidden
287
290
  const forbiddenKinds = useMemo(() => {
@@ -1813,6 +1813,20 @@ export interface ResourceQueryResult {
1813
1813
  dataUpdatedAt?: number
1814
1814
  }
1815
1815
 
1816
+ export interface LargeListGuardState {
1817
+ kind: string
1818
+ count?: number
1819
+ reason?: 'too-many' | 'count-unavailable'
1820
+ limit: number
1821
+ namespaces: string[]
1822
+ }
1823
+
1824
+ function formatLargeListScope(namespaces: string[]): string {
1825
+ if (namespaces.length === 0) return 'all namespaces'
1826
+ if (namespaces.length === 1) return namespaces[0]
1827
+ return `${namespaces.length.toLocaleString()} namespaces`
1828
+ }
1829
+
1816
1830
  interface ResourcesViewProps {
1817
1831
  namespaces: string[]
1818
1832
  selectedResource?: SelectedResource | null
@@ -1826,8 +1840,10 @@ interface ResourcesViewProps {
1826
1840
  // Lightweight counts for sidebar badges (from /api/resource-counts)
1827
1841
  resourceCounts?: Record<string, number>
1828
1842
  resourceForbidden?: string[]
1843
+ resourceUnavailable?: string[]
1829
1844
  // Single query for the currently selected kind's full data
1830
1845
  selectedKindQuery?: ResourceQueryResult
1846
+ largeListGuard?: LargeListGuardState | null
1831
1847
  topPodMetrics?: TopPodMetrics[]
1832
1848
  topNodeMetrics?: TopNodeMetrics[]
1833
1849
  certExpiry?: Record<string, { expired?: boolean; daysLeft: number }>
@@ -2042,7 +2058,9 @@ export function ResourcesView({
2042
2058
  resourceQueries: resourceQueriesProp,
2043
2059
  resourceCounts: resourceCountsProp,
2044
2060
  resourceForbidden: resourceForbiddenProp,
2061
+ resourceUnavailable: resourceUnavailableProp,
2045
2062
  selectedKindQuery: selectedKindQueryProp,
2063
+ largeListGuard,
2046
2064
  topPodMetrics,
2047
2065
  topNodeMetrics,
2048
2066
  certExpiry,
@@ -3237,22 +3255,29 @@ export function ResourcesView({
3237
3255
  const counts = useMemo(() => {
3238
3256
  if (useNewCountsMode) {
3239
3257
  // resourceCountsProp uses "group/Kind" keys for CRDs, "Kind" for core — same format as sidebar
3240
- const results: Record<string, number> = {}
3258
+ const unavailableKinds = new Set(resourceUnavailableProp ?? [])
3259
+ const results: Record<string, number | null> = {}
3241
3260
  for (const resource of resourcesToCount) {
3242
3261
  const key = resource.group ? `${resource.group}/${resource.kind}` : resource.kind
3243
- results[key] = resourceCountsProp![key] ?? 0
3262
+ if (unavailableKinds.has(key)) {
3263
+ results[key] = null
3264
+ } else if (key in resourceCountsProp!) {
3265
+ results[key] = resourceCountsProp![key]
3266
+ } else {
3267
+ results[key] = 0
3268
+ }
3244
3269
  }
3245
3270
  return results
3246
3271
  }
3247
3272
  // Legacy: derive counts from full query data
3248
- const results: Record<string, number> = {}
3273
+ const results: Record<string, number | null> = {}
3249
3274
  resourcesToCount.forEach((resource, index) => {
3250
3275
  const data = resourceQueries[index]?.data
3251
3276
  const key = resource.group ? `${resource.group}/${resource.kind}` : resource.kind
3252
3277
  results[key] = Array.isArray(data) ? data.length : 0
3253
3278
  })
3254
3279
  return results
3255
- }, [useNewCountsMode, resourcesToCount, resourceCountsProp, resourceQueries])
3280
+ }, [useNewCountsMode, resourcesToCount, resourceCountsProp, resourceUnavailableProp, resourceQueries])
3256
3281
 
3257
3282
  // Track which resource kinds returned 403 Forbidden
3258
3283
  const forbiddenKinds = useMemo(() => {
@@ -3987,6 +4012,7 @@ export function ResourcesView({
3987
4012
  apiResources={apiResourcesProp}
3988
4013
  resourceCounts={counts}
3989
4014
  resourceForbidden={Array.from(forbiddenKinds)}
4015
+ resourceUnavailable={resourceUnavailableProp}
3990
4016
  pinned={pinned}
3991
4017
  togglePin={togglePin}
3992
4018
  isPinned={isPinned}
@@ -4484,6 +4510,26 @@ export function ResourcesView({
4484
4510
  <p className="text-theme-text-secondary font-medium">Access Restricted</p>
4485
4511
  <p className="text-sm mt-1">Insufficient permissions to list {selectedKind.kind} resources</p>
4486
4512
  </div>
4513
+ ) : largeListGuard ? (
4514
+ <div className="absolute inset-0 flex flex-col items-center justify-center text-theme-text-tertiary px-6 text-center">
4515
+ <AlertTriangle className="w-8 h-8 text-amber-400 mb-2" />
4516
+ <p className="text-theme-text-secondary font-medium">
4517
+ {largeListGuard.reason === 'count-unavailable'
4518
+ ? `${largeListGuard.kind} count unavailable`
4519
+ : `Too many ${largeListGuard.kind.toLowerCase()} to show`}
4520
+ </p>
4521
+ <p className="text-sm mt-1 max-w-xl">
4522
+ {largeListGuard.reason === 'count-unavailable'
4523
+ ? 'Radar could not verify this list is small enough to load. Choose a namespace or smaller namespace set to try again.'
4524
+ : `${largeListGuard.count?.toLocaleString()} ${largeListGuard.kind.toLowerCase()} are in the current scope. Choose a namespace or smaller namespace set to load this view.`}
4525
+ </p>
4526
+ <p className="text-xs mt-2 text-theme-text-disabled">
4527
+ Radar limits full table loads to {largeListGuard.limit.toLocaleString()} resources to keep the UI responsive.
4528
+ </p>
4529
+ <p className="text-xs mt-2 text-theme-text-disabled">
4530
+ Scope: {formatLargeListScope(largeListGuard.namespaces)}
4531
+ </p>
4532
+ </div>
4487
4533
  ) : filteredResources.length === 0 ? (
4488
4534
  <div className="absolute inset-0 flex flex-col items-center justify-center text-theme-text-tertiary">
4489
4535
  <p>No {selectedKind.kind} found</p>
@@ -16,6 +16,6 @@ export * from './resource-utils-trivy'
16
16
  export * from './resource-utils-traefik'
17
17
  export * from './resource-utils-velero'
18
18
  export { ResourcesView, ResourcesViewDataContext } from './ResourcesView'
19
- export type { ResourceQueryResult, ExtraColumn } from './ResourcesView'
19
+ export type { ResourceQueryResult, ExtraColumn, LargeListGuardState } from './ResourcesView'
20
20
  export { ResourcesSidebar } from './ResourcesSidebar'
21
21
  export type { ResourcesSidebarProps, SelectedKindInfo, PinnedItem } from './ResourcesSidebar'
@@ -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(
@@ -1,4 +1,5 @@
1
1
  import { createContext, useContext, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
+ import { isMac as isMacPlatform } from '../utils/platform'
2
3
 
3
4
  export type ShortcutScope = 'global' | 'topology' | 'resources' | 'timeline' | 'helm' | 'gitops' | 'traffic' | 'applications' | 'audit' | 'drawer'
4
5
 
@@ -78,7 +79,7 @@ interface KeyMatcher {
78
79
  altKey?: boolean
79
80
  }
80
81
 
81
- const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform)
82
+ const isMac = isMacPlatform()
82
83
 
83
84
  function parseKeys(keys: string): KeyMatcher {
84
85
  // Multi-key sequence (space-separated, e.g. "g g")