@skyhook-io/radar-app 1.15.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +2 -0
  2. package/package.json +6 -5
  3. package/src/App.tsx +14 -11
  4. package/src/RadarApp.tsx +51 -21
  5. package/src/api/apiResources.test.ts +19 -1
  6. package/src/api/apiResources.ts +18 -3
  7. package/src/api/client.capacity.test.ts +15 -0
  8. package/src/api/client.trace.test.ts +9 -1
  9. package/src/api/client.ts +232 -201
  10. package/src/api/diagnose.test.ts +102 -1
  11. package/src/api/diagnose.ts +42 -11
  12. package/src/api/drain-plan.test.ts +0 -10
  13. package/src/api/httpErrors.test.ts +95 -0
  14. package/src/api/httpErrors.ts +101 -0
  15. package/src/api/policy.ts +33 -5
  16. package/src/api/radarFeatures.render.test.tsx +190 -0
  17. package/src/api/radarFeatures.test.ts +133 -0
  18. package/src/api/radarFeatures.ts +147 -0
  19. package/src/api/timelineSource.test.ts +2 -10
  20. package/src/api/timelineSource.ts +3 -2
  21. package/src/api/traffic.ts +4 -3
  22. package/src/components/CloudFunnelButton.tsx +9 -9
  23. package/src/components/NamespaceSwitcher.test.tsx +47 -0
  24. package/src/components/NamespaceSwitcher.tsx +17 -1
  25. package/src/components/SelfManagedStart.tsx +5 -6
  26. package/src/components/applications/ApplicationsView.tsx +20 -6
  27. package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
  28. package/src/components/audit/UpgradeReadinessView.tsx +9 -13
  29. package/src/components/capacity/CapacityView.test.tsx +13 -4
  30. package/src/components/capacity/shared.tsx +14 -13
  31. package/src/components/cost/ApplicationCostTab.tsx +9 -1
  32. package/src/components/cost/CostConnectionAction.tsx +30 -0
  33. package/src/components/cost/CostTrendChart.tsx +2 -0
  34. package/src/components/cost/CostView.tsx +4 -24
  35. package/src/components/cost/WorkloadCostTab.tsx +7 -1
  36. package/src/components/cost/source.test.ts +30 -3
  37. package/src/components/cost/source.ts +37 -2
  38. package/src/components/curl/ServiceCurlButton.tsx +7 -3
  39. package/src/components/dock/NodeTerminalTab.tsx +2 -1
  40. package/src/components/dock/TerminalTab.tsx +2 -1
  41. package/src/components/dock/TrafficFlowListTab.tsx +2 -2
  42. package/src/components/gitops/GitOpsView.tsx +12 -0
  43. package/src/components/home/HomeView.tsx +1 -1
  44. package/src/components/home/MCPSetupDialog.tsx +37 -37
  45. package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
  46. package/src/components/resource/PVCUsageBar.tsx +4 -1
  47. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
  48. package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
  49. package/src/components/resources/ImageFilesystemModal.tsx +2 -1
  50. package/src/components/resources/PodFilePreview.tsx +4 -1
  51. package/src/components/resources/PodFilesystemModal.tsx +3 -2
  52. package/src/components/resources/ResourcesView.tsx +2 -1
  53. package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
  54. package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
  55. package/src/components/settings/FormSaveActions.tsx +76 -0
  56. package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
  57. package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
  58. package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
  59. package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
  60. package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
  61. package/src/components/settings/PrometheusConfigField.tsx +30 -0
  62. package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
  63. package/src/components/settings/SettingsDialog.tsx +382 -521
  64. package/src/components/settings/currency-options.test.ts +1 -1
  65. package/src/components/settings/currency-options.ts +1 -1
  66. package/src/components/settings/settings-state.test.ts +17 -10
  67. package/src/components/settings/settings-state.ts +20 -12
  68. package/src/components/timeline/TimelineList.test.ts +56 -0
  69. package/src/components/timeline/TimelineList.tsx +7 -1
  70. package/src/components/traffic/TrafficFlowList.tsx +5 -21
  71. package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
  72. package/src/components/traffic/TrafficGraph.tsx +79 -48
  73. package/src/components/traffic/TrafficView.tsx +61 -37
  74. package/src/components/traffic/TrafficWizard.tsx +4 -2
  75. package/src/components/traffic/trafficFilters.test.ts +154 -1
  76. package/src/components/traffic/trafficFilters.ts +141 -0
  77. package/src/components/ui/Omnibar.tsx +1 -1
  78. package/src/components/ui/UpdateNotification.tsx +57 -12
  79. package/src/components/workload/WorkloadView.tsx +36 -13
  80. package/src/components/workload/historyPaging.test.ts +72 -0
  81. package/src/components/workload/historyPaging.ts +147 -0
  82. package/src/components/workload/useHistoryPaging.test.tsx +81 -0
  83. package/src/context/ConnectionContext.tsx +2 -1
  84. package/src/context/RadarUpgrade.tsx +30 -0
  85. package/src/context/RadarUpgradeHost.tsx +23 -0
  86. package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
  87. package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
  88. package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
  89. package/src/index.ts +1 -0
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from 'react'
1
+ import { useState, useEffect, useRef, useId } from 'react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Download, X, Copy, Check, RotateCw, ArrowDownToLine, Loader2 } from 'lucide-react'
4
4
  import { useQueryClient } from '@tanstack/react-query'
@@ -13,6 +13,7 @@ import type { DesktopUpdateState } from '../../api/client'
13
13
  import { WithTooltip } from './Tooltip'
14
14
  import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
15
15
  import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
16
+ import { IN_CLUSTER_UPGRADE_URL } from '../../utils/version'
16
17
 
17
18
  const DISMISSED_KEY = 'radar-update-dismissed'
18
19
 
@@ -27,7 +28,12 @@ export function isUpdateDismissed(latestVersion: string): boolean {
27
28
  }
28
29
  }
29
30
 
30
- export function UpdateNotification() {
31
+ export function UpdateNotification({ placement = 'floating' }: { placement?: 'floating' | 'settings' }) {
32
+ const inSettings = placement === 'settings'
33
+ const [expanded, setExpanded] = useState(false)
34
+ const containerRef = useRef<HTMLDivElement>(null)
35
+ const triggerRef = useRef<HTMLButtonElement>(null)
36
+ const panelId = useId()
31
37
  const queryClient = useQueryClient()
32
38
  const { data: capabilities } = useCapabilities()
33
39
  const deploymentMode = capabilities ? (capabilities.deployment?.mode ?? 'local') : undefined
@@ -40,9 +46,17 @@ export function UpdateNotification() {
40
46
  const [desktopUpdating, setDesktopUpdating] = useState(false)
41
47
  const startUpdate = useStartDesktopUpdate()
42
48
  const applyUpdate = useApplyDesktopUpdate()
43
- const { data: updateStatus } = useDesktopUpdateStatus(desktopUpdating)
44
-
45
49
  const isDesktop = versionInfo?.installMethod === 'desktop'
50
+ const { data: updateStatus } = useDesktopUpdateStatus(desktopUpdating || (inSettings && expanded && isDesktop))
51
+
52
+ useEffect(() => {
53
+ if (!expanded) return
54
+ const closeOutside = (event: PointerEvent) => {
55
+ if (!containerRef.current?.contains(event.target as Node)) setExpanded(false)
56
+ }
57
+ document.addEventListener('pointerdown', closeOutside)
58
+ return () => document.removeEventListener('pointerdown', closeOutside)
59
+ }, [expanded])
46
60
 
47
61
  // Listen for "Check for Updates" menu item in desktop app.
48
62
  useEffect(() => {
@@ -110,22 +124,25 @@ export function UpdateNotification() {
110
124
 
111
125
  // Shared in-cluster viewers get a persistent Home notice instead of a
112
126
  // floating action prompt they may not be able to act on.
113
- const show = !!versionInfo?.updateAvailable && !dismissed && deploymentMode !== undefined && deploymentMode !== 'in-cluster' && deploymentMode !== 'cloud'
127
+ const available = !!versionInfo?.updateAvailable && deploymentMode !== undefined && deploymentMode !== 'cloud'
128
+ const show = available && deploymentMode !== 'in-cluster' && (inSettings ? expanded : !dismissed)
114
129
  // Presence outlives `show` by the menu exit so a dismiss fades the chip out
115
130
  // instead of snapping it away; the enter runs the same transition in reverse.
116
131
  const { shouldRender, isOpen } = useAnimatedUnmount(show, overlayExitMs('menu'))
117
- if (!shouldRender || !versionInfo) {
132
+ if ((!inSettings && !shouldRender) || (inSettings && !available) || !versionInfo) {
118
133
  return null
119
134
  }
120
135
 
121
136
  // Determine what the current effective state is
122
137
  const effectiveState: DesktopUpdateState = updateStatus?.state ?? 'idle'
123
138
 
124
- return (
139
+ const panel = shouldRender && (
125
140
  <div
141
+ id={panelId}
126
142
  inert={!show || undefined}
127
143
  className={clsx(
128
- 'fixed bottom-4 right-4 z-50 max-w-sm bg-theme-surface border border-accent/50 rounded-lg shadow-xl p-4 origin-bottom-right',
144
+ 'bg-theme-surface border border-accent/50 rounded-lg shadow-theme-lg p-4',
145
+ inSettings ? 'absolute top-full left-0 mt-2 z-10 w-[28rem] max-sm:left-4 max-sm:right-4 max-sm:w-auto origin-top-left' : 'fixed bottom-4 right-4 z-50 max-w-sm origin-bottom-right',
129
146
  TRANSITION_MENU,
130
147
  isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-1 scale-[0.97]',
131
148
  !show && 'pointer-events-none',
@@ -142,7 +159,7 @@ export function UpdateNotification() {
142
159
  </h4>
143
160
  <p className="text-xs text-theme-text-secondary mt-1">
144
161
  Radar {versionInfo.latestVersion} is available.{' '}
145
- You're on {versionInfo.currentVersion}.
162
+ {!inSettings && <>You're on {versionInfo.currentVersion}.</>}
146
163
  </p>
147
164
 
148
165
  {/* Desktop: in-app update flow */}
@@ -166,7 +183,7 @@ export function UpdateNotification() {
166
183
  onClick={handleCopyCommand}
167
184
  className="flex items-center gap-2 mt-2 px-2 py-1.5 bg-theme-elevated rounded font-mono text-theme-text-primary hover:bg-theme-surface-hover transition-colors w-full"
168
185
  >
169
- <code className="inline-code flex-1 truncate text-left text-[11px]">{versionInfo.updateCommand}</code>
186
+ <code className={clsx('inline-code flex-1 text-left text-[11px]', inSettings ? 'whitespace-pre-wrap break-all' : 'truncate')}>{versionInfo.updateCommand}</code>
170
187
  <CopyIcon copied={copied} failed={copyFailed} />
171
188
  </button>
172
189
  </WithTooltip>
@@ -201,15 +218,43 @@ export function UpdateNotification() {
201
218
  fixed on the parent already establishes the positioning context. */}
202
219
  {effectiveState !== 'downloading' && effectiveState !== 'applying' && (
203
220
  <button
204
- onClick={handleDismiss}
221
+ onClick={inSettings ? () => { setExpanded(false); triggerRef.current?.focus() } : handleDismiss}
205
222
  className="absolute top-2 right-2 p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
206
- aria-label="Dismiss"
223
+ aria-label={inSettings ? 'Close update details' : 'Dismiss'}
207
224
  >
208
225
  <X className="w-4 h-4" />
209
226
  </button>
210
227
  )}
211
228
  </div>
212
229
  )
230
+
231
+ if (!inSettings) return panel
232
+
233
+ const triggerClass = 'inline-flex items-center gap-1.5 rounded-lg border border-accent/40 bg-accent-muted px-2.5 py-1.5 text-xs font-semibold text-accent-text hover:bg-theme-hover'
234
+ if (deploymentMode === 'in-cluster') return (
235
+ <a href={IN_CLUSTER_UPGRADE_URL} target="_blank" rel="noreferrer" className={triggerClass}>
236
+ <Download aria-hidden="true" className="h-4 w-4" />
237
+ Update available
238
+ </a>
239
+ )
240
+
241
+ return (
242
+ <div ref={containerRef} className="sm:relative" onBlur={event => {
243
+ if (!event.currentTarget.contains(event.relatedTarget)) setExpanded(false)
244
+ }} onKeyDown={event => {
245
+ if (event.key !== 'Escape' || !expanded) return
246
+ event.preventDefault()
247
+ event.stopPropagation()
248
+ setExpanded(false)
249
+ triggerRef.current?.focus()
250
+ }}>
251
+ <button ref={triggerRef} type="button" className={triggerClass} aria-expanded={expanded} aria-controls={panelId} onClick={() => setExpanded(value => !value)}>
252
+ <Download aria-hidden="true" className="h-4 w-4" />
253
+ Update available
254
+ </button>
255
+ {panel}
256
+ </div>
257
+ )
213
258
  }
214
259
 
215
260
  function CopyIcon({ copied, failed }: { copied: boolean; failed: boolean }) {
@@ -36,6 +36,7 @@ import {
36
36
  import type { ServicePortRenderProps } from '@skyhook-io/k8s-ui/components/resources/renderers/ServiceRenderer'
37
37
  import { isJobSetV1Alpha2 } from '@skyhook-io/k8s-ui/components/resources/resource-utils-jobset-lws'
38
38
  import type { SelectedResource, ResourceRef, Relationships, ResourceWithRelationships } from '../../types'
39
+ import { useHistoryPaging } from './historyPaging'
39
40
  import {
40
41
  kindToPlural,
41
42
  kindToPluralWithGroup,
@@ -46,7 +47,8 @@ import {
46
47
  type NavigateToResource,
47
48
  } from '../../utils/navigation'
48
49
  import {
49
- useChanges,
50
+ useWorkloadHistory,
51
+ fetchWorkloadHistoryPage,
50
52
  useResourceWithRelationships,
51
53
  usePodLogs,
52
54
  useTopology,
@@ -77,7 +79,6 @@ import {
77
79
  useUncordonNode,
78
80
  useDrainNode,
79
81
  useDrainPlan,
80
- DrainPlanUnsupportedError,
81
82
  useCascadeDeletePreview,
82
83
  useResourceEvents,
83
84
  useResource,
@@ -92,8 +93,9 @@ import { RestartEventLane } from '../resource/RestartChart'
92
93
  import { RightsizingPanel } from '../resource/RightsizingStrip'
93
94
  import { WorkloadCostTab } from '../cost/WorkloadCostTab'
94
95
  import { isOpenCostWorkloadKind } from '../cost/kinds'
96
+ import { isRadarFeatureUnsupported } from '../../api/radarFeatures'
95
97
  import { useResourceAudit, useResourceIssues, useResources, useTrace, fetchTraceWithProbes, fetchInClusterCapability, runInClusterMerged } from '../../api/client'
96
- import { AuditAlerts, ResourceIssuesSection, ReachabilityView, TraceSummary, InClusterConsentDialog, traceFingerprint, staticPollUnreliable, summarizeInClusterTests, type Trace as NetworkTrace, type InClusterCapability, inClusterConsentGiven, consentRequestRows } from '@skyhook-io/k8s-ui'
98
+ import { AuditAlerts, getRadarUpgradeRequirement, ResourceIssuesSection, ReachabilityView, TraceSummary, InClusterConsentDialog, traceFingerprint, staticPollUnreliable, summarizeInClusterTests, type Trace as NetworkTrace, type InClusterCapability, inClusterConsentGiven, consentRequestRows } from '@skyhook-io/k8s-ui'
97
99
  import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer'
98
100
  import { ScheduledWorkloadLogsViewer } from '../logs/ScheduledWorkloadLogsViewer'
99
101
  import { LogsViewer } from '../logs/LogsViewer'
@@ -518,7 +520,9 @@ function useActionsBarProps(
518
520
  drainPlan: drainPlanMutation.data ?? null,
519
521
  isPlanningDrain: drainPlanMutation.isPending,
520
522
  drainPlanError: drainPlanMutation.error?.message ?? null,
521
- drainPlanUnsupported: drainPlanMutation.error instanceof DrainPlanUnsupportedError,
523
+ drainPlanUpgrade: isRadarFeatureUnsupported(drainPlanMutation.error, 'drainPlan')
524
+ ? getRadarUpgradeRequirement(drainPlanMutation.error)
525
+ : null,
522
526
  }
523
527
  }
524
528
 
@@ -791,15 +795,26 @@ export function WorkloadView({
791
795
  updatesError: resourceFocusedUpdatesError,
792
796
  } = useResourceEvents(apiKind, namespace, name, effectiveGroup)
793
797
 
794
- // Fetch all events for this resource's namespace (only when expanded)
795
- const { data: allEvents, isLoading: eventsLoading } = useChanges({
796
- namespaces: [namespace],
797
- timeRange: 'all',
798
- includeK8sEvents: true,
799
- includeManaged: true,
800
- limit: 10000,
801
- enabled: expanded,
802
- })
798
+ // This workload's history (only when expanded): the workload, what it owns,
799
+ // K8s Events about those, and the resources attached to it. Older pages load
800
+ // on demand and stay until the viewed workload changes.
801
+ // The group settles from the route, or from the fetched resource when the
802
+ // route has none; asking before then would key the history (and resolve a
803
+ // colliding kind) under the wrong group and refetch once it settles.
804
+ const historyGroupSettled = Boolean(rest.group) || resource !== undefined || resourceError != null
805
+ const historyQuery = useWorkloadHistory(apiKind, namespace, name, effectiveGroup, expanded && historyGroupSettled)
806
+ const {
807
+ events: allEvents,
808
+ truncated: historyTruncated,
809
+ loadOlder: loadOlderHistory,
810
+ loadingOlder: loadingOlderHistory,
811
+ olderError: olderHistoryError,
812
+ } = useHistoryPaging(
813
+ `${effectiveGroup ?? ''}/${apiKind}/${namespace}/${name}`,
814
+ historyQuery.data,
815
+ (beforeSeq) => fetchWorkloadHistoryPage(apiKind, namespace, name, effectiveGroup, beforeSeq),
816
+ )
817
+ const eventsLoading = historyQuery.isLoading || (expanded && !historyGroupSettled)
803
818
 
804
819
  // RBAC
805
820
  const canUpdateSecrets = useCanUpdateSecrets()
@@ -1196,6 +1211,14 @@ export function WorkloadView({
1196
1211
  refetch={refetchResourceAndRuns}
1197
1212
  // Timeline
1198
1213
  allEvents={allEvents}
1214
+ historyTruncated={historyTruncated}
1215
+ onLoadOlderHistory={loadOlderHistory}
1216
+ loadingOlderHistory={loadingOlderHistory}
1217
+ olderHistoryError={olderHistoryError}
1218
+ historyScoped
1219
+ historyIncomplete={Boolean(historyQuery.data?.incomplete)}
1220
+ historyError={historyQuery.error as Error | null}
1221
+ onRetryHistory={() => void historyQuery.refetch()}
1199
1222
  relatedTimelineEvents={relatedTimelineEvents}
1200
1223
  eventsLoading={eventsLoading || (batchExecution && batchKind !== 'JobSet' && batchRunsQuery.isLoading)}
1201
1224
  topology={topology}
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { WorkloadHistoryPage } from '../../api/client'
3
+ import type { TimelineEvent } from '../../types'
4
+ import { addOlderPage, foldRefreshedPage } from './historyPaging'
5
+
6
+ // Rows newest first, each id its own seq.
7
+ const ev = (id: string, seq: number) => ({ id, seq }) as TimelineEvent
8
+ const rows = (from: number, to: number) => Array.from({ length: from - to + 1 }, (_, i) => ev(String(from - i), from - i))
9
+ const page = (from: number, to: number, truncated: boolean): WorkloadHistoryPage => ({
10
+ events: rows(from, to),
11
+ truncated,
12
+ nextBeforeSeq: truncated ? to : undefined,
13
+ })
14
+ const ids = (p: WorkloadHistoryPage) => p.events.map((e) => Number(e.id))
15
+ const contiguous = (p: WorkloadHistoryPage, from: number, to: number) =>
16
+ expect(new Set(ids(p))).toEqual(new Set(rows(from, to).map((e) => Number(e.id))))
17
+
18
+ describe('workload history paging', () => {
19
+ it('keeps rows the newest page slides past once older pages are loaded', () => {
20
+ const first = page(40, 21, true)
21
+ const step = addOlderPage(null, page(20, 1, false), first, first)
22
+ if (step.kind !== 'set') throw new Error(step.kind)
23
+ let loaded = step.history
24
+ for (const refreshed of [page(50, 31, true), page(60, 41, true)]) {
25
+ const next = foldRefreshedPage(loaded, refreshed)
26
+ if (next.kind === 'set') loaded = next.history
27
+ else expect(next.kind).toBe('keep')
28
+ }
29
+ contiguous(loaded, 60, 1)
30
+ expect(loaded.truncated).toBe(false)
31
+ })
32
+
33
+ it('restarts paging when a refresh no longer reaches what is loaded', () => {
34
+ const loaded = page(40, 1, false)
35
+ expect(foldRefreshedPage(loaded, page(80, 61, true)).kind).toBe('restart')
36
+ expect(foldRefreshedPage(loaded, page(45, 26, true)).kind).toBe('set')
37
+ expect(foldRefreshedPage(loaded, page(40, 21, true)).kind).toBe('keep')
38
+ })
39
+
40
+ it('anchors a first older page to what refreshed while it loaded', () => {
41
+ const base = page(40, 21, true)
42
+ const step = addOlderPage(null, page(20, 1, false), base, page(45, 26, true))
43
+ if (step.kind !== 'set') throw new Error(step.kind)
44
+ contiguous(step.history, 45, 1)
45
+ })
46
+
47
+ it('restarts instead of landing a first older page behind a hole', () => {
48
+ const base = page(40, 21, true)
49
+ expect(addOlderPage(null, page(20, 1, false), base, page(90, 71, true)).kind).toBe('restart')
50
+ })
51
+
52
+ it('a repeated K8s Event must not hide a gap after Load older', () => {
53
+ // Loaded after Load older: events 120..1. "backoff" is a K8s Event already held, at seq 50.
54
+ const loaded: WorkloadHistoryPage = { events: [...rows(120, 51), ev('backoff', 50), ...rows(49, 1)], truncated: false }
55
+ // 200 arrive (121..320) and the page holds 20; "backoff" fired again: same id, seq 320.
56
+ const refreshed: WorkloadHistoryPage = { events: [ev('backoff', 320), ...rows(319, 301)], truncated: true, nextBeforeSeq: 301 }
57
+ expect(foldRefreshedPage(loaded, refreshed).kind).toBe('restart')
58
+ })
59
+
60
+ it('handles a loaded history far larger than a page', () => {
61
+ const loaded: WorkloadHistoryPage = { events: rows(200_000, 1), truncated: false }
62
+ expect(foldRefreshedPage(loaded, page(200_020, 200_001, true)).kind).toBe('restart')
63
+ expect(foldRefreshedPage(loaded, page(200_010, 199_991, true)).kind).toBe('set')
64
+ })
65
+
66
+ it("keeps a repeated K8s Event's newer copy", () => {
67
+ const loaded: WorkloadHistoryPage = { events: [...rows(30, 21), ev('backoff', 20), ...rows(19, 1)], truncated: false }
68
+ const step = foldRefreshedPage(loaded, { events: [ev('backoff', 31), ...rows(30, 22)], truncated: true, nextBeforeSeq: 22 })
69
+ if (step.kind !== 'set') throw new Error(step.kind)
70
+ expect(step.history.events.filter((e) => e.id === 'backoff').map((e) => e.seq)).toEqual([31])
71
+ })
72
+ })
@@ -0,0 +1,147 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import type { WorkloadHistoryPage } from '../../api/client'
3
+ import type { TimelineEvent } from '../../types'
4
+
5
+ // Paging state for a workload's history: the newest page keeps refreshing on
6
+ // its own, and older pages load on demand below it. These rules keep the two
7
+ // contiguous. When they can't (more arrived between refreshes than one page
8
+ // holds), paging restarts from the newest page instead of leaving a hole.
9
+ //
10
+ // Continuity is judged by seq, not by id: a K8s Event that fires again keeps
11
+ // its id but moves to a new, higher seq, so a page can share an id with what's
12
+ // loaded and still not reach it.
13
+
14
+ export type PagingStep = { kind: 'keep' } | { kind: 'restart' } | { kind: 'set'; history: WorkloadHistoryPage }
15
+
16
+ const seqOf = (e: TimelineEvent) => e.seq ?? 0
17
+
18
+ /** Whether a newest page reaches down to rows already loaded, leaving no gap. */
19
+ function reaches(page: WorkloadHistoryPage, loaded: TimelineEvent[]): boolean {
20
+ if (!page.truncated) return true
21
+ if (loaded.length === 0 || page.events.length === 0) return false
22
+ // Loops, not Math.max(...rows): loaded history has no cap, and spreading it
23
+ // into arguments overflows the stack.
24
+ let newestLoaded = -Infinity
25
+ for (const e of loaded) newestLoaded = Math.max(newestLoaded, seqOf(e))
26
+ let oldestOnPage = Infinity
27
+ for (const e of page.events) oldestOnPage = Math.min(oldestOnPage, seqOf(e))
28
+ return oldestOnPage <= newestLoaded
29
+ }
30
+
31
+ /** Newer rows first; a row both lists hold keeps the newer copy (a repeated
32
+ * K8s Event's count and message move with it). */
33
+ function mergeNewer(newer: TimelineEvent[], older: TimelineEvent[]): TimelineEvent[] {
34
+ const ids = new Set(newer.map((e) => e.id))
35
+ return [...newer, ...older.filter((e) => !ids.has(e.id))]
36
+ }
37
+
38
+ /** Folds a refreshed newest page into the loaded older pages. */
39
+ export function foldRefreshedPage(loaded: WorkloadHistoryPage, page: WorkloadHistoryPage): PagingStep {
40
+ const seqById = new Map(loaded.events.map((e) => [e.id, seqOf(e)]))
41
+ if (page.events.every((e) => seqById.get(e.id) === seqOf(e))) return { kind: 'keep' }
42
+ if (!reaches(page, loaded.events)) return { kind: 'restart' }
43
+ return { kind: 'set', history: { ...loaded, events: mergeNewer(page.events, loaded.events) } }
44
+ }
45
+
46
+ /**
47
+ * Adds an older page. The first one continues from the newest page it was
48
+ * requested from (basePage), and keeps that page plus whatever refreshed
49
+ * since (latest), unless the refresh has already moved past it.
50
+ */
51
+ export function addOlderPage(
52
+ loaded: WorkloadHistoryPage | null,
53
+ page: WorkloadHistoryPage,
54
+ basePage: WorkloadHistoryPage | undefined,
55
+ latest: WorkloadHistoryPage | undefined,
56
+ ): PagingStep {
57
+ const more = { truncated: page.truncated, nextBeforeSeq: page.nextBeforeSeq }
58
+ if (loaded) return { kind: 'set', history: { events: mergeNewer(loaded.events, page.events), ...more } }
59
+ let newest = basePage?.events ?? []
60
+ if (latest && latest !== basePage) {
61
+ if (!reaches(latest, newest)) return { kind: 'restart' }
62
+ newest = mergeNewer(latest.events, newest)
63
+ }
64
+ return { kind: 'set', history: { events: mergeNewer(newest, page.events), ...more } }
65
+ }
66
+
67
+ /**
68
+ * A workload's history: the newest page (refreshed by its query) plus older
69
+ * pages loaded on demand. identity names the workload; changing it restarts
70
+ * paging, and a page requested before a restart is dropped when it arrives.
71
+ */
72
+ export function useHistoryPaging(
73
+ identity: string,
74
+ newest: WorkloadHistoryPage | undefined,
75
+ fetchOlder: (beforeSeq: number) => Promise<WorkloadHistoryPage>,
76
+ ) {
77
+ // Loaded pages carry the workload they belong to, so the render between a
78
+ // navigation and the restart below never shows another workload's rows.
79
+ const [olderState, setOlderState] = useState<{ identity: string; history: WorkloadHistoryPage } | null>(null)
80
+ const older = olderState?.identity === identity ? olderState.history : null
81
+ const setOlder = useCallback(
82
+ (history: WorkloadHistoryPage | null) => setOlderState(history ? { identity, history } : null),
83
+ [identity],
84
+ )
85
+ const [loadingOlder, setLoadingOlder] = useState(false)
86
+ const [olderError, setOlderError] = useState<Error | null>(null)
87
+ const generation = useRef(0)
88
+ const latest = useRef(newest)
89
+ latest.current = newest
90
+ const loaded = useRef(older)
91
+ loaded.current = older
92
+
93
+ const restart = useCallback(() => {
94
+ generation.current++
95
+ // Effects later in this commit (the refresh fold below) read the ref
96
+ // before the next render would update it.
97
+ loaded.current = null
98
+ setOlderState(null)
99
+ setOlderError(null)
100
+ setLoadingOlder(false)
101
+ }, [])
102
+ useEffect(restart, [identity, restart])
103
+
104
+ useEffect(() => {
105
+ if (!newest || !loaded.current) return
106
+ const step = foldRefreshedPage(loaded.current, newest)
107
+ if (step.kind === 'restart') restart()
108
+ else if (step.kind === 'set') setOlder(step.history)
109
+ }, [newest, restart, setOlder])
110
+
111
+ const events = useMemo<TimelineEvent[] | undefined>(() => {
112
+ if (!newest) return undefined
113
+ if (!older) return newest.events
114
+ const byId = new Map(newest.events.map((e) => [e.id, e]))
115
+ for (const e of older.events) if (!byId.has(e.id)) byId.set(e.id, e)
116
+ return [...byId.values()]
117
+ }, [newest, older])
118
+
119
+ const loadOlder = useCallback(async () => {
120
+ const basePage = older ? undefined : newest
121
+ const cursor = older ? older.nextBeforeSeq : basePage?.nextBeforeSeq
122
+ if (!cursor) return
123
+ const requested = generation.current
124
+ setLoadingOlder(true)
125
+ setOlderError(null)
126
+ try {
127
+ const page = await fetchOlder(cursor)
128
+ if (generation.current !== requested) return
129
+ const step = addOlderPage(loaded.current, page, basePage, latest.current)
130
+ if (step.kind === 'restart') restart()
131
+ else if (step.kind === 'set') setOlder(step.history)
132
+ } catch (err) {
133
+ if (generation.current !== requested) return
134
+ setOlderError(err instanceof Error ? err : new Error(String(err)))
135
+ } finally {
136
+ if (generation.current === requested) setLoadingOlder(false)
137
+ }
138
+ }, [older, newest, fetchOlder, restart, setOlder])
139
+
140
+ return {
141
+ events,
142
+ truncated: older ? older.truncated : Boolean(newest?.truncated),
143
+ loadOlder,
144
+ loadingOlder,
145
+ olderError,
146
+ }
147
+ }
@@ -0,0 +1,81 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
+ import type { WorkloadHistoryPage } from '../../api/client'
6
+ import type { TimelineEvent } from '../../types'
7
+ import { useHistoryPaging } from './historyPaging'
8
+
9
+ const page = (prefix: string, from: number, to: number, truncated: boolean): WorkloadHistoryPage => ({
10
+ events: Array.from({ length: from - to + 1 }, (_, i) => ({ id: `${prefix}${from - i}`, seq: from - i }) as TimelineEvent),
11
+ truncated,
12
+ nextBeforeSeq: truncated ? to : undefined,
13
+ })
14
+
15
+ type Paging = ReturnType<typeof useHistoryPaging>
16
+ const renders: Paging[] = []
17
+ function Harness(props: { identity: string; newest?: WorkloadHistoryPage; fetchOlder: (seq: number) => Promise<WorkloadHistoryPage> }) {
18
+ renders.push(useHistoryPaging(props.identity, props.newest, props.fetchOlder))
19
+ return null
20
+ }
21
+ const latest = () => renders[renders.length - 1]
22
+
23
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
24
+ let root: Root
25
+ let element: HTMLDivElement
26
+ beforeEach(() => {
27
+ renders.length = 0
28
+ element = document.createElement('div')
29
+ root = createRoot(element)
30
+ })
31
+ afterEach(async () => {
32
+ await act(async () => root.unmount())
33
+ })
34
+ const render = (props: Parameters<typeof Harness>[0]) => act(async () => root.render(<Harness {...props} />))
35
+ const ids = () => (latest().events ?? []).map((e) => e.id).sort()
36
+ function deferred() {
37
+ let resolve!: (p: WorkloadHistoryPage) => void
38
+ const promise = new Promise<WorkloadHistoryPage>((r) => (resolve = r))
39
+ return { promise, resolve }
40
+ }
41
+
42
+ describe('useHistoryPaging', () => {
43
+ it("shows only the new workload's history after navigating away from loaded older pages", async () => {
44
+ const fetchOlder = async () => page('a', 2, 1, false)
45
+ await render({ identity: 'A', newest: page('a', 4, 3, true), fetchOlder })
46
+ await act(async () => latest().loadOlder())
47
+ expect(ids()).toEqual(['a1', 'a2', 'a3', 'a4'])
48
+ await render({ identity: 'B', newest: page('b', 2, 1, false), fetchOlder })
49
+ expect(ids()).toEqual(['b1', 'b2'])
50
+ expect(latest().truncated).toBe(false)
51
+ })
52
+
53
+ it('drops an older page that arrives after navigating to another workload', async () => {
54
+ const pending = deferred()
55
+ await render({ identity: 'A', newest: page('a', 4, 3, true), fetchOlder: () => pending.promise })
56
+ let loading!: Promise<void>
57
+ await act(async () => { loading = latest().loadOlder() })
58
+ await render({ identity: 'B', newest: page('b', 4, 3, true), fetchOlder: () => pending.promise })
59
+ await act(async () => { pending.resolve(page('a', 2, 1, false)); await loading })
60
+ expect(ids()).toEqual(['b3', 'b4'])
61
+ expect(latest().truncated).toBe(true)
62
+ expect(latest().loadingOlder).toBe(false)
63
+ })
64
+
65
+ it('drops an older page that arrives after a refresh restarted paging', async () => {
66
+ const first = deferred()
67
+ let calls = 0
68
+ const fetchOlder = () => (++calls === 1 ? Promise.resolve(page('', 20, 11, true)) : first.promise)
69
+ await render({ identity: 'A', newest: page('', 30, 21, true), fetchOlder })
70
+ await act(async () => latest().loadOlder())
71
+ let loading!: Promise<void>
72
+ await act(async () => { loading = latest().loadOlder() })
73
+ expect(latest().loadingOlder).toBe(true)
74
+ // More arrived than one page holds: the refreshed page no longer reaches what's loaded.
75
+ await render({ identity: 'A', newest: page('', 60, 51, true), fetchOlder })
76
+ expect(latest().loadingOlder).toBe(false)
77
+ await act(async () => { first.resolve(page('', 10, 1, false)); await loading })
78
+ expect(ids()).toEqual(page('', 60, 51, true).events.map((e) => e.id).sort())
79
+ expect(latest().truncated).toBe(true)
80
+ })
81
+ })
@@ -1,6 +1,7 @@
1
1
  import { createContext, useContext, useState, useCallback, useEffect, useRef, ReactNode } from 'react'
2
2
  import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
3
3
  import { getApiBase } from '../api/config'
4
+ import { readErrorBody } from '../api/httpErrors'
4
5
  import { apiFetch, type SyncStatusSnapshot } from '../api/client'
5
6
 
6
7
  export type ConnectionStateType = 'connected' | 'disconnected' | 'connecting'
@@ -90,7 +91,7 @@ async function retryConnection(): Promise<ConnectionState> {
90
91
  method: 'POST',
91
92
  })
92
93
  if (!response.ok) {
93
- const error = await response.json().catch(() => ({ error: 'Unknown error' })) as { error?: string; errorType?: string }
94
+ const error = await readErrorBody(response) as { error?: string; errorType?: string }
94
95
  throw new ConnectionRetryError(error.error || `HTTP ${response.status}`, error.errorType)
95
96
  }
96
97
  return response.json()
@@ -0,0 +1,30 @@
1
+ import { useMemo, type ReactNode } from 'react'
2
+ import { RadarUpgradeContext, type RadarUpgradeActions } from '@skyhook-io/k8s-ui'
3
+ import { useCapabilities, useVersionCheck } from '../api/client'
4
+ import { IN_CLUSTER_UPGRADE_URL } from '../utils/version'
5
+ import { useRadarUpgradeHost } from './RadarUpgradeHost'
6
+
7
+ /**
8
+ * Supplies the "Upgrade Radar" action to the inline notes k8s-ui renders for
9
+ * features the connected Radar is too old to serve. A host that owns upgrades
10
+ * (Radar Cloud) gets the request; standalone Radar links to the instructions
11
+ * for how it was installed.
12
+ */
13
+ export function RadarUpgradeProvider({ children }: { children: ReactNode }) {
14
+ const { onRequestRadarUpgrade } = useRadarUpgradeHost()
15
+ const { data: capabilities } = useCapabilities()
16
+ const { data: versionInfo } = useVersionCheck()
17
+ const mode = capabilities?.deployment?.mode ?? 'local'
18
+ // A local binary upgrades from its release page; an in-cluster or Cloud
19
+ // agent from the docs section that covers Helm, Argo CD and Flux installs.
20
+ const upgradeHref = mode === 'local' && versionInfo?.releaseUrl
21
+ ? versionInfo.releaseUrl
22
+ : `${IN_CLUSTER_UPGRADE_URL}#upgrading`
23
+
24
+ const value = useMemo<RadarUpgradeActions>(
25
+ () => (onRequestRadarUpgrade ? { onRequestUpgrade: onRequestRadarUpgrade } : { upgradeHref }),
26
+ [onRequestRadarUpgrade, upgradeHref],
27
+ )
28
+
29
+ return <RadarUpgradeContext.Provider value={value}>{children}</RadarUpgradeContext.Provider>
30
+ }
@@ -0,0 +1,23 @@
1
+ import { createContext, useContext } from 'react'
2
+ import type { RadarUpgradeRequirement } from '@skyhook-io/k8s-ui'
3
+
4
+ /** What the user is missing and which Radar serves it; see RadarUpgradeRequirement. */
5
+ export type RadarUpgradeRequest = RadarUpgradeRequirement
6
+
7
+ /**
8
+ * What an embedding host knows about the connected Radar and how it upgrades
9
+ * it: RadarApp's `radarVersion` and `onRequestRadarUpgrade` props. Kept apart
10
+ * from RadarUpgrade.tsx so api/client.ts can read it without an import cycle.
11
+ */
12
+ export interface RadarUpgradeHost {
13
+ radarVersion?: string
14
+ onRequestRadarUpgrade?: (request: RadarUpgradeRequest) => void
15
+ }
16
+
17
+ const RadarUpgradeHostContext = createContext<RadarUpgradeHost>({})
18
+
19
+ export const RadarUpgradeHostProvider = RadarUpgradeHostContext.Provider
20
+
21
+ export function useRadarUpgradeHost(): RadarUpgradeHost {
22
+ return useContext(RadarUpgradeHostContext)
23
+ }