@skyhook-io/radar-app 1.0.1 → 1.1.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.
@@ -2,11 +2,12 @@ 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
4
  import { Home, Network, List, Clock, Package, Activity, Sun, Stethoscope, DollarSign, ShieldCheck } from 'lucide-react'
5
+ import { GitBranch } from 'lucide-react'
5
6
  import { clsx } from 'clsx'
6
7
  import { useNamespaces, useContexts } from '../../api/client'
7
8
  import { CORE_RESOURCES, useAPIResources } from '../../api/apiResources'
8
9
  import { getResourceIcon } from '../../utils/resource-icons'
9
- type MainView = 'home' | 'topology' | 'resources' | 'timeline' | 'helm' | 'traffic' | 'cost' | 'audit'
10
+ type MainView = 'home' | 'topology' | 'resources' | 'timeline' | 'helm' | 'traffic' | 'cost' | 'audit' | 'gitops'
10
11
 
11
12
  interface CommandPaletteProps {
12
13
  onClose: () => void
@@ -154,9 +155,10 @@ export function CommandPalette({
154
155
  { view: 'resources', label: 'Resources', icon: List, shortcut: '3' },
155
156
  { view: 'timeline', label: 'Timeline', icon: Clock, shortcut: '4' },
156
157
  { view: 'helm', label: 'Helm', icon: Package, shortcut: '5' },
157
- { view: 'traffic', label: 'Traffic', icon: Activity, shortcut: '6' },
158
- { view: 'cost', label: 'Cost', icon: DollarSign, shortcut: '7' },
159
- { view: 'audit', label: 'Audit', icon: ShieldCheck, shortcut: '8' },
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' },
160
162
  ]
161
163
  for (const v of viewEntries) {
162
164
  result.push({
@@ -19,7 +19,7 @@ const CONTEXT_CATEGORIES: ShortcutCategory[] = ['Drawer', 'Dock']
19
19
 
20
20
  // Preferred ordering within the view section
21
21
  const VIEW_CATEGORY_ORDER: ShortcutCategory[] = [
22
- 'Search', 'Table', 'Resource Actions', 'Topology', 'Timeline', 'Helm',
22
+ 'Search', 'Table', 'Resource Actions', 'Topology', 'Timeline', 'Helm', 'GitOps',
23
23
  ]
24
24
 
25
25
  const VIEW_LABELS: Record<string, string> = {
@@ -28,6 +28,7 @@ const VIEW_LABELS: Record<string, string> = {
28
28
  resources: 'Resources',
29
29
  timeline: 'Timeline',
30
30
  helm: 'Helm',
31
+ gitops: 'GitOps',
31
32
  traffic: 'Traffic',
32
33
  }
33
34
 
@@ -8,6 +8,7 @@ import {
8
8
  useApplyDesktopUpdate,
9
9
  } from '../../api/client'
10
10
  import type { DesktopUpdateState } from '../../api/client'
11
+ import { WithTooltip } from './Tooltip'
11
12
 
12
13
  const DISMISSED_KEY = 'radar-update-dismissed'
13
14
 
@@ -117,7 +118,7 @@ export function UpdateNotification() {
117
118
  <UpdateIcon state={effectiveState} />
118
119
  </div>
119
120
  <div className="flex-1 min-w-0">
120
- <h4 className="text-sm font-medium text-theme-text-primary">
121
+ <h4 className="text-sm font-medium text-theme-text-primary pr-6">
121
122
  <UpdateTitle state={effectiveState} />
122
123
  </h4>
123
124
  <p className="text-xs text-theme-text-secondary mt-1">
@@ -140,15 +141,30 @@ export function UpdateNotification() {
140
141
 
141
142
  {/* CLI: show update command with copy button for package managers */}
142
143
  {!isDesktop && versionInfo.updateCommand ? (
143
- <button
144
- onClick={handleCopyCommand}
145
- className="flex items-center gap-2 mt-2 px-2 py-1.5 bg-theme-elevated rounded text-xs font-mono text-theme-text-primary hover:bg-theme-surface-hover transition-colors w-full"
146
- >
147
- <code className="flex-1 text-left truncate">{versionInfo.updateCommand}</code>
148
- <CopyIcon copied={copied} failed={copyFailed} />
149
- </button>
144
+ <>
145
+ <WithTooltip tip={versionInfo.updateCommand} delay={100}>
146
+ <button
147
+ onClick={handleCopyCommand}
148
+ className="flex items-center gap-2 mt-2 px-2 py-1.5 bg-theme-elevated rounded font-mono text-theme-text-primary hover:bg-theme-surface-hover transition-colors w-full"
149
+ >
150
+ <code className="flex-1 text-left truncate text-[11px]">{versionInfo.updateCommand}</code>
151
+ <CopyIcon copied={copied} failed={copyFailed} />
152
+ </button>
153
+ </WithTooltip>
154
+ {/* Direct installs may have placed the binary somewhere the install
155
+ script won't touch — surface a download link as a fallback. */}
156
+ {versionInfo.installMethod === 'direct' && versionInfo.releaseUrl && (
157
+ <a
158
+ href={versionInfo.releaseUrl}
159
+ target="_blank"
160
+ rel="noopener noreferrer"
161
+ className="inline-flex items-center gap-1 mt-1.5 text-xs text-theme-text-tertiary hover:text-theme-text-secondary hover:underline"
162
+ >
163
+ or download from GitHub →
164
+ </a>
165
+ )}
166
+ </>
150
167
  ) : (
151
- /* Direct download - show release link */
152
168
  !isDesktop && versionInfo.releaseUrl && (
153
169
  <a
154
170
  href={versionInfo.releaseUrl}
@@ -161,17 +177,18 @@ export function UpdateNotification() {
161
177
  )
162
178
  )}
163
179
  </div>
164
- {/* Don't show dismiss during active update */}
165
- {effectiveState !== 'downloading' && effectiveState !== 'applying' && (
166
- <button
167
- onClick={handleDismiss}
168
- className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
169
- aria-label="Dismiss"
170
- >
171
- <X className="w-4 h-4" />
172
- </button>
173
- )}
174
180
  </div>
181
+ {/* Dismiss is absolute so it doesn't compress the chip's width.
182
+ fixed on the parent already establishes the positioning context. */}
183
+ {effectiveState !== 'downloading' && effectiveState !== 'applying' && (
184
+ <button
185
+ onClick={handleDismiss}
186
+ className="absolute top-2 right-2 p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
187
+ aria-label="Dismiss"
188
+ >
189
+ <X className="w-4 h-4" />
190
+ </button>
191
+ )}
175
192
  </div>
176
193
  )
177
194
  }
@@ -6,9 +6,11 @@ import { Terminal } from 'lucide-react'
6
6
  import {
7
7
  WorkloadView as BaseWorkloadView,
8
8
  type RendererOverrides,
9
+ type GitOpsOwnerRef,
10
+ gitOpsRouteForOwner,
9
11
  } from '@skyhook-io/k8s-ui'
10
12
  import type { SelectedResource, ResourceRef, ResolvedEnvFrom } from '../../types'
11
- import { kindToPlural, type NavigateToResource } from '../../utils/navigation'
13
+ import { kindToPlural, buildWorkloadPath, type NavigateToResource } from '../../utils/navigation'
12
14
  import {
13
15
  useChanges, useResourceWithRelationships, usePodLogs, useTopology, useUpdateResource,
14
16
  useDeleteResource, useTriggerCronJob, useSuspendCronJob, useResumeCronJob,
@@ -21,7 +23,7 @@ import {
21
23
  fetchJSON,
22
24
  } from '../../api/client'
23
25
  import { PrometheusCharts, isPrometheusSupported } from '../resource/PrometheusCharts'
24
- import { useResourceAudit } from '../../api/client'
26
+ import { useResourceAudit, useResources } from '../../api/client'
25
27
  import { AuditAlerts } from '@skyhook-io/k8s-ui'
26
28
  import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer'
27
29
  import { LogsViewer } from '../logs/LogsViewer'
@@ -35,6 +37,7 @@ import { ServiceRenderer } from '../resources/renderers/ServiceRenderer'
35
37
  import { WorkloadRenderer } from '../resources/renderers/WorkloadRenderer'
36
38
  import { CreateResourceDialog } from '../shared/CreateResourceDialog'
37
39
  import { cleanYamlForDuplicate } from '../../utils/skeleton-yaml'
40
+ import { useDesktopDownload } from '../../hooks/useDesktopDownload'
38
41
 
39
42
  type TabType = 'overview' | 'timeline' | 'logs' | 'metrics' | 'yaml'
40
43
 
@@ -54,13 +57,19 @@ interface WorkloadViewRouteProps {
54
57
  export function WorkloadViewRoute({ onNavigateToResource }: WorkloadViewRouteProps) {
55
58
  const location = useLocation()
56
59
  const navigate = useNavigate()
60
+ const [searchParams] = useSearchParams()
57
61
 
58
- // Parse /workload/:kind/:ns/:name from pathname
62
+ // Parse /workload/:kind/:ns/:name from pathname. Segments are URL-encoded by
63
+ // buildWorkloadPath; names can also contain literal slashes (e.g. some CRD names),
64
+ // which survive encoding as %2F and reassemble correctly here.
59
65
  const parts = location.pathname.replace(/^\//, '').split('/')
60
- // parts[0] = 'workload', parts[1] = kind, parts[2] = ns, parts[3+] = name (may contain slashes)
61
- const kind = parts[1] || ''
62
- const namespace = parts[2] || ''
63
- const name = parts.slice(3).join('/') || ''
66
+ const decode = (s: string): string => {
67
+ try { return decodeURIComponent(s) } catch { return s }
68
+ }
69
+ const kind = decode(parts[1] ?? '')
70
+ const namespace = decode(parts[2] ?? '')
71
+ const name = parts.slice(3).map(decode).join('/')
72
+ const group = searchParams.get('apiGroup') || ''
64
73
 
65
74
  if (!kind || !namespace || !name) {
66
75
  return (
@@ -79,8 +88,7 @@ export function WorkloadViewRoute({ onNavigateToResource }: WorkloadViewRoutePro
79
88
  }, [navigate])
80
89
 
81
90
  const handleNavigate = useCallback((resource: SelectedResource) => {
82
- // Navigate to another workload view
83
- navigate(`/workload/${resource.kind}/${resource.namespace}/${resource.name}`)
91
+ navigate(buildWorkloadPath(resource))
84
92
  }, [navigate])
85
93
 
86
94
  return (
@@ -88,6 +96,7 @@ export function WorkloadViewRoute({ onNavigateToResource }: WorkloadViewRoutePro
88
96
  kind={kind}
89
97
  namespace={namespace}
90
98
  name={name}
99
+ group={group}
91
100
  onBack={handleBack}
92
101
  onNavigateToResource={onNavigateToResource || handleNavigate}
93
102
  />
@@ -315,11 +324,22 @@ export function WorkloadView({
315
324
  const canUpdateSecrets = useCanUpdateSecrets()
316
325
  const updateResource = useUpdateResource()
317
326
  const actionsBarProps = useActionsBarProps(kindProp, namespace, name)
327
+ const desktopDownload = useDesktopDownload()
318
328
 
319
329
  const handleUpdateResource = useCallback(async (params: { kind: string; namespace: string; name: string; yaml: string }) => {
320
330
  await updateResource.mutateAsync(params)
321
331
  }, [updateResource])
322
332
 
333
+ const navigateRouter = useNavigate()
334
+ const handleOpenGitOpsResource = useCallback(
335
+ (ref: GitOpsOwnerRef) => navigateRouter(gitOpsRouteForOwner(ref)),
336
+ [navigateRouter],
337
+ )
338
+ const handleNavigateGitOpsPath = useCallback(
339
+ (path: string) => navigateRouter(path),
340
+ [navigateRouter],
341
+ )
342
+
323
343
  // Duplicate dialog
324
344
  const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
325
345
  const [duplicateYaml, setDuplicateYaml] = useState('')
@@ -370,12 +390,18 @@ export function WorkloadView({
370
390
  isPrometheusSupported(kind) && !(kind === 'Pod' && res?.status?.phase === 'Pending')
371
391
  }
372
392
  onDuplicate={handleDuplicate}
393
+ onDownload={desktopDownload}
373
394
  actionsBarProps={actionsBarProps}
374
395
  rendererOverrides={rendererOverrides}
375
396
  resolvedEnvFrom={resolvedEnvFrom}
376
397
  renderOverviewExtra={({ kind: k, namespace: ns, name: n }) => (
377
- <AuditSection kind={k} namespace={ns} name={n} />
398
+ <>
399
+ <AuditSection kind={k} namespace={ns} name={n} />
400
+ <FluxSourceConsumersSection kind={k} namespace={ns} name={n} />
401
+ </>
378
402
  )}
403
+ onOpenGitOpsResource={handleOpenGitOpsResource}
404
+ onNavigateGitOpsPath={handleNavigateGitOpsPath}
379
405
  />
380
406
  <CreateResourceDialog
381
407
  open={duplicateDialogOpen}
@@ -546,3 +572,93 @@ function AuditSection({ kind, namespace, name }: { kind: string; namespace: stri
546
572
  if (!findings || findings.length === 0) return null
547
573
  return <AuditAlerts findings={findings} onViewAll={() => navigate('/audit')} />
548
574
  }
575
+
576
+ // FluxSourceConsumersSection lists the reconcilers (Kustomization, HelmRelease)
577
+ // that reference this Flux source CR — the inverse of `spec.sourceRef`. Renders
578
+ // only when the focused resource is a Flux source kind; otherwise null. Sources
579
+ // can have many consumers (one repo feeding multiple apps), so this answers
580
+ // "if I edit this source, what gets affected on the next reconcile?".
581
+ //
582
+ // Filtering happens client-side off the namespaced reconciler lists — these
583
+ // are typically small (tens, not thousands) and the dynamic informer cache
584
+ // makes the request cheap. If a cluster ever has thousands of HelmReleases,
585
+ // a dedicated /api/gitops/consumers endpoint would be the right move; today
586
+ // it'd be premature.
587
+ // Outer component is cheap — it does only the kind check and decides whether
588
+ // to mount the data-fetching child. Without this split, useResources would
589
+ // fire two API calls on EVERY workload drawer open (Pod, Deployment, Service,
590
+ // …), since the hook has no `enabled` flag and can't be conditionally called
591
+ // (Rules of Hooks). The hooks only need to run when the focused resource is
592
+ // actually a Flux source CR.
593
+ function FluxSourceConsumersSection({ kind, namespace, name }: { kind: string; namespace: string; name: string }) {
594
+ // The inner WorkloadView de-pluralizes the URL's plural form, which gives
595
+ // "Gitrepository" (single-uppercase) rather than the wire-correct
596
+ // "GitRepository" — so we match lowercase. spec.sourceRef.kind on consumers
597
+ // is always wire-correct, so we look that up separately.
598
+ const sourceKind = FLUX_SOURCE_KIND_BY_LOWER.get(kind.toLowerCase()) ?? null
599
+ if (!sourceKind) return null
600
+ return <FluxSourceConsumersInner sourceKind={sourceKind} namespace={namespace} name={name} />
601
+ }
602
+
603
+ function FluxSourceConsumersInner({ sourceKind, namespace, name }: { sourceKind: string; namespace: string; name: string }) {
604
+ const navigate = useNavigate()
605
+ const { data: kustomizations } = useResources<any>('kustomizations', undefined, 'kustomize.toolkit.fluxcd.io')
606
+ const { data: helmReleases } = useResources<any>('helmreleases', undefined, 'helm.toolkit.fluxcd.io')
607
+
608
+ const consumers: Array<{ kind: 'Kustomization' | 'HelmRelease'; namespace: string; name: string; plural: string }> = []
609
+ for (const k of kustomizations ?? []) {
610
+ const ref = k?.spec?.sourceRef ?? {}
611
+ const refNs = ref.namespace || k?.metadata?.namespace
612
+ if (ref.kind === sourceKind && ref.name === name && refNs === namespace) {
613
+ consumers.push({ kind: 'Kustomization', namespace: k.metadata.namespace, name: k.metadata.name, plural: 'kustomizations' })
614
+ }
615
+ }
616
+ for (const h of helmReleases ?? []) {
617
+ const ref = h?.spec?.chart?.spec?.sourceRef ?? {}
618
+ const refNs = ref.namespace || h?.metadata?.namespace
619
+ if (ref.kind === sourceKind && ref.name === name && refNs === namespace) {
620
+ consumers.push({ kind: 'HelmRelease', namespace: h.metadata.namespace, name: h.metadata.name, plural: 'helmreleases' })
621
+ }
622
+ }
623
+
624
+ if (consumers.length === 0) {
625
+ return (
626
+ <div className="rounded-lg border border-theme-border bg-theme-elevated/40 p-3 text-xs text-theme-text-tertiary">
627
+ Consumed by — no Kustomization or HelmRelease references this source.
628
+ </div>
629
+ )
630
+ }
631
+
632
+ return (
633
+ <div className="rounded-lg border border-theme-border bg-theme-elevated/40 p-3">
634
+ <h3 className="mb-2 text-xs font-medium text-theme-text-secondary">
635
+ Consumed by ({consumers.length})
636
+ </h3>
637
+ <div className="flex flex-wrap gap-1.5">
638
+ {consumers.map((c) => (
639
+ <button
640
+ key={`${c.kind}/${c.namespace}/${c.name}`}
641
+ onClick={() => navigate(`/gitops/detail/${c.plural}/${encodeURIComponent(c.namespace)}/${encodeURIComponent(c.name)}`)}
642
+ className="inline-flex items-center gap-1.5 rounded border border-theme-border bg-theme-surface px-1.5 py-0.5 text-[11px] text-theme-text-secondary hover:border-skyhook-500/60 hover:text-skyhook-500 transition-colors"
643
+ title={`${c.kind} ${c.namespace}/${c.name}`}
644
+ >
645
+ <span className="text-theme-text-tertiary">{c.kind === 'HelmRelease' ? 'HR' : 'K'}</span>
646
+ <span>{c.namespace}/{c.name}</span>
647
+ </button>
648
+ ))}
649
+ </div>
650
+ </div>
651
+ )
652
+ }
653
+
654
+ // FLUX_SOURCE_KIND_BY_LOWER maps lowercase kind (what the inner WorkloadView
655
+ // produces via its plural-to-singular fallback) to the wire-correct
656
+ // PascalCase form that consumers carry in spec.sourceRef.kind. HelmChart is
657
+ // intentionally absent — it's an auto-generated internal CR, not something
658
+ // users create or point reconcilers at directly.
659
+ const FLUX_SOURCE_KIND_BY_LOWER = new Map<string, string>([
660
+ ['gitrepository', 'GitRepository'],
661
+ ['helmrepository', 'HelmRepository'],
662
+ ['ocirepository', 'OCIRepository'],
663
+ ['bucket', 'Bucket'],
664
+ ])
package/src/types.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  // Re-export all types from the shared @skyhook-io/k8s-ui package.
2
2
  // This file exists for backward compatibility — new code should import from '@skyhook-io/k8s-ui' directly.
3
3
  export * from '@skyhook-io/k8s-ui/types/core'
4
+ export * from '@skyhook-io/k8s-ui/types/gitops-tree'
5
+ export * from '@skyhook-io/k8s-ui/types/gitops-insights'
@@ -1,9 +1,22 @@
1
1
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../api/config'
2
+ import type { SelectedResource } from '@skyhook-io/k8s-ui/types/core'
2
3
 
3
4
  // Re-export shared navigation utilities from @skyhook-io/k8s-ui.
4
- export { kindToPlural, pluralToKind, refToSelectedResource } from '@skyhook-io/k8s-ui/utils/navigation'
5
+ export { kindToPlural, pluralToKind, refToSelectedResource, apiVersionToGroup } from '@skyhook-io/k8s-ui/utils/navigation'
5
6
  export type { NavigateToResource } from '@skyhook-io/k8s-ui/utils/navigation'
6
7
 
8
+ /**
9
+ * Build a /workload/:kind/:namespace/:name URL, preserving the API group as a
10
+ * query param so the WorkloadView can resolve CRDs with colliding kind names.
11
+ */
12
+ export function buildWorkloadPath(resource: SelectedResource): string {
13
+ const kind = encodeURIComponent(resource.kind)
14
+ const namespace = encodeURIComponent(resource.namespace)
15
+ const name = encodeURIComponent(resource.name)
16
+ const base = `/workload/${kind}/${namespace}/${name}`
17
+ return resource.group ? `${base}?apiGroup=${encodeURIComponent(resource.group)}` : base
18
+ }
19
+
7
20
  // radar-specific: open URL in system browser (desktop app support)
8
21
  export function openExternal(url: string): void {
9
22
  fetch(apiUrl('/desktop/open-url'), {