mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 17:41:58 -06:00
feat: adopt ag grid in device list
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<Box component="span" sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
{label ? (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
px: 0.75,
|
||||
py: 0.1,
|
||||
borderRadius: 999,
|
||||
bgcolor: badgeBg,
|
||||
color: badgeColor,
|
||||
fontSize: "11px",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Box>
|
||||
) : null}
|
||||
<a
|
||||
href="#"
|
||||
onClick={handleClick}
|
||||
style={{ color: "#58a6ff", textDecoration: "none", fontWeight: 500 }}
|
||||
>
|
||||
{row.hostname || ""}
|
||||
</a>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
[onSelectDevice]
|
||||
);
|
||||
|
||||
const statusCellRenderer = useCallback(
|
||||
(params) => {
|
||||
const status = params.value || "";
|
||||
if (!status) return null;
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: "inline-block",
|
||||
px: 1.2,
|
||||
py: 0.25,
|
||||
borderRadius: 999,
|
||||
bgcolor: statusColor(status),
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
[statusColor]
|
||||
);
|
||||
|
||||
const actionCellRenderer = useCallback(
|
||||
(params) => {
|
||||
const row = params.data;
|
||||
if (!row) return null;
|
||||
const handleClick = (event) => {
|
||||
event.stopPropagation();
|
||||
openMenu(event, row);
|
||||
};
|
||||
return (
|
||||
<IconButton size="small" onClick={handleClick} sx={{ color: "#ccc" }}>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</IconButton>
|
||||
);
|
||||
},
|
||||
[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 (
|
||||
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
|
||||
@@ -835,189 +1072,36 @@ export default function DeviceList({
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<Table size="small" sx={{ minWidth: 820 }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
indeterminate={isIndeterminate}
|
||||
checked={isAllChecked}
|
||||
onChange={toggleAll}
|
||||
sx={{ color: "#777" }}
|
||||
/>
|
||||
</TableCell>
|
||||
{columns.map((col) => (
|
||||
<TableCell
|
||||
key={col.id}
|
||||
sortDirection={orderBy === col.id ? order : false}
|
||||
draggable
|
||||
onDragStart={onHeaderDragStart(col.id)}
|
||||
onDragOver={onHeaderDragOver}
|
||||
onDrop={onHeaderDrop(col.id)}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<TableSortLabel
|
||||
active={orderBy === col.id}
|
||||
direction={orderBy === col.id ? order : "asc"}
|
||||
onClick={() => handleSort(col.id)}
|
||||
>
|
||||
{col.label}
|
||||
</TableSortLabel>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={openFilter(col.id)}
|
||||
sx={{ color: filters[col.id] ? "#58a6ff" : "#888" }}
|
||||
>
|
||||
<FilterListIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{sorted.map((r, i) => (
|
||||
<TableRow key={r.id || i} hover>
|
||||
<TableCell padding="checkbox" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(r.id)}
|
||||
onChange={toggleOne(r.id)}
|
||||
sx={{ color: "#777" }}
|
||||
/>
|
||||
</TableCell>
|
||||
{columns.map((col) => {
|
||||
switch (col.id) {
|
||||
case "status":
|
||||
return (
|
||||
<TableCell key={col.id}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "inline-block",
|
||||
px: 1.2,
|
||||
py: 0.25,
|
||||
borderRadius: 999,
|
||||
bgcolor: statusColor(r.status),
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
{r.status}
|
||||
</Box>
|
||||
</TableCell>
|
||||
);
|
||||
case "agentVersion":
|
||||
return <TableCell key={col.id}>{r.agentVersion || ""}</TableCell>;
|
||||
case "site":
|
||||
return <TableCell key={col.id}>{r.site || "Not Configured"}</TableCell>;
|
||||
case "hostname":
|
||||
return (
|
||||
<TableCell
|
||||
key={col.id}
|
||||
onClick={() => onSelectDevice && onSelectDevice(r)}
|
||||
sx={{
|
||||
color: "#58a6ff",
|
||||
"&:hover": {
|
||||
cursor: onSelectDevice ? "pointer" : "default",
|
||||
textDecoration: onSelectDevice ? "underline" : "none",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
{r.isRemote && (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
px: 0.75,
|
||||
py: 0.1,
|
||||
borderRadius: 999,
|
||||
bgcolor: "#2a3b28",
|
||||
color: "#7cffc4",
|
||||
fontSize: "11px",
|
||||
fontWeight: 600
|
||||
}}
|
||||
>
|
||||
SSH
|
||||
</Box>
|
||||
)}
|
||||
<span>{r.hostname}</span>
|
||||
</Box>
|
||||
</TableCell>
|
||||
);
|
||||
case "description":
|
||||
return <TableCell key={col.id}>{r.description || ""}</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 "internalIp":
|
||||
return <TableCell key={col.id}>{r.internalIp || ""}</TableCell>;
|
||||
case "externalIp":
|
||||
return <TableCell key={col.id}>{r.externalIp || ""}</TableCell>;
|
||||
case "lastReboot":
|
||||
return <TableCell key={col.id}>{r.lastReboot || ""}</TableCell>;
|
||||
case "created":
|
||||
return (
|
||||
<TableCell key={col.id}>{formatCreated(r.created, r.createdTs)}</TableCell>
|
||||
);
|
||||
case "lastSeen":
|
||||
return (
|
||||
<TableCell key={col.id}>{formatLastSeen(r.lastSeen)}</TableCell>
|
||||
);
|
||||
case "agentId":
|
||||
return <TableCell key={col.id}>{r.agentId || ""}</TableCell>;
|
||||
case "agentHash":
|
||||
return <TableCell key={col.id}>{r.agentHash || ""}</TableCell>;
|
||||
case "agentGuid":
|
||||
return <TableCell key={col.id}>{r.agentGuid || ""}</TableCell>;
|
||||
case "domain":
|
||||
return <TableCell key={col.id}>{r.domain || ""}</TableCell>;
|
||||
case "uptime":
|
||||
return <TableCell key={col.id}>{r.uptimeDisplay || ''}</TableCell>;
|
||||
case "memory":
|
||||
return <TableCell key={col.id}>{r.memory || ""}</TableCell>;
|
||||
case "network":
|
||||
return <TableCell key={col.id}>{r.network || ""}</TableCell>;
|
||||
case "software":
|
||||
return <TableCell key={col.id}>{r.software || ""}</TableCell>;
|
||||
case "storage":
|
||||
return <TableCell key={col.id}>{r.storage || ""}</TableCell>;
|
||||
case "cpu":
|
||||
return <TableCell key={col.id}>{r.cpu || ""}</TableCell>;
|
||||
case "siteDescription":
|
||||
return <TableCell key={col.id}>{r.siteDescription || ""}</TableCell>;
|
||||
default:
|
||||
return <TableCell key={col.id}>{String(r[col.id] || "")}</TableCell>;
|
||||
}
|
||||
})}
|
||||
<TableCell align="right">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openMenu(e, r);
|
||||
}}
|
||||
sx={{ color: "#ccc" }}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{sorted.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length + 2} sx={{ color: "#888" }}>
|
||||
No agents connected.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Box sx={{ px: 2, pb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: 600,
|
||||
minHeight: 400,
|
||||
"& .ag-root-wrapper": {
|
||||
border: "1px solid #2c3645",
|
||||
borderRadius: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AgGridReact
|
||||
ref={gridRef}
|
||||
rowData={rows}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColDef}
|
||||
rowSelection="multiple"
|
||||
rowMultiSelectWithClick
|
||||
pagination
|
||||
paginationPageSize={25}
|
||||
animateRows
|
||||
onSelectionChanged={handleSelectionChanged}
|
||||
onFilterChanged={handleFilterChanged}
|
||||
onGridReady={handleGridReady}
|
||||
getRowId={getRowId}
|
||||
theme={myTheme}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
{/* View actions menu (rename/delete for custom views) */}
|
||||
<Menu
|
||||
anchorEl={viewActionAnchor}
|
||||
@@ -1149,46 +1233,6 @@ export default function DeviceList({
|
||||
</Box>
|
||||
</Box>
|
||||
</Popover>
|
||||
{/* 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
|
||||
anchorEl={menuAnchor}
|
||||
open={Boolean(menuAnchor)}
|
||||
|
||||
Reference in New Issue
Block a user