diff --git a/Data/Server/WebUI/src/Devices/Device_List.jsx b/Data/Server/WebUI/src/Devices/Device_List.jsx index 9b249f6..99cff1f 100644 --- a/Data/Server/WebUI/src/Devices/Device_List.jsx +++ b/Data/Server/WebUI/src/Devices/Device_List.jsx @@ -1,6 +1,6 @@ ////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: /Data/WebUI/src/Device_List.jsx -import React, { useState, useEffect, useCallback, useMemo } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Paper, Box, @@ -15,9 +15,12 @@ import { Button, IconButton, Menu, - MenuItem + MenuItem, + Popover, + TextField } from "@mui/material"; import MoreVertIcon from "@mui/icons-material/MoreVert"; +import FilterListIcon from "@mui/icons-material/FilterList"; import { DeleteDeviceDialog } from "../Dialogs.jsx"; import QuickJob from "../Scheduling/Quick_Job.jsx"; @@ -51,26 +54,109 @@ export default function DeviceList({ onSelectDevice }) { const [menuAnchor, setMenuAnchor] = useState(null); const [selected, setSelected] = useState(null); 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); + // 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 () => { try { const res = await fetch("/api/agents"); const data = await res.json(); - const arr = Object.entries(data || {}).map(([id, a]) => ({ - id, - hostname: a.hostname || id || "unknown", - status: statusFromHeartbeat(a.last_seen), - lastSeen: a.last_seen || 0, - os: a.agent_operating_system || a.os || "-" - })); + const arr = Object.entries(data || {}).map(([id, a]) => { + const hostname = a.hostname || id || "unknown"; + const details = detailsByHost[hostname] || {}; + return { + id, + hostname, + status: statusFromHeartbeat(a.last_seen), + lastSeen: a.last_seen || 0, + 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); + + // 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) { console.warn("Failed to load agents:", e); setRows([]); } - }, []); + }, [detailsByHost]); useEffect(() => { fetchAgents(); @@ -78,15 +164,46 @@ export default function DeviceList({ onSelectDevice }) { return () => clearInterval(t); }, [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 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 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) => { if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc"); @@ -122,31 +239,69 @@ export default function DeviceList({ onSelectDevice }) { setSelected(null); }; - const isAllChecked = sorted.length > 0 && sorted.every((r) => selectedHosts.has(r.hostname)); - const isIndeterminate = selectedHosts.size > 0 && !isAllChecked; + const isAllChecked = sorted.length > 0 && sorted.every((r) => selectedIds.has(r.id)); + const isIndeterminate = selectedIds.size > 0 && !isAllChecked; const toggleAll = (e) => { const checked = e.target.checked; - setSelectedHosts((prev) => { + setSelectedIds((prev) => { const next = new Set(prev); - if (checked) { - sorted.forEach((r) => next.add(r.hostname)); - } else { - next.clear(); - } + if (checked) sorted.forEach((r) => next.add(r.id)); + else next.clear(); return next; }); }; - const toggleOne = (hostname) => (e) => { + const toggleOne = (id) => (e) => { const checked = e.target.checked; - setSelectedHosts((prev) => { + setSelectedIds((prev) => { const next = new Set(prev); - if (checked) next.add(hostname); - else next.delete(hostname); + if (checked) next.add(id); + else next.delete(id); 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 ( @@ -157,12 +312,12 @@ export default function DeviceList({ onSelectDevice }) { - +
@@ -181,42 +336,33 @@ export default function DeviceList({ onSelectDevice }) { sx={{ color: "#777" }} /> - - handleSort("status")} + {columns.map((col) => ( + - Status - - - - handleSort("hostname")} - > - Device Hostname - - - - handleSort("lastSeen")} - > - Last Seen - - - - handleSort("os")} - > - OS - - + + handleSort(col.id)} + > + {col.label} + + + + + + + ))} @@ -225,41 +371,62 @@ export default function DeviceList({ onSelectDevice }) { e.stopPropagation()}> - - - {r.status} - - - onSelectDevice && onSelectDevice(r)} - sx={{ - color: "#58a6ff", - "&:hover": { - cursor: onSelectDevice ? "pointer" : "default", - textDecoration: onSelectDevice ? "underline" : "none", - }, - }} - > - {r.hostname} - - {formatLastSeen(r.lastSeen)} - {r.os} + {columns.map((col) => { + switch (col.id) { + case "status": + return ( + + + {r.status} + + + ); + case "hostname": + return ( + onSelectDevice && onSelectDevice(r)} + sx={{ + color: "#58a6ff", + "&:hover": { + cursor: onSelectDevice ? "pointer" : "default", + textDecoration: onSelectDevice ? "underline" : "none", + }, + }} + > + {r.hostname} + + ); + case "lastUser": + return {r.lastUser || ""}; + case "type": + return {r.type || ""}; + case "os": + return {r.os}; + case "created": + return ( + {formatCreated(r.created, r.createdTs)} + ); + default: + return ; + } + })} - + No agents connected. )}
+ {/* Filter popover */} + + {filterAnchor && ( + + 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" }, + }, + }} + /> + + + )} + setQuickJobOpen(false)} - hostnames={[...selectedHosts]} + hostnames={rows.filter((r) => selectedIds.has(r.id)).map((r) => r.hostname)} /> )}