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 SortableTh from "../components/SortableTh";
|
||||||
import { usePagination } from "../hooks/usePagination";
|
import { usePagination } from "../hooks/usePagination";
|
||||||
import Pagination from "../components/Pagination";
|
import Pagination from "../components/Pagination";
|
||||||
|
import { useSelection } from "../hooks/useSelection";
|
||||||
|
|
||||||
const emptyForm: IpamInput = { ipAddress: "", label: "", vendor: "", location: "", notes: "" };
|
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 [saving, setSaving] = useState(false);
|
||||||
const [syncing, setSyncing] = useState<"tailscale" | "proxmox" | null>(null);
|
const [syncing, setSyncing] = useState<"tailscale" | "proxmox" | null>(null);
|
||||||
const [syncResult, setSyncResult] = useState<string | null>(null);
|
const [syncResult, setSyncResult] = useState<string | null>(null);
|
||||||
|
const selection = useSelection<number>();
|
||||||
|
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
api.ipam
|
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") {
|
async function runSync(source: "tailscale" | "proxmox") {
|
||||||
setError(null);
|
setError(null);
|
||||||
setSyncResult(null);
|
setSyncResult(null);
|
||||||
@@ -246,10 +266,31 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="table-responsive">
|
||||||
<table className="table table-vcenter card-table">
|
<table className="table table-vcenter card-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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="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="Label" sortKeyName="label" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||||
<SortableTh<IpamEntry> label="Vendor" sortKeyName="vendor" 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>
|
<tbody>
|
||||||
{(pageItems ?? []).map((entry) => (
|
{(pageItems ?? []).map((entry) => (
|
||||||
<tr key={entry.id}>
|
<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>
|
<td>
|
||||||
<span className={`badge ${isIpv6(entry.ipAddress) ? "bg-purple-lt" : "bg-blue-lt"} me-2`}>
|
<span className={`badge ${isIpv6(entry.ipAddress) ? "bg-purple-lt" : "bg-blue-lt"} me-2`}>
|
||||||
{isIpv6(entry.ipAddress) ? "IPv6" : "IPv4"}
|
{isIpv6(entry.ipAddress) ? "IPv6" : "IPv4"}
|
||||||
@@ -292,7 +343,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
|||||||
))}
|
))}
|
||||||
{(sorted ?? []).length === 0 && (
|
{(sorted ?? []).length === 0 && (
|
||||||
<tr>
|
<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.
|
No IP addresses tracked yet.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useSortable } from "../hooks/useSortable";
|
|||||||
import SortableTh from "../components/SortableTh";
|
import SortableTh from "../components/SortableTh";
|
||||||
import { usePagination } from "../hooks/usePagination";
|
import { usePagination } from "../hooks/usePagination";
|
||||||
import Pagination from "../components/Pagination";
|
import Pagination from "../components/Pagination";
|
||||||
|
import { useSelection } from "../hooks/useSelection";
|
||||||
|
|
||||||
const TYPE_LABELS: Record<SecretRecord["type"], string> = {
|
const TYPE_LABELS: Record<SecretRecord["type"], string> = {
|
||||||
api_token: "API Token",
|
api_token: "API Token",
|
||||||
@@ -39,6 +40,8 @@ export default function Secrets({ user }: { user: CurrentUser }) {
|
|||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [form, setForm] = useState<SecretInput>(emptyForm);
|
const [form, setForm] = useState<SecretInput>(emptyForm);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const selection = useSelection<number>();
|
||||||
|
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
api.secrets
|
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 { sorted, sortKey, sortDir, requestSort } = useSortable(filtered);
|
||||||
const { pageItems, page, setPage, pageCount, totalCount } = usePagination(sorted);
|
const { pageItems, page, setPage, pageCount, totalCount } = usePagination(sorted);
|
||||||
|
|
||||||
@@ -244,10 +264,31 @@ export default function Secrets({ user }: { user: CurrentUser }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="table-responsive">
|
||||||
<table className="table table-vcenter card-table">
|
<table className="table table-vcenter card-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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="Name" sortKeyName="name" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||||
<SortableTh<SecretRecord> label="Type" sortKeyName="type" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
<SortableTh<SecretRecord> label="Type" sortKeyName="type" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||||
<SortableTh<SecretRecord>
|
<SortableTh<SecretRecord>
|
||||||
@@ -278,6 +319,16 @@ export default function Secrets({ user }: { user: CurrentUser }) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{(pageItems ?? []).map((s) => (
|
{(pageItems ?? []).map((s) => (
|
||||||
<tr key={s.id}>
|
<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>{s.name}</td>
|
||||||
<td>{TYPE_LABELS[s.type]}</td>
|
<td>{TYPE_LABELS[s.type]}</td>
|
||||||
<td>{s.expiryDate}</td>
|
<td>{s.expiryDate}</td>
|
||||||
@@ -302,7 +353,7 @@ export default function Secrets({ user }: { user: CurrentUser }) {
|
|||||||
))}
|
))}
|
||||||
{(sorted ?? []).length === 0 && (
|
{(sorted ?? []).length === 0 && (
|
||||||
<tr>
|
<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.
|
No secrets tracked yet.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import SortableTh from "../components/SortableTh";
|
|||||||
import { usePagination } from "../hooks/usePagination";
|
import { usePagination } from "../hooks/usePagination";
|
||||||
import Pagination from "../components/Pagination";
|
import Pagination from "../components/Pagination";
|
||||||
import { downloadCsv } from "../utils/csv";
|
import { downloadCsv } from "../utils/csv";
|
||||||
|
import { useSelection } from "../hooks/useSelection";
|
||||||
|
|
||||||
const KEY_EXPIRY_WARN_DAYS = 30;
|
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 [data, setData] = useState<TailscaleDevicesResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const selection = useSelection<string>();
|
||||||
|
const [bulkActing, setBulkActing] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.integrations
|
api.integrations
|
||||||
@@ -65,6 +68,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
selection.clear();
|
||||||
if (selected?.enabled) {
|
if (selected?.enabled) {
|
||||||
loadDevices(selected.id);
|
loadDevices(selected.id);
|
||||||
} else {
|
} 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 { sorted, sortKey, sortDir, requestSort } = useSortable(data?.devices);
|
||||||
const { pageItems, page, setPage, pageCount, totalCount } = usePagination(sorted);
|
const { pageItems, page, setPage, pageCount, totalCount } = usePagination(sorted);
|
||||||
|
|
||||||
@@ -178,10 +217,37 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="table-responsive">
|
||||||
<table className="table table-vcenter card-table">
|
<table className="table table-vcenter card-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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="Device" sortKeyName="label" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||||
<SortableTh<TailscaleDevice>
|
<SortableTh<TailscaleDevice>
|
||||||
label="Address"
|
label="Address"
|
||||||
@@ -212,6 +278,16 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{pageItems?.map((d) => (
|
{pageItems?.map((d) => (
|
||||||
<tr key={d.id}>
|
<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>
|
<td>
|
||||||
{d.label || d.hostname}
|
{d.label || d.hostname}
|
||||||
{d.isExitNode && <span className="badge bg-purple-lt ms-2">Exit node</span>}
|
{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 && (
|
{sorted?.length === 0 && (
|
||||||
<tr>
|
<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.
|
No devices in this tailnet.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
Reference in new issue
Block a user