multi select
This commit is contained in:
+160
-11
@@ -11,6 +11,7 @@ const sorters = {
|
|||||||
|
|
||||||
type SortKey = keyof typeof sorters;
|
type SortKey = keyof typeof sorters;
|
||||||
type SortDir = "asc" | "desc";
|
type SortDir = "asc" | "desc";
|
||||||
|
type PresenceFilter = "all" | "online" | "likely_online" | "unknown";
|
||||||
|
|
||||||
import { runCommand, type Agent } from "@/api";
|
import { runCommand, type Agent } from "@/api";
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ type WakeEvent = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const WAKE_HISTORY_KEY = "wakey_recent_wakes_v1";
|
const WAKE_HISTORY_KEY = "wakey_recent_wakes_v1";
|
||||||
|
const PRESENCE_FILTERS: PresenceFilter[] = ["all", "online", "likely_online", "unknown"];
|
||||||
|
|
||||||
function parseInventoryRows(payload: unknown): DeviceRow[] {
|
function parseInventoryRows(payload: unknown): DeviceRow[] {
|
||||||
if (!payload || typeof payload !== "object") return [];
|
if (!payload || typeof payload !== "object") return [];
|
||||||
@@ -167,28 +169,49 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
|
|||||||
}
|
}
|
||||||
return { key: "name", dir: "asc" };
|
return { key: "name", dir: "asc" };
|
||||||
}
|
}
|
||||||
const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>(getSortFromUrl());
|
|
||||||
|
|
||||||
// Keep sort state in sync with URL
|
function getPresenceFromUrl(): PresenceFilter {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const raw = params.get("presence");
|
||||||
|
return PRESENCE_FILTERS.includes(raw as PresenceFilter) ? (raw as PresenceFilter) : "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>(getSortFromUrl());
|
||||||
|
const [presenceFilter, setPresenceFilter] = useState<PresenceFilter>(getPresenceFromUrl());
|
||||||
|
|
||||||
|
// Keep view state in sync with URL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
params.set("sort", sort.key);
|
params.set("sort", sort.key);
|
||||||
params.set("dir", sort.dir);
|
params.set("dir", sort.dir);
|
||||||
navigate({ search: params.toString() }, { replace: true });
|
if (presenceFilter === "all") {
|
||||||
|
params.delete("presence");
|
||||||
|
} else {
|
||||||
|
params.set("presence", presenceFilter);
|
||||||
|
}
|
||||||
|
const nextSearch = params.toString();
|
||||||
|
if (nextSearch !== location.search.slice(1)) {
|
||||||
|
navigate({ search: nextSearch }, { replace: true });
|
||||||
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [sort.key, sort.dir]);
|
}, [sort.key, sort.dir, presenceFilter, location.search]);
|
||||||
|
|
||||||
// Update sort state if URL changes (e.g., back/forward nav)
|
// Update state if URL changes (e.g., back/forward nav)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSort(getSortFromUrl());
|
setSort(getSortFromUrl());
|
||||||
|
setPresenceFilter(getPresenceFromUrl());
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [location.search]);
|
}, [location.search]);
|
||||||
|
|
||||||
const [rows, setRows] = useState<DeviceRow[]>([]);
|
const [rows, setRows] = useState<DeviceRow[]>([]);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [wakeBusyId, setWakeBusyId] = useState("");
|
const [wakeBusyId, setWakeBusyId] = useState("");
|
||||||
|
const [bulkWakeBusy, setBulkWakeBusy] = useState(false);
|
||||||
const [quickWake, setQuickWake] = useState("");
|
const [quickWake, setQuickWake] = useState("");
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
const [copyStatus, setCopyStatus] = useState("");
|
||||||
const [recentWakes, setRecentWakes] = useState<WakeEvent[]>([]);
|
const [recentWakes, setRecentWakes] = useState<WakeEvent[]>([]);
|
||||||
function appendWakeEvent(event: WakeEvent) {
|
function appendWakeEvent(event: WakeEvent) {
|
||||||
setRecentWakes((prev) => {
|
setRecentWakes((prev) => {
|
||||||
@@ -234,7 +257,7 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
|
|||||||
await wakeTarget(target, device.id);
|
await wakeTarget(target, device.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function wakeTarget(target: string, busyId: string) {
|
async function wakeTarget(target: string, busyId: string, opts: { refresh: boolean; notify: boolean } = { refresh: true, notify: true }) {
|
||||||
if (!target || !selectedAgentId) return;
|
if (!target || !selectedAgentId) return;
|
||||||
setWakeBusyId(busyId);
|
setWakeBusyId(busyId);
|
||||||
try {
|
try {
|
||||||
@@ -248,8 +271,8 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
|
|||||||
requestId: summary.requestId,
|
requestId: summary.requestId,
|
||||||
detail: summary.detail,
|
detail: summary.detail,
|
||||||
});
|
});
|
||||||
await loadInventory();
|
if (opts.refresh) await loadInventory();
|
||||||
if (onAfterWake) await onAfterWake();
|
if (opts.notify && onAfterWake) await onAfterWake();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
appendWakeEvent({
|
appendWakeEvent({
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
@@ -264,13 +287,57 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function wakeSelectedDevices() {
|
||||||
|
if (!selectedAgentId || bulkWakeBusy) return;
|
||||||
|
const selectedRows = filtered.filter((row) => selectedIds.includes(row.id));
|
||||||
|
if (!selectedRows.length) return;
|
||||||
|
|
||||||
|
setBulkWakeBusy(true);
|
||||||
|
try {
|
||||||
|
for (const row of selectedRows) {
|
||||||
|
const target = chooseWakeTarget(row);
|
||||||
|
if (!target) continue;
|
||||||
|
await wakeTarget(target, `bulk:${row.id}`, { refresh: false, notify: false });
|
||||||
|
}
|
||||||
|
await loadInventory();
|
||||||
|
if (onAfterWake) await onAfterWake();
|
||||||
|
} finally {
|
||||||
|
setBulkWakeBusy(false);
|
||||||
|
setWakeBusyId("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyValue(label: string, value: string) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(trimmed);
|
||||||
|
setCopyStatus(`Copied ${label}`);
|
||||||
|
} catch {
|
||||||
|
setCopyStatus(`Copy failed for ${label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadInventory();
|
void loadInventory();
|
||||||
}, [selectedAgentId]);
|
}, [selectedAgentId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIds((prev) => prev.filter((id) => rows.some((row) => row.id === id)));
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!copyStatus) return;
|
||||||
|
const timer = window.setTimeout(() => setCopyStatus(""), 1800);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [copyStatus]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
let result = rows;
|
let result = rows;
|
||||||
|
if (presenceFilter !== "all") {
|
||||||
|
result = result.filter((row) => row.presence === presenceFilter);
|
||||||
|
}
|
||||||
if (q) {
|
if (q) {
|
||||||
result = result.filter((row) =>
|
result = result.filter((row) =>
|
||||||
row.name.toLowerCase().includes(q)
|
row.name.toLowerCase().includes(q)
|
||||||
@@ -285,7 +352,29 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
|
|||||||
result = [...result].sort(sorter);
|
result = [...result].sort(sorter);
|
||||||
if (sort.dir === "desc") result.reverse();
|
if (sort.dir === "desc") result.reverse();
|
||||||
return result;
|
return result;
|
||||||
}, [rows, query, sort]);
|
}, [rows, query, sort, presenceFilter]);
|
||||||
|
|
||||||
|
const selectedVisibleCount = filtered.filter((row) => selectedIds.includes(row.id)).length;
|
||||||
|
const allVisibleSelected = filtered.length > 0 && selectedVisibleCount === filtered.length;
|
||||||
|
|
||||||
|
function toggleRowSelection(id: string, checked: boolean) {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
if (checked) return prev.includes(id) ? prev : [...prev, id];
|
||||||
|
return prev.filter((v) => v !== id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllVisible(checked: boolean) {
|
||||||
|
if (!checked) {
|
||||||
|
setSelectedIds((prev) => prev.filter((id) => !filtered.some((row) => row.id === id)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const row of filtered) next.add(row.id);
|
||||||
|
return Array.from(next);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="two-col">
|
<section className="two-col">
|
||||||
@@ -340,10 +429,59 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="presence-filters" role="group" aria-label="Filter by presence">
|
||||||
|
{PRESENCE_FILTERS.map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
className={`filter-chip ${presenceFilter === value ? "active" : ""}`}
|
||||||
|
onClick={() => setPresenceFilter(value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{value === "all" ? "all" : value.replace("_", " ")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bulk-actions">
|
||||||
|
<label className="select-visible">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allVisibleSelected}
|
||||||
|
onChange={(e) => toggleAllVisible(e.target.checked)}
|
||||||
|
disabled={!filtered.length}
|
||||||
|
/>
|
||||||
|
Select visible
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => void wakeSelectedDevices()}
|
||||||
|
disabled={!selectedVisibleCount || !selectedAgentId || bulkWakeBusy}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{bulkWakeBusy ? "Waking selected..." : `Wake selected (${selectedVisibleCount})`}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedIds([])}
|
||||||
|
disabled={!selectedIds.length}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Clear selection
|
||||||
|
</button>
|
||||||
|
{copyStatus && <span className="muted">{copyStatus}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="muted">Showing {filtered.length} of {rows.length}</p>
|
<p className="muted">Showing {filtered.length} of {rows.length}</p>
|
||||||
{error && <pre className="error">{error}</pre>}
|
{error && <pre className="error">{error}</pre>}
|
||||||
<div className="list device-list">
|
<div className="list device-list">
|
||||||
<div className="row plain device-row device-header">
|
<div className="row plain device-row device-header">
|
||||||
|
<span className="device-cell device-select">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allVisibleSelected}
|
||||||
|
onChange={(e) => toggleAllVisible(e.target.checked)}
|
||||||
|
disabled={!filtered.length}
|
||||||
|
aria-label="Select all visible devices"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
className="sortable-col device-cell"
|
className="sortable-col device-cell"
|
||||||
onClick={() => setSort((s) => ({ key: "name", dir: s.key === "name" && s.dir === "asc" ? "desc" : "asc" }))}
|
onClick={() => setSort((s) => ({ key: "name", dir: s.key === "name" && s.dir === "asc" ? "desc" : "asc" }))}
|
||||||
@@ -369,19 +507,30 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
|
|||||||
Presence {sort.key === "presence" ? (sort.dir === "asc" ? "▲" : "▼") : ""}
|
Presence {sort.key === "presence" ? (sort.dir === "asc" ? "▲" : "▼") : ""}
|
||||||
</span>
|
</span>
|
||||||
<span className="device-cell">Interfaces</span>
|
<span className="device-cell">Interfaces</span>
|
||||||
<span className="device-cell device-action"></span>
|
<span className="device-cell device-action">Actions</span>
|
||||||
</div>
|
</div>
|
||||||
{filtered.map((row) => (
|
{filtered.map((row) => (
|
||||||
<div className="row plain device-row" key={row.id}>
|
<div className="row plain device-row" key={row.id}>
|
||||||
|
<span className="device-cell device-select" data-label="Pick">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.includes(row.id)}
|
||||||
|
onChange={(e) => toggleRowSelection(row.id, e.target.checked)}
|
||||||
|
aria-label={`Select ${row.name}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
<span className="device-cell" data-label="Name" title={row.name}>{row.name}</span>
|
<span className="device-cell" data-label="Name" title={row.name}>{row.name}</span>
|
||||||
<span className="device-cell muted" data-label="IP" title={row.ips.join(", ") || "-"}>{summarize(row.ips)}</span>
|
<span className="device-cell muted" data-label="IP" title={row.ips.join(", ") || "-"}>{summarize(row.ips)}</span>
|
||||||
<span className="device-cell muted" data-label="MAC" title={row.macs.join(", ") || "-"}>{summarize(row.macs)}</span>
|
<span className="device-cell muted" data-label="MAC" title={row.macs.join(", ") || "-"}>{summarize(row.macs)}</span>
|
||||||
<span className="device-cell" data-label="Presence"><span className="pill">{row.presence}</span></span>
|
<span className="device-cell" data-label="Presence"><span className="pill">{row.presence}</span></span>
|
||||||
<span className="device-cell" data-label="Interfaces" title={row.interfaces.join(", ") || "-"}>{summarize(row.interfaces)}</span>
|
<span className="device-cell" data-label="Interfaces" title={row.interfaces.join(", ") || "-"}>{summarize(row.interfaces)}</span>
|
||||||
<span className="device-cell device-action" data-label="">
|
<span className="device-cell device-action" data-label="">
|
||||||
<button onClick={() => void wakeDevice(row)} disabled={wakeBusyId === row.id || !selectedAgentId}>
|
<button onClick={() => void wakeDevice(row)} disabled={wakeBusyId === row.id || !selectedAgentId || bulkWakeBusy}>
|
||||||
{wakeBusyId === row.id ? "Waking..." : "Wake"}
|
{wakeBusyId === row.id ? "Waking..." : "Wake"}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="mini-btn" onClick={() => void copyValue("name", chooseWakeTarget(row))}>Copy name</button>
|
||||||
|
<button type="button" className="mini-btn" onClick={() => void copyValue("ip", row.ips[0] || "")}>Copy IP</button>
|
||||||
|
<button type="button" className="mini-btn" onClick={() => void copyValue("mac", row.macs[0] || "")}>Copy MAC</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+61
-2
@@ -138,6 +138,44 @@ input, select { width: 100%; padding: 0.45rem 0.55rem; }
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.presence-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip {
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #0b1528;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip.active {
|
||||||
|
border-color: #6b84ad;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.45rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-visible {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
.linkish {
|
.linkish {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
@@ -169,7 +207,7 @@ input, select { width: 100%; padding: 0.45rem 0.55rem; }
|
|||||||
|
|
||||||
.device-row {
|
.device-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(110px, 1.1fr) minmax(160px, 2fr) minmax(130px, 1.5fr) minmax(110px, 0.9fr) minmax(120px, 1fr) auto;
|
grid-template-columns: 36px minmax(110px, 1.1fr) minmax(160px, 2fr) minmax(130px, 1.5fr) minmax(110px, 0.9fr) minmax(120px, 1fr) minmax(220px, auto);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
}
|
}
|
||||||
@@ -195,9 +233,24 @@ input, select { width: 100%; padding: 0.45rem 0.55rem; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.device-action {
|
.device-action {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-select {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-btn {
|
||||||
|
padding: 0.28rem 0.45rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.device-header {
|
.device-header {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -206,7 +259,8 @@ input, select { width: 100%; padding: 0.45rem 0.55rem; }
|
|||||||
.device-row {
|
.device-row {
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"name action"
|
"select action"
|
||||||
|
"name name"
|
||||||
"presence presence"
|
"presence presence"
|
||||||
"ip ip"
|
"ip ip"
|
||||||
"mac mac"
|
"mac mac"
|
||||||
@@ -234,6 +288,10 @@ input, select { width: 100%; padding: 0.45rem 0.55rem; }
|
|||||||
grid-area: name;
|
grid-area: name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-row .device-cell[data-label="Pick"] {
|
||||||
|
grid-area: select;
|
||||||
|
}
|
||||||
|
|
||||||
.device-row .device-cell[data-label="Presence"] {
|
.device-row .device-cell[data-label="Presence"] {
|
||||||
grid-area: presence;
|
grid-area: presence;
|
||||||
}
|
}
|
||||||
@@ -253,6 +311,7 @@ input, select { width: 100%; padding: 0.45rem 0.55rem; }
|
|||||||
.device-row .device-action {
|
.device-row .device-action {
|
||||||
grid-area: action;
|
grid-area: action;
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-wake {
|
.quick-wake {
|
||||||
|
|||||||
Reference in New Issue
Block a user