diff --git a/web/src/hooks/useSelection.ts b/web/src/hooks/useSelection.ts new file mode 100644 index 0000000..0599e4d --- /dev/null +++ b/web/src/hooks/useSelection.ts @@ -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() { + const [selected, setSelected] = useState>(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 }; +} diff --git a/web/src/pages/Ipam.tsx b/web/src/pages/Ipam.tsx index 0f9e8d3..9410539 100644 --- a/web/src/pages/Ipam.tsx +++ b/web/src/pages/Ipam.tsx @@ -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(null); + const selection = useSelection(); + 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 }) { + {canEdit && selection.count > 0 && ( +
+ {selection.count} selected + + +
+ )}
+ {canEdit && ( + + )} label="IP address" sortKeyName="ipAddress" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> label="Label" sortKeyName="label" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> label="Vendor" sortKeyName="vendor" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> @@ -261,6 +302,16 @@ export default function Ipam({ user }: { user: CurrentUser }) { {(pageItems ?? []).map((entry) => ( + {canEdit && ( + + )} - diff --git a/web/src/pages/Secrets.tsx b/web/src/pages/Secrets.tsx index 642e358..8288892 100644 --- a/web/src/pages/Secrets.tsx +++ b/web/src/pages/Secrets.tsx @@ -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 = { api_token: "API Token", @@ -39,6 +40,8 @@ export default function Secrets({ user }: { user: CurrentUser }) { const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(emptyForm); const [saving, setSaving] = useState(false); + const selection = useSelection(); + 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 }) { + {canEdit && selection.count > 0 && ( +
+ {selection.count} selected + + +
+ )}
+ 0 && (pageItems ?? []).every((e) => selection.isSelected(e.id))} + onChange={() => selection.toggleAll((pageItems ?? []).map((e) => e.id))} + /> +
+ selection.toggle(entry.id)} + /> + {isIpv6(entry.ipAddress) ? "IPv6" : "IPv4"} @@ -292,7 +343,7 @@ export default function Ipam({ user }: { user: CurrentUser }) { ))} {(sorted ?? []).length === 0 && (
+ No IP addresses tracked yet.
+ {canEdit && ( + + )} label="Name" sortKeyName="name" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> label="Type" sortKeyName="type" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> @@ -278,6 +319,16 @@ export default function Secrets({ user }: { user: CurrentUser }) { {(pageItems ?? []).map((s) => ( + {canEdit && ( + + )} @@ -302,7 +353,7 @@ export default function Secrets({ user }: { user: CurrentUser }) { ))} {(sorted ?? []).length === 0 && ( - diff --git a/web/src/pages/Tailscale.tsx b/web/src/pages/Tailscale.tsx index 4242270..b7345d5 100644 --- a/web/src/pages/Tailscale.tsx +++ b/web/src/pages/Tailscale.tsx @@ -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(null); const [loading, setLoading] = useState(false); + const selection = useSelection(); + 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 }) { + {canEdit && selection.count > 0 && ( +
+ {selection.count} selected + + + + +
+ )}
+ 0 && (pageItems ?? []).every((s) => selection.isSelected(s.id))} + onChange={() => selection.toggleAll((pageItems ?? []).map((s) => s.id))} + /> +
+ selection.toggle(s.id)} + /> + {s.name} {TYPE_LABELS[s.type]} {s.expiryDate}
+ No secrets tracked yet.
+ {canEdit && ( + + )} label="Device" sortKeyName="label" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> label="Address" @@ -212,6 +278,16 @@ export default function Tailscale({ user }: { user: CurrentUser }) { {pageItems?.map((d) => ( + {canEdit && ( + + )} -
+ 0 && (pageItems ?? []).every((d) => selection.isSelected(d.id))} + onChange={() => selection.toggleAll((pageItems ?? []).map((d) => d.id))} + /> +
+ selection.toggle(d.id)} + /> + {d.label || d.hostname} {d.isExitNode && Exit node} @@ -250,7 +326,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) { ))} {sorted?.length === 0 && (
+ No devices in this tailnet.