@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.
Files changed (79) hide show
  1. package/package.json +7 -2
  2. package/src/components/charts/AreaChart.tsx +371 -0
  3. package/src/components/charts/MetricsSummary.tsx +49 -0
  4. package/src/components/charts/SeriesLegend.tsx +27 -0
  5. package/src/components/charts/colors.ts +49 -0
  6. package/src/components/charts/format.ts +41 -0
  7. package/src/components/charts/index.ts +14 -0
  8. package/src/components/charts/saturation.test.ts +60 -0
  9. package/src/components/charts/saturation.ts +32 -0
  10. package/src/components/charts/types.ts +36 -0
  11. package/src/components/compare/CompareResourcePicker.tsx +192 -0
  12. package/src/components/compare/CompareTray.tsx +130 -0
  13. package/src/components/compare/ResourceCompareView.tsx +272 -0
  14. package/src/components/compare/index.ts +14 -0
  15. package/src/components/compare/normalize.test.ts +193 -0
  16. package/src/components/compare/normalize.ts +69 -0
  17. package/src/components/compare/picks.test.ts +97 -0
  18. package/src/components/compare/picks.ts +35 -0
  19. package/src/components/compare/sort.test.ts +78 -0
  20. package/src/components/compare/sort.ts +26 -0
  21. package/src/components/compare/types.ts +43 -0
  22. package/src/components/compare/url.test.ts +61 -0
  23. package/src/components/compare/url.ts +26 -0
  24. package/src/components/gitops/GitOpsDetailLayout.tsx +621 -0
  25. package/src/components/gitops/GitOpsGraphFilterRail.tsx +216 -0
  26. package/src/components/gitops/GitOpsTableView.tsx +1441 -0
  27. package/src/components/gitops/RollbackDialog.tsx +117 -0
  28. package/src/components/gitops/SyncOptionsDialog.tsx +160 -0
  29. package/src/components/gitops/detail-helpers.test.ts +97 -0
  30. package/src/components/gitops/detail-helpers.ts +112 -0
  31. package/src/components/gitops/index.ts +52 -1
  32. package/src/components/gitops/short-cluster-name.test.ts +36 -0
  33. package/src/components/gitops/tree/index.ts +2 -0
  34. package/src/components/gitops/tree/merge.test.ts +240 -0
  35. package/src/components/gitops/tree/merge.ts +160 -0
  36. package/src/components/resources/ResourcesSidebar.tsx +42 -15
  37. package/src/components/resources/ResourcesView.tsx +403 -69
  38. package/src/components/resources/index.ts +1 -0
  39. package/src/components/resources/renderers/CompositeRenderer.tsx +233 -0
  40. package/src/components/resources/renderers/CompositionRenderer.tsx +218 -0
  41. package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +145 -0
  42. package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +71 -0
  43. package/src/components/resources/renderers/HPARenderer.tsx +6 -1
  44. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +131 -0
  45. package/src/components/resources/renderers/NamespaceRenderer.tsx +223 -0
  46. package/src/components/resources/renderers/PVCRenderer.tsx +6 -1
  47. package/src/components/resources/renderers/PodRenderer.tsx +207 -2
  48. package/src/components/resources/renderers/RoleBindingRenderer.tsx +132 -20
  49. package/src/components/resources/renderers/RoleRenderer.tsx +148 -18
  50. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +456 -3
  51. package/src/components/resources/renderers/WorkloadRenderer.tsx +189 -2
  52. package/src/components/resources/renderers/XRDRenderer.tsx +153 -0
  53. package/src/components/resources/renderers/crossplane-cells.tsx +146 -0
  54. package/src/components/resources/renderers/flux-cells.tsx +12 -0
  55. package/src/components/resources/renderers/index.ts +8 -0
  56. package/src/components/resources/resource-utils-crossplane.test.ts +609 -0
  57. package/src/components/resources/resource-utils-crossplane.ts +325 -0
  58. package/src/components/resources/resource-utils-flux.ts +14 -0
  59. package/src/components/resources/resource-utils.ts +1 -0
  60. package/src/components/resources/resources-column-filter.test.ts +74 -0
  61. package/src/components/shared/ResourceActionsBar.tsx +77 -0
  62. package/src/components/shared/ResourceRendererDispatch.tsx +138 -9
  63. package/src/components/ui/YamlEditor.tsx +28 -15
  64. package/src/components/ui/drawer-components.tsx +8 -20
  65. package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
  66. package/src/components/workload/WorkloadView.tsx +2 -2
  67. package/src/index.ts +3 -0
  68. package/src/types/core.ts +31 -6
  69. package/src/types/index.ts +1 -0
  70. package/src/types/rbac.ts +99 -0
  71. package/src/utils/api-resources.ts +9 -1
  72. package/src/utils/badge-colors.ts +25 -0
  73. package/src/utils/gitops-owner.test.ts +70 -111
  74. package/src/utils/gitops-owner.ts +37 -74
  75. package/src/utils/helm-status.test.ts +50 -0
  76. package/src/utils/index.ts +1 -0
  77. package/src/utils/rbac-badges.ts +61 -0
  78. package/src/utils/rbac-blast-radius.test.ts +137 -0
  79. 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'