@skyhook-io/radar-app 1.16.1 → 1.16.3

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 (73) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +51 -9
  3. package/src/RadarApp.tsx +10 -2
  4. package/src/api/client.ts +61 -49
  5. package/src/api/client.updateResource.test.tsx +58 -0
  6. package/src/api/cnpg.ts +95 -0
  7. package/src/api/diagnose.ts +26 -0
  8. package/src/api/httpErrors.ts +12 -0
  9. package/src/api/timelineSource.ts +1 -1
  10. package/src/components/ConnectionErrorView.test.tsx +2 -0
  11. package/src/components/ConnectionErrorView.tsx +2 -1
  12. package/src/components/NoClusterAccessBanner.tsx +68 -0
  13. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  14. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  15. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  16. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  17. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  18. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  19. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  20. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  21. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  22. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  23. package/src/components/cnpg/CNPGView.tsx +156 -0
  24. package/src/components/cnpg/paths.ts +13 -0
  25. package/src/components/cnpg/routes.test.ts +60 -0
  26. package/src/components/cnpg/routes.ts +112 -0
  27. package/src/components/cnpg/shared.tsx +290 -0
  28. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  29. package/src/components/diagnose/AgentControls.tsx +1 -1
  30. package/src/components/diagnose/AssessmentCard.tsx +11 -1
  31. package/src/components/diagnose/DiagnoseContext.tsx +35 -22
  32. package/src/components/diagnose/DiagnoseSurface.tsx +12 -8
  33. package/src/components/diagnose/Home.tsx +1 -1
  34. package/src/components/diagnose/InvestigationEvidencePane.tsx +1 -1
  35. package/src/components/diagnose/InvestigationRefusalAction.test.tsx +156 -0
  36. package/src/components/diagnose/InvestigationRefusalAction.tsx +14 -0
  37. package/src/components/diagnose/InvestigationView.tsx +199 -65
  38. package/src/components/diagnose/investigationSourceFocus.ts +6 -6
  39. package/src/components/diagnose/investigationState.test.ts +83 -1
  40. package/src/components/diagnose/investigationState.ts +43 -0
  41. package/src/components/diagnose/launch.ts +2 -2
  42. package/src/components/diagnose/useDisclosureReveal.ts +1 -1
  43. package/src/components/dock/BottomDock.tsx +1 -1
  44. package/src/components/dock/DockContext.tsx +27 -2
  45. package/src/components/dock/LocalTerminalContext.test.tsx +210 -0
  46. package/src/components/dock/LocalTerminalTab.test.tsx +122 -0
  47. package/src/components/dock/LocalTerminalTab.tsx +81 -6
  48. package/src/components/helm/ChartBrowser.tsx +6 -7
  49. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  50. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  51. package/src/components/helm/InstallWizard.tsx +5 -2
  52. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  53. package/src/components/helm/ValuesViewer.tsx +1 -1
  54. package/src/components/logs/LogsViewer.tsx +3 -0
  55. package/src/components/logs/WorkloadLogsViewer.tsx +3 -0
  56. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  57. package/src/components/resources/ResourcesView.tsx +6 -38
  58. package/src/components/settings/LocalConfigurationDetails.tsx +21 -15
  59. package/src/components/settings/LocalConnectionSettings.tsx +41 -10
  60. package/src/components/timeline/TimelineList.test.ts +47 -29
  61. package/src/components/timeline/TimelineList.tsx +57 -11
  62. package/src/components/timeline/TimelineView.tsx +9 -2
  63. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  64. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  65. package/src/components/whats-new/WhatsNew.tsx +146 -86
  66. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  67. package/src/components/whats-new/releaseNotes.ts +156 -25
  68. package/src/components/workload/WorkloadView.tsx +30 -4
  69. package/src/context/DiagnoseCustomization.tsx +13 -3
  70. package/src/contexts/CapabilitiesContext.tsx +9 -1
  71. package/src/hooks/useResourceCounts.ts +49 -0
  72. package/src/index.ts +4 -2
  73. package/src/components/helm/RoleGatedPanel.tsx +0 -47
@@ -73,6 +73,13 @@ export function PreviousIntegrationSettingsNotice({ profiles, onNavigate, onDism
73
73
 
74
74
  export const integrationSettingsByClusterId = 'integration-settings-by-cluster'
75
75
 
76
+ // Switching to auto-discovery keeps an empty record, so a record alone doesn't
77
+ // mean anything is saved. An invalid record disables the integration instead.
78
+ function integrationStatus(use: StoredConnection) {
79
+ if (use.error || use.mode !== 'auto' || use.url || use.secretSet || use.clusterId) return 'saved'
80
+ return use.integration === 'cost' ? 'Automatic' : 'auto-discovery'
81
+ }
82
+
76
83
  export function LocalConfigurationDetails({ onNavigate }: { onNavigate: (section: SettingsSectionId) => void }) {
77
84
  return (
78
85
  <div className="mt-5 border-t border-theme-border pt-4">
@@ -168,7 +175,7 @@ export function SavedClusterConnections({
168
175
  .then(async (response) => {
169
176
  const data = (await response.json().catch(() => ({}))) as ConnectionResponse
170
177
  if (!response.ok)
171
- throw new Error(data.error || 'Could not load saved connections.')
178
+ throw new Error(data.error || 'Could not load integration settings.')
172
179
  if (controller.signal.aborted || getApiBase() !== base) return
173
180
  setUses(data.connections)
174
181
  })
@@ -215,10 +222,10 @@ export function SavedClusterConnections({
215
222
  const data = (await response.json().catch(() => ({}))) as ConnectionResponse
216
223
  if (controller.signal.aborted || getApiBase() !== base) return
217
224
  if (!response.ok)
218
- throw new Error(data.error || 'Could not remove the saved connection.')
225
+ throw new Error(data.error || 'Could not remove the saved settings.')
219
226
  setUses(data.connections)
220
227
  setMessage(
221
- `Removed the saved ${integrationNames[selected.integration]} connection for ${selected.context}.`,
228
+ `Removed the saved ${integrationNames[selected.integration]} settings for ${selected.context}.`,
222
229
  )
223
230
  focusSummary.current = true
224
231
  setSelected(null)
@@ -261,12 +268,12 @@ export function SavedClusterConnections({
261
268
  </p>
262
269
  {loading && (
263
270
  <p role="status" className="text-xs text-theme-text-tertiary">
264
- Loading saved connections…
271
+ Loading integration settings…
265
272
  </p>
266
273
  )}
267
274
  {!loading && !error && clusters.size === 0 && (
268
275
  <p className="text-xs text-theme-text-tertiary">
269
- No saved connections yet.
276
+ No integration settings saved yet.
270
277
  </p>
271
278
  )}
272
279
  {!loading &&
@@ -300,17 +307,16 @@ export function SavedClusterConnections({
300
307
  </p>
301
308
  {!current && entry.availability === 'removed' && (
302
309
  <p className="text-theme-text-secondary">
303
- This kubeconfig entry is gone, but its connections are still
304
- saved. To reuse one, select the new context and choose “Copy
310
+ This kubeconfig entry is gone, but its integration settings
311
+ are still saved. To reuse one, select the new context and choose “Copy
305
312
  settings from…” in that integration’s settings.
306
313
  </p>
307
314
  )}
308
315
  {(current ||
309
316
  entries.every((use) => use.availability === 'available')) && (
310
317
  <p className="text-theme-text-secondary">
311
- Saved:{' '}
312
318
  {entries
313
- .map((use) => integrationNames[use.integration])
319
+ .map((use) => `${integrationNames[use.integration]}: ${integrationStatus(use)}`)
314
320
  .join(' · ')}
315
321
  </p>
316
322
  )}
@@ -325,7 +331,7 @@ export function SavedClusterConnections({
325
331
  type="button"
326
332
  disabled={busy}
327
333
  className="text-accent-text hover:underline disabled:opacity-50"
328
- aria-label={`Remove saved ${integrationNames[use.integration]} connection for ${use.context}`}
334
+ aria-label={`Remove saved ${integrationNames[use.integration]} settings for ${use.context}`}
329
335
  onClick={() => {
330
336
  setSelected(use)
331
337
  setError('')
@@ -353,7 +359,7 @@ export function SavedClusterConnections({
353
359
  className="underline"
354
360
  onClick={() => setVersion((value) => value + 1)}
355
361
  >
356
- Reload saved connections
362
+ Reload integration settings
357
363
  </button>
358
364
  </p>
359
365
  )}
@@ -362,13 +368,13 @@ export function SavedClusterConnections({
362
368
  open={!!selected}
363
369
  onClose={() => setSelected(null)}
364
370
  onConfirm={() => void remove()}
365
- title="Remove saved connection?"
371
+ title="Remove saved settings?"
366
372
  message={
367
373
  selected
368
- ? `Remove the saved ${integrationNames[selected.integration]} connection and credentials for ${selected.context}? Other saved connections are unchanged. Nothing is deleted from Kubernetes or the backend.${selected.availability === 'unavailable' ? ' This entry may still exist in another kubeconfig; removal also affects any CLI or Desktop using that entry.' : ''}`
374
+ ? `Remove the saved ${integrationNames[selected.integration]} settings and credentials for ${selected.context}? Other saved settings are unchanged. Nothing is deleted from Kubernetes or the backend.${selected.availability === 'unavailable' ? ' This entry may still exist in another kubeconfig; removal also affects any CLI or Desktop using that entry.' : ''}`
369
375
  : ''
370
376
  }
371
- confirmLabel="Remove connection"
377
+ confirmLabel="Remove settings"
372
378
  isLoading={busy}
373
379
  confirmDisabled={!!error}
374
380
  showWarning={false}
@@ -380,7 +386,7 @@ export function SavedClusterConnections({
380
386
  role="alert"
381
387
  className="text-sm text-warning-text"
382
388
  >
383
- {error} Cancel and reload saved connections before trying again.
389
+ {error} Cancel and reload integration settings before trying again.
384
390
  </p>
385
391
  )}
386
392
  </ConfirmDialog>
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
2
2
  import { Badge, Collapse, ConfirmDialog, SelectMenu } from '@skyhook-io/k8s-ui'
3
3
  import { ArrowLeft, Info } from 'lucide-react'
4
4
  import { Tooltip } from '../ui/Tooltip'
@@ -47,11 +47,13 @@ export interface StoredConnection {
47
47
  source: string
48
48
  inFileName: string
49
49
  availability: 'available' | 'removed' | 'unavailable'
50
+ mode: string
50
51
  url: string
51
52
  headerKeys: string[]
52
53
  envHeaderKeys: string[]
53
54
  secretSet: boolean
54
55
  insecureTls: boolean
56
+ clusterId: string
55
57
  error?: string
56
58
  }
57
59
  export interface IntegrationProfile {
@@ -126,6 +128,11 @@ const automaticAction: Record<IntegrationKind, string> = {
126
128
  argocd: 'Use auto-discovery',
127
129
  cost: 'Reset to Automatic'
128
130
  }
131
+ function sameIdentity(a?: TargetIdentity, b?: TargetIdentity) {
132
+ return !!a && !!b && a.server === b.server && a.user === b.user &&
133
+ a.tlsName === b.tlsName && a.trust === b.trust && a.proxy === b.proxy &&
134
+ !!a.insecureTls === !!b.insecureTls
135
+ }
129
136
  const names: Record<IntegrationKind, string> = {
130
137
  metrics: 'Metrics',
131
138
  argocd: 'Argo CD',
@@ -180,9 +187,18 @@ export function LocalConnectionSettings({
180
187
  const confirmationCopy = useRef({ title: '', message: '', label: '' })
181
188
  const [confirmBack, setConfirmBack] = useState(false)
182
189
  const [accepted, setAccepted] = useState<IntegrationKind[]>([])
183
- const acceptedChanges = accepted.filter(
190
+ // The review page compares against one previous identity, so it can only
191
+ // approve integrations that were paused from that same identity.
192
+ const pausedKinds = (Object.keys(snapshot) as IntegrationKind[]).filter(
184
193
  (k) => snapshot[k].state === 'target_changed'
185
194
  )
195
+ const reviewKinds = pausedKinds.filter(
196
+ (k) => k === kind || sameIdentity(snapshot[k].previousIdentity, profile.previousIdentity)
197
+ )
198
+ const separateReviewKinds = pausedKinds.filter((k) => !reviewKinds.includes(k))
199
+ const separateIdentityKnown = !!profile.previousIdentity &&
200
+ separateReviewKinds.every((k) => !!snapshot[k].previousIdentity)
201
+ const acceptedChanges = accepted.filter((k) => reviewKinds.includes(k))
186
202
  const request = useRef<AbortController | null>(null)
187
203
  const catalogRequest = useRef<AbortController | null>(null)
188
204
  const heading = useRef<HTMLHeadingElement>(null)
@@ -191,6 +207,14 @@ export function LocalConnectionSettings({
191
207
  const editorRegion = useRef<HTMLFieldSetElement>(null)
192
208
  const trigger = useRef<HTMLElement | null>(null)
193
209
  const mounted = useRef(true)
210
+ // Focus moves in the commit that mounts a new draft form. Deferring it to a
211
+ // later frame lets keystrokes typed in between land in a different field.
212
+ const focusAfterDraft = useRef<(() => void) | null>(null)
213
+ useLayoutEffect(() => {
214
+ const focus = focusAfterDraft.current
215
+ focusAfterDraft.current = null
216
+ focus?.()
217
+ })
194
218
  const copySources = catalog.filter(c => c.integration === kind && !c.error && c.url && c.binding !== profile.target.binding)
195
219
  const removalContext = profile.target.context
196
220
  const dirty =
@@ -220,7 +244,7 @@ export function LocalConnectionSettings({
220
244
  .then(async (response) => {
221
245
  const data = (await response.json().catch(() => ({}))) as ConnectionResponse
222
246
  if (!response.ok)
223
- throw new Error(data.error || 'Could not load saved connections.')
247
+ throw new Error(data.error || 'Could not load other clusters’ settings.')
224
248
  if (!controller.signal.aborted && getApiBase() === base) {
225
249
  setCatalog(data.connections)
226
250
  setCatalogError(false)
@@ -519,7 +543,7 @@ export function LocalConnectionSettings({
519
543
  setDraftGeneration((generation) => generation + 1)
520
544
  setError('')
521
545
  // Cost's first control is its source picker, a listbox trigger.
522
- requestAnimationFrame(() => region.current?.querySelector<HTMLElement>('input:not(:disabled), button[aria-haspopup="listbox"]:not(:disabled)')?.focus())
546
+ focusAfterDraft.current = () => region.current?.querySelector<HTMLElement>('input:not(:disabled), button[aria-haspopup="listbox"]:not(:disabled)')?.focus()
523
547
  }}
524
548
  className="text-xs text-accent-text hover:underline disabled:opacity-50 shrink-0"
525
549
  >
@@ -539,7 +563,7 @@ export function LocalConnectionSettings({
539
563
  setDraftGeneration(generation => generation + 1)
540
564
  setMessage('')
541
565
  setError('')
542
- requestAnimationFrame(() => editorRegion.current?.querySelector<HTMLInputElement>('input:not(:disabled)')?.focus())
566
+ focusAfterDraft.current = () => editorRegion.current?.querySelector<HTMLInputElement>('input:not(:disabled)')?.focus()
543
567
  }
544
568
  const copySource = (source: StoredConnection) => {
545
569
  setSelected(source)
@@ -555,7 +579,7 @@ export function LocalConnectionSettings({
555
579
  setDraftGeneration(generation => generation + 1)
556
580
  setMessage('')
557
581
  setError('')
558
- requestAnimationFrame(() => editorRegion.current?.querySelector<HTMLInputElement>('input:not(:disabled)')?.focus())
582
+ focusAfterDraft.current = () => editorRegion.current?.querySelector<HTMLInputElement>('input:not(:disabled)')?.focus()
559
583
  }
560
584
  const reuseOptions = task === 'main' && profile.state !== 'launch' ? [
561
585
  ...(previous ? [{
@@ -836,7 +860,7 @@ export function LocalConnectionSettings({
836
860
  ) : null}
837
861
  </>
838
862
  )}
839
- <fieldset ref={editorRegion} className="min-w-0">
863
+ <fieldset ref={editorRegion} className="min-w-0 empty:hidden">
840
864
  {editor &&
841
865
  (kind === 'metrics' ? (
842
866
  <PrometheusConnectionForm
@@ -904,7 +928,7 @@ export function LocalConnectionSettings({
904
928
  />
905
929
  ))}
906
930
  </fieldset>
907
- {!editor && <Collapse open={showFeedback} className="!mt-0">
931
+ {!editor && task === 'main' && <Collapse open={showFeedback} className="!mt-0">
908
932
  <p role="status" className={`pt-2 text-xs ${messageWarning ? 'text-warning-text' : 'text-theme-text-secondary'}`}>{feedback?.message}</p>
909
933
  </Collapse>}
910
934
  <ConfirmDialog
@@ -968,8 +992,7 @@ export function LocalConnectionSettings({
968
992
  : 'Unchanged'}
969
993
  </dd>
970
994
  </dl>
971
- {(Object.keys(snapshot) as IntegrationKind[])
972
- .filter((k) => snapshot[k].state === 'target_changed')
995
+ {reviewKinds
973
996
  .map((k) => (
974
997
  <label key={k} className="flex items-center gap-2 text-sm">
975
998
  <input
@@ -996,6 +1019,14 @@ export function LocalConnectionSettings({
996
1019
  >
997
1020
  Keep selected settings for this cluster
998
1021
  </button>
1022
+ {separateReviewKinds.length > 0 && (
1023
+ <p className="text-xs text-theme-text-secondary">
1024
+ {separateReviewKinds.map((k) => names[k]).join(' and ')}{' '}
1025
+ {separateIdentityKnown
1026
+ ? `${separateReviewKinds.length > 1 ? 'were' : 'was'} saved for a different previous cluster, so ${separateReviewKinds.length > 1 ? 'review them in their own tabs' : 'review it in its own tab'}.`
1027
+ : `${separateReviewKinds.length > 1 ? 'need separate reviews in their own tabs' : 'needs a separate review in its own tab'}.`}
1028
+ </p>
1029
+ )}
999
1030
  </div>
1000
1031
  )}
1001
1032
  {error && !pending && (
@@ -1,11 +1,13 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { applyClientFilters } from '../../api/timelineSource'
3
3
  import type { TimelineEvent } from '../../types'
4
- import { listIncludesManaged } from './TimelineList'
4
+ import { splitRoutineActivity } from './TimelineList'
5
5
 
6
+ let clock = Date.parse('2024-01-01T00:00:00.000Z')
6
7
  function ev(over: Partial<TimelineEvent> & { id: string }): TimelineEvent {
8
+ clock += 1000
7
9
  return {
8
- timestamp: '2024-01-01T00:00:00.000Z',
10
+ timestamp: new Date(clock).toISOString(),
9
11
  source: 'informer',
10
12
  kind: 'Deployment',
11
13
  namespace: 'ns-a',
@@ -15,42 +17,58 @@ function ev(over: Partial<TimelineEvent> & { id: string }): TimelineEvent {
15
17
  }
16
18
  }
17
19
 
18
- // Mirrors the host: the source filter runs first, then the UI keeps the selected kinds.
19
- function listRows(events: TimelineEvent[], kinds: string[]): string[] {
20
- return applyClientFilters(events, { includeManaged: listIncludesManaged(false, kinds) })
21
- .filter((e) => kinds.length === 0 || kinds.includes(e.kind))
22
- .map((e) => e.id)
20
+ const deploy = ev({ id: 'deploy', kind: 'Deployment' })
21
+ const podStarted = ev({ id: 'pod-started', kind: 'Pod', source: 'k8s_event', eventType: 'Normal', reason: 'Started' })
22
+ const podOom = ev({ id: 'pod-oom', kind: 'Pod', source: 'k8s_event', eventType: 'Warning', reason: 'OOMKilled' })
23
+ const rsScaled = ev({ id: 'rs-scaled', kind: 'ReplicaSet' })
24
+ const cronJobOk = ev({ id: 'cron-job-ok', kind: 'Job', owner: { kind: 'CronJob', name: 'nightly' }, healthState: 'healthy' })
25
+ const cronJobFailed = ev({ id: 'cron-job-failed', kind: 'Job', owner: { kind: 'CronJob', name: 'nightly' }, healthState: 'unhealthy' })
26
+ const standaloneJob = ev({ id: 'standalone-job', kind: 'Job' })
27
+ const ALL = [deploy, podStarted, podOom, rsScaled, cronJobOk, cronJobFailed, standaloneJob]
28
+
29
+ // Mirrors the host: one uncapped query through the source's client filters.
30
+ function split(events: TimelineEvent[], kinds: string[], limit = 2000) {
31
+ return splitRoutineActivity(applyClientFilters(events, { kinds, includeManaged: true }), limit)
23
32
  }
33
+ const ids = (rows: TimelineEvent[]) => rows.map((e) => e.id).sort()
34
+ const oom = (id: string) => ev({ id, kind: 'Pod', source: 'k8s_event', eventType: 'Warning', reason: 'OOMKilled' })
35
+ const started = (id: string) => ev({ id, kind: 'Pod', source: 'k8s_event', eventType: 'Normal', reason: 'Started' })
24
36
 
25
- describe('listIncludesManaged', () => {
26
- it('includes managed rows when app-scoped or when any kind is selected', () => {
27
- expect(listIncludesManaged(true, [])).toBe(true)
28
- expect(listIncludesManaged(false, ['Pod'])).toBe(true)
29
- expect(listIncludesManaged(false, ['Job'])).toBe(true)
37
+ describe('splitRoutineActivity', () => {
38
+ it('keeps owner rows and child problems, holds routine child rows apart', () => {
39
+ const { withoutRoutine, routine } = split(ALL, [])
40
+ expect(ids(withoutRoutine)).toEqual(['cron-job-failed', 'deploy', 'pod-oom', 'standalone-job'])
41
+ expect(ids(routine)).toEqual(['cron-job-ok', 'pod-started', 'rs-scaled'])
30
42
  })
31
43
 
32
- it('keeps hiding managed rows for the unfiltered list', () => {
33
- expect(listIncludesManaged(false, [])).toBe(false)
34
- })
35
- })
44
+ it('shows a picked kind\'s problems and holds its routine rows apart', () => {
45
+ const pods = split(ALL, ['Pod'])
46
+ expect(ids(pods.withoutRoutine)).toEqual(['pod-oom'])
47
+ expect(ids(pods.routine)).toEqual(['pod-started'])
36
48
 
37
- describe('timeline list rows', () => {
38
- const events = [
39
- ev({ id: 'dep', kind: 'Deployment' }),
40
- ev({ id: 'pod', kind: 'Pod' }),
41
- ev({ id: 'job', kind: 'Job' }),
42
- ev({ id: 'cron-job', kind: 'Job', owner: { kind: 'CronJob', name: 'nightly' } }),
43
- ]
49
+ const jobs = split(ALL, ['Job'])
50
+ expect(ids(jobs.withoutRoutine)).toEqual(['cron-job-failed', 'standalone-job'])
51
+ expect(ids(jobs.routine)).toEqual(['cron-job-ok'])
52
+ })
44
53
 
45
- it('lists pod rows for a Pod selection', () => {
46
- expect(listRows(events, ['Pod'])).toEqual(['pod'])
54
+ it('lists rows newest first', () => {
55
+ expect(split(ALL, []).withoutRoutine.map((e) => e.id)).toEqual(['standalone-job', 'cron-job-failed', 'pod-oom', 'deploy'])
47
56
  })
48
57
 
49
- it('lists owned rows of an unmanaged kind for that kind', () => {
50
- expect(listRows(events, ['Job']).sort()).toEqual(['cron-job', 'job'])
58
+ it('never lets owner rows crowd out child problems', () => {
59
+ const owners = [1, 2, 3].map((n) => ev({ id: `deploy-${n}`, kind: 'Deployment' }))
60
+ const { withoutRoutine, truncated } = split([oom('old-oom'), ...owners], [], 2)
61
+ expect(ids(withoutRoutine)).toEqual(['deploy-2', 'deploy-3', 'old-oom'])
62
+ expect(truncated).toBe(true)
51
63
  })
52
64
 
53
- it('hides pod and owned rows when no kind is selected', () => {
54
- expect(listRows(events, []).sort()).toEqual(['dep', 'job'])
65
+ it('only adds rows when routine activity is shown', () => {
66
+ const olderDeploy = ev({ id: 'older-deploy', kind: 'Deployment' })
67
+ const burst = [1, 2, 3].map((n) => started(`started-${n}`))
68
+ const { withoutRoutine, withRoutine, routine, routineTruncated } = split([olderDeploy, ...burst], [], 2)
69
+ expect(ids(withoutRoutine)).toEqual(['older-deploy'])
70
+ expect(ids(withRoutine)).toEqual(['older-deploy', 'started-2', 'started-3'])
71
+ expect(ids(routine)).toEqual(['started-2', 'started-3'])
72
+ expect(routineTruncated).toBe(true)
55
73
  })
56
74
  })
@@ -3,13 +3,16 @@ import {
3
3
  SEVERITY_TEXT,
4
4
  TimelineList as TimelineListUI,
5
5
  eventsForApplication,
6
+ isTimelineProblem,
6
7
  type ActivityTypeFilter,
7
8
  type ActivityFilterKey,
8
9
  type AppMembershipIndex,
9
10
  type TimeRange,
10
11
  type Topology,
11
12
  } from '@skyhook-io/k8s-ui'
13
+ import type { TimelineEvent } from '../../types'
12
14
  import { useTimelineSource } from '../../context/TimelineSource'
15
+ import { isManagedTimelineEvent } from '../../api/timelineSource'
13
16
  import { useHasLimitedAccess } from '../../contexts/CapabilitiesContext'
14
17
  import type { NavigateToResource } from '../../utils/navigation'
15
18
  import { AlertTriangle, RefreshCw } from 'lucide-react'
@@ -24,10 +27,33 @@ export type { ActivityTypeFilter, ActivityFilterKey }
24
27
  const LIST_FETCH_LIMIT = 2000
25
28
  const APP_SCOPED_FETCH_LIMIT = 10000
26
29
 
27
- // The source drops managed rows (Pod, ReplicaSet, Event, or any owned row) before
28
- // the kind filter runs, so an explicit kind selection must keep them or it lists nothing.
29
- export function listIncludesManaged(appScoped: boolean, kinds: string[]): boolean {
30
- return appScoped || kinds.length > 0
30
+ function isChildProblem(e: TimelineEvent): boolean {
31
+ return isManagedTimelineEvent(e) && isTimelineProblem(e)
32
+ }
33
+
34
+ const byNewest = (a: TimelineEvent, b: TimelineEvent) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
35
+
36
+ // Splits the list's rows into owner-level rows, problems on child resources,
37
+ // and routine child activity, each newest first under its own `limit`. Separate
38
+ // budgets keep the promises the toggle makes: a burst of owner rows can't crowd
39
+ // out child problems, and showing routine activity only ever adds rows.
40
+ export function splitRoutineActivity(allRows: TimelineEvent[], limit: number) {
41
+ const owners: TimelineEvent[] = []
42
+ const childProblems: TimelineEvent[] = []
43
+ const routine: TimelineEvent[] = []
44
+ for (const e of allRows) {
45
+ const bucket = !isManagedTimelineEvent(e) ? owners : isTimelineProblem(e) ? childProblems : routine
46
+ bucket.push(e)
47
+ }
48
+ const base = [...owners.slice(0, limit), ...childProblems.slice(0, limit)].sort(byNewest)
49
+ const shownRoutine = routine.slice(0, limit)
50
+ return {
51
+ withoutRoutine: base,
52
+ withRoutine: [...base, ...shownRoutine].sort(byNewest),
53
+ routine: shownRoutine,
54
+ truncated: owners.length > limit || childProblems.length > limit,
55
+ routineTruncated: routine.length > limit,
56
+ }
31
57
  }
32
58
 
33
59
  interface TimelineListProps {
@@ -39,6 +65,10 @@ interface TimelineListProps {
39
65
  initialTimeRange?: TimeRange
40
66
  showDeleted: boolean
41
67
  onShowDeletedChange: (showDeleted: boolean) => void
68
+ // Routine activity (normal steps of pods, replica sets and other child
69
+ // resources) is hidden unless the user opts in. Problems always show.
70
+ showRoutine: boolean
71
+ onShowRoutineChange: (showRoutine: boolean) => void
42
72
  // Shared filter state lifted to TimelineView so it survives the view switch.
43
73
  search: string
44
74
  onSearchChange: (value: string) => void
@@ -64,7 +94,7 @@ interface TimelineListProps {
64
94
  appScopeLoading?: boolean
65
95
  }
66
96
 
67
- export function TimelineList({ namespaces, onViewChange, currentView, onResourceClick, initialFilter, initialTimeRange, showDeleted, onShowDeletedChange, search, onSearchChange, activityFilter, onActivityFilterChange, kindFilter, onKindFilterChange, selectionWindow, onVisibleWindowChange, scrollToMs, focusedAppIndex, appScoped = false, topology, appScopeLoading = false }: TimelineListProps) {
97
+ export function TimelineList({ namespaces, onViewChange, currentView, onResourceClick, initialFilter, initialTimeRange, showDeleted, onShowDeletedChange, showRoutine, onShowRoutineChange, search, onSearchChange, activityFilter, onActivityFilterChange, kindFilter, onKindFilterChange, selectionWindow, onVisibleWindowChange, scrollToMs, focusedAppIndex, appScoped = false, topology, appScopeLoading = false }: TimelineListProps) {
68
98
  const hasLimitedAccess = useHasLimitedAccess()
69
99
  const timelineSource = useTimelineSource()
70
100
  const [queryParams, setQueryParams] = useState<{ timeRange: TimeRange; kinds: string[] }>({
@@ -77,26 +107,36 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
77
107
  }, [])
78
108
 
79
109
  const fetchLimit = appScoped ? APP_SCOPED_FETCH_LIMIT : LIST_FETCH_LIMIT
110
+ // The application-scoped list keeps every row: its app filter needs the pods.
111
+ const splitRows = !appScoped
112
+ // Uncapped when splitting: each part gets its own cap below. The rows come
113
+ // from the loaded ring, so this costs no extra fetch.
80
114
  const { data: fetchedEvents, isLoading, isError, error, refetch } = timelineSource.useEvents({
81
115
  namespaces,
82
116
  kinds: queryParams.kinds,
83
117
  timeRange: queryParams.timeRange,
84
118
  includeK8sEvents: true,
85
- includeManaged: listIncludesManaged(appScoped, queryParams.kinds),
119
+ includeManaged: true,
86
120
  includeDeleted: showDeleted,
87
- limit: fetchLimit,
121
+ limit: splitRows ? undefined : fetchLimit,
88
122
  fromMs: selectionWindow?.fromMs,
89
123
  toMs: selectionWindow?.toMs,
90
124
  })
125
+ const split = useMemo(
126
+ () => (splitRows && fetchedEvents ? splitRoutineActivity(fetchedEvents, fetchLimit) : undefined),
127
+ [splitRows, fetchedEvents, fetchLimit],
128
+ )
91
129
  const events = useMemo(() => {
92
- const unscoped = fetchedEvents ?? []
130
+ const unscoped = split ? (showRoutine ? split.withRoutine : split.withoutRoutine) : fetchedEvents ?? []
93
131
  return appScoped
94
132
  ? focusedAppIndex
95
133
  ? eventsForApplication(unscoped, topology, focusedAppIndex)
96
134
  : []
97
135
  : unscoped
98
- }, [appScoped, focusedAppIndex, topology, fetchedEvents])
99
- const sourceTruncated = (fetchedEvents?.length ?? 0) >= fetchLimit
136
+ }, [appScoped, focusedAppIndex, topology, split, showRoutine, fetchedEvents])
137
+ const sourceTruncated = split
138
+ ? split.truncated || (showRoutine && split.routineTruncated)
139
+ : (fetchedEvents?.length ?? 0) >= fetchLimit
100
140
 
101
141
  // Full-screen error only when nothing is loaded; a failing background poll
102
142
  // with data on screen keeps rendering (data before error).
@@ -151,6 +191,10 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
151
191
  hideRangeSelector={!!selectionWindow}
152
192
  showDeleted={showDeleted}
153
193
  onShowDeletedChange={onShowDeletedChange}
194
+ showRoutine={showRoutine}
195
+ onShowRoutineChange={split ? onShowRoutineChange : undefined}
196
+ routineEvents={split?.routine}
197
+ foldPerResource={split && !showRoutine ? isChildProblem : undefined}
154
198
  search={search}
155
199
  onSearchChange={onSearchChange}
156
200
  activityFilter={activityFilter}
@@ -163,7 +207,9 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
163
207
  isTruncated={sourceTruncated}
164
208
  truncationMessage={appScoped && sourceTruncated
165
209
  ? `Showing application activity found in the newest ${fetchLimit.toLocaleString()} events in this range — narrow the query to see older activity`
166
- : undefined}
210
+ : split && sourceTruncated
211
+ ? 'Some older activity in this range is not listed. Narrow the time range to see it.'
212
+ : undefined}
167
213
  />
168
214
  </div>
169
215
  </div>
@@ -125,6 +125,7 @@ export interface PersistedTimelineState {
125
125
  viewMode: TimelineViewMode
126
126
  mode: TimelineMode
127
127
  showDeleted: boolean
128
+ showRoutine: boolean
128
129
  pinnedOnly: boolean
129
130
  search: string
130
131
  activityFilter: ActivityFilterKey[]
@@ -237,6 +238,7 @@ export function writeTimelineParams(
237
238
  set('activity', s.activityFilter.length ? s.activityFilter.join(',') : null)
238
239
  set('kinds', s.kindFilter.length ? s.kindFilter.join(',') : null)
239
240
  set('deleted', s.showDeleted ? null : '0')
241
+ set('routine', s.showRoutine ? '1' : null)
240
242
  set('pinnedOnly', s.pinnedOnly ? '1' : null)
241
243
  set('q', s.search.length ? s.search : null)
242
244
  set('grouping', s.grouping !== DEFAULT_GROUPING ? s.grouping : null)
@@ -309,6 +311,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
309
311
  // and so the fetch can exclude deletes at the source rather than only hiding
310
312
  // them client-side.
311
313
  const [showDeleted, setShowDeleted] = useState(() => searchParams.get('deleted') !== '0')
314
+ const [showRoutine, setShowRoutine] = useState(() => searchParams.get('routine') === '1')
312
315
  // ?pinnedOnly=1 is inert without pins: honoring it with no stored pins would
313
316
  // arm a filter that hides everything. Gate the read on stored pins so the param
314
317
  // can never arm on its own — ordering-proof, independent of when the empty-pins
@@ -634,6 +637,8 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
634
637
  setMode((prev) => (timeModeEqual(prev, nextMode) ? prev : nextMode))
635
638
  const nextDeleted = sp.get('deleted') !== '0'
636
639
  setShowDeleted((prev) => (prev === nextDeleted ? prev : nextDeleted))
640
+ const nextRoutine = sp.get('routine') === '1'
641
+ setShowRoutine((prev) => (prev === nextRoutine ? prev : nextRoutine))
637
642
  // Same guard as the lazy init: the param can only arm the filter when pins
638
643
  // exist, so a mount that runs this after the empty-pins reset can't re-arm it.
639
644
  const nextPinnedOnly = sp.get('pinnedOnly') === '1' && pinnedLanes.length > 0
@@ -658,7 +663,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
658
663
  const current = searchParamsRef.current
659
664
  const target = writeTimelineParams(
660
665
  current,
661
- { viewMode, mode, showDeleted, pinnedOnly, search: debouncedSearch, activityFilter, kindFilter, grouping, sort, selectedEventId },
666
+ { viewMode, mode, showDeleted, showRoutine, pinnedOnly, search: debouncedSearch, activityFilter, kindFilter, grouping, sort, selectedEventId },
662
667
  { isRetained: isRetained || isLocal, requiresNamespaceFilter: scopeRequiresNamespaceFilter },
663
668
  )
664
669
  const targetStr = target.toString()
@@ -672,7 +677,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
672
677
  const replace = !didMountUrlSyncRef.current || onlyHighFreqDiffer(currentStr, targetStr)
673
678
  didMountUrlSyncRef.current = true
674
679
  setSearchParamsRef.current(target, { replace })
675
- }, [viewMode, mode, showDeleted, pinnedOnly, debouncedSearch, activityFilter, kindFilter, grouping, sort, selectedEventId, isRetained, isLocal, scopeRequiresNamespaceFilter])
680
+ }, [viewMode, mode, showDeleted, showRoutine, pinnedOnly, debouncedSearch, activityFilter, kindFilter, grouping, sort, selectedEventId, isRetained, isLocal, scopeRequiresNamespaceFilter])
676
681
 
677
682
  // Fetch all activity - zoom controls what's visible in the UI. This ring feeds
678
683
  // the swimlanes and the local strip's histogram, so it also runs in list mode
@@ -1032,6 +1037,8 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
1032
1037
  initialTimeRange={initialTimeRange}
1033
1038
  showDeleted={showDeleted}
1034
1039
  onShowDeletedChange={setShowDeleted}
1040
+ showRoutine={showRoutine}
1041
+ onShowRoutineChange={setShowRoutine}
1035
1042
  search={search}
1036
1043
  onSearchChange={setSearch}
1037
1044
  activityFilter={activityFilter}
@@ -75,6 +75,7 @@ function parseState(
75
75
  viewMode,
76
76
  mode: parseTimeMode(params, opts.isRetained, opts.maxRangeDays),
77
77
  showDeleted: params.get('deleted') !== '0',
78
+ showRoutine: params.get('routine') === '1',
78
79
  pinnedOnly: params.get('pinnedOnly') === '1' && opts.hasPins,
79
80
  search: params.get('q') ?? '',
80
81
  activityFilter,
@@ -89,6 +90,7 @@ const defaultState: PersistedTimelineState = {
89
90
  viewMode: DEFAULT_VIEW,
90
91
  mode: live(DEFAULT_LIVE_WIDTH_MS),
91
92
  showDeleted: true,
93
+ showRoutine: false,
92
94
  pinnedOnly: false,
93
95
  search: '',
94
96
  activityFilter: [],
@@ -158,6 +160,7 @@ describe('writeTimelineParams', () => {
158
160
  viewMode: 'list',
159
161
  mode: live(6 * 60 * 60 * 1000),
160
162
  showDeleted: false,
163
+ showRoutine: true,
161
164
  pinnedOnly: true,
162
165
  search: 'nginx',
163
166
  activityFilter: ['warnings', 'unhealthy'],
@@ -175,6 +178,7 @@ describe('writeTimelineParams', () => {
175
178
  ['kinds', 'Pod,Deployment'],
176
179
  ['pinnedOnly', '1'],
177
180
  ['q', 'nginx'],
181
+ ['routine', '1'],
178
182
  ['sort', 'recent'],
179
183
  ['view', 'list'],
180
184
  ['window', '21600000'],
@@ -265,6 +269,7 @@ describe('parse(write(state)) round-trip', () => {
265
269
  viewMode: 'list',
266
270
  mode: live(6 * 60 * 60 * 1000),
267
271
  showDeleted: false,
272
+ showRoutine: true,
268
273
  pinnedOnly: true,
269
274
  search: 'nginx',
270
275
  activityFilter: ['warnings', 'unhealthy'],
@@ -338,6 +343,7 @@ describe('onlyHighFreqDiffer (history replace vs push)', () => {
338
343
  expect(onlyHighFreqDiffer('', 'activity=warnings')).toBe(false)
339
344
  expect(onlyHighFreqDiffer('', 'kinds=Pod')).toBe(false)
340
345
  expect(onlyHighFreqDiffer('', 'deleted=0')).toBe(false)
346
+ expect(onlyHighFreqDiffer('', 'routine=1')).toBe(false)
341
347
  expect(onlyHighFreqDiffer('', 'pinnedOnly=1')).toBe(false)
342
348
  })
343
349