@skyhook-io/k8s-ui 1.12.0 → 1.13.3

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 (117) hide show
  1. package/package.json +4 -4
  2. package/src/components/applications/ApplicationDetail.tsx +2 -2
  3. package/src/components/cluster-switcher/ClusterSwitcher.tsx +32 -7
  4. package/src/components/issues/severity.ts +1 -0
  5. package/src/components/resources/ResourcesView.empty-note.test.ts +41 -0
  6. package/src/components/resources/ResourcesView.tsx +419 -9
  7. package/src/components/resources/index.ts +27 -0
  8. package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
  9. package/src/components/resources/renderers/AnalysisRunRenderer.test.tsx +241 -0
  10. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +166 -0
  11. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +31 -2
  12. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +33 -0
  13. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +47 -4
  14. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +243 -0
  15. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +99 -0
  16. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +235 -0
  17. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +23 -1
  18. package/src/components/resources/renderers/CNPGShared.tsx +27 -0
  19. package/src/components/resources/renderers/CalicoHostEndpointRenderer.tsx +71 -0
  20. package/src/components/resources/renderers/CalicoIPPoolRenderer.tsx +32 -0
  21. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.test.tsx +205 -0
  22. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.tsx +537 -0
  23. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.test.tsx +223 -0
  24. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.tsx +670 -0
  25. package/src/components/resources/renderers/CalicoTierRenderer.tsx +19 -0
  26. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +13 -6
  27. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +3 -2
  28. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.test.ts +31 -0
  29. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +124 -0
  30. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +28 -2
  31. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +303 -0
  32. package/src/components/resources/renderers/LookupFailureNote.test.tsx +60 -0
  33. package/src/components/resources/renderers/LookupFailureNote.tsx +42 -0
  34. package/src/components/resources/renderers/NamespaceRenderer.test.ts +30 -0
  35. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -3
  36. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +27 -26
  37. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
  38. package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +348 -0
  39. package/src/components/resources/renderers/PolicyCoverageSection.tsx +939 -0
  40. package/src/components/resources/renderers/PolicySection.test.tsx +59 -2
  41. package/src/components/resources/renderers/PolicySection.tsx +32 -5
  42. package/src/components/resources/renderers/RolloutRenderer.test.tsx +491 -0
  43. package/src/components/resources/renderers/RolloutRenderer.tsx +391 -92
  44. package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +374 -0
  45. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +179 -3
  46. package/src/components/resources/renderers/VeleroBackupRenderer.test.tsx +221 -0
  47. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +119 -6
  48. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.test.tsx +69 -0
  49. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +96 -0
  50. package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +55 -0
  51. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +22 -4
  52. package/src/components/resources/renderers/VeleroRunMessages.test.tsx +152 -0
  53. package/src/components/resources/renderers/VeleroRunMessages.tsx +139 -0
  54. package/src/components/resources/renderers/VeleroScheduleRenderer.test.tsx +88 -0
  55. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +16 -6
  56. package/src/components/resources/renderers/calico-cells.tsx +177 -0
  57. package/src/components/resources/renderers/cnpg-cells.tsx +100 -0
  58. package/src/components/resources/renderers/index.ts +16 -1
  59. package/src/components/resources/renderers/kyverno-cells.tsx +81 -0
  60. package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
  61. package/src/components/resources/renderers/velero-cells.tsx +29 -7
  62. package/src/components/resources/renderers/velero-dead-ends.test.tsx +142 -0
  63. package/src/components/resources/resource-utils-calico.test.ts +215 -0
  64. package/src/components/resources/resource-utils-calico.ts +261 -0
  65. package/src/components/resources/resource-utils-cnpg-declarative.test.ts +104 -0
  66. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +122 -0
  67. package/src/components/resources/resource-utils-cnpg.test.ts +54 -0
  68. package/src/components/resources/resource-utils-cnpg.ts +264 -3
  69. package/src/components/resources/resource-utils-kyverno-modern.ts +24 -0
  70. package/src/components/resources/resource-utils-kyverno-queue.test.ts +164 -0
  71. package/src/components/resources/resource-utils-kyverno-queue.ts +198 -0
  72. package/src/components/resources/resource-utils-kyverno.ts +43 -7
  73. package/src/components/resources/resource-utils-velero.test.ts +26 -0
  74. package/src/components/resources/resource-utils-velero.ts +68 -0
  75. package/src/components/resources/resource-utils.ts +98 -0
  76. package/src/components/shared/ResourceActionsBar.tsx +101 -16
  77. package/src/components/shared/ResourceRendererDispatch.test.tsx +379 -3
  78. package/src/components/shared/ResourceRendererDispatch.tsx +199 -22
  79. package/src/components/shared/index.ts +1 -1
  80. package/src/components/shared/revision-role-badges.test.ts +120 -0
  81. package/src/components/topology/K8sResourceNode.test.ts +10 -0
  82. package/src/components/topology/K8sResourceNode.tsx +22 -0
  83. package/src/components/topology/TopologyFilterSidebar.test.tsx +31 -0
  84. package/src/components/topology/TopologyFilterSidebar.tsx +5 -0
  85. package/src/components/topology/TopologyGraph.tsx +6 -6
  86. package/src/components/topology/TopologySearch.tsx +7 -0
  87. package/src/components/topology/layout.ts +3 -0
  88. package/src/components/trace/ReachabilityView.tsx +1 -0
  89. package/src/components/trace/TracePanel.tsx +13 -2
  90. package/src/components/trace/reachFixtures.ts +26 -1
  91. package/src/components/trace/reachGraphModel.test.ts +20 -0
  92. package/src/components/trace/reachGraphModel.ts +8 -1
  93. package/src/components/trace/reachOrigins.test.ts +31 -0
  94. package/src/components/trace/reachOrigins.ts +11 -1
  95. package/src/components/ui/Badge.tsx +10 -0
  96. package/src/components/ui/InClusterConsentDialog.tsx +5 -4
  97. package/src/components/ui/ScheduleValue.test.tsx +80 -0
  98. package/src/components/ui/ScheduleValue.tsx +85 -0
  99. package/src/components/ui/SelectMenu.tsx +202 -28
  100. package/src/components/ui/drawer-components.test.tsx +17 -1
  101. package/src/components/ui/drawer-components.tsx +9 -2
  102. package/src/components/ui/index.ts +2 -0
  103. package/src/components/workload/WorkloadView.tsx +5 -2
  104. package/src/types/core.ts +36 -4
  105. package/src/types/policy.ts +153 -2
  106. package/src/utils/clipboard.test.ts +86 -0
  107. package/src/utils/clipboard.ts +50 -0
  108. package/src/utils/extended-resources.ts +4 -7
  109. package/src/utils/format.test.ts +83 -1
  110. package/src/utils/format.ts +80 -1
  111. package/src/utils/inClusterConsent.test.ts +9 -0
  112. package/src/utils/inClusterConsent.ts +9 -9
  113. package/src/utils/index.ts +1 -0
  114. package/src/utils/navigation.test.ts +5 -0
  115. package/src/utils/navigation.ts +5 -0
  116. package/src/utils/resource-icons.test.ts +49 -2
  117. package/src/utils/resource-icons.ts +29 -2
@@ -1,5 +1,5 @@
1
- import { useEffect, useRef, useState } from 'react'
2
- import { Check, ChevronDown } from 'lucide-react'
1
+ import { useEffect, useId, useMemo, useRef, useState } from 'react'
2
+ import { Check, ChevronDown, Search } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
 
5
5
  export interface SelectMenuOption {
@@ -13,67 +13,241 @@ export function SelectMenu({
13
13
  onChange,
14
14
  ariaLabel,
15
15
  className,
16
+ searchPlaceholder,
16
17
  }: {
17
18
  value: string
18
19
  options: SelectMenuOption[]
19
20
  onChange: (value: string) => void
20
21
  ariaLabel: string
21
22
  className?: string
23
+ searchPlaceholder?: string
22
24
  }) {
23
25
  const [open, setOpen] = useState(false)
26
+ const [query, setQuery] = useState('')
27
+ const [highlightedIndex, setHighlightedIndex] = useState(0)
28
+ const listboxId = useId()
24
29
  const rootRef = useRef<HTMLDivElement>(null)
30
+ const triggerRef = useRef<HTMLButtonElement>(null)
31
+ const searchInputRef = useRef<HTMLInputElement>(null)
32
+ const listRef = useRef<HTMLDivElement>(null)
33
+ const pointerDownInsideRef = useRef(false)
25
34
  const selected = options.find((option) => option.value === value) ?? options[0]
35
+ const filteredOptions = useMemo(() => {
36
+ const normalized = query.trim().toLowerCase()
37
+ if (!normalized) return options
38
+ return options.filter((option) => option.label.toLowerCase().includes(normalized))
39
+ }, [options, query])
40
+ const selectedIsVisible = filteredOptions.some((option) => option.value === value)
41
+
42
+ const selectOption = (nextValue: string) => {
43
+ onChange(nextValue)
44
+ setOpen(false)
45
+ triggerRef.current?.focus()
46
+ }
26
47
 
27
48
  useEffect(() => {
28
49
  if (!open) return
29
50
  const onPointerDown = (event: MouseEvent) => {
30
51
  if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
31
52
  }
32
- const onKeyDown = (event: KeyboardEvent) => {
33
- if (event.key === 'Escape') setOpen(false)
34
- }
35
53
  document.addEventListener('mousedown', onPointerDown)
36
- document.addEventListener('keydown', onKeyDown)
37
54
  return () => {
38
55
  document.removeEventListener('mousedown', onPointerDown)
39
- document.removeEventListener('keydown', onKeyDown)
40
56
  }
41
57
  }, [open])
42
58
 
59
+ useEffect(() => {
60
+ if (!open) setQuery('')
61
+ }, [open])
62
+
63
+ useEffect(() => {
64
+ if (!open || !query) return
65
+ if (listRef.current) listRef.current.scrollTop = 0
66
+ }, [open, query])
67
+
68
+ useEffect(() => {
69
+ if (!open) return
70
+ if (!searchPlaceholder) {
71
+ triggerRef.current?.focus()
72
+ return
73
+ }
74
+ listRef.current
75
+ ?.querySelector<HTMLElement>('[role="option"][aria-selected="true"]')
76
+ ?.scrollIntoView({ block: 'nearest' })
77
+ }, [open, searchPlaceholder])
78
+
43
79
  return (
44
- <div ref={rootRef} className={clsx('relative', className)}>
80
+ <div
81
+ ref={rootRef}
82
+ className={clsx('relative', className)}
83
+ onMouseDownCapture={() => {
84
+ pointerDownInsideRef.current = true
85
+ requestAnimationFrame(() => {
86
+ pointerDownInsideRef.current = false
87
+ })
88
+ }}
89
+ onKeyDown={(event) => {
90
+ if (event.key !== 'Escape' || !open) return
91
+ event.preventDefault()
92
+ event.stopPropagation()
93
+ setOpen(false)
94
+ triggerRef.current?.focus()
95
+ }}
96
+ onBlur={(event) => {
97
+ if (!open) return
98
+ if (event.relatedTarget && rootRef.current?.contains(event.relatedTarget)) return
99
+ if (!pointerDownInsideRef.current) {
100
+ setOpen(false)
101
+ return
102
+ }
103
+ requestAnimationFrame(() => {
104
+ if (!document.hasFocus() || !listRef.current) return
105
+ if (searchPlaceholder) searchInputRef.current?.focus()
106
+ else triggerRef.current?.focus()
107
+ })
108
+ }}
109
+ >
45
110
  <button
111
+ ref={triggerRef}
46
112
  type="button"
47
113
  aria-label={ariaLabel}
48
114
  aria-haspopup="listbox"
49
115
  aria-expanded={open}
50
- onClick={() => setOpen((current) => !current)}
116
+ aria-controls={open ? listboxId : undefined}
117
+ onClick={() => {
118
+ if (!open) {
119
+ setHighlightedIndex(
120
+ Math.max(
121
+ options.findIndex((option) => option.value === value),
122
+ 0,
123
+ ),
124
+ )
125
+ }
126
+ setOpen(!open)
127
+ }}
51
128
  className="flex h-8 w-full items-center justify-between gap-2 rounded-md border border-theme-border bg-theme-elevated px-2.5 text-xs text-theme-text-primary transition-colors hover:bg-theme-hover"
52
129
  >
53
130
  <span className="truncate">{selected?.label}</span>
54
131
  <ChevronDown className={clsx('h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform', open && 'rotate-180')} />
55
132
  </button>
56
133
  {open && (
57
- <div role="listbox" className="absolute right-0 top-full z-50 mt-1 min-w-full overflow-hidden rounded-md border border-theme-border bg-theme-surface py-1 shadow-theme-lg">
58
- {options.map((option) => {
59
- const active = option.value === value
60
- return (
61
- <button
62
- key={option.value}
63
- type="button"
64
- role="option"
65
- aria-selected={active}
66
- onClick={() => {
67
- onChange(option.value)
68
- setOpen(false)
134
+ <div
135
+ className={clsx(
136
+ 'absolute top-full z-50 mt-1 min-w-full overflow-hidden rounded-md border border-theme-border bg-theme-surface shadow-theme-lg',
137
+ searchPlaceholder ? 'left-0 right-0' : 'right-0'
138
+ )}
139
+ >
140
+ {searchPlaceholder && (
141
+ <div
142
+ className="flex items-center gap-2 border-b border-theme-border px-2.5 py-2"
143
+ onMouseDown={(event) => {
144
+ if (event.target !== searchInputRef.current) event.preventDefault()
145
+ }}
146
+ >
147
+ <Search className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
148
+ <input
149
+ ref={searchInputRef}
150
+ autoFocus
151
+ value={query}
152
+ onChange={(event) => {
153
+ setQuery(event.target.value)
154
+ setHighlightedIndex(0)
69
155
  }}
70
- className="flex w-full items-center gap-2 whitespace-nowrap px-2.5 py-1.5 text-left text-xs text-theme-text-secondary transition-colors hover:bg-theme-hover hover:text-theme-text-primary"
71
- >
72
- <Check className={clsx('h-3.5 w-3.5 shrink-0 text-accent', !active && 'opacity-0')} />
73
- {option.label}
74
- </button>
75
- )
76
- })}
156
+ onKeyDown={(event) => {
157
+ if (event.key === 'Enter' && filteredOptions.length > 0) {
158
+ event.preventDefault()
159
+ const nextIndex = Math.min(highlightedIndex, filteredOptions.length - 1)
160
+ selectOption(filteredOptions[nextIndex].value)
161
+ } else if (event.key === 'ArrowDown') {
162
+ event.preventDefault()
163
+ const optionElements = listRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]')
164
+ optionElements?.[Math.min(highlightedIndex, optionElements.length - 1)]?.focus()
165
+ }
166
+ }}
167
+ aria-label={searchPlaceholder}
168
+ role="combobox"
169
+ aria-autocomplete="list"
170
+ aria-controls={listboxId}
171
+ aria-expanded="true"
172
+ aria-activedescendant={
173
+ filteredOptions.length > 0
174
+ ? `${listboxId}-option-${Math.min(highlightedIndex, filteredOptions.length - 1)}`
175
+ : undefined
176
+ }
177
+ placeholder={searchPlaceholder}
178
+ className="min-w-0 flex-1 bg-transparent text-xs text-theme-text-primary outline-none placeholder:text-theme-text-tertiary"
179
+ />
180
+ </div>
181
+ )}
182
+ {searchPlaceholder && (
183
+ <span className="sr-only" aria-live="polite">
184
+ {filteredOptions.length === 0
185
+ ? 'No matches.'
186
+ : `${filteredOptions.length} ${filteredOptions.length === 1 ? 'option' : 'options'} available.`}
187
+ </span>
188
+ )}
189
+ <div
190
+ id={listboxId}
191
+ ref={listRef}
192
+ role="listbox"
193
+ aria-label={ariaLabel}
194
+ onKeyDown={(event) => {
195
+ if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return
196
+ const optionElements = Array.from(
197
+ listRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]') ?? [],
198
+ )
199
+ const currentIndex = optionElements.indexOf(event.target as HTMLButtonElement)
200
+ if (currentIndex < 0) return
201
+ event.preventDefault()
202
+ if (event.key === 'ArrowUp' && currentIndex === 0 && searchPlaceholder) {
203
+ searchInputRef.current?.focus()
204
+ return
205
+ }
206
+ const nextIndex = event.key === 'Home'
207
+ ? 0
208
+ : event.key === 'End'
209
+ ? optionElements.length - 1
210
+ : event.key === 'ArrowDown'
211
+ ? Math.min(currentIndex + 1, optionElements.length - 1)
212
+ : Math.max(currentIndex - 1, 0)
213
+ optionElements[nextIndex]?.focus()
214
+ }}
215
+ className={clsx(filteredOptions.length > 0 && 'py-1', searchPlaceholder && 'max-h-64 overflow-y-auto')}
216
+ >
217
+ {filteredOptions.map((option, index) => {
218
+ const active = option.value === value
219
+ return (
220
+ <button
221
+ key={option.value}
222
+ id={`${listboxId}-option-${index}`}
223
+ type="button"
224
+ role="option"
225
+ aria-selected={active}
226
+ tabIndex={
227
+ searchPlaceholder ? (index === highlightedIndex ? 0 : -1) : active || (!selectedIsVisible && index === 0) ? 0 : -1
228
+ }
229
+ onClick={() => selectOption(option.value)}
230
+ onFocus={() => setHighlightedIndex(index)}
231
+ className={clsx(
232
+ 'flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs text-theme-text-secondary transition-colors hover:bg-theme-hover hover:text-theme-text-primary',
233
+ searchPlaceholder && index === highlightedIndex && 'bg-theme-hover text-theme-text-primary',
234
+ !searchPlaceholder && 'whitespace-nowrap'
235
+ )}
236
+ >
237
+ <Check className={clsx('h-3.5 w-3.5 shrink-0 text-accent', !active && 'opacity-0')} />
238
+ <span className={clsx(searchPlaceholder && 'truncate')}>{option.label}</span>
239
+ </button>
240
+ )
241
+ })}
242
+ </div>
243
+ {filteredOptions.length === 0 && (
244
+ <p
245
+ onMouseDown={(event) => event.preventDefault()}
246
+ className="px-3 py-2 text-xs text-theme-text-tertiary"
247
+ >
248
+ No matches.
249
+ </p>
250
+ )}
77
251
  </div>
78
252
  )}
79
253
  </div>
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { renderToString } from 'react-dom/server'
3
- import { ProblemAlerts, OperationalIssuesShownContext } from './drawer-components'
3
+ import { ProblemAlerts, OperationalIssuesShownContext, RelatedResourcesSection } from './drawer-components'
4
4
 
5
5
  const problems = [
6
6
  { color: 'red' as const, message: 'Application is Degraded' },
@@ -33,3 +33,19 @@ describe('ProblemAlerts', () => {
33
33
  expect(html).toContain('Application is OutOfSync')
34
34
  })
35
35
  })
36
+
37
+ describe('RelatedResourcesSection', () => {
38
+ it('renders an associated Node', () => {
39
+ const html = renderToString(
40
+ <RelatedResourcesSection
41
+ relationships={{ node: { kind: 'Node', namespace: '', name: 'worker-1' } }}
42
+ onNavigate={() => {}}
43
+ />,
44
+ )
45
+
46
+ expect(html).toContain('Related Resources')
47
+ expect(html).toContain('Node')
48
+ expect(html).toContain('worker-1')
49
+ expect(html).toContain('<button')
50
+ })
51
+ })
@@ -167,7 +167,10 @@ export function Property({ label, value, copyable, onCopy, copied }: PropertyPro
167
167
  return (
168
168
  <div className="flex items-start gap-2 text-sm">
169
169
  <span className="text-theme-text-tertiary w-40 shrink-0">{label}</span>
170
- <span className={clsx('text-theme-text-primary flex-1 min-w-0', isReactElement(value) ? 'break-normal' : 'break-all')}>{displayValue}</span>
170
+ {/* break-words, not break-all: a long unbroken token (image ref, UID)
171
+ still wraps because it has to, while a sentence keeps its words
172
+ intact. break-all split "PostgreSQL" across two lines. */}
173
+ <span className={clsx('text-theme-text-primary flex-1 min-w-0', isReactElement(value) ? 'break-normal' : 'break-words')}>{displayValue}</span>
171
174
  {copyable && onCopy && !isReactElement(value) && (
172
175
  <button
173
176
  onClick={() => onCopy(strValue, labelKey)}
@@ -789,6 +792,7 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
789
792
  const hasRelationships =
790
793
  relationships.owner ||
791
794
  relationships.deployment ||
795
+ relationships.node ||
792
796
  (relationships.children && relationships.children.length > 0) ||
793
797
  (relationships.services && relationships.services.length > 0) ||
794
798
  (relationships.ingresses && relationships.ingresses.length > 0) ||
@@ -815,6 +819,9 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
815
819
  {relationships.deployment && (
816
820
  <RelationshipGroup label="Deployment" refs={[relationships.deployment]} onNavigate={onNavigate} />
817
821
  )}
822
+ {relationships.node && (
823
+ <RelationshipGroup label="Node" refs={[relationships.node]} onNavigate={onNavigate} />
824
+ )}
818
825
  {relationships.children && relationships.children.length > 0 && (
819
826
  <RelationshipGroup label="Children" refs={dedupeRefs(relationships.children)} onNavigate={onNavigate} />
820
827
  )}
@@ -870,7 +877,7 @@ interface RelationshipGroupProps {
870
877
 
871
878
  const RELATIONSHIP_TRUNCATE_LIMIT = 10
872
879
 
873
- function RelationshipGroup({ label, refs, onNavigate }: RelationshipGroupProps) {
880
+ export function RelationshipGroup({ label, refs, onNavigate }: RelationshipGroupProps) {
874
881
  const [showAll, setShowAll] = useState(false)
875
882
  if (!refs || refs.length === 0) return null
876
883
 
@@ -1,4 +1,6 @@
1
1
  export { Tooltip, WithTooltip } from './Tooltip'
2
+ export { CronValue, TimeValue } from './ScheduleValue'
3
+ export type { CronDialect } from './ScheduleValue'
2
4
  export { Badge } from './Badge'
3
5
  export type { BadgeSeverity, BadgeSize, BadgeTone } from './Badge'
4
6
  export { FreshnessControl } from './FreshnessControl'
@@ -30,7 +30,7 @@ import {
30
30
  BarChart3,
31
31
  Network,
32
32
  Stethoscope,
33
- DollarSign,
33
+ Coins,
34
34
  } from 'lucide-react'
35
35
  import type { TimelineEvent, ResourceRef, Relationships, SelectedResource, ResolvedEnvFrom, Topology, TopologyNode, HPADiagnosis, WorkloadPodInfo } from '../../types'
36
36
  import type { GitOpsStatus } from '../../types/gitops'
@@ -506,6 +506,9 @@ export function WorkloadView({
506
506
  Service: 1, Ingress: 1, HTTPRoute: 1,
507
507
  ConfigMap: 2, Secret: 2,
508
508
  HorizontalPodAutoscaler: 3, PodDisruptionBudget: 3, NetworkPolicy: 3,
509
+ CalicoNetworkPolicy: 3, CalicoGlobalNetworkPolicy: 3,
510
+ CalicoStagedNetworkPolicy: 3, CalicoStagedGlobalNetworkPolicy: 3,
511
+ CalicoStagedKubernetesNetworkPolicy: 3,
509
512
  ReplicaSet: 4, Pod: 5,
510
513
  }
511
514
  return neighborhood.nodes
@@ -719,7 +722,7 @@ export function WorkloadView({
719
722
  icon: <Stethoscope className="w-4 h-4" />,
720
723
  hidden: !(renderDiagnoseTab && (isDiagnoseKind(apiKind, group) || (reachableVia?.length ?? 0) > 0)),
721
724
  },
722
- { id: 'cost', label: 'Cost', icon: <DollarSign className="w-4 h-4" />, hidden: !costTabVisible },
725
+ { id: 'cost', label: 'Cost', icon: <Coins className="w-4 h-4" />, hidden: !costTabVisible },
723
726
  { id: 'yaml', label: 'YAML', icon: <FileText className="w-4 h-4" /> },
724
727
  ]
725
728
  const requestedTabAvailable = tabs.some((tab) => tab.id === requestedTab && !tab.hidden)
package/src/types/core.ts CHANGED
@@ -202,6 +202,11 @@ export type CoreNodeKind =
202
202
  | 'KubeadmControlPlane' // Cluster API KubeadmControlPlane
203
203
  | 'ClusterClass' // Cluster API ClusterClass
204
204
  | 'MachineHealthCheck' // Cluster API MachineHealthCheck
205
+ | 'CalicoNetworkPolicy'
206
+ | 'CalicoGlobalNetworkPolicy'
207
+ | 'CalicoStagedNetworkPolicy'
208
+ | 'CalicoStagedGlobalNetworkPolicy'
209
+ | 'CalicoStagedKubernetesNetworkPolicy'
205
210
 
206
211
  // NodeKind can be a core kind or any arbitrary CRD kind string
207
212
  export type NodeKind = CoreNodeKind | (string & {})
@@ -236,6 +241,11 @@ export function displayKind(kind: string): string {
236
241
  KubeadmControlPlane: 'Control Plane',
237
242
  ClusterClass: 'ClusterClass',
238
243
  MachineHealthCheck: 'Health Check',
244
+ CalicoNetworkPolicy: 'Calico NetworkPolicy',
245
+ CalicoGlobalNetworkPolicy: 'Calico GlobalNetworkPolicy',
246
+ CalicoStagedNetworkPolicy: 'Calico StagedNetworkPolicy',
247
+ CalicoStagedGlobalNetworkPolicy: 'Calico StagedGlobalNetworkPolicy',
248
+ CalicoStagedKubernetesNetworkPolicy: 'Calico StagedKubernetesNetworkPolicy',
239
249
  }
240
250
  return shortNames[kind] || kind
241
251
  }
@@ -258,6 +268,7 @@ export interface TopologyEdge {
258
268
  target: string
259
269
  type: EdgeType
260
270
  label?: string
271
+ partial?: boolean
261
272
  /** Hover tooltip for the edge label. Used by the Reachability view to keep the
262
273
  * DECLARED route path available when the label shows an overridden tested path. */
263
274
  labelTitle?: string
@@ -444,12 +455,15 @@ export interface ClusterInfo {
444
455
  // Context info for context switching
445
456
  export interface ContextInfo {
446
457
  name: string
458
+ /** Original context name inside its source file. Set for isolated sources
459
+ * so display code need not infer backend-added collision qualifiers. */
460
+ originalName?: string
447
461
  cluster: string
448
462
  user: string
449
463
  namespace: string
450
464
  isCurrent: boolean
451
- /** Source kubeconfig label (e.g. "kube-cluster-paris"). Set by backend
452
- * only when 2+ kubeconfig files are loaded; empty otherwise. */
465
+ /** Source kubeconfig label (e.g. "kube-cluster-paris"). Set by the backend
466
+ * for contexts loaded through the isolated source registry. */
453
467
  source?: string
454
468
  }
455
469
 
@@ -1204,6 +1218,13 @@ export interface TrafficFlow {
1204
1218
  bytesSent: number
1205
1219
  bytesRecv: number
1206
1220
  connections: number
1221
+ /** The conversation's initiator could not be established, so source and
1222
+ * destination are ordered arbitrarily rather than describing a caller. */
1223
+ directionUnknown?: boolean
1224
+ /** 5xx responses per second. How a rate-based source reports failures: it
1225
+ * measures a rate rather than observing individual responses, so it has a
1226
+ * status code for no single flow. */
1227
+ errorRate?: number
1207
1228
  verdict: string // forwarded, dropped, error
1208
1229
  lastSeen: string // ISO date string
1209
1230
  }
@@ -1236,6 +1257,9 @@ export interface AggregatedFlow {
1236
1257
  bytesRecv: number
1237
1258
  connections: number
1238
1259
  lastSeen: string
1260
+ /** The conversation's initiator could not be established, so the endpoints are
1261
+ * ordered arbitrarily and the edge is drawn without an arrowhead. */
1262
+ directionUnknown?: boolean
1239
1263
  l7Protocol?: string // HTTP, gRPC, DNS
1240
1264
  requestCount?: number
1241
1265
  errorCount?: number
@@ -1252,8 +1276,8 @@ export interface AggregatedFlow {
1252
1276
 
1253
1277
  // Cluster info for traffic detection
1254
1278
  export interface TrafficClusterInfo {
1255
- platform: string // gke, eks, aks, generic
1256
- cni: string // cilium, calico, flannel, vpc-cni, azure-cni
1279
+ platform: string // rke2, gke, eks, aks, minikube, kind, docker-desktop, openshift, rancher, generic
1280
+ cni: string // cilium, canal, calico, flannel, vpc-cni, azure-cni, gke-native, unknown
1257
1281
  dataplaneV2: boolean
1258
1282
  clusterName?: string
1259
1283
  k8sVersion?: string
@@ -1307,6 +1331,11 @@ export interface TrafficFlowsResponse {
1307
1331
  flows: TrafficFlow[]
1308
1332
  aggregated: AggregatedFlow[]
1309
1333
  warning?: string // Non-fatal warning (e.g., query errors)
1334
+ /** 'transient' (or absent) means the condition may clear on its own and a
1335
+ * retry is worthwhile. 'partial' means the flows are correct but incomplete
1336
+ * for a reason retrying cannot change, so show the warning next to them and
1337
+ * do not refetch. */
1338
+ warningKind?: 'transient' | 'partial'
1310
1339
  }
1311
1340
 
1312
1341
  // Wizard state for traffic setup
@@ -1424,6 +1453,9 @@ export interface WorkloadRevision {
1424
1453
  isCurrent: boolean
1425
1454
  replicas: number
1426
1455
  template?: string // Pod template spec as YAML (for revision diff)
1456
+ // Rollouts only: mid-canary isCurrent (rolling out) and isStable (serving traffic) differ.
1457
+ isStable?: boolean
1458
+ podHash?: string
1427
1459
  }
1428
1460
 
1429
1461
  // Workload log stream event data
@@ -26,17 +26,168 @@ export interface PolicyResourceCounts {
26
26
  skip: number
27
27
  }
28
28
 
29
+ /** One resource a policy recorded an outcome for, from
30
+ * GET /api/policy/policies/{policy}. */
31
+ export interface PolicyCoverageSubject {
32
+ group?: string
33
+ kind: string
34
+ namespace?: string
35
+ name: string
36
+ result: PolicyResult
37
+ message?: string
38
+ /** Matched through a rule Kyverno synthesised from an authored Pod rule,
39
+ * not through a rule the user wrote. */
40
+ autogen?: boolean
41
+ }
42
+
43
+ export interface PolicyCoverageRule {
44
+ /** Empty for the modern CEL families, which have no named rules. */
45
+ rule: string
46
+ counts: PolicyResourceCounts
47
+ subjects: PolicyCoverageSubject[]
48
+ total: number
49
+ truncated?: boolean
50
+ /** How many of `total` the namespace view filter held back. `counts` still
51
+ * describe every outcome, so the two must be read together. */
52
+ hiddenByFilter?: number
53
+ /** The non-passing part of `hiddenByFilter`. Only non-passing subjects are
54
+ * listed, so this is what separates a row the server cap dropped from one
55
+ * the view filter held back. */
56
+ hiddenNotable?: number
57
+ }
58
+
59
+ /** What one policy decided about the cluster — the inverse of
60
+ * PolicyResourceResponse.
61
+ *
62
+ * Carries one partiality the per-resource view never needed: a resource may be
63
+ * missing because the caller cannot read its namespace, so an absent subject is
64
+ * not evidence of absence. `withheldNamespaces` is how the UI says so. */
65
+ export interface PolicyCoverageResponse {
66
+ evaluated: boolean
67
+ status: string
68
+ reasonCode?: string
69
+ deniedGroups?: string[]
70
+ liveUpdates: boolean
71
+ /** The report-side name that matched — namespaced policies report as
72
+ * "namespace/name". */
73
+ policy: string
74
+ counts: PolicyResourceCounts
75
+ scopeNamespaces: number
76
+ withheldNamespaces: number
77
+ clusterScoped?: boolean
78
+ /** Separate from withheldNamespaces — a cluster-scoped subject has no
79
+ * namespace to name. */
80
+ withheldClusterScoped?: boolean
81
+ /** Outcomes dropped because they came only from a report family this caller
82
+ * cannot read. The server drops them before counting, so the note can state
83
+ * how many were left out rather than hedging that some might have been. */
84
+ withheldByFamily?: number
85
+ /** Report families the CALLER cannot read. Distinct from deniedGroups, which
86
+ * are the ones radar's own probe could not read. */
87
+ unreadableFamilies?: string[]
88
+ /** How many distinct resources these outcomes describe, and how many of those
89
+ * have at least one non-passing outcome. One resource matched by two rules
90
+ * produces two outcomes, so any sentence saying "resources" needs these —
91
+ * `counts` and `examined` are checks. Not derivable client-side: the per-rule
92
+ * subject lists are capped. */
93
+ subjects?: number
94
+ subjectsFailing?: number
95
+ /** How many outcomes back these counts. */
96
+ examined: number
97
+ /** How many subjects the namespace view filter held back, across all rules. */
98
+ hiddenByFilter?: number
99
+ engines?: string[]
100
+ rules: PolicyCoverageRule[]
101
+ }
102
+
29
103
  export interface PolicyResourceResponse {
30
104
  /** False whenever the answer is "we could not check". Never treat an empty
31
105
  * findings array as "clean" without consulting this first. */
32
106
  evaluated: boolean
33
107
  status: string
34
108
  reasonCode?: string
35
- /** Report families the caller could not read. Present even when evaluated:
36
- * the answer is real but incomplete. */
109
+ /** Report families RADAR's own probe could not read, so nobody's answer
110
+ * carries them — not the same as `withheldByFamily`, which is what THIS
111
+ * caller may not see. Present even when evaluated: the answer is real but
112
+ * incomplete. */
37
113
  deniedGroups?: string[]
38
114
  /** False when the index is frozen at its initial contents. */
39
115
  liveUpdates: boolean
40
116
  counts: PolicyResourceCounts
41
117
  findings: PolicyResourceFinding[]
118
+ /** Findings dropped because they came only from a report family this caller
119
+ * cannot read. `counts` excludes them too, so an all-passing count with this
120
+ * set is not an all-clear — it is the part of the answer they may see. */
121
+ withheldByFamily?: number
122
+ }
123
+
124
+ /** Background work Kyverno currently has in flight for one policy, from
125
+ * GET /api/policy/policies/{policy}/queued. Computed server-side: the requests
126
+ * live in the engine's namespace, not the policy's. */
127
+ export interface PolicyQueuedResponse {
128
+ /** Zero is a real answer — Kyverno deletes these seconds after they complete. */
129
+ requests: number
130
+ byState?: Record<string, number>
131
+ /** Separates healthy churn from a stopped controller. */
132
+ oldestPending?: string
133
+ /** The only diagnosis these objects carry. */
134
+ messages?: string[]
135
+ }
136
+
137
+ /** One Cluster pinned to a CloudNativePG image catalog. */
138
+ export interface CNPGCatalogUser {
139
+ namespace: string
140
+ name: string
141
+ /** Absent when the reference carries no major — the screen must not render
142
+ * "asks for PostgreSQL 0". */
143
+ major?: number
144
+ /** A catalog-pinned Cluster has no spec.imageName, so this is the only place
145
+ * the image it actually resolved appears. */
146
+ image?: string
147
+ }
148
+
149
+ /** Clusters referencing one image catalog, from the CNPG catalog-users endpoint. */
150
+ export interface CNPGCatalogUsersResponse {
151
+ clusters: CNPGCatalogUser[]
152
+ }
153
+
154
+ /** What one Velero BackupStorageLocation holds, from the stored-backups lookup. */
155
+ export interface VeleroStoredBackupsResponse {
156
+ backups: Array<{
157
+ namespace: string
158
+ name: string
159
+ phase?: string
160
+ expiration?: string
161
+ completed?: string
162
+ }>
163
+ /** How many reached Completed and are still inside their retention — the rest
164
+ * are stored here but are not something to restore from. Counted over the
165
+ * whole location, like `stored`: recomputing it from the capped list above
166
+ * undercounts every location big enough to be capped. */
167
+ restorable: number
168
+ /** How many reached Completed but have aged out. Same scope as `restorable`. */
169
+ expired?: number
170
+ /** How many the location holds in total, counted before the list was capped. */
171
+ stored?: number
172
+ /** The list above is a page, not the whole location. */
173
+ truncated?: boolean
174
+ }
175
+
176
+ /** One warning or error behind a Velero run's counts, with the scope Velero
177
+ * filed it under: "velero", "cluster", or a namespace name. */
178
+ export interface VeleroMessage {
179
+ scope: string
180
+ message: string
181
+ }
182
+
183
+ /** What a Backup or Restore actually reported, fetched on demand.
184
+ *
185
+ * The counts live on the CR; these messages do not — they are in a results
186
+ * file in object storage that only Velero's controller can hand out a link to.
187
+ * So this is never present until someone asks for it. */
188
+ export interface VeleroRunMessagesResponse {
189
+ warnings: VeleroMessage[]
190
+ errors: VeleroMessage[]
191
+ /** Set when the results file held more than were returned. */
192
+ truncated?: boolean
42
193
  }