@skyhook-io/radar-app 0.2.1 → 0.3.1

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 (180) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/assets/platform-icons/aws_eks.png +0 -0
  19. package/src/assets/platform-icons/azure-aks.svg +2 -0
  20. package/src/assets/platform-icons/google_kubernetes_engine.png +0 -0
  21. package/src/components/ConnectionErrorView.tsx +186 -70
  22. package/src/components/ContextSwitcher.tsx +63 -18
  23. package/src/components/DebugOverlay.tsx +5 -3
  24. package/src/components/NamespaceSwitcher.tsx +41 -0
  25. package/src/components/UserMenu.tsx +69 -21
  26. package/src/components/applications/ApplicationsView.tsx +936 -0
  27. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  28. package/src/components/audit/AuditView.tsx +65 -62
  29. package/src/components/compare/CompareViewRoute.tsx +124 -0
  30. package/src/components/compare/useCompareCandidates.ts +27 -0
  31. package/src/components/compare/useCompareLauncher.tsx +79 -0
  32. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  33. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  34. package/src/components/cost/CostTrendChart.tsx +106 -75
  35. package/src/components/cost/CostView.test.ts +12 -0
  36. package/src/components/cost/CostView.tsx +507 -223
  37. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  38. package/src/components/cost/CostViewTabs.tsx +40 -0
  39. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  40. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  41. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  42. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  43. package/src/components/cost/cloud-console.test.ts +39 -0
  44. package/src/components/cost/cloud-console.ts +81 -0
  45. package/src/components/cost/errors.ts +8 -0
  46. package/src/components/cost/format.test.ts +27 -0
  47. package/src/components/cost/format.ts +46 -0
  48. package/src/components/cost/kinds.ts +5 -0
  49. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  50. package/src/components/diagnose/AISettings.tsx +147 -0
  51. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  52. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  53. package/src/components/diagnose/Home.tsx +163 -0
  54. package/src/components/diagnose/InvestigationView.tsx +622 -0
  55. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  56. package/src/components/diagnose/launch.ts +65 -0
  57. package/src/components/diagnose/parts.tsx +1756 -0
  58. package/src/components/dock/BottomDock.tsx +2 -3
  59. package/src/components/dock/DockContext.tsx +1 -0
  60. package/src/components/dock/TerminalTab.tsx +1 -1
  61. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  62. package/src/components/dock/index.ts +1 -1
  63. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  64. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  65. package/src/components/execution/batch-run-actions.test.ts +48 -0
  66. package/src/components/execution/batch-run-actions.ts +24 -0
  67. package/src/components/execution/batch-timeline.test.ts +57 -0
  68. package/src/components/execution/batch-timeline.ts +46 -0
  69. package/src/components/execution/execution-definition.test.ts +208 -0
  70. package/src/components/execution/execution-definition.ts +245 -0
  71. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  72. package/src/components/gitops/GitOpsView.tsx +1042 -0
  73. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  74. package/src/components/helm/ChartBrowser.tsx +87 -31
  75. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  76. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  77. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  78. package/src/components/helm/HelmView.tsx +237 -96
  79. package/src/components/helm/InstallWizard.tsx +94 -38
  80. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  81. package/src/components/helm/OwnedResources.tsx +34 -59
  82. package/src/components/helm/RevisionHistory.tsx +52 -3
  83. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  84. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  85. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  86. package/src/components/helm/ValuesViewer.tsx +49 -53
  87. package/src/components/helm/helm-utils.ts +4 -0
  88. package/src/components/home/ActivitySummary.tsx +4 -1
  89. package/src/components/home/ClusterHealthCard.tsx +116 -52
  90. package/src/components/home/CostCard.tsx +21 -36
  91. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  92. package/src/components/home/HelmSummary.tsx +3 -1
  93. package/src/components/home/HomeView.tsx +339 -105
  94. package/src/components/home/MCPSetupDialog.tsx +29 -87
  95. package/src/components/home/TrafficSummary.tsx +2 -2
  96. package/src/components/home/mcpToolCatalog.ts +333 -0
  97. package/src/components/issues/IssuesPane.tsx +151 -0
  98. package/src/components/logs/LogsViewer.tsx +4 -1
  99. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  100. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  101. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  102. package/src/components/portforward/PortForwardButton.tsx +118 -47
  103. package/src/components/portforward/PortForwardManager.tsx +253 -131
  104. package/src/components/resource/HPACharts.tsx +237 -0
  105. package/src/components/resource/PVCUsageBar.tsx +59 -0
  106. package/src/components/resource/PrometheusCharts.tsx +160 -584
  107. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  108. package/src/components/resource/RestartChart.tsx +133 -0
  109. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  110. package/src/components/resource/RightsizingStrip.tsx +363 -0
  111. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  112. package/src/components/resources/CompositeRenderer.tsx +101 -0
  113. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  114. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  115. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  116. package/src/components/resources/ResourcesView.tsx +194 -17
  117. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  118. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  119. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  120. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  121. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  122. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  123. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  124. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  125. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  126. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  127. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  128. package/src/components/resources/renderers/index.ts +2 -0
  129. package/src/components/resources/resource-utils.ts +2 -1
  130. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  131. package/src/components/rightsizing/copy.test.ts +56 -0
  132. package/src/components/rightsizing/model.test.ts +227 -0
  133. package/src/components/rightsizing/model.ts +158 -0
  134. package/src/components/rightsizing/presentation.test.ts +104 -0
  135. package/src/components/rightsizing/presentation.ts +94 -0
  136. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  137. package/src/components/settings/SettingsDialog.tsx +1505 -165
  138. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  139. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  140. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  141. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  142. package/src/components/timeline/TimelineList.tsx +86 -13
  143. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  144. package/src/components/timeline/TimelineView.tsx +873 -24
  145. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  146. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  147. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  148. package/src/components/traffic/TrafficGraph.tsx +42 -24
  149. package/src/components/traffic/TrafficView.tsx +32 -19
  150. package/src/components/ui/CommandPalette.tsx +8 -215
  151. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  152. package/src/components/ui/Markdown.tsx +3 -3
  153. package/src/components/ui/Omnibar.tsx +602 -0
  154. package/src/components/ui/RadarOmnibar.tsx +52 -0
  155. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  156. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  157. package/src/components/ui/UpdateNotification.tsx +48 -36
  158. package/src/components/ui/command-items.ts +178 -0
  159. package/src/components/workload/WorkloadView.tsx +1342 -158
  160. package/src/context/ConnectionContext.tsx +146 -21
  161. package/src/context/DiagnoseCustomization.tsx +93 -0
  162. package/src/context/NavCustomization.tsx +75 -0
  163. package/src/context/TimelineSource.tsx +50 -0
  164. package/src/contexts/CapabilitiesContext.tsx +38 -8
  165. package/src/filter/FilterLocationBridge.tsx +30 -0
  166. package/src/hooks/useClusterLoadState.ts +73 -0
  167. package/src/hooks/useDocumentTitle.ts +25 -0
  168. package/src/hooks/useEventSource.ts +6 -0
  169. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  170. package/src/hooks/useMediaQuery.ts +21 -0
  171. package/src/hooks/useNavRailPinned.ts +46 -0
  172. package/src/hooks/useRecentResources.ts +49 -0
  173. package/src/index.css +162 -1
  174. package/src/index.ts +73 -1
  175. package/src/main.tsx +7 -5
  176. package/src/types/clusterLoadState.ts +33 -0
  177. package/src/types.ts +2 -0
  178. package/src/utils/auditBadges.ts +53 -0
  179. package/src/utils/navigation.ts +64 -1
  180. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -1,9 +1,72 @@
1
1
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../api/config'
2
+ import { kindToPlural } from '@skyhook-io/k8s-ui/utils/navigation'
3
+ import type { SelectedResource } from '@skyhook-io/k8s-ui/types/core'
4
+ import type { SearchHit } from '../api/client'
5
+
6
+ /**
7
+ * Map a resource-search Hit to a SelectedResource. Hit.kind is the singular
8
+ * Kind (e.g. "Deployment"); downstream openers pluralize. `group` is carried so
9
+ * CRD/core collisions disambiguate (Service vs Knative Service), and the
10
+ * namespace defaults to '' for cluster-scoped hits (Node/Namespace/PV).
11
+ */
12
+ export function searchHitToSelectedResource(hit: SearchHit): SelectedResource {
13
+ return { kind: hit.kind, namespace: hit.namespace ?? '', name: hit.name, group: hit.group || undefined }
14
+ }
2
15
 
3
16
  // Re-export shared navigation utilities from @skyhook-io/k8s-ui.
4
- export { kindToPlural, pluralToKind, refToSelectedResource } from '@skyhook-io/k8s-ui/utils/navigation'
17
+ export { kindToPlural, pluralToKind, refToSelectedResource, apiVersionToGroup } from '@skyhook-io/k8s-ui/utils/navigation'
5
18
  export type { NavigateToResource } from '@skyhook-io/k8s-ui/utils/navigation'
6
19
 
20
+ /**
21
+ * Build a /workload/:kind/:namespace/:name URL, preserving the API group as a
22
+ * query param so the WorkloadView can resolve CRDs with colliding kind names.
23
+ * Cluster-scoped resources (Node, PersistentVolume, Namespace, …) have no
24
+ * namespace; they're encoded with a '_' sentinel segment so the path stays
25
+ * positional and WorkloadViewRoute can parse it back. '_' is safe — it's not a
26
+ * valid DNS-1123 namespace label, so it can never collide with a real one.
27
+ */
28
+ export function buildWorkloadPath(resource: SelectedResource): string {
29
+ const kind = encodeURIComponent(resource.kind)
30
+ const namespace = encodeURIComponent(resource.namespace || '_')
31
+ const name = encodeURIComponent(resource.name)
32
+ const base = `/workload/${kind}/${namespace}/${name}`
33
+ return resource.group ? `${base}?apiGroup=${encodeURIComponent(resource.group)}` : base
34
+ }
35
+
36
+ /**
37
+ * Build a /resources/:plural?resource=:namespace/:name URL — the deep link that
38
+ * opens a resource's detail drawer in the resources view. Cluster-scoped
39
+ * resources use ?resource=:name (no slash); the API group rides in ?apiGroup=
40
+ * to disambiguate CRD/core kind collisions. This is the exact form the
41
+ * ResourcesView mount effect parses (the `?resource=` reader in
42
+ * packages/k8s-ui/src/components/resources/ResourcesView.tsx) — keep the two in
43
+ * lockstep.
44
+ *
45
+ * Unlike buildWorkloadPath, this opens the detail drawer for ANY kind,
46
+ * including cluster-scoped resources. Returns a basename-relative path;
47
+ * embedders (Radar Hub) prepend their cluster prefix (e.g. /c/:id).
48
+ */
49
+ export function resourcePath(resource: SelectedResource): string {
50
+ const params = new URLSearchParams()
51
+ // No name → nothing to open; the kind list is the sane fallback.
52
+ if (resource.name) {
53
+ params.set('resource', resource.namespace ? `${resource.namespace}/${resource.name}` : resource.name)
54
+ }
55
+ if (resource.group) params.set('apiGroup', resource.group)
56
+ const query = params.toString()
57
+ return `/resources/${kindToPlural(resource.kind)}${query ? `?${query}` : ''}`
58
+ }
59
+
60
+ const FULLSCREEN_RESOURCE_KINDS = new Set(['pods', 'deployments', 'statefulsets', 'daemonsets', 'jobs', 'cronjobs', 'nodes'])
61
+
62
+ export function relatedResourcePath(resource: SelectedResource): string {
63
+ const apiKind = kindToPlural(resource.kind).toLowerCase()
64
+ if (FULLSCREEN_RESOURCE_KINDS.has(apiKind)) {
65
+ return buildWorkloadPath({ ...resource, kind: apiKind })
66
+ }
67
+ return resourcePath(resource)
68
+ }
69
+
7
70
  // radar-specific: open URL in system browser (desktop app support)
8
71
  export function openExternal(url: string): void {
9
72
  fetch(apiUrl('/desktop/open-url'), {
@@ -1,436 +0,0 @@
1
- import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
2
- import { createPortal } from 'react-dom'
3
- import { clsx } from 'clsx'
4
- import { ChevronDown, Search, X, Check, Shield } from 'lucide-react'
5
- import { Tooltip } from './Tooltip'
6
- import { isForbiddenError } from '../../api/client'
7
-
8
- interface Namespace {
9
- name: string
10
- }
11
-
12
- interface NamespaceSelectorProps {
13
- value: string[]
14
- onChange: (value: string[]) => void
15
- namespaces: Namespace[] | undefined
16
- namespacesError?: Error | null
17
- className?: string
18
- disabled?: boolean
19
- disabledTooltip?: string
20
- }
21
-
22
- export function NamespaceSelector({
23
- value,
24
- onChange,
25
- namespaces,
26
- namespacesError,
27
- className,
28
- disabled,
29
- disabledTooltip,
30
- }: NamespaceSelectorProps) {
31
- const [isOpen, setIsOpen] = useState(false)
32
- const [search, setSearch] = useState('')
33
- const [manualInput, setManualInput] = useState('')
34
- const [highlightedIndex, setHighlightedIndex] = useState(0)
35
- const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 })
36
-
37
- const triggerRef = useRef<HTMLButtonElement>(null)
38
- const dropdownRef = useRef<HTMLDivElement>(null)
39
- const searchInputRef = useRef<HTMLInputElement>(null)
40
-
41
- const isForbidden = isForbiddenError(namespacesError)
42
-
43
- // Convert value to Set for efficient lookups
44
- const selectedSet = useMemo(() => new Set(value), [value])
45
-
46
- // Sort and filter namespaces
47
- const sortedNamespaces = useMemo(() => {
48
- if (!namespaces) return []
49
- return [...namespaces].sort((a, b) => a.name.localeCompare(b.name))
50
- }, [namespaces])
51
-
52
- const filteredNamespaces = useMemo(() => {
53
- if (!search.trim()) return sortedNamespaces
54
- const searchLower = search.toLowerCase()
55
- return sortedNamespaces.filter((ns) =>
56
- ns.name.toLowerCase().includes(searchLower)
57
- )
58
- }, [sortedNamespaces, search])
59
-
60
- // Update position when dropdown opens
61
- const updatePosition = useCallback(() => {
62
- if (!triggerRef.current) return
63
- const rect = triggerRef.current.getBoundingClientRect()
64
- const dropdownWidth = Math.max(rect.width, 220) // Minimum width for checkboxes
65
- // Align dropdown to the right edge of the button
66
- const left = rect.right - dropdownWidth
67
- setDropdownPosition({
68
- top: rect.bottom + 4,
69
- left: Math.max(8, left), // Ensure at least 8px from screen edge
70
- width: dropdownWidth,
71
- })
72
- }, [])
73
-
74
- // Open dropdown
75
- const openDropdown = useCallback(() => {
76
- setIsOpen(true)
77
- setSearch('')
78
- setHighlightedIndex(0)
79
- updatePosition()
80
- }, [updatePosition])
81
-
82
- // Close dropdown
83
- const closeDropdown = useCallback(() => {
84
- setIsOpen(false)
85
- setSearch('')
86
- }, [])
87
-
88
- // Toggle a namespace selection
89
- const toggleNamespace = useCallback((ns: string) => {
90
- if (selectedSet.has(ns)) {
91
- onChange(value.filter((v) => v !== ns))
92
- } else {
93
- onChange([...value, ns])
94
- }
95
- }, [selectedSet, value, onChange])
96
-
97
- // Select all visible namespaces
98
- const selectAll = useCallback(() => {
99
- const allNames = sortedNamespaces.map((ns) => ns.name)
100
- onChange(allNames)
101
- }, [sortedNamespaces, onChange])
102
-
103
- // Clear all selections (shows all namespaces)
104
- const clearAll = useCallback(() => {
105
- onChange([])
106
- }, [onChange])
107
-
108
- // Add a manually typed namespace
109
- const addManualNamespace = useCallback(() => {
110
- const ns = manualInput.trim()
111
- if (ns && !selectedSet.has(ns)) {
112
- onChange([...value, ns])
113
- }
114
- setManualInput('')
115
- }, [manualInput, selectedSet, value, onChange])
116
-
117
- // Focus search input when dropdown opens
118
- useEffect(() => {
119
- if (isOpen) {
120
- // Small delay to ensure the dropdown is rendered
121
- requestAnimationFrame(() => {
122
- searchInputRef.current?.focus()
123
- })
124
- }
125
- }, [isOpen])
126
-
127
- // Reset highlighted index when filtered options change
128
- useEffect(() => {
129
- setHighlightedIndex(0)
130
- }, [filteredNamespaces])
131
-
132
- // Handle click outside
133
- useEffect(() => {
134
- if (!isOpen) return
135
-
136
- const handleClickOutside = (e: MouseEvent) => {
137
- const target = e.target as Node
138
- if (
139
- triggerRef.current?.contains(target) ||
140
- dropdownRef.current?.contains(target)
141
- ) {
142
- return
143
- }
144
- closeDropdown()
145
- }
146
-
147
- // Small delay to prevent immediate close on open click
148
- const timeoutId = setTimeout(() => {
149
- document.addEventListener('mousedown', handleClickOutside)
150
- }, 0)
151
-
152
- return () => {
153
- clearTimeout(timeoutId)
154
- document.removeEventListener('mousedown', handleClickOutside)
155
- }
156
- }, [isOpen, closeDropdown])
157
-
158
- // Handle keyboard navigation
159
- const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
160
- switch (e.key) {
161
- case 'ArrowDown':
162
- e.preventDefault()
163
- setHighlightedIndex((prev) =>
164
- prev < filteredNamespaces.length - 1 ? prev + 1 : prev
165
- )
166
- break
167
- case 'ArrowUp':
168
- e.preventDefault()
169
- setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0))
170
- break
171
- case 'Enter':
172
- case ' ':
173
- e.preventDefault()
174
- if (filteredNamespaces[highlightedIndex]) {
175
- toggleNamespace(filteredNamespaces[highlightedIndex].name)
176
- }
177
- break
178
- case 'Escape':
179
- e.preventDefault()
180
- closeDropdown()
181
- break
182
- case 'Tab':
183
- closeDropdown()
184
- break
185
- }
186
- }, [filteredNamespaces, highlightedIndex, toggleNamespace, closeDropdown])
187
-
188
- // Scroll highlighted item into view
189
- useEffect(() => {
190
- if (!isOpen || !dropdownRef.current) return
191
- const highlighted = dropdownRef.current.querySelector('[data-highlighted="true"]')
192
- if (highlighted) {
193
- highlighted.scrollIntoView({ block: 'nearest' })
194
- }
195
- }, [highlightedIndex, isOpen])
196
-
197
- // Get display value
198
- const displayValue = useMemo(() => {
199
- if (value.length === 0) return 'All Namespaces'
200
- if (value.length === 1) return value[0]
201
- return `${value.length} namespaces`
202
- }, [value])
203
-
204
- const allSelected = sortedNamespaces.length > 0 && value.length === sortedNamespaces.length
205
-
206
- return (
207
- <>
208
- <Tooltip content={disabledTooltip} disabled={!disabled} position="bottom">
209
- <button
210
- ref={triggerRef}
211
- type="button"
212
- disabled={disabled}
213
- onClick={() => (isOpen ? closeDropdown() : openDropdown())}
214
- className={clsx(
215
- 'appearance-none bg-theme-elevated text-theme-text-primary text-xs rounded px-2 py-1 pr-6 border border-theme-border-light',
216
- 'focus:outline-none focus:ring-1 focus:ring-blue-500 min-w-[100px] text-left relative',
217
- 'transition-colors',
218
- disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-theme-hover',
219
- className
220
- )}
221
- >
222
- <span className="block truncate">{displayValue}</span>
223
- <ChevronDown
224
- className={clsx(
225
- 'absolute right-1.5 top-1/2 -translate-y-1/2 w-3 h-3 text-theme-text-secondary transition-transform',
226
- isOpen && 'rotate-180'
227
- )}
228
- />
229
- </button>
230
- </Tooltip>
231
-
232
- {isOpen &&
233
- createPortal(
234
- <>
235
- {/* Backdrop to capture clicks outside the dropdown */}
236
- <div
237
- className="fixed inset-0 z-[9998]"
238
- onClick={closeDropdown}
239
- />
240
- <div
241
- ref={dropdownRef}
242
- className="fixed z-[9999] bg-theme-elevated border border-theme-border rounded-md shadow-lg overflow-hidden"
243
- style={{
244
- top: dropdownPosition.top,
245
- left: dropdownPosition.left,
246
- width: dropdownPosition.width,
247
- }}
248
- onKeyDown={handleKeyDown}
249
- >
250
- {isForbidden ? (
251
- <>
252
- {/* Manual namespace input when listing is forbidden */}
253
- <div className="p-2 border-b border-theme-border">
254
- <div className="flex items-center gap-1.5 text-[10px] text-amber-400 mb-2">
255
- <Shield className="w-3 h-3" />
256
- <span>Cannot list namespaces — type a name</span>
257
- </div>
258
- <div className="flex gap-1">
259
- <input
260
- ref={searchInputRef}
261
- type="text"
262
- value={manualInput}
263
- onChange={(e) => setManualInput(e.target.value)}
264
- onKeyDown={(e) => {
265
- if (e.key === 'Enter') {
266
- e.preventDefault()
267
- addManualNamespace()
268
- } else if (e.key === 'Escape') {
269
- closeDropdown()
270
- }
271
- }}
272
- placeholder="Namespace name..."
273
- className="flex-1 bg-theme-base text-theme-text-primary text-xs rounded px-2 py-1.5 border border-theme-border-light focus:outline-none focus:ring-1 focus:ring-blue-500 placeholder:text-theme-text-tertiary"
274
- />
275
- <button
276
- type="button"
277
- onClick={addManualNamespace}
278
- disabled={!manualInput.trim()}
279
- className="px-2 py-1 text-xs btn-brand rounded"
280
- >
281
- Add
282
- </button>
283
- </div>
284
- </div>
285
-
286
- {/* Show currently selected namespaces with remove button */}
287
- <div className="max-h-[200px] overflow-y-auto">
288
- {value.length === 0 ? (
289
- <div className="px-3 py-4 text-center text-xs text-theme-text-tertiary">
290
- All namespaces (type a name to filter)
291
- </div>
292
- ) : (
293
- value.map((ns) => (
294
- <div
295
- key={ns}
296
- className="w-full text-left px-3 py-1.5 text-xs flex items-center justify-between gap-2 text-theme-text-primary hover:bg-theme-hover"
297
- >
298
- <span className="truncate">{ns}</span>
299
- <button
300
- type="button"
301
- onClick={() => onChange(value.filter(v => v !== ns))}
302
- className="text-theme-text-tertiary hover:text-red-400 flex-shrink-0"
303
- >
304
- <X className="w-3 h-3" />
305
- </button>
306
- </div>
307
- ))
308
- )}
309
- </div>
310
-
311
- {value.length > 0 && (
312
- <div className="px-3 py-1.5 text-[10px] text-theme-text-tertiary border-t border-theme-border bg-theme-base flex justify-between">
313
- <span>{value.length} selected</span>
314
- <button type="button" onClick={clearAll} className="text-blue-400 hover:text-blue-300">
315
- Clear all
316
- </button>
317
- </div>
318
- )}
319
- </>
320
- ) : (
321
- <>
322
- {/* Search input */}
323
- <div className="p-2 border-b border-theme-border">
324
- <div className="relative">
325
- <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-theme-text-tertiary" />
326
- <input
327
- ref={searchInputRef}
328
- type="text"
329
- value={search}
330
- onChange={(e) => setSearch(e.target.value)}
331
- placeholder="Search namespaces..."
332
- className="w-full bg-theme-base text-theme-text-primary text-xs rounded px-2 py-1.5 pl-7 pr-7 border border-theme-border-light focus:outline-none focus:ring-1 focus:ring-blue-500 placeholder:text-theme-text-tertiary"
333
- />
334
- {search && (
335
- <button
336
- type="button"
337
- onClick={() => setSearch('')}
338
- className="absolute right-2 top-1/2 -translate-y-1/2 text-theme-text-tertiary hover:text-theme-text-secondary"
339
- >
340
- <X className="w-3.5 h-3.5" />
341
- </button>
342
- )}
343
- </div>
344
- </div>
345
-
346
- {/* Select All / Clear All buttons */}
347
- <div className="flex gap-1 px-2 py-1.5 border-b border-theme-border bg-theme-base">
348
- <button
349
- type="button"
350
- onClick={selectAll}
351
- disabled={allSelected}
352
- className={clsx(
353
- 'flex-1 text-[10px] px-2 py-1 rounded transition-colors',
354
- allSelected
355
- ? 'text-theme-text-tertiary cursor-not-allowed'
356
- : 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
357
- )}
358
- >
359
- Select All
360
- </button>
361
- <button
362
- type="button"
363
- onClick={clearAll}
364
- disabled={value.length === 0}
365
- className={clsx(
366
- 'flex-1 text-[10px] px-2 py-1 rounded transition-colors',
367
- value.length === 0
368
- ? 'text-theme-text-tertiary cursor-not-allowed'
369
- : 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
370
- )}
371
- >
372
- Clear All
373
- </button>
374
- </div>
375
-
376
- {/* Options list with checkboxes */}
377
- <div className="max-h-[240px] overflow-y-auto">
378
- {filteredNamespaces.length === 0 ? (
379
- <div className="px-3 py-6 text-center text-xs text-theme-text-tertiary">
380
- No namespaces match "{search}"
381
- </div>
382
- ) : (
383
- filteredNamespaces.map((ns, index) => {
384
- const isSelected = selectedSet.has(ns.name)
385
- return (
386
- <button
387
- key={ns.name}
388
- type="button"
389
- data-highlighted={index === highlightedIndex}
390
- onClick={() => toggleNamespace(ns.name)}
391
- onMouseEnter={() => setHighlightedIndex(index)}
392
- className={clsx(
393
- 'w-full text-left px-3 py-1.5 text-xs transition-colors flex items-center gap-2',
394
- 'text-theme-text-primary',
395
- index === highlightedIndex && 'bg-theme-hover'
396
- )}
397
- >
398
- <div
399
- className={clsx(
400
- 'w-3.5 h-3.5 rounded border flex items-center justify-center flex-shrink-0',
401
- isSelected
402
- ? 'bg-blue-500 border-blue-500'
403
- : 'border-theme-border-light bg-theme-base'
404
- )}
405
- >
406
- {isSelected && <Check className="w-2.5 h-2.5 text-white" />}
407
- </div>
408
- <span className="truncate">{ns.name}</span>
409
- </button>
410
- )
411
- })
412
- )}
413
- </div>
414
-
415
- {/* Namespace count and selection info */}
416
- {sortedNamespaces.length > 0 && (
417
- <div className="px-3 py-1.5 text-[10px] text-theme-text-tertiary border-t border-theme-border bg-theme-base flex justify-between">
418
- <span>
419
- {filteredNamespaces.length === sortedNamespaces.length
420
- ? `${sortedNamespaces.length} namespaces`
421
- : `${filteredNamespaces.length} of ${sortedNamespaces.length} namespaces`}
422
- </span>
423
- {value.length > 0 && (
424
- <span className="text-blue-400">{value.length} selected</span>
425
- )}
426
- </div>
427
- )}
428
- </>
429
- )}
430
- </div>
431
- </>,
432
- document.body
433
- )}
434
- </>
435
- )
436
- }