@skyhook-io/radar-app 1.14.6 → 1.15.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.
- package/package.json +1 -1
- package/src/App.tsx +51 -6
- package/src/api/client.admission.test.ts +34 -0
- package/src/api/client.ts +37 -3
- package/src/api/usage-data.fixtures.ts +33 -0
- package/src/api/usage-data.ts +165 -0
- package/src/components/CloudFunnelButton.test.tsx +2 -2
- package/src/components/CloudFunnelButton.tsx +99 -55
- package/src/components/SelfManagedStart.tsx +96 -0
- package/src/components/cloudConnectHandoff.test.ts +1 -1
- package/src/components/cloudConnectHandoff.ts +1 -1
- package/src/components/diagnose/ActivityTurn.tsx +3 -2
- package/src/components/diagnose/ApplyDialog.tsx +4 -2
- package/src/components/dialog-names.test.ts +69 -0
- package/src/components/execution/BatchExecutionView.tsx +1 -1
- package/src/components/execution/JobSetAdmission.test.tsx +160 -37
- package/src/components/execution/JobSetAdmission.tsx +16 -7
- package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
- package/src/components/home/HomeView.tsx +6 -2
- package/src/components/home/RadarVersionLine.test.tsx +14 -0
- package/src/components/home/RadarVersionLine.tsx +28 -3
- package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
- package/src/components/nav/PrimaryNavRail.tsx +25 -3
- package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
- package/src/components/settings/PrivacySection.test.tsx +84 -0
- package/src/components/settings/PrivacySection.tsx +140 -0
- package/src/components/settings/SettingsDialog.tsx +13 -52
- package/src/components/settings/controls.tsx +55 -0
- package/src/components/settings/settings-state.ts +1 -0
- package/src/components/ui/ErrorBoundary.tsx +5 -0
- package/src/components/ui/Omnibar.tsx +16 -0
- package/src/components/ui/UpdateNotification.tsx +13 -9
- package/src/components/ui/command-items.ts +14 -0
- package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
- package/src/components/usage-data/UsageDataAsk.tsx +106 -0
- package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
- package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
- package/src/components/whats-new/WhatsNew.test.ts +110 -0
- package/src/components/whats-new/WhatsNew.tsx +423 -0
- package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
- package/src/components/whats-new/check-whats-new.test.ts +65 -0
- package/src/components/whats-new/releaseNotes.ts +121 -0
- package/src/components/workload/WorkloadView.tsx +11 -1
- package/src/k8s-ui-exports.test.ts +73 -0
- package/src/utils/navigation.test.ts +17 -1
- package/src/utils/navigation.ts +16 -0
- package/src/utils/version.ts +11 -0
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -49,6 +49,9 @@ import { CapabilitiesProvider, useCapabilitiesContext } from './contexts/Capabil
|
|
|
49
49
|
import { UserMenu } from './components/UserMenu'
|
|
50
50
|
import { ErrorBoundary } from './components/ui/ErrorBoundary'
|
|
51
51
|
import { UpdateNotification } from './components/ui/UpdateNotification'
|
|
52
|
+
import { openWhatsNew, useWhatsNewStatus, WhatsNew } from './components/whats-new/WhatsNew'
|
|
53
|
+
import { useUsageData, useUsageRecording } from './api/usage-data'
|
|
54
|
+
import { UsageDataPrompt } from './components/usage-data/UsageDataPrompt'
|
|
52
55
|
import { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay'
|
|
53
56
|
import { DiagnosticsOverlay } from './components/ui/DiagnosticsOverlay'
|
|
54
57
|
import { useEventSource } from './hooks/useEventSource'
|
|
@@ -67,7 +70,7 @@ import { Tooltip } from './components/ui/Tooltip'
|
|
|
67
70
|
import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
|
|
68
71
|
import { SettingsDialog, type SettingsSectionId } from './components/settings/SettingsDialog'
|
|
69
72
|
import type { APIResource, TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
|
|
70
|
-
import { kindToPluralWithGroup, pluralToKind, openExternal, apiVersionToGroup, relatedResourcePath, searchHitToSelectedResource } from './utils/navigation'
|
|
73
|
+
import { kindToPluralWithGroup, pluralToKind, openExternal, apiVersionToGroup, relatedResourcePath, searchHitToSelectedResource, withCrossViewParams } from './utils/navigation'
|
|
71
74
|
import { findSelectedTopologyNode } from './utils/topology-selection'
|
|
72
75
|
import { type OmnibarHandle } from './components/ui/Omnibar'
|
|
73
76
|
import { RadarOmnibar } from './components/ui/RadarOmnibar'
|
|
@@ -144,6 +147,28 @@ function getViewFromPath(pathname: string): ExtendedMainView {
|
|
|
144
147
|
return 'home'
|
|
145
148
|
}
|
|
146
149
|
|
|
150
|
+
// The usage-data name for the current screen: a resource list names its
|
|
151
|
+
// kind's plural ("resources:deployments"); the server keeps built-in kinds
|
|
152
|
+
// only, so a custom resource's name never leaves.
|
|
153
|
+
function usageView(pathname: string, view: ExtendedMainView, upgrade: boolean): string {
|
|
154
|
+
if (upgrade) return 'upgrade'
|
|
155
|
+
if (view === 'resources') {
|
|
156
|
+
const plural = pathname.match(/^\/resources\/([^/]+)/)?.[1]
|
|
157
|
+
if (plural) return `resources:${plural.toLowerCase()}`
|
|
158
|
+
}
|
|
159
|
+
return view
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The screen a crash is counted under. Fixed names, because release builds
|
|
163
|
+
// shorten component names and the component stack can't say which screen.
|
|
164
|
+
const CRASH_LABELS: Record<ExtendedMainView, string> = {
|
|
165
|
+
home: 'Home', topology: 'Topology', resources: 'Resources', timeline: 'Timeline',
|
|
166
|
+
issues: 'Issues', helm: 'Helm', helmCompare: 'HelmCompare', traffic: 'Traffic',
|
|
167
|
+
cost: 'Cost', capacity: 'Capacity', checks: 'Checks', gitops: 'GitOps',
|
|
168
|
+
applications: 'Applications', workload: 'Workload', compare: 'Compare',
|
|
169
|
+
investigations: 'Investigations',
|
|
170
|
+
}
|
|
171
|
+
|
|
147
172
|
// The namespace scope filter is meaningful only on namespaced surfaces. On
|
|
148
173
|
// cluster-scoped views it does nothing, so we disable it with an explanation
|
|
149
174
|
// rather than leaving a dead control that silently ignores the pick:
|
|
@@ -417,6 +442,11 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
417
442
|
const mainView = getViewFromPath(location.pathname)
|
|
418
443
|
const upgradeReadinessRoute = location.pathname.startsWith('/checks/upgrade')
|
|
419
444
|
|
|
445
|
+
// Opt-in usage data. Embedded hosts own their own consent, so Radar never asks there.
|
|
446
|
+
const usageData = useUsageData(!navCustomization.embedded)
|
|
447
|
+
// A status cached by another screen must not start recording inside a host.
|
|
448
|
+
useUsageRecording(usageView(location.pathname, mainView, upgradeReadinessRoute), navCustomization.embedded ? undefined : usageData.data)
|
|
449
|
+
|
|
420
450
|
// Initialize the kind→plural discovery map app-wide (not just on ResourcesView
|
|
421
451
|
// mount) so the omnibar can open a CRD hit with an irregular plural from any
|
|
422
452
|
// view — kindToPlural would otherwise English-guess the route before a
|
|
@@ -492,6 +522,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
492
522
|
navigate({ pathname: path, search: newParams.toString() })
|
|
493
523
|
}, [location.search, navigate, takeover, goHost])
|
|
494
524
|
|
|
525
|
+
const whatsNewStatus = useWhatsNewStatus()
|
|
526
|
+
|
|
527
|
+
const navigateToPath = useCallback((path: string) => {
|
|
528
|
+
navigate(withCrossViewParams(path, location.search))
|
|
529
|
+
}, [location.search, navigate])
|
|
530
|
+
|
|
495
531
|
// The standalone rail expresses intent to leave the full-width investigation
|
|
496
532
|
// workspace. Close it before routing so the destination is immediately visible;
|
|
497
533
|
// docked investigations stay open across views as a persistent side panel.
|
|
@@ -777,13 +813,14 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
777
813
|
metadata: { namespace: resource.namespace ?? '', name: resource.name },
|
|
778
814
|
})
|
|
779
815
|
if (gitOpsPath) {
|
|
780
|
-
const destination = new URL(gitOpsPath, window.location.origin)
|
|
781
|
-
if (investigationRunID) destination.searchParams.
|
|
816
|
+
const destination = new URL(withCrossViewParams(gitOpsPath, searchParams.toString()), window.location.origin)
|
|
817
|
+
if (investigationRunID === null) destination.searchParams.delete('ai-run')
|
|
818
|
+
else if (investigationRunID) destination.searchParams.set('ai-run', investigationRunID)
|
|
782
819
|
navigate(`${destination.pathname}${destination.search}${destination.hash}`)
|
|
783
820
|
return
|
|
784
821
|
}
|
|
785
822
|
navigateToResourceList(resource, investigationRunID)
|
|
786
|
-
}, [navigate, navigateToHelmRelease, navigateToResourceList])
|
|
823
|
+
}, [navigate, navigateToHelmRelease, navigateToResourceList, searchParams])
|
|
787
824
|
|
|
788
825
|
// Collapse the over-list fullscreen back to the drawer = drop ?full=1 (and the
|
|
789
826
|
// resource-scoped ?tab) in place. The button means "collapse THIS to a drawer"
|
|
@@ -1732,6 +1769,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1732
1769
|
showPinToggle={!railForcedSlim}
|
|
1733
1770
|
onOpenSettings={() => openSettings()}
|
|
1734
1771
|
accountSlot={<UserMenu variant="rail" pinned={navRailEffectivePinned} />}
|
|
1772
|
+
whatsNew={whatsNewStatus.available ? { unread: whatsNewStatus.unread, onOpen: openWhatsNew } : undefined}
|
|
1735
1773
|
/>
|
|
1736
1774
|
)}
|
|
1737
1775
|
{/* `relative` makes this column the containing block for the absolute
|
|
@@ -1833,6 +1871,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1833
1871
|
onSetNamespaces={(ns) => { setNamespaces(ns); setActiveNamespace.mutate({ namespaces: ns }) }}
|
|
1834
1872
|
onToggleTheme={toggleTheme}
|
|
1835
1873
|
onShowDiagnostics={() => setShowDiagnostics(true)}
|
|
1874
|
+
onShowWhatsNew={whatsNewStatus.available ? openWhatsNew : undefined}
|
|
1836
1875
|
onOpenResource={(hit) => navigateToResourceList(searchHitToSelectedResource(hit))}
|
|
1837
1876
|
/>
|
|
1838
1877
|
</div>
|
|
@@ -1977,7 +2016,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1977
2016
|
{/* Search included, not just the path: selection inside a view rides in
|
|
1978
2017
|
the query (?resource=, ?release=), so a path-only key would still
|
|
1979
2018
|
strand a crash on the view that produced it. */}
|
|
1980
|
-
<ErrorBoundary
|
|
2019
|
+
<ErrorBoundary
|
|
2020
|
+
resetKey={location.pathname + location.search}
|
|
2021
|
+
usageLabel={upgradeReadinessRoute ? 'Upgrade' : CRASH_LABELS[mainView]}
|
|
2022
|
+
>
|
|
1981
2023
|
{/* Initial sync in progress: views that need the full cluster dataset
|
|
1982
2024
|
show per-kind progress instead; resource views work as kinds sync. */}
|
|
1983
2025
|
{viewsSyncGated && connection.syncStatus && (
|
|
@@ -1994,7 +2036,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1994
2036
|
fallbackClusterLoadState={showHomeClusterLoadFallback ? clusterLoadState : undefined}
|
|
1995
2037
|
onNavigateToView={setMainView}
|
|
1996
2038
|
onNavigateToHelmRelease={navCustomization.embedded ? undefined : navigateToHelmRelease}
|
|
1997
|
-
onNavigateToManagerPath={navCustomization.embedded || takeover.gitops ? undefined :
|
|
2039
|
+
onNavigateToManagerPath={navCustomization.embedded || takeover.gitops ? undefined : navigateToPath}
|
|
2040
|
+
onShowWhatsNew={!navCustomization.embedded && whatsNewStatus.available ? openWhatsNew : undefined}
|
|
1998
2041
|
// Upgrade impact lives under /checks, which a Cloud host takes
|
|
1999
2042
|
// over wholesale — its fleet pages have no upgrade sub-route, so
|
|
2000
2043
|
// the version line stays plain text there.
|
|
@@ -2435,6 +2478,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
2435
2478
|
|
|
2436
2479
|
{/* Update notification — hidden in embedded mode (OSS download nudge). */}
|
|
2437
2480
|
{!navCustomization.embedded && <UpdateNotification />}
|
|
2481
|
+
{!navCustomization.embedded && <WhatsNew onNavigate={navigateToPath} usageData={usageData.data} />}
|
|
2482
|
+
{!navCustomization.embedded && <UsageDataPrompt status={usageData.data} />}
|
|
2438
2483
|
|
|
2439
2484
|
{/* Bottom Dock for Terminal/Logs */}
|
|
2440
2485
|
<BottomDock />
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { useQuery } from '@tanstack/react-query'
|
|
3
|
+
import { ApiError, useKueueAdmission as admissionQuery } from './client'
|
|
4
|
+
|
|
5
|
+
vi.mock('@tanstack/react-query', async (original) => ({ ...await original<typeof import('@tanstack/react-query')>(), useQuery: vi.fn(() => ({})) }))
|
|
6
|
+
|
|
7
|
+
describe('Kueue admission polling', () => {
|
|
8
|
+
function options(settings?: Parameters<typeof admissionQuery>[3]) {
|
|
9
|
+
admissionQuery('ml', 'training', 'current', settings)
|
|
10
|
+
return vi.mocked(useQuery).mock.calls.at(-1)![0]
|
|
11
|
+
}
|
|
12
|
+
function interval(settings: Parameters<typeof admissionQuery>[3], state: any = {}) {
|
|
13
|
+
const poll = options(settings).refetchInterval as (query: any) => number | false
|
|
14
|
+
return poll({ state })
|
|
15
|
+
}
|
|
16
|
+
it('separates Job and JobSet identities and recreated roots', () => {
|
|
17
|
+
expect(options({ isJob: true }).queryKey).toEqual(['kueue-admission', 'batch', 'jobs', 'ml', 'training', 'current'])
|
|
18
|
+
expect(options({ isRayJob: true }).queryKey).toEqual(['kueue-admission', 'ray.io', 'rayjobs', 'ml', 'training', 'current'])
|
|
19
|
+
expect(options().queryKey).toEqual(['kueue-admission', 'jobset.x-k8s.io', 'jobsets', 'ml', 'training', 'current'])
|
|
20
|
+
})
|
|
21
|
+
it('polls quiet and terminal Jobs slowly, active admission quickly', () => {
|
|
22
|
+
expect(interval({ isJob: true })).toBe(30000)
|
|
23
|
+
expect(interval({ isJob: true, hinted: true })).toBe(5000)
|
|
24
|
+
expect(interval({ isJob: true }, { data: { workloads: [{}] } })).toBe(5000)
|
|
25
|
+
expect(interval({ isJob: true, hinted: true, terminal: true })).toBe(30000)
|
|
26
|
+
expect(interval(undefined)).toBe(5000)
|
|
27
|
+
})
|
|
28
|
+
it('stops on confirmed absence or denied requests, not transient outages or old absence', () => {
|
|
29
|
+
expect(interval({ isJob: true }, { data: { uid: 'current', installed: false } })).toBe(false)
|
|
30
|
+
expect(interval({ isJob: true, hinted: true }, { data: { uid: 'old', installed: false } })).toBe(5000)
|
|
31
|
+
expect(interval({ isJob: true }, { error: new ApiError('denied', 403) })).toBe(false)
|
|
32
|
+
expect(interval({ isJob: true, hinted: true }, { error: new ApiError('warming', 503) })).toBe(5000)
|
|
33
|
+
})
|
|
34
|
+
})
|
package/src/api/client.ts
CHANGED
|
@@ -1604,6 +1604,37 @@ export function useClusterInfo() {
|
|
|
1604
1604
|
export type InstallMethod =
|
|
1605
1605
|
"homebrew" | "krew" | "scoop" | "direct" | "desktop";
|
|
1606
1606
|
|
|
1607
|
+
export interface WhatsNewState {
|
|
1608
|
+
currentVersion: string;
|
|
1609
|
+
// "server": seenVersion / priorInstall are authoritative (local installs).
|
|
1610
|
+
// "browser": the client keeps its own record (in-cluster).
|
|
1611
|
+
storage: "server" | "browser";
|
|
1612
|
+
seenVersion?: string;
|
|
1613
|
+
priorInstall?: boolean;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
export function useWhatsNewState(enabled: boolean) {
|
|
1617
|
+
return useQuery<WhatsNewState>({
|
|
1618
|
+
queryKey: ["whats-new", getApiBase()],
|
|
1619
|
+
queryFn: () => fetchJSON("/whats-new"),
|
|
1620
|
+
enabled,
|
|
1621
|
+
// Refetched on focus (the app default is off), so another tab's
|
|
1622
|
+
// acknowledgment clears this tab's dot.
|
|
1623
|
+
staleTime: 0,
|
|
1624
|
+
refetchOnWindowFocus: true,
|
|
1625
|
+
retry: false,
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
export async function markWhatsNewSeen(version: string): Promise<void> {
|
|
1630
|
+
const response = await apiFetch(`${getApiBase()}/whats-new/seen`, {
|
|
1631
|
+
method: "POST",
|
|
1632
|
+
headers: { "Content-Type": "application/json" },
|
|
1633
|
+
body: JSON.stringify({ version }),
|
|
1634
|
+
});
|
|
1635
|
+
if (!response.ok) throw new ApiError(`HTTP ${response.status}`, response.status);
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1607
1638
|
export interface VersionInfo {
|
|
1608
1639
|
currentVersion: string;
|
|
1609
1640
|
latestVersion?: string;
|
|
@@ -6905,15 +6936,18 @@ export function useWorkloadRuns(
|
|
|
6905
6936
|
});
|
|
6906
6937
|
}
|
|
6907
6938
|
|
|
6908
|
-
export function useKueueAdmission(namespace: string, name: string, uid: string | undefined) {
|
|
6939
|
+
export function useKueueAdmission(namespace: string, name: string, uid: string | undefined, options?: { isJob?: boolean; isRayJob?: boolean; hinted?: boolean; terminal?: boolean }) {
|
|
6940
|
+
const group = options?.isRayJob ? 'ray.io' : options?.isJob ? 'batch' : 'jobset.x-k8s.io'
|
|
6941
|
+
const kind = options?.isRayJob ? 'rayjobs' : options?.isJob ? 'jobs' : 'jobsets'
|
|
6909
6942
|
return useQuery<KueueAdmissionResponse>({
|
|
6910
|
-
queryKey: ['kueue-admission',
|
|
6911
|
-
queryFn: () => fetchJSON(`/kueue/admission
|
|
6943
|
+
queryKey: ['kueue-admission', group, kind, namespace, name, uid],
|
|
6944
|
+
queryFn: () => fetchJSON(`/kueue/admission/${kind}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}?group=${group}`),
|
|
6912
6945
|
enabled: Boolean(namespace && name && uid),
|
|
6913
6946
|
staleTime: 5000,
|
|
6914
6947
|
refetchInterval: (query) => {
|
|
6915
6948
|
if (query.state.error instanceof ApiError && query.state.error.status < 500) return false
|
|
6916
6949
|
if (query.state.data?.uid === uid && query.state.data?.installed === false) return false
|
|
6950
|
+
if ((options?.isJob || options?.isRayJob) && (options.terminal || (!options.hinted && !query.state.data?.workloads.length))) return 30000
|
|
6917
6951
|
return 5000
|
|
6918
6952
|
},
|
|
6919
6953
|
retry: (count, error) => !(error instanceof ApiError && error.status < 500) && count < 2,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { UsageDataStatus, UsageReport } from './usage-data'
|
|
2
|
+
|
|
3
|
+
// Test-only: a report and status shaped like the server's.
|
|
4
|
+
export function exampleReport(patch: Partial<UsageReport> = {}): UsageReport {
|
|
5
|
+
return {
|
|
6
|
+
schema: 3,
|
|
7
|
+
version: '1.15.0', os: 'darwin', arch: 'arm64', installMethod: 'homebrew', mode: 'local',
|
|
8
|
+
periodStart: '2026-09-23', periodEnd: '2026-09-24',
|
|
9
|
+
setup: {
|
|
10
|
+
authMode: 'none', timelineStorage: 'memory', mcpEnabled: true, prometheus: 'none',
|
|
11
|
+
costSource: 'auto', browsers: ['chrome'],
|
|
12
|
+
},
|
|
13
|
+
engagement: { sessions: 2, activeMinutes: '20-49' },
|
|
14
|
+
views: {}, actions: {}, mcpTools: {}, uiEvents: {}, errors: {},
|
|
15
|
+
clusters: {
|
|
16
|
+
contexts: '5-9', used: 1,
|
|
17
|
+
shapes: [{ kubernetesVersion: '1.33', platform: 'eks', nodes: '10-19', integrations: ['argo-cd'] }],
|
|
18
|
+
},
|
|
19
|
+
...patch,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function exampleStatus(patch: Partial<UsageDataStatus> = {}): UsageDataStatus {
|
|
24
|
+
return {
|
|
25
|
+
state: 'undecided', source: 'default', canChange: true,
|
|
26
|
+
developmentBuild: false,
|
|
27
|
+
preview: exampleReport(),
|
|
28
|
+
firstRunPrompt: false,
|
|
29
|
+
ask: true,
|
|
30
|
+
shared: false,
|
|
31
|
+
...patch,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { useCallback, useEffect } from 'react'
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
3
|
+
import { fetchJSON } from './client'
|
|
4
|
+
import { apiUrl, getApiBase, getAuthHeaders, getCredentialsMode } from './config'
|
|
5
|
+
|
|
6
|
+
export type UsageDataState = 'undecided' | 'on' | 'off'
|
|
7
|
+
export type UsageDataSource = 'default' | 'user' | 'env' | 'deployment'
|
|
8
|
+
|
|
9
|
+
export interface ClusterShape {
|
|
10
|
+
kubernetesVersion: string
|
|
11
|
+
platform: string
|
|
12
|
+
nodes: string
|
|
13
|
+
integrations: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UsageReport {
|
|
17
|
+
schema: number
|
|
18
|
+
version: string
|
|
19
|
+
os: string
|
|
20
|
+
arch: string
|
|
21
|
+
installMethod: string
|
|
22
|
+
mode: string
|
|
23
|
+
periodStart: string
|
|
24
|
+
periodEnd: string
|
|
25
|
+
setup: {
|
|
26
|
+
authMode: string
|
|
27
|
+
timelineStorage: string
|
|
28
|
+
mcpEnabled: boolean
|
|
29
|
+
prometheus: string
|
|
30
|
+
costSource: string
|
|
31
|
+
browsers: string[]
|
|
32
|
+
}
|
|
33
|
+
engagement: { sessions: number; activeMinutes: string }
|
|
34
|
+
views: Record<string, number>
|
|
35
|
+
actions: Record<string, number>
|
|
36
|
+
mcpTools: Record<string, number>
|
|
37
|
+
uiEvents: Record<string, number>
|
|
38
|
+
errors: Record<string, number>
|
|
39
|
+
clusters: { contexts: string; used: number; shapes: ClusterShape[] }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface UsageDataStatus {
|
|
43
|
+
state: UsageDataState
|
|
44
|
+
source: UsageDataSource
|
|
45
|
+
canChange: boolean
|
|
46
|
+
developmentBuild: boolean
|
|
47
|
+
nextReportAt?: string
|
|
48
|
+
preview: UsageReport
|
|
49
|
+
firstRunPrompt: boolean
|
|
50
|
+
// What's New may ask: never asked, or asked and left unanswered long enough ago.
|
|
51
|
+
ask: boolean
|
|
52
|
+
// Several people use this Radar, so its configuration decides and nobody is asked.
|
|
53
|
+
shared: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const usageDataKey = (apiBase: string) => ['usage-data', apiBase]
|
|
57
|
+
|
|
58
|
+
// `fresh` is for the report preview: it must match what would be sent now, so
|
|
59
|
+
// it refetches every time it is shown instead of reusing the app shell's copy.
|
|
60
|
+
export function useUsageData(enabled = true, { fresh = false }: { fresh?: boolean } = {}) {
|
|
61
|
+
const apiBase = getApiBase()
|
|
62
|
+
return useQuery<UsageDataStatus>({
|
|
63
|
+
queryKey: usageDataKey(apiBase),
|
|
64
|
+
queryFn: () => fetchJSON<UsageDataStatus>('/usage-data'),
|
|
65
|
+
enabled,
|
|
66
|
+
staleTime: fresh ? 0 : 60_000,
|
|
67
|
+
refetchOnMount: fresh ? 'always' : true,
|
|
68
|
+
retry: false,
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function useSetUsageData() {
|
|
73
|
+
const queryClient = useQueryClient()
|
|
74
|
+
const apiBase = getApiBase()
|
|
75
|
+
return useMutation({
|
|
76
|
+
mutationFn: (enabled: boolean) =>
|
|
77
|
+
fetchJSON<UsageDataStatus>('/usage-data', {
|
|
78
|
+
method: 'PUT',
|
|
79
|
+
headers: { 'Content-Type': 'application/json' },
|
|
80
|
+
body: JSON.stringify({ enabled }),
|
|
81
|
+
}),
|
|
82
|
+
meta: { errorMessage: 'Failed to save usage data choice' },
|
|
83
|
+
// A status read still in flight predates the choice; it must not
|
|
84
|
+
// replace the answer the server just confirmed.
|
|
85
|
+
onSuccess: async (status) => {
|
|
86
|
+
const queryKey = usageDataKey(apiBase)
|
|
87
|
+
await queryClient.cancelQueries({ queryKey })
|
|
88
|
+
queryClient.setQueryData(queryKey, status)
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Records that the question was shown, answered or not, and stops this tab
|
|
94
|
+
// offering it again from its cached status. Fire and forget: a failure only
|
|
95
|
+
// means it might show once more.
|
|
96
|
+
export function useMarkUsagePromptShown() {
|
|
97
|
+
const queryClient = useQueryClient()
|
|
98
|
+
const apiBase = getApiBase()
|
|
99
|
+
return useCallback(() => {
|
|
100
|
+
const queryKey = usageDataKey(apiBase)
|
|
101
|
+
void queryClient.cancelQueries({ queryKey })
|
|
102
|
+
queryClient.setQueryData<UsageDataStatus>(queryKey, (prev) => prev && { ...prev, firstRunPrompt: false, ask: false })
|
|
103
|
+
void fetch(apiUrl('/usage-data/prompt-shown'), {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: getAuthHeaders(),
|
|
106
|
+
credentials: getCredentialsMode(),
|
|
107
|
+
}).catch(() => {})
|
|
108
|
+
}, [queryClient, apiBase])
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function isRecording(status: UsageDataStatus | undefined): boolean {
|
|
112
|
+
return status?.state === 'on'
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Set from the status query so code without hook access (error boundaries,
|
|
116
|
+
// event handlers) can skip the request entirely when usage data is off.
|
|
117
|
+
let recording = false
|
|
118
|
+
|
|
119
|
+
function setUsageRecording(on: boolean) {
|
|
120
|
+
recording = on
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
type UsageEvent =
|
|
124
|
+
| { type: 'view'; name: string }
|
|
125
|
+
| { type: 'ui'; name: string }
|
|
126
|
+
| { type: 'session' }
|
|
127
|
+
| { type: 'active'; minutes: number }
|
|
128
|
+
|
|
129
|
+
// Fire and forget; the server drops anything outside its allow-list.
|
|
130
|
+
export function recordUsageEvent(event: UsageEvent): void {
|
|
131
|
+
if (!recording) return
|
|
132
|
+
void fetch(apiUrl('/usage-data/event'), {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
|
|
135
|
+
credentials: getCredentialsMode(),
|
|
136
|
+
body: JSON.stringify(event),
|
|
137
|
+
keepalive: true,
|
|
138
|
+
}).catch(() => {})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const ACTIVE_TICK_MINUTES = 5
|
|
142
|
+
|
|
143
|
+
// Counts views, one session per page load, and visible time in five-minute
|
|
144
|
+
// ticks. Nothing is sent unless usage data is on.
|
|
145
|
+
export function useUsageRecording(view: string, status: UsageDataStatus | undefined) {
|
|
146
|
+
const on = isRecording(status)
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
setUsageRecording(on)
|
|
149
|
+
}, [on])
|
|
150
|
+
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (on) recordUsageEvent({ type: 'view', name: view })
|
|
153
|
+
}, [view, on])
|
|
154
|
+
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (!on) return
|
|
157
|
+
recordUsageEvent({ type: 'session' })
|
|
158
|
+
const tick = window.setInterval(() => {
|
|
159
|
+
if (document.visibilityState === 'visible') {
|
|
160
|
+
recordUsageEvent({ type: 'active', minutes: ACTIVE_TICK_MINUTES })
|
|
161
|
+
}
|
|
162
|
+
}, ACTIVE_TICK_MINUTES * 60_000)
|
|
163
|
+
return () => window.clearInterval(tick)
|
|
164
|
+
}, [on])
|
|
165
|
+
}
|
|
@@ -91,9 +91,9 @@ describe('Cloud dialog connection availability', () => {
|
|
|
91
91
|
expect(requests).not.toContain('/api/cloud/install/discover')
|
|
92
92
|
expect(requests).not.toContain('/api/cloud/install/prepare')
|
|
93
93
|
await act(async () => { button('How it works and what it costs')!.click() })
|
|
94
|
-
expect(document.body.textContent).toContain('
|
|
94
|
+
expect(document.body.textContent).toContain('Radar runs in your cluster and tunnels outward to Radar Cloud')
|
|
95
95
|
expect(document.body.textContent).not.toContain('Setup runs here in the app')
|
|
96
|
-
expect(document.body.textContent).not.toContain('
|
|
96
|
+
expect(document.body.textContent).not.toContain('see what gets installed')
|
|
97
97
|
})
|
|
98
98
|
|
|
99
99
|
it('restores cluster discovery and Connect in the open dialog when the connection recovers', async () => {
|