@skyhook-io/radar-app 1.11.0 → 1.12.3
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 +7 -7
- package/src/App.tsx +37 -17
- package/src/api/client.images.test.ts +63 -0
- package/src/api/client.ts +244 -39
- package/src/api/client.yaml.test.ts +3 -3
- package/src/api/version-check.test.ts +78 -0
- package/src/components/CloudConnectFlow.tsx +46 -26
- package/src/components/CloudFunnelButton.tsx +166 -129
- package/src/components/ConnectionErrorView.test.tsx +74 -1
- package/src/components/ConnectionErrorView.tsx +22 -20
- package/src/components/ContextSwitcher.tsx +10 -13
- package/src/components/applications/ApplicationsView.tsx +10 -9
- package/src/components/audit/AuditView.tsx +6 -3
- package/src/components/audit/UpgradeReadinessView.test.ts +26 -2
- package/src/components/audit/UpgradeReadinessView.tsx +22 -12
- package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
- package/src/components/capacity/schedulingBar.test.ts +10 -0
- package/src/components/cost/ApplicationCostTab.test.ts +6 -0
- package/src/components/cost/ApplicationCostTab.tsx +28 -16
- package/src/components/cost/CostTrendChart.tsx +20 -10
- package/src/components/cost/CostView.tsx +79 -28
- package/src/components/cost/CurrentAllocationUse.tsx +6 -4
- package/src/components/cost/WorkloadCostTab.test.ts +10 -0
- package/src/components/cost/WorkloadCostTab.tsx +24 -12
- package/src/components/cost/format.test.ts +27 -8
- package/src/components/cost/format.ts +78 -27
- package/src/components/diagnose/DiagnoseSurface.tsx +14 -10
- package/src/components/gitops/GitOpsView.tsx +8 -3
- package/src/components/helm/HelmReleaseDrawer.tsx +4 -3
- package/src/components/helm/OwnedResources.tsx +10 -2
- package/src/components/home/ClusterHealthCard.test.ts +31 -0
- package/src/components/home/ClusterHealthCard.tsx +63 -1
- package/src/components/home/CostCard.tsx +12 -7
- package/src/components/home/HomeView.tsx +36 -11
- package/src/components/home/MCPSetupDialog.tsx +5 -4
- package/src/components/home/RadarVersionLine.test.tsx +145 -0
- package/src/components/home/RadarVersionLine.tsx +137 -0
- package/src/components/home/mcpToolCatalog.ts +12 -0
- package/src/components/nav/PrimaryNavRail.tsx +2 -2
- package/src/components/resources/PodFilesystemModal.tsx +54 -2
- package/src/components/resources/ResourcesView.tsx +31 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +13 -5
- package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
- package/src/components/settings/SettingsDialog.tsx +181 -40
- package/src/components/settings/currency-options.test.ts +49 -0
- package/src/components/settings/currency-options.ts +38 -0
- package/src/components/ui/DiagnosticsOverlay.test.ts +40 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +18 -6
- package/src/components/ui/ErrorBoundary.test.tsx +55 -0
- package/src/components/ui/ErrorBoundary.tsx +17 -2
- package/src/components/ui/UpdateNotification.test.tsx +49 -0
- package/src/components/ui/UpdateNotification.tsx +6 -2
- package/src/components/ui/command-items.ts +4 -14
- package/src/components/workload/WorkloadView.test.ts +60 -0
- package/src/components/workload/WorkloadView.tsx +272 -30
- package/src/contexts/CapabilitiesContext.test.tsx +29 -0
- package/src/contexts/CapabilitiesContext.tsx +7 -3
- package/src/main.tsx +4 -114
- package/src/utils/context-name.test.ts +63 -0
- package/src/utils/context-name.ts +22 -0
- package/src/utils/navigation.test.ts +45 -0
- package/src/utils/navigation.ts +5 -5
- package/src/utils/topology-selection.ts +3 -2
- package/src/utils/version.test.ts +37 -0
- package/src/utils/version.ts +56 -0
- package/src/utils/wails-clipboard.test.ts +109 -0
- package/src/utils/wails-clipboard.ts +127 -0
package/src/main.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import React from 'react'
|
|
|
2
2
|
import ReactDOM from 'react-dom/client'
|
|
3
3
|
import { RadarApp } from './RadarApp'
|
|
4
4
|
import { openExternal } from './utils/navigation'
|
|
5
|
+
import { installWailsClipboardShim } from './utils/wails-clipboard'
|
|
5
6
|
import './index.css'
|
|
6
7
|
|
|
7
8
|
// Intercept external link clicks in the Wails desktop app.
|
|
@@ -17,120 +18,9 @@ window.addEventListener('click', (e: MouseEvent) => {
|
|
|
17
18
|
openExternal(href)
|
|
18
19
|
})
|
|
19
20
|
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
// webview from wails:// to http://localhost:<port>. After the redirect,
|
|
24
|
-
// window.runtime (Wails JS API) is no longer available. Clipboard operations
|
|
25
|
-
// must use navigator.clipboard and DOM events instead.
|
|
26
|
-
//
|
|
27
|
-
// What works and why:
|
|
28
|
-
// Cmd+C / Cmd+X: Handled in keydown listener below. The Edit menu registers
|
|
29
|
-
// these accelerators with nil callbacks (native responder chain), but WKWebView
|
|
30
|
-
// does NOT dispatch a DOM copy/cut event from the native copy: selector.
|
|
31
|
-
// The keydown event DOES reach JS, so we intercept it here.
|
|
32
|
-
// Cmd+V: Handled by menu.go's explicit WindowExecJS callback which reads
|
|
33
|
-
// navigator.clipboard.readText() and dispatches a synthetic paste event.
|
|
34
|
-
// Right-click Copy/Cut (Monaco): Monaco calls document.execCommand('copy'/'cut'),
|
|
35
|
-
// intercepted by the monkey-patch below.
|
|
36
|
-
// Right-click Paste (Monaco): Not supported — Monaco calls navigator.clipboard
|
|
37
|
-
// .readText() directly (not execCommand), and WKWebView blocks readText() from
|
|
38
|
-
// page JS context. Use Cmd+V instead.
|
|
39
|
-
|
|
40
|
-
// Read selected text from Monaco if it has focus. Monaco uses virtual selection
|
|
41
|
-
// (not DOM selection), so window.getSelection() doesn't work — we access the
|
|
42
|
-
// editor instance exposed by YamlEditor.tsx.
|
|
43
|
-
function getMonacoSelection(): { text: string; editor: any } | null {
|
|
44
|
-
const editor = (window as any).__radarMonacoEditor
|
|
45
|
-
if (!editor?.hasTextFocus?.()) return null
|
|
46
|
-
const sel = editor.getSelection()
|
|
47
|
-
const model = editor.getModel()
|
|
48
|
-
if (!sel || !model) return null
|
|
49
|
-
const text = model.getValueInRange(sel)
|
|
50
|
-
if (!text) return null
|
|
51
|
-
return { text, editor }
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function getSelectedText(): { text: string; monaco: { text: string; editor: any } | null } {
|
|
55
|
-
const monaco = getMonacoSelection()
|
|
56
|
-
if (monaco) return { text: monaco.text, monaco }
|
|
57
|
-
const sel = window.getSelection()
|
|
58
|
-
const text = sel ? sel.toString() : ''
|
|
59
|
-
return { text, monaco: null }
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function deleteMonacoSelection(editor: any): void {
|
|
63
|
-
editor.pushUndoStop()
|
|
64
|
-
editor.executeEdits('cut', [{ range: editor.getSelection(), text: '' }])
|
|
65
|
-
editor.pushUndoStop()
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function handleCopyOrCut(isCut: boolean): void {
|
|
69
|
-
const { text, monaco } = getSelectedText()
|
|
70
|
-
if (!text) return
|
|
71
|
-
navigator.clipboard.writeText(text).catch((err) => { console.warn('[Radar] Clipboard write failed:', err) })
|
|
72
|
-
if (isCut) {
|
|
73
|
-
if (monaco) {
|
|
74
|
-
deleteMonacoSelection(monaco.editor)
|
|
75
|
-
} else {
|
|
76
|
-
_origExecCommand('delete')
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Cmd+C/X: the menu's nil callback does NOT dispatch a DOM copy event.
|
|
82
|
-
document.addEventListener('keydown', (e) => {
|
|
83
|
-
if (!(e.metaKey || e.ctrlKey)) return
|
|
84
|
-
if (e.key !== 'c' && e.key !== 'x') return
|
|
85
|
-
handleCopyOrCut(e.key === 'x')
|
|
86
|
-
}, true)
|
|
87
|
-
|
|
88
|
-
// Intercept copy/cut DOM events to handle Monaco's virtual selection.
|
|
89
|
-
// These fire from right-click -> Copy in some contexts. When a real
|
|
90
|
-
// ClipboardEvent is available, we write directly to e.clipboardData
|
|
91
|
-
// (synchronous, more reliable than the async clipboard API).
|
|
92
|
-
document.addEventListener('copy', (e: ClipboardEvent) => {
|
|
93
|
-
const result = getMonacoSelection()
|
|
94
|
-
if (result && e.clipboardData) {
|
|
95
|
-
e.preventDefault()
|
|
96
|
-
e.clipboardData.setData('text/plain', result.text)
|
|
97
|
-
}
|
|
98
|
-
}, true)
|
|
99
|
-
|
|
100
|
-
document.addEventListener('cut', (e: ClipboardEvent) => {
|
|
101
|
-
const result = getMonacoSelection()
|
|
102
|
-
if (result && e.clipboardData) {
|
|
103
|
-
e.preventDefault()
|
|
104
|
-
e.clipboardData.setData('text/plain', result.text)
|
|
105
|
-
deleteMonacoSelection(result.editor)
|
|
106
|
-
}
|
|
107
|
-
}, true)
|
|
108
|
-
|
|
109
|
-
// Monkey-patch document.execCommand for Wails WebView compatibility.
|
|
110
|
-
// Handles copy/cut from Monaco's right-click context menu, and paste from
|
|
111
|
-
// any context that calls execCommand('paste').
|
|
112
|
-
const _origExecCommand = document.execCommand.bind(document)
|
|
113
|
-
document.execCommand = function (command: string, showUI?: boolean, value?: string) {
|
|
114
|
-
if (command === 'copy' || command === 'cut') {
|
|
115
|
-
handleCopyOrCut(command === 'cut')
|
|
116
|
-
return true
|
|
117
|
-
}
|
|
118
|
-
if (command === 'paste') {
|
|
119
|
-
navigator.clipboard.readText().then((text) => {
|
|
120
|
-
if (!text) return
|
|
121
|
-
const el = document.activeElement || document.body
|
|
122
|
-
try {
|
|
123
|
-
const dt = new DataTransfer()
|
|
124
|
-
dt.setData('text/plain', text)
|
|
125
|
-
const ev = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })
|
|
126
|
-
if (!el.dispatchEvent(ev)) return
|
|
127
|
-
} catch { /* ClipboardEvent dispatch failed, fall back to insertText */ }
|
|
128
|
-
_origExecCommand('insertText', false, text)
|
|
129
|
-
}).catch((err) => { console.warn('[Radar] Paste failed:', err) })
|
|
130
|
-
return true
|
|
131
|
-
}
|
|
132
|
-
return _origExecCommand(command, showUI, value)
|
|
133
|
-
} as typeof document.execCommand
|
|
21
|
+
// Wails desktop clipboard shim — see web/src/utils/wails-clipboard.ts for the
|
|
22
|
+
// full WKWebView background and what each interception exists for.
|
|
23
|
+
installWailsClipboardShim()
|
|
134
24
|
|
|
135
25
|
// Mouse back/forward button navigation (button 3 = back, button 4 = forward).
|
|
136
26
|
// Uses 'mouseup' in capture phase to intercept before the browser's native handler.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { ContextInfo } from '../types'
|
|
4
|
+
import { parseContextForSwitcher, visibleContextQualifier } from './context-name'
|
|
5
|
+
|
|
6
|
+
function context(name: string, originalName?: string): ContextInfo {
|
|
7
|
+
return {
|
|
8
|
+
name,
|
|
9
|
+
originalName,
|
|
10
|
+
cluster: 'cluster',
|
|
11
|
+
user: 'user',
|
|
12
|
+
namespace: '',
|
|
13
|
+
isCurrent: false,
|
|
14
|
+
source: 'eks',
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('parseContextForSwitcher', () => {
|
|
19
|
+
it('preserves provider parsing while separating a backend qualifier', () => {
|
|
20
|
+
const originalName = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
|
|
21
|
+
const parsed = parseContextForSwitcher(context(`${originalName} (eks)`, originalName))
|
|
22
|
+
|
|
23
|
+
expect(parsed.provider).toBe('EKS')
|
|
24
|
+
expect(parsed.clusterName).toBe('prod')
|
|
25
|
+
expect(parsed.region).toBe('us-east-1')
|
|
26
|
+
expect(parsed.nameQualifier).toBe('(eks)')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('keeps a qualifier after its colliding sibling disappears', () => {
|
|
30
|
+
const parsed = parseContextForSwitcher(context('prod (secondary)', 'prod'))
|
|
31
|
+
|
|
32
|
+
expect(parsed.raw).toBe('prod')
|
|
33
|
+
expect(parsed.nameQualifier).toBe('(secondary)')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('does not reinterpret a natural parenthesized name', () => {
|
|
37
|
+
const parsed = parseContextForSwitcher(context('prod (config)', 'prod (config)'))
|
|
38
|
+
|
|
39
|
+
expect(parsed.raw).toBe('prod (config)')
|
|
40
|
+
expect(parsed.nameQualifier).toBeUndefined()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('uses the visible name for direct single-file contexts', () => {
|
|
44
|
+
const parsed = parseContextForSwitcher(context('local'))
|
|
45
|
+
|
|
46
|
+
expect(parsed.raw).toBe('local')
|
|
47
|
+
expect(parsed.nameQualifier).toBeUndefined()
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('visibleContextQualifier', () => {
|
|
52
|
+
it('suppresses a qualifier repeated by the visible source label', () => {
|
|
53
|
+
expect(visibleContextQualifier('(secondary)', 'secondary', true)).toBeUndefined()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('retains qualifiers that distinguish identical source labels', () => {
|
|
57
|
+
expect(visibleContextQualifier('(secondary #2)', 'secondary', true)).toBe('(secondary #2)')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('retains the qualifier when the source label is hidden', () => {
|
|
61
|
+
expect(visibleContextQualifier('(secondary)', 'secondary', false)).toBe('(secondary)')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -1,2 +1,24 @@
|
|
|
1
|
+
import { parseContextName } from '@skyhook-io/k8s-ui/utils/context-name'
|
|
2
|
+
import type { ContextInfo } from '../types'
|
|
3
|
+
|
|
1
4
|
// Re-export from the shared @skyhook-io/k8s-ui package.
|
|
2
5
|
export * from '@skyhook-io/k8s-ui/utils/context-name'
|
|
6
|
+
|
|
7
|
+
export function parseContextForSwitcher(context: ContextInfo) {
|
|
8
|
+
const raw = context.originalName || context.name
|
|
9
|
+
const nameQualifier = context.originalName && context.name !== context.originalName
|
|
10
|
+
? context.name.slice(context.originalName.length).trim() || undefined
|
|
11
|
+
: undefined
|
|
12
|
+
return {
|
|
13
|
+
...parseContextName(raw),
|
|
14
|
+
nameQualifier,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function visibleContextQualifier(
|
|
19
|
+
qualifier: string | undefined,
|
|
20
|
+
source: string | undefined,
|
|
21
|
+
sourceLabelVisible: boolean,
|
|
22
|
+
) {
|
|
23
|
+
return sourceLabelVisible && qualifier === `(${source})` ? undefined : qualifier
|
|
24
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
initNavigationMap,
|
|
4
|
+
resetNavigationMap,
|
|
5
|
+
} from '@skyhook-io/k8s-ui/utils/navigation'
|
|
6
|
+
import { relatedResourcePath, resourcePath } from './navigation'
|
|
7
|
+
|
|
8
|
+
afterEach(resetNavigationMap)
|
|
9
|
+
|
|
10
|
+
describe('resource navigation paths', () => {
|
|
11
|
+
it('routes the native PodGroup by its scheduling API group', () => {
|
|
12
|
+
initNavigationMap([
|
|
13
|
+
{
|
|
14
|
+
group: 'scheduling.k8s.io',
|
|
15
|
+
version: 'v1beta1',
|
|
16
|
+
kind: 'PodGroup',
|
|
17
|
+
name: 'podgroups',
|
|
18
|
+
namespaced: true,
|
|
19
|
+
isCrd: false,
|
|
20
|
+
verbs: ['get', 'list'],
|
|
21
|
+
},
|
|
22
|
+
])
|
|
23
|
+
|
|
24
|
+
expect(
|
|
25
|
+
resourcePath({
|
|
26
|
+
kind: 'PodGroup',
|
|
27
|
+
group: 'scheduling.k8s.io',
|
|
28
|
+
namespace: 'default',
|
|
29
|
+
name: 'batch',
|
|
30
|
+
}),
|
|
31
|
+
).toBe(
|
|
32
|
+
'/resources/podgroups?resource=default%2Fbatch&apiGroup=scheduling.k8s.io',
|
|
33
|
+
)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('preserves Radar virtual PodGroup navigation without an API group', () => {
|
|
37
|
+
expect(
|
|
38
|
+
relatedResourcePath({
|
|
39
|
+
kind: 'PodGroup',
|
|
40
|
+
namespace: 'default',
|
|
41
|
+
name: 'batch',
|
|
42
|
+
}),
|
|
43
|
+
).toBe('/workload/pods/default/batch')
|
|
44
|
+
})
|
|
45
|
+
})
|
package/src/utils/navigation.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { apiUrl, getAuthHeaders, getCredentialsMode } from '../api/config'
|
|
2
|
-
import { apiVersionToGroup,
|
|
2
|
+
import { apiVersionToGroup, kindToPluralWithGroup } from '@skyhook-io/k8s-ui/utils/navigation'
|
|
3
3
|
import type { SelectedResource, Topology } from '@skyhook-io/k8s-ui/types/core'
|
|
4
4
|
import type { SearchHit } from '../api/client'
|
|
5
5
|
|
|
@@ -14,7 +14,7 @@ export function searchHitToSelectedResource(hit: SearchHit): SelectedResource {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
// Re-export shared navigation utilities from @skyhook-io/k8s-ui.
|
|
17
|
-
export { kindToPlural, pluralToKind, refToSelectedResource, apiVersionToGroup } from '@skyhook-io/k8s-ui/utils/navigation'
|
|
17
|
+
export { kindToPlural, kindToPluralWithGroup, pluralToKind, refToSelectedResource, apiVersionToGroup } from '@skyhook-io/k8s-ui/utils/navigation'
|
|
18
18
|
export type { NavigateToResource } from '@skyhook-io/k8s-ui/utils/navigation'
|
|
19
19
|
|
|
20
20
|
const NETWORK_POLICY_TOPOLOGY_KINDS = new Set([
|
|
@@ -48,7 +48,7 @@ export function getNetworkPolicyResourceTarget(topology: Topology | null): { kin
|
|
|
48
48
|
|
|
49
49
|
const group = networkPolicyGroup(node)
|
|
50
50
|
const target = {
|
|
51
|
-
kind:
|
|
51
|
+
kind: kindToPluralWithGroup(node.kind, group ?? ''),
|
|
52
52
|
...(group ? { group } : {}),
|
|
53
53
|
}
|
|
54
54
|
targets.set(`${target.kind}\u0000${target.group ?? ''}`, target)
|
|
@@ -96,13 +96,13 @@ export function resourcePath(resource: SelectedResource): string {
|
|
|
96
96
|
}
|
|
97
97
|
if (resource.group) params.set('apiGroup', resource.group)
|
|
98
98
|
const query = params.toString()
|
|
99
|
-
return `/resources/${
|
|
99
|
+
return `/resources/${kindToPluralWithGroup(resource.kind, resource.group ?? '')}${query ? `?${query}` : ''}`
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
const FULLSCREEN_RESOURCE_KINDS = new Set(['pods', 'deployments', 'statefulsets', 'daemonsets', 'jobs', 'cronjobs', 'nodes'])
|
|
103
103
|
|
|
104
104
|
export function relatedResourcePath(resource: SelectedResource): string {
|
|
105
|
-
const apiKind =
|
|
105
|
+
const apiKind = kindToPluralWithGroup(resource.kind, resource.group ?? '').toLowerCase()
|
|
106
106
|
if (FULLSCREEN_RESOURCE_KINDS.has(apiKind)) {
|
|
107
107
|
return buildWorkloadPath({ ...resource, kind: apiKind })
|
|
108
108
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SelectedResource, Topology, TopologyNode } from '@skyhook-io/k8s-ui/types/core'
|
|
2
|
-
import { apiVersionToGroup,
|
|
2
|
+
import { apiVersionToGroup, kindToPluralWithGroup } from '@skyhook-io/k8s-ui/utils/navigation'
|
|
3
3
|
|
|
4
4
|
function topologyNodeGroup(node: TopologyNode): string | undefined {
|
|
5
5
|
const apiVersionGroup = apiVersionToGroup(node.data.apiVersion as string | undefined)
|
|
@@ -18,10 +18,11 @@ export function findSelectedTopologyNode(
|
|
|
18
18
|
selectedResource: SelectedResource,
|
|
19
19
|
): TopologyNode | undefined {
|
|
20
20
|
const namespace = selectedResource.namespace || ''
|
|
21
|
+
const selectedKind = kindToPluralWithGroup(selectedResource.kind, selectedResource.group ?? '')
|
|
21
22
|
const candidates = topology?.nodes.filter(node =>
|
|
22
23
|
((node.data.namespace as string) || '') === namespace &&
|
|
23
24
|
node.name === selectedResource.name &&
|
|
24
|
-
(
|
|
25
|
+
(kindToPluralWithGroup(node.kind, topologyNodeGroup(node) ?? '') === selectedKind || node.kind === selectedResource.kind),
|
|
25
26
|
) ?? []
|
|
26
27
|
|
|
27
28
|
if (candidates.length === 0) return undefined
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
getVersionUpdateStatus,
|
|
4
|
+
IN_CLUSTER_UPGRADE_URL,
|
|
5
|
+
parseMajorMinor,
|
|
6
|
+
versionUpdateURL,
|
|
7
|
+
} from './version'
|
|
8
|
+
|
|
9
|
+
describe('Radar version helpers', () => {
|
|
10
|
+
it('parses major and minor versions with an optional v prefix', () => {
|
|
11
|
+
expect(parseMajorMinor('v1.12.3')).toEqual({ major: 1, minor: 12 })
|
|
12
|
+
expect(parseMajorMinor('dev')).toBeNull()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('grades available updates by distance from the latest release', () => {
|
|
16
|
+
expect(getVersionUpdateStatus('1.12.0', '1.12.1')).toEqual({ tier: 'patch' })
|
|
17
|
+
expect(getVersionUpdateStatus('1.11.4', '1.12.1')).toEqual({ tier: 'minor', minorVersionsBehind: 1 })
|
|
18
|
+
expect(getVersionUpdateStatus('1.10.4', '1.12.1')).toEqual({ tier: 'minor', minorVersionsBehind: 2 })
|
|
19
|
+
expect(getVersionUpdateStatus('1.9.4', '1.12.1')).toEqual({ tier: 'stale', minorVersionsBehind: 3 })
|
|
20
|
+
expect(getVersionUpdateStatus('0.12.4', '1.12.1')).toEqual({ tier: 'stale', majorVersionBehind: true })
|
|
21
|
+
expect(getVersionUpdateStatus('1.12.1-rc.1', '1.12.1')).toEqual({ tier: 'patch' })
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('does not flag invalid, current, or newer local builds', () => {
|
|
25
|
+
expect(getVersionUpdateStatus('dev', '1.12.1')).toEqual({ tier: 'none' })
|
|
26
|
+
expect(getVersionUpdateStatus('1.12.1', '1.12.1')).toEqual({ tier: 'none' })
|
|
27
|
+
expect(getVersionUpdateStatus('1.13.0', '1.12.1')).toEqual({ tier: 'none' })
|
|
28
|
+
expect(getVersionUpdateStatus('2.0.0', '1.12.1')).toEqual({ tier: 'none' })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('routes in-cluster upgrades to instructions instead of release notes', () => {
|
|
32
|
+
const releaseURL = 'https://github.com/skyhook-io/radar/releases/tag/v1.2.4'
|
|
33
|
+
expect(versionUpdateURL('in-cluster', releaseURL)).toBe(IN_CLUSTER_UPGRADE_URL)
|
|
34
|
+
expect(versionUpdateURL('local', releaseURL)).toBe(releaseURL)
|
|
35
|
+
expect(versionUpdateURL('cloud', releaseURL)).toBeUndefined()
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { DeploymentMode } from '../types'
|
|
2
|
+
|
|
3
|
+
export interface MajorMinorVersion {
|
|
4
|
+
major: number
|
|
5
|
+
minor: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type VersionUpdateTier = 'none' | 'patch' | 'minor' | 'stale'
|
|
9
|
+
|
|
10
|
+
export interface VersionUpdateStatus {
|
|
11
|
+
tier: VersionUpdateTier
|
|
12
|
+
minorVersionsBehind?: number
|
|
13
|
+
majorVersionBehind?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const IN_CLUSTER_UPGRADE_URL = 'https://radarhq.io/docs/configuration/in-cluster'
|
|
17
|
+
|
|
18
|
+
export function versionUpdateURL(deploymentMode: DeploymentMode, releaseURL?: string): string | undefined {
|
|
19
|
+
if (deploymentMode === 'cloud') return undefined
|
|
20
|
+
return deploymentMode === 'in-cluster' ? IN_CLUSTER_UPGRADE_URL : releaseURL
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseMajorMinor(version: string): MajorMinorVersion | null {
|
|
24
|
+
const match = /^v?(\d+)\.(\d+)/.exec(version.trim())
|
|
25
|
+
if (!match) return null
|
|
26
|
+
return { major: Number(match[1]), minor: Number(match[2]) }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseVersion(version: string): [major: number, minor: number, patch: number, prerelease: boolean] | null {
|
|
30
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version.trim())
|
|
31
|
+
if (!match) return null
|
|
32
|
+
return [Number(match[1]), Number(match[2]), Number(match[3]), !!match[4]]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getVersionUpdateStatus(current: string, latest?: string): VersionUpdateStatus {
|
|
36
|
+
if (!latest) return { tier: 'none' }
|
|
37
|
+
|
|
38
|
+
const currentVersion = parseVersion(current)
|
|
39
|
+
const latestVersion = parseVersion(latest)
|
|
40
|
+
if (!currentVersion || !latestVersion) return { tier: 'none' }
|
|
41
|
+
|
|
42
|
+
const [currentMajor, currentMinor, currentPatch, currentPrerelease] = currentVersion
|
|
43
|
+
const [latestMajor, latestMinor, latestPatch, latestPrerelease] = latestVersion
|
|
44
|
+
|
|
45
|
+
if (latestMajor < currentMajor) return { tier: 'none' }
|
|
46
|
+
if (latestMajor > currentMajor) return { tier: 'stale', majorVersionBehind: true }
|
|
47
|
+
|
|
48
|
+
const minorVersionsBehind = latestMinor - currentMinor
|
|
49
|
+
if (minorVersionsBehind >= 3) return { tier: 'stale', minorVersionsBehind }
|
|
50
|
+
if (minorVersionsBehind > 0) return { tier: 'minor', minorVersionsBehind }
|
|
51
|
+
if (minorVersionsBehind < 0 || latestPatch < currentPatch) return { tier: 'none' }
|
|
52
|
+
if (latestPatch === currentPatch) {
|
|
53
|
+
return currentPrerelease && !latestPrerelease ? { tier: 'patch' } : { tier: 'none' }
|
|
54
|
+
}
|
|
55
|
+
return { tier: 'patch' }
|
|
56
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { copyText } from '@skyhook-io/k8s-ui/utils/clipboard'
|
|
4
|
+
|
|
5
|
+
import { installWailsClipboardShim } from './wails-clipboard'
|
|
6
|
+
|
|
7
|
+
function focusedMonacoEditor(text: string) {
|
|
8
|
+
return {
|
|
9
|
+
hasTextFocus: () => true,
|
|
10
|
+
getSelection: () => ({}),
|
|
11
|
+
getModel: () => ({ getValueInRange: () => text }),
|
|
12
|
+
pushUndoStop: vi.fn(),
|
|
13
|
+
executeEdits: vi.fn(),
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function setupDom({ monacoEditor = null as unknown, nativeCopyResult = true, insecureOrigin = false } = {}) {
|
|
18
|
+
const origExecCommand = vi.fn(() => nativeCopyResult)
|
|
19
|
+
const textarea = {
|
|
20
|
+
value: '',
|
|
21
|
+
style: {} as CSSStyleDeclaration,
|
|
22
|
+
setAttribute: vi.fn(),
|
|
23
|
+
select: vi.fn(),
|
|
24
|
+
setSelectionRange: vi.fn(),
|
|
25
|
+
remove: vi.fn(),
|
|
26
|
+
}
|
|
27
|
+
const doc = {
|
|
28
|
+
execCommand: origExecCommand as Document['execCommand'],
|
|
29
|
+
addEventListener: vi.fn(),
|
|
30
|
+
createElement: vi.fn(() => textarea),
|
|
31
|
+
body: { appendChild: vi.fn() },
|
|
32
|
+
activeElement: null,
|
|
33
|
+
}
|
|
34
|
+
const win = { __radarMonacoEditor: monacoEditor, getSelection: () => null }
|
|
35
|
+
// navigator.clipboard is a secure-context-only API — absent entirely on plain HTTP.
|
|
36
|
+
const writeText = vi.fn(() => Promise.resolve())
|
|
37
|
+
const nav = {
|
|
38
|
+
clipboard: insecureOrigin ? undefined : { writeText, readText: vi.fn(() => Promise.resolve('')) },
|
|
39
|
+
}
|
|
40
|
+
vi.stubGlobal('document', doc)
|
|
41
|
+
vi.stubGlobal('window', win)
|
|
42
|
+
vi.stubGlobal('navigator', nav)
|
|
43
|
+
installWailsClipboardShim()
|
|
44
|
+
return { doc, writeText, origExecCommand, textarea }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
vi.unstubAllGlobals()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('installWailsClipboardShim execCommand patch', () => {
|
|
52
|
+
it('delegates copy to the native command when Monaco is not focused', () => {
|
|
53
|
+
const { doc, writeText, origExecCommand } = setupDom()
|
|
54
|
+
|
|
55
|
+
expect(doc.execCommand('copy')).toBe(true)
|
|
56
|
+
|
|
57
|
+
expect(origExecCommand).toHaveBeenCalledWith('copy', undefined, undefined)
|
|
58
|
+
expect(writeText).not.toHaveBeenCalled()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('reports the native command failing instead of fabricating success', () => {
|
|
62
|
+
const { doc } = setupDom({ nativeCopyResult: false })
|
|
63
|
+
|
|
64
|
+
expect(doc.execCommand('copy')).toBe(false)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('hijacks copy for Monaco virtual selections, which the native command cannot see', () => {
|
|
68
|
+
const editor = focusedMonacoEditor('monaco-selection')
|
|
69
|
+
const { doc, writeText, origExecCommand } = setupDom({ monacoEditor: editor })
|
|
70
|
+
|
|
71
|
+
expect(doc.execCommand('copy')).toBe(true)
|
|
72
|
+
|
|
73
|
+
expect(writeText).toHaveBeenCalledWith('monaco-selection')
|
|
74
|
+
expect(origExecCommand).not.toHaveBeenCalled()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('hijacks cut for Monaco and deletes the selection', () => {
|
|
78
|
+
const editor = focusedMonacoEditor('monaco-selection')
|
|
79
|
+
const { doc, writeText } = setupDom({ monacoEditor: editor })
|
|
80
|
+
|
|
81
|
+
expect(doc.execCommand('cut')).toBe(true)
|
|
82
|
+
|
|
83
|
+
expect(writeText).toHaveBeenCalledWith('monaco-selection')
|
|
84
|
+
expect(editor.executeEdits).toHaveBeenCalledOnce()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('delegates unrelated commands untouched', () => {
|
|
88
|
+
const { doc, origExecCommand } = setupDom()
|
|
89
|
+
|
|
90
|
+
doc.execCommand('insertText', false, 'x')
|
|
91
|
+
|
|
92
|
+
expect(origExecCommand).toHaveBeenCalledWith('insertText', false, 'x')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('lets copyText reach the native command through the patch when the Clipboard API is unavailable', async () => {
|
|
96
|
+
const { doc, origExecCommand, textarea } = setupDom({ insecureOrigin: true })
|
|
97
|
+
|
|
98
|
+
await expect(copyText('busybox', undefined, doc as unknown as Document)).resolves.toBe(true)
|
|
99
|
+
|
|
100
|
+
expect(textarea.value).toBe('busybox')
|
|
101
|
+
expect(origExecCommand).toHaveBeenCalledWith('copy', undefined, undefined)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('surfaces copyText failure when the native command fails under the patch', async () => {
|
|
105
|
+
const { doc } = setupDom({ nativeCopyResult: false, insecureOrigin: true })
|
|
106
|
+
|
|
107
|
+
await expect(copyText('busybox', undefined, doc as unknown as Document)).resolves.toBe(false)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// === Wails Desktop Clipboard ===
|
|
2
|
+
//
|
|
3
|
+
// Background: The desktop app uses a RedirectHandler that navigates the Wails
|
|
4
|
+
// webview from wails:// to http://localhost:<port>. After the redirect,
|
|
5
|
+
// window.runtime (Wails JS API) is no longer available. Clipboard operations
|
|
6
|
+
// must use navigator.clipboard and DOM events instead.
|
|
7
|
+
//
|
|
8
|
+
// What works and why:
|
|
9
|
+
// Cmd+C / Cmd+X: Handled in keydown listener below. The Edit menu registers
|
|
10
|
+
// these accelerators with nil callbacks (native responder chain), but WKWebView
|
|
11
|
+
// does NOT dispatch a DOM copy/cut event from the native copy: selector.
|
|
12
|
+
// The keydown event DOES reach JS, so we intercept it here.
|
|
13
|
+
// Cmd+V: Handled by menu.go's explicit WindowExecJS callback which reads
|
|
14
|
+
// navigator.clipboard.readText() and dispatches a synthetic paste event.
|
|
15
|
+
// Right-click Copy/Cut (Monaco): Monaco calls document.execCommand('copy'/'cut'),
|
|
16
|
+
// intercepted by the monkey-patch below.
|
|
17
|
+
// Right-click Paste (Monaco): Not supported — Monaco calls navigator.clipboard
|
|
18
|
+
// .readText() directly (not execCommand), and WKWebView blocks readText() from
|
|
19
|
+
// page JS context. Use Cmd+V instead.
|
|
20
|
+
|
|
21
|
+
// Read selected text from Monaco if it has focus. Monaco uses virtual selection
|
|
22
|
+
// (not DOM selection), so window.getSelection() doesn't work — we access the
|
|
23
|
+
// editor instance exposed by YamlEditor.tsx.
|
|
24
|
+
function getMonacoSelection(): { text: string; editor: any } | null {
|
|
25
|
+
const editor = (window as any).__radarMonacoEditor
|
|
26
|
+
if (!editor?.hasTextFocus?.()) return null
|
|
27
|
+
const sel = editor.getSelection()
|
|
28
|
+
const model = editor.getModel()
|
|
29
|
+
if (!sel || !model) return null
|
|
30
|
+
const text = model.getValueInRange(sel)
|
|
31
|
+
if (!text) return null
|
|
32
|
+
return { text, editor }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getSelectedText(): { text: string; monaco: { text: string; editor: any } | null } {
|
|
36
|
+
const monaco = getMonacoSelection()
|
|
37
|
+
if (monaco) return { text: monaco.text, monaco }
|
|
38
|
+
const sel = window.getSelection()
|
|
39
|
+
const text = sel ? sel.toString() : ''
|
|
40
|
+
return { text, monaco: null }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function deleteMonacoSelection(editor: any): void {
|
|
44
|
+
editor.pushUndoStop()
|
|
45
|
+
editor.executeEdits('cut', [{ range: editor.getSelection(), text: '' }])
|
|
46
|
+
editor.pushUndoStop()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function installWailsClipboardShim(): void {
|
|
50
|
+
const _origExecCommand = document.execCommand.bind(document)
|
|
51
|
+
|
|
52
|
+
function handleCopyOrCut(isCut: boolean): void {
|
|
53
|
+
const { text, monaco } = getSelectedText()
|
|
54
|
+
if (!text) return
|
|
55
|
+
navigator.clipboard.writeText(text).catch((err) => { console.warn('[Radar] Clipboard write failed:', err) })
|
|
56
|
+
if (isCut) {
|
|
57
|
+
if (monaco) {
|
|
58
|
+
deleteMonacoSelection(monaco.editor)
|
|
59
|
+
} else {
|
|
60
|
+
_origExecCommand('delete')
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Cmd+C/X: the menu's nil callback does NOT dispatch a DOM copy event.
|
|
66
|
+
document.addEventListener('keydown', (e) => {
|
|
67
|
+
if (!(e.metaKey || e.ctrlKey)) return
|
|
68
|
+
if (e.key !== 'c' && e.key !== 'x') return
|
|
69
|
+
handleCopyOrCut(e.key === 'x')
|
|
70
|
+
}, true)
|
|
71
|
+
|
|
72
|
+
// Intercept copy/cut DOM events to handle Monaco's virtual selection.
|
|
73
|
+
// These fire from right-click -> Copy in some contexts. When a real
|
|
74
|
+
// ClipboardEvent is available, we write directly to e.clipboardData
|
|
75
|
+
// (synchronous, more reliable than the async clipboard API).
|
|
76
|
+
document.addEventListener('copy', (e: ClipboardEvent) => {
|
|
77
|
+
const result = getMonacoSelection()
|
|
78
|
+
if (result && e.clipboardData) {
|
|
79
|
+
e.preventDefault()
|
|
80
|
+
e.clipboardData.setData('text/plain', result.text)
|
|
81
|
+
}
|
|
82
|
+
}, true)
|
|
83
|
+
|
|
84
|
+
document.addEventListener('cut', (e: ClipboardEvent) => {
|
|
85
|
+
const result = getMonacoSelection()
|
|
86
|
+
if (result && e.clipboardData) {
|
|
87
|
+
e.preventDefault()
|
|
88
|
+
e.clipboardData.setData('text/plain', result.text)
|
|
89
|
+
deleteMonacoSelection(result.editor)
|
|
90
|
+
}
|
|
91
|
+
}, true)
|
|
92
|
+
|
|
93
|
+
// Monkey-patch document.execCommand for Wails WebView compatibility.
|
|
94
|
+
// Handles copy/cut from Monaco's right-click context menu, and paste from
|
|
95
|
+
// any context that calls execCommand('paste').
|
|
96
|
+
//
|
|
97
|
+
// Only Monaco needs the copy/cut hijack: its virtual selection is invisible
|
|
98
|
+
// to the native command. Every other caller (notably the hidden-textarea
|
|
99
|
+
// fallback in @skyhook-io/k8s-ui's copyText, which is how copy works at all
|
|
100
|
+
// on insecure origins where navigator.clipboard doesn't exist) must reach
|
|
101
|
+
// the native command and see its real return value — hijacking those routes
|
|
102
|
+
// them back into the async Clipboard API and fabricates success.
|
|
103
|
+
document.execCommand = function (command: string, showUI?: boolean, value?: string) {
|
|
104
|
+
if (command === 'copy' || command === 'cut') {
|
|
105
|
+
const monaco = getMonacoSelection()
|
|
106
|
+
if (!monaco) return _origExecCommand(command, showUI, value)
|
|
107
|
+
navigator.clipboard.writeText(monaco.text).catch((err) => { console.warn('[Radar] Clipboard write failed:', err) })
|
|
108
|
+
if (command === 'cut') deleteMonacoSelection(monaco.editor)
|
|
109
|
+
return true
|
|
110
|
+
}
|
|
111
|
+
if (command === 'paste') {
|
|
112
|
+
navigator.clipboard.readText().then((text) => {
|
|
113
|
+
if (!text) return
|
|
114
|
+
const el = document.activeElement || document.body
|
|
115
|
+
try {
|
|
116
|
+
const dt = new DataTransfer()
|
|
117
|
+
dt.setData('text/plain', text)
|
|
118
|
+
const ev = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })
|
|
119
|
+
if (!el.dispatchEvent(ev)) return
|
|
120
|
+
} catch { /* ClipboardEvent dispatch failed, fall back to insertText */ }
|
|
121
|
+
_origExecCommand('insertText', false, text)
|
|
122
|
+
}).catch((err) => { console.warn('[Radar] Paste failed:', err) })
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
return _origExecCommand(command, showUI, value)
|
|
126
|
+
} as typeof document.execCommand
|
|
127
|
+
}
|