From 278f00e3960f2c2726290d3d9e4faccbcb9d8c19 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Thu, 16 Oct 2025 00:41:58 -0600 Subject: [PATCH 1/7] 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" }, - }, - }} - /> - - - )} - Date: Thu, 16 Oct 2025 00:56:15 -0600 Subject: [PATCH 2/7] Style device grid with Quartz theme --- Data/Server/WebUI/src/Devices/Device_List.jsx | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/Data/Server/WebUI/src/Devices/Device_List.jsx b/Data/Server/WebUI/src/Devices/Device_List.jsx index 88a7d74..9082a24 100644 --- a/Data/Server/WebUI/src/Devices/Device_List.jsx +++ b/Data/Server/WebUI/src/Devices/Device_List.jsx @@ -42,6 +42,8 @@ const myTheme = themeQuartz.withParams({ headerFontSize: 14, }); +const themeClassName = myTheme.themeName || "ag-theme-quartz"; + function formatLastSeen(tsSec, offlineAfter = 300) { if (!tsSec) return "unknown"; const now = Date.now() / 1000; @@ -196,6 +198,11 @@ export default function DeviceList({ const [repoHash, setRepoHash] = useState(null); const lastRepoFetchRef = useRef(0); + const gridWrapperClass = useMemo( + () => `${themeClassName} borealis-device-grid`, + [themeClassName] + ); + const fetchLatestRepoHash = useCallback(async (options = {}) => { const { force = false } = options || {}; const now = Date.now(); @@ -1074,13 +1081,38 @@ export default function DeviceList({ From f0baf4cdda5223a835ee6b3b7a116f2fef3f35f1 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Thu, 16 Oct 2025 01:02:40 -0600 Subject: [PATCH 3/7] Remove custom overrides from device grid theme --- Data/Server/WebUI/src/Devices/Device_List.jsx | 31 ++----------------- 1 file changed, 2 insertions(+), 29 deletions(-) diff --git a/Data/Server/WebUI/src/Devices/Device_List.jsx b/Data/Server/WebUI/src/Devices/Device_List.jsx index 9082a24..9728c88 100644 --- a/Data/Server/WebUI/src/Devices/Device_List.jsx +++ b/Data/Server/WebUI/src/Devices/Device_List.jsx @@ -198,10 +198,7 @@ export default function DeviceList({ const [repoHash, setRepoHash] = useState(null); const lastRepoFetchRef = useRef(0); - const gridWrapperClass = useMemo( - () => `${themeClassName} borealis-device-grid`, - [themeClassName] - ); + const gridWrapperClass = themeClassName; const fetchLatestRepoHash = useCallback(async (options = {}) => { const { force = false } = options || {}; @@ -1086,33 +1083,8 @@ export default function DeviceList({ width: "100%", height: 600, minHeight: 400, - bgcolor: "#111822", - borderRadius: 1, - "& .ag-root-wrapper, & .ag-header, & .ag-cell": { - fontFamily: '"IBM Plex Sans", sans-serif', - }, "& .ag-root-wrapper": { borderRadius: 1, - border: "1px solid rgba(255, 255, 255, 0.08)", - boxShadow: "0 0 0 1px rgba(0, 0, 0, 0.35)", - }, - "& .ag-header": { - borderBottom: "1px solid rgba(255, 255, 255, 0.1)", - }, - "& .ag-row": { - borderColor: "rgba(255, 255, 255, 0.04)", - }, - "& .ag-row-hover": { - backgroundColor: "rgba(88, 166, 255, 0.08) !important", - }, - "& .ag-row-selected": { - backgroundColor: "rgba(255, 166, 255, 0.12) !important", - }, - "& .ag-header-cell-text": { - fontWeight: 600, - }, - "& .ag-paging-panel": { - borderTop: "1px solid rgba(255, 255, 255, 0.08)", }, }} > @@ -1131,6 +1103,7 @@ export default function DeviceList({ onGridReady={handleGridReady} getRowId={getRowId} theme={myTheme} + style={{ width: "100%", height: "100%" }} /> From 07c3de1bf25d0dc5102dffbbbf332cfe22bfafea Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Thu, 16 Oct 2025 01:12:49 -0600 Subject: [PATCH 4/7] Upgrade AG Grid packages and fix duplicate InputProps --- Data/Server/WebUI/package.json | 4 ++-- .../Flow_Editor/Node_Configuration_Sidebar.jsx | 17 +++++++---------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/Data/Server/WebUI/package.json b/Data/Server/WebUI/package.json index b056568..701a86b 100644 --- a/Data/Server/WebUI/package.json +++ b/Data/Server/WebUI/package.json @@ -14,8 +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", + "ag-grid-community": "34.2.0", + "ag-grid-react": "34.2.0", "dayjs": "1.11.18", "normalize.css": "8.0.1", "prismjs": "1.30.0", diff --git a/Data/Server/WebUI/src/Flow_Editor/Node_Configuration_Sidebar.jsx b/Data/Server/WebUI/src/Flow_Editor/Node_Configuration_Sidebar.jsx index a2a1c65..bf8ea64 100644 --- a/Data/Server/WebUI/src/Flow_Editor/Node_Configuration_Sidebar.jsx +++ b/Data/Server/WebUI/src/Flow_Editor/Node_Configuration_Sidebar.jsx @@ -191,7 +191,13 @@ export default function NodeConfigurationSidebar({ drawerOpen, setDrawerOpen, ti disabled={isReadOnly} InputProps={{ readOnly: isReadOnly, - sx: { color: "#ccc" } + sx: { + color: "#ccc", + backgroundColor: "#1e1e1e", + "& fieldset": { borderColor: "#444" }, + "&:hover fieldset": { borderColor: "#666" }, + "&.Mui-focused fieldset": { borderColor: "#58a6ff" } + } }} onChange={(e) => { if (isReadOnly) return; @@ -206,15 +212,6 @@ export default function NodeConfigurationSidebar({ drawerOpen, setDrawerOpen, ti ); window.BorealisValueBus[nodeId] = newValue; }} - InputProps={{ - sx: { - backgroundColor: "#1e1e1e", - color: "#ccc", - "& fieldset": { borderColor: "#444" }, - "&:hover fieldset": { borderColor: "#666" }, - "&.Mui-focused fieldset": { borderColor: "#58a6ff" } - } - }} /> ); From 31d16fa41748af8e1ca9d43dfbf61bb243d36104 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Thu, 16 Oct 2025 01:16:46 -0600 Subject: [PATCH 5/7] Register AG Grid community module for device list --- Data/Server/WebUI/src/Devices/Device_List.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Data/Server/WebUI/src/Devices/Device_List.jsx b/Data/Server/WebUI/src/Devices/Device_List.jsx index 9728c88..5911357 100644 --- a/Data/Server/WebUI/src/Devices/Device_List.jsx +++ b/Data/Server/WebUI/src/Devices/Device_List.jsx @@ -19,13 +19,15 @@ 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 { ModuleRegistry, AllCommunityModule, 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"; +ModuleRegistry.registerModules([AllCommunityModule]); + const myTheme = themeQuartz.withParams({ accentColor: "#FFA6FF", backgroundColor: "#1f2836", From 1e016e9584327937bb2e00f6acef5898eb530ae6 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Thu, 16 Oct 2025 01:20:59 -0600 Subject: [PATCH 6/7] Remove legacy AG Grid CSS imports --- Data/Server/WebUI/src/Devices/Device_List.jsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/Data/Server/WebUI/src/Devices/Device_List.jsx b/Data/Server/WebUI/src/Devices/Device_List.jsx index 5911357..fadcb53 100644 --- a/Data/Server/WebUI/src/Devices/Device_List.jsx +++ b/Data/Server/WebUI/src/Devices/Device_List.jsx @@ -20,8 +20,6 @@ import AddIcon from "@mui/icons-material/Add"; import CachedIcon from "@mui/icons-material/Cached"; import { AgGridReact } from "ag-grid-react"; import { ModuleRegistry, AllCommunityModule, 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"; From 42eb3b6f8c43c14a7aeed9ec9c7b00a6fb384be0 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Thu, 16 Oct 2025 01:43:38 -0600 Subject: [PATCH 7/7] Improve device grid theming and filters --- Data/Server/WebUI/package.json | 1 + Data/Server/WebUI/src/Borealis.css | 6 + Data/Server/WebUI/src/Devices/Device_List.jsx | 206 +++++++++++++----- Data/Server/WebUI/src/index.jsx | 3 + 4 files changed, 165 insertions(+), 51 deletions(-) diff --git a/Data/Server/WebUI/package.json b/Data/Server/WebUI/package.json index 701a86b..ad3d25b 100644 --- a/Data/Server/WebUI/package.json +++ b/Data/Server/WebUI/package.json @@ -10,6 +10,7 @@ "dependencies": { "@emotion/react": "11.14.0", "@emotion/styled": "11.14.0", + "@fontsource/ibm-plex-sans": "5.0.17", "@mui/icons-material": "7.0.2", "@mui/material": "7.0.2", "@mui/x-date-pickers": "8.11.3", diff --git a/Data/Server/WebUI/src/Borealis.css b/Data/Server/WebUI/src/Borealis.css index 57ed151..f2881d9 100644 --- a/Data/Server/WebUI/src/Borealis.css +++ b/Data/Server/WebUI/src/Borealis.css @@ -1,5 +1,11 @@ /* ///////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: /Data/WebUI/src/Borealis.css +body { + font-family: "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif; + background-color: #0b0f19; + color: #f5f7fa; +} + /* ======================================= */ /* FLOW EDITOR */ /* ======================================= */ diff --git a/Data/Server/WebUI/src/Devices/Device_List.jsx b/Data/Server/WebUI/src/Devices/Device_List.jsx index fadcb53..6325035 100644 --- a/Data/Server/WebUI/src/Devices/Device_List.jsx +++ b/Data/Server/WebUI/src/Devices/Device_List.jsx @@ -43,6 +43,8 @@ const myTheme = themeQuartz.withParams({ }); const themeClassName = myTheme.themeName || "ag-theme-quartz"; +const gridFontFamily = '"IBM Plex Sans", "Helvetica Neue", Arial, sans-serif'; +const iconFontFamily = '"Quartz Regular"'; function formatLastSeen(tsSec, offlineAfter = 300) { if (!tsSec) return "unknown"; @@ -188,7 +190,103 @@ export default function DeviceList({ const gridRef = useRef(null); // Per-column filters - const [filters, setFilters] = useState({}); + const [filtersState, setFiltersState] = useState({}); + + const sanitizeFilterModel = useCallback((raw) => { + if (!raw || typeof raw !== "object") return {}; + const sanitized = {}; + Object.entries(raw).forEach(([key, value]) => { + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed) { + sanitized[key] = { + filterType: "text", + type: "contains", + filter: trimmed, + }; + } + return; + } + if (!value || typeof value !== "object") return; + const clone = JSON.parse(JSON.stringify(value)); + if (!clone.filterType) clone.filterType = "text"; + if (clone.filterType === "text") { + if (typeof clone.filter === "string") { + clone.filter = clone.filter.trim(); + } + if (Array.isArray(clone.conditions)) { + clone.conditions = clone.conditions + .map((condition) => { + if (!condition || typeof condition !== "object") return null; + const condClone = { ...condition }; + if (typeof condClone.filter === "string") { + condClone.filter = condClone.filter.trim(); + } + if ( + !condClone.filter && + !["blank", "notBlank"].includes(condClone.type ?? "") + ) { + return null; + } + return condClone; + }) + .filter(Boolean); + if (!clone.conditions.length) { + delete clone.conditions; + } + } + if ( + !clone.filter && + !clone.conditions && + !["blank", "notBlank"].includes(clone.type ?? "") + ) { + return; + } + } + sanitized[key] = clone; + }); + return sanitized; + }, []); + + const filterModelsEqual = useCallback( + (a, b) => JSON.stringify(a ?? {}) === JSON.stringify(b ?? {}), + [] + ); + + const replaceFilters = useCallback( + (raw) => { + const sanitized = + raw && typeof raw === "object" ? sanitizeFilterModel(raw) : {}; + setFiltersState((prev) => + filterModelsEqual(prev, sanitized) ? prev : sanitized + ); + }, + [filterModelsEqual, sanitizeFilterModel] + ); + + const mergeFilters = useCallback( + (raw) => { + if (!raw || typeof raw !== "object") return; + const sanitized = sanitizeFilterModel(raw); + if (!Object.keys(sanitized).length) return; + setFiltersState((prev) => { + const base = prev || {}; + const next = { ...base }; + let changed = false; + Object.entries(sanitized).forEach(([key, value]) => { + if (!value) return; + if (!next[key] || !filterModelsEqual(next[key], value)) { + next[key] = value; + changed = true; + } + }); + return changed ? next : base; + }); + }, + [filterModelsEqual, sanitizeFilterModel] + ); + + const filters = filtersState; const [sites, setSites] = useState([]); // sites list for assignment const [assignDialogOpen, setAssignDialogOpen] = useState(false); @@ -472,7 +570,7 @@ export default function DeviceList({ if (json) { const obj = JSON.parse(json); if (obj && typeof obj === 'object') { - setFilters((prev) => ({ ...prev, ...obj })); + mergeFilters(obj); // Optionally ensure Site column exists when site filter is present if (obj.site) { setColumns((prev) => { @@ -505,16 +603,16 @@ export default function DeviceList({ next.splice(insertAt, 0, { id: 'site', label: COL_LABELS.site }); return next; }); - setFilters((f) => ({ ...f, site })); + mergeFilters({ site }); localStorage.removeItem('device_list_initial_site_filter'); } } catch {} - }, [COL_LABELS.site]); + }, [COL_LABELS.site, mergeFilters]); const applyView = useCallback((view) => { if (!view || view.id === "default") { setColumns(defaultColumns); - setFilters({}); + replaceFilters({}); return; } try { @@ -525,12 +623,14 @@ export default function DeviceList({ .filter((id) => COL_LABELS[id]) .map((id) => ({ id, label: COL_LABELS[id] })); setColumns(mapped.length ? mapped : defaultColumns); - setFilters(view.filters && typeof view.filters === "object" ? view.filters : {}); + replaceFilters( + view.filters && typeof view.filters === "object" ? view.filters : {} + ); } catch { setColumns(defaultColumns); - setFilters({}); + replaceFilters({}); } - }, [COL_LABELS, defaultColumns]); + }, [COL_LABELS, defaultColumns, replaceFilters]); const statusColor = useCallback( (s) => (s === "Online" ? "#00d18c" : "#ff4f4f"), @@ -551,20 +651,10 @@ export default function DeviceList({ 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]); + const filterModel = useMemo( + () => JSON.parse(JSON.stringify(filters || {})), + [filters] + ); useEffect(() => { if (gridRef.current?.api) { @@ -572,28 +662,13 @@ export default function DeviceList({ } }, [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 handleFilterChanged = useCallback( + (event) => { + const model = event.api.getFilterModel() || {}; + replaceFilters(model); + }, + [replaceFilters] + ); const handleSelectionChanged = useCallback(() => { const api = gridRef.current?.api; @@ -699,21 +774,27 @@ export default function DeviceList({ {status} ); }, - [statusColor] + [statusColor, gridFontFamily] ); const actionCellRenderer = useCallback( @@ -938,7 +1019,16 @@ export default function DeviceList({ ); return ( - + {/* Header area with title on left and controls on right */} @@ -1083,9 +1173,18 @@ export default function DeviceList({ width: "100%", height: 600, minHeight: 400, + fontFamily: gridFontFamily, + "--ag-font-family": gridFontFamily, + "--ag-icon-font-family": iconFontFamily, "& .ag-root-wrapper": { borderRadius: 1, }, + "& .ag-root, & .ag-header, & .ag-center-cols-container, & .ag-paging-panel": { + fontFamily: gridFontFamily, + }, + "& .ag-icon": { + fontFamily: iconFontFamily, + }, }} > diff --git a/Data/Server/WebUI/src/index.jsx b/Data/Server/WebUI/src/index.jsx index fa54af8..157e619 100644 --- a/Data/Server/WebUI/src/index.jsx +++ b/Data/Server/WebUI/src/index.jsx @@ -5,6 +5,9 @@ import ReactDOM from 'react-dom/client'; // Global Styles import "normalize.css/normalize.css"; +import "@fontsource/ibm-plex-sans/400.css"; +import "@fontsource/ibm-plex-sans/500.css"; +import "@fontsource/ibm-plex-sans/600.css"; import './Borealis.css'; // Global Theming for All of Borealis import App from './App.jsx';