@skyhook-io/k8s-ui 1.14.5 → 1.14.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/package.json +5 -5
  2. package/src/components/applications/ApplicationDetail.tsx +36 -4
  3. package/src/components/applications/ApplicationsView.tsx +2 -1
  4. package/src/components/audit/AuditAlerts.tsx +35 -37
  5. package/src/components/audit/AuditCard.tsx +18 -7
  6. package/src/components/audit/AuditCoveragePresentation.test.tsx +45 -0
  7. package/src/components/audit/AuditFindingsTable.tsx +23 -27
  8. package/src/components/charts/AreaChart.test.tsx +13 -0
  9. package/src/components/charts/AreaChart.tsx +28 -25
  10. package/src/components/charts/PrometheusChartsView.tsx +41 -10
  11. package/src/components/charts/axis.test.ts +16 -0
  12. package/src/components/charts/axis.ts +3 -1
  13. package/src/components/charts/format.ts +4 -0
  14. package/src/components/charts/nearestSample.test.ts +31 -0
  15. package/src/components/charts/nearestSample.ts +15 -0
  16. package/src/components/checks/ChecksView.tsx +76 -28
  17. package/src/components/gitops/GitOpsDetailLayout.tsx +25 -9
  18. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +141 -0
  19. package/src/components/gitops/health-provenance.test.ts +54 -0
  20. package/src/components/gitops/health-provenance.ts +24 -0
  21. package/src/components/gitops/index.ts +2 -0
  22. package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -21
  23. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +59 -8
  24. package/src/components/gitops/tree/merge.test.ts +34 -0
  25. package/src/components/gitops/tree/merge.ts +7 -0
  26. package/src/components/issues/IssuesView.tsx +35 -54
  27. package/src/components/logs/LogCore.tsx +40 -9
  28. package/src/components/resources/ResourcesSidebar.tsx +66 -78
  29. package/src/components/resources/ResourcesView.tsx +67 -2
  30. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  31. package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
  32. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +21 -15
  33. package/src/components/resources/renderers/CompositionRenderer.tsx +30 -10
  34. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +16 -12
  35. package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
  36. package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
  37. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +10 -4
  38. package/src/components/resources/renderers/GenericRenderer.tsx +13 -7
  39. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +6 -4
  40. package/src/components/resources/renderers/LimitRangeRenderer.test.tsx +156 -0
  41. package/src/components/resources/renderers/LimitRangeRenderer.tsx +277 -0
  42. package/src/components/resources/renderers/NamespaceRenderer.tsx +24 -1
  43. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +9 -16
  44. package/src/components/resources/renderers/NetworkPolicyRenderer.test.tsx +75 -0
  45. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +50 -36
  46. package/src/components/resources/renderers/PodRenderer.tsx +11 -0
  47. package/src/components/resources/renderers/PolicyCoverageSection.tsx +7 -4
  48. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +13 -15
  49. package/src/components/resources/renderers/ReflectorSection.test.tsx +281 -0
  50. package/src/components/resources/renderers/ReflectorSection.tsx +296 -0
  51. package/src/components/resources/renderers/RolloutRenderer.test.tsx +117 -0
  52. package/src/components/resources/renderers/RolloutRenderer.tsx +219 -56
  53. package/src/components/resources/renderers/SbomReportRenderer.tsx +10 -4
  54. package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
  55. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +10 -4
  56. package/src/components/resources/renderers/WorkloadRenderer.tsx +11 -1
  57. package/src/components/resources/renderers/index.ts +3 -0
  58. package/src/components/resources/renderers/rollout/CanaryStepTimeline.tsx +199 -0
  59. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +123 -0
  60. package/src/components/resources/resource-utils.ts +18 -9
  61. package/src/components/shared/CreateResourceDialog.tsx +237 -14
  62. package/src/components/shared/DrainPlanDialog.test.tsx +154 -0
  63. package/src/components/shared/DrainPlanDialog.tsx +297 -0
  64. package/src/components/shared/ResourceActionsBar.tsx +53 -26
  65. package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
  66. package/src/components/shared/ResourceRendererDispatch.tsx +27 -6
  67. package/src/components/shared/index.ts +4 -0
  68. package/src/components/timeline/TimelineList.tsx +14 -11
  69. package/src/components/timeline/TimelineStrip.tsx +26 -4
  70. package/src/components/timeline/TimelineSwimlanes.tsx +3 -5
  71. package/src/components/timeline/TimelineToolbar.tsx +43 -10
  72. package/src/components/topology/TopologyControls.tsx +27 -8
  73. package/src/components/topology/TopologyGraph.tsx +14 -5
  74. package/src/components/trace/ReachabilityView.tsx +17 -16
  75. package/src/components/ui/CardSection.tsx +79 -35
  76. package/src/components/ui/Collapse.test.tsx +74 -0
  77. package/src/components/ui/Collapse.tsx +115 -29
  78. package/src/components/ui/DialogPortal.tsx +7 -2
  79. package/src/components/ui/FetchResult.test.tsx +20 -2
  80. package/src/components/ui/FetchResult.tsx +33 -3
  81. package/src/components/ui/ForceDeleteConfirmDialog.tsx +7 -4
  82. package/src/components/ui/FreshnessControl.tsx +8 -2
  83. package/src/components/ui/RestrictedState.tsx +7 -4
  84. package/src/components/ui/RowActionMenu.tsx +20 -8
  85. package/src/components/ui/SelectMenu.tsx +18 -6
  86. package/src/components/ui/YamlEditor.tsx +7 -9
  87. package/src/components/ui/drawer-components.tsx +14 -21
  88. package/src/components/ui/index.ts +30 -22
  89. package/src/components/workload/WorkloadView.tsx +8 -12
  90. package/src/hooks/useAnimatedUnmount.ts +21 -3
  91. package/src/theme/components.css +20 -1
  92. package/src/types/core.ts +15 -1
  93. package/src/types/gitops-insights.ts +23 -0
  94. package/src/types/gitops-tree.ts +29 -0
  95. package/src/utils/animation.ts +93 -13
  96. package/src/utils/index.ts +1 -0
  97. package/src/utils/navigation.test.ts +20 -1
  98. package/src/utils/navigation.ts +25 -0
  99. package/src/utils/network-policy.test.ts +42 -0
  100. package/src/utils/network-policy.ts +59 -0
  101. package/src/utils/reflector.ts +139 -0
  102. package/src/utils/yaml-file-import.test.ts +189 -0
  103. package/src/utils/yaml-file-import.ts +95 -0
  104. package/src/components/logs/JsonLogLine.tsx +0 -93
@@ -1,7 +1,8 @@
1
1
  import { useState } from 'react'
2
- import { Info, CheckCircle, XCircle, AlertCircle, ChevronRight, ChevronDown, FileCode, AlertTriangle, Layers } from 'lucide-react'
2
+ import { Info, CheckCircle, XCircle, AlertCircle, FileCode, AlertTriangle, Layers } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
5
+ import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
5
6
  import { getGenericResourceStatus } from '../generic-status'
6
7
  import type { HealthLevel } from '../resource-utils'
7
8
 
@@ -244,19 +245,23 @@ interface NestedObjectViewerProps {
244
245
 
245
246
  function NestedObjectViewer({ name, value, depth = 0 }: NestedObjectViewerProps) {
246
247
  const [expanded, setExpanded] = useState(depth < 1)
248
+ const { panelId, buttonProps } = useDisclosure(expanded)
247
249
 
248
250
  if (Array.isArray(value)) {
249
251
  return (
250
252
  <div className="text-sm">
251
253
  <button
254
+ {...buttonProps}
252
255
  onClick={() => setExpanded(!expanded)}
253
256
  className="flex items-center gap-1 text-theme-text-secondary hover:text-theme-text-primary transition-colors"
254
257
  >
255
- {expanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
258
+ <CollapseChevron open={expanded} className="w-3.5 h-3.5" />
256
259
  <span className="font-medium">{name}</span>
257
260
  <span className="text-xs text-theme-text-tertiary">({value.length} items)</span>
258
261
  </button>
259
- {expanded && (
262
+ {/* Recursive and unbounded: closed branches unmount so a deep object
263
+ costs only the rows the user has actually opened. */}
264
+ <Collapse open={expanded} unmountOnExit id={panelId}>
260
265
  <div className="ml-5 mt-1 space-y-1">
261
266
  {value.map((item, i) => {
262
267
  if (typeof item === 'object' && item !== null) {
@@ -269,7 +274,7 @@ function NestedObjectViewer({ name, value, depth = 0 }: NestedObjectViewerProps)
269
274
  )
270
275
  })}
271
276
  </div>
272
- )}
277
+ </Collapse>
273
278
  </div>
274
279
  )
275
280
  }
@@ -282,14 +287,15 @@ function NestedObjectViewer({ name, value, depth = 0 }: NestedObjectViewerProps)
282
287
  return (
283
288
  <div className="text-sm">
284
289
  <button
290
+ {...buttonProps}
285
291
  onClick={() => setExpanded(!expanded)}
286
292
  className="flex items-center gap-1 text-theme-text-secondary hover:text-theme-text-primary transition-colors"
287
293
  >
288
- {expanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
294
+ <CollapseChevron open={expanded} className="w-3.5 h-3.5" />
289
295
  <span className="font-medium">{name}</span>
290
296
  <span className="text-xs text-theme-text-tertiary">({entries.length} fields)</span>
291
297
  </button>
292
- {expanded && (
298
+ <Collapse open={expanded} unmountOnExit id={panelId}>
293
299
  <div className="ml-5 mt-1 space-y-1">
294
300
  {simpleEntries.map(([k, v]) => (
295
301
  <div key={k} className="flex items-start gap-2 text-xs">
@@ -301,7 +307,7 @@ function NestedObjectViewer({ name, value, depth = 0 }: NestedObjectViewerProps)
301
307
  <NestedObjectViewer key={k} name={formatFieldName(k)} value={v} depth={depth + 1} />
302
308
  ))}
303
309
  </div>
304
- )}
310
+ </Collapse>
305
311
  </div>
306
312
  )
307
313
  }
@@ -1,8 +1,9 @@
1
1
  import type React from 'react'
2
- import { Shield, ShieldCheck, ShieldAlert, FileWarning, ListChecks, ChevronDown, ChevronRight } from 'lucide-react'
2
+ import { Shield, ShieldCheck, ShieldAlert, FileWarning, ListChecks } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { useState } from 'react'
5
5
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
6
+ import { Collapse, CollapseChevron } from '../../ui/Collapse'
6
7
  import {
7
8
  getPolicyReportSummary,
8
9
  getPolicyReportResults,
@@ -60,7 +61,7 @@ function ResultRow({ result }: { result: any }) {
60
61
  onClick={() => hasMessage && setExpanded(!expanded)}
61
62
  >
62
63
  {hasMessage ? (
63
- expanded ? <ChevronDown className="w-3 h-3 text-theme-text-tertiary shrink-0" /> : <ChevronRight className="w-3 h-3 text-theme-text-tertiary shrink-0" />
64
+ <CollapseChevron open={expanded} className="w-3 h-3" />
64
65
  ) : (
65
66
  <span className="w-3 shrink-0" />
66
67
  )}
@@ -80,7 +81,8 @@ function ResultRow({ result }: { result: any }) {
80
81
  <span className="text-theme-text-tertiary truncate">/ {result.rule}</span>
81
82
  )}
82
83
  </div>
83
- {expanded && message && (
84
+ {/* A report can hold hundreds of rows; each detail renders on first open. */}
85
+ <Collapse open={expanded && hasMessage} mountLazily>
84
86
  <div className="px-2 pb-2 pl-7">
85
87
  <div className="text-xs text-theme-text-secondary break-all card-inner">
86
88
  {message}
@@ -97,7 +99,7 @@ function ResultRow({ result }: { result: any }) {
97
99
  </div>
98
100
  )}
99
101
  </div>
100
- )}
102
+ </Collapse>
101
103
  </div>
102
104
  )
103
105
  }
@@ -0,0 +1,156 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { LimitRangeRenderer, NamespaceLimitRangeLink, NamespaceLimitRangesSection } from './LimitRangeRenderer'
4
+
5
+ function err(message: string, status?: number): Error {
6
+ return Object.assign(new Error(message), status === undefined ? {} : { status })
7
+ }
8
+
9
+ const multiType = {
10
+ apiVersion: 'v1',
11
+ kind: 'LimitRange',
12
+ metadata: { name: 'team-limits', namespace: 'dev' },
13
+ spec: {
14
+ limits: [
15
+ {
16
+ type: 'Container',
17
+ min: { cpu: '10m', memory: '16Mi' },
18
+ max: { cpu: '2', memory: '2Gi' },
19
+ default: { cpu: '500m', memory: '512Mi' },
20
+ defaultRequest: { cpu: '100m', memory: '128Mi' },
21
+ maxLimitRequestRatio: { cpu: '4' },
22
+ },
23
+ { type: 'Pod', max: { cpu: '4' } },
24
+ { type: 'PersistentVolumeClaim', min: { storage: '1Gi' }, max: { storage: '100Gi' } },
25
+ ],
26
+ },
27
+ }
28
+
29
+ describe('LimitRangeRenderer', () => {
30
+ it('renders every declared entry type with its own rules', () => {
31
+ const html = renderToString(<LimitRangeRenderer data={multiType} />)
32
+ expect(html).toContain('Container')
33
+ expect(html).toContain('Pod')
34
+ expect(html).toContain('PersistentVolumeClaim')
35
+ expect(html).toContain('100m')
36
+ expect(html).toContain('2Gi')
37
+ expect(html).toContain('storage')
38
+ })
39
+
40
+ // Units and resource names carry the meaning; a reformatted quantity is a
41
+ // different claim from the one the object makes.
42
+ it('shows quantities verbatim', () => {
43
+ const html = renderToString(<LimitRangeRenderer data={multiType} />)
44
+ expect(html).toContain('16Mi')
45
+ expect(html).toContain('10m')
46
+ expect(html).not.toContain('0.01 cores')
47
+ })
48
+
49
+ // An absent facet is not a limit of zero, and a limit of zero is a real rule.
50
+ it('keeps an unset value distinct from an explicit zero', () => {
51
+ const html = renderToString(<LimitRangeRenderer data={{
52
+ spec: { limits: [{ type: 'Container', min: { cpu: '0' }, max: { cpu: '1', memory: '1Gi' } }] },
53
+ }} />)
54
+ // memory has a max but no min, so its min cell is the unset marker.
55
+ expect(html).toContain('Not set')
56
+ expect(html).toContain('>0<')
57
+ })
58
+
59
+ it('drops facets the entry never declares instead of showing empty columns', () => {
60
+ const html = renderToString(<LimitRangeRenderer data={{
61
+ spec: { limits: [{ type: 'Pod', max: { cpu: '4' } }] },
62
+ }} />)
63
+ expect(html).toContain('Max')
64
+ expect(html).not.toContain('Default request')
65
+ expect(html).not.toContain('Max limit/request')
66
+ })
67
+
68
+ it('states that a LimitRange with no entries constrains nothing', () => {
69
+ const html = renderToString(<LimitRangeRenderer data={{ spec: {} }} />)
70
+ expect(html).toContain('constrains nothing')
71
+ })
72
+ })
73
+
74
+ describe('NamespaceLimitRangesSection', () => {
75
+ it('summarizes each LimitRange and links to it', () => {
76
+ const html = renderToString(
77
+ <NamespaceLimitRangesSection limitRanges={[multiType]} namespace="dev" onNavigate={() => {}} />,
78
+ )
79
+ expect(html).toContain('team-limits')
80
+ expect(html).toContain('Container')
81
+ expect(html).toContain('default request')
82
+ })
83
+
84
+ it('tells an empty namespace apart from one still loading', () => {
85
+ const empty = renderToString(<NamespaceLimitRangesSection limitRanges={[]} namespace="dev" />)
86
+ expect(empty).toContain('Limit Ranges (0)')
87
+ expect(empty).toContain('No LimitRanges found')
88
+ expect(empty).toContain('Other admission policies may still apply')
89
+
90
+ const loading = renderToString(<NamespaceLimitRangesSection loading namespace="dev" />)
91
+ expect(loading).toContain('Loading limit ranges')
92
+ expect(loading).not.toContain('No LimitRanges found')
93
+ })
94
+
95
+ // "You can't see the rules" and "there are no rules" lead to opposite
96
+ // conclusions about what will happen at admission.
97
+ it('reports a denied read rather than implying no rules exist', () => {
98
+ const html = renderToString(
99
+ <NamespaceLimitRangesSection error={err('forbidden', 403)} namespace="dev" />,
100
+ )
101
+ expect(html).toContain('permission')
102
+ expect(html).not.toContain('No LimitRanges found')
103
+ })
104
+
105
+ it('keeps cached rules visible with a stale warning when a refresh fails', () => {
106
+ const html = renderToString(
107
+ <NamespaceLimitRangesSection limitRanges={[multiType]} error={err('unavailable', 500)} namespace="dev" />,
108
+ )
109
+ expect(html).toContain('team-limits')
110
+ expect(html).toContain('Showing previously loaded rules')
111
+ expect(html).toContain('unavailable')
112
+ expect(html).not.toContain('This may be incomplete')
113
+ })
114
+
115
+ it('hides cached rules when access is denied', () => {
116
+ const html = renderToString(
117
+ <NamespaceLimitRangesSection limitRanges={[multiType]} error={err('forbidden', 403)} namespace="dev" />,
118
+ )
119
+ expect(html).toContain('permission')
120
+ expect(html).not.toContain('team-limits')
121
+ expect(html).not.toContain('No LimitRanges found')
122
+ })
123
+
124
+ it('keeps a fetch fault loud', () => {
125
+ const html = renderToString(
126
+ <NamespaceLimitRangesSection error={err('boom', 500)} namespace="dev" />,
127
+ )
128
+ expect(html).toContain('boom')
129
+ expect(html).toContain('text-red-400')
130
+ })
131
+ })
132
+
133
+ describe('NamespaceLimitRangeLink', () => {
134
+ it('links every LimitRange that governs the namespace', () => {
135
+ const html = renderToString(
136
+ <NamespaceLimitRangeLink namespace="dev" names={['team-limits', 'storage-limits']} scope="pod" onNavigate={() => {}} />,
137
+ )
138
+ expect(html).toContain('Namespace defaults')
139
+ expect(html).toContain('team-limits')
140
+ expect(html).toContain('storage-limits')
141
+ })
142
+
143
+ // Nothing to point at yet is not the same as pointing at nothing.
144
+ it('renders nothing when the lookup produced no names', () => {
145
+ expect(renderToString(<NamespaceLimitRangeLink namespace="dev" names={[]} scope="pod" />)).toBe('')
146
+ expect(renderToString(<NamespaceLimitRangeLink namespace="dev" scope="pod" />)).toBe('')
147
+ })
148
+
149
+ it('offers the scope explanation without adding inline prose', () => {
150
+ const html = renderToString(
151
+ <NamespaceLimitRangeLink namespace="dev" names={['team-limits']} scope="workload" onNavigate={() => {}} />,
152
+ )
153
+ expect(html).toContain('About namespace defaults and constraints')
154
+ expect(html).not.toContain('not the workload object itself')
155
+ })
156
+ })
@@ -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 &amp; 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 = policyTypes.includes('Ingress')
29
- const hasEgress = policyTypes.includes('Egress')
30
- const targetLabel = Object.keys(matchLabels).length > 0
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((p: any) => {
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 labels = selector?.matchLabels || {}
172
- if (Object.keys(labels).length === 0) return 'all'
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({