mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-15 17:35:48 -07:00
Device Filter Editor UI Changes
This commit is contained in:
@@ -13,6 +13,8 @@ import {
|
|||||||
Chip,
|
Chip,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import {
|
import {
|
||||||
FilterAlt as HeaderIcon,
|
FilterAlt as HeaderIcon,
|
||||||
@@ -123,6 +125,22 @@ const OS_ICON_MAP = {
|
|||||||
|
|
||||||
const TAB_HOVER_GRADIENT = "linear-gradient(120deg, rgba(125,211,252,0.18), rgba(192,132,252,0.22))";
|
const TAB_HOVER_GRADIENT = "linear-gradient(120deg, rgba(125,211,252,0.18), rgba(192,132,252,0.22))";
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ value: "name", label: "Name" },
|
||||||
|
{ value: "scope", label: "Scope" },
|
||||||
|
{ value: "criteria", label: "Criteria" },
|
||||||
|
{ value: "results", label: "Results" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TabPanel = ({ value, active, children }) => {
|
||||||
|
if (value !== active) return null;
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 2, display: "flex", flexDirection: "column", gap: 2.75, flex: 1, minHeight: 0 }}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const AUTO_SIZE_COLUMNS = ["status", "site", "hostname", "description", "type", "os"];
|
const AUTO_SIZE_COLUMNS = ["status", "site", "hostname", "description", "type", "os"];
|
||||||
|
|
||||||
const resolveApplyAll = (filter) => Boolean(filter?.applyToAllSites ?? filter?.apply_to_all_sites);
|
const resolveApplyAll = (filter) => Boolean(filter?.applyToAllSites ?? filter?.apply_to_all_sites);
|
||||||
@@ -199,7 +217,7 @@ const normalizeGroupsForUI = (rawGroups) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved }) {
|
export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, onPageMetaChange }) {
|
||||||
const [name, setName] = useState(initialFilter?.name || "");
|
const [name, setName] = useState(initialFilter?.name || "");
|
||||||
const initialScope = resolveSiteScope(initialFilter);
|
const initialScope = resolveSiteScope(initialFilter);
|
||||||
const [scope, setScope] = useState(initialScope === "scoped" ? "site" : "global");
|
const [scope, setScope] = useState(initialScope === "scoped" ? "site" : "global");
|
||||||
@@ -218,6 +236,8 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
|
|||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const [previewError, setPreviewError] = useState(null);
|
const [previewError, setPreviewError] = useState(null);
|
||||||
const [previewAppliedAt, setPreviewAppliedAt] = useState(null);
|
const [previewAppliedAt, setPreviewAppliedAt] = useState(null);
|
||||||
|
const [tab, setTab] = useState(TABS[0].value);
|
||||||
|
const isEditing = Boolean(initialFilter);
|
||||||
const gridRef = useRef(null);
|
const gridRef = useRef(null);
|
||||||
const sendNotification = useCallback(async (message) => {
|
const sendNotification = useCallback(async (message) => {
|
||||||
if (!message) return;
|
if (!message) return;
|
||||||
@@ -251,6 +271,9 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
|
|||||||
);
|
);
|
||||||
const gridFontFamily = "'IBM Plex Sans','Helvetica Neue',Arial,sans-serif";
|
const gridFontFamily = "'IBM Plex Sans','Helvetica Neue',Arial,sans-serif";
|
||||||
const iconFontFamily = "'Quartz Regular'";
|
const iconFontFamily = "'Quartz Regular'";
|
||||||
|
const pageTitle = isEditing ? "Edit Device Filter" : "Create Device Filter";
|
||||||
|
const pageSubtitle =
|
||||||
|
"Combine grouped criteria with AND/OR logic to build reusable device scopes for automation and reporting.";
|
||||||
|
|
||||||
const applyFilterData = useCallback((filter) => {
|
const applyFilterData = useCallback((filter) => {
|
||||||
if (!filter) return;
|
if (!filter) return;
|
||||||
@@ -268,6 +291,15 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
|
|||||||
applyFilterData(initialFilter);
|
applyFilterData(initialFilter);
|
||||||
}, [applyFilterData, initialFilter]);
|
}, [applyFilterData, initialFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onPageMetaChange?.({
|
||||||
|
page_title: pageTitle,
|
||||||
|
page_subtitle: pageSubtitle,
|
||||||
|
page_icon: HeaderIcon,
|
||||||
|
});
|
||||||
|
return () => onPageMetaChange?.(null);
|
||||||
|
}, [onPageMetaChange, pageSubtitle, pageTitle]);
|
||||||
|
|
||||||
const handleGridReady = useCallback((params) => {
|
const handleGridReady = useCallback((params) => {
|
||||||
gridRef.current = params.api;
|
gridRef.current = params.api;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -659,7 +691,7 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
|
|||||||
key={condition.id}
|
key={condition.id}
|
||||||
sx={{
|
sx={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "110px 220px 220px 1fr auto",
|
gridTemplateColumns: "94px 220px 220px 1fr auto",
|
||||||
gap: 0.5,
|
gap: 0.5,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
background: "rgba(12,18,35,0.7)",
|
background: "rgba(12,18,35,0.7)",
|
||||||
@@ -777,6 +809,9 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
|
|||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
minHeight: "100vh",
|
minHeight: "100vh",
|
||||||
|
height: "100vh",
|
||||||
|
flex: 1,
|
||||||
|
position: "relative",
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
color: AURORA_SHELL.text,
|
color: AURORA_SHELL.text,
|
||||||
p: 3,
|
p: 3,
|
||||||
@@ -784,41 +819,39 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: 3,
|
gap: 3,
|
||||||
pb: 6,
|
pb: 3,
|
||||||
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 3 }}>
|
{loadingFilter ? (
|
||||||
<Box sx={{ display: "flex", gap: 1.5, alignItems: "flex-start" }}>
|
<Box sx={{ mb: 2, color: "#7dd3fc" }}>Loading filter...</Box>
|
||||||
<Box
|
) : null}
|
||||||
sx={{
|
{loadError ? (
|
||||||
width: 36,
|
<Box
|
||||||
height: 36,
|
sx={{
|
||||||
borderRadius: 2,
|
mb: 2,
|
||||||
background: "linear-gradient(135deg, rgba(125,211,252,0.28), rgba(192,132,252,0.32))",
|
background: "rgba(255,179,179,0.08)",
|
||||||
display: "flex",
|
color: "#ffb4b4",
|
||||||
alignItems: "center",
|
border: "1px solid rgba(255,179,179,0.35)",
|
||||||
justifyContent: "center",
|
borderRadius: 1.5,
|
||||||
color: "#0f172a",
|
p: 1.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<HeaderIcon fontSize="small" />
|
{loadError}
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Typography sx={{ fontSize: "1.35rem", fontWeight: 700, lineHeight: 1.2 }}>
|
|
||||||
{initialFilter ? "Edit Device Filter" : "Create Device Filter"}
|
|
||||||
</Typography>
|
|
||||||
<Typography sx={{ color: AURORA_SHELL.subtext, mt: 0.2 }}>
|
|
||||||
Combine grouped criteria with AND/OR logic to build reusable device scopes for automation and reporting.
|
|
||||||
</Typography>
|
|
||||||
{lastEditedTs && (
|
|
||||||
<Typography sx={{ color: AURORA_SHELL.subtext, fontSize: "0.9rem", mt: 0.4 }}>
|
|
||||||
{formatLastEditedLabel(lastEditedTs, lastEditedBy)}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Stack direction="row" spacing={1}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 12,
|
||||||
|
right: 24,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
zIndex: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={1.25}>
|
||||||
<Tooltip title="Cancel and return">
|
<Tooltip title="Cancel and return">
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -848,305 +881,365 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{loadingFilter ? (
|
<Box
|
||||||
<Box sx={{ mb: 2, color: "#7dd3fc" }}>Loading filter...</Box>
|
sx={{
|
||||||
) : null}
|
display: "flex",
|
||||||
{loadError ? (
|
flexDirection: "column",
|
||||||
<Box
|
gap: 2,
|
||||||
sx={{
|
flex: 1,
|
||||||
mb: 2,
|
minHeight: 0,
|
||||||
background: "rgba(255,179,179,0.08)",
|
position: "relative",
|
||||||
color: "#ffb4b4",
|
}}
|
||||||
border: "1px solid rgba(255,179,179,0.35)",
|
>
|
||||||
borderRadius: 1.5,
|
<Tabs
|
||||||
p: 1.5,
|
value={tab}
|
||||||
|
onChange={(_, val) => setTab(val)}
|
||||||
|
variant="scrollable"
|
||||||
|
scrollButtons="auto"
|
||||||
|
TabIndicatorProps={{
|
||||||
|
style: {
|
||||||
|
height: 3,
|
||||||
|
borderRadius: 3,
|
||||||
|
background: "linear-gradient(90deg,#7dd3fc,#c084fc)",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
{loadError}
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
mt: 0,
|
||||||
flexDirection: "column",
|
borderBottom: `1px solid ${AURORA_SHELL.border}`,
|
||||||
gap: 2.75,
|
"& .MuiTab-root": {
|
||||||
flex: 1,
|
color: AURORA_SHELL.subtext,
|
||||||
}}
|
fontFamily: gridFontFamily,
|
||||||
>
|
fontSize: 15,
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
|
||||||
<Typography sx={{ fontWeight: 700 }}>Name</Typography>
|
|
||||||
<TextField
|
|
||||||
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={{ 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={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
|
||||||
<Stack direction="row" alignItems="center" spacing={1}>
|
|
||||||
<Switch
|
|
||||||
checked={applyToAllSites}
|
|
||||||
onChange={(e) => setApplyToAllSites(e.target.checked)}
|
|
||||||
color="info"
|
|
||||||
/>
|
|
||||||
<Box>
|
|
||||||
<Typography sx={{ fontWeight: 600 }}>Add filter to all Sites</Typography>
|
|
||||||
<Typography sx={{ color: AURORA_SHELL.subtext, fontSize: "0.9rem" }}>
|
|
||||||
Future sites will also inherit this filter when enabled.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{!applyToAllSites && (
|
|
||||||
<Autocomplete
|
|
||||||
disablePortal
|
|
||||||
loading={loadingSites}
|
|
||||||
options={sites}
|
|
||||||
value={sites.find((s) => s.value === targetSite) || null}
|
|
||||||
getOptionLabel={(option) => option?.label || ""}
|
|
||||||
isOptionEqualToValue={(option, value) => option?.value === value?.value}
|
|
||||||
onChange={(_, val) => setTargetSite(val?.value || "")}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
label="Target Site"
|
|
||||||
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 },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<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" }} />
|
|
||||||
</Box>
|
|
||||||
<Typography sx={{ color: AURORA_SHELL.subtext, fontSize: "0.95rem", mb: 1 }}>
|
|
||||||
Add conditions inside each group, mixing AND/OR as needed. Groups themselves can be chained with AND or OR to
|
|
||||||
mirror complex targeting logic (e.g., (A AND B) OR (C AND D)).
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{groups.map((group, idx) => (
|
|
||||||
<Box key={group.id} sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
|
||||||
{idx > 0 && (
|
|
||||||
<ToggleButtonGroup
|
|
||||||
exclusive
|
|
||||||
size="small"
|
|
||||||
value={group.joinWith || "OR"}
|
|
||||||
onChange={(_, val) => {
|
|
||||||
if (!val) return;
|
|
||||||
updateGroup(group.id, { ...group, joinWith: val });
|
|
||||||
}}
|
|
||||||
color="info"
|
|
||||||
sx={{
|
|
||||||
alignSelf: "center",
|
|
||||||
"& .MuiToggleButton-root": { px: 2, textTransform: "uppercase", fontSize: "0.8rem" },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ToggleButton value="AND">AND</ToggleButton>
|
|
||||||
<ToggleButton value="OR">OR</ToggleButton>
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
border: `1px solid ${AURORA_SHELL.border}`,
|
|
||||||
borderRadius: 2,
|
|
||||||
background: "linear-gradient(135deg, rgba(7,10,22,0.85), rgba(9,11,24,0.92))",
|
|
||||||
p: 1.5,
|
|
||||||
boxShadow: "0 12px 28px rgba(3,7,18,0.5)",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
|
||||||
<Typography sx={{ fontWeight: 600 }}>Criteria Group {idx + 1}</Typography>
|
|
||||||
<Stack direction="row" spacing={1}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<AddIcon />}
|
|
||||||
onClick={() => addCondition(group.id)}
|
|
||||||
sx={{
|
|
||||||
textTransform: "none",
|
|
||||||
color: "#7dd3fc",
|
|
||||||
borderColor: "rgba(125,211,252,0.5)",
|
|
||||||
borderRadius: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add Condition
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<RemoveIcon />}
|
|
||||||
disabled={groups.length === 1}
|
|
||||||
onClick={() => removeGroup(group.id)}
|
|
||||||
sx={{
|
|
||||||
textTransform: "none",
|
|
||||||
color: "#ffb4b4",
|
|
||||||
borderColor: "rgba(255,180,180,0.5)",
|
|
||||||
borderRadius: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Remove Group
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack spacing={1}>
|
|
||||||
{group.conditions.map((condition, cIdx) =>
|
|
||||||
renderConditionRow(group.id, condition, cIdx === 0)
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
startIcon={<AddIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => addGroup("OR")}
|
|
||||||
sx={{
|
|
||||||
textTransform: "none",
|
textTransform: "none",
|
||||||
alignSelf: "flex-start",
|
fontWeight: 600,
|
||||||
color: "#a5e0ff",
|
minHeight: 44,
|
||||||
borderColor: "rgba(125,183,255,0.5)",
|
opacity: 1,
|
||||||
borderRadius: 1.5,
|
borderRadius: 1,
|
||||||
}}
|
transition: "background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease",
|
||||||
>
|
"&:hover": {
|
||||||
Add Group
|
color: AURORA_SHELL.text,
|
||||||
</Button>
|
backgroundImage: TAB_HOVER_GRADIENT,
|
||||||
</Box>
|
boxShadow: "0 0 0 1px rgba(148,163,184,0.25) inset",
|
||||||
|
},
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
},
|
||||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
"& .Mui-selected": {
|
||||||
<Box>
|
color: AURORA_SHELL.text,
|
||||||
<Typography sx={{ fontWeight: 700 }}>Results</Typography>
|
"&:hover": {
|
||||||
<Typography sx={{ color: AURORA_SHELL.subtext, fontSize: "0.95rem" }}>
|
backgroundImage: TAB_HOVER_GRADIENT,
|
||||||
Apply criteria to preview matching devices.
|
},
|
||||||
</Typography>
|
},
|
||||||
{previewAppliedAt && (
|
|
||||||
<Typography sx={{ color: AURORA_SHELL.subtext, fontSize: "0.85rem" }}>
|
|
||||||
Last applied: {previewAppliedAt.toLocaleString()}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{previewError ? (
|
|
||||||
<Typography sx={{ color: "#ffb4b4", fontSize: "0.9rem", mt: 0.5 }}>{previewError}</Typography>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
startIcon={previewLoading ? <CachedIcon /> : <PlayIcon />}
|
|
||||||
onClick={applyCriteria}
|
|
||||||
disabled={previewLoading}
|
|
||||||
sx={gradientButtonSx}
|
|
||||||
>
|
|
||||||
{previewLoading ? "Applying..." : "Apply Criteria"}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
className={gridTheme.themeName}
|
|
||||||
sx={{
|
|
||||||
height: 420,
|
|
||||||
"& .ag-root-wrapper": { borderRadius: 1.5 },
|
|
||||||
"& .ag-cell.auto-col-tight": { paddingLeft: 2, paddingRight: 2 },
|
|
||||||
}}
|
}}
|
||||||
style={{
|
>
|
||||||
"--ag-icon-font-family": iconFontFamily,
|
{TABS.map((tabDef) => (
|
||||||
"--ag-background-color": "#070b1a",
|
<Tab key={tabDef.value} label={tabDef.label} value={tabDef.value} />
|
||||||
"--ag-foreground-color": "#f4f7ff",
|
))}
|
||||||
"--ag-header-background-color": "#0f172a",
|
</Tabs>
|
||||||
"--ag-header-foreground-color": "#cfe0ff",
|
|
||||||
"--ag-odd-row-background-color": "rgba(255,255,255,0.02)",
|
<TabPanel value="name" active={tab}>
|
||||||
"--ag-row-hover-color": "rgba(125,183,255,0.08)",
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||||
"--ag-selected-row-background-color": "rgba(64,164,255,0.18)",
|
<Typography sx={{ fontWeight: 700 }}>Name</Typography>
|
||||||
"--ag-border-color": "rgba(125,183,255,0.18)",
|
<TextField
|
||||||
"--ag-row-border-color": "rgba(125,183,255,0.14)",
|
size="small"
|
||||||
"--ag-border-radius": "8px",
|
value={name}
|
||||||
"--ag-checkbox-border-radius": "3px",
|
onChange={(e) => setName(e.target.value)}
|
||||||
"--ag-checkbox-background-color": "rgba(255,255,255,0.06)",
|
placeholder="Filter name or convention (e.g., RMM targeting)"
|
||||||
"--ag-checkbox-border-color": "rgba(180,200,220,0.6)",
|
sx={{
|
||||||
"--ag-checkbox-checked-color": "#7dd3fc",
|
width: { xs: "100%", md: "65%" },
|
||||||
}}
|
maxWidth: 546,
|
||||||
>
|
"& .MuiInputBase-root": { backgroundColor: "rgba(4,7,17,0.65)" },
|
||||||
<AgGridReact
|
"& .MuiOutlinedInput-notchedOutline": { borderColor: AURORA_SHELL.border },
|
||||||
rowData={previewRows}
|
}}
|
||||||
columnDefs={previewColumns}
|
|
||||||
defaultColDef={defaultPreviewColDef}
|
|
||||||
animateRows
|
|
||||||
rowHeight={46}
|
|
||||||
headerHeight={44}
|
|
||||||
suppressCellFocus
|
|
||||||
overlayNoRowsTemplate="<span class='ag-overlay-no-rows-center'>Apply criteria to preview devices.</span>"
|
|
||||||
onGridReady={handleGridReady}
|
|
||||||
theme={gridTheme}
|
|
||||||
pagination
|
|
||||||
paginationPageSize={20}
|
|
||||||
style={{ width: "100%", height: "100%", fontFamily: gridFontFamily }}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value="scope" active={tab}>
|
||||||
|
<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={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
|
<Switch
|
||||||
|
checked={applyToAllSites}
|
||||||
|
onChange={(e) => setApplyToAllSites(e.target.checked)}
|
||||||
|
color="info"
|
||||||
|
/>
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontWeight: 600 }}>Add filter to all Sites</Typography>
|
||||||
|
<Typography sx={{ color: AURORA_SHELL.subtext, fontSize: "0.9rem" }}>
|
||||||
|
Future sites will also inherit this filter when enabled.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{!applyToAllSites && (
|
||||||
|
<Autocomplete
|
||||||
|
disablePortal
|
||||||
|
loading={loadingSites}
|
||||||
|
options={sites}
|
||||||
|
value={sites.find((s) => s.value === targetSite) || null}
|
||||||
|
getOptionLabel={(option) => option?.label || ""}
|
||||||
|
isOptionEqualToValue={(option, value) => option?.value === value?.value}
|
||||||
|
onChange={(_, val) => setTargetSite(val?.value || "")}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Target Site"
|
||||||
|
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 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value="criteria" active={tab}>
|
||||||
|
<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" }} />
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ color: AURORA_SHELL.subtext, fontSize: "0.95rem", mb: 1 }}>
|
||||||
|
Add conditions inside each group, mixing AND/OR as needed. Groups themselves can be chained with AND or OR to
|
||||||
|
mirror complex targeting logic (e.g., (A AND B) OR (C AND D)).
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{groups.map((group, idx) => (
|
||||||
|
<Box key={group.id} sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||||
|
{idx > 0 && (
|
||||||
|
<ToggleButtonGroup
|
||||||
|
exclusive
|
||||||
|
size="small"
|
||||||
|
value={group.joinWith || "OR"}
|
||||||
|
onChange={(_, val) => {
|
||||||
|
if (!val) return;
|
||||||
|
updateGroup(group.id, { ...group, joinWith: val });
|
||||||
|
}}
|
||||||
|
color="info"
|
||||||
|
sx={{
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
"& .MuiToggleButton-root": { px: 2, textTransform: "uppercase", fontSize: "0.8rem" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ToggleButton value="AND">AND</ToggleButton>
|
||||||
|
<ToggleButton value="OR">OR</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
border: `1px solid ${AURORA_SHELL.border}`,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: "linear-gradient(135deg, rgba(7,10,22,0.85), rgba(9,11,24,0.92))",
|
||||||
|
p: 1.5,
|
||||||
|
boxShadow: "0 12px 28px rgba(3,7,18,0.5)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ pr: 0.5 }}>
|
||||||
|
<Typography sx={{ fontWeight: 600 }}>Criteria Group {idx + 1}</Typography>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => addCondition(group.id)}
|
||||||
|
sx={{
|
||||||
|
textTransform: "none",
|
||||||
|
color: "#7dd3fc",
|
||||||
|
borderColor: "rgba(125,211,252,0.5)",
|
||||||
|
borderRadius: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Condition
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<RemoveIcon />}
|
||||||
|
disabled={groups.length === 1}
|
||||||
|
onClick={() => removeGroup(group.id)}
|
||||||
|
sx={{
|
||||||
|
textTransform: "none",
|
||||||
|
color: "#ffb4b4",
|
||||||
|
borderColor: "rgba(255,180,180,0.5)",
|
||||||
|
borderRadius: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove Group
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{group.conditions.map((condition, cIdx) =>
|
||||||
|
renderConditionRow(group.id, condition, cIdx === 0)
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => addGroup("OR")}
|
||||||
|
sx={{
|
||||||
|
textTransform: "none",
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
color: "#a5e0ff",
|
||||||
|
borderColor: "rgba(125,183,255,0.5)",
|
||||||
|
borderRadius: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Group
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value="results" active={tab}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 1.5,
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
pb: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ flexShrink: 0 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontWeight: 700 }}>Results</Typography>
|
||||||
|
<Typography sx={{ color: AURORA_SHELL.subtext, fontSize: "0.95rem" }}>
|
||||||
|
Apply criteria to preview matching devices.
|
||||||
|
</Typography>
|
||||||
|
{previewAppliedAt && (
|
||||||
|
<Typography sx={{ color: AURORA_SHELL.subtext, fontSize: "0.85rem" }}>
|
||||||
|
Last applied: {previewAppliedAt.toLocaleString()}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{previewError ? (
|
||||||
|
<Typography sx={{ color: "#ffb4b4", fontSize: "0.9rem", mt: 0.5 }}>{previewError}</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={previewLoading ? <CachedIcon /> : <PlayIcon />}
|
||||||
|
onClick={applyCriteria}
|
||||||
|
disabled={previewLoading}
|
||||||
|
sx={gradientButtonSx}
|
||||||
|
>
|
||||||
|
{previewLoading ? "Applying..." : "Apply Criteria"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "auto",
|
||||||
|
pb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
className={gridTheme.themeName}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
height: "100%",
|
||||||
|
overflow: "hidden",
|
||||||
|
"& .ag-root-wrapper": { borderRadius: 1.5 },
|
||||||
|
"& .ag-cell.auto-col-tight": { paddingLeft: 2, paddingRight: 2 },
|
||||||
|
}}
|
||||||
|
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": "8px",
|
||||||
|
"--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={previewRows}
|
||||||
|
columnDefs={previewColumns}
|
||||||
|
defaultColDef={defaultPreviewColDef}
|
||||||
|
animateRows
|
||||||
|
rowHeight={46}
|
||||||
|
headerHeight={44}
|
||||||
|
suppressCellFocus
|
||||||
|
overlayNoRowsTemplate="<span class='ag-overlay-no-rows-center'>Apply criteria to preview devices.</span>"
|
||||||
|
onGridReady={handleGridReady}
|
||||||
|
theme={gridTheme}
|
||||||
|
pagination
|
||||||
|
paginationPageSize={20}
|
||||||
|
style={{ width: "100%", height: "100%", minHeight: "100%", fontFamily: gridFontFamily }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
{saveError ? (
|
{saveError ? (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Applies to all Borealis frontends. Use `Data/Engine/web-interface/src/Admin/Page
|
|||||||
- Overlays/menus: `rgba(8,12,24,0.96)` canvas, blurred backdrops, thin steel borders; bright typography; deep blue glass inputs; cyan confirm, mauve destructive accents.
|
- Overlays/menus: `rgba(8,12,24,0.96)` canvas, blurred backdrops, thin steel borders; bright typography; deep blue glass inputs; cyan confirm, mauve destructive accents.
|
||||||
|
|
||||||
## Aurora Tabs (MagicUI Tabbed Interfaces)
|
## Aurora Tabs (MagicUI Tabbed Interfaces)
|
||||||
- Placement: sit directly below the hero title/subtitle band (8–16px gap). Tabs span the full width of the content column and anchor secondary hero metrics (see `Scheduling/Create_Job.jsx` and `Devices/Device_Details.jsx`).
|
- Placement: sit directly below the hero title/subtitle band (8–16px gap). Tabs span the full width of the content column.
|
||||||
- Typography: IBM Plex Sans, `fontSize: 15`, mixed case labels (`textTransform: "none"`). Use `fontWeight: 600` for emphasis, but avoid uppercase that crowds the aurora glow.
|
- Typography: IBM Plex Sans, `fontSize: 15`, mixed case labels (`textTransform: "none"`). Use `fontWeight: 600` for emphasis, but avoid uppercase that crowds the aurora glow.
|
||||||
- Indicator: 3px tall bar with rounded corners that uses the cyan→violet aurora gradient `linear-gradient(90deg,#7dd3fc,#c084fc)`. Keep it flush with the bottom border so it looks like a light strip under the active tab.
|
- Indicator: 3px tall bar with rounded corners that uses the cyan→violet aurora gradient `linear-gradient(90deg,#7dd3fc,#c084fc)`. Keep it flush with the bottom border so it looks like a light strip under the active tab.
|
||||||
- Hover/active treatment: tabs float on a translucent aurora panel `linear-gradient(120deg, rgba(125,211,252,0.18), rgba(192,132,252,0.22))` with a 1px inset steel outline. This gradient applies on hover for both selected and non-selected tabs to keep parity.
|
- Hover/active treatment: tabs float on a translucent aurora panel `linear-gradient(120deg, rgba(125,211,252,0.18), rgba(192,132,252,0.22))` with a 1px inset steel outline. This gradient applies on hover for both selected and non-selected tabs to keep parity.
|
||||||
@@ -83,6 +83,13 @@ Applies to all Borealis frontends. Use `Data/Engine/web-interface/src/Admin/Page
|
|||||||
- Interaction rules: tabs should never scroll vertically; rely on horizontal scroll for overflow. Always align the tab rail with the first section header on the page so the aurora indicator lines up with hero metrics.
|
- Interaction rules: tabs should never scroll vertically; rely on horizontal scroll for overflow. Always align the tab rail with the first section header on the page so the aurora indicator lines up with hero metrics.
|
||||||
- Accessibility: keep `aria-label`/`aria-controls` pairs when the panes hold complex content, and ensure the gradient backgrounds preserve 4.5:1 contrast for the text (the current cyan on dark meets this).
|
- Accessibility: keep `aria-label`/`aria-controls` pairs when the panes hold complex content, and ensure the gradient backgrounds preserve 4.5:1 contrast for the text (the current cyan on dark meets this).
|
||||||
|
|
||||||
|
## Page-Level Actions with Tab Rails
|
||||||
|
- When a page uses Aurora Tabs, place primary/secondary page actions inline with the tab rail, floating on the top-right of the content layer (under the global nav).
|
||||||
|
- Wrap the tab stack and actions in a `position: relative` container so actions can be absolutely positioned without leaving the shell flow.
|
||||||
|
- Position the action bar as `position: "absolute", top: 12, right: 24, zIndex: 3` (adjust top/right to match your page padding) and keep it `display: "flex"` with a `Stack` for spacing.
|
||||||
|
- Use the same gradient primary pill and outlined secondary styles from the template; preserve the rounded 999 radius and MagicUI colors.
|
||||||
|
- Keep the bar above content but separate from the title/subtitle block—do not nest buttons inside the title grid. This matches the Filter Editor pattern and keeps tabs and actions visually aligned.
|
||||||
|
|
||||||
## AG Grid Column Behavior (All Tables)
|
## AG Grid Column Behavior (All Tables)
|
||||||
- Auto-size value columns and let the last column absorb remaining width so views span available space.
|
- Auto-size value columns and let the last column absorb remaining width so views span available space.
|
||||||
- Declare `AUTO_SIZE_COLUMNS` near the grid component (exclude the fill column).
|
- Declare `AUTO_SIZE_COLUMNS` near the grid component (exclude the fill column).
|
||||||
|
|||||||
Reference in New Issue
Block a user