mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-09-10 23:08:43 -06:00
Added additional columns to Device list and fixed checkboxes.
This commit is contained in:
@@ -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]) => {
|
||||||
id,
|
const hostname = a.hostname || id || "unknown";
|
||||||
hostname: a.hostname || id || "unknown",
|
const details = detailsByHost[hostname] || {};
|
||||||
status: statusFromHeartbeat(a.last_seen),
|
return {
|
||||||
lastSeen: a.last_seen || 0,
|
id,
|
||||||
os: a.agent_operating_system || a.os || "-"
|
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);
|
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>
|
<TableSortLabel
|
||||||
</TableCell>
|
active={orderBy === col.id}
|
||||||
<TableCell sortDirection={orderBy === "hostname" ? order : false}>
|
direction={orderBy === col.id ? order : "asc"}
|
||||||
<TableSortLabel
|
onClick={() => handleSort(col.id)}
|
||||||
active={orderBy === "hostname"}
|
>
|
||||||
direction={orderBy === "hostname" ? order : "asc"}
|
{col.label}
|
||||||
onClick={() => handleSort("hostname")}
|
</TableSortLabel>
|
||||||
>
|
<IconButton
|
||||||
Device Hostname
|
size="small"
|
||||||
</TableSortLabel>
|
onClick={openFilter(col.id)}
|
||||||
</TableCell>
|
sx={{ color: filters[col.id] ? "#58a6ff" : "#888" }}
|
||||||
<TableCell sortDirection={orderBy === "lastSeen" ? order : false}>
|
>
|
||||||
<TableSortLabel
|
<FilterListIcon fontSize="inherit" />
|
||||||
active={orderBy === "lastSeen"}
|
</IconButton>
|
||||||
direction={orderBy === "lastSeen" ? order : "asc"}
|
</Box>
|
||||||
onClick={() => handleSort("lastSeen")}
|
</TableCell>
|
||||||
>
|
))}
|
||||||
Last Seen
|
|
||||||
</TableSortLabel>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell sortDirection={orderBy === "os" ? order : false}>
|
|
||||||
<TableSortLabel
|
|
||||||
active={orderBy === "os"}
|
|
||||||
direction={orderBy === "os" ? order : "asc"}
|
|
||||||
onClick={() => handleSort("os")}
|
|
||||||
>
|
|
||||||
OS
|
|
||||||
</TableSortLabel>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell />
|
<TableCell />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -225,41 +371,62 @@ 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) => {
|
||||||
<Box
|
switch (col.id) {
|
||||||
sx={{
|
case "status":
|
||||||
display: "inline-block",
|
return (
|
||||||
px: 1.2,
|
<TableCell key={col.id}>
|
||||||
py: 0.25,
|
<Box
|
||||||
borderRadius: 999,
|
sx={{
|
||||||
bgcolor: statusColor(r.status),
|
display: "inline-block",
|
||||||
color: "#fff",
|
px: 1.2,
|
||||||
fontWeight: 600,
|
py: 0.25,
|
||||||
fontSize: "12px",
|
borderRadius: 999,
|
||||||
}}
|
bgcolor: statusColor(r.status),
|
||||||
>
|
color: "#fff",
|
||||||
{r.status}
|
fontWeight: 600,
|
||||||
</Box>
|
fontSize: "12px",
|
||||||
</TableCell>
|
}}
|
||||||
<TableCell
|
>
|
||||||
onClick={() => onSelectDevice && onSelectDevice(r)}
|
{r.status}
|
||||||
sx={{
|
</Box>
|
||||||
color: "#58a6ff",
|
</TableCell>
|
||||||
"&:hover": {
|
);
|
||||||
cursor: onSelectDevice ? "pointer" : "default",
|
case "hostname":
|
||||||
textDecoration: onSelectDevice ? "underline" : "none",
|
return (
|
||||||
},
|
<TableCell
|
||||||
}}
|
key={col.id}
|
||||||
>
|
onClick={() => onSelectDevice && onSelectDevice(r)}
|
||||||
{r.hostname}
|
sx={{
|
||||||
</TableCell>
|
color: "#58a6ff",
|
||||||
<TableCell>{formatLastSeen(r.lastSeen)}</TableCell>
|
"&:hover": {
|
||||||
<TableCell>{r.os}</TableCell>
|
cursor: onSelectDevice ? "pointer" : "default",
|
||||||
|
textDecoration: onSelectDevice ? "underline" : "none",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.hostname}
|
||||||
|
</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>
|
||||||
|
Reference in New Issue
Block a user