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:
1 parent
e081eecba4
commit
35979cb043
4 files changed
+216
-3
No files matched your search
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user