@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.
- 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 +374 -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 +35 -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,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="
|
|
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="
|
|
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="
|
|
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
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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
|
|
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"
|