mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-14 22:35:47 -07:00
Implemented Filter-Based Device Targeting for Scheduled Jobs
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
542
Data/Engine/services/filters/matcher.py
Normal file
542
Data/Engine/services/filters/matcher.py
Normal 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"]
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user