@skyhook-io/radar-app 1.15.0 → 1.16.1
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/README.md +2 -0
- package/package.json +6 -5
- package/src/App.tsx +14 -11
- package/src/RadarApp.tsx +51 -21
- package/src/api/apiResources.test.ts +19 -1
- package/src/api/apiResources.ts +18 -3
- package/src/api/client.capacity.test.ts +15 -0
- package/src/api/client.trace.test.ts +9 -1
- package/src/api/client.ts +247 -201
- package/src/api/diagnose.test.ts +102 -1
- package/src/api/diagnose.ts +42 -11
- package/src/api/drain-plan.test.ts +0 -10
- package/src/api/httpErrors.test.ts +95 -0
- package/src/api/httpErrors.ts +101 -0
- package/src/api/policy.ts +33 -5
- package/src/api/radarFeatures.render.test.tsx +223 -0
- package/src/api/radarFeatures.test.ts +142 -0
- package/src/api/radarFeatures.ts +148 -0
- package/src/api/timelineSource.test.ts +2 -10
- package/src/api/timelineSource.ts +3 -2
- package/src/api/traffic.ts +4 -3
- package/src/components/CloudFunnelButton.tsx +9 -9
- package/src/components/NamespaceSwitcher.test.tsx +47 -0
- package/src/components/NamespaceSwitcher.tsx +17 -1
- package/src/components/SelfManagedStart.tsx +5 -6
- package/src/components/applications/ApplicationsView.tsx +20 -6
- package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
- package/src/components/audit/UpgradeReadinessView.tsx +9 -13
- package/src/components/capacity/CapacityView.test.tsx +13 -4
- package/src/components/capacity/shared.tsx +14 -13
- package/src/components/cost/ApplicationCostTab.tsx +9 -1
- package/src/components/cost/CostConnectionAction.tsx +30 -0
- package/src/components/cost/CostTrendChart.tsx +2 -0
- package/src/components/cost/CostView.tsx +4 -24
- package/src/components/cost/WorkloadCostTab.tsx +7 -1
- package/src/components/cost/source.test.ts +30 -3
- package/src/components/cost/source.ts +37 -2
- package/src/components/curl/ServiceCurlButton.tsx +7 -3
- package/src/components/dock/NodeTerminalTab.tsx +2 -1
- package/src/components/dock/TerminalTab.tsx +2 -1
- package/src/components/dock/TrafficFlowListTab.tsx +2 -2
- package/src/components/gitops/GitOpsView.tsx +12 -0
- package/src/components/home/HomeView.tsx +1 -1
- package/src/components/home/MCPSetupDialog.tsx +37 -37
- package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
- package/src/components/resource/PVCUsageBar.tsx +4 -1
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
- package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
- package/src/components/resources/ImageFilesystemModal.tsx +2 -1
- package/src/components/resources/PodFilePreview.tsx +4 -1
- package/src/components/resources/PodFilesystemModal.tsx +3 -2
- package/src/components/resources/ResourcesView.tsx +2 -1
- package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
- package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
- package/src/components/settings/FormSaveActions.tsx +76 -0
- package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
- package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
- package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
- package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
- package/src/components/settings/PrometheusConfigField.tsx +30 -0
- package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
- package/src/components/settings/SettingsDialog.tsx +382 -521
- package/src/components/settings/currency-options.test.ts +1 -1
- package/src/components/settings/currency-options.ts +1 -1
- package/src/components/settings/settings-state.test.ts +17 -10
- package/src/components/settings/settings-state.ts +20 -12
- package/src/components/timeline/TimelineList.test.ts +56 -0
- package/src/components/timeline/TimelineList.tsx +7 -1
- package/src/components/traffic/TrafficFlowList.tsx +5 -21
- package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
- package/src/components/traffic/TrafficGraph.tsx +79 -48
- package/src/components/traffic/TrafficView.tsx +61 -37
- package/src/components/traffic/TrafficWizard.tsx +4 -2
- package/src/components/traffic/trafficFilters.test.ts +154 -1
- package/src/components/traffic/trafficFilters.ts +141 -0
- package/src/components/ui/Omnibar.tsx +1 -1
- package/src/components/ui/UpdateNotification.tsx +57 -12
- package/src/components/workload/WorkloadView.tsx +44 -6
- package/src/components/workload/historyPaging.test.ts +72 -0
- package/src/components/workload/historyPaging.ts +147 -0
- package/src/components/workload/useHistoryPaging.test.tsx +81 -0
- package/src/context/ConnectionContext.tsx +2 -1
- package/src/context/RadarUpgrade.tsx +30 -0
- package/src/context/RadarUpgradeHost.tsx +23 -0
- package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
- package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
- package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
- 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
|
|
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
|
-
|
|
139
|
+
const panel = shouldRender && (
|
|
125
140
|
<div
|
|
141
|
+
id={panelId}
|
|
126
142
|
inert={!show || undefined}
|
|
127
143
|
className={clsx(
|
|
128
|
-
'
|
|
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=
|
|
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=
|
|
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,6 +47,8 @@ import {
|
|
|
46
47
|
type NavigateToResource,
|
|
47
48
|
} from '../../utils/navigation'
|
|
48
49
|
import {
|
|
50
|
+
useWorkloadHistory,
|
|
51
|
+
fetchWorkloadHistoryPage,
|
|
49
52
|
useChanges,
|
|
50
53
|
useResourceWithRelationships,
|
|
51
54
|
usePodLogs,
|
|
@@ -77,7 +80,6 @@ import {
|
|
|
77
80
|
useUncordonNode,
|
|
78
81
|
useDrainNode,
|
|
79
82
|
useDrainPlan,
|
|
80
|
-
DrainPlanUnsupportedError,
|
|
81
83
|
useCascadeDeletePreview,
|
|
82
84
|
useResourceEvents,
|
|
83
85
|
useResource,
|
|
@@ -92,8 +94,9 @@ import { RestartEventLane } from '../resource/RestartChart'
|
|
|
92
94
|
import { RightsizingPanel } from '../resource/RightsizingStrip'
|
|
93
95
|
import { WorkloadCostTab } from '../cost/WorkloadCostTab'
|
|
94
96
|
import { isOpenCostWorkloadKind } from '../cost/kinds'
|
|
97
|
+
import { isRadarFeatureUnsupported } from '../../api/radarFeatures'
|
|
95
98
|
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'
|
|
99
|
+
import { AuditAlerts, getRadarUpgradeRequirement, ResourceIssuesSection, ReachabilityView, TraceSummary, InClusterConsentDialog, traceFingerprint, staticPollUnreliable, summarizeInClusterTests, type Trace as NetworkTrace, type InClusterCapability, inClusterConsentGiven, consentRequestRows } from '@skyhook-io/k8s-ui'
|
|
97
100
|
import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer'
|
|
98
101
|
import { ScheduledWorkloadLogsViewer } from '../logs/ScheduledWorkloadLogsViewer'
|
|
99
102
|
import { LogsViewer } from '../logs/LogsViewer'
|
|
@@ -518,7 +521,9 @@ function useActionsBarProps(
|
|
|
518
521
|
drainPlan: drainPlanMutation.data ?? null,
|
|
519
522
|
isPlanningDrain: drainPlanMutation.isPending,
|
|
520
523
|
drainPlanError: drainPlanMutation.error?.message ?? null,
|
|
521
|
-
|
|
524
|
+
drainPlanUpgrade: isRadarFeatureUnsupported(drainPlanMutation.error, 'drainPlan')
|
|
525
|
+
? getRadarUpgradeRequirement(drainPlanMutation.error)
|
|
526
|
+
: null,
|
|
522
527
|
}
|
|
523
528
|
}
|
|
524
529
|
|
|
@@ -791,15 +796,40 @@ export function WorkloadView({
|
|
|
791
796
|
updatesError: resourceFocusedUpdatesError,
|
|
792
797
|
} = useResourceEvents(apiKind, namespace, name, effectiveGroup)
|
|
793
798
|
|
|
794
|
-
//
|
|
795
|
-
|
|
799
|
+
// This workload's history (only when expanded): the workload, what it owns,
|
|
800
|
+
// K8s Events about those, and the resources attached to it. Older pages load
|
|
801
|
+
// on demand and stay until the viewed workload changes.
|
|
802
|
+
// The group settles from the route, or from the fetched resource when the
|
|
803
|
+
// route has none; asking before then would key the history (and resolve a
|
|
804
|
+
// colliding kind) under the wrong group and refetch once it settles.
|
|
805
|
+
const historyGroupSettled = Boolean(rest.group) || resource !== undefined || resourceError != null
|
|
806
|
+
const historyQuery = useWorkloadHistory(apiKind, namespace, name, effectiveGroup, expanded && historyGroupSettled)
|
|
807
|
+
// A Radar without the scoped history endpoint gets the namespace's newest
|
|
808
|
+
// changes instead, which the timeline filters to this workload.
|
|
809
|
+
const historyFallback = isRadarFeatureUnsupported(historyQuery.error, 'workloadHistory')
|
|
810
|
+
const namespaceChanges = useChanges({
|
|
796
811
|
namespaces: [namespace],
|
|
797
812
|
timeRange: 'all',
|
|
798
813
|
includeK8sEvents: true,
|
|
799
814
|
includeManaged: true,
|
|
800
815
|
limit: 10000,
|
|
801
|
-
enabled: expanded,
|
|
816
|
+
enabled: expanded && historyFallback,
|
|
802
817
|
})
|
|
818
|
+
const {
|
|
819
|
+
events: scopedEvents,
|
|
820
|
+
truncated: historyTruncated,
|
|
821
|
+
loadOlder: loadOlderHistory,
|
|
822
|
+
loadingOlder: loadingOlderHistory,
|
|
823
|
+
olderError: olderHistoryError,
|
|
824
|
+
} = useHistoryPaging(
|
|
825
|
+
`${effectiveGroup ?? ''}/${apiKind}/${namespace}/${name}`,
|
|
826
|
+
historyFallback ? undefined : historyQuery.data,
|
|
827
|
+
(beforeSeq) => fetchWorkloadHistoryPage(apiKind, namespace, name, effectiveGroup, beforeSeq),
|
|
828
|
+
)
|
|
829
|
+
const allEvents = historyFallback ? namespaceChanges.data : scopedEvents
|
|
830
|
+
const eventsLoading = historyFallback
|
|
831
|
+
? namespaceChanges.isLoading
|
|
832
|
+
: historyQuery.isLoading || (expanded && !historyGroupSettled)
|
|
803
833
|
|
|
804
834
|
// RBAC
|
|
805
835
|
const canUpdateSecrets = useCanUpdateSecrets()
|
|
@@ -1196,6 +1226,14 @@ export function WorkloadView({
|
|
|
1196
1226
|
refetch={refetchResourceAndRuns}
|
|
1197
1227
|
// Timeline
|
|
1198
1228
|
allEvents={allEvents}
|
|
1229
|
+
historyTruncated={!historyFallback && historyTruncated}
|
|
1230
|
+
onLoadOlderHistory={historyFallback ? undefined : loadOlderHistory}
|
|
1231
|
+
loadingOlderHistory={!historyFallback && loadingOlderHistory}
|
|
1232
|
+
olderHistoryError={historyFallback ? null : olderHistoryError}
|
|
1233
|
+
historyScoped={!historyFallback}
|
|
1234
|
+
historyIncomplete={!historyFallback && Boolean(historyQuery.data?.incomplete)}
|
|
1235
|
+
historyError={(historyFallback ? namespaceChanges.error : historyQuery.error) as Error | null}
|
|
1236
|
+
onRetryHistory={() => void (historyFallback ? namespaceChanges.refetch() : historyQuery.refetch())}
|
|
1199
1237
|
relatedTimelineEvents={relatedTimelineEvents}
|
|
1200
1238
|
eventsLoading={eventsLoading || (batchExecution && batchKind !== 'JobSet' && batchRunsQuery.isLoading)}
|
|
1201
1239
|
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
|
|
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
|
+
}
|