diff --git a/Borealis.ps1 b/Borealis.ps1 index b95b5a3f..f921b818 100644 --- a/Borealis.ps1 +++ b/Borealis.ps1 @@ -10,7 +10,6 @@ param( [switch]$EngineTests, [switch]$EngineProduction, [switch]$EngineDev, - [Alias('enrollmentcode','Enrollmentcode')] [string]$EnrollmentCode = '' ) diff --git a/Data/Engine/database.py b/Data/Engine/database.py index 9851d202..7c232f9f 100644 --- a/Data/Engine/database.py +++ b/Data/Engine/database.py @@ -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: diff --git a/Data/Engine/services/API/__init__.py b/Data/Engine/services/API/__init__.py index 0c35d399..6a00ad5d 100644 --- a/Data/Engine/services/API/__init__.py +++ b/Data/Engine/services/API/__init__.py @@ -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: diff --git a/Data/Engine/services/API/filters/management.py b/Data/Engine/services/API/filters/management.py index e2456aba..84e67608 100644 --- a/Data/Engine/services/API/filters/management.py +++ b/Data/Engine/services/API/filters/management.py @@ -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/ +# - POST /api/device_filters +# - PUT /api/device_filters/ # ====================================================== -"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("/", 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("/", 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.") diff --git a/Data/Engine/web-interface/src/App.jsx b/Data/Engine/web-interface/src/App.jsx index 98a39543..ce6f3150 100644 --- a/Data/Engine/web-interface/src/App.jsx +++ b/Data/Engine/web-interface/src/App.jsx @@ -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 ; + case "filters": + return ( + { + setFilterEditorState(null); + navigateTo("filter_editor"); + }} + onEditFilter={(filter) => { + setFilterEditorState(filter); + navigateTo("filter_editor", { filterId: filter?.id }); + }} + /> + ); + + case "filter_editor": + return ( + { + setFilterEditorState(null); + navigateTo("filters"); + }} + onSaved={(filter) => { + setFilterEditorState(null); + setFiltersRefreshToken(Date.now()); + navigateTo("filters"); + }} + /> + ); + case "device_details": return ( !["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 ( + + + {!isFirst && ( + { + if (!val) return; + updateCondition(groupId, condition.id, (c) => ({ ...c, joinWith: val })); + }} + color="info" + sx={{ + "& .MuiToggleButton-root": { + px: 1.5, + textTransform: "uppercase", + fontSize: "0.7rem", + }, + }} + > + AND + OR + + )} + + + 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) => ( + + )} + /> + + + 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) => ( + + ))} + + + + 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 }, + }} + /> + + + + addCondition(groupId)} sx={{ color: "#7dd3fc" }}> + + + + + removeCondition(groupId, condition.id)} + sx={{ color: "#ffb4b4" }} + > + + + + + + ); + }; + + return ( + + + + + + + + + {initialFilter ? "Edit Device Filter" : "Create Device Filter"} + + + Combine grouped criteria with AND/OR logic to build reusable device scopes for automation and reporting. + + {lastEditedTs && ( + + Last edited {new Date(lastEditedTs).toLocaleString()} + + )} + + + + + + + + + + + + + + {loadingFilter ? ( + Loading filter... + ) : null} + {loadError ? ( + + {loadError} + + ) : null} + + + + Name + 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 }, + }} + /> + + + + + + Scope + + Choose whether this filter is global or pinned to a specific site. + + + { + 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", + }, + }} + > + Global + Site + + + + {scope === "site" && ( + + + setApplyToAllSites(e.target.checked)} + color="info" + /> + + Add filter to all Sites + + Future sites will also inherit this filter when enabled. + + + + + {!applyToAllSites && ( + s.value === targetSite) || null} + getOptionLabel={(option) => option?.label || ""} + isOptionEqualToValue={(option, value) => option?.value === value?.value} + onChange={(_, val) => setTargetSite(val?.value || "")} + renderInput={(params) => ( + + )} + /> + )} + + )} + + + + + Criteria + + + + 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)). + + + {groups.map((group, idx) => ( + + {idx > 0 && ( + { + if (!val) return; + updateGroup(group.id, { ...group, joinWith: val }); + }} + color="info" + sx={{ + alignSelf: "center", + "& .MuiToggleButton-root": { px: 2, textTransform: "uppercase", fontSize: "0.8rem" }, + }} + > + AND + OR + + )} + + + + Criteria Group {idx + 1} + + + + + + + + {group.conditions.map((condition, cIdx) => + renderConditionRow(group.id, condition, cIdx === 0) + )} + + + + ))} + + + + + {saveError ? ( + + {saveError} + + ) : null} + + + ); +} diff --git a/Data/Engine/web-interface/src/Devices/Filters/Filter_List.jsx b/Data/Engine/web-interface/src/Devices/Filters/Filter_List.jsx new file mode 100644 index 00000000..7e684e67 --- /dev/null +++ b/Data/Engine/web-interface/src/Devices/Filters/Filter_List.jsx @@ -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 ( + + ); + }, + 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 ; + }, + 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) => ( + 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)" }, + }} + > + + + ), + cellClass: "auto-col-tight", + }, + ]; + }, [onEditFilter]); + + const defaultColDef = useMemo( + () => ({ + sortable: true, + filter: "agTextColumnFilter", + resizable: true, + cellClass: "auto-col-tight", + suppressMenu: true, + }), + [] + ); + + return ( + + + + + + + + + Device Filters + + + Build reusable filter definitions to target devices and assemblies without per-site duplication. + + + + + + + + + + + + + + + + + Filters + + {loading ? "Loading…" : `${rows.length} filter${rows.length === 1 ? "" : "s"}`} + + + + {error ? ( + + {error} + + ) : null} + + + + + + + ); +} diff --git a/Data/Engine/web-interface/src/Navigation_Sidebar.jsx b/Data/Engine/web-interface/src/Navigation_Sidebar.jsx index a71c41ed..e4e861ee 100644 --- a/Data/Engine/web-interface/src/Navigation_Sidebar.jsx +++ b/Data/Engine/web-interface/src/Navigation_Sidebar.jsx @@ -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", diff --git a/Docs/Codex/USER_INTERFACE.md b/Docs/Codex/USER_INTERFACE.md index 732e9559..8fa97473 100644 --- a/Docs/Codex/USER_INTERFACE.md +++ b/Docs/Codex/USER_INTERFACE.md @@ -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 doesn’t 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`.