@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.
- package/README.md +7 -1
- package/package.json +33 -25
- package/src/App.tsx +1449 -382
- package/src/RadarApp.tsx +132 -19
- package/src/api/apiResources.ts +1 -1
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.delta.test.ts +89 -0
- package/src/api/client.deltaSync.test.ts +216 -0
- package/src/api/client.metrics.test.ts +106 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +2730 -271
- package/src/api/client.yaml.test.ts +45 -0
- package/src/api/diagnose.ts +289 -0
- package/src/api/quotas.ts +16 -0
- package/src/api/rbac.ts +57 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/assets/platform-icons/aws_eks.png +0 -0
- package/src/assets/platform-icons/azure-aks.svg +2 -0
- package/src/assets/platform-icons/google_kubernetes_engine.png +0 -0
- package/src/components/ConnectionErrorView.tsx +186 -70
- package/src/components/ContextSwitcher.tsx +63 -18
- package/src/components/DebugOverlay.tsx +5 -3
- package/src/components/NamespaceSwitcher.tsx +41 -0
- package/src/components/UserMenu.tsx +69 -21
- package/src/components/applications/ApplicationsView.tsx +936 -0
- package/src/components/audit/AuditSettingsDialog.tsx +79 -17
- package/src/components/audit/AuditView.tsx +65 -62
- package/src/components/compare/CompareViewRoute.tsx +124 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +79 -0
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +106 -75
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +507 -223
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/diagnose/AISettings.tsx +147 -0
- package/src/components/diagnose/DiagnoseContext.tsx +495 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +622 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1756 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/dock/index.ts +1 -1
- package/src/components/execution/BatchExecutionView.test.ts +170 -0
- package/src/components/execution/BatchExecutionView.tsx +1329 -0
- package/src/components/execution/batch-run-actions.test.ts +48 -0
- package/src/components/execution/batch-run-actions.ts +24 -0
- package/src/components/execution/batch-timeline.test.ts +57 -0
- package/src/components/execution/batch-timeline.ts +46 -0
- package/src/components/execution/execution-definition.test.ts +208 -0
- package/src/components/execution/execution-definition.ts +245 -0
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +1042 -0
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +87 -31
- package/src/components/helm/HelmCompareRoute.tsx +1341 -0
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
- package/src/components/helm/HelmView.tsx +237 -96
- package/src/components/helm/InstallWizard.tsx +94 -38
- package/src/components/helm/ManifestDiffViewer.tsx +8 -27
- package/src/components/helm/OwnedResources.tsx +34 -59
- package/src/components/helm/RevisionHistory.tsx +52 -3
- package/src/components/helm/RoleGatedPanel.tsx +3 -3
- package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/helm/ValuesViewer.tsx +49 -53
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/home/ClusterHealthCard.tsx +116 -52
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/GitOpsControllersCard.tsx +110 -0
- package/src/components/home/HelmSummary.tsx +3 -1
- package/src/components/home/HomeView.tsx +339 -105
- package/src/components/home/MCPSetupDialog.tsx +29 -87
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +333 -0
- package/src/components/issues/IssuesPane.tsx +151 -0
- package/src/components/logs/LogsViewer.tsx +4 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
- package/src/components/nav/PrimaryNavRail.tsx +285 -0
- package/src/components/portforward/PortForwardButton.tsx +118 -47
- package/src/components/portforward/PortForwardManager.tsx +253 -131
- package/src/components/resource/HPACharts.tsx +237 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +160 -584
- package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
- package/src/components/resource/RestartChart.tsx +133 -0
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +363 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/ImageFilesystemModal.tsx +19 -12
- package/src/components/resources/PodFilesystemModal.tsx +6 -5
- package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
- package/src/components/resources/ResourcesView.tsx +194 -17
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +20 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +30 -6
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils.ts +2 -1
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +241 -0
- package/src/components/settings/SettingsDialog.tsx +1505 -165
- package/src/components/shared/CreateResourceDialog.tsx +9 -2
- package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +86 -13
- package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
- package/src/components/timeline/TimelineView.tsx +873 -24
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
- package/src/components/traffic/TrafficFlowList.tsx +29 -15
- package/src/components/traffic/TrafficGraph.tsx +42 -24
- package/src/components/traffic/TrafficView.tsx +32 -19
- package/src/components/ui/CommandPalette.tsx +8 -215
- package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
- package/src/components/ui/Markdown.tsx +3 -3
- package/src/components/ui/Omnibar.tsx +602 -0
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
- package/src/components/ui/UpdateNotification.tsx +48 -36
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +1342 -158
- package/src/context/ConnectionContext.tsx +146 -21
- package/src/context/DiagnoseCustomization.tsx +93 -0
- package/src/context/NavCustomization.tsx +75 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/contexts/CapabilitiesContext.tsx +38 -8
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/index.css +162 -1
- package/src/index.ts +73 -1
- package/src/main.tsx +7 -5
- package/src/types/clusterLoadState.ts +33 -0
- package/src/types.ts +2 -0
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +64 -1
- package/src/components/ui/NamespaceSelector.tsx +0 -436
package/src/utils/navigation.ts
CHANGED
|
@@ -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
|
-
}
|