@skyhook-io/k8s-ui 1.7.6 → 1.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -1
- package/src/components/charts/PrometheusChartsView.tsx +233 -0
- package/src/components/charts/index.ts +13 -0
- package/src/components/checks/ChecksView.tsx +5 -1
- package/src/components/gitops/GitOpsDetailLayout.tsx +4 -4
- package/src/components/gitops/GitOpsTableView.tsx +203 -6
- package/src/components/gitops/index.ts +1 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +3 -3
- package/src/components/issues/IssuesView.tsx +352 -0
- package/src/components/issues/index.ts +24 -0
- package/src/components/issues/issues.test.ts +100 -0
- package/src/components/issues/severity.ts +125 -0
- package/src/components/issues/types.ts +194 -0
- package/src/components/resources/ResourcesView.tsx +27 -25
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +47 -3
- 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/resources/resource-utils-argo.ts +18 -0
- 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/RowActionMenu.tsx +149 -0
- package/src/components/ui/Tooltip.tsx +37 -2
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/WorkloadView.tsx +118 -112
- package/src/index.ts +5 -0
- package/src/theme/components.css +16 -0
- package/src/types/core.ts +3 -2
- package/src/utils/env-from.ts +3 -0
- package/src/utils/git-provider-urls.test.ts +348 -0
- package/src/utils/git-provider-urls.ts +142 -0
- package/src/utils/index.ts +2 -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
|
+
}
|
|
@@ -229,6 +229,13 @@ const TAILWIND_WIDTH_TO_PX: Record<string, number> = {
|
|
|
229
229
|
'w-48': 192, 'w-56': 224, 'w-64': 256,
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
+
const COMPARE_COLUMN_WIDTH = 36
|
|
233
|
+
const COMPARE_COLUMN_STYLE: React.CSSProperties = {
|
|
234
|
+
width: COMPARE_COLUMN_WIDTH,
|
|
235
|
+
minWidth: COMPARE_COLUMN_WIDTH,
|
|
236
|
+
maxWidth: COMPARE_COLUMN_WIDTH,
|
|
237
|
+
}
|
|
238
|
+
|
|
232
239
|
function getColumnMinWidth(col: Column): number {
|
|
233
240
|
if (col.minWidth) return col.minWidth
|
|
234
241
|
if (!col.width) return 200 // Name column (no width class) gets wider minimum
|
|
@@ -249,7 +256,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
249
256
|
pods: [
|
|
250
257
|
{ key: 'name', label: 'Name' },
|
|
251
258
|
{ key: 'namespace', label: 'Namespace', width: 'w-48' },
|
|
252
|
-
{ 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' },
|
|
253
260
|
{ key: 'status', label: 'Status', width: 'w-40' },
|
|
254
261
|
{ key: 'cpu', label: 'CPU', width: 'w-40', tooltip: 'CPU usage / limit (marker = request)' },
|
|
255
262
|
{ key: 'memory', label: 'Memory', width: 'w-40', tooltip: 'Memory usage / limit (marker = request)' },
|
|
@@ -3367,6 +3374,18 @@ export function ResourcesView({
|
|
|
3367
3374
|
return allColumns.filter(c => visibleColumns.has(c.key))
|
|
3368
3375
|
}, [allColumns, visibleColumns])
|
|
3369
3376
|
|
|
3377
|
+
// Fixed-width columns can consume the table's flexible space and collapse
|
|
3378
|
+
// the required name column. Keep a real table minimum and let the container
|
|
3379
|
+
// scroll horizontally when the viewport is too narrow.
|
|
3380
|
+
const tableMinWidth = useMemo(() => {
|
|
3381
|
+
const compareColumnWidth = compareMode ? COMPARE_COLUMN_WIDTH : 0
|
|
3382
|
+
const baseMinWidth = columns.reduce((sum, col) => sum + (columnWidths[col.key] || getColumnMinWidth(col)), compareColumnWidth)
|
|
3383
|
+
const flexibleNameColumn = columns.find(col => col.key === 'name' && !columnWidths[col.key])
|
|
3384
|
+
|
|
3385
|
+
if (!hasResizedColumns || !flexibleNameColumn) return baseMinWidth
|
|
3386
|
+
return baseMinWidth + getColumnMinWidth(flexibleNameColumn)
|
|
3387
|
+
}, [columns, columnWidths, compareMode, hasResizedColumns])
|
|
3388
|
+
|
|
3370
3389
|
// Stable virtuoso components — memoized to avoid remounting the table on every render
|
|
3371
3390
|
const virtuosoComponents = useMemo(() => ({
|
|
3372
3391
|
Table: React.forwardRef<HTMLTableElement, React.TableHTMLAttributes<HTMLTableElement>>(function VirtuosoTable(props, ref) {
|
|
@@ -3375,7 +3394,7 @@ export function ResourcesView({
|
|
|
3375
3394
|
{...props}
|
|
3376
3395
|
ref={ref}
|
|
3377
3396
|
className="w-full"
|
|
3378
|
-
style={{ ...props.style, tableLayout: 'fixed' }}
|
|
3397
|
+
style={{ ...props.style, tableLayout: 'fixed', minWidth: tableMinWidth }}
|
|
3379
3398
|
>
|
|
3380
3399
|
<colgroup>
|
|
3381
3400
|
{/*
|
|
@@ -3385,7 +3404,7 @@ export function ResourcesView({
|
|
|
3385
3404
|
the missing entry by stealing width from a sized neighbour
|
|
3386
3405
|
— typically blowing this narrow column out to ~200px.
|
|
3387
3406
|
*/}
|
|
3388
|
-
{compareMode && <col style={{ width:
|
|
3407
|
+
{compareMode && <col style={{ width: COMPARE_COLUMN_WIDTH }} />}
|
|
3389
3408
|
{columns.map(col => (
|
|
3390
3409
|
<col
|
|
3391
3410
|
key={col.key}
|
|
@@ -3403,7 +3422,7 @@ export function ResourcesView({
|
|
|
3403
3422
|
)
|
|
3404
3423
|
}),
|
|
3405
3424
|
TableRow: VirtuosoTableRow,
|
|
3406
|
-
}), [columns, columnWidths, hasResizedColumns, compareMode])
|
|
3425
|
+
}), [columns, columnWidths, hasResizedColumns, compareMode, tableMinWidth])
|
|
3407
3426
|
|
|
3408
3427
|
// Calculate filter options with counts based on current resources (before filtering)
|
|
3409
3428
|
const filterOptions = useMemo(() => {
|
|
@@ -3949,7 +3968,7 @@ export function ResourcesView({
|
|
|
3949
3968
|
|
|
3950
3969
|
{/* Table */}
|
|
3951
3970
|
<div
|
|
3952
|
-
className="flex-1 overflow-
|
|
3971
|
+
className="flex-1 overflow-auto relative"
|
|
3953
3972
|
ref={tableContainerRef}
|
|
3954
3973
|
onClick={(e) => {
|
|
3955
3974
|
if (e.target === e.currentTarget && selectedResource) {
|
|
@@ -3977,23 +3996,6 @@ export function ResourcesView({
|
|
|
3977
3996
|
<X className="w-3.5 h-3.5" />
|
|
3978
3997
|
</button>
|
|
3979
3998
|
)}
|
|
3980
|
-
{/*
|
|
3981
|
-
The sidebar's count badge shows the cluster-wide
|
|
3982
|
-
total for the selected kind (from resourceCounts) and
|
|
3983
|
-
deliberately stays unfiltered. When a search yields
|
|
3984
|
-
zero results the user can think the badge is lying.
|
|
3985
|
-
Spell out that the badge is the cluster total, not
|
|
3986
|
-
the filtered count.
|
|
3987
|
-
*/}
|
|
3988
|
-
{searchTerm && (() => {
|
|
3989
|
-
const totalForKind = counts[selectedKind.group ? `${selectedKind.group}/${selectedKind.kind}` : selectedKind.kind] ?? 0
|
|
3990
|
-
if (totalForKind === 0) return null
|
|
3991
|
-
return (
|
|
3992
|
-
<p className="text-xs mt-1 text-theme-text-disabled">
|
|
3993
|
-
The sidebar shows {pluralize(totalForKind, selectedKind.kind)} in the cluster — the count is unfiltered.
|
|
3994
|
-
</p>
|
|
3995
|
-
)
|
|
3996
|
-
})()}
|
|
3997
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>}
|
|
3998
4000
|
{/* Show active filters as dismissible badges so user can clear them */}
|
|
3999
4001
|
{(() => {
|
|
@@ -4062,7 +4064,7 @@ export function ResourcesView({
|
|
|
4062
4064
|
// Inline px width — under `table-layout:fixed`,
|
|
4063
4065
|
// `w-9` is a hint the browser absorbs into leftover
|
|
4064
4066
|
// row width on an icon-only column.
|
|
4065
|
-
style={
|
|
4067
|
+
style={COMPARE_COLUMN_STYLE}
|
|
4066
4068
|
className="px-2 py-3 text-xs font-medium uppercase tracking-wide bg-theme-base border-b border-r-subtle border-theme-border text-center text-skyhook-400"
|
|
4067
4069
|
title="Compare mode"
|
|
4068
4070
|
>
|
|
@@ -4344,7 +4346,7 @@ function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, h
|
|
|
4344
4346
|
<td
|
|
4345
4347
|
onClick={onClick}
|
|
4346
4348
|
onMouseEnter={onMouseEnter}
|
|
4347
|
-
style={
|
|
4349
|
+
style={COMPARE_COLUMN_STYLE}
|
|
4348
4350
|
className={clsx('px-2 py-3 border-b-subtle cursor-pointer text-center align-middle transition-colors', rowHighlight)}
|
|
4349
4351
|
>
|
|
4350
4352
|
{pickedSide ? (
|
|
@@ -4914,7 +4916,7 @@ function PodCell({ resource, column }: { resource: any; column: string }) {
|
|
|
4914
4916
|
const squares = getContainerSquareStates(resource)
|
|
4915
4917
|
const hasInit = squares.some(s => s.isInit)
|
|
4916
4918
|
return (
|
|
4917
|
-
<div className="flex items-center gap-1">
|
|
4919
|
+
<div className="flex w-full items-center justify-center gap-1">
|
|
4918
4920
|
{squares.map((sq, i) => {
|
|
4919
4921
|
const showSeparator = hasInit && i > 0 && sq.isInit !== squares[i - 1].isInit
|
|
4920
4922
|
const bgClass =
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { GitBranch, FolderTree, Settings, Target, XCircle, History, ListChecks } from 'lucide-react'
|
|
1
|
+
import { GitBranch, FolderTree, Settings, Target, XCircle, History, ListChecks, ExternalLink } from 'lucide-react'
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import { Section, PropertyList, Property, ConditionsSection, ProblemAlerts } from '../../ui/drawer-components'
|
|
4
4
|
import { formatAge } from '../resource-utils'
|
|
@@ -10,6 +10,9 @@ import {
|
|
|
10
10
|
type ArgoResource,
|
|
11
11
|
} from '../../../types/gitops'
|
|
12
12
|
import { BADGE_INACTIVE } from '../../../utils/badge-colors'
|
|
13
|
+
import { buildRepoBrowseUrl, buildPathBrowseUrl } from '../../../utils/git-provider-urls'
|
|
14
|
+
|
|
15
|
+
const REPO_LINK_CLASS = 'text-blue-400 hover:text-blue-300 hover:underline break-all'
|
|
13
16
|
|
|
14
17
|
interface ArgoApplicationRendererProps {
|
|
15
18
|
data: any
|
|
@@ -19,10 +22,51 @@ interface ArgoApplicationRendererProps {
|
|
|
19
22
|
|
|
20
23
|
function SourceProperties({ source }: { source: any }) {
|
|
21
24
|
if (!source) return null
|
|
25
|
+
// Helm chart sources point repoURL at a chart registry, not a browseable git repo.
|
|
26
|
+
const isHelmSource = !!source.chart
|
|
27
|
+
const repoHref = isHelmSource ? null : buildRepoBrowseUrl(source.repoURL)
|
|
28
|
+
const pathHref = isHelmSource ? null : buildPathBrowseUrl(source.repoURL, source.path, source.targetRevision)
|
|
22
29
|
return (
|
|
23
30
|
<>
|
|
24
|
-
<Property
|
|
25
|
-
|
|
31
|
+
<Property
|
|
32
|
+
label="Repository"
|
|
33
|
+
value={
|
|
34
|
+
repoHref ? (
|
|
35
|
+
<a
|
|
36
|
+
href={repoHref}
|
|
37
|
+
target="_blank"
|
|
38
|
+
rel="noopener noreferrer"
|
|
39
|
+
title={source.repoURL}
|
|
40
|
+
className={`${REPO_LINK_CLASS} inline-flex items-center gap-1`}
|
|
41
|
+
>
|
|
42
|
+
{source.repoURL}
|
|
43
|
+
<ExternalLink className="w-3 h-3 shrink-0" />
|
|
44
|
+
</a>
|
|
45
|
+
) : (
|
|
46
|
+
source.repoURL
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
/>
|
|
50
|
+
{source.path && (
|
|
51
|
+
<Property
|
|
52
|
+
label="Path"
|
|
53
|
+
value={
|
|
54
|
+
pathHref ? (
|
|
55
|
+
<a
|
|
56
|
+
href={pathHref}
|
|
57
|
+
target="_blank"
|
|
58
|
+
rel="noopener noreferrer"
|
|
59
|
+
title={source.path}
|
|
60
|
+
className={REPO_LINK_CLASS}
|
|
61
|
+
>
|
|
62
|
+
{source.path}
|
|
63
|
+
</a>
|
|
64
|
+
) : (
|
|
65
|
+
source.path
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
/>
|
|
69
|
+
)}
|
|
26
70
|
{source.targetRevision && (
|
|
27
71
|
<Property
|
|
28
72
|
label="Target Revision"
|
|
@@ -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
|
+
})
|