@skyhook-io/radar-app 1.0.2 → 1.1.0
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/package.json +6 -6
- package/src/App.tsx +214 -39
- package/src/api/client.ts +235 -3
- package/src/components/NamespaceSwitcher.tsx +298 -0
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/index.ts +1 -1
- package/src/components/gitops/GitOpsView.tsx +2441 -0
- package/src/components/gitops/RollbackDialog.tsx +107 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +144 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +20 -3
- package/src/components/helm/HelmView.tsx +9 -1
- package/src/components/helm/OwnedResources.tsx +2 -2
- package/src/components/home/GitOpsControllersCard.tsx +108 -0
- package/src/components/home/HomeView.tsx +9 -1
- package/src/components/portforward/PortForwardManager.tsx +135 -109
- package/src/components/resources/ResourcesView.tsx +27 -2
- package/src/components/resources/resource-utils.ts +2 -1
- package/src/components/timeline/TimelineSwimlanes.tsx +20 -6
- package/src/components/ui/CommandPalette.tsx +6 -4
- package/src/components/ui/ShortcutHelpOverlay.tsx +2 -1
- package/src/components/ui/UpdateNotification.tsx +36 -19
- package/src/components/workload/WorkloadView.tsx +126 -10
- package/src/types.ts +2 -0
- package/src/utils/navigation.ts +14 -1
- package/src/components/ui/NamespaceSelector.tsx +0 -446
|
@@ -6,9 +6,11 @@ import { Terminal } from 'lucide-react'
|
|
|
6
6
|
import {
|
|
7
7
|
WorkloadView as BaseWorkloadView,
|
|
8
8
|
type RendererOverrides,
|
|
9
|
+
type GitOpsOwnerRef,
|
|
10
|
+
gitOpsRouteForOwner,
|
|
9
11
|
} from '@skyhook-io/k8s-ui'
|
|
10
12
|
import type { SelectedResource, ResourceRef, ResolvedEnvFrom } from '../../types'
|
|
11
|
-
import { kindToPlural, type NavigateToResource } from '../../utils/navigation'
|
|
13
|
+
import { kindToPlural, buildWorkloadPath, type NavigateToResource } from '../../utils/navigation'
|
|
12
14
|
import {
|
|
13
15
|
useChanges, useResourceWithRelationships, usePodLogs, useTopology, useUpdateResource,
|
|
14
16
|
useDeleteResource, useTriggerCronJob, useSuspendCronJob, useResumeCronJob,
|
|
@@ -21,7 +23,7 @@ import {
|
|
|
21
23
|
fetchJSON,
|
|
22
24
|
} from '../../api/client'
|
|
23
25
|
import { PrometheusCharts, isPrometheusSupported } from '../resource/PrometheusCharts'
|
|
24
|
-
import { useResourceAudit } from '../../api/client'
|
|
26
|
+
import { useResourceAudit, useResources } from '../../api/client'
|
|
25
27
|
import { AuditAlerts } from '@skyhook-io/k8s-ui'
|
|
26
28
|
import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer'
|
|
27
29
|
import { LogsViewer } from '../logs/LogsViewer'
|
|
@@ -35,6 +37,7 @@ import { ServiceRenderer } from '../resources/renderers/ServiceRenderer'
|
|
|
35
37
|
import { WorkloadRenderer } from '../resources/renderers/WorkloadRenderer'
|
|
36
38
|
import { CreateResourceDialog } from '../shared/CreateResourceDialog'
|
|
37
39
|
import { cleanYamlForDuplicate } from '../../utils/skeleton-yaml'
|
|
40
|
+
import { useDesktopDownload } from '../../hooks/useDesktopDownload'
|
|
38
41
|
|
|
39
42
|
type TabType = 'overview' | 'timeline' | 'logs' | 'metrics' | 'yaml'
|
|
40
43
|
|
|
@@ -54,13 +57,19 @@ interface WorkloadViewRouteProps {
|
|
|
54
57
|
export function WorkloadViewRoute({ onNavigateToResource }: WorkloadViewRouteProps) {
|
|
55
58
|
const location = useLocation()
|
|
56
59
|
const navigate = useNavigate()
|
|
60
|
+
const [searchParams] = useSearchParams()
|
|
57
61
|
|
|
58
|
-
// Parse /workload/:kind/:ns/:name from pathname
|
|
62
|
+
// Parse /workload/:kind/:ns/:name from pathname. Segments are URL-encoded by
|
|
63
|
+
// buildWorkloadPath; names can also contain literal slashes (e.g. some CRD names),
|
|
64
|
+
// which survive encoding as %2F and reassemble correctly here.
|
|
59
65
|
const parts = location.pathname.replace(/^\//, '').split('/')
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
66
|
+
const decode = (s: string): string => {
|
|
67
|
+
try { return decodeURIComponent(s) } catch { return s }
|
|
68
|
+
}
|
|
69
|
+
const kind = decode(parts[1] ?? '')
|
|
70
|
+
const namespace = decode(parts[2] ?? '')
|
|
71
|
+
const name = parts.slice(3).map(decode).join('/')
|
|
72
|
+
const group = searchParams.get('apiGroup') || ''
|
|
64
73
|
|
|
65
74
|
if (!kind || !namespace || !name) {
|
|
66
75
|
return (
|
|
@@ -79,8 +88,7 @@ export function WorkloadViewRoute({ onNavigateToResource }: WorkloadViewRoutePro
|
|
|
79
88
|
}, [navigate])
|
|
80
89
|
|
|
81
90
|
const handleNavigate = useCallback((resource: SelectedResource) => {
|
|
82
|
-
|
|
83
|
-
navigate(`/workload/${resource.kind}/${resource.namespace}/${resource.name}`)
|
|
91
|
+
navigate(buildWorkloadPath(resource))
|
|
84
92
|
}, [navigate])
|
|
85
93
|
|
|
86
94
|
return (
|
|
@@ -88,6 +96,7 @@ export function WorkloadViewRoute({ onNavigateToResource }: WorkloadViewRoutePro
|
|
|
88
96
|
kind={kind}
|
|
89
97
|
namespace={namespace}
|
|
90
98
|
name={name}
|
|
99
|
+
group={group}
|
|
91
100
|
onBack={handleBack}
|
|
92
101
|
onNavigateToResource={onNavigateToResource || handleNavigate}
|
|
93
102
|
/>
|
|
@@ -315,11 +324,22 @@ export function WorkloadView({
|
|
|
315
324
|
const canUpdateSecrets = useCanUpdateSecrets()
|
|
316
325
|
const updateResource = useUpdateResource()
|
|
317
326
|
const actionsBarProps = useActionsBarProps(kindProp, namespace, name)
|
|
327
|
+
const desktopDownload = useDesktopDownload()
|
|
318
328
|
|
|
319
329
|
const handleUpdateResource = useCallback(async (params: { kind: string; namespace: string; name: string; yaml: string }) => {
|
|
320
330
|
await updateResource.mutateAsync(params)
|
|
321
331
|
}, [updateResource])
|
|
322
332
|
|
|
333
|
+
const navigateRouter = useNavigate()
|
|
334
|
+
const handleOpenGitOpsResource = useCallback(
|
|
335
|
+
(ref: GitOpsOwnerRef) => navigateRouter(gitOpsRouteForOwner(ref)),
|
|
336
|
+
[navigateRouter],
|
|
337
|
+
)
|
|
338
|
+
const handleNavigateGitOpsPath = useCallback(
|
|
339
|
+
(path: string) => navigateRouter(path),
|
|
340
|
+
[navigateRouter],
|
|
341
|
+
)
|
|
342
|
+
|
|
323
343
|
// Duplicate dialog
|
|
324
344
|
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
|
|
325
345
|
const [duplicateYaml, setDuplicateYaml] = useState('')
|
|
@@ -370,12 +390,18 @@ export function WorkloadView({
|
|
|
370
390
|
isPrometheusSupported(kind) && !(kind === 'Pod' && res?.status?.phase === 'Pending')
|
|
371
391
|
}
|
|
372
392
|
onDuplicate={handleDuplicate}
|
|
393
|
+
onDownload={desktopDownload}
|
|
373
394
|
actionsBarProps={actionsBarProps}
|
|
374
395
|
rendererOverrides={rendererOverrides}
|
|
375
396
|
resolvedEnvFrom={resolvedEnvFrom}
|
|
376
397
|
renderOverviewExtra={({ kind: k, namespace: ns, name: n }) => (
|
|
377
|
-
|
|
398
|
+
<>
|
|
399
|
+
<AuditSection kind={k} namespace={ns} name={n} />
|
|
400
|
+
<FluxSourceConsumersSection kind={k} namespace={ns} name={n} />
|
|
401
|
+
</>
|
|
378
402
|
)}
|
|
403
|
+
onOpenGitOpsResource={handleOpenGitOpsResource}
|
|
404
|
+
onNavigateGitOpsPath={handleNavigateGitOpsPath}
|
|
379
405
|
/>
|
|
380
406
|
<CreateResourceDialog
|
|
381
407
|
open={duplicateDialogOpen}
|
|
@@ -546,3 +572,93 @@ function AuditSection({ kind, namespace, name }: { kind: string; namespace: stri
|
|
|
546
572
|
if (!findings || findings.length === 0) return null
|
|
547
573
|
return <AuditAlerts findings={findings} onViewAll={() => navigate('/audit')} />
|
|
548
574
|
}
|
|
575
|
+
|
|
576
|
+
// FluxSourceConsumersSection lists the reconcilers (Kustomization, HelmRelease)
|
|
577
|
+
// that reference this Flux source CR — the inverse of `spec.sourceRef`. Renders
|
|
578
|
+
// only when the focused resource is a Flux source kind; otherwise null. Sources
|
|
579
|
+
// can have many consumers (one repo feeding multiple apps), so this answers
|
|
580
|
+
// "if I edit this source, what gets affected on the next reconcile?".
|
|
581
|
+
//
|
|
582
|
+
// Filtering happens client-side off the namespaced reconciler lists — these
|
|
583
|
+
// are typically small (tens, not thousands) and the dynamic informer cache
|
|
584
|
+
// makes the request cheap. If a cluster ever has thousands of HelmReleases,
|
|
585
|
+
// a dedicated /api/gitops/consumers endpoint would be the right move; today
|
|
586
|
+
// it'd be premature.
|
|
587
|
+
// Outer component is cheap — it does only the kind check and decides whether
|
|
588
|
+
// to mount the data-fetching child. Without this split, useResources would
|
|
589
|
+
// fire two API calls on EVERY workload drawer open (Pod, Deployment, Service,
|
|
590
|
+
// …), since the hook has no `enabled` flag and can't be conditionally called
|
|
591
|
+
// (Rules of Hooks). The hooks only need to run when the focused resource is
|
|
592
|
+
// actually a Flux source CR.
|
|
593
|
+
function FluxSourceConsumersSection({ kind, namespace, name }: { kind: string; namespace: string; name: string }) {
|
|
594
|
+
// The inner WorkloadView de-pluralizes the URL's plural form, which gives
|
|
595
|
+
// "Gitrepository" (single-uppercase) rather than the wire-correct
|
|
596
|
+
// "GitRepository" — so we match lowercase. spec.sourceRef.kind on consumers
|
|
597
|
+
// is always wire-correct, so we look that up separately.
|
|
598
|
+
const sourceKind = FLUX_SOURCE_KIND_BY_LOWER.get(kind.toLowerCase()) ?? null
|
|
599
|
+
if (!sourceKind) return null
|
|
600
|
+
return <FluxSourceConsumersInner sourceKind={sourceKind} namespace={namespace} name={name} />
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function FluxSourceConsumersInner({ sourceKind, namespace, name }: { sourceKind: string; namespace: string; name: string }) {
|
|
604
|
+
const navigate = useNavigate()
|
|
605
|
+
const { data: kustomizations } = useResources<any>('kustomizations', undefined, 'kustomize.toolkit.fluxcd.io')
|
|
606
|
+
const { data: helmReleases } = useResources<any>('helmreleases', undefined, 'helm.toolkit.fluxcd.io')
|
|
607
|
+
|
|
608
|
+
const consumers: Array<{ kind: 'Kustomization' | 'HelmRelease'; namespace: string; name: string; plural: string }> = []
|
|
609
|
+
for (const k of kustomizations ?? []) {
|
|
610
|
+
const ref = k?.spec?.sourceRef ?? {}
|
|
611
|
+
const refNs = ref.namespace || k?.metadata?.namespace
|
|
612
|
+
if (ref.kind === sourceKind && ref.name === name && refNs === namespace) {
|
|
613
|
+
consumers.push({ kind: 'Kustomization', namespace: k.metadata.namespace, name: k.metadata.name, plural: 'kustomizations' })
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
for (const h of helmReleases ?? []) {
|
|
617
|
+
const ref = h?.spec?.chart?.spec?.sourceRef ?? {}
|
|
618
|
+
const refNs = ref.namespace || h?.metadata?.namespace
|
|
619
|
+
if (ref.kind === sourceKind && ref.name === name && refNs === namespace) {
|
|
620
|
+
consumers.push({ kind: 'HelmRelease', namespace: h.metadata.namespace, name: h.metadata.name, plural: 'helmreleases' })
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (consumers.length === 0) {
|
|
625
|
+
return (
|
|
626
|
+
<div className="rounded-lg border border-theme-border bg-theme-elevated/40 p-3 text-xs text-theme-text-tertiary">
|
|
627
|
+
Consumed by — no Kustomization or HelmRelease references this source.
|
|
628
|
+
</div>
|
|
629
|
+
)
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return (
|
|
633
|
+
<div className="rounded-lg border border-theme-border bg-theme-elevated/40 p-3">
|
|
634
|
+
<h3 className="mb-2 text-xs font-medium text-theme-text-secondary">
|
|
635
|
+
Consumed by ({consumers.length})
|
|
636
|
+
</h3>
|
|
637
|
+
<div className="flex flex-wrap gap-1.5">
|
|
638
|
+
{consumers.map((c) => (
|
|
639
|
+
<button
|
|
640
|
+
key={`${c.kind}/${c.namespace}/${c.name}`}
|
|
641
|
+
onClick={() => navigate(`/gitops/detail/${c.plural}/${encodeURIComponent(c.namespace)}/${encodeURIComponent(c.name)}`)}
|
|
642
|
+
className="inline-flex items-center gap-1.5 rounded border border-theme-border bg-theme-surface px-1.5 py-0.5 text-[11px] text-theme-text-secondary hover:border-skyhook-500/60 hover:text-skyhook-500 transition-colors"
|
|
643
|
+
title={`${c.kind} ${c.namespace}/${c.name}`}
|
|
644
|
+
>
|
|
645
|
+
<span className="text-theme-text-tertiary">{c.kind === 'HelmRelease' ? 'HR' : 'K'}</span>
|
|
646
|
+
<span>{c.namespace}/{c.name}</span>
|
|
647
|
+
</button>
|
|
648
|
+
))}
|
|
649
|
+
</div>
|
|
650
|
+
</div>
|
|
651
|
+
)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// FLUX_SOURCE_KIND_BY_LOWER maps lowercase kind (what the inner WorkloadView
|
|
655
|
+
// produces via its plural-to-singular fallback) to the wire-correct
|
|
656
|
+
// PascalCase form that consumers carry in spec.sourceRef.kind. HelmChart is
|
|
657
|
+
// intentionally absent — it's an auto-generated internal CR, not something
|
|
658
|
+
// users create or point reconcilers at directly.
|
|
659
|
+
const FLUX_SOURCE_KIND_BY_LOWER = new Map<string, string>([
|
|
660
|
+
['gitrepository', 'GitRepository'],
|
|
661
|
+
['helmrepository', 'HelmRepository'],
|
|
662
|
+
['ocirepository', 'OCIRepository'],
|
|
663
|
+
['bucket', 'Bucket'],
|
|
664
|
+
])
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
// Re-export all types from the shared @skyhook-io/k8s-ui package.
|
|
2
2
|
// This file exists for backward compatibility — new code should import from '@skyhook-io/k8s-ui' directly.
|
|
3
3
|
export * from '@skyhook-io/k8s-ui/types/core'
|
|
4
|
+
export * from '@skyhook-io/k8s-ui/types/gitops-tree'
|
|
5
|
+
export * from '@skyhook-io/k8s-ui/types/gitops-insights'
|
package/src/utils/navigation.ts
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import { apiUrl, getAuthHeaders, getCredentialsMode } from '../api/config'
|
|
2
|
+
import type { SelectedResource } from '@skyhook-io/k8s-ui/types/core'
|
|
2
3
|
|
|
3
4
|
// Re-export shared navigation utilities from @skyhook-io/k8s-ui.
|
|
4
|
-
export { kindToPlural, pluralToKind, refToSelectedResource } from '@skyhook-io/k8s-ui/utils/navigation'
|
|
5
|
+
export { kindToPlural, pluralToKind, refToSelectedResource, apiVersionToGroup } from '@skyhook-io/k8s-ui/utils/navigation'
|
|
5
6
|
export type { NavigateToResource } from '@skyhook-io/k8s-ui/utils/navigation'
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Build a /workload/:kind/:namespace/:name URL, preserving the API group as a
|
|
10
|
+
* query param so the WorkloadView can resolve CRDs with colliding kind names.
|
|
11
|
+
*/
|
|
12
|
+
export function buildWorkloadPath(resource: SelectedResource): string {
|
|
13
|
+
const kind = encodeURIComponent(resource.kind)
|
|
14
|
+
const namespace = encodeURIComponent(resource.namespace)
|
|
15
|
+
const name = encodeURIComponent(resource.name)
|
|
16
|
+
const base = `/workload/${kind}/${namespace}/${name}`
|
|
17
|
+
return resource.group ? `${base}?apiGroup=${encodeURIComponent(resource.group)}` : base
|
|
18
|
+
}
|
|
19
|
+
|
|
7
20
|
// radar-specific: open URL in system browser (desktop app support)
|
|
8
21
|
export function openExternal(url: string): void {
|
|
9
22
|
fetch(apiUrl('/desktop/open-url'), {
|
|
@@ -1,446 +0,0 @@
|
|
|
1
|
-
import { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } 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
|
-
export interface NamespaceSelectorHandle {
|
|
13
|
-
open: () => void
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
interface NamespaceSelectorProps {
|
|
17
|
-
value: string[]
|
|
18
|
-
onChange: (value: string[]) => void
|
|
19
|
-
namespaces: Namespace[] | undefined
|
|
20
|
-
namespacesError?: Error | null
|
|
21
|
-
className?: string
|
|
22
|
-
disabled?: boolean
|
|
23
|
-
disabledTooltip?: string
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export const NamespaceSelector = forwardRef<NamespaceSelectorHandle, NamespaceSelectorProps>(({
|
|
27
|
-
value,
|
|
28
|
-
onChange,
|
|
29
|
-
namespaces,
|
|
30
|
-
namespacesError,
|
|
31
|
-
className,
|
|
32
|
-
disabled,
|
|
33
|
-
disabledTooltip,
|
|
34
|
-
}, ref) => {
|
|
35
|
-
const [isOpen, setIsOpen] = useState(false)
|
|
36
|
-
const [search, setSearch] = useState('')
|
|
37
|
-
const [manualInput, setManualInput] = useState('')
|
|
38
|
-
const [highlightedIndex, setHighlightedIndex] = useState(0)
|
|
39
|
-
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 })
|
|
40
|
-
|
|
41
|
-
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
42
|
-
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
43
|
-
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
44
|
-
|
|
45
|
-
const isForbidden = isForbiddenError(namespacesError)
|
|
46
|
-
|
|
47
|
-
// Convert value to Set for efficient lookups
|
|
48
|
-
const selectedSet = useMemo(() => new Set(value), [value])
|
|
49
|
-
|
|
50
|
-
// Sort and filter namespaces
|
|
51
|
-
const sortedNamespaces = useMemo(() => {
|
|
52
|
-
if (!namespaces) return []
|
|
53
|
-
return [...namespaces].sort((a, b) => a.name.localeCompare(b.name))
|
|
54
|
-
}, [namespaces])
|
|
55
|
-
|
|
56
|
-
const filteredNamespaces = useMemo(() => {
|
|
57
|
-
if (!search.trim()) return sortedNamespaces
|
|
58
|
-
const searchLower = search.toLowerCase()
|
|
59
|
-
return sortedNamespaces.filter((ns) =>
|
|
60
|
-
ns.name.toLowerCase().includes(searchLower)
|
|
61
|
-
)
|
|
62
|
-
}, [sortedNamespaces, search])
|
|
63
|
-
|
|
64
|
-
// Update position when dropdown opens
|
|
65
|
-
const updatePosition = useCallback(() => {
|
|
66
|
-
if (!triggerRef.current) return
|
|
67
|
-
const rect = triggerRef.current.getBoundingClientRect()
|
|
68
|
-
const dropdownWidth = Math.max(rect.width, 220) // Minimum width for checkboxes
|
|
69
|
-
// Align dropdown to the right edge of the button
|
|
70
|
-
const left = rect.right - dropdownWidth
|
|
71
|
-
setDropdownPosition({
|
|
72
|
-
top: rect.bottom + 4,
|
|
73
|
-
left: Math.max(8, left), // Ensure at least 8px from screen edge
|
|
74
|
-
width: dropdownWidth,
|
|
75
|
-
})
|
|
76
|
-
}, [])
|
|
77
|
-
|
|
78
|
-
// Open dropdown
|
|
79
|
-
const openDropdown = useCallback(() => {
|
|
80
|
-
if (disabled) return
|
|
81
|
-
setIsOpen(true)
|
|
82
|
-
setSearch('')
|
|
83
|
-
setHighlightedIndex(0)
|
|
84
|
-
updatePosition()
|
|
85
|
-
}, [disabled, updatePosition])
|
|
86
|
-
|
|
87
|
-
// Expose open method via ref
|
|
88
|
-
useImperativeHandle(ref, () => ({
|
|
89
|
-
open: openDropdown
|
|
90
|
-
}), [openDropdown])
|
|
91
|
-
|
|
92
|
-
// Close dropdown
|
|
93
|
-
const closeDropdown = useCallback(() => {
|
|
94
|
-
setIsOpen(false)
|
|
95
|
-
setSearch('')
|
|
96
|
-
}, [])
|
|
97
|
-
|
|
98
|
-
// Toggle a namespace selection
|
|
99
|
-
const toggleNamespace = useCallback((ns: string) => {
|
|
100
|
-
if (selectedSet.has(ns)) {
|
|
101
|
-
onChange(value.filter((v) => v !== ns))
|
|
102
|
-
} else {
|
|
103
|
-
onChange([...value, ns])
|
|
104
|
-
}
|
|
105
|
-
}, [selectedSet, value, onChange])
|
|
106
|
-
|
|
107
|
-
// Select all visible namespaces
|
|
108
|
-
const selectAll = useCallback(() => {
|
|
109
|
-
const allNames = sortedNamespaces.map((ns) => ns.name)
|
|
110
|
-
onChange(allNames)
|
|
111
|
-
}, [sortedNamespaces, onChange])
|
|
112
|
-
|
|
113
|
-
// Clear all selections (shows all namespaces)
|
|
114
|
-
const clearAll = useCallback(() => {
|
|
115
|
-
onChange([])
|
|
116
|
-
}, [onChange])
|
|
117
|
-
|
|
118
|
-
// Add a manually typed namespace
|
|
119
|
-
const addManualNamespace = useCallback(() => {
|
|
120
|
-
const ns = manualInput.trim()
|
|
121
|
-
if (ns && !selectedSet.has(ns)) {
|
|
122
|
-
onChange([...value, ns])
|
|
123
|
-
}
|
|
124
|
-
setManualInput('')
|
|
125
|
-
}, [manualInput, selectedSet, value, onChange])
|
|
126
|
-
|
|
127
|
-
// Focus search input when dropdown opens
|
|
128
|
-
useEffect(() => {
|
|
129
|
-
if (isOpen) {
|
|
130
|
-
// Small delay to ensure the dropdown is rendered
|
|
131
|
-
requestAnimationFrame(() => {
|
|
132
|
-
searchInputRef.current?.focus()
|
|
133
|
-
})
|
|
134
|
-
}
|
|
135
|
-
}, [isOpen])
|
|
136
|
-
|
|
137
|
-
// Reset highlighted index when filtered options change
|
|
138
|
-
useEffect(() => {
|
|
139
|
-
setHighlightedIndex(0)
|
|
140
|
-
}, [filteredNamespaces])
|
|
141
|
-
|
|
142
|
-
// Handle click outside
|
|
143
|
-
useEffect(() => {
|
|
144
|
-
if (!isOpen) return
|
|
145
|
-
|
|
146
|
-
const handleClickOutside = (e: MouseEvent) => {
|
|
147
|
-
const target = e.target as Node
|
|
148
|
-
if (
|
|
149
|
-
triggerRef.current?.contains(target) ||
|
|
150
|
-
dropdownRef.current?.contains(target)
|
|
151
|
-
) {
|
|
152
|
-
return
|
|
153
|
-
}
|
|
154
|
-
closeDropdown()
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Small delay to prevent immediate close on open click
|
|
158
|
-
const timeoutId = setTimeout(() => {
|
|
159
|
-
document.addEventListener('mousedown', handleClickOutside)
|
|
160
|
-
}, 0)
|
|
161
|
-
|
|
162
|
-
return () => {
|
|
163
|
-
clearTimeout(timeoutId)
|
|
164
|
-
document.removeEventListener('mousedown', handleClickOutside)
|
|
165
|
-
}
|
|
166
|
-
}, [isOpen, closeDropdown])
|
|
167
|
-
|
|
168
|
-
// Handle keyboard navigation
|
|
169
|
-
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
170
|
-
switch (e.key) {
|
|
171
|
-
case 'ArrowDown':
|
|
172
|
-
e.preventDefault()
|
|
173
|
-
setHighlightedIndex((prev) =>
|
|
174
|
-
prev < filteredNamespaces.length - 1 ? prev + 1 : prev
|
|
175
|
-
)
|
|
176
|
-
break
|
|
177
|
-
case 'ArrowUp':
|
|
178
|
-
e.preventDefault()
|
|
179
|
-
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0))
|
|
180
|
-
break
|
|
181
|
-
case 'Enter':
|
|
182
|
-
case ' ':
|
|
183
|
-
e.preventDefault()
|
|
184
|
-
if (filteredNamespaces[highlightedIndex]) {
|
|
185
|
-
toggleNamespace(filteredNamespaces[highlightedIndex].name)
|
|
186
|
-
}
|
|
187
|
-
break
|
|
188
|
-
case 'Escape':
|
|
189
|
-
e.preventDefault()
|
|
190
|
-
closeDropdown()
|
|
191
|
-
break
|
|
192
|
-
case 'Tab':
|
|
193
|
-
closeDropdown()
|
|
194
|
-
break
|
|
195
|
-
}
|
|
196
|
-
}, [filteredNamespaces, highlightedIndex, toggleNamespace, closeDropdown])
|
|
197
|
-
|
|
198
|
-
// Scroll highlighted item into view
|
|
199
|
-
useEffect(() => {
|
|
200
|
-
if (!isOpen || !dropdownRef.current) return
|
|
201
|
-
const highlighted = dropdownRef.current.querySelector('[data-highlighted="true"]')
|
|
202
|
-
if (highlighted) {
|
|
203
|
-
highlighted.scrollIntoView({ block: 'nearest' })
|
|
204
|
-
}
|
|
205
|
-
}, [highlightedIndex, isOpen])
|
|
206
|
-
|
|
207
|
-
// Get display value
|
|
208
|
-
const displayValue = useMemo(() => {
|
|
209
|
-
if (value.length === 0) return 'All Namespaces'
|
|
210
|
-
if (value.length === 1) return value[0]
|
|
211
|
-
return `${value.length} namespaces`
|
|
212
|
-
}, [value])
|
|
213
|
-
|
|
214
|
-
const allSelected = sortedNamespaces.length > 0 && value.length === sortedNamespaces.length
|
|
215
|
-
|
|
216
|
-
return (
|
|
217
|
-
<>
|
|
218
|
-
<Tooltip content={disabledTooltip} disabled={!disabled} position="bottom">
|
|
219
|
-
<button
|
|
220
|
-
ref={triggerRef}
|
|
221
|
-
type="button"
|
|
222
|
-
disabled={disabled}
|
|
223
|
-
onClick={() => (isOpen ? closeDropdown() : openDropdown())}
|
|
224
|
-
className={clsx(
|
|
225
|
-
'appearance-none bg-theme-elevated text-theme-text-primary text-xs rounded px-2 py-1 pr-6 border border-theme-border-light',
|
|
226
|
-
'focus:outline-none focus:ring-1 focus:ring-blue-500 min-w-[100px] text-left relative',
|
|
227
|
-
'transition-colors',
|
|
228
|
-
disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-theme-hover',
|
|
229
|
-
className
|
|
230
|
-
)}
|
|
231
|
-
>
|
|
232
|
-
<span className="block truncate">{displayValue}</span>
|
|
233
|
-
<ChevronDown
|
|
234
|
-
className={clsx(
|
|
235
|
-
'absolute right-1.5 top-1/2 -translate-y-1/2 w-3 h-3 text-theme-text-secondary transition-transform',
|
|
236
|
-
isOpen && 'rotate-180'
|
|
237
|
-
)}
|
|
238
|
-
/>
|
|
239
|
-
</button>
|
|
240
|
-
</Tooltip>
|
|
241
|
-
|
|
242
|
-
{isOpen &&
|
|
243
|
-
createPortal(
|
|
244
|
-
<>
|
|
245
|
-
{/* Backdrop to capture clicks outside the dropdown */}
|
|
246
|
-
<div
|
|
247
|
-
className="fixed inset-0 z-[9998]"
|
|
248
|
-
onClick={closeDropdown}
|
|
249
|
-
/>
|
|
250
|
-
<div
|
|
251
|
-
ref={dropdownRef}
|
|
252
|
-
className="fixed z-[9999] bg-theme-elevated border border-theme-border rounded-md shadow-lg overflow-hidden"
|
|
253
|
-
style={{
|
|
254
|
-
top: dropdownPosition.top,
|
|
255
|
-
left: dropdownPosition.left,
|
|
256
|
-
width: dropdownPosition.width,
|
|
257
|
-
}}
|
|
258
|
-
onKeyDown={handleKeyDown}
|
|
259
|
-
>
|
|
260
|
-
{isForbidden ? (
|
|
261
|
-
<>
|
|
262
|
-
{/* Manual namespace input when listing is forbidden */}
|
|
263
|
-
<div className="p-2 border-b border-theme-border">
|
|
264
|
-
<div className="flex items-center gap-1.5 text-[10px] text-amber-400 mb-2">
|
|
265
|
-
<Shield className="w-3 h-3" />
|
|
266
|
-
<span>Cannot list namespaces — type a name</span>
|
|
267
|
-
</div>
|
|
268
|
-
<div className="flex gap-1">
|
|
269
|
-
<input
|
|
270
|
-
ref={searchInputRef}
|
|
271
|
-
type="text"
|
|
272
|
-
value={manualInput}
|
|
273
|
-
onChange={(e) => setManualInput(e.target.value)}
|
|
274
|
-
onKeyDown={(e) => {
|
|
275
|
-
if (e.key === 'Enter') {
|
|
276
|
-
e.preventDefault()
|
|
277
|
-
addManualNamespace()
|
|
278
|
-
} else if (e.key === 'Escape') {
|
|
279
|
-
closeDropdown()
|
|
280
|
-
}
|
|
281
|
-
}}
|
|
282
|
-
placeholder="Namespace name..."
|
|
283
|
-
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"
|
|
284
|
-
/>
|
|
285
|
-
<button
|
|
286
|
-
type="button"
|
|
287
|
-
onClick={addManualNamespace}
|
|
288
|
-
disabled={!manualInput.trim()}
|
|
289
|
-
className="px-2 py-1 text-xs btn-brand rounded"
|
|
290
|
-
>
|
|
291
|
-
Add
|
|
292
|
-
</button>
|
|
293
|
-
</div>
|
|
294
|
-
</div>
|
|
295
|
-
|
|
296
|
-
{/* Show currently selected namespaces with remove button */}
|
|
297
|
-
<div className="max-h-[200px] overflow-y-auto">
|
|
298
|
-
{value.length === 0 ? (
|
|
299
|
-
<div className="px-3 py-4 text-center text-xs text-theme-text-tertiary">
|
|
300
|
-
All namespaces (type a name to filter)
|
|
301
|
-
</div>
|
|
302
|
-
) : (
|
|
303
|
-
value.map((ns) => (
|
|
304
|
-
<div
|
|
305
|
-
key={ns}
|
|
306
|
-
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"
|
|
307
|
-
>
|
|
308
|
-
<span className="truncate">{ns}</span>
|
|
309
|
-
<button
|
|
310
|
-
type="button"
|
|
311
|
-
onClick={() => onChange(value.filter(v => v !== ns))}
|
|
312
|
-
className="text-theme-text-tertiary hover:text-red-400 flex-shrink-0"
|
|
313
|
-
>
|
|
314
|
-
<X className="w-3 h-3" />
|
|
315
|
-
</button>
|
|
316
|
-
</div>
|
|
317
|
-
))
|
|
318
|
-
)}
|
|
319
|
-
</div>
|
|
320
|
-
|
|
321
|
-
{value.length > 0 && (
|
|
322
|
-
<div className="px-3 py-1.5 text-[10px] text-theme-text-tertiary border-t border-theme-border bg-theme-base flex justify-between">
|
|
323
|
-
<span>{value.length} selected</span>
|
|
324
|
-
<button type="button" onClick={clearAll} className="text-blue-400 hover:text-blue-300">
|
|
325
|
-
Clear all
|
|
326
|
-
</button>
|
|
327
|
-
</div>
|
|
328
|
-
)}
|
|
329
|
-
</>
|
|
330
|
-
) : (
|
|
331
|
-
<>
|
|
332
|
-
{/* Search input */}
|
|
333
|
-
<div className="p-2 border-b border-theme-border">
|
|
334
|
-
<div className="relative">
|
|
335
|
-
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
336
|
-
<input
|
|
337
|
-
ref={searchInputRef}
|
|
338
|
-
type="text"
|
|
339
|
-
value={search}
|
|
340
|
-
onChange={(e) => setSearch(e.target.value)}
|
|
341
|
-
placeholder="Search namespaces..."
|
|
342
|
-
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"
|
|
343
|
-
/>
|
|
344
|
-
{search && (
|
|
345
|
-
<button
|
|
346
|
-
type="button"
|
|
347
|
-
onClick={() => setSearch('')}
|
|
348
|
-
className="absolute right-2 top-1/2 -translate-y-1/2 text-theme-text-tertiary hover:text-theme-text-secondary"
|
|
349
|
-
>
|
|
350
|
-
<X className="w-3.5 h-3.5" />
|
|
351
|
-
</button>
|
|
352
|
-
)}
|
|
353
|
-
</div>
|
|
354
|
-
</div>
|
|
355
|
-
|
|
356
|
-
{/* Select All / Clear All buttons */}
|
|
357
|
-
<div className="flex gap-1 px-2 py-1.5 border-b border-theme-border bg-theme-base">
|
|
358
|
-
<button
|
|
359
|
-
type="button"
|
|
360
|
-
onClick={selectAll}
|
|
361
|
-
disabled={allSelected}
|
|
362
|
-
className={clsx(
|
|
363
|
-
'flex-1 text-[10px] px-2 py-1 rounded transition-colors',
|
|
364
|
-
allSelected
|
|
365
|
-
? 'text-theme-text-tertiary cursor-not-allowed'
|
|
366
|
-
: 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
|
|
367
|
-
)}
|
|
368
|
-
>
|
|
369
|
-
Select All
|
|
370
|
-
</button>
|
|
371
|
-
<button
|
|
372
|
-
type="button"
|
|
373
|
-
onClick={clearAll}
|
|
374
|
-
disabled={value.length === 0}
|
|
375
|
-
className={clsx(
|
|
376
|
-
'flex-1 text-[10px] px-2 py-1 rounded transition-colors',
|
|
377
|
-
value.length === 0
|
|
378
|
-
? 'text-theme-text-tertiary cursor-not-allowed'
|
|
379
|
-
: 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
|
|
380
|
-
)}
|
|
381
|
-
>
|
|
382
|
-
Clear All
|
|
383
|
-
</button>
|
|
384
|
-
</div>
|
|
385
|
-
|
|
386
|
-
{/* Options list with checkboxes */}
|
|
387
|
-
<div className="max-h-[240px] overflow-y-auto">
|
|
388
|
-
{filteredNamespaces.length === 0 ? (
|
|
389
|
-
<div className="px-3 py-6 text-center text-xs text-theme-text-tertiary">
|
|
390
|
-
No namespaces match "{search}"
|
|
391
|
-
</div>
|
|
392
|
-
) : (
|
|
393
|
-
filteredNamespaces.map((ns, index) => {
|
|
394
|
-
const isSelected = selectedSet.has(ns.name)
|
|
395
|
-
return (
|
|
396
|
-
<button
|
|
397
|
-
key={ns.name}
|
|
398
|
-
type="button"
|
|
399
|
-
data-highlighted={index === highlightedIndex}
|
|
400
|
-
onClick={() => toggleNamespace(ns.name)}
|
|
401
|
-
onMouseEnter={() => setHighlightedIndex(index)}
|
|
402
|
-
className={clsx(
|
|
403
|
-
'w-full text-left px-3 py-1.5 text-xs transition-colors flex items-center gap-2',
|
|
404
|
-
'text-theme-text-primary',
|
|
405
|
-
index === highlightedIndex && 'bg-theme-hover'
|
|
406
|
-
)}
|
|
407
|
-
>
|
|
408
|
-
<div
|
|
409
|
-
className={clsx(
|
|
410
|
-
'w-3.5 h-3.5 rounded border flex items-center justify-center flex-shrink-0',
|
|
411
|
-
isSelected
|
|
412
|
-
? 'bg-blue-500 border-blue-500'
|
|
413
|
-
: 'border-theme-border-light bg-theme-base'
|
|
414
|
-
)}
|
|
415
|
-
>
|
|
416
|
-
{isSelected && <Check className="w-2.5 h-2.5 text-white" />}
|
|
417
|
-
</div>
|
|
418
|
-
<span className="truncate">{ns.name}</span>
|
|
419
|
-
</button>
|
|
420
|
-
)
|
|
421
|
-
})
|
|
422
|
-
)}
|
|
423
|
-
</div>
|
|
424
|
-
|
|
425
|
-
{/* Namespace count and selection info */}
|
|
426
|
-
{sortedNamespaces.length > 0 && (
|
|
427
|
-
<div className="px-3 py-1.5 text-[10px] text-theme-text-tertiary border-t border-theme-border bg-theme-base flex justify-between">
|
|
428
|
-
<span>
|
|
429
|
-
{filteredNamespaces.length === sortedNamespaces.length
|
|
430
|
-
? `${sortedNamespaces.length} namespaces`
|
|
431
|
-
: `${filteredNamespaces.length} of ${sortedNamespaces.length} namespaces`}
|
|
432
|
-
</span>
|
|
433
|
-
{value.length > 0 && (
|
|
434
|
-
<span className="text-blue-400">{value.length} selected</span>
|
|
435
|
-
)}
|
|
436
|
-
</div>
|
|
437
|
-
)}
|
|
438
|
-
</>
|
|
439
|
-
)}
|
|
440
|
-
</div>
|
|
441
|
-
</>,
|
|
442
|
-
document.body
|
|
443
|
-
)}
|
|
444
|
-
</>
|
|
445
|
-
)
|
|
446
|
-
})
|