@skyhook-io/radar-app 1.14.5 → 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/README.md +2 -9
- package/package.json +6 -6
- package/src/App.tsx +142 -334
- package/src/RadarApp.tsx +3 -8
- package/src/api/client.admission.test.ts +34 -0
- package/src/api/client.jobset.test.ts +18 -0
- package/src/api/client.resource-identity.test.ts +44 -0
- package/src/api/client.rightsizing.test.ts +7 -3
- package/src/api/client.ts +267 -78
- package/src/api/preferences.test.ts +54 -0
- package/src/api/preferences.ts +38 -0
- package/src/api/usage-data.fixtures.ts +33 -0
- package/src/api/usage-data.ts +165 -0
- package/src/components/CloudFunnelButton.test.tsx +155 -0
- package/src/components/CloudFunnelButton.tsx +113 -63
- package/src/components/ContextSwitcher.tsx +13 -132
- package/src/components/SelfManagedStart.tsx +96 -0
- package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
- package/src/components/applications/ApplicationsView.tsx +5 -3
- package/src/components/audit/AuditSettingsDialog.tsx +15 -11
- 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/AgentControls.tsx +26 -3
- package/src/components/diagnose/ApplyDialog.tsx +4 -2
- package/src/components/diagnose/DiagnoseContext.tsx +1 -0
- package/src/components/diagnose/agentCatalog.ts +6 -0
- package/src/components/diagnose/launch.test.ts +23 -0
- package/src/components/diagnose/launch.ts +4 -0
- package/src/components/diagnose/parts.test.tsx +18 -0
- package/src/components/dialog-names.test.ts +69 -0
- package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
- package/src/components/dock/NodeTerminalTab.tsx +5 -3
- package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
- package/src/components/dock/WorkloadLogsTab.tsx +2 -1
- package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
- package/src/components/execution/BatchExecutionView.test.ts +52 -1
- package/src/components/execution/BatchExecutionView.tsx +324 -65
- package/src/components/execution/JobSetAdmission.test.tsx +165 -0
- package/src/components/execution/JobSetAdmission.tsx +26 -0
- package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
- package/src/components/execution/JobSetMemberComparison.tsx +88 -0
- package/src/components/execution/batch-run-actions.test.ts +1 -0
- package/src/components/execution/batch-timeline.test.ts +3 -0
- package/src/components/execution/execution-definition.test.ts +15 -1
- package/src/components/execution/execution-definition.ts +4 -0
- package/src/components/execution/member-collection.render.test.tsx +142 -0
- package/src/components/gitops/GitOpsView.tsx +33 -2
- package/src/components/gitops/destination-toast.test.ts +33 -0
- package/src/components/gitops/destination-toast.ts +46 -0
- package/src/components/gitops/useDestinationCluster.ts +30 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +25 -14
- package/src/components/home/HomeView.tsx +6 -2
- package/src/components/home/MCPSetupDialog.tsx +10 -0
- package/src/components/home/RadarVersionLine.test.tsx +14 -0
- package/src/components/home/RadarVersionLine.tsx +28 -3
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
- package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
- package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
- package/src/components/nav/PrimaryNavRail.tsx +27 -8
- package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
- package/src/components/resource/PVCUsageBar.tsx +62 -16
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +3 -3
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
- package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
- package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
- package/src/components/rightsizing/copy.test.ts +7 -0
- package/src/components/rightsizing/model.test.ts +2 -1
- package/src/components/rightsizing/notices.test.tsx +4 -3
- package/src/components/settings/OperatorManagedNotice.tsx +17 -0
- package/src/components/settings/PrivacySection.test.tsx +84 -0
- package/src/components/settings/PrivacySection.tsx +140 -0
- package/src/components/settings/SettingsDialog.tsx +168 -92
- package/src/components/settings/controls.tsx +55 -0
- package/src/components/settings/settings-state.test.ts +18 -0
- package/src/components/settings/settings-state.ts +19 -0
- package/src/components/timeline/TimelineView.tsx +9 -3
- package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
- 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 -3
- 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/useContextSwitchFlow.tsx +147 -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.test.ts +17 -1
- package/src/components/workload/WorkloadView.tsx +47 -19
- package/src/context/ContextSwitchContext.tsx +18 -2
- package/src/context/NavCustomization.tsx +4 -58
- package/src/context/ThemeContext.tsx +3 -11
- package/src/hooks/useClusterLoadState.ts +2 -2
- package/src/hooks/useFavorites.ts +3 -5
- package/src/k8s-ui-exports.test.ts +73 -0
- package/src/utils/auditBadges.ts +1 -1
- package/src/utils/navigation.test.ts +34 -1
- package/src/utils/navigation.ts +28 -5
- package/src/utils/topology-selection.test.ts +56 -0
- package/src/utils/topology-selection.ts +9 -9
- package/src/utils/version.ts +11 -0
- package/src/components/ui/CommandPalette.tsx +0 -261
|
@@ -7,6 +7,7 @@ export type SettingsSectionId =
|
|
|
7
7
|
| 'argocd'
|
|
8
8
|
| 'ai'
|
|
9
9
|
| 'advanced'
|
|
10
|
+
| 'privacy'
|
|
10
11
|
|
|
11
12
|
export function shouldOfferCostReview(
|
|
12
13
|
costIntegrationDirty: boolean,
|
|
@@ -37,3 +38,21 @@ export function costSourceApplyLabel(
|
|
|
37
38
|
): string {
|
|
38
39
|
return source === 'prometheus' ? 'Apply source' : 'Test & apply source'
|
|
39
40
|
}
|
|
41
|
+
|
|
42
|
+
export function prometheusHeadersFromRows(
|
|
43
|
+
rows: { key: string; value: string }[] | null,
|
|
44
|
+
): Record<string, string> | undefined {
|
|
45
|
+
if (rows === null) return undefined
|
|
46
|
+
if (rows.length === 0) return {}
|
|
47
|
+
const entries: [string, string][] = []
|
|
48
|
+
const seen = new Set<string>()
|
|
49
|
+
for (const row of rows) {
|
|
50
|
+
const key = row.key.trim()
|
|
51
|
+
if (!key && !row.value) continue
|
|
52
|
+
if (!key || !row.value) throw new Error('Enter both a name and value for each header, or remove the incomplete row.')
|
|
53
|
+
if (seen.has(key.toLowerCase())) throw new Error(`Header "${key}" is entered more than once.`)
|
|
54
|
+
seen.add(key.toLowerCase())
|
|
55
|
+
entries.push([key, row.value])
|
|
56
|
+
}
|
|
57
|
+
return entries.length ? Object.fromEntries(entries) : undefined
|
|
58
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
buildAppMembershipIndex,
|
|
11
11
|
eventsForApplication,
|
|
12
12
|
isPinnedLaneRef,
|
|
13
|
+
pinnedLaneRefMatches,
|
|
13
14
|
LIVE_TICK_MS,
|
|
14
15
|
SEVERITY_TEXT,
|
|
15
16
|
type ScrubberRange,
|
|
@@ -50,6 +51,13 @@ function loadPinnedLanes(): PinnedLaneRef[] {
|
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
export function togglePinnedLaneRefs(previous: PinnedLaneRef[], ref: PinnedLaneRef): PinnedLaneRef[] {
|
|
55
|
+
const pinned = previous.some((stored) => pinnedLaneRefMatches(stored, ref))
|
|
56
|
+
return pinned
|
|
57
|
+
? previous.filter((stored) => !pinnedLaneRefMatches(stored, ref))
|
|
58
|
+
: [...previous, ref]
|
|
59
|
+
}
|
|
60
|
+
|
|
53
61
|
// Helper to check if topology has meaningfully changed
|
|
54
62
|
function topologyContentEqual(a: Topology | undefined, b: Topology | undefined): boolean {
|
|
55
63
|
if (a === b) return true
|
|
@@ -348,9 +356,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
348
356
|
}
|
|
349
357
|
}, [pinnedLanes])
|
|
350
358
|
const togglePin = useCallback((ref: PinnedLaneRef) => {
|
|
351
|
-
setPinnedLanes((prev) => (
|
|
352
|
-
prev.some((p) => p.id === ref.id) ? prev.filter((p) => p.id !== ref.id) : [...prev, ref]
|
|
353
|
-
))
|
|
359
|
+
setPinnedLanes((prev) => togglePinnedLaneRefs(prev, ref))
|
|
354
360
|
}, [])
|
|
355
361
|
// pinnedOnly is meaningless with no pins: unpinning the last lane (or landing
|
|
356
362
|
// on ?pinnedOnly=1 with no stored pins) would otherwise leave the filter stuck
|
|
@@ -1,16 +1,33 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import type { ActivityFilterKey, TimelineGrouping, TimelineSort } from '@skyhook-io/k8s-ui'
|
|
2
|
+
import type { ActivityFilterKey, PinnedLaneRef, TimelineGrouping, TimelineSort } from '@skyhook-io/k8s-ui'
|
|
3
3
|
import {
|
|
4
4
|
parseTimeMode,
|
|
5
5
|
resolveApplicationTimelineScope,
|
|
6
6
|
writeTimelineParams,
|
|
7
7
|
onlyHighFreqDiffer,
|
|
8
8
|
timeModeEqual,
|
|
9
|
+
togglePinnedLaneRefs,
|
|
9
10
|
type TimelineMode,
|
|
10
11
|
type PersistedTimelineState,
|
|
11
12
|
type TimelineViewMode,
|
|
12
13
|
} from './TimelineView'
|
|
13
14
|
|
|
15
|
+
describe('togglePinnedLaneRefs', () => {
|
|
16
|
+
it('unpins a migrated legacy CRD pin by its canonical live identity', () => {
|
|
17
|
+
const legacy: PinnedLaneRef = { id: 'Cluster/prod/main', kind: 'Cluster', namespace: 'prod', name: 'main' }
|
|
18
|
+
const live: PinnedLaneRef = { id: 'Cluster.postgresql.cnpg.io/prod/main', kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'prod', name: 'main' }
|
|
19
|
+
|
|
20
|
+
expect(togglePinnedLaneRefs([legacy], live)).toEqual([])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('does not unpin a different explicit API group', () => {
|
|
24
|
+
const cnpg: PinnedLaneRef = { id: 'Cluster.postgresql.cnpg.io/prod/main', kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'prod', name: 'main' }
|
|
25
|
+
const capi: PinnedLaneRef = { id: 'Cluster.cluster.x-k8s.io/prod/main', kind: 'Cluster', group: 'cluster.x-k8s.io', namespace: 'prod', name: 'main' }
|
|
26
|
+
|
|
27
|
+
expect(togglePinnedLaneRefs([cnpg], capi)).toEqual([cnpg, capi])
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
14
31
|
// These mirror the module-private defaults writeTimelineParams omits at (and
|
|
15
32
|
// parseTimeMode falls back to). Kept in sync deliberately: the round-trip tests
|
|
16
33
|
// below fail loudly if a module default drifts from what is asserted here.
|
|
@@ -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: {
|
|
@@ -146,9 +149,6 @@ const VIEW_ENTRIES: {
|
|
|
146
149
|
{ view: "cost", label: "Cost", icon: Coins, shortcut: "g c" },
|
|
147
150
|
];
|
|
148
151
|
|
|
149
|
-
// The static command-palette items (Views, Resource Kinds, Contexts,
|
|
150
|
-
// Namespaces, Actions) — shared by the centered modal (embedded) and the
|
|
151
|
-
// standalone omnibar so the two never drift.
|
|
152
152
|
export function useCommandItems(cb: CommandItemCallbacks): CommandItem[] {
|
|
153
153
|
const { data: namespacesData } = useNamespaces();
|
|
154
154
|
const { data: contexts } = useContexts();
|
|
@@ -265,6 +265,16 @@ export function useCommandItems(cb: CommandItemCallbacks): CommandItem[] {
|
|
|
265
265
|
shortcut: "t",
|
|
266
266
|
action: () => cb.onToggleTheme(),
|
|
267
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
|
+
}
|
|
268
278
|
if (cb.onShowDiagnostics) {
|
|
269
279
|
result.push({
|
|
270
280
|
id: "action-diagnostics",
|
|
@@ -288,5 +298,6 @@ export function useCommandItems(cb: CommandItemCallbacks): CommandItem[] {
|
|
|
288
298
|
cb.onSetNamespaces,
|
|
289
299
|
cb.onToggleTheme,
|
|
290
300
|
cb.onShowDiagnostics,
|
|
301
|
+
cb.onShowWhatsNew,
|
|
291
302
|
]);
|
|
292
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
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { BarChart3, Check } from 'lucide-react'
|
|
3
|
+
import { useMarkUsagePromptShown, useSetUsageData, type UsageDataStatus } from '../../api/usage-data'
|
|
4
|
+
|
|
5
|
+
// One wording for every place Radar asks, so the question can't drift.
|
|
6
|
+
export function UsageDataBlurb({ onReadMore }: { onReadMore: () => void }) {
|
|
7
|
+
return (
|
|
8
|
+
<>
|
|
9
|
+
<p className="text-sm font-medium text-theme-text-primary">Help improve Radar</p>
|
|
10
|
+
<p className="mt-0.5 text-xs text-theme-text-secondary leading-relaxed">
|
|
11
|
+
Anonymous, with no third-party trackers.{' '}
|
|
12
|
+
<button
|
|
13
|
+
type="button"
|
|
14
|
+
onClick={onReadMore}
|
|
15
|
+
className="whitespace-nowrap text-theme-text-primary underline underline-offset-2 hover:text-accent-text"
|
|
16
|
+
>
|
|
17
|
+
See what's sent
|
|
18
|
+
</button>
|
|
19
|
+
</p>
|
|
20
|
+
</>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Shown in place of the question once answered, so it doesn't vanish under
|
|
25
|
+
// the cursor.
|
|
26
|
+
export function UsageDataAnswered({ answer }: { answer: boolean }) {
|
|
27
|
+
return (
|
|
28
|
+
<>
|
|
29
|
+
<Check className="w-3.5 h-3.5 shrink-0 text-accent" aria-hidden />
|
|
30
|
+
{answer
|
|
31
|
+
? 'Thanks. Change this any time in Settings > Privacy.'
|
|
32
|
+
: 'Nothing will be sent. Change this any time in Settings > Privacy.'}
|
|
33
|
+
</>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The usage-data question, asked inside What's New when the server says to:
|
|
38
|
+
// someone who closed it without answering is asked again only months later,
|
|
39
|
+
// and a "no" is never asked again. A shared Radar, or one set by its
|
|
40
|
+
// configuration, never asks.
|
|
41
|
+
export function UsageDataAsk({ usageData, onReadMore }: {
|
|
42
|
+
usageData: UsageDataStatus | undefined
|
|
43
|
+
onReadMore: () => void
|
|
44
|
+
}) {
|
|
45
|
+
const setUsageData = useSetUsageData()
|
|
46
|
+
const markUsagePromptShown = useMarkUsagePromptShown()
|
|
47
|
+
const [answer, setAnswer] = useState<boolean | null>(null)
|
|
48
|
+
// Latched: recording the showing stops the server offering the question,
|
|
49
|
+
// and the block must not vanish while the dialog is open.
|
|
50
|
+
const [offered, setOffered] = useState(!!usageData?.ask)
|
|
51
|
+
const recorded = useRef(false)
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (usageData?.ask) setOffered(true)
|
|
55
|
+
}, [usageData?.ask])
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (offered && !recorded.current) {
|
|
58
|
+
recorded.current = true
|
|
59
|
+
markUsagePromptShown()
|
|
60
|
+
}
|
|
61
|
+
}, [offered, markUsagePromptShown])
|
|
62
|
+
|
|
63
|
+
const undecided = offered && usageData?.state === 'undecided' && usageData.canChange
|
|
64
|
+
if (!undecided && answer === null) return null
|
|
65
|
+
|
|
66
|
+
if (answer !== null) {
|
|
67
|
+
return (
|
|
68
|
+
<div className="flex items-center gap-2 px-6 py-3 border-t border-theme-border text-xs text-theme-text-secondary">
|
|
69
|
+
<UsageDataAnswered answer={answer} />
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const choose = (enabled: boolean) => setUsageData.mutate(enabled, { onSuccess: () => setAnswer(enabled) })
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div className="flex flex-col sm:flex-row sm:items-center gap-3 px-6 py-3.5 border-t border-theme-border">
|
|
78
|
+
<div className="flex gap-3 min-w-0 flex-1">
|
|
79
|
+
<span className="flex items-center justify-center w-8 h-8 shrink-0 rounded-lg bg-accent-muted text-accent">
|
|
80
|
+
<BarChart3 className="w-4 h-4" aria-hidden />
|
|
81
|
+
</span>
|
|
82
|
+
<div className="min-w-0">
|
|
83
|
+
<UsageDataBlurb onReadMore={onReadMore} />
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
<div className="flex items-center gap-2 shrink-0 pl-11 sm:pl-0">
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
disabled={setUsageData.isPending}
|
|
90
|
+
onClick={() => choose(true)}
|
|
91
|
+
className="px-3 py-1.5 text-xs font-medium rounded-lg border border-theme-border bg-theme-surface text-theme-text-primary hover:bg-theme-hover disabled:opacity-50"
|
|
92
|
+
>
|
|
93
|
+
Send usage stats
|
|
94
|
+
</button>
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
disabled={setUsageData.isPending}
|
|
98
|
+
onClick={() => choose(false)}
|
|
99
|
+
className="px-3 py-1.5 text-xs font-medium rounded-lg text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated disabled:opacity-50"
|
|
100
|
+
>
|
|
101
|
+
No thanks
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import type { UsageDataStatus } from '../../api/usage-data'
|
|
3
|
+
|
|
4
|
+
vi.mock('../../api/client', () => ({ useVersionCheck: () => ({ data: undefined }) }))
|
|
5
|
+
vi.mock('../../api/usage-data', () => ({ useSetUsageData: () => ({ mutate: vi.fn(), isPending: false }), useMarkUsagePromptShown: () => vi.fn() }))
|
|
6
|
+
|
|
7
|
+
import { cardStillAsking, shouldShowFirstRunPrompt, updateNoticeVisible } from './UsageDataPrompt'
|
|
8
|
+
|
|
9
|
+
const status = (firstRunPrompt: boolean) => ({ firstRunPrompt } as UsageDataStatus)
|
|
10
|
+
|
|
11
|
+
describe('shouldShowFirstRunPrompt', () => {
|
|
12
|
+
it('shows only when the server offers it, after the delay, with the corner free', () => {
|
|
13
|
+
expect(shouldShowFirstRunPrompt({ status: status(true), ready: true, updateNoticeShowing: false })).toBe(true)
|
|
14
|
+
expect(shouldShowFirstRunPrompt({ status: status(false), ready: true, updateNoticeShowing: false })).toBe(false)
|
|
15
|
+
expect(shouldShowFirstRunPrompt({ status: status(true), ready: false, updateNoticeShowing: false })).toBe(false)
|
|
16
|
+
expect(shouldShowFirstRunPrompt({ status: status(true), ready: true, updateNoticeShowing: true })).toBe(false)
|
|
17
|
+
expect(shouldShowFirstRunPrompt({ status: undefined, ready: true, updateNoticeShowing: false })).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('updateNoticeVisible', () => {
|
|
22
|
+
const store = new Map<string, string>()
|
|
23
|
+
vi.stubGlobal('localStorage', {
|
|
24
|
+
getItem: (k: string) => store.get(k) ?? null,
|
|
25
|
+
setItem: (k: string, v: string) => void store.set(k, v),
|
|
26
|
+
})
|
|
27
|
+
afterEach(() => store.clear())
|
|
28
|
+
|
|
29
|
+
it('is visible until that version is dismissed', () => {
|
|
30
|
+
expect(updateNoticeVisible('1.16.0', true)).toBe(true)
|
|
31
|
+
store.set('radar-update-dismissed', '1.16.0')
|
|
32
|
+
expect(updateNoticeVisible('1.16.0', true)).toBe(false)
|
|
33
|
+
expect(updateNoticeVisible('1.16.0', false)).toBe(false)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('cardStillAsking', () => {
|
|
38
|
+
const st = (state: UsageDataStatus['state'], canChange = true) => ({ state, canChange } as UsageDataStatus)
|
|
39
|
+
|
|
40
|
+
it('keeps asking while the choice is still open', () => {
|
|
41
|
+
expect(cardStillAsking(st('undecided'), null)).toBe(true)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('stops asking once the choice was made elsewhere', () => {
|
|
45
|
+
expect(cardStillAsking(st('off'), null)).toBe(false)
|
|
46
|
+
expect(cardStillAsking(st('on'), null)).toBe(false)
|
|
47
|
+
expect(cardStillAsking(st('undecided', false), null)).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('keeps its own answer on screen', () => {
|
|
51
|
+
expect(cardStillAsking(st('off'), false)).toBe(true)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import { BarChart3, X } from 'lucide-react'
|
|
4
|
+
import { useMarkUsagePromptShown, useSetUsageData, type UsageDataStatus } from '../../api/usage-data'
|
|
5
|
+
import { useVersionCheck } from '../../api/client'
|
|
6
|
+
import { isUpdateDismissed } from '../ui/UpdateNotification'
|
|
7
|
+
import { UsageDataAnswered, UsageDataBlurb } from './UsageDataAsk'
|
|
8
|
+
import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
|
|
9
|
+
import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
|
|
10
|
+
|
|
11
|
+
// Long enough that the first thing a new user sees is their cluster, not a
|
|
12
|
+
// question about it.
|
|
13
|
+
export const FIRST_RUN_PROMPT_DELAY_MS = 15_000
|
|
14
|
+
const THANKS_MS = 4_000
|
|
15
|
+
|
|
16
|
+
// The update notice uses the same corner. One nudge at a time.
|
|
17
|
+
export function updateNoticeVisible(latest: string | undefined, updateAvailable: boolean | undefined): boolean {
|
|
18
|
+
return !!updateAvailable && !!latest && !isUpdateDismissed(latest)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function shouldShowFirstRunPrompt(opts: {
|
|
22
|
+
status: UsageDataStatus | undefined
|
|
23
|
+
ready: boolean
|
|
24
|
+
updateNoticeShowing: boolean
|
|
25
|
+
}): boolean {
|
|
26
|
+
return !!opts.status?.firstRunPrompt && opts.ready && !opts.updateNoticeShowing
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Once shown, the card stays until answered or closed, unless the choice was
|
|
30
|
+
// made elsewhere (another tab, Settings) while it sat unanswered.
|
|
31
|
+
export function cardStillAsking(status: UsageDataStatus | undefined, answer: boolean | null): boolean {
|
|
32
|
+
return answer !== null || (status?.state === 'undecided' && !!status?.canChange)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Shown once per machine, after the first install. Upgrading installs are
|
|
36
|
+
// asked in What's New instead. The server records the showing, so a reload,
|
|
37
|
+
// another browser or the Desktop app on the same machine won't show it again.
|
|
38
|
+
export function UsageDataPrompt({ status }: { status: UsageDataStatus | undefined }) {
|
|
39
|
+
const setUsageData = useSetUsageData()
|
|
40
|
+
const markUsagePromptShown = useMarkUsagePromptShown()
|
|
41
|
+
const { data: versionInfo } = useVersionCheck()
|
|
42
|
+
const [ready, setReady] = useState(false)
|
|
43
|
+
const [, setTick] = useState(0)
|
|
44
|
+
// Latched once shown: the server stops offering the prompt the moment it is
|
|
45
|
+
// recorded, and the card must not disappear because of that.
|
|
46
|
+
const [latched, setLatched] = useState(false)
|
|
47
|
+
const [dismissed, setDismissed] = useState(false)
|
|
48
|
+
const [answer, setAnswer] = useState<boolean | null>(null)
|
|
49
|
+
const recorded = useRef(false)
|
|
50
|
+
|
|
51
|
+
const offered = !!status?.firstRunPrompt
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!offered) return
|
|
54
|
+
const delay = window.setTimeout(() => setReady(true), FIRST_RUN_PROMPT_DELAY_MS)
|
|
55
|
+
// Dismissing the update notice only touches localStorage, which does not
|
|
56
|
+
// notify this tab, so look again now and then.
|
|
57
|
+
const poll = window.setInterval(() => setTick((t) => t + 1), 5_000)
|
|
58
|
+
return () => {
|
|
59
|
+
window.clearTimeout(delay)
|
|
60
|
+
window.clearInterval(poll)
|
|
61
|
+
}
|
|
62
|
+
}, [offered])
|
|
63
|
+
|
|
64
|
+
const eligible = shouldShowFirstRunPrompt({
|
|
65
|
+
status,
|
|
66
|
+
ready,
|
|
67
|
+
updateNoticeShowing: updateNoticeVisible(versionInfo?.latestVersion, versionInfo?.updateAvailable),
|
|
68
|
+
})
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (eligible && !recorded.current) {
|
|
71
|
+
recorded.current = true
|
|
72
|
+
setLatched(true)
|
|
73
|
+
markUsagePromptShown()
|
|
74
|
+
}
|
|
75
|
+
}, [eligible, markUsagePromptShown])
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (answer === null) return
|
|
79
|
+
const t = window.setTimeout(() => setDismissed(true), THANKS_MS)
|
|
80
|
+
return () => window.clearTimeout(t)
|
|
81
|
+
}, [answer])
|
|
82
|
+
|
|
83
|
+
const show = latched && !dismissed && cardStillAsking(status, answer)
|
|
84
|
+
const { shouldRender, isOpen } = useAnimatedUnmount(show, overlayExitMs('menu'))
|
|
85
|
+
if (!shouldRender) return null
|
|
86
|
+
|
|
87
|
+
const readMore = () => {
|
|
88
|
+
setDismissed(true)
|
|
89
|
+
window.dispatchEvent(new CustomEvent('radar:open-settings', { detail: { section: 'privacy' } }))
|
|
90
|
+
}
|
|
91
|
+
const choose = (enabled: boolean) => setUsageData.mutate(enabled, { onSuccess: () => setAnswer(enabled) })
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div
|
|
95
|
+
role="dialog"
|
|
96
|
+
aria-label="Help improve Radar"
|
|
97
|
+
inert={!show || undefined}
|
|
98
|
+
className={clsx(
|
|
99
|
+
'fixed bottom-4 right-4 z-40 w-[21rem] max-w-[calc(100vw-2rem)] rounded-xl border border-theme-border bg-theme-surface shadow-theme-lg overflow-hidden origin-bottom-right',
|
|
100
|
+
TRANSITION_MENU,
|
|
101
|
+
isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-1 scale-[0.97]',
|
|
102
|
+
!show && 'pointer-events-none',
|
|
103
|
+
)}
|
|
104
|
+
style={overlayTransitionStyle(isOpen, 'menu')}
|
|
105
|
+
>
|
|
106
|
+
{answer === null ? (
|
|
107
|
+
<div className="relative flex gap-3 p-3.5 pr-9">
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
onClick={() => setDismissed(true)}
|
|
111
|
+
aria-label="Close"
|
|
112
|
+
className="absolute top-2.5 right-2.5 p-1 rounded text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated"
|
|
113
|
+
>
|
|
114
|
+
<X className="w-3.5 h-3.5" />
|
|
115
|
+
</button>
|
|
116
|
+
<span className="flex items-center justify-center w-8 h-8 shrink-0 rounded-lg bg-accent-muted text-accent">
|
|
117
|
+
<BarChart3 className="w-4 h-4" aria-hidden />
|
|
118
|
+
</span>
|
|
119
|
+
<div className="min-w-0">
|
|
120
|
+
<UsageDataBlurb onReadMore={readMore} />
|
|
121
|
+
<div className="flex items-center gap-2 mt-2.5">
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
disabled={setUsageData.isPending}
|
|
125
|
+
onClick={() => choose(true)}
|
|
126
|
+
className="btn-brand px-3 py-1 text-xs font-medium rounded-md whitespace-nowrap disabled:opacity-50"
|
|
127
|
+
>
|
|
128
|
+
Send usage stats
|
|
129
|
+
</button>
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
disabled={setUsageData.isPending}
|
|
133
|
+
onClick={() => choose(false)}
|
|
134
|
+
className="px-3 py-1 text-xs font-medium rounded-md text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated disabled:opacity-50"
|
|
135
|
+
>
|
|
136
|
+
No thanks
|
|
137
|
+
</button>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
) : (
|
|
142
|
+
<div className="flex items-center gap-2 px-4 py-3 text-xs text-theme-text-secondary">
|
|
143
|
+
<UsageDataAnswered answer={answer} />
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
)
|
|
148
|
+
}
|