@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.
Files changed (48) hide show
  1. package/package.json +4 -4
  2. package/src/App.tsx +58 -50
  3. package/src/api/client.argoResourceSync.test.ts +69 -0
  4. package/src/api/client.rightsizing.test.ts +32 -0
  5. package/src/api/client.ts +1222 -234
  6. package/src/api/timelineSource.ts +4 -2
  7. package/src/components/applications/ApplicationsView.tsx +613 -219
  8. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  9. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  10. package/src/components/cost/CostTrendChart.tsx +103 -72
  11. package/src/components/cost/CostView.test.ts +12 -0
  12. package/src/components/cost/CostView.tsx +494 -229
  13. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  14. package/src/components/cost/CostViewTabs.tsx +40 -0
  15. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  16. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  17. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  18. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  19. package/src/components/cost/cloud-console.test.ts +39 -0
  20. package/src/components/cost/cloud-console.ts +81 -0
  21. package/src/components/cost/errors.ts +8 -0
  22. package/src/components/cost/format.test.ts +27 -0
  23. package/src/components/cost/format.ts +46 -0
  24. package/src/components/cost/kinds.ts +5 -0
  25. package/src/components/diagnose/AISettings.tsx +7 -12
  26. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  27. package/src/components/gitops/GitOpsView.tsx +81 -14
  28. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  29. package/src/components/helm/HelmCompareRoute.tsx +1 -2
  30. package/src/components/helm/ManifestDiffViewer.tsx +1 -31
  31. package/src/components/helm/ValuesDiffPreview.tsx +2 -3
  32. package/src/components/home/CostCard.tsx +21 -36
  33. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  34. package/src/components/resource/RightsizingStrip.tsx +319 -123
  35. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  36. package/src/components/rightsizing/copy.test.ts +56 -0
  37. package/src/components/rightsizing/model.test.ts +227 -0
  38. package/src/components/rightsizing/model.ts +158 -0
  39. package/src/components/rightsizing/presentation.test.ts +104 -0
  40. package/src/components/rightsizing/presentation.ts +94 -0
  41. package/src/components/settings/MyPermissionsDialog.tsx +66 -116
  42. package/src/components/settings/SettingsDialog.tsx +1268 -318
  43. package/src/components/timeline/TimelineList.tsx +35 -8
  44. package/src/components/timeline/TimelineView.tsx +156 -26
  45. package/src/components/timeline/TimelineView.urlparams.test.ts +43 -2
  46. package/src/components/workload/WorkloadView.tsx +711 -328
  47. package/src/index.css +5 -1
  48. package/src/main.tsx +1 -1
@@ -1,6 +1,13 @@
1
- import { useState, useCallback } from 'react'
2
- import { TimelineList as TimelineListUI, type ActivityTypeFilter, type ActivityFilterKey } from '@skyhook-io/k8s-ui'
3
- import type { TimeRange } from '@skyhook-io/k8s-ui'
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 { data: events = [], isLoading, isError, refetch } = timelineSource.useEvents({
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: LIST_FETCH_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={LIST_FETCH_LIMIT}
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 = requiresNamespaceFilter ? 'list' : (parseView(searchParams) ?? initialViewMode ?? DEFAULT_VIEW)
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' && !requiresNamespaceFilter
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 && !requiresNamespaceFilter
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 = requiresNamespaceFilter ? 'list' : (parseView(sp) ?? DEFAULT_VIEW)
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, requiresNamespaceFilter, pinnedLanes, retainedMaxRangeDays])
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, requiresNamespaceFilter])
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
- // Fetch topology for service stack grouping skip on large clusters (empty anyway)
638
- const { data: rawTopology } = useTopology(namespaces, 'resources', { enabled: showSwimlanes })
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
- // Only the app-grouping swimlane path consumes the membership index; gate the
645
- // fetch (and its background poll) on that so list view / non-app groupings
646
- // don't drive an unused /applications poll. Disabled → appsData undefined →
647
- // appIndex undefined → the swimlane's legacy owner-label fallback.
648
- const { data: appsData, dataUpdatedAt: appsUpdatedAt } = useApplications(namespaces, {
649
- enabled: showSwimlanes && grouping === 'app',
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
- // Use stable reference for events to prevent unnecessary re-renders
673
- const events = activity ?? EMPTY_EVENTS
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) return node
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&apos;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 (requiresNamespaceFilter) {
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={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={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({ tab: 'topology', filter: 'warnings' })
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) => {