diff --git a/ui/src/components/resource-table.tsx b/ui/src/components/resource-table.tsx
index bfae039e..118c47ac 100644
--- a/ui/src/components/resource-table.tsx
+++ b/ui/src/components/resource-table.tsx
@@ -132,17 +132,34 @@ export function ResourceTable({
})
const [refreshInterval, setRefreshInterval] = useState(5000)
- const [selectedNamespace, setSelectedNamespace] = useState<
- string | undefined
- >(() => {
- // Try to get the stored namespace from localStorage
- const storedNamespace = localStorage.getItem(
+ const [selectedNamespaces, setSelectedNamespaces] = useState(() => {
+ const stored = localStorage.getItem(
+ localStorage.getItem('current-cluster') + 'selectedNamespaces'
+ )
+ if (clusterScope) {
+ return []
+ }
+ if (stored) {
+ try {
+ const parsed = JSON.parse(stored)
+ return Array.isArray(parsed) ? parsed : [parsed]
+ } catch {
+ return [stored]
+ }
+ }
+ const oldNamespace = localStorage.getItem(
localStorage.getItem('current-cluster') + 'selectedNamespace'
)
- return clusterScope
- ? undefined // No namespace for cluster scope
- : storedNamespace || 'default' // Default to 'default' if not set
+ return oldNamespace ? [oldNamespace] : ['default']
})
+
+ const apiNamespace = useMemo(() => {
+ if (selectedNamespaces.length === 0) return undefined
+ if (selectedNamespaces.length === 1 && selectedNamespaces[0] !== '_all') {
+ return selectedNamespaces[0]
+ }
+ return '_all'
+ }, [selectedNamespaces])
const [useSSE, setUseSSE] = useState(false)
const {
isLoading: queryLoading,
@@ -152,7 +169,7 @@ export function ResourceTable({
refetch: queryRefetch,
} = useResources(
resourceType ?? (resourceName.toLowerCase() as ResourceType),
- selectedNamespace,
+ apiNamespace,
{
refreshInterval: useSSE ? 0 : refreshInterval, // disable polling when SSE
reduce: true, // Fetch reduced data for performance
@@ -171,7 +188,7 @@ export function ResourceTable({
} = useResourcesWatch(
(resourceType ??
(resourceName.toLowerCase() as ResourceType)) as ResourceType,
- selectedNamespace,
+ apiNamespace,
{ reduce: true, enabled: useSSE }
)
@@ -218,21 +235,19 @@ export function ResourceTable({
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
}, [columnFilters, searchQuery])
- // Handle namespace change
const handleNamespaceChange = useCallback(
- (value: string) => {
- if (setSelectedNamespace) {
- localStorage.setItem(
- localStorage.getItem('current-cluster') + 'selectedNamespace',
- value
- )
- setSelectedNamespace(value)
- // Reset pagination and search when changing namespace
- setPagination({ pageIndex: 0, pageSize: pagination.pageSize })
- setSearchQuery('')
- }
+ (namespaces: string[]) => {
+ const currentCluster = localStorage.getItem('current-cluster')
+ localStorage.setItem(
+ currentCluster + 'selectedNamespaces',
+ JSON.stringify(namespaces)
+ )
+ localStorage.removeItem(currentCluster + 'selectedNamespace')
+ setSelectedNamespaces(namespaces)
+ setPagination({ pageIndex: 0, pageSize: pagination.pageSize })
+ setSearchQuery('')
},
- [setSelectedNamespace, pagination.pageSize]
+ [pagination.pageSize]
)
// Add namespace column when showing all namespaces
@@ -262,12 +277,10 @@ export function ResourceTable({
const baseColumns = [selectColumn, ...columns]
- // Only add namespace column if not cluster scope, showing all namespaces,
- // and there isn't already a namespace column in the provided columns
- if (!clusterScope && selectedNamespace === '_all') {
- // Check if namespace column already exists in the provided columns
+ const showMultipleNamespaces = selectedNamespaces.length > 1 ||
+ (selectedNamespaces.length === 1 && selectedNamespaces[0] === '_all')
+ if (!clusterScope && showMultipleNamespaces) {
const hasNamespaceColumn = columns.some((col) => {
- // Check if the column accesses namespace data
if ('accessorKey' in col && col.accessorKey === 'metadata.namespace') {
return true
}
@@ -277,13 +290,11 @@ export function ResourceTable({
return false
})
- // Only add namespace column if it doesn't already exist
if (!hasNamespaceColumn) {
const namespaceColumn = {
id: 'namespace',
header: t('resourceTable.namespace'),
accessorFn: (row: T) => {
- // Try to get namespace from metadata.namespace
const metadata = (row as { metadata?: { namespace?: string } })
?.metadata
return metadata?.namespace || '-'
@@ -295,14 +306,13 @@ export function ResourceTable({
),
}
- // Insert namespace column after select and first column (typically name)
const columnsWithNamespace = [...baseColumns]
columnsWithNamespace.splice(2, 0, namespaceColumn)
return columnsWithNamespace
}
}
return baseColumns
- }, [columns, clusterScope, selectedNamespace, t])
+ }, [columns, clusterScope, selectedNamespaces, t])
const data = useMemo(() => {
if (useSSE) return watchData
@@ -315,7 +325,24 @@ export function ResourceTable({
: (queryError as unknown as Error | null)
const refetch = useSSE ? reconnectSSE : queryRefetch
- const memoizedData = useMemo(() => (data || []) as T[], [data])
+ const filteredData = useMemo(() => {
+ const allData = (data || []) as T[]
+ if (selectedNamespaces.length === 0) return allData
+ if (selectedNamespaces.length === 1 && selectedNamespaces[0] === '_all') {
+ return allData
+ }
+ if (selectedNamespaces.length === 1) {
+ return allData
+ }
+ const selectedSet = new Set(selectedNamespaces)
+ return allData.filter((item) => {
+ const metadata = (item as { metadata?: { namespace?: string } })?.metadata
+ const namespace = metadata?.namespace
+ return namespace && selectedSet.has(namespace)
+ })
+ }, [data, selectedNamespaces])
+
+ const memoizedData = useMemo(() => filteredData, [filteredData])
useEffect(() => {
if (!useSSE && error) {
@@ -462,8 +489,14 @@ export function ResourceTable({
Retrieving data
- {!clusterScope && selectedNamespace
- ? ` from ${selectedNamespace === '_all' ? 'All Namespaces' : `namespace ${selectedNamespace}`}`
+ {!clusterScope && selectedNamespaces.length > 0
+ ? ` from ${
+ selectedNamespaces.length === 1 && selectedNamespaces[0] === '_all'
+ ? 'All Namespaces'
+ : selectedNamespaces.length === 1
+ ? `namespace ${selectedNamespaces[0]}`
+ : `${selectedNamespaces.length} namespaces`
+ }`
: ''}
@@ -494,7 +527,9 @@ export function ResourceTable({
? `No results match your search query: "${searchQuery}"`
: clusterScope
? `There are no ${resourceName.toLowerCase()} found`
- : `There are no ${resourceName.toLowerCase()} in the ${selectedNamespace} namespace`}
+ : selectedNamespaces.length === 1 && selectedNamespaces[0] !== '_all'
+ ? `There are no ${resourceName.toLowerCase()} in the ${selectedNamespaces[0]} namespace`
+ : `There are no ${resourceName.toLowerCase()} in the selected namespaces`}
{searchQuery && (
({
{resourceName}
- {!clusterScope && selectedNamespace && (
-
-
Namespace:
-
- {selectedNamespace === '_all'
- ? 'All Namespaces'
- : selectedNamespace}
-
+ {!clusterScope && selectedNamespaces.length > 0 && (
+
+
Namespace{selectedNamespaces.length > 1 ? 's' : ''}:
+ {selectedNamespaces.length === 1 && selectedNamespaces[0] === '_all' ? (
+
All Namespaces
+ ) : (
+
+ {selectedNamespaces.map((ns) => (
+
+ {ns}
+
+ ))}
+
+ )}
)}
@@ -588,9 +629,10 @@ export function ResourceTable
({
{!clusterScope && (
)}
{/* Column Filters */}
diff --git a/ui/src/components/selector/namespace-selector.tsx b/ui/src/components/selector/namespace-selector.tsx
index 8b6c59db..a9e714b4 100644
--- a/ui/src/components/selector/namespace-selector.tsx
+++ b/ui/src/components/selector/namespace-selector.tsx
@@ -1,6 +1,17 @@
+import { useState } from 'react'
import { Namespace } from 'kubernetes-types/core/v1'
+import { X } from 'lucide-react'
import { useResources } from '@/lib/api'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Input } from '@/components/ui/input'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
import {
Select,
SelectContent,
@@ -11,14 +22,22 @@ import {
export function NamespaceSelector({
selectedNamespace,
+ selectedNamespaces,
handleNamespaceChange,
+ handleNamespacesChange,
showAll = false,
+ multiSelect = false,
}: {
selectedNamespace?: string
- handleNamespaceChange: (namespace: string) => void
+ selectedNamespaces?: string[]
+ handleNamespaceChange?: (namespace: string) => void
+ handleNamespacesChange?: (namespaces: string[]) => void
showAll?: boolean
+ multiSelect?: boolean
}) {
const { data, isLoading } = useResources('namespaces')
+ const [open, setOpen] = useState(false)
+ const [searchTerm, setSearchTerm] = useState('')
const sortedNamespaces = data?.sort((a, b) => {
const nameA = a.metadata?.name?.toLowerCase() || ''
@@ -26,28 +45,200 @@ export function NamespaceSelector({
return nameA.localeCompare(nameB)
}) || [{ metadata: { name: 'default' } }]
+ const filteredNamespaces = sortedNamespaces.filter((ns) => {
+ const name = ns.metadata?.name?.toLowerCase() || ''
+ return name.includes(searchTerm.toLowerCase())
+ })
+
+ const isMultiSelect = multiSelect || handleNamespacesChange !== undefined
+ const currentSelected = isMultiSelect
+ ? (selectedNamespaces || [])
+ : (selectedNamespace ? [selectedNamespace] : [])
+
+ const selectedSet = new Set(currentSelected)
+ const allSelected = showAll && selectedSet.has('_all')
+
+ const handleToggleNamespace = (namespace: string) => {
+ if (!isMultiSelect && handleNamespaceChange) {
+ handleNamespaceChange(namespace === '_all' ? '' : namespace)
+ setOpen(false)
+ return
+ }
+
+ if (!handleNamespacesChange) return
+
+ const newSelected = new Set(currentSelected)
+
+ if (namespace === '_all') {
+ if (allSelected) {
+ newSelected.clear()
+ } else {
+ newSelected.clear()
+ newSelected.add('_all')
+ }
+ } else {
+ newSelected.delete('_all')
+ if (newSelected.has(namespace)) {
+ newSelected.delete(namespace)
+ } else {
+ newSelected.add(namespace)
+ }
+ }
+
+ handleNamespacesChange(Array.from(newSelected))
+ }
+
+ const handleRemoveNamespace = (namespace: string) => {
+ if (!isMultiSelect || !handleNamespacesChange) return
+ const newSelected = new Set(currentSelected)
+ newSelected.delete(namespace)
+ handleNamespacesChange(Array.from(newSelected))
+ }
+
+ const displayText = () => {
+ if (!isMultiSelect) {
+ if (selectedNamespace === '_all') {
+ return 'All Namespaces'
+ }
+ return selectedNamespace || 'Select a namespace'
+ }
+
+ if (allSelected) {
+ return 'All Namespaces'
+ }
+ if (currentSelected.length > 0) {
+ if (currentSelected.length === 1) {
+ return currentSelected[0]
+ }
+ return `${currentSelected.length} namespaces`
+ }
+ return 'Select namespaces'
+ }
+
+ if (!isMultiSelect && handleNamespaceChange) {
+ return (
+
+
+
+
+
+ {isLoading && (
+
+ Loading namespaces...
+
+ )}
+ {showAll && (
+
+ All Namespaces
+
+ )}
+ {sortedNamespaces?.map((ns: Namespace) => (
+
+ {ns.metadata!.name}
+
+ ))}
+
+
+ )
+ }
+
return (
-
-
-
-
-
- {isLoading && (
-
- Loading namespaces...
-
- )}
- {showAll && (
-
- All Namespaces
-
+
+
+
+ {displayText()}
+
+
+
+
+ setSearchTerm(e.target.value)}
+ className="h-8"
+ />
+
+
+ {isLoading && (
+
+ Loading namespaces...
+
+ )}
+ {showAll && (
+
+ handleToggleNamespace('_all')}
+ id="namespace-all"
+ />
+
+ All Namespaces
+
+
+ )}
+ {filteredNamespaces.map((ns: Namespace) => {
+ const name = ns.metadata!.name!
+ const isSelected = selectedSet.has(name)
+ return (
+
handleToggleNamespace(name)}
+ >
+ {isMultiSelect ? (
+ <>
+ handleToggleNamespace(name)}
+ id={`namespace-${name}`}
+ />
+
+ {name}
+
+ >
+ ) : (
+ {name}
+ )}
+
+ )
+ })}
+ {!isLoading && filteredNamespaces.length === 0 && (
+
+ No namespaces found
+
+ )}
+
+ {isMultiSelect && currentSelected.length > 0 && !allSelected && (
+
+
+ {currentSelected.map((ns) => (
+
+ {ns}
+ handleRemoveNamespace(ns)}
+ className="ml-1 hover:bg-accent rounded-full p-0.5"
+ >
+
+
+
+ ))}
+
+
)}
- {sortedNamespaces?.map((ns: Namespace) => (
-
- {ns.metadata!.name}
-
- ))}
-
-
+
+
)
}