@skyhook-io/radar-app 1.6.0 → 1.6.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.
@@ -1,8 +1,6 @@
1
1
  import { useMemo, type ReactNode } from 'react'
2
- import { useDashboard, useDashboardCRDs, useDashboardHelm } from '../../api/client'
3
- import type { DashboardResponse } from '../../api/client'
2
+ import { useDashboard, useDashboardCRDs, useDashboardHelm, useIssues, type IssuesResponse } from '../../api/client'
4
3
  import type { ExtendedMainView, Topology, SelectedResource } from '../../types'
5
- import { kindToPlural } from '../../utils/navigation'
6
4
  import { TopologyPreview } from './TopologyPreview'
7
5
  import { HelmSummary } from './HelmSummary'
8
6
  import { ActivitySummary } from './ActivitySummary'
@@ -11,9 +9,18 @@ import { CertificateHealthCard } from './CertificateHealthCard'
11
9
  import { NetworkPolicyCoverageCard } from './NetworkPolicyCoverageCard'
12
10
  import { CostCard } from './CostCard'
13
11
  import { GitOpsControllersCard } from './GitOpsControllersCard'
14
- import { AuditCard, PaneLoader, StatusDot, mapHealthToTone } from '@skyhook-io/k8s-ui'
12
+ import {
13
+ AuditCard,
14
+ PaneLoader,
15
+ StatusDot,
16
+ categoryLabel,
17
+ groupLabel,
18
+ subjectRef,
19
+ type Issue,
20
+ } from '@skyhook-io/k8s-ui'
21
+ import { formatCompactAge } from '@skyhook-io/k8s-ui/utils/format'
15
22
  import { ClusterHealthCard } from './ClusterHealthCard'
16
- import { AlertTriangle, Loader2, Shield } from 'lucide-react'
23
+ import { AlertTriangle, CheckCircle, Loader2, Shield } from 'lucide-react'
17
24
  import { clsx } from 'clsx'
18
25
 
19
26
  interface HomeViewProps {
@@ -26,6 +33,10 @@ interface HomeViewProps {
26
33
 
27
34
  export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToResourceKind, onNavigateToResource }: HomeViewProps) {
28
35
  const { data, isLoading, error } = useDashboard(namespaces)
36
+ const { data: issuesData, isLoading: issuesLoading, isFetching: issuesFetching, error: issuesError } = useIssues(namespaces)
37
+ const issues = issuesData?.issues ?? []
38
+ const issueCount = issuesData?.total_matched ?? issuesData?.total ?? issues.length
39
+ const hasCriticalIssues = issues.some((issue) => issue.severity === 'critical')
29
40
 
30
41
  // SSE is cluster-wide on small/medium clusters; the picker only narrows the
31
42
  // dashboard summary, so re-apply the filter here or the legend disagrees.
@@ -73,8 +84,6 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
73
84
  )
74
85
  }
75
86
 
76
- const hasProblems = data.problems && data.problems.length > 0
77
-
78
87
  const stillLoading = data.deferredLoading || (data.partialData && data.partialData.length > 0)
79
88
 
80
89
  return (
@@ -98,19 +107,17 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
98
107
  metrics={data.metrics}
99
108
  metricsServerAvailable={data.metricsServerAvailable}
100
109
  topCRDs={crdsData?.topCRDs}
101
- problems={data.problems ?? []}
110
+ issueCount={issueCount}
111
+ hasCriticalIssues={hasCriticalIssues}
102
112
  nodeVersionSkew={data.nodeVersionSkew}
103
113
  onNavigateToKind={onNavigateToResourceKind}
104
114
  onNavigateToView={() => onNavigateToView('resources')}
105
115
  onWarningEventsClick={() => onNavigateToView('timeline', { view: 'list', filter: 'warnings', time: 'all' })}
106
- onUnhealthyClick={() => onNavigateToView('timeline', { view: 'list', filter: 'unhealthy', time: 'all' })}
116
+ onIssuesClick={() => onNavigateToView('issues')}
107
117
  />
108
118
 
109
- {/* Row 2: Main content columns teasers left, problems right (if any) */}
110
- <div className={clsx(
111
- 'grid gap-6',
112
- hasProblems ? 'grid-cols-1 lg:grid-cols-[1fr_420px]' : 'grid-cols-1'
113
- )}>
119
+ {/* Row 2: Main content columns - teasers left, issues right */}
120
+ <div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_420px]">
114
121
  {/* Left column: teaser cards */}
115
122
  <div className="flex flex-col gap-6 auto-rows-min">
116
123
  {/* Live band — Topology + Timeline always render, so a fixed 2-up never strands.
@@ -190,14 +197,18 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
190
197
  )}
191
198
  </div>
192
199
 
193
- {/* Right column: problems panel */}
194
- {hasProblems && (
195
- <ProblemsPanel
196
- problems={data.problems}
197
- onNavigateToIssues={() => onNavigateToView('issues')}
198
- onResourceClick={onNavigateToResource}
199
- />
200
- )}
200
+ <ProblemsPanel
201
+ issues={issues}
202
+ issueCount={issueCount}
203
+ visibility={issuesData?.visibility}
204
+ hasData={!!issuesData}
205
+ isLoading={issuesLoading && !issuesData}
206
+ isRefreshing={issuesFetching && !!issuesData}
207
+ error={issuesError}
208
+ totalReturned={issues.length}
209
+ onNavigateToIssues={() => onNavigateToView('issues')}
210
+ onResourceClick={onNavigateToResource}
211
+ />
201
212
  </div>
202
213
  </div>
203
214
  </div>
@@ -216,19 +227,56 @@ function BandItem({ children }: { children: ReactNode }) {
216
227
  // ============================================================================
217
228
 
218
229
  interface ProblemsPanelProps {
219
- problems: DashboardResponse['problems']
230
+ issues: Issue[]
231
+ issueCount: number
232
+ visibility?: IssuesResponse['visibility']
233
+ hasData: boolean
234
+ isLoading: boolean
235
+ isRefreshing: boolean
236
+ error: unknown
237
+ totalReturned: number
220
238
  onNavigateToIssues: () => void
221
239
  onResourceClick: (resource: SelectedResource) => void
222
240
  }
223
241
 
224
242
 
225
- function ProblemsPanel({ problems, onNavigateToIssues, onResourceClick }: ProblemsPanelProps) {
243
+ function ProblemsPanel({
244
+ issues,
245
+ issueCount,
246
+ visibility,
247
+ hasData,
248
+ isLoading,
249
+ isRefreshing,
250
+ error,
251
+ totalReturned,
252
+ onNavigateToIssues,
253
+ onResourceClick,
254
+ }: ProblemsPanelProps) {
255
+ const hasCriticalIssues = issues.some((issue) => issue.severity === 'critical')
256
+ const hasIssues = issueCount > 0
257
+ const hasHardError = !!error && !hasData
258
+ const hasLimitedVisibility = !!visibility?.impact
259
+ const isTruncated = issueCount > totalReturned
260
+ const titleClass = hasCriticalIssues
261
+ ? 'text-red-500'
262
+ : hasIssues || hasHardError || hasLimitedVisibility
263
+ ? 'text-amber-500'
264
+ : 'text-theme-text-secondary'
265
+ const countClass = hasHardError
266
+ ? 'status-unknown'
267
+ : hasCriticalIssues
268
+ ? 'status-unhealthy'
269
+ : hasIssues || hasLimitedVisibility
270
+ ? 'status-degraded'
271
+ : 'status-healthy'
272
+ const countLabel = isLoading ? '...' : hasHardError ? 'error' : String(issueCount)
273
+
226
274
  return (
227
275
  <div className="rounded-xl bg-theme-surface shadow-theme-sm flex flex-col lg:max-h-[calc(100vh-280px)] lg:sticky lg:top-0">
228
276
  <div className="flex items-center justify-between px-5 py-3 border-b border-theme-border/50 shrink-0">
229
277
  <div className="flex items-center gap-2">
230
- <AlertTriangle className="w-4 h-4 text-red-500" />
231
- <span className="text-xs font-semibold uppercase tracking-wider text-red-500">Active Issues</span>
278
+ <AlertTriangle className={clsx('w-4 h-4', titleClass)} />
279
+ <span className={clsx('text-xs font-semibold uppercase tracking-wider', titleClass)}>Active Issues</span>
232
280
  </div>
233
281
  <div className="flex items-center gap-2">
234
282
  <button
@@ -238,41 +286,141 @@ function ProblemsPanel({ problems, onNavigateToIssues, onResourceClick }: Proble
238
286
  >
239
287
  View all
240
288
  </button>
241
- <span className="badge status-unhealthy rounded-full">{problems.length}</span>
289
+ {isRefreshing && !isLoading && (
290
+ <Loader2 className="h-3.5 w-3.5 animate-spin text-theme-text-tertiary" aria-label="Refreshing issues" />
291
+ )}
292
+ <span className={clsx('badge rounded-full', countClass)}>{countLabel}</span>
242
293
  </div>
243
294
  </div>
244
295
  <div className="overflow-y-auto flex-1 min-h-0">
245
- <div className="divide-y divide-theme-border">
246
- {problems.map((p, i) => (
247
- <button
248
- key={`${p.kind}-${p.namespace}-${p.name}-${i}`}
249
- className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-theme-hover transition-colors text-left"
250
- onClick={() => onResourceClick({
251
- kind: kindToPlural(p.kind),
252
- namespace: p.namespace,
253
- name: p.name,
254
- group: p.group,
255
- })}
256
- >
257
- <StatusDot tone={mapHealthToTone(p.severity)} className="shrink-0" />
258
- <div className="min-w-0 flex-1">
259
- <div className="flex items-center gap-1.5">
260
- <span className="text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 py-0.5 rounded">{p.kind}</span>
261
- <span className="text-xs text-theme-text-primary truncate font-medium">{p.name}</span>
262
- <span className="text-[10px] text-theme-text-tertiary ml-auto shrink-0">{p.duration || p.age}</span>
263
- </div>
264
- <div className="flex items-center gap-1.5 mt-0.5">
265
- <span className="text-[11px] text-theme-text-secondary truncate">{p.reason}</span>
266
- <span className="text-[10px] text-theme-text-tertiary shrink-0">{p.namespace}</span>
267
- </div>
268
- {p.message && (
269
- <div className="text-[10px] text-theme-text-tertiary truncate mt-0.5">{p.message}</div>
270
- )}
296
+ {isLoading ? (
297
+ <ProblemsPanelState
298
+ icon={<Loader2 className="h-5 w-5 animate-spin text-theme-text-tertiary" />}
299
+ title="Loading issues"
300
+ body="Checking live cluster issues for the selected scope."
301
+ />
302
+ ) : hasHardError ? (
303
+ <ProblemsPanelState
304
+ icon={<AlertTriangle className="h-5 w-5 text-amber-500" />}
305
+ title="Issues unavailable"
306
+ body={formatIssueError(error)}
307
+ />
308
+ ) : (
309
+ <>
310
+ {!!error && (
311
+ <ProblemsPanelNotice tone="warning">
312
+ Issue refresh failed. Showing the last successful result.
313
+ </ProblemsPanelNotice>
314
+ )}
315
+ {visibility?.impact && (
316
+ <ProblemsPanelNotice tone="warning">
317
+ Limited visibility - {visibility.impact} Results may be incomplete.
318
+ </ProblemsPanelNotice>
319
+ )}
320
+ {isTruncated && (
321
+ <ProblemsPanelNotice tone="neutral">
322
+ Showing {totalReturned} of {issueCount} issues. Narrow by namespace to see the rest.
323
+ </ProblemsPanelNotice>
324
+ )}
325
+
326
+ {issues.length === 0 ? (
327
+ <ProblemsPanelState
328
+ icon={
329
+ hasLimitedVisibility
330
+ ? <AlertTriangle className="h-5 w-5 text-amber-500" />
331
+ : <CheckCircle className="h-5 w-5 text-green-500" />
332
+ }
333
+ title={hasLimitedVisibility ? 'No visible active issues' : 'No active issues'}
334
+ body={
335
+ hasLimitedVisibility
336
+ ? 'Radar could not read every workload source in this scope.'
337
+ : 'No critical or warning issues across the selected scope.'
338
+ }
339
+ />
340
+ ) : (
341
+ <div className="divide-y divide-theme-border">
342
+ {issues.map((issue) => {
343
+ const ref = subjectRef(issue)
344
+ const age = issue.first_seen
345
+ ? issue.issue_timing === 'started_at_resource_creation'
346
+ ? 'since deploy'
347
+ : formatCompactAge(issue.first_seen)
348
+ : ''
349
+
350
+ return (
351
+ <button
352
+ key={issue.id}
353
+ className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-theme-hover transition-colors text-left"
354
+ onClick={() => onResourceClick({
355
+ kind: ref.kind,
356
+ namespace: ref.namespace ?? '',
357
+ name: ref.name,
358
+ group: ref.group ?? '',
359
+ })}
360
+ >
361
+ <StatusDot tone={issue.severity === 'critical' ? 'unhealthy' : 'degraded'} className="shrink-0" />
362
+ <div className="min-w-0 flex-1">
363
+ <div className="flex items-center gap-1.5">
364
+ <span className="text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 py-0.5 rounded">{issue.kind}</span>
365
+ <span className="text-xs text-theme-text-primary truncate font-medium">{issue.name}</span>
366
+ {age && <span className="text-[10px] text-theme-text-tertiary ml-auto shrink-0">{age}</span>}
367
+ </div>
368
+ <div className="flex items-center gap-1.5 mt-0.5">
369
+ <span className="text-[11px] text-theme-text-secondary truncate">{categoryLabel(issue.category)}</span>
370
+ <span className="text-[10px] text-theme-text-tertiary shrink-0">{groupLabel(issue.category_group)}</span>
371
+ {issue.namespace && <span className="text-[10px] text-theme-text-tertiary shrink-0">{issue.namespace}</span>}
372
+ </div>
373
+ {(issue.reason || issue.message) && (
374
+ <div className="text-[10px] text-theme-text-tertiary truncate mt-0.5">
375
+ {issue.reason}
376
+ {issue.reason && issue.message ? ' - ' : ''}
377
+ {issue.message}
378
+ </div>
379
+ )}
380
+ </div>
381
+ </button>
382
+ )
383
+ })}
271
384
  </div>
272
- </button>
273
- ))}
274
- </div>
385
+ )}
386
+ </>
387
+ )}
275
388
  </div>
276
389
  </div>
277
390
  )
278
391
  }
392
+
393
+ function ProblemsPanelState({ icon, title, body }: { icon: ReactNode; title: string; body: string }) {
394
+ return (
395
+ <div className="flex min-h-[220px] flex-col items-center justify-center gap-2 px-6 py-10 text-center">
396
+ <div className="flex h-9 w-9 items-center justify-center rounded-full bg-theme-elevated">
397
+ {icon}
398
+ </div>
399
+ <p className="text-sm font-medium text-theme-text-primary">{title}</p>
400
+ <p className="max-w-[280px] text-xs leading-5 text-theme-text-secondary">{body}</p>
401
+ </div>
402
+ )
403
+ }
404
+
405
+ function ProblemsPanelNotice({ tone, children }: { tone: 'warning' | 'neutral'; children: ReactNode }) {
406
+ return (
407
+ <div className="px-3 pt-3">
408
+ <div
409
+ className={clsx(
410
+ 'rounded-lg border px-3 py-2 text-xs leading-5',
411
+ tone === 'warning'
412
+ ? 'border-amber-500/20 bg-amber-500/10 text-theme-text-secondary'
413
+ : 'border-theme-border bg-theme-elevated text-theme-text-secondary',
414
+ )}
415
+ >
416
+ {children}
417
+ </div>
418
+ </div>
419
+ )
420
+ }
421
+
422
+ function formatIssueError(error: unknown): string {
423
+ return error instanceof Error && error.message
424
+ ? error.message
425
+ : 'Failed to load active issues.'
426
+ }
@@ -18,6 +18,9 @@ interface ResourceDetailDrawerProps {
18
18
  onExpand?: (resource: SelectedResource) => void
19
19
  /** Navigate to another resource within expanded WorkloadView */
20
20
  onNavigateToResource?: (resource: SelectedResource) => void
21
+ /** Top offset for the drawer (px). Defaults to Radar's 49px header height;
22
+ * pass 0 in chromeless embeds where there's no Radar header above it. */
23
+ headerHeight?: number
21
24
  }
22
25
 
23
26
  export function ResourceDetailDrawer(props: ResourceDetailDrawerProps) {
@@ -23,6 +23,7 @@ import { getSkeletonYaml } from '../../utils/skeleton-yaml'
23
23
  interface ResourceCountsResponse {
24
24
  counts: Record<string, number>
25
25
  forbidden?: string[]
26
+ unavailable?: string[]
26
27
  }
27
28
 
28
29
  interface ResourcesViewProps {
@@ -36,6 +37,14 @@ interface ResourcesViewProps {
36
37
 
37
38
  type SelectedKindInfo = { name: string; kind: string; group: string } | null
38
39
 
40
+ const LARGE_RESOURCE_LIST_LIMIT = 25000
41
+ const LARGE_RESOURCE_LIST_GUARD_KEYS = new Set([
42
+ 'Pod',
43
+ 'Event',
44
+ 'apps/ReplicaSet',
45
+ 'discovery.k8s.io/EndpointSlice',
46
+ ])
47
+
39
48
  const deniedWorkloadWrites: WorkloadWritePermissions = {
40
49
  deployments: false,
41
50
  daemonSets: false,
@@ -43,6 +52,10 @@ const deniedWorkloadWrites: WorkloadWritePermissions = {
43
52
  rollouts: false,
44
53
  }
45
54
 
55
+ function resourceCountKey(kind: NonNullable<SelectedKindInfo>): string {
56
+ return kind.group ? `${kind.group}/${kind.kind}` : kind.kind
57
+ }
58
+
46
59
  export function ResourcesView({ namespaces, selectedResource, onResourceClick, onResourceClickYaml, onKindChange, onClearNamespaces }: ResourcesViewProps) {
47
60
  const location = useLocation()
48
61
  const navigate = useNavigate()
@@ -93,7 +106,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
93
106
 
94
107
  // Lightweight resource counts for sidebar badges (~2KB instead of ~608MB)
95
108
  const namespacesParam = namespaces.join(',')
96
- const { data: countsData } = useQuery({
109
+ const { data: countsData, isError: countsIsError } = useQuery({
97
110
  queryKey: ['resource-counts', namespacesParam],
98
111
  queryFn: async () => {
99
112
  const params = new URLSearchParams()
@@ -123,6 +136,29 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
123
136
  return match?.isCrd ?? (!!selectedKind.group) // default: has group = likely CRD
124
137
  }, [selectedKind, apiResources])
125
138
 
139
+ const selectedCountKey = selectedKind ? resourceCountKey(selectedKind) : ''
140
+ const selectedCount = selectedCountKey ? countsData?.counts[selectedCountKey] : undefined
141
+ const selectedCountUnavailable = selectedCountKey ? countsData?.unavailable?.includes(selectedCountKey) ?? false : false
142
+ const isSelectedKindGuarded = selectedCountKey !== '' && LARGE_RESOURCE_LIST_GUARD_KEYS.has(selectedCountKey)
143
+ const waitingForGuardCount = isSelectedKindGuarded && !countsData && !countsIsError
144
+ const largeListBlocked = isSelectedKindGuarded && countsData != null && (selectedCountUnavailable || (selectedCount ?? 0) > LARGE_RESOURCE_LIST_LIMIT)
145
+ const selectedKindQueryBlocked = waitingForGuardCount || largeListBlocked
146
+ const podCount = countsData?.counts.Pod
147
+ const podCountUnavailable = countsData?.unavailable?.includes('Pod') ?? false
148
+ const podCountAllowsBulkMetrics = countsData != null && !podCountUnavailable && (podCount ?? 0) <= LARGE_RESOURCE_LIST_LIMIT
149
+ const selectedKindName = selectedKind?.name.toLowerCase() ?? ''
150
+ const topPodMetricsEnabled = selectedKindName === 'pods' && podCountAllowsBulkMetrics
151
+ const topNodeMetricsEnabled = selectedKindName === 'nodes' && namespaces.length === 0 && podCountAllowsBulkMetrics
152
+ const largeListGuard = selectedKind && largeListBlocked
153
+ ? {
154
+ kind: selectedKind.name,
155
+ count: selectedCountUnavailable ? undefined : selectedCount,
156
+ reason: selectedCountUnavailable ? 'count-unavailable' as const : 'too-many' as const,
157
+ limit: LARGE_RESOURCE_LIST_LIMIT,
158
+ namespaces,
159
+ }
160
+ : null
161
+
126
162
  // Fetch full data only for the selected kind
127
163
  const selectedKindQuery = useQuery({
128
164
  queryKey: ['resources', selectedKind?.name, isSelectedCrd ? selectedKind?.group : '', namespaces],
@@ -156,7 +192,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
156
192
  }
157
193
  return res.json()
158
194
  },
159
- enabled: !!selectedKind,
195
+ enabled: !!selectedKind && !selectedKindQueryBlocked,
160
196
  staleTime: 30000,
161
197
  refetchInterval: 120000, // Safety net — SSE k8s_event drives near-real-time invalidation
162
198
  retry: (failureCount: number, error: Error) => {
@@ -169,17 +205,17 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
169
205
  const selectedKindQueryResult: ResourceQueryResult | undefined = useMemo(() => {
170
206
  if (!selectedKind) return undefined
171
207
  return {
172
- data: selectedKindQuery.data as any[] | undefined,
173
- isLoading: selectedKindQuery.isLoading,
174
- error: selectedKindQuery.error,
208
+ data: selectedKindQueryBlocked ? [] : selectedKindQuery.data as any[] | undefined,
209
+ isLoading: waitingForGuardCount || selectedKindQuery.isLoading,
210
+ error: selectedKindQueryBlocked ? undefined : selectedKindQuery.error,
175
211
  refetch: selectedKindQuery.refetch,
176
212
  dataUpdatedAt: selectedKindQuery.dataUpdatedAt,
177
213
  }
178
- }, [selectedKind, selectedKindQuery.data, selectedKindQuery.isLoading, selectedKindQuery.error, selectedKindQuery.refetch, selectedKindQuery.dataUpdatedAt])
214
+ }, [selectedKind, selectedKindQueryBlocked, waitingForGuardCount, selectedKindQuery.data, selectedKindQuery.isLoading, selectedKindQuery.error, selectedKindQuery.refetch, selectedKindQuery.dataUpdatedAt])
179
215
 
180
216
  // Metrics
181
- const { data: topPodMetrics } = useTopPodMetrics()
182
- const { data: topNodeMetrics } = useTopNodeMetrics()
217
+ const { data: topPodMetrics } = useTopPodMetrics({ enabled: topPodMetricsEnabled, namespaces })
218
+ const { data: topNodeMetrics } = useTopNodeMetrics({ enabled: topNodeMetricsEnabled })
183
219
 
184
220
  // Certificate expiry
185
221
  const { data: certExpiry, isError: certExpiryError } = useSecretCertExpiry()
@@ -248,7 +284,9 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
248
284
  // Lightweight counts for sidebar (replaces 233 parallel queries)
249
285
  resourceCounts={countsData?.counts}
250
286
  resourceForbidden={countsData?.forbidden}
287
+ resourceUnavailable={countsData?.unavailable}
251
288
  selectedKindQuery={selectedKindQueryResult}
289
+ largeListGuard={largeListGuard}
252
290
  onSelectedKindChange={setSelectedKind}
253
291
  topPodMetrics={topPodMetrics}
254
292
  topNodeMetrics={topNodeMetrics}
@@ -15,9 +15,11 @@ interface TimelineListProps {
15
15
  onResourceClick?: NavigateToResource
16
16
  initialFilter?: ActivityTypeFilter
17
17
  initialTimeRange?: TimeRange
18
+ showDeleted: boolean
19
+ onShowDeletedChange: (showDeleted: boolean) => void
18
20
  }
19
21
 
20
- export function TimelineList({ namespaces, onViewChange, currentView, onResourceClick, initialFilter, initialTimeRange }: TimelineListProps) {
22
+ export function TimelineList({ namespaces, onViewChange, currentView, onResourceClick, initialFilter, initialTimeRange, showDeleted, onShowDeletedChange }: TimelineListProps) {
21
23
  const hasLimitedAccess = useHasLimitedAccess()
22
24
  const [queryParams, setQueryParams] = useState<{ timeRange: TimeRange; kind?: string }>({
23
25
  timeRange: initialTimeRange ?? '1h',
@@ -32,6 +34,7 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
32
34
  kind: queryParams.kind,
33
35
  timeRange: queryParams.timeRange,
34
36
  includeK8sEvents: true,
37
+ includeDeleted: showDeleted,
35
38
  limit: 500,
36
39
  })
37
40
 
@@ -64,6 +67,8 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
64
67
  onResourceClick={onResourceClick}
65
68
  initialFilter={initialFilter}
66
69
  initialTimeRange={initialTimeRange}
70
+ showDeleted={showDeleted}
71
+ onShowDeletedChange={onShowDeletedChange}
67
72
  />
68
73
  )
69
74
  }
@@ -42,6 +42,10 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
42
42
  // Force list view on large clusters without namespace filter
43
43
  const effectiveInitialMode = requiresNamespaceFilter ? 'list' : (initialViewMode ?? 'swimlane')
44
44
  const [viewMode, setViewMode] = useState<TimelineViewMode>(effectiveInitialMode)
45
+ // Shared across list + swimlane so the toggle carries across the view switch,
46
+ // and so the swimlane fetch can exclude deletes server-side (before LIMIT)
47
+ // rather than only hiding them client-side after the 10k cap.
48
+ const [showDeleted, setShowDeleted] = useState(true)
45
49
 
46
50
  // Only fetch heavy swimlane data when actually showing swimlanes
47
51
  const showSwimlanes = viewMode === 'swimlane' && !requiresNamespaceFilter
@@ -53,6 +57,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
53
57
  timeRange: 'all',
54
58
  includeK8sEvents: true,
55
59
  includeManaged: true,
60
+ includeDeleted: showDeleted,
56
61
  limit: 10000,
57
62
  enabled: showSwimlanes,
58
63
  })
@@ -119,6 +124,8 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
119
124
  onViewModeChange={setViewMode}
120
125
  topology={stableTopology}
121
126
  namespaces={namespaces}
127
+ showDeleted={showDeleted}
128
+ onShowDeletedChange={setShowDeleted}
122
129
  />
123
130
  )
124
131
  }
@@ -131,6 +138,8 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
131
138
  onResourceClick={onResourceClick}
132
139
  initialFilter={initialFilter}
133
140
  initialTimeRange={initialTimeRange}
141
+ showDeleted={showDeleted}
142
+ onShowDeletedChange={setShowDeleted}
134
143
  />
135
144
  )
136
145
  }