Added additional columns to Device list and fixed checkboxes.

This commit is contained in:
2025-09-05 19:04:44 -06:00
parent b55bf1c66a
commit 05b2d7ef77

View File

@@ -1,6 +1,6 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Device_List.jsx ////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Device_List.jsx
import React, { useState, useEffect, useCallback, useMemo } from "react"; import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { import {
Paper, Paper,
Box, Box,
@@ -15,9 +15,12 @@ import {
Button, Button,
IconButton, IconButton,
Menu, Menu,
MenuItem MenuItem,
Popover,
TextField
} from "@mui/material"; } from "@mui/material";
import MoreVertIcon from "@mui/icons-material/MoreVert"; import MoreVertIcon from "@mui/icons-material/MoreVert";
import FilterListIcon from "@mui/icons-material/FilterList";
import { DeleteDeviceDialog } from "../Dialogs.jsx"; import { DeleteDeviceDialog } from "../Dialogs.jsx";
import QuickJob from "../Scheduling/Quick_Job.jsx"; import QuickJob from "../Scheduling/Quick_Job.jsx";
@@ -51,26 +54,109 @@ export default function DeviceList({ onSelectDevice }) {
const [menuAnchor, setMenuAnchor] = useState(null); const [menuAnchor, setMenuAnchor] = useState(null);
const [selected, setSelected] = useState(null); const [selected, setSelected] = useState(null);
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
const [selectedHosts, setSelectedHosts] = useState(() => new Set()); // Track selection by agent id to avoid duplicate hostname collisions
const [selectedIds, setSelectedIds] = useState(() => new Set());
const [quickJobOpen, setQuickJobOpen] = useState(false); const [quickJobOpen, setQuickJobOpen] = useState(false);
// Column configuration and rearranging state
const defaultColumns = useMemo(
() => [
{ id: "status", label: "Status" },
{ id: "hostname", label: "Hostname" },
{ id: "lastUser", label: "Last User" },
{ id: "type", label: "Type" },
{ id: "os", label: "OS" },
{ id: "created", label: "Created" }
],
[]
);
const [columns, setColumns] = useState(defaultColumns);
const dragColId = useRef(null);
// Per-column filters
const [filters, setFilters] = useState({});
const [filterAnchor, setFilterAnchor] = useState(null); // { id, anchorEl }
// Cache device details to avoid re-fetching every refresh
const [detailsByHost, setDetailsByHost] = useState({}); // hostname -> { lastUser, created, createdTs }
const fetchAgents = useCallback(async () => { const fetchAgents = useCallback(async () => {
try { try {
const res = await fetch("/api/agents"); const res = await fetch("/api/agents");
const data = await res.json(); const data = await res.json();
const arr = Object.entries(data || {}).map(([id, a]) => ({ const arr = Object.entries(data || {}).map(([id, a]) => {
const hostname = a.hostname || id || "unknown";
const details = detailsByHost[hostname] || {};
return {
id, id,
hostname: a.hostname || id || "unknown", hostname,
status: statusFromHeartbeat(a.last_seen), status: statusFromHeartbeat(a.last_seen),
lastSeen: a.last_seen || 0, lastSeen: a.last_seen || 0,
os: a.agent_operating_system || a.os || "-" os: a.agent_operating_system || a.os || "-",
})); // Enriched fields from details cache
lastUser: details.lastUser || "",
type: "", // Placeholder until provided by backend
created: details.created || "",
createdTs: details.createdTs || 0,
};
});
setRows(arr); setRows(arr);
// Fetch missing details (last_user, created) for hosts not cached yet
const hostsToFetch = arr
.map((r) => r.hostname)
.filter((h) => h && !detailsByHost[h]);
if (hostsToFetch.length) {
// Limit concurrency a bit
const chunks = [];
const size = 6;
for (let i = 0; i < hostsToFetch.length; i += size) {
chunks.push(hostsToFetch.slice(i, i + size));
}
for (const chunk of chunks) {
await Promise.all(
chunk.map(async (h) => {
try {
const resp = await fetch(`/api/device/details/${encodeURIComponent(h)}`);
const det = await resp.json();
const summary = det?.summary || {};
const lastUser = summary.last_user || "";
const createdRaw = summary.created || "";
// Try to parse created to epoch seconds for sorting; fallback to 0
let createdTs = 0;
if (createdRaw) {
const parsed = Date.parse(createdRaw.replace(" ", "T"));
createdTs = isNaN(parsed) ? 0 : Math.floor(parsed / 1000);
}
setDetailsByHost((prev) => ({
...prev,
[h]: { lastUser, created: createdRaw, createdTs },
}));
} catch {
// ignore per-host failure
}
})
);
}
// After caching, refresh rows to apply newly available details
setRows((prev) =>
prev.map((r) => {
const det = detailsByHost[r.hostname];
if (!det) return r;
return {
...r,
lastUser: det.lastUser || r.lastUser,
created: det.created || r.created,
createdTs: det.createdTs || r.createdTs,
};
})
);
}
} catch (e) { } catch (e) {
console.warn("Failed to load agents:", e); console.warn("Failed to load agents:", e);
setRows([]); setRows([]);
} }
}, []); }, [detailsByHost]);
useEffect(() => { useEffect(() => {
fetchAgents(); fetchAgents();
@@ -78,15 +164,46 @@ export default function DeviceList({ onSelectDevice }) {
return () => clearInterval(t); return () => clearInterval(t);
}, [fetchAgents]); }, [fetchAgents]);
const filtered = useMemo(() => {
// Apply simple contains filter per column based on displayed string
const activeFilters = Object.entries(filters).filter(([, v]) => (v || "").trim() !== "");
if (!activeFilters.length) return rows;
const toDisplay = (colId, row) => {
switch (colId) {
case "status":
return row.status || "";
case "hostname":
return row.hostname || "";
case "lastUser":
return row.lastUser || "";
case "type":
return row.type || "";
case "os":
return row.os || "";
case "created":
return formatCreated(row.created, row.createdTs);
default:
return "";
}
};
return rows.filter((r) =>
activeFilters.every(([k, val]) =>
toDisplay(k, r).toLowerCase().includes(String(val).toLowerCase())
)
);
}, [rows, filters]);
const sorted = useMemo(() => { const sorted = useMemo(() => {
const dir = order === "asc" ? 1 : -1; const dir = order === "asc" ? 1 : -1;
return [...rows].sort((a, b) => { return [...filtered].sort((a, b) => {
// Support numeric sort for created/lastSeen
if (orderBy === "lastSeen") return ((a.lastSeen || 0) - (b.lastSeen || 0)) * dir;
if (orderBy === "created") return ((a.createdTs || 0) - (b.createdTs || 0)) * dir;
const A = a[orderBy]; const A = a[orderBy];
const B = b[orderBy]; const B = b[orderBy];
if (orderBy === "lastSeen") return (A - B) * dir; return String(A || "").localeCompare(String(B || "")) * dir;
return String(A).localeCompare(String(B)) * dir;
}); });
}, [rows, orderBy, order]); }, [filtered, orderBy, order]);
const handleSort = (col) => { const handleSort = (col) => {
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc"); if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
@@ -122,31 +239,69 @@ export default function DeviceList({ onSelectDevice }) {
setSelected(null); setSelected(null);
}; };
const isAllChecked = sorted.length > 0 && sorted.every((r) => selectedHosts.has(r.hostname)); const isAllChecked = sorted.length > 0 && sorted.every((r) => selectedIds.has(r.id));
const isIndeterminate = selectedHosts.size > 0 && !isAllChecked; const isIndeterminate = selectedIds.size > 0 && !isAllChecked;
const toggleAll = (e) => { const toggleAll = (e) => {
const checked = e.target.checked; const checked = e.target.checked;
setSelectedHosts((prev) => { setSelectedIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (checked) { if (checked) sorted.forEach((r) => next.add(r.id));
sorted.forEach((r) => next.add(r.hostname)); else next.clear();
} else {
next.clear();
}
return next; return next;
}); });
}; };
const toggleOne = (hostname) => (e) => { const toggleOne = (id) => (e) => {
const checked = e.target.checked; const checked = e.target.checked;
setSelectedHosts((prev) => { setSelectedIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (checked) next.add(hostname); if (checked) next.add(id);
else next.delete(hostname); else next.delete(id);
return next; return next;
}); });
}; };
// Column drag handlers
const onHeaderDragStart = (colId) => (e) => {
dragColId.current = colId;
try { e.dataTransfer.setData("text/plain", colId); } catch {}
};
const onHeaderDragOver = (e) => { e.preventDefault(); };
const onHeaderDrop = (targetColId) => (e) => {
e.preventDefault();
const fromId = dragColId.current;
if (!fromId || fromId === targetColId) return;
setColumns((prev) => {
const cur = [...prev];
const fromIdx = cur.findIndex((c) => c.id === fromId);
const toIdx = cur.findIndex((c) => c.id === targetColId);
if (fromIdx < 0 || toIdx < 0) return prev;
const [moved] = cur.splice(fromIdx, 1);
cur.splice(toIdx, 0, moved);
return cur;
});
dragColId.current = null;
};
// Filter popover handlers
const openFilter = (id) => (e) => setFilterAnchor({ id, anchorEl: e.currentTarget });
const closeFilter = () => setFilterAnchor(null);
const onFilterChange = (id) => (e) => setFilters((prev) => ({ ...prev, [id]: e.target.value }));
const formatCreated = (created, createdTs) => {
if (createdTs) {
const d = new Date(createdTs * 1000);
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
const yyyy = d.getFullYear();
const hh = d.getHours() % 12 || 12;
const min = String(d.getMinutes()).padStart(2, "0");
const ampm = d.getHours() >= 12 ? "PM" : "AM";
return `${mm}/${dd}/${yyyy} @ ${hh}:${min} ${ampm}`;
}
return created || "";
};
return ( return (
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}> <Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
<Box sx={{ p: 2, pb: 1, display: "flex", alignItems: "center", justifyContent: "space-between" }}> <Box sx={{ p: 2, pb: 1, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
@@ -157,12 +312,12 @@ export default function DeviceList({ onSelectDevice }) {
<Button <Button
variant="outlined" variant="outlined"
size="small" size="small"
disabled={selectedHosts.size === 0} disabled={selectedIds.size === 0}
onClick={() => setQuickJobOpen(true)} onClick={() => setQuickJobOpen(true)}
sx={{ sx={{
mr: 1, mr: 1,
color: selectedHosts.size === 0 ? "#666" : "#58a6ff", color: selectedIds.size === 0 ? "#666" : "#58a6ff",
borderColor: selectedHosts.size === 0 ? "#333" : "#58a6ff", borderColor: selectedIds.size === 0 ? "#333" : "#58a6ff",
textTransform: "none" textTransform: "none"
}} }}
> >
@@ -170,7 +325,7 @@ export default function DeviceList({ onSelectDevice }) {
</Button> </Button>
</Box> </Box>
</Box> </Box>
<Table size="small" sx={{ minWidth: 680 }}> <Table size="small" sx={{ minWidth: 820 }}>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell padding="checkbox"> <TableCell padding="checkbox">
@@ -181,42 +336,33 @@ export default function DeviceList({ onSelectDevice }) {
sx={{ color: "#777" }} sx={{ color: "#777" }}
/> />
</TableCell> </TableCell>
<TableCell sortDirection={orderBy === "status" ? order : false}> {columns.map((col) => (
<TableSortLabel <TableCell
active={orderBy === "status"} key={col.id}
direction={orderBy === "status" ? order : "asc"} sortDirection={orderBy === col.id ? order : false}
onClick={() => handleSort("status")} draggable
onDragStart={onHeaderDragStart(col.id)}
onDragOver={onHeaderDragOver}
onDrop={onHeaderDrop(col.id)}
> >
Status <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
</TableSortLabel>
</TableCell>
<TableCell sortDirection={orderBy === "hostname" ? order : false}>
<TableSortLabel <TableSortLabel
active={orderBy === "hostname"} active={orderBy === col.id}
direction={orderBy === "hostname" ? order : "asc"} direction={orderBy === col.id ? order : "asc"}
onClick={() => handleSort("hostname")} onClick={() => handleSort(col.id)}
> >
Device Hostname {col.label}
</TableSortLabel> </TableSortLabel>
</TableCell> <IconButton
<TableCell sortDirection={orderBy === "lastSeen" ? order : false}> size="small"
<TableSortLabel onClick={openFilter(col.id)}
active={orderBy === "lastSeen"} sx={{ color: filters[col.id] ? "#58a6ff" : "#888" }}
direction={orderBy === "lastSeen" ? order : "asc"}
onClick={() => handleSort("lastSeen")}
> >
Last Seen <FilterListIcon fontSize="inherit" />
</TableSortLabel> </IconButton>
</TableCell> </Box>
<TableCell sortDirection={orderBy === "os" ? order : false}>
<TableSortLabel
active={orderBy === "os"}
direction={orderBy === "os" ? order : "asc"}
onClick={() => handleSort("os")}
>
OS
</TableSortLabel>
</TableCell> </TableCell>
))}
<TableCell /> <TableCell />
</TableRow> </TableRow>
</TableHead> </TableHead>
@@ -225,12 +371,16 @@ export default function DeviceList({ onSelectDevice }) {
<TableRow key={r.id || i} hover> <TableRow key={r.id || i} hover>
<TableCell padding="checkbox" onClick={(e) => e.stopPropagation()}> <TableCell padding="checkbox" onClick={(e) => e.stopPropagation()}>
<Checkbox <Checkbox
checked={selectedHosts.has(r.hostname)} checked={selectedIds.has(r.id)}
onChange={toggleOne(r.hostname)} onChange={toggleOne(r.id)}
sx={{ color: "#777" }} sx={{ color: "#777" }}
/> />
</TableCell> </TableCell>
<TableCell> {columns.map((col) => {
switch (col.id) {
case "status":
return (
<TableCell key={col.id}>
<Box <Box
sx={{ sx={{
display: "inline-block", display: "inline-block",
@@ -246,7 +396,11 @@ export default function DeviceList({ onSelectDevice }) {
{r.status} {r.status}
</Box> </Box>
</TableCell> </TableCell>
);
case "hostname":
return (
<TableCell <TableCell
key={col.id}
onClick={() => onSelectDevice && onSelectDevice(r)} onClick={() => onSelectDevice && onSelectDevice(r)}
sx={{ sx={{
color: "#58a6ff", color: "#58a6ff",
@@ -258,8 +412,21 @@ export default function DeviceList({ onSelectDevice }) {
> >
{r.hostname} {r.hostname}
</TableCell> </TableCell>
<TableCell>{formatLastSeen(r.lastSeen)}</TableCell> );
<TableCell>{r.os}</TableCell> case "lastUser":
return <TableCell key={col.id}>{r.lastUser || ""}</TableCell>;
case "type":
return <TableCell key={col.id}>{r.type || ""}</TableCell>;
case "os":
return <TableCell key={col.id}>{r.os}</TableCell>;
case "created":
return (
<TableCell key={col.id}>{formatCreated(r.created, r.createdTs)}</TableCell>
);
default:
return <TableCell key={col.id} />;
}
})}
<TableCell align="right"> <TableCell align="right">
<IconButton <IconButton
size="small" size="small"
@@ -276,13 +443,53 @@ export default function DeviceList({ onSelectDevice }) {
))} ))}
{sorted.length === 0 && ( {sorted.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={6} sx={{ color: "#888" }}> <TableCell colSpan={columns.length + 2} sx={{ color: "#888" }}>
No agents connected. No agents connected.
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
</TableBody> </TableBody>
</Table> </Table>
{/* Filter popover */}
<Popover
open={Boolean(filterAnchor)}
anchorEl={filterAnchor?.anchorEl || null}
onClose={closeFilter}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
PaperProps={{ sx: { bgcolor: "#1e1e1e", p: 1 } }}
>
{filterAnchor && (
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<TextField
autoFocus
size="small"
placeholder={`Filter ${columns.find((c) => c.id === filterAnchor.id)?.label || ""}`}
value={filters[filterAnchor.id] || ""}
onChange={onFilterChange(filterAnchor.id)}
onKeyDown={(e) => { if (e.key === "Escape") closeFilter(); }}
sx={{
input: { color: "#fff" },
minWidth: 220,
"& .MuiOutlinedInput-root": {
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" },
},
}}
/>
<Button
variant="outlined"
size="small"
onClick={() => {
setFilters((prev) => ({ ...prev, [filterAnchor.id]: "" }));
closeFilter();
}}
sx={{ textTransform: "none", borderColor: "#555", color: "#bbb" }}
>
Clear
</Button>
</Box>
)}
</Popover>
<Menu <Menu
anchorEl={menuAnchor} anchorEl={menuAnchor}
open={Boolean(menuAnchor)} open={Boolean(menuAnchor)}
@@ -301,7 +508,7 @@ export default function DeviceList({ onSelectDevice }) {
<QuickJob <QuickJob
open={quickJobOpen} open={quickJobOpen}
onClose={() => setQuickJobOpen(false)} onClose={() => setQuickJobOpen(false)}
hostnames={[...selectedHosts]} hostnames={rows.filter((r) => selectedIds.has(r.id)).map((r) => r.hostname)}
/> />
)} )}
</Paper> </Paper>