From 278f00e3960f2c2726290d3d9e4faccbcb9d8c19 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Thu, 16 Oct 2025 00:41:58 -0600 Subject: [PATCH] feat: adopt ag grid in device list --- Data/Server/WebUI/package.json | 2 + Data/Server/WebUI/src/Devices/Device_List.jsx | 846 +++++++++--------- 2 files changed, 447 insertions(+), 401 deletions(-) diff --git a/Data/Server/WebUI/package.json b/Data/Server/WebUI/package.json index 4251e7d..b056568 100644 --- a/Data/Server/WebUI/package.json +++ b/Data/Server/WebUI/package.json @@ -14,6 +14,8 @@ "@mui/material": "7.0.2", "@mui/x-date-pickers": "8.11.3", "@mui/x-tree-view": "8.10.0", + "ag-grid-community": "^32.3.2", + "ag-grid-react": "^32.3.2", "dayjs": "1.11.18", "normalize.css": "8.0.1", "prismjs": "1.30.0", diff --git a/Data/Server/WebUI/src/Devices/Device_List.jsx b/Data/Server/WebUI/src/Devices/Device_List.jsx index c78512c..88a7d74 100644 --- a/Data/Server/WebUI/src/Devices/Device_List.jsx +++ b/Data/Server/WebUI/src/Devices/Device_List.jsx @@ -5,30 +5,43 @@ import { Paper, Box, Typography, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - TableSortLabel, - Checkbox, Button, IconButton, Menu, MenuItem, Popover, TextField, - Tooltip + Tooltip, + Checkbox, } from "@mui/material"; import MoreVertIcon from "@mui/icons-material/MoreVert"; -import FilterListIcon from "@mui/icons-material/FilterList"; import ViewColumnIcon from "@mui/icons-material/ViewColumn"; import AddIcon from "@mui/icons-material/Add"; import CachedIcon from "@mui/icons-material/Cached"; +import { AgGridReact } from "ag-grid-react"; +import { themeQuartz } from "ag-grid-community"; +import "ag-grid-community/styles/ag-grid.css"; +import "ag-grid-community/styles/ag-theme-quartz.css"; import { DeleteDeviceDialog, CreateCustomViewDialog, RenameCustomViewDialog } from "../Dialogs.jsx"; import QuickJob from "../Scheduling/Quick_Job.jsx"; import AddDevice from "./Add_Device.jsx"; +const myTheme = themeQuartz.withParams({ + accentColor: "#FFA6FF", + backgroundColor: "#1f2836", + browserColorScheme: "dark", + chromeBackgroundColor: { + ref: "foregroundColor", + mix: 0.07, + onto: "backgroundColor", + }, + fontFamily: { + googleFont: "IBM Plex Sans", + }, + foregroundColor: "#FFF", + headerFontSize: 14, +}); + function formatLastSeen(tsSec, offlineAfter = 300) { if (!tsSec) return "unknown"; const now = Date.now() / 1000; @@ -76,8 +89,6 @@ export default function DeviceList({ defaultAddType, }) { const [rows, setRows] = useState([]); - const [orderBy, setOrderBy] = useState("status"); - const [order, setOrder] = useState("desc"); const [menuAnchor, setMenuAnchor] = useState(null); const [selected, setSelected] = useState(null); const [confirmOpen, setConfirmOpen] = useState(false); @@ -171,12 +182,11 @@ export default function DeviceList({ [COL_LABELS] ); const [columns, setColumns] = useState(defaultColumns); - const dragColId = useRef(null); const [colChooserAnchor, setColChooserAnchor] = useState(null); + const gridRef = useRef(null); // Per-column filters const [filters, setFilters] = useState({}); - const [filterAnchor, setFilterAnchor] = useState(null); // { id, anchorEl } const [sites, setSites] = useState([]); // sites list for assignment const [assignDialogOpen, setAssignDialogOpen] = useState(false); @@ -518,171 +528,12 @@ export default function DeviceList({ } }, [COL_LABELS, defaultColumns]); - 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 "site": - return row.site || "Not Configured"; - case "hostname": - return row.hostname || ""; - case "description": - return row.description || ""; - case "lastUser": - return row.lastUser || ""; - case "type": - return row.type || ""; - case "os": - return row.os || ""; - case "agentVersion": - return row.agentVersion || ""; - case "internalIp": - return row.internalIp || ""; - case "externalIp": - return row.externalIp || ""; - case "lastReboot": - return row.lastReboot || ""; - case "created": - return formatCreated(row.created, row.createdTs); - case "lastSeen": - return formatLastSeen(row.lastSeen); - case "agentId": - return row.agentId || ""; - case "agentHash": - return row.agentHash || ""; - case "agentGuid": - return row.agentGuid || ""; - case "domain": - return row.domain || ""; - case "uptime": - return row.uptimeDisplay || (row.uptime ? String(row.uptime) : ""); - case "memory": - return row.memoryRaw || row.memory || ""; - case "network": - return row.networkRaw || row.network || ""; - case "software": - return row.softwareRaw || row.software || ""; - case "storage": - return row.storageRaw || row.storage || ""; - case "cpu": - return row.cpuRaw || row.cpu || ""; - case "siteDescription": - return row.siteDescription || ""; - default: - return ""; - } - }; - return rows.filter((r) => - activeFilters.every(([k, val]) => - toDisplay(k, r).toLowerCase().includes(String(val).toLowerCase()) - ) - ); - }, [rows, filters]); + const statusColor = useCallback( + (s) => (s === "Online" ? "#00d18c" : "#ff4f4f"), + [] + ); - const sorted = useMemo(() => { - const dir = order === "asc" ? 1 : -1; - return [...filtered].sort((a, b) => { - // Support numeric sort for created/lastSeen/uptime - if (orderBy === "lastSeen") return ((a.lastSeen || 0) - (b.lastSeen || 0)) * dir; - if (orderBy === "created") return ((a.createdTs || 0) - (b.createdTs || 0)) * dir; - if (orderBy === "uptime") return ((a.uptime || 0) - (b.uptime || 0)) * dir; - const A = a[orderBy]; - const B = b[orderBy]; - return String(A || "").localeCompare(String(B || "")) * dir; - }); - }, [filtered, orderBy, order]); - - const handleSort = (col) => { - if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc"); - else { - setOrderBy(col); - setOrder("asc"); - } - }; - - const statusColor = (s) => (s === "Online" ? "#00d18c" : "#ff4f4f"); - - const openMenu = (e, row) => { - setMenuAnchor(e.currentTarget); - setSelected(row); - }; - - const closeMenu = () => setMenuAnchor(null); - - const confirmDelete = () => { - closeMenu(); - setConfirmOpen(true); - }; - - const handleDelete = async () => { - if (!selected) return; - const targetAgentId = selected.agentId || selected.summary?.agent_id || selected.id; - try { - if (targetAgentId) { - await fetch(`/api/agent/${encodeURIComponent(targetAgentId)}`, { method: "DELETE" }); - } - } catch (e) { - console.warn("Failed to remove agent", e); - } - setRows((r) => r.filter((x) => x.id !== selected.id)); - setConfirmOpen(false); - setSelected(null); - }; - - 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; - setSelectedIds((prev) => { - const next = new Set(prev); - if (checked) sorted.forEach((r) => next.add(r.id)); - else next.clear(); - return next; - }); - }; - - const toggleOne = (id) => (e) => { - const checked = e.target.checked; - setSelectedIds((prev) => { - const next = new Set(prev); - 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) => { + const formatCreated = useCallback((created, createdTs) => { if (createdTs) { const d = new Date(createdTs * 1000); const mm = String(d.getMonth() + 1).padStart(2, "0"); @@ -694,7 +545,393 @@ export default function DeviceList({ return `${mm}/${dd}/${yyyy} @ ${hh}:${min} ${ampm}`; } return created || ""; - }; + }, []); + + const filterModel = useMemo(() => { + const model = {}; + Object.entries(filters).forEach(([key, value]) => { + const trimmed = (value || "").trim(); + if (trimmed) { + model[key] = { + filterType: "text", + type: "contains", + filter: trimmed, + }; + } + }); + return model; + }, [filters]); + + useEffect(() => { + if (gridRef.current?.api) { + gridRef.current.api.setFilterModel(filterModel); + } + }, [filterModel]); + + const handleFilterChanged = useCallback((event) => { + const model = event.api.getFilterModel() || {}; + setFilters((prev) => { + const next = {}; + Object.entries(model).forEach(([key, cfg]) => { + const filterValue = + cfg && typeof cfg.filter === "string" ? cfg.filter : ""; + if (filterValue) { + next[key] = filterValue; + } + }); + const prevEntries = Object.entries(prev); + const nextEntries = Object.entries(next); + if ( + prevEntries.length === nextEntries.length && + prevEntries.every(([k, v]) => next[k] === v) + ) { + return prev; + } + return next; + }); + }, []); + + const handleSelectionChanged = useCallback(() => { + const api = gridRef.current?.api; + if (!api) return; + const selectedNodes = api.getSelectedNodes(); + const ids = selectedNodes + .map((node) => node.data?.id) + .filter((id) => id !== undefined && id !== null); + setSelectedIds(new Set(ids)); + }, []); + + const openMenu = useCallback((event, row) => { + setMenuAnchor(event.currentTarget); + setSelected(row); + }, []); + + const closeMenu = useCallback(() => setMenuAnchor(null), []); + + const confirmDelete = useCallback(() => { + closeMenu(); + setConfirmOpen(true); + }, [closeMenu]); + + const handleDelete = useCallback(async () => { + if (!selected) return; + const targetAgentId = selected.agentId || selected.summary?.agent_id || selected.id; + try { + if (targetAgentId) { + await fetch(`/api/agent/${encodeURIComponent(targetAgentId)}`, { method: "DELETE" }); + } + } catch (e) { + console.warn("Failed to remove agent", e); + } + setRows((r) => r.filter((x) => x.id !== selected.id)); + setSelectedIds((prev) => { + if (!prev.has(selected.id)) return prev; + const next = new Set(prev); + next.delete(selected.id); + return next; + }); + setConfirmOpen(false); + setSelected(null); + }, [selected]); + + const hostnameCellRenderer = useCallback( + (params) => { + const row = params.data; + if (!row) return null; + const handleClick = (event) => { + event.preventDefault(); + event.stopPropagation(); + if (onSelectDevice) onSelectDevice(row); + }; + const label = row.connectionLabel || ""; + let badgeBg = "#2d3042"; + let badgeColor = "#a4c7ff"; + if (label === "SSH") { + badgeBg = "#2a3b28"; + badgeColor = "#7cffc4"; + } else if (label === "WinRM") { + badgeBg = "#352e3b"; + badgeColor = "#ffb6ff"; + } + return ( + + {label ? ( + + {label} + + ) : null} + + {row.hostname || ""} + + + ); + }, + [onSelectDevice] + ); + + const statusCellRenderer = useCallback( + (params) => { + const status = params.value || ""; + if (!status) return null; + return ( + + {status} + + ); + }, + [statusColor] + ); + + const actionCellRenderer = useCallback( + (params) => { + const row = params.data; + if (!row) return null; + const handleClick = (event) => { + event.stopPropagation(); + openMenu(event, row); + }; + return ( + + + + ); + }, + [openMenu] + ); + + const columnDefs = useMemo(() => { + const defs = columns.map((col) => { + switch (col.id) { + case "status": + return { + field: "status", + headerName: col.label, + cellRenderer: statusCellRenderer, + width: 140, + minWidth: 140, + flex: 0, + }; + case "agentVersion": + return { + field: "agentVersion", + headerName: col.label, + minWidth: 160, + }; + case "site": + return { + field: "site", + headerName: col.label, + valueGetter: (params) => params.data?.site || "Not Configured", + minWidth: 180, + }; + case "hostname": + return { + field: "hostname", + headerName: col.label, + cellRenderer: hostnameCellRenderer, + minWidth: 220, + }; + case "description": + return { + field: "description", + headerName: col.label, + minWidth: 220, + }; + case "lastUser": + return { + field: "lastUser", + headerName: col.label, + minWidth: 160, + }; + case "type": + return { + field: "type", + headerName: col.label, + minWidth: 140, + }; + case "os": + return { + field: "os", + headerName: col.label, + minWidth: 200, + }; + case "internalIp": + return { + field: "internalIp", + headerName: col.label, + minWidth: 160, + }; + case "externalIp": + return { + field: "externalIp", + headerName: col.label, + minWidth: 160, + }; + case "lastReboot": + return { + field: "lastReboot", + headerName: col.label, + minWidth: 200, + }; + case "created": + return { + field: "created", + headerName: col.label, + valueGetter: (params) => + formatCreated(params.data?.created, params.data?.createdTs), + comparator: (a, b, nodeA, nodeB) => + (nodeA?.data?.createdTs || 0) - (nodeB?.data?.createdTs || 0), + minWidth: 220, + }; + case "lastSeen": + return { + field: "lastSeen", + headerName: col.label, + valueGetter: (params) => formatLastSeen(params.data?.lastSeen), + comparator: (a, b, nodeA, nodeB) => + (nodeA?.data?.lastSeen || 0) - (nodeB?.data?.lastSeen || 0), + minWidth: 220, + }; + case "agentId": + return { + field: "agentId", + headerName: col.label, + minWidth: 200, + }; + case "agentHash": + return { + field: "agentHash", + headerName: col.label, + minWidth: 220, + }; + case "agentGuid": + return { + field: "agentGuid", + headerName: col.label, + minWidth: 240, + }; + case "domain": + return { + field: "domain", + headerName: col.label, + minWidth: 180, + }; + case "uptime": + return { + field: "uptime", + headerName: col.label, + valueGetter: (params) => + params.data?.uptimeDisplay || + formatUptime(params.data?.uptime || 0), + comparator: (a, b, nodeA, nodeB) => + (nodeA?.data?.uptime || 0) - (nodeB?.data?.uptime || 0), + minWidth: 160, + }; + case "memory": + case "network": + case "software": + case "storage": + case "cpu": + case "siteDescription": + return { + field: col.id, + headerName: col.label, + minWidth: 200, + }; + default: + return { + field: col.id, + headerName: col.label, + }; + } + }); + return [ + { + headerName: "", + field: "__select__", + width: 52, + maxWidth: 52, + checkboxSelection: true, + headerCheckboxSelection: true, + resizable: false, + sortable: false, + suppressMenu: true, + filter: false, + pinned: "left", + lockPosition: true, + }, + ...defs, + { + headerName: "", + field: "__actions__", + width: 64, + maxWidth: 64, + resizable: false, + sortable: false, + suppressMenu: true, + filter: false, + cellRenderer: actionCellRenderer, + pinned: "right", + }, + ]; + }, [columns, actionCellRenderer, formatCreated, hostnameCellRenderer, statusCellRenderer]); + + const defaultColDef = useMemo( + () => ({ + sortable: true, + filter: "agTextColumnFilter", + resizable: true, + flex: 1, + minWidth: 160, + }), + [] + ); + + const handleGridReady = useCallback( + (params) => { + params.api.setFilterModel(filterModel); + }, + [filterModel] + ); + + const getRowId = useCallback( + (params) => + params.data?.id || + params.data?.agentGuid || + params.data?.hostname || + String(params.rowIndex ?? ""), + [] + ); return ( @@ -835,189 +1072,36 @@ export default function DeviceList({ - - - - - - - {columns.map((col) => ( - - - handleSort(col.id)} - > - {col.label} - - - - - - - ))} - - - - - {sorted.map((r, i) => ( - - e.stopPropagation()}> - - - {columns.map((col) => { - switch (col.id) { - case "status": - return ( - - - {r.status} - - - ); - case "agentVersion": - return {r.agentVersion || ""}; - case "site": - return {r.site || "Not Configured"}; - case "hostname": - return ( - onSelectDevice && onSelectDevice(r)} - sx={{ - color: "#58a6ff", - "&:hover": { - cursor: onSelectDevice ? "pointer" : "default", - textDecoration: onSelectDevice ? "underline" : "none", - }, - }} - > - - {r.isRemote && ( - - SSH - - )} - {r.hostname} - - - ); - case "description": - return {r.description || ""}; - case "lastUser": - return {r.lastUser || ""}; - case "type": - return {r.type || ""}; - case "os": - return {r.os}; - case "internalIp": - return {r.internalIp || ""}; - case "externalIp": - return {r.externalIp || ""}; - case "lastReboot": - return {r.lastReboot || ""}; - case "created": - return ( - {formatCreated(r.created, r.createdTs)} - ); - case "lastSeen": - return ( - {formatLastSeen(r.lastSeen)} - ); - case "agentId": - return {r.agentId || ""}; - case "agentHash": - return {r.agentHash || ""}; - case "agentGuid": - return {r.agentGuid || ""}; - case "domain": - return {r.domain || ""}; - case "uptime": - return {r.uptimeDisplay || ''}; - case "memory": - return {r.memory || ""}; - case "network": - return {r.network || ""}; - case "software": - return {r.software || ""}; - case "storage": - return {r.storage || ""}; - case "cpu": - return {r.cpu || ""}; - case "siteDescription": - return {r.siteDescription || ""}; - default: - return {String(r[col.id] || "")}; - } - })} - - { - e.stopPropagation(); - openMenu(e, r); - }} - sx={{ color: "#ccc" }} - > - - - - - ))} - {sorted.length === 0 && ( - - - No agents connected. - - - )} - -
+ + + + + {/* View actions menu (rename/delete for custom views) */} - {/* 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" }, - }, - }} - /> - - - )} -