diff --git a/Data/Engine/web-interface/src/Devices/Filters/Filter_Editor.jsx b/Data/Engine/web-interface/src/Devices/Filters/Filter_Editor.jsx
index b72194d9..b182ea39 100644
--- a/Data/Engine/web-interface/src/Devices/Filters/Filter_Editor.jsx
+++ b/Data/Engine/web-interface/src/Devices/Filters/Filter_Editor.jsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useMemo, useState } from "react";
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Paper,
Box,
@@ -21,7 +21,12 @@ import {
Add as AddIcon,
Remove as RemoveIcon,
Cached as CachedIcon,
+ PlayArrow as PlayIcon,
} from "@mui/icons-material";
+import { AgGridReact } from "ag-grid-react";
+import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
+
+ModuleRegistry.registerModules([AllCommunityModule]);
const AURORA_SHELL = {
background:
@@ -132,6 +137,25 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
const [lastEditedTs, setLastEditedTs] = useState(resolveLastEdited(initialFilter));
const [loadingFilter, setLoadingFilter] = useState(false);
const [loadError, setLoadError] = useState(null);
+ const [previewRows, setPreviewRows] = useState([]);
+ const [previewLoading, setPreviewLoading] = useState(false);
+ const [previewError, setPreviewError] = useState(null);
+ const [previewAppliedAt, setPreviewAppliedAt] = useState(null);
+ const gridRef = useRef(null);
+ const gridTheme = useMemo(
+ () =>
+ 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 applyFilterData = useCallback((filter) => {
if (!filter) return;
@@ -148,6 +172,161 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
applyFilterData(initialFilter);
}, [applyFilterData, initialFilter]);
+ const handleGridReady = useCallback((params) => {
+ gridRef.current = params.api;
+ requestAnimationFrame(() => {
+ try {
+ params.api.autoSizeColumns(AUTO_SIZE_COLUMNS, true);
+ } catch {}
+ });
+ }, []);
+
+ const autoSizeGrid = useCallback(() => {
+ if (!gridRef.current || !previewRows.length) return;
+ requestAnimationFrame(() => {
+ try {
+ gridRef.current.autoSizeColumns(AUTO_SIZE_COLUMNS, true);
+ } catch {}
+ });
+ }, [previewRows.length]);
+
+ useEffect(() => {
+ autoSizeGrid();
+ }, [previewRows, autoSizeGrid]);
+
+ const getDeviceField = (device, field) => {
+ const summary = device && typeof device.summary === "object" ? device.summary : {};
+ switch (field) {
+ case "status":
+ return device.status || summary.status || "";
+ case "site":
+ return device.site || device.site_name || summary.site || "";
+ case "hostname":
+ return device.hostname || summary.hostname || "";
+ case "description":
+ return device.description || summary.description || "";
+ case "type":
+ return device.type || summary.type || summary.device_type || device.device_type || "";
+ default:
+ return device[field] || summary[field] || "";
+ }
+ };
+
+ const evaluateCondition = (device, condition) => {
+ const operator = (condition.operator || "contains").toLowerCase();
+ const value = String(condition.value ?? "").trim();
+ const fieldValueRaw = getDeviceField(device, condition.field);
+ const fieldValue = fieldValueRaw == null ? "" : String(fieldValueRaw);
+ const lcField = fieldValue.toLowerCase();
+ const lcValue = value.toLowerCase();
+
+ switch (operator) {
+ case "contains":
+ return lcField.includes(lcValue);
+ case "not_contains":
+ return !lcField.includes(lcValue);
+ case "empty":
+ return lcField.length === 0;
+ case "not_empty":
+ return lcField.length > 0;
+ case "begins_with":
+ return lcField.startsWith(lcValue);
+ case "not_begins_with":
+ return !lcField.startsWith(lcValue);
+ case "ends_with":
+ return lcField.endsWith(lcValue);
+ case "not_ends_with":
+ return !lcField.endsWith(lcValue);
+ case "equals":
+ return lcField === lcValue;
+ case "not_equals":
+ return lcField !== lcValue;
+ default:
+ return false;
+ }
+ };
+
+ const evaluateGroup = (device, group) => {
+ const conditions = group?.conditions || [];
+ if (!conditions.length) return true;
+ let result = evaluateCondition(device, conditions[0]);
+ for (let i = 1; i < conditions.length; i++) {
+ const cond = conditions[i];
+ const joiner = (cond.joinWith || "AND").toUpperCase();
+ const res = evaluateCondition(device, cond);
+ result = joiner === "OR" ? result || res : result && res;
+ }
+ return result;
+ };
+
+ const evaluateCriteria = useCallback(
+ (device) => {
+ if (!groups.length) return true;
+ let result = evaluateGroup(device, groups[0]);
+ for (let i = 1; i < groups.length; i++) {
+ const group = groups[i];
+ const joiner = (group.joinWith || "OR").toUpperCase();
+ const res = evaluateGroup(device, group);
+ result = joiner === "AND" ? result && res : result || res;
+ }
+ return result;
+ },
+ [groups]
+ );
+
+ const applyCriteria = useCallback(async () => {
+ setPreviewLoading(true);
+ setPreviewError(null);
+ try {
+ const resp = await fetch("/api/devices");
+ if (!resp.ok) {
+ throw new Error(`Failed to load devices (${resp.status})`);
+ }
+ const payload = await resp.json();
+ const list = Array.isArray(payload?.devices) ? payload.devices : [];
+ const filtered = list.filter((d) => evaluateCriteria(d));
+ const rows = filtered.map((d, idx) => ({
+ id: d.agent_guid || d.agent_id || d.hostname || `device-${idx}`,
+ status: getDeviceField(d, "status"),
+ site: getDeviceField(d, "site"),
+ hostname: getDeviceField(d, "hostname"),
+ description: getDeviceField(d, "description"),
+ type: getDeviceField(d, "type"),
+ }));
+ setPreviewRows(rows);
+ setPreviewAppliedAt(new Date());
+ } catch (err) {
+ setPreviewError(err?.message || "Unable to apply criteria");
+ setPreviewRows([]);
+ } finally {
+ setPreviewLoading(false);
+ autoSizeGrid();
+ }
+ }, [autoSizeGrid, evaluateCriteria]);
+
+ const previewColumns = useMemo(
+ () => [
+ { field: "status", headerName: "Status", minWidth: 110, cellClass: "auto-col-tight" },
+ { field: "site", headerName: "Site", minWidth: 140, cellClass: "auto-col-tight" },
+ { field: "hostname", headerName: "Hostname", minWidth: 160, cellClass: "auto-col-tight" },
+ { field: "description", headerName: "Description", minWidth: 200, cellClass: "auto-col-tight" },
+ { field: "type", headerName: "Device Type", minWidth: 140, cellClass: "auto-col-tight" },
+ ],
+ []
+ );
+
+ const defaultPreviewColDef = useMemo(
+ () => ({
+ sortable: true,
+ filter: "agTextColumnFilter",
+ resizable: true,
+ flex: 1,
+ cellClass: "auto-col-tight",
+ suppressMenu: true,
+ }),
+ []
+ );
+
useEffect(() => {
if (!initialFilter?.id) return;
const missingGroups = !initialFilter.groups || initialFilter.groups.length === 0;
@@ -736,6 +915,88 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
+
+
+
+ Results
+
+ Apply criteria to preview matching devices (20 per page).
+
+ {previewAppliedAt && (
+
+ Last applied: {previewAppliedAt.toLocaleString()}
+
+ )}
+ {previewError ? (
+ {previewError}
+ ) : null}
+
+ : }
+ onClick={applyCriteria}
+ disabled={previewLoading}
+ sx={gradientButtonSx}
+ >
+ {previewLoading ? "Applying..." : "Apply Criteria"}
+
+
+
+
+
+
+
+
{saveError ? (