@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.
- package/package.json +5 -5
- package/src/App.tsx +60 -12
- package/src/RadarApp.tsx +27 -17
- package/src/api/client.goneResource.test.ts +49 -0
- package/src/api/client.ts +168 -55
- package/src/api/cnpg.ts +95 -0
- package/src/api/diagnose.test.ts +40 -0
- package/src/api/diagnose.ts +12 -1
- package/src/api/radarFeatures.render.test.tsx +19 -1
- package/src/api/radarFeatures.ts +3 -0
- package/src/api/timelineSource.ts +1 -1
- package/src/api/traffic.sources.test.ts +55 -0
- package/src/api/traffic.ts +173 -14
- package/src/components/NoClusterAccessBanner.tsx +68 -0
- package/src/components/applications/ApplicationsView.tsx +3 -0
- package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
- package/src/components/capacity/CapacityView.test.tsx +31 -1
- package/src/components/capacity/shared.tsx +12 -21
- package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
- package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
- package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
- package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
- package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
- package/src/components/cnpg/CNPGOperator.tsx +234 -0
- package/src/components/cnpg/CNPGOverview.tsx +307 -0
- package/src/components/cnpg/CNPGPooling.tsx +116 -0
- package/src/components/cnpg/CNPGProtection.tsx +320 -0
- package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
- package/src/components/cnpg/CNPGView.tsx +156 -0
- package/src/components/cnpg/paths.ts +13 -0
- package/src/components/cnpg/routes.test.ts +60 -0
- package/src/components/cnpg/routes.ts +112 -0
- package/src/components/cnpg/shared.tsx +290 -0
- package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
- package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
- package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
- package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
- package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
- package/src/components/diagnose/ApplyDialog.tsx +26 -28
- package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
- package/src/components/diagnose/DiagnoseContext.tsx +136 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
- package/src/components/diagnose/InvestigationView.tsx +39 -2
- package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
- package/src/components/diagnose/agentCatalog.ts +2 -2
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
- package/src/components/dock/TrafficFlowListTab.tsx +27 -6
- package/src/components/execution/BatchExecutionView.tsx +11 -11
- package/src/components/helm/ChartBrowser.tsx +9 -10
- package/src/components/helm/HelmCompareRoute.tsx +108 -115
- package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
- package/src/components/helm/InstallWizard.tsx +5 -2
- package/src/components/helm/RevisionHistory.tsx +3 -2
- package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
- package/src/components/helm/ValuesViewer.tsx +1 -1
- package/src/components/home/ClusterHealthCard.tsx +7 -0
- package/src/components/home/mcpToolCatalog.ts +1 -1
- package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
- package/src/components/resources/ResourcesView.tsx +6 -38
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
- package/src/components/settings/SettingsDialog.tsx +122 -19
- package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
- package/src/components/timeline/TimelineList.test.ts +47 -29
- package/src/components/timeline/TimelineList.tsx +60 -11
- package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
- package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
- package/src/components/timeline/TimelineView.tsx +16 -2
- package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
- package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
- package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
- package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
- package/src/components/traffic/TrafficGraph.tsx +109 -26
- package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
- package/src/components/traffic/TrafficView.tsx +582 -149
- package/src/components/traffic/trafficFilters.test.ts +398 -1
- package/src/components/traffic/trafficFilters.ts +408 -0
- package/src/components/whats-new/WhatsNew.test.ts +107 -28
- package/src/components/whats-new/WhatsNew.tsx +146 -86
- package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
- package/src/components/whats-new/releaseNotes.ts +155 -24
- package/src/components/workload/WorkloadView.test.ts +2 -1
- package/src/components/workload/WorkloadView.tsx +165 -294
- package/src/context/TimelineCustomization.tsx +15 -0
- package/src/contexts/CapabilitiesContext.tsx +9 -1
- package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
- package/src/hooks/useGitOpsWriteGuard.ts +193 -0
- package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
- package/src/hooks/useResourceCounts.ts +49 -0
- 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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
:
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
72
|
+
setValue(emptyValue)
|
|
65
73
|
setFlowSearch('')
|
|
66
74
|
}
|
|
67
75
|
}, [])
|