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