mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-17 19:55:48 -07:00
Initial Development of Device Filter System
This commit is contained in:
@@ -38,6 +38,8 @@ import DeviceDetails from "./Devices/Device_Details";
|
||||
import AgentDevices from "./Devices/Agent_Devices.jsx";
|
||||
import SSHDevices from "./Devices/SSH_Devices.jsx";
|
||||
import WinRMDevices from "./Devices/WinRM_Devices.jsx";
|
||||
import DeviceFilterList from "./Devices/Filters/Filter_List.jsx";
|
||||
import DeviceFilterEditor from "./Devices/Filters/Filter_Editor.jsx";
|
||||
import AssemblyList from "./Assemblies/Assembly_List";
|
||||
import AssemblyEditor from "./Assemblies/Assembly_Editor";
|
||||
import ScheduledJobsList from "./Scheduling/Scheduled_Jobs_List";
|
||||
@@ -116,6 +118,8 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
const [jobsRefreshToken, setJobsRefreshToken] = useState(0);
|
||||
const [quickJobDraft, setQuickJobDraft] = useState(null);
|
||||
const [assemblyEditorState, setAssemblyEditorState] = useState(null); // { mode: 'script'|'ansible', row, nonce }
|
||||
const [filterEditorState, setFilterEditorState] = useState(null);
|
||||
const [filtersRefreshToken, setFiltersRefreshToken] = useState(0);
|
||||
const [sessionResolved, setSessionResolved] = useState(false);
|
||||
const initialPathRef = useRef(window.location.pathname + window.location.search);
|
||||
const pendingPathRef = useRef(null);
|
||||
@@ -177,6 +181,21 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
return "/devices/ssh";
|
||||
case "winrm_devices":
|
||||
return "/devices/winrm";
|
||||
case "filters":
|
||||
return "/devices/filters";
|
||||
case "filter_editor": {
|
||||
const params = new URLSearchParams();
|
||||
const filterId =
|
||||
options.filterId ||
|
||||
filterEditorState?.id ||
|
||||
filterEditorState?.filter_id ||
|
||||
filterEditorState?.raw?.id ||
|
||||
filterEditorState?.raw?.filter_id ||
|
||||
null;
|
||||
if (filterId) params.set("id", filterId);
|
||||
const query = params.toString();
|
||||
return query ? `/devices/filters/editor?${query}` : "/devices/filters/editor";
|
||||
}
|
||||
case "device_details": {
|
||||
const device =
|
||||
options.device ||
|
||||
@@ -254,6 +273,11 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
if (path === "/devices/agent") return { page: "agent_devices", options: {} };
|
||||
if (path === "/devices/ssh") return { page: "ssh_devices", options: {} };
|
||||
if (path === "/devices/winrm") return { page: "winrm_devices", options: {} };
|
||||
if (path === "/devices/filters") return { page: "filters", options: {} };
|
||||
if (path === "/devices/filters/editor") {
|
||||
const filterId = params.get("id");
|
||||
return { page: "filter_editor", options: filterId ? { filterId } : {} };
|
||||
}
|
||||
if (segments[0] === "device" && segments[1]) {
|
||||
const id = decodeURIComponent(segments[1]);
|
||||
return {
|
||||
@@ -314,8 +338,17 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
if ((page === "scripts" || page === "ansible_editor") && options.assemblyState) {
|
||||
setAssemblyEditorState(options.assemblyState);
|
||||
}
|
||||
if (page === "filter_editor") {
|
||||
if (options.filter) {
|
||||
setFilterEditorState(options.filter);
|
||||
} else if (options.filterId && !filterEditorState) {
|
||||
setFilterEditorState({ id: options.filterId });
|
||||
}
|
||||
} else if (!options.preserveFilter) {
|
||||
setFilterEditorState(null);
|
||||
}
|
||||
},
|
||||
[setAssemblyEditorState, setCurrentPageState, setSelectedDevice]
|
||||
[filterEditorState, setAssemblyEditorState, setCurrentPageState, setFilterEditorState, setSelectedDevice]
|
||||
);
|
||||
|
||||
const navigateTo = useCallback(
|
||||
@@ -516,6 +549,11 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
items.push({ label: "Filters & Groups", page: "filters" });
|
||||
items.push({ label: "Filters", page: "filters" });
|
||||
break;
|
||||
case "filter_editor":
|
||||
items.push({ label: "Filters & Groups", page: "filters" });
|
||||
items.push({ label: "Filters", page: "filters" });
|
||||
items.push({ label: filterEditorState?.name ? `Edit ${filterEditorState.name}` : "Filter Editor" });
|
||||
break;
|
||||
case "groups":
|
||||
items.push({ label: "Filters & Groups", page: "filters" });
|
||||
items.push({ label: "Groups", page: "groups" });
|
||||
@@ -525,7 +563,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
if (currentPage) items.push({ label: String(currentPage) });
|
||||
}
|
||||
return items;
|
||||
}, [currentPage, selectedDevice, editingJob]);
|
||||
}, [currentPage, selectedDevice, editingJob, filterEditorState]);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
@@ -1088,6 +1126,37 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
case "winrm_devices":
|
||||
return <WinRMDevices onQuickJobLaunch={handleQuickJobLaunch} />;
|
||||
|
||||
case "filters":
|
||||
return (
|
||||
<DeviceFilterList
|
||||
refreshToken={filtersRefreshToken}
|
||||
onCreateFilter={() => {
|
||||
setFilterEditorState(null);
|
||||
navigateTo("filter_editor");
|
||||
}}
|
||||
onEditFilter={(filter) => {
|
||||
setFilterEditorState(filter);
|
||||
navigateTo("filter_editor", { filterId: filter?.id });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case "filter_editor":
|
||||
return (
|
||||
<DeviceFilterEditor
|
||||
initialFilter={filterEditorState}
|
||||
onCancel={() => {
|
||||
setFilterEditorState(null);
|
||||
navigateTo("filters");
|
||||
}}
|
||||
onSaved={(filter) => {
|
||||
setFilterEditorState(null);
|
||||
setFiltersRefreshToken(Date.now());
|
||||
navigateTo("filters");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case "device_details":
|
||||
return (
|
||||
<DeviceDetails
|
||||
|
||||
755
Data/Engine/web-interface/src/Devices/Filters/Filter_Editor.jsx
Normal file
755
Data/Engine/web-interface/src/Devices/Filters/Filter_Editor.jsx
Normal file
@@ -0,0 +1,755 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Paper,
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
IconButton,
|
||||
Stack,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Switch,
|
||||
Chip,
|
||||
Tooltip,
|
||||
Autocomplete,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
FilterAlt as HeaderIcon,
|
||||
Save as SaveIcon,
|
||||
Close as CloseIcon,
|
||||
Add as AddIcon,
|
||||
Remove as RemoveIcon,
|
||||
Cached as CachedIcon,
|
||||
} from "@mui/icons-material";
|
||||
|
||||
const AURORA_SHELL = {
|
||||
background:
|
||||
"radial-gradient(120% 120% at 0% 0%, rgba(76, 186, 255, 0.16), transparent 55%), " +
|
||||
"radial-gradient(120% 120% at 100% 0%, rgba(214, 130, 255, 0.18), transparent 60%), #040711",
|
||||
text: "#e2e8f0",
|
||||
subtext: "#94a3b8",
|
||||
border: "rgba(148,163,184,0.35)",
|
||||
glass: "rgba(15,23,42,0.72)",
|
||||
};
|
||||
|
||||
const gradientButtonSx = {
|
||||
backgroundImage: "linear-gradient(135deg,#7dd3fc,#c084fc)",
|
||||
color: "#0b1220",
|
||||
borderRadius: 999,
|
||||
textTransform: "none",
|
||||
boxShadow: "0 10px 26px rgba(124,58,237,0.28)",
|
||||
px: 2.4,
|
||||
minWidth: 126,
|
||||
"&:hover": {
|
||||
backgroundImage: "linear-gradient(135deg,#86e1ff,#d1a6ff)",
|
||||
boxShadow: "0 12px 34px rgba(124,58,237,0.38)",
|
||||
},
|
||||
};
|
||||
|
||||
const DEVICE_FIELDS = [
|
||||
{ value: "hostname", label: "Hostname" },
|
||||
{ value: "description", label: "Description" },
|
||||
{ value: "site", label: "Site" },
|
||||
{ value: "os", label: "Operating System" },
|
||||
{ value: "type", label: "Device Type" },
|
||||
{ value: "status", label: "Status" },
|
||||
{ value: "agentVersion", label: "Agent Version" },
|
||||
{ value: "lastUser", label: "Last User" },
|
||||
{ value: "internalIp", label: "Internal IP" },
|
||||
{ value: "externalIp", label: "External IP" },
|
||||
{ value: "lastReboot", label: "Last Reboot" },
|
||||
{ value: "lastSeen", label: "Last Seen" },
|
||||
{ value: "domain", label: "Domain" },
|
||||
{ value: "memory", label: "Memory" },
|
||||
{ value: "network", label: "Network" },
|
||||
{ value: "software", label: "Software" },
|
||||
{ value: "storage", label: "Storage" },
|
||||
{ value: "cpu", label: "CPU" },
|
||||
{ value: "agentId", label: "Agent ID" },
|
||||
{ value: "agentGuid", label: "Agent GUID" },
|
||||
];
|
||||
|
||||
const OPERATORS = [
|
||||
{ value: "contains", label: "contains" },
|
||||
{ value: "not_contains", label: "does not contain" },
|
||||
{ value: "empty", label: "is empty" },
|
||||
{ value: "not_empty", label: "is not empty" },
|
||||
{ value: "begins_with", label: "begins with" },
|
||||
{ value: "not_begins_with", label: "does not begin with" },
|
||||
{ value: "ends_with", label: "ends with" },
|
||||
{ value: "not_ends_with", label: "does not end with" },
|
||||
{ value: "equals", label: "equals" },
|
||||
{ value: "not_equals", label: "does not equal" },
|
||||
];
|
||||
|
||||
const operatorNeedsValue = (op) => !["empty", "not_empty"].includes(op);
|
||||
const genId = (prefix) =>
|
||||
`${prefix}-${typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
const buildEmptyCondition = () => ({
|
||||
id: genId("condition"),
|
||||
field: DEVICE_FIELDS[0].value,
|
||||
operator: "contains",
|
||||
value: "",
|
||||
joinWith: "AND",
|
||||
});
|
||||
|
||||
const buildEmptyGroup = (joinWith = null) => ({
|
||||
id: genId("group"),
|
||||
joinWith,
|
||||
conditions: [buildEmptyCondition()],
|
||||
});
|
||||
|
||||
const resolveApplyAll = (filter) => Boolean(filter?.applyToAllSites ?? filter?.apply_to_all_sites);
|
||||
|
||||
const resolveLastEdited = (filter) =>
|
||||
filter?.lastEdited || filter?.last_edited || filter?.updated_at || filter?.updated || null;
|
||||
|
||||
const resolveSiteScope = (filter) => {
|
||||
const raw = filter?.site_scope || filter?.siteScope || filter?.scope || filter?.type;
|
||||
const normalized = String(raw || "").toLowerCase();
|
||||
return normalized === "scoped" ? "scoped" : "global";
|
||||
};
|
||||
|
||||
const resolveGroups = (filter) => {
|
||||
const candidate = filter?.groups || filter?.raw?.groups;
|
||||
if (candidate && Array.isArray(candidate) && candidate.length) return candidate;
|
||||
return [buildEmptyGroup()];
|
||||
};
|
||||
|
||||
export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved }) {
|
||||
const [name, setName] = useState(initialFilter?.name || "");
|
||||
const initialScope = resolveSiteScope(initialFilter);
|
||||
const [scope, setScope] = useState(initialScope === "scoped" ? "site" : "global");
|
||||
const [applyToAllSites, setApplyToAllSites] = useState(initialScope !== "scoped");
|
||||
const [targetSite, setTargetSite] = useState(initialFilter?.site || initialFilter?.siteName || "");
|
||||
const [groups, setGroups] = useState(resolveGroups(initialFilter));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState(null);
|
||||
const [sites, setSites] = useState([]);
|
||||
const [loadingSites, setLoadingSites] = useState(false);
|
||||
const [lastEditedTs, setLastEditedTs] = useState(resolveLastEdited(initialFilter));
|
||||
const [loadingFilter, setLoadingFilter] = useState(false);
|
||||
const [loadError, setLoadError] = useState(null);
|
||||
|
||||
const applyFilterData = useCallback((filter) => {
|
||||
if (!filter) return;
|
||||
setName(filter?.name || "");
|
||||
const resolvedScope = resolveSiteScope(filter);
|
||||
setScope(resolvedScope === "scoped" ? "site" : "global");
|
||||
setApplyToAllSites(resolvedScope !== "scoped");
|
||||
setTargetSite(filter?.site || filter?.site_scope || filter?.siteName || filter?.site_name || "");
|
||||
setGroups(resolveGroups(filter));
|
||||
setLastEditedTs(resolveLastEdited(filter));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
applyFilterData(initialFilter);
|
||||
}, [applyFilterData, initialFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialFilter?.id) return;
|
||||
const missingGroups = !initialFilter.groups || initialFilter.groups.length === 0;
|
||||
if (!missingGroups) return;
|
||||
let canceled = false;
|
||||
setLoadingFilter(true);
|
||||
setLoadError(null);
|
||||
fetch(`/api/device_filters/${encodeURIComponent(initialFilter.id)}`)
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`Failed to load filter (${r.status})`))))
|
||||
.then((data) => {
|
||||
if (canceled) return;
|
||||
if (data?.filter) {
|
||||
applyFilterData(data.filter);
|
||||
} else if (data) {
|
||||
applyFilterData(data);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (canceled) return;
|
||||
setLoadError(err?.message || "Unable to load filter");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!canceled) setLoadingFilter(false);
|
||||
});
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [applyFilterData, initialFilter]);
|
||||
|
||||
const loadSites = useCallback(async () => {
|
||||
setLoadingSites(true);
|
||||
try {
|
||||
const resp = await fetch("/api/sites");
|
||||
const json = await resp.json().catch(() => []);
|
||||
const siteList = Array.isArray(json?.sites) ? json.sites : Array.isArray(json) ? json : [];
|
||||
const normalized = siteList.map((s, idx) => ({
|
||||
label: s.name || s.site_name || `Site ${idx + 1}`,
|
||||
value: s.id || s.site_id || s.name || s.site_name || idx,
|
||||
}));
|
||||
setSites(normalized);
|
||||
} catch {
|
||||
setSites([]);
|
||||
} finally {
|
||||
setLoadingSites(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSites();
|
||||
}, [loadSites]);
|
||||
|
||||
const updateGroup = useCallback((groupId, updater) => {
|
||||
setGroups((prev) =>
|
||||
prev.map((g) => {
|
||||
if (g.id !== groupId) return g;
|
||||
const next = typeof updater === "function" ? updater(g) : updater;
|
||||
return { ...next };
|
||||
})
|
||||
);
|
||||
}, []);
|
||||
|
||||
const updateCondition = useCallback((groupId, conditionId, updater) => {
|
||||
updateGroup(groupId, (group) => ({
|
||||
...group,
|
||||
conditions: group.conditions.map((c, idx) => {
|
||||
if (c.id !== conditionId) return c;
|
||||
const updated = typeof updater === "function" ? updater(c, idx) : updater;
|
||||
return { ...updated };
|
||||
}),
|
||||
}));
|
||||
}, [updateGroup]);
|
||||
|
||||
const addCondition = useCallback((groupId) => {
|
||||
updateGroup(groupId, (group) => ({
|
||||
...group,
|
||||
conditions: [
|
||||
...group.conditions,
|
||||
{ ...buildEmptyCondition(), joinWith: group.conditions.length === 0 ? null : "AND" },
|
||||
],
|
||||
}));
|
||||
}, [updateGroup]);
|
||||
|
||||
const removeCondition = useCallback((groupId, conditionId) => {
|
||||
updateGroup(groupId, (group) => {
|
||||
const filtered = group.conditions.filter((c) => c.id !== conditionId);
|
||||
return { ...group, conditions: filtered.length ? filtered : [buildEmptyCondition()] };
|
||||
});
|
||||
}, [updateGroup]);
|
||||
|
||||
const addGroup = useCallback((joinWith = "OR") => {
|
||||
setGroups((prev) => [...prev, buildEmptyGroup(prev.length === 0 ? null : joinWith)]);
|
||||
}, []);
|
||||
|
||||
const removeGroup = useCallback((groupId) => {
|
||||
setGroups((prev) => {
|
||||
const filtered = prev.filter((g) => g.id !== groupId);
|
||||
if (!filtered.length) return [buildEmptyGroup()];
|
||||
const next = filtered.map((g, idx) => ({ ...g, joinWith: idx === 0 ? null : g.joinWith || "OR" }));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
const siteScope = scope === "site" && !applyToAllSites ? "scoped" : "global";
|
||||
const payload = {
|
||||
id: initialFilter?.id || initialFilter?.filter_id,
|
||||
name: name.trim() || "Unnamed Filter",
|
||||
site_scope: siteScope,
|
||||
site: siteScope === "scoped" ? targetSite : null,
|
||||
groups: groups.map((g, gIdx) => ({
|
||||
join_with: gIdx === 0 ? null : g.joinWith || "OR",
|
||||
conditions: (g.conditions || []).map((c, cIdx) => ({
|
||||
join_with: cIdx === 0 ? null : c.joinWith || "AND",
|
||||
field: c.field,
|
||||
operator: c.operator,
|
||||
value: operatorNeedsValue(c.operator) ? c.value : "",
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
const method = payload.id ? "PUT" : "POST";
|
||||
const url = payload.id ? `/api/device_filters/${encodeURIComponent(payload.id)}` : "/api/device_filters";
|
||||
const resp = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to save filter (${resp.status})`);
|
||||
}
|
||||
const json = await resp.json().catch(() => ({}));
|
||||
const saved = json?.filter || json || payload;
|
||||
onSaved?.(saved);
|
||||
} catch (err) {
|
||||
setSaveError(err?.message || "Unable to save filter");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [applyToAllSites, groups, initialFilter, name, onSaved, scope, targetSite]);
|
||||
|
||||
const renderConditionRow = (groupId, condition, isFirst) => {
|
||||
const label = DEVICE_FIELDS.find((f) => f.value === condition.field)?.label || condition.field;
|
||||
const needsValue = operatorNeedsValue(condition.operator);
|
||||
return (
|
||||
<Box
|
||||
key={condition.id}
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "120px 220px 220px 1fr auto",
|
||||
gap: 1,
|
||||
alignItems: "center",
|
||||
background: "rgba(12,18,35,0.7)",
|
||||
border: `1px solid ${AURORA_SHELL.border}`,
|
||||
borderRadius: 2,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
{!isFirst && (
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
size="small"
|
||||
value={condition.joinWith || "AND"}
|
||||
onChange={(_, val) => {
|
||||
if (!val) return;
|
||||
updateCondition(groupId, condition.id, (c) => ({ ...c, joinWith: val }));
|
||||
}}
|
||||
color="info"
|
||||
sx={{
|
||||
"& .MuiToggleButton-root": {
|
||||
px: 1.5,
|
||||
textTransform: "uppercase",
|
||||
fontSize: "0.7rem",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="AND">AND</ToggleButton>
|
||||
<ToggleButton value="OR">OR</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Autocomplete
|
||||
disablePortal
|
||||
options={DEVICE_FIELDS}
|
||||
value={DEVICE_FIELDS.find((f) => f.value === condition.field) || DEVICE_FIELDS[0]}
|
||||
getOptionLabel={(option) => option?.label || ""}
|
||||
isOptionEqualToValue={(option, value) => option?.value === value?.value}
|
||||
onChange={(_, val) =>
|
||||
updateCondition(groupId, condition.id, (c) => ({ ...c, field: val?.value || DEVICE_FIELDS[0].value }))
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Field"
|
||||
size="small"
|
||||
sx={{
|
||||
"& .MuiInputBase-root": { backgroundColor: "rgba(4,7,17,0.65)" },
|
||||
"& .MuiOutlinedInput-notchedOutline": { borderColor: AURORA_SHELL.border },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label="Operator"
|
||||
value={condition.operator}
|
||||
onChange={(e) =>
|
||||
updateCondition(groupId, condition.id, (c) => ({ ...c, operator: e.target.value }))
|
||||
}
|
||||
SelectProps={{ native: true }}
|
||||
sx={{
|
||||
"& .MuiInputBase-root": { backgroundColor: "rgba(4,7,17,0.65)" },
|
||||
"& .MuiOutlinedInput-notchedOutline": { borderColor: AURORA_SHELL.border },
|
||||
}}
|
||||
>
|
||||
{OPERATORS.map((op) => (
|
||||
<option key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</option>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label={`Value${needsValue ? "" : " (ignored)"}`}
|
||||
value={condition.value}
|
||||
onChange={(e) =>
|
||||
updateCondition(groupId, condition.id, (c) => ({ ...c, value: e.target.value }))
|
||||
}
|
||||
disabled={!needsValue}
|
||||
placeholder={needsValue ? `Enter value for ${label}` : "Not needed for this operator"}
|
||||
sx={{
|
||||
"& .MuiInputBase-root": { backgroundColor: "rgba(4,7,17,0.65)" },
|
||||
"& .MuiOutlinedInput-notchedOutline": { borderColor: AURORA_SHELL.border },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
|
||||
<Tooltip title="Add condition">
|
||||
<IconButton size="small" onClick={() => addCondition(groupId)} sx={{ color: "#7dd3fc" }}>
|
||||
<AddIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Remove condition">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => removeCondition(groupId, condition.id)}
|
||||
sx={{ color: "#ffb4b4" }}
|
||||
>
|
||||
<RemoveIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
background: AURORA_SHELL.background,
|
||||
color: AURORA_SHELL.text,
|
||||
p: 3,
|
||||
borderRadius: 0,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 3 }}>
|
||||
<Box sx={{ display: "flex", gap: 1.5, alignItems: "flex-start" }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 2,
|
||||
background: "linear-gradient(135deg, rgba(125,211,252,0.28), rgba(192,132,252,0.32))",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#0f172a",
|
||||
}}
|
||||
>
|
||||
<HeaderIcon fontSize="small" />
|
||||
</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 }}>
|
||||
Last edited {new Date(lastEditedTs).toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Tooltip title="Cancel and return">
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<CloseIcon />}
|
||||
onClick={() => onCancel?.()}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
borderColor: AURORA_SHELL.border,
|
||||
color: AURORA_SHELL.text,
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Save filter">
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={saving ? <CachedIcon /> : <SaveIcon />}
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
sx={gradientButtonSx}
|
||||
>
|
||||
{saving ? "Saving..." : "Save Filter"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</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
|
||||
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)",
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontWeight: 700, mb: 1 }}>Name</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Filter name or convention (e.g., RMM targeting)"
|
||||
sx={{
|
||||
"& .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>
|
||||
|
||||
{scope === "site" && (
|
||||
<Box sx={{ mt: 2, display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
<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={{
|
||||
"& .MuiInputBase-root": { backgroundColor: "rgba(4,7,17,0.65)" },
|
||||
"& .MuiOutlinedInput-notchedOutline": { borderColor: AURORA_SHELL.border },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</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", 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",
|
||||
alignSelf: "flex-start",
|
||||
color: "#a5e0ff",
|
||||
borderColor: "rgba(125,183,255,0.5)",
|
||||
borderRadius: 1.5,
|
||||
}}
|
||||
>
|
||||
Add Group
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{saveError ? (
|
||||
<Box
|
||||
sx={{
|
||||
background: "rgba(255,179,179,0.08)",
|
||||
color: "#ffb4b4",
|
||||
border: "1px solid rgba(255,179,179,0.35)",
|
||||
borderRadius: 1.5,
|
||||
p: 1.5,
|
||||
}}
|
||||
>
|
||||
{saveError}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
397
Data/Engine/web-interface/src/Devices/Filters/Filter_List.jsx
Normal file
397
Data/Engine/web-interface/src/Devices/Filters/Filter_List.jsx
Normal file
@@ -0,0 +1,397 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Paper,
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
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";
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
const gridTheme = themeQuartz.withParams({
|
||||
accentColor: "#8b5cf6",
|
||||
backgroundColor: "#070b1a",
|
||||
browserColorScheme: "dark",
|
||||
fontFamily: { googleFont: "IBM Plex Sans" },
|
||||
foregroundColor: "#f4f7ff",
|
||||
headerFontSize: 13,
|
||||
});
|
||||
const gridFontFamily = "'IBM Plex Sans','Helvetica Neue',Arial,sans-serif";
|
||||
const iconFontFamily = "'Quartz Regular'";
|
||||
|
||||
const AURORA_SHELL = {
|
||||
background:
|
||||
"radial-gradient(120% 120% at 0% 0%, rgba(76, 186, 255, 0.16), transparent 55%), " +
|
||||
"radial-gradient(120% 120% at 100% 0%, rgba(214, 130, 255, 0.18), transparent 60%), #040711",
|
||||
text: "#e2e8f0",
|
||||
subtext: "#94a3b8",
|
||||
accent: "#7dd3fc",
|
||||
};
|
||||
|
||||
const gradientButtonSx = {
|
||||
backgroundImage: "linear-gradient(135deg,#7dd3fc,#c084fc)",
|
||||
color: "#0b1220",
|
||||
borderRadius: 999,
|
||||
textTransform: "none",
|
||||
boxShadow: "0 10px 26px rgba(124,58,237,0.28)",
|
||||
px: 2.4,
|
||||
minWidth: 126,
|
||||
"&:hover": {
|
||||
backgroundImage: "linear-gradient(135deg,#86e1ff,#d1a6ff)",
|
||||
boxShadow: "0 12px 34px rgba(124,58,237,0.38)",
|
||||
},
|
||||
};
|
||||
|
||||
const AUTO_SIZE_COLUMNS = ["name", "type", "site", "lastEditedBy", "lastEdited"];
|
||||
|
||||
const SAMPLE_ROWS = [
|
||||
{
|
||||
id: "sample-global",
|
||||
name: "Windows Workstations",
|
||||
type: "global",
|
||||
site: null,
|
||||
lastEditedBy: "System",
|
||||
lastEdited: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "sample-site",
|
||||
name: "West Campus Servers",
|
||||
type: "site",
|
||||
site: "West Campus",
|
||||
lastEditedBy: "Demo User",
|
||||
lastEdited: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
function formatTimestamp(ts) {
|
||||
if (!ts) return "—";
|
||||
const date = new Date(ts);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function normalizeFilters(raw) {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map((f, idx) => ({
|
||||
id: f.id || f.filter_id || `filter-${idx}`,
|
||||
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",
|
||||
lastEdited: f.last_edited || f.updated_at || f.updated || f.created_at || null,
|
||||
raw: f,
|
||||
}));
|
||||
}
|
||||
|
||||
export default function DeviceFilterList({ onCreateFilter, onEditFilter, refreshToken }) {
|
||||
const gridRef = useRef(null);
|
||||
const [rows, setRows] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const loadFilters = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const resp = await fetch("/api/device_filters");
|
||||
if (resp.status === 404) {
|
||||
// Endpoint not available yet; surface sample data without hard failure
|
||||
setRows(normalizeFilters(SAMPLE_ROWS));
|
||||
setError("Device filter API not found (404) — showing sample filters.");
|
||||
} else {
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to load filters (${resp.status})`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
const normalized = normalizeFilters(data?.filters || data);
|
||||
setRows(normalized);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err?.message || "Unable to load filters");
|
||||
setRows((prev) => (prev.length ? prev : normalizeFilters(SAMPLE_ROWS)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadFilters();
|
||||
}, [loadFilters, refreshToken]);
|
||||
|
||||
const handleGridReady = useCallback((params) => {
|
||||
gridRef.current = params.api;
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
params.api.autoSizeColumns(AUTO_SIZE_COLUMNS, true);
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const autoSize = useCallback(() => {
|
||||
if (!gridRef.current || loading || !rows.length) return;
|
||||
const api = gridRef.current;
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
api.autoSizeColumns(AUTO_SIZE_COLUMNS, true);
|
||||
} catch {
|
||||
/* ignore autosize failures */
|
||||
}
|
||||
});
|
||||
}, [loading, rows.length]);
|
||||
|
||||
useEffect(() => {
|
||||
autoSize();
|
||||
}, [rows, loading, autoSize]);
|
||||
|
||||
const columnDefs = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
headerName: "Filter Name",
|
||||
field: "name",
|
||||
minWidth: 200,
|
||||
cellRenderer: (params) => {
|
||||
const value = params.value || "Unnamed Filter";
|
||||
return (
|
||||
<Button
|
||||
onClick={() => onEditFilter?.(params.data)}
|
||||
variant="text"
|
||||
size="small"
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
color: "#7dd3fc",
|
||||
fontWeight: 600,
|
||||
px: 0,
|
||||
minWidth: "unset",
|
||||
"&:hover": { color: "#a5e7ff", textDecoration: "underline" },
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cellClass: "auto-col-tight",
|
||||
},
|
||||
{
|
||||
headerName: "Type",
|
||||
field: "type",
|
||||
width: 120,
|
||||
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" }} />;
|
||||
},
|
||||
cellClass: "auto-col-tight",
|
||||
},
|
||||
{
|
||||
headerName: "Site",
|
||||
field: "site",
|
||||
minWidth: 140,
|
||||
cellRenderer: (params) => {
|
||||
const value = params.value;
|
||||
return value ? value : "—";
|
||||
},
|
||||
cellClass: "auto-col-tight",
|
||||
},
|
||||
{
|
||||
headerName: "Last Edited By",
|
||||
field: "lastEditedBy",
|
||||
minWidth: 160,
|
||||
cellClass: "auto-col-tight",
|
||||
},
|
||||
{
|
||||
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>
|
||||
),
|
||||
cellClass: "auto-col-tight",
|
||||
},
|
||||
];
|
||||
}, [onEditFilter]);
|
||||
|
||||
const defaultColDef = useMemo(
|
||||
() => ({
|
||||
sortable: true,
|
||||
filter: "agTextColumnFilter",
|
||||
resizable: true,
|
||||
cellClass: "auto-col-tight",
|
||||
suppressMenu: true,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
background: AURORA_SHELL.background,
|
||||
color: AURORA_SHELL.text,
|
||||
p: 3,
|
||||
borderRadius: 0,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 2.5 }}>
|
||||
<Box sx={{ display: "flex", gap: 1.5, alignItems: "flex-start" }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 2,
|
||||
background: "linear-gradient(135deg, rgba(125,211,252,0.28), rgba(192,132,252,0.32))",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#0f172a",
|
||||
}}
|
||||
>
|
||||
<HeaderIcon fontSize="small" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: "1.35rem", fontWeight: 700, lineHeight: 1.2 }}>
|
||||
Device Filters
|
||||
</Typography>
|
||||
<Typography sx={{ color: AURORA_SHELL.subtext, mt: 0.2 }}>
|
||||
Build reusable filter definitions to target devices and assemblies without per-site duplication.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" gap={1}>
|
||||
<Tooltip title="Refresh">
|
||||
<IconButton
|
||||
aria-label="Refresh filters"
|
||||
onClick={loadFilters}
|
||||
sx={{
|
||||
color: "#a5e0ff",
|
||||
border: "1px solid rgba(148,163,184,0.4)",
|
||||
backgroundColor: "rgba(5,7,15,0.6)",
|
||||
"&:hover": { backgroundColor: "rgba(125,183,255,0.16)" },
|
||||
}}
|
||||
>
|
||||
<CachedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
variant="contained"
|
||||
onClick={() => onCreateFilter?.()}
|
||||
sx={gradientButtonSx}
|
||||
>
|
||||
New Filter
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
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={{
|
||||
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>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -62,7 +62,7 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
|
||||
"admin_device_approvals",
|
||||
].includes(currentPage),
|
||||
automation: ["jobs", "assemblies", "community"].includes(currentPage),
|
||||
filters: ["filters", "groups"].includes(currentPage),
|
||||
filters: ["filters", "filter_editor", "groups"].includes(currentPage),
|
||||
access: [
|
||||
"access_credentials",
|
||||
"access_users",
|
||||
|
||||
Reference in New Issue
Block a user