Device Filter Editor UI Changes

This commit is contained in:
2025-11-28 06:55:57 -07:00
parent 736e659e40
commit c4c3aedea9
2 changed files with 427 additions and 327 deletions

View File

@@ -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>
) : null}
{loadError ? (
<Box <Box
sx={{ sx={{
width: 36, mb: 2,
height: 36, background: "rgba(255,179,179,0.08)",
borderRadius: 2, color: "#ffb4b4",
background: "linear-gradient(135deg, rgba(125,211,252,0.28), rgba(192,132,252,0.32))", border: "1px solid rgba(255,179,179,0.35)",
display: "flex", borderRadius: 1.5,
alignItems: "center", p: 1.5,
justifyContent: "center",
color: "#0f172a",
}} }}
> >
<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,32 +881,61 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
</Stack> </Stack>
</Box> </Box>
{loadingFilter ? (
<Box sx={{ mb: 2, color: "#7dd3fc" }}>Loading filter...</Box>
) : null}
{loadError ? (
<Box
sx={{
mb: 2,
background: "rgba(255,179,179,0.08)",
color: "#ffb4b4",
border: "1px solid rgba(255,179,179,0.35)",
borderRadius: 1.5,
p: 1.5,
}}
>
{loadError}
</Box>
) : null}
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 2.75, gap: 2,
flex: 1, flex: 1,
minHeight: 0,
position: "relative",
}} }}
> >
<Tabs
value={tab}
onChange={(_, val) => setTab(val)}
variant="scrollable"
scrollButtons="auto"
TabIndicatorProps={{
style: {
height: 3,
borderRadius: 3,
background: "linear-gradient(90deg,#7dd3fc,#c084fc)",
},
}}
sx={{
mt: 0,
borderBottom: `1px solid ${AURORA_SHELL.border}`,
"& .MuiTab-root": {
color: AURORA_SHELL.subtext,
fontFamily: gridFontFamily,
fontSize: 15,
textTransform: "none",
fontWeight: 600,
minHeight: 44,
opacity: 1,
borderRadius: 1,
transition: "background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease",
"&:hover": {
color: AURORA_SHELL.text,
backgroundImage: TAB_HOVER_GRADIENT,
boxShadow: "0 0 0 1px rgba(148,163,184,0.25) inset",
},
},
"& .Mui-selected": {
color: AURORA_SHELL.text,
"&:hover": {
backgroundImage: TAB_HOVER_GRADIENT,
},
},
}}
>
{TABS.map((tabDef) => (
<Tab key={tabDef.value} label={tabDef.label} value={tabDef.value} />
))}
</Tabs>
<TabPanel value="name" active={tab}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography sx={{ fontWeight: 700 }}>Name</Typography> <Typography sx={{ fontWeight: 700 }}>Name</Typography>
<TextField <TextField
@@ -882,14 +944,16 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="Filter name or convention (e.g., RMM targeting)" placeholder="Filter name or convention (e.g., RMM targeting)"
sx={{ sx={{
width: { xs: "100%", md: "50%" }, width: { xs: "100%", md: "65%" },
maxWidth: 420, maxWidth: 546,
"& .MuiInputBase-root": { backgroundColor: "rgba(4,7,17,0.65)" }, "& .MuiInputBase-root": { backgroundColor: "rgba(4,7,17,0.65)" },
"& .MuiOutlinedInput-notchedOutline": { borderColor: AURORA_SHELL.border }, "& .MuiOutlinedInput-notchedOutline": { borderColor: AURORA_SHELL.border },
}} }}
/> />
</Box> </Box>
</TabPanel>
<TabPanel value="scope" active={tab}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
<Typography sx={{ fontWeight: 700 }}>Scope</Typography> <Typography sx={{ fontWeight: 700 }}>Scope</Typography>
<Typography sx={{ color: AURORA_SHELL.subtext, fontSize: "0.95rem" }}> <Typography sx={{ color: AURORA_SHELL.subtext, fontSize: "0.95rem" }}>
@@ -973,7 +1037,9 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
</Box> </Box>
)} )}
</Box> </Box>
</TabPanel>
<TabPanel value="criteria" active={tab}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.75 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2.75 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Typography sx={{ fontWeight: 700 }}>Criteria</Typography> <Typography sx={{ fontWeight: 700 }}>Criteria</Typography>
@@ -997,7 +1063,7 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
}} }}
color="info" color="info"
sx={{ sx={{
alignSelf: "center", alignSelf: "flex-start",
"& .MuiToggleButton-root": { px: 2, textTransform: "uppercase", fontSize: "0.8rem" }, "& .MuiToggleButton-root": { px: 2, textTransform: "uppercase", fontSize: "0.8rem" },
}} }}
> >
@@ -1018,7 +1084,7 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
gap: 1, gap: 1,
}} }}
> >
<Stack direction="row" alignItems="center" justifyContent="space-between"> <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ pr: 0.5 }}>
<Typography sx={{ fontWeight: 600 }}>Criteria Group {idx + 1}</Typography> <Typography sx={{ fontWeight: 600 }}>Criteria Group {idx + 1}</Typography>
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
<Button <Button
@@ -1077,9 +1143,21 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
Add Group Add Group
</Button> </Button>
</Box> </Box>
</TabPanel>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}> <TabPanel value="results" active={tab}>
<Stack direction="row" alignItems="center" justifyContent="space-between"> <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> <Box>
<Typography sx={{ fontWeight: 700 }}>Results</Typography> <Typography sx={{ fontWeight: 700 }}>Results</Typography>
<Typography sx={{ color: AURORA_SHELL.subtext, fontSize: "0.95rem" }}> <Typography sx={{ color: AURORA_SHELL.subtext, fontSize: "0.95rem" }}>
@@ -1105,10 +1183,23 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
</Button> </Button>
</Stack> </Stack>
<Box
sx={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
overflow: "auto",
pb: 1,
}}
>
<Box <Box
className={gridTheme.themeName} className={gridTheme.themeName}
sx={{ sx={{
height: 420, flex: 1,
minHeight: 0,
height: "100%",
overflow: "hidden",
"& .ag-root-wrapper": { borderRadius: 1.5 }, "& .ag-root-wrapper": { borderRadius: 1.5 },
"& .ag-cell.auto-col-tight": { paddingLeft: 2, paddingRight: 2 }, "& .ag-cell.auto-col-tight": { paddingLeft: 2, paddingRight: 2 },
}} }}
@@ -1143,10 +1234,12 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
theme={gridTheme} theme={gridTheme}
pagination pagination
paginationPageSize={20} paginationPageSize={20}
style={{ width: "100%", height: "100%", fontFamily: gridFontFamily }} style={{ width: "100%", height: "100%", minHeight: "100%", fontFamily: gridFontFamily }}
/> />
</Box> </Box>
</Box> </Box>
</Box>
</TabPanel>
{saveError ? ( {saveError ? (
<Box <Box

View File

@@ -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 (816px 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 (816px 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).