@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.
Files changed (48) hide show
  1. package/package.json +6 -1
  2. package/src/components/charts/PrometheusChartsView.tsx +233 -0
  3. package/src/components/charts/index.ts +13 -0
  4. package/src/components/checks/ChecksView.tsx +5 -1
  5. package/src/components/gitops/GitOpsTableView.tsx +1 -2
  6. package/src/components/gitops/insights/GitOpsInsightViews.tsx +3 -3
  7. package/src/components/issues/IssuesView.tsx +352 -0
  8. package/src/components/issues/index.ts +24 -0
  9. package/src/components/issues/issues.test.ts +100 -0
  10. package/src/components/issues/severity.ts +125 -0
  11. package/src/components/issues/types.ts +194 -0
  12. package/src/components/resources/ResourcesView.tsx +2 -19
  13. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +1 -1
  14. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +1 -1
  15. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +1 -1
  16. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -1
  17. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  18. package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +5 -5
  19. package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +1 -1
  20. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -1
  21. package/src/components/resources/renderers/PodRenderer.test.tsx +53 -0
  22. package/src/components/resources/renderers/PodRenderer.tsx +7 -1
  23. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -1
  24. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +41 -0
  25. package/src/components/resources/renderers/WorkloadRenderer.tsx +49 -7
  26. package/src/components/resources/renderers/XRDRenderer.tsx +2 -2
  27. package/src/components/shared/DetailShell.tsx +107 -0
  28. package/src/components/shared/ManagedByChip.tsx +149 -16
  29. package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
  30. package/src/components/shared/ResourceRendererDispatch.tsx +13 -1
  31. package/src/components/shared/index.ts +2 -1
  32. package/src/components/timeline/TimelineSwimlanes.tsx +1320 -0
  33. package/src/components/timeline/index.ts +1 -0
  34. package/src/components/topology/K8sResourceNode.tsx +60 -60
  35. package/src/components/topology/TopologyFilterSidebar.tsx +25 -3
  36. package/src/components/topology/TopologyGraph.tsx +168 -52
  37. package/src/components/topology/TopologySearch.tsx +17 -8
  38. package/src/components/topology/topology-search-match.test.ts +1 -1
  39. package/src/components/topology/topology.css +18 -0
  40. package/src/components/ui/Tooltip.tsx +37 -2
  41. package/src/components/workload/WorkloadView.tsx +118 -112
  42. package/src/index.ts +5 -0
  43. package/src/theme/components.css +16 -0
  44. package/src/types/core.ts +3 -2
  45. package/src/utils/env-from.ts +3 -0
  46. package/src/utils/index.ts +1 -0
  47. package/src/utils/replica-scalers.ts +9 -0
  48. 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 resolved = resolvedEnvFrom?.[sourceName]
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="text-sm font-mono text-theme-text-secondary break-all">
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 type { RBACSubjectResponse, RBACPolicyRule } from '../../../types'
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
- export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale, isScalePending, onRequestRefresh, rbacData, rbacLoading, rbacError }: WorkloadRendererProps) {
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 isScalable = (kind === 'deployments' || kind === 'statefulsets') && !!onScale
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="font-mono">{names.listKind}</span>} />
54
+ <Property label="List Kind" value={<span className="inline-code">{names.listKind}</span>} />
55
55
  )}
56
- <Property label="Group" value={<span className="font-mono break-all">{group}</span>} />
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 title = `Managed by ${toolLabel} · ${label}`
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
- <Wrapper
30
- {...(interactive
31
- ? { type: 'button' as const, onClick: () => onOpen?.(owner) }
32
- : {})}
33
- title={title}
34
- className={clsx(
35
- 'inline-flex items-center gap-1 rounded border border-theme-border bg-theme-elevated px-1.5 py-0.5 text-[11px] text-theme-text-secondary',
36
- variant === 'block' && 'mt-1',
37
- interactive && 'hover:border-skyhook-500/60 hover:text-skyhook-500 transition-colors',
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) && <WorkloadComp kind={kind} data={data} onNavigate={onNavigate} />}
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'