@skyhook-io/k8s-ui 1.7.7 → 1.7.9

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 +374 -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 +35 -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,194 @@
1
+ // Shared Issues identity contract + data shapes for the live-issues queue.
2
+ //
3
+ // k8s-ui owns these because the Issues queue presentation (IssuesView) is
4
+ // host-agnostic: Radar Hub feeds it fleet-resolved grouped issues, and OSS
5
+ // Radar feeds a single-cluster ("fleet of one") set. Hosts map their wire
6
+ // payloads onto these types; the component renders against them.
7
+ //
8
+ // Mirrors the grouped Issue model radar emits (internal/issues.GroupIssues →
9
+ // /api/issues, and the hub's /api/fleet/issues). IssueResourceRef intentionally
10
+ // matches the Checks queue's contract (components/checks/types.ts) and
11
+ // radar/pkg/audit.ResourceKey — so Issues and Checks share deep-links rather
12
+ // than forking a second convention.
13
+
14
+ /** Operational severity for live issues — distinct from the Checks 4-tier
15
+ * posture ladder on purpose (operational urgency vs compliance risk are
16
+ * separate axes). Matches radar's issues.Severity. */
17
+ export type IssueSeverity = 'critical' | 'warning';
18
+
19
+ /** Ordered worst→least. */
20
+ export const ISSUE_SEVERITIES: IssueSeverity[] = ['critical', 'warning'];
21
+
22
+ export const ISSUE_SEVERITY_RANK: Record<IssueSeverity, number> = {
23
+ critical: 2,
24
+ warning: 1,
25
+ };
26
+
27
+ export function isIssueSeverity(s: string): s is IssueSeverity {
28
+ return s === 'critical' || s === 'warning';
29
+ }
30
+
31
+ /**
32
+ * Canonical resource identity. `group` is '' for the core API group;
33
+ * `namespace` is '' for cluster-scoped resources. `cluster_id` scopes the ref
34
+ * to its source cluster (the hub injects it; single-cluster OSS leaves it
35
+ * undefined). Same shape as Checks' CheckResourceRef so deep-link plumbing is
36
+ * shared.
37
+ */
38
+ export interface IssueResourceRef {
39
+ cluster_id?: string;
40
+ // group/namespace are optional to match the Go wire (omitempty): a
41
+ // cluster-scoped or core-group member (Node, a core/v1 object) arrives
42
+ // without them. Consumers default to '' (subjectRef/memberRef do this).
43
+ group?: string;
44
+ kind: string;
45
+ namespace?: string;
46
+ name: string;
47
+ }
48
+
49
+ /** Rollup of the underlying resources folded into a grouped issue, by kind
50
+ * bucket. Empty for single-resource issues (no fan-out). Mirrors the Go
51
+ * issues.Affected struct. */
52
+ export interface IssueAffected {
53
+ pods?: number;
54
+ workloads?: number;
55
+ services?: number;
56
+ pvcs?: number;
57
+ nodes?: number;
58
+ }
59
+
60
+ /**
61
+ * A grouped live issue — one row of the triage queue. Subject (kind/group/
62
+ * namespace/name) is the topmost owner when the rows folded under a workload,
63
+ * else the resource itself; `members` are the folded underlying resources
64
+ * (the fan-out), bounded inline with `members_truncated`. Mirrors the Go
65
+ * issues.Issue after GroupIssues.
66
+ */
67
+ export interface Issue {
68
+ id: string;
69
+ severity: IssueSeverity;
70
+ /** Detection channel (problem|missing_ref|scheduling|condition) — an output
71
+ * label, not the triage axis. */
72
+ source: string;
73
+ /** Symptom taxonomy (image_pull_failed, crashloop, …) — the triage axis. */
74
+ category: string;
75
+ /** Coarse rollup of category (startup|runtime|networking|…). Server-emitted
76
+ * so the UI never needs its own category→group map. */
77
+ category_group: string;
78
+ /** Subject kind bucket (workload|service|pvc|ingress|node|unknown). */
79
+ grouping_scope: string;
80
+
81
+ // Subject identity (the grouped thing). group is omitted for the core API
82
+ // group, namespace for cluster-scoped subjects — both optional to match the
83
+ // wire (radar emits them omitempty).
84
+ cluster_id?: string;
85
+ cluster_name?: string;
86
+ group?: string;
87
+ kind: string;
88
+ namespace?: string;
89
+ name: string;
90
+
91
+ reason: string;
92
+ message?: string;
93
+ first_seen?: string;
94
+ last_seen?: string;
95
+ /** Affected-resource fan-out, EXCLUDING the subject (the row header).
96
+ * 0/omitted for a single-resource issue; e.g. 50 for one Deployment's
97
+ * 50 crashlooping pods. Exposed to API/MCP/CEL consumers, not just here. */
98
+ count?: number;
99
+
100
+ affected?: IssueAffected;
101
+ members?: IssueResourceRef[];
102
+ members_truncated?: boolean;
103
+
104
+ // Pod crash context carried from the representative member.
105
+ restart_count?: number;
106
+ last_terminated_reason?: string;
107
+ }
108
+
109
+ /** subjectRef builds a deep-linkable ref for an issue's subject — the row's
110
+ * cluster_id threaded onto its group/kind/namespace/name. */
111
+ export function subjectRef(issue: Issue): IssueResourceRef {
112
+ return {
113
+ cluster_id: issue.cluster_id,
114
+ group: issue.group ?? '',
115
+ kind: issue.kind,
116
+ namespace: issue.namespace ?? '',
117
+ name: issue.name,
118
+ };
119
+ }
120
+
121
+ /** memberRef threads the issue's cluster_id onto a member ref (members carry
122
+ * no cluster_id of their own — every member shares the issue's cluster). */
123
+ export function memberRef(issue: Issue, member: IssueResourceRef): IssueResourceRef {
124
+ // Normalize the same wire-omitted optionals subjectRef does: Go's Ref.Group /
125
+ // Ref.Namespace are omitempty, so core-API members (Pods) arrive with group /
126
+ // namespace undefined — left raw they'd interpolate "undefined" into host
127
+ // deep-links / React keys and break callbacks that assume a string.
128
+ return {
129
+ ...member,
130
+ group: member.group ?? '',
131
+ namespace: member.namespace ?? '',
132
+ cluster_id: issue.cluster_id,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * compareIssues is the queue's stable sort order (extracted from IssuesView so
138
+ * it can be unit-tested). Severity first (critical before warning), then ONSET
139
+ * — first_seen DESC, deliberately NOT last_seen: last_seen bumps to compose-time
140
+ * on every poll, so sorting by it would reshuffle same-severity rows on each
141
+ * refetch. The remaining keys (cluster → namespace → name → id) are a fully
142
+ * deterministic tiebreak so the order never churns under auto-refresh.
143
+ */
144
+ export function compareIssues(a: Issue, b: Issue): number {
145
+ const r = ISSUE_SEVERITY_RANK[b.severity] - ISSUE_SEVERITY_RANK[a.severity];
146
+ if (r !== 0) return r;
147
+ const fa = a.first_seen ?? '';
148
+ const fb = b.first_seen ?? '';
149
+ if (fa !== fb) return fb.localeCompare(fa);
150
+ const c = (a.cluster_name ?? '').localeCompare(b.cluster_name ?? '');
151
+ if (c !== 0) return c;
152
+ const ns = (a.namespace ?? '').localeCompare(b.namespace ?? '');
153
+ if (ns !== 0) return ns;
154
+ const nm = a.name.localeCompare(b.name);
155
+ if (nm !== 0) return nm;
156
+ return a.id.localeCompare(b.id);
157
+ }
158
+
159
+ /**
160
+ * normalizeImagePullMessage turns a raw containerd/CRI image-pull error — which
161
+ * is verbose and re-quotes the image ref at every wrapped layer ("Back-off
162
+ * pulling image X: ErrImagePull: rpc error: code = NotFound desc = failed to
163
+ * pull and unpack image X: failed to resolve reference X: X: not found") — into
164
+ * a short headline: cause + the image ref once. Returns null for shapes it
165
+ * doesn't recognize, so the caller falls back to the raw string.
166
+ */
167
+ export function normalizeImagePullMessage(raw: string): string | null {
168
+ if (!raw) return null;
169
+ const ref = raw.match(/image "([^"]+)"/)?.[1];
170
+ const lower = raw.toLowerCase();
171
+ let cause: string | null = null;
172
+ if (/not\s*found|manifest\s*unknown|no such (image|manifest)/.test(lower)) cause = 'Image not found';
173
+ else if (/unauthorized|forbidden|denied|\b401\b|\b403\b|authentication required/.test(lower)) cause = 'Not authorized to pull image';
174
+ else if (/no such host|i\/o timeout|\btimeout\b|connection refused|dial tcp/.test(lower)) cause = 'Registry unreachable';
175
+ else if (/toomanyrequests|too many requests|rate limit/.test(lower)) cause = 'Registry rate-limited';
176
+ if (!cause) return null;
177
+ return ref ? `${cause}: ${ref}` : cause;
178
+ }
179
+
180
+ /**
181
+ * issueMessageParts splits an issue's message into the inline headline and the
182
+ * raw secondary detail. For image-pull issues the headline is a normalized
183
+ * one-liner and detail holds the original CRI string; for every other issue the
184
+ * headline IS the (already concise) message and detail is empty — no
185
+ * duplication. Gated on image-pull so a generic "not found" in, say, a
186
+ * missing_config_ref message ('secret "x" not found') is never mislabeled.
187
+ */
188
+ export function issueMessageParts(issue: Issue): { headline: string; detail: string } {
189
+ const raw = issue.message ?? '';
190
+ const isImagePull = issue.category === 'image_pull_failed' || /ImagePull|ErrImage|InvalidImageName|ImageInspect/i.test(issue.reason ?? '');
191
+ const normalized = isImagePull ? normalizeImagePullMessage(raw) : null;
192
+ if (normalized && normalized !== raw) return { headline: normalized, detail: raw };
193
+ return { headline: raw, detail: '' };
194
+ }
@@ -256,7 +256,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
256
256
  pods: [
257
257
  { key: 'name', label: 'Name' },
258
258
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
259
- { key: 'containers', label: 'Containers', width: 'w-32' },
259
+ { key: 'containers', label: 'Containers', width: 'w-32', tooltip: 'Container readiness; hover each square for name and state' },
260
260
  { key: 'status', label: 'Status', width: 'w-40' },
261
261
  { key: 'cpu', label: 'CPU', width: 'w-40', tooltip: 'CPU usage / limit (marker = request)' },
262
262
  { key: 'memory', label: 'Memory', width: 'w-40', tooltip: 'Memory usage / limit (marker = request)' },
@@ -3996,23 +3996,6 @@ export function ResourcesView({
3996
3996
  <X className="w-3.5 h-3.5" />
3997
3997
  </button>
3998
3998
  )}
3999
- {/*
4000
- The sidebar's count badge shows the cluster-wide
4001
- total for the selected kind (from resourceCounts) and
4002
- deliberately stays unfiltered. When a search yields
4003
- zero results the user can think the badge is lying.
4004
- Spell out that the badge is the cluster total, not
4005
- the filtered count.
4006
- */}
4007
- {searchTerm && (() => {
4008
- const totalForKind = counts[selectedKind.group ? `${selectedKind.group}/${selectedKind.kind}` : selectedKind.kind] ?? 0
4009
- if (totalForKind === 0) return null
4010
- return (
4011
- <p className="text-xs mt-1 text-theme-text-disabled">
4012
- The sidebar shows {pluralize(totalForKind, selectedKind.kind)} in the cluster — the count is unfiltered.
4013
- </p>
4014
- )
4015
- })()}
4016
3999
  {namespaces.length > 0 && <p className="text-sm mt-1 text-theme-text-disabled">Searching in {namespaces.length === 1 ? `namespace: ${namespaces[0]}` : `${namespaces.length} namespaces`}</p>}
4017
4000
  {/* Show active filters as dismissible badges so user can clear them */}
4018
4001
  {(() => {
@@ -4933,7 +4916,7 @@ function PodCell({ resource, column }: { resource: any; column: string }) {
4933
4916
  const squares = getContainerSquareStates(resource)
4934
4917
  const hasInit = squares.some(s => s.isInit)
4935
4918
  return (
4936
- <div className="flex items-center gap-1">
4919
+ <div className="flex w-full items-center justify-center gap-1">
4937
4920
  {squares.map((sq, i) => {
4938
4921
  const showSeparator = hasInit && i > 0 && sq.isInit !== squares[i - 1].isInit
4939
4922
  const bgClass =
@@ -115,7 +115,7 @@ export function CAPIKubeadmControlPlaneRenderer({ data, onNavigate }: Props) {
115
115
 
116
116
  {/* Owned Machines hint */}
117
117
  <div className="px-3 py-1.5 text-xs text-theme-text-tertiary">
118
- Machines with label <code className="bg-theme-surface px-1 py-0.5 rounded text-[10px] font-mono select-all">cluster.x-k8s.io/control-plane-name={data.metadata?.name}</code>
118
+ Machines with label <code className="inline-code text-[10px] select-all">cluster.x-k8s.io/control-plane-name={data.metadata?.name}</code>
119
119
  </div>
120
120
 
121
121
  <ConditionsSection conditions={conditions} />
@@ -119,7 +119,7 @@ export function CAPIMachineDeploymentRenderer({ data, onNavigate }: Props) {
119
119
 
120
120
  {/* Owned Machines hint */}
121
121
  <div className="px-3 py-1.5 text-xs text-theme-text-tertiary">
122
- Machines with label <code className="bg-theme-surface px-1 py-0.5 rounded text-[10px] font-mono select-all">cluster.x-k8s.io/deployment-name={data.metadata?.name}</code>
122
+ Machines with label <code className="inline-code text-[10px] select-all">cluster.x-k8s.io/deployment-name={data.metadata?.name}</code>
123
123
  </div>
124
124
 
125
125
  <ConditionsSection conditions={conditions} />
@@ -92,7 +92,7 @@ export function CAPIMachineSetRenderer({ data, onNavigate }: Props) {
92
92
 
93
93
  {/* Owned Machines hint */}
94
94
  <div className="px-3 py-1.5 text-xs text-theme-text-tertiary">
95
- Machines with label <code className="bg-theme-surface px-1 py-0.5 rounded text-[10px] font-mono select-all">cluster.x-k8s.io/set-name={data.metadata?.name}</code>
95
+ Machines with label <code className="inline-code text-[10px] select-all">cluster.x-k8s.io/set-name={data.metadata?.name}</code>
96
96
  </div>
97
97
 
98
98
  <ConditionsSection conditions={conditions} />
@@ -50,7 +50,7 @@ export function CNPGPoolerRenderer({ data, onNavigate }: CNPGPoolerRendererProps
50
50
  <PropertyList>
51
51
  {authQuery && (
52
52
  <Property label="Auth Query" value={
53
- <code className="text-xs font-mono bg-theme-elevated px-1.5 py-0.5 rounded break-all">{authQuery}</code>
53
+ <code className="inline-code text-xs break-all">{authQuery}</code>
54
54
  } />
55
55
  )}
56
56
  {authQuerySecret && (
@@ -57,13 +57,13 @@ function CompositionBody({ data, onNavigate, revision }: CompositionRendererProp
57
57
  {xrdKind && (
58
58
  <Property
59
59
  label="Composite Kind"
60
- value={<span className="font-mono text-theme-text-secondary">{xrdKind}</span>}
60
+ value={<span className="inline-code">{xrdKind}</span>}
61
61
  />
62
62
  )}
63
63
  {compositeTypeRef.apiVersion && (
64
64
  <Property
65
65
  label="API Version"
66
- value={<span className="font-mono text-theme-text-tertiary text-xs">{compositeTypeRef.apiVersion}</span>}
66
+ value={<span className="inline-code text-xs">{compositeTypeRef.apiVersion}</span>}
67
67
  />
68
68
  )}
69
69
  {writeConnNs && <Property label="Connection Secret Namespace" value={writeConnNs} />}
@@ -149,7 +149,7 @@ function CompositionBody({ data, onNavigate, revision }: CompositionRendererProp
149
149
  return (
150
150
  <div key={i} className="card-inner text-sm flex items-center gap-2 flex-wrap">
151
151
  <span className="badge-sm status-neutral">{baseKind || 'Unknown'}</span>
152
- <span className="font-mono text-theme-text-secondary break-all">{res.name || `resource-${i}`}</span>
152
+ <span className="inline-code break-all">{res.name || `resource-${i}`}</span>
153
153
  {baseApiVersion && (
154
154
  <span className="text-theme-text-tertiary text-xs">{baseApiVersion}</span>
155
155
  )}
@@ -54,7 +54,7 @@ export function CrossplanePackageRenderer({ data, kindLabel, onNavigate }: Cross
54
54
 
55
55
  <Section title={kindLabel} icon={Package} defaultExpanded>
56
56
  <PropertyList>
57
- <Property label="Package" value={<span className="font-mono break-all">{pkg}</span>} />
57
+ <Property label="Package" value={<span className="inline-code break-all">{pkg}</span>} />
58
58
  {pullPolicy && <Property label="Pull Policy" value={pullPolicy} />}
59
59
  {data?.spec?.revisionActivationPolicy && (
60
60
  <Property label="Revision Activation" value={data.spec.revisionActivationPolicy} />
@@ -73,12 +73,12 @@ export function CrossplanePackageRenderer({ data, kindLabel, onNavigate }: Cross
73
73
  <Section title="Revision" icon={ScrollText} defaultExpanded>
74
74
  <PropertyList>
75
75
  {currentRevision && (
76
- <Property label="Current Revision" value={<span className="font-mono break-all">{currentRevision}</span>} />
76
+ <Property label="Current Revision" value={<span className="inline-code break-all">{currentRevision}</span>} />
77
77
  )}
78
78
  {data?.status?.currentIdentifier && (
79
79
  <Property
80
80
  label="Current Identifier"
81
- value={<span className="font-mono break-all">{data.status.currentIdentifier}</span>}
81
+ value={<span className="inline-code break-all">{data.status.currentIdentifier}</span>}
82
82
  />
83
83
  )}
84
84
  </PropertyList>
@@ -112,7 +112,7 @@ export function CrossplanePackageRenderer({ data, kindLabel, onNavigate }: Cross
112
112
  return (
113
113
  <div key={i} className="card-inner text-sm flex items-center gap-2 flex-wrap">
114
114
  <span className="badge-sm status-neutral">{depKind}</span>
115
- <span className="font-mono text-theme-text-secondary break-all">{ref || '-'}</span>
115
+ <span className="inline-code break-all">{ref || '-'}</span>
116
116
  {dep.version && (
117
117
  <span className="text-theme-text-tertiary text-xs">version: {dep.version}</span>
118
118
  )}
@@ -129,7 +129,7 @@ export function CrossplanePackageRenderer({ data, kindLabel, onNavigate }: Cross
129
129
  {objectRefs.map((ref: any, i: number) => (
130
130
  <div key={i} className="card-inner text-sm flex items-center gap-2 flex-wrap">
131
131
  <span className="badge-sm status-neutral">{ref.kind || 'Unknown'}</span>
132
- <span className="font-mono text-theme-text-secondary break-all">{ref.name}</span>
132
+ <span className="inline-code break-all">{ref.name}</span>
133
133
  {ref.apiVersion && (
134
134
  <span className="text-theme-text-tertiary text-xs">{ref.apiVersion}</span>
135
135
  )}
@@ -56,7 +56,7 @@ export function CrossplaneProviderConfigRenderer({ data, onNavigate }: Crossplan
56
56
  }
57
57
  />
58
58
  {secretRef.namespace && <Property label="Namespace" value={secretRef.namespace} />}
59
- {secretRef.key && <Property label="Key" value={<span className="font-mono">{secretRef.key}</span>} />}
59
+ {secretRef.key && <Property label="Key" value={<span className="inline-code">{secretRef.key}</span>} />}
60
60
  </PropertyList>
61
61
  </Section>
62
62
  )}
@@ -58,7 +58,7 @@ export function PingSourceRenderer({ data, onNavigate }: RendererProps) {
58
58
  <Property label="Timezone" value={spec.timezone} />
59
59
  <Property label="Content Type" value={spec.contentType} />
60
60
  <Property label="Data" value={spec.data ? (
61
- <span className="font-mono text-xs break-all">{spec.data.length > 200 ? spec.data.slice(0, 200) + '...' : spec.data}</span>
61
+ <span className="inline-code text-xs break-all">{spec.data.length > 200 ? spec.data.slice(0, 200) + '...' : spec.data}</span>
62
62
  ) : undefined} />
63
63
  <SinkProperty sink={spec.sink} ns={ns} onNavigate={onNavigate} />
64
64
  </PropertyList>
@@ -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"