Skip to content

Commit 4d4da5b

Browse files
authored
Fix bulk-delete review findings: selection count, partial-failure refresh, API group (#871)
Three fixes for issues found in post-merge review of #322: - Selection count mismatch: the bulk bar, select-all checkbox, and confirm dialog now all derive from checkedItems (checked rows still visible under current filters) — the same set the delete acts on. Previously the bar showed the raw checked-set size, which overstated the deletion scope when filters hid checked rows. - Partial-failure refresh: bulk delete invalidates queries in onSettled instead of onSuccess, so resources deleted before a partial failure disappear from the table instead of lingering. - API group disambiguation: DELETE /api/resources accepts ?group= and resolves via GetGVRWithGroup. Kind-only resolution picks whichever group discovery found first, so deletes from CRD tables whose kind collides with another group (Knative vs core Service, CAPI vs CNPG Cluster) could 404 or delete the same-named object in the wrong group. Both the single-resource and bulk delete paths now pass the table's group through.
1 parent 884f6c6 commit 4d4da5b

5 files changed

Lines changed: 34 additions & 20 deletions

File tree

internal/server/server.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2595,6 +2595,7 @@ func (s *Server) handleDeleteResource(w http.ResponseWriter, r *http.Request) {
25952595
namespace := chi.URLParam(r, "namespace")
25962596
name := chi.URLParam(r, "name")
25972597
force := r.URL.Query().Get("force") == "true"
2598+
group := r.URL.Query().Get("group")
25982599

25992600
auth.AuditLog(r, namespace, name)
26002601
client := s.getDynamicClientForRequest(r)
@@ -2604,6 +2605,7 @@ func (s *Server) handleDeleteResource(w http.ResponseWriter, r *http.Request) {
26042605
}
26052606
err := k8s.DeleteResourceWithClient(r.Context(), k8s.DeleteResourceOptions{
26062607
Kind: kind,
2608+
Group: group,
26072609
Namespace: namespace,
26082610
Name: name,
26092611
Force: force,

packages/k8s-ui/src/components/resources/ResourcesView.tsx

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1808,7 +1808,7 @@ interface ResourcesViewProps {
18081808
*/
18091809
onClearNamespaces?: () => void
18101810
// Bulk operations
1811-
onBulkDelete?: (items: Array<{ kind: string; namespace: string; name: string }>, options?: { force?: boolean; onSuccess?: () => void }) => void
1811+
onBulkDelete?: (items: Array<{ kind: string; group?: string; namespace: string; name: string }>, options?: { force?: boolean; onSuccess?: () => void }) => void
18121812
isBulkDeleting?: boolean
18131813
}
18141814

@@ -3416,18 +3416,22 @@ export function ResourcesView({
34163416
})
34173417
}, [getResourceKey])
34183418

3419-
const toggleCheckAll = useCallback(() => {
3420-
setCheckedResources(prev => {
3421-
if (prev.size > 0 && prev.size === filteredResources.length) return new Set()
3422-
return new Set(filteredResources.map(getResourceKey))
3423-
})
3424-
}, [filteredResources, getResourceKey])
3425-
3419+
// Selection state is keyed by resource, but everything user-facing
3420+
// (count, select-all, the delete payload) derives from checkedItems —
3421+
// the checked ∩ currently-visible intersection. Rows checked and then
3422+
// hidden by a filter are excluded, so the bar never promises more
3423+
// deletions than the confirm dialog will perform.
34263424
const checkedItems = useMemo(() => {
34273425
if (checkedResources.size === 0) return []
34283426
return filteredResources.filter(r => checkedResources.has(getResourceKey(r)))
34293427
}, [filteredResources, checkedResources, getResourceKey])
34303428

3429+
const allVisibleChecked = filteredResources.length > 0 && checkedItems.length === filteredResources.length
3430+
3431+
const toggleCheckAll = useCallback(() => {
3432+
setCheckedResources(allVisibleChecked ? new Set() : new Set(filteredResources.map(getResourceKey)))
3433+
}, [allVisibleChecked, filteredResources, getResourceKey])
3434+
34313435
const isCheckboxMode = onBulkDelete != null && bulkMode
34323436

34333437
// Filter columns by visibility
@@ -4057,11 +4061,11 @@ export function ResourcesView({
40574061
{isCheckboxMode && (
40584062
<div className="flex items-center gap-3 px-4 py-2 bg-skyhook-500/10 border-b border-skyhook-400/20 shrink-0">
40594063
<span className="text-sm font-medium text-theme-text-primary">
4060-
{checkedResources.size} selected
4064+
{checkedItems.length} selected
40614065
</span>
40624066
<button
40634067
onClick={() => setShowBulkDeleteConfirm(true)}
4064-
disabled={checkedResources.size === 0}
4068+
disabled={checkedItems.length === 0}
40654069
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-red-600 hover:bg-red-700 disabled:opacity-50 disabled:pointer-events-none text-white rounded-lg transition-colors"
40664070
>
40674071
<Trash2 className="w-3.5 h-3.5" />
@@ -4185,11 +4189,11 @@ export function ResourcesView({
41854189
<th className="bg-theme-surface border-b border-theme-border w-10 text-center px-0 py-3">
41864190
<input
41874191
type="checkbox"
4188-
checked={filteredResources.length > 0 && checkedResources.size === filteredResources.length}
4189-
ref={(el) => { if (el) el.indeterminate = checkedResources.size > 0 && checkedResources.size < filteredResources.length }}
4192+
checked={allVisibleChecked}
4193+
ref={(el) => { if (el) el.indeterminate = checkedItems.length > 0 && checkedItems.length < filteredResources.length }}
41904194
onChange={toggleCheckAll}
41914195
className="w-3.5 h-3.5 rounded border-theme-border accent-skyhook-500 cursor-pointer"
4192-
title={checkedResources.size > 0 ? 'Deselect all' : 'Select all'}
4196+
title={checkedItems.length > 0 ? 'Deselect all' : 'Select all'}
41934197
/>
41944198
</th>
41954199
)}
@@ -4425,6 +4429,7 @@ export function ResourcesView({
44254429
onConfirm={() => {
44264430
const items = checkedItems.map(r => ({
44274431
kind: selectedKind.name,
4432+
group: selectedKind.group,
44284433
namespace: r.metadata?.namespace || '',
44294434
name: r.metadata?.name || '',
44304435
}))

packages/k8s-ui/src/components/shared/ResourceActionsBar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ interface ResourceActionsBarProps {
6565
renderPortForward?: (props: { type: 'pod' | 'service'; namespace: string; name: string; className?: string }) => React.ReactNode
6666

6767
// Delete
68-
onDelete?: (params: { kind: string; namespace: string; name: string; force: boolean }, callbacks?: { onSuccess?: () => void; onError?: (err: unknown) => void }) => void
68+
onDelete?: (params: { kind: string; group?: string; namespace: string; name: string; force: boolean }, callbacks?: { onSuccess?: () => void; onError?: (err: unknown) => void }) => void
6969
isDeleting?: boolean
7070
cascadeDependents?: CascadeDependent[]
7171
cascadeLoading?: boolean
@@ -166,7 +166,7 @@ export function ResourceActionsBar({
166166

167167
function handleDeleteConfirm(force: boolean) {
168168
onDelete?.(
169-
{ kind: resource.kind, namespace: resource.namespace, name: resource.name, force },
169+
{ kind: resource.kind, group: resource.group, namespace: resource.namespace, name: resource.name, force },
170170
{
171171
onSuccess: () => {
172172
setShowDeleteConfirm(false)

pkg/k8score/workload.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ type UpdateResourceOptions struct {
4141
// DeleteResourceOptions contains options for deleting a resource.
4242
type DeleteResourceOptions struct {
4343
Kind string
44+
Group string // API group, disambiguates kinds that collide across groups (e.g. Knative vs core Service)
4445
Namespace string
4546
Name string
4647
Force bool // Force delete with grace period 0
@@ -378,7 +379,7 @@ func (m *WorkloadManager) DeleteResource(ctx context.Context, opts DeleteResourc
378379
return fmt.Errorf("dynamic client not initialized")
379380
}
380381

381-
gvr, ok := m.discovery.GetGVR(opts.Kind)
382+
gvr, ok := m.discovery.GetGVRWithGroup(opts.Kind, opts.Group)
382383
if !ok {
383384
return fmt.Errorf("unknown resource kind: %s", opts.Kind)
384385
}

web/src/api/client.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1699,8 +1699,11 @@ export function useDeleteResource() {
16991699
const queryClient = useQueryClient()
17001700

17011701
return useMutation({
1702-
mutationFn: async ({ kind, namespace, name, force }: { kind: string; namespace: string; name: string; force?: boolean }) => {
1702+
mutationFn: async ({ kind, group, namespace, name, force }: { kind: string; group?: string; namespace: string; name: string; force?: boolean }) => {
17031703
const url = new URL(`${getApiBase()}/resources/${kind}/${namespace}/${name}`, window.location.origin)
1704+
if (group) {
1705+
url.searchParams.set('group', group)
1706+
}
17041707
if (force) {
17051708
url.searchParams.set('force', 'true')
17061709
}
@@ -1729,10 +1732,11 @@ export function useBulkDeleteResources() {
17291732
const queryClient = useQueryClient()
17301733

17311734
return useMutation({
1732-
mutationFn: async ({ items, force }: { items: Array<{ kind: string; namespace: string; name: string }>; force?: boolean }) => {
1735+
mutationFn: async ({ items, force }: { items: Array<{ kind: string; group?: string; namespace: string; name: string }>; force?: boolean }) => {
17331736
const results = await Promise.allSettled(
1734-
items.map(async ({ kind, namespace, name }) => {
1737+
items.map(async ({ kind, group, namespace, name }) => {
17351738
const url = new URL(`${getApiBase()}/resources/${kind}/${namespace}/${name}`, window.location.origin)
1739+
if (group) url.searchParams.set('group', group)
17361740
if (force) url.searchParams.set('force', 'true')
17371741
const response = await apiFetch(url.toString(), { method: 'DELETE' })
17381742
if (!response.ok) {
@@ -1752,7 +1756,9 @@ export function useBulkDeleteResources() {
17521756
errorMessage: 'Failed to delete some resources',
17531757
successMessage: 'Resources deleted',
17541758
},
1755-
onSuccess: () => {
1759+
// onSettled, not onSuccess — a partial failure still deleted some
1760+
// resources, and the table must refetch to drop them.
1761+
onSettled: () => {
17561762
queryClient.invalidateQueries({ queryKey: ['resources'] })
17571763
queryClient.invalidateQueries({ queryKey: ['resource-counts'] })
17581764
queryClient.invalidateQueries({ queryKey: ['topology'] })

0 commit comments

Comments
 (0)