@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
@@ -13,6 +13,7 @@ export interface GitOpsInsight {
13
13
  }
14
14
 
15
15
  import type { GitOpsTool } from './gitops'
16
+ import type { GitOpsHealthMode, GitOpsHealthSource } from './gitops-tree'
16
17
 
17
18
  // Closed enums mirroring `pkg/gitops/insights/vocab.go`. Keeping the FE
18
19
  // vocabulary in lockstep with the Go side means switches over these fields
@@ -79,6 +80,19 @@ export interface GitOpsInsightSummary {
79
80
  // (jqPathExpressions / managedFieldsManagers rules) — the
80
81
  // drift panel may surface fields Argo's own UI suppresses.
81
82
  ignoredDifferences?: GitOpsIgnoredDifferences
83
+ // Argo roots: where Argo keeps per-resource health. 'appTree' means the
84
+ // controller's verdicts are not in the Application object and any
85
+ // per-resource health shown is Radar's own read.
86
+ resourceHealthMode?: GitOpsHealthMode
87
+ // The Application deploys to another cluster; Radar derives nothing about
88
+ // its resources from here.
89
+ remoteDestination?: boolean
90
+ // Per-resource health came from Argo CD's API server: the verdicts shown
91
+ // are Argo's even though the Application object doesn't carry them.
92
+ resourceHealthFromApi?: boolean
93
+ // The controller's API server was asked and didn't answer usefully — why,
94
+ // in the user's words. Only set when the integration is configured.
95
+ resourceHealthApiError?: string
82
96
  }
83
97
 
84
98
  export interface GitOpsIgnoredDifferences {
@@ -116,6 +130,10 @@ export interface GitOpsIssue {
116
130
  // a contextual action button. Nil when no automated remedy applies — the
117
131
  // `action` string still describes the manual path in that case.
118
132
  remediation?: GitOpsRemediation
133
+ // Set on resource-scoped issues: 'controller' when the GitOps controller's
134
+ // own per-resource health produced it, 'radar' when Radar's issues engine
135
+ // did because the controller's verdict wasn't available.
136
+ source?: GitOpsHealthSource
119
137
  }
120
138
 
121
139
  export type GitOpsRemediationKind = 'create-namespace'
@@ -131,6 +149,11 @@ export interface GitOpsChange {
131
149
  category: GitOpsCategory
132
150
  sync?: string
133
151
  health?: string
152
+ // Provenance of `health`; see GitOpsTreeNode.healthSource. `message` holds
153
+ // the health message from whichever source produced `health`.
154
+ healthSource?: GitOpsHealthSource
155
+ healthReason?: string
156
+ healthSeverity?: string
134
157
  message?: string
135
158
  // Per-resource sync failure message (Argo's status.resources[].syncResult).
136
159
  // Distinct from `message` (live health). Empty when sync succeeded.
@@ -24,6 +24,16 @@ export interface GitOpsTreeNode {
24
24
  tool: GitOpsTreeTool
25
25
  sync?: SyncStatus
26
26
  health?: GitOpsHealthStatus
27
+ // Who assessed `health`. 'controller' is the GitOps controller's own
28
+ // verdict; 'radar' is Radar's read of the live object (topology status, or
29
+ // the issues engine's finding when the controller's verdict isn't
30
+ // available). The UI labels 'radar' values so they never pass as the
31
+ // controller's. Absent when `health` is absent. Kept open for sources a
32
+ // newer backend may add — unknown values render without a label.
33
+ healthSource?: GitOpsHealthSource
34
+ healthReason?: string
35
+ healthMessage?: string
36
+ healthSeverity?: 'critical' | 'warning' | (string & {})
27
37
  topologyStatus?: HealthStatus
28
38
  info?: GitOpsTreeInfoItem[]
29
39
  resource?: unknown
@@ -32,6 +42,16 @@ export interface GitOpsTreeNode {
32
42
  data?: Record<string, unknown>
33
43
  }
34
44
 
45
+ // 'controllerApi' is the controller's own verdict read from its API server
46
+ // (argocd-server) because the CR doesn't carry it; same authority as
47
+ // 'controller'.
48
+ export type GitOpsHealthSource = 'controller' | 'controllerApi' | 'radar' | (string & {})
49
+
50
+ // Where an Argo CD Application keeps per-resource health. 'appTree' is the
51
+ // Argo CD 3 default: the controller's per-resource verdicts are not in the
52
+ // Application object, so any node health present came from Radar.
53
+ export type GitOpsHealthMode = 'inline' | 'appTree' | (string & {})
54
+
35
55
  export type GitOpsTreeEdgeType = 'owns' | 'source' | 'dependsOn'
36
56
 
37
57
  export interface GitOpsTreeEdge {
@@ -54,4 +74,13 @@ export interface GitOpsResourceTree {
54
74
  edges: GitOpsTreeEdge[]
55
75
  warnings?: string[]
56
76
  summary?: GitOpsTreeSummary
77
+ healthMode?: GitOpsHealthMode
78
+ // Per-resource health came from the controller's API server, so in
79
+ // appTree mode the verdicts are still the controller's.
80
+ healthFromApi?: boolean
81
+ // The controller's API server was asked and didn't answer usefully.
82
+ healthApiError?: string
83
+ // The Application deploys to another cluster; Radar derives nothing about
84
+ // its resources from here.
85
+ remoteDestination?: boolean
57
86
  }
@@ -1,27 +1,57 @@
1
1
  /**
2
2
  * Central animation presets for consistent motion across the UI.
3
3
  *
4
- * All animations use GPU-composited properties only (translate, scale, opacity)
5
- * to guarantee 60fps. The iOS-like spring easing gives a fast-in, gentle-out feel.
4
+ * One curve, a handful of durations, and class fragments that apply them.
5
+ * Every surface that opens, closes, expands or collapses takes its timing
6
+ * from here — the hub (radar-hub-web) re-exports this module so Radar Cloud
7
+ * chrome and the embedded Radar views move alike.
8
+ *
9
+ * Property choice: geometry that has to move (grid-template-rows on a
10
+ * disclosure, width on a drawer) is animated directly; everything else is
11
+ * transform + opacity so it stays GPU-composited.
6
12
  */
7
13
 
8
14
  // -- Easing ------------------------------------------------------------------
9
15
 
16
+ /** The one UI curve. iOS-style decelerate: fast out of the gate, gentle
17
+ * settle. Used for disclosures, menus, dialogs, sheets and drawers alike so
18
+ * a click reads as the cause of the motion and the content is legible early.
19
+ * Chosen over Material's standard curve in a side-by-side on real content. */
20
+ export const CSS_EASE = 'cubic-bezier(0.32, 0.72, 0, 1)'
21
+ /** Alias with the intent in the name; prefer it in new code. */
22
+ export const EASE_UI = CSS_EASE
23
+
10
24
  // Tailwind-safe easing (no spaces — Tailwind splits class strings on whitespace)
11
25
  const TW_EASE = 'ease-[cubic-bezier(0.32,0.72,0,1)]'
12
-
13
- /** Raw CSS easing for inline transition strings */
14
- export const CSS_EASE = 'cubic-bezier(0.32, 0.72, 0, 1)'
26
+ /** The same curve as a Tailwind class fragment, for class-string composition. */
27
+ export const TW_EASE_UI = TW_EASE
15
28
 
16
29
  // -- Durations (ms) ----------------------------------------------------------
17
30
 
18
- /** Standard UI transitions: overlays, panels, toasts */
31
+ /** Standard UI transitions: drawers, toasts. */
19
32
  export const DURATION_NORMAL = 300
20
- /** Dock height, subtle layout shifts */
33
+ /** Dock height, subtle layout shifts. Deliberately fast: the dock is direct
34
+ * manipulation the user performs dozens of times a day. */
21
35
  export const DURATION_DOCK = 150
22
36
  /** Toast exit animation */
23
37
  export const DURATION_TOAST_EXIT = 200
24
38
 
39
+ /** In-flow disclosure (Collapse / drawer sections / row expanders): panel
40
+ * and caret share this, open and close alike. Symmetric on purpose — an
41
+ * interrupted toggle reverses cleanly when both directions run one clock. */
42
+ export const DURATION_DISCLOSURE = 300
43
+
44
+ /** Menus, popovers, listboxes. Exits are shorter than entrances: a dismissal
45
+ * should feel immediate. */
46
+ export const DURATION_MENU_IN = 140
47
+ export const DURATION_MENU_OUT = 100
48
+ /** Modal dialogs and command palettes. */
49
+ export const DURATION_DIALOG_IN = 220
50
+ export const DURATION_DIALOG_OUT = 160
51
+ /** Bottom sheets: more travel, a little more time. */
52
+ export const DURATION_SHEET_IN = 280
53
+ export const DURATION_SHEET_OUT = 220
54
+
25
55
  /** Drawer ↔ fullscreen expand/collapse morph. Longer than DURATION_NORMAL so a
26
56
  * large width change advances in small per-frame steps (reads as fluid, not
27
57
  * abrupt). Drives the frame width, the content crossfade, and the JS window
@@ -32,19 +62,69 @@ export const DURATION_DRAWER_MORPH = 260
32
62
  * crossfade fixed the reflow that originally needed the gentle start. */
33
63
  export const EASE_DRAWER_MORPH = 'cubic-bezier(0.2, 0.8, 0.2, 1)'
34
64
 
65
+ // -- Reduced motion -----------------------------------------------------------
66
+
67
+ /** True when the OS asks for reduced motion. Presence hooks use it to dispose
68
+ * overlays at logical close instead of after an exit transition that the app
69
+ * stylesheet has already zeroed. SSR- and test-safe. */
70
+ export function prefersReducedMotion(): boolean {
71
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
72
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches
73
+ }
74
+
35
75
  // -- Tailwind class presets ---------------------------------------------------
36
76
  // Reusable class fragments — import and spread into clsx() calls.
37
77
 
78
+ /** Disclosure panel: grid-template-rows 0fr ↔ 1fr on the wrapper. Pair with
79
+ * TRANSITION_CHEVRON on the caret so both run one clock. */
80
+ export const TRANSITION_DISCLOSURE =
81
+ `transition-[grid-template-rows] duration-300 ${TW_EASE} motion-reduce:transition-none`
82
+
83
+ /** Disclosure caret (rotate). Same duration and curve as the panel it opens. */
84
+ export const TRANSITION_CHEVRON =
85
+ `transition-transform duration-300 ${TW_EASE} motion-reduce:transition-none`
86
+
38
87
  /** Drawer slide from right — use with translate-x-0/translate-x-full + opacity.
39
88
  * Also handles expand/collapse (width) so a single transition covers both. */
40
89
  export const TRANSITION_DRAWER =
41
- `transition-[translate,opacity,width] duration-300 ${TW_EASE} will-change-[transform,width]`
90
+ `transition-[translate,opacity,width] duration-300 ${TW_EASE} will-change-[transform,width] motion-reduce:transition-none`
42
91
 
43
-
44
- /** Overlay backdrop fade */
92
+ /** Overlay backdrop fade. Duration comes from the presence state (see
93
+ * overlayTransitionStyle) so the exit can be shorter than the entrance. */
45
94
  export const TRANSITION_BACKDROP =
46
- 'transition-opacity duration-200'
95
+ `transition-opacity ${TW_EASE} motion-reduce:transition-none`
47
96
 
48
- /** Overlay panel scale + fade — use with scale-100/scale-[0.97] + opacity */
97
+ /** Overlay panel scale + fade — use with scale-100/scale-[0.97] + opacity.
98
+ * Duration comes from overlayTransitionStyle, not from this fragment. */
49
99
  export const TRANSITION_PANEL =
50
- `transition-[translate,scale,opacity] duration-300 ${TW_EASE}`
100
+ `transition-[translate,scale,opacity] ${TW_EASE} motion-reduce:transition-none`
101
+
102
+ /** Menu / popover: translate + scale + opacity from its anchor corner. Pair
103
+ * with a transform-origin utility so it grows from the trigger. */
104
+ export const TRANSITION_MENU =
105
+ `transition-[translate,scale,opacity] ${TW_EASE} motion-reduce:transition-none`
106
+
107
+ export type OverlayKind = 'menu' | 'dialog' | 'sheet' | 'drawer'
108
+
109
+ const OVERLAY_DURATIONS: Record<OverlayKind, { in: number; out: number }> = {
110
+ menu: { in: DURATION_MENU_IN, out: DURATION_MENU_OUT },
111
+ dialog: { in: DURATION_DIALOG_IN, out: DURATION_DIALOG_OUT },
112
+ sheet: { in: DURATION_SHEET_IN, out: DURATION_SHEET_OUT },
113
+ // Side drawers keep their symmetric slide (TRANSITION_DRAWER); this entry
114
+ // gives their backdrop the same clock.
115
+ drawer: { in: DURATION_NORMAL, out: DURATION_NORMAL },
116
+ }
117
+
118
+ /** Exit duration for an overlay kind — what useAnimatedUnmount should wait. */
119
+ export function overlayExitMs(kind: OverlayKind): number {
120
+ return OVERLAY_DURATIONS[kind].out
121
+ }
122
+
123
+ /** Inline style that gives an overlay its entrance or exit duration. Apply to
124
+ * the element that carries TRANSITION_PANEL / TRANSITION_MENU / TRANSITION_BACKDROP;
125
+ * the class fragments deliberately carry no duration so the two directions
126
+ * can differ. */
127
+ export function overlayTransitionStyle(isOpen: boolean, kind: OverlayKind): { transitionDuration: string } {
128
+ const d = OVERLAY_DURATIONS[kind]
129
+ return { transitionDuration: `${isOpen ? d.in : d.out}ms` }
130
+ }
@@ -30,3 +30,4 @@ export * from './application-history'
30
30
  export * from './bulk-workload-actions'
31
31
  export * from './inClusterConsent'
32
32
  export * from './workflow-execution'
33
+ export * from './network-policy'
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect, afterEach } from 'vitest'
2
2
  import { englishPlural } from './pluralize'
3
- import { kindToPlural, kindToPluralWithGroup, pluralToKind, refToSelectedResource, initNavigationMap, resetNavigationMap, laneId, laneResourceKey, groupQualifiesLaneId, parseLaneId } from './navigation'
3
+ import { kindToPlural, kindToPluralWithGroup, pluralToKind, knownKindForPluralWithGroup, refToSelectedResource, initNavigationMap, resetNavigationMap, laneId, laneResourceKey, groupQualifiesLaneId, parseLaneId } from './navigation'
4
4
 
5
5
  afterEach(() => {
6
6
  resetNavigationMap()
@@ -223,6 +223,25 @@ describe('initNavigationMap', () => {
223
223
  { group: 'metrics.k8s.io', version: 'v1beta1', kind: 'PodMetrics', name: 'pods', namespaced: true, isCrd: false, verbs: ['get'] },
224
224
  ])
225
225
  expect(pluralToKind('pods')).toBe('Pod')
226
+ expect(knownKindForPluralWithGroup('pods', '')).toBe('Pod')
227
+ expect(knownKindForPluralWithGroup('pods', 'metrics.k8s.io')).toBe('PodMetrics')
228
+ expect(kindToPluralWithGroup('PodMetrics', 'metrics.k8s.io')).toBe('pods')
229
+ })
230
+
231
+ test('resolves discovered plural collisions by API group', () => {
232
+ initNavigationMap([
233
+ { group: 'a.example.io', version: 'v1', kind: 'Widget', name: 'widgets', namespaced: true, isCrd: true, verbs: ['get'] },
234
+ { group: 'b.example.io', version: 'v1', kind: 'OtherWidget', name: 'widgets', namespaced: true, isCrd: true, verbs: ['get'] },
235
+ ])
236
+ expect(knownKindForPluralWithGroup('widgets', 'a.example.io')).toBe('Widget')
237
+ expect(knownKindForPluralWithGroup('widgets', 'b.example.io')).toBe('OtherWidget')
238
+ expect(knownKindForPluralWithGroup('OtherWidget', 'b.example.io')).toBe('OtherWidget')
239
+ })
240
+
241
+ test('does not invent a Kind for an undiscovered irregular CRD', () => {
242
+ expect(knownKindForPluralWithGroup('databases', 'postgresql.cnpg.io')).toBeUndefined()
243
+ expect(kindToPluralWithGroup('Database', 'postgresql.cnpg.io')).toBe('databases')
244
+ expect(kindToPluralWithGroup('databases', 'postgresql.cnpg.io')).toBe('databases')
226
245
  })
227
246
 
228
247
  test('keeps the virtual PodGroup alias distinct from scheduling PodGroup', () => {
@@ -1,5 +1,6 @@
1
1
  import type { SelectedResource, ResourceRef, APIResource } from '../types/core'
2
2
  import { englishPlural, englishSingular, isEnglishPlural } from './pluralize'
3
+ import { CORE_RESOURCES } from './api-resources'
3
4
 
4
5
  /**
5
6
  * Canonical callback type for navigating to a resource.
@@ -44,6 +45,15 @@ const BUILTIN_PLURAL_TO_KIND: Record<string, string> = {
44
45
 
45
46
  const BUILTIN_GROUP_KIND_TO_PLURAL: Record<string, string> = {
46
47
  'scheduling.k8s.io/podgroup': 'podgroups',
48
+ 'metrics.k8s.io/podmetrics': 'pods',
49
+ 'metrics.k8s.io/nodemetrics': 'nodes',
50
+ }
51
+
52
+ const BUILTIN_GROUP_PLURAL_TO_KIND: Record<string, string> = {
53
+ ...Object.fromEntries(CORE_RESOURCES.map(r => [`${r.group}/${r.name}`, r.kind])),
54
+ 'metrics.k8s.io/pods': 'PodMetrics',
55
+ 'metrics.k8s.io/nodes': 'NodeMetrics',
56
+ 'scheduling.k8s.io/podgroups': 'PodGroup',
47
57
  }
48
58
 
49
59
  // Dynamic map built from API discovery — populated by initNavigationMap().
@@ -51,6 +61,7 @@ const BUILTIN_GROUP_KIND_TO_PLURAL: Record<string, string> = {
51
61
  let discoveredPluralToKind: Record<string, string> | null = null
52
62
  let discoveredKindToPlural: Record<string, string> | null = null
53
63
  let discoveredGroupKindToPlural: Record<string, string> | null = null
64
+ let discoveredGroupPluralToKind: Record<string, string> | null = null
54
65
 
55
66
  /**
56
67
  * Initialize navigation maps from discovered API resources.
@@ -61,6 +72,7 @@ export function initNavigationMap(resources: APIResource[]) {
61
72
  const p2k: Record<string, string> = { ...BUILTIN_PLURAL_TO_KIND }
62
73
  const k2p: Record<string, string> = {}
63
74
  const gk2p: Record<string, string> = {}
75
+ const gp2k: Record<string, string> = { ...BUILTIN_GROUP_PLURAL_TO_KIND }
64
76
  for (const r of resources) {
65
77
  const plural = r.name.toLowerCase()
66
78
  // First-wins on plurals: BUILTIN_PLURAL_TO_KIND seeds canonical core mappings
@@ -69,10 +81,13 @@ export function initNavigationMap(resources: APIResource[]) {
69
81
  if (!(plural in p2k)) p2k[plural] = r.kind
70
82
  k2p[r.kind.toLowerCase()] = plural
71
83
  gk2p[`${r.group}/${r.kind.toLowerCase()}`] = plural
84
+ const groupPlural = `${r.group}/${plural}`
85
+ if (!(groupPlural in gp2k)) gp2k[groupPlural] = r.kind
72
86
  }
73
87
  discoveredPluralToKind = p2k
74
88
  discoveredKindToPlural = k2p
75
89
  discoveredGroupKindToPlural = gk2p
90
+ discoveredGroupPluralToKind = gp2k
76
91
  }
77
92
 
78
93
  /** Reset navigation maps to builtin-only state. For testing. */
@@ -80,6 +95,7 @@ export function resetNavigationMap() {
80
95
  discoveredPluralToKind = null
81
96
  discoveredKindToPlural = null
82
97
  discoveredGroupKindToPlural = null
98
+ discoveredGroupPluralToKind = null
83
99
  }
84
100
 
85
101
  function getPluralToKind(): Record<string, string> {
@@ -166,6 +182,15 @@ export function pluralToKind(plural: string): string {
166
182
  return singular.charAt(0).toUpperCase() + singular.slice(1)
167
183
  }
168
184
 
185
+ /** Return an exact Kind only when the input or group-qualified discovery proves it. */
186
+ export function knownKindForPluralWithGroup(plural: string, group: string): string | undefined {
187
+ if (!plural) return undefined
188
+ if (plural[0] !== plural[0].toLowerCase()) return plural
189
+ const groupPlural = `${group}/${plural.toLowerCase()}`
190
+ return discoveredGroupPluralToKind?.[groupPlural]
191
+ ?? BUILTIN_GROUP_PLURAL_TO_KIND[groupPlural]
192
+ }
193
+
169
194
  /**
170
195
  * Convert a ResourceRef (from backend relationships) to a SelectedResource (for navigation).
171
196
  * Handles kind singular→plural conversion.
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { effectivePolicyTypeNames, effectivePolicyTypes, formatNetworkPolicyPort } from './network-policy'
3
+
4
+ describe('effectivePolicyTypes', () => {
5
+ it('reads an explicit list as authoritative', () => {
6
+ expect(effectivePolicyTypes({ policyTypes: ['Egress'] })).toEqual({ ingress: false, egress: true, explicit: true })
7
+ expect(effectivePolicyTypes({ policyTypes: ['Ingress', 'Egress'], egress: [] })).toEqual({ ingress: true, egress: true, explicit: true })
8
+ })
9
+
10
+ it('defaults an omitted list to Ingress — the classic default-deny', () => {
11
+ expect(effectivePolicyTypes({ podSelector: {} })).toEqual({ ingress: true, egress: false, explicit: false })
12
+ expect(effectivePolicyTypes({ policyTypes: [] })).toEqual({ ingress: true, egress: false, explicit: false })
13
+ expect(effectivePolicyTypes(undefined)).toEqual({ ingress: true, egress: false, explicit: false })
14
+ })
15
+
16
+ it('adds Egress to the default only when egress rules are declared', () => {
17
+ expect(effectivePolicyTypes({ egress: [{}] })).toEqual({ ingress: true, egress: true, explicit: false })
18
+ expect(effectivePolicyTypes({ egress: [] })).toEqual({ ingress: true, egress: false, explicit: false })
19
+ })
20
+
21
+ it('names the effective types as Ingress then Egress', () => {
22
+ expect(effectivePolicyTypeNames({})).toEqual(['Ingress'])
23
+ expect(effectivePolicyTypeNames({ egress: [{}] })).toEqual(['Ingress', 'Egress'])
24
+ expect(effectivePolicyTypeNames({ policyTypes: ['Egress'] })).toEqual(['Egress'])
25
+ expect(effectivePolicyTypeNames({ policyTypes: ['Egress', 'Ingress'] })).toEqual(['Ingress', 'Egress'])
26
+ })
27
+ })
28
+
29
+ describe('formatNetworkPolicyPort', () => {
30
+ it('renders single ports, ranges, named ports and protocol-only entries', () => {
31
+ expect(formatNetworkPolicyPort({ port: 80 })).toBe('TCP/80')
32
+ expect(formatNetworkPolicyPort({ protocol: 'UDP', port: 53 })).toBe('UDP/53')
33
+ expect(formatNetworkPolicyPort({ port: 9000, endPort: 9100 })).toBe('TCP/9000-9100')
34
+ expect(formatNetworkPolicyPort({ port: 'metrics' })).toBe('TCP/metrics')
35
+ expect(formatNetworkPolicyPort({ protocol: 'SCTP' })).toBe('SCTP/*')
36
+ })
37
+
38
+ it('never shows a range off a named port or off no port', () => {
39
+ expect(formatNetworkPolicyPort({ port: 'metrics', endPort: 9100 })).toBe('TCP/metrics')
40
+ expect(formatNetworkPolicyPort({ endPort: 9100 })).toBe('TCP/*')
41
+ })
42
+ })
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Core networking.k8s.io/v1 NetworkPolicy presentation helpers, shared by the
3
+ * list column, the detail renderer and the flow diagram so the three cannot
4
+ * disagree about what a policy does.
5
+ */
6
+
7
+ export interface EffectivePolicyTypes {
8
+ ingress: boolean
9
+ egress: boolean
10
+ /** True when spec.policyTypes was set; false when Kubernetes' defaulting decided. */
11
+ explicit: boolean
12
+ }
13
+
14
+ /**
15
+ * Kubernetes' policyTypes defaulting, applied exactly: when spec.policyTypes
16
+ * is set it is authoritative; when omitted, a policy always isolates Ingress
17
+ * and additionally isolates Egress iff it declares egress rules. The common
18
+ * default-deny (`podSelector: {}` and nothing else) has no policyTypes and
19
+ * isolates ingress — reading the field raw shows it as isolating nothing.
20
+ */
21
+ export function effectivePolicyTypes(spec: any): EffectivePolicyTypes {
22
+ const declared: unknown = spec?.policyTypes
23
+ if (Array.isArray(declared) && declared.length > 0) {
24
+ return {
25
+ ingress: declared.includes('Ingress'),
26
+ egress: declared.includes('Egress'),
27
+ explicit: true,
28
+ }
29
+ }
30
+ const egressRules: unknown = spec?.egress
31
+ return {
32
+ ingress: true,
33
+ egress: Array.isArray(egressRules) && egressRules.length > 0,
34
+ explicit: false,
35
+ }
36
+ }
37
+
38
+ /** The effective types as the list they would have been declared as. */
39
+ export function effectivePolicyTypeNames(spec: any): string[] {
40
+ const t = effectivePolicyTypes(spec)
41
+ const out: string[] = []
42
+ if (t.ingress) out.push('Ingress')
43
+ if (t.egress) out.push('Egress')
44
+ return out
45
+ }
46
+
47
+ /**
48
+ * One NetworkPolicyPort as `PROTO/port`, `PROTO/start-end` for an endPort
49
+ * range, or `PROTO/*` when the entry names a protocol but no port.
50
+ */
51
+ export function formatNetworkPolicyPort(port: any): string {
52
+ const proto = port?.protocol || 'TCP'
53
+ const start = port?.port ?? '*'
54
+ const end = port?.endPort
55
+ // endPort only ever pairs with a numeric port; a range off a named port or
56
+ // off no port is not a range Kubernetes would accept, so it isn't shown as one.
57
+ const isRange = end != null && typeof start === 'number'
58
+ return isRange ? `${proto}/${start}-${end}` : `${proto}/${start}`
59
+ }
@@ -0,0 +1,139 @@
1
+ import type { Relationships } from "../types";
2
+
3
+ const prefix = "reflector.v1.k8s.emberstack.com/";
4
+ const annotationNames = [
5
+ "reflects",
6
+ "auto-reflects",
7
+ "reflected-version",
8
+ "reflected-at",
9
+ "reflection-allowed",
10
+ "reflection-auto-enabled",
11
+ "reflection-allowed-namespaces",
12
+ "reflection-allowed-namespaces-selector",
13
+ "reflection-auto-namespaces",
14
+ "reflection-auto-namespaces-selector",
15
+ ];
16
+
17
+ export type Reflection = NonNullable<Relationships["reflection"]>;
18
+ export interface ReflectorResource {
19
+ apiVersion?: string;
20
+ metadata?: {
21
+ name?: string;
22
+ namespace?: string;
23
+ annotations?: Record<string, string>;
24
+ };
25
+ }
26
+
27
+ function annotationsFor(data: ReflectorResource): Record<string, string> {
28
+ return data.apiVersion === "v1" ? (data.metadata?.annotations ?? {}) : {};
29
+ }
30
+
31
+ export function hasReflectorDetails(
32
+ data: ReflectorResource,
33
+ reflection?: Reflection,
34
+ ): boolean {
35
+ if (data.apiVersion !== "v1") return false;
36
+ const annotations = annotationsFor(data);
37
+ return (
38
+ annotationNames.some((name) => prefix + name in annotations) ||
39
+ !!reflection?.source ||
40
+ !!reflection?.mirrors?.length
41
+ );
42
+ }
43
+
44
+ export function isReflectorMirror(data: ReflectorResource): boolean {
45
+ return /^[^/\s]+\/[^/\s]+$/.test(
46
+ annotationsFor(data)[prefix + "reflects"] ?? "",
47
+ );
48
+ }
49
+
50
+ export const reflectorEditNotice =
51
+ "This object is a Reflector mirror. Edit the source for lasting changes. Local edits may persist until the source changes, then be overwritten.";
52
+
53
+ function booleanValue(value: string | undefined): boolean | undefined {
54
+ if (value === undefined) return false;
55
+ if (value.trim().toLowerCase() === "true") return true;
56
+ if (value.trim().toLowerCase() === "false") return false;
57
+ return undefined;
58
+ }
59
+
60
+ export function parseReflector(
61
+ data: ReflectorResource,
62
+ reflection?: Reflection,
63
+ ) {
64
+ const annotations = annotationsFor(data);
65
+ const value = (name: string) => annotations[prefix + name];
66
+ const declaration = value("reflects");
67
+ const hasDeclaration = declaration !== undefined;
68
+ const isMirror = isReflectorMirror(data);
69
+ const allowed = booleanValue(value("reflection-allowed"));
70
+ const autoEnabled = booleanValue(value("reflection-auto-enabled"));
71
+ const automatic = booleanValue(value("auto-reflects"));
72
+ const source =
73
+ reflection?.source &&
74
+ `${reflection.source.namespace}/${reflection.source.name}` === declaration
75
+ ? reflection.source
76
+ : undefined;
77
+ const mirrors = reflection?.mirrors ?? [];
78
+ const sourceSettings =
79
+ !isMirror ||
80
+ annotationNames.some(
81
+ (name) => name.startsWith("reflection-") && value(name) !== undefined,
82
+ ) ||
83
+ mirrors.length > 0;
84
+ const copiedVersion = value("reflected-version");
85
+ const sourceVersion = source ? reflection?.sourceResourceVersion : undefined;
86
+ const warnings: string[] = [];
87
+ for (const name of [
88
+ "reflection-allowed",
89
+ "reflection-auto-enabled",
90
+ "auto-reflects",
91
+ ]) {
92
+ if (booleanValue(value(name)) === undefined)
93
+ warnings.push(
94
+ `${name} must be true or false (currently "${value(name)}").`,
95
+ );
96
+ }
97
+ if (hasDeclaration && !isMirror)
98
+ warnings.push(
99
+ "The reflects annotation must name a source as namespace/name.",
100
+ );
101
+ if (
102
+ declaration &&
103
+ declaration === `${data.metadata?.namespace}/${data.metadata?.name}`
104
+ )
105
+ warnings.push(
106
+ "The reflects annotation points to this object itself. Choose a different source.",
107
+ );
108
+ if (!isMirror && mirrors.length > 0 && allowed === false)
109
+ warnings.push(
110
+ "Reflection is not enabled on this source, but visible mirrors still reference it.",
111
+ );
112
+ if (!isMirror && autoEnabled === true && allowed === false)
113
+ warnings.push(
114
+ "Automatic mirror creation requires reflection-allowed to be true.",
115
+ );
116
+ if (
117
+ isMirror &&
118
+ (mirrors.length > 0 || allowed === true || autoEnabled === true)
119
+ )
120
+ warnings.push(
121
+ "This object is itself a mirror. Reflector does not propagate its updates as source updates to other mirrors.",
122
+ );
123
+
124
+ return {
125
+ value,
126
+ declaration,
127
+ hasDeclaration,
128
+ isMirror,
129
+ allowed,
130
+ autoEnabled,
131
+ automatic,
132
+ source,
133
+ mirrors,
134
+ sourceSettings,
135
+ copiedVersion,
136
+ sourceVersion,
137
+ warnings,
138
+ };
139
+ }