Add bulk actions to Secrets, IP Addresses, and Tailscale devices

One row at a time was tedious for anything beyond a handful of items.
New useSelection hook (id-based Set, so picks made on page 1 survive
moving to page 2 rather than resetting per page) backs a checkbox
column and a bulk-actions bar on each of the three tables:
- Secrets / IP Addresses: bulk delete.
- Tailscale devices: bulk authorize/deauthorize/remove.

No new backend endpoints — each bulk action is a client-side
Promise.allSettled loop over the existing single-item endpoints, so
one failure doesn't block the rest, and the bar reports how many (if
any) failed. Selection clears after a bulk action completes or when
switching Tailscale integrations (device ids aren't comparable across
different tailnets).

Verified the selection hook's Set logic directly (extracted as pure
functions, no React renderer needed): select-all/deselect-all
toggling, individual toggle, and — the case most likely to have a
subtle bug — that selecting items on one page and then selecting
different items on another page keeps both, with toggleAll on either
page only ever touching that page's own ids.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-24 19:13:56 +02:00
1 parent e081eecba4
commit 35979cb043
4 files changed
+216 -3

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
import { useState } from "react";
/** Tracks a set of selected row ids across pages/sorts — selecting on page 1 then moving to page 2 keeps page 1's picks. */
export function useSelection<T extends string | number>() {
const [selected, setSelected] = useState<Set<T>>(new Set());
function toggle(id: T) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
/** Selects every id in `ids` if any of them aren't already selected, otherwise deselects all of them (standard "select all on this page" checkbox behavior). */
function toggleAll(ids: T[]) {
setSelected((prev) => {
const allSelected = ids.length > 0 && ids.every((id) => prev.has(id));
const next = new Set(prev);
if (allSelected) {
for (const id of ids) next.delete(id);
} else {
for (const id of ids) next.add(id);
}
return next;
});
}
function clear() {
setSelected(new Set());
}
return { selected, toggle, toggleAll, clear, isSelected: (id: T) => selected.has(id), count: selected.size };
}
+52 -1
View File
@@ -6,6 +6,7 @@ import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh";
import { usePagination } from "../hooks/usePagination";
import Pagination from "../components/Pagination";
import { useSelection } from "../hooks/useSelection";
const emptyForm: IpamInput = { ipAddress: "", label: "", vendor: "", location: "", notes: "" };
@@ -25,6 +26,8 @@ export default function Ipam({ user }: { user: CurrentUser }) {
const [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState<"tailscale" | "proxmox" | null>(null);
const [syncResult, setSyncResult] = useState<string | null>(null);
const selection = useSelection<number>();
const [bulkDeleting, setBulkDeleting] = useState(false);
function load() {
api.ipam
@@ -105,6 +108,23 @@ export default function Ipam({ user }: { user: CurrentUser }) {
}
}
async function bulkDelete() {
const ids = Array.from(selection.selected);
if (ids.length === 0) return;
if (!confirm(`Delete ${ids.length} IP address${ids.length !== 1 ? "es" : ""}?`)) return;
setError(null);
setBulkDeleting(true);
try {
const results = await Promise.allSettled(ids.map((id) => api.ipam.remove(id)));
const failed = results.filter((r) => r.status === "rejected").length;
if (failed > 0) setError(`${failed} of ${ids.length} deletions failed.`);
selection.clear();
load();
} finally {
setBulkDeleting(false);
}
}
async function runSync(source: "tailscale" | "proxmox") {
setError(null);
setSyncResult(null);
@@ -246,10 +266,31 @@ export default function Ipam({ user }: { user: CurrentUser }) {
</button>
</div>
</div>
{canEdit && selection.count > 0 && (
<div className="card-header bg-blue-lt d-flex align-items-center gap-2">
<span>{selection.count} selected</span>
<button className="btn btn-sm btn-outline-danger" onClick={bulkDelete} disabled={bulkDeleting}>
{bulkDeleting ? "Deleting…" : "Delete selected"}
</button>
<button className="btn btn-sm btn-link" onClick={selection.clear}>
Clear selection
</button>
</div>
)}
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
{canEdit && (
<th className="w-1">
<input
type="checkbox"
className="form-check-input m-0"
checked={(pageItems ?? []).length > 0 && (pageItems ?? []).every((e) => selection.isSelected(e.id))}
onChange={() => selection.toggleAll((pageItems ?? []).map((e) => e.id))}
/>
</th>
)}
<SortableTh<IpamEntry> label="IP address" sortKeyName="ipAddress" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<IpamEntry> label="Label" sortKeyName="label" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<IpamEntry> label="Vendor" sortKeyName="vendor" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
@@ -261,6 +302,16 @@ export default function Ipam({ user }: { user: CurrentUser }) {
<tbody>
{(pageItems ?? []).map((entry) => (
<tr key={entry.id}>
{canEdit && (
<td>
<input
type="checkbox"
className="form-check-input m-0"
checked={selection.isSelected(entry.id)}
onChange={() => selection.toggle(entry.id)}
/>
</td>
)}
<td>
<span className={`badge ${isIpv6(entry.ipAddress) ? "bg-purple-lt" : "bg-blue-lt"} me-2`}>
{isIpv6(entry.ipAddress) ? "IPv6" : "IPv4"}
@@ -292,7 +343,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
))}
{(sorted ?? []).length === 0 && (
<tr>
<td colSpan={canEdit ? 6 : 5} className="text-secondary text-center">
<td colSpan={canEdit ? 7 : 5} className="text-secondary text-center">
No IP addresses tracked yet.
</td>
</tr>
+52 -1
View File
@@ -6,6 +6,7 @@ import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh";
import { usePagination } from "../hooks/usePagination";
import Pagination from "../components/Pagination";
import { useSelection } from "../hooks/useSelection";
const TYPE_LABELS: Record<SecretRecord["type"], string> = {
api_token: "API Token",
@@ -39,6 +40,8 @@ export default function Secrets({ user }: { user: CurrentUser }) {
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<SecretInput>(emptyForm);
const [saving, setSaving] = useState(false);
const selection = useSelection<number>();
const [bulkDeleting, setBulkDeleting] = useState(false);
function load() {
api.secrets
@@ -112,6 +115,23 @@ export default function Secrets({ user }: { user: CurrentUser }) {
}
}
async function bulkDelete() {
const ids = Array.from(selection.selected);
if (ids.length === 0) return;
if (!confirm(`Delete ${ids.length} secret${ids.length !== 1 ? "s" : ""}?`)) return;
setError(null);
setBulkDeleting(true);
try {
const results = await Promise.allSettled(ids.map((id) => api.secrets.remove(id)));
const failed = results.filter((r) => r.status === "rejected").length;
if (failed > 0) setError(`${failed} of ${ids.length} deletions failed.`);
selection.clear();
load();
} finally {
setBulkDeleting(false);
}
}
const { sorted, sortKey, sortDir, requestSort } = useSortable(filtered);
const { pageItems, page, setPage, pageCount, totalCount } = usePagination(sorted);
@@ -244,10 +264,31 @@ export default function Secrets({ user }: { user: CurrentUser }) {
</button>
</div>
</div>
{canEdit && selection.count > 0 && (
<div className="card-header bg-blue-lt d-flex align-items-center gap-2">
<span>{selection.count} selected</span>
<button className="btn btn-sm btn-outline-danger" onClick={bulkDelete} disabled={bulkDeleting}>
{bulkDeleting ? "Deleting…" : "Delete selected"}
</button>
<button className="btn btn-sm btn-link" onClick={selection.clear}>
Clear selection
</button>
</div>
)}
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
{canEdit && (
<th className="w-1">
<input
type="checkbox"
className="form-check-input m-0"
checked={(pageItems ?? []).length > 0 && (pageItems ?? []).every((s) => selection.isSelected(s.id))}
onChange={() => selection.toggleAll((pageItems ?? []).map((s) => s.id))}
/>
</th>
)}
<SortableTh<SecretRecord> label="Name" sortKeyName="name" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<SecretRecord> label="Type" sortKeyName="type" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<SecretRecord>
@@ -278,6 +319,16 @@ export default function Secrets({ user }: { user: CurrentUser }) {
<tbody>
{(pageItems ?? []).map((s) => (
<tr key={s.id}>
{canEdit && (
<td>
<input
type="checkbox"
className="form-check-input m-0"
checked={selection.isSelected(s.id)}
onChange={() => selection.toggle(s.id)}
/>
</td>
)}
<td>{s.name}</td>
<td>{TYPE_LABELS[s.type]}</td>
<td>{s.expiryDate}</td>
@@ -302,7 +353,7 @@ export default function Secrets({ user }: { user: CurrentUser }) {
))}
{(sorted ?? []).length === 0 && (
<tr>
<td colSpan={canEdit ? 7 : 6} className="text-secondary text-center">
<td colSpan={canEdit ? 8 : 6} className="text-secondary text-center">
No secrets tracked yet.
</td>
</tr>
+77 -1
View File
@@ -13,6 +13,7 @@ import SortableTh from "../components/SortableTh";
import { usePagination } from "../hooks/usePagination";
import Pagination from "../components/Pagination";
import { downloadCsv } from "../utils/csv";
import { useSelection } from "../hooks/useSelection";
const KEY_EXPIRY_WARN_DAYS = 30;
@@ -39,6 +40,8 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
const [data, setData] = useState<TailscaleDevicesResponse | null>(null);
const [loading, setLoading] = useState(false);
const selection = useSelection<string>();
const [bulkActing, setBulkActing] = useState(false);
useEffect(() => {
api.integrations
@@ -65,6 +68,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
}
useEffect(() => {
selection.clear();
if (selected?.enabled) {
loadDevices(selected.id);
} else {
@@ -94,6 +98,41 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
}
}
async function bulkSetAuthorized(authorized: boolean) {
if (!selectedId) return;
const ids = Array.from(selection.selected);
if (ids.length === 0) return;
setError(null);
setBulkActing(true);
try {
const results = await Promise.allSettled(ids.map((id) => api.integrations.tailscale.setAuthorized(selectedId, id, authorized)));
const failed = results.filter((r) => r.status === "rejected").length;
if (failed > 0) setError(`${failed} of ${ids.length} updates failed.`);
selection.clear();
loadDevices(selectedId);
} finally {
setBulkActing(false);
}
}
async function bulkRemove() {
if (!selectedId) return;
const ids = Array.from(selection.selected);
if (ids.length === 0) return;
if (!confirm(`Remove ${ids.length} device${ids.length !== 1 ? "s" : ""} from the tailnet?`)) return;
setError(null);
setBulkActing(true);
try {
const results = await Promise.allSettled(ids.map((id) => api.integrations.tailscale.remove(selectedId, id)));
const failed = results.filter((r) => r.status === "rejected").length;
if (failed > 0) setError(`${failed} of ${ids.length} removals failed.`);
selection.clear();
loadDevices(selectedId);
} finally {
setBulkActing(false);
}
}
const { sorted, sortKey, sortDir, requestSort } = useSortable(data?.devices);
const { pageItems, page, setPage, pageCount, totalCount } = usePagination(sorted);
@@ -178,10 +217,37 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
</button>
</div>
</div>
{canEdit && selection.count > 0 && (
<div className="card-header bg-blue-lt d-flex align-items-center gap-2">
<span>{selection.count} selected</span>
<button className="btn btn-sm btn-primary" onClick={() => bulkSetAuthorized(true)} disabled={bulkActing}>
Authorize
</button>
<button className="btn btn-sm" onClick={() => bulkSetAuthorized(false)} disabled={bulkActing}>
Deauthorize
</button>
<button className="btn btn-sm btn-outline-danger" onClick={bulkRemove} disabled={bulkActing}>
Remove
</button>
<button className="btn btn-sm btn-link" onClick={selection.clear}>
Clear selection
</button>
</div>
)}
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
{canEdit && (
<th className="w-1">
<input
type="checkbox"
className="form-check-input m-0"
checked={(pageItems ?? []).length > 0 && (pageItems ?? []).every((d) => selection.isSelected(d.id))}
onChange={() => selection.toggleAll((pageItems ?? []).map((d) => d.id))}
/>
</th>
)}
<SortableTh<TailscaleDevice> label="Device" sortKeyName="label" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<TailscaleDevice>
label="Address"
@@ -212,6 +278,16 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
<tbody>
{pageItems?.map((d) => (
<tr key={d.id}>
{canEdit && (
<td>
<input
type="checkbox"
className="form-check-input m-0"
checked={selection.isSelected(d.id)}
onChange={() => selection.toggle(d.id)}
/>
</td>
)}
<td>
{d.label || d.hostname}
{d.isExitNode && <span className="badge bg-purple-lt ms-2">Exit node</span>}
@@ -250,7 +326,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
))}
{sorted?.length === 0 && (
<tr>
<td colSpan={canEdit ? 7 : 6} className="text-secondary text-center">
<td colSpan={canEdit ? 8 : 6} className="text-secondary text-center">
No devices in this tailnet.
</td>
</tr>