@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.
@@ -0,0 +1,107 @@
1
+ import { useState, useEffect } from 'react'
2
+ import { DialogPortal } from '@skyhook-io/k8s-ui'
3
+ import { History, Loader2 } from 'lucide-react'
4
+ import { Tooltip } from '../ui/Tooltip'
5
+
6
+ interface RollbackDialogProps {
7
+ open: boolean
8
+ // Identifies the target history entry. Caller provides the user-visible
9
+ // revision (mono SHA / tag) and the API id; Argo uses id, the user reads
10
+ // the revision.
11
+ appLabel: string
12
+ revision: string
13
+ historyId?: string
14
+ pending?: boolean
15
+ onCancel: () => void
16
+ onConfirm: (opts: { prune: boolean; dryRun: boolean }) => void
17
+ }
18
+
19
+ // Confirms an Argo Application rollback. Defaults are conservative — no
20
+ // prune (avoid surprise deletions), no dry run (just do it). Operators
21
+ // who want to preview check Dry Run; the result lands in Activity.
22
+ export function RollbackDialog({ open, appLabel, revision, historyId, pending, onCancel, onConfirm }: RollbackDialogProps) {
23
+ const [prune, setPrune] = useState(false)
24
+ const [dryRun, setDryRun] = useState(false)
25
+
26
+ useEffect(() => {
27
+ if (open) {
28
+ setPrune(false)
29
+ setDryRun(false)
30
+ }
31
+ }, [open])
32
+
33
+ return (
34
+ <DialogPortal open={open} onClose={pending ? () => {} : onCancel} className="w-[440px]" closable={!pending}>
35
+ <div className="border-b border-theme-border px-4 py-3">
36
+ <h2 className="text-sm font-semibold text-theme-text-primary">Roll back application</h2>
37
+ <p className="mt-0.5 text-xs text-theme-text-tertiary">{appLabel}</p>
38
+ </div>
39
+ <div className="space-y-4 px-4 py-4 text-sm">
40
+ <div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
41
+ Argo will sync this application to a previous revision. This is a write operation on the cluster.
42
+ </div>
43
+ <dl className="grid grid-cols-[80px_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs">
44
+ <dt className="text-theme-text-tertiary">Revision</dt>
45
+ <dd className="min-w-0">
46
+ <Tooltip content={revision} delay={400} disabled={!revision} wrapperClassName="block max-w-full">
47
+ <span className="block truncate font-mono text-theme-text-primary">{revision || '-'}</span>
48
+ </Tooltip>
49
+ </dd>
50
+ {historyId && (
51
+ <>
52
+ <dt className="text-theme-text-tertiary">History ID</dt>
53
+ <dd className="font-mono text-theme-text-primary">#{historyId}</dd>
54
+ </>
55
+ )}
56
+ </dl>
57
+ <div className="space-y-2">
58
+ <label className="flex items-start gap-2">
59
+ <input
60
+ type="checkbox"
61
+ checked={prune}
62
+ onChange={(e) => setPrune(e.target.checked)}
63
+ disabled={pending}
64
+ className="mt-0.5 h-3.5 w-3.5 cursor-pointer accent-sky-500 disabled:cursor-not-allowed"
65
+ />
66
+ <div className="min-w-0">
67
+ <div className="text-xs text-theme-text-primary">Prune resources added since this revision</div>
68
+ <div className="text-[11px] text-theme-text-tertiary">Off by default — leaves any resources created after this revision untouched.</div>
69
+ </div>
70
+ </label>
71
+ <label className="flex items-start gap-2">
72
+ <input
73
+ type="checkbox"
74
+ checked={dryRun}
75
+ onChange={(e) => setDryRun(e.target.checked)}
76
+ disabled={pending}
77
+ className="mt-0.5 h-3.5 w-3.5 cursor-pointer accent-sky-500 disabled:cursor-not-allowed"
78
+ />
79
+ <div className="min-w-0">
80
+ <div className="text-xs text-theme-text-primary">Dry run</div>
81
+ <div className="text-[11px] text-theme-text-tertiary">Preview the rollback without applying it.</div>
82
+ </div>
83
+ </label>
84
+ </div>
85
+ </div>
86
+ <div className="flex items-center justify-end gap-2 border-t border-theme-border bg-theme-base px-4 py-3">
87
+ <button
88
+ type="button"
89
+ onClick={onCancel}
90
+ disabled={pending}
91
+ className="rounded-md border border-theme-border bg-theme-surface px-3 py-1.5 text-xs text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary disabled:cursor-not-allowed disabled:opacity-50"
92
+ >
93
+ Cancel
94
+ </button>
95
+ <button
96
+ type="button"
97
+ onClick={() => onConfirm({ prune, dryRun })}
98
+ disabled={pending}
99
+ className="inline-flex items-center gap-1.5 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-1.5 text-xs font-medium text-amber-700 hover:bg-amber-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-amber-400"
100
+ >
101
+ {pending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <History className="h-3.5 w-3.5" />}
102
+ {dryRun ? 'Run dry-run' : 'Roll back'}
103
+ </button>
104
+ </div>
105
+ </DialogPortal>
106
+ )
107
+ }
@@ -0,0 +1,144 @@
1
+ import { useState, useEffect, type ComponentType } from 'react'
2
+ import { DialogPortal } from '@skyhook-io/k8s-ui'
3
+ import { Loader2, RefreshCw } from 'lucide-react'
4
+
5
+ interface SyncOptionsDialogProps {
6
+ open: boolean
7
+ appLabel: string
8
+ pending?: boolean
9
+ onCancel: () => void
10
+ onConfirm: (opts: {
11
+ revision?: string
12
+ prune: boolean
13
+ dryRun: boolean
14
+ force: boolean
15
+ applyOnly: boolean
16
+ syncOptions: string[]
17
+ }) => void
18
+ }
19
+
20
+ // Modeled on ArgoCD's Sync drawer. Single Sync-now button at the bottom;
21
+ // defaults are "what most users want" (prune true, no dry-run, no force)
22
+ // so the common path is two clicks. Revision is optional and falls
23
+ // through to whatever Argo had targeted before.
24
+ export function SyncOptionsDialog({ open, appLabel, pending, onCancel, onConfirm }: SyncOptionsDialogProps) {
25
+ const [revision, setRevision] = useState('')
26
+ const [prune, setPrune] = useState(true)
27
+ const [dryRun, setDryRun] = useState(false)
28
+ const [force, setForce] = useState(false)
29
+ const [applyOnly, setApplyOnly] = useState(false)
30
+ const [replace, setReplace] = useState(false)
31
+ const [serverSideApply, setServerSideApply] = useState(false)
32
+
33
+ // Reset on each open so a previous attempt's flags don't leak into the
34
+ // next sync — easy footgun in modal-heavy flows.
35
+ useEffect(() => {
36
+ if (open) {
37
+ setRevision('')
38
+ setPrune(true)
39
+ setDryRun(false)
40
+ setForce(false)
41
+ setApplyOnly(false)
42
+ setReplace(false)
43
+ setServerSideApply(false)
44
+ }
45
+ }, [open])
46
+
47
+ function submit() {
48
+ const syncOptions: string[] = []
49
+ if (replace) syncOptions.push('Replace=true')
50
+ if (serverSideApply) syncOptions.push('ServerSideApply=true')
51
+ onConfirm({
52
+ revision: revision.trim() || undefined,
53
+ prune,
54
+ dryRun,
55
+ force,
56
+ applyOnly,
57
+ syncOptions,
58
+ })
59
+ }
60
+
61
+ return (
62
+ <DialogPortal open={open} onClose={pending ? () => {} : onCancel} className="w-[480px]" closable={!pending}>
63
+ <div className="border-b border-theme-border px-4 py-3">
64
+ <h2 className="text-sm font-semibold text-theme-text-primary">Sync application</h2>
65
+ <p className="mt-0.5 text-xs text-theme-text-tertiary">{appLabel}</p>
66
+ </div>
67
+ <div className="space-y-4 px-4 py-4 text-sm">
68
+ <label className="block">
69
+ <span className="text-xs font-medium text-theme-text-secondary">Revision (optional)</span>
70
+ <input
71
+ type="text"
72
+ value={revision}
73
+ onChange={(e) => setRevision(e.target.value)}
74
+ placeholder="HEAD"
75
+ disabled={pending}
76
+ className="mt-1 w-full rounded-md border border-theme-border bg-theme-base px-2 py-1.5 font-mono text-xs text-theme-text-primary outline-none placeholder:text-theme-text-tertiary focus:border-sky-500"
77
+ />
78
+ <span className="mt-0.5 block text-[11px] text-theme-text-tertiary">
79
+ Branch, tag, or commit SHA. Leave empty to use the Application's targetRevision.
80
+ </span>
81
+ </label>
82
+
83
+ {/* Common (Prune / Dry run) sit above a divider; Advanced toggles
84
+ stay accessible but visually subordinate so the common-case user
85
+ can scan past them without parsing every helper line. */}
86
+ <fieldset className="space-y-2">
87
+ <legend className="mb-1 text-xs font-medium text-theme-text-secondary">Sync options</legend>
88
+ <Toggle label="Prune" checked={prune} onChange={setPrune} disabled={pending} hint="Delete resources that are no longer in Git." />
89
+ <Toggle label="Dry run" checked={dryRun} onChange={setDryRun} disabled={pending} hint="Preview only — Argo computes the diff but applies nothing." />
90
+ </fieldset>
91
+ <fieldset className="space-y-2 border-t border-theme-border pt-3">
92
+ <legend className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Advanced</legend>
93
+ <Toggle label="Apply only" checked={applyOnly} onChange={setApplyOnly} disabled={pending} hint="Skip PreSync / PostSync / SyncFail hooks." />
94
+ <Toggle label="Force" checked={force} onChange={setForce} disabled={pending} hint="Use kubectl --force; required for some immutable-field changes." />
95
+ <Toggle label="Replace" checked={replace} onChange={setReplace} disabled={pending} hint="kubectl replace instead of apply (drops fields not in source)." />
96
+ <Toggle label="Server-side apply" checked={serverSideApply} onChange={setServerSideApply} disabled={pending} hint="Use the K8s server-side apply mechanism for ownership tracking." />
97
+ </fieldset>
98
+ </div>
99
+ <div className="flex items-center justify-end gap-2 border-t border-theme-border bg-theme-base px-4 py-3">
100
+ <button
101
+ type="button"
102
+ onClick={onCancel}
103
+ disabled={pending}
104
+ className="rounded-md border border-theme-border bg-theme-surface px-3 py-1.5 text-xs text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary disabled:cursor-not-allowed disabled:opacity-50"
105
+ >
106
+ Cancel
107
+ </button>
108
+ <PrimaryButton onClick={submit} disabled={pending} icon={pending ? Loader2 : RefreshCw} loading={pending} label={dryRun ? 'Run dry-run' : 'Sync now'} />
109
+ </div>
110
+ </DialogPortal>
111
+ )
112
+ }
113
+
114
+ function Toggle({ label, checked, onChange, disabled, hint }: { label: string; checked: boolean; onChange: (v: boolean) => void; disabled?: boolean; hint?: string }) {
115
+ return (
116
+ <label className="flex items-start gap-2">
117
+ <input
118
+ type="checkbox"
119
+ checked={checked}
120
+ onChange={(e) => onChange(e.target.checked)}
121
+ disabled={disabled}
122
+ className="mt-0.5 h-3.5 w-3.5 cursor-pointer accent-sky-500 disabled:cursor-not-allowed"
123
+ />
124
+ <div className="min-w-0">
125
+ <div className="text-xs text-theme-text-primary">{label}</div>
126
+ {hint && <div className="text-[11px] text-theme-text-tertiary">{hint}</div>}
127
+ </div>
128
+ </label>
129
+ )
130
+ }
131
+
132
+ function PrimaryButton({ onClick, disabled, icon: Icon, loading, label }: { onClick: () => void; disabled?: boolean; icon: ComponentType<{ className?: string }>; loading?: boolean; label: string }) {
133
+ return (
134
+ <button
135
+ type="button"
136
+ onClick={onClick}
137
+ disabled={disabled}
138
+ className="btn-brand inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-50"
139
+ >
140
+ <Icon className={`h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
141
+ {label}
142
+ </button>
143
+ )
144
+ }
@@ -1,10 +1,11 @@
1
1
  import { useState, useCallback, useEffect, useRef } from 'react'
2
2
  import { flushSync } from 'react-dom'
3
- import { PaneLoader } from '@skyhook-io/k8s-ui'
3
+ import { PaneLoader, useDockReservedHeight } from '@skyhook-io/k8s-ui'
4
4
  import { startViewTransitionSafe } from '@skyhook-io/k8s-ui/utils/view-transition'
5
5
  import { TRANSITION_DRAWER } from '../../utils/animation'
6
6
  import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
7
- import { X, Copy, Check, RefreshCw, Package, Code, History, FileText, Settings, Link2, Anchor, GitFork, BookOpen, ArrowUpCircle, Trash2 } from 'lucide-react'
7
+ import { X, Copy, Check, RefreshCw, Package, Code, History, FileText, Settings, Link2, Anchor, GitFork, BookOpen, ArrowUpCircle, Trash2, GitBranch } from 'lucide-react'
8
+ import { useNavigate } from 'react-router-dom'
8
9
  import { clsx } from 'clsx'
9
10
  import { useHelmRelease, useHelmManifest, useHelmValues, useHelmManifestDiff, useHelmUpgradeInfo, useHelmUninstall, upgradeWithProgress, rollbackWithProgress } from '../../api/client'
10
11
  import { useQueryClient } from '@tanstack/react-query'
@@ -37,6 +38,7 @@ const MAX_WIDTH_PERCENT = 0.8
37
38
  const DEFAULT_WIDTH = 1000
38
39
 
39
40
  export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOpen = true }: HelmReleaseDrawerProps) {
41
+ const navigate = useNavigate()
40
42
  const [activeTab, setActiveTab] = useState<TabId>('overview')
41
43
  const [copied, setCopied] = useState<string | null>(null)
42
44
  const [drawerWidth, setDrawerWidth] = useState(DEFAULT_WIDTH)
@@ -279,6 +281,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
279
281
  }
280
282
 
281
283
  const headerHeight = 49
284
+ const dockInset = useDockReservedHeight()
282
285
 
283
286
  const tabs: { id: TabId; label: string; icon: typeof Package }[] = [
284
287
  { id: 'overview', label: 'Overview', icon: Package },
@@ -301,7 +304,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
301
304
  TRANSITION_DRAWER,
302
305
  isOpen ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
303
306
  )}
304
- style={{ width: drawerWidth, top: headerHeight, height: `calc(100vh - ${headerHeight}px)` }}
307
+ style={{ width: drawerWidth, top: headerHeight, height: `calc(100vh - ${headerHeight}px - ${dockInset}px)` }}
305
308
  >
306
309
  {/* Resize handle */}
307
310
  <div
@@ -405,6 +408,20 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
405
408
  </button>
406
409
  </div>
407
410
  <p className="text-sm text-theme-text-tertiary">{release.namespace}</p>
411
+ {releaseDetail?.managedByFluxHelmRelease && (
412
+ <button
413
+ type="button"
414
+ onClick={() => {
415
+ const [ns, name] = releaseDetail.managedByFluxHelmRelease!.split('/')
416
+ navigate(`/gitops/detail/helmreleases/${encodeURIComponent(ns || '_')}/${encodeURIComponent(name)}`)
417
+ }}
418
+ className="mt-1 inline-flex items-center gap-1 rounded border border-amber-500/40 bg-amber-500/10 px-1.5 py-0.5 text-[11px] text-amber-700 dark:text-amber-400 hover:bg-amber-500/20 transition-colors"
419
+ title={`Installed by Flux helm-controller via HelmRelease ${releaseDetail.managedByFluxHelmRelease}. Changes here would be reverted at the next reconcile.`}
420
+ >
421
+ <GitBranch className="w-3 h-3" />
422
+ Managed by Flux · {releaseDetail.managedByFluxHelmRelease}
423
+ </button>
424
+ )}
408
425
  </div>
409
426
 
410
427
  {/* Tabs */}
@@ -1,7 +1,7 @@
1
1
  import { useState, useMemo, useRef, useEffect, useCallback, forwardRef } from 'react'
2
2
  import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
3
3
  import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
4
- import { Package, Search, RefreshCw, ArrowUpCircle, LayoutGrid, List, Shield } from 'lucide-react'
4
+ import { Package, Search, RefreshCw, ArrowUpCircle, LayoutGrid, List, Shield, GitBranch } from 'lucide-react'
5
5
  import { PaneLoader } from '@skyhook-io/k8s-ui'
6
6
  import { clsx } from 'clsx'
7
7
  import { useHelmReleases, useHelmBatchUpgradeInfo, isForbiddenError } from '../../api/client'
@@ -453,6 +453,14 @@ const ReleaseRow = forwardRef<HTMLTableRowElement, ReleaseRowProps>(
453
453
  <Package className="w-4 h-4 text-theme-text-tertiary shrink-0" />
454
454
  <span className="text-sm text-theme-text-primary font-medium truncate">{release.name}</span>
455
455
  {getHealthBadge()}
456
+ {release.managedByFluxHelmRelease && (
457
+ <Tooltip content={`Installed by Flux helm-controller via HelmRelease ${release.managedByFluxHelmRelease}. Changes here will be reverted at the next reconcile — manage via the GitOps tab.`}>
458
+ <span className="badge-sm shrink-0 border border-theme-border bg-theme-elevated text-theme-text-secondary">
459
+ <GitBranch className="w-3 h-3" />
460
+ Flux
461
+ </span>
462
+ </Tooltip>
463
+ )}
456
464
  {upgradeInfo?.updateAvailable && (
457
465
  <Tooltip content={`Upgrade available: ${release.chartVersion} → ${upgradeInfo.latestVersion}`}>
458
466
  <span className={clsx('badge-sm shrink-0', SEVERITY_BADGE.warning)}>
@@ -4,7 +4,7 @@ import { getResourceIcon } from '../../utils/resource-icons'
4
4
  import { clsx } from 'clsx'
5
5
  import type { HelmOwnedResource } from '../../types'
6
6
  import type { NavigateToResource } from '../../utils/navigation'
7
- import { kindToPlural } from '../../utils/navigation'
7
+ import { kindToPlural, apiVersionToGroup } from '../../utils/navigation'
8
8
  import { getResourceStatusColor, SEVERITY_BADGE } from '../../utils/badge-colors'
9
9
  import { useQueryClient } from '@tanstack/react-query'
10
10
  import { useOpenTerminal, useOpenLogs } from '../dock'
@@ -205,7 +205,7 @@ function ResourceItem({ resource, onNavigate }: ResourceItemProps) {
205
205
 
206
206
  const handleClick = () => {
207
207
  if (onNavigate) {
208
- onNavigate({ kind: kindToPlural(resource.kind), namespace: resource.namespace, name: resource.name })
208
+ onNavigate({ kind: kindToPlural(resource.kind), namespace: resource.namespace, name: resource.name, group: apiVersionToGroup(resource.apiVersion) })
209
209
  }
210
210
  }
211
211
 
@@ -0,0 +1,108 @@
1
+ import { GitBranch, AlertCircle, CheckCircle2 } from 'lucide-react'
2
+ import type { DashboardGitOpsControllers, DashboardGitOpsController } from '../../api/client'
3
+ import { clsx } from 'clsx'
4
+
5
+ interface GitOpsControllersCardProps {
6
+ data: DashboardGitOpsControllers
7
+ onNavigate: () => void
8
+ }
9
+
10
+ // GitOpsControllersCard surfaces Argo CD / Flux controller pod health
11
+ // on the Home dashboard so an operator can spot "source-controller is
12
+ // CrashLoopBackOff" before drilling into individual GitOps applications
13
+ // and seeing the *symptoms* (apps stuck OutOfSync, sources unfetched).
14
+ //
15
+ // Capability-gated: the parent only renders this card when the backend
16
+ // actually detected controllers in the cluster (DashboardResponse.gitopsControllers
17
+ // is null on non-GitOps clusters). The card itself doesn't have an
18
+ // "empty state" branch — by the time we get here, we have something to show.
19
+ export function GitOpsControllersCard({ data, onNavigate }: GitOpsControllersCardProps) {
20
+ const headerTone =
21
+ data.status === 'crashing'
22
+ ? 'text-red-500'
23
+ : data.status === 'degraded'
24
+ ? 'text-amber-400'
25
+ : 'text-emerald-500'
26
+
27
+ const headerLabel =
28
+ data.status === 'crashing'
29
+ ? 'Controllers crashing'
30
+ : data.status === 'degraded'
31
+ ? 'Controllers degraded'
32
+ : 'Controllers healthy'
33
+
34
+ // Group controllers by tool so the card reads as two sections (Argo +
35
+ // Flux) when both are installed. Operators with only one tool see a
36
+ // single-section card without empty placeholders.
37
+ const argo = data.controllers.filter((c) => c.tool === 'argocd')
38
+ const flux = data.controllers.filter((c) => c.tool === 'fluxcd')
39
+
40
+ return (
41
+ <button
42
+ type="button"
43
+ onClick={onNavigate}
44
+ className="flex flex-col gap-3 rounded-xl bg-theme-surface p-4 text-left shadow-theme-sm transition-colors hover:bg-theme-hover"
45
+ >
46
+ <div className="flex items-center justify-between">
47
+ <div className="flex items-center gap-2">
48
+ <GitBranch className="h-4 w-4 text-theme-text-tertiary" />
49
+ <span className="text-xs font-semibold uppercase tracking-wider text-theme-text-secondary">
50
+ GitOps Controllers
51
+ </span>
52
+ </div>
53
+ <span className={clsx('text-[11px] font-medium', headerTone)}>{headerLabel}</span>
54
+ </div>
55
+
56
+ <div className="flex flex-col gap-2">
57
+ {argo.length > 0 && <ControllerSection label="Argo CD" controllers={argo} />}
58
+ {flux.length > 0 && <ControllerSection label="Flux CD" controllers={flux} />}
59
+ </div>
60
+ </button>
61
+ )
62
+ }
63
+
64
+ function ControllerSection({ label, controllers }: { label: string; controllers: DashboardGitOpsController[] }) {
65
+ return (
66
+ <div>
67
+ <div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">{label}</div>
68
+ <div className="flex flex-col gap-1">
69
+ {controllers.map((c) => (
70
+ <ControllerRow key={`${c.tool}-${c.name}`} c={c} />
71
+ ))}
72
+ </div>
73
+ </div>
74
+ )
75
+ }
76
+
77
+ function ControllerRow({ c }: { c: DashboardGitOpsController }) {
78
+ // Per-row tone matches the per-controller status. We don't reuse
79
+ // mapHealthToTone because the controller status vocabulary
80
+ // (healthy/degraded/crashing/pending) is different from resource
81
+ // health (Healthy/Degraded/Missing/etc). Mapping inline keeps the
82
+ // intent obvious.
83
+ const tone =
84
+ c.status === 'crashing'
85
+ ? 'text-red-500'
86
+ : c.status === 'degraded' || c.status === 'pending'
87
+ ? 'text-amber-400'
88
+ : 'text-emerald-500'
89
+ const Icon = c.status === 'crashing' ? AlertCircle : c.status === 'degraded' || c.status === 'pending' ? AlertCircle : CheckCircle2
90
+ return (
91
+ <div className="flex items-center justify-between gap-2 text-[12px]">
92
+ <div className="flex min-w-0 items-center gap-1.5">
93
+ <Icon className={clsx('h-3 w-3 shrink-0', tone)} />
94
+ <span className="truncate text-theme-text-primary">{c.name}</span>
95
+ </div>
96
+ <div className="flex shrink-0 items-center gap-1.5 text-[11px] text-theme-text-secondary">
97
+ <span>
98
+ {c.ready}/{c.total} ready
99
+ </span>
100
+ {c.crashReason && (
101
+ <span className={clsx('rounded border px-1 py-px text-[10px] font-medium', 'border-red-500/40 bg-red-500/10 text-red-400')}>
102
+ {c.crashReason}
103
+ </span>
104
+ )}
105
+ </div>
106
+ </div>
107
+ )
108
+ }
@@ -9,6 +9,7 @@ import { TrafficSummary } from './TrafficSummary'
9
9
  import { CertificateHealthCard } from './CertificateHealthCard'
10
10
  import { NetworkPolicyCoverageCard } from './NetworkPolicyCoverageCard'
11
11
  import { CostCard } from './CostCard'
12
+ import { GitOpsControllersCard } from './GitOpsControllersCard'
12
13
  import { AuditCard, PaneLoader, StatusDot, mapHealthToTone } from '@skyhook-io/k8s-ui'
13
14
  import { ClusterHealthCard } from './ClusterHealthCard'
14
15
  import { AlertTriangle, Loader2, Shield } from 'lucide-react'
@@ -120,7 +121,7 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
120
121
  </div>
121
122
 
122
123
  {/* Health & compliance cards — 3-col when enough cards, 2-col fallback */}
123
- {(data.certificateHealth || data.networkPolicyCoverage || data.audit) && (() => {
124
+ {(data.certificateHealth || data.networkPolicyCoverage || data.audit || data.gitopsControllers) && (() => {
124
125
  const healthCards = [
125
126
  data.certificateHealth && (
126
127
  <CertificateHealthCard
@@ -136,6 +137,13 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
136
137
  onNavigate={() => onNavigateToResourceKind('networkpolicies', 'networking.k8s.io')}
137
138
  />
138
139
  ),
140
+ data.gitopsControllers && (
141
+ <GitOpsControllersCard
142
+ key="gitops-controllers"
143
+ data={data.gitopsControllers}
144
+ onNavigate={() => onNavigateToView('gitops')}
145
+ />
146
+ ),
139
147
  data.audit && (
140
148
  <AuditCard
141
149
  key="audit"
@@ -1,7 +1,7 @@
1
1
  import { useState, useMemo, useCallback, useEffect } from 'react'
2
2
  import { useLocation, useNavigate } from 'react-router-dom'
3
3
  import { useQuery } from '@tanstack/react-query'
4
- import { ApiError, fetchJSON, isForbiddenError, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics } from '../../api/client'
4
+ import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics } from '../../api/client'
5
5
  import { apiUrl, getAuthHeaders, getCredentialsMode, getBasename } from '../../api/config'
6
6
  import { useAPIResources } from '../../api/apiResources'
7
7
  import { initNavigationMap } from '@skyhook-io/k8s-ui'
@@ -52,7 +52,17 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
52
52
  queryFn: async () => {
53
53
  const params = new URLSearchParams()
54
54
  if (namespaces.length > 0) params.set('namespaces', namespacesParam)
55
- return fetchJSON<ResourceCountsResponse>(`/resource-counts?${params}`)
55
+ const startedAt = performance.now()
56
+ debugNamespaceLog('resources:counts-fetch-start', { namespaces, params: params.toString() })
57
+ try {
58
+ return await fetchJSON<ResourceCountsResponse>(`/resource-counts?${params}`)
59
+ } finally {
60
+ debugNamespaceLog('resources:counts-fetch-end', {
61
+ namespaces,
62
+ params: params.toString(),
63
+ durationMs: Math.round(performance.now() - startedAt),
64
+ })
65
+ }
56
66
  },
57
67
  staleTime: 10000,
58
68
  refetchInterval: 60000, // Safety net — SSE k8s_event drives near-real-time invalidation
@@ -75,10 +85,25 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
75
85
  const params = new URLSearchParams()
76
86
  if (namespaces.length > 0) params.set('namespaces', namespacesParam)
77
87
  if (isSelectedCrd && selectedKind.group) params.set('group', selectedKind.group)
88
+ const startedAt = performance.now()
89
+ debugNamespaceLog('resources:selected-kind-fetch-start', {
90
+ kind: selectedKind.name,
91
+ group: isSelectedCrd ? selectedKind.group : '',
92
+ namespaces,
93
+ params: params.toString(),
94
+ })
78
95
  const res = await fetch(apiUrl(`/resources/${selectedKind.name}?${params}`), {
79
96
  credentials: getCredentialsMode(),
80
97
  headers: getAuthHeaders(),
81
98
  })
99
+ debugNamespaceLog('resources:selected-kind-fetch-response', {
100
+ kind: selectedKind.name,
101
+ group: isSelectedCrd ? selectedKind.group : '',
102
+ namespaces,
103
+ params: params.toString(),
104
+ status: res.status,
105
+ durationMs: Math.round(performance.now() - startedAt),
106
+ })
82
107
  if (!res.ok) {
83
108
  const errorData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
84
109
  throw new ApiError(errorData.error || `Failed to fetch ${selectedKind.name}`, res.status, errorData)
@@ -1,5 +1,6 @@
1
1
  // Re-export all resource utilities from the shared @skyhook-io/k8s-ui package.
2
2
  export * from '@skyhook-io/k8s-ui/components/resources/resource-utils'
3
3
 
4
- // Backward compatibility: re-export formatBytes (previously re-exported here)
4
+ // formatBytes lives in utils/format but is re-exported here so consumers
5
+ // can import it from the same module as the rest of the resource utilities.
5
6
  export { formatBytes } from '@skyhook-io/k8s-ui/utils/format'
@@ -26,8 +26,9 @@ import {
26
26
  import { useHasLimitedAccess } from '../../contexts/CapabilitiesContext'
27
27
  import type { TimelineEvent, Topology } from '../../types'
28
28
  import type { NavigateToResource } from '../../utils/navigation'
29
- import { kindToPlural } from '../../utils/navigation'
30
- import { PaneLoader, pluralize } from '@skyhook-io/k8s-ui'
29
+ import { kindToPlural, apiVersionToGroup } from '../../utils/navigation'
30
+ import { PaneLoader, pluralize, gitOpsRouteForKind } from '@skyhook-io/k8s-ui'
31
+ import { useNavigate } from 'react-router-dom'
31
32
  import { isChangeEvent, isHistoricalEvent, isOperation, displayKind } from '../../types'
32
33
  import { DiffViewer } from './DiffViewer'
33
34
  import { getOperationColor, getHealthBadgeColor, getEventTypeColor } from '../../utils/badge-colors'
@@ -174,7 +175,20 @@ function calculateInterestingnessWithBreakdown(lane: ResourceLane): ScoreBreakdo
174
175
  }
175
176
 
176
177
  export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode, onViewModeChange, topology, namespaces }: TimelineSwimlanesProps) {
178
+ const navigate = useNavigate()
177
179
  const hasLimitedAccess = useHasLimitedAccess()
180
+ // Timeline lane labels for GitOps CRs (Application/Kustomization/HelmRelease)
181
+ // deep-link to GitOps detail rather than the resource drawer — the lane is
182
+ // already telling the user "this controller had changes/events"; the GitOps
183
+ // tab is the right place to investigate further.
184
+ const handleLaneOpen = useCallback((kind: string, namespace: string, name: string, group?: string) => {
185
+ const gitOpsPath = gitOpsRouteForKind(kind, namespace, name)
186
+ if (gitOpsPath) {
187
+ navigate(gitOpsPath)
188
+ return
189
+ }
190
+ onResourceClick?.({ kind: kindToPlural(kind), namespace, name, group })
191
+ }, [navigate, onResourceClick])
178
192
  const containerRef = useRef<HTMLDivElement>(null)
179
193
  const searchInputRef = useRef<HTMLInputElement>(null)
180
194
  const [zoom, setZoom] = useState(1)
@@ -682,7 +696,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
682
696
  )}
683
697
  <div
684
698
  className="flex-1 min-w-0 cursor-pointer hover:bg-theme-surface/30 rounded px-1 -mx-1 group"
685
- onClick={() => onResourceClick?.({ kind: kindToPlural(lane.kind), namespace: lane.namespace, name: lane.name })}
699
+ onClick={() => handleLaneOpen(lane.kind, lane.namespace, lane.name, lane.group)}
686
700
  >
687
701
  <div className="flex items-center gap-1">
688
702
  <span className={clsx(
@@ -760,7 +774,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
760
774
  <div className="flex">
761
775
  <div
762
776
  className="w-[19.25rem] shrink-0 border-r border-theme-border/50 pl-4 pr-3 py-1.5 flex items-center gap-2 cursor-pointer hover:bg-theme-elevated/30 group"
763
- onClick={() => onResourceClick?.({ kind: kindToPlural(lane.kind), namespace: lane.namespace, name: lane.name })}
777
+ onClick={() => handleLaneOpen(lane.kind, lane.namespace, lane.name, lane.group)}
764
778
  >
765
779
  <div className="flex-1 min-w-0">
766
780
  <div className="flex items-center gap-1">
@@ -812,7 +826,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
812
826
  {/* Child lane label - indented */}
813
827
  <div
814
828
  className="w-[19.25rem] shrink-0 border-r border-theme-border/50 pl-4 pr-3 py-1.5 flex items-center gap-2 cursor-pointer hover:bg-theme-elevated/30 group"
815
- onClick={() => onResourceClick?.({ kind: kindToPlural(child.kind), namespace: child.namespace, name: child.name })}
829
+ onClick={() => handleLaneOpen(child.kind, child.namespace, child.name, child.group)}
816
830
  >
817
831
  <div className="flex-1 min-w-0">
818
832
  <div className="flex items-center gap-1">
@@ -1234,7 +1248,7 @@ function EventDetailPanel({ event, onClose, onResourceClick }: EventDetailPanelP
1234
1248
  {displayKind(event.kind)}
1235
1249
  </span>
1236
1250
  <button
1237
- onClick={() => onResourceClick?.({ kind: kindToPlural(event.kind), namespace: event.namespace, name: event.name })}
1251
+ onClick={() => onResourceClick?.({ kind: kindToPlural(event.kind), namespace: event.namespace, name: event.name, group: apiVersionToGroup(event.apiVersion) })}
1238
1252
  className="text-theme-text-primary font-medium hover:text-accent-text"
1239
1253
  >
1240
1254
  {event.name}