mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-14 21:15:47 -07:00
Dozens of UI Changes to FIlter List, Filter Editor, and Scheduled Job Creator
This commit is contained in:
@@ -33,6 +33,9 @@ def register(
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
def _iso(dt: datetime) -> str:
|
||||
return dt.astimezone(timezone.utc).isoformat()
|
||||
|
||||
def _iso_now() -> str:
|
||||
return datetime.now(tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
@@ -120,6 +120,8 @@ const OS_ICON_MAP = {
|
||||
mac: "fab fa-apple",
|
||||
};
|
||||
|
||||
const TAB_HOVER_GRADIENT = "linear-gradient(120deg, rgba(125,211,252,0.18), rgba(192,132,252,0.22))";
|
||||
|
||||
const AUTO_SIZE_COLUMNS = ["status", "site", "hostname", "description", "type", "os"];
|
||||
|
||||
const resolveApplyAll = (filter) => Boolean(filter?.applyToAllSites ?? filter?.apply_to_all_sites);
|
||||
@@ -127,6 +129,45 @@ const resolveApplyAll = (filter) => Boolean(filter?.applyToAllSites ?? filter?.a
|
||||
const resolveLastEdited = (filter) =>
|
||||
filter?.lastEdited || filter?.last_edited || filter?.updated_at || filter?.updated || null;
|
||||
|
||||
const resolveLastEditedBy = (filter) => {
|
||||
const candidate =
|
||||
filter?.last_edited_by_username ||
|
||||
filter?.last_edited_by_name ||
|
||||
filter?.last_edited_by ||
|
||||
filter?.lastEditedBy ||
|
||||
filter?.last_editor ||
|
||||
filter?.lastEditor ||
|
||||
filter?.updated_by ||
|
||||
filter?.updatedBy ||
|
||||
filter?.owner ||
|
||||
filter?.user ||
|
||||
filter?.modified_by;
|
||||
if (candidate && typeof candidate === "object") {
|
||||
if (candidate.name) return candidate.name;
|
||||
if (candidate.username) return candidate.username;
|
||||
if (candidate.user) return candidate.user;
|
||||
}
|
||||
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
||||
return "Unknown";
|
||||
};
|
||||
|
||||
const formatLastEditedLabel = (ts, user) => {
|
||||
if (!ts) return "";
|
||||
const date = new Date(ts);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const year = date.getFullYear();
|
||||
let hours = date.getHours();
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
const suffix = hours >= 12 ? "PM" : "AM";
|
||||
hours = hours % 12 || 12;
|
||||
const datePart = `${month}/${day}/${year}`;
|
||||
const timePart = `${hours}:${minutes}${suffix}`;
|
||||
const editor = user && typeof user === "string" && user.trim() ? user : "Unknown";
|
||||
return `Last edited by ${editor} @ ${datePart} @ ${timePart}`;
|
||||
};
|
||||
|
||||
const resolveSiteScope = (filter) => {
|
||||
const raw = filter?.site_scope || filter?.siteScope || filter?.scope || filter?.type;
|
||||
const normalized = String(raw || "").toLowerCase();
|
||||
@@ -169,6 +210,7 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
|
||||
const [sites, setSites] = useState([]);
|
||||
const [loadingSites, setLoadingSites] = useState(false);
|
||||
const [lastEditedTs, setLastEditedTs] = useState(resolveLastEdited(initialFilter));
|
||||
const [lastEditedBy, setLastEditedBy] = useState(resolveLastEditedBy(initialFilter));
|
||||
const [loadingFilter, setLoadingFilter] = useState(false);
|
||||
const [loadError, setLoadError] = useState(null);
|
||||
const [previewRows, setPreviewRows] = useState([]);
|
||||
@@ -200,6 +242,7 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
|
||||
setTargetSite(filter?.site || filter?.site_scope || filter?.siteName || filter?.site_name || "");
|
||||
setGroups(normalizeGroupsForUI(filter?.groups || filter?.raw?.groups));
|
||||
setLastEditedTs(resolveLastEdited(filter));
|
||||
setLastEditedBy(resolveLastEditedBy(filter));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -597,17 +640,17 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
|
||||
key={condition.id}
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "120px 220px 220px 1fr auto",
|
||||
gap: 1,
|
||||
gridTemplateColumns: "110px 220px 220px 1fr auto",
|
||||
gap: 0.5,
|
||||
alignItems: "center",
|
||||
background: "rgba(12,18,35,0.7)",
|
||||
border: `1px solid ${AURORA_SHELL.border}`,
|
||||
borderRadius: 2,
|
||||
px: 1.5,
|
||||
px: 1.25,
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
{!isFirst && (
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
@@ -716,6 +759,10 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
background: AURORA_SHELL.background,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "100% 520px",
|
||||
backgroundAttachment: "scroll",
|
||||
backgroundColor: "#040711",
|
||||
color: AURORA_SHELL.text,
|
||||
p: 3,
|
||||
borderRadius: 0,
|
||||
@@ -746,7 +793,7 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
|
||||
</Typography>
|
||||
{lastEditedTs && (
|
||||
<Typography sx={{ color: AURORA_SHELL.subtext, fontSize: "0.9rem", mt: 0.4 }}>
|
||||
Last edited {new Date(lastEditedTs).toLocaleString()}
|
||||
{formatLastEditedLabel(lastEditedTs, lastEditedBy)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
@@ -800,83 +847,70 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
background: AURORA_SHELL.glass,
|
||||
border: `1px solid ${AURORA_SHELL.border}`,
|
||||
borderRadius: 2.5,
|
||||
p: 2,
|
||||
boxShadow: "0 18px 38px rgba(3,7,18,0.65)",
|
||||
backdropFilter: "blur(12px)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2.75,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontWeight: 700, mb: 1 }}>Name</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>Name</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Filter name or convention (e.g., RMM targeting)"
|
||||
sx={{
|
||||
width: { xs: "100%", md: "50%" },
|
||||
maxWidth: 420,
|
||||
"& .MuiInputBase-root": { backgroundColor: "rgba(4,7,17,0.65)" },
|
||||
"& .MuiOutlinedInput-notchedOutline": { borderColor: AURORA_SHELL.border },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
background: AURORA_SHELL.glass,
|
||||
border: `1px solid ${AURORA_SHELL.border}`,
|
||||
borderRadius: 2.5,
|
||||
p: 2,
|
||||
boxShadow: "0 18px 38px rgba(3,7,18,0.65)",
|
||||
backdropFilter: "blur(12px)",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1.5}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 700 }}>Scope</Typography>
|
||||
<Typography sx={{ color: AURORA_SHELL.subtext, fontSize: "0.95rem" }}>
|
||||
Choose whether this filter is global or pinned to a specific site.
|
||||
</Typography>
|
||||
</Box>
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
value={scope}
|
||||
onChange={(_, val) => {
|
||||
if (!val) return;
|
||||
setScope(val);
|
||||
}}
|
||||
color="info"
|
||||
sx={{
|
||||
background: "rgba(7,12,26,0.8)",
|
||||
borderRadius: 2,
|
||||
"& .MuiToggleButton-root": {
|
||||
textTransform: "none",
|
||||
color: AURORA_SHELL.text,
|
||||
borderColor: "rgba(148,163,184,0.4)",
|
||||
},
|
||||
"& .Mui-selected": {
|
||||
background: "linear-gradient(135deg, rgba(125,211,252,0.24), rgba(192,132,252,0.22))",
|
||||
color: "#0b1220",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="global">Global</ToggleButton>
|
||||
<ToggleButton value="site">Site</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Stack>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>Scope</Typography>
|
||||
<Typography sx={{ color: AURORA_SHELL.subtext, fontSize: "0.95rem" }}>
|
||||
Choose whether this filter is global or pinned to a specific site.
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
value={scope}
|
||||
onChange={(_, val) => {
|
||||
if (!val) return;
|
||||
setScope(val);
|
||||
}}
|
||||
color="info"
|
||||
sx={{
|
||||
alignSelf: "flex-start",
|
||||
background: "rgba(7,12,26,0.7)",
|
||||
borderRadius: 2,
|
||||
"& .MuiToggleButton-root": {
|
||||
textTransform: "none",
|
||||
color: AURORA_SHELL.text,
|
||||
borderColor: "rgba(148,163,184,0.35)",
|
||||
minHeight: 32,
|
||||
paddingTop: 0.25,
|
||||
paddingBottom: 0.25,
|
||||
paddingLeft: 1.6,
|
||||
paddingRight: 1.6,
|
||||
fontWeight: 700,
|
||||
},
|
||||
"& .Mui-selected": {
|
||||
background: TAB_HOVER_GRADIENT,
|
||||
color: "#0b1220",
|
||||
boxShadow: "0 0 0 1px rgba(148,163,184,0.35) inset",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="global">Global</ToggleButton>
|
||||
<ToggleButton value="site">Site</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{scope === "site" && (
|
||||
<Box sx={{ mt: 2, display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Switch
|
||||
checked={applyToAllSites}
|
||||
@@ -907,6 +941,8 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
|
||||
size="small"
|
||||
placeholder="Search sites"
|
||||
sx={{
|
||||
width: { xs: "100%", md: "50%" },
|
||||
maxWidth: 420,
|
||||
"& .MuiInputBase-root": { backgroundColor: "rgba(4,7,17,0.65)" },
|
||||
"& .MuiOutlinedInput-notchedOutline": { borderColor: AURORA_SHELL.border },
|
||||
}}
|
||||
@@ -918,19 +954,7 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
background: AURORA_SHELL.glass,
|
||||
border: `1px solid ${AURORA_SHELL.border}`,
|
||||
borderRadius: 2.5,
|
||||
p: 2,
|
||||
boxShadow: "0 18px 38px rgba(3,7,18,0.65)",
|
||||
backdropFilter: "blur(12px)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.75 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>Criteria</Typography>
|
||||
<Chip label="Grouped AND / OR" size="small" sx={{ backgroundColor: "rgba(125,211,252,0.12)", color: "#7dd3fc" }} />
|
||||
@@ -1034,24 +1058,12 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
background: AURORA_SHELL.glass,
|
||||
border: `1px solid ${AURORA_SHELL.border}`,
|
||||
borderRadius: 2.5,
|
||||
p: 2,
|
||||
boxShadow: "0 18px 38px rgba(3,7,18,0.65)",
|
||||
backdropFilter: "blur(12px)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 700 }}>Results</Typography>
|
||||
<Typography sx={{ color: AURORA_SHELL.subtext, fontSize: "0.95rem" }}>
|
||||
Apply criteria to preview matching devices (20 per page).
|
||||
Apply criteria to preview matching devices.
|
||||
</Typography>
|
||||
{previewAppliedAt && (
|
||||
<Typography sx={{ color: AURORA_SHELL.subtext, fontSize: "0.85rem" }}>
|
||||
|
||||
@@ -7,13 +7,11 @@ import {
|
||||
IconButton,
|
||||
Stack,
|
||||
Tooltip,
|
||||
Chip,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
FilterAlt as HeaderIcon,
|
||||
Cached as CachedIcon,
|
||||
Add as AddIcon,
|
||||
OpenInNew as DetailsIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
|
||||
@@ -55,6 +53,20 @@ const gradientButtonSx = {
|
||||
};
|
||||
|
||||
const AUTO_SIZE_COLUMNS = ["name", "type", "deviceCount", "site", "lastEditedBy", "lastEdited"];
|
||||
const FILTER_TYPE_META = {
|
||||
global: {
|
||||
label: "Global",
|
||||
textColor: "#8fdaa2",
|
||||
backgroundColor: "rgba(56,161,105,0.16)",
|
||||
borderColor: "rgba(56,161,105,0.4)",
|
||||
},
|
||||
site: {
|
||||
label: "Site-Scoped",
|
||||
textColor: "#8ab4ff",
|
||||
backgroundColor: "rgba(125,180,255,0.16)",
|
||||
borderColor: "rgba(125,180,255,0.42)",
|
||||
},
|
||||
};
|
||||
|
||||
const SAMPLE_ROWS = [
|
||||
{
|
||||
@@ -84,6 +96,55 @@ function formatTimestamp(ts) {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function resolveLastEditor(filter) {
|
||||
const candidate =
|
||||
filter?.last_edited_by_username ||
|
||||
filter?.last_edited_by_name ||
|
||||
filter?.last_edited_by ||
|
||||
filter?.lastEditedBy ||
|
||||
filter?.last_editor ||
|
||||
filter?.lastEditor ||
|
||||
filter?.updated_by ||
|
||||
filter?.updatedBy ||
|
||||
filter?.owner ||
|
||||
filter?.user ||
|
||||
filter?.modified_by;
|
||||
if (candidate && typeof candidate === "object") {
|
||||
if (candidate.name) return candidate.name;
|
||||
if (candidate.username) return candidate.username;
|
||||
if (candidate.user) return candidate.user;
|
||||
}
|
||||
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
function FilterTypePill({ type }) {
|
||||
const key = String(type || "").toLowerCase() === "site" ? "site" : "global";
|
||||
const meta = FILTER_TYPE_META[key];
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
px: 1.1,
|
||||
py: 0.25,
|
||||
borderRadius: 8,
|
||||
minWidth: 58,
|
||||
justifyContent: "center",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.72rem",
|
||||
letterSpacing: 0.2,
|
||||
color: meta.textColor,
|
||||
border: `1px solid ${meta.borderColor}`,
|
||||
backgroundColor: meta.backgroundColor,
|
||||
textTransform: "none",
|
||||
}}
|
||||
>
|
||||
{meta.label}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeFilters(raw) {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map((f, idx) => ({
|
||||
@@ -91,7 +152,7 @@ function normalizeFilters(raw) {
|
||||
name: f.name || f.title || "Unnamed Filter",
|
||||
type: (f.site_scope || f.scope || f.type || "global") === "scoped" ? "site" : "global",
|
||||
site: f.site || f.site_scope || f.site_name || f.target_site || null,
|
||||
lastEditedBy: f.last_edited_by || f.owner || f.updated_by || "Unknown",
|
||||
lastEditedBy: resolveLastEditor(f),
|
||||
lastEdited: f.last_edited || f.updated_at || f.updated || f.created_at || null,
|
||||
deviceCount:
|
||||
typeof f.matching_device_count === "number"
|
||||
@@ -196,11 +257,10 @@ export default function DeviceFilterList({ onCreateFilter, onEditFilter, refresh
|
||||
{
|
||||
headerName: "Type",
|
||||
field: "type",
|
||||
width: 120,
|
||||
minWidth: 150,
|
||||
cellRenderer: (params) => {
|
||||
const type = String(params.value || "").toLowerCase() === "site" ? "Site" : "Global";
|
||||
const color = type === "Global" ? "success" : "info";
|
||||
return <Chip size="small" label={type} color={color} sx={{ fontSize: "0.75rem" }} />;
|
||||
return <FilterTypePill type={type} />;
|
||||
},
|
||||
cellClass: "auto-col-tight",
|
||||
},
|
||||
@@ -236,31 +296,8 @@ export default function DeviceFilterList({ onCreateFilter, onEditFilter, refresh
|
||||
headerName: "Last Edited",
|
||||
field: "lastEdited",
|
||||
minWidth: 180,
|
||||
valueFormatter: (params) => formatTimestamp(params.value),
|
||||
cellClass: "auto-col-tight",
|
||||
},
|
||||
{
|
||||
headerName: "Details",
|
||||
field: "details",
|
||||
width: 120,
|
||||
minWidth: 140,
|
||||
flex: 1,
|
||||
cellRenderer: (params) => (
|
||||
<IconButton
|
||||
aria-label="Open filter details"
|
||||
size="small"
|
||||
onClick={() => onEditFilter?.(params.data)}
|
||||
sx={{
|
||||
color: "#7dd3fc",
|
||||
border: "1px solid rgba(148,163,184,0.4)",
|
||||
borderRadius: 1.5,
|
||||
backgroundColor: "rgba(255,255,255,0.03)",
|
||||
"&:hover": { backgroundColor: "rgba(125,183,255,0.12)" },
|
||||
}}
|
||||
>
|
||||
<DetailsIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
),
|
||||
valueFormatter: (params) => formatTimestamp(params.value),
|
||||
cellClass: "auto-col-tight",
|
||||
},
|
||||
];
|
||||
@@ -273,6 +310,12 @@ export default function DeviceFilterList({ onCreateFilter, onEditFilter, refresh
|
||||
resizable: true,
|
||||
cellClass: "auto-col-tight",
|
||||
suppressMenu: true,
|
||||
cellStyle: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
textAlign: "left",
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
@@ -281,11 +324,15 @@ export default function DeviceFilterList({ onCreateFilter, onEditFilter, refresh
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
height: "100vh",
|
||||
minHeight: "100vh",
|
||||
background: AURORA_SHELL.background,
|
||||
color: AURORA_SHELL.text,
|
||||
p: 3,
|
||||
borderRadius: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 2.5 }}>
|
||||
@@ -341,76 +388,84 @@ export default function DeviceFilterList({ onCreateFilter, onEditFilter, refresh
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
className={gridTheme.themeName}
|
||||
sx={{
|
||||
background: "rgba(10,16,31,0.85)",
|
||||
border: "1px solid rgba(148,163,184,0.3)",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 18px 38px rgba(3,7,18,0.65)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
minHeight: 0,
|
||||
pb: 2,
|
||||
width: "100%",
|
||||
fontFamily: gridFontFamily,
|
||||
"& .ag-root-wrapper": { borderRadius: 0 },
|
||||
"& .ag-center-cols-container .ag-cell, & .ag-pinned-left-cols-container .ag-cell, & .ag-pinned-right-cols-container .ag-cell": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
px: 2,
|
||||
py: 1.25,
|
||||
borderBottom: "1px solid rgba(148,163,184,0.2)",
|
||||
background: "linear-gradient(90deg, rgba(148,163,184,0.08), rgba(148,163,184,0.04))",
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ color: "#e2e8f0", fontWeight: 600 }}>Filters</Typography>
|
||||
<Typography sx={{ color: "rgba(226,232,240,0.7)", fontSize: "0.9rem" }}>
|
||||
{loading ? "Loading…" : `${rows.length} filter${rows.length === 1 ? "" : "s"}`}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{error ? (
|
||||
<Box sx={{ px: 2, py: 1.5, color: "#ffb4b4", borderBottom: "1px solid rgba(255,179,179,0.4)" }}>
|
||||
{error}
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box
|
||||
className={gridTheme.themeName}
|
||||
sx={{
|
||||
height: "calc(100vh - 220px)",
|
||||
"& .ag-root-wrapper": { borderRadius: 0 },
|
||||
"& .ag-cell.auto-col-tight": { paddingLeft: 8, paddingRight: 6 },
|
||||
}}
|
||||
style={{
|
||||
"--ag-icon-font-family": iconFontFamily,
|
||||
"--ag-background-color": "#070b1a",
|
||||
"--ag-foreground-color": "#f4f7ff",
|
||||
"--ag-header-background-color": "#0f172a",
|
||||
"--ag-header-foreground-color": "#cfe0ff",
|
||||
"--ag-odd-row-background-color": "rgba(255,255,255,0.02)",
|
||||
"--ag-row-hover-color": "rgba(125,183,255,0.08)",
|
||||
"--ag-selected-row-background-color": "rgba(64,164,255,0.18)",
|
||||
"--ag-border-color": "rgba(125,183,255,0.18)",
|
||||
"--ag-row-border-color": "rgba(125,183,255,0.14)",
|
||||
"--ag-border-radius": "0px",
|
||||
"--ag-checkbox-border-radius": "3px",
|
||||
"--ag-checkbox-background-color": "rgba(255,255,255,0.06)",
|
||||
"--ag-checkbox-border-color": "rgba(180,200,220,0.6)",
|
||||
"--ag-checkbox-checked-color": "#7dd3fc",
|
||||
}}
|
||||
>
|
||||
<AgGridReact
|
||||
rowData={rows}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColDef}
|
||||
animateRows
|
||||
rowHeight={46}
|
||||
headerHeight={44}
|
||||
suppressCellFocus
|
||||
overlayNoRowsTemplate="<span class='ag-overlay-no-rows-center'>No device filters found.</span>"
|
||||
onGridReady={handleGridReady}
|
||||
theme={gridTheme}
|
||||
style={{ width: "100%", height: "100%", fontFamily: gridFontFamily }}
|
||||
/>
|
||||
</Box>
|
||||
justifyContent: "flex-start",
|
||||
textAlign: "left",
|
||||
paddingTop: "8px",
|
||||
paddingBottom: "8px",
|
||||
paddingLeft: "18px",
|
||||
paddingRight: "12px",
|
||||
},
|
||||
"& .ag-center-cols-container .ag-cell .ag-cell-wrapper, & .ag-pinned-left-cols-container .ag-cell .ag-cell-wrapper, & .ag-pinned-right-cols-container .ag-cell .ag-cell-wrapper": {
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
padding: 0,
|
||||
},
|
||||
"& .ag-center-cols-container .ag-cell .ag-cell-value, & .ag-pinned-left-cols-container .ag-cell .ag-cell-value, & .ag-pinned-right-cols-container .ag-cell .ag-cell-value": {
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
textAlign: "left",
|
||||
},
|
||||
"& .ag-center-cols-container .ag-cell.auto-col-tight, & .ag-pinned-left-cols-container .ag-cell.auto-col-tight, & .ag-pinned-right-cols-container .ag-cell.auto-col-tight": {
|
||||
paddingLeft: "12px",
|
||||
paddingRight: "9px",
|
||||
},
|
||||
"& .ag-center-cols-container .ag-cell.auto-col-tight .ag-cell-wrapper, & .ag-pinned-left-cols-container .ag-cell.auto-col-tight .ag-cell-wrapper, & .ag-pinned-right-cols-container .ag-cell.auto-col-tight .ag-cell-wrapper": {
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
"& .ag-center-cols-container .ag-cell.auto-col-tight .ag-cell-value, & .ag-pinned-left-cols-container .ag-cell.auto-col-tight .ag-cell-value, & .ag-pinned-right-cols-container .ag-cell.auto-col-tight .ag-cell-value": {
|
||||
textAlign: "left",
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
}}
|
||||
style={{
|
||||
"--ag-icon-font-family": iconFontFamily,
|
||||
"--ag-background-color": "#070b1a",
|
||||
"--ag-foreground-color": "#f4f7ff",
|
||||
"--ag-header-background-color": "#0f172a",
|
||||
"--ag-header-foreground-color": "#cfe0ff",
|
||||
"--ag-odd-row-background-color": "rgba(255,255,255,0.02)",
|
||||
"--ag-row-hover-color": "rgba(125,183,255,0.08)",
|
||||
"--ag-selected-row-background-color": "rgba(64,164,255,0.18)",
|
||||
"--ag-border-color": "rgba(125,183,255,0.18)",
|
||||
"--ag-row-border-color": "rgba(125,183,255,0.14)",
|
||||
"--ag-border-radius": "0px",
|
||||
"--ag-checkbox-border-radius": "3px",
|
||||
"--ag-checkbox-background-color": "rgba(255,255,255,0.06)",
|
||||
"--ag-checkbox-border-color": "rgba(180,200,220,0.6)",
|
||||
"--ag-checkbox-checked-color": "#7dd3fc",
|
||||
}}
|
||||
>
|
||||
<AgGridReact
|
||||
rowData={rows}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColDef}
|
||||
animateRows
|
||||
rowHeight={46}
|
||||
headerHeight={44}
|
||||
suppressCellFocus
|
||||
pagination
|
||||
paginationPageSize={20}
|
||||
paginationPageSizeSelector={[20, 50, 100]}
|
||||
overlayNoRowsTemplate="<span class='ag-overlay-no-rows-center'>No device filters found.</span>"
|
||||
onGridReady={handleGridReady}
|
||||
theme={gridTheme}
|
||||
style={{ width: "100%", height: "100%", fontFamily: gridFontFamily }}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -17,11 +17,6 @@ import {
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Table,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableBody,
|
||||
GlobalStyles,
|
||||
CircularProgress
|
||||
} from "@mui/material";
|
||||
@@ -422,21 +417,6 @@ const SCHEDULE_LABELS = {
|
||||
yearly: "Yearly cadence",
|
||||
};
|
||||
|
||||
const TABLE_BASE_SX = {
|
||||
"& .MuiTableCell-root": {
|
||||
borderColor: "rgba(148,163,184,0.18)",
|
||||
color: MAGIC_UI.textBright,
|
||||
},
|
||||
"& .MuiTableHead-root .MuiTableCell-root": {
|
||||
color: MAGIC_UI.textMuted,
|
||||
fontWeight: 600,
|
||||
backgroundColor: "rgba(8,12,24,0.7)",
|
||||
},
|
||||
"& .MuiTableBody-root .MuiTableRow-root:hover": {
|
||||
backgroundColor: "rgba(56,189,248,0.08)",
|
||||
},
|
||||
};
|
||||
|
||||
const hiddenHandleStyle = {
|
||||
width: 12,
|
||||
height: 12,
|
||||
@@ -1082,6 +1062,8 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null, quic
|
||||
const [filterAnchorEl, setFilterAnchorEl] = useState(null);
|
||||
const [activeFilterColumn, setActiveFilterColumn] = useState(null);
|
||||
const [pendingFilterValue, setPendingFilterValue] = useState("");
|
||||
const devicePickerGridApiRef = useRef(null);
|
||||
const filterPickerGridApiRef = useRef(null);
|
||||
|
||||
const normalizeTarget = useCallback((rawTarget) => {
|
||||
if (!rawTarget) return null;
|
||||
@@ -1093,7 +1075,19 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null, quic
|
||||
const rawKind = String(rawTarget.kind || "").toLowerCase();
|
||||
if (rawKind === "device" || rawTarget.hostname) {
|
||||
const host = String(rawTarget.hostname || "").trim();
|
||||
return host ? { kind: "device", hostname: host } : null;
|
||||
if (!host) return null;
|
||||
const osValue =
|
||||
rawTarget.os ||
|
||||
rawTarget.operating_system ||
|
||||
rawTarget.device_os ||
|
||||
rawTarget.platform ||
|
||||
rawTarget.agent_os ||
|
||||
rawTarget.system ||
|
||||
rawTarget.os_name ||
|
||||
(rawTarget.summary && (rawTarget.summary.os || rawTarget.summary.operating_system)) ||
|
||||
"";
|
||||
const siteValue = rawTarget.site || rawTarget.site_name || rawTarget.site_scope || rawTarget.siteScope || "";
|
||||
return { kind: "device", hostname: host, os: osValue, site: siteValue };
|
||||
}
|
||||
if (rawKind === "filter" || rawTarget.filter_id != null || rawTarget.id != null) {
|
||||
const idValue = rawTarget.filter_id ?? rawTarget.id;
|
||||
@@ -1200,10 +1194,109 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null, quic
|
||||
},
|
||||
[targetKey]
|
||||
);
|
||||
const availableDeviceMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
availableDevices.forEach((device) => {
|
||||
const key = String(device?.hostname || "").toLowerCase();
|
||||
if (!key) return;
|
||||
map.set(key, device);
|
||||
});
|
||||
return map;
|
||||
}, [availableDevices]);
|
||||
const devicePickerRows = useMemo(() => {
|
||||
const query = deviceSearch.trim().toLowerCase();
|
||||
if (query.length < 3) return [];
|
||||
return availableDevices
|
||||
.filter((device) => {
|
||||
const display = String(device?.display || device?.hostname || "").toLowerCase();
|
||||
return display.includes(query);
|
||||
})
|
||||
.map((device, index) => ({
|
||||
id: String(device?.hostname || device?.display || `device-${index}`).toLowerCase(),
|
||||
name: device?.display || device?.hostname || "Device",
|
||||
status: device?.online ? "Online" : "Offline",
|
||||
os: device?.os || "",
|
||||
site: device?.site || "",
|
||||
raw: device,
|
||||
}));
|
||||
}, [availableDevices, deviceSearch]);
|
||||
const devicePickerOverlay = useMemo(
|
||||
() =>
|
||||
deviceSearch.trim().length < 3
|
||||
? "Type 3+ characters to search devices."
|
||||
: "No devices match your search.",
|
||||
[deviceSearch]
|
||||
);
|
||||
const filterPickerRows = useMemo(() => {
|
||||
const query = filterSearch.trim().toLowerCase();
|
||||
if (query.length < 3) return [];
|
||||
return (filterCatalog || [])
|
||||
.filter((f) => String(f?.name || "").toLowerCase().includes(query))
|
||||
.map((f, index) => ({
|
||||
id: String(f.id ?? f.filter_id ?? index),
|
||||
name: f.name || `Filter ${index + 1}`,
|
||||
deviceCount: typeof f.deviceCount === "number" ? f.deviceCount : f.devices_targeted ?? f.matching_device_count ?? null,
|
||||
scope: (f.scope || f.site_scope || f.type) === "scoped" ? "Site" : "Global",
|
||||
site: f.site || f.site_name || "",
|
||||
raw: f,
|
||||
}));
|
||||
}, [filterCatalog, filterSearch]);
|
||||
const filterPickerOverlay = useMemo(
|
||||
() =>
|
||||
filterSearch.trim().length < 3
|
||||
? "Type 3+ characters to search filters."
|
||||
: "No filters match your search.",
|
||||
[filterSearch]
|
||||
);
|
||||
const deviceRowsMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
deviceRows.forEach((device) => {
|
||||
const key = String(device?.hostname || device?.agent_hostname || device?.id || "").toLowerCase();
|
||||
if (!key) return;
|
||||
const osValue =
|
||||
device?.os ||
|
||||
device?.operating_system ||
|
||||
device?.agent_os ||
|
||||
device?.system ||
|
||||
device?.os_name ||
|
||||
(device?.summary && (device.summary.os || device.summary.operating_system)) ||
|
||||
"";
|
||||
const siteValue = device?.site || device?.site_name || device?.site_scope || device?.summary?.site || "";
|
||||
map.set(key, { ...device, os: osValue, site: siteValue });
|
||||
});
|
||||
return map;
|
||||
}, [deviceRows]);
|
||||
const targetGridRows = useMemo(() => {
|
||||
const resolveOs = (target) => {
|
||||
if (!target || target.kind !== "device") return "";
|
||||
const explicit =
|
||||
target.os ||
|
||||
target.operating_system ||
|
||||
target.device_os ||
|
||||
target.platform ||
|
||||
target.agent_os ||
|
||||
target.system ||
|
||||
target.os_name;
|
||||
if (explicit) return explicit;
|
||||
const key = String(target.hostname || "").toLowerCase();
|
||||
if (!key) return "";
|
||||
const fromAvailable = availableDeviceMap.get(key);
|
||||
if (fromAvailable?.os) return fromAvailable.os;
|
||||
const fromHistory = deviceRowsMap.get(key);
|
||||
if (fromHistory?.os) return fromHistory.os;
|
||||
return "";
|
||||
};
|
||||
return targets.map((target) => {
|
||||
const key = targetKey(target) || `${target?.kind || "target"}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const isFilter = target?.kind === "filter";
|
||||
const siteLabel = isFilter
|
||||
? "N/A"
|
||||
: target?.site ||
|
||||
target?.site_name ||
|
||||
(() => {
|
||||
const key = String(target.hostname || "").toLowerCase();
|
||||
return availableDeviceMap.get(key)?.site || deviceRowsMap.get(key)?.site || "";
|
||||
})();
|
||||
const deviceCount =
|
||||
typeof target?.deviceCount === "number" && Number.isFinite(target.deviceCount) ? target.deviceCount : null;
|
||||
const detailText = isFilter
|
||||
@@ -1211,25 +1304,45 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null, quic
|
||||
target?.site_scope === "scoped" ? ` • ${target?.site || "Specific site"}` : ""
|
||||
}`
|
||||
: "—";
|
||||
const osLabel = isFilter ? "—" : resolveOs(target) || "Unknown";
|
||||
return {
|
||||
id: key,
|
||||
typeLabel: isFilter ? "Filter" : "Device",
|
||||
siteLabel,
|
||||
targetLabel: isFilter ? target?.name || `Filter #${target?.filter_id}` : target?.hostname,
|
||||
detailText,
|
||||
osLabel,
|
||||
rawTarget: target,
|
||||
};
|
||||
});
|
||||
}, [targets, targetKey]);
|
||||
}, [targets, targetKey, availableDeviceMap, deviceRowsMap]);
|
||||
const targetGridColumnDefs = useMemo(
|
||||
() => [
|
||||
{ field: "typeLabel", headerName: "Type", minWidth: 120, filter: "agTextColumnFilter" },
|
||||
{ field: "targetLabel", headerName: "Target", minWidth: 200, flex: 1.1, filter: "agTextColumnFilter" },
|
||||
{ field: "detailText", headerName: "Details", minWidth: 200, flex: 1.4, filter: "agTextColumnFilter" },
|
||||
{ field: "typeLabel", headerName: "Type", minWidth: 120, flex: 0.9, filter: "agTextColumnFilter" },
|
||||
{
|
||||
field: "siteLabel",
|
||||
headerName: "Site",
|
||||
minWidth: 160,
|
||||
flex: 1,
|
||||
filter: "agTextColumnFilter",
|
||||
cellRenderer: (params) => {
|
||||
const isFilter = params?.data?.typeLabel === "Filter";
|
||||
return (
|
||||
<Box component="span" sx={{ color: isFilter ? "rgba(226,232,240,0.7)" : "#e2e8f0" }}>
|
||||
{params.value || ""}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
cellClass: "auto-col-tight",
|
||||
},
|
||||
{ field: "targetLabel", headerName: "Target", minWidth: 200, flex: 1.2, filter: "agTextColumnFilter" },
|
||||
{ field: "osLabel", headerName: "Operating System", minWidth: 170, flex: 1.1, filter: "agTextColumnFilter" },
|
||||
{ field: "detailText", headerName: "Details", minWidth: 200, flex: 1, filter: "agTextColumnFilter" },
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "",
|
||||
minWidth: 80,
|
||||
maxWidth: 100,
|
||||
minWidth: 100,
|
||||
flex: 1.5,
|
||||
cellRenderer: "TargetActionsRenderer",
|
||||
sortable: false,
|
||||
suppressMenu: true,
|
||||
@@ -1241,19 +1354,21 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null, quic
|
||||
const targetGridComponents = useMemo(
|
||||
() => ({
|
||||
TargetActionsRenderer: (params) => (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
params.context?.removeTarget?.(params.data?.rawTarget);
|
||||
}}
|
||||
sx={{
|
||||
color: "#fb7185",
|
||||
"&:hover": { color: "#fecdd3" },
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", width: "100%" }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
params.context?.removeTarget?.(params.data?.rawTarget);
|
||||
}}
|
||||
sx={{
|
||||
color: "#fb7185",
|
||||
"&:hover": { color: "#fecdd3" },
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
),
|
||||
}),
|
||||
[]
|
||||
@@ -1272,7 +1387,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null, quic
|
||||
[]
|
||||
);
|
||||
const targetGridApiRef = useRef(null);
|
||||
const TARGET_AUTO_COLS = useRef(["typeLabel", "targetLabel", "detailText"]);
|
||||
const TARGET_AUTO_COLS = useRef(["typeLabel", "targetLabel", "osLabel", "detailText"]);
|
||||
const handleTargetGridReady = useCallback((params) => {
|
||||
targetGridApiRef.current = params.api;
|
||||
requestAnimationFrame(() => {
|
||||
@@ -1290,6 +1405,116 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null, quic
|
||||
});
|
||||
}, [targetGridRows]);
|
||||
|
||||
const devicePickerSelectionCol = {
|
||||
headerName: "",
|
||||
field: "__select__",
|
||||
width: 52,
|
||||
maxWidth: 52,
|
||||
checkboxSelection: true,
|
||||
headerCheckboxSelection: true,
|
||||
resizable: false,
|
||||
sortable: false,
|
||||
suppressMenu: true,
|
||||
filter: false,
|
||||
pinned: "left",
|
||||
lockPosition: true,
|
||||
};
|
||||
const devicePickerColumnDefs = useMemo(
|
||||
() => [
|
||||
devicePickerSelectionCol,
|
||||
{ field: "site", headerName: "Site", minWidth: 160, flex: 1, cellClass: "auto-col-tight" },
|
||||
{ field: "name", headerName: "Name", minWidth: 200, flex: 1.2, cellClass: "auto-col-tight" },
|
||||
{ field: "status", headerName: "Status", minWidth: 140, flex: 0.9, cellClass: "auto-col-tight" },
|
||||
{ field: "os", headerName: "OS", minWidth: 180, flex: 1.8, cellClass: "auto-col-tight" },
|
||||
],
|
||||
[]
|
||||
);
|
||||
const devicePickerDefaultColDef = useMemo(
|
||||
() => ({
|
||||
sortable: true,
|
||||
filter: "agTextColumnFilter",
|
||||
resizable: true,
|
||||
flex: 1,
|
||||
cellClass: "auto-col-tight",
|
||||
cellStyle: LEFT_ALIGN_CELL_STYLE,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
const handleDevicePickerReady = useCallback((params) => {
|
||||
devicePickerGridApiRef.current = params.api;
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
params.api.autoSizeColumns(["name", "status", "os"], true);
|
||||
} catch {}
|
||||
});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const api = devicePickerGridApiRef.current;
|
||||
if (!api) return;
|
||||
api.forEachNode((node) => {
|
||||
const selected = !!selectedDeviceTargets[node?.data?.id];
|
||||
if (node.isSelected() !== selected) {
|
||||
node.setSelected(selected);
|
||||
}
|
||||
});
|
||||
}, [selectedDeviceTargets, devicePickerRows]);
|
||||
const handleDevicePickerSelectionChanged = useCallback(() => {
|
||||
const api = devicePickerGridApiRef.current;
|
||||
if (!api) return;
|
||||
const next = {};
|
||||
api.getSelectedNodes().forEach((node) => {
|
||||
if (node?.data?.id) next[node.data.id] = true;
|
||||
});
|
||||
setSelectedDeviceTargets(next);
|
||||
}, []);
|
||||
|
||||
const filterPickerSelectionCol = { ...devicePickerSelectionCol };
|
||||
const filterPickerColumnDefs = useMemo(
|
||||
() => [
|
||||
filterPickerSelectionCol,
|
||||
{ field: "name", headerName: "Filter", minWidth: 220, flex: 1.4, cellClass: "auto-col-tight" },
|
||||
{
|
||||
field: "deviceCount",
|
||||
headerName: "Devices",
|
||||
minWidth: 140,
|
||||
flex: 0.8,
|
||||
valueFormatter: (params) => (typeof params.value === "number" ? params.value.toLocaleString() : "—"),
|
||||
cellClass: "auto-col-tight",
|
||||
},
|
||||
{ field: "scope", headerName: "Scope", minWidth: 140, flex: 0.9, cellClass: "auto-col-tight" },
|
||||
{ field: "site", headerName: "Site", minWidth: 160, flex: 1, cellClass: "auto-col-tight" },
|
||||
],
|
||||
[]
|
||||
);
|
||||
const filterPickerDefaultColDef = devicePickerDefaultColDef;
|
||||
const handleFilterPickerReady = useCallback((params) => {
|
||||
filterPickerGridApiRef.current = params.api;
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
params.api.autoSizeColumns(["name", "deviceCount", "scope", "site"], true);
|
||||
} catch {}
|
||||
});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const api = filterPickerGridApiRef.current;
|
||||
if (!api) return;
|
||||
api.forEachNode((node) => {
|
||||
const selected = !!selectedFilterTargets[node?.data?.id];
|
||||
if (node.isSelected() !== selected) {
|
||||
node.setSelected(selected);
|
||||
}
|
||||
});
|
||||
}, [selectedFilterTargets, filterPickerRows]);
|
||||
const handleFilterPickerSelectionChanged = useCallback(() => {
|
||||
const api = filterPickerGridApiRef.current;
|
||||
if (!api) return;
|
||||
const next = {};
|
||||
api.getSelectedNodes().forEach((node) => {
|
||||
if (node?.data?.id) next[node.data.id] = true;
|
||||
});
|
||||
setSelectedFilterTargets(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setTargets((prev) => {
|
||||
let changed = false;
|
||||
@@ -2401,7 +2626,17 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null, quic
|
||||
const list = Object.values(data || {}).map((a) => ({
|
||||
hostname: a.hostname || a.agent_hostname || a.id || "unknown",
|
||||
display: a.hostname || a.agent_hostname || a.id || "unknown",
|
||||
online: !!a.collector_active
|
||||
online: !!a.collector_active,
|
||||
site: a.site || a.site_name || a.site_scope || a.group || "",
|
||||
os:
|
||||
a.os ||
|
||||
a.operating_system ||
|
||||
a.platform ||
|
||||
a.agent_os ||
|
||||
a.system ||
|
||||
a.os_name ||
|
||||
(a.summary && (a.summary.os || a.summary.operating_system)) ||
|
||||
"",
|
||||
}));
|
||||
list.sort((a, b) => a.display.localeCompare(b.display));
|
||||
setAvailableDevices(list);
|
||||
@@ -2729,18 +2964,19 @@ const heroTiles = useMemo(() => {
|
||||
{tab === 0 && (
|
||||
<Box sx={TAB_SECTION_SX}>
|
||||
<SectionHeader title="Job Name" />
|
||||
<TextField
|
||||
fullWidth
|
||||
sx={{ width: { xs: "100%", md: "60%" }, maxWidth: 540, ...INPUT_FIELD_SX }}
|
||||
placeholder="Example Job Name"
|
||||
value={jobName}
|
||||
onChange={(e) => handleJobNameInputChange(e.target.value)}
|
||||
onBlur={(e) => setPageTitleJobName(e.target.value.trim())}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
error={jobName.trim().length === 0}
|
||||
helperText={jobName.trim().length === 0 ? "Job name is required" : ""}
|
||||
/>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
sx={{ width: { xs: "100%", md: "60%" }, maxWidth: 540, ...INPUT_FIELD_SX }}
|
||||
placeholder="Example Job Name"
|
||||
value={jobName}
|
||||
onChange={(e) => handleJobNameInputChange(e.target.value)}
|
||||
onBlur={(e) => setPageTitleJobName(e.target.value.trim())}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
error={jobName.trim().length === 0}
|
||||
helperText={jobName.trim().length === 0 ? "Job name is required" : ""}
|
||||
inputProps={{ sx: { py: 0.9 } }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 1 && (
|
||||
@@ -2782,7 +3018,7 @@ const heroTiles = useMemo(() => {
|
||||
)}
|
||||
|
||||
{tab === 2 && (
|
||||
<Box sx={TAB_SECTION_SX}>
|
||||
<Box sx={{ ...TAB_SECTION_SX, flexGrow: 1, minHeight: 0, display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
<SectionHeader
|
||||
title="Targets"
|
||||
action={
|
||||
@@ -2797,7 +3033,16 @@ const heroTiles = useMemo(() => {
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Box className={gridThemeClass} sx={{ ...GRID_WRAPPER_SX, height: 320 }}>
|
||||
<Box
|
||||
className={gridThemeClass}
|
||||
sx={{
|
||||
...GRID_WRAPPER_SX,
|
||||
flexGrow: 1,
|
||||
minHeight: 420,
|
||||
height: "100%",
|
||||
maxHeight: "100%",
|
||||
}}
|
||||
>
|
||||
<AgGridReact
|
||||
rowData={targetGridRows}
|
||||
columnDefs={targetGridColumnDefs}
|
||||
@@ -3207,18 +3452,22 @@ const heroTiles = useMemo(() => {
|
||||
open={addCompOpen}
|
||||
onClose={() => setAddCompOpen(false)}
|
||||
fullWidth
|
||||
maxWidth="md"
|
||||
maxWidth={false}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
background: MAGIC_UI.panelBg,
|
||||
color: MAGIC_UI.textBright,
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
boxShadow: MAGIC_UI.glow,
|
||||
width: "95vw",
|
||||
maxWidth: "95vw",
|
||||
height: "95vh",
|
||||
maxHeight: "95vh",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Select an Assembly</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, height: "100%", pb: 0.5, pt: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
@@ -3267,30 +3516,17 @@ const heroTiles = useMemo(() => {
|
||||
}}
|
||||
>
|
||||
<Tab label="Scripts" value="scripts" />
|
||||
<Tab label="Ansible" value="ansible" />
|
||||
<Tab label="Ansible Playbooks" value="ansible" />
|
||||
<Tab label="Workflows" value="workflows" />
|
||||
</Tabs>
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center", ml: "auto" }}>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Search assemblies…"
|
||||
value={assemblyFilterText}
|
||||
onChange={(e) => setAssemblyFilterText(e.target.value)}
|
||||
sx={{ minWidth: 220, ...INPUT_FIELD_SX }}
|
||||
sx={{ minWidth: 352, maxWidth: 520, ...INPUT_FIELD_SX }}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => loadAssemblies()}
|
||||
sx={{
|
||||
color: MAGIC_UI.accentA,
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
borderRadius: 2,
|
||||
width: 38,
|
||||
height: 38,
|
||||
}}
|
||||
>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
{assembliesError ? (
|
||||
@@ -3304,7 +3540,16 @@ const heroTiles = useMemo(() => {
|
||||
<Typography variant="body2">Loading assemblies…</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Box className={gridThemeClass} sx={{ ...GRID_WRAPPER_SX, height: 420 }}>
|
||||
<Box
|
||||
className={gridThemeClass}
|
||||
sx={{
|
||||
...GRID_WRAPPER_SX,
|
||||
flexGrow: 1,
|
||||
minHeight: 520,
|
||||
maxHeight: "calc(95vh - 210px)",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<AgGridReact
|
||||
rowData={filteredAssemblyRows}
|
||||
columnDefs={assemblyColumnDefs}
|
||||
@@ -3404,145 +3649,141 @@ const heroTiles = useMemo(() => {
|
||||
<Tab label="Filters" value="filters" />
|
||||
</Tabs>
|
||||
|
||||
{targetPickerTab === "devices" ? (
|
||||
<>
|
||||
<Box sx={{ mb: 2, display: "flex", gap: 2 }}>
|
||||
{targetPickerTab === "devices" ? (
|
||||
<>
|
||||
<Box sx={{ mb: 1.25, display: "flex", gap: 2 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Search devices..."
|
||||
value={deviceSearch}
|
||||
onChange={(e) => setDeviceSearch(e.target.value)}
|
||||
sx={{ flex: 1, ...INPUT_FIELD_SX }}
|
||||
helperText={deviceSearch.trim().length < 3 ? "Type at least 3 characters to search devices." : ""}
|
||||
FormHelperTextProps={{ sx: { color: MAGIC_UI.textMuted } }}
|
||||
/>
|
||||
</Box>
|
||||
<Table size="small" sx={TABLE_BASE_SX}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width={40}></TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{availableDevices
|
||||
.filter((d) => d.display.toLowerCase().includes(deviceSearch.toLowerCase()))
|
||||
.map((d) => (
|
||||
<TableRow key={d.hostname} hover onClick={() => setSelectedDeviceTargets((prev) => ({ ...prev, [d.hostname]: !prev[d.hostname] }))}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={!!selectedDeviceTargets[d.hostname]}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedDeviceTargets((prev) => ({ ...prev, [d.hostname]: e.target.checked }));
|
||||
}}
|
||||
sx={{
|
||||
color: MAGIC_UI.accentA,
|
||||
"&.Mui-checked": { color: MAGIC_UI.accentB },
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{d.display}</TableCell>
|
||||
<TableCell>
|
||||
<span style={{ display: "inline-block", width: 10, height: 10, borderRadius: 10, background: d.online ? "#00d18c" : "#ff4f4f", marginRight: 8, verticalAlign: "middle" }} />
|
||||
{d.online ? "Online" : "Offline"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{availableDevices.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} sx={{ color: MAGIC_UI.textMuted }}>
|
||||
No devices available.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Box
|
||||
className={gridThemeClass}
|
||||
sx={{
|
||||
...GRID_WRAPPER_SX,
|
||||
height: 420,
|
||||
}}
|
||||
>
|
||||
<AgGridReact
|
||||
rowData={devicePickerRows}
|
||||
columnDefs={devicePickerColumnDefs}
|
||||
defaultColDef={devicePickerDefaultColDef}
|
||||
animateRows
|
||||
rowHeight={46}
|
||||
headerHeight={44}
|
||||
suppressCellFocus
|
||||
rowSelection="multiple"
|
||||
rowMultiSelectWithClick
|
||||
overlayNoRowsTemplate={`<span class='ag-overlay-no-rows-center'>${devicePickerOverlay}</span>`}
|
||||
getRowId={(params) => params.data?.id || params.rowIndex}
|
||||
onGridReady={handleDevicePickerReady}
|
||||
onSelectionChanged={handleDevicePickerSelectionChanged}
|
||||
pagination
|
||||
paginationPageSize={20}
|
||||
paginationPageSizeSelector={[20, 50, 100]}
|
||||
theme={gridTheme}
|
||||
style={{ width: "100%", height: "100%", fontFamily: gridFontFamily, "--ag-icon-font-family": iconFontFamily }}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Box sx={{ mb: 2, display: "flex", gap: 2 }}>
|
||||
) : (
|
||||
<>
|
||||
<Box sx={{ mb: 1.25, display: "flex", gap: 2 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Search filters..."
|
||||
value={filterSearch}
|
||||
onChange={(e) => setFilterSearch(e.target.value)}
|
||||
sx={{ flex: 1, ...INPUT_FIELD_SX }}
|
||||
helperText={filterSearch.trim().length < 3 ? "Type at least 3 characters to search filters." : ""}
|
||||
FormHelperTextProps={{ sx: { color: MAGIC_UI.textMuted } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
className={gridThemeClass}
|
||||
sx={{
|
||||
...GRID_WRAPPER_SX,
|
||||
height: 420,
|
||||
}}
|
||||
>
|
||||
<AgGridReact
|
||||
rowData={filterPickerRows}
|
||||
columnDefs={filterPickerColumnDefs}
|
||||
defaultColDef={filterPickerDefaultColDef}
|
||||
animateRows
|
||||
rowHeight={46}
|
||||
headerHeight={44}
|
||||
suppressCellFocus
|
||||
rowSelection="multiple"
|
||||
rowMultiSelectWithClick
|
||||
overlayNoRowsTemplate={`<span class='ag-overlay-no-rows-center'>${filterPickerOverlay}</span>`}
|
||||
getRowId={(params) => params.data?.id || params.rowIndex}
|
||||
onGridReady={handleFilterPickerReady}
|
||||
onSelectionChanged={handleFilterPickerSelectionChanged}
|
||||
pagination
|
||||
paginationPageSize={20}
|
||||
paginationPageSizeSelector={[20, 50, 100]}
|
||||
theme={gridTheme}
|
||||
style={{ width: "100%", height: "100%", fontFamily: gridFontFamily, "--ag-icon-font-family": iconFontFamily }}
|
||||
/>
|
||||
</Box>
|
||||
<Table size="small" sx={TABLE_BASE_SX}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width={40}></TableCell>
|
||||
<TableCell>Filter</TableCell>
|
||||
<TableCell>Devices</TableCell>
|
||||
<TableCell>Scope</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{(filterCatalog || [])
|
||||
.filter((f) => (f.name || "").toLowerCase().includes(filterSearch.toLowerCase()))
|
||||
.map((f) => (
|
||||
<TableRow key={f.id} hover onClick={() => setSelectedFilterTargets((prev) => ({ ...prev, [f.id]: !prev[f.id] }))}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={!!selectedFilterTargets[f.id]}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedFilterTargets((prev) => ({ ...prev, [f.id]: e.target.checked }));
|
||||
}}
|
||||
sx={{
|
||||
color: MAGIC_UI.accentA,
|
||||
"&.Mui-checked": { color: MAGIC_UI.accentB },
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{f.name}</TableCell>
|
||||
<TableCell>{typeof f.deviceCount === "number" ? f.deviceCount.toLocaleString() : "—"}</TableCell>
|
||||
<TableCell>{f.scope === "scoped" ? (f.site || "Specific Site") : "All Sites"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!loadingFilterCatalog && (!filterCatalog || filterCatalog.length === 0) && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} sx={{ color: MAGIC_UI.textMuted }}>
|
||||
No filters available.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{loadingFilterCatalog && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} sx={{ color: MAGIC_UI.textMuted }}>
|
||||
Loading filters…
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAddTargetOpen(false)} sx={{ color: MAGIC_UI.textMuted, textTransform: "none" }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (targetPickerTab === "filters") {
|
||||
const additions = filterCatalog
|
||||
.filter((f) => selectedFilterTargets[f.id])
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAddTargetOpen(false)} sx={{ color: MAGIC_UI.textMuted, textTransform: "none" }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (targetPickerTab === "filters") {
|
||||
const selectedIds = new Set(
|
||||
Object.entries(selectedFilterTargets)
|
||||
.filter(([, checked]) => checked)
|
||||
.map(([id]) => String(id))
|
||||
);
|
||||
const additions = (filterCatalog || [])
|
||||
.filter((f) => selectedIds.has(String(f.id ?? f.filter_id)))
|
||||
.map((f) => ({
|
||||
kind: "filter",
|
||||
filter_id: f.id,
|
||||
filter_id: f.id ?? f.filter_id,
|
||||
name: f.name,
|
||||
site_scope: f.scope,
|
||||
site_scope: f.scope || f.site_scope || f.type,
|
||||
site: f.site,
|
||||
deviceCount: f.deviceCount,
|
||||
deviceCount: f.deviceCount ?? f.devices_targeted ?? f.matching_device_count,
|
||||
}));
|
||||
// Fallback to visible picker rows if catalog didn't return anything yet
|
||||
if (!additions.length && selectedIds.size) {
|
||||
filterPickerRows.forEach((row) => {
|
||||
if (!selectedIds.has(String(row.id))) return;
|
||||
additions.push({
|
||||
kind: "filter",
|
||||
filter_id: Number(row.id),
|
||||
name: row.name,
|
||||
site_scope: row.scope,
|
||||
site: row.site,
|
||||
deviceCount: row.deviceCount,
|
||||
});
|
||||
});
|
||||
}
|
||||
if (additions.length) addTargets(additions);
|
||||
} else {
|
||||
const chosenHosts = Object.keys(selectedDeviceTargets).filter((hostname) => selectedDeviceTargets[hostname]);
|
||||
if (chosenHosts.length) addTargets(chosenHosts);
|
||||
const chosenDevices = Object.keys(selectedDeviceTargets)
|
||||
.filter((hostname) => selectedDeviceTargets[hostname])
|
||||
.map((hostname) => {
|
||||
const normalized = String(hostname || "").toLowerCase();
|
||||
const lookup = availableDeviceMap.get(normalized);
|
||||
if (lookup) {
|
||||
return { kind: "device", hostname: lookup.hostname, os: lookup.os, site: lookup.site };
|
||||
}
|
||||
return { kind: "device", hostname };
|
||||
});
|
||||
if (chosenDevices.length) addTargets(chosenDevices);
|
||||
}
|
||||
setAddTargetOpen(false);
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user