@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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import type { UsageDataStatus } from '../../api/usage-data'
|
|
5
|
+
import { exampleReport, exampleStatus } from '../../api/usage-data.fixtures'
|
|
6
|
+
|
|
7
|
+
let current: UsageDataStatus
|
|
8
|
+
|
|
9
|
+
vi.mock('../../api/usage-data', async (orig) => ({
|
|
10
|
+
...(await orig<typeof import('../../api/usage-data')>()),
|
|
11
|
+
useUsageData: () => ({ data: current, isLoading: false, error: null }),
|
|
12
|
+
useSetUsageData: () => ({ mutate: vi.fn(), isPending: false }),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
import { PrivacySection } from './PrivacySection'
|
|
16
|
+
|
|
17
|
+
function make(patch: Partial<UsageDataStatus>): UsageDataStatus {
|
|
18
|
+
return exampleStatus({ preview: exampleReport({ views: { topology: 3 } }), ...patch })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function render(status: UsageDataStatus) {
|
|
22
|
+
current = status
|
|
23
|
+
return renderToString(
|
|
24
|
+
<QueryClientProvider client={new QueryClient()}>
|
|
25
|
+
<PrivacySection active />
|
|
26
|
+
</QueryClientProvider>,
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('PrivacySection', () => {
|
|
31
|
+
it('shows an example report and promises nothing is sent while off', () => {
|
|
32
|
+
const html = render(make({}))
|
|
33
|
+
expect(html).toContain('See an example report')
|
|
34
|
+
expect(html).toContain('What isn')
|
|
35
|
+
expect(html).not.toContain('usage-report.json')
|
|
36
|
+
expect(html).toContain('aria-checked="false"')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('shows the pending report and where it lives once on', () => {
|
|
40
|
+
const html = render(make({ state: 'on', source: 'user', nextReportAt: '2026-09-30T12:00:00Z' }))
|
|
41
|
+
expect(html).toContain('See the next report')
|
|
42
|
+
expect(html).toContain('usage-report.json')
|
|
43
|
+
expect(html).toContain('"topology": 3')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('names the control that fixed the choice', () => {
|
|
47
|
+
expect(render(make({ state: 'off', source: 'deployment', canChange: false }))).toContain('Radar Cloud manages')
|
|
48
|
+
const helm = make({ state: 'on', source: 'env', canChange: false, shared: true })
|
|
49
|
+
expect(render({ ...helm, preview: { ...helm.preview, mode: 'in-cluster' } })).toContain('Helm value usageReporting.enabled')
|
|
50
|
+
expect(render(make({ state: 'off', source: 'env', canChange: false }))).toContain('RADAR_USAGE_REPORTING=off')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('PrivacySection intro', () => {
|
|
55
|
+
it('does not claim data stays on this machine for an in-cluster install', () => {
|
|
56
|
+
const base = make({ state: 'off', source: 'deployment', canChange: false })
|
|
57
|
+
const html = render({ ...base, preview: { ...base.preview, mode: 'in-cluster' } })
|
|
58
|
+
expect(html).toContain('from inside it')
|
|
59
|
+
expect(html).not.toContain('from this machine')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('PrivacySection on a shared Radar', () => {
|
|
64
|
+
it('points at the Helm value while nothing set it', () => {
|
|
65
|
+
const base = make({ shared: true, canChange: false })
|
|
66
|
+
const html = render({ ...base, preview: { ...base.preview, mode: 'in-cluster' } })
|
|
67
|
+
expect(html).toContain('Applies to everyone using this Radar.')
|
|
68
|
+
expect(html).toContain('usageReporting.enabled')
|
|
69
|
+
expect(html).toContain('aria-checked="false"')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('uses the environment variable outside a cluster', () => {
|
|
73
|
+
expect(render(make({ shared: true, canChange: false }))).toContain('RADAR_USAGE_REPORTING=on')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('PrivacySection storage and log wording', () => {
|
|
78
|
+
it('says logs, not sends, for development builds', () => {
|
|
79
|
+
const html = render(make({ state: 'on', source: 'user', developmentBuild: true, nextReportAt: '2026-09-25T12:00:00Z' }))
|
|
80
|
+
expect(html).toContain('logs')
|
|
81
|
+
expect(html).toContain('reports go to the log, never sent')
|
|
82
|
+
expect(render(make({ state: 'on', source: 'user', nextReportAt: '2026-09-25T12:00:00Z' }))).toContain('sends')
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { ExternalLink } from 'lucide-react'
|
|
3
|
+
import { Collapse, CollapseChevron, useDisclosure } from '@skyhook-io/k8s-ui/components/ui/Collapse'
|
|
4
|
+
import { ConfigToggle, SubHeading } from './controls'
|
|
5
|
+
import { isRecording, useSetUsageData, useUsageData, type UsageDataStatus } from '../../api/usage-data'
|
|
6
|
+
|
|
7
|
+
const DOCS_URL = 'https://radarhq.io/docs/configuration/usage-stats'
|
|
8
|
+
|
|
9
|
+
export function PrivacySection({ active }: { active: boolean }) {
|
|
10
|
+
const { data: status, isLoading, error, refetch } = useUsageData(active, { fresh: true })
|
|
11
|
+
const setUsageData = useSetUsageData()
|
|
12
|
+
|
|
13
|
+
// The pane stays mounted while hidden, so refetch each time it is revealed.
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (active) void refetch()
|
|
16
|
+
}, [active, refetch])
|
|
17
|
+
|
|
18
|
+
if (isLoading) {
|
|
19
|
+
return <p className="text-sm text-theme-text-tertiary">Loading…</p>
|
|
20
|
+
}
|
|
21
|
+
if (error || !status) {
|
|
22
|
+
return <p className="text-sm text-theme-text-secondary">Couldn't load privacy settings.</p>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const recording = isRecording(status)
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="space-y-6">
|
|
29
|
+
<p className="text-sm text-theme-text-secondary">
|
|
30
|
+
{status.preview.mode === 'in-cluster' || status.preview.mode === 'cloud'
|
|
31
|
+
? 'Radar reads this cluster from inside it and keeps what it reads there.'
|
|
32
|
+
: 'Radar reads your cluster from this machine and keeps what it reads here.'}
|
|
33
|
+
</p>
|
|
34
|
+
|
|
35
|
+
<section className="space-y-3">
|
|
36
|
+
<SubHeading>Usage stats</SubHeading>
|
|
37
|
+
<ConfigToggle
|
|
38
|
+
label="Send anonymous usage stats"
|
|
39
|
+
description={<>Shows us which features get used, so we know what to build next.<br />Sent straight to us, with no third-party trackers.</>}
|
|
40
|
+
value={recording}
|
|
41
|
+
disabled={!status.canChange || setUsageData.isPending}
|
|
42
|
+
onChange={(v) => setUsageData.mutate(v)}
|
|
43
|
+
/>
|
|
44
|
+
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-xs">
|
|
45
|
+
<dt className="text-theme-text-tertiary">What's sent</dt>
|
|
46
|
+
<dd className="text-theme-text-secondary">
|
|
47
|
+
The views you open, the actions and MCP tools you use, and each cluster's version,
|
|
48
|
+
platform, size range and known integrations.
|
|
49
|
+
</dd>
|
|
50
|
+
<dt className="text-theme-text-tertiary">What isn't</dt>
|
|
51
|
+
<dd className="text-theme-text-secondary">
|
|
52
|
+
Names, contents, logs, identifiers, timestamps, or the resources in your clusters.
|
|
53
|
+
</dd>
|
|
54
|
+
</dl>
|
|
55
|
+
<ManagedReason status={status} />
|
|
56
|
+
<SharedNote status={status} />
|
|
57
|
+
<ReportPreview status={status} />
|
|
58
|
+
</section>
|
|
59
|
+
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function ManagedReason({ status }: { status: UsageDataStatus }) {
|
|
65
|
+
let text: string | null = null
|
|
66
|
+
switch (status.source) {
|
|
67
|
+
case 'env':
|
|
68
|
+
text = status.shared && status.preview.mode === 'in-cluster'
|
|
69
|
+
? 'Set for everyone by the Helm value usageReporting.enabled.'
|
|
70
|
+
: `Set by RADAR_USAGE_REPORTING=${status.state}.`
|
|
71
|
+
break
|
|
72
|
+
case 'deployment':
|
|
73
|
+
text = 'Radar Cloud manages usage data for this installation.'
|
|
74
|
+
break
|
|
75
|
+
}
|
|
76
|
+
if (!text && status.developmentBuild && isRecording(status)) {
|
|
77
|
+
text = 'Development build: reports go to the log, never sent.'
|
|
78
|
+
}
|
|
79
|
+
return text ? <p className="text-xs text-theme-text-tertiary">{text}</p> : null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A shared Radar is decided by its configuration; say where, while nothing set it.
|
|
83
|
+
function SharedNote({ status }: { status: UsageDataStatus }) {
|
|
84
|
+
if (!status.shared || status.source !== 'default') return null
|
|
85
|
+
return (
|
|
86
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
87
|
+
Applies to everyone using this Radar. Set it with{' '}
|
|
88
|
+
{status.preview.mode === 'in-cluster'
|
|
89
|
+
? <>the Helm value <code className="inline-code">usageReporting.enabled</code></>
|
|
90
|
+
: <code className="inline-code">RADAR_USAGE_REPORTING=on</code>}.
|
|
91
|
+
</p>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function ReportPreview({ status }: { status: UsageDataStatus }) {
|
|
96
|
+
const recording = isRecording(status)
|
|
97
|
+
const [open, setOpen] = useState(false)
|
|
98
|
+
const disclosure = useDisclosure(open)
|
|
99
|
+
// Development builds write the report to the log instead of sending it.
|
|
100
|
+
const logsOnly = status.developmentBuild
|
|
101
|
+
const next = status.nextReportAt ? new Date(status.nextReportAt) : null
|
|
102
|
+
return (
|
|
103
|
+
<div className="space-y-1.5">
|
|
104
|
+
<div className="flex items-center justify-between gap-3">
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={() => setOpen(o => !o)}
|
|
108
|
+
{...disclosure.buttonProps}
|
|
109
|
+
className="flex items-center gap-1 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
|
|
110
|
+
>
|
|
111
|
+
<CollapseChevron open={open} />
|
|
112
|
+
{recording ? 'See the next report' : 'See an example report'}
|
|
113
|
+
{recording && next && (
|
|
114
|
+
<span className="font-normal text-theme-text-tertiary">
|
|
115
|
+
{' '}· {logsOnly ? 'logs' : 'sends'} {next.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
|
116
|
+
</span>
|
|
117
|
+
)}
|
|
118
|
+
</button>
|
|
119
|
+
<a
|
|
120
|
+
href={DOCS_URL}
|
|
121
|
+
target="_blank"
|
|
122
|
+
rel="noopener noreferrer"
|
|
123
|
+
className="inline-flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:underline"
|
|
124
|
+
>
|
|
125
|
+
How it works <ExternalLink className="w-3 h-3" aria-hidden />
|
|
126
|
+
</a>
|
|
127
|
+
</div>
|
|
128
|
+
<Collapse open={open} id={disclosure.panelId}>
|
|
129
|
+
<pre className="max-h-80 overflow-auto rounded-md border border-theme-border bg-theme-elevated px-3 py-2 text-[11px] leading-relaxed font-mono text-theme-text-primary">
|
|
130
|
+
{JSON.stringify(status.preview, null, 2)}
|
|
131
|
+
</pre>
|
|
132
|
+
</Collapse>
|
|
133
|
+
{recording && status.canChange && (
|
|
134
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
135
|
+
Waiting in <code className="inline-code">~/.radar/usage-report.json</code>. Turning this off deletes it.
|
|
136
|
+
</p>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'
|
|
|
3
3
|
import {
|
|
4
4
|
Settings, X, RotateCcw, RotateCw, Loader2, Copy, Check, Pin, Shield, Lock, Plug,
|
|
5
5
|
Plus, Terminal, Boxes, Activity, GitBranch, Sparkles, SlidersHorizontal, Zap,
|
|
6
|
-
LayoutDashboard, ChevronRight, ExternalLink, Download, AlertTriangle, Coins,
|
|
6
|
+
LayoutDashboard, ChevronRight, ExternalLink, Download, AlertTriangle, Coins, EyeOff,
|
|
7
7
|
type LucideIcon,
|
|
8
8
|
} from 'lucide-react'
|
|
9
9
|
import { clsx } from 'clsx'
|
|
@@ -21,6 +21,8 @@ import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Coll
|
|
|
21
21
|
import { Tooltip } from '../ui/Tooltip'
|
|
22
22
|
import { AISettingsSection, type AIDraft } from '../diagnose/AISettings'
|
|
23
23
|
import { MyPermissionsContent } from './MyPermissionsDialog'
|
|
24
|
+
import { PrivacySection } from './PrivacySection'
|
|
25
|
+
import { ConfigToggle, SubHeading } from './controls'
|
|
24
26
|
import { useDiagnose } from '../diagnose/DiagnoseContext'
|
|
25
27
|
import { currencyOptionsForValue } from './currency-options'
|
|
26
28
|
import { versionUpdateURL } from '../../utils/version'
|
|
@@ -449,6 +451,7 @@ export function SettingsDialog({
|
|
|
449
451
|
{ id: 'cost', label: 'Cost', icon: Coins, ownerOnly: true, dirty: costIntegrationDirty },
|
|
450
452
|
{ id: 'argocd', label: 'Argo CD', icon: GitBranch, ownerOnly: true, dirty: false },
|
|
451
453
|
{ id: 'ai', label: 'AI investigations', icon: Sparkles, ownerOnly: false, dirty: aiDirty },
|
|
454
|
+
{ id: 'privacy', label: 'Privacy', icon: EyeOff, ownerOnly: false, dirty: false },
|
|
452
455
|
{ id: 'advanced', label: 'Advanced', icon: SlidersHorizontal, ownerOnly: true, dirty: advancedDirty },
|
|
453
456
|
]
|
|
454
457
|
|
|
@@ -510,6 +513,7 @@ export function SettingsDialog({
|
|
|
510
513
|
Radar{versionInfo?.currentVersion ? ` v${versionInfo.currentVersion}` : ''}
|
|
511
514
|
<span className="text-theme-text-disabled"> · by Skyhook</span>
|
|
512
515
|
</span>
|
|
516
|
+
|
|
513
517
|
</div>
|
|
514
518
|
</div>
|
|
515
519
|
<button
|
|
@@ -562,10 +566,10 @@ export function SettingsDialog({
|
|
|
562
566
|
</div>
|
|
563
567
|
)}
|
|
564
568
|
|
|
565
|
-
{!configData && !['overview', 'perms', 'ai'].includes(section) ? (
|
|
569
|
+
{!configData && !['overview', 'perms', 'ai', 'privacy'].includes(section) ? (
|
|
566
570
|
<p className="text-sm text-theme-text-secondary">{loadError ? 'Configuration is unavailable. Close Settings and try again.' : 'Loading configuration…'}</p>
|
|
567
571
|
) : <>
|
|
568
|
-
{operatorManaged && section !== 'perms' && section !== 'ai' && <div className="mb-4"><OperatorManagedNotice /></div>}
|
|
572
|
+
{operatorManaged && section !== 'perms' && section !== 'ai' && section !== 'privacy' && <div className="mb-4"><OperatorManagedNotice /></div>}
|
|
569
573
|
{/* Overview — status at a glance; the landing section */}
|
|
570
574
|
<div className={clsx(section !== 'overview' && 'hidden')} role="tabpanel" inert={section !== 'overview' || undefined}>
|
|
571
575
|
<div className="mb-1">
|
|
@@ -796,6 +800,12 @@ export function SettingsDialog({
|
|
|
796
800
|
)}
|
|
797
801
|
</div>
|
|
798
802
|
|
|
803
|
+
{/* Privacy: the usage-stats choice. Readable by everyone; only a local
|
|
804
|
+
install can change it here. */}
|
|
805
|
+
<SectionPane id="privacy" active={section} title="Privacy" caption="Applies immediately." live>
|
|
806
|
+
<PrivacySection active={section === 'privacy'} />
|
|
807
|
+
</SectionPane>
|
|
808
|
+
|
|
799
809
|
{/* Advanced — MCP + Timeline merged */}
|
|
800
810
|
<SectionPane
|
|
801
811
|
id="advanced"
|
|
@@ -954,16 +964,6 @@ interface NavItemDef {
|
|
|
954
964
|
dirty: boolean
|
|
955
965
|
}
|
|
956
966
|
|
|
957
|
-
// Light subheading separating the two field groups inside a merged pane
|
|
958
|
-
// (Cluster/Server, MCP/Timeline).
|
|
959
|
-
function SubHeading({ children }: { children: ReactNode }) {
|
|
960
|
-
return (
|
|
961
|
-
<h4 className="text-xs font-semibold uppercase tracking-wider text-theme-text-tertiary">
|
|
962
|
-
{children}
|
|
963
|
-
</h4>
|
|
964
|
-
)
|
|
965
|
-
}
|
|
966
|
-
|
|
967
967
|
function NavItem({
|
|
968
968
|
item,
|
|
969
969
|
active,
|
|
@@ -2798,45 +2798,6 @@ function ConfigNumberField({
|
|
|
2798
2798
|
)
|
|
2799
2799
|
}
|
|
2800
2800
|
|
|
2801
|
-
function ConfigToggle({
|
|
2802
|
-
label,
|
|
2803
|
-
description,
|
|
2804
|
-
value,
|
|
2805
|
-
onChange,
|
|
2806
|
-
}: {
|
|
2807
|
-
label: string
|
|
2808
|
-
description?: string
|
|
2809
|
-
value: boolean
|
|
2810
|
-
onChange: (value: boolean) => void
|
|
2811
|
-
}) {
|
|
2812
|
-
return (
|
|
2813
|
-
<label className="flex items-start justify-between gap-3 py-1 cursor-pointer">
|
|
2814
|
-
<span className="min-w-0">
|
|
2815
|
-
<span className="block text-sm text-theme-text-primary">{label}</span>
|
|
2816
|
-
{description && (
|
|
2817
|
-
<span className="mt-0.5 block text-xs text-theme-text-tertiary">{description}</span>
|
|
2818
|
-
)}
|
|
2819
|
-
</span>
|
|
2820
|
-
<button
|
|
2821
|
-
role="switch"
|
|
2822
|
-
aria-checked={value}
|
|
2823
|
-
onClick={() => onChange(!value)}
|
|
2824
|
-
className={clsx(
|
|
2825
|
-
'relative w-9 h-5 shrink-0 rounded-full transition-colors',
|
|
2826
|
-
value ? 'bg-skyhook-600' : 'bg-theme-elevated border border-theme-border'
|
|
2827
|
-
)}
|
|
2828
|
-
>
|
|
2829
|
-
<span
|
|
2830
|
-
className={clsx(
|
|
2831
|
-
'absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform shadow-sm',
|
|
2832
|
-
value && 'translate-x-4'
|
|
2833
|
-
)}
|
|
2834
|
-
/>
|
|
2835
|
-
</button>
|
|
2836
|
-
</label>
|
|
2837
|
-
)
|
|
2838
|
-
}
|
|
2839
|
-
|
|
2840
2801
|
function EffectiveHint({
|
|
2841
2802
|
current,
|
|
2842
2803
|
effective,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
|
|
4
|
+
export function SubHeading({ children }: { children: ReactNode }) {
|
|
5
|
+
return (
|
|
6
|
+
<h4 className="text-xs font-semibold uppercase tracking-wider text-theme-text-tertiary">
|
|
7
|
+
{children}
|
|
8
|
+
</h4>
|
|
9
|
+
)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ConfigToggle({
|
|
13
|
+
label,
|
|
14
|
+
description,
|
|
15
|
+
value,
|
|
16
|
+
disabled,
|
|
17
|
+
onChange,
|
|
18
|
+
}: {
|
|
19
|
+
label: string
|
|
20
|
+
description?: ReactNode
|
|
21
|
+
value: boolean
|
|
22
|
+
disabled?: boolean
|
|
23
|
+
onChange: (value: boolean) => void
|
|
24
|
+
}) {
|
|
25
|
+
return (
|
|
26
|
+
<label className={clsx('flex items-start justify-between gap-3 py-1', disabled ? 'cursor-not-allowed' : 'cursor-pointer')}>
|
|
27
|
+
<span className="min-w-0">
|
|
28
|
+
<span className={clsx('block text-sm', disabled ? 'text-theme-text-secondary' : 'text-theme-text-primary')}>{label}</span>
|
|
29
|
+
{description && (
|
|
30
|
+
<span className="mt-0.5 block text-xs text-theme-text-tertiary">{description}</span>
|
|
31
|
+
)}
|
|
32
|
+
</span>
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
role="switch"
|
|
36
|
+
aria-checked={value}
|
|
37
|
+
aria-label={label}
|
|
38
|
+
disabled={disabled}
|
|
39
|
+
onClick={() => onChange(!value)}
|
|
40
|
+
className={clsx(
|
|
41
|
+
'relative w-9 h-5 shrink-0 rounded-full transition-colors',
|
|
42
|
+
value ? 'bg-skyhook-600' : 'bg-theme-elevated border border-theme-border',
|
|
43
|
+
disabled && 'opacity-50'
|
|
44
|
+
)}
|
|
45
|
+
>
|
|
46
|
+
<span
|
|
47
|
+
className={clsx(
|
|
48
|
+
'absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform shadow-sm',
|
|
49
|
+
value && 'translate-x-4'
|
|
50
|
+
)}
|
|
51
|
+
/>
|
|
52
|
+
</button>
|
|
53
|
+
</label>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Component, ReactNode } from 'react'
|
|
2
2
|
import { AlertTriangle, RefreshCw } from 'lucide-react'
|
|
3
|
+
import { recordUsageEvent } from '../../api/usage-data'
|
|
3
4
|
|
|
4
5
|
interface Props {
|
|
5
6
|
children: ReactNode
|
|
@@ -12,6 +13,8 @@ interface Props {
|
|
|
12
13
|
* while nothing is wrong.
|
|
13
14
|
*/
|
|
14
15
|
resetKey: string | number
|
|
16
|
+
/** Fixed name of the screen inside, counted in usage stats when it crashes. */
|
|
17
|
+
usageLabel?: string
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
interface State {
|
|
@@ -34,6 +37,8 @@ export class ErrorBoundary extends Component<Props, State> {
|
|
|
34
37
|
|
|
35
38
|
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
|
36
39
|
console.error('[ErrorBoundary]', error, info.componentStack)
|
|
40
|
+
// Only which screen crashed is counted, never the message.
|
|
41
|
+
if (this.props.usageLabel) recordUsageEvent({ type: 'ui', name: `ui_error:${this.props.usageLabel}` })
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
handleReset = () => this.setState({ hasError: false, error: null })
|
|
@@ -7,6 +7,7 @@ import { TRANSITION_BACKDROP, TRANSITION_MENU, TW_EASE_UI, overlayExitMs, overla
|
|
|
7
7
|
import { SearchPillInput, type SearchModifier } from '@skyhook-io/k8s-ui'
|
|
8
8
|
import { getResourceIcon } from '../../utils/resource-icons'
|
|
9
9
|
import type { SearchHit, SearchMatchedField } from '../../api/client'
|
|
10
|
+
import { recordUsageEvent } from '../../api/usage-data'
|
|
10
11
|
import { bestScore, type CommandItem } from './command-items'
|
|
11
12
|
import { SearchSyntaxHelp } from './SearchSyntaxHelp'
|
|
12
13
|
|
|
@@ -182,6 +183,21 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
|
|
|
182
183
|
|
|
183
184
|
useImperativeHandle(ref, () => ({ focus: () => { inputRef.current?.focus(); inputRef.current?.select() } }), [])
|
|
184
185
|
|
|
186
|
+
// Usage data: one count per opening, one per opening that searched. Never
|
|
187
|
+
// the query itself.
|
|
188
|
+
const searchedThisOpen = useRef(false)
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (!open) return
|
|
191
|
+
searchedThisOpen.current = false
|
|
192
|
+
recordUsageEvent({ type: 'ui', name: 'command_palette' })
|
|
193
|
+
}, [open])
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
if (open && text.trim() && !searchedThisOpen.current) {
|
|
196
|
+
searchedThisOpen.current = true
|
|
197
|
+
recordUsageEvent({ type: 'ui', name: 'search' })
|
|
198
|
+
}
|
|
199
|
+
}, [open, text])
|
|
200
|
+
|
|
185
201
|
// Hero autofocus parks the cursor in the field on mount (Home's primary
|
|
186
202
|
// action) but must NOT pop the dropdown — landing on the page shouldn't dim
|
|
187
203
|
// it behind a command palette. Suppress exactly the programmatic focus; a
|
|
@@ -16,6 +16,17 @@ import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
|
|
|
16
16
|
|
|
17
17
|
const DISMISSED_KEY = 'radar-update-dismissed'
|
|
18
18
|
|
|
19
|
+
// Whether the notice for this release was dismissed. The usage-data card
|
|
20
|
+
// shares the corner and waits for it.
|
|
21
|
+
export function isUpdateDismissed(latestVersion: string): boolean {
|
|
22
|
+
try {
|
|
23
|
+
return localStorage.getItem(DISMISSED_KEY) === latestVersion
|
|
24
|
+
} catch {
|
|
25
|
+
// localStorage unavailable (e.g. Safari private mode)
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
export function UpdateNotification() {
|
|
20
31
|
const queryClient = useQueryClient()
|
|
21
32
|
const { data: capabilities } = useCapabilities()
|
|
@@ -54,15 +65,8 @@ export function UpdateNotification() {
|
|
|
54
65
|
|
|
55
66
|
// Check if this version was already dismissed
|
|
56
67
|
useEffect(() => {
|
|
57
|
-
if (versionInfo?.latestVersion) {
|
|
58
|
-
|
|
59
|
-
const dismissedVersion = localStorage.getItem(DISMISSED_KEY)
|
|
60
|
-
if (dismissedVersion === versionInfo.latestVersion) {
|
|
61
|
-
setDismissed(true)
|
|
62
|
-
}
|
|
63
|
-
} catch {
|
|
64
|
-
// localStorage unavailable (e.g. Safari private mode)
|
|
65
|
-
}
|
|
68
|
+
if (versionInfo?.latestVersion && isUpdateDismissed(versionInfo.latestVersion)) {
|
|
69
|
+
setDismissed(true)
|
|
66
70
|
}
|
|
67
71
|
}, [versionInfo?.latestVersion])
|
|
68
72
|
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
AlertTriangle,
|
|
16
16
|
Boxes,
|
|
17
17
|
Server,
|
|
18
|
+
Megaphone,
|
|
18
19
|
} from "lucide-react";
|
|
19
20
|
import { useNamespaces, useContexts } from "../../api/client";
|
|
20
21
|
import { CORE_RESOURCES, useAPIResources } from "../../api/apiResources";
|
|
@@ -124,6 +125,8 @@ export interface CommandItemCallbacks {
|
|
|
124
125
|
onSetNamespaces: (ns: string[]) => void;
|
|
125
126
|
onToggleTheme: () => void;
|
|
126
127
|
onShowDiagnostics?: () => void;
|
|
128
|
+
// Present only when release notes exist for this version.
|
|
129
|
+
onShowWhatsNew?: () => void;
|
|
127
130
|
}
|
|
128
131
|
|
|
129
132
|
const VIEW_ENTRIES: {
|
|
@@ -262,6 +265,16 @@ export function useCommandItems(cb: CommandItemCallbacks): CommandItem[] {
|
|
|
262
265
|
shortcut: "t",
|
|
263
266
|
action: () => cb.onToggleTheme(),
|
|
264
267
|
});
|
|
268
|
+
if (cb.onShowWhatsNew) {
|
|
269
|
+
result.push({
|
|
270
|
+
id: "action-whats-new",
|
|
271
|
+
label: "What's new",
|
|
272
|
+
category: "Actions",
|
|
273
|
+
icon: Megaphone,
|
|
274
|
+
action: () => cb.onShowWhatsNew?.(),
|
|
275
|
+
searchTerms: ["release notes", "changelog", "updates", "new features"],
|
|
276
|
+
});
|
|
277
|
+
}
|
|
265
278
|
if (cb.onShowDiagnostics) {
|
|
266
279
|
result.push({
|
|
267
280
|
id: "action-diagnostics",
|
|
@@ -285,5 +298,6 @@ export function useCommandItems(cb: CommandItemCallbacks): CommandItem[] {
|
|
|
285
298
|
cb.onSetNamespaces,
|
|
286
299
|
cb.onToggleTheme,
|
|
287
300
|
cb.onShowDiagnostics,
|
|
301
|
+
cb.onShowWhatsNew,
|
|
288
302
|
]);
|
|
289
303
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { renderToString } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import type { UsageDataStatus } from '../../api/usage-data'
|
|
4
|
+
import { exampleReport, exampleStatus } from '../../api/usage-data.fixtures'
|
|
5
|
+
|
|
6
|
+
vi.mock('../../api/usage-data', () => ({
|
|
7
|
+
useSetUsageData: () => ({ mutate: vi.fn(), isPending: false }),
|
|
8
|
+
useMarkUsagePromptShown: () => vi.fn(),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
import { UsageDataAsk } from './UsageDataAsk'
|
|
12
|
+
|
|
13
|
+
function status(patch: Partial<UsageDataStatus>): UsageDataStatus {
|
|
14
|
+
return exampleStatus({ preview: exampleReport({ views: {} }), ...patch })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const render = (s: UsageDataStatus | undefined) =>
|
|
18
|
+
renderToString(<UsageDataAsk usageData={s} onReadMore={() => {}} />)
|
|
19
|
+
|
|
20
|
+
describe('UsageDataAsk', () => {
|
|
21
|
+
it('asks an undecided user who can decide', () => {
|
|
22
|
+
const html = render(status({}))
|
|
23
|
+
expect(html).toContain('Help improve Radar')
|
|
24
|
+
expect(html).toContain('Anonymous, with no third-party trackers.')
|
|
25
|
+
expect(html).toContain('See what')
|
|
26
|
+
expect(html).toContain('Send usage stats')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('stays out of the way once answered or where the viewer cannot decide', () => {
|
|
30
|
+
expect(render(status({ state: 'on', source: 'user' }))).toBe('')
|
|
31
|
+
expect(render(status({ state: 'off', source: 'user' }))).toBe('')
|
|
32
|
+
expect(render(status({ state: 'off', source: 'deployment', canChange: false }))).toBe('')
|
|
33
|
+
expect(render(status({ state: 'off', source: 'env', canChange: false }))).toBe('')
|
|
34
|
+
expect(render(status({ shared: true, canChange: false, ask: false }))).toBe('')
|
|
35
|
+
expect(render(undefined)).toBe('')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('waits when the server says it asked recently', () => {
|
|
39
|
+
expect(render(status({ ask: false }))).toBe('')
|
|
40
|
+
})
|
|
41
|
+
})
|