@skyhook-io/k8s-ui 1.14.5 → 1.14.6
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 +5 -5
- package/src/components/audit/AuditCard.tsx +18 -7
- package/src/components/audit/AuditCoveragePresentation.test.tsx +45 -0
- package/src/components/charts/AreaChart.test.tsx +13 -0
- package/src/components/charts/AreaChart.tsx +28 -25
- package/src/components/charts/PrometheusChartsView.tsx +12 -0
- package/src/components/charts/axis.test.ts +16 -0
- package/src/components/charts/axis.ts +3 -1
- package/src/components/charts/format.ts +4 -0
- package/src/components/charts/nearestSample.test.ts +31 -0
- package/src/components/charts/nearestSample.ts +15 -0
- package/src/components/checks/ChecksView.tsx +20 -2
- package/src/components/gitops/GitOpsDetailLayout.tsx +13 -0
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +141 -0
- package/src/components/gitops/health-provenance.test.ts +54 -0
- package/src/components/gitops/health-provenance.ts +24 -0
- package/src/components/gitops/index.ts +2 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -21
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +59 -8
- package/src/components/gitops/tree/merge.test.ts +34 -0
- package/src/components/gitops/tree/merge.ts +7 -0
- package/src/components/resources/ResourcesView.tsx +67 -2
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
- package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
- package/src/components/resources/renderers/LimitRangeRenderer.test.tsx +156 -0
- package/src/components/resources/renderers/LimitRangeRenderer.tsx +277 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +24 -1
- package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +9 -16
- package/src/components/resources/renderers/NetworkPolicyRenderer.test.tsx +75 -0
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +50 -36
- package/src/components/resources/renderers/PodRenderer.tsx +11 -0
- package/src/components/resources/renderers/ReflectorSection.test.tsx +281 -0
- package/src/components/resources/renderers/ReflectorSection.tsx +296 -0
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +117 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +219 -56
- package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
- package/src/components/resources/renderers/WorkloadRenderer.tsx +11 -1
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/renderers/rollout/CanaryStepTimeline.tsx +199 -0
- package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +116 -0
- package/src/components/resources/resource-utils.ts +18 -9
- package/src/components/shared/CreateResourceDialog.tsx +231 -11
- package/src/components/shared/DrainPlanDialog.test.tsx +154 -0
- package/src/components/shared/DrainPlanDialog.tsx +297 -0
- package/src/components/shared/ResourceActionsBar.tsx +53 -26
- package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +27 -6
- package/src/components/shared/index.ts +4 -0
- package/src/components/ui/FetchResult.test.tsx +20 -2
- package/src/components/ui/FetchResult.tsx +33 -3
- package/src/components/ui/FreshnessControl.tsx +8 -2
- package/src/components/workload/WorkloadView.tsx +2 -2
- package/src/theme/components.css +16 -0
- package/src/types/core.ts +15 -1
- package/src/types/gitops-insights.ts +23 -0
- package/src/types/gitops-tree.ts +29 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +20 -1
- package/src/utils/navigation.ts +25 -0
- package/src/utils/network-policy.test.ts +42 -0
- package/src/utils/network-policy.ts +59 -0
- package/src/utils/reflector.ts +139 -0
- package/src/utils/yaml-file-import.test.ts +189 -0
- package/src/utils/yaml-file-import.ts +95 -0
- package/src/components/logs/JsonLogLine.tsx +0 -93
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { Info, SlidersHorizontal } from 'lucide-react'
|
|
2
|
+
import { Section, ResourceLink } from '../../ui/drawer-components'
|
|
3
|
+
import { Tooltip } from '../../ui/Tooltip'
|
|
4
|
+
import { LookupFailureNote } from './LookupFailureNote'
|
|
5
|
+
import { isForbiddenError } from '../../../types/fetch-error'
|
|
6
|
+
|
|
7
|
+
// A LimitRange declares the defaults and constraints the API server applies to
|
|
8
|
+
// pods, containers and PVCs admitted into a namespace. It has no status, so
|
|
9
|
+
// there is nothing to diagnose here — only the declared rules, shown as
|
|
10
|
+
// written.
|
|
11
|
+
|
|
12
|
+
interface LimitRangeFacet {
|
|
13
|
+
key: 'min' | 'max' | 'defaultRequest' | 'default' | 'maxLimitRequestRatio'
|
|
14
|
+
label: string
|
|
15
|
+
hint: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const FACETS: LimitRangeFacet[] = [
|
|
19
|
+
{ key: 'min', label: 'Min', hint: 'Lowest value accepted at admission.' },
|
|
20
|
+
{ key: 'max', label: 'Max', hint: 'Highest value accepted at admission.' },
|
|
21
|
+
{ key: 'defaultRequest', label: 'Default request', hint: 'Request applied when the resource omits one.' },
|
|
22
|
+
{ key: 'default', label: 'Default limit', hint: 'Limit applied when the resource omits one.' },
|
|
23
|
+
{
|
|
24
|
+
key: 'maxLimitRequestRatio',
|
|
25
|
+
label: 'Max limit/request',
|
|
26
|
+
hint: 'Largest allowed limit ÷ request. A ratio, not a quantity — it carries no unit.',
|
|
27
|
+
},
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const TYPE_SCOPE: Record<string, string> = {
|
|
31
|
+
Container: 'each container, individually',
|
|
32
|
+
Pod: 'the pod’s resource requests and limits',
|
|
33
|
+
PersistentVolumeClaim: 'each PersistentVolumeClaim',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type QuantityMap = Record<string, unknown>
|
|
37
|
+
|
|
38
|
+
function facetMap(item: any, key: LimitRangeFacet['key']): QuantityMap {
|
|
39
|
+
const value = item?.[key]
|
|
40
|
+
return value && typeof value === 'object' ? (value as QuantityMap) : {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Resource names this entry mentions anywhere, so a resource constrained by
|
|
44
|
+
* only one facet still gets a row. */
|
|
45
|
+
function entryResourceNames(item: any): string[] {
|
|
46
|
+
const names = new Set<string>()
|
|
47
|
+
for (const facet of FACETS) {
|
|
48
|
+
for (const name of Object.keys(facetMap(item, facet.key))) names.add(name)
|
|
49
|
+
}
|
|
50
|
+
return [...names].sort()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Facets this entry actually declares. An absent facet is not a zero, so it is
|
|
54
|
+
* dropped rather than rendered as an empty column. */
|
|
55
|
+
function entryFacets(item: any): LimitRangeFacet[] {
|
|
56
|
+
return FACETS.filter(facet => Object.keys(facetMap(item, facet.key)).length > 0)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function limitRangeItems(data: any): any[] {
|
|
60
|
+
const limits = data?.spec?.limits
|
|
61
|
+
return Array.isArray(limits) ? limits : []
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function LimitRangeRenderer({ data }: { data: any }) {
|
|
65
|
+
const items = limitRangeItems(data)
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<Section title="Defaults & Constraints" icon={SlidersHorizontal}>
|
|
69
|
+
{items.length === 0 ? (
|
|
70
|
+
<div className="text-xs text-theme-text-secondary">
|
|
71
|
+
This LimitRange declares no entries, so it constrains nothing.
|
|
72
|
+
</div>
|
|
73
|
+
) : (
|
|
74
|
+
<div className="space-y-3">
|
|
75
|
+
{items.map((item: any, index: number) => (
|
|
76
|
+
<LimitRangeEntry key={`${item?.type ?? 'entry'}-${index}`} item={item} />
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
</Section>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function LimitRangeEntry({ item }: { item: any }) {
|
|
85
|
+
const type = typeof item?.type === 'string' && item.type ? item.type : 'Unknown'
|
|
86
|
+
const scope = TYPE_SCOPE[type]
|
|
87
|
+
const facets = entryFacets(item)
|
|
88
|
+
const resources = entryResourceNames(item)
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div className="card-inner">
|
|
92
|
+
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5 mb-2">
|
|
93
|
+
<span className="text-xs font-medium text-theme-text-primary">{type}</span>
|
|
94
|
+
{scope && <span className="text-xs text-theme-text-tertiary">applies to {scope}</span>}
|
|
95
|
+
</div>
|
|
96
|
+
{resources.length === 0 ? (
|
|
97
|
+
<div className="text-xs text-theme-text-secondary">No resources constrained by this entry.</div>
|
|
98
|
+
) : (
|
|
99
|
+
<div className="overflow-x-auto">
|
|
100
|
+
<table className="w-full text-xs">
|
|
101
|
+
<thead>
|
|
102
|
+
<tr className="text-theme-text-tertiary text-left">
|
|
103
|
+
<th className="font-normal pr-3 pb-1">Resource</th>
|
|
104
|
+
{facets.map(facet => (
|
|
105
|
+
<th key={facet.key} className="font-normal pr-3 pb-1 text-right whitespace-nowrap">
|
|
106
|
+
<Tooltip content={facet.hint} delay={150}>
|
|
107
|
+
<span className="border-b border-dotted border-theme-text-tertiary cursor-help">{facet.label}</span>
|
|
108
|
+
</Tooltip>
|
|
109
|
+
</th>
|
|
110
|
+
))}
|
|
111
|
+
</tr>
|
|
112
|
+
</thead>
|
|
113
|
+
<tbody>
|
|
114
|
+
{resources.map(name => (
|
|
115
|
+
<tr key={name} className="border-t border-theme-border">
|
|
116
|
+
<td className="pr-3 py-1 text-theme-text-secondary break-all">{name}</td>
|
|
117
|
+
{facets.map(facet => {
|
|
118
|
+
const raw = facetMap(item, facet.key)[name]
|
|
119
|
+
const set = raw !== undefined && raw !== null
|
|
120
|
+
return (
|
|
121
|
+
<td
|
|
122
|
+
key={facet.key}
|
|
123
|
+
className="pr-3 py-1 text-right tabular-nums whitespace-nowrap text-theme-text-primary"
|
|
124
|
+
>
|
|
125
|
+
{set ? String(raw) : <span className="text-theme-text-tertiary" title="Not set">—</span>}
|
|
126
|
+
</td>
|
|
127
|
+
)
|
|
128
|
+
})}
|
|
129
|
+
</tr>
|
|
130
|
+
))}
|
|
131
|
+
</tbody>
|
|
132
|
+
</table>
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ============================================================================
|
|
140
|
+
// NAMESPACE SECTION
|
|
141
|
+
// ============================================================================
|
|
142
|
+
// Namespace-level summary: which LimitRanges exist and what each one declares,
|
|
143
|
+
// linking out to the full tables. Reached from the namespace an operator is
|
|
144
|
+
// already troubleshooting, which is where the question "what gets applied to
|
|
145
|
+
// pods here?" actually comes up.
|
|
146
|
+
|
|
147
|
+
export function NamespaceLimitRangesSection({
|
|
148
|
+
limitRanges,
|
|
149
|
+
loading,
|
|
150
|
+
error,
|
|
151
|
+
namespace,
|
|
152
|
+
onNavigate,
|
|
153
|
+
}: {
|
|
154
|
+
limitRanges?: any[]
|
|
155
|
+
loading?: boolean
|
|
156
|
+
error?: unknown
|
|
157
|
+
namespace: string
|
|
158
|
+
onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
|
|
159
|
+
}) {
|
|
160
|
+
const denied = isForbiddenError(error)
|
|
161
|
+
const showCached = !!error && !denied && !!limitRanges?.length
|
|
162
|
+
const empty = !error && !loading && limitRanges?.length === 0
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<Section
|
|
166
|
+
key={`${namespace}-${empty ? 'empty' : 'rules'}`}
|
|
167
|
+
title={empty ? 'Limit Ranges (0)' : 'Limit Ranges'}
|
|
168
|
+
icon={SlidersHorizontal}
|
|
169
|
+
defaultExpanded={!empty}
|
|
170
|
+
>
|
|
171
|
+
{error != null && (
|
|
172
|
+
<div className="mb-2">
|
|
173
|
+
<LookupFailureNote errors={[error]} what="this namespace’s LimitRanges" />
|
|
174
|
+
{showCached && (
|
|
175
|
+
<div className="mt-1 text-xs text-theme-text-secondary">
|
|
176
|
+
Showing previously loaded rules. They may have changed.
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
{error && !showCached ? null : !limitRanges ? (
|
|
182
|
+
<div className="text-xs text-theme-text-secondary">
|
|
183
|
+
{loading ? 'Loading limit ranges…' : 'Limit ranges have not been read yet.'}
|
|
184
|
+
</div>
|
|
185
|
+
) : limitRanges.length === 0 ? (
|
|
186
|
+
<div className="text-xs text-theme-text-secondary">
|
|
187
|
+
No LimitRanges found in this namespace. Other admission policies may still apply.
|
|
188
|
+
</div>
|
|
189
|
+
) : (
|
|
190
|
+
<div className="space-y-2">
|
|
191
|
+
{limitRanges.map((lr: any, index: number) => {
|
|
192
|
+
const name = lr?.metadata?.name ?? `limitrange-${index}`
|
|
193
|
+
const items = limitRangeItems(lr)
|
|
194
|
+
return (
|
|
195
|
+
<div key={name} className="card-inner">
|
|
196
|
+
<div className="text-xs font-medium text-theme-text-primary">
|
|
197
|
+
<ResourceLink
|
|
198
|
+
name={name}
|
|
199
|
+
kind="limitranges"
|
|
200
|
+
namespace={lr?.metadata?.namespace || namespace}
|
|
201
|
+
onNavigate={onNavigate}
|
|
202
|
+
/>
|
|
203
|
+
</div>
|
|
204
|
+
{items.length === 0 ? (
|
|
205
|
+
<div className="mt-1 text-xs text-theme-text-tertiary">No entries.</div>
|
|
206
|
+
) : (
|
|
207
|
+
<div className="mt-1 space-y-0.5">
|
|
208
|
+
{items.map((item: any, itemIndex: number) => {
|
|
209
|
+
const facets = entryFacets(item)
|
|
210
|
+
return (
|
|
211
|
+
<div key={`${item?.type ?? 'entry'}-${itemIndex}`} className="text-xs text-theme-text-tertiary">
|
|
212
|
+
<span className="text-theme-text-secondary">{item?.type || 'Unknown'}</span>
|
|
213
|
+
{facets.length > 0 && ` · ${facets.map(f => f.label.toLowerCase()).join(', ')}`}
|
|
214
|
+
</div>
|
|
215
|
+
)
|
|
216
|
+
})}
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
)
|
|
221
|
+
})}
|
|
222
|
+
</div>
|
|
223
|
+
)}
|
|
224
|
+
</Section>
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ============================================================================
|
|
229
|
+
// CONTEXTUAL LINK (Pod / workload resource sections)
|
|
230
|
+
// ============================================================================
|
|
231
|
+
|
|
232
|
+
const SCOPE_CAVEAT: Record<'pod' | 'workload', string> = {
|
|
233
|
+
pod: 'The namespace’s current rules for admitting pods — not a record of what was applied to this pod. Other admission plugins may also change a pod’s resources.',
|
|
234
|
+
workload:
|
|
235
|
+
'The namespace’s current rules for admitting this workload’s pods, not the workload object itself. Other admission plugins may also change a pod’s resources.',
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Small pointer from a resource's requests/limits to the namespace rules that
|
|
239
|
+
* govern them. Renders nothing until the host has names to show, so a pending
|
|
240
|
+
* or unreadable lookup makes no claim either way. */
|
|
241
|
+
export function NamespaceLimitRangeLink({
|
|
242
|
+
namespace,
|
|
243
|
+
names,
|
|
244
|
+
scope,
|
|
245
|
+
onNavigate,
|
|
246
|
+
}: {
|
|
247
|
+
namespace: string
|
|
248
|
+
names?: string[] | null
|
|
249
|
+
scope: 'pod' | 'workload'
|
|
250
|
+
onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
|
|
251
|
+
}) {
|
|
252
|
+
if (!names?.length) return null
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<div className="mt-3 pt-2 border-t border-theme-border text-xs text-theme-text-tertiary">
|
|
256
|
+
<div>
|
|
257
|
+
Namespace defaults & constraints:{' '}
|
|
258
|
+
{names.map((name, index) => (
|
|
259
|
+
<span key={name}>
|
|
260
|
+
{index > 0 && ', '}
|
|
261
|
+
<ResourceLink name={name} kind="limitranges" namespace={namespace} onNavigate={onNavigate} />
|
|
262
|
+
</span>
|
|
263
|
+
))}
|
|
264
|
+
{' '}
|
|
265
|
+
<Tooltip content={SCOPE_CAVEAT[scope]}>
|
|
266
|
+
<button
|
|
267
|
+
type="button"
|
|
268
|
+
aria-label="About namespace defaults and constraints"
|
|
269
|
+
className="inline-flex align-middle text-theme-text-tertiary hover:text-theme-text-secondary"
|
|
270
|
+
>
|
|
271
|
+
<Info className="w-3 h-3" />
|
|
272
|
+
</button>
|
|
273
|
+
</Tooltip>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
)
|
|
277
|
+
}
|
|
@@ -4,6 +4,7 @@ import { Section, PropertyList, Property, ResourceLink } from '../../ui/drawer-c
|
|
|
4
4
|
import type { RBACNamespaceResponse, RBACBindingWithSubjects, RBACSubject, ResourceRef } from '../../../types'
|
|
5
5
|
import { rbacKindBadgeClass } from '../../../utils/rbac-badges'
|
|
6
6
|
import { RBACErrorSection } from './RBACErrorSection'
|
|
7
|
+
import { NamespaceLimitRangesSection } from './LimitRangeRenderer'
|
|
7
8
|
import { SEVERITY_TEXT, SEVERITY_DOT } from '../../../utils/badge-colors'
|
|
8
9
|
import { parseCPUToNanocores, parseMemoryToBytes, parseQuantityToNumber } from '../../../utils/format'
|
|
9
10
|
|
|
@@ -30,10 +31,21 @@ interface NamespaceRendererProps {
|
|
|
30
31
|
* fetch 500/503s isn't mistaken for quota-free. (403 stays hidden upstream.)
|
|
31
32
|
*/
|
|
32
33
|
quotaError?: Error | null
|
|
34
|
+
/**
|
|
35
|
+
* LimitRange objects for this namespace (from /api/resources/limitranges
|
|
36
|
+
* ?namespace=). Undefined when the host hasn't wired the fetch (section
|
|
37
|
+
* omitted) or while the read is in flight — an empty array is a real answer
|
|
38
|
+
* ("nothing constrains this namespace") and must not be confused with it.
|
|
39
|
+
*/
|
|
40
|
+
limitRangeData?: any[]
|
|
41
|
+
limitRangeLoading?: boolean
|
|
42
|
+
/** Passed through including 403: a denial reads as "you can't check", which
|
|
43
|
+
* is a different statement from "there are none". */
|
|
44
|
+
limitRangeError?: unknown
|
|
33
45
|
onNavigate?: (ref: ResourceRef) => void
|
|
34
46
|
}
|
|
35
47
|
|
|
36
|
-
export function NamespaceRenderer({ data, rbacData, rbacLoading, rbacError, quotaData, quotaError, onNavigate }: NamespaceRendererProps) {
|
|
48
|
+
export function NamespaceRenderer({ data, rbacData, rbacLoading, rbacError, quotaData, quotaError, limitRangeData, limitRangeLoading, limitRangeError, onNavigate }: NamespaceRendererProps) {
|
|
37
49
|
const metadata = data.metadata || {}
|
|
38
50
|
const status = data.status || {}
|
|
39
51
|
const phase = status.phase
|
|
@@ -69,6 +81,17 @@ export function NamespaceRenderer({ data, rbacData, rbacLoading, rbacError, quot
|
|
|
69
81
|
<NamespaceQuotaSection quotas={quotaData ?? []} error={quotaError ?? null} />
|
|
70
82
|
)}
|
|
71
83
|
|
|
84
|
+
{/* LimitRange rules — only when host wired the fetch. */}
|
|
85
|
+
{(limitRangeData !== undefined || limitRangeLoading || limitRangeError != null) && (
|
|
86
|
+
<NamespaceLimitRangesSection
|
|
87
|
+
limitRanges={limitRangeData}
|
|
88
|
+
loading={limitRangeLoading}
|
|
89
|
+
error={limitRangeError}
|
|
90
|
+
namespace={metadata.name ?? ''}
|
|
91
|
+
onNavigate={onNavigate}
|
|
92
|
+
/>
|
|
93
|
+
)}
|
|
94
|
+
|
|
72
95
|
{/* RBAC summary — only when host wired the fetch. */}
|
|
73
96
|
{rbacData !== undefined && (
|
|
74
97
|
<NamespaceRBACSection
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
NETWORK_POLICY_PEER_STYLES,
|
|
7
7
|
type NetworkPolicyPeerType,
|
|
8
8
|
} from './network-policy-peer-styles'
|
|
9
|
+
import { effectivePolicyTypes, formatNetworkPolicyPort } from '../../../utils/network-policy'
|
|
10
|
+
import { formatKubernetesLabelSelector } from '../resource-utils-calico'
|
|
9
11
|
|
|
10
12
|
interface NetworkPolicyDiagramProps {
|
|
11
13
|
spec: any
|
|
@@ -20,16 +22,12 @@ interface NetworkPolicyDiagramProps {
|
|
|
20
22
|
*/
|
|
21
23
|
export function NetworkPolicyDiagram({ spec, staged = false }: NetworkPolicyDiagramProps) {
|
|
22
24
|
const podSelector = spec.podSelector || {}
|
|
23
|
-
const matchLabels = podSelector.matchLabels || {}
|
|
24
|
-
const policyTypes: string[] = spec.policyTypes || []
|
|
25
25
|
const ingress: any[] | undefined = spec.ingress
|
|
26
26
|
const egress: any[] | undefined = spec.egress
|
|
27
27
|
|
|
28
|
-
const hasIngress =
|
|
29
|
-
const
|
|
30
|
-
const targetLabel =
|
|
31
|
-
? Object.entries(matchLabels).map(([k, v]) => `${k}=${v}`).join(', ')
|
|
32
|
-
: 'All pods'
|
|
28
|
+
const { ingress: hasIngress, egress: hasEgress } = effectivePolicyTypes(spec)
|
|
29
|
+
const target = formatSelector(podSelector)
|
|
30
|
+
const targetLabel = target === 'all' ? 'All pods' : target
|
|
33
31
|
|
|
34
32
|
const ingressDenied = hasIngress && (!ingress || ingress.length === 0)
|
|
35
33
|
const egressDenied = hasEgress && (!egress || egress.length === 0)
|
|
@@ -159,18 +157,13 @@ function extractPeers(peers: any[] | undefined): PeerInfo[] {
|
|
|
159
157
|
|
|
160
158
|
function extractPorts(ports: any[] | undefined): string[] {
|
|
161
159
|
if (!ports || ports.length === 0) return []
|
|
162
|
-
return ports.map(
|
|
163
|
-
const proto = p.protocol || 'TCP'
|
|
164
|
-
const port = p.port || '*'
|
|
165
|
-
const endPort = p.endPort
|
|
166
|
-
return endPort ? `${proto}/${port}-${endPort}` : `${proto}/${port}`
|
|
167
|
-
})
|
|
160
|
+
return ports.map(formatNetworkPolicyPort)
|
|
168
161
|
}
|
|
169
162
|
|
|
163
|
+
// matchExpressions select too; a selector made only of them is not "all".
|
|
170
164
|
function formatSelector(selector: any): string {
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
return Object.entries(labels).map(([k, v]) => `${k}=${v}`).join(', ')
|
|
165
|
+
const text = formatKubernetesLabelSelector(selector)
|
|
166
|
+
return text === 'all workloads' ? 'all' : text
|
|
174
167
|
}
|
|
175
168
|
|
|
176
169
|
function FlowRow({
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { NetworkPolicyRenderer } from './NetworkPolicyRenderer'
|
|
4
|
+
import { NetworkPolicyDiagram } from './NetworkPolicyDiagram'
|
|
5
|
+
|
|
6
|
+
const render = (spec: any) => renderToString(<NetworkPolicyRenderer data={{ kind: 'NetworkPolicy', spec }} />)
|
|
7
|
+
const renderDiagram = (spec: any) => renderToString(<NetworkPolicyDiagram spec={spec} />)
|
|
8
|
+
|
|
9
|
+
describe('NetworkPolicyRenderer', () => {
|
|
10
|
+
it('shows a policy without policyTypes as isolating ingress, and says the type was defaulted', () => {
|
|
11
|
+
const html = render({ podSelector: {} })
|
|
12
|
+
expect(html).toContain('Ingress Rules')
|
|
13
|
+
expect(html).toContain('Deny all ingress')
|
|
14
|
+
expect(html).toContain('policyTypes not set')
|
|
15
|
+
expect(html).not.toContain('Egress Rules')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('does not call an explicitly typed policy defaulted', () => {
|
|
19
|
+
const html = render({ podSelector: {}, policyTypes: ['Ingress'] })
|
|
20
|
+
expect(html).toContain('Deny all ingress')
|
|
21
|
+
expect(html).not.toContain('policyTypes not set')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('renders both selectors of a peer that sets podSelector and namespaceSelector', () => {
|
|
25
|
+
const html = render({
|
|
26
|
+
podSelector: { matchLabels: { app: 'prometheus' } },
|
|
27
|
+
policyTypes: ['Ingress'],
|
|
28
|
+
ingress: [{
|
|
29
|
+
from: [{
|
|
30
|
+
podSelector: { matchLabels: { app: 'radar' } },
|
|
31
|
+
namespaceSelector: { matchLabels: { 'kubernetes.io/metadata.name': 'radar' } },
|
|
32
|
+
}],
|
|
33
|
+
}],
|
|
34
|
+
})
|
|
35
|
+
expect(html).toContain('podSelector')
|
|
36
|
+
expect(html).toContain('in namespaceSelector')
|
|
37
|
+
expect(html).toContain('kubernetes.io/metadata.name')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('renders matchExpressions rather than calling the selector empty', () => {
|
|
41
|
+
const html = render({
|
|
42
|
+
podSelector: { matchExpressions: [{ key: 'tier', operator: 'In', values: ['web', 'api'] }] },
|
|
43
|
+
policyTypes: ['Ingress'],
|
|
44
|
+
ingress: [{ from: [{ podSelector: { matchExpressions: [{ key: 'role', operator: 'Exists' }] } }] }],
|
|
45
|
+
})
|
|
46
|
+
expect(html).toContain('tier')
|
|
47
|
+
expect(html).toContain('In')
|
|
48
|
+
expect(html).toContain('role')
|
|
49
|
+
expect(html).not.toContain('All pods in namespace')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('renders endPort ranges in the rule port list', () => {
|
|
53
|
+
const html = render({
|
|
54
|
+
podSelector: {},
|
|
55
|
+
policyTypes: ['Ingress'],
|
|
56
|
+
ingress: [{ ports: [{ port: 9000, endPort: 9100 }, { protocol: 'UDP', port: 53 }] }],
|
|
57
|
+
})
|
|
58
|
+
expect(html).toContain('TCP/9000-9100')
|
|
59
|
+
expect(html).toContain('UDP/53')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('diagram: reads matchExpressions, shows ranges, and treats an empty selector as all pods', () => {
|
|
63
|
+
const html = renderDiagram({
|
|
64
|
+
podSelector: { matchExpressions: [{ key: 'tier', operator: 'In', values: ['web'] }] },
|
|
65
|
+
ingress: [{ ports: [{ port: 9000, endPort: 9100 }] }],
|
|
66
|
+
})
|
|
67
|
+
expect(html).toContain('tier In (web)')
|
|
68
|
+
expect(html).toContain('TCP/9000-9100')
|
|
69
|
+
expect(html).not.toContain('All pods')
|
|
70
|
+
|
|
71
|
+
const empty = renderDiagram({ podSelector: {} })
|
|
72
|
+
expect(empty).toContain('All pods')
|
|
73
|
+
expect(empty).not.toContain('all workloads')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -2,6 +2,7 @@ import { Shield, ArrowDownToLine, ArrowUpFromLine, GitFork } from 'lucide-react'
|
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import { Section, LabelSelectorDisplay } from '../../ui/drawer-components'
|
|
4
4
|
import { NetworkPolicyDiagram } from './NetworkPolicyDiagram'
|
|
5
|
+
import { effectivePolicyTypeNames, effectivePolicyTypes, formatNetworkPolicyPort } from '../../../utils/network-policy'
|
|
5
6
|
|
|
6
7
|
interface NetworkPolicyRendererProps {
|
|
7
8
|
data: any
|
|
@@ -11,12 +12,13 @@ interface NetworkPolicyRendererProps {
|
|
|
11
12
|
export function NetworkPolicyRenderer({ data, staged = false }: NetworkPolicyRendererProps) {
|
|
12
13
|
const spec = data.spec || {}
|
|
13
14
|
const podSelector = spec.podSelector || {}
|
|
14
|
-
const policyTypes: string[] = spec.policyTypes || []
|
|
15
15
|
const ingress: any[] | undefined = spec.ingress
|
|
16
16
|
const egress: any[] | undefined = spec.egress
|
|
17
17
|
|
|
18
|
-
const
|
|
19
|
-
const
|
|
18
|
+
const types = effectivePolicyTypes(spec)
|
|
19
|
+
const policyTypes = effectivePolicyTypeNames(spec)
|
|
20
|
+
const hasIngress = types.ingress
|
|
21
|
+
const hasEgress = types.egress
|
|
20
22
|
|
|
21
23
|
const hasDiagramContent = hasIngress || hasEgress
|
|
22
24
|
|
|
@@ -33,26 +35,29 @@ export function NetworkPolicyRenderer({ data, staged = false }: NetworkPolicyRen
|
|
|
33
35
|
<div className="text-xs text-theme-text-tertiary mb-1">Pod Selector</div>
|
|
34
36
|
<LabelSelectorDisplay selector={podSelector} emptyText="All pods in namespace" />
|
|
35
37
|
</div>
|
|
36
|
-
|
|
37
|
-
<div className="
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
38
|
+
<div className="mt-2">
|
|
39
|
+
<div className="text-xs text-theme-text-tertiary mb-1">Policy Types</div>
|
|
40
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
41
|
+
{policyTypes.map((type) => (
|
|
42
|
+
<span
|
|
43
|
+
key={type}
|
|
44
|
+
className={clsx(
|
|
45
|
+
'badge',
|
|
46
|
+
type === 'Ingress'
|
|
47
|
+
? 'status-blue'
|
|
48
|
+
: 'status-purple'
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
{type}
|
|
52
|
+
</span>
|
|
53
|
+
))}
|
|
54
|
+
{!types.explicit && (
|
|
55
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
56
|
+
by default — policyTypes not set
|
|
57
|
+
</span>
|
|
58
|
+
)}
|
|
54
59
|
</div>
|
|
55
|
-
|
|
60
|
+
</div>
|
|
56
61
|
</Section>
|
|
57
62
|
|
|
58
63
|
{hasIngress && (
|
|
@@ -118,7 +123,7 @@ function IngressEgressRuleCard({
|
|
|
118
123
|
key={j}
|
|
119
124
|
className="badge bg-theme-elevated text-theme-text-secondary"
|
|
120
125
|
>
|
|
121
|
-
{port
|
|
126
|
+
{formatNetworkPolicyPort(port)}
|
|
122
127
|
</span>
|
|
123
128
|
))}
|
|
124
129
|
</div>
|
|
@@ -135,20 +140,29 @@ function IngressEgressRuleCard({
|
|
|
135
140
|
}
|
|
136
141
|
|
|
137
142
|
function PeerEntry({ peer }: { peer: any }) {
|
|
138
|
-
|
|
143
|
+
// podSelector and namespaceSelector on one peer are ANDed: the peer is the
|
|
144
|
+
// pods matching the first inside the namespaces matching the second. Both
|
|
145
|
+
// have to be shown, or a narrow cross-namespace grant reads as a broad
|
|
146
|
+
// same-namespace one.
|
|
147
|
+
const hasPod = peer.podSelector !== undefined
|
|
148
|
+
const hasNs = peer.namespaceSelector !== undefined
|
|
149
|
+
if (hasPod || hasNs) {
|
|
139
150
|
return (
|
|
140
|
-
<div className="text-sm">
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
151
|
+
<div className="text-sm space-y-0.5">
|
|
152
|
+
{hasPod && (
|
|
153
|
+
<div>
|
|
154
|
+
<span className="text-theme-text-secondary text-xs">podSelector: </span>
|
|
155
|
+
<LabelSelectorDisplay selector={peer.podSelector} emptyText="all pods" inline />
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
{hasNs && (
|
|
159
|
+
<div>
|
|
160
|
+
<span className="text-theme-text-secondary text-xs">
|
|
161
|
+
{hasPod ? 'in namespaceSelector: ' : 'namespaceSelector: '}
|
|
162
|
+
</span>
|
|
163
|
+
<LabelSelectorDisplay selector={peer.namespaceSelector} emptyText="all namespaces" inline />
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
152
166
|
</div>
|
|
153
167
|
)
|
|
154
168
|
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { resolvedEnvFromKey } from '../../../utils/env-from'
|
|
15
15
|
import { detectBlastRadius, rulePermissivenessScore } from '../../../utils/rbac-blast-radius'
|
|
16
16
|
import { RBACErrorSection, isRBACUnavailable } from './RBACErrorSection'
|
|
17
|
+
import { NamespaceLimitRangeLink } from './LimitRangeRenderer'
|
|
17
18
|
import type {
|
|
18
19
|
PodEnvironmentResponse,
|
|
19
20
|
PodEnvironmentRevealResponse,
|
|
@@ -114,6 +115,9 @@ interface PodRendererProps {
|
|
|
114
115
|
policyData?: PolicyResourceResponse | null
|
|
115
116
|
policyLoading?: boolean
|
|
116
117
|
policyError?: Error | null
|
|
118
|
+
/** Names of the LimitRanges in this Pod's namespace. Absent while the lookup
|
|
119
|
+
* is pending or unreadable, which leaves the contextual link unrendered. */
|
|
120
|
+
namespaceLimitRangeNames?: string[] | null
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
// ── Env vars section — extracted to use hooks (useState for reveal) ──────────
|
|
@@ -296,6 +300,7 @@ export function PodRenderer({
|
|
|
296
300
|
policyData,
|
|
297
301
|
policyLoading,
|
|
298
302
|
policyError,
|
|
303
|
+
namespaceLimitRangeNames,
|
|
299
304
|
}: PodRendererProps) {
|
|
300
305
|
const containerStatuses = data.status?.containerStatuses || []
|
|
301
306
|
const containers = data.spec?.containers || []
|
|
@@ -763,6 +768,12 @@ export function PodRenderer({
|
|
|
763
768
|
)
|
|
764
769
|
})}
|
|
765
770
|
</div>
|
|
771
|
+
<NamespaceLimitRangeLink
|
|
772
|
+
namespace={namespace || ''}
|
|
773
|
+
names={namespaceLimitRangeNames}
|
|
774
|
+
scope="pod"
|
|
775
|
+
onNavigate={onNavigate}
|
|
776
|
+
/>
|
|
766
777
|
</Section>
|
|
767
778
|
|
|
768
779
|
{/* Environment Variables */}
|