@skyhook-io/radar-app 1.8.7 → 1.8.8
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 +4 -4
- package/src/App.tsx +58 -50
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +1222 -234
- package/src/api/timelineSource.ts +4 -2
- package/src/components/applications/ApplicationsView.tsx +613 -219
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +103 -72
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +494 -229
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/diagnose/AISettings.tsx +7 -12
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +81 -14
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/HelmCompareRoute.tsx +1 -2
- package/src/components/helm/ManifestDiffViewer.tsx +1 -31
- package/src/components/helm/ValuesDiffPreview.tsx +2 -3
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +319 -123
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +66 -116
- package/src/components/settings/SettingsDialog.tsx +1268 -318
- package/src/components/timeline/TimelineList.tsx +35 -8
- package/src/components/timeline/TimelineView.tsx +156 -26
- package/src/components/timeline/TimelineView.urlparams.test.ts +43 -2
- package/src/components/workload/WorkloadView.tsx +711 -328
- package/src/index.css +5 -1
- package/src/main.tsx +1 -1
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import { useState, useCallback } from 'react'
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
import { useState, useCallback, useMemo } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
TimelineList as TimelineListUI,
|
|
4
|
+
eventsForApplication,
|
|
5
|
+
type ActivityTypeFilter,
|
|
6
|
+
type ActivityFilterKey,
|
|
7
|
+
type AppMembershipIndex,
|
|
8
|
+
type TimeRange,
|
|
9
|
+
type Topology,
|
|
10
|
+
} from '@skyhook-io/k8s-ui'
|
|
4
11
|
import { useTimelineSource } from '../../context/TimelineSource'
|
|
5
12
|
import { useHasLimitedAccess } from '../../contexts/CapabilitiesContext'
|
|
6
13
|
import type { NavigateToResource } from '../../utils/navigation'
|
|
@@ -13,6 +20,7 @@ export type { ActivityTypeFilter, ActivityFilterKey }
|
|
|
13
20
|
// this only caps pathological bursts. Surfaced to the list as `truncatedAt` so a
|
|
14
21
|
// window that does hit it shows an end-of-list note instead of dropping silently.
|
|
15
22
|
const LIST_FETCH_LIMIT = 2000
|
|
23
|
+
const APP_SCOPED_FETCH_LIMIT = 10000
|
|
16
24
|
|
|
17
25
|
interface TimelineListProps {
|
|
18
26
|
namespaces: string[]
|
|
@@ -44,9 +52,13 @@ interface TimelineListProps {
|
|
|
44
52
|
onVisibleWindowChange?: (window: { fromMs: number; toMs: number } | null) => void
|
|
45
53
|
// Carries the swimlane's view window into the list on view switch (scroll target).
|
|
46
54
|
scrollToMs?: number
|
|
55
|
+
focusedAppIndex?: AppMembershipIndex
|
|
56
|
+
appScoped?: boolean
|
|
57
|
+
topology?: Topology
|
|
58
|
+
appScopeLoading?: boolean
|
|
47
59
|
}
|
|
48
60
|
|
|
49
|
-
export function TimelineList({ namespaces, onViewChange, currentView, onResourceClick, initialFilter, initialTimeRange, showDeleted, onShowDeletedChange, search, onSearchChange, activityFilter, onActivityFilterChange, kindFilter, onKindFilterChange, selectionWindow, sliding, onVisibleWindowChange, scrollToMs }: TimelineListProps) {
|
|
61
|
+
export function TimelineList({ namespaces, onViewChange, currentView, onResourceClick, initialFilter, initialTimeRange, showDeleted, onShowDeletedChange, search, onSearchChange, activityFilter, onActivityFilterChange, kindFilter, onKindFilterChange, selectionWindow, sliding, onVisibleWindowChange, scrollToMs, focusedAppIndex, appScoped = false, topology, appScopeLoading = false }: TimelineListProps) {
|
|
50
62
|
const hasLimitedAccess = useHasLimitedAccess()
|
|
51
63
|
const timelineSource = useTimelineSource()
|
|
52
64
|
const [queryParams, setQueryParams] = useState<{ timeRange: TimeRange; kinds: string[] }>({
|
|
@@ -58,17 +70,28 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
|
|
|
58
70
|
setQueryParams(params)
|
|
59
71
|
}, [])
|
|
60
72
|
|
|
61
|
-
const
|
|
73
|
+
const fetchLimit = appScoped ? APP_SCOPED_FETCH_LIMIT : LIST_FETCH_LIMIT
|
|
74
|
+
const { data: unscopedEvents = [], isLoading, isError, refetch } = timelineSource.useEvents({
|
|
62
75
|
namespaces,
|
|
63
76
|
kinds: queryParams.kinds,
|
|
64
77
|
timeRange: queryParams.timeRange,
|
|
65
78
|
includeK8sEvents: true,
|
|
79
|
+
includeManaged: appScoped,
|
|
66
80
|
includeDeleted: showDeleted,
|
|
67
|
-
limit:
|
|
81
|
+
limit: fetchLimit,
|
|
68
82
|
fromMs: selectionWindow?.fromMs,
|
|
69
83
|
toMs: selectionWindow?.toMs,
|
|
70
84
|
sliding,
|
|
71
85
|
})
|
|
86
|
+
const events = useMemo(
|
|
87
|
+
() => appScoped
|
|
88
|
+
? focusedAppIndex
|
|
89
|
+
? eventsForApplication(unscopedEvents, topology, focusedAppIndex)
|
|
90
|
+
: []
|
|
91
|
+
: unscopedEvents,
|
|
92
|
+
[appScoped, focusedAppIndex, topology, unscopedEvents],
|
|
93
|
+
)
|
|
94
|
+
const sourceTruncated = unscopedEvents.length >= fetchLimit
|
|
72
95
|
|
|
73
96
|
if (isError) {
|
|
74
97
|
return (
|
|
@@ -89,7 +112,7 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
|
|
|
89
112
|
return (
|
|
90
113
|
<TimelineListUI
|
|
91
114
|
events={events}
|
|
92
|
-
isLoading={isLoading}
|
|
115
|
+
isLoading={isLoading || appScopeLoading}
|
|
93
116
|
onQueryChange={handleQueryChange}
|
|
94
117
|
hasLimitedAccess={hasLimitedAccess}
|
|
95
118
|
namespaces={namespaces}
|
|
@@ -109,7 +132,11 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
|
|
|
109
132
|
onKindFilterChange={onKindFilterChange}
|
|
110
133
|
onVisibleWindowChange={onVisibleWindowChange}
|
|
111
134
|
scrollToMs={scrollToMs}
|
|
112
|
-
truncatedAt={
|
|
135
|
+
truncatedAt={fetchLimit}
|
|
136
|
+
isTruncated={sourceTruncated}
|
|
137
|
+
truncationMessage={appScoped && sourceTruncated
|
|
138
|
+
? `Showing application activity found in the newest ${fetchLimit.toLocaleString()} events in this range — narrow the query to see older activity`
|
|
139
|
+
: undefined}
|
|
113
140
|
/>
|
|
114
141
|
)
|
|
115
142
|
}
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
|
2
2
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
3
3
|
import type { ReactNode } from 'react'
|
|
4
|
-
import { Network, AlertTriangle, RefreshCw } from 'lucide-react'
|
|
4
|
+
import { Network, AlertTriangle, RefreshCw, Boxes, X } from 'lucide-react'
|
|
5
5
|
import {
|
|
6
6
|
clampLensToSelection,
|
|
7
7
|
deriveLiveSelection,
|
|
8
8
|
isLensLatched,
|
|
9
9
|
advanceLatchedLens,
|
|
10
10
|
buildAppMembershipIndex,
|
|
11
|
+
eventsForApplication,
|
|
11
12
|
isPinnedLaneRef,
|
|
12
13
|
LIVE_TICK_MS,
|
|
14
|
+
SEVERITY_TEXT,
|
|
13
15
|
type ScrubberRange,
|
|
14
16
|
type TimelineLiveState,
|
|
15
17
|
type TimelineGrouping,
|
|
@@ -259,14 +261,36 @@ interface TimelineViewProps {
|
|
|
259
261
|
onNamespaceSelect?: (ns: string) => void
|
|
260
262
|
}
|
|
261
263
|
|
|
264
|
+
export function resolveApplicationTimelineScope(searchParams: URLSearchParams, namespaces: string[]) {
|
|
265
|
+
const appKey = searchParams.get('app')
|
|
266
|
+
const appNamespaces = Array.from(new Set(
|
|
267
|
+
(searchParams.get('scopeNamespaces') ?? '')
|
|
268
|
+
.split(',')
|
|
269
|
+
.map((namespace) => namespace.trim())
|
|
270
|
+
.filter(Boolean),
|
|
271
|
+
))
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
appKey,
|
|
275
|
+
namespaces: appKey ? appNamespaces : namespaces,
|
|
276
|
+
ready: !appKey || appNamespaces.length > 0,
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
262
280
|
export function TimelineView({ namespaces, onResourceClick, initialViewMode, initialFilter, initialTimeRange, requiresNamespaceFilter, availableNamespaces, onNamespaceSelect }: TimelineViewProps) {
|
|
263
281
|
// URL is the source of truth for every control below (deep-linkable +
|
|
264
282
|
// back/forward-restorable). Read on mount, written on user change.
|
|
265
283
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
284
|
+
const appScope = useMemo(() => resolveApplicationTimelineScope(searchParams, namespaces), [namespaces, searchParams])
|
|
285
|
+
const focusedAppKey = appScope.appKey
|
|
286
|
+
const appScopeNamespaces = focusedAppKey ? appScope.namespaces : []
|
|
287
|
+
const appScopeReady = appScope.ready
|
|
288
|
+
const timelineNamespaces = appScope.namespaces
|
|
289
|
+
const scopeRequiresNamespaceFilter = Boolean(requiresNamespaceFilter) && appScopeNamespaces.length === 0
|
|
266
290
|
|
|
267
291
|
// Force list view on large clusters without namespace filter; otherwise the
|
|
268
292
|
// URL `view` (or the home-page seed) decides.
|
|
269
|
-
const effectiveInitialMode =
|
|
293
|
+
const effectiveInitialMode = scopeRequiresNamespaceFilter ? 'list' : (parseView(searchParams) ?? initialViewMode ?? DEFAULT_VIEW)
|
|
270
294
|
const [viewMode, setViewMode] = useState<TimelineViewMode>(effectiveInitialMode)
|
|
271
295
|
// Shared across list + swimlane so the toggle carries across the view switch,
|
|
272
296
|
// and so the swimlane fetch can exclude deletes server-side (before LIMIT)
|
|
@@ -330,7 +354,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
330
354
|
}, [navigate])
|
|
331
355
|
|
|
332
356
|
// Only fetch heavy swimlane data when actually showing swimlanes
|
|
333
|
-
const showSwimlanes = viewMode === 'swimlane' && !
|
|
357
|
+
const showSwimlanes = viewMode === 'swimlane' && !scopeRequiresNamespaceFilter && appScopeReady
|
|
334
358
|
|
|
335
359
|
const timelineSource = useTimelineSource()
|
|
336
360
|
const isRetained = timelineSource.capabilities.mode === 'retained'
|
|
@@ -345,8 +369,8 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
345
369
|
// full-ring load the swimlane gate avoids — and the list then shows its own
|
|
346
370
|
// range dropdown instead (selectionWindow is only passed when a scrubber is
|
|
347
371
|
// on screen to own the range).
|
|
348
|
-
const showLocalScrubber = isLocal && !
|
|
349
|
-
const showScrubber = isRetained || showLocalScrubber
|
|
372
|
+
const showLocalScrubber = isLocal && !scopeRequiresNamespaceFilter && appScopeReady
|
|
373
|
+
const showScrubber = appScopeReady && (isRetained || showLocalScrubber)
|
|
350
374
|
|
|
351
375
|
// Both sources drive a scrubber now: retained fetches a server overview, local
|
|
352
376
|
// derives one client-side from the loaded ring. The time-selection machinery
|
|
@@ -572,7 +596,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
572
596
|
|
|
573
597
|
useEffect(() => {
|
|
574
598
|
const sp = searchParams
|
|
575
|
-
const nextView =
|
|
599
|
+
const nextView = scopeRequiresNamespaceFilter ? 'list' : (parseView(sp) ?? DEFAULT_VIEW)
|
|
576
600
|
setViewMode((prev) => (prev === nextView ? prev : nextView))
|
|
577
601
|
const nextMode = parseTimeMode(sp, isRetained || isLocal, retainedMaxRangeDays)
|
|
578
602
|
setMode((prev) => (timeModeEqual(prev, nextMode) ? prev : nextMode))
|
|
@@ -594,14 +618,14 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
594
618
|
setSort((prev) => (prev === nextSort ? prev : nextSort))
|
|
595
619
|
const nextEvent = sp.get('event')
|
|
596
620
|
setSelectedEventId((prev) => (prev === nextEvent ? prev : nextEvent))
|
|
597
|
-
}, [searchParams, isRetained, isLocal,
|
|
621
|
+
}, [searchParams, isRetained, isLocal, scopeRequiresNamespaceFilter, pinnedLanes, retainedMaxRangeDays])
|
|
598
622
|
|
|
599
623
|
useEffect(() => {
|
|
600
624
|
const current = searchParamsRef.current
|
|
601
625
|
const target = writeTimelineParams(
|
|
602
626
|
current,
|
|
603
627
|
{ viewMode, mode, showDeleted, pinnedOnly, search, activityFilter, kindFilter, grouping, sort, selectedEventId },
|
|
604
|
-
{ isRetained: isRetained || isLocal, requiresNamespaceFilter },
|
|
628
|
+
{ isRetained: isRetained || isLocal, requiresNamespaceFilter: scopeRequiresNamespaceFilter },
|
|
605
629
|
)
|
|
606
630
|
const targetStr = target.toString()
|
|
607
631
|
const currentStr = current.toString()
|
|
@@ -614,13 +638,13 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
614
638
|
const replace = !didMountUrlSyncRef.current || onlyHighFreqDiffer(currentStr, targetStr)
|
|
615
639
|
didMountUrlSyncRef.current = true
|
|
616
640
|
setSearchParamsRef.current(target, { replace })
|
|
617
|
-
}, [viewMode, mode, showDeleted, pinnedOnly, search, activityFilter, kindFilter, grouping, sort, selectedEventId, isRetained, isLocal,
|
|
641
|
+
}, [viewMode, mode, showDeleted, pinnedOnly, search, activityFilter, kindFilter, grouping, sort, selectedEventId, isRetained, isLocal, scopeRequiresNamespaceFilter])
|
|
618
642
|
|
|
619
643
|
// Fetch all activity - zoom controls what's visible in the UI. The heavy 10k
|
|
620
644
|
// ring feeds the swimlanes and the local strip's histogram, so it also runs in
|
|
621
645
|
// list mode when that strip is shown; the list itself fetches its own 2000.
|
|
622
646
|
const { data: activity, isLoading, isError, refetch } = timelineSource.useEvents({
|
|
623
|
-
namespaces,
|
|
647
|
+
namespaces: timelineNamespaces,
|
|
624
648
|
timeRange: 'all',
|
|
625
649
|
includeK8sEvents: true,
|
|
626
650
|
includeManaged: true,
|
|
@@ -628,25 +652,30 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
628
652
|
limit: 10000,
|
|
629
653
|
// The local strip derives its histogram from this ring fetch, so it must
|
|
630
654
|
// run in list mode too whenever the strip is shown.
|
|
631
|
-
enabled: showSwimlanes || showLocalScrubber,
|
|
655
|
+
enabled: appScopeReady && (showSwimlanes || showLocalScrubber),
|
|
632
656
|
fromMs: isRetained ? selection.fromMs : undefined,
|
|
633
657
|
toMs: isRetained ? selection.toMs : undefined,
|
|
634
658
|
sliding: isRetained && mode.kind === 'live',
|
|
635
659
|
})
|
|
636
660
|
|
|
637
|
-
//
|
|
638
|
-
const { data: rawTopology } = useTopology(
|
|
661
|
+
// Topology powers both swimlane hierarchy and application-scoped attribution.
|
|
662
|
+
const { data: rawTopology } = useTopology(timelineNamespaces, 'resources', {
|
|
663
|
+
enabled: appScopeReady && (showSwimlanes || Boolean(focusedAppKey)),
|
|
664
|
+
})
|
|
639
665
|
|
|
640
666
|
// Server application grouping — the single grouping authority. Joined to the
|
|
641
667
|
// timeline lanes client-side via the membership index. A failed/absent fetch
|
|
642
668
|
// leaves the index undefined; the swimlane degrades to its legacy label
|
|
643
669
|
// grouping (no crash, events still render).
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
670
|
+
// App-grouped swimlanes and application-scoped handoffs consume this index.
|
|
671
|
+
// Other Timeline states avoid the background applications poll.
|
|
672
|
+
const {
|
|
673
|
+
data: appsData,
|
|
674
|
+
dataUpdatedAt: appsUpdatedAt,
|
|
675
|
+
isLoading: appsLoading,
|
|
676
|
+
isError: appsError,
|
|
677
|
+
} = useApplications(timelineNamespaces, {
|
|
678
|
+
enabled: appScopeReady && ((showSwimlanes && grouping === 'app') || Boolean(focusedAppKey)),
|
|
650
679
|
})
|
|
651
680
|
const appIndex = useMemo(
|
|
652
681
|
() => (appsData?.applications ? buildAppMembershipIndex(appsData.applications) : undefined),
|
|
@@ -669,14 +698,98 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
669
698
|
return rawTopology
|
|
670
699
|
}, [rawTopology])
|
|
671
700
|
|
|
672
|
-
|
|
673
|
-
|
|
701
|
+
const focusedApp = useMemo(
|
|
702
|
+
() => appsData?.applications.find((candidate) => candidate.key === focusedAppKey),
|
|
703
|
+
[appsData?.applications, focusedAppKey],
|
|
704
|
+
)
|
|
705
|
+
const focusedAppIndex = useMemo(
|
|
706
|
+
() => (focusedApp ? buildAppMembershipIndex([focusedApp]) : undefined),
|
|
707
|
+
[focusedApp],
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
// Application History hands off to Timeline with an explicit app scope. Keep
|
|
711
|
+
// that scope local to Timeline rather than changing the global namespace
|
|
712
|
+
// preference, and fail closed while the application cannot be resolved.
|
|
713
|
+
const unscopedEvents = activity ?? EMPTY_EVENTS
|
|
714
|
+
const events = useMemo(
|
|
715
|
+
() => focusedAppKey
|
|
716
|
+
? focusedAppIndex
|
|
717
|
+
? eventsForApplication(unscopedEvents, stableTopology, focusedAppIndex)
|
|
718
|
+
: EMPTY_EVENTS
|
|
719
|
+
: unscopedEvents,
|
|
720
|
+
[focusedAppIndex, focusedAppKey, stableTopology, unscopedEvents],
|
|
721
|
+
)
|
|
722
|
+
const focusedAppLoading = Boolean(focusedAppKey) && appsLoading
|
|
723
|
+
const focusedAppUnavailable = Boolean(focusedAppKey) && (!appScopeReady || (!appsLoading && (appsError || !focusedApp)))
|
|
724
|
+
const focusedAppTimelineLimited = Boolean(focusedAppKey) && unscopedEvents.length >= 10_000
|
|
725
|
+
const clearFocusedApp = useCallback(() => {
|
|
726
|
+
const next = new URLSearchParams(searchParamsRef.current)
|
|
727
|
+
next.delete('app')
|
|
728
|
+
next.delete('scopeNamespaces')
|
|
729
|
+
next.delete('grouping')
|
|
730
|
+
next.delete('window')
|
|
731
|
+
next.delete('from')
|
|
732
|
+
next.delete('to')
|
|
733
|
+
setSearchParamsRef.current(next)
|
|
734
|
+
}, [])
|
|
735
|
+
|
|
736
|
+
const appScopeBar = focusedAppKey ? (
|
|
737
|
+
<div className="flex items-start justify-between gap-3 border-b border-theme-border bg-theme-surface px-4 py-2">
|
|
738
|
+
<div className="min-w-0 space-y-1">
|
|
739
|
+
<div className="flex min-w-0 items-center gap-2 text-sm">
|
|
740
|
+
<Boxes className="h-4 w-4 shrink-0 text-accent" />
|
|
741
|
+
<span className="shrink-0 text-theme-text-tertiary">Application</span>
|
|
742
|
+
{focusedApp ? (
|
|
743
|
+
<button
|
|
744
|
+
type="button"
|
|
745
|
+
onClick={() => handleAppClick(focusedApp.key)}
|
|
746
|
+
className="truncate font-medium text-accent-text hover:underline"
|
|
747
|
+
>
|
|
748
|
+
{focusedApp.name}
|
|
749
|
+
</button>
|
|
750
|
+
) : (
|
|
751
|
+
<span className="truncate font-medium text-theme-text-secondary">
|
|
752
|
+
{focusedAppLoading ? 'Resolving scope...' : 'Scope unavailable'}
|
|
753
|
+
</span>
|
|
754
|
+
)}
|
|
755
|
+
{focusedAppUnavailable && (
|
|
756
|
+
<span className="truncate text-theme-text-tertiary">
|
|
757
|
+
{appScopeReady
|
|
758
|
+
? 'The application is not available in the current cluster view.'
|
|
759
|
+
: 'This link is missing the namespaces needed to resolve the application.'}
|
|
760
|
+
</span>
|
|
761
|
+
)}
|
|
762
|
+
</div>
|
|
763
|
+
{showSwimlanes && focusedAppTimelineLimited && (
|
|
764
|
+
<div className="flex items-center gap-1.5 pl-6 text-xs text-theme-text-tertiary">
|
|
765
|
+
<AlertTriangle className={`h-3.5 w-3.5 shrink-0 ${SEVERITY_TEXT.warning}`} />
|
|
766
|
+
Showing application activity found in the newest 10,000 events in this range. Narrow the range to see older activity.
|
|
767
|
+
</div>
|
|
768
|
+
)}
|
|
769
|
+
</div>
|
|
770
|
+
<button
|
|
771
|
+
type="button"
|
|
772
|
+
onClick={clearFocusedApp}
|
|
773
|
+
className="flex shrink-0 items-center gap-1.5 rounded-lg px-2 py-1 text-xs text-theme-text-secondary transition-colors hover:bg-theme-hover hover:text-theme-text-primary"
|
|
774
|
+
>
|
|
775
|
+
<X className="h-3.5 w-3.5" />
|
|
776
|
+
Clear scope
|
|
777
|
+
</button>
|
|
778
|
+
</div>
|
|
779
|
+
) : null
|
|
674
780
|
|
|
675
781
|
// The scrubber sits above whichever view is active, sharing one selection
|
|
676
782
|
// across list + swimlane. Retained draws its server-overview strip; local
|
|
677
783
|
// derives the strip client-side from the loaded ring and omits the gap band.
|
|
678
784
|
const wrap = (node: ReactNode): ReactNode => {
|
|
679
|
-
if (!showScrubber)
|
|
785
|
+
if (!showScrubber) {
|
|
786
|
+
return (
|
|
787
|
+
<div className="flex-1 flex flex-col min-h-0">
|
|
788
|
+
{appScopeBar}
|
|
789
|
+
<div className="flex-1 flex flex-col min-h-0">{node}</div>
|
|
790
|
+
</div>
|
|
791
|
+
)
|
|
792
|
+
}
|
|
680
793
|
// In list mode the lens mirrors the rows visible in the list's scrollport
|
|
681
794
|
// (scrolling moves it) — and dragging the band works the OTHER way too: it
|
|
682
795
|
// scrolls the list to that time (two-way, like the swimlane). In swimlane
|
|
@@ -688,6 +801,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
688
801
|
: setLens
|
|
689
802
|
return (
|
|
690
803
|
<div className="flex-1 flex flex-col min-h-0">
|
|
804
|
+
{appScopeBar}
|
|
691
805
|
{isRetained ? (
|
|
692
806
|
<RetainedTimelineScrubber
|
|
693
807
|
source={timelineSource}
|
|
@@ -725,9 +839,21 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
725
839
|
)
|
|
726
840
|
}
|
|
727
841
|
|
|
842
|
+
if (!appScopeReady) {
|
|
843
|
+
return wrap(
|
|
844
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
|
|
845
|
+
<AlertTriangle className="h-8 w-8 text-theme-text-tertiary" />
|
|
846
|
+
<h2 className="text-base font-semibold text-theme-text-primary">Application scope is incomplete</h2>
|
|
847
|
+
<p className="max-w-lg text-sm text-theme-text-secondary">
|
|
848
|
+
Reopen Timeline from the application's History tab so its runtime and deployment-source namespaces are included.
|
|
849
|
+
</p>
|
|
850
|
+
</div>,
|
|
851
|
+
)
|
|
852
|
+
}
|
|
853
|
+
|
|
728
854
|
if (viewMode === 'swimlane') {
|
|
729
855
|
// Large cluster without namespace: show picker instead of swimlanes
|
|
730
|
-
if (
|
|
856
|
+
if (scopeRequiresNamespaceFilter) {
|
|
731
857
|
return wrap(
|
|
732
858
|
<div className="flex-1 flex flex-col">
|
|
733
859
|
{/* Toolbar with view toggle so user can switch back to list */}
|
|
@@ -788,12 +914,12 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
788
914
|
return wrap(
|
|
789
915
|
<TimelineSwimlanes
|
|
790
916
|
events={events}
|
|
791
|
-
isLoading={isLoading}
|
|
917
|
+
isLoading={isLoading || focusedAppLoading}
|
|
792
918
|
onResourceClick={onResourceClick}
|
|
793
919
|
viewMode={viewMode}
|
|
794
920
|
onViewModeChange={setViewMode}
|
|
795
921
|
topology={stableTopology}
|
|
796
|
-
namespaces={
|
|
922
|
+
namespaces={timelineNamespaces}
|
|
797
923
|
showDeleted={showDeleted}
|
|
798
924
|
onShowDeletedChange={setShowDeleted}
|
|
799
925
|
pinnedOnly={pinnedOnly}
|
|
@@ -827,7 +953,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
827
953
|
|
|
828
954
|
return wrap(
|
|
829
955
|
<TimelineList
|
|
830
|
-
namespaces={
|
|
956
|
+
namespaces={timelineNamespaces}
|
|
831
957
|
currentView={viewMode}
|
|
832
958
|
onViewChange={setViewMode}
|
|
833
959
|
onResourceClick={onResourceClick}
|
|
@@ -850,6 +976,10 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
850
976
|
// Seeded with the swimlane's window at the switch (see the viewMode
|
|
851
977
|
// effect); afterwards, dragging the strip band retargets the scroll.
|
|
852
978
|
scrollToMs={listScrollToMs}
|
|
979
|
+
focusedAppIndex={focusedAppIndex}
|
|
980
|
+
appScoped={Boolean(focusedAppKey)}
|
|
981
|
+
topology={stableTopology}
|
|
982
|
+
appScopeLoading={focusedAppLoading}
|
|
853
983
|
/>
|
|
854
984
|
)
|
|
855
985
|
}
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|
|
2
2
|
import type { ActivityFilterKey, TimelineGrouping, TimelineSort } from '@skyhook-io/k8s-ui'
|
|
3
3
|
import {
|
|
4
4
|
parseTimeMode,
|
|
5
|
+
resolveApplicationTimelineScope,
|
|
5
6
|
writeTimelineParams,
|
|
6
7
|
onlyHighFreqDiffer,
|
|
7
8
|
timeModeEqual,
|
|
@@ -172,10 +173,17 @@ describe('writeTimelineParams', () => {
|
|
|
172
173
|
expect(written.has('window')).toBe(false)
|
|
173
174
|
})
|
|
174
175
|
|
|
175
|
-
it('preserves foreign params and strips the legacy filter seed', () => {
|
|
176
|
-
const base = new URLSearchParams({
|
|
176
|
+
it('preserves foreign params, including application scope, and strips the legacy filter seed', () => {
|
|
177
|
+
const base = new URLSearchParams({
|
|
178
|
+
tab: 'topology',
|
|
179
|
+
app: 'staging/Deployment/api',
|
|
180
|
+
scopeNamespaces: 'argocd,staging',
|
|
181
|
+
filter: 'warnings',
|
|
182
|
+
})
|
|
177
183
|
const written = writeTimelineParams(base, defaultState, retainedOpts)
|
|
178
184
|
expect(written.get('tab')).toBe('topology')
|
|
185
|
+
expect(written.get('app')).toBe('staging/Deployment/api')
|
|
186
|
+
expect(written.get('scopeNamespaces')).toBe('argocd,staging')
|
|
179
187
|
expect(written.has('filter')).toBe(false)
|
|
180
188
|
})
|
|
181
189
|
|
|
@@ -188,6 +196,39 @@ describe('writeTimelineParams', () => {
|
|
|
188
196
|
})
|
|
189
197
|
})
|
|
190
198
|
|
|
199
|
+
describe('resolveApplicationTimelineScope', () => {
|
|
200
|
+
it('uses the current namespace selection outside application scope', () => {
|
|
201
|
+
expect(resolveApplicationTimelineScope(new URLSearchParams(), ['default', 'staging'])).toEqual({
|
|
202
|
+
appKey: null,
|
|
203
|
+
namespaces: ['default', 'staging'],
|
|
204
|
+
ready: true,
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('uses the application scope namespaces and removes duplicates', () => {
|
|
209
|
+
const params = sp({
|
|
210
|
+
app: '/Application/radar-hub-staging',
|
|
211
|
+
scopeNamespaces: 'argocd, staging,argocd',
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
expect(resolveApplicationTimelineScope(params, ['default'])).toEqual({
|
|
215
|
+
appKey: '/Application/radar-hub-staging',
|
|
216
|
+
namespaces: ['argocd', 'staging'],
|
|
217
|
+
ready: true,
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('fails closed when an application link omits its namespace scope', () => {
|
|
222
|
+
const params = sp({ app: '/Application/radar-hub-staging' })
|
|
223
|
+
|
|
224
|
+
expect(resolveApplicationTimelineScope(params, ['default'])).toEqual({
|
|
225
|
+
appKey: '/Application/radar-hub-staging',
|
|
226
|
+
namespaces: [],
|
|
227
|
+
ready: false,
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
191
232
|
describe('parse(write(state)) round-trip', () => {
|
|
192
233
|
const opts = { isRetained: true, requiresNamespaceFilter: false, maxRangeDays: undefined, hasPins: true }
|
|
193
234
|
const roundTrip = (state: PersistedTimelineState) => {
|