@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.
- package/package.json +6 -6
- package/src/App.tsx +144 -25
- package/src/api/client.ts +158 -8
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/index.ts +1 -1
- package/src/components/gitops/GitOpsView.tsx +2441 -0
- package/src/components/gitops/RollbackDialog.tsx +107 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +144 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +20 -3
- package/src/components/helm/HelmView.tsx +9 -1
- package/src/components/helm/OwnedResources.tsx +2 -2
- package/src/components/home/GitOpsControllersCard.tsx +108 -0
- package/src/components/home/HomeView.tsx +9 -1
- package/src/components/resources/ResourcesView.tsx +27 -2
- package/src/components/resources/resource-utils.ts +2 -1
- package/src/components/timeline/TimelineSwimlanes.tsx +20 -6
- package/src/components/ui/CommandPalette.tsx +6 -4
- package/src/components/ui/ShortcutHelpOverlay.tsx +2 -1
- package/src/components/ui/UpdateNotification.tsx +36 -19
- package/src/components/workload/WorkloadView.tsx +126 -10
- package/src/types.ts +2 -0
- package/src/utils/navigation.ts +14 -1
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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={() =>
|
|
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={() =>
|
|
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={() =>
|
|
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}
|