@skyhook-io/k8s-ui 1.6.0 → 1.6.2
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 +7 -2
- package/src/components/charts/AreaChart.tsx +371 -0
- package/src/components/charts/MetricsSummary.tsx +49 -0
- package/src/components/charts/SeriesLegend.tsx +27 -0
- package/src/components/charts/colors.ts +49 -0
- package/src/components/charts/format.ts +41 -0
- package/src/components/charts/index.ts +14 -0
- package/src/components/charts/saturation.test.ts +60 -0
- package/src/components/charts/saturation.ts +32 -0
- package/src/components/charts/types.ts +36 -0
- package/src/components/compare/CompareResourcePicker.tsx +192 -0
- package/src/components/compare/CompareTray.tsx +130 -0
- package/src/components/compare/ResourceCompareView.tsx +272 -0
- package/src/components/compare/index.ts +14 -0
- package/src/components/compare/normalize.test.ts +193 -0
- package/src/components/compare/normalize.ts +69 -0
- package/src/components/compare/picks.test.ts +97 -0
- package/src/components/compare/picks.ts +35 -0
- package/src/components/compare/sort.test.ts +78 -0
- package/src/components/compare/sort.ts +26 -0
- package/src/components/compare/types.ts +43 -0
- package/src/components/compare/url.test.ts +61 -0
- package/src/components/compare/url.ts +26 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +621 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +216 -0
- package/src/components/gitops/GitOpsTableView.tsx +1441 -0
- package/src/components/gitops/RollbackDialog.tsx +117 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +160 -0
- package/src/components/gitops/detail-helpers.test.ts +97 -0
- package/src/components/gitops/detail-helpers.ts +112 -0
- package/src/components/gitops/index.ts +52 -1
- package/src/components/gitops/short-cluster-name.test.ts +36 -0
- package/src/components/gitops/tree/index.ts +2 -0
- package/src/components/gitops/tree/merge.test.ts +240 -0
- package/src/components/gitops/tree/merge.ts +160 -0
- package/src/components/resources/ResourcesSidebar.tsx +42 -15
- package/src/components/resources/ResourcesView.tsx +403 -69
- package/src/components/resources/index.ts +1 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +233 -0
- package/src/components/resources/renderers/CompositionRenderer.tsx +218 -0
- package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +145 -0
- package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +71 -0
- package/src/components/resources/renderers/HPARenderer.tsx +6 -1
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +131 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +223 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +6 -1
- package/src/components/resources/renderers/PodRenderer.tsx +207 -2
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +132 -20
- package/src/components/resources/renderers/RoleRenderer.tsx +148 -18
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +456 -3
- package/src/components/resources/renderers/WorkloadRenderer.tsx +189 -2
- package/src/components/resources/renderers/XRDRenderer.tsx +153 -0
- package/src/components/resources/renderers/crossplane-cells.tsx +146 -0
- package/src/components/resources/renderers/flux-cells.tsx +12 -0
- package/src/components/resources/renderers/index.ts +8 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +609 -0
- package/src/components/resources/resource-utils-crossplane.ts +325 -0
- package/src/components/resources/resource-utils-flux.ts +14 -0
- package/src/components/resources/resource-utils.ts +1 -0
- package/src/components/resources/resources-column-filter.test.ts +74 -0
- package/src/components/shared/ResourceActionsBar.tsx +77 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +138 -9
- package/src/components/ui/YamlEditor.tsx +28 -15
- package/src/components/ui/drawer-components.tsx +8 -20
- package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
- package/src/components/workload/WorkloadView.tsx +2 -2
- package/src/index.ts +3 -0
- package/src/types/core.ts +31 -6
- package/src/types/index.ts +1 -0
- package/src/types/rbac.ts +99 -0
- package/src/utils/api-resources.ts +9 -1
- package/src/utils/badge-colors.ts +25 -0
- package/src/utils/gitops-owner.test.ts +70 -111
- package/src/utils/gitops-owner.ts +37 -74
- package/src/utils/helm-status.test.ts +50 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/rbac-badges.ts +61 -0
- package/src/utils/rbac-blast-radius.test.ts +137 -0
- package/src/utils/rbac-blast-radius.ts +98 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
import { History, Loader2 } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
import { DialogPortal } from '../ui/DialogPortal'
|
|
5
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
6
|
+
|
|
7
|
+
// =============================================================================
|
|
8
|
+
// RollbackDialog — Argo CD Application rollback confirmation. Shared
|
|
9
|
+
// between per-cluster Radar (OSS web/) and Radar Hub's fleet GitOps
|
|
10
|
+
// detail page. Presentational only; caller mints the historyId/revision
|
|
11
|
+
// from the clicked GitOpsHistoryItem and handles the POST to
|
|
12
|
+
// /api/argo/applications/{ns}/{name}/rollback in onConfirm.
|
|
13
|
+
//
|
|
14
|
+
// Defaults are conservative: no prune (avoid surprise deletions of
|
|
15
|
+
// resources added after the target revision), no dry-run (default is to
|
|
16
|
+
// actually roll back). Operators wanting a preview check Dry Run; the
|
|
17
|
+
// result lands in the Activity tab.
|
|
18
|
+
// =============================================================================
|
|
19
|
+
|
|
20
|
+
export interface RollbackDialogProps {
|
|
21
|
+
open: boolean
|
|
22
|
+
// Caller provides the user-visible revision (mono SHA / tag) and the
|
|
23
|
+
// history id — Argo's API uses the id, the user reads the revision.
|
|
24
|
+
appLabel: string
|
|
25
|
+
revision: string
|
|
26
|
+
historyId?: string
|
|
27
|
+
pending?: boolean
|
|
28
|
+
onCancel: () => void
|
|
29
|
+
onConfirm: (opts: { prune: boolean; dryRun: boolean }) => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function RollbackDialog({ open, appLabel, revision, historyId, pending, onCancel, onConfirm }: RollbackDialogProps) {
|
|
33
|
+
const [prune, setPrune] = useState(false)
|
|
34
|
+
const [dryRun, setDryRun] = useState(false)
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (open) {
|
|
38
|
+
setPrune(false)
|
|
39
|
+
setDryRun(false)
|
|
40
|
+
}
|
|
41
|
+
}, [open])
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<DialogPortal open={open} onClose={pending ? () => {} : onCancel} className="w-[440px]" closable={!pending}>
|
|
45
|
+
<div className="border-b border-theme-border px-4 py-3">
|
|
46
|
+
<h2 className="text-sm font-semibold text-theme-text-primary">Roll back application</h2>
|
|
47
|
+
<p className="mt-0.5 text-xs text-theme-text-tertiary">{appLabel}</p>
|
|
48
|
+
</div>
|
|
49
|
+
<div className="space-y-4 px-4 py-4 text-sm">
|
|
50
|
+
<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">
|
|
51
|
+
Argo will sync this application to a previous revision. This is a write operation on the cluster.
|
|
52
|
+
</div>
|
|
53
|
+
<dl className="grid grid-cols-[80px_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs">
|
|
54
|
+
<dt className="text-theme-text-tertiary">Revision</dt>
|
|
55
|
+
<dd className="min-w-0">
|
|
56
|
+
<Tooltip content={revision} delay={400} disabled={!revision} wrapperClassName="block max-w-full">
|
|
57
|
+
<span className="block truncate font-mono text-theme-text-primary">{revision || '-'}</span>
|
|
58
|
+
</Tooltip>
|
|
59
|
+
</dd>
|
|
60
|
+
{historyId && (
|
|
61
|
+
<>
|
|
62
|
+
<dt className="text-theme-text-tertiary">History ID</dt>
|
|
63
|
+
<dd className="font-mono text-theme-text-primary">#{historyId}</dd>
|
|
64
|
+
</>
|
|
65
|
+
)}
|
|
66
|
+
</dl>
|
|
67
|
+
<div className="space-y-2">
|
|
68
|
+
<label className="flex items-start gap-2">
|
|
69
|
+
<input
|
|
70
|
+
type="checkbox"
|
|
71
|
+
checked={prune}
|
|
72
|
+
onChange={(e) => setPrune(e.target.checked)}
|
|
73
|
+
disabled={pending}
|
|
74
|
+
className="mt-0.5 h-3.5 w-3.5 cursor-pointer accent-sky-500 disabled:cursor-not-allowed"
|
|
75
|
+
/>
|
|
76
|
+
<div className="min-w-0">
|
|
77
|
+
<div className="text-xs text-theme-text-primary">Prune resources added since this revision</div>
|
|
78
|
+
<div className="text-[11px] text-theme-text-tertiary">Off by default — leaves any resources created after this revision untouched.</div>
|
|
79
|
+
</div>
|
|
80
|
+
</label>
|
|
81
|
+
<label className="flex items-start gap-2">
|
|
82
|
+
<input
|
|
83
|
+
type="checkbox"
|
|
84
|
+
checked={dryRun}
|
|
85
|
+
onChange={(e) => setDryRun(e.target.checked)}
|
|
86
|
+
disabled={pending}
|
|
87
|
+
className="mt-0.5 h-3.5 w-3.5 cursor-pointer accent-sky-500 disabled:cursor-not-allowed"
|
|
88
|
+
/>
|
|
89
|
+
<div className="min-w-0">
|
|
90
|
+
<div className="text-xs text-theme-text-primary">Dry run</div>
|
|
91
|
+
<div className="text-[11px] text-theme-text-tertiary">Preview the rollback without applying it.</div>
|
|
92
|
+
</div>
|
|
93
|
+
</label>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
<div className="flex items-center justify-end gap-2 border-t border-theme-border bg-theme-base px-4 py-3">
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
onClick={onCancel}
|
|
100
|
+
disabled={pending}
|
|
101
|
+
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"
|
|
102
|
+
>
|
|
103
|
+
Cancel
|
|
104
|
+
</button>
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={() => onConfirm({ prune, dryRun })}
|
|
108
|
+
disabled={pending}
|
|
109
|
+
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"
|
|
110
|
+
>
|
|
111
|
+
{pending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <History className="h-3.5 w-3.5" />}
|
|
112
|
+
{dryRun ? 'Run dry-run' : 'Roll back'}
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
</DialogPortal>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { useState, useEffect, type ComponentType } from 'react'
|
|
2
|
+
import { Loader2, RefreshCw } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
import { DialogPortal } from '../ui/DialogPortal'
|
|
5
|
+
|
|
6
|
+
// =============================================================================
|
|
7
|
+
// SyncOptionsDialog — Argo CD Sync drawer, shared between per-cluster
|
|
8
|
+
// Radar (OSS web/) and Radar Hub's fleet GitOps detail page. Presentational
|
|
9
|
+
// only: caller controls open state, supplies the app label, and handles
|
|
10
|
+
// the onConfirm callback (POST to /api/argo/applications/{ns}/{name}/sync
|
|
11
|
+
// or the hub-proxied equivalent /c/{ctrl}/api/argo/...).
|
|
12
|
+
//
|
|
13
|
+
// Defaults match Argo's most-common path (prune true, no dry-run, no
|
|
14
|
+
// force) so the two-click sync stays cheap. Revision is optional — empty
|
|
15
|
+
// falls through to the Application's targetRevision.
|
|
16
|
+
// =============================================================================
|
|
17
|
+
|
|
18
|
+
// ArgoSyncOpts is the payload shape the dialog passes to its onConfirm
|
|
19
|
+
// callback (and that downstream POSTs to `/api/argo/applications/{ns}/{name}/sync`
|
|
20
|
+
// expect). Exported separately so consumers can type the mutationFn arg
|
|
21
|
+
// without redeclaring the structure — keeps OSS and Hub in lockstep on
|
|
22
|
+
// the wire shape.
|
|
23
|
+
export interface ArgoSyncOpts {
|
|
24
|
+
revision?: string
|
|
25
|
+
prune: boolean
|
|
26
|
+
dryRun: boolean
|
|
27
|
+
force: boolean
|
|
28
|
+
applyOnly: boolean
|
|
29
|
+
syncOptions: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SyncOptionsDialogProps {
|
|
33
|
+
open: boolean
|
|
34
|
+
appLabel: string
|
|
35
|
+
pending?: boolean
|
|
36
|
+
onCancel: () => void
|
|
37
|
+
onConfirm: (opts: ArgoSyncOpts) => void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function SyncOptionsDialog({ open, appLabel, pending, onCancel, onConfirm }: SyncOptionsDialogProps) {
|
|
41
|
+
const [revision, setRevision] = useState('')
|
|
42
|
+
const [prune, setPrune] = useState(true)
|
|
43
|
+
const [dryRun, setDryRun] = useState(false)
|
|
44
|
+
const [force, setForce] = useState(false)
|
|
45
|
+
const [applyOnly, setApplyOnly] = useState(false)
|
|
46
|
+
const [replace, setReplace] = useState(false)
|
|
47
|
+
const [serverSideApply, setServerSideApply] = useState(false)
|
|
48
|
+
|
|
49
|
+
// Reset on each open so a previous attempt's flags don't leak into the
|
|
50
|
+
// next sync — easy footgun in modal-heavy flows.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (open) {
|
|
53
|
+
setRevision('')
|
|
54
|
+
setPrune(true)
|
|
55
|
+
setDryRun(false)
|
|
56
|
+
setForce(false)
|
|
57
|
+
setApplyOnly(false)
|
|
58
|
+
setReplace(false)
|
|
59
|
+
setServerSideApply(false)
|
|
60
|
+
}
|
|
61
|
+
}, [open])
|
|
62
|
+
|
|
63
|
+
function submit() {
|
|
64
|
+
const syncOptions: string[] = []
|
|
65
|
+
if (replace) syncOptions.push('Replace=true')
|
|
66
|
+
if (serverSideApply) syncOptions.push('ServerSideApply=true')
|
|
67
|
+
onConfirm({
|
|
68
|
+
revision: revision.trim() || undefined,
|
|
69
|
+
prune,
|
|
70
|
+
dryRun,
|
|
71
|
+
force,
|
|
72
|
+
applyOnly,
|
|
73
|
+
syncOptions,
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<DialogPortal open={open} onClose={pending ? () => {} : onCancel} className="w-[480px]" closable={!pending}>
|
|
79
|
+
<div className="border-b border-theme-border px-4 py-3">
|
|
80
|
+
<h2 className="text-sm font-semibold text-theme-text-primary">Sync application</h2>
|
|
81
|
+
<p className="mt-0.5 text-xs text-theme-text-tertiary">{appLabel}</p>
|
|
82
|
+
</div>
|
|
83
|
+
<div className="space-y-4 px-4 py-4 text-sm">
|
|
84
|
+
<label className="block">
|
|
85
|
+
<span className="text-xs font-medium text-theme-text-secondary">Revision (optional)</span>
|
|
86
|
+
<input
|
|
87
|
+
type="text"
|
|
88
|
+
value={revision}
|
|
89
|
+
onChange={(e) => setRevision(e.target.value)}
|
|
90
|
+
placeholder="HEAD"
|
|
91
|
+
disabled={pending}
|
|
92
|
+
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"
|
|
93
|
+
/>
|
|
94
|
+
<span className="mt-0.5 block text-[11px] text-theme-text-tertiary">
|
|
95
|
+
Branch, tag, or commit SHA. Leave empty to use the Application's targetRevision.
|
|
96
|
+
</span>
|
|
97
|
+
</label>
|
|
98
|
+
|
|
99
|
+
{/* Common (Prune / Dry run) sit above a divider; Advanced toggles
|
|
100
|
+
stay accessible but visually subordinate so the common-case user
|
|
101
|
+
can scan past them without parsing every helper line. */}
|
|
102
|
+
<fieldset className="space-y-2">
|
|
103
|
+
<legend className="mb-1 text-xs font-medium text-theme-text-secondary">Sync options</legend>
|
|
104
|
+
<Toggle label="Prune" checked={prune} onChange={setPrune} disabled={pending} hint="Delete resources that are no longer in Git." />
|
|
105
|
+
<Toggle label="Dry run" checked={dryRun} onChange={setDryRun} disabled={pending} hint="Preview only — Argo computes the diff but applies nothing." />
|
|
106
|
+
</fieldset>
|
|
107
|
+
<fieldset className="space-y-2 border-t border-theme-border pt-3">
|
|
108
|
+
<legend className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Advanced</legend>
|
|
109
|
+
<Toggle label="Apply only" checked={applyOnly} onChange={setApplyOnly} disabled={pending} hint="Skip PreSync / PostSync / SyncFail hooks." />
|
|
110
|
+
<Toggle label="Force" checked={force} onChange={setForce} disabled={pending} hint="Use kubectl --force; required for some immutable-field changes." />
|
|
111
|
+
<Toggle label="Replace" checked={replace} onChange={setReplace} disabled={pending} hint="kubectl replace instead of apply (drops fields not in source)." />
|
|
112
|
+
<Toggle label="Server-side apply" checked={serverSideApply} onChange={setServerSideApply} disabled={pending} hint="Use the K8s server-side apply mechanism for ownership tracking." />
|
|
113
|
+
</fieldset>
|
|
114
|
+
</div>
|
|
115
|
+
<div className="flex items-center justify-end gap-2 border-t border-theme-border bg-theme-base px-4 py-3">
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
onClick={onCancel}
|
|
119
|
+
disabled={pending}
|
|
120
|
+
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"
|
|
121
|
+
>
|
|
122
|
+
Cancel
|
|
123
|
+
</button>
|
|
124
|
+
<PrimaryButton onClick={submit} disabled={pending} icon={pending ? Loader2 : RefreshCw} loading={pending} label={dryRun ? 'Run dry-run' : 'Sync now'} />
|
|
125
|
+
</div>
|
|
126
|
+
</DialogPortal>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function Toggle({ label, checked, onChange, disabled, hint }: { label: string; checked: boolean; onChange: (v: boolean) => void; disabled?: boolean; hint?: string }) {
|
|
131
|
+
return (
|
|
132
|
+
<label className="flex items-start gap-2">
|
|
133
|
+
<input
|
|
134
|
+
type="checkbox"
|
|
135
|
+
checked={checked}
|
|
136
|
+
onChange={(e) => onChange(e.target.checked)}
|
|
137
|
+
disabled={disabled}
|
|
138
|
+
className="mt-0.5 h-3.5 w-3.5 cursor-pointer accent-sky-500 disabled:cursor-not-allowed"
|
|
139
|
+
/>
|
|
140
|
+
<div className="min-w-0">
|
|
141
|
+
<div className="text-xs text-theme-text-primary">{label}</div>
|
|
142
|
+
{hint && <div className="text-[11px] text-theme-text-tertiary">{hint}</div>}
|
|
143
|
+
</div>
|
|
144
|
+
</label>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function PrimaryButton({ onClick, disabled, icon: Icon, loading, label }: { onClick: () => void; disabled?: boolean; icon: ComponentType<{ className?: string }>; loading?: boolean; label: string }) {
|
|
149
|
+
return (
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
onClick={onClick}
|
|
153
|
+
disabled={disabled}
|
|
154
|
+
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"
|
|
155
|
+
>
|
|
156
|
+
<Icon className={`h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
|
|
157
|
+
{label}
|
|
158
|
+
</button>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
formatGitOpsDestination,
|
|
5
|
+
formatGitOpsSourceUrl,
|
|
6
|
+
getGitOpsTool,
|
|
7
|
+
parseArgoRollbackID,
|
|
8
|
+
} from './detail-helpers'
|
|
9
|
+
|
|
10
|
+
describe('parseArgoRollbackID', () => {
|
|
11
|
+
// The function exists specifically to defend against three foot-guns
|
|
12
|
+
// that would each route to a wrong rollback target without it. Pins
|
|
13
|
+
// them so a future "simplification" can't regress.
|
|
14
|
+
test('undefined returns null', () => {
|
|
15
|
+
expect(parseArgoRollbackID(undefined)).toBeNull()
|
|
16
|
+
})
|
|
17
|
+
test('empty string returns null (would be Number("") === 0)', () => {
|
|
18
|
+
expect(parseArgoRollbackID('')).toBeNull()
|
|
19
|
+
})
|
|
20
|
+
test('"0" returns null (non-positive, Argo history starts at 1)', () => {
|
|
21
|
+
expect(parseArgoRollbackID('0')).toBeNull()
|
|
22
|
+
})
|
|
23
|
+
test('negative numbers return null', () => {
|
|
24
|
+
expect(parseArgoRollbackID('-5')).toBeNull()
|
|
25
|
+
})
|
|
26
|
+
test('Flux condition type string returns null', () => {
|
|
27
|
+
expect(parseArgoRollbackID('Ready')).toBeNull()
|
|
28
|
+
})
|
|
29
|
+
test('positive integer returns the number', () => {
|
|
30
|
+
expect(parseArgoRollbackID('42')).toBe(42)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('formatGitOpsDestination', () => {
|
|
35
|
+
// The literal Argo controllers write for the in-cluster destination.
|
|
36
|
+
test('canonical in-cluster URL collapses to "in-cluster"', () => {
|
|
37
|
+
expect(formatGitOpsDestination('https://kubernetes.default.svc', 'prod')).toBe('in-cluster, Namespace: prod')
|
|
38
|
+
})
|
|
39
|
+
// Argo 2.13 writes a trailing slash. Must normalize equivalently.
|
|
40
|
+
test('in-cluster URL with trailing slash collapses to "in-cluster"', () => {
|
|
41
|
+
expect(formatGitOpsDestination('https://kubernetes.default.svc/', 'prod')).toBe('in-cluster, Namespace: prod')
|
|
42
|
+
})
|
|
43
|
+
// Empty/whitespace-only server — Argo's "default when unspecified"
|
|
44
|
+
// semantics target the controller's own cluster.
|
|
45
|
+
test('empty server renders as "in-cluster"', () => {
|
|
46
|
+
expect(formatGitOpsDestination('', 'prod')).toBe('in-cluster, Namespace: prod')
|
|
47
|
+
expect(formatGitOpsDestination(' ', 'prod')).toBe('in-cluster, Namespace: prod')
|
|
48
|
+
})
|
|
49
|
+
// External destination URLs render with protocol stripped — keeps the
|
|
50
|
+
// chip readable (no redundant "https://" prefix when every URL has it).
|
|
51
|
+
test('external server strips protocol prefix', () => {
|
|
52
|
+
expect(formatGitOpsDestination('https://prod.k8s.example.com', 'web')).toBe('prod.k8s.example.com, Namespace: web')
|
|
53
|
+
expect(formatGitOpsDestination('http://insecure.example.com', 'web')).toBe('insecure.example.com, Namespace: web')
|
|
54
|
+
})
|
|
55
|
+
// No namespace → host alone.
|
|
56
|
+
test('namespace omitted when undefined', () => {
|
|
57
|
+
expect(formatGitOpsDestination('https://kubernetes.default.svc', undefined)).toBe('in-cluster')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('formatGitOpsSourceUrl', () => {
|
|
62
|
+
test('strips https://', () => {
|
|
63
|
+
expect(formatGitOpsSourceUrl('https://github.com/org/repo')).toBe('github.com/org/repo')
|
|
64
|
+
})
|
|
65
|
+
test('strips http://', () => {
|
|
66
|
+
expect(formatGitOpsSourceUrl('http://gitlab.example.com/repo')).toBe('gitlab.example.com/repo')
|
|
67
|
+
})
|
|
68
|
+
// SSH-style + on-prem mirrors don't carry the http(s) prefix; preserve
|
|
69
|
+
// the user's literal so they recognize their own config.
|
|
70
|
+
test('SSH origins pass through unchanged', () => {
|
|
71
|
+
expect(formatGitOpsSourceUrl('git@github.com:org/repo.git')).toBe('git@github.com:org/repo.git')
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('getGitOpsTool', () => {
|
|
76
|
+
// Group is the canonical discriminator.
|
|
77
|
+
test('Argo via group', () => {
|
|
78
|
+
expect(getGitOpsTool('applications', 'argoproj.io')).toBe('argo')
|
|
79
|
+
expect(getGitOpsTool('applicationsets', 'argoproj.io')).toBe('argo')
|
|
80
|
+
expect(getGitOpsTool('appprojects', 'argoproj.io')).toBe('argo')
|
|
81
|
+
})
|
|
82
|
+
// Argo kinds without an explicit group still route to 'argo'.
|
|
83
|
+
// Defends against a normalizer that drops `group` from the SPA payload.
|
|
84
|
+
test('Argo kinds without group still route to argo', () => {
|
|
85
|
+
expect(getGitOpsTool('applications', undefined)).toBe('argo')
|
|
86
|
+
expect(getGitOpsTool('applicationsets', '')).toBe('argo')
|
|
87
|
+
})
|
|
88
|
+
// Flux kinds with their canonical groups.
|
|
89
|
+
test('Flux via group', () => {
|
|
90
|
+
expect(getGitOpsTool('kustomizations', 'kustomize.toolkit.fluxcd.io')).toBe('flux')
|
|
91
|
+
expect(getGitOpsTool('helmreleases', 'helm.toolkit.fluxcd.io')).toBe('flux')
|
|
92
|
+
})
|
|
93
|
+
// Unknown kinds default to flux (matches the OSS fall-through).
|
|
94
|
+
test('unknown kind defaults to flux', () => {
|
|
95
|
+
expect(getGitOpsTool('somecustomresource', 'unknown.io')).toBe('flux')
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { argoStatusToGitOpsStatus, fluxConditionsToGitOpsStatus, type FluxCondition, type GitOpsStatus } from '../../types/gitops'
|
|
2
|
+
import { formatCompactAge } from '../../utils/format'
|
|
3
|
+
|
|
4
|
+
// =============================================================================
|
|
5
|
+
// Shared helpers used by both per-cluster Radar's GitOps detail view (OSS
|
|
6
|
+
// web/src/components/gitops/GitOpsView.tsx) and Radar Hub's fleet GitOps
|
|
7
|
+
// detail page (radar-hub-web/src/pages/fleet/GitOpsDetailPage.tsx). Lives
|
|
8
|
+
// here so the two surfaces can't drift on what "in-cluster" means, how
|
|
9
|
+
// rollback ids parse, or how terminating tooltips read.
|
|
10
|
+
// =============================================================================
|
|
11
|
+
|
|
12
|
+
// formatGitOpsSourceUrl drops the protocol prefix from a Git source URL
|
|
13
|
+
// so the row reads as "github.com/org/repo" instead of the redundant
|
|
14
|
+
// "https://github.com/org/repo". Leaves non-https URLs alone so SSH-style
|
|
15
|
+
// origins (`git@github.com:org/repo`) and HTTP-only on-prem mirrors still
|
|
16
|
+
// render as the user wrote them.
|
|
17
|
+
export function formatGitOpsSourceUrl(repo: string): string {
|
|
18
|
+
return repo.replace(/^https?:\/\//, '')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// formatGitOpsDestination collapses the canonical in-cluster API server
|
|
22
|
+
// URL to the literal "in-cluster" so the destination cell reads cleanly
|
|
23
|
+
// for the common case. Empty/whitespace-only server also renders as
|
|
24
|
+
// "in-cluster" — matches Argo's default-when-unspecified semantics
|
|
25
|
+
// (controller-managed apps without an explicit destination target the
|
|
26
|
+
// same cluster the controller runs in). Any other server URL gets its
|
|
27
|
+
// protocol stripped for the same reason as the source. Namespace is
|
|
28
|
+
// appended on the same line so operators see "<host>, Namespace: <ns>"
|
|
29
|
+
// without two lookups.
|
|
30
|
+
export function formatGitOpsDestination(server: string | undefined, namespace: string | undefined): string {
|
|
31
|
+
// Normalize: trim whitespace, drop any trailing slash so Argo 2.13's
|
|
32
|
+
// "https://kubernetes.default.svc/" variant (the in-cluster URL with a
|
|
33
|
+
// trailing slash that some controller versions emit) matches the literal
|
|
34
|
+
// we collapse to "in-cluster".
|
|
35
|
+
let host = (server || '').trim().replace(/\/+$/, '')
|
|
36
|
+
if (host === '' || host === 'https://kubernetes.default.svc' || host === 'in-cluster') {
|
|
37
|
+
host = 'in-cluster'
|
|
38
|
+
} else {
|
|
39
|
+
host = host.replace(/^https?:\/\//, '')
|
|
40
|
+
}
|
|
41
|
+
return namespace ? `${host}, Namespace: ${namespace}` : host
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// gitOpsInsightChangeKey produces the stable string GitOpsChangesView
|
|
45
|
+
// matches against to scroll-and-highlight a row when the user clicks an
|
|
46
|
+
// issue alert in IssuesBand. Kept here so both pages key their focus
|
|
47
|
+
// state identically.
|
|
48
|
+
export function gitOpsInsightChangeKey(ref: { kind: string; namespace?: string; name: string }): string {
|
|
49
|
+
return `${ref.kind}/${ref.namespace || ''}/${ref.name}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// parseArgoRollbackID parses an Argo HistoryItem.id into the int64 the
|
|
53
|
+
// rollback API needs. Returns null when:
|
|
54
|
+
// - id is missing
|
|
55
|
+
// - id is non-numeric (Flux condition rows reuse the same slot for
|
|
56
|
+
// condition.type, which we never want to interpret as a rollback id)
|
|
57
|
+
// - id is non-positive ("0" and negative strings parse fine via
|
|
58
|
+
// Number() but aren't valid Argo history ids — Argo's id sequence
|
|
59
|
+
// starts at 1; rolling back to id 0 would unhelpfully target the
|
|
60
|
+
// first revision).
|
|
61
|
+
export function parseArgoRollbackID(id: string | undefined): number | null {
|
|
62
|
+
if (!id) return null
|
|
63
|
+
const n = Number(id)
|
|
64
|
+
if (!Number.isFinite(n) || n <= 0) return null
|
|
65
|
+
return n
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// describeGitOpsTerminating renders the two tooltip strings the detail
|
|
69
|
+
// header + action buttons need when a resource is mid-deletion.
|
|
70
|
+
// Returns:
|
|
71
|
+
// chipTooltip: "Pending deletion 21d ago. Finalizers: foo, bar.
|
|
72
|
+
// Mutating actions are disabled until cleanup completes."
|
|
73
|
+
// actionDisabledTooltip: "Disabled — resource is pending deletion (21d)"
|
|
74
|
+
//
|
|
75
|
+
// The age formatter shares its tier breakpoints with
|
|
76
|
+
// pkg/gitops/insights/insights.go::formatAgeShort + pkg/audit/checks.go::
|
|
77
|
+
// formatDurationShort so UI, lifecycle Issue messages, and audit findings
|
|
78
|
+
// agree on units. Empty input → empty suffix.
|
|
79
|
+
export function describeGitOpsTerminating(summary?: {
|
|
80
|
+
terminationStartedAt?: string
|
|
81
|
+
finalizers?: string[]
|
|
82
|
+
}): { chipTooltip: string; actionDisabledTooltip: string } {
|
|
83
|
+
const ageText = formatCompactAge(summary?.terminationStartedAt)
|
|
84
|
+
const ageSuffix = ageText ? ` ${ageText} ago` : ''
|
|
85
|
+
const finalizers = summary?.finalizers ?? []
|
|
86
|
+
const finSuffix = finalizers.length > 0 ? ` Finalizers: ${finalizers.join(', ')}.` : ''
|
|
87
|
+
const ageInline = ageText ? ` (${ageText})` : ''
|
|
88
|
+
return {
|
|
89
|
+
chipTooltip: `Pending deletion${ageSuffix}.${finSuffix} Mutating actions are disabled until cleanup completes.`,
|
|
90
|
+
actionDisabledTooltip: `Disabled — resource is pending deletion${ageInline}`,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// getGitOpsResourceStatus dispatches sync/health extraction by tool. Argo
|
|
95
|
+
// reads from .status (rich operationState + sync state); Flux reads from
|
|
96
|
+
// .status.conditions + .spec.suspend. Returns null if the resource has
|
|
97
|
+
// no recognizable status (e.g. just-created, status not yet populated).
|
|
98
|
+
export function getGitOpsResourceStatus(kind: string, resource: any): GitOpsStatus | null {
|
|
99
|
+
if (kind === 'applications') {
|
|
100
|
+
return argoStatusToGitOpsStatus(resource?.status ?? {})
|
|
101
|
+
}
|
|
102
|
+
const conditions = (resource?.status?.conditions ?? []) as FluxCondition[]
|
|
103
|
+
return fluxConditionsToGitOpsStatus(conditions, resource?.spec?.suspend === true)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// getGitOpsTool routes a kind+group to 'argo' or 'flux'. Same shape used
|
|
107
|
+
// by both detail pages + the fleet list normalizers so a kustomization
|
|
108
|
+
// always reads as 'flux' and an Argo Application always as 'argo'.
|
|
109
|
+
export function getGitOpsTool(kind: string, group?: string): 'argo' | 'flux' {
|
|
110
|
+
if (group === 'argoproj.io' || kind === 'applications' || kind === 'applicationsets' || kind === 'appprojects') return 'argo'
|
|
111
|
+
return 'flux'
|
|
112
|
+
}
|
|
@@ -2,7 +2,58 @@ export { GitOpsStatusBadge, SyncStatusBadge, HealthStatusBadge } from './GitOpsS
|
|
|
2
2
|
export { SyncCountdown, IntervalDisplay } from './SyncCountdown'
|
|
3
3
|
export { ManagedResourcesList, InventoryCount } from './ManagedResourcesList'
|
|
4
4
|
export { GitOpsActions, SyncButton, SuspendToggle } from './GitOpsActions'
|
|
5
|
-
export { GitOpsTreeGraph } from './tree'
|
|
5
|
+
export { GitOpsTreeGraph, mergeGitOpsTrees, MERGED_NODE_SOURCE_KEY } from './tree'
|
|
6
|
+
export type { MergedNodeSource } from './tree'
|
|
6
7
|
export { gitOpsFilterSet, hasGitOpsTreeFilters, matchesGitOpsTreeFilters } from './tree'
|
|
7
8
|
export type { GitOpsTreeFilters, GitOpsTreePreset } from './tree'
|
|
8
9
|
export * from './insights'
|
|
10
|
+
export {
|
|
11
|
+
GitOpsTableView,
|
|
12
|
+
GitOpsFilterSection,
|
|
13
|
+
GitOpsFacetButton,
|
|
14
|
+
GitOpsIconToggle,
|
|
15
|
+
shortClusterName,
|
|
16
|
+
summarizeGitOpsRows,
|
|
17
|
+
normalizeArgoApplication,
|
|
18
|
+
normalizeFluxKustomization,
|
|
19
|
+
normalizeFluxHelmRelease,
|
|
20
|
+
buildFluxSourceUrlMap,
|
|
21
|
+
} from './GitOpsTableView'
|
|
22
|
+
export type {
|
|
23
|
+
GitOpsTableViewProps,
|
|
24
|
+
GitOpsRow,
|
|
25
|
+
GitOpsMode,
|
|
26
|
+
GitOpsViewMode,
|
|
27
|
+
SortKey,
|
|
28
|
+
DestinationFilter,
|
|
29
|
+
FleetClusterStamp,
|
|
30
|
+
FleetDestinationStamp,
|
|
31
|
+
FleetDestinationMatch,
|
|
32
|
+
} from './GitOpsTableView'
|
|
33
|
+
export { GitOpsGraphFilterRail, buildTreeFacets, toggleSet } from './GitOpsGraphFilterRail'
|
|
34
|
+
export {
|
|
35
|
+
formatGitOpsSourceUrl,
|
|
36
|
+
formatGitOpsDestination,
|
|
37
|
+
gitOpsInsightChangeKey,
|
|
38
|
+
parseArgoRollbackID,
|
|
39
|
+
describeGitOpsTerminating,
|
|
40
|
+
getGitOpsResourceStatus,
|
|
41
|
+
getGitOpsTool,
|
|
42
|
+
} from './detail-helpers'
|
|
43
|
+
export { SyncOptionsDialog } from './SyncOptionsDialog'
|
|
44
|
+
export type { SyncOptionsDialogProps, ArgoSyncOpts } from './SyncOptionsDialog'
|
|
45
|
+
export { RollbackDialog } from './RollbackDialog'
|
|
46
|
+
export type { RollbackDialogProps } from './RollbackDialog'
|
|
47
|
+
export type { GitOpsGraphFilterRailProps, GitOpsTreeFacets } from './GitOpsGraphFilterRail'
|
|
48
|
+
export { GitOpsDetailLayout } from './GitOpsDetailLayout'
|
|
49
|
+
export type {
|
|
50
|
+
GitOpsDetailLayoutProps,
|
|
51
|
+
GitOpsDetailIdentity,
|
|
52
|
+
GitOpsDetailLineage,
|
|
53
|
+
GitOpsDetailStatus,
|
|
54
|
+
GitOpsDetailMetadata,
|
|
55
|
+
GitOpsDetailTab,
|
|
56
|
+
ArgoActionHandlers,
|
|
57
|
+
FluxActionHandlers,
|
|
58
|
+
GitOpsHelmValuesData,
|
|
59
|
+
} from './GitOpsDetailLayout'
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { shortClusterName } from './GitOpsTableView'
|
|
4
|
+
|
|
5
|
+
// shortClusterName strips kubectl-context-style prefixes so the Cluster
|
|
6
|
+
// column shows the human-recognizable suffix. The orphan-empty-string
|
|
7
|
+
// fallback ("|| full") is the function's main contract — without it,
|
|
8
|
+
// malformed inputs would render as empty cells. These tests pin both
|
|
9
|
+
// the happy paths and the fallback.
|
|
10
|
+
|
|
11
|
+
describe('shortClusterName', () => {
|
|
12
|
+
test('GKE kubectl context strips to trailing name', () => {
|
|
13
|
+
expect(shortClusterName('gke_koalabackend_me-west1-a_management-cluster')).toBe('management-cluster')
|
|
14
|
+
expect(shortClusterName('gke_proj_us-east1-b_nonprod')).toBe('nonprod')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('EKS ARN strips to suffix after final slash', () => {
|
|
18
|
+
expect(shortClusterName('arn:aws:eks:us-east-1:123456789:cluster/prod-east')).toBe('prod-east')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('user-named clusters (kind, k3d, plain) pass through unchanged', () => {
|
|
22
|
+
expect(shortClusterName('k3d-radar-demo')).toBe('k3d-radar-demo')
|
|
23
|
+
expect(shortClusterName('kind-radar-gitops-demo')).toBe('kind-radar-gitops-demo')
|
|
24
|
+
expect(shortClusterName('production')).toBe('production')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// Malformed-input fallback: the function must not return empty/garbage
|
|
28
|
+
// for inputs that "look like" a recognized prefix but don't have a
|
|
29
|
+
// trailing segment.
|
|
30
|
+
test('malformed GKE prefix with trailing underscore falls back to full string', () => {
|
|
31
|
+
expect(shortClusterName('gke_proj_region_')).toBe('gke_proj_region_')
|
|
32
|
+
})
|
|
33
|
+
test('malformed EKS ARN with trailing slash falls back to full string', () => {
|
|
34
|
+
expect(shortClusterName('arn:aws:eks:us-east-1:123:cluster/')).toBe('arn:aws:eks:us-east-1:123:cluster/')
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -2,3 +2,5 @@ export { GitOpsTreeGraph } from './GitOpsTreeGraph'
|
|
|
2
2
|
export type { GitOpsTreePreset } from './GitOpsTreeGraph'
|
|
3
3
|
export type { GitOpsTreeFilters } from './tree-helpers'
|
|
4
4
|
export { gitOpsFilterSet, hasGitOpsTreeFilters, matchesGitOpsTreeFilters } from './tree-helpers'
|
|
5
|
+
export { mergeGitOpsTrees, MERGED_NODE_SOURCE_KEY } from './merge'
|
|
6
|
+
export type { MergedNodeSource } from './merge'
|