@skyhook-io/radar-app 1.4.2 → 1.6.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 +10 -11
- package/src/App.tsx +168 -42
- package/src/RadarApp.tsx +9 -1
- package/src/api/client.ts +185 -6
- package/src/components/UserMenu.tsx +56 -10
- package/src/components/applications/ApplicationsView.tsx +27 -19
- package/src/components/audit/AuditSettingsDialog.tsx +1 -1
- package/src/components/audit/AuditView.tsx +23 -35
- package/src/components/gitops/GitOpsView.tsx +24 -2
- package/src/components/helm/HelmView.tsx +12 -8
- package/src/components/home/HomeView.tsx +1 -1
- package/src/components/home/mcpToolCatalog.ts +34 -0
- package/src/components/issues/IssuesPane.tsx +82 -28
- package/src/components/nav/PrimaryNavRail.tsx +282 -0
- package/src/components/resource/HPACharts.tsx +7 -2
- package/src/components/resource/RestartChart.tsx +8 -0
- package/src/components/resources/ResourcesView.tsx +54 -3
- package/src/components/resources/renderers/HPARenderer.tsx +4 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +34 -3
- package/src/components/settings/SettingsDialog.tsx +44 -7
- package/src/components/ui/CommandPalette.tsx +6 -215
- package/src/components/ui/Omnibar.tsx +493 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +3 -1
- package/src/context/NavCustomization.tsx +11 -0
- package/src/contexts/CapabilitiesContext.tsx +16 -5
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/utils/navigation.ts +11 -0
|
@@ -5,7 +5,9 @@ import { clsx } from 'clsx'
|
|
|
5
5
|
import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
|
|
6
6
|
import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
|
|
7
7
|
import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
|
|
8
|
-
import { useCloudRole } from '../../api/client'
|
|
8
|
+
import { useCloudRole, useVersionCheck } from '../../api/client'
|
|
9
|
+
import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
|
|
10
|
+
import type { DeploymentMode } from '../../types'
|
|
9
11
|
|
|
10
12
|
interface Config {
|
|
11
13
|
kubeconfig?: string
|
|
@@ -13,6 +15,7 @@ interface Config {
|
|
|
13
15
|
namespace?: string
|
|
14
16
|
port?: number
|
|
15
17
|
noBrowser?: boolean
|
|
18
|
+
browser?: string
|
|
16
19
|
timelineStorage?: 'memory' | 'sqlite'
|
|
17
20
|
timelineDbPath?: string
|
|
18
21
|
historyLimit?: number
|
|
@@ -35,12 +38,14 @@ interface SettingsDialogProps {
|
|
|
35
38
|
export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsDialogProps) {
|
|
36
39
|
const dialogRef = useRef<HTMLDivElement>(null)
|
|
37
40
|
const { shouldRender, isOpen } = useAnimatedUnmount(open, 200)
|
|
41
|
+
const { data: versionInfo } = useVersionCheck()
|
|
38
42
|
// Radar configuration (kubeconfig, port, integrations…) is host-level and
|
|
39
43
|
// affects every user of this instance, so it's gated to owners. Personal
|
|
40
44
|
// sections (My permissions) stay visible to everyone. Non-Cloud callers
|
|
41
45
|
// (OSS, OIDC, kubectl plugin) have no role and pass — single-user laptops
|
|
42
46
|
// are never locked out of their own config. Backend enforces this too.
|
|
43
47
|
const { canAtLeast } = useCloudRole()
|
|
48
|
+
const capabilities = useCapabilitiesContext()
|
|
44
49
|
const canEditConfig = canAtLeast('owner')
|
|
45
50
|
const [configData, setConfigData] = useState<ConfigResponse | null>(null)
|
|
46
51
|
const [editedConfig, setEditedConfig] = useState<Config>({})
|
|
@@ -130,6 +135,7 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
|
|
|
130
135
|
if (!shouldRender) return null
|
|
131
136
|
|
|
132
137
|
const isDesktop = configData?.isDesktop ?? false
|
|
138
|
+
const deploymentMode = capabilities.deployment?.mode ?? 'local'
|
|
133
139
|
|
|
134
140
|
return createPortal(
|
|
135
141
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
@@ -205,6 +211,7 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
|
|
|
205
211
|
config={editedConfig}
|
|
206
212
|
effectiveConfig={configData?.effective}
|
|
207
213
|
isDesktop={isDesktop}
|
|
214
|
+
deploymentMode={deploymentMode}
|
|
208
215
|
onChange={updateConfigField}
|
|
209
216
|
/>
|
|
210
217
|
) : (
|
|
@@ -256,6 +263,22 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
|
|
|
256
263
|
</button>
|
|
257
264
|
</div>
|
|
258
265
|
)}
|
|
266
|
+
|
|
267
|
+
{/* About — muted version footer (canonical "Settings → About"). */}
|
|
268
|
+
<div className="flex items-center justify-between gap-2 px-4 py-2 border-t border-theme-border/60 text-[11px] text-theme-text-tertiary shrink-0">
|
|
269
|
+
<span>
|
|
270
|
+
Radar{versionInfo?.currentVersion ? ` v${versionInfo.currentVersion}` : ''}
|
|
271
|
+
<span className="text-theme-text-disabled"> · by Skyhook</span>
|
|
272
|
+
</span>
|
|
273
|
+
<a
|
|
274
|
+
href="https://github.com/skyhook-io/radar"
|
|
275
|
+
target="_blank"
|
|
276
|
+
rel="noopener noreferrer"
|
|
277
|
+
className="text-accent-text hover:underline"
|
|
278
|
+
>
|
|
279
|
+
GitHub
|
|
280
|
+
</a>
|
|
281
|
+
</div>
|
|
259
282
|
</div>
|
|
260
283
|
</div>,
|
|
261
284
|
document.body
|
|
@@ -278,13 +301,16 @@ function StartupConfigTab({
|
|
|
278
301
|
config,
|
|
279
302
|
effectiveConfig,
|
|
280
303
|
isDesktop,
|
|
304
|
+
deploymentMode,
|
|
281
305
|
onChange,
|
|
282
306
|
}: {
|
|
283
307
|
config: Config
|
|
284
308
|
effectiveConfig?: Config
|
|
285
309
|
isDesktop: boolean
|
|
310
|
+
deploymentMode: DeploymentMode
|
|
286
311
|
onChange: <K extends keyof Config>(field: K, value: Config[K]) => void
|
|
287
312
|
}) {
|
|
313
|
+
const showBrowserLaunchControls = !isDesktop && deploymentMode === 'local'
|
|
288
314
|
return (
|
|
289
315
|
<div className="space-y-4">
|
|
290
316
|
<p className="text-xs text-theme-text-tertiary">
|
|
@@ -332,12 +358,23 @@ function StartupConfigTab({
|
|
|
332
358
|
onChange={(v) => onChange('port', v)}
|
|
333
359
|
/>
|
|
334
360
|
|
|
335
|
-
{
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
361
|
+
{showBrowserLaunchControls && (
|
|
362
|
+
<>
|
|
363
|
+
<ConfigToggle
|
|
364
|
+
label="Open browser on start"
|
|
365
|
+
value={!(config.noBrowser ?? false)}
|
|
366
|
+
onChange={(v) => onChange('noBrowser', !v ? true : undefined)}
|
|
367
|
+
/>
|
|
368
|
+
|
|
369
|
+
<ConfigField
|
|
370
|
+
label="Browser"
|
|
371
|
+
help="Browser for automatic launch; macOS app names are supported"
|
|
372
|
+
value={config.browser ?? ''}
|
|
373
|
+
effectiveValue={effectiveConfig?.browser}
|
|
374
|
+
placeholder="System default"
|
|
375
|
+
onChange={(v) => onChange('browser', v || undefined)}
|
|
376
|
+
/>
|
|
377
|
+
</>
|
|
341
378
|
)}
|
|
342
379
|
|
|
343
380
|
<div className="border-t border-theme-border pt-4 mt-4">
|
|
@@ -1,130 +1,26 @@
|
|
|
1
1
|
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
|
2
2
|
import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
|
|
3
3
|
import { Search, X, ChevronRight } from 'lucide-react'
|
|
4
|
-
import { Home, Network, List, Clock, Package, Activity, Sun, Stethoscope, DollarSign, ShieldCheck } from 'lucide-react'
|
|
5
|
-
import { GitBranch } from 'lucide-react'
|
|
6
4
|
import { clsx } from 'clsx'
|
|
7
|
-
import {
|
|
8
|
-
import { CORE_RESOURCES, useAPIResources } from '../../api/apiResources'
|
|
9
|
-
import { getResourceIcon } from '../../utils/resource-icons'
|
|
10
|
-
type MainView = 'home' | 'topology' | 'resources' | 'timeline' | 'helm' | 'traffic' | 'cost' | 'audit' | 'gitops'
|
|
5
|
+
import { useCommandItems, bestScore, type CommandItem, type CommandItemCallbacks } from './command-items'
|
|
11
6
|
|
|
12
|
-
interface CommandPaletteProps {
|
|
7
|
+
interface CommandPaletteProps extends CommandItemCallbacks {
|
|
13
8
|
onClose: () => void
|
|
14
|
-
onNavigateView: (view: MainView) => void
|
|
15
|
-
onNavigateKind: (kind: string, group: string) => void
|
|
16
|
-
onSwitchContext: (name: string) => void
|
|
17
|
-
onSetNamespaces: (ns: string[]) => void
|
|
18
|
-
onToggleTheme: () => void
|
|
19
|
-
onShowDiagnostics?: () => void
|
|
20
9
|
/** Controls fade-in/out animation (driven by useAnimatedUnmount) */
|
|
21
10
|
isOpen?: boolean
|
|
22
11
|
}
|
|
23
12
|
|
|
24
|
-
interface CommandItem {
|
|
25
|
-
id: string
|
|
26
|
-
label: string
|
|
27
|
-
sublabel?: string
|
|
28
|
-
category: string
|
|
29
|
-
icon?: React.ComponentType<{ className?: string }>
|
|
30
|
-
shortcut?: string
|
|
31
|
-
action: () => void
|
|
32
|
-
/** Extra terms to match against during search (not displayed) */
|
|
33
|
-
searchTerms?: string[]
|
|
34
|
-
/** Small priority bonus added to the final score (only if the item matched). Used to nudge built-in k8s kinds above CRDs on tied queries like "policy" or "event". */
|
|
35
|
-
priorityBonus?: number
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Built-in k8s API groups. Used to nudge these above CRDs on tied matches.
|
|
39
|
-
const CORE_GROUP_BONUS = 10
|
|
40
|
-
const WELL_KNOWN_GROUP_BONUS = 5
|
|
41
|
-
const WELL_KNOWN_GROUPS = new Set([
|
|
42
|
-
'apps',
|
|
43
|
-
'batch',
|
|
44
|
-
'autoscaling',
|
|
45
|
-
'policy',
|
|
46
|
-
'networking.k8s.io',
|
|
47
|
-
'rbac.authorization.k8s.io',
|
|
48
|
-
'storage.k8s.io',
|
|
49
|
-
'scheduling.k8s.io',
|
|
50
|
-
'coordination.k8s.io',
|
|
51
|
-
'apiextensions.k8s.io',
|
|
52
|
-
'admissionregistration.k8s.io',
|
|
53
|
-
'apiregistration.k8s.io',
|
|
54
|
-
'certificates.k8s.io',
|
|
55
|
-
'events.k8s.io',
|
|
56
|
-
'discovery.k8s.io',
|
|
57
|
-
'flowcontrol.apiserver.k8s.io',
|
|
58
|
-
'node.k8s.io',
|
|
59
|
-
'authentication.k8s.io',
|
|
60
|
-
'authorization.k8s.io',
|
|
61
|
-
])
|
|
62
|
-
|
|
63
|
-
function groupPriorityBonus(group: string): number {
|
|
64
|
-
if (!group) return CORE_GROUP_BONUS
|
|
65
|
-
if (WELL_KNOWN_GROUPS.has(group)) return WELL_KNOWN_GROUP_BONUS
|
|
66
|
-
return 0
|
|
67
|
-
}
|
|
68
13
|
|
|
69
|
-
|
|
70
|
-
// Within a tier, a coverage bonus (up to +20) breaks ties in favor of
|
|
71
|
-
// shorter labels — so "serv" picks Service over ServiceAccount, and
|
|
72
|
-
// "service" picks Service (exact) decisively. Bonus is capped below the
|
|
73
|
-
// 25-point tier gap, so tier ordering is preserved.
|
|
74
|
-
function scoreMatch(text: string, query: string): number {
|
|
75
|
-
const lower = text.toLowerCase()
|
|
76
|
-
const q = query.toLowerCase()
|
|
77
|
-
if (!lower.includes(q)) return 0
|
|
78
|
-
let base: number
|
|
79
|
-
if (lower === q) base = 150
|
|
80
|
-
else if (lower.startsWith(q)) base = 100
|
|
81
|
-
else {
|
|
82
|
-
const wordStart = lower.indexOf(q)
|
|
83
|
-
const prev = lower[wordStart - 1]
|
|
84
|
-
base = wordStart > 0 && (prev === ' ' || prev === '/' || prev === '-' || prev === '.') ? 75 : 50
|
|
85
|
-
}
|
|
86
|
-
return base + (q.length / lower.length) * 20
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function bestScore(item: CommandItem, query: string): number {
|
|
90
|
-
// Primary label gets full score; secondary fields are discounted
|
|
91
|
-
// so that e.g. "node" matching the label "Node" ranks above
|
|
92
|
-
// "UpdateInfo" where "node" only matches the group "nodemanagement.gke.io"
|
|
93
|
-
let best = scoreMatch(item.label, query)
|
|
94
|
-
const secondary = Math.floor(Math.max(
|
|
95
|
-
scoreMatch(item.sublabel || '', query),
|
|
96
|
-
scoreMatch(item.category, query)
|
|
97
|
-
) * 0.6)
|
|
98
|
-
best = Math.max(best, secondary)
|
|
99
|
-
if (item.searchTerms) {
|
|
100
|
-
for (const term of item.searchTerms) {
|
|
101
|
-
best = Math.max(best, scoreMatch(term, query))
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
// Only apply the priority bonus to items that actually matched, so we don't
|
|
105
|
-
// surface unrelated built-ins ahead of a relevant CRD.
|
|
106
|
-
return best > 0 ? best + (item.priorityBonus || 0) : 0
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function CommandPalette({
|
|
110
|
-
onClose,
|
|
111
|
-
onNavigateView,
|
|
112
|
-
onNavigateKind,
|
|
113
|
-
onSwitchContext,
|
|
114
|
-
onSetNamespaces,
|
|
115
|
-
onToggleTheme,
|
|
116
|
-
onShowDiagnostics,
|
|
117
|
-
isOpen = true,
|
|
118
|
-
}: CommandPaletteProps) {
|
|
14
|
+
export function CommandPalette({ onClose, isOpen = true, ...callbacks }: CommandPaletteProps) {
|
|
119
15
|
const [query, setQuery] = useState('')
|
|
120
16
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
121
17
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
122
18
|
const resultsRef = useRef<HTMLDivElement>(null)
|
|
123
19
|
const isKeyboardNav = useRef(false)
|
|
124
20
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
21
|
+
// The static command items (Views / Kinds / Namespaces / Actions) — shared
|
|
22
|
+
// with the standalone omnibar via useCommandItems so the two never drift.
|
|
23
|
+
const items = useCommandItems(callbacks)
|
|
128
24
|
|
|
129
25
|
// Focus input on mount
|
|
130
26
|
useEffect(() => {
|
|
@@ -144,111 +40,6 @@ export function CommandPalette({
|
|
|
144
40
|
return () => document.removeEventListener('keydown', handler, true)
|
|
145
41
|
}, [onClose])
|
|
146
42
|
|
|
147
|
-
// Build command items
|
|
148
|
-
const items = useMemo<CommandItem[]>(() => {
|
|
149
|
-
const result: CommandItem[] = []
|
|
150
|
-
|
|
151
|
-
// Views
|
|
152
|
-
const viewEntries: { view: MainView; label: string; icon: React.ComponentType<{ className?: string }>; shortcut: string }[] = [
|
|
153
|
-
{ view: 'home', label: 'Home', icon: Home, shortcut: '1' },
|
|
154
|
-
{ view: 'topology', label: 'Topology', icon: Network, shortcut: '2' },
|
|
155
|
-
{ view: 'resources', label: 'Resources', icon: List, shortcut: '3' },
|
|
156
|
-
{ view: 'timeline', label: 'Timeline', icon: Clock, shortcut: '4' },
|
|
157
|
-
{ view: 'helm', label: 'Helm', icon: Package, shortcut: '5' },
|
|
158
|
-
{ view: 'gitops', label: 'GitOps', icon: GitBranch, shortcut: '6' },
|
|
159
|
-
{ view: 'traffic', label: 'Traffic', icon: Activity, shortcut: '7' },
|
|
160
|
-
{ view: 'cost', label: 'Cost', icon: DollarSign, shortcut: '8' },
|
|
161
|
-
{ view: 'audit', label: 'Audit', icon: ShieldCheck, shortcut: '9' },
|
|
162
|
-
]
|
|
163
|
-
for (const v of viewEntries) {
|
|
164
|
-
result.push({
|
|
165
|
-
id: `view-${v.view}`,
|
|
166
|
-
label: `Go to ${v.label}`,
|
|
167
|
-
category: 'Views',
|
|
168
|
-
icon: v.icon,
|
|
169
|
-
shortcut: v.shortcut,
|
|
170
|
-
action: () => { onNavigateView(v.view) },
|
|
171
|
-
})
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Resource kinds (deduplicate by name+group — backend may return multiple API versions)
|
|
175
|
-
const resources = apiResources || CORE_RESOURCES
|
|
176
|
-
const seenKinds = new Set<string>()
|
|
177
|
-
for (const r of resources) {
|
|
178
|
-
if (!r.verbs?.includes('list')) continue
|
|
179
|
-
const kindKey = `${r.name}/${r.group}`
|
|
180
|
-
if (seenKinds.has(kindKey)) continue
|
|
181
|
-
seenKinds.add(kindKey)
|
|
182
|
-
result.push({
|
|
183
|
-
id: `kind-${r.name}-${r.group}`,
|
|
184
|
-
label: r.kind,
|
|
185
|
-
sublabel: r.group || 'core',
|
|
186
|
-
category: 'Resource Kinds',
|
|
187
|
-
icon: getResourceIcon(r.kind),
|
|
188
|
-
action: () => { onNavigateKind(r.name, r.group) },
|
|
189
|
-
searchTerms: [r.name, r.kind],
|
|
190
|
-
priorityBonus: groupPriorityBonus(r.group),
|
|
191
|
-
})
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Contexts
|
|
195
|
-
if (contexts) {
|
|
196
|
-
for (const ctx of contexts) {
|
|
197
|
-
result.push({
|
|
198
|
-
id: `context-${ctx.name}`,
|
|
199
|
-
label: ctx.name,
|
|
200
|
-
sublabel: ctx.isCurrent ? 'current' : ctx.cluster,
|
|
201
|
-
category: 'Contexts',
|
|
202
|
-
action: () => { if (!ctx.isCurrent) onSwitchContext(ctx.name) },
|
|
203
|
-
})
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Namespaces
|
|
208
|
-
if (namespacesData) {
|
|
209
|
-
for (const ns of namespacesData) {
|
|
210
|
-
result.push({
|
|
211
|
-
id: `ns-${ns.name}`,
|
|
212
|
-
label: ns.name,
|
|
213
|
-
category: 'Namespaces',
|
|
214
|
-
action: () => { onSetNamespaces([ns.name]) },
|
|
215
|
-
})
|
|
216
|
-
}
|
|
217
|
-
// "All namespaces" option
|
|
218
|
-
result.push({
|
|
219
|
-
id: 'ns-all',
|
|
220
|
-
label: 'All Namespaces',
|
|
221
|
-
category: 'Namespaces',
|
|
222
|
-
action: () => { onSetNamespaces([]) },
|
|
223
|
-
})
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Actions
|
|
227
|
-
result.push({
|
|
228
|
-
id: 'action-theme',
|
|
229
|
-
label: 'Toggle Theme',
|
|
230
|
-
category: 'Actions',
|
|
231
|
-
icon: Sun,
|
|
232
|
-
shortcut: 't',
|
|
233
|
-
action: () => { onToggleTheme() },
|
|
234
|
-
})
|
|
235
|
-
|
|
236
|
-
if (onShowDiagnostics) {
|
|
237
|
-
result.push({
|
|
238
|
-
id: 'action-diagnostics',
|
|
239
|
-
label: 'Diagnostics',
|
|
240
|
-
category: 'Actions',
|
|
241
|
-
icon: Stethoscope,
|
|
242
|
-
shortcut: 'Ctrl+Shift+D',
|
|
243
|
-
action: () => { onShowDiagnostics() },
|
|
244
|
-
searchTerms: ['debug', 'health', 'status', 'snapshot'],
|
|
245
|
-
})
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
return result
|
|
249
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
250
|
-
}, [apiResources, contexts, namespacesData, onNavigateView, onNavigateKind, onSwitchContext, onSetNamespaces, onToggleTheme, onShowDiagnostics])
|
|
251
|
-
|
|
252
43
|
// Filter and rank results
|
|
253
44
|
const filteredItems = useMemo(() => {
|
|
254
45
|
if (!query.trim()) {
|