@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.
- package/package.json +5 -5
- package/src/App.tsx +51 -9
- package/src/RadarApp.tsx +10 -2
- package/src/api/client.ts +61 -49
- package/src/api/client.updateResource.test.tsx +58 -0
- package/src/api/cnpg.ts +95 -0
- package/src/api/diagnose.ts +26 -0
- package/src/api/httpErrors.ts +12 -0
- package/src/api/timelineSource.ts +1 -1
- package/src/components/ConnectionErrorView.test.tsx +2 -0
- package/src/components/ConnectionErrorView.tsx +2 -1
- package/src/components/NoClusterAccessBanner.tsx +68 -0
- 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/AgentControls.tsx +1 -1
- package/src/components/diagnose/AssessmentCard.tsx +11 -1
- package/src/components/diagnose/DiagnoseContext.tsx +35 -22
- package/src/components/diagnose/DiagnoseSurface.tsx +12 -8
- package/src/components/diagnose/Home.tsx +1 -1
- package/src/components/diagnose/InvestigationEvidencePane.tsx +1 -1
- package/src/components/diagnose/InvestigationRefusalAction.test.tsx +156 -0
- package/src/components/diagnose/InvestigationRefusalAction.tsx +14 -0
- package/src/components/diagnose/InvestigationView.tsx +199 -65
- package/src/components/diagnose/investigationSourceFocus.ts +6 -6
- package/src/components/diagnose/investigationState.test.ts +83 -1
- package/src/components/diagnose/investigationState.ts +43 -0
- package/src/components/diagnose/launch.ts +2 -2
- package/src/components/diagnose/useDisclosureReveal.ts +1 -1
- package/src/components/dock/BottomDock.tsx +1 -1
- package/src/components/dock/DockContext.tsx +27 -2
- package/src/components/dock/LocalTerminalContext.test.tsx +210 -0
- package/src/components/dock/LocalTerminalTab.test.tsx +122 -0
- package/src/components/dock/LocalTerminalTab.tsx +81 -6
- package/src/components/helm/ChartBrowser.tsx +6 -7
- 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/TrackChartSourceDialog.tsx +10 -2
- package/src/components/helm/ValuesViewer.tsx +1 -1
- package/src/components/logs/LogsViewer.tsx +3 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +3 -0
- package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
- package/src/components/resources/ResourcesView.tsx +6 -38
- package/src/components/settings/LocalConfigurationDetails.tsx +21 -15
- package/src/components/settings/LocalConnectionSettings.tsx +41 -10
- package/src/components/timeline/TimelineList.test.ts +47 -29
- package/src/components/timeline/TimelineList.tsx +57 -11
- package/src/components/timeline/TimelineView.tsx +9 -2
- package/src/components/timeline/TimelineView.urlparams.test.ts +6 -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 +156 -25
- package/src/components/workload/WorkloadView.tsx +30 -4
- package/src/context/DiagnoseCustomization.tsx +13 -3
- package/src/contexts/CapabilitiesContext.tsx +9 -1
- package/src/hooks/useResourceCounts.ts +49 -0
- package/src/index.ts +4 -2
- 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
|
|
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
|
|
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]}
|
|
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
|
|
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
|
|
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
|
|
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]}
|
|
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
|
|
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
|
|
371
|
+
title="Remove saved settings?"
|
|
366
372
|
message={
|
|
367
373
|
selected
|
|
368
|
-
? `Remove the saved ${integrationNames[selected.integration]}
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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('
|
|
26
|
-
it('
|
|
27
|
-
|
|
28
|
-
expect(
|
|
29
|
-
expect(
|
|
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('
|
|
33
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
46
|
-
expect(
|
|
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('
|
|
50
|
-
|
|
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('
|
|
54
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
:
|
|
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
|
|