@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.
Files changed (31) hide show
  1. package/package.json +10 -11
  2. package/src/App.tsx +168 -42
  3. package/src/RadarApp.tsx +9 -1
  4. package/src/api/client.ts +185 -6
  5. package/src/components/UserMenu.tsx +56 -10
  6. package/src/components/applications/ApplicationsView.tsx +27 -19
  7. package/src/components/audit/AuditSettingsDialog.tsx +1 -1
  8. package/src/components/audit/AuditView.tsx +23 -35
  9. package/src/components/gitops/GitOpsView.tsx +24 -2
  10. package/src/components/helm/HelmView.tsx +12 -8
  11. package/src/components/home/HomeView.tsx +1 -1
  12. package/src/components/home/mcpToolCatalog.ts +34 -0
  13. package/src/components/issues/IssuesPane.tsx +82 -28
  14. package/src/components/nav/PrimaryNavRail.tsx +282 -0
  15. package/src/components/resource/HPACharts.tsx +7 -2
  16. package/src/components/resource/RestartChart.tsx +8 -0
  17. package/src/components/resources/ResourcesView.tsx +54 -3
  18. package/src/components/resources/renderers/HPARenderer.tsx +4 -1
  19. package/src/components/resources/renderers/WorkloadRenderer.tsx +34 -3
  20. package/src/components/settings/SettingsDialog.tsx +44 -7
  21. package/src/components/ui/CommandPalette.tsx +6 -215
  22. package/src/components/ui/Omnibar.tsx +493 -0
  23. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  24. package/src/components/ui/command-items.ts +178 -0
  25. package/src/components/workload/WorkloadView.tsx +3 -1
  26. package/src/context/NavCustomization.tsx +11 -0
  27. package/src/contexts/CapabilitiesContext.tsx +16 -5
  28. package/src/hooks/useMediaQuery.ts +21 -0
  29. package/src/hooks/useNavRailPinned.ts +46 -0
  30. package/src/hooks/useRecentResources.ts +49 -0
  31. 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
- {!isDesktop && (
336
- <ConfigToggle
337
- label="Open browser on start"
338
- value={!(config.noBrowser ?? false)}
339
- onChange={(v) => onChange('noBrowser', !v ? true : undefined)}
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 { useNamespaces, useContexts } from '../../api/client'
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
- // Fuzzy match scoring: exact > prefix > word boundary > substring.
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
- const { data: namespacesData } = useNamespaces()
126
- const { data: contexts } = useContexts()
127
- const { data: apiResources } = useAPIResources()
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()) {