@skyhook-io/k8s-ui 1.7.7 → 1.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -1
- package/src/components/charts/PrometheusChartsView.tsx +233 -0
- package/src/components/charts/index.ts +13 -0
- package/src/components/checks/ChecksView.tsx +5 -1
- package/src/components/gitops/GitOpsTableView.tsx +1 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +3 -3
- package/src/components/issues/IssuesView.tsx +352 -0
- package/src/components/issues/index.ts +24 -0
- package/src/components/issues/issues.test.ts +100 -0
- package/src/components/issues/severity.ts +125 -0
- package/src/components/issues/types.ts +194 -0
- package/src/components/resources/ResourcesView.tsx +2 -19
- package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +1 -1
- package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +1 -1
- package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +1 -1
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -1
- package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
- package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +5 -5
- package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +1 -1
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -1
- package/src/components/resources/renderers/PodRenderer.test.tsx +53 -0
- package/src/components/resources/renderers/PodRenderer.tsx +7 -1
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -1
- package/src/components/resources/renderers/WorkloadRenderer.test.tsx +41 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +49 -7
- package/src/components/resources/renderers/XRDRenderer.tsx +2 -2
- package/src/components/shared/DetailShell.tsx +107 -0
- package/src/components/shared/ManagedByChip.tsx +149 -16
- package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +13 -1
- package/src/components/shared/index.ts +2 -1
- package/src/components/timeline/TimelineSwimlanes.tsx +1320 -0
- package/src/components/timeline/index.ts +1 -0
- package/src/components/topology/K8sResourceNode.tsx +60 -60
- package/src/components/topology/TopologyFilterSidebar.tsx +25 -3
- package/src/components/topology/TopologyGraph.tsx +168 -52
- package/src/components/topology/TopologySearch.tsx +17 -8
- package/src/components/topology/topology-search-match.test.ts +1 -1
- package/src/components/topology/topology.css +18 -0
- package/src/components/ui/Tooltip.tsx +37 -2
- package/src/components/workload/WorkloadView.tsx +118 -112
- package/src/index.ts +5 -0
- package/src/theme/components.css +16 -0
- package/src/types/core.ts +3 -2
- package/src/utils/env-from.ts +3 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/replica-scalers.ts +9 -0
- package/src/components/resources/resources-search-sidebar-hint.test.ts +0 -85
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { PodRenderer } from './PodRenderer'
|
|
4
|
+
import { resolvedEnvFromKey } from '../../../utils/env-from'
|
|
5
|
+
import type { ResolvedEnvFrom } from '../../../types'
|
|
6
|
+
|
|
7
|
+
const pod = {
|
|
8
|
+
metadata: { name: 'api', namespace: 'default' },
|
|
9
|
+
spec: {
|
|
10
|
+
containers: [{
|
|
11
|
+
name: 'api',
|
|
12
|
+
image: 'example/api:latest',
|
|
13
|
+
envFrom: [
|
|
14
|
+
{ configMapRef: { name: 'shared' } },
|
|
15
|
+
{ secretRef: { name: 'shared' } },
|
|
16
|
+
],
|
|
17
|
+
}],
|
|
18
|
+
},
|
|
19
|
+
status: { phase: 'Running' },
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('PodRenderer envFrom expansion', () => {
|
|
23
|
+
it('keeps same-name ConfigMap and Secret values separate', () => {
|
|
24
|
+
const resolvedEnvFrom: ResolvedEnvFrom = {
|
|
25
|
+
[resolvedEnvFromKey('configmap', 'shared')]: {
|
|
26
|
+
keys: ['PUBLIC_URL'],
|
|
27
|
+
values: { PUBLIC_URL: 'https://example.com' },
|
|
28
|
+
isSecret: false,
|
|
29
|
+
},
|
|
30
|
+
[resolvedEnvFromKey('secret', 'shared')]: {
|
|
31
|
+
keys: ['API_TOKEN'],
|
|
32
|
+
values: { API_TOKEN: 'secret-value' },
|
|
33
|
+
isSecret: true,
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const html = renderToString(
|
|
38
|
+
<PodRenderer
|
|
39
|
+
data={pod}
|
|
40
|
+
onCopy={() => undefined}
|
|
41
|
+
copied={null}
|
|
42
|
+
resolvedEnvFrom={resolvedEnvFrom}
|
|
43
|
+
/>,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
expect(html).toContain('ConfigMap')
|
|
47
|
+
expect(html).toContain('PUBLIC_URL')
|
|
48
|
+
expect(html).toContain('https://example.com')
|
|
49
|
+
expect(html).toContain('Secret')
|
|
50
|
+
expect(html).toContain('API_TOKEN')
|
|
51
|
+
expect(html).not.toContain('PUBLIC_URL<!-- -->=')
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
rbacResourceBadgeClass,
|
|
10
10
|
rbacApiGroupBadgeClass,
|
|
11
11
|
} from '../../../utils/rbac-badges'
|
|
12
|
+
import { resolvedEnvFromKey } from '../../../utils/env-from'
|
|
12
13
|
import { detectBlastRadius, rulePermissivenessScore } from '../../../utils/rbac-blast-radius'
|
|
13
14
|
import { RBACErrorSection, isRBACUnavailable } from './RBACErrorSection'
|
|
14
15
|
import type { ResolvedEnvFrom, RBACSubjectResponse, RBACPolicyRule } from '../../../types'
|
|
@@ -195,7 +196,12 @@ function EnvVarsSection({
|
|
|
195
196
|
const isSecret = !!ef.secretRef
|
|
196
197
|
const sourceName = ef.configMapRef?.name ?? ef.secretRef?.name ?? 'unknown'
|
|
197
198
|
const prefix = ef.configMapRef ? 'ConfigMap' : ef.secretRef ? 'Secret' : 'Source'
|
|
198
|
-
const
|
|
199
|
+
const sourceKey = ef.configMapRef
|
|
200
|
+
? resolvedEnvFromKey('configmap', sourceName)
|
|
201
|
+
: ef.secretRef
|
|
202
|
+
? resolvedEnvFromKey('secret', sourceName)
|
|
203
|
+
: undefined
|
|
204
|
+
const resolved = sourceKey ? resolvedEnvFrom?.[sourceKey] : undefined
|
|
199
205
|
return (
|
|
200
206
|
<div key={i} className="mb-1">
|
|
201
207
|
<div className="flex items-center gap-1.5 text-xs font-mono py-0.5">
|
|
@@ -161,7 +161,7 @@ export function VeleroBackupRenderer({ data }: VeleroBackupRendererProps) {
|
|
|
161
161
|
)}
|
|
162
162
|
{data.spec?.labelSelector && (
|
|
163
163
|
<Property label="Label Selector" value={
|
|
164
|
-
<span className="
|
|
164
|
+
<span className="inline-code break-all">
|
|
165
165
|
{JSON.stringify(data.spec.labelSelector)}
|
|
166
166
|
</span>
|
|
167
167
|
} />
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { WorkloadRenderer } from './WorkloadRenderer'
|
|
4
|
+
|
|
5
|
+
const deployment = {
|
|
6
|
+
metadata: { name: 'api', namespace: 'prod' },
|
|
7
|
+
spec: { replicas: 3 },
|
|
8
|
+
status: { readyReplicas: 3, availableReplicas: 3, updatedReplicas: 3 },
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('WorkloadRenderer', () => {
|
|
12
|
+
it('enables manual scaling when no replica controller targets the workload', () => {
|
|
13
|
+
const html = renderToString(
|
|
14
|
+
<WorkloadRenderer kind="deployments" data={deployment} onScale={async () => {}} />,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
expect(html).toContain('Scale')
|
|
18
|
+
expect(html).not.toContain('disabled=""')
|
|
19
|
+
expect(html).not.toContain('Manual scaling is disabled')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('disables manual scaling when an HPA or KEDA ScaledObject owns replicas', () => {
|
|
23
|
+
const html = renderToString(
|
|
24
|
+
<WorkloadRenderer
|
|
25
|
+
kind="deployments"
|
|
26
|
+
data={deployment}
|
|
27
|
+
onScale={async () => {}}
|
|
28
|
+
scaleBlockedBy={[
|
|
29
|
+
{ kind: 'HorizontalPodAutoscaler', namespace: 'prod', name: 'api' },
|
|
30
|
+
{ kind: 'ScaledObject', namespace: 'prod', name: 'api-queue' },
|
|
31
|
+
]}
|
|
32
|
+
/>,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
expect(html).toContain('disabled=""')
|
|
36
|
+
expect(html).toContain('Manual scaling is disabled')
|
|
37
|
+
expect(html).toContain('Controlled by')
|
|
38
|
+
expect(html).toContain('HorizontalPodAutoscaler prod/api')
|
|
39
|
+
expect(html).toContain('ScaledObject prod/api-queue')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react'
|
|
2
2
|
import { Server, ExternalLink, Scale, Minus, Plus, Loader2, Shield } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
|
-
import { Section, PropertyList, Property, ConditionsSection, PodTemplateSection, AlertBanner, ResourceLink } from '../../ui/drawer-components'
|
|
4
|
+
import { Section, PropertyList, Property, ConditionsSection, PodTemplateSection, AlertBanner, ResourceLink, ResourceRefBadge } from '../../ui/drawer-components'
|
|
5
5
|
import { DialogPortal } from '../../ui/DialogPortal'
|
|
6
|
-
import
|
|
6
|
+
import { Tooltip } from '../../ui/Tooltip'
|
|
7
|
+
import type { RBACSubjectResponse, RBACPolicyRule, ResourceRef } from '../../../types'
|
|
7
8
|
import { detectBlastRadius, rulePermissivenessScore } from '../../../utils/rbac-blast-radius'
|
|
8
9
|
import { RBACErrorSection, isRBACUnavailable } from './RBACErrorSection'
|
|
9
10
|
import {
|
|
@@ -19,6 +20,7 @@ interface WorkloadRendererProps {
|
|
|
19
20
|
onViewPods?: () => void
|
|
20
21
|
onScale?: (replicas: number) => Promise<void>
|
|
21
22
|
isScalePending?: boolean
|
|
23
|
+
scaleBlockedBy?: ResourceRef[]
|
|
22
24
|
onRequestRefresh?: () => void
|
|
23
25
|
/**
|
|
24
26
|
* RBAC reverse-lookup for the workload's pod-template ServiceAccount.
|
|
@@ -103,14 +105,23 @@ function getWorkloadProgress(status: any, spec: any, kind: string): string | nul
|
|
|
103
105
|
return null
|
|
104
106
|
}
|
|
105
107
|
|
|
106
|
-
|
|
108
|
+
function formatScalerLabel(ref: ResourceRef): string {
|
|
109
|
+
const prefix = ref.namespace ? `${ref.namespace}/` : ''
|
|
110
|
+
return `${ref.kind} ${prefix}${ref.name}`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale, isScalePending, scaleBlockedBy, onRequestRefresh, rbacData, rbacLoading, rbacError }: WorkloadRendererProps) {
|
|
107
114
|
const status = data.status || {}
|
|
108
115
|
const spec = data.spec || {}
|
|
109
116
|
const metadata = data.metadata || {}
|
|
110
117
|
|
|
111
118
|
const isDaemonSet = kind === 'daemonsets'
|
|
112
119
|
const isStatefulSet = kind === 'statefulsets'
|
|
113
|
-
const
|
|
120
|
+
const isScalableKind = kind === 'deployments' || kind === 'statefulsets'
|
|
121
|
+
const isScaleBlocked = !!scaleBlockedBy?.length
|
|
122
|
+
const isScalable = isScalableKind && !!onScale && !isScaleBlocked
|
|
123
|
+
const scaleBlockedLabel = scaleBlockedBy?.map(formatScalerLabel).join(', ')
|
|
124
|
+
const scaleBlockedReason = `Manual scaling is disabled because replicas are controlled by ${scaleBlockedLabel}. Manage scaling there instead.`
|
|
114
125
|
|
|
115
126
|
// Scale dialog state
|
|
116
127
|
const [showScaleDialog, setShowScaleDialog] = useState(false)
|
|
@@ -139,8 +150,14 @@ export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale,
|
|
|
139
150
|
return () => clearInterval(interval)
|
|
140
151
|
}, [isScaling, onRequestRefresh])
|
|
141
152
|
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (isScaleBlocked) {
|
|
155
|
+
setShowScaleDialog(false)
|
|
156
|
+
}
|
|
157
|
+
}, [isScaleBlocked])
|
|
158
|
+
|
|
142
159
|
const handleScale = async () => {
|
|
143
|
-
if (!onScale) return
|
|
160
|
+
if (!onScale || isScaleBlocked) return
|
|
144
161
|
try {
|
|
145
162
|
await onScale(targetReplicas)
|
|
146
163
|
setScaledTo(targetReplicas)
|
|
@@ -202,6 +219,18 @@ export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale,
|
|
|
202
219
|
) : (
|
|
203
220
|
<>
|
|
204
221
|
<Property label="Replicas" value={`${status.readyReplicas || 0}/${spec.replicas || 0}`} />
|
|
222
|
+
{scaleBlockedBy && scaleBlockedBy.length > 0 && (
|
|
223
|
+
<Property
|
|
224
|
+
label="Controlled by"
|
|
225
|
+
value={
|
|
226
|
+
<div className="flex flex-wrap gap-1">
|
|
227
|
+
{scaleBlockedBy.map((ref) => (
|
|
228
|
+
<ResourceRefBadge key={`${ref.kind}/${ref.namespace}/${ref.name}`} resourceRef={ref} onClick={onNavigate} />
|
|
229
|
+
))}
|
|
230
|
+
</div>
|
|
231
|
+
}
|
|
232
|
+
/>
|
|
233
|
+
)}
|
|
205
234
|
<Property label="Updated" value={status.updatedReplicas} />
|
|
206
235
|
<Property label="Available" value={status.availableReplicas} />
|
|
207
236
|
<Property label="Unavailable" value={status.unavailableReplicas} />
|
|
@@ -218,7 +247,7 @@ export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale,
|
|
|
218
247
|
View Managed Pods
|
|
219
248
|
</button>
|
|
220
249
|
)}
|
|
221
|
-
{isScalable
|
|
250
|
+
{isScalable ? (
|
|
222
251
|
<button
|
|
223
252
|
onClick={openScaleDialog}
|
|
224
253
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-emerald-400 hover:text-emerald-300 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 rounded transition-colors"
|
|
@@ -226,7 +255,20 @@ export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale,
|
|
|
226
255
|
<Scale className="w-3 h-3" />
|
|
227
256
|
Scale
|
|
228
257
|
</button>
|
|
229
|
-
)
|
|
258
|
+
) : isScalableKind && !!onScale && isScaleBlocked ? (
|
|
259
|
+
<Tooltip content={scaleBlockedReason}>
|
|
260
|
+
<button
|
|
261
|
+
type="button"
|
|
262
|
+
disabled
|
|
263
|
+
title={scaleBlockedReason}
|
|
264
|
+
aria-label={scaleBlockedReason}
|
|
265
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-theme-text-tertiary bg-theme-elevated border border-theme-border rounded cursor-not-allowed"
|
|
266
|
+
>
|
|
267
|
+
<Scale className="w-3 h-3" />
|
|
268
|
+
Scale
|
|
269
|
+
</button>
|
|
270
|
+
</Tooltip>
|
|
271
|
+
) : null}
|
|
230
272
|
</div>
|
|
231
273
|
</Section>
|
|
232
274
|
|
|
@@ -51,9 +51,9 @@ export function XRDRenderer({ data, onNavigate }: XRDRendererProps) {
|
|
|
51
51
|
<Property label="Singular" value={<span className="font-mono">{names.singular}</span>} />
|
|
52
52
|
)}
|
|
53
53
|
{names.listKind && (
|
|
54
|
-
<Property label="List Kind" value={<span className="
|
|
54
|
+
<Property label="List Kind" value={<span className="inline-code">{names.listKind}</span>} />
|
|
55
55
|
)}
|
|
56
|
-
<Property label="Group" value={<span className="
|
|
56
|
+
<Property label="Group" value={<span className="inline-code break-all">{group}</span>} />
|
|
57
57
|
{scope && (
|
|
58
58
|
<Property
|
|
59
59
|
label="Scope"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
|
|
4
|
+
// Shared tabbed detail chrome. Hosts provide all data-aware pieces; this owns
|
|
5
|
+
// only the header, tab strip, optional controls, overlay, and content frame.
|
|
6
|
+
export interface DetailShellTab<TId extends string = string> {
|
|
7
|
+
id: TId
|
|
8
|
+
label: string
|
|
9
|
+
icon?: ReactNode
|
|
10
|
+
/** Trailing adornment after the label, e.g. an event count badge. */
|
|
11
|
+
badge?: ReactNode
|
|
12
|
+
/** Omit the tab from the strip without disturbing the others' identity. */
|
|
13
|
+
hidden?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DetailShellProps<TId extends string = string> {
|
|
17
|
+
/**
|
|
18
|
+
* A thin breadcrumb line above the identity header (the parent path —
|
|
19
|
+
* ends at the current entity's parent, since the identity title already
|
|
20
|
+
* names the entity). Hosted surfaces use this instead of `nav`.
|
|
21
|
+
*/
|
|
22
|
+
breadcrumb?: ReactNode
|
|
23
|
+
/** Inline leading control on the identity row — a back button in standalone Radar. */
|
|
24
|
+
nav?: ReactNode
|
|
25
|
+
identity: ReactNode
|
|
26
|
+
headerActions?: ReactNode
|
|
27
|
+
scopeControls?: ReactNode
|
|
28
|
+
tabs: DetailShellTab<TId>[]
|
|
29
|
+
activeTab: TId
|
|
30
|
+
onTabChange: (id: TId) => void
|
|
31
|
+
tabStripEnd?: ReactNode
|
|
32
|
+
overlay?: ReactNode
|
|
33
|
+
children: ReactNode
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function DetailShell<TId extends string = string>({
|
|
37
|
+
breadcrumb,
|
|
38
|
+
nav,
|
|
39
|
+
identity,
|
|
40
|
+
headerActions,
|
|
41
|
+
scopeControls,
|
|
42
|
+
tabs,
|
|
43
|
+
activeTab,
|
|
44
|
+
onTabChange,
|
|
45
|
+
tabStripEnd,
|
|
46
|
+
overlay,
|
|
47
|
+
children,
|
|
48
|
+
}: DetailShellProps<TId>) {
|
|
49
|
+
const visibleTabs = tabs.filter((t) => !t.hidden)
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="flex flex-col h-full w-full bg-theme-surface">
|
|
53
|
+
{/* Header */}
|
|
54
|
+
<div className="shrink-0 border-b border-theme-border bg-theme-surface">
|
|
55
|
+
{breadcrumb && <div className="px-6 pt-2.5">{breadcrumb}</div>}
|
|
56
|
+
<div className={clsx('px-6 flex items-start gap-4', breadcrumb ? 'pb-3 pt-1.5' : 'py-3')}>
|
|
57
|
+
{nav}
|
|
58
|
+
<div className="flex-1 min-w-0">{identity}</div>
|
|
59
|
+
{headerActions}
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
{/* Tabs (left) + scope controls / actions (right) */}
|
|
63
|
+
<div className="px-6 flex items-center border-t border-theme-border">
|
|
64
|
+
<div className="flex gap-1" role="tablist">
|
|
65
|
+
{visibleTabs.map((t) => (
|
|
66
|
+
<DetailShellTabButton key={t.id} active={activeTab === t.id} onClick={() => onTabChange(t.id)}>
|
|
67
|
+
{t.icon}
|
|
68
|
+
{t.label}
|
|
69
|
+
{t.badge}
|
|
70
|
+
</DetailShellTabButton>
|
|
71
|
+
))}
|
|
72
|
+
</div>
|
|
73
|
+
{(scopeControls || tabStripEnd) && (
|
|
74
|
+
<div className="ml-auto flex items-center gap-2">
|
|
75
|
+
{scopeControls}
|
|
76
|
+
{tabStripEnd}
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
{overlay}
|
|
83
|
+
|
|
84
|
+
{/* Tab content */}
|
|
85
|
+
<div className="flex-1 overflow-hidden relative">{children}</div>
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function DetailShellTabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) {
|
|
91
|
+
return (
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
role="tab"
|
|
95
|
+
aria-selected={active}
|
|
96
|
+
onClick={onClick}
|
|
97
|
+
className={clsx(
|
|
98
|
+
'flex items-center gap-1.5 px-3 py-2 text-sm font-medium border-b-2 transition-colors',
|
|
99
|
+
active
|
|
100
|
+
? 'text-theme-text-primary border-skyhook-500'
|
|
101
|
+
: 'text-theme-text-secondary border-transparent hover:text-theme-text-primary hover:border-theme-border-light',
|
|
102
|
+
)}
|
|
103
|
+
>
|
|
104
|
+
{children}
|
|
105
|
+
</button>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import { GitBranch } from 'lucide-react'
|
|
1
|
+
import { GitBranch, Package } from 'lucide-react'
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import type { GitOpsOwnerRef } from '../../utils/gitops-owner'
|
|
4
|
+
import type { GitOpsStatus } from '../../types/gitops'
|
|
5
|
+
import { StatusDot, type StatusTone } from '../ui/status-tone'
|
|
6
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
7
|
+
|
|
8
|
+
export type HelmOwnerRef = {
|
|
9
|
+
namespace: string
|
|
10
|
+
name: string
|
|
11
|
+
}
|
|
4
12
|
|
|
5
13
|
// ManagedByChip renders the "Managed by <ArgoCD/FluxCD app>" affordance for
|
|
6
14
|
// resources detected (via labels/annotations) to be GitOps-managed. The chip
|
|
@@ -13,33 +21,158 @@ import type { GitOpsOwnerRef } from '../../utils/gitops-owner'
|
|
|
13
21
|
// - block: starts a new line with mt-1 spacing, used in WorkloadView title strip
|
|
14
22
|
export function ManagedByChip({
|
|
15
23
|
owner,
|
|
24
|
+
status,
|
|
16
25
|
onOpen,
|
|
26
|
+
verified = true,
|
|
27
|
+
pending = false,
|
|
28
|
+
source,
|
|
17
29
|
variant = 'inline',
|
|
18
30
|
}: {
|
|
19
31
|
owner: GitOpsOwnerRef
|
|
32
|
+
status?: GitOpsStatus | null
|
|
20
33
|
onOpen?: (ref: GitOpsOwnerRef) => void
|
|
34
|
+
verified?: boolean
|
|
35
|
+
pending?: boolean
|
|
36
|
+
source?: string | null
|
|
21
37
|
variant?: 'inline' | 'block'
|
|
22
38
|
}) {
|
|
23
39
|
const toolLabel = owner.tool === 'argocd' ? 'ArgoCD' : 'FluxCD'
|
|
40
|
+
const ownerKindLabel = gitOpsOwnerKindLabel(owner)
|
|
24
41
|
const label = owner.namespace ? `${owner.namespace}/${owner.name}` : owner.name
|
|
25
|
-
const
|
|
42
|
+
const statusLabel = status ? gitOpsStatusLabel(status) : null
|
|
43
|
+
const prefix = pending ? 'Resolving' : verified ? 'Managed by' : 'Tracked by'
|
|
44
|
+
const tooltipContent = (
|
|
45
|
+
<span className="flex flex-col gap-1">
|
|
46
|
+
<span>
|
|
47
|
+
{pending ? (
|
|
48
|
+
<>
|
|
49
|
+
Looking up matching {ownerKindLabel} <TooltipCode>{label}</TooltipCode> in this cluster.
|
|
50
|
+
</>
|
|
51
|
+
) : verified ? (
|
|
52
|
+
<>
|
|
53
|
+
Managed by {toolLabel} · <TooltipCode>{label}</TooltipCode>
|
|
54
|
+
{statusLabel && <> · {statusLabel}{status?.message ? `: ${status.message}` : ''}</>}
|
|
55
|
+
</>
|
|
56
|
+
) : (
|
|
57
|
+
<>
|
|
58
|
+
{toolLabel} tracking metadata references <TooltipCode>{label}</TooltipCode>, but the matching {ownerKindLabel} is not visible in this cluster.
|
|
59
|
+
</>
|
|
60
|
+
)}
|
|
61
|
+
</span>
|
|
62
|
+
{source && (
|
|
63
|
+
<span>
|
|
64
|
+
Inferred from <TooltipCode>{source}</TooltipCode>.
|
|
65
|
+
</span>
|
|
66
|
+
)}
|
|
67
|
+
</span>
|
|
68
|
+
)
|
|
26
69
|
const interactive = !!onOpen
|
|
27
70
|
const Wrapper = interactive ? 'button' : 'span'
|
|
28
71
|
return (
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
72
|
+
<Tooltip content={tooltipContent} delay={500} position="bottom" wrapperClassName={variant === 'block' ? 'mt-1' : undefined}>
|
|
73
|
+
<Wrapper
|
|
74
|
+
{...(interactive
|
|
75
|
+
? { type: 'button' as const, onClick: () => onOpen?.(owner) }
|
|
76
|
+
: {})}
|
|
77
|
+
className={clsx(
|
|
78
|
+
'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',
|
|
79
|
+
interactive && 'hover:border-skyhook-500/60 hover:text-skyhook-500 transition-colors',
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
<GitBranch className="h-3 w-3 shrink-0" />
|
|
83
|
+
<span className="shrink-0 text-theme-text-tertiary">{prefix}</span>
|
|
84
|
+
<span className="truncate max-w-[180px]">{label}</span>
|
|
85
|
+
{statusLabel && (
|
|
86
|
+
<>
|
|
87
|
+
<span className="mx-0.5 h-3 w-px shrink-0 bg-theme-border" aria-hidden />
|
|
88
|
+
<span className="inline-flex shrink-0 items-center gap-1 text-theme-text-tertiary">
|
|
89
|
+
<StatusDot tone={gitOpsStatusTone(status!)} size="xs" />
|
|
90
|
+
<span>{statusLabel}</span>
|
|
91
|
+
</span>
|
|
92
|
+
</>
|
|
93
|
+
)}
|
|
94
|
+
</Wrapper>
|
|
95
|
+
</Tooltip>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function HelmManagedByChip({
|
|
100
|
+
owner,
|
|
101
|
+
onOpen,
|
|
102
|
+
source,
|
|
103
|
+
variant = 'inline',
|
|
104
|
+
}: {
|
|
105
|
+
owner: HelmOwnerRef
|
|
106
|
+
onOpen?: (ref: HelmOwnerRef) => void
|
|
107
|
+
source?: string | null
|
|
108
|
+
variant?: 'inline' | 'block'
|
|
109
|
+
}) {
|
|
110
|
+
const label = owner.namespace ? `${owner.namespace}/${owner.name}` : owner.name
|
|
111
|
+
const tooltipContent = (
|
|
112
|
+
<span className="flex flex-col gap-1">
|
|
113
|
+
<span>
|
|
114
|
+
Managed by Helm release <TooltipCode>{label}</TooltipCode>.
|
|
115
|
+
</span>
|
|
116
|
+
{source && (
|
|
117
|
+
<span>
|
|
118
|
+
Inferred from <TooltipCode>{source}</TooltipCode>.
|
|
119
|
+
</span>
|
|
38
120
|
)}
|
|
39
|
-
>
|
|
40
|
-
<GitBranch className="h-3 w-3 shrink-0" />
|
|
41
|
-
<span className="shrink-0 text-theme-text-tertiary">Managed by</span>
|
|
42
|
-
<span className="truncate max-w-[180px]">{label}</span>
|
|
43
|
-
</Wrapper>
|
|
121
|
+
</span>
|
|
44
122
|
)
|
|
123
|
+
const interactive = !!onOpen
|
|
124
|
+
const Wrapper = interactive ? 'button' : 'span'
|
|
125
|
+
return (
|
|
126
|
+
<Tooltip content={tooltipContent} delay={500} position="bottom" wrapperClassName={variant === 'block' ? 'mt-1' : undefined}>
|
|
127
|
+
<Wrapper
|
|
128
|
+
{...(interactive
|
|
129
|
+
? { type: 'button' as const, onClick: () => onOpen?.(owner) }
|
|
130
|
+
: {})}
|
|
131
|
+
className={clsx(
|
|
132
|
+
'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',
|
|
133
|
+
interactive && 'hover:border-skyhook-500/60 hover:text-skyhook-500 transition-colors',
|
|
134
|
+
)}
|
|
135
|
+
>
|
|
136
|
+
<Package className="h-3 w-3 shrink-0" />
|
|
137
|
+
<span className="shrink-0 text-theme-text-tertiary">Managed by Helm</span>
|
|
138
|
+
<span className="truncate max-w-[180px]">{label}</span>
|
|
139
|
+
</Wrapper>
|
|
140
|
+
</Tooltip>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function gitOpsOwnerKindLabel(owner: GitOpsOwnerRef): string {
|
|
145
|
+
if (owner.tool === 'argocd') return 'Application'
|
|
146
|
+
if (owner.kind === 'helmreleases') return 'HelmRelease'
|
|
147
|
+
if (owner.kind === 'kustomizations') return 'Kustomization'
|
|
148
|
+
return 'GitOps resource'
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function TooltipCode({ children }: { children: string }) {
|
|
152
|
+
return (
|
|
153
|
+
<code className="inline-code break-all">
|
|
154
|
+
{children}
|
|
155
|
+
</code>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function gitOpsStatusLabel(status: GitOpsStatus): string {
|
|
160
|
+
if (status.suspended) return 'Suspended'
|
|
161
|
+
if (status.sync === 'Reconciling') return 'Syncing'
|
|
162
|
+
if (status.health === 'Degraded') return 'Degraded'
|
|
163
|
+
if (status.health === 'Missing') return 'Missing'
|
|
164
|
+
if (status.sync === 'OutOfSync') return 'OutOfSync'
|
|
165
|
+
if (status.health === 'Progressing') return 'Progressing'
|
|
166
|
+
if (status.sync === 'Synced' && status.health === 'Healthy') return 'Synced'
|
|
167
|
+
if (status.sync !== 'Unknown') return status.sync
|
|
168
|
+
return status.health
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function gitOpsStatusTone(status: GitOpsStatus): StatusTone {
|
|
172
|
+
if (status.suspended) return 'degraded'
|
|
173
|
+
if (status.sync === 'Synced' && status.health === 'Healthy') return 'healthy'
|
|
174
|
+
if (status.health === 'Degraded' || status.health === 'Missing') return 'unhealthy'
|
|
175
|
+
if (status.sync === 'OutOfSync') return 'degraded'
|
|
176
|
+
if (status.sync === 'Reconciling' || status.health === 'Progressing') return 'neutral'
|
|
177
|
+
return 'unknown'
|
|
45
178
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { ResourceRendererDispatch, type RendererOverrides } from './ResourceRendererDispatch'
|
|
4
|
+
import type { ResourceRef } from '../../types'
|
|
5
|
+
|
|
6
|
+
function renderWithScalers(scalers: ResourceRef[]): string {
|
|
7
|
+
const overrides: RendererOverrides = {
|
|
8
|
+
WorkloadRenderer: ({ scaleBlockedBy }) => (
|
|
9
|
+
<span>{scaleBlockedBy?.map((ref) => ref.kind).join(',') || 'none'}</span>
|
|
10
|
+
),
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return renderToString(
|
|
14
|
+
<ResourceRendererDispatch
|
|
15
|
+
resource={{ kind: 'deployments', namespace: 'prod', name: 'api' }}
|
|
16
|
+
data={{ metadata: { name: 'api', namespace: 'prod' } }}
|
|
17
|
+
relationships={{ scalers }}
|
|
18
|
+
onCopy={() => {}}
|
|
19
|
+
copied={null}
|
|
20
|
+
rendererOverrides={overrides}
|
|
21
|
+
showCommonSections={false}
|
|
22
|
+
/>,
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('ResourceRendererDispatch', () => {
|
|
27
|
+
it('blocks manual replica scaling for HPA and KEDA ScaledObject scalers only', () => {
|
|
28
|
+
const html = renderWithScalers([
|
|
29
|
+
{ kind: 'VerticalPodAutoscaler', namespace: 'prod', name: 'api-vpa' },
|
|
30
|
+
{ kind: 'HorizontalPodAutoscaler', namespace: 'prod', name: 'api-hpa' },
|
|
31
|
+
{ kind: 'ScaledObject', namespace: 'prod', name: 'api-keda' },
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
expect(html).toContain('HorizontalPodAutoscaler,ScaledObject')
|
|
35
|
+
expect(html).not.toContain('VerticalPodAutoscaler')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('does not block manual replica scaling for VPA-only relationships', () => {
|
|
39
|
+
const html = renderWithScalers([
|
|
40
|
+
{ kind: 'VerticalPodAutoscaler', namespace: 'prod', name: 'api-vpa' },
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
expect(html).toContain('none')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -220,6 +220,7 @@ import {
|
|
|
220
220
|
import type { SelectedResource, Relationships, ResourceRef, SecretCertificateInfo, ResolvedEnvFrom, TimelineEvent } from '../../types'
|
|
221
221
|
import type { CopyHandler } from '../ui/drawer-components'
|
|
222
222
|
import { AlertBanner } from '../ui/drawer-components'
|
|
223
|
+
import { replicaScalers } from '../../utils/replica-scalers'
|
|
223
224
|
|
|
224
225
|
/**
|
|
225
226
|
* Override map letting each platform consumer swap in its own renderer components.
|
|
@@ -245,6 +246,8 @@ export interface RendererOverrides {
|
|
|
245
246
|
WorkloadRenderer?: React.ComponentType<{
|
|
246
247
|
kind: string; data: any
|
|
247
248
|
onNavigate?: (ref: ResourceRef) => void
|
|
249
|
+
relationships?: Relationships
|
|
250
|
+
scaleBlockedBy?: ResourceRef[]
|
|
248
251
|
}>
|
|
249
252
|
// Optional override for Crossplane Composite / Claim — host wraps the
|
|
250
253
|
// package renderer to fan out per-composed-ref status fetches via React Query.
|
|
@@ -464,6 +467,7 @@ export function ResourceRendererDispatch({
|
|
|
464
467
|
const NamespaceComp = rendererOverrides?.NamespaceRenderer ?? NamespaceRenderer
|
|
465
468
|
const HPAComp = rendererOverrides?.HPARenderer ?? HPARenderer
|
|
466
469
|
const PVCComp = rendererOverrides?.PVCRenderer ?? PVCRenderer
|
|
470
|
+
const scaleBlockedBy = replicaScalers(relationships?.scalers)
|
|
467
471
|
|
|
468
472
|
const sidebarContent = showCommonSections && (
|
|
469
473
|
<>
|
|
@@ -480,7 +484,15 @@ export function ResourceRendererDispatch({
|
|
|
480
484
|
<div className={clsx('p-4 space-y-4', renderSidebar && 'lg:flex-1 lg:min-w-0')}>
|
|
481
485
|
{/* Kind-specific content - delegates to modular renderers */}
|
|
482
486
|
{kind === 'pods' && <PodComp data={data} onCopy={onCopy} copied={copied} onNavigate={onNavigate} onOpenLogs={onOpenLogs} resolvedEnvFrom={resolvedEnvFrom} />}
|
|
483
|
-
{['deployments', 'statefulsets', 'daemonsets'].includes(kind) &&
|
|
487
|
+
{['deployments', 'statefulsets', 'daemonsets'].includes(kind) && (
|
|
488
|
+
<WorkloadComp
|
|
489
|
+
kind={kind}
|
|
490
|
+
data={data}
|
|
491
|
+
onNavigate={onNavigate}
|
|
492
|
+
relationships={relationships}
|
|
493
|
+
scaleBlockedBy={scaleBlockedBy}
|
|
494
|
+
/>
|
|
495
|
+
)}
|
|
484
496
|
{kind === 'replicasets' && <ReplicaSetRenderer data={data} />}
|
|
485
497
|
{kind === 'services' && !data?.apiVersion?.includes('serving.knative.dev') && <ServiceComp data={data} onCopy={onCopy} copied={copied} />}
|
|
486
498
|
{kind === 'ingresses' && !data?.apiVersion?.includes('networking.internal.knative.dev') && <IngressRenderer data={data} onNavigate={onNavigate} />}
|
|
@@ -2,4 +2,5 @@ export { ResourceRendererDispatch, getResourceStatus, type RendererOverrides } f
|
|
|
2
2
|
export { EditableYamlView, SaveSuccessAnimation } from './EditableYamlView'
|
|
3
3
|
export { ResourceActionsBar, RevisionHistoryDialog } from './ResourceActionsBar'
|
|
4
4
|
export { CreateResourceDialog, type CreateResourceDialogProps, type ApplyResult } from './CreateResourceDialog'
|
|
5
|
-
export { ManagedByChip } from './ManagedByChip'
|
|
5
|
+
export { HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from './ManagedByChip'
|
|
6
|
+
export { DetailShell, type DetailShellProps, type DetailShellTab } from './DetailShell'
|