Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 144 additions & 7 deletions packages/k8s-ui/src/components/resources/ResourcesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ import { VirtualServiceCell, DestinationRuleCell, IstioGatewayCell, ServiceEntry
import { KnativeServiceCell, ConfigurationCell as KnativeConfigurationCell, RevisionCell as KnativeRevisionCell, RouteCell as KnativeRouteCell, BrokerCell, TriggerCell, EventTypeCell, PingSourceCell, ApiServerSourceCell, ContainerSourceCell, SinkBindingCell, ChannelCell, InMemoryChannelCell, SubscriptionCell, SequenceCell, ParallelCell, DomainMappingCell, ServerlessServiceCell, KnativeIngressCell, KnativeCertificateCell } from './renderers/knative-cells'
import { IngressRouteCell, MiddlewareCell, TraefikServiceCell, ServersTransportCell, TLSOptionCell } from './renderers/traefik-cells'
import { useRegisterShortcut, useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
import { ConfirmDialog } from '../ui/ConfirmDialog'

// Pod problem filter options (special multi-select, not a single column value)
const POD_PROBLEMS = ['CrashLoopBackOff', 'ImagePullBackOff', 'OOMKilled', 'Unschedulable', 'Not Ready', 'High Restarts'] as const
Expand Down Expand Up @@ -1453,6 +1454,9 @@ interface ResourcesViewProps {
onOpenWorkloadLogs?: (params: { namespace: string; workloadKind: string; workloadName: string }) => void
// Callback when selected kind changes — used by parent to fetch data for the selected kind
onSelectedKindChange?: (kind: { name: string; kind: string; group: string }) => void
// Bulk operations
onBulkDelete?: (items: Array<{ kind: string; namespace: string; name: string }>, options?: { force?: boolean; onSuccess?: () => void }) => void
isBulkDeleting?: boolean
}

// Default selected kind
Expand Down Expand Up @@ -1527,13 +1531,16 @@ export function ResourcesView({
onOpenLogs,
onOpenWorkloadLogs,
onSelectedKindChange,
onBulkDelete,
isBulkDeleting = false,
}: ResourcesViewProps) {
const location = useMemo(() => ({ search: locationSearch, pathname: locationPathname }), [locationSearch, locationPathname])
const initialFilters = getInitialFiltersFromURL()
const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(() => getInitialKindFromURL(basePath))
// Notify parent of selected kind changes (including initial mount)
useEffect(() => {
onSelectedKindChange?.(selectedKind)
setCheckedResources(new Set())
}, [selectedKind.name, selectedKind.group]) // eslint-disable-line react-hooks/exhaustive-deps
const [searchTerm, setSearchTerm] = useState(initialFilters.search)
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
Expand Down Expand Up @@ -1567,6 +1574,11 @@ export function ResourcesView({
const [ownerKind, setOwnerKind] = useState<string>(initialFilters.ownerKind)
const [ownerName, setOwnerName] = useState<string>(initialFilters.ownerName)

// Multi-select state for bulk operations
const [checkedResources, setCheckedResources] = useState<Set<string>>(new Set())
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
const [bulkForceDelete, setBulkForceDelete] = useState(false)

// Column filter helpers
const clearColumnFilter = useCallback((key: string) => {
setColumnFilters(prev => {
Expand Down Expand Up @@ -2858,6 +2870,35 @@ export function ResourcesView({
})
}

// Multi-select helpers
const getResourceKey = useCallback((resource: any) => {
return resource.metadata?.uid || `${resource.metadata?.namespace || ''}/${resource.metadata?.name || ''}`
}, [])

const toggleChecked = useCallback((resource: any) => {
const key = getResourceKey(resource)
setCheckedResources(prev => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}, [getResourceKey])

const toggleCheckAll = useCallback(() => {
setCheckedResources(prev => {
if (prev.size > 0 && prev.size === filteredResources.length) return new Set()
return new Set(filteredResources.map(getResourceKey))
})
}, [filteredResources, getResourceKey])

const checkedItems = useMemo(() => {
if (checkedResources.size === 0) return []
return filteredResources.filter(r => checkedResources.has(getResourceKey(r)))
}, [filteredResources, checkedResources, getResourceKey])

const isCheckboxMode = onBulkDelete != null

// Filter columns by visibility
const columns = useMemo(() => {
if (visibleColumns.size === 0) return allColumns.filter(c => c.defaultVisible !== false)
Expand All @@ -2875,6 +2916,7 @@ export function ResourcesView({
style={{ ...props.style, tableLayout: 'fixed' }}
>
<colgroup>
{isCheckboxMode && <col style={{ width: '40px' }} />}
{columns.map(col => (
<col
key={col.key}
Expand All @@ -2892,7 +2934,7 @@ export function ResourcesView({
)
}),
TableRow: VirtuosoTableRow,
}), [columns, columnWidths, hasResizedColumns])
}), [columns, columnWidths, hasResizedColumns, isCheckboxMode])

// Calculate filter options with counts based on current resources (before filtering)
const filterOptions = useMemo(() => {
Expand Down Expand Up @@ -3476,6 +3518,28 @@ export function ResourcesView({
</button>
</div>

{/* Bulk actions bar */}
{checkedResources.size > 0 && isCheckboxMode && (
<div className="flex items-center gap-3 px-4 py-2 bg-blue-500/10 border-b border-blue-500/20 shrink-0">
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
{checkedResources.size} selected
</span>
<button
onClick={() => setShowBulkDeleteConfirm(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
Delete
</button>
<button
onClick={() => setCheckedResources(new Set())}
className="px-3 py-1.5 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors"
>
Cancel
</button>
</div>
)}

{/* Table */}
<div
className="flex-1 overflow-y-auto overflow-x-hidden relative"
Expand Down Expand Up @@ -3561,6 +3625,18 @@ export function ResourcesView({
components={virtuosoComponents}
fixedHeaderContent={() => (
<tr>
{isCheckboxMode && (
<th className="bg-theme-surface border-b border-theme-border w-10 text-center px-0 py-3">
<input
type="checkbox"
checked={filteredResources.length > 0 && checkedResources.size === filteredResources.length}
ref={(el) => { if (el) el.indeterminate = checkedResources.size > 0 && checkedResources.size < filteredResources.length }}
onChange={toggleCheckAll}
className="w-3.5 h-3.5 rounded border-theme-border accent-blue-500 cursor-pointer"
title={checkedResources.size > 0 ? 'Deselect all' : 'Select all'}
/>
</th>
)}
{columns.map((col, colIdx) => {
const isSortable = ['name', 'namespace', 'age', 'status', 'ready', 'restarts', 'type', 'version', 'desired', 'available', 'upToDate', 'lastSeen', 'count', 'reason', 'object', 'cpu', 'memory'].includes(col.key)
const isSorted = sortColumn === col.key
Expand Down Expand Up @@ -3732,6 +3808,7 @@ export function ResourcesView({
selectedResource?.namespace === resource.metadata?.namespace &&
selectedResource?.name === resource.metadata?.name
const isHighlighted = index === highlightedIndex
const resourceKey = getResourceKey(resource)
return (
<ResourceRowCells
resource={resource}
Expand All @@ -3741,12 +3818,15 @@ export function ResourcesView({
hasSpacerColumn={hasResizedColumns}
isSelected={isSelected}
isHighlighted={isHighlighted}
isChecked={checkedResources.has(resourceKey)}
showCheckbox={isCheckboxMode}
majorityNodeMinorVersion={majorityNodeMinorVersion}
onClick={() => {
const res = { kind: selectedKind.name, namespace: resource.metadata?.namespace || '', name: resource.metadata?.name, group: selectedKind.group }
onResourceClick?.(isSelected ? null : res)
}}
onMouseEnter={() => setHighlightedIndex(-1)}
onCheckToggle={() => toggleChecked(resource)}
/>
)
}}
Expand All @@ -3764,6 +3844,44 @@ export function ResourcesView({
</div>
</div>
</div>

{/* Bulk delete confirmation */}
<ConfirmDialog
open={showBulkDeleteConfirm}
onClose={() => { setShowBulkDeleteConfirm(false); setBulkForceDelete(false) }}
onConfirm={() => {
const items = checkedItems.map(r => ({
kind: selectedKind.name,
namespace: r.metadata?.namespace || '',
name: r.metadata?.name || '',
}))
onBulkDelete?.(items, {
force: bulkForceDelete,
onSuccess: () => {
setCheckedResources(new Set())
setShowBulkDeleteConfirm(false)
setBulkForceDelete(false)
},
})
}}
title={`Delete ${checkedItems.length} ${selectedKind.kind}${checkedItems.length > 1 ? 's' : ''}?`}
message={`You are about to delete ${checkedItems.length} resource${checkedItems.length > 1 ? 's' : ''}. This action cannot be undone.`}
details={checkedItems.map(r => `${r.metadata?.namespace ? r.metadata.namespace + '/' : ''}${r.metadata?.name}`).join('\n')}
confirmLabel={bulkForceDelete ? `Force Delete ${checkedItems.length} resource${checkedItems.length > 1 ? 's' : ''}` : `Delete ${checkedItems.length} resource${checkedItems.length > 1 ? 's' : ''}`}
variant="danger"
isLoading={isBulkDeleting}
isClosable
>
<label className="flex items-center gap-2 text-sm text-theme-text-secondary">
<input
type="checkbox"
checked={bulkForceDelete}
onChange={(e) => setBulkForceDelete(e.target.checked)}
className="w-4 h-4 rounded border-theme-border bg-theme-base text-red-600 focus:ring-red-500 focus:ring-offset-0"
/>
<span>Force delete (strips finalizers and bypasses grace period)</span>
</label>
</ConfirmDialog>
</ResourcesViewDataContext.Provider>
)
}
Expand Down Expand Up @@ -3847,14 +3965,37 @@ interface ResourceRowCellsProps {
hasSpacerColumn: boolean
isSelected?: boolean
isHighlighted?: boolean
isChecked?: boolean
showCheckbox?: boolean
majorityNodeMinorVersion?: string
onClick?: () => void
onMouseEnter?: () => void
onCheckToggle?: () => void
}

function ResourceRowCells({ resource, kind, group, columns, hasSpacerColumn, isSelected, isHighlighted, majorityNodeMinorVersion, onClick, onMouseEnter }: ResourceRowCellsProps) {
function ResourceRowCells({ resource, kind, group, columns, hasSpacerColumn, isSelected, isHighlighted, isChecked, showCheckbox, majorityNodeMinorVersion, onClick, onMouseEnter, onCheckToggle }: ResourceRowCellsProps) {
const rowBg = isSelected
? 'bg-blue-500/20 group-hover/row:bg-blue-500/30'
: isChecked
? 'bg-blue-500/10 group-hover/row:bg-blue-500/15'
: isHighlighted
? 'bg-blue-500/10 ring-1 ring-inset ring-blue-400/30'
: 'group-hover/row:bg-theme-surface/50'
return (
<>
{showCheckbox && (
<td
className={clsx('border-b-subtle text-center px-0 py-3 w-10', rowBg)}
onClick={(e) => { e.stopPropagation(); onCheckToggle?.() }}
>
<input
type="checkbox"
checked={!!isChecked}
onChange={() => {}}
className="w-3.5 h-3.5 rounded border-theme-border accent-blue-500 cursor-pointer"
/>
</td>
)}
{columns.map((col) => (
<td
key={col.key}
Expand All @@ -3863,11 +4004,7 @@ function ResourceRowCells({ resource, kind, group, columns, hasSpacerColumn, isS
className={clsx(
'px-4 py-3 border-b-subtle cursor-pointer transition-colors',
col.key !== 'status' && 'overflow-hidden truncate',
isSelected
? 'bg-blue-500/20 group-hover/row:bg-blue-500/30'
: isHighlighted
? 'bg-blue-500/10 ring-1 ring-inset ring-blue-400/30'
: 'group-hover/row:bg-theme-surface/50'
rowBg
)}
>
<CellContent resource={resource} kind={kind} group={group} column={col.key} majorityNodeMinorVersion={majorityNodeMinorVersion} />
Expand Down
2 changes: 1 addition & 1 deletion packages/k8s-ui/src/components/ui/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export function ConfirmDialog({

{/* Custom content */}
{children && (
<div className="px-4 pt-4">
<div className="px-4 py-4">
{children}
</div>
)}
Expand Down
35 changes: 35 additions & 0 deletions web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1123,6 +1123,41 @@ export function useDeleteResource() {
})
}

export function useBulkDeleteResources() {
const queryClient = useQueryClient()

return useMutation({
mutationFn: async ({ items, force }: { items: Array<{ kind: string; namespace: string; name: string }>; force?: boolean }) => {
const results = await Promise.allSettled(
items.map(async ({ kind, namespace, name }) => {
const url = new URL(`${API_BASE}/resources/${kind}/${namespace}/${name}`, window.location.origin)
if (force) url.searchParams.set('force', 'true')
const response = await fetch(url.toString(), { method: 'DELETE' })
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(error.error || `Failed to delete ${namespace}/${name}`)
}
return { kind, namespace, name }
})
)
const failed = results.filter(r => r.status === 'rejected') as PromiseRejectedResult[]
if (failed.length > 0) {
throw new Error(`Failed to delete ${failed.length} of ${items.length} resources`)
}
return { deleted: items.length }
},
meta: {
errorMessage: 'Failed to delete some resources',
successMessage: 'Resources deleted',
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] })
queryClient.invalidateQueries({ queryKey: ['resource-counts'] })
queryClient.invalidateQueries({ queryKey: ['topology'] })
},
})
}

// ============================================================================
// CronJob operations
// ============================================================================
Expand Down
8 changes: 7 additions & 1 deletion web/src/components/resources/ResourcesView.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, useMemo } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { ApiError, fetchJSON, isForbiddenError, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics } from '../../api/client'
import { ApiError, fetchJSON, isForbiddenError, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics, useBulkDeleteResources } from '../../api/client'
import { useAPIResources } from '../../api/apiResources'
import { usePinnedKinds } from '../../hooks/useFavorites'
import { useOpenLogs, useOpenWorkloadLogs } from '../dock'
Expand Down Expand Up @@ -108,6 +108,9 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
const openLogs = useOpenLogs()
const openWorkloadLogs = useOpenWorkloadLogs()

// Bulk delete
const bulkDeleteMutation = useBulkDeleteResources()

// Navigation adapter
const handleNavigate = useMemo(() => {
return (path: string, options?: { replace?: boolean }) => {
Expand Down Expand Up @@ -144,6 +147,9 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
// Dock actions
onOpenLogs={openLogs}
onOpenWorkloadLogs={openWorkloadLogs}
// Bulk operations
onBulkDelete={(items, options) => bulkDeleteMutation.mutate({ items, force: options?.force }, { onSuccess: options?.onSuccess })}
isBulkDeleting={bulkDeleteMutation.isPending}
/>
)
}
Loading