@skyhook-io/radar-app 1.16.2 → 1.17.0

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 (93) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +60 -12
  3. package/src/RadarApp.tsx +27 -17
  4. package/src/api/client.goneResource.test.ts +49 -0
  5. package/src/api/client.ts +168 -55
  6. package/src/api/cnpg.ts +95 -0
  7. package/src/api/diagnose.test.ts +40 -0
  8. package/src/api/diagnose.ts +12 -1
  9. package/src/api/radarFeatures.render.test.tsx +19 -1
  10. package/src/api/radarFeatures.ts +3 -0
  11. package/src/api/timelineSource.ts +1 -1
  12. package/src/api/traffic.sources.test.ts +55 -0
  13. package/src/api/traffic.ts +173 -14
  14. package/src/components/NoClusterAccessBanner.tsx +68 -0
  15. package/src/components/applications/ApplicationsView.tsx +3 -0
  16. package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
  17. package/src/components/capacity/CapacityView.test.tsx +31 -1
  18. package/src/components/capacity/shared.tsx +12 -21
  19. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  20. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  21. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  22. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  23. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  24. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  25. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  26. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  27. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  28. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  29. package/src/components/cnpg/CNPGView.tsx +156 -0
  30. package/src/components/cnpg/paths.ts +13 -0
  31. package/src/components/cnpg/routes.test.ts +60 -0
  32. package/src/components/cnpg/routes.ts +112 -0
  33. package/src/components/cnpg/shared.tsx +290 -0
  34. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  35. package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
  36. package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
  37. package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
  38. package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
  39. package/src/components/diagnose/ApplyDialog.tsx +26 -28
  40. package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
  41. package/src/components/diagnose/DiagnoseContext.tsx +136 -54
  42. package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
  43. package/src/components/diagnose/InvestigationView.tsx +39 -2
  44. package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
  45. package/src/components/diagnose/agentCatalog.ts +2 -2
  46. package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
  47. package/src/components/dock/TrafficFlowListTab.tsx +27 -6
  48. package/src/components/execution/BatchExecutionView.tsx +11 -11
  49. package/src/components/helm/ChartBrowser.tsx +9 -10
  50. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  51. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  52. package/src/components/helm/InstallWizard.tsx +5 -2
  53. package/src/components/helm/RevisionHistory.tsx +3 -2
  54. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  55. package/src/components/helm/ValuesViewer.tsx +1 -1
  56. package/src/components/home/ClusterHealthCard.tsx +7 -0
  57. package/src/components/home/mcpToolCatalog.ts +1 -1
  58. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  59. package/src/components/resources/ResourcesView.tsx +6 -38
  60. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
  61. package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
  62. package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
  63. package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
  64. package/src/components/settings/SettingsDialog.tsx +122 -19
  65. package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
  66. package/src/components/timeline/TimelineList.test.ts +47 -29
  67. package/src/components/timeline/TimelineList.tsx +60 -11
  68. package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
  69. package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
  70. package/src/components/timeline/TimelineView.tsx +16 -2
  71. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  72. package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
  73. package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
  74. package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
  75. package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
  76. package/src/components/traffic/TrafficGraph.tsx +109 -26
  77. package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
  78. package/src/components/traffic/TrafficView.tsx +582 -149
  79. package/src/components/traffic/trafficFilters.test.ts +398 -1
  80. package/src/components/traffic/trafficFilters.ts +408 -0
  81. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  82. package/src/components/whats-new/WhatsNew.tsx +146 -86
  83. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  84. package/src/components/whats-new/releaseNotes.ts +155 -24
  85. package/src/components/workload/WorkloadView.test.ts +2 -1
  86. package/src/components/workload/WorkloadView.tsx +165 -294
  87. package/src/context/TimelineCustomization.tsx +15 -0
  88. package/src/contexts/CapabilitiesContext.tsx +9 -1
  89. package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
  90. package/src/hooks/useGitOpsWriteGuard.ts +193 -0
  91. package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
  92. package/src/hooks/useResourceCounts.ts +49 -0
  93. package/src/components/helm/RoleGatedPanel.tsx +0 -47
@@ -3,13 +3,17 @@ 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'
16
+ import { TimelineRetentionBanner } from './TimelineRetentionNote'
13
17
  import { useHasLimitedAccess } from '../../contexts/CapabilitiesContext'
14
18
  import type { NavigateToResource } from '../../utils/navigation'
15
19
  import { AlertTriangle, RefreshCw } from 'lucide-react'
@@ -24,10 +28,33 @@ export type { ActivityTypeFilter, ActivityFilterKey }
24
28
  const LIST_FETCH_LIMIT = 2000
25
29
  const APP_SCOPED_FETCH_LIMIT = 10000
26
30
 
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
31
+ function isChildProblem(e: TimelineEvent): boolean {
32
+ return isManagedTimelineEvent(e) && isTimelineProblem(e)
33
+ }
34
+
35
+ const byNewest = (a: TimelineEvent, b: TimelineEvent) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
36
+
37
+ // Splits the list's rows into owner-level rows, problems on child resources,
38
+ // and routine child activity, each newest first under its own `limit`. Separate
39
+ // budgets keep the promises the toggle makes: a burst of owner rows can't crowd
40
+ // out child problems, and showing routine activity only ever adds rows.
41
+ export function splitRoutineActivity(allRows: TimelineEvent[], limit: number) {
42
+ const owners: TimelineEvent[] = []
43
+ const childProblems: TimelineEvent[] = []
44
+ const routine: TimelineEvent[] = []
45
+ for (const e of allRows) {
46
+ const bucket = !isManagedTimelineEvent(e) ? owners : isTimelineProblem(e) ? childProblems : routine
47
+ bucket.push(e)
48
+ }
49
+ const base = [...owners.slice(0, limit), ...childProblems.slice(0, limit)].sort(byNewest)
50
+ const shownRoutine = routine.slice(0, limit)
51
+ return {
52
+ withoutRoutine: base,
53
+ withRoutine: [...base, ...shownRoutine].sort(byNewest),
54
+ routine: shownRoutine,
55
+ truncated: owners.length > limit || childProblems.length > limit,
56
+ routineTruncated: routine.length > limit,
57
+ }
31
58
  }
32
59
 
33
60
  interface TimelineListProps {
@@ -39,6 +66,10 @@ interface TimelineListProps {
39
66
  initialTimeRange?: TimeRange
40
67
  showDeleted: boolean
41
68
  onShowDeletedChange: (showDeleted: boolean) => void
69
+ // Routine activity (normal steps of pods, replica sets and other child
70
+ // resources) is hidden unless the user opts in. Problems always show.
71
+ showRoutine: boolean
72
+ onShowRoutineChange: (showRoutine: boolean) => void
42
73
  // Shared filter state lifted to TimelineView so it survives the view switch.
43
74
  search: string
44
75
  onSearchChange: (value: string) => void
@@ -64,7 +95,7 @@ interface TimelineListProps {
64
95
  appScopeLoading?: boolean
65
96
  }
66
97
 
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) {
98
+ 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
99
  const hasLimitedAccess = useHasLimitedAccess()
69
100
  const timelineSource = useTimelineSource()
70
101
  const [queryParams, setQueryParams] = useState<{ timeRange: TimeRange; kinds: string[] }>({
@@ -77,26 +108,36 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
77
108
  }, [])
78
109
 
79
110
  const fetchLimit = appScoped ? APP_SCOPED_FETCH_LIMIT : LIST_FETCH_LIMIT
111
+ // The application-scoped list keeps every row: its app filter needs the pods.
112
+ const splitRows = !appScoped
113
+ // Uncapped when splitting: each part gets its own cap below. The rows come
114
+ // from the loaded ring, so this costs no extra fetch.
80
115
  const { data: fetchedEvents, isLoading, isError, error, refetch } = timelineSource.useEvents({
81
116
  namespaces,
82
117
  kinds: queryParams.kinds,
83
118
  timeRange: queryParams.timeRange,
84
119
  includeK8sEvents: true,
85
- includeManaged: listIncludesManaged(appScoped, queryParams.kinds),
120
+ includeManaged: true,
86
121
  includeDeleted: showDeleted,
87
- limit: fetchLimit,
122
+ limit: splitRows ? undefined : fetchLimit,
88
123
  fromMs: selectionWindow?.fromMs,
89
124
  toMs: selectionWindow?.toMs,
90
125
  })
126
+ const split = useMemo(
127
+ () => (splitRows && fetchedEvents ? splitRoutineActivity(fetchedEvents, fetchLimit) : undefined),
128
+ [splitRows, fetchedEvents, fetchLimit],
129
+ )
91
130
  const events = useMemo(() => {
92
- const unscoped = fetchedEvents ?? []
131
+ const unscoped = split ? (showRoutine ? split.withRoutine : split.withoutRoutine) : fetchedEvents ?? []
93
132
  return appScoped
94
133
  ? focusedAppIndex
95
134
  ? eventsForApplication(unscoped, topology, focusedAppIndex)
96
135
  : []
97
136
  : unscoped
98
- }, [appScoped, focusedAppIndex, topology, fetchedEvents])
99
- const sourceTruncated = (fetchedEvents?.length ?? 0) >= fetchLimit
137
+ }, [appScoped, focusedAppIndex, topology, split, showRoutine, fetchedEvents])
138
+ const sourceTruncated = split
139
+ ? split.truncated || (showRoutine && split.routineTruncated)
140
+ : (fetchedEvents?.length ?? 0) >= fetchLimit
100
141
 
101
142
  // Full-screen error only when nothing is loaded; a failing background poll
102
143
  // with data on screen keeps rendering (data before error).
@@ -135,6 +176,8 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
135
176
 
136
177
  return (
137
178
  <div className="flex h-full min-h-0 flex-1 flex-col">
179
+ {/* Under a scrubber TimelineView already shows the note above it. */}
180
+ {!selectionWindow && <TimelineRetentionBanner />}
138
181
  {staleNote}
139
182
  <div className="min-h-0 flex-1">
140
183
  <TimelineListUI
@@ -151,6 +194,10 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
151
194
  hideRangeSelector={!!selectionWindow}
152
195
  showDeleted={showDeleted}
153
196
  onShowDeletedChange={onShowDeletedChange}
197
+ showRoutine={showRoutine}
198
+ onShowRoutineChange={split ? onShowRoutineChange : undefined}
199
+ routineEvents={split?.routine}
200
+ foldPerResource={split && !showRoutine ? isChildProblem : undefined}
154
201
  search={search}
155
202
  onSearchChange={onSearchChange}
156
203
  activityFilter={activityFilter}
@@ -163,7 +210,9 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
163
210
  isTruncated={sourceTruncated}
164
211
  truncationMessage={appScoped && sourceTruncated
165
212
  ? `Showing application activity found in the newest ${fetchLimit.toLocaleString()} events in this range — narrow the query to see older activity`
166
- : undefined}
213
+ : split && sourceTruncated
214
+ ? 'Some older activity in this range is not listed. Narrow the time range to see it.'
215
+ : undefined}
167
216
  />
168
217
  </div>
169
218
  </div>
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import type { TimelineEventsResult, TimelineSource } from '../../api/timelineSource'
4
+
5
+ // The list reads its source from context; hand it a fixed empty result so the
6
+ // test is about the note row alone.
7
+ const eventsResult: TimelineEventsResult = { data: [], isLoading: false, isFetching: false, isError: false, refetch: () => {} }
8
+ const fakeSource: TimelineSource = {
9
+ capabilities: { mode: 'local', ringLimit: 10_000 },
10
+ useEvents: () => eventsResult,
11
+ }
12
+ vi.mock('../../context/TimelineSource', () => ({ useTimelineSource: () => fakeSource }))
13
+
14
+ import { TimelineRetentionNoteProvider } from '../../context/TimelineCustomization'
15
+ import { TimelineList } from './TimelineList'
16
+
17
+ describe('TimelineList history-limit row', () => {
18
+ const props = {
19
+ namespaces: [],
20
+ showDeleted: false,
21
+ onShowDeletedChange: () => {},
22
+ showRoutine: false,
23
+ onShowRoutineChange: () => {},
24
+ search: '',
25
+ onSearchChange: () => {},
26
+ activityFilter: [],
27
+ onActivityFilterChange: () => {},
28
+ kindFilter: [],
29
+ onKindFilterChange: () => {},
30
+ }
31
+ const withNote = (node: React.ReactNode) => (
32
+ <TimelineRetentionNoteProvider value={<a href="/plans">Host note</a>}>{node}</TimelineRetentionNoteProvider>
33
+ )
34
+
35
+ it('shows the host note in its own row', () => {
36
+ const html = renderToStaticMarkup(withNote(<TimelineList {...props} />))
37
+ expect(html).toContain('data-testid="timeline-retention-note"')
38
+ expect(html).toContain('<a href="/plans">Host note</a>')
39
+ })
40
+
41
+ it('renders no row without a host note (standalone Radar)', () => {
42
+ expect(renderToStaticMarkup(<TimelineList {...props} />)).not.toContain('timeline-retention-note')
43
+ })
44
+
45
+ it('leaves the note to the view when a scrubber owns the range', () => {
46
+ const html = renderToStaticMarkup(withNote(<TimelineList {...props} selectionWindow={{ fromMs: 0, toMs: 1 }} />))
47
+ expect(html).not.toContain('timeline-retention-note')
48
+ })
49
+ })
@@ -0,0 +1,20 @@
1
+ import { Clock } from 'lucide-react'
2
+ import { useTimelineRetentionNote } from '../../context/TimelineCustomization'
3
+
4
+ // The host's history-limit note as a full-width row above the timeline,
5
+ // beside the other history-completeness rows (truncation, stale live
6
+ // updates). Informational, not a warning: nothing is broken, the server
7
+ // serves this much. Renders nothing when the host has no note.
8
+ export function TimelineRetentionBanner() {
9
+ const note = useTimelineRetentionNote()
10
+ if (!note) return null
11
+ return (
12
+ <div
13
+ className="flex flex-wrap items-center gap-x-1.5 gap-y-1 border-b border-theme-border px-4 py-1.5 text-xs text-theme-text-tertiary"
14
+ data-testid="timeline-retention-note"
15
+ >
16
+ <Clock className="h-3.5 w-3.5 shrink-0" />
17
+ {note}
18
+ </div>
19
+ )
20
+ }
@@ -25,6 +25,7 @@ import type { ActivityFilterKey } from './TimelineList'
25
25
  import { TimelineSwimlanes } from './TimelineSwimlanes'
26
26
  import { RetainedTimelineScrubber, extendSelection, type ScrubberDomainInfo } from './RetainedTimelineScrubber'
27
27
  import { LocalTimelineScrubber } from './LocalTimelineScrubber'
28
+ import { TimelineRetentionBanner } from './TimelineRetentionNote'
28
29
  import { useTopology, useApplications } from '../../api/client'
29
30
  import { useTimelineSource } from '../../context/TimelineSource'
30
31
  import type { Topology } from '../../types'
@@ -125,6 +126,7 @@ export interface PersistedTimelineState {
125
126
  viewMode: TimelineViewMode
126
127
  mode: TimelineMode
127
128
  showDeleted: boolean
129
+ showRoutine: boolean
128
130
  pinnedOnly: boolean
129
131
  search: string
130
132
  activityFilter: ActivityFilterKey[]
@@ -237,6 +239,7 @@ export function writeTimelineParams(
237
239
  set('activity', s.activityFilter.length ? s.activityFilter.join(',') : null)
238
240
  set('kinds', s.kindFilter.length ? s.kindFilter.join(',') : null)
239
241
  set('deleted', s.showDeleted ? null : '0')
242
+ set('routine', s.showRoutine ? '1' : null)
240
243
  set('pinnedOnly', s.pinnedOnly ? '1' : null)
241
244
  set('q', s.search.length ? s.search : null)
242
245
  set('grouping', s.grouping !== DEFAULT_GROUPING ? s.grouping : null)
@@ -309,6 +312,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
309
312
  // and so the fetch can exclude deletes at the source rather than only hiding
310
313
  // them client-side.
311
314
  const [showDeleted, setShowDeleted] = useState(() => searchParams.get('deleted') !== '0')
315
+ const [showRoutine, setShowRoutine] = useState(() => searchParams.get('routine') === '1')
312
316
  // ?pinnedOnly=1 is inert without pins: honoring it with no stored pins would
313
317
  // arm a filter that hides everything. Gate the read on stored pins so the param
314
318
  // can never arm on its own — ordering-proof, independent of when the empty-pins
@@ -634,6 +638,8 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
634
638
  setMode((prev) => (timeModeEqual(prev, nextMode) ? prev : nextMode))
635
639
  const nextDeleted = sp.get('deleted') !== '0'
636
640
  setShowDeleted((prev) => (prev === nextDeleted ? prev : nextDeleted))
641
+ const nextRoutine = sp.get('routine') === '1'
642
+ setShowRoutine((prev) => (prev === nextRoutine ? prev : nextRoutine))
637
643
  // Same guard as the lazy init: the param can only arm the filter when pins
638
644
  // exist, so a mount that runs this after the empty-pins reset can't re-arm it.
639
645
  const nextPinnedOnly = sp.get('pinnedOnly') === '1' && pinnedLanes.length > 0
@@ -658,7 +664,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
658
664
  const current = searchParamsRef.current
659
665
  const target = writeTimelineParams(
660
666
  current,
661
- { viewMode, mode, showDeleted, pinnedOnly, search: debouncedSearch, activityFilter, kindFilter, grouping, sort, selectedEventId },
667
+ { viewMode, mode, showDeleted, showRoutine, pinnedOnly, search: debouncedSearch, activityFilter, kindFilter, grouping, sort, selectedEventId },
662
668
  { isRetained: isRetained || isLocal, requiresNamespaceFilter: scopeRequiresNamespaceFilter },
663
669
  )
664
670
  const targetStr = target.toString()
@@ -672,7 +678,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
672
678
  const replace = !didMountUrlSyncRef.current || onlyHighFreqDiffer(currentStr, targetStr)
673
679
  didMountUrlSyncRef.current = true
674
680
  setSearchParamsRef.current(target, { replace })
675
- }, [viewMode, mode, showDeleted, pinnedOnly, debouncedSearch, activityFilter, kindFilter, grouping, sort, selectedEventId, isRetained, isLocal, scopeRequiresNamespaceFilter])
681
+ }, [viewMode, mode, showDeleted, showRoutine, pinnedOnly, debouncedSearch, activityFilter, kindFilter, grouping, sort, selectedEventId, isRetained, isLocal, scopeRequiresNamespaceFilter])
676
682
 
677
683
  // Fetch all activity - zoom controls what's visible in the UI. This ring feeds
678
684
  // the swimlanes and the local strip's histogram, so it also runs in list mode
@@ -824,11 +830,16 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
824
830
  // The scrubber sits above whichever view is active, sharing one selection
825
831
  // across list + swimlane. Retained draws its server-overview strip; local
826
832
  // derives the strip client-side from the loaded ring and omits the gap band.
833
+ // The host's note about how far back history goes: without it the stretch
834
+ // before the limit reads as "nothing happened". Without a scrubber the list
835
+ // view shows it itself, above its own range selector.
836
+ const retentionBanner = showScrubber || viewMode === 'swimlane' ? <TimelineRetentionBanner /> : null
827
837
  const wrap = (node: ReactNode): ReactNode => {
828
838
  if (!showScrubber) {
829
839
  return (
830
840
  <div className="flex-1 flex flex-col min-h-0">
831
841
  {appScopeBar}
842
+ {retentionBanner}
832
843
  <div className="flex-1 flex flex-col min-h-0">{node}</div>
833
844
  </div>
834
845
  )
@@ -845,6 +856,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
845
856
  return (
846
857
  <div className="flex-1 flex flex-col min-h-0">
847
858
  {appScopeBar}
859
+ {retentionBanner}
848
860
  {/* A row-capped ring load means the OLDEST part of the retention
849
861
  window is not loaded — say so, or an old selection reads as
850
862
  "nothing happened back then". */}
@@ -1032,6 +1044,8 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
1032
1044
  initialTimeRange={initialTimeRange}
1033
1045
  showDeleted={showDeleted}
1034
1046
  onShowDeletedChange={setShowDeleted}
1047
+ showRoutine={showRoutine}
1048
+ onShowRoutineChange={setShowRoutine}
1035
1049
  search={search}
1036
1050
  onSearchChange={setSearch}
1037
1051
  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
 
@@ -0,0 +1,144 @@
1
+ import { useMemo } from 'react'
2
+ import { Virtuoso } from 'react-virtuoso'
3
+ import { clsx } from 'clsx'
4
+ import { ArrowRight, Crosshair, Minus } from 'lucide-react'
5
+ import type { AggregatedFlow } from '../../types'
6
+ import { SEVERITY_TEXT } from '@skyhook-io/k8s-ui/utils/badge-colors'
7
+ import { Tooltip } from '../ui/Tooltip'
8
+ import type { TrafficGraphSelection } from './TrafficGraph'
9
+ import {
10
+ connectionRows, displayVolume, edgesOnPort, flowDrops, flowErrors, formatRate, graphEndpointId, hasStatusData, isExternalKind,
11
+ type GraphFlow, type TrafficFocus,
12
+ } from './trafficFilters'
13
+
14
+ interface TrafficConnectionsTableProps {
15
+ flows: AggregatedFlow[]
16
+ isRateBased: boolean
17
+ selection: TrafficGraphSelection | null
18
+ onSelect: (selection: TrafficGraphSelection | null) => void
19
+ onFocus: (focus: TrafficFocus) => void
20
+ /** The focused endpoint, whose name is not offered as a focus again. */
21
+ focusedId?: string
22
+ }
23
+
24
+ const GRID = 'grid grid-cols-[minmax(0,1fr)_1rem_minmax(0,1fr)_6.5rem_5rem_4.5rem_4.5rem] items-center gap-x-3'
25
+ // Without HTTP status anywhere in view (plain TCP) a 5xx column would read as
26
+ // "no errors" rather than "not measured".
27
+ const GRID_NO_STATUS = 'grid grid-cols-[minmax(0,1fr)_1rem_minmax(0,1fr)_6.5rem_5rem_4.5rem] items-center gap-x-3'
28
+
29
+ function rowSelection(flow: AggregatedFlow): TrafficGraphSelection {
30
+ return {
31
+ type: 'edge',
32
+ sourceId: graphEndpointId(flow.source),
33
+ destId: graphEndpointId(flow.destination),
34
+ port: flow.port,
35
+ directionUnknown: !!flow.directionUnknown,
36
+ }
37
+ }
38
+
39
+ function selectedRows(rows: GraphFlow[], selection: TrafficGraphSelection | null): Set<GraphFlow> {
40
+ if (selection?.type !== 'edge' || selection.port === undefined) return new Set()
41
+ const between = rows.filter(flow =>
42
+ graphEndpointId(flow.source) === selection.sourceId && graphEndpointId(flow.destination) === selection.destId &&
43
+ !!flow.directionUnknown === !!selection.directionUnknown)
44
+ return new Set(edgesOnPort(between, f => f, selection.port))
45
+ }
46
+
47
+ /**
48
+ * The view's connections as rows, for when there are too many to draw. Where
49
+ * the problems are comes first. Selecting a row loads its records into the
50
+ * flow list, as selecting an edge does; a name narrows the view to it.
51
+ */
52
+ export function TrafficConnectionsTable({ flows, isRateBased, selection, onSelect, onFocus, focusedId }: TrafficConnectionsTableProps) {
53
+ const rows = useMemo(() => connectionRows(flows), [flows])
54
+ const selected = useMemo(() => selectedRows(rows, selection), [rows, selection])
55
+ const showStatus = useMemo(() => flows.some(hasStatusData), [flows])
56
+ const grid = showStatus ? GRID : GRID_NO_STATUS
57
+
58
+ const endpoint = (e: AggregatedFlow['source']) => {
59
+ const id = graphEndpointId(e)
60
+ const label = (
61
+ <span className="min-w-0">
62
+ <span className="block truncate text-theme-text-primary">{e.name}</span>
63
+ {e.namespace && <span className="block truncate text-[10px] text-theme-text-tertiary">{e.namespace}</span>}
64
+ </span>
65
+ )
66
+ if (id === focusedId || e.kind === 'Internet') return <span className="flex min-w-0 items-center">{label}</span>
67
+ return (
68
+ <Tooltip content={`Focus on ${e.name}`} wrapperClassName="min-w-0">
69
+ <button
70
+ type="button"
71
+ onClick={ev => {
72
+ ev.stopPropagation()
73
+ onFocus({ namespace: e.namespace || undefined, name: e.name })
74
+ }}
75
+ className="group flex min-w-0 max-w-full items-center gap-1 text-left hover:underline"
76
+ >
77
+ {label}
78
+ <Crosshair className="h-3 w-3 shrink-0 opacity-0 group-hover:opacity-70" />
79
+ </button>
80
+ </Tooltip>
81
+ )
82
+ }
83
+
84
+ return (
85
+ <div className="flex h-full min-h-0 flex-col">
86
+ <div className={clsx(grid, 'px-3 py-1.5 text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary border-b border-theme-border')}>
87
+ <span>Source</span>
88
+ <span />
89
+ <span>Destination</span>
90
+ <span>Port</span>
91
+ <span className="text-right">{isRateBased ? 'Req/s' : 'Conns'}</span>
92
+ {showStatus && <span className="text-right">5xx</span>}
93
+ <span className="text-right">Dropped</span>
94
+ </div>
95
+ <Virtuoso
96
+ className="flex-1"
97
+ data={rows}
98
+ computeItemKey={(_, flow) => `${graphEndpointId(flow.source)}->${graphEndpointId(flow.destination)}:${flow.port}${flow.directionUnknown ? ':u' : ''}`}
99
+ itemContent={(_, flow) => {
100
+ const sel = rowSelection(flow)
101
+ const isSelected = selected.has(flow)
102
+ const errors = flowErrors(flow)
103
+ const drops = flowDrops(flow)
104
+ return (
105
+ <div
106
+ role="row"
107
+ aria-selected={isSelected}
108
+ onClick={() => onSelect(isSelected ? null : sel)}
109
+ className={clsx(
110
+ grid,
111
+ 'cursor-pointer px-3 py-1.5 text-xs border-b border-theme-border/50 transition-colors',
112
+ isSelected ? 'bg-theme-elevated' : 'hover:bg-theme-hover',
113
+ )}
114
+ >
115
+ {endpoint(flow.source)}
116
+ {flow.directionUnknown
117
+ ? <Tooltip content="Direction unknown"><Minus className="h-3 w-3 text-theme-text-tertiary" /></Tooltip>
118
+ : <ArrowRight className="h-3 w-3 text-theme-text-tertiary" />}
119
+ {endpoint(flow.destination)}
120
+ <span className="truncate text-theme-text-secondary tabular-nums">
121
+ {flow.port > 0 ? flow.port : '—'}
122
+ {(flow.l7Protocol || flow.protocol) && (
123
+ <span className="ml-1 text-theme-text-tertiary">{flow.l7Protocol || flow.protocol.toUpperCase()}</span>
124
+ )}
125
+ {isExternalKind(flow.destination.kind) && <span className="ml-1 text-theme-text-tertiary">ext</span>}
126
+ </span>
127
+ <span className="text-right tabular-nums text-theme-text-secondary">
128
+ {isRateBased ? formatRate(displayVolume(flow, true)) : flow.connections.toLocaleString()}
129
+ </span>
130
+ {showStatus && (
131
+ <span className={clsx('text-right tabular-nums', errors > 0 ? clsx(SEVERITY_TEXT.error, 'font-medium') : 'text-theme-text-tertiary')}>
132
+ {errors > 0 ? (isRateBased ? formatRate(errors) : errors.toLocaleString()) : hasStatusData(flow) ? '0' : '—'}
133
+ </span>
134
+ )}
135
+ <span className={clsx('text-right tabular-nums', drops > 0 ? clsx(SEVERITY_TEXT.error, 'font-medium') : 'text-theme-text-tertiary')}>
136
+ {drops > 0 ? drops.toLocaleString() : '—'}
137
+ </span>
138
+ </div>
139
+ )
140
+ }}
141
+ />
142
+ </div>
143
+ )
144
+ }
@@ -10,6 +10,7 @@ import {
10
10
  Filter,
11
11
  Info,
12
12
  Puzzle,
13
+ Boxes,
13
14
  } from 'lucide-react'
14
15
  import { clsx } from 'clsx'
15
16
  import { SEVERITY_BADGE } from '@skyhook-io/k8s-ui/utils/badge-colors'
@@ -41,6 +42,8 @@ interface TrafficFilterSidebarProps {
41
42
  setShowNamespaceGroups: (v: boolean) => void
42
43
  collapseInternet: boolean
43
44
  setCollapseInternet: (v: boolean) => void
45
+ groupByWorkload: boolean
46
+ setGroupByWorkload: (v: boolean) => void
44
47
  addonMode: AddonMode
45
48
  setAddonMode: (v: AddonMode) => void
46
49
 
@@ -83,6 +86,9 @@ interface TrafficFilterSidebarProps {
83
86
  namespaces: Array<{ name: string; nodeCount: number }>
84
87
  hiddenNamespaces: Set<string>
85
88
  onToggleNamespace: (ns: string) => void
89
+ /** Set while the view is focused on an endpoint: the namespace choices are
90
+ * kept but not applied, so a neighbor in a hidden namespace still shows. */
91
+ namespacesPausedFor?: string
86
92
 
87
93
  }
88
94
 
@@ -150,6 +156,8 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
150
156
  setShowNamespaceGroups,
151
157
  collapseInternet,
152
158
  setCollapseInternet,
159
+ groupByWorkload,
160
+ setGroupByWorkload,
153
161
  addonMode,
154
162
  setAddonMode,
155
163
  aggregateExternal,
@@ -177,6 +185,7 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
177
185
  namespaces,
178
186
  hiddenNamespaces,
179
187
  onToggleNamespace,
188
+ namespacesPausedFor,
180
189
  }: TrafficFilterSidebarProps) {
181
190
  const [namespacesExpanded, setNamespacesExpanded] = useState(false)
182
191
 
@@ -298,6 +307,13 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
298
307
  onToggle={() => setCollapseInternet(!collapseInternet)}
299
308
  icon={Globe}
300
309
  />
310
+ <ToggleOption
311
+ label="Group by Workload"
312
+ description="Draw a workload's pods as one node. Off, each pod gets its own node"
313
+ enabled={groupByWorkload}
314
+ onToggle={() => setGroupByWorkload(!groupByWorkload)}
315
+ icon={Boxes}
316
+ />
301
317
  </div>
302
318
  </div>
303
319
 
@@ -494,7 +510,12 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
494
510
  </button>
495
511
  </div>
496
512
  </div>
497
- <div className="space-y-0.5">
513
+ {namespacesPausedFor && (
514
+ <p className="mb-1.5 text-[10px] text-theme-text-tertiary">
515
+ Paused while focused on {namespacesPausedFor}: its neighbors show whatever their namespace.
516
+ </p>
517
+ )}
518
+ <div className={clsx('space-y-0.5', namespacesPausedFor && 'opacity-50 pointer-events-none')} aria-disabled={!!namespacesPausedFor}>
498
519
  {visibleNamespaces.map(({ name, nodeCount }) => {
499
520
  const isHidden = hiddenNamespaces.has(name)
500
521
  return (
@@ -7,10 +7,16 @@ interface TrafficFlowListContextValue {
7
7
  responsesCallerOriented: boolean
8
8
  graphSelection: TrafficGraphSelection | null
9
9
  clearSelection: () => void
10
+ /** The records for the current selection are still being fetched. */
11
+ loading?: boolean
12
+ /** Says which records the list holds when it is not all of them. */
13
+ note?: string
10
14
  }
11
15
 
16
+ const emptyValue: TrafficFlowListContextValue = { flows: [], responsesCallerOriented: false, graphSelection: null, clearSelection: () => {} }
17
+
12
18
  // Module-level store — TrafficView writes, dock tab reads.
13
- let currentValue: TrafficFlowListContextValue = { flows: [], responsesCallerOriented: false, graphSelection: null, clearSelection: () => {} }
19
+ let currentValue: TrafficFlowListContextValue = emptyValue
14
20
  const listeners = new Set<() => void>()
15
21
 
16
22
  function setValue(val: TrafficFlowListContextValue) {
@@ -53,15 +59,17 @@ export function TrafficFlowListProvider({
53
59
  responsesCallerOriented,
54
60
  graphSelection,
55
61
  clearSelection,
62
+ loading,
63
+ note,
56
64
  children,
57
65
  }: TrafficFlowListContextValue & { children: ReactNode }) {
58
66
  useEffect(() => {
59
- setValue({ flows, responsesCallerOriented, graphSelection, clearSelection })
60
- }, [flows, responsesCallerOriented, graphSelection, clearSelection])
67
+ setValue({ flows, responsesCallerOriented, graphSelection, clearSelection, loading, note })
68
+ }, [flows, responsesCallerOriented, graphSelection, clearSelection, loading, note])
61
69
 
62
70
  useEffect(() => {
63
71
  return () => {
64
- setValue({ flows: [], responsesCallerOriented: false, graphSelection: null, clearSelection: () => {} })
72
+ setValue(emptyValue)
65
73
  setFlowSearch('')
66
74
  }
67
75
  }, [])