@skyhook-io/k8s-ui 1.18.0 → 1.18.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/README.md +21 -0
- package/package.json +1 -1
- package/src/components/applications/ApplicationsView.tsx +8 -7
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
- package/src/components/compare/CompareResourcePicker.tsx +18 -4
- package/src/components/compare/ResourceCompareView.tsx +6 -2
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
- package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
- package/src/components/gitops/GitOpsTableView.tsx +46 -12
- package/src/components/gitops/detail-helpers.test.ts +58 -13
- package/src/components/gitops/detail-helpers.ts +37 -6
- package/src/components/gitops/index.ts +1 -0
- package/src/components/issues/IssuesView.tsx +16 -11
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/severity.ts +1 -0
- package/src/components/logs/LogCore.tsx +13 -11
- package/src/components/logs/LogsViewer.test.tsx +104 -59
- package/src/components/logs/LogsViewer.tsx +28 -8
- package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +44 -8
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +62 -26
- package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
- package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
- package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
- package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
- package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
- package/src/components/resources/ResourcesView.tsx +342 -83
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +15 -0
- package/src/components/resources/printer-columns.test.ts +36 -0
- package/src/components/resources/printer-columns.ts +12 -1
- package/src/components/resources/readiness-gates.test.ts +95 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
- package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
- package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
- package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +32 -9
- package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
- package/src/components/resources/resource-utils-crossplane.ts +44 -8
- package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
- package/src/components/resources/resource-utils-flagger.ts +76 -0
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
- package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
- package/src/components/resources/resource-utils-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -0
- package/src/components/resources/resource-utils.ts +80 -2
- package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
- package/src/components/shared/CreateResourceDialog.tsx +5 -0
- package/src/components/shared/DetailShell.phone.test.tsx +54 -0
- package/src/components/shared/DetailShell.tsx +21 -8
- package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
- package/src/components/shared/EditableYamlView.test.tsx +58 -0
- package/src/components/shared/EditableYamlView.tsx +18 -3
- package/src/components/shared/FilterRailPanel.test.tsx +39 -0
- package/src/components/shared/FilterRailPanel.tsx +62 -0
- package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
- package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
- package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
- package/src/components/shared/ManagedByChip.test.tsx +22 -0
- package/src/components/shared/ManagedByChip.tsx +158 -5
- package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
- package/src/components/shared/ResourceActionsBar.tsx +31 -9
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +69 -31
- package/src/components/shared/SetImageDialog.tsx +3 -0
- package/src/components/timeline/TimelineList.tsx +5 -1
- package/src/components/topology/TopologyControls.tsx +1 -1
- package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
- package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
- package/src/components/ui/FetchResult.test.tsx +19 -2
- package/src/components/ui/FetchResult.tsx +13 -1
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
- package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
- package/src/components/ui/MultiSelectPicker.tsx +20 -2
- package/src/components/ui/PageHeader.tsx +7 -4
- package/src/components/ui/SummaryTile.tsx +13 -3
- package/src/components/ui/Toast.tsx +33 -26
- package/src/components/ui/Tooltip.touch.test.tsx +55 -0
- package/src/components/ui/Tooltip.tsx +11 -2
- package/src/components/ui/YamlReview.test.tsx +52 -0
- package/src/components/ui/YamlReview.tsx +7 -0
- package/src/components/ui/drawer-components.test.tsx +280 -0
- package/src/components/ui/drawer-components.tsx +302 -18
- package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +73 -0
- package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
- package/src/components/workload/WorkloadView.tsx +105 -34
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
- package/src/hooks/useKeepInViewport.test.ts +27 -0
- package/src/hooks/useKeepInViewport.ts +87 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/usePhoneLayout.ts +12 -0
- package/src/theme/tailwind-theme.css +5 -0
- package/src/types/core.ts +119 -3
- package/src/types/gitops-flux-inventory.test.ts +24 -0
- package/src/types/gitops.ts +5 -5
- package/src/types/index.ts +3 -0
- package/src/types/read-coverage.ts +65 -0
- package/src/types/resource-coverage.ts +40 -0
- package/src/utils/api-resources.ts +1 -0
- package/src/utils/format.test.ts +7 -0
- package/src/utils/format.ts +6 -1
- package/src/utils/gitops-owner.test.ts +42 -1
- package/src/utils/gitops-owner.ts +158 -8
- package/src/utils/gitops-write-guard.test.ts +332 -0
- package/src/utils/gitops-write-guard.ts +175 -11
- package/src/utils/index.ts +3 -0
- package/src/utils/read-actions.test.ts +22 -0
- package/src/utils/read-actions.ts +90 -0
- package/src/utils/resource-coverage.test.ts +121 -0
- package/src/utils/resource-coverage.ts +117 -0
- package/src/utils/touch-intent.test.ts +39 -0
- package/src/utils/touch-intent.ts +35 -0
- package/vitest.config.ts +26 -0
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
1
2
|
import { GitBranch, Package } from 'lucide-react'
|
|
2
3
|
import { clsx } from 'clsx'
|
|
3
|
-
import type
|
|
4
|
+
import { activeManagerClaims, gitOpsOwnerFromRef, managerCandidates, managerChipPresentation, managerChoices, managerRefLabel, type GitOpsOwnerRef, type ManagerChoice, type ManagerListing } from '../../utils/gitops-owner'
|
|
5
|
+
import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
|
|
6
|
+
import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
|
|
7
|
+
import type { ManagerAnswer, ResourceRef } from '../../types/core'
|
|
8
|
+
import { resourceRefKey } from '../../utils/resource-refs'
|
|
4
9
|
import type { GitOpsStatus } from '../../types/gitops'
|
|
5
10
|
import { StatusDot, type StatusTone } from '../ui/status-tone'
|
|
6
11
|
import { Tooltip } from '../ui/Tooltip'
|
|
@@ -11,7 +16,10 @@ export type HelmOwnerRef = {
|
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
// ManagedByChip renders the "Managed by <ArgoCD/FluxCD app>" affordance for
|
|
14
|
-
// resources detected (via labels/annotations) to be GitOps-managed.
|
|
19
|
+
// resources detected (via labels/annotations) to be GitOps-managed. `listing`
|
|
20
|
+
// compares that claim with the managers whose status lists the resource: a
|
|
21
|
+
// manager that doesn't list it while another does is shown as "Tracked by"
|
|
22
|
+
// with a warning, since the claim is all that names it. The chip
|
|
15
23
|
// is clickable when the host wires `onOpen`; integrators that don't surface
|
|
16
24
|
// a GitOps tab can omit the handler and the chip degrades to a passive badge
|
|
17
25
|
// so the relationship is still visible.
|
|
@@ -21,26 +29,33 @@ export type HelmOwnerRef = {
|
|
|
21
29
|
// - block: starts a new line with mt-1 spacing, used in WorkloadView title strip
|
|
22
30
|
export function ManagedByChip({
|
|
23
31
|
owner,
|
|
32
|
+
manager,
|
|
24
33
|
status,
|
|
25
34
|
onOpen,
|
|
26
35
|
verified = true,
|
|
27
36
|
pending = false,
|
|
28
37
|
source,
|
|
38
|
+
listing,
|
|
29
39
|
variant = 'inline',
|
|
30
40
|
}: {
|
|
31
|
-
owner
|
|
41
|
+
owner?: GitOpsOwnerRef | null
|
|
42
|
+
manager?: ManagerAnswer
|
|
32
43
|
status?: GitOpsStatus | null
|
|
33
44
|
onOpen?: (ref: GitOpsOwnerRef) => void
|
|
34
45
|
verified?: boolean
|
|
35
46
|
pending?: boolean
|
|
36
47
|
source?: string | null
|
|
48
|
+
listing?: ManagerListing
|
|
37
49
|
variant?: 'inline' | 'block'
|
|
38
50
|
}) {
|
|
51
|
+
if (manager) return <ManagerAnswerChip manager={manager} status={status} onOpen={onOpen} variant={variant} />
|
|
52
|
+
if (!owner) return null
|
|
39
53
|
const toolLabel = owner.tool === 'argocd' ? 'ArgoCD' : 'FluxCD'
|
|
40
54
|
const ownerKindLabel = gitOpsOwnerKindLabel(owner)
|
|
41
55
|
const label = owner.namespace ? `${owner.namespace}/${owner.name}` : owner.name
|
|
42
56
|
const statusLabel = status ? gitOpsStatusLabel(status) : null
|
|
43
|
-
const
|
|
57
|
+
const conflict = listing?.state === 'conflict' ? listing.listedBy : null
|
|
58
|
+
const prefix = pending ? 'Resolving' : verified && !conflict ? 'Managed by' : 'Tracked by'
|
|
44
59
|
const tooltipContent = (
|
|
45
60
|
<span className="flex flex-col gap-1">
|
|
46
61
|
<span>
|
|
@@ -48,10 +63,23 @@ export function ManagedByChip({
|
|
|
48
63
|
<>
|
|
49
64
|
Looking up matching {ownerKindLabel} <TooltipCode>{label}</TooltipCode> in this cluster.
|
|
50
65
|
</>
|
|
66
|
+
) : conflict ? (
|
|
67
|
+
<>
|
|
68
|
+
This resource's {toolLabel} tracking metadata names <TooltipCode>{label}</TooltipCode>, but that {ownerKindLabel}'s status doesn't list it.{' '}
|
|
69
|
+
{conflict.length === 1 ? 'The status of ' : 'The statuses of '}
|
|
70
|
+
{conflict.map((m, i) => (
|
|
71
|
+
<span key={`${m.namespace}/${m.name}`}>
|
|
72
|
+
{i > 0 && ', '}
|
|
73
|
+
<TooltipCode>{m.namespace ? `${m.namespace}/${m.name}` : m.name}</TooltipCode>
|
|
74
|
+
</span>
|
|
75
|
+
))}{' '}
|
|
76
|
+
{conflict.length === 1 ? 'does.' : 'do.'}
|
|
77
|
+
</>
|
|
51
78
|
) : verified ? (
|
|
52
79
|
<>
|
|
53
80
|
Managed by {toolLabel} · <TooltipCode>{label}</TooltipCode>
|
|
54
81
|
{statusLabel && <> · {statusLabel}{status?.message ? `: ${status.message}` : ''}</>}
|
|
82
|
+
{listing?.state === 'confirmed' && <>. Its status lists this resource too.</>}
|
|
55
83
|
</>
|
|
56
84
|
) : (
|
|
57
85
|
<>
|
|
@@ -80,6 +108,7 @@ export function ManagedByChip({
|
|
|
80
108
|
)}
|
|
81
109
|
>
|
|
82
110
|
<GitBranch className="h-3 w-3 shrink-0" />
|
|
111
|
+
{conflict && <StatusDot tone="degraded" size="xs" />}
|
|
83
112
|
<span className="shrink-0 text-theme-text-tertiary">{prefix}</span>
|
|
84
113
|
<span className="truncate max-w-[180px]">{label}</span>
|
|
85
114
|
{statusLabel && (
|
|
@@ -96,13 +125,136 @@ export function ManagedByChip({
|
|
|
96
125
|
)
|
|
97
126
|
}
|
|
98
127
|
|
|
128
|
+
function ManagerAnswerChip({ manager, status, onOpen, variant }: {
|
|
129
|
+
manager: ManagerAnswer
|
|
130
|
+
status?: GitOpsStatus | null
|
|
131
|
+
onOpen?: (ref: GitOpsOwnerRef) => void
|
|
132
|
+
variant: 'inline' | 'block'
|
|
133
|
+
}) {
|
|
134
|
+
const [open, setOpen] = useState(false)
|
|
135
|
+
const { shouldRender, isOpen } = useAnimatedUnmount(open, overlayExitMs('menu'))
|
|
136
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (!open) return
|
|
139
|
+
const onDown = (e: MouseEvent) => {
|
|
140
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
|
141
|
+
}
|
|
142
|
+
// Consumed, so the Escape that closes the menu doesn't also close the drawer.
|
|
143
|
+
const onKey = (e: KeyboardEvent) => {
|
|
144
|
+
if (e.key !== 'Escape') return
|
|
145
|
+
e.preventDefault()
|
|
146
|
+
e.stopPropagation()
|
|
147
|
+
setOpen(false)
|
|
148
|
+
}
|
|
149
|
+
document.addEventListener('mousedown', onDown)
|
|
150
|
+
document.addEventListener('keydown', onKey, true)
|
|
151
|
+
return () => {
|
|
152
|
+
document.removeEventListener('mousedown', onDown)
|
|
153
|
+
document.removeEventListener('keydown', onKey, true)
|
|
154
|
+
}
|
|
155
|
+
}, [open])
|
|
156
|
+
|
|
157
|
+
const presentation = managerChipPresentation(manager)
|
|
158
|
+
if (!presentation) return null
|
|
159
|
+
// Several candidates open a menu naming each, so none is reached only
|
|
160
|
+
// through the one shown first; one opens directly.
|
|
161
|
+
// Every candidate is named; only one Radar found links. A label that names
|
|
162
|
+
// no namespace stays unlinked: Radar doesn't guess it.
|
|
163
|
+
const choices = managerChoices(manager).map(c => {
|
|
164
|
+
const owner = gitOpsOwnerFromRef(c.ref)
|
|
165
|
+
return { ...c, owner: c.found && owner?.namespace ? owner : null }
|
|
166
|
+
})
|
|
167
|
+
// A label naming no namespace is unresolved only when its own check says
|
|
168
|
+
// so; otherwise Applications of that name list the object and it doesn't
|
|
169
|
+
// say which.
|
|
170
|
+
const evidence = manager.via ?? manager.direct
|
|
171
|
+
const unresolved = (ref: ResourceRef) => activeManagerClaims(evidence).some(c => c.check === 'unresolved' && resourceRefKey(c.manager) === resourceRefKey(ref))
|
|
172
|
+
const several = managerCandidates(manager).length > 1
|
|
173
|
+
const lead = manager.lead ? gitOpsOwnerFromRef(manager.lead) : null
|
|
174
|
+
const direct = !several ? choices.find(c => c.owner && lead && c.owner.name === lead.name && c.owner.namespace === lead.namespace)?.owner ?? null : null
|
|
175
|
+
const menu = Boolean(onOpen) && several && choices.some(c => c.owner)
|
|
176
|
+
// The manager's own status, shown only when it is confirmed to manage this
|
|
177
|
+
// resource: beside a list alone, Synced reads as this resource's state.
|
|
178
|
+
const statusLabel = lead && status && !several && manager.state === 'agree' ? gitOpsStatusLabel(status) : null
|
|
179
|
+
const chipClass = clsx(
|
|
180
|
+
'inline-flex min-w-0 items-center gap-1 rounded border border-theme-border bg-theme-elevated px-1.5 py-0.5 text-[11px] text-theme-text-secondary',
|
|
181
|
+
(menu || direct && onOpen) && 'hover:border-accent hover:text-accent transition-colors',
|
|
182
|
+
)
|
|
183
|
+
const content = (
|
|
184
|
+
<>
|
|
185
|
+
<GitBranch className="h-3 w-3 shrink-0" />
|
|
186
|
+
{presentation.warning && <StatusDot tone="degraded" size="xs" />}
|
|
187
|
+
<span className="shrink-0 text-theme-text-tertiary">{presentation.prefix}</span>
|
|
188
|
+
<span className="truncate max-w-[180px]">{presentation.label}</span>
|
|
189
|
+
{presentation.via && <span className="shrink-0 text-theme-text-tertiary">via {presentation.via}</span>}
|
|
190
|
+
{statusLabel && <><span className="mx-0.5 text-theme-text-disabled">·</span><StatusDot tone={gitOpsStatusTone(status!)} size="xs" /><span>{statusLabel}</span></>}
|
|
191
|
+
</>
|
|
192
|
+
)
|
|
193
|
+
const tooltip = `${presentation.tooltip}${statusLabel ? ` ${statusLabel}${status?.message ? `: ${status.message}` : ''}.` : ''}`
|
|
194
|
+
const chip = menu ? (
|
|
195
|
+
<button type="button" aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(v => !v)} className={chipClass}>{content}</button>
|
|
196
|
+
) : direct && onOpen ? (
|
|
197
|
+
<button type="button" onClick={() => onOpen(direct)} className={chipClass}>{content}</button>
|
|
198
|
+
) : (
|
|
199
|
+
// Focusable, so keyboard users reach the tooltip that explains it.
|
|
200
|
+
<span tabIndex={0} className={chipClass}>{content}</span>
|
|
201
|
+
)
|
|
202
|
+
return (
|
|
203
|
+
<div ref={ref} className={clsx('relative inline-flex min-w-0', variant === 'block' && 'mt-1')}>
|
|
204
|
+
<Tooltip content={tooltip} delay={500} position="bottom">{chip}</Tooltip>
|
|
205
|
+
{menu && shouldRender && (
|
|
206
|
+
<div
|
|
207
|
+
role="menu"
|
|
208
|
+
inert={!open}
|
|
209
|
+
className={clsx(
|
|
210
|
+
'absolute left-0 top-full z-50 mt-1 min-w-[220px] origin-top-left rounded-lg border border-theme-border bg-theme-surface py-1 shadow-theme-lg',
|
|
211
|
+
TRANSITION_MENU,
|
|
212
|
+
isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
|
|
213
|
+
!open && 'pointer-events-none',
|
|
214
|
+
)}
|
|
215
|
+
style={overlayTransitionStyle(isOpen, 'menu')}
|
|
216
|
+
>
|
|
217
|
+
{choices.map(c => (
|
|
218
|
+
<button
|
|
219
|
+
key={`${c.ref.group}/${c.ref.kind}/${c.ref.namespace}/${c.ref.name}`}
|
|
220
|
+
type="button"
|
|
221
|
+
role="menuitem"
|
|
222
|
+
disabled={!c.owner}
|
|
223
|
+
onClick={() => {
|
|
224
|
+
if (!c.owner) return
|
|
225
|
+
setOpen(false)
|
|
226
|
+
onOpen?.(c.owner)
|
|
227
|
+
}}
|
|
228
|
+
className={clsx(
|
|
229
|
+
'flex w-full flex-col items-start px-3 py-1.5 text-left text-xs transition-colors',
|
|
230
|
+
c.owner ? 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary' : 'cursor-default text-theme-text-tertiary',
|
|
231
|
+
)}
|
|
232
|
+
>
|
|
233
|
+
<span className="truncate">{managerRefLabel(c.ref)}</span>
|
|
234
|
+
<span className="text-[11px] text-theme-text-tertiary">{choiceRole(c)}{c.owner ? '' : !c.ref.namespace ? unresolved(c.ref) ? ` · Radar found no ${c.ref.kind} of this name listing it; it may be managed from another cluster` : ` · its label doesn't say which ${c.ref.kind} of this name` : !c.found ? ' · Radar found no such manager you can view' : ''}</span>
|
|
235
|
+
</button>
|
|
236
|
+
))}
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function choiceRole(c: Pick<ManagerChoice, 'listed' | 'claimed' | 'found'>): string {
|
|
244
|
+
if (c.listed && c.claimed) return 'Its status lists this, and the metadata names it'
|
|
245
|
+
if (c.listed) return 'Its status lists this'
|
|
246
|
+
return 'This metadata names it'
|
|
247
|
+
}
|
|
248
|
+
|
|
99
249
|
export function HelmManagedByChip({
|
|
100
250
|
owner,
|
|
251
|
+
manager,
|
|
101
252
|
onOpen,
|
|
102
253
|
source,
|
|
103
254
|
variant = 'inline',
|
|
104
255
|
}: {
|
|
105
256
|
owner: HelmOwnerRef
|
|
257
|
+
manager?: ManagerAnswer
|
|
106
258
|
onOpen?: (ref: HelmOwnerRef) => void
|
|
107
259
|
source?: string | null
|
|
108
260
|
variant?: 'inline' | 'block'
|
|
@@ -113,6 +265,7 @@ export function HelmManagedByChip({
|
|
|
113
265
|
<span>
|
|
114
266
|
Managed by Helm release <TooltipCode>{label}</TooltipCode>.
|
|
115
267
|
</span>
|
|
268
|
+
{manager && <span>{managerChipPresentation(manager)?.tooltip}</span>}
|
|
116
269
|
{source && (
|
|
117
270
|
<span>
|
|
118
271
|
Inferred from <TooltipCode>{source}</TooltipCode>.
|
|
@@ -141,7 +294,7 @@ export function HelmManagedByChip({
|
|
|
141
294
|
)
|
|
142
295
|
}
|
|
143
296
|
|
|
144
|
-
function gitOpsOwnerKindLabel(owner: GitOpsOwnerRef): string {
|
|
297
|
+
export function gitOpsOwnerKindLabel(owner: GitOpsOwnerRef): string {
|
|
145
298
|
if (owner.tool === 'argocd') return 'Application'
|
|
146
299
|
if (owner.kind === 'helmreleases') return 'HelmRelease'
|
|
147
300
|
if (owner.kind === 'kustomizations') return 'Kustomization'
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { ResourceActionsBar } from './ResourceActionsBar'
|
|
4
|
+
|
|
5
|
+
const noop = () => {}
|
|
6
|
+
|
|
7
|
+
function actions(kind: string, apiVersion: string) {
|
|
8
|
+
return renderToString(
|
|
9
|
+
<ResourceActionsBar
|
|
10
|
+
resource={{ kind, name: 'example', namespace: 'test' }}
|
|
11
|
+
data={{ apiVersion, metadata: { name: 'example', namespace: 'test' } }}
|
|
12
|
+
onFluxReconcile={noop}
|
|
13
|
+
onFluxSuspend={noop}
|
|
14
|
+
onFluxResume={noop}
|
|
15
|
+
onArgoSync={noop}
|
|
16
|
+
onArgoRefresh={noop}
|
|
17
|
+
onArgoSuspend={noop}
|
|
18
|
+
onArgoResume={noop}
|
|
19
|
+
canPortForward
|
|
20
|
+
renderPortForward={() => <button>Port forward</button>}
|
|
21
|
+
/>,
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('resource actions use the loaded API group', () => {
|
|
26
|
+
it.each(['app.k8s.io/v1beta1', 'notargoproj.io/v1alpha1', ''])('does not offer Argo actions for Application in %s', (apiVersion) => {
|
|
27
|
+
const html = actions('applications', apiVersion)
|
|
28
|
+
expect(html).not.toContain('>Sync<')
|
|
29
|
+
expect(html).not.toContain('>Refresh<')
|
|
30
|
+
expect(html).not.toContain('Enable Auto-Sync')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('retains Argo actions for its exact Application group', () => {
|
|
34
|
+
const html = actions('applications', 'argoproj.io/v1alpha1')
|
|
35
|
+
expect(html).toContain('>Sync<')
|
|
36
|
+
expect(html).toContain('>Refresh<')
|
|
37
|
+
expect(html).toContain('Enable Auto-Sync')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it.each(['serving.knative.dev/v1', 'vendor.serving.knative.dev/v1', 'vendor.io/v1', ''])('does not port-forward a non-core Service in %s', (apiVersion) => {
|
|
41
|
+
expect(actions('services', apiVersion)).not.toContain('Port forward')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('retains port forwarding for core Service', () => {
|
|
45
|
+
expect(actions('services', 'v1')).toContain('Port forward')
|
|
46
|
+
})
|
|
47
|
+
it.each([
|
|
48
|
+
['gitrepositories', 'source.toolkit.fluxcd.io'],
|
|
49
|
+
['ocirepositories', 'source.toolkit.fluxcd.io'],
|
|
50
|
+
['helmrepositories', 'source.toolkit.fluxcd.io'],
|
|
51
|
+
['kustomizations', 'kustomize.toolkit.fluxcd.io'],
|
|
52
|
+
['helmreleases', 'helm.toolkit.fluxcd.io'],
|
|
53
|
+
['alerts', 'notification.toolkit.fluxcd.io'],
|
|
54
|
+
])('only offers Flux actions for %s in its exact group', (kind, group) => {
|
|
55
|
+
expect(actions(kind, `${group}/v1`)).toContain('>Reconcile<')
|
|
56
|
+
for (const apiVersion of ['', 'vendor.io/v1', `foreign.${group}/v1`, 'argoproj.io/v1alpha1']) {
|
|
57
|
+
const html = actions(kind, apiVersion)
|
|
58
|
+
expect(html).not.toContain('>Reconcile<')
|
|
59
|
+
expect(html).not.toContain('>Suspend<')
|
|
60
|
+
expect(html).not.toContain('>Resume<')
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getNodeLifecycle } from '../../utils/node-lifecycle'
|
|
2
|
+
import { resourceRefKey } from '../../utils/resource-refs'
|
|
2
3
|
import { useEffect, useState, useRef, useId } from 'react'
|
|
3
4
|
import {
|
|
4
5
|
RefreshCw,
|
|
@@ -21,7 +22,7 @@ import { createTwoFilesPatch } from 'diff'
|
|
|
21
22
|
import { clsx } from 'clsx'
|
|
22
23
|
import { classifyDiffLine } from './UnifiedDiff'
|
|
23
24
|
import { Tooltip } from '../ui/Tooltip'
|
|
24
|
-
import { ForceDeleteConfirmDialog, type CascadeDependent } from '../ui/ForceDeleteConfirmDialog'
|
|
25
|
+
import { ForceDeleteConfirmDialog, type CascadeDependent, type CascadeDetail } from '../ui/ForceDeleteConfirmDialog'
|
|
25
26
|
import { ConfirmDialog } from '../ui/ConfirmDialog'
|
|
26
27
|
import { AlertBanner } from '../ui/drawer-components'
|
|
27
28
|
import { DialogPortal } from '../ui/DialogPortal'
|
|
@@ -32,6 +33,9 @@ import { getDefaultContainerName, getExecContainerNames } from '../resources/res
|
|
|
32
33
|
import { SetImageDialog, type SetImageOwnership } from './SetImageDialog'
|
|
33
34
|
import { DrainPlanDialog, DEFAULT_DRAIN_DIALOG_OPTIONS, type DrainDialogOptions, type DrainPlan } from './DrainPlanDialog'
|
|
34
35
|
import type { WorkloadImageInventory, WorkloadImageUpdate } from '../../types/core'
|
|
36
|
+
import { getGitOpsTool } from '../gitops/detail-helpers'
|
|
37
|
+
import { apiVersionToGroup } from '../../utils/navigation'
|
|
38
|
+
import { isApiGroup } from '../resources/resource-utils-cnpg'
|
|
35
39
|
import { isArgoRolloutResource } from '../../utils/workload-rollout'
|
|
36
40
|
import { isCoreBatchJob } from '../../utils/api-resources'
|
|
37
41
|
import { formatRelativeAgeTime } from '../../utils/format'
|
|
@@ -40,6 +44,8 @@ import { formatRelativeAgeTime } from '../../utils/format'
|
|
|
40
44
|
// ACTIONS BAR - Interactive buttons that change based on resource kind
|
|
41
45
|
// ============================================================================
|
|
42
46
|
|
|
47
|
+
const FLUX_ACTION_KINDS = new Set(['gitrepositories', 'ocirepositories', 'helmrepositories', 'kustomizations', 'helmreleases', 'alerts'])
|
|
48
|
+
|
|
43
49
|
interface ResourceActionsBarProps {
|
|
44
50
|
resource: SelectedResource
|
|
45
51
|
leadingActions?: React.ReactNode
|
|
@@ -80,6 +86,8 @@ interface ResourceActionsBarProps {
|
|
|
80
86
|
isDeleting?: boolean
|
|
81
87
|
cascadeDependents?: CascadeDependent[]
|
|
82
88
|
cascadeLoading?: boolean
|
|
89
|
+
onDeletePreviewOpenChange?: (open: boolean) => void
|
|
90
|
+
cascadeDetail?: CascadeDetail
|
|
83
91
|
cascadeRootResolved?: boolean
|
|
84
92
|
cascadePreviewFailed?: boolean
|
|
85
93
|
|
|
@@ -163,7 +171,7 @@ export function ResourceActionsBar({
|
|
|
163
171
|
canExec, canViewLogs, canPortForward,
|
|
164
172
|
onOpenTerminal, onOpenLogs: openLogs, onOpenWorkloadLogs: openWorkloadLogs, onCopyCommand,
|
|
165
173
|
renderPortForward,
|
|
166
|
-
onDelete, isDeleting, cascadeDependents, cascadeLoading, cascadeRootResolved, cascadePreviewFailed,
|
|
174
|
+
onDelete, isDeleting, onDeletePreviewOpenChange, cascadeDetail, cascadeDependents, cascadeLoading, cascadeRootResolved, cascadePreviewFailed,
|
|
167
175
|
onRestart, isRestarting,
|
|
168
176
|
onLoadImages, onSetImages, isSettingImages, imageOwnership,
|
|
169
177
|
revisions: revisionsList, revisionsLoading, revisionsError, onRollback, isRollingBack,
|
|
@@ -187,6 +195,7 @@ export function ResourceActionsBar({
|
|
|
187
195
|
onPlanDrain, onPlanDrainReset, drainPlan, isPlanningDrain, drainPlanError, drainPlanUpgrade,
|
|
188
196
|
}: ResourceActionsBarProps) {
|
|
189
197
|
const kind = resource.kind.toLowerCase()
|
|
198
|
+
const isFluxAction = FLUX_ACTION_KINDS.has(kind) && getGitOpsTool(kind, apiVersionToGroup(data?.apiVersion)) === 'flux'
|
|
190
199
|
const coreBatchJob = isCoreBatchJob(kind, resource.group)
|
|
191
200
|
const supportsWorkloadActions = ['deployments', 'statefulsets', 'daemonsets'].includes(kind) ||
|
|
192
201
|
(kind === 'rollouts' && isArgoRolloutResource(data))
|
|
@@ -199,6 +208,14 @@ export function ResourceActionsBar({
|
|
|
199
208
|
|
|
200
209
|
// Delete confirmation state
|
|
201
210
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
|
211
|
+
const deleteMounted = useRef(true)
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
deleteMounted.current = true
|
|
214
|
+
return () => { deleteMounted.current = false }
|
|
215
|
+
}, [])
|
|
216
|
+
const currentDeleteTarget = useRef({ key: resourceRefKey(resource), uid: data?.metadata?.uid })
|
|
217
|
+
currentDeleteTarget.current = { key: resourceRefKey(resource), uid: data?.metadata?.uid }
|
|
218
|
+
useEffect(() => { onDeletePreviewOpenChange?.(showDeleteConfirm) }, [showDeleteConfirm, onDeletePreviewOpenChange])
|
|
202
219
|
|
|
203
220
|
// Node operation confirmation state
|
|
204
221
|
const [showCordonConfirm, setShowCordonConfirm] = useState(false)
|
|
@@ -234,12 +251,13 @@ export function ResourceActionsBar({
|
|
|
234
251
|
const hasMultipleRevisions = (revisionsList?.length ?? 0) > 1
|
|
235
252
|
|
|
236
253
|
function handleDeleteConfirm(force: boolean) {
|
|
254
|
+
const target = currentDeleteTarget.current
|
|
255
|
+
setShowDeleteConfirm(false)
|
|
237
256
|
onDelete?.(
|
|
238
257
|
{ kind: resource.kind, group: resource.group, namespace: resource.namespace, name: resource.name, force },
|
|
239
258
|
{
|
|
240
259
|
onSuccess: () => {
|
|
241
|
-
|
|
242
|
-
onClose?.()
|
|
260
|
+
if (deleteMounted.current && currentDeleteTarget.current.key === target.key && currentDeleteTarget.current.uid === target.uid) onClose?.()
|
|
243
261
|
},
|
|
244
262
|
}
|
|
245
263
|
)
|
|
@@ -406,7 +424,7 @@ export function ResourceActionsBar({
|
|
|
406
424
|
)}
|
|
407
425
|
|
|
408
426
|
{/* Service actions */}
|
|
409
|
-
{kind === 'services' &&
|
|
427
|
+
{kind === 'services' && data?.apiVersion === 'v1' && canPortForward && resource.namespace && resource.name && renderPortForward && (
|
|
410
428
|
renderPortForward({
|
|
411
429
|
type: 'service',
|
|
412
430
|
namespace: resource.namespace,
|
|
@@ -516,7 +534,7 @@ export function ResourceActionsBar({
|
|
|
516
534
|
)}
|
|
517
535
|
|
|
518
536
|
{/* FluxCD actions */}
|
|
519
|
-
{
|
|
537
|
+
{isFluxAction && (
|
|
520
538
|
<FluxActions
|
|
521
539
|
resource={resource}
|
|
522
540
|
data={data}
|
|
@@ -532,7 +550,7 @@ export function ResourceActionsBar({
|
|
|
532
550
|
)}
|
|
533
551
|
|
|
534
552
|
{/* ArgoCD actions */}
|
|
535
|
-
{kind === 'applications' && (
|
|
553
|
+
{kind === 'applications' && isApiGroup(data?.apiVersion, 'argoproj.io') && (
|
|
536
554
|
<ArgoActions
|
|
537
555
|
resource={resource}
|
|
538
556
|
data={data}
|
|
@@ -667,12 +685,14 @@ export function ResourceActionsBar({
|
|
|
667
685
|
)}
|
|
668
686
|
|
|
669
687
|
{onDelete && !((kind === 'node' || kind === 'nodes') && data?.metadata?.deletionTimestamp) && (
|
|
670
|
-
<Tooltip content=
|
|
688
|
+
<Tooltip content={isDeleting ? 'Deleting resource' : 'Delete resource'}>
|
|
671
689
|
<button
|
|
690
|
+
aria-label={isDeleting ? 'Deleting resource' : 'Delete resource'}
|
|
691
|
+
disabled={isDeleting}
|
|
672
692
|
onClick={() => setShowDeleteConfirm(true)}
|
|
673
693
|
className="p-1.5 text-theme-text-secondary border border-theme-border-light rounded-lg hover:text-red-400 hover:border-red-400/50 hover:bg-red-500/10 transition-colors"
|
|
674
694
|
>
|
|
675
|
-
<Trash2 className="w-3.5 h-3.5" />
|
|
695
|
+
{isDeleting ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
|
|
676
696
|
</button>
|
|
677
697
|
</Tooltip>
|
|
678
698
|
)}
|
|
@@ -684,9 +704,11 @@ export function ResourceActionsBar({
|
|
|
684
704
|
onClose={() => setShowDeleteConfirm(false)}
|
|
685
705
|
onConfirm={handleDeleteConfirm}
|
|
686
706
|
resourceName={resource.name}
|
|
707
|
+
resourceData={data}
|
|
687
708
|
resourceKind={displayKindName(resource.kind, data?.kind)}
|
|
688
709
|
namespaceName={resource.namespace}
|
|
689
710
|
isLoading={isDeleting ?? false}
|
|
711
|
+
cascadeDetail={cascadeDetail}
|
|
690
712
|
cascadeDependents={cascadeDependents}
|
|
691
713
|
cascadeLoading={cascadeLoading}
|
|
692
714
|
cascadeRootResolved={cascadeRootResolved}
|
|
@@ -1087,3 +1087,52 @@ it('uses RayJob native detail only for the supported exact API', () => {
|
|
|
1087
1087
|
if (apiVersion !== 'ray.io/v1') { expect(html).toContain('native-fallback'); expect(getResourceStatus('rayjobs', data)).toBeNull() }
|
|
1088
1088
|
}
|
|
1089
1089
|
})
|
|
1090
|
+
|
|
1091
|
+
describe('API group identity', () => {
|
|
1092
|
+
it.each([
|
|
1093
|
+
['applications', 'app.k8s.io/v1beta1', 'Application'],
|
|
1094
|
+
['applications', 'notargoproj.io/v1alpha1', 'Application'],
|
|
1095
|
+
['services', 'vendor.serving.knative.dev/v1', 'Service'],
|
|
1096
|
+
['ingresses', 'vendor.networking.internal.knative.dev/v1alpha1', 'Ingress'],
|
|
1097
|
+
['certificates', 'vendor.networking.internal.knative.dev/v1alpha1', 'Certificate'],
|
|
1098
|
+
['gateways', 'vendor.networking.istio.io/v1', 'Gateway'],
|
|
1099
|
+
])('keeps %s in %s generic', (plural, apiVersion, kind) => {
|
|
1100
|
+
const data = { apiVersion, kind, metadata: { name: 'foreign', namespace: 'test' }, spec: { vendorSetting: 'custom-value' }, status: { phase: 'VendorReady' } }
|
|
1101
|
+
expect(renderKind(plural, data, 'test')).toContain('custom-value')
|
|
1102
|
+
expect(getResourceStatus(plural, data)?.text).toBe('VendorReady')
|
|
1103
|
+
})
|
|
1104
|
+
|
|
1105
|
+
it('retains Argo CD status for its exact Application group', () => {
|
|
1106
|
+
const data = { apiVersion: 'argoproj.io/v1alpha1', kind: 'Application', metadata: { name: 'real' }, status: { sync: { status: 'Synced' }, health: { status: 'Healthy' } } }
|
|
1107
|
+
expect(renderKind('applications', data)).toContain('Synced')
|
|
1108
|
+
expect(getResourceStatus('applications', data)?.text).toBe('Healthy')
|
|
1109
|
+
})
|
|
1110
|
+
|
|
1111
|
+
it('retains Knative Service status and core Service presentation', () => {
|
|
1112
|
+
expect(getResourceStatus('services', { apiVersion: 'serving.knative.dev/v1', status: { conditions: [{ type: 'Ready', status: 'True' }] } })?.text).toBe('Ready')
|
|
1113
|
+
expect(renderKind('services', { apiVersion: 'v1', metadata: { name: 'core' }, spec: { type: 'ClusterIP', clusterIP: '10.0.0.1' } })).toContain('10.0.0.1')
|
|
1114
|
+
})
|
|
1115
|
+
})
|
|
1116
|
+
|
|
1117
|
+
describe('Flux kind collisions', () => {
|
|
1118
|
+
it.each([
|
|
1119
|
+
['gitrepositories', 'source.toolkit.fluxcd.io'],
|
|
1120
|
+
['ocirepositories', 'source.toolkit.fluxcd.io'],
|
|
1121
|
+
['helmrepositories', 'source.toolkit.fluxcd.io'],
|
|
1122
|
+
['kustomizations', 'kustomize.toolkit.fluxcd.io'],
|
|
1123
|
+
['helmreleases', 'helm.toolkit.fluxcd.io'],
|
|
1124
|
+
['alerts', 'notification.toolkit.fluxcd.io'],
|
|
1125
|
+
])('keeps foreign %s generic and preserves its real Flux renderer', (kind, group) => {
|
|
1126
|
+
const resource = {
|
|
1127
|
+
apiVersion: `${group}/v1`, metadata: { name: 'example', namespace: 'test' },
|
|
1128
|
+
spec: { vendorSetting: 'custom-value' }, status: { phase: 'VendorReady' },
|
|
1129
|
+
}
|
|
1130
|
+
expect(renderKind(kind, resource, 'test')).not.toContain('custom-value')
|
|
1131
|
+
expect(getResourceStatus(kind, resource)?.text).not.toBe('VendorReady')
|
|
1132
|
+
for (const apiVersion of ['vendor.io/v1', `foreign.${group}/v1`, 'argoproj.io/v1alpha1']) {
|
|
1133
|
+
const foreign = { ...resource, apiVersion }
|
|
1134
|
+
expect(renderKind(kind, foreign, 'test')).toContain('custom-value')
|
|
1135
|
+
expect(getResourceStatus(kind, foreign)?.text).toBe('VendorReady')
|
|
1136
|
+
}
|
|
1137
|
+
})
|
|
1138
|
+
})
|