Initial Development of Device Filter System

This commit is contained in:
2025-11-18 21:04:06 -07:00
parent c322dfa092
commit 6649b28d46
9 changed files with 1542 additions and 9 deletions

View File

@@ -10,7 +10,6 @@ param(
[switch]$EngineTests,
[switch]$EngineProduction,
[switch]$EngineDev,
[Alias('enrollmentcode','Enrollmentcode')]
[string]$EnrollmentCode = ''
)

View File

@@ -65,6 +65,7 @@ def initialise_engine_database(database_path: str, *, logger: Optional[logging.L
_ensure_agent_service_accounts(conn, logger=logger)
_ensure_credentials(conn, logger=logger)
_ensure_github_token(conn, logger=logger)
_ensure_device_filters(conn, database_path=str(path), logger=logger)
_ensure_scheduled_jobs(conn, logger=logger)
conn.commit()
except Exception as exc: # pragma: no cover - defensive runtime guard
@@ -583,6 +584,122 @@ def _ensure_github_token(conn: sqlite3.Connection, *, logger: Optional[logging.L
cur.close()
def _ensure_device_filters(
conn: sqlite3.Connection, *, database_path: Optional[str] = None, logger: Optional[logging.Logger] = None
) -> None:
cur = conn.cursor()
try:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS device_filters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
site_scope TEXT NOT NULL DEFAULT 'global',
site_name TEXT,
criteria_json TEXT,
last_edited_by TEXT,
last_edited TEXT,
created_at TEXT,
updated_at TEXT
)
"""
)
cur.execute("PRAGMA table_info(device_filters)")
columns: Sequence[Sequence[object]] = cur.fetchall()
existing = {row[1] for row in columns}
alterations = [
("site_scope", "ALTER TABLE device_filters ADD COLUMN site_scope TEXT"),
("site_name", "ALTER TABLE device_filters ADD COLUMN site_name TEXT"),
("criteria_json", "ALTER TABLE device_filters ADD COLUMN criteria_json TEXT"),
("last_edited_by", "ALTER TABLE device_filters ADD COLUMN last_edited_by TEXT"),
("last_edited", "ALTER TABLE device_filters ADD COLUMN last_edited TEXT"),
("created_at", "ALTER TABLE device_filters ADD COLUMN created_at TEXT"),
("updated_at", "ALTER TABLE device_filters ADD COLUMN updated_at TEXT"),
]
for column, statement in alterations:
if column not in existing:
cur.execute(statement)
# Rebuild table if legacy columns are present (scope/apply_to_all_sites)
rebuild_needed = "scope" in existing or "apply_to_all_sites" in existing
if rebuild_needed:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS device_filters_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
site_scope TEXT NOT NULL DEFAULT 'global',
site_name TEXT,
criteria_json TEXT,
last_edited_by TEXT,
last_edited TEXT,
created_at TEXT,
updated_at TEXT
)
"""
)
cur.execute(
"""
SELECT id, name, scope, apply_to_all_sites, site_name, site_scope, criteria_json,
last_edited_by, last_edited, created_at, updated_at
FROM device_filters
"""
)
rows = cur.fetchall()
payloads = []
for (
pid,
name,
legacy_scope,
apply_all,
site_name,
site_scope,
criteria_json,
last_edited_by,
last_edited,
created_at,
updated_at,
) in rows:
basis = (site_scope or legacy_scope or "global")
basis = str(basis).lower()
resolved_scope = "global" if basis == "global" or bool(apply_all) else "scoped"
payloads.append(
(
pid,
name,
resolved_scope,
site_name,
criteria_json,
last_edited_by,
last_edited,
created_at,
updated_at,
)
)
if payloads:
cur.executemany(
"""
INSERT INTO device_filters_new (
id, name, site_scope, site_name, criteria_json,
last_edited_by, last_edited, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
payloads,
)
cur.execute("DROP TABLE device_filters")
cur.execute("ALTER TABLE device_filters_new RENAME TO device_filters")
except Exception as exc:
if logger:
logger.error("Failed to ensure device_filters table: %s", exc, exc_info=True)
else:
raise
finally:
cur.close()
def _ensure_scheduled_jobs(conn: sqlite3.Connection, *, logger: Optional[logging.Logger]) -> None:
cur = conn.cursor()
try:

View File

@@ -40,10 +40,11 @@ from .assemblies.execution import register_execution
from .devices import routes as device_routes
from .devices.approval import register_admin_endpoints
from .devices.management import register_management
from .filters import management as filters_management
from .scheduled_jobs import management as scheduled_jobs_management
from .server import info as server_info, log_management
DEFAULT_API_GROUPS: Sequence[str] = ("core", "auth", "tokens", "enrollment", "devices", "server", "assemblies", "scheduled_jobs")
DEFAULT_API_GROUPS: Sequence[str] = ("core", "auth", "tokens", "enrollment", "devices", "filters", "server", "assemblies", "scheduled_jobs")
_SERVER_SCOPE_PATTERN = re.compile(r"\\b(?:scope|context|agent_context)=([A-Za-z0-9_-]+)", re.IGNORECASE)
_SERVER_AGENT_ID_PATTERN = re.compile(r"\\bagent_id=([^\\s,]+)", re.IGNORECASE)
@@ -265,6 +266,9 @@ def _register_devices(app: Flask, adapters: EngineServiceAdapters) -> None:
register_admin_endpoints(app, adapters)
device_routes.register_agents(app, adapters)
def _register_filters(app: Flask, adapters: EngineServiceAdapters) -> None:
filters_management.register_filters(app, adapters)
def _register_scheduled_jobs(app: Flask, adapters: EngineServiceAdapters) -> None:
scheduled_jobs_management.register_management(app, adapters)
@@ -285,6 +289,7 @@ _GROUP_REGISTRARS: Mapping[str, Callable[[Flask, EngineServiceAdapters], None]]
"tokens": _register_tokens,
"enrollment": _register_enrollment,
"devices": _register_devices,
"filters": _register_filters,
"server": _register_server,
"assemblies": _register_assemblies,
"scheduled_jobs": _register_scheduled_jobs,
@@ -309,6 +314,8 @@ def register_api(app: Flask, context: EngineContext) -> None:
enabled_groups: Iterable[str] = context.api_groups or DEFAULT_API_GROUPS
normalized = [group.strip().lower() for group in enabled_groups if group]
if "filters" not in normalized:
normalized.append("filters")
adapters: Optional[EngineServiceAdapters] = None
for group in normalized:

View File

@@ -1,8 +1,196 @@
# ======================================================
# Data\Engine\services\API\filters\management.py
# Description: Placeholder for filter management endpoints.
# Description: Device filter management endpoints backed by the Engine database.
#
# API Endpoints (if applicable): None
# API Endpoints (if applicable):
# - GET /api/device_filters
# - GET /api/device_filters/<filter_id>
# - POST /api/device_filters
# - PUT /api/device_filters/<filter_id>
# ======================================================
"Placeholder for API module filters/management.py."
from __future__ import annotations
import json
import sqlite3
import time
from typing import Any, Dict, TYPE_CHECKING
from flask import Blueprint, Flask, jsonify, request
if TYPE_CHECKING:
from .. import EngineServiceAdapters
def register_filters(app: Flask, adapters: "EngineServiceAdapters") -> None:
"""
Register device filter endpoints backed by the Engine database.
"""
def _conn() -> sqlite3.Connection:
conn = adapters.db_conn_factory()
conn.row_factory = sqlite3.Row
return conn
def _row_to_filter(row: sqlite3.Row) -> Dict[str, Any]:
try:
groups = json.loads(row["criteria_json"] or "[]")
except Exception:
groups = []
scope = (row["site_scope"] or "global").lower()
site_value = row["site_scope"] or row["site_name"]
return {
"id": row["id"],
"name": row["name"],
"scope": scope,
"type": "site" if scope == "scoped" else "global",
"applyToAllSites": scope != "scoped",
"site": site_value,
"site_name": site_value,
"site_scope": site_value,
"groups": groups,
"last_edited_by": row["last_edited_by"],
"last_edited": row["last_edited"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def _select_filter(filter_id: str) -> Dict[str, Any] | None:
conn = _conn()
try:
cur = conn.execute(
"""
SELECT id, name, site_scope, site_name,
criteria_json, last_edited_by, last_edited, created_at, updated_at
FROM device_filters
WHERE id = ?
""",
(filter_id,),
)
row = cur.fetchone()
return _row_to_filter(row) if row else None
finally:
conn.close()
def _normalize_payload(data: Dict[str, Any], existing: Dict[str, Any] | None = None) -> Dict[str, Any]:
now_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
base = existing or {}
scope = (data.get("site_scope") or data.get("scope") or data.get("type") or base.get("scope") or "global").lower()
site_name = (
data.get("site")
or data.get("site_name")
or data.get("target_site")
or base.get("site")
or base.get("site_name")
)
site_scope = scope if scope in ("global", "scoped") else (base.get("site_scope") or "global")
groups = data.get("groups")
if not isinstance(groups, list):
groups = base.get("groups") if isinstance(base.get("groups"), list) else []
last_edited = data.get("last_edited") or base.get("last_edited") or now_iso
return {
"name": (data.get("name") or base.get("name") or "Unnamed Filter").strip(),
"site_scope": site_scope,
"site_name": site_name,
"site_scope": site_scope,
"criteria_json": json.dumps(groups or []),
"last_edited_by": data.get("last_edited_by") or data.get("owner") or base.get("last_edited_by") or "Unknown",
"last_edited": last_edited,
"created_at": base.get("created_at") or now_iso,
"updated_at": now_iso,
}
blueprint = Blueprint("device_filters", __name__, url_prefix="/api/device_filters")
@blueprint.route("", methods=["GET"])
def list_filters() -> Any:
conn = _conn()
try:
cur = conn.execute(
"""
SELECT id, name, site_scope, site_name,
criteria_json, last_edited_by, last_edited, created_at, updated_at
FROM device_filters
ORDER BY COALESCE(updated_at, created_at) DESC, id DESC
"""
)
rows = cur.fetchall()
return jsonify({"filters": [_row_to_filter(r) for r in rows]})
finally:
conn.close()
@blueprint.route("/<filter_id>", methods=["GET"])
def get_filter(filter_id: str) -> Any:
record = _select_filter(filter_id)
if not record:
return jsonify({"error": "Filter not found"}), 404
return jsonify({"filter": record})
@blueprint.route("", methods=["POST"])
def create_filter() -> Any:
data = request.get_json(silent=True) or {}
payload = _normalize_payload(data)
conn = _conn()
try:
cur = conn.execute(
"""
INSERT INTO device_filters (
name, site_scope, site_name, criteria_json,
last_edited_by, last_edited, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
payload["name"],
payload["site_scope"],
payload["site_name"],
payload["criteria_json"],
payload["last_edited_by"],
payload["last_edited"],
payload["created_at"],
payload["updated_at"],
),
)
conn.commit()
record = _select_filter(cur.lastrowid)
adapters.service_log("device_filters", f"Created device filter '{payload['name']}'.")
return jsonify({"filter": record or payload}), 201
finally:
conn.close()
@blueprint.route("/<filter_id>", methods=["PUT"])
def update_filter(filter_id: str) -> Any:
existing = _select_filter(filter_id)
if not existing:
return jsonify({"error": "Filter not found"}), 404
data = request.get_json(silent=True) or {}
payload = _normalize_payload(data, existing=existing)
conn = _conn()
try:
conn.execute(
"""
UPDATE device_filters
SET name = ?, site_scope = ?, site_name = ?, criteria_json = ?,
last_edited_by = ?, last_edited = ?, updated_at = ?
WHERE id = ?
""",
(
payload["name"],
payload["site_scope"],
payload["site_name"],
payload["criteria_json"],
payload["last_edited_by"],
payload["last_edited"],
payload["updated_at"],
filter_id,
),
)
conn.commit()
record = _select_filter(filter_id)
adapters.service_log("device_filters", f"Updated device filter '{payload['name']}' (id={filter_id}).")
return jsonify({"filter": record or payload})
finally:
conn.close()
app.register_blueprint(blueprint)
adapters.service_log("device_filters", "Registered device filter endpoints.")

View File

@@ -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

View 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>
);
}

View 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>
);
}

View File

@@ -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",

View File

@@ -22,6 +22,7 @@ Applies to all Borealis frontends. Use `Data/Engine/web-interface/src/Admin/Page
- Buttons & chips: gradient pills for primary CTAs (`linear-gradient(135deg,#34d399,#22d3ee)` success; `#7dd3fc→#c084fc` creation); neutral actions use rounded outlines with `rgba(148,163,184,0.4)` borders and uppercase microcopy.
- Rainbow accents: for creation CTAs, use dark-fill pills with rainbow border gradients + teal halo (shared with Quick Job).
- AG Grid treatment: Quartz theme with matte navy headers, subtle alternating row opacity, cyan/magenta interaction glows, rounded wrappers, soft borders, inset selection glows.
- Default grid cell padding: leave a small left inset (≈4px) on value cells (including `auto-col-tight`) so text doesnt hug column edges; keep right padding modest (≈6px) to balance density.
- 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.
## AG Grid Column Behavior (All Tables)
@@ -30,6 +31,6 @@ Applies to all Borealis frontends. Use `Data/Engine/web-interface/src/Admin/Page
- Helper: store the grid API in a ref and call `api.autoSizeColumns(AUTO_SIZE_COLUMNS, true)` inside `requestAnimationFrame` (or `setTimeout(...,0)` fallback); swallow errors because it can run before rows render.
- Hook the helper into both `onGridReady` and a `useEffect` watching the dataset (e.g., `[filteredRows, loading]`); skip while `loading` or when there are zero rows.
- Column defs: apply shared `cellClass: "auto-col-tight"` (or equivalent) to every auto-sized column for consistent padding. Last column keeps the class for styling consistency.
- CSS override: add `& .ag-cell.auto-col-tight { padding-left: 0; padding-right: 0; }` in the theme scope.
- CSS override: add `& .ag-cell.auto-col-tight { padding-left: 8px; padding-right: 6px; }` (or equivalent) in the theme scope to keep text away from the left edge.
- Fill column: last column `{ flex: 1, minWidth: X }` (no width/maxWidth) to stretch when horizontal space remains.
- Example: follow the scaffolding in `Engine/web-interface/src/Scheduling/Scheduled_Jobs_List.jsx` and the structure in `Data/Engine/web-interface/src/Admin/Page_Template.jsx`.