Implemented Filter-Based Device Targeting for Scheduled Jobs

This commit is contained in:
2025-11-20 19:59:26 -07:00
parent cae497087a
commit 636e944162
5 changed files with 1087 additions and 89 deletions

View File

@@ -14,10 +14,12 @@ from __future__ import annotations
import json
import sqlite3
import time
from typing import Any, Dict, TYPE_CHECKING
from typing import Any, Dict, TYPE_CHECKING, List
from flask import Blueprint, Flask, jsonify, request
from Data.Engine.services.filters.matcher import DeviceFilterMatcher
if TYPE_CHECKING:
from .. import EngineServiceAdapters
@@ -100,6 +102,30 @@ def register_filters(app: Flask, adapters: "EngineServiceAdapters") -> None:
"updated_at": now_iso,
}
matcher = DeviceFilterMatcher(db_conn_factory=adapters.db_conn_factory)
def _attach_match_counts(records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
if not records:
return records
try:
devices = matcher.fetch_devices()
except Exception:
devices = None
for record in records:
try:
record["matching_device_count"] = matcher.count_filter_devices(record, devices=devices)
except Exception as exc: # pragma: no cover - defensive log path
record["matching_device_count"] = 0
try:
adapters.service_log(
"device_filters",
f"failed to compute device match count for filter {record.get('id')}: {exc}",
level="ERROR",
)
except Exception:
pass
return records
blueprint = Blueprint("device_filters", __name__, url_prefix="/api/device_filters")
@blueprint.route("", methods=["GET"])
@@ -115,7 +141,9 @@ def register_filters(app: Flask, adapters: "EngineServiceAdapters") -> None:
"""
)
rows = cur.fetchall()
return jsonify({"filters": [_row_to_filter(r) for r in rows]})
filters = [_row_to_filter(r) for r in rows]
_attach_match_counts(filters)
return jsonify({"filters": filters})
finally:
conn.close()
@@ -124,6 +152,7 @@ def register_filters(app: Flask, adapters: "EngineServiceAdapters") -> None:
record = _select_filter(filter_id)
if not record:
return jsonify({"error": "Filter not found"}), 404
_attach_match_counts([record])
return jsonify({"filter": record})
@blueprint.route("", methods=["POST"])
@@ -153,6 +182,8 @@ def register_filters(app: Flask, adapters: "EngineServiceAdapters") -> None:
)
conn.commit()
record = _select_filter(cur.lastrowid)
if record:
_attach_match_counts([record])
adapters.service_log("device_filters", f"Created device filter '{payload['name']}'.")
return jsonify({"filter": record or payload}), 201
finally:
@@ -187,6 +218,8 @@ def register_filters(app: Flask, adapters: "EngineServiceAdapters") -> None:
)
conn.commit()
record = _select_filter(filter_id)
if record:
_attach_match_counts([record])
adapters.service_log("device_filters", f"Updated device filter '{payload['name']}' (id={filter_id}).")
return jsonify({"filter": record or payload})
finally:

View File

@@ -17,9 +17,10 @@ import re
import sqlite3
import time
import uuid
from typing import Any, Callable, Dict, List, Optional, Tuple
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
from ...assemblies.service import AssemblyRuntimeService
from ...filters.matcher import DeviceFilterMatcher
_WINRM_USERNAME_VAR = "__borealis_winrm_username"
_WINRM_PASSWORD_VAR = "__borealis_winrm_password"
@@ -335,6 +336,7 @@ class JobScheduler:
self.app = app
self.socketio = socketio
self.db_path = db_path
self._filter_matcher = DeviceFilterMatcher(db_path=db_path)
self._script_signer = script_signer
self._running = False
self._service_log = service_logger
@@ -364,6 +366,16 @@ class JobScheduler:
},
)
def _targets_include_filters(self, entries: Sequence[Any]) -> bool:
if not isinstance(entries, (list, tuple)):
return False
for entry in entries:
if isinstance(entry, dict):
kind = str(entry.get("kind") or entry.get("type") or "").strip().lower()
if kind == "filter" or entry.get("filter_id") is not None:
return True
return False
def _log_event(
self,
message: str,
@@ -1403,6 +1415,7 @@ class JobScheduler:
)
now_min = _now_minute()
device_inventory_cache: Optional[List[Dict[str, Any]]] = None
for (
job_id,
@@ -1418,11 +1431,36 @@ class JobScheduler:
) in jobs:
try:
try:
targets = json.loads(targets_json or "[]")
raw_targets = json.loads(targets_json or "[]")
except Exception as exc:
raw_targets = []
self._log_event(
"failed to parse targets JSON for job",
job_id=job_id,
level="ERROR",
extra={"error": str(exc)},
)
include_filters = self._targets_include_filters(raw_targets)
if include_filters and device_inventory_cache is None:
try:
device_inventory_cache = self._filter_matcher.fetch_devices()
except Exception as exc:
device_inventory_cache = []
self._log_event(
"failed to load device inventory for filter targets",
job_id=job_id,
level="ERROR",
extra={"error": str(exc)},
)
try:
targets, _meta = self._filter_matcher.resolve_target_entries(
raw_targets,
devices=device_inventory_cache if include_filters else None,
)
except Exception as exc:
targets = []
self._log_event(
"failed to parse targets JSON for job",
"failed to resolve job targets",
job_id=job_id,
level="ERROR",
extra={"error": str(exc)},
@@ -1995,6 +2033,68 @@ class JobScheduler:
base["next_run_ts"] = None
return base
def _normalize_targets_for_save(raw_targets: Any) -> List[Any]:
normalized: List[Any] = []
if not isinstance(raw_targets, list):
raw_list = [raw_targets]
else:
raw_list = raw_targets
seen_hosts: set[str] = set()
seen_filters: set[int] = set()
for entry in raw_list:
if isinstance(entry, str):
host = entry.strip()
if not host:
continue
lowered = host.lower()
if lowered in seen_hosts:
continue
seen_hosts.add(lowered)
normalized.append(host)
continue
if isinstance(entry, (int, float)):
host = str(entry).strip()
if not host:
continue
lowered = host.lower()
if lowered in seen_hosts:
continue
seen_hosts.add(lowered)
normalized.append(host)
continue
if not isinstance(entry, dict):
continue
kind = str(entry.get("kind") or entry.get("type") or "").strip().lower()
if kind == "filter" or entry.get("filter_id") is not None:
filter_id = entry.get("filter_id") or entry.get("id")
try:
filter_id_int = int(filter_id)
except (TypeError, ValueError):
continue
if filter_id_int in seen_filters:
continue
seen_filters.add(filter_id_int)
normalized.append(
{
"kind": "filter",
"filter_id": filter_id_int,
"name": entry.get("name"),
"site_scope": entry.get("site_scope") or entry.get("scope") or "global",
"site": entry.get("site") or entry.get("site_name"),
}
)
continue
hostname = entry.get("hostname")
if hostname:
host = str(hostname).strip()
if host:
lowered = host.lower()
if lowered in seen_hosts:
continue
seen_hosts.add(lowered)
normalized.append(host)
return normalized
@app.route("/api/scheduled_jobs", methods=["GET"])
def api_scheduled_jobs_list():
try:
@@ -2020,7 +2120,7 @@ class JobScheduler:
data = self._json_body()
name = (data.get("name") or "").strip()
components = data.get("components") or []
targets = data.get("targets") or []
targets = _normalize_targets_for_save(data.get("targets") or [])
schedule_type = (data.get("schedule", {}).get("type") or data.get("schedule_type") or "immediately").strip().lower()
start = data.get("schedule", {}).get("start") or data.get("start") or None
start_ts = _parse_ts(start) if start else None
@@ -2109,7 +2209,10 @@ class JobScheduler:
if "components" in data:
fields["components_json"] = json.dumps(data.get("components") or [])
if "targets" in data:
fields["targets_json"] = json.dumps(data.get("targets") or [])
normalized_targets = _normalize_targets_for_save(data.get("targets") or [])
if not normalized_targets:
return json.dumps({"error": "targets required"}), 400, {"Content-Type": "application/json"}
fields["targets_json"] = json.dumps(normalized_targets)
if "schedule" in data or "schedule_type" in data:
schedule_type = (data.get("schedule", {}).get("type") or data.get("schedule_type") or "immediately").strip().lower()
fields["schedule_type"] = schedule_type
@@ -2259,10 +2362,19 @@ class JobScheduler:
conn.close()
return json.dumps({"error": "not found"}), 404, {"Content-Type": "application/json"}
try:
targets = json.loads(row[0] or "[]")
raw_targets = json.loads(row[0] or "[]")
except Exception:
targets = []
targets = [str(t) for t in targets if isinstance(t, (str, int))]
raw_targets = []
try:
targets, target_meta = self._filter_matcher.resolve_target_entries(raw_targets)
except Exception as exc:
self._log_event(
"failed to resolve targets for devices endpoint",
job_id=job_id,
level="ERROR",
extra={"error": str(exc)},
)
targets = [str(t) for t in raw_targets if isinstance(t, (str, int))]
# Determine occurrence if not provided
if occ is None:

View File

@@ -0,0 +1,542 @@
# ======================================================
# Data\Engine\services\filters\matcher.py
# Description: Shared helpers for evaluating device filters and resolving
# target lists for scheduled jobs and filter list summaries.
#
# API Endpoints (if applicable): None
# ======================================================
"""Utilities that evaluate device filters against the Engine inventory."""
from __future__ import annotations
import json
import sqlite3
import time
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
from Data.Engine.auth.guid_utils import normalize_guid
_DEVICE_SELECT_SQL = """
SELECT
d.guid,
d.hostname,
d.description,
d.created_at,
d.agent_hash,
d.memory,
d.network,
d.software,
d.storage,
d.cpu,
d.device_type,
d.domain,
d.external_ip,
d.internal_ip,
d.last_reboot,
d.last_seen,
d.last_user,
d.operating_system,
d.uptime,
d.agent_id,
d.ansible_ee_ver,
d.connection_type,
d.connection_endpoint,
s.id AS site_id,
s.name AS site_name,
s.description AS site_description
FROM devices AS d
LEFT JOIN device_sites AS ds ON ds.device_hostname = d.hostname
LEFT JOIN sites AS s ON s.id = ds.site_id
"""
def _safe_json(raw: Optional[str], default: Any) -> Any:
if raw is None:
return json.loads(json.dumps(default)) if isinstance(default, (list, dict)) else default
try:
parsed = json.loads(raw)
except Exception:
return default
if isinstance(default, list) and isinstance(parsed, list):
return parsed
if isinstance(default, dict) and isinstance(parsed, dict):
return parsed
return default
def _status_from_last_seen(last_seen: Optional[int]) -> str:
if not last_seen:
return "Offline"
try:
if (time.time() - float(last_seen)) <= 300:
return "Online"
except Exception:
pass
return "Offline"
def _ts_to_iso(ts: Optional[int]) -> str:
if not ts:
return ""
try:
from datetime import datetime, timezone
return datetime.fromtimestamp(int(ts), timezone.utc).isoformat()
except Exception:
return ""
class DeviceFilterMatcher:
"""Evaluates device filters against the Engine inventory data."""
def __init__(
self,
*,
db_conn_factory: Optional[Callable[[], sqlite3.Connection]] = None,
db_path: Optional[str] = None,
) -> None:
if db_conn_factory is not None:
self._conn_factory: Callable[[], sqlite3.Connection] = db_conn_factory
elif db_path:
def _factory() -> sqlite3.Connection:
return sqlite3.connect(db_path)
self._conn_factory = _factory
else: # pragma: no cover - defensive guard
raise ValueError("DeviceFilterMatcher requires a db_conn_factory or db_path.")
def _conn(self) -> sqlite3.Connection:
conn = self._conn_factory()
conn.row_factory = sqlite3.Row
return conn
# ---------- Device loading ----------
def fetch_devices(self) -> List[Dict[str, Any]]:
conn = self._conn()
try:
cur = conn.cursor()
cur.execute(_DEVICE_SELECT_SQL)
return [self._row_to_device(row) for row in cur.fetchall()]
finally:
conn.close()
def _row_to_device(self, row: sqlite3.Row) -> Dict[str, Any]:
last_seen = row["last_seen"] or 0
created_at = row["created_at"] or 0
summary = {
"hostname": row["hostname"] or "",
"description": row["description"] or "",
"agent_hash": (row["agent_hash"] or "").strip(),
"agent_guid": normalize_guid(row["guid"]) or "",
"agent_id": (row["agent_id"] or "").strip(),
"device_type": row["device_type"] or "",
"domain": row["domain"] or "",
"external_ip": row["external_ip"] or "",
"internal_ip": row["internal_ip"] or "",
"last_reboot": row["last_reboot"] or "",
"last_seen": last_seen or 0,
"last_user": row["last_user"] or "",
"operating_system": row["operating_system"] or "",
"uptime": row["uptime"] or 0,
"created_at": created_at or 0,
"connection_type": row["connection_type"] or "",
"connection_endpoint": row["connection_endpoint"] or "",
"ansible_ee_ver": row["ansible_ee_ver"] or "",
}
details = {
"summary": summary,
"memory": _safe_json(row["memory"], []),
"network": _safe_json(row["network"], []),
"software": _safe_json(row["software"], []),
"storage": _safe_json(row["storage"], []),
"cpu": _safe_json(row["cpu"], {}),
}
payload = {
"hostname": summary["hostname"],
"description": summary["description"],
"details": details,
"summary": summary,
"created_at": created_at or 0,
"created_at_iso": _ts_to_iso(created_at),
"agent_hash": summary["agent_hash"],
"agent_guid": summary["agent_guid"],
"guid": summary["agent_guid"],
"memory": details["memory"],
"network": details["network"],
"software": details["software"],
"storage": details["storage"],
"cpu": details["cpu"],
"device_type": summary["device_type"],
"domain": summary["domain"],
"external_ip": summary["external_ip"],
"internal_ip": summary["internal_ip"],
"last_reboot": summary["last_reboot"],
"last_seen": last_seen or 0,
"last_seen_iso": _ts_to_iso(last_seen),
"last_user": summary["last_user"],
"operating_system": summary["operating_system"],
"uptime": summary["uptime"],
"agent_id": summary["agent_id"],
"connection_type": summary["connection_type"],
"connection_endpoint": summary["connection_endpoint"],
"site_id": row["site_id"],
"site_name": row["site_name"] or "",
"site_description": row["site_description"] or "",
"status": _status_from_last_seen(last_seen or 0),
"agentVersion": summary["agent_hash"] or "",
}
return payload
# ---------- Filter evaluation ----------
def count_filter_devices(
self,
filter_record: Dict[str, Any],
devices: Optional[Sequence[Dict[str, Any]]] = None,
) -> int:
matches = self.match_filter_devices(filter_record, devices=devices)
return len(matches)
def match_filter_devices(
self,
filter_record: Dict[str, Any],
devices: Optional[Sequence[Dict[str, Any]]] = None,
) -> List[Dict[str, Any]]:
dataset = list(devices) if devices is not None else self.fetch_devices()
if not dataset:
return []
normalized_groups = self._normalize_groups(filter_record.get("groups"))
site_limit = self._resolve_site_limit(filter_record)
matches: List[Dict[str, Any]] = []
seen_hosts: set[str] = set()
for device in dataset:
hostname = (device.get("hostname") or "").strip()
if not hostname:
continue
if site_limit and not self._site_matches(site_limit, device):
continue
if self._device_matches_groups(device, normalized_groups):
key = hostname.lower()
if key in seen_hosts:
continue
seen_hosts.add(key)
matches.append(device)
return matches
def _resolve_site_limit(self, filter_record: Dict[str, Any]) -> Optional[str]:
scope = (
filter_record.get("site_scope")
or filter_record.get("scope")
or filter_record.get("type")
or ""
)
normalized_scope = str(scope).strip().lower()
if normalized_scope != "scoped":
return None
site_value = (
filter_record.get("site")
or filter_record.get("site_name")
or filter_record.get("site_scope_value")
)
if not site_value:
return None
return str(site_value).strip().lower()
def _site_matches(self, expected: str, device: Dict[str, Any]) -> bool:
site_candidates = [
device.get("site_name"),
device.get("site"),
device.get("summary", {}).get("site"),
]
for candidate in site_candidates:
if candidate and str(candidate).strip().lower() == expected:
return True
return False
def _normalize_groups(self, raw_groups: Any) -> List[Dict[str, Any]]:
if not isinstance(raw_groups, list) or not raw_groups:
return []
normalized: List[Dict[str, Any]] = []
for idx, group in enumerate(raw_groups):
conditions = group.get("conditions") if isinstance(group, dict) else None
normalized_conditions: List[Dict[str, Any]] = []
if isinstance(conditions, list) and conditions:
for c_idx, cond in enumerate(conditions):
if not isinstance(cond, dict):
continue
normalized_conditions.append(
{
"field": (cond.get("field") or "hostname").strip(),
"operator": str(cond.get("operator") or "contains").strip().lower(),
"value": "" if cond.get("value") is None else cond.get("value"),
"join": (cond.get("join_with") or cond.get("joinWith") or ("AND" if c_idx else None)),
}
)
if not normalized_conditions:
# Empty group matches everything by default.
normalized_conditions = [
{
"field": "hostname",
"operator": "not_empty",
"value": "",
"join": None,
}
]
normalized.append(
{
"join": (group.get("join_with") or group.get("joinWith") or ("OR" if idx else None)),
"conditions": normalized_conditions,
}
)
return normalized
def _device_matches_groups(self, device: Dict[str, Any], groups: List[Dict[str, Any]]) -> bool:
if not groups:
return True
result = self._evaluate_group(device, groups[0])
for group in groups[1:]:
join = str(group.get("join") or "OR").upper()
res = self._evaluate_group(device, group)
if join == "AND":
result = result and res
else:
result = result or res
return result
def _evaluate_group(self, device: Dict[str, Any], group: Dict[str, Any]) -> bool:
conditions = group.get("conditions") or []
if not conditions:
return True
result = self._evaluate_condition(device, conditions[0])
for cond in conditions[1:]:
join = str(cond.get("join") or "AND").upper()
res = self._evaluate_condition(device, cond)
if join == "OR":
result = result or res
else:
result = result and res
return result
def _evaluate_condition(self, device: Dict[str, Any], condition: Dict[str, Any]) -> bool:
operator = str(condition.get("operator") or "contains").lower()
raw_value = condition.get("value")
value = "" if raw_value is None else str(raw_value)
field_value_raw = self._get_device_field(device, condition.get("field"))
field_value = "" if field_value_raw is None else str(field_value_raw)
lc_field = field_value.lower()
lc_value = value.lower()
if operator == "contains":
return lc_value in lc_field
if operator == "not_contains":
return lc_value not in lc_field
if operator == "empty":
return lc_field == ""
if operator == "not_empty":
return lc_field != ""
if operator == "begins_with":
return lc_field.startswith(lc_value)
if operator == "not_begins_with":
return not lc_field.startswith(lc_value)
if operator == "ends_with":
return lc_field.endswith(lc_value)
if operator == "not_ends_with":
return not lc_field.endswith(lc_value)
if operator == "equals":
return lc_field == lc_value
if operator == "not_equals":
return lc_field != lc_value
return False
def _get_device_field(self, device: Dict[str, Any], field: Any) -> Any:
summary = device.get("summary") if isinstance(device.get("summary"), dict) else {}
name = str(field or "").strip()
if name == "status":
return device.get("status") or summary.get("status")
if name == "site":
return (
device.get("site_name")
or device.get("site")
or summary.get("site")
or ""
)
if name == "hostname":
return device.get("hostname") or summary.get("hostname")
if name == "description":
return device.get("description") or summary.get("description")
if name == "os":
return device.get("operating_system") or summary.get("operating_system")
if name == "type":
return device.get("device_type") or summary.get("device_type")
if name == "agentVersion":
return device.get("agentVersion") or summary.get("agent_hash")
if name == "lastUser":
return device.get("last_user") or summary.get("last_user")
if name == "internalIp":
return device.get("internal_ip") or summary.get("internal_ip")
if name == "externalIp":
return device.get("external_ip") or summary.get("external_ip")
if name == "lastReboot":
return device.get("last_reboot") or summary.get("last_reboot")
if name == "lastSeen":
return device.get("last_seen") or summary.get("last_seen")
if name == "domain":
return device.get("domain") or summary.get("domain")
if name == "memory":
return device.get("memory")
if name == "network":
return device.get("network")
if name == "software":
return device.get("software")
if name == "storage":
return device.get("storage")
if name == "cpu":
return device.get("cpu")
if name == "agentId":
return device.get("agent_id") or summary.get("agent_id")
if name == "agentGuid":
return device.get("agent_guid") or summary.get("agent_guid")
return device.get(name) or summary.get(name)
# ---------- Filter lookup ----------
def load_filters(
self,
filter_ids: Optional[Iterable[Any]] = None,
) -> Dict[int, Dict[str, Any]]:
conn = self._conn()
try:
cur = conn.cursor()
params: Tuple[Any, ...]
if filter_ids:
ids = [int(fid) for fid in filter_ids if str(fid).strip()]
if not ids:
return {}
placeholders = ",".join("?" for _ in ids)
sql = f"""
SELECT id, name, site_scope, site_name,
criteria_json, last_edited_by, last_edited,
created_at, updated_at
FROM device_filters
WHERE id IN ({placeholders})
"""
params = tuple(ids)
cur.execute(sql, params)
else:
cur.execute(
"""
SELECT id, name, site_scope, site_name,
criteria_json, last_edited_by, last_edited,
created_at, updated_at
FROM device_filters
"""
)
results: Dict[int, Dict[str, Any]] = {}
for row in cur.fetchall():
entry = {
"id": row["id"],
"name": row["name"],
"site_scope": row["site_scope"],
"site_name": row["site_name"],
"criteria_json": row["criteria_json"],
"last_edited_by": row["last_edited_by"],
"last_edited": row["last_edited"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
try:
entry["groups"] = json.loads(entry.get("criteria_json") or "[]")
except Exception:
entry["groups"] = []
results[int(entry["id"])] = entry
return results
finally:
conn.close()
# ---------- Target resolution ----------
def resolve_target_entries(
self,
raw_targets: Sequence[Any],
*,
devices: Optional[Sequence[Dict[str, Any]]] = None,
filters_by_id: Optional[Dict[int, Dict[str, Any]]] = None,
) -> Tuple[List[str], Dict[str, Any]]:
target_hosts: List[str] = []
host_set: set[str] = set()
filter_ids: List[int] = []
for entry in raw_targets or []:
parsed = self._normalize_target_entry(entry)
if parsed["kind"] == "device":
hostname = parsed.get("hostname")
if not hostname:
continue
lowered = hostname.lower()
if lowered in host_set:
continue
host_set.add(lowered)
target_hosts.append(hostname)
elif parsed["kind"] == "filter":
filter_id = parsed.get("filter_id")
if filter_id is None:
continue
filter_ids.append(int(filter_id))
filter_matches: Dict[int, List[str]] = {}
dataset = devices
if filter_ids:
if dataset is None:
dataset = self.fetch_devices()
filters = filters_by_id or self.load_filters(filter_ids)
for filter_id in filter_ids:
record = filters.get(int(filter_id))
if not record:
continue
matches = self.match_filter_devices(record, devices=dataset)
hostnames = [
(device.get("hostname") or "").strip() for device in matches
]
final_hosts = []
for hostname in hostnames:
if not hostname:
continue
lowered = hostname.lower()
if lowered in host_set:
continue
host_set.add(lowered)
final_hosts.append(hostname)
target_hosts.append(hostname)
filter_matches[int(filter_id)] = final_hosts
metadata = {
"filters_resolved": filter_matches,
"total_hosts": len(target_hosts),
}
return target_hosts, metadata
def _normalize_target_entry(self, entry: Any) -> Dict[str, Any]:
if isinstance(entry, str):
return {"kind": "device", "hostname": entry.strip()}
if isinstance(entry, (int, float)):
return {"kind": "device", "hostname": str(entry)}
if isinstance(entry, dict):
kind = (entry.get("kind") or entry.get("type") or "").strip().lower()
if kind == "filter" or entry.get("filter_id") is not None:
filter_id = entry.get("filter_id") or entry.get("id")
try:
filter_id = int(filter_id)
except Exception:
filter_id = None
return {
"kind": "filter",
"filter_id": filter_id,
"name": entry.get("name"),
"site_scope": entry.get("site_scope"),
"site": entry.get("site") or entry.get("site_name"),
}
hostname = entry.get("hostname")
if hostname:
return {"kind": "device", "hostname": str(hostname).strip()}
return {"kind": "unknown"}
__all__ = ["DeviceFilterMatcher"]

View File

@@ -54,7 +54,7 @@ const gradientButtonSx = {
},
};
const AUTO_SIZE_COLUMNS = ["name", "type", "site", "lastEditedBy", "lastEdited"];
const AUTO_SIZE_COLUMNS = ["name", "type", "deviceCount", "site", "lastEditedBy", "lastEdited"];
const SAMPLE_ROWS = [
{
@@ -64,6 +64,7 @@ const SAMPLE_ROWS = [
site: null,
lastEditedBy: "System",
lastEdited: new Date().toISOString(),
deviceCount: 24,
},
{
id: "sample-site",
@@ -72,6 +73,7 @@ const SAMPLE_ROWS = [
site: "West Campus",
lastEditedBy: "Demo User",
lastEdited: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(),
deviceCount: 6,
},
];
@@ -91,6 +93,12 @@ function normalizeFilters(raw) {
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,
deviceCount:
typeof f.matching_device_count === "number"
? f.matching_device_count
: typeof f.devices_targeted === "number"
? f.devices_targeted
: null,
raw: f,
}));
}
@@ -196,6 +204,18 @@ export default function DeviceFilterList({ onCreateFilter, onEditFilter, refresh
},
cellClass: "auto-col-tight",
},
{
headerName: "Devices Targeted",
field: "deviceCount",
width: 160,
valueFormatter: (params) => {
if (typeof params.value === "number" && Number.isFinite(params.value)) {
return params.value.toLocaleString();
}
return "—";
},
cellClass: "auto-col-tight",
},
{
headerName: "Site",
field: "site",

View File

@@ -89,6 +89,30 @@ const DEVICE_COLUMNS = [
{ key: "output", label: "StdOut / StdErr" }
];
const normalizeFilterCatalog = (raw) => {
if (!Array.isArray(raw)) return [];
return raw
.map((item, idx) => {
const idValue = item?.id ?? item?.filter_id ?? idx;
const id = Number(idValue);
if (!Number.isFinite(id)) return null;
const scopeText = String(item?.site_scope || item?.scope || item?.type || "global").toLowerCase();
const scope = scopeText === "scoped" ? "scoped" : "global";
const deviceCount =
typeof item?.matching_device_count === "number" && Number.isFinite(item.matching_device_count)
? item.matching_device_count
: null;
return {
id,
name: item?.name || `Filter ${idx + 1}`,
scope,
site: item?.site || item?.site_name || item?.target_site || null,
deviceCount,
};
})
.filter(Boolean);
};
function StatusNode({ data }) {
const { label, color, count, onClick, isActive, Icon } = data || {};
const displayCount = Number.isFinite(count) ? count : Number(count) || 0;
@@ -361,7 +385,37 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null, quic
const [pageTitleJobName, setPageTitleJobName] = useState("");
// Components the job will run: {type:'script'|'workflow', path, name, description}
const [components, setComponents] = useState([]);
const [targets, setTargets] = useState([]); // array of hostnames
const [targets, setTargets] = useState([]); // array of target descriptors
const [filterCatalog, setFilterCatalog] = useState([]);
const [loadingFilterCatalog, setLoadingFilterCatalog] = useState(false);
const filterCatalogMapRef = useRef({});
const loadFilterCatalog = useCallback(async () => {
setLoadingFilterCatalog(true);
try {
const resp = await fetch("/api/device_filters");
if (resp.ok) {
const data = await resp.json();
setFilterCatalog(normalizeFilterCatalog(data?.filters || data || []));
} else {
setFilterCatalog([]);
}
} catch {
setFilterCatalog([]);
} finally {
setLoadingFilterCatalog(false);
}
}, []);
useEffect(() => {
loadFilterCatalog();
}, [loadFilterCatalog]);
useEffect(() => {
const nextMap = {};
filterCatalog.forEach((entry) => {
nextMap[entry.id] = entry;
nextMap[String(entry.id)] = entry;
});
filterCatalogMapRef.current = nextMap;
}, [filterCatalog]);
const [scheduleType, setScheduleType] = useState("immediately");
const [startDateTime, setStartDateTime] = useState(() => dayjs().add(5, "minute").second(0));
const [stopAfterEnabled, setStopAfterEnabled] = useState(false);
@@ -506,8 +560,11 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null, quic
const [addTargetOpen, setAddTargetOpen] = useState(false);
const [availableDevices, setAvailableDevices] = useState([]); // [{hostname, display, online}]
const [selectedTargets, setSelectedTargets] = useState({}); // map hostname->bool
const [selectedDeviceTargets, setSelectedDeviceTargets] = useState({});
const [selectedFilterTargets, setSelectedFilterTargets] = useState({});
const [deviceSearch, setDeviceSearch] = useState("");
const [filterSearch, setFilterSearch] = useState("");
const [targetPickerTab, setTargetPickerTab] = useState("devices");
const [componentVarErrors, setComponentVarErrors] = useState({});
const [quickJobMeta, setQuickJobMeta] = useState(null);
const primaryComponentName = useMemo(() => {
@@ -537,6 +594,144 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null, quic
const [activeFilterColumn, setActiveFilterColumn] = useState(null);
const [pendingFilterValue, setPendingFilterValue] = useState("");
const normalizeTarget = useCallback((rawTarget) => {
if (!rawTarget) return null;
if (typeof rawTarget === "string") {
const host = rawTarget.trim();
return host ? { kind: "device", hostname: host } : null;
}
if (typeof rawTarget === "object") {
const rawKind = String(rawTarget.kind || "").toLowerCase();
if (rawKind === "device" || rawTarget.hostname) {
const host = String(rawTarget.hostname || "").trim();
return host ? { kind: "device", hostname: host } : null;
}
if (rawKind === "filter" || rawTarget.filter_id != null || rawTarget.id != null) {
const idValue = rawTarget.filter_id ?? rawTarget.id;
const filterId = Number(idValue);
if (!Number.isFinite(filterId)) return null;
const catalogEntry =
filterCatalogMapRef.current[filterId] || filterCatalogMapRef.current[String(filterId)] || {};
const scopeText = String(rawTarget.site_scope || rawTarget.scope || rawTarget.type || catalogEntry.scope || "global").toLowerCase();
const scope = scopeText === "scoped" ? "scoped" : "global";
const deviceCount =
typeof rawTarget.deviceCount === "number" && Number.isFinite(rawTarget.deviceCount)
? rawTarget.deviceCount
: typeof rawTarget.matching_device_count === "number" && Number.isFinite(rawTarget.matching_device_count)
? rawTarget.matching_device_count
: typeof catalogEntry.deviceCount === "number"
? catalogEntry.deviceCount
: null;
return {
kind: "filter",
filter_id: filterId,
name: rawTarget.name || catalogEntry.name || `Filter #${filterId}`,
site_scope: scope,
site: rawTarget.site || rawTarget.site_name || catalogEntry.site || null,
deviceCount,
};
}
}
return null;
}, []);
const targetKey = useCallback((target) => {
if (!target) return "";
if (target.kind === "filter") return `filter-${target.filter_id}`;
if (target.kind === "device") return `device-${(target.hostname || "").toLowerCase()}`;
return "";
}, []);
const normalizeTargetList = useCallback(
(list) => {
if (!Array.isArray(list)) return [];
const seen = new Set();
const next = [];
list.forEach((entry) => {
const normalized = normalizeTarget(entry);
if (!normalized) return;
const key = targetKey(normalized);
if (!key || seen.has(key)) return;
seen.add(key);
next.push(normalized);
});
return next;
},
[normalizeTarget, targetKey]
);
const serializeTargetsForSave = useCallback((list) => {
if (!Array.isArray(list)) return [];
return list
.map((target) => {
if (!target) return null;
if (target.kind === "filter") {
return {
kind: "filter",
filter_id: target.filter_id,
name: target.name,
site_scope: target.site_scope,
site: target.site,
};
}
if (target.kind === "device") {
return target.hostname;
}
return null;
})
.filter(Boolean);
}, []);
const addTargets = useCallback(
(entries) => {
const candidateList = Array.isArray(entries) ? entries : [entries];
setTargets((prev) => {
const seen = new Set(prev.map((existing) => targetKey(existing)).filter(Boolean));
const additions = [];
candidateList.forEach((entry) => {
const normalized = normalizeTarget(entry);
if (!normalized) return;
const key = targetKey(normalized);
if (!key || seen.has(key)) return;
seen.add(key);
additions.push(normalized);
});
if (!additions.length) return prev;
return [...prev, ...additions];
});
},
[normalizeTarget, targetKey]
);
const removeTarget = useCallback(
(targetToRemove) => {
const removalKey = targetKey(targetToRemove);
if (!removalKey) return;
setTargets((prev) => prev.filter((target) => targetKey(target) !== removalKey));
},
[targetKey]
);
useEffect(() => {
setTargets((prev) => {
let changed = false;
const next = prev.map((target) => {
if (target?.kind === "filter") {
const normalized = normalizeTarget(target);
if (normalized) {
const sameKey = targetKey(normalized) === targetKey(target);
if (!sameKey || normalized.name !== target.name || normalized.deviceCount !== target.deviceCount || normalized.site !== target.site) {
changed = true;
return normalized;
}
}
}
return target;
});
return changed ? next : prev;
});
}, [filterCatalog, normalizeTarget, targetKey]);
const generateLocalId = useCallback(
() => `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
[]
@@ -1423,7 +1618,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null, quic
if (initialJob && initialJob.id) {
setJobName(initialJob.name || "");
setPageTitleJobName(typeof initialJob.name === "string" ? initialJob.name.trim() : "");
setTargets(Array.isArray(initialJob.targets) ? initialJob.targets : []);
setTargets(normalizeTargetList(initialJob.targets || []));
setScheduleType(initialJob.schedule_type || initialJob.schedule?.type || "immediately");
setStartDateTime(initialJob.start_ts ? dayjs(Number(initialJob.start_ts) * 1000).second(0) : (initialJob.schedule?.start ? dayjs(initialJob.schedule.start).second(0) : dayjs().add(5, "minute").second(0)));
setStopAfterEnabled(Boolean(initialJob.duration_stop_enabled));
@@ -1453,7 +1648,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null, quic
return () => {
canceled = true;
};
}, [initialJob, hydrateExistingComponents]);
}, [initialJob, hydrateExistingComponents, normalizeTargetList]);
const openAddComponent = async () => {
setAddCompOpen(true);
@@ -1506,7 +1701,10 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null, quic
const openAddTargets = async () => {
setAddTargetOpen(true);
setSelectedTargets({});
setTargetPickerTab("devices");
setSelectedDeviceTargets({});
setSelectedFilterTargets({});
loadFilterCatalog();
try {
const resp = await fetch("/api/agents");
if (resp.ok) {
@@ -1555,7 +1753,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null, quic
const payload = {
name: jobName,
components: payloadComponents,
targets,
targets: serializeTargetsForSave(targets),
schedule: { type: scheduleType, start: scheduleType !== "immediately" ? (() => { try { const d = startDateTime?.toDate?.() || new Date(startDateTime); d.setSeconds(0,0); return d.toISOString(); } catch { return startDateTime; } })() : null },
duration: { stopAfterEnabled, expiration },
execution_context: execContext,
@@ -1594,22 +1792,18 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null, quic
if (!quickJobDraft || !quickJobDraft.id) return;
if (quickDraftAppliedRef.current === quickJobDraft.id) return;
quickDraftAppliedRef.current = quickJobDraft.id;
const uniqueTargets = [];
const pushTarget = (value) => {
const normalized = typeof value === "string" ? value.trim() : "";
if (!normalized) return;
if (!uniqueTargets.includes(normalized)) uniqueTargets.push(normalized);
};
const incoming = Array.isArray(quickJobDraft.hostnames) ? quickJobDraft.hostnames : [];
incoming.forEach(pushTarget);
setTargets(uniqueTargets);
setSelectedTargets({});
const normalizedTargets = normalizeTargetList(incoming);
setTargets(normalizedTargets);
setSelectedDeviceTargets({});
setSelectedFilterTargets({});
setComponents([]);
setComponentVarErrors({});
const normalizedSchedule = String(quickJobDraft.scheduleType || "immediately").trim().toLowerCase() || "immediately";
setScheduleType(normalizedSchedule);
const placeholderAssembly = (quickJobDraft.placeholderAssemblyLabel || "Choose Assembly").trim() || "Choose Assembly";
const deviceLabel = (quickJobDraft.deviceLabel || uniqueTargets[0] || "Selected Device").trim() || "Selected Device";
const defaultDeviceLabel = normalizedTargets[0]?.hostname || incoming[0] || "Selected Device";
const deviceLabel = (quickJobDraft.deviceLabel || defaultDeviceLabel).trim() || "Selected Device";
const initialName = `Quick Job - ${placeholderAssembly} - ${deviceLabel}`;
setJobName(initialName);
setPageTitleJobName(initialName.trim());
@@ -1626,7 +1820,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null, quic
if (typeof onConsumeQuickJobDraft === "function") {
onConsumeQuickJobDraft(quickJobDraft.id);
}
}, [editing, quickJobDraft, tabDefs, onConsumeQuickJobDraft]);
}, [editing, quickJobDraft, tabDefs, onConsumeQuickJobDraft, normalizeTargetList]);
useEffect(() => {
if (!quickJobMeta?.allowAutoRename) return;
@@ -1749,26 +1943,38 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null, quic
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Status</TableCell>
<TableCell>Type</TableCell>
<TableCell>Target</TableCell>
<TableCell>Details</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{targets.map((h) => (
<TableRow key={h} hover>
<TableCell>{h}</TableCell>
<TableCell></TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => setTargets((prev) => prev.filter((x) => x !== h))} sx={{ color: "#ff6666" }}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
{targets.map((target) => {
const key = targetKey(target) || target.hostname || target.filter_id || Math.random().toString(36);
const isFilter = target?.kind === "filter";
const deviceCount = typeof target?.deviceCount === "number" && Number.isFinite(target.deviceCount) ? target.deviceCount : null;
const detailText = isFilter
? `${deviceCount != null ? deviceCount.toLocaleString() : "—"} device${deviceCount === 1 ? "" : "s"}${
target?.site_scope === "scoped" ? `${target?.site || "Specific site"}` : ""
}`
: "—";
return (
<TableRow key={key} hover>
<TableCell>{isFilter ? "Filter" : "Device"}</TableCell>
<TableCell>{isFilter ? (target?.name || `Filter #${target?.filter_id}`) : target?.hostname}</TableCell>
<TableCell>{detailText}</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => removeTarget(target)} sx={{ color: "#ff6666" }}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
);
})}
{targets.length === 0 && (
<TableRow>
<TableCell colSpan={3} sx={{ color: "#888" }}>No targets selected.</TableCell>
<TableCell colSpan={4} sx={{ color: "#888" }}>No targets selected.</TableCell>
</TableRow>
)}
</TableBody>
@@ -2193,53 +2399,141 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null, quic
>
<DialogTitle>Select Targets</DialogTitle>
<DialogContent>
<Box sx={{ mb: 2, display: "flex", gap: 2 }}>
<TextField
size="small"
placeholder="Search devices..."
value={deviceSearch}
onChange={(e) => setDeviceSearch(e.target.value)}
sx={{ flex: 1, "& .MuiOutlinedInput-root": { bgcolor: "#1b1b1b" }, "& .MuiInputBase-input": { color: "#e6edf3" } }}
/>
</Box>
<Table size="small">
<TableHead>
<TableRow>
<TableCell width={40}></TableCell>
<TableCell>Name</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{availableDevices
.filter((d) => d.display.toLowerCase().includes(deviceSearch.toLowerCase()))
.map((d) => (
<TableRow key={d.hostname} hover onClick={() => setSelectedTargets((prev) => ({ ...prev, [d.hostname]: !prev[d.hostname] }))}>
<TableCell>
<Checkbox size="small" checked={!!selectedTargets[d.hostname]}
onChange={(e) => setSelectedTargets((prev) => ({ ...prev, [d.hostname]: e.target.checked }))}
/>
</TableCell>
<TableCell>{d.display}</TableCell>
<TableCell>
<span style={{ display: "inline-block", width: 10, height: 10, borderRadius: 10, background: d.online ? "#00d18c" : "#ff4f4f", marginRight: 8, verticalAlign: "middle" }} />
{d.online ? "Online" : "Offline"}
</TableCell>
</TableRow>
))}
{availableDevices.length === 0 && (
<TableRow><TableCell colSpan={3} sx={{ color: "#888" }}>No devices available.</TableCell></TableRow>
)}
</TableBody>
</Table>
<Tabs
value={targetPickerTab}
onChange={(_, value) => setTargetPickerTab(value)}
sx={{ mb: 2 }}
textColor="inherit"
indicatorColor="primary"
>
<Tab label="Devices" value="devices" />
<Tab label="Filters" value="filters" />
</Tabs>
{targetPickerTab === "devices" ? (
<>
<Box sx={{ mb: 2, display: "flex", gap: 2 }}>
<TextField
size="small"
placeholder="Search devices..."
value={deviceSearch}
onChange={(e) => setDeviceSearch(e.target.value)}
sx={{ flex: 1, "& .MuiOutlinedInput-root": { bgcolor: "#1b1b1b" }, "& .MuiInputBase-input": { color: "#e6edf3" } }}
/>
</Box>
<Table size="small">
<TableHead>
<TableRow>
<TableCell width={40}></TableCell>
<TableCell>Name</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{availableDevices
.filter((d) => d.display.toLowerCase().includes(deviceSearch.toLowerCase()))
.map((d) => (
<TableRow key={d.hostname} hover onClick={() => setSelectedDeviceTargets((prev) => ({ ...prev, [d.hostname]: !prev[d.hostname] }))}>
<TableCell>
<Checkbox
size="small"
checked={!!selectedDeviceTargets[d.hostname]}
onChange={(e) => {
e.stopPropagation();
setSelectedDeviceTargets((prev) => ({ ...prev, [d.hostname]: e.target.checked }));
}}
/>
</TableCell>
<TableCell>{d.display}</TableCell>
<TableCell>
<span style={{ display: "inline-block", width: 10, height: 10, borderRadius: 10, background: d.online ? "#00d18c" : "#ff4f4f", marginRight: 8, verticalAlign: "middle" }} />
{d.online ? "Online" : "Offline"}
</TableCell>
</TableRow>
))}
{availableDevices.length === 0 && (
<TableRow><TableCell colSpan={3} sx={{ color: "#888" }}>No devices available.</TableCell></TableRow>
)}
</TableBody>
</Table>
</>
) : (
<>
<Box sx={{ mb: 2, display: "flex", gap: 2 }}>
<TextField
size="small"
placeholder="Search filters..."
value={filterSearch}
onChange={(e) => setFilterSearch(e.target.value)}
sx={{ flex: 1, "& .MuiOutlinedInput-root": { bgcolor: "#1b1b1b" }, "& .MuiInputBase-input": { color: "#e6edf3" } }}
/>
</Box>
<Table size="small">
<TableHead>
<TableRow>
<TableCell width={40}></TableCell>
<TableCell>Filter</TableCell>
<TableCell>Devices</TableCell>
<TableCell>Scope</TableCell>
</TableRow>
</TableHead>
<TableBody>
{(filterCatalog || [])
.filter((f) => (f.name || "").toLowerCase().includes(filterSearch.toLowerCase()))
.map((f) => (
<TableRow key={f.id} hover onClick={() => setSelectedFilterTargets((prev) => ({ ...prev, [f.id]: !prev[f.id] }))}>
<TableCell>
<Checkbox
size="small"
checked={!!selectedFilterTargets[f.id]}
onChange={(e) => {
e.stopPropagation();
setSelectedFilterTargets((prev) => ({ ...prev, [f.id]: e.target.checked }));
}}
/>
</TableCell>
<TableCell>{f.name}</TableCell>
<TableCell>{typeof f.deviceCount === "number" ? f.deviceCount.toLocaleString() : "—"}</TableCell>
<TableCell>{f.scope === "scoped" ? (f.site || "Specific Site") : "All Sites"}</TableCell>
</TableRow>
))}
{!loadingFilterCatalog && (!filterCatalog || filterCatalog.length === 0) && (
<TableRow><TableCell colSpan={4} sx={{ color: "#888" }}>No filters available.</TableCell></TableRow>
)}
{loadingFilterCatalog && (
<TableRow><TableCell colSpan={4} sx={{ color: "#888" }}>Loading filters</TableCell></TableRow>
)}
</TableBody>
</Table>
</>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setAddTargetOpen(false)} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={() => {
const chosen = Object.keys(selectedTargets).filter((h) => selectedTargets[h]);
setTargets((prev) => Array.from(new Set([...prev, ...chosen])));
setAddTargetOpen(false);
}} sx={{ color: "#58a6ff" }}>Add Selected</Button>
<Button
onClick={() => {
if (targetPickerTab === "filters") {
const additions = filterCatalog
.filter((f) => selectedFilterTargets[f.id])
.map((f) => ({
kind: "filter",
filter_id: f.id,
name: f.name,
site_scope: f.scope,
site: f.site,
deviceCount: f.deviceCount,
}));
if (additions.length) addTargets(additions);
} else {
const chosenHosts = Object.keys(selectedDeviceTargets).filter((hostname) => selectedDeviceTargets[hostname]);
if (chosenHosts.length) addTargets(chosenHosts);
}
setAddTargetOpen(false);
}}
sx={{ color: "#58a6ff" }}
>
Add Selected
</Button>
</DialogActions>
</Dialog>
@@ -2258,6 +2552,3 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null, quic
</Paper>
);
}