mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-16 12:05:48 -07:00
Removal of Legacy Servere Codebase
This commit is contained in:
@@ -77,11 +77,11 @@ def _stage_web_interface_assets(logger: Optional[logging.Logger] = None, *, forc
|
|||||||
|
|
||||||
project_root = _project_root()
|
project_root = _project_root()
|
||||||
engine_web_root = project_root / "Engine" / "web-interface"
|
engine_web_root = project_root / "Engine" / "web-interface"
|
||||||
legacy_source = project_root / "Data" / "Server" / "WebUI"
|
modern_source = project_root / "Data" / "Engine" / "web-interface"
|
||||||
|
|
||||||
if not legacy_source.is_dir():
|
if not modern_source.is_dir():
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Engine web interface source missing: {legacy_source}"
|
f"Engine web interface source missing: {modern_source}"
|
||||||
)
|
)
|
||||||
|
|
||||||
index_path = engine_web_root / "index.html"
|
index_path = engine_web_root / "index.html"
|
||||||
@@ -92,14 +92,14 @@ def _stage_web_interface_assets(logger: Optional[logging.Logger] = None, *, forc
|
|||||||
if engine_web_root.exists():
|
if engine_web_root.exists():
|
||||||
shutil.rmtree(engine_web_root)
|
shutil.rmtree(engine_web_root)
|
||||||
|
|
||||||
shutil.copytree(legacy_source, engine_web_root)
|
shutil.copytree(modern_source, engine_web_root)
|
||||||
|
|
||||||
if not index_path.is_file():
|
if not index_path.is_file():
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Engine web interface staging failed; missing {index_path}"
|
f"Engine web interface staging failed; missing {index_path}"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Engine web interface staged from %s to %s", legacy_source, engine_web_root)
|
logger.info("Engine web interface staged from %s to %s", modern_source, engine_web_root)
|
||||||
return engine_web_root
|
return engine_web_root
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,496 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import secrets
|
|
||||||
import sqlite3
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
|
||||||
|
|
||||||
from Modules.guid_utils import normalize_guid
|
|
||||||
|
|
||||||
|
|
||||||
VALID_TTL_HOURS = {1, 3, 6, 12, 24}
|
|
||||||
|
|
||||||
|
|
||||||
def register(
|
|
||||||
app,
|
|
||||||
*,
|
|
||||||
db_conn_factory: Callable[[], sqlite3.Connection],
|
|
||||||
require_admin: Callable[[], Optional[Any]],
|
|
||||||
current_user: Callable[[], Optional[Dict[str, str]]],
|
|
||||||
log: Callable[[str, str, Optional[str]], None],
|
|
||||||
) -> None:
|
|
||||||
blueprint = Blueprint("admin", __name__)
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
|
||||||
return datetime.now(tz=timezone.utc)
|
|
||||||
|
|
||||||
def _iso(dt: datetime) -> str:
|
|
||||||
return dt.isoformat()
|
|
||||||
|
|
||||||
def _lookup_user_id(cur: sqlite3.Cursor, username: str) -> Optional[str]:
|
|
||||||
if not username:
|
|
||||||
return None
|
|
||||||
cur.execute(
|
|
||||||
"SELECT id FROM users WHERE LOWER(username) = LOWER(?)",
|
|
||||||
(username,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if row:
|
|
||||||
return str(row[0])
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _hostname_conflict(
|
|
||||||
cur: sqlite3.Cursor,
|
|
||||||
hostname: Optional[str],
|
|
||||||
pending_guid: Optional[str],
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
if not hostname:
|
|
||||||
return None
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT d.guid, d.ssl_key_fingerprint, ds.site_id, s.name
|
|
||||||
FROM devices d
|
|
||||||
LEFT JOIN device_sites ds ON ds.device_hostname = d.hostname
|
|
||||||
LEFT JOIN sites s ON s.id = ds.site_id
|
|
||||||
WHERE d.hostname = ?
|
|
||||||
""",
|
|
||||||
(hostname,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
existing_guid = normalize_guid(row[0])
|
|
||||||
existing_fingerprint = (row[1] or "").strip().lower()
|
|
||||||
pending_norm = normalize_guid(pending_guid)
|
|
||||||
if existing_guid and pending_norm and existing_guid == pending_norm:
|
|
||||||
return None
|
|
||||||
site_id_raw = row[2]
|
|
||||||
site_id = None
|
|
||||||
if site_id_raw is not None:
|
|
||||||
try:
|
|
||||||
site_id = int(site_id_raw)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
site_id = None
|
|
||||||
site_name = row[3] or ""
|
|
||||||
return {
|
|
||||||
"guid": existing_guid or None,
|
|
||||||
"ssl_key_fingerprint": existing_fingerprint or None,
|
|
||||||
"site_id": site_id,
|
|
||||||
"site_name": site_name,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _suggest_alternate_hostname(
|
|
||||||
cur: sqlite3.Cursor,
|
|
||||||
hostname: Optional[str],
|
|
||||||
pending_guid: Optional[str],
|
|
||||||
) -> Optional[str]:
|
|
||||||
base = (hostname or "").strip()
|
|
||||||
if not base:
|
|
||||||
return None
|
|
||||||
base = base[:253]
|
|
||||||
candidate = base
|
|
||||||
pending_norm = normalize_guid(pending_guid)
|
|
||||||
suffix = 1
|
|
||||||
while True:
|
|
||||||
cur.execute(
|
|
||||||
"SELECT guid FROM devices WHERE hostname = ?",
|
|
||||||
(candidate,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return candidate
|
|
||||||
existing_guid = normalize_guid(row[0])
|
|
||||||
if pending_norm and existing_guid == pending_norm:
|
|
||||||
return candidate
|
|
||||||
candidate = f"{base}-{suffix}"
|
|
||||||
suffix += 1
|
|
||||||
if suffix > 50:
|
|
||||||
return pending_norm or candidate
|
|
||||||
|
|
||||||
@blueprint.before_request
|
|
||||||
def _check_admin():
|
|
||||||
result = require_admin()
|
|
||||||
if result is not None:
|
|
||||||
return result
|
|
||||||
return None
|
|
||||||
|
|
||||||
@blueprint.route("/api/admin/enrollment-codes", methods=["GET"])
|
|
||||||
def list_enrollment_codes():
|
|
||||||
status_filter = request.args.get("status")
|
|
||||||
conn = db_conn_factory()
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
sql = """
|
|
||||||
SELECT id,
|
|
||||||
code,
|
|
||||||
expires_at,
|
|
||||||
created_by_user_id,
|
|
||||||
used_at,
|
|
||||||
used_by_guid,
|
|
||||||
max_uses,
|
|
||||||
use_count,
|
|
||||||
last_used_at
|
|
||||||
FROM enrollment_install_codes
|
|
||||||
"""
|
|
||||||
params: List[str] = []
|
|
||||||
now_iso = _iso(_now())
|
|
||||||
if status_filter == "active":
|
|
||||||
sql += " WHERE use_count < max_uses AND expires_at > ?"
|
|
||||||
params.append(now_iso)
|
|
||||||
elif status_filter == "expired":
|
|
||||||
sql += " WHERE use_count < max_uses AND expires_at <= ?"
|
|
||||||
params.append(now_iso)
|
|
||||||
elif status_filter == "used":
|
|
||||||
sql += " WHERE use_count >= max_uses"
|
|
||||||
sql += " ORDER BY expires_at ASC"
|
|
||||||
cur.execute(sql, params)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
records = []
|
|
||||||
for row in rows:
|
|
||||||
records.append(
|
|
||||||
{
|
|
||||||
"id": row[0],
|
|
||||||
"code": row[1],
|
|
||||||
"expires_at": row[2],
|
|
||||||
"created_by_user_id": row[3],
|
|
||||||
"used_at": row[4],
|
|
||||||
"used_by_guid": row[5],
|
|
||||||
"max_uses": row[6],
|
|
||||||
"use_count": row[7],
|
|
||||||
"last_used_at": row[8],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return jsonify({"codes": records})
|
|
||||||
|
|
||||||
@blueprint.route("/api/admin/enrollment-codes", methods=["POST"])
|
|
||||||
def create_enrollment_code():
|
|
||||||
payload = request.get_json(force=True, silent=True) or {}
|
|
||||||
ttl_hours = int(payload.get("ttl_hours") or 1)
|
|
||||||
if ttl_hours not in VALID_TTL_HOURS:
|
|
||||||
return jsonify({"error": "invalid_ttl"}), 400
|
|
||||||
|
|
||||||
max_uses_value = payload.get("max_uses")
|
|
||||||
if max_uses_value is None:
|
|
||||||
max_uses_value = payload.get("allowed_uses")
|
|
||||||
try:
|
|
||||||
max_uses = int(max_uses_value)
|
|
||||||
except Exception:
|
|
||||||
max_uses = 2
|
|
||||||
if max_uses < 1:
|
|
||||||
max_uses = 1
|
|
||||||
if max_uses > 10:
|
|
||||||
max_uses = 10
|
|
||||||
|
|
||||||
user = current_user() or {}
|
|
||||||
username = user.get("username") or ""
|
|
||||||
|
|
||||||
conn = db_conn_factory()
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
created_by = _lookup_user_id(cur, username) or username or "system"
|
|
||||||
code_value = _generate_install_code()
|
|
||||||
issued_at = _now()
|
|
||||||
expires_at = issued_at + timedelta(hours=ttl_hours)
|
|
||||||
record_id = str(uuid.uuid4())
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO enrollment_install_codes (
|
|
||||||
id, code, expires_at, created_by_user_id, max_uses, use_count
|
|
||||||
)
|
|
||||||
VALUES (?, ?, ?, ?, ?, 0)
|
|
||||||
""",
|
|
||||||
(record_id, code_value, _iso(expires_at), created_by, max_uses),
|
|
||||||
)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO enrollment_install_codes_persistent (
|
|
||||||
id,
|
|
||||||
code,
|
|
||||||
created_at,
|
|
||||||
expires_at,
|
|
||||||
created_by_user_id,
|
|
||||||
used_at,
|
|
||||||
used_by_guid,
|
|
||||||
max_uses,
|
|
||||||
last_known_use_count,
|
|
||||||
last_used_at,
|
|
||||||
is_active,
|
|
||||||
archived_at,
|
|
||||||
consumed_at
|
|
||||||
)
|
|
||||||
VALUES (?, ?, ?, ?, ?, NULL, NULL, ?, 0, NULL, 1, NULL, NULL)
|
|
||||||
ON CONFLICT(id) DO UPDATE
|
|
||||||
SET code = excluded.code,
|
|
||||||
created_at = excluded.created_at,
|
|
||||||
expires_at = excluded.expires_at,
|
|
||||||
created_by_user_id = excluded.created_by_user_id,
|
|
||||||
max_uses = excluded.max_uses,
|
|
||||||
last_known_use_count = 0,
|
|
||||||
used_at = NULL,
|
|
||||||
used_by_guid = NULL,
|
|
||||||
last_used_at = NULL,
|
|
||||||
is_active = 1,
|
|
||||||
archived_at = NULL,
|
|
||||||
consumed_at = NULL
|
|
||||||
""",
|
|
||||||
(record_id, code_value, _iso(issued_at), _iso(expires_at), created_by, max_uses),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
log(
|
|
||||||
"server",
|
|
||||||
f"installer code created id={record_id} by={username} ttl={ttl_hours}h max_uses={max_uses}",
|
|
||||||
)
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"id": record_id,
|
|
||||||
"code": code_value,
|
|
||||||
"expires_at": _iso(expires_at),
|
|
||||||
"max_uses": max_uses,
|
|
||||||
"use_count": 0,
|
|
||||||
"last_used_at": None,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@blueprint.route("/api/admin/enrollment-codes/<code_id>", methods=["DELETE"])
|
|
||||||
def delete_enrollment_code(code_id: str):
|
|
||||||
conn = db_conn_factory()
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(
|
|
||||||
"DELETE FROM enrollment_install_codes WHERE id = ? AND use_count = 0",
|
|
||||||
(code_id,),
|
|
||||||
)
|
|
||||||
deleted = cur.rowcount
|
|
||||||
if deleted:
|
|
||||||
archive_ts = _iso(_now())
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
UPDATE enrollment_install_codes_persistent
|
|
||||||
SET is_active = 0,
|
|
||||||
archived_at = COALESCE(archived_at, ?)
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(archive_ts, code_id),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if not deleted:
|
|
||||||
return jsonify({"error": "not_found"}), 404
|
|
||||||
log("server", f"installer code deleted id={code_id}")
|
|
||||||
return jsonify({"status": "deleted"})
|
|
||||||
|
|
||||||
@blueprint.route("/api/admin/device-approvals", methods=["GET"])
|
|
||||||
def list_device_approvals():
|
|
||||||
status_raw = request.args.get("status")
|
|
||||||
status = (status_raw or "").strip().lower()
|
|
||||||
approvals: List[Dict[str, Any]] = []
|
|
||||||
conn = db_conn_factory()
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
params: List[str] = []
|
|
||||||
sql = """
|
|
||||||
SELECT
|
|
||||||
da.id,
|
|
||||||
da.approval_reference,
|
|
||||||
da.guid,
|
|
||||||
da.hostname_claimed,
|
|
||||||
da.ssl_key_fingerprint_claimed,
|
|
||||||
da.enrollment_code_id,
|
|
||||||
da.status,
|
|
||||||
da.client_nonce,
|
|
||||||
da.server_nonce,
|
|
||||||
da.created_at,
|
|
||||||
da.updated_at,
|
|
||||||
da.approved_by_user_id,
|
|
||||||
u.username AS approved_by_username
|
|
||||||
FROM device_approvals AS da
|
|
||||||
LEFT JOIN users AS u
|
|
||||||
ON (
|
|
||||||
CAST(da.approved_by_user_id AS TEXT) = CAST(u.id AS TEXT)
|
|
||||||
OR LOWER(da.approved_by_user_id) = LOWER(u.username)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
if status and status != "all":
|
|
||||||
sql += " WHERE LOWER(da.status) = ?"
|
|
||||||
params.append(status)
|
|
||||||
sql += " ORDER BY da.created_at ASC"
|
|
||||||
cur.execute(sql, params)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
for row in rows:
|
|
||||||
record_guid = row[2]
|
|
||||||
hostname = row[3]
|
|
||||||
fingerprint_claimed = row[4]
|
|
||||||
claimed_fp_norm = (fingerprint_claimed or "").strip().lower()
|
|
||||||
conflict_raw = _hostname_conflict(cur, hostname, record_guid)
|
|
||||||
fingerprint_match = False
|
|
||||||
requires_prompt = False
|
|
||||||
conflict = None
|
|
||||||
if conflict_raw:
|
|
||||||
conflict_fp = (conflict_raw.get("ssl_key_fingerprint") or "").strip().lower()
|
|
||||||
fingerprint_match = bool(conflict_fp and claimed_fp_norm) and conflict_fp == claimed_fp_norm
|
|
||||||
requires_prompt = not fingerprint_match
|
|
||||||
conflict = {
|
|
||||||
**conflict_raw,
|
|
||||||
"fingerprint_match": fingerprint_match,
|
|
||||||
"requires_prompt": requires_prompt,
|
|
||||||
}
|
|
||||||
alternate_hostname = (
|
|
||||||
_suggest_alternate_hostname(cur, hostname, record_guid)
|
|
||||||
if conflict_raw and requires_prompt
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
approvals.append(
|
|
||||||
{
|
|
||||||
"id": row[0],
|
|
||||||
"approval_reference": row[1],
|
|
||||||
"guid": record_guid,
|
|
||||||
"hostname_claimed": hostname,
|
|
||||||
"ssl_key_fingerprint_claimed": fingerprint_claimed,
|
|
||||||
"enrollment_code_id": row[5],
|
|
||||||
"status": row[6],
|
|
||||||
"client_nonce": row[7],
|
|
||||||
"server_nonce": row[8],
|
|
||||||
"created_at": row[9],
|
|
||||||
"updated_at": row[10],
|
|
||||||
"approved_by_user_id": row[11],
|
|
||||||
"hostname_conflict": conflict,
|
|
||||||
"alternate_hostname": alternate_hostname,
|
|
||||||
"conflict_requires_prompt": requires_prompt,
|
|
||||||
"fingerprint_match": fingerprint_match,
|
|
||||||
"approved_by_username": row[12],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return jsonify({"approvals": approvals})
|
|
||||||
|
|
||||||
def _set_approval_status(
|
|
||||||
approval_id: str,
|
|
||||||
status: str,
|
|
||||||
*,
|
|
||||||
guid: Optional[str] = None,
|
|
||||||
resolution: Optional[str] = None,
|
|
||||||
):
|
|
||||||
user = current_user() or {}
|
|
||||||
username = user.get("username") or ""
|
|
||||||
|
|
||||||
conn = db_conn_factory()
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT status,
|
|
||||||
guid,
|
|
||||||
hostname_claimed,
|
|
||||||
ssl_key_fingerprint_claimed
|
|
||||||
FROM device_approvals
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(approval_id,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return {"error": "not_found"}, 404
|
|
||||||
existing_status = (row[0] or "").strip().lower()
|
|
||||||
if existing_status != "pending":
|
|
||||||
return {"error": "approval_not_pending"}, 409
|
|
||||||
stored_guid = row[1]
|
|
||||||
hostname_claimed = row[2]
|
|
||||||
fingerprint_claimed = (row[3] or "").strip().lower()
|
|
||||||
|
|
||||||
guid_effective = normalize_guid(guid) if guid else normalize_guid(stored_guid)
|
|
||||||
resolution_effective = (resolution.strip().lower() if isinstance(resolution, str) else None)
|
|
||||||
|
|
||||||
conflict = None
|
|
||||||
if status == "approved":
|
|
||||||
conflict = _hostname_conflict(cur, hostname_claimed, guid_effective)
|
|
||||||
if conflict:
|
|
||||||
conflict_fp = (conflict.get("ssl_key_fingerprint") or "").strip().lower()
|
|
||||||
fingerprint_match = bool(conflict_fp and fingerprint_claimed) and conflict_fp == fingerprint_claimed
|
|
||||||
if fingerprint_match:
|
|
||||||
guid_effective = conflict.get("guid") or guid_effective
|
|
||||||
if not resolution_effective:
|
|
||||||
resolution_effective = "auto_merge_fingerprint"
|
|
||||||
elif resolution_effective == "overwrite":
|
|
||||||
guid_effective = conflict.get("guid") or guid_effective
|
|
||||||
elif resolution_effective == "coexist":
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
"error": "conflict_resolution_required",
|
|
||||||
"hostname": hostname_claimed,
|
|
||||||
}, 409
|
|
||||||
|
|
||||||
guid_to_store = guid_effective or normalize_guid(stored_guid) or None
|
|
||||||
|
|
||||||
approved_by = _lookup_user_id(cur, username) or username or "system"
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
UPDATE device_approvals
|
|
||||||
SET status = ?,
|
|
||||||
guid = ?,
|
|
||||||
approved_by_user_id = ?,
|
|
||||||
updated_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
status,
|
|
||||||
guid_to_store,
|
|
||||||
approved_by,
|
|
||||||
_iso(_now()),
|
|
||||||
approval_id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
resolution_note = f" ({resolution_effective})" if resolution_effective else ""
|
|
||||||
log("server", f"device approval {approval_id} -> {status}{resolution_note} by {username}")
|
|
||||||
payload: Dict[str, Any] = {"status": status}
|
|
||||||
if resolution_effective:
|
|
||||||
payload["conflict_resolution"] = resolution_effective
|
|
||||||
return payload, 200
|
|
||||||
|
|
||||||
@blueprint.route("/api/admin/device-approvals/<approval_id>/approve", methods=["POST"])
|
|
||||||
def approve_device(approval_id: str):
|
|
||||||
payload = request.get_json(force=True, silent=True) or {}
|
|
||||||
guid = payload.get("guid")
|
|
||||||
if guid:
|
|
||||||
guid = str(guid).strip()
|
|
||||||
resolution_val = payload.get("conflict_resolution")
|
|
||||||
resolution = None
|
|
||||||
if isinstance(resolution_val, str):
|
|
||||||
cleaned = resolution_val.strip().lower()
|
|
||||||
if cleaned:
|
|
||||||
resolution = cleaned
|
|
||||||
result, status_code = _set_approval_status(
|
|
||||||
approval_id,
|
|
||||||
"approved",
|
|
||||||
guid=guid,
|
|
||||||
resolution=resolution,
|
|
||||||
)
|
|
||||||
return jsonify(result), status_code
|
|
||||||
|
|
||||||
@blueprint.route("/api/admin/device-approvals/<approval_id>/deny", methods=["POST"])
|
|
||||||
def deny_device(approval_id: str):
|
|
||||||
result, status_code = _set_approval_status(approval_id, "denied")
|
|
||||||
return jsonify(result), status_code
|
|
||||||
|
|
||||||
app.register_blueprint(blueprint)
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_install_code() -> str:
|
|
||||||
raw = secrets.token_hex(16).upper()
|
|
||||||
return "-".join(raw[i : i + 4] for i in range(0, len(raw), 4))
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import sqlite3
|
|
||||||
from typing import Any, Callable, Dict, Optional
|
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, g
|
|
||||||
|
|
||||||
from Modules.auth.device_auth import DeviceAuthManager, require_device_auth
|
|
||||||
from Modules.crypto.signing import ScriptSigner
|
|
||||||
from Modules.guid_utils import normalize_guid
|
|
||||||
|
|
||||||
AGENT_CONTEXT_HEADER = "X-Borealis-Agent-Context"
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical_context(value: Optional[str]) -> Optional[str]:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
cleaned = "".join(ch for ch in str(value) if ch.isalnum() or ch in ("_", "-"))
|
|
||||||
if not cleaned:
|
|
||||||
return None
|
|
||||||
return cleaned.upper()
|
|
||||||
|
|
||||||
|
|
||||||
def register(
|
|
||||||
app,
|
|
||||||
*,
|
|
||||||
db_conn_factory: Callable[[], Any],
|
|
||||||
auth_manager: DeviceAuthManager,
|
|
||||||
log: Callable[[str, str, Optional[str]], None],
|
|
||||||
script_signer: ScriptSigner,
|
|
||||||
) -> None:
|
|
||||||
blueprint = Blueprint("agents", __name__)
|
|
||||||
|
|
||||||
def _json_or_none(value) -> Optional[str]:
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return json.dumps(value)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _context_hint(ctx=None) -> Optional[str]:
|
|
||||||
if ctx is not None and getattr(ctx, "service_mode", None):
|
|
||||||
return _canonical_context(getattr(ctx, "service_mode", None))
|
|
||||||
return _canonical_context(request.headers.get(AGENT_CONTEXT_HEADER))
|
|
||||||
|
|
||||||
def _auth_context():
|
|
||||||
ctx = getattr(g, "device_auth", None)
|
|
||||||
if ctx is None:
|
|
||||||
log("server", f"device auth context missing for {request.path}", _context_hint())
|
|
||||||
return ctx
|
|
||||||
|
|
||||||
@blueprint.route("/api/agent/heartbeat", methods=["POST"])
|
|
||||||
@require_device_auth(auth_manager)
|
|
||||||
def heartbeat():
|
|
||||||
ctx = _auth_context()
|
|
||||||
if ctx is None:
|
|
||||||
return jsonify({"error": "auth_context_missing"}), 500
|
|
||||||
payload = request.get_json(force=True, silent=True) or {}
|
|
||||||
context_label = _context_hint(ctx)
|
|
||||||
|
|
||||||
now_ts = int(time.time())
|
|
||||||
updates: Dict[str, Optional[str]] = {"last_seen": now_ts}
|
|
||||||
|
|
||||||
hostname = payload.get("hostname")
|
|
||||||
if isinstance(hostname, str) and hostname.strip():
|
|
||||||
updates["hostname"] = hostname.strip()
|
|
||||||
|
|
||||||
inventory = payload.get("inventory") if isinstance(payload.get("inventory"), dict) else {}
|
|
||||||
for key in ("memory", "network", "software", "storage", "cpu"):
|
|
||||||
if key in inventory and inventory[key] is not None:
|
|
||||||
encoded = _json_or_none(inventory[key])
|
|
||||||
if encoded is not None:
|
|
||||||
updates[key] = encoded
|
|
||||||
|
|
||||||
metrics = payload.get("metrics") if isinstance(payload.get("metrics"), dict) else {}
|
|
||||||
def _maybe_str(field: str) -> Optional[str]:
|
|
||||||
val = metrics.get(field)
|
|
||||||
if isinstance(val, str):
|
|
||||||
return val.strip()
|
|
||||||
return None
|
|
||||||
|
|
||||||
if "last_user" in metrics and metrics["last_user"]:
|
|
||||||
updates["last_user"] = str(metrics["last_user"])
|
|
||||||
if "operating_system" in metrics and metrics["operating_system"]:
|
|
||||||
updates["operating_system"] = str(metrics["operating_system"])
|
|
||||||
if "uptime" in metrics and metrics["uptime"] is not None:
|
|
||||||
try:
|
|
||||||
updates["uptime"] = int(metrics["uptime"])
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
for field in ("external_ip", "internal_ip", "device_type"):
|
|
||||||
if field in payload and payload[field]:
|
|
||||||
updates[field] = str(payload[field])
|
|
||||||
|
|
||||||
conn = db_conn_factory()
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
def _apply_updates() -> int:
|
|
||||||
if not updates:
|
|
||||||
return 0
|
|
||||||
columns = ", ".join(f"{col} = ?" for col in updates.keys())
|
|
||||||
values = list(updates.values())
|
|
||||||
normalized_guid = normalize_guid(ctx.guid)
|
|
||||||
selected_guid: Optional[str] = None
|
|
||||||
if normalized_guid:
|
|
||||||
cur.execute(
|
|
||||||
"SELECT guid FROM devices WHERE UPPER(guid) = ?",
|
|
||||||
(normalized_guid,),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
for (stored_guid,) in rows or []:
|
|
||||||
if stored_guid == ctx.guid:
|
|
||||||
selected_guid = stored_guid
|
|
||||||
break
|
|
||||||
if not selected_guid and rows:
|
|
||||||
selected_guid = rows[0][0]
|
|
||||||
target_guid = selected_guid or ctx.guid
|
|
||||||
cur.execute(
|
|
||||||
f"UPDATE devices SET {columns} WHERE guid = ?",
|
|
||||||
values + [target_guid],
|
|
||||||
)
|
|
||||||
updated = cur.rowcount
|
|
||||||
if updated > 0 and normalized_guid and target_guid != normalized_guid:
|
|
||||||
try:
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE devices SET guid = ? WHERE guid = ?",
|
|
||||||
(normalized_guid, target_guid),
|
|
||||||
)
|
|
||||||
except sqlite3.IntegrityError:
|
|
||||||
pass
|
|
||||||
return updated
|
|
||||||
|
|
||||||
try:
|
|
||||||
rowcount = _apply_updates()
|
|
||||||
except sqlite3.IntegrityError as exc:
|
|
||||||
if "devices.hostname" in str(exc) and "UNIQUE" in str(exc).upper():
|
|
||||||
# Another device already claims this hostname; keep the existing
|
|
||||||
# canonical hostname assigned during enrollment to avoid breaking
|
|
||||||
# the unique constraint and continue updating the remaining fields.
|
|
||||||
existing_guid_for_hostname: Optional[str] = None
|
|
||||||
if "hostname" in updates:
|
|
||||||
try:
|
|
||||||
cur.execute(
|
|
||||||
"SELECT guid FROM devices WHERE hostname = ?",
|
|
||||||
(updates["hostname"],),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if row and row[0]:
|
|
||||||
existing_guid_for_hostname = normalize_guid(row[0])
|
|
||||||
except Exception:
|
|
||||||
existing_guid_for_hostname = None
|
|
||||||
if "hostname" in updates:
|
|
||||||
updates.pop("hostname", None)
|
|
||||||
try:
|
|
||||||
rowcount = _apply_updates()
|
|
||||||
except sqlite3.IntegrityError:
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
current_guid = normalize_guid(ctx.guid)
|
|
||||||
except Exception:
|
|
||||||
current_guid = ctx.guid
|
|
||||||
if (
|
|
||||||
existing_guid_for_hostname
|
|
||||||
and current_guid
|
|
||||||
and existing_guid_for_hostname == current_guid
|
|
||||||
):
|
|
||||||
pass # Same device contexts; no log needed.
|
|
||||||
else:
|
|
||||||
log(
|
|
||||||
"server",
|
|
||||||
"heartbeat hostname collision ignored for guid="
|
|
||||||
f"{ctx.guid}",
|
|
||||||
context_label,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
if rowcount == 0:
|
|
||||||
log("server", f"heartbeat missing device record guid={ctx.guid}", context_label)
|
|
||||||
return jsonify({"error": "device_not_registered"}), 404
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return jsonify({"status": "ok", "poll_after_ms": 15000})
|
|
||||||
|
|
||||||
@blueprint.route("/api/agent/script/request", methods=["POST"])
|
|
||||||
@require_device_auth(auth_manager)
|
|
||||||
def script_request():
|
|
||||||
ctx = _auth_context()
|
|
||||||
if ctx is None:
|
|
||||||
return jsonify({"error": "auth_context_missing"}), 500
|
|
||||||
if ctx.status != "active":
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"status": "quarantined",
|
|
||||||
"poll_after_ms": 60000,
|
|
||||||
"sig_alg": "ed25519",
|
|
||||||
"signing_key": script_signer.public_base64_spki(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Placeholder: actual dispatch logic will integrate with job scheduler.
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"status": "idle",
|
|
||||||
"poll_after_ms": 30000,
|
|
||||||
"sig_alg": "ed25519",
|
|
||||||
"signing_key": script_signer.public_base64_spki(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
app.register_blueprint(blueprint)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import functools
|
|
||||||
import sqlite3
|
|
||||||
import time
|
|
||||||
from contextlib import closing
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any, Callable, Dict, Optional
|
|
||||||
|
|
||||||
import jwt
|
|
||||||
from flask import g, jsonify, request
|
|
||||||
|
|
||||||
from Modules.auth.dpop import DPoPValidator, DPoPVerificationError, DPoPReplayError
|
|
||||||
from Modules.auth.rate_limit import SlidingWindowRateLimiter
|
|
||||||
from Modules.guid_utils import normalize_guid
|
|
||||||
|
|
||||||
AGENT_CONTEXT_HEADER = "X-Borealis-Agent-Context"
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical_context(value: Optional[str]) -> Optional[str]:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
cleaned = "".join(ch for ch in str(value) if ch.isalnum() or ch in ("_", "-"))
|
|
||||||
if not cleaned:
|
|
||||||
return None
|
|
||||||
return cleaned.upper()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DeviceAuthContext:
|
|
||||||
guid: str
|
|
||||||
ssl_key_fingerprint: str
|
|
||||||
token_version: int
|
|
||||||
access_token: str
|
|
||||||
claims: Dict[str, Any]
|
|
||||||
dpop_jkt: Optional[str]
|
|
||||||
status: str
|
|
||||||
service_mode: Optional[str]
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceAuthError(Exception):
|
|
||||||
status_code = 401
|
|
||||||
error_code = "unauthorized"
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
message: str = "unauthorized",
|
|
||||||
*,
|
|
||||||
status_code: Optional[int] = None,
|
|
||||||
retry_after: Optional[float] = None,
|
|
||||||
):
|
|
||||||
super().__init__(message)
|
|
||||||
if status_code is not None:
|
|
||||||
self.status_code = status_code
|
|
||||||
self.message = message
|
|
||||||
self.retry_after = retry_after
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceAuthManager:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
db_conn_factory: Callable[[], Any],
|
|
||||||
jwt_service,
|
|
||||||
dpop_validator: Optional[DPoPValidator],
|
|
||||||
log: Callable[[str, str, Optional[str]], None],
|
|
||||||
rate_limiter: Optional[SlidingWindowRateLimiter] = None,
|
|
||||||
) -> None:
|
|
||||||
self._db_conn_factory = db_conn_factory
|
|
||||||
self._jwt_service = jwt_service
|
|
||||||
self._dpop_validator = dpop_validator
|
|
||||||
self._log = log
|
|
||||||
self._rate_limiter = rate_limiter
|
|
||||||
|
|
||||||
def authenticate(self) -> DeviceAuthContext:
|
|
||||||
auth_header = request.headers.get("Authorization", "")
|
|
||||||
if not auth_header.startswith("Bearer "):
|
|
||||||
raise DeviceAuthError("missing_authorization")
|
|
||||||
token = auth_header[len("Bearer ") :].strip()
|
|
||||||
if not token:
|
|
||||||
raise DeviceAuthError("missing_authorization")
|
|
||||||
|
|
||||||
try:
|
|
||||||
claims = self._jwt_service.decode(token)
|
|
||||||
except jwt.ExpiredSignatureError:
|
|
||||||
raise DeviceAuthError("token_expired")
|
|
||||||
except Exception:
|
|
||||||
raise DeviceAuthError("invalid_token")
|
|
||||||
|
|
||||||
raw_guid = str(claims.get("guid") or "").strip()
|
|
||||||
guid = normalize_guid(raw_guid)
|
|
||||||
fingerprint = str(claims.get("ssl_key_fingerprint") or "").lower().strip()
|
|
||||||
token_version = int(claims.get("token_version") or 0)
|
|
||||||
if not guid or not fingerprint or token_version <= 0:
|
|
||||||
raise DeviceAuthError("invalid_claims")
|
|
||||||
|
|
||||||
if self._rate_limiter:
|
|
||||||
decision = self._rate_limiter.check(f"fp:{fingerprint}", 60, 60.0)
|
|
||||||
if not decision.allowed:
|
|
||||||
raise DeviceAuthError(
|
|
||||||
"rate_limited",
|
|
||||||
status_code=429,
|
|
||||||
retry_after=decision.retry_after,
|
|
||||||
)
|
|
||||||
|
|
||||||
context_label = _canonical_context(request.headers.get(AGENT_CONTEXT_HEADER))
|
|
||||||
|
|
||||||
with closing(self._db_conn_factory()) as conn:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT guid, ssl_key_fingerprint, token_version, status
|
|
||||||
FROM devices
|
|
||||||
WHERE UPPER(guid) = ?
|
|
||||||
""",
|
|
||||||
(guid,),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
row = None
|
|
||||||
for candidate in rows or []:
|
|
||||||
candidate_guid = normalize_guid(candidate[0])
|
|
||||||
if candidate_guid == guid:
|
|
||||||
row = candidate
|
|
||||||
break
|
|
||||||
if row is None and rows:
|
|
||||||
row = rows[0]
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
row = self._recover_device_record(
|
|
||||||
conn, guid, fingerprint, token_version, context_label
|
|
||||||
)
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
raise DeviceAuthError("device_not_found", status_code=403)
|
|
||||||
|
|
||||||
db_guid, db_fp, db_token_version, status = row
|
|
||||||
db_guid_normalized = normalize_guid(db_guid)
|
|
||||||
|
|
||||||
if not db_guid_normalized or db_guid_normalized != guid:
|
|
||||||
raise DeviceAuthError("device_guid_mismatch", status_code=403)
|
|
||||||
|
|
||||||
db_fp = (db_fp or "").lower().strip()
|
|
||||||
if db_fp and db_fp != fingerprint:
|
|
||||||
raise DeviceAuthError("fingerprint_mismatch", status_code=403)
|
|
||||||
|
|
||||||
if db_token_version and db_token_version > token_version:
|
|
||||||
raise DeviceAuthError("token_version_revoked", status_code=401)
|
|
||||||
|
|
||||||
status_normalized = (status or "active").strip().lower()
|
|
||||||
allowed_statuses = {"active", "quarantined"}
|
|
||||||
if status_normalized not in allowed_statuses:
|
|
||||||
raise DeviceAuthError("device_revoked", status_code=403)
|
|
||||||
if status_normalized == "quarantined":
|
|
||||||
self._log(
|
|
||||||
"server",
|
|
||||||
f"device {guid} is quarantined; limited access for {request.path}",
|
|
||||||
context_label,
|
|
||||||
)
|
|
||||||
|
|
||||||
dpop_jkt: Optional[str] = None
|
|
||||||
dpop_proof = request.headers.get("DPoP")
|
|
||||||
if dpop_proof:
|
|
||||||
if not self._dpop_validator:
|
|
||||||
raise DeviceAuthError("dpop_not_supported", status_code=400)
|
|
||||||
try:
|
|
||||||
htu = request.url
|
|
||||||
dpop_jkt = self._dpop_validator.verify(request.method, htu, dpop_proof, token)
|
|
||||||
except DPoPReplayError:
|
|
||||||
raise DeviceAuthError("dpop_replayed", status_code=400)
|
|
||||||
except DPoPVerificationError:
|
|
||||||
raise DeviceAuthError("dpop_invalid", status_code=400)
|
|
||||||
|
|
||||||
ctx = DeviceAuthContext(
|
|
||||||
guid=guid,
|
|
||||||
ssl_key_fingerprint=fingerprint,
|
|
||||||
token_version=token_version,
|
|
||||||
access_token=token,
|
|
||||||
claims=claims,
|
|
||||||
dpop_jkt=dpop_jkt,
|
|
||||||
status=status_normalized,
|
|
||||||
service_mode=context_label,
|
|
||||||
)
|
|
||||||
return ctx
|
|
||||||
|
|
||||||
def _recover_device_record(
|
|
||||||
self,
|
|
||||||
conn: sqlite3.Connection,
|
|
||||||
guid: str,
|
|
||||||
fingerprint: str,
|
|
||||||
token_version: int,
|
|
||||||
context_label: Optional[str],
|
|
||||||
) -> Optional[tuple]:
|
|
||||||
"""Attempt to recreate a missing device row for an authenticated token."""
|
|
||||||
|
|
||||||
guid = normalize_guid(guid)
|
|
||||||
fingerprint = (fingerprint or "").strip()
|
|
||||||
if not guid or not fingerprint:
|
|
||||||
return None
|
|
||||||
|
|
||||||
cur = conn.cursor()
|
|
||||||
now_ts = int(time.time())
|
|
||||||
try:
|
|
||||||
now_iso = datetime.now(tz=timezone.utc).isoformat()
|
|
||||||
except Exception:
|
|
||||||
now_iso = datetime.utcnow().isoformat() # pragma: no cover
|
|
||||||
|
|
||||||
base_hostname = f"RECOVERED-{guid[:12].upper()}" if guid else "RECOVERED"
|
|
||||||
|
|
||||||
for attempt in range(6):
|
|
||||||
hostname = base_hostname if attempt == 0 else f"{base_hostname}-{attempt}"
|
|
||||||
try:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO devices (
|
|
||||||
guid,
|
|
||||||
hostname,
|
|
||||||
created_at,
|
|
||||||
last_seen,
|
|
||||||
ssl_key_fingerprint,
|
|
||||||
token_version,
|
|
||||||
status,
|
|
||||||
key_added_at
|
|
||||||
)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 'active', ?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
guid,
|
|
||||||
hostname,
|
|
||||||
now_ts,
|
|
||||||
now_ts,
|
|
||||||
fingerprint,
|
|
||||||
max(token_version or 1, 1),
|
|
||||||
now_iso,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except sqlite3.IntegrityError as exc:
|
|
||||||
# Hostname collision – try again with a suffixed placeholder.
|
|
||||||
message = str(exc).lower()
|
|
||||||
if "hostname" in message and "unique" in message:
|
|
||||||
continue
|
|
||||||
self._log(
|
|
||||||
"server",
|
|
||||||
f"device auth failed to recover guid={guid} due to integrity error: {exc}",
|
|
||||||
context_label,
|
|
||||||
)
|
|
||||||
conn.rollback()
|
|
||||||
return None
|
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
|
||||||
self._log(
|
|
||||||
"server",
|
|
||||||
f"device auth unexpected error recovering guid={guid}: {exc}",
|
|
||||||
context_label,
|
|
||||||
)
|
|
||||||
conn.rollback()
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
conn.commit()
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# Exhausted attempts because of hostname collisions.
|
|
||||||
self._log(
|
|
||||||
"server",
|
|
||||||
f"device auth could not recover guid={guid}; hostname collisions persisted",
|
|
||||||
context_label,
|
|
||||||
)
|
|
||||||
conn.rollback()
|
|
||||||
return None
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT guid, ssl_key_fingerprint, token_version, status
|
|
||||||
FROM devices
|
|
||||||
WHERE guid = ?
|
|
||||||
""",
|
|
||||||
(guid,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
self._log(
|
|
||||||
"server",
|
|
||||||
f"device auth recovery for guid={guid} committed but row still missing",
|
|
||||||
context_label,
|
|
||||||
)
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
def require_device_auth(manager: DeviceAuthManager):
|
|
||||||
def decorator(func):
|
|
||||||
@functools.wraps(func)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
try:
|
|
||||||
ctx = manager.authenticate()
|
|
||||||
except DeviceAuthError as exc:
|
|
||||||
response = jsonify({"error": exc.message})
|
|
||||||
response.status_code = exc.status_code
|
|
||||||
retry_after = getattr(exc, "retry_after", None)
|
|
||||||
if retry_after:
|
|
||||||
try:
|
|
||||||
response.headers["Retry-After"] = str(max(1, int(retry_after)))
|
|
||||||
except Exception:
|
|
||||||
response.headers["Retry-After"] = "1"
|
|
||||||
return response
|
|
||||||
|
|
||||||
g.device_auth = ctx
|
|
||||||
return func(*args, **kwargs)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
"""
|
|
||||||
DPoP proof verification helpers.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import time
|
|
||||||
from threading import Lock
|
|
||||||
from typing import Dict, Optional
|
|
||||||
|
|
||||||
import jwt
|
|
||||||
|
|
||||||
_DP0P_MAX_SKEW = 300.0 # seconds
|
|
||||||
|
|
||||||
|
|
||||||
class DPoPVerificationError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class DPoPReplayError(DPoPVerificationError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class DPoPValidator:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._observed_jti: Dict[str, float] = {}
|
|
||||||
self._lock = Lock()
|
|
||||||
|
|
||||||
def verify(
|
|
||||||
self,
|
|
||||||
method: str,
|
|
||||||
htu: str,
|
|
||||||
proof: str,
|
|
||||||
access_token: Optional[str] = None,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
Verify the presented DPoP proof. Returns the JWK thumbprint on success.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not proof:
|
|
||||||
raise DPoPVerificationError("DPoP proof missing")
|
|
||||||
|
|
||||||
try:
|
|
||||||
header = jwt.get_unverified_header(proof)
|
|
||||||
except Exception as exc:
|
|
||||||
raise DPoPVerificationError("invalid DPoP header") from exc
|
|
||||||
|
|
||||||
jwk = header.get("jwk")
|
|
||||||
alg = header.get("alg")
|
|
||||||
if not jwk or not isinstance(jwk, dict):
|
|
||||||
raise DPoPVerificationError("missing jwk in DPoP header")
|
|
||||||
if alg not in ("EdDSA", "ES256", "ES384", "ES512"):
|
|
||||||
raise DPoPVerificationError(f"unsupported DPoP alg {alg}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
key = jwt.PyJWK(jwk)
|
|
||||||
public_key = key.key
|
|
||||||
except Exception as exc:
|
|
||||||
raise DPoPVerificationError("invalid jwk in DPoP header") from exc
|
|
||||||
|
|
||||||
try:
|
|
||||||
claims = jwt.decode(
|
|
||||||
proof,
|
|
||||||
public_key,
|
|
||||||
algorithms=[alg],
|
|
||||||
options={"require": ["htm", "htu", "jti", "iat"]},
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
raise DPoPVerificationError("invalid DPoP signature") from exc
|
|
||||||
|
|
||||||
htm = claims.get("htm")
|
|
||||||
proof_htu = claims.get("htu")
|
|
||||||
jti = claims.get("jti")
|
|
||||||
iat = claims.get("iat")
|
|
||||||
ath = claims.get("ath")
|
|
||||||
|
|
||||||
if not isinstance(htm, str) or htm.lower() != method.lower():
|
|
||||||
raise DPoPVerificationError("DPoP htm mismatch")
|
|
||||||
if not isinstance(proof_htu, str) or proof_htu != htu:
|
|
||||||
raise DPoPVerificationError("DPoP htu mismatch")
|
|
||||||
if not isinstance(jti, str):
|
|
||||||
raise DPoPVerificationError("DPoP jti missing")
|
|
||||||
if not isinstance(iat, (int, float)):
|
|
||||||
raise DPoPVerificationError("DPoP iat missing")
|
|
||||||
|
|
||||||
now = time.time()
|
|
||||||
if abs(now - float(iat)) > _DP0P_MAX_SKEW:
|
|
||||||
raise DPoPVerificationError("DPoP proof outside allowed skew")
|
|
||||||
|
|
||||||
if ath and access_token:
|
|
||||||
expected_ath = jwt.utils.base64url_encode(
|
|
||||||
hashlib.sha256(access_token.encode("utf-8")).digest()
|
|
||||||
).decode("ascii")
|
|
||||||
if expected_ath != ath:
|
|
||||||
raise DPoPVerificationError("DPoP ath mismatch")
|
|
||||||
|
|
||||||
with self._lock:
|
|
||||||
expiry = self._observed_jti.get(jti)
|
|
||||||
if expiry and expiry > now:
|
|
||||||
raise DPoPReplayError("DPoP proof replay detected")
|
|
||||||
self._observed_jti[jti] = now + _DP0P_MAX_SKEW
|
|
||||||
# Opportunistic cleanup
|
|
||||||
stale = [key for key, exp in self._observed_jti.items() if exp <= now]
|
|
||||||
for key in stale:
|
|
||||||
self._observed_jti.pop(key, None)
|
|
||||||
|
|
||||||
thumbprint = jwt.PyJWK(jwk).thumbprint()
|
|
||||||
return thumbprint.decode("ascii")
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
"""
|
|
||||||
JWT access-token helpers backed by an Ed25519 signing key.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
import jwt
|
|
||||||
from cryptography.hazmat.primitives import serialization
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
||||||
|
|
||||||
from Modules.runtime import ensure_runtime_dir, runtime_path
|
|
||||||
|
|
||||||
_KEY_DIR = runtime_path("auth_keys")
|
|
||||||
_KEY_FILE = _KEY_DIR / "borealis-jwt-ed25519.key"
|
|
||||||
_LEGACY_KEY_FILE = runtime_path("keys") / "borealis-jwt-ed25519.key"
|
|
||||||
|
|
||||||
|
|
||||||
class JWTService:
|
|
||||||
def __init__(self, private_key: ed25519.Ed25519PrivateKey, key_id: str):
|
|
||||||
self._private_key = private_key
|
|
||||||
self._public_key = private_key.public_key()
|
|
||||||
self._key_id = key_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def key_id(self) -> str:
|
|
||||||
return self._key_id
|
|
||||||
|
|
||||||
def issue_access_token(
|
|
||||||
self,
|
|
||||||
guid: str,
|
|
||||||
ssl_key_fingerprint: str,
|
|
||||||
token_version: int,
|
|
||||||
expires_in: int = 900,
|
|
||||||
extra_claims: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> str:
|
|
||||||
now = int(time.time())
|
|
||||||
payload: Dict[str, Any] = {
|
|
||||||
"sub": f"device:{guid}",
|
|
||||||
"guid": guid,
|
|
||||||
"ssl_key_fingerprint": ssl_key_fingerprint,
|
|
||||||
"token_version": int(token_version),
|
|
||||||
"iat": now,
|
|
||||||
"nbf": now,
|
|
||||||
"exp": now + int(expires_in),
|
|
||||||
}
|
|
||||||
if extra_claims:
|
|
||||||
payload.update(extra_claims)
|
|
||||||
|
|
||||||
token = jwt.encode(
|
|
||||||
payload,
|
|
||||||
self._private_key.private_bytes(
|
|
||||||
encoding=serialization.Encoding.PEM,
|
|
||||||
format=serialization.PrivateFormat.PKCS8,
|
|
||||||
encryption_algorithm=serialization.NoEncryption(),
|
|
||||||
),
|
|
||||||
algorithm="EdDSA",
|
|
||||||
headers={"kid": self._key_id},
|
|
||||||
)
|
|
||||||
return token
|
|
||||||
|
|
||||||
def decode(self, token: str, *, audience: Optional[str] = None) -> Dict[str, Any]:
|
|
||||||
options = {"require": ["exp", "iat", "sub"]}
|
|
||||||
public_pem = self._public_key.public_bytes(
|
|
||||||
encoding=serialization.Encoding.PEM,
|
|
||||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
||||||
)
|
|
||||||
return jwt.decode(
|
|
||||||
token,
|
|
||||||
public_pem,
|
|
||||||
algorithms=["EdDSA"],
|
|
||||||
audience=audience,
|
|
||||||
options=options,
|
|
||||||
)
|
|
||||||
|
|
||||||
def public_jwk(self) -> Dict[str, Any]:
|
|
||||||
public_bytes = self._public_key.public_bytes(
|
|
||||||
encoding=serialization.Encoding.Raw,
|
|
||||||
format=serialization.PublicFormat.Raw,
|
|
||||||
)
|
|
||||||
# PyJWT expects base64url without padding.
|
|
||||||
jwk_x = jwt.utils.base64url_encode(public_bytes).decode("ascii")
|
|
||||||
return {"kty": "OKP", "crv": "Ed25519", "kid": self._key_id, "alg": "EdDSA", "use": "sig", "x": jwk_x}
|
|
||||||
|
|
||||||
|
|
||||||
def load_service() -> JWTService:
|
|
||||||
private_key = _load_or_create_private_key()
|
|
||||||
public_bytes = private_key.public_key().public_bytes(
|
|
||||||
encoding=serialization.Encoding.DER,
|
|
||||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
||||||
)
|
|
||||||
key_id = hashlib.sha256(public_bytes).hexdigest()[:16]
|
|
||||||
return JWTService(private_key, key_id)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_or_create_private_key() -> ed25519.Ed25519PrivateKey:
|
|
||||||
ensure_runtime_dir("auth_keys")
|
|
||||||
_migrate_legacy_key_if_present()
|
|
||||||
|
|
||||||
if _KEY_FILE.exists():
|
|
||||||
with _KEY_FILE.open("rb") as fh:
|
|
||||||
return serialization.load_pem_private_key(fh.read(), password=None)
|
|
||||||
|
|
||||||
if _LEGACY_KEY_FILE.exists():
|
|
||||||
with _LEGACY_KEY_FILE.open("rb") as fh:
|
|
||||||
return serialization.load_pem_private_key(fh.read(), password=None)
|
|
||||||
|
|
||||||
private_key = ed25519.Ed25519PrivateKey.generate()
|
|
||||||
pem = private_key.private_bytes(
|
|
||||||
encoding=serialization.Encoding.PEM,
|
|
||||||
format=serialization.PrivateFormat.PKCS8,
|
|
||||||
encryption_algorithm=serialization.NoEncryption(),
|
|
||||||
)
|
|
||||||
with _KEY_FILE.open("wb") as fh:
|
|
||||||
fh.write(pem)
|
|
||||||
try:
|
|
||||||
if _KEY_FILE.exists() and hasattr(_KEY_FILE, "chmod"):
|
|
||||||
_KEY_FILE.chmod(0o600)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return private_key
|
|
||||||
|
|
||||||
|
|
||||||
def _migrate_legacy_key_if_present() -> None:
|
|
||||||
if not _LEGACY_KEY_FILE.exists() or _KEY_FILE.exists():
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
ensure_runtime_dir("auth_keys")
|
|
||||||
try:
|
|
||||||
_LEGACY_KEY_FILE.replace(_KEY_FILE)
|
|
||||||
except Exception:
|
|
||||||
_KEY_FILE.write_bytes(_LEGACY_KEY_FILE.read_bytes())
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
"""
|
|
||||||
Tiny in-memory rate limiter suitable for single-process development servers.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
from collections import deque
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from threading import Lock
|
|
||||||
from typing import Deque, Dict, Tuple
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RateLimitDecision:
|
|
||||||
allowed: bool
|
|
||||||
retry_after: float
|
|
||||||
|
|
||||||
|
|
||||||
class SlidingWindowRateLimiter:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._buckets: Dict[str, Deque[float]] = {}
|
|
||||||
self._lock = Lock()
|
|
||||||
|
|
||||||
def check(self, key: str, limit: int, window_seconds: float) -> RateLimitDecision:
|
|
||||||
now = time.monotonic()
|
|
||||||
with self._lock:
|
|
||||||
bucket = self._buckets.get(key)
|
|
||||||
if bucket is None:
|
|
||||||
bucket = deque()
|
|
||||||
self._buckets[key] = bucket
|
|
||||||
|
|
||||||
while bucket and now - bucket[0] > window_seconds:
|
|
||||||
bucket.popleft()
|
|
||||||
|
|
||||||
if len(bucket) >= limit:
|
|
||||||
retry_after = max(0.0, window_seconds - (now - bucket[0]))
|
|
||||||
return RateLimitDecision(False, retry_after)
|
|
||||||
|
|
||||||
bucket.append(now)
|
|
||||||
return RateLimitDecision(True, 0.0)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
"""
|
|
||||||
Server TLS certificate management.
|
|
||||||
|
|
||||||
Borealis now issues a dedicated root CA and a leaf server certificate so that
|
|
||||||
agents can pin the CA without requiring a re-enrollment every time the server
|
|
||||||
certificate is refreshed. The CA is persisted alongside the server key so that
|
|
||||||
existing deployments can be upgraded in-place.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import ssl
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional, Tuple
|
|
||||||
|
|
||||||
from cryptography import x509
|
|
||||||
from cryptography.hazmat.primitives import hashes, serialization
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import ec
|
|
||||||
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
|
|
||||||
|
|
||||||
from Modules.runtime import ensure_server_certificates_dir, runtime_path, server_certificates_path
|
|
||||||
|
|
||||||
_CERT_DIR = server_certificates_path()
|
|
||||||
_CERT_FILE = _CERT_DIR / "borealis-server-cert.pem"
|
|
||||||
_KEY_FILE = _CERT_DIR / "borealis-server-key.pem"
|
|
||||||
_BUNDLE_FILE = _CERT_DIR / "borealis-server-bundle.pem"
|
|
||||||
_CA_KEY_FILE = _CERT_DIR / "borealis-root-ca-key.pem"
|
|
||||||
_CA_CERT_FILE = _CERT_DIR / "borealis-root-ca.pem"
|
|
||||||
|
|
||||||
_LEGACY_CERT_DIR = runtime_path("certs")
|
|
||||||
_LEGACY_CERT_FILE = _LEGACY_CERT_DIR / "borealis-server-cert.pem"
|
|
||||||
_LEGACY_KEY_FILE = _LEGACY_CERT_DIR / "borealis-server-key.pem"
|
|
||||||
_LEGACY_BUNDLE_FILE = _LEGACY_CERT_DIR / "borealis-server-bundle.pem"
|
|
||||||
|
|
||||||
_ROOT_COMMON_NAME = "Borealis Root CA"
|
|
||||||
_ORG_NAME = "Borealis"
|
|
||||||
_ROOT_VALIDITY = timedelta(days=365 * 100)
|
|
||||||
_SERVER_VALIDITY = timedelta(days=365 * 5)
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_certificate(common_name: str = "Borealis Server") -> Tuple[Path, Path, Path]:
|
|
||||||
"""
|
|
||||||
Ensure the root CA, server certificate, and bundle exist on disk.
|
|
||||||
|
|
||||||
Returns (cert_path, key_path, bundle_path).
|
|
||||||
"""
|
|
||||||
|
|
||||||
ensure_server_certificates_dir()
|
|
||||||
_migrate_legacy_material_if_present()
|
|
||||||
|
|
||||||
ca_key, ca_cert, ca_regenerated = _ensure_root_ca()
|
|
||||||
|
|
||||||
server_cert = _load_certificate(_CERT_FILE)
|
|
||||||
needs_regen = ca_regenerated or _server_certificate_needs_regeneration(server_cert, ca_cert)
|
|
||||||
if needs_regen:
|
|
||||||
server_cert = _generate_server_certificate(common_name, ca_key, ca_cert)
|
|
||||||
|
|
||||||
if server_cert is None:
|
|
||||||
server_cert = _generate_server_certificate(common_name, ca_key, ca_cert)
|
|
||||||
|
|
||||||
_write_bundle(server_cert, ca_cert)
|
|
||||||
|
|
||||||
return _CERT_FILE, _KEY_FILE, _BUNDLE_FILE
|
|
||||||
|
|
||||||
|
|
||||||
def _migrate_legacy_material_if_present() -> None:
|
|
||||||
# Promote legacy runtime certificates (Server/Borealis/certs) into the new location.
|
|
||||||
if not _CERT_FILE.exists() or not _KEY_FILE.exists():
|
|
||||||
legacy_cert = _LEGACY_CERT_FILE
|
|
||||||
legacy_key = _LEGACY_KEY_FILE
|
|
||||||
if legacy_cert.exists() and legacy_key.exists():
|
|
||||||
try:
|
|
||||||
ensure_server_certificates_dir()
|
|
||||||
if not _CERT_FILE.exists():
|
|
||||||
_safe_copy(legacy_cert, _CERT_FILE)
|
|
||||||
if not _KEY_FILE.exists():
|
|
||||||
_safe_copy(legacy_key, _KEY_FILE)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_root_ca() -> Tuple[ec.EllipticCurvePrivateKey, x509.Certificate, bool]:
|
|
||||||
regenerated = False
|
|
||||||
|
|
||||||
ca_key: Optional[ec.EllipticCurvePrivateKey] = None
|
|
||||||
ca_cert: Optional[x509.Certificate] = None
|
|
||||||
|
|
||||||
if _CA_KEY_FILE.exists() and _CA_CERT_FILE.exists():
|
|
||||||
try:
|
|
||||||
ca_key = _load_private_key(_CA_KEY_FILE)
|
|
||||||
ca_cert = _load_certificate(_CA_CERT_FILE)
|
|
||||||
if ca_cert is not None and ca_key is not None:
|
|
||||||
expiry = _cert_not_after(ca_cert)
|
|
||||||
subject = ca_cert.subject
|
|
||||||
subject_cn = ""
|
|
||||||
try:
|
|
||||||
subject_cn = subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value # type: ignore[index]
|
|
||||||
except Exception:
|
|
||||||
subject_cn = ""
|
|
||||||
try:
|
|
||||||
basic = ca_cert.extensions.get_extension_for_class(x509.BasicConstraints).value # type: ignore[attr-defined]
|
|
||||||
is_ca = bool(basic.ca)
|
|
||||||
except Exception:
|
|
||||||
is_ca = False
|
|
||||||
if (
|
|
||||||
expiry <= datetime.now(tz=timezone.utc)
|
|
||||||
or not is_ca
|
|
||||||
or subject_cn != _ROOT_COMMON_NAME
|
|
||||||
):
|
|
||||||
regenerated = True
|
|
||||||
else:
|
|
||||||
regenerated = True
|
|
||||||
except Exception:
|
|
||||||
regenerated = True
|
|
||||||
else:
|
|
||||||
regenerated = True
|
|
||||||
|
|
||||||
if regenerated or ca_key is None or ca_cert is None:
|
|
||||||
ca_key = ec.generate_private_key(ec.SECP384R1())
|
|
||||||
public_key = ca_key.public_key()
|
|
||||||
|
|
||||||
now = datetime.now(tz=timezone.utc)
|
|
||||||
builder = (
|
|
||||||
x509.CertificateBuilder()
|
|
||||||
.subject_name(
|
|
||||||
x509.Name(
|
|
||||||
[
|
|
||||||
x509.NameAttribute(NameOID.COMMON_NAME, _ROOT_COMMON_NAME),
|
|
||||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, _ORG_NAME),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.issuer_name(
|
|
||||||
x509.Name(
|
|
||||||
[
|
|
||||||
x509.NameAttribute(NameOID.COMMON_NAME, _ROOT_COMMON_NAME),
|
|
||||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, _ORG_NAME),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.public_key(public_key)
|
|
||||||
.serial_number(x509.random_serial_number())
|
|
||||||
.not_valid_before(now - timedelta(minutes=5))
|
|
||||||
.not_valid_after(now + _ROOT_VALIDITY)
|
|
||||||
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
|
||||||
.add_extension(
|
|
||||||
x509.KeyUsage(
|
|
||||||
digital_signature=True,
|
|
||||||
content_commitment=False,
|
|
||||||
key_encipherment=False,
|
|
||||||
data_encipherment=False,
|
|
||||||
key_agreement=False,
|
|
||||||
key_cert_sign=True,
|
|
||||||
crl_sign=True,
|
|
||||||
encipher_only=False,
|
|
||||||
decipher_only=False,
|
|
||||||
),
|
|
||||||
critical=True,
|
|
||||||
)
|
|
||||||
.add_extension(
|
|
||||||
x509.SubjectKeyIdentifier.from_public_key(public_key),
|
|
||||||
critical=False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
builder = builder.add_extension(
|
|
||||||
x509.AuthorityKeyIdentifier.from_issuer_public_key(public_key),
|
|
||||||
critical=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
ca_cert = builder.sign(private_key=ca_key, algorithm=hashes.SHA384())
|
|
||||||
|
|
||||||
_CA_KEY_FILE.write_bytes(
|
|
||||||
ca_key.private_bytes(
|
|
||||||
encoding=serialization.Encoding.PEM,
|
|
||||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
||||||
encryption_algorithm=serialization.NoEncryption(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
_CA_CERT_FILE.write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM))
|
|
||||||
|
|
||||||
_tighten_permissions(_CA_KEY_FILE)
|
|
||||||
_tighten_permissions(_CA_CERT_FILE)
|
|
||||||
else:
|
|
||||||
regenerated = False
|
|
||||||
|
|
||||||
return ca_key, ca_cert, regenerated
|
|
||||||
|
|
||||||
|
|
||||||
def _server_certificate_needs_regeneration(
|
|
||||||
server_cert: Optional[x509.Certificate],
|
|
||||||
ca_cert: x509.Certificate,
|
|
||||||
) -> bool:
|
|
||||||
if server_cert is None:
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
|
||||||
if server_cert.issuer != ca_cert.subject:
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
|
||||||
expiry = _cert_not_after(server_cert)
|
|
||||||
if expiry <= datetime.now(tz=timezone.utc):
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
|
||||||
basic = server_cert.extensions.get_extension_for_class(x509.BasicConstraints).value # type: ignore[attr-defined]
|
|
||||||
if basic.ca:
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
|
||||||
eku = server_cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value # type: ignore[attr-defined]
|
|
||||||
if ExtendedKeyUsageOID.SERVER_AUTH not in eku:
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_server_certificate(
|
|
||||||
common_name: str,
|
|
||||||
ca_key: ec.EllipticCurvePrivateKey,
|
|
||||||
ca_cert: x509.Certificate,
|
|
||||||
) -> x509.Certificate:
|
|
||||||
private_key = ec.generate_private_key(ec.SECP384R1())
|
|
||||||
public_key = private_key.public_key()
|
|
||||||
|
|
||||||
now = datetime.now(tz=timezone.utc)
|
|
||||||
ca_expiry = _cert_not_after(ca_cert)
|
|
||||||
candidate_expiry = now + _SERVER_VALIDITY
|
|
||||||
not_after = min(ca_expiry - timedelta(days=1), candidate_expiry)
|
|
||||||
|
|
||||||
builder = (
|
|
||||||
x509.CertificateBuilder()
|
|
||||||
.subject_name(
|
|
||||||
x509.Name(
|
|
||||||
[
|
|
||||||
x509.NameAttribute(NameOID.COMMON_NAME, common_name),
|
|
||||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, _ORG_NAME),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.issuer_name(ca_cert.subject)
|
|
||||||
.public_key(public_key)
|
|
||||||
.serial_number(x509.random_serial_number())
|
|
||||||
.not_valid_before(now - timedelta(minutes=5))
|
|
||||||
.not_valid_after(not_after)
|
|
||||||
.add_extension(
|
|
||||||
x509.SubjectAlternativeName(
|
|
||||||
[
|
|
||||||
x509.DNSName("localhost"),
|
|
||||||
x509.DNSName("127.0.0.1"),
|
|
||||||
x509.DNSName("::1"),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
critical=False,
|
|
||||||
)
|
|
||||||
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
|
||||||
.add_extension(
|
|
||||||
x509.KeyUsage(
|
|
||||||
digital_signature=True,
|
|
||||||
content_commitment=False,
|
|
||||||
key_encipherment=False,
|
|
||||||
data_encipherment=False,
|
|
||||||
key_agreement=False,
|
|
||||||
key_cert_sign=False,
|
|
||||||
crl_sign=False,
|
|
||||||
encipher_only=False,
|
|
||||||
decipher_only=False,
|
|
||||||
),
|
|
||||||
critical=True,
|
|
||||||
)
|
|
||||||
.add_extension(
|
|
||||||
x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]),
|
|
||||||
critical=False,
|
|
||||||
)
|
|
||||||
.add_extension(
|
|
||||||
x509.SubjectKeyIdentifier.from_public_key(public_key),
|
|
||||||
critical=False,
|
|
||||||
)
|
|
||||||
.add_extension(
|
|
||||||
x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()),
|
|
||||||
critical=False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
certificate = builder.sign(private_key=ca_key, algorithm=hashes.SHA384())
|
|
||||||
|
|
||||||
_KEY_FILE.write_bytes(
|
|
||||||
private_key.private_bytes(
|
|
||||||
encoding=serialization.Encoding.PEM,
|
|
||||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
||||||
encryption_algorithm=serialization.NoEncryption(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
_CERT_FILE.write_bytes(certificate.public_bytes(serialization.Encoding.PEM))
|
|
||||||
|
|
||||||
_tighten_permissions(_KEY_FILE)
|
|
||||||
_tighten_permissions(_CERT_FILE)
|
|
||||||
|
|
||||||
return certificate
|
|
||||||
|
|
||||||
|
|
||||||
def _write_bundle(server_cert: x509.Certificate, ca_cert: x509.Certificate) -> None:
|
|
||||||
try:
|
|
||||||
server_pem = server_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8").strip()
|
|
||||||
ca_pem = ca_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8").strip()
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
bundle = f"{server_pem}\n{ca_pem}\n"
|
|
||||||
_BUNDLE_FILE.write_text(bundle, encoding="utf-8")
|
|
||||||
_tighten_permissions(_BUNDLE_FILE)
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_copy(src: Path, dst: Path) -> None:
|
|
||||||
try:
|
|
||||||
dst.write_bytes(src.read_bytes())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _tighten_permissions(path: Path) -> None:
|
|
||||||
try:
|
|
||||||
if os.name == "posix":
|
|
||||||
path.chmod(0o600)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _load_private_key(path: Path) -> ec.EllipticCurvePrivateKey:
|
|
||||||
with path.open("rb") as fh:
|
|
||||||
return serialization.load_pem_private_key(fh.read(), password=None)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_certificate(path: Path) -> Optional[x509.Certificate]:
|
|
||||||
try:
|
|
||||||
return x509.load_pem_x509_certificate(path.read_bytes())
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _cert_not_after(cert: x509.Certificate) -> datetime:
|
|
||||||
try:
|
|
||||||
return cert.not_valid_after_utc # type: ignore[attr-defined]
|
|
||||||
except AttributeError:
|
|
||||||
value = cert.not_valid_after
|
|
||||||
if value.tzinfo is None:
|
|
||||||
return value.replace(tzinfo=timezone.utc)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def build_ssl_context() -> ssl.SSLContext:
|
|
||||||
cert_path, key_path, bundle_path = ensure_certificate()
|
|
||||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
||||||
context.minimum_version = ssl.TLSVersion.TLSv1_3
|
|
||||||
context.load_cert_chain(certfile=str(bundle_path), keyfile=str(key_path))
|
|
||||||
return context
|
|
||||||
|
|
||||||
|
|
||||||
def certificate_paths() -> Tuple[str, str, str]:
|
|
||||||
cert_path, key_path, bundle_path = ensure_certificate()
|
|
||||||
return str(cert_path), str(key_path), str(bundle_path)
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
"""
|
|
||||||
Utility helpers for working with Ed25519 keys and fingerprints.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import re
|
|
||||||
from typing import Tuple
|
|
||||||
|
|
||||||
from cryptography.hazmat.primitives import serialization
|
|
||||||
from cryptography.hazmat.primitives.serialization import load_der_public_key
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
||||||
|
|
||||||
|
|
||||||
def generate_ed25519_keypair() -> Tuple[ed25519.Ed25519PrivateKey, bytes]:
|
|
||||||
"""
|
|
||||||
Generate a new Ed25519 keypair.
|
|
||||||
|
|
||||||
Returns the private key object and the public key encoded as SubjectPublicKeyInfo DER bytes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
private_key = ed25519.Ed25519PrivateKey.generate()
|
|
||||||
public_key = private_key.public_key().public_bytes(
|
|
||||||
encoding=serialization.Encoding.DER,
|
|
||||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
||||||
)
|
|
||||||
return private_key, public_key
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_base64(data: str) -> str:
|
|
||||||
"""
|
|
||||||
Collapse whitespace and normalise URL-safe encodings so we can reliably decode.
|
|
||||||
"""
|
|
||||||
|
|
||||||
cleaned = re.sub(r"\\s+", "", data or "")
|
|
||||||
return cleaned.replace("-", "+").replace("_", "/")
|
|
||||||
|
|
||||||
|
|
||||||
def spki_der_from_base64(spki_b64: str) -> bytes:
|
|
||||||
return base64.b64decode(normalize_base64(spki_b64), validate=True)
|
|
||||||
|
|
||||||
|
|
||||||
def base64_from_spki_der(spki_der: bytes) -> str:
|
|
||||||
return base64.b64encode(spki_der).decode("ascii")
|
|
||||||
|
|
||||||
|
|
||||||
def fingerprint_from_spki_der(spki_der: bytes) -> str:
|
|
||||||
digest = hashlib.sha256(spki_der).hexdigest()
|
|
||||||
return digest.lower()
|
|
||||||
|
|
||||||
|
|
||||||
def fingerprint_from_base64_spki(spki_b64: str) -> str:
|
|
||||||
return fingerprint_from_spki_der(spki_der_from_base64(spki_b64))
|
|
||||||
|
|
||||||
|
|
||||||
def private_key_to_pem(private_key: ed25519.Ed25519PrivateKey) -> bytes:
|
|
||||||
return private_key.private_bytes(
|
|
||||||
encoding=serialization.Encoding.PEM,
|
|
||||||
format=serialization.PrivateFormat.PKCS8,
|
|
||||||
encryption_algorithm=serialization.NoEncryption(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def public_key_to_pem(public_spki_der: bytes) -> bytes:
|
|
||||||
public_key = load_der_public_key(public_spki_der)
|
|
||||||
return public_key.public_bytes(
|
|
||||||
encoding=serialization.Encoding.PEM,
|
|
||||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
||||||
)
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
"""
|
|
||||||
Code-signing helpers for delivering scripts to agents.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Tuple
|
|
||||||
|
|
||||||
from cryptography.hazmat.primitives import serialization
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
||||||
|
|
||||||
from Modules.runtime import (
|
|
||||||
ensure_server_certificates_dir,
|
|
||||||
server_certificates_path,
|
|
||||||
runtime_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .keys import base64_from_spki_der
|
|
||||||
|
|
||||||
_KEY_DIR = server_certificates_path("Code-Signing")
|
|
||||||
_SIGNING_KEY_FILE = _KEY_DIR / "borealis-script-ed25519.key"
|
|
||||||
_SIGNING_PUB_FILE = _KEY_DIR / "borealis-script-ed25519.pub"
|
|
||||||
_LEGACY_KEY_FILE = runtime_path("keys") / "borealis-script-ed25519.key"
|
|
||||||
_LEGACY_PUB_FILE = runtime_path("keys") / "borealis-script-ed25519.pub"
|
|
||||||
_OLD_RUNTIME_KEY_DIR = runtime_path("script_signing_keys")
|
|
||||||
_OLD_RUNTIME_KEY_FILE = _OLD_RUNTIME_KEY_DIR / "borealis-script-ed25519.key"
|
|
||||||
_OLD_RUNTIME_PUB_FILE = _OLD_RUNTIME_KEY_DIR / "borealis-script-ed25519.pub"
|
|
||||||
|
|
||||||
|
|
||||||
class ScriptSigner:
|
|
||||||
def __init__(self, private_key: ed25519.Ed25519PrivateKey):
|
|
||||||
self._private = private_key
|
|
||||||
self._public = private_key.public_key()
|
|
||||||
|
|
||||||
def sign(self, payload: bytes) -> bytes:
|
|
||||||
return self._private.sign(payload)
|
|
||||||
|
|
||||||
def public_spki_der(self) -> bytes:
|
|
||||||
return self._public.public_bytes(
|
|
||||||
encoding=serialization.Encoding.DER,
|
|
||||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
||||||
)
|
|
||||||
|
|
||||||
def public_base64_spki(self) -> str:
|
|
||||||
return base64_from_spki_der(self.public_spki_der())
|
|
||||||
|
|
||||||
|
|
||||||
def load_signer() -> ScriptSigner:
|
|
||||||
private_key = _load_or_create()
|
|
||||||
return ScriptSigner(private_key)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_or_create() -> ed25519.Ed25519PrivateKey:
|
|
||||||
ensure_server_certificates_dir("Code-Signing")
|
|
||||||
_migrate_legacy_material_if_present()
|
|
||||||
|
|
||||||
if _SIGNING_KEY_FILE.exists():
|
|
||||||
with _SIGNING_KEY_FILE.open("rb") as fh:
|
|
||||||
return serialization.load_pem_private_key(fh.read(), password=None)
|
|
||||||
|
|
||||||
if _LEGACY_KEY_FILE.exists():
|
|
||||||
with _LEGACY_KEY_FILE.open("rb") as fh:
|
|
||||||
return serialization.load_pem_private_key(fh.read(), password=None)
|
|
||||||
|
|
||||||
private_key = ed25519.Ed25519PrivateKey.generate()
|
|
||||||
pem = private_key.private_bytes(
|
|
||||||
encoding=serialization.Encoding.PEM,
|
|
||||||
format=serialization.PrivateFormat.PKCS8,
|
|
||||||
encryption_algorithm=serialization.NoEncryption(),
|
|
||||||
)
|
|
||||||
with _SIGNING_KEY_FILE.open("wb") as fh:
|
|
||||||
fh.write(pem)
|
|
||||||
try:
|
|
||||||
if hasattr(_SIGNING_KEY_FILE, "chmod"):
|
|
||||||
_SIGNING_KEY_FILE.chmod(0o600)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
pub_der = private_key.public_key().public_bytes(
|
|
||||||
encoding=serialization.Encoding.DER,
|
|
||||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
||||||
)
|
|
||||||
_SIGNING_PUB_FILE.write_bytes(pub_der)
|
|
||||||
|
|
||||||
return private_key
|
|
||||||
|
|
||||||
|
|
||||||
def _migrate_legacy_material_if_present() -> None:
|
|
||||||
if _SIGNING_KEY_FILE.exists():
|
|
||||||
return
|
|
||||||
|
|
||||||
# First migrate from legacy runtime path embedded in Server runtime.
|
|
||||||
try:
|
|
||||||
if _OLD_RUNTIME_KEY_FILE.exists() and not _SIGNING_KEY_FILE.exists():
|
|
||||||
ensure_server_certificates_dir("Code-Signing")
|
|
||||||
try:
|
|
||||||
_OLD_RUNTIME_KEY_FILE.replace(_SIGNING_KEY_FILE)
|
|
||||||
except Exception:
|
|
||||||
_SIGNING_KEY_FILE.write_bytes(_OLD_RUNTIME_KEY_FILE.read_bytes())
|
|
||||||
if _OLD_RUNTIME_PUB_FILE.exists() and not _SIGNING_PUB_FILE.exists():
|
|
||||||
try:
|
|
||||||
_OLD_RUNTIME_PUB_FILE.replace(_SIGNING_PUB_FILE)
|
|
||||||
except Exception:
|
|
||||||
_SIGNING_PUB_FILE.write_bytes(_OLD_RUNTIME_PUB_FILE.read_bytes())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not _LEGACY_KEY_FILE.exists() or _SIGNING_KEY_FILE.exists():
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
ensure_server_certificates_dir("Code-Signing")
|
|
||||||
try:
|
|
||||||
_LEGACY_KEY_FILE.replace(_SIGNING_KEY_FILE)
|
|
||||||
except Exception:
|
|
||||||
_SIGNING_KEY_FILE.write_bytes(_LEGACY_KEY_FILE.read_bytes())
|
|
||||||
|
|
||||||
if _LEGACY_PUB_FILE.exists() and not _SIGNING_PUB_FILE.exists():
|
|
||||||
try:
|
|
||||||
_LEGACY_PUB_FILE.replace(_SIGNING_PUB_FILE)
|
|
||||||
except Exception:
|
|
||||||
_SIGNING_PUB_FILE.write_bytes(_LEGACY_PUB_FILE.read_bytes())
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
@@ -1,488 +0,0 @@
|
|||||||
"""
|
|
||||||
Database migration helpers for Borealis.
|
|
||||||
|
|
||||||
This module centralises schema evolution so the main server module can stay
|
|
||||||
focused on request handling. The migration functions are intentionally
|
|
||||||
idempotent — they can run repeatedly without changing state once the schema
|
|
||||||
matches the desired shape.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import List, Optional, Sequence, Tuple
|
|
||||||
|
|
||||||
|
|
||||||
DEVICE_TABLE = "devices"
|
|
||||||
|
|
||||||
|
|
||||||
def apply_all(conn: sqlite3.Connection) -> None:
|
|
||||||
"""
|
|
||||||
Run all known schema migrations against the provided sqlite3 connection.
|
|
||||||
"""
|
|
||||||
|
|
||||||
_ensure_devices_table(conn)
|
|
||||||
_ensure_device_aux_tables(conn)
|
|
||||||
_ensure_refresh_token_table(conn)
|
|
||||||
_ensure_install_code_table(conn)
|
|
||||||
_ensure_install_code_persistence_table(conn)
|
|
||||||
_ensure_device_approval_table(conn)
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_devices_table(conn: sqlite3.Connection) -> None:
|
|
||||||
cur = conn.cursor()
|
|
||||||
if not _table_exists(cur, DEVICE_TABLE):
|
|
||||||
_create_devices_table(cur)
|
|
||||||
return
|
|
||||||
|
|
||||||
column_info = _table_info(cur, DEVICE_TABLE)
|
|
||||||
col_names = [c[1] for c in column_info]
|
|
||||||
pk_cols = [c[1] for c in column_info if c[5]]
|
|
||||||
|
|
||||||
needs_rebuild = pk_cols != ["guid"]
|
|
||||||
required_columns = {
|
|
||||||
"guid": "TEXT",
|
|
||||||
"hostname": "TEXT",
|
|
||||||
"description": "TEXT",
|
|
||||||
"created_at": "INTEGER",
|
|
||||||
"agent_hash": "TEXT",
|
|
||||||
"memory": "TEXT",
|
|
||||||
"network": "TEXT",
|
|
||||||
"software": "TEXT",
|
|
||||||
"storage": "TEXT",
|
|
||||||
"cpu": "TEXT",
|
|
||||||
"device_type": "TEXT",
|
|
||||||
"domain": "TEXT",
|
|
||||||
"external_ip": "TEXT",
|
|
||||||
"internal_ip": "TEXT",
|
|
||||||
"last_reboot": "TEXT",
|
|
||||||
"last_seen": "INTEGER",
|
|
||||||
"last_user": "TEXT",
|
|
||||||
"operating_system": "TEXT",
|
|
||||||
"uptime": "INTEGER",
|
|
||||||
"agent_id": "TEXT",
|
|
||||||
"ansible_ee_ver": "TEXT",
|
|
||||||
"connection_type": "TEXT",
|
|
||||||
"connection_endpoint": "TEXT",
|
|
||||||
"ssl_key_fingerprint": "TEXT",
|
|
||||||
"token_version": "INTEGER",
|
|
||||||
"status": "TEXT",
|
|
||||||
"key_added_at": "TEXT",
|
|
||||||
}
|
|
||||||
|
|
||||||
missing_columns = [col for col in required_columns if col not in col_names]
|
|
||||||
if missing_columns:
|
|
||||||
needs_rebuild = True
|
|
||||||
|
|
||||||
if needs_rebuild:
|
|
||||||
_rebuild_devices_table(conn, column_info)
|
|
||||||
else:
|
|
||||||
_ensure_column_defaults(cur)
|
|
||||||
|
|
||||||
_ensure_device_indexes(cur)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_device_aux_tables(conn: sqlite3.Connection) -> None:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS device_keys (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
guid TEXT NOT NULL,
|
|
||||||
ssl_key_fingerprint TEXT NOT NULL,
|
|
||||||
added_at TEXT NOT NULL,
|
|
||||||
retired_at TEXT
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_device_keys_guid_fingerprint
|
|
||||||
ON device_keys(guid, ssl_key_fingerprint)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_device_keys_guid
|
|
||||||
ON device_keys(guid)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_refresh_token_table(conn: sqlite3.Connection) -> None:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
guid TEXT NOT NULL,
|
|
||||||
token_hash TEXT NOT NULL,
|
|
||||||
dpop_jkt TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
expires_at TEXT NOT NULL,
|
|
||||||
revoked_at TEXT,
|
|
||||||
last_used_at TEXT
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_guid
|
|
||||||
ON refresh_tokens(guid)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at
|
|
||||||
ON refresh_tokens(expires_at)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_install_code_table(conn: sqlite3.Connection) -> None:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS enrollment_install_codes (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
code TEXT NOT NULL UNIQUE,
|
|
||||||
expires_at TEXT NOT NULL,
|
|
||||||
created_by_user_id TEXT,
|
|
||||||
used_at TEXT,
|
|
||||||
used_by_guid TEXT,
|
|
||||||
max_uses INTEGER NOT NULL DEFAULT 1,
|
|
||||||
use_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
last_used_at TEXT
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_eic_expires_at
|
|
||||||
ON enrollment_install_codes(expires_at)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
columns = {row[1] for row in _table_info(cur, "enrollment_install_codes")}
|
|
||||||
if "max_uses" not in columns:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
ALTER TABLE enrollment_install_codes
|
|
||||||
ADD COLUMN max_uses INTEGER NOT NULL DEFAULT 1
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
if "use_count" not in columns:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
ALTER TABLE enrollment_install_codes
|
|
||||||
ADD COLUMN use_count INTEGER NOT NULL DEFAULT 0
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
if "last_used_at" not in columns:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
ALTER TABLE enrollment_install_codes
|
|
||||||
ADD COLUMN last_used_at TEXT
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_install_code_persistence_table(conn: sqlite3.Connection) -> None:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS enrollment_install_codes_persistent (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
code TEXT NOT NULL UNIQUE,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
expires_at TEXT NOT NULL,
|
|
||||||
created_by_user_id TEXT,
|
|
||||||
used_at TEXT,
|
|
||||||
used_by_guid TEXT,
|
|
||||||
max_uses INTEGER NOT NULL DEFAULT 1,
|
|
||||||
last_known_use_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
last_used_at TEXT,
|
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
|
||||||
archived_at TEXT,
|
|
||||||
consumed_at TEXT
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_eicp_active
|
|
||||||
ON enrollment_install_codes_persistent(is_active, expires_at)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_eicp_code
|
|
||||||
ON enrollment_install_codes_persistent(code)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
columns = {row[1] for row in _table_info(cur, "enrollment_install_codes_persistent")}
|
|
||||||
if "last_known_use_count" not in columns:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
ALTER TABLE enrollment_install_codes_persistent
|
|
||||||
ADD COLUMN last_known_use_count INTEGER NOT NULL DEFAULT 0
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
if "archived_at" not in columns:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
ALTER TABLE enrollment_install_codes_persistent
|
|
||||||
ADD COLUMN archived_at TEXT
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
if "consumed_at" not in columns:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
ALTER TABLE enrollment_install_codes_persistent
|
|
||||||
ADD COLUMN consumed_at TEXT
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
if "is_active" not in columns:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
ALTER TABLE enrollment_install_codes_persistent
|
|
||||||
ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
if "used_at" not in columns:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
ALTER TABLE enrollment_install_codes_persistent
|
|
||||||
ADD COLUMN used_at TEXT
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
if "used_by_guid" not in columns:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
ALTER TABLE enrollment_install_codes_persistent
|
|
||||||
ADD COLUMN used_by_guid TEXT
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
if "last_used_at" not in columns:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
ALTER TABLE enrollment_install_codes_persistent
|
|
||||||
ADD COLUMN last_used_at TEXT
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_device_approval_table(conn: sqlite3.Connection) -> None:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS device_approvals (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
approval_reference TEXT NOT NULL UNIQUE,
|
|
||||||
guid TEXT,
|
|
||||||
hostname_claimed TEXT NOT NULL,
|
|
||||||
ssl_key_fingerprint_claimed TEXT NOT NULL,
|
|
||||||
enrollment_code_id TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
client_nonce TEXT NOT NULL,
|
|
||||||
server_nonce TEXT NOT NULL,
|
|
||||||
agent_pubkey_der BLOB NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
approved_by_user_id TEXT
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_da_status
|
|
||||||
ON device_approvals(status)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_da_fp_status
|
|
||||||
ON device_approvals(ssl_key_fingerprint_claimed, status)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_devices_table(cur: sqlite3.Cursor) -> None:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE devices (
|
|
||||||
guid TEXT PRIMARY KEY,
|
|
||||||
hostname TEXT,
|
|
||||||
description TEXT,
|
|
||||||
created_at INTEGER,
|
|
||||||
agent_hash TEXT,
|
|
||||||
memory TEXT,
|
|
||||||
network TEXT,
|
|
||||||
software TEXT,
|
|
||||||
storage TEXT,
|
|
||||||
cpu TEXT,
|
|
||||||
device_type TEXT,
|
|
||||||
domain TEXT,
|
|
||||||
external_ip TEXT,
|
|
||||||
internal_ip TEXT,
|
|
||||||
last_reboot TEXT,
|
|
||||||
last_seen INTEGER,
|
|
||||||
last_user TEXT,
|
|
||||||
operating_system TEXT,
|
|
||||||
uptime INTEGER,
|
|
||||||
agent_id TEXT,
|
|
||||||
ansible_ee_ver TEXT,
|
|
||||||
connection_type TEXT,
|
|
||||||
connection_endpoint TEXT,
|
|
||||||
ssl_key_fingerprint TEXT,
|
|
||||||
token_version INTEGER DEFAULT 1,
|
|
||||||
status TEXT DEFAULT 'active',
|
|
||||||
key_added_at TEXT
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
_ensure_device_indexes(cur)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_device_indexes(cur: sqlite3.Cursor) -> None:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_devices_hostname
|
|
||||||
ON devices(hostname)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_devices_ssl_key
|
|
||||||
ON devices(ssl_key_fingerprint)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_devices_status
|
|
||||||
ON devices(status)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_column_defaults(cur: sqlite3.Cursor) -> None:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
UPDATE devices
|
|
||||||
SET token_version = COALESCE(token_version, 1)
|
|
||||||
WHERE token_version IS NULL
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
UPDATE devices
|
|
||||||
SET status = COALESCE(status, 'active')
|
|
||||||
WHERE status IS NULL OR status = ''
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _rebuild_devices_table(conn: sqlite3.Connection, column_info: Sequence[Tuple]) -> None:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("PRAGMA foreign_keys=OFF")
|
|
||||||
cur.execute("BEGIN IMMEDIATE")
|
|
||||||
|
|
||||||
cur.execute("ALTER TABLE devices RENAME TO devices_legacy")
|
|
||||||
_create_devices_table(cur)
|
|
||||||
|
|
||||||
legacy_columns = [c[1] for c in column_info]
|
|
||||||
cur.execute(f"SELECT {', '.join(legacy_columns)} FROM devices_legacy")
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
insert_sql = (
|
|
||||||
"""
|
|
||||||
INSERT OR REPLACE INTO devices (
|
|
||||||
guid, hostname, description, created_at, agent_hash, memory,
|
|
||||||
network, software, storage, cpu, device_type, domain, external_ip,
|
|
||||||
internal_ip, last_reboot, last_seen, last_user, operating_system,
|
|
||||||
uptime, agent_id, ansible_ee_ver, connection_type, connection_endpoint,
|
|
||||||
ssl_key_fingerprint, token_version, status, key_added_at
|
|
||||||
)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
record = dict(zip(legacy_columns, row))
|
|
||||||
guid = _normalized_guid(record.get("guid"))
|
|
||||||
if not guid:
|
|
||||||
guid = str(uuid.uuid4())
|
|
||||||
hostname = record.get("hostname")
|
|
||||||
created_at = record.get("created_at")
|
|
||||||
key_added_at = record.get("key_added_at")
|
|
||||||
if key_added_at is None:
|
|
||||||
key_added_at = _default_key_added_at(created_at)
|
|
||||||
|
|
||||||
params: Tuple = (
|
|
||||||
guid,
|
|
||||||
hostname,
|
|
||||||
record.get("description"),
|
|
||||||
created_at,
|
|
||||||
record.get("agent_hash"),
|
|
||||||
record.get("memory"),
|
|
||||||
record.get("network"),
|
|
||||||
record.get("software"),
|
|
||||||
record.get("storage"),
|
|
||||||
record.get("cpu"),
|
|
||||||
record.get("device_type"),
|
|
||||||
record.get("domain"),
|
|
||||||
record.get("external_ip"),
|
|
||||||
record.get("internal_ip"),
|
|
||||||
record.get("last_reboot"),
|
|
||||||
record.get("last_seen"),
|
|
||||||
record.get("last_user"),
|
|
||||||
record.get("operating_system"),
|
|
||||||
record.get("uptime"),
|
|
||||||
record.get("agent_id"),
|
|
||||||
record.get("ansible_ee_ver"),
|
|
||||||
record.get("connection_type"),
|
|
||||||
record.get("connection_endpoint"),
|
|
||||||
record.get("ssl_key_fingerprint"),
|
|
||||||
record.get("token_version") or 1,
|
|
||||||
record.get("status") or "active",
|
|
||||||
key_added_at,
|
|
||||||
)
|
|
||||||
cur.execute(insert_sql, params)
|
|
||||||
|
|
||||||
cur.execute("DROP TABLE devices_legacy")
|
|
||||||
cur.execute("COMMIT")
|
|
||||||
cur.execute("PRAGMA foreign_keys=ON")
|
|
||||||
|
|
||||||
|
|
||||||
def _default_key_added_at(created_at: Optional[int]) -> Optional[str]:
|
|
||||||
if created_at:
|
|
||||||
try:
|
|
||||||
dt = datetime.fromtimestamp(int(created_at), tz=timezone.utc)
|
|
||||||
return dt.isoformat()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return datetime.now(tz=timezone.utc).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def _table_exists(cur: sqlite3.Cursor, name: str) -> bool:
|
|
||||||
cur.execute(
|
|
||||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
|
||||||
(name,),
|
|
||||||
)
|
|
||||||
return cur.fetchone() is not None
|
|
||||||
|
|
||||||
|
|
||||||
def _table_info(cur: sqlite3.Cursor, name: str) -> List[Tuple]:
|
|
||||||
cur.execute(f"PRAGMA table_info({name})")
|
|
||||||
return cur.fetchall()
|
|
||||||
|
|
||||||
|
|
||||||
def _normalized_guid(value: Optional[str]) -> str:
|
|
||||||
if not value:
|
|
||||||
return ""
|
|
||||||
return str(value).strip()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
"""
|
|
||||||
Short-lived nonce cache to defend against replay attacks during enrollment.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
from threading import Lock
|
|
||||||
from typing import Dict
|
|
||||||
|
|
||||||
|
|
||||||
class NonceCache:
|
|
||||||
def __init__(self, ttl_seconds: float = 300.0) -> None:
|
|
||||||
self._ttl = ttl_seconds
|
|
||||||
self._entries: Dict[str, float] = {}
|
|
||||||
self._lock = Lock()
|
|
||||||
|
|
||||||
def consume(self, key: str) -> bool:
|
|
||||||
"""
|
|
||||||
Attempt to consume the nonce identified by `key`.
|
|
||||||
|
|
||||||
Returns True on first use within TTL, False if already consumed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
now = time.monotonic()
|
|
||||||
with self._lock:
|
|
||||||
expire_at = self._entries.get(key)
|
|
||||||
if expire_at and expire_at > now:
|
|
||||||
return False
|
|
||||||
self._entries[key] = now + self._ttl
|
|
||||||
# Opportunistic cleanup to keep the dict small
|
|
||||||
stale = [nonce for nonce, expiry in self._entries.items() if expiry <= now]
|
|
||||||
for nonce in stale:
|
|
||||||
self._entries.pop(nonce, None)
|
|
||||||
return True
|
|
||||||
@@ -1,759 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import secrets
|
|
||||||
import sqlite3
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
import time
|
|
||||||
from typing import Any, Callable, Dict, Optional, Tuple
|
|
||||||
|
|
||||||
AGENT_CONTEXT_HEADER = "X-Borealis-Agent-Context"
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical_context(value: Optional[str]) -> Optional[str]:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
cleaned = "".join(ch for ch in str(value) if ch.isalnum() or ch in ("_", "-"))
|
|
||||||
if not cleaned:
|
|
||||||
return None
|
|
||||||
return cleaned.upper()
|
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
|
||||||
|
|
||||||
from Modules.auth.rate_limit import SlidingWindowRateLimiter
|
|
||||||
from Modules.crypto import keys as crypto_keys
|
|
||||||
from Modules.enrollment.nonce_store import NonceCache
|
|
||||||
from Modules.guid_utils import normalize_guid
|
|
||||||
from cryptography.hazmat.primitives import serialization
|
|
||||||
|
|
||||||
|
|
||||||
def register(
|
|
||||||
app,
|
|
||||||
*,
|
|
||||||
db_conn_factory: Callable[[], sqlite3.Connection],
|
|
||||||
log: Callable[[str, str, Optional[str]], None],
|
|
||||||
jwt_service,
|
|
||||||
tls_bundle_path: str,
|
|
||||||
ip_rate_limiter: SlidingWindowRateLimiter,
|
|
||||||
fp_rate_limiter: SlidingWindowRateLimiter,
|
|
||||||
nonce_cache: NonceCache,
|
|
||||||
script_signer,
|
|
||||||
) -> None:
|
|
||||||
blueprint = Blueprint("enrollment", __name__)
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
|
||||||
return datetime.now(tz=timezone.utc)
|
|
||||||
|
|
||||||
def _iso(dt: datetime) -> str:
|
|
||||||
return dt.isoformat()
|
|
||||||
|
|
||||||
def _remote_addr() -> str:
|
|
||||||
forwarded = request.headers.get("X-Forwarded-For")
|
|
||||||
if forwarded:
|
|
||||||
return forwarded.split(",")[0].strip()
|
|
||||||
addr = request.remote_addr or "unknown"
|
|
||||||
return addr.strip()
|
|
||||||
|
|
||||||
def _signing_key_b64() -> str:
|
|
||||||
if not script_signer:
|
|
||||||
return ""
|
|
||||||
try:
|
|
||||||
return script_signer.public_base64_spki()
|
|
||||||
except Exception:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def _rate_limited(
|
|
||||||
key: str,
|
|
||||||
limiter: SlidingWindowRateLimiter,
|
|
||||||
limit: int,
|
|
||||||
window_s: float,
|
|
||||||
context_hint: Optional[str],
|
|
||||||
):
|
|
||||||
decision = limiter.check(key, limit, window_s)
|
|
||||||
if not decision.allowed:
|
|
||||||
log(
|
|
||||||
"server",
|
|
||||||
f"enrollment rate limited key={key} limit={limit}/{window_s}s retry_after={decision.retry_after:.2f}",
|
|
||||||
context_hint,
|
|
||||||
)
|
|
||||||
response = jsonify({"error": "rate_limited", "retry_after": decision.retry_after})
|
|
||||||
response.status_code = 429
|
|
||||||
response.headers["Retry-After"] = f"{int(decision.retry_after) or 1}"
|
|
||||||
return response
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _load_install_code(cur: sqlite3.Cursor, code_value: str) -> Optional[Dict[str, Any]]:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT id,
|
|
||||||
code,
|
|
||||||
expires_at,
|
|
||||||
used_at,
|
|
||||||
used_by_guid,
|
|
||||||
max_uses,
|
|
||||||
use_count,
|
|
||||||
last_used_at
|
|
||||||
FROM enrollment_install_codes
|
|
||||||
WHERE code = ?
|
|
||||||
""",
|
|
||||||
(code_value,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
keys = [
|
|
||||||
"id",
|
|
||||||
"code",
|
|
||||||
"expires_at",
|
|
||||||
"used_at",
|
|
||||||
"used_by_guid",
|
|
||||||
"max_uses",
|
|
||||||
"use_count",
|
|
||||||
"last_used_at",
|
|
||||||
]
|
|
||||||
record = dict(zip(keys, row))
|
|
||||||
return record
|
|
||||||
|
|
||||||
def _install_code_valid(
|
|
||||||
record: Dict[str, Any], fingerprint: str, cur: sqlite3.Cursor
|
|
||||||
) -> Tuple[bool, Optional[str]]:
|
|
||||||
if not record:
|
|
||||||
return False, None
|
|
||||||
expires_at = record.get("expires_at")
|
|
||||||
if not isinstance(expires_at, str):
|
|
||||||
return False, None
|
|
||||||
try:
|
|
||||||
expiry = datetime.fromisoformat(expires_at)
|
|
||||||
except Exception:
|
|
||||||
return False, None
|
|
||||||
if expiry <= _now():
|
|
||||||
return False, None
|
|
||||||
try:
|
|
||||||
max_uses = int(record.get("max_uses") or 1)
|
|
||||||
except Exception:
|
|
||||||
max_uses = 1
|
|
||||||
if max_uses < 1:
|
|
||||||
max_uses = 1
|
|
||||||
try:
|
|
||||||
use_count = int(record.get("use_count") or 0)
|
|
||||||
except Exception:
|
|
||||||
use_count = 0
|
|
||||||
if use_count < max_uses:
|
|
||||||
return True, None
|
|
||||||
|
|
||||||
guid = normalize_guid(record.get("used_by_guid"))
|
|
||||||
if not guid:
|
|
||||||
return False, None
|
|
||||||
cur.execute(
|
|
||||||
"SELECT ssl_key_fingerprint FROM devices WHERE UPPER(guid) = ?",
|
|
||||||
(guid,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return False, None
|
|
||||||
stored_fp = (row[0] or "").strip().lower()
|
|
||||||
if not stored_fp:
|
|
||||||
return False, None
|
|
||||||
if stored_fp == (fingerprint or "").strip().lower():
|
|
||||||
return True, guid
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
def _normalize_host(hostname: str, guid: str, cur: sqlite3.Cursor) -> str:
|
|
||||||
guid_norm = normalize_guid(guid)
|
|
||||||
base = (hostname or "").strip() or guid_norm
|
|
||||||
base = base[:253]
|
|
||||||
candidate = base
|
|
||||||
suffix = 1
|
|
||||||
while True:
|
|
||||||
cur.execute(
|
|
||||||
"SELECT guid FROM devices WHERE hostname = ?",
|
|
||||||
(candidate,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return candidate
|
|
||||||
existing_guid = normalize_guid(row[0])
|
|
||||||
if existing_guid == guid_norm:
|
|
||||||
return candidate
|
|
||||||
candidate = f"{base}-{suffix}"
|
|
||||||
suffix += 1
|
|
||||||
if suffix > 50:
|
|
||||||
return guid_norm
|
|
||||||
|
|
||||||
def _store_device_key(cur: sqlite3.Cursor, guid: str, fingerprint: str) -> None:
|
|
||||||
guid_norm = normalize_guid(guid)
|
|
||||||
added_at = _iso(_now())
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT OR IGNORE INTO device_keys (id, guid, ssl_key_fingerprint, added_at)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(str(uuid.uuid4()), guid_norm, fingerprint, added_at),
|
|
||||||
)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
UPDATE device_keys
|
|
||||||
SET retired_at = ?
|
|
||||||
WHERE guid = ?
|
|
||||||
AND ssl_key_fingerprint != ?
|
|
||||||
AND retired_at IS NULL
|
|
||||||
""",
|
|
||||||
(_iso(_now()), guid_norm, fingerprint),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _ensure_device_record(cur: sqlite3.Cursor, guid: str, hostname: str, fingerprint: str) -> Dict[str, Any]:
|
|
||||||
guid_norm = normalize_guid(guid)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT guid, hostname, token_version, status, ssl_key_fingerprint, key_added_at
|
|
||||||
FROM devices
|
|
||||||
WHERE UPPER(guid) = ?
|
|
||||||
""",
|
|
||||||
(guid_norm,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if row:
|
|
||||||
keys = [
|
|
||||||
"guid",
|
|
||||||
"hostname",
|
|
||||||
"token_version",
|
|
||||||
"status",
|
|
||||||
"ssl_key_fingerprint",
|
|
||||||
"key_added_at",
|
|
||||||
]
|
|
||||||
record = dict(zip(keys, row))
|
|
||||||
record["guid"] = normalize_guid(record.get("guid"))
|
|
||||||
stored_fp = (record.get("ssl_key_fingerprint") or "").strip().lower()
|
|
||||||
new_fp = (fingerprint or "").strip().lower()
|
|
||||||
if not stored_fp and new_fp:
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE devices SET ssl_key_fingerprint = ?, key_added_at = ? WHERE guid = ?",
|
|
||||||
(fingerprint, _iso(_now()), record["guid"]),
|
|
||||||
)
|
|
||||||
record["ssl_key_fingerprint"] = fingerprint
|
|
||||||
elif new_fp and stored_fp != new_fp:
|
|
||||||
now_iso = _iso(_now())
|
|
||||||
try:
|
|
||||||
current_version = int(record.get("token_version") or 1)
|
|
||||||
except Exception:
|
|
||||||
current_version = 1
|
|
||||||
new_version = max(current_version + 1, 1)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
UPDATE devices
|
|
||||||
SET ssl_key_fingerprint = ?,
|
|
||||||
key_added_at = ?,
|
|
||||||
token_version = ?,
|
|
||||||
status = 'active'
|
|
||||||
WHERE guid = ?
|
|
||||||
""",
|
|
||||||
(fingerprint, now_iso, new_version, record["guid"]),
|
|
||||||
)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
UPDATE refresh_tokens
|
|
||||||
SET revoked_at = ?
|
|
||||||
WHERE guid = ?
|
|
||||||
AND revoked_at IS NULL
|
|
||||||
""",
|
|
||||||
(now_iso, record["guid"]),
|
|
||||||
)
|
|
||||||
record["ssl_key_fingerprint"] = fingerprint
|
|
||||||
record["token_version"] = new_version
|
|
||||||
record["status"] = "active"
|
|
||||||
record["key_added_at"] = now_iso
|
|
||||||
return record
|
|
||||||
|
|
||||||
resolved_hostname = _normalize_host(hostname, guid_norm, cur)
|
|
||||||
created_at = int(time.time())
|
|
||||||
key_added_at = _iso(_now())
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO devices (
|
|
||||||
guid, hostname, created_at, last_seen, ssl_key_fingerprint,
|
|
||||||
token_version, status, key_added_at
|
|
||||||
)
|
|
||||||
VALUES (?, ?, ?, ?, ?, 1, 'active', ?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
guid_norm,
|
|
||||||
resolved_hostname,
|
|
||||||
created_at,
|
|
||||||
created_at,
|
|
||||||
fingerprint,
|
|
||||||
key_added_at,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"guid": guid_norm,
|
|
||||||
"hostname": resolved_hostname,
|
|
||||||
"token_version": 1,
|
|
||||||
"status": "active",
|
|
||||||
"ssl_key_fingerprint": fingerprint,
|
|
||||||
"key_added_at": key_added_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _hash_refresh_token(token: str) -> str:
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
def _issue_refresh_token(cur: sqlite3.Cursor, guid: str) -> Dict[str, Any]:
|
|
||||||
token = secrets.token_urlsafe(48)
|
|
||||||
now = _now()
|
|
||||||
expires_at = now.replace(microsecond=0) + timedelta(days=30)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO refresh_tokens (id, guid, token_hash, created_at, expires_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
str(uuid.uuid4()),
|
|
||||||
guid,
|
|
||||||
_hash_refresh_token(token),
|
|
||||||
_iso(now),
|
|
||||||
_iso(expires_at),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return {"token": token, "expires_at": expires_at}
|
|
||||||
|
|
||||||
@blueprint.route("/api/agent/enroll/request", methods=["POST"])
|
|
||||||
def enrollment_request():
|
|
||||||
remote = _remote_addr()
|
|
||||||
context_hint = _canonical_context(request.headers.get(AGENT_CONTEXT_HEADER))
|
|
||||||
|
|
||||||
rate_error = _rate_limited(f"ip:{remote}", ip_rate_limiter, 40, 60.0, context_hint)
|
|
||||||
if rate_error:
|
|
||||||
return rate_error
|
|
||||||
|
|
||||||
payload = request.get_json(force=True, silent=True) or {}
|
|
||||||
hostname = str(payload.get("hostname") or "").strip()
|
|
||||||
enrollment_code = str(payload.get("enrollment_code") or "").strip()
|
|
||||||
agent_pubkey_b64 = payload.get("agent_pubkey")
|
|
||||||
client_nonce_b64 = payload.get("client_nonce")
|
|
||||||
|
|
||||||
log(
|
|
||||||
"server",
|
|
||||||
"enrollment request received "
|
|
||||||
f"ip={remote} hostname={hostname or '<missing>'} code_mask={_mask_code(enrollment_code)} "
|
|
||||||
f"pubkey_len={len(agent_pubkey_b64 or '')} nonce_len={len(client_nonce_b64 or '')}",
|
|
||||||
context_hint,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not hostname:
|
|
||||||
log("server", f"enrollment rejected missing_hostname ip={remote}", context_hint)
|
|
||||||
return jsonify({"error": "hostname_required"}), 400
|
|
||||||
if not enrollment_code:
|
|
||||||
log("server", f"enrollment rejected missing_code ip={remote} host={hostname}", context_hint)
|
|
||||||
return jsonify({"error": "enrollment_code_required"}), 400
|
|
||||||
if not isinstance(agent_pubkey_b64, str):
|
|
||||||
log("server", f"enrollment rejected missing_pubkey ip={remote} host={hostname}", context_hint)
|
|
||||||
return jsonify({"error": "agent_pubkey_required"}), 400
|
|
||||||
if not isinstance(client_nonce_b64, str):
|
|
||||||
log("server", f"enrollment rejected missing_nonce ip={remote} host={hostname}", context_hint)
|
|
||||||
return jsonify({"error": "client_nonce_required"}), 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
agent_pubkey_der = crypto_keys.spki_der_from_base64(agent_pubkey_b64)
|
|
||||||
except Exception:
|
|
||||||
log("server", f"enrollment rejected invalid_pubkey ip={remote} host={hostname}", context_hint)
|
|
||||||
return jsonify({"error": "invalid_agent_pubkey"}), 400
|
|
||||||
|
|
||||||
if len(agent_pubkey_der) < 10:
|
|
||||||
log("server", f"enrollment rejected short_pubkey ip={remote} host={hostname}", context_hint)
|
|
||||||
return jsonify({"error": "invalid_agent_pubkey"}), 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
client_nonce_bytes = base64.b64decode(client_nonce_b64, validate=True)
|
|
||||||
except Exception:
|
|
||||||
log("server", f"enrollment rejected invalid_nonce ip={remote} host={hostname}", context_hint)
|
|
||||||
return jsonify({"error": "invalid_client_nonce"}), 400
|
|
||||||
if len(client_nonce_bytes) < 16:
|
|
||||||
log("server", f"enrollment rejected short_nonce ip={remote} host={hostname}", context_hint)
|
|
||||||
return jsonify({"error": "invalid_client_nonce"}), 400
|
|
||||||
|
|
||||||
fingerprint = crypto_keys.fingerprint_from_spki_der(agent_pubkey_der)
|
|
||||||
rate_error = _rate_limited(f"fp:{fingerprint}", fp_rate_limiter, 12, 60.0, context_hint)
|
|
||||||
if rate_error:
|
|
||||||
return rate_error
|
|
||||||
|
|
||||||
conn = db_conn_factory()
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
install_code = _load_install_code(cur, enrollment_code)
|
|
||||||
valid_code, reuse_guid = _install_code_valid(install_code, fingerprint, cur)
|
|
||||||
if not valid_code:
|
|
||||||
log(
|
|
||||||
"server",
|
|
||||||
"enrollment request invalid_code "
|
|
||||||
f"host={hostname} fingerprint={fingerprint[:12]} code_mask={_mask_code(enrollment_code)}",
|
|
||||||
context_hint,
|
|
||||||
)
|
|
||||||
return jsonify({"error": "invalid_enrollment_code"}), 400
|
|
||||||
|
|
||||||
approval_reference: str
|
|
||||||
record_id: str
|
|
||||||
server_nonce_bytes = secrets.token_bytes(32)
|
|
||||||
server_nonce_b64 = base64.b64encode(server_nonce_bytes).decode("ascii")
|
|
||||||
now = _iso(_now())
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, approval_reference
|
|
||||||
FROM device_approvals
|
|
||||||
WHERE ssl_key_fingerprint_claimed = ?
|
|
||||||
AND status = 'pending'
|
|
||||||
""",
|
|
||||||
(fingerprint,),
|
|
||||||
)
|
|
||||||
existing = cur.fetchone()
|
|
||||||
if existing:
|
|
||||||
record_id = existing[0]
|
|
||||||
approval_reference = existing[1]
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
UPDATE device_approvals
|
|
||||||
SET hostname_claimed = ?,
|
|
||||||
guid = ?,
|
|
||||||
enrollment_code_id = ?,
|
|
||||||
client_nonce = ?,
|
|
||||||
server_nonce = ?,
|
|
||||||
agent_pubkey_der = ?,
|
|
||||||
updated_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
hostname,
|
|
||||||
reuse_guid,
|
|
||||||
install_code["id"],
|
|
||||||
client_nonce_b64,
|
|
||||||
server_nonce_b64,
|
|
||||||
agent_pubkey_der,
|
|
||||||
now,
|
|
||||||
record_id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
record_id = str(uuid.uuid4())
|
|
||||||
approval_reference = str(uuid.uuid4())
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO device_approvals (
|
|
||||||
id, approval_reference, guid, hostname_claimed,
|
|
||||||
ssl_key_fingerprint_claimed, enrollment_code_id,
|
|
||||||
status, client_nonce, server_nonce, agent_pubkey_der,
|
|
||||||
created_at, updated_at
|
|
||||||
)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
record_id,
|
|
||||||
approval_reference,
|
|
||||||
reuse_guid,
|
|
||||||
hostname,
|
|
||||||
fingerprint,
|
|
||||||
install_code["id"],
|
|
||||||
client_nonce_b64,
|
|
||||||
server_nonce_b64,
|
|
||||||
agent_pubkey_der,
|
|
||||||
now,
|
|
||||||
now,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
response = {
|
|
||||||
"status": "pending",
|
|
||||||
"approval_reference": approval_reference,
|
|
||||||
"server_nonce": server_nonce_b64,
|
|
||||||
"poll_after_ms": 3000,
|
|
||||||
"server_certificate": _load_tls_bundle(tls_bundle_path),
|
|
||||||
"signing_key": _signing_key_b64(),
|
|
||||||
}
|
|
||||||
log(
|
|
||||||
"server",
|
|
||||||
f"enrollment request queued fingerprint={fingerprint[:12]} host={hostname} ip={remote}",
|
|
||||||
context_hint,
|
|
||||||
)
|
|
||||||
return jsonify(response)
|
|
||||||
|
|
||||||
@blueprint.route("/api/agent/enroll/poll", methods=["POST"])
|
|
||||||
def enrollment_poll():
|
|
||||||
payload = request.get_json(force=True, silent=True) or {}
|
|
||||||
approval_reference = payload.get("approval_reference")
|
|
||||||
client_nonce_b64 = payload.get("client_nonce")
|
|
||||||
proof_sig_b64 = payload.get("proof_sig")
|
|
||||||
context_hint = _canonical_context(request.headers.get(AGENT_CONTEXT_HEADER))
|
|
||||||
|
|
||||||
log(
|
|
||||||
"server",
|
|
||||||
"enrollment poll received "
|
|
||||||
f"ref={approval_reference} client_nonce_len={len(client_nonce_b64 or '')}"
|
|
||||||
f" proof_sig_len={len(proof_sig_b64 or '')}",
|
|
||||||
context_hint,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not isinstance(approval_reference, str) or not approval_reference:
|
|
||||||
log("server", "enrollment poll rejected missing_reference", context_hint)
|
|
||||||
return jsonify({"error": "approval_reference_required"}), 400
|
|
||||||
if not isinstance(client_nonce_b64, str):
|
|
||||||
log("server", f"enrollment poll rejected missing_nonce ref={approval_reference}", context_hint)
|
|
||||||
return jsonify({"error": "client_nonce_required"}), 400
|
|
||||||
if not isinstance(proof_sig_b64, str):
|
|
||||||
log("server", f"enrollment poll rejected missing_sig ref={approval_reference}", context_hint)
|
|
||||||
return jsonify({"error": "proof_sig_required"}), 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
client_nonce_bytes = base64.b64decode(client_nonce_b64, validate=True)
|
|
||||||
except Exception:
|
|
||||||
log("server", f"enrollment poll invalid_client_nonce ref={approval_reference}", context_hint)
|
|
||||||
return jsonify({"error": "invalid_client_nonce"}), 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
proof_sig = base64.b64decode(proof_sig_b64, validate=True)
|
|
||||||
except Exception:
|
|
||||||
log("server", f"enrollment poll invalid_sig ref={approval_reference}", context_hint)
|
|
||||||
return jsonify({"error": "invalid_proof_sig"}), 400
|
|
||||||
|
|
||||||
conn = db_conn_factory()
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, guid, hostname_claimed, ssl_key_fingerprint_claimed,
|
|
||||||
enrollment_code_id, status, client_nonce, server_nonce,
|
|
||||||
agent_pubkey_der, created_at, updated_at, approved_by_user_id
|
|
||||||
FROM device_approvals
|
|
||||||
WHERE approval_reference = ?
|
|
||||||
""",
|
|
||||||
(approval_reference,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
log("server", f"enrollment poll unknown_reference ref={approval_reference}", context_hint)
|
|
||||||
return jsonify({"status": "unknown"}), 404
|
|
||||||
|
|
||||||
(
|
|
||||||
record_id,
|
|
||||||
guid,
|
|
||||||
hostname_claimed,
|
|
||||||
fingerprint,
|
|
||||||
enrollment_code_id,
|
|
||||||
status,
|
|
||||||
client_nonce_stored,
|
|
||||||
server_nonce_b64,
|
|
||||||
agent_pubkey_der,
|
|
||||||
created_at,
|
|
||||||
updated_at,
|
|
||||||
approved_by,
|
|
||||||
) = row
|
|
||||||
|
|
||||||
if client_nonce_stored != client_nonce_b64:
|
|
||||||
log("server", f"enrollment poll nonce_mismatch ref={approval_reference}", context_hint)
|
|
||||||
return jsonify({"error": "nonce_mismatch"}), 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
server_nonce_bytes = base64.b64decode(server_nonce_b64, validate=True)
|
|
||||||
except Exception:
|
|
||||||
log("server", f"enrollment poll invalid_server_nonce ref={approval_reference}", context_hint)
|
|
||||||
return jsonify({"error": "server_nonce_invalid"}), 400
|
|
||||||
|
|
||||||
message = server_nonce_bytes + approval_reference.encode("utf-8") + client_nonce_bytes
|
|
||||||
|
|
||||||
try:
|
|
||||||
public_key = serialization.load_der_public_key(agent_pubkey_der)
|
|
||||||
except Exception:
|
|
||||||
log("server", f"enrollment poll pubkey_load_failed ref={approval_reference}", context_hint)
|
|
||||||
public_key = None
|
|
||||||
|
|
||||||
if public_key is None:
|
|
||||||
log("server", f"enrollment poll invalid_pubkey ref={approval_reference}", context_hint)
|
|
||||||
return jsonify({"error": "agent_pubkey_invalid"}), 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
public_key.verify(proof_sig, message)
|
|
||||||
except Exception:
|
|
||||||
log("server", f"enrollment poll invalid_proof ref={approval_reference}", context_hint)
|
|
||||||
return jsonify({"error": "invalid_proof"}), 400
|
|
||||||
|
|
||||||
if status == "pending":
|
|
||||||
log(
|
|
||||||
"server",
|
|
||||||
f"enrollment poll pending ref={approval_reference} host={hostname_claimed}"
|
|
||||||
f" fingerprint={fingerprint[:12]}",
|
|
||||||
context_hint,
|
|
||||||
)
|
|
||||||
return jsonify({"status": "pending", "poll_after_ms": 5000})
|
|
||||||
if status == "denied":
|
|
||||||
log(
|
|
||||||
"server",
|
|
||||||
f"enrollment poll denied ref={approval_reference} host={hostname_claimed}",
|
|
||||||
context_hint,
|
|
||||||
)
|
|
||||||
return jsonify({"status": "denied", "reason": "operator_denied"})
|
|
||||||
if status == "expired":
|
|
||||||
log(
|
|
||||||
"server",
|
|
||||||
f"enrollment poll expired ref={approval_reference} host={hostname_claimed}",
|
|
||||||
context_hint,
|
|
||||||
)
|
|
||||||
return jsonify({"status": "expired"})
|
|
||||||
if status == "completed":
|
|
||||||
log(
|
|
||||||
"server",
|
|
||||||
f"enrollment poll already_completed ref={approval_reference} host={hostname_claimed}",
|
|
||||||
context_hint,
|
|
||||||
)
|
|
||||||
return jsonify({"status": "approved", "detail": "finalized"})
|
|
||||||
|
|
||||||
if status != "approved":
|
|
||||||
log(
|
|
||||||
"server",
|
|
||||||
f"enrollment poll unexpected_status={status} ref={approval_reference}",
|
|
||||||
context_hint,
|
|
||||||
)
|
|
||||||
return jsonify({"status": status or "unknown"}), 400
|
|
||||||
|
|
||||||
nonce_key = f"{approval_reference}:{base64.b64encode(proof_sig).decode('ascii')}"
|
|
||||||
if not nonce_cache.consume(nonce_key):
|
|
||||||
log(
|
|
||||||
"server",
|
|
||||||
f"enrollment poll replay_detected ref={approval_reference} fingerprint={fingerprint[:12]}",
|
|
||||||
context_hint,
|
|
||||||
)
|
|
||||||
return jsonify({"error": "proof_replayed"}), 409
|
|
||||||
|
|
||||||
# Finalize enrollment
|
|
||||||
effective_guid = normalize_guid(guid) if guid else normalize_guid(str(uuid.uuid4()))
|
|
||||||
now_iso = _iso(_now())
|
|
||||||
|
|
||||||
device_record = _ensure_device_record(cur, effective_guid, hostname_claimed, fingerprint)
|
|
||||||
_store_device_key(cur, effective_guid, fingerprint)
|
|
||||||
|
|
||||||
# Mark install code used
|
|
||||||
if enrollment_code_id:
|
|
||||||
cur.execute(
|
|
||||||
"SELECT use_count, max_uses FROM enrollment_install_codes WHERE id = ?",
|
|
||||||
(enrollment_code_id,),
|
|
||||||
)
|
|
||||||
usage_row = cur.fetchone()
|
|
||||||
try:
|
|
||||||
prior_count = int(usage_row[0]) if usage_row else 0
|
|
||||||
except Exception:
|
|
||||||
prior_count = 0
|
|
||||||
try:
|
|
||||||
allowed_uses = int(usage_row[1]) if usage_row else 1
|
|
||||||
except Exception:
|
|
||||||
allowed_uses = 1
|
|
||||||
if allowed_uses < 1:
|
|
||||||
allowed_uses = 1
|
|
||||||
new_count = prior_count + 1
|
|
||||||
consumed = new_count >= allowed_uses
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
UPDATE enrollment_install_codes
|
|
||||||
SET use_count = ?,
|
|
||||||
used_by_guid = ?,
|
|
||||||
last_used_at = ?,
|
|
||||||
used_at = CASE WHEN ? THEN ? ELSE used_at END
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
new_count,
|
|
||||||
effective_guid,
|
|
||||||
now_iso,
|
|
||||||
1 if consumed else 0,
|
|
||||||
now_iso,
|
|
||||||
enrollment_code_id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
UPDATE enrollment_install_codes_persistent
|
|
||||||
SET last_known_use_count = ?,
|
|
||||||
used_by_guid = ?,
|
|
||||||
last_used_at = ?,
|
|
||||||
used_at = CASE WHEN ? THEN ? ELSE used_at END,
|
|
||||||
is_active = CASE WHEN ? THEN 0 ELSE is_active END,
|
|
||||||
consumed_at = CASE WHEN ? THEN COALESCE(consumed_at, ?) ELSE consumed_at END,
|
|
||||||
archived_at = CASE WHEN ? THEN COALESCE(archived_at, ?) ELSE archived_at END
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
new_count,
|
|
||||||
effective_guid,
|
|
||||||
now_iso,
|
|
||||||
1 if consumed else 0,
|
|
||||||
now_iso,
|
|
||||||
1 if consumed else 0,
|
|
||||||
1 if consumed else 0,
|
|
||||||
now_iso,
|
|
||||||
1 if consumed else 0,
|
|
||||||
now_iso,
|
|
||||||
enrollment_code_id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update approval record with final state
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
UPDATE device_approvals
|
|
||||||
SET guid = ?,
|
|
||||||
status = 'completed',
|
|
||||||
updated_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(effective_guid, now_iso, record_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
refresh_info = _issue_refresh_token(cur, effective_guid)
|
|
||||||
access_token = jwt_service.issue_access_token(
|
|
||||||
effective_guid,
|
|
||||||
fingerprint,
|
|
||||||
device_record.get("token_version") or 1,
|
|
||||||
)
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
log(
|
|
||||||
"server",
|
|
||||||
f"enrollment finalized guid={effective_guid} fingerprint={fingerprint[:12]} host={hostname_claimed}",
|
|
||||||
context_hint,
|
|
||||||
)
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"status": "approved",
|
|
||||||
"guid": effective_guid,
|
|
||||||
"access_token": access_token,
|
|
||||||
"expires_in": 900,
|
|
||||||
"refresh_token": refresh_info["token"],
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"server_certificate": _load_tls_bundle(tls_bundle_path),
|
|
||||||
"signing_key": _signing_key_b64(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
app.register_blueprint(blueprint)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_tls_bundle(path: str) -> str:
|
|
||||||
try:
|
|
||||||
with open(path, "r", encoding="utf-8") as fh:
|
|
||||||
return fh.read()
|
|
||||||
except Exception:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _mask_code(code: str) -> str:
|
|
||||||
if not code:
|
|
||||||
return "<missing>"
|
|
||||||
trimmed = str(code).strip()
|
|
||||||
if len(trimmed) <= 6:
|
|
||||||
return "***"
|
|
||||||
return f"{trimmed[:3]}***{trimmed[-3:]}"
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import string
|
|
||||||
import uuid
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_guid(value: Optional[str]) -> str:
|
|
||||||
"""
|
|
||||||
Canonicalize GUID strings so the server treats different casings/formats uniformly.
|
|
||||||
"""
|
|
||||||
candidate = (value or "").strip()
|
|
||||||
if not candidate:
|
|
||||||
return ""
|
|
||||||
candidate = candidate.strip("{}")
|
|
||||||
try:
|
|
||||||
return str(uuid.UUID(candidate)).upper()
|
|
||||||
except Exception:
|
|
||||||
cleaned = "".join(ch for ch in candidate if ch in string.hexdigits or ch == "-")
|
|
||||||
cleaned = cleaned.strip("-")
|
|
||||||
if cleaned:
|
|
||||||
try:
|
|
||||||
return str(uuid.UUID(cleaned)).upper()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return candidate.upper()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Callable, List, Optional
|
|
||||||
|
|
||||||
import eventlet
|
|
||||||
from flask_socketio import SocketIO
|
|
||||||
|
|
||||||
|
|
||||||
def start_prune_job(
|
|
||||||
socketio: SocketIO,
|
|
||||||
*,
|
|
||||||
db_conn_factory: Callable[[], any],
|
|
||||||
log: Callable[[str, str, Optional[str]], None],
|
|
||||||
) -> None:
|
|
||||||
def _job_loop():
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
_run_once(db_conn_factory, log)
|
|
||||||
except Exception as exc:
|
|
||||||
log("server", f"prune job failure: {exc}")
|
|
||||||
eventlet.sleep(24 * 60 * 60)
|
|
||||||
|
|
||||||
socketio.start_background_task(_job_loop)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_once(db_conn_factory: Callable[[], any], log: Callable[[str, str, Optional[str]], None]) -> None:
|
|
||||||
now = datetime.now(tz=timezone.utc)
|
|
||||||
now_iso = now.isoformat()
|
|
||||||
stale_before = (now - timedelta(hours=24)).isoformat()
|
|
||||||
conn = db_conn_factory()
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
persistent_table_exists = False
|
|
||||||
try:
|
|
||||||
cur.execute(
|
|
||||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='enrollment_install_codes_persistent'"
|
|
||||||
)
|
|
||||||
persistent_table_exists = cur.fetchone() is not None
|
|
||||||
except Exception:
|
|
||||||
persistent_table_exists = False
|
|
||||||
|
|
||||||
expired_ids: List[str] = []
|
|
||||||
if persistent_table_exists:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT id
|
|
||||||
FROM enrollment_install_codes
|
|
||||||
WHERE use_count = 0
|
|
||||||
AND expires_at < ?
|
|
||||||
""",
|
|
||||||
(now_iso,),
|
|
||||||
)
|
|
||||||
expired_ids = [str(row[0]) for row in cur.fetchall() if row and row[0]]
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
DELETE FROM enrollment_install_codes
|
|
||||||
WHERE use_count = 0
|
|
||||||
AND expires_at < ?
|
|
||||||
""",
|
|
||||||
(now_iso,),
|
|
||||||
)
|
|
||||||
codes_pruned = cur.rowcount or 0
|
|
||||||
if expired_ids:
|
|
||||||
placeholders = ",".join("?" for _ in expired_ids)
|
|
||||||
try:
|
|
||||||
cur.execute(
|
|
||||||
f"""
|
|
||||||
UPDATE enrollment_install_codes_persistent
|
|
||||||
SET is_active = 0,
|
|
||||||
archived_at = COALESCE(archived_at, ?)
|
|
||||||
WHERE id IN ({placeholders})
|
|
||||||
""",
|
|
||||||
(now_iso, *expired_ids),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
# Best-effort archival; continue if the persistence table is absent.
|
|
||||||
pass
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
UPDATE device_approvals
|
|
||||||
SET status = 'expired',
|
|
||||||
updated_at = ?
|
|
||||||
WHERE status = 'pending'
|
|
||||||
AND (
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM enrollment_install_codes c
|
|
||||||
WHERE c.id = device_approvals.enrollment_code_id
|
|
||||||
AND (
|
|
||||||
c.expires_at < ?
|
|
||||||
OR c.use_count >= c.max_uses
|
|
||||||
)
|
|
||||||
)
|
|
||||||
OR created_at < ?
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
(now_iso, now_iso, stale_before),
|
|
||||||
)
|
|
||||||
approvals_marked = cur.rowcount or 0
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if codes_pruned:
|
|
||||||
log("server", f"prune job removed {codes_pruned} expired enrollment codes")
|
|
||||||
if approvals_marked:
|
|
||||||
log("server", f"prune job expired {approvals_marked} device approvals")
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
"""Utility helpers for locating runtime storage paths.
|
|
||||||
|
|
||||||
The Borealis repository keeps the authoritative source code under ``Data/``
|
|
||||||
so that the bootstrap scripts can copy those assets into sibling ``Server/``
|
|
||||||
and ``Agent/`` directories for execution. Runtime artefacts such as TLS
|
|
||||||
certificates or signing keys must therefore live outside ``Data`` to avoid
|
|
||||||
polluting the template tree. This module centralises the path selection so
|
|
||||||
other modules can rely on a consistent location regardless of whether they
|
|
||||||
are executed from the copied runtime directory or directly from ``Data``
|
|
||||||
during development.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from functools import lru_cache
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
def _env_path(name: str) -> Optional[Path]:
|
|
||||||
"""Return a resolved ``Path`` for the given environment variable."""
|
|
||||||
|
|
||||||
value = os.environ.get(name)
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return Path(value).expanduser().resolve()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=None)
|
|
||||||
def project_root() -> Path:
|
|
||||||
"""Best-effort detection of the repository root."""
|
|
||||||
|
|
||||||
env = _env_path("BOREALIS_PROJECT_ROOT")
|
|
||||||
if env:
|
|
||||||
return env
|
|
||||||
|
|
||||||
current = Path(__file__).resolve()
|
|
||||||
for parent in current.parents:
|
|
||||||
if (parent / "Borealis.ps1").exists() or (parent / ".git").is_dir():
|
|
||||||
return parent
|
|
||||||
|
|
||||||
# Fallback to the ancestor that corresponds to ``<repo>/`` when the module
|
|
||||||
# lives under ``Data/Server/Modules``.
|
|
||||||
try:
|
|
||||||
return current.parents[4]
|
|
||||||
except IndexError:
|
|
||||||
return current.parent
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=None)
|
|
||||||
def server_runtime_root() -> Path:
|
|
||||||
"""Location where the running server stores mutable artefacts."""
|
|
||||||
|
|
||||||
env = _env_path("BOREALIS_SERVER_ROOT")
|
|
||||||
if env:
|
|
||||||
return env
|
|
||||||
|
|
||||||
root = project_root()
|
|
||||||
runtime = root / "Server" / "Borealis"
|
|
||||||
return runtime
|
|
||||||
|
|
||||||
|
|
||||||
def runtime_path(*parts: str) -> Path:
|
|
||||||
"""Return a path relative to the server runtime root."""
|
|
||||||
|
|
||||||
return server_runtime_root().joinpath(*parts)
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_runtime_dir(*parts: str) -> Path:
|
|
||||||
"""Create (if required) and return a runtime directory."""
|
|
||||||
|
|
||||||
path = runtime_path(*parts)
|
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=None)
|
|
||||||
def certificates_root() -> Path:
|
|
||||||
"""Base directory for persisted certificate material."""
|
|
||||||
|
|
||||||
env = _env_path("BOREALIS_CERTIFICATES_ROOT") or _env_path("BOREALIS_CERT_ROOT")
|
|
||||||
if env:
|
|
||||||
env.mkdir(parents=True, exist_ok=True)
|
|
||||||
return env
|
|
||||||
|
|
||||||
root = project_root() / "Certificates"
|
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
|
||||||
# Ensure expected subdirectories exist for agent and server material.
|
|
||||||
try:
|
|
||||||
(root / "Server").mkdir(parents=True, exist_ok=True)
|
|
||||||
(root / "Agent").mkdir(parents=True, exist_ok=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=None)
|
|
||||||
def server_certificates_root() -> Path:
|
|
||||||
"""Base directory for server certificate material."""
|
|
||||||
|
|
||||||
env = _env_path("BOREALIS_SERVER_CERT_ROOT")
|
|
||||||
if env:
|
|
||||||
env.mkdir(parents=True, exist_ok=True)
|
|
||||||
return env
|
|
||||||
|
|
||||||
root = certificates_root() / "Server"
|
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=None)
|
|
||||||
def agent_certificates_root() -> Path:
|
|
||||||
"""Base directory for agent certificate material."""
|
|
||||||
|
|
||||||
env = _env_path("BOREALIS_AGENT_CERT_ROOT")
|
|
||||||
if env:
|
|
||||||
env.mkdir(parents=True, exist_ok=True)
|
|
||||||
return env
|
|
||||||
|
|
||||||
root = certificates_root() / "Agent"
|
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
def certificates_path(*parts: str) -> Path:
|
|
||||||
"""Return a path under the certificates root."""
|
|
||||||
|
|
||||||
return certificates_root().joinpath(*parts)
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_certificates_dir(*parts: str) -> Path:
|
|
||||||
"""Create (if required) and return a certificates subdirectory."""
|
|
||||||
|
|
||||||
path = certificates_path(*parts)
|
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def server_certificates_path(*parts: str) -> Path:
|
|
||||||
"""Return a path under the server certificates root."""
|
|
||||||
|
|
||||||
return server_certificates_root().joinpath(*parts)
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_server_certificates_dir(*parts: str) -> Path:
|
|
||||||
"""Create (if required) and return a server certificates subdirectory."""
|
|
||||||
|
|
||||||
path = server_certificates_path(*parts)
|
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def agent_certificates_path(*parts: str) -> Path:
|
|
||||||
"""Return a path under the agent certificates root."""
|
|
||||||
|
|
||||||
return agent_certificates_root().joinpath(*parts)
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_agent_certificates_dir(*parts: str) -> Path:
|
|
||||||
"""Create (if required) and return an agent certificates subdirectory."""
|
|
||||||
|
|
||||||
path = agent_certificates_path(*parts)
|
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
|
||||||
return path
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
|
||||||
|
|
||||||
from Modules.auth.dpop import DPoPValidator, DPoPVerificationError, DPoPReplayError
|
|
||||||
|
|
||||||
|
|
||||||
def register(
|
|
||||||
app,
|
|
||||||
*,
|
|
||||||
db_conn_factory: Callable[[], sqlite3.Connection],
|
|
||||||
jwt_service,
|
|
||||||
dpop_validator: DPoPValidator,
|
|
||||||
) -> None:
|
|
||||||
blueprint = Blueprint("tokens", __name__)
|
|
||||||
|
|
||||||
def _hash_token(token: str) -> str:
|
|
||||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
def _iso_now() -> str:
|
|
||||||
return datetime.now(tz=timezone.utc).isoformat()
|
|
||||||
|
|
||||||
def _parse_iso(ts: str) -> datetime:
|
|
||||||
return datetime.fromisoformat(ts)
|
|
||||||
|
|
||||||
@blueprint.route("/api/agent/token/refresh", methods=["POST"])
|
|
||||||
def refresh():
|
|
||||||
payload = request.get_json(force=True, silent=True) or {}
|
|
||||||
guid = str(payload.get("guid") or "").strip()
|
|
||||||
refresh_token = str(payload.get("refresh_token") or "").strip()
|
|
||||||
|
|
||||||
if not guid or not refresh_token:
|
|
||||||
return jsonify({"error": "invalid_request"}), 400
|
|
||||||
|
|
||||||
conn = db_conn_factory()
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, guid, token_hash, dpop_jkt, created_at, expires_at, revoked_at
|
|
||||||
FROM refresh_tokens
|
|
||||||
WHERE guid = ?
|
|
||||||
AND token_hash = ?
|
|
||||||
""",
|
|
||||||
(guid, _hash_token(refresh_token)),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return jsonify({"error": "invalid_refresh_token"}), 401
|
|
||||||
|
|
||||||
record_id, row_guid, _token_hash, stored_jkt, created_at, expires_at, revoked_at = row
|
|
||||||
if row_guid != guid:
|
|
||||||
return jsonify({"error": "invalid_refresh_token"}), 401
|
|
||||||
if revoked_at:
|
|
||||||
return jsonify({"error": "refresh_token_revoked"}), 401
|
|
||||||
if expires_at:
|
|
||||||
try:
|
|
||||||
if _parse_iso(expires_at) <= datetime.now(tz=timezone.utc):
|
|
||||||
return jsonify({"error": "refresh_token_expired"}), 401
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT guid, ssl_key_fingerprint, token_version, status
|
|
||||||
FROM devices
|
|
||||||
WHERE guid = ?
|
|
||||||
""",
|
|
||||||
(guid,),
|
|
||||||
)
|
|
||||||
device_row = cur.fetchone()
|
|
||||||
if not device_row:
|
|
||||||
return jsonify({"error": "device_not_found"}), 404
|
|
||||||
|
|
||||||
device_guid, fingerprint, token_version, status = device_row
|
|
||||||
status_norm = (status or "active").strip().lower()
|
|
||||||
if status_norm in {"revoked", "decommissioned"}:
|
|
||||||
return jsonify({"error": "device_revoked"}), 403
|
|
||||||
|
|
||||||
dpop_proof = request.headers.get("DPoP")
|
|
||||||
jkt = stored_jkt or ""
|
|
||||||
if dpop_proof:
|
|
||||||
try:
|
|
||||||
jkt = dpop_validator.verify(request.method, request.url, dpop_proof, access_token=None)
|
|
||||||
except DPoPReplayError:
|
|
||||||
return jsonify({"error": "dpop_replayed"}), 400
|
|
||||||
except DPoPVerificationError:
|
|
||||||
return jsonify({"error": "dpop_invalid"}), 400
|
|
||||||
elif stored_jkt:
|
|
||||||
# The agent does not yet emit DPoP proofs; allow recovery by clearing
|
|
||||||
# the stored binding so refreshes can succeed. This preserves
|
|
||||||
# backward compatibility while the client gains full DPoP support.
|
|
||||||
try:
|
|
||||||
app.logger.warning(
|
|
||||||
"Clearing stored DPoP binding for guid=%s due to missing proof",
|
|
||||||
guid,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE refresh_tokens SET dpop_jkt = NULL WHERE id = ?",
|
|
||||||
(record_id,),
|
|
||||||
)
|
|
||||||
|
|
||||||
new_access_token = jwt_service.issue_access_token(
|
|
||||||
guid,
|
|
||||||
fingerprint or "",
|
|
||||||
token_version or 1,
|
|
||||||
)
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
UPDATE refresh_tokens
|
|
||||||
SET last_used_at = ?,
|
|
||||||
dpop_jkt = COALESCE(NULLIF(?, ''), dpop_jkt)
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(_iso_now(), jkt, record_id),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"access_token": new_access_token,
|
|
||||||
"expires_in": 900,
|
|
||||||
"token_type": "Bearer",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
app.register_blueprint(blueprint)
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
#////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/Server/Package-Borealis-Server.ps1
|
|
||||||
|
|
||||||
# ------------- Configuration -------------
|
|
||||||
# (all paths are made absolute via Join-Path and $scriptDir)
|
|
||||||
$scriptDir = Split-Path $MyInvocation.MyCommand.Definition -Parent
|
|
||||||
$projectRoot = Resolve-Path (Join-Path $scriptDir "..\..") # go up two levels to <ProjectRoot>\Borealis
|
|
||||||
$packagingDir = Join-Path $scriptDir "Packaging_Server"
|
|
||||||
$venvDir = Join-Path $packagingDir "Pyinstaller_Virtual_Environment"
|
|
||||||
$distDir = Join-Path $packagingDir "dist"
|
|
||||||
$buildDir = Join-Path $packagingDir "build"
|
|
||||||
$specPath = $packagingDir
|
|
||||||
|
|
||||||
$serverScript = Join-Path $scriptDir "server.py"
|
|
||||||
$outputName = "Borealis-Server"
|
|
||||||
$finalExeName = "$outputName.exe"
|
|
||||||
$requirementsPath = Join-Path $scriptDir "server-requirements.txt"
|
|
||||||
$iconPath = Join-Path $scriptDir "Borealis.ico"
|
|
||||||
|
|
||||||
# Static assets to bundle:
|
|
||||||
# - the compiled React build under Server/web-interface/build
|
|
||||||
$staticBuildSrc = Join-Path $projectRoot "Server\web-interface\build"
|
|
||||||
$staticBuildDst = "web-interface/build"
|
|
||||||
# - Tesseract-OCR folder must be nested under 'Borealis/Python_API_Endpoints/Tesseract-OCR'
|
|
||||||
$ocrSrc = Join-Path $scriptDir "Python_API_Endpoints\Tesseract-OCR"
|
|
||||||
$ocrDst = "Borealis/Python_API_Endpoints/Tesseract-OCR"
|
|
||||||
$soundsSrc = Join-Path $scriptDir "Sounds"
|
|
||||||
$soundsDst = "Sounds"
|
|
||||||
|
|
||||||
# Embedded Python shipped under Dependencies\Python\python.exe
|
|
||||||
$embeddedPython = Join-Path $projectRoot "Dependencies\Python\python.exe"
|
|
||||||
|
|
||||||
# ------------- Prepare packaging folder -------------
|
|
||||||
if (-Not (Test-Path $packagingDir)) {
|
|
||||||
New-Item -ItemType Directory -Path $packagingDir | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
# 1) Create or upgrade virtual environment
|
|
||||||
if (-Not (Test-Path (Join-Path $venvDir "Scripts\python.exe"))) {
|
|
||||||
Write-Host "[SETUP] Creating virtual environment at $venvDir"
|
|
||||||
& $embeddedPython -m venv --upgrade-deps $venvDir
|
|
||||||
}
|
|
||||||
|
|
||||||
# helper to invoke venv's python
|
|
||||||
$venvPy = Join-Path $venvDir "Scripts\python.exe"
|
|
||||||
|
|
||||||
# 2) Bootstrap & upgrade pip
|
|
||||||
Write-Host "[INFO] Bootstrapping pip"
|
|
||||||
& $venvPy -m ensurepip --upgrade
|
|
||||||
& $venvPy -m pip install --upgrade pip
|
|
||||||
|
|
||||||
# 3) Install server dependencies
|
|
||||||
Write-Host "[INFO] Installing server dependencies"
|
|
||||||
& $venvPy -m pip install -r $requirementsPath
|
|
||||||
# Ensure dnspython is available for Eventlet's greendns support
|
|
||||||
& $venvPy -m pip install dnspython
|
|
||||||
|
|
||||||
# 4) Install PyInstaller
|
|
||||||
Write-Host "[INFO] Installing PyInstaller"
|
|
||||||
& $venvPy -m pip install pyinstaller
|
|
||||||
|
|
||||||
# 5) Clean previous artifacts
|
|
||||||
Write-Host "[INFO] Cleaning previous artifacts"
|
|
||||||
Remove-Item -Recurse -Force $distDir, $buildDir, "$specPath\$outputName.spec" -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
# 6) Run PyInstaller, bundling server code and assets
|
|
||||||
# Collect all Eventlet and DNS submodules to avoid missing dynamic imports
|
|
||||||
Write-Host "[INFO] Running PyInstaller"
|
|
||||||
& $venvPy -m PyInstaller `
|
|
||||||
--onefile `
|
|
||||||
--name $outputName `
|
|
||||||
--icon $iconPath `
|
|
||||||
--collect-submodules eventlet `
|
|
||||||
--collect-submodules dns `
|
|
||||||
--distpath $distDir `
|
|
||||||
--workpath $buildDir `
|
|
||||||
--specpath $specPath `
|
|
||||||
--add-data "$staticBuildSrc;$staticBuildDst" `
|
|
||||||
--add-data "$ocrSrc;$ocrDst" `
|
|
||||||
--add-data "$soundsSrc;$soundsDst" `
|
|
||||||
$serverScript
|
|
||||||
|
|
||||||
# 7) Copy the final EXE back to Data/Server
|
|
||||||
if (Test-Path (Join-Path $distDir $finalExeName)) {
|
|
||||||
Copy-Item (Join-Path $distDir $finalExeName) (Join-Path $scriptDir $finalExeName) -Force
|
|
||||||
Write-Host "[SUCCESS] Server packaged at $finalExeName"
|
|
||||||
} else {
|
|
||||||
Write-Host "[FAILURE] Packaging failed." -ForegroundColor Red
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
#////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/Python_API_Endpoints/ocr_engines.py
|
|
||||||
|
|
||||||
import os
|
|
||||||
import io
|
|
||||||
import sys
|
|
||||||
import base64
|
|
||||||
import torch
|
|
||||||
import pytesseract
|
|
||||||
import easyocr
|
|
||||||
import numpy as np
|
|
||||||
import platform
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
|
||||||
# Configure cross-platform Tesseract path
|
|
||||||
# ---------------------------------------------------------------------
|
|
||||||
SYSTEM = platform.system()
|
|
||||||
|
|
||||||
def get_tesseract_folder():
|
|
||||||
if getattr(sys, 'frozen', False):
|
|
||||||
# PyInstaller EXE
|
|
||||||
base_path = sys._MEIPASS
|
|
||||||
return os.path.join(base_path, "Borealis", "Python_API_Endpoints", "Tesseract-OCR")
|
|
||||||
else:
|
|
||||||
# Normal Python environment
|
|
||||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
return os.path.join(base_dir, "Tesseract-OCR")
|
|
||||||
|
|
||||||
if SYSTEM == "Windows":
|
|
||||||
TESSERACT_FOLDER = get_tesseract_folder()
|
|
||||||
TESSERACT_EXE = os.path.join(TESSERACT_FOLDER, "tesseract.exe")
|
|
||||||
TESSDATA_DIR = os.path.join(TESSERACT_FOLDER, "tessdata")
|
|
||||||
|
|
||||||
if not os.path.isfile(TESSERACT_EXE):
|
|
||||||
raise EnvironmentError(f"Missing tesseract.exe at expected path: {TESSERACT_EXE}")
|
|
||||||
|
|
||||||
pytesseract.pytesseract.tesseract_cmd = TESSERACT_EXE
|
|
||||||
os.environ["TESSDATA_PREFIX"] = TESSDATA_DIR
|
|
||||||
else:
|
|
||||||
# Assume Linux/macOS with system-installed Tesseract
|
|
||||||
pytesseract.pytesseract.tesseract_cmd = "tesseract"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
|
||||||
# EasyOCR Global Instances
|
|
||||||
# ---------------------------------------------------------------------
|
|
||||||
easyocr_reader_cpu = None
|
|
||||||
easyocr_reader_gpu = None
|
|
||||||
|
|
||||||
def initialize_ocr_engines():
|
|
||||||
global easyocr_reader_cpu, easyocr_reader_gpu
|
|
||||||
if easyocr_reader_cpu is None:
|
|
||||||
easyocr_reader_cpu = easyocr.Reader(['en'], gpu=False)
|
|
||||||
if easyocr_reader_gpu is None:
|
|
||||||
easyocr_reader_gpu = easyocr.Reader(['en'], gpu=torch.cuda.is_available())
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
|
||||||
# Main OCR Handler
|
|
||||||
# ---------------------------------------------------------------------
|
|
||||||
def run_ocr_on_base64(image_b64: str, engine: str = "tesseract", backend: str = "cpu") -> list[str]:
|
|
||||||
if not image_b64:
|
|
||||||
raise ValueError("No base64 image data provided.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw_bytes = base64.b64decode(image_b64)
|
|
||||||
image = Image.open(io.BytesIO(raw_bytes)).convert("RGB")
|
|
||||||
except Exception as e:
|
|
||||||
raise ValueError(f"Invalid base64 image input: {e}")
|
|
||||||
|
|
||||||
engine = engine.lower().strip()
|
|
||||||
backend = backend.lower().strip()
|
|
||||||
|
|
||||||
if engine in ["tesseract", "tesseractocr"]:
|
|
||||||
try:
|
|
||||||
text = pytesseract.image_to_string(image, config="--psm 6 --oem 1")
|
|
||||||
except pytesseract.TesseractNotFoundError:
|
|
||||||
raise RuntimeError("Tesseract binary not found or not available on this platform.")
|
|
||||||
elif engine == "easyocr":
|
|
||||||
initialize_ocr_engines()
|
|
||||||
reader = easyocr_reader_gpu if backend == "gpu" else easyocr_reader_cpu
|
|
||||||
result = reader.readtext(np.array(image), detail=1)
|
|
||||||
|
|
||||||
# Group by Y position (line-aware sorting)
|
|
||||||
result = sorted(result, key=lambda r: r[0][0][1])
|
|
||||||
lines = []
|
|
||||||
current_line = []
|
|
||||||
last_y = None
|
|
||||||
line_threshold = 10
|
|
||||||
|
|
||||||
for (bbox, text, _) in result:
|
|
||||||
y = bbox[0][1]
|
|
||||||
if last_y is None or abs(y - last_y) < line_threshold:
|
|
||||||
current_line.append(text)
|
|
||||||
else:
|
|
||||||
lines.append(" ".join(current_line))
|
|
||||||
current_line = [text]
|
|
||||||
last_y = y
|
|
||||||
|
|
||||||
if current_line:
|
|
||||||
lines.append(" ".join(current_line))
|
|
||||||
text = "\n".join(lines)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"OCR engine '{engine}' not recognized.")
|
|
||||||
|
|
||||||
return [line.strip() for line in text.splitlines() if line.strip()]
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import platform
|
|
||||||
|
|
||||||
|
|
||||||
def run_powershell_script(script_path: str):
|
|
||||||
"""
|
|
||||||
Execute a PowerShell script with ExecutionPolicy Bypass.
|
|
||||||
|
|
||||||
Returns (returncode, stdout, stderr)
|
|
||||||
"""
|
|
||||||
if not script_path or not os.path.isfile(script_path):
|
|
||||||
raise FileNotFoundError(f"Script not found: {script_path}")
|
|
||||||
|
|
||||||
if not script_path.lower().endswith(".ps1"):
|
|
||||||
raise ValueError("run_powershell_script only accepts .ps1 files")
|
|
||||||
|
|
||||||
system = platform.system()
|
|
||||||
|
|
||||||
# Choose powershell binary
|
|
||||||
ps_bin = None
|
|
||||||
if system == "Windows":
|
|
||||||
# Prefer Windows PowerShell
|
|
||||||
ps_bin = os.path.expandvars(r"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
|
|
||||||
if not os.path.isfile(ps_bin):
|
|
||||||
ps_bin = "powershell.exe"
|
|
||||||
else:
|
|
||||||
# PowerShell Core (pwsh) may exist cross-platform
|
|
||||||
ps_bin = "pwsh"
|
|
||||||
|
|
||||||
# Build command
|
|
||||||
# -ExecutionPolicy Bypass (Windows only), -NoProfile, -File "script"
|
|
||||||
cmd = [ps_bin]
|
|
||||||
if system == "Windows":
|
|
||||||
cmd += ["-ExecutionPolicy", "Bypass"]
|
|
||||||
cmd += ["-NoProfile", "-File", script_path]
|
|
||||||
|
|
||||||
# Hide window on Windows
|
|
||||||
creationflags = 0
|
|
||||||
startupinfo = None
|
|
||||||
if system == "Windows":
|
|
||||||
creationflags = 0x08000000 # CREATE_NO_WINDOW
|
|
||||||
startupinfo = subprocess.STARTUPINFO()
|
|
||||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
||||||
|
|
||||||
proc = subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
universal_newlines=True,
|
|
||||||
creationflags=creationflags,
|
|
||||||
startupinfo=startupinfo,
|
|
||||||
)
|
|
||||||
out, err = proc.communicate()
|
|
||||||
return proc.returncode, out or "", err or ""
|
|
||||||
|
|
||||||
Binary file not shown.
@@ -1,22 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<!-- Vite serves everything in /public at the site root -->
|
|
||||||
<link rel="icon" href="/favicon.ico" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta name="theme-color" content="#000000" />
|
|
||||||
<meta name="description" content="Borealis - Automation Platform" />
|
|
||||||
<link rel="apple-touch-icon" href="/Borealis_Logo.png" />
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
|
||||||
|
|
||||||
<title>Borealis</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
|
||||||
<div id="root"></div>
|
|
||||||
|
|
||||||
<!-- Vite entrypoint; adjust to main.tsx if you switch to TS -->
|
|
||||||
<script type="module" src="/src/index.jsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "borealis-webui",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@emotion/react": "11.14.0",
|
|
||||||
"@emotion/styled": "11.14.0",
|
|
||||||
"@fortawesome/fontawesome-free": "7.1.0",
|
|
||||||
"@fontsource/ibm-plex-sans": "5.0.17",
|
|
||||||
"@mui/icons-material": "7.0.2",
|
|
||||||
"@mui/material": "7.0.2",
|
|
||||||
"@mui/x-date-pickers": "8.11.3",
|
|
||||||
"@mui/x-tree-view": "8.10.0",
|
|
||||||
"ag-grid-community": "34.2.0",
|
|
||||||
"ag-grid-react": "34.2.0",
|
|
||||||
"dayjs": "1.11.18",
|
|
||||||
"normalize.css": "8.0.1",
|
|
||||||
"prismjs": "1.30.0",
|
|
||||||
"react-simple-code-editor": "0.13.1",
|
|
||||||
"react": "19.1.0",
|
|
||||||
"react-color": "2.19.3",
|
|
||||||
"react-dom": "19.1.0",
|
|
||||||
"react-resizable": "3.0.5",
|
|
||||||
"react-markdown": "8.0.6",
|
|
||||||
"reactflow": "11.11.4",
|
|
||||||
"react-simple-keyboard": "3.8.62",
|
|
||||||
"socket.io-client": "4.8.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@vitejs/plugin-react": "^4.0.0",
|
|
||||||
"vite": "^5.0.0"
|
|
||||||
},
|
|
||||||
"browserslist": {
|
|
||||||
"production": [
|
|
||||||
">0.2%",
|
|
||||||
"not dead",
|
|
||||||
"not op_mini all"
|
|
||||||
],
|
|
||||||
"development": [
|
|
||||||
"last 1 chrome version",
|
|
||||||
"last 1 firefox version",
|
|
||||||
"last 1 safari version"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 327 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 388 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,549 +0,0 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
MenuItem,
|
|
||||||
Select,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
IconButton,
|
|
||||||
Tooltip,
|
|
||||||
CircularProgress
|
|
||||||
} from "@mui/material";
|
|
||||||
import UploadIcon from "@mui/icons-material/UploadFile";
|
|
||||||
import ClearIcon from "@mui/icons-material/Clear";
|
|
||||||
|
|
||||||
const CREDENTIAL_TYPES = [
|
|
||||||
{ value: "machine", label: "Machine" },
|
|
||||||
{ value: "domain", label: "Domain" },
|
|
||||||
{ value: "token", label: "Token" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const CONNECTION_TYPES = [
|
|
||||||
{ value: "ssh", label: "SSH" },
|
|
||||||
{ value: "winrm", label: "WinRM" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const BECOME_METHODS = [
|
|
||||||
{ value: "", label: "None" },
|
|
||||||
{ value: "sudo", label: "sudo" },
|
|
||||||
{ value: "su", label: "su" },
|
|
||||||
{ value: "runas", label: "runas" },
|
|
||||||
{ value: "enable", label: "enable" }
|
|
||||||
];
|
|
||||||
|
|
||||||
function emptyForm() {
|
|
||||||
return {
|
|
||||||
name: "",
|
|
||||||
description: "",
|
|
||||||
site_id: "",
|
|
||||||
credential_type: "machine",
|
|
||||||
connection_type: "ssh",
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
private_key: "",
|
|
||||||
private_key_passphrase: "",
|
|
||||||
become_method: "",
|
|
||||||
become_username: "",
|
|
||||||
become_password: ""
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSiteId(value) {
|
|
||||||
if (value === null || typeof value === "undefined" || value === "") return "";
|
|
||||||
const num = Number(value);
|
|
||||||
if (Number.isNaN(num)) return "";
|
|
||||||
return String(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CredentialEditor({
|
|
||||||
open,
|
|
||||||
mode = "create",
|
|
||||||
credential,
|
|
||||||
onClose,
|
|
||||||
onSaved
|
|
||||||
}) {
|
|
||||||
const isEdit = mode === "edit" && credential && credential.id;
|
|
||||||
const [form, setForm] = useState(emptyForm);
|
|
||||||
const [sites, setSites] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [passwordDirty, setPasswordDirty] = useState(false);
|
|
||||||
const [privateKeyDirty, setPrivateKeyDirty] = useState(false);
|
|
||||||
const [passphraseDirty, setPassphraseDirty] = useState(false);
|
|
||||||
const [becomePasswordDirty, setBecomePasswordDirty] = useState(false);
|
|
||||||
const [clearPassword, setClearPassword] = useState(false);
|
|
||||||
const [clearPrivateKey, setClearPrivateKey] = useState(false);
|
|
||||||
const [clearPassphrase, setClearPassphrase] = useState(false);
|
|
||||||
const [clearBecomePassword, setClearBecomePassword] = useState(false);
|
|
||||||
const [fetchingDetail, setFetchingDetail] = useState(false);
|
|
||||||
|
|
||||||
const credentialId = credential?.id;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
let canceled = false;
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch("/api/sites");
|
|
||||||
if (!resp.ok) return;
|
|
||||||
const data = await resp.json();
|
|
||||||
if (canceled) return;
|
|
||||||
const parsed = Array.isArray(data?.sites)
|
|
||||||
? data.sites
|
|
||||||
.filter((s) => s && s.id)
|
|
||||||
.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
name: s.name || `Site ${s.id}`
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
parsed.sort((a, b) => String(a.name || "").localeCompare(String(b.name || "")));
|
|
||||||
setSites(parsed);
|
|
||||||
} catch {
|
|
||||||
if (!canceled) setSites([]);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
canceled = true;
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
setError("");
|
|
||||||
setPasswordDirty(false);
|
|
||||||
setPrivateKeyDirty(false);
|
|
||||||
setPassphraseDirty(false);
|
|
||||||
setBecomePasswordDirty(false);
|
|
||||||
setClearPassword(false);
|
|
||||||
setClearPrivateKey(false);
|
|
||||||
setClearPassphrase(false);
|
|
||||||
setClearBecomePassword(false);
|
|
||||||
if (isEdit && credentialId) {
|
|
||||||
const applyData = (detail) => {
|
|
||||||
const next = emptyForm();
|
|
||||||
next.name = detail?.name || "";
|
|
||||||
next.description = detail?.description || "";
|
|
||||||
next.site_id = normalizeSiteId(detail?.site_id);
|
|
||||||
next.credential_type = (detail?.credential_type || "machine").toLowerCase();
|
|
||||||
next.connection_type = (detail?.connection_type || "ssh").toLowerCase();
|
|
||||||
next.username = detail?.username || "";
|
|
||||||
next.become_method = (detail?.become_method || "").toLowerCase();
|
|
||||||
next.become_username = detail?.become_username || "";
|
|
||||||
setForm(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (credential?.name) {
|
|
||||||
applyData(credential);
|
|
||||||
} else {
|
|
||||||
setFetchingDetail(true);
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/credentials/${credentialId}`);
|
|
||||||
if (resp.ok) {
|
|
||||||
const data = await resp.json();
|
|
||||||
applyData(data?.credential || {});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
} finally {
|
|
||||||
setFetchingDetail(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setForm(emptyForm());
|
|
||||||
}
|
|
||||||
}, [open, isEdit, credentialId, credential]);
|
|
||||||
|
|
||||||
const currentCredentialFlags = useMemo(() => ({
|
|
||||||
hasPassword: Boolean(credential?.has_password),
|
|
||||||
hasPrivateKey: Boolean(credential?.has_private_key),
|
|
||||||
hasPrivateKeyPassphrase: Boolean(credential?.has_private_key_passphrase),
|
|
||||||
hasBecomePassword: Boolean(credential?.has_become_password)
|
|
||||||
}), [credential]);
|
|
||||||
|
|
||||||
const disableSave = loading || fetchingDetail;
|
|
||||||
|
|
||||||
const updateField = (key) => (event) => {
|
|
||||||
const value = event?.target?.value ?? "";
|
|
||||||
setForm((prev) => ({ ...prev, [key]: value }));
|
|
||||||
if (key === "password") {
|
|
||||||
setPasswordDirty(true);
|
|
||||||
setClearPassword(false);
|
|
||||||
} else if (key === "private_key") {
|
|
||||||
setPrivateKeyDirty(true);
|
|
||||||
setClearPrivateKey(false);
|
|
||||||
} else if (key === "private_key_passphrase") {
|
|
||||||
setPassphraseDirty(true);
|
|
||||||
setClearPassphrase(false);
|
|
||||||
} else if (key === "become_password") {
|
|
||||||
setBecomePasswordDirty(true);
|
|
||||||
setClearBecomePassword(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrivateKeyUpload = async (event) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
try {
|
|
||||||
const text = await file.text();
|
|
||||||
setForm((prev) => ({ ...prev, private_key: text }));
|
|
||||||
setPrivateKeyDirty(true);
|
|
||||||
setClearPrivateKey(false);
|
|
||||||
} catch {
|
|
||||||
setError("Unable to read private key file.");
|
|
||||||
} finally {
|
|
||||||
event.target.value = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
if (loading) return;
|
|
||||||
onClose && onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const validate = () => {
|
|
||||||
if (!form.name.trim()) {
|
|
||||||
setError("Credential name is required.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setError("");
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildPayload = () => {
|
|
||||||
const payload = {
|
|
||||||
name: form.name.trim(),
|
|
||||||
description: form.description.trim(),
|
|
||||||
credential_type: (form.credential_type || "machine").toLowerCase(),
|
|
||||||
connection_type: (form.connection_type || "ssh").toLowerCase(),
|
|
||||||
username: form.username.trim(),
|
|
||||||
become_method: form.become_method.trim(),
|
|
||||||
become_username: form.become_username.trim()
|
|
||||||
};
|
|
||||||
const siteId = normalizeSiteId(form.site_id);
|
|
||||||
if (siteId) {
|
|
||||||
payload.site_id = Number(siteId);
|
|
||||||
} else {
|
|
||||||
payload.site_id = null;
|
|
||||||
}
|
|
||||||
if (passwordDirty) {
|
|
||||||
payload.password = form.password;
|
|
||||||
}
|
|
||||||
if (privateKeyDirty) {
|
|
||||||
payload.private_key = form.private_key;
|
|
||||||
}
|
|
||||||
if (passphraseDirty) {
|
|
||||||
payload.private_key_passphrase = form.private_key_passphrase;
|
|
||||||
}
|
|
||||||
if (becomePasswordDirty) {
|
|
||||||
payload.become_password = form.become_password;
|
|
||||||
}
|
|
||||||
if (clearPassword) payload.clear_password = true;
|
|
||||||
if (clearPrivateKey) payload.clear_private_key = true;
|
|
||||||
if (clearPassphrase) payload.clear_private_key_passphrase = true;
|
|
||||||
if (clearBecomePassword) payload.clear_become_password = true;
|
|
||||||
return payload;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!validate()) return;
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
const payload = buildPayload();
|
|
||||||
try {
|
|
||||||
const resp = await fetch(
|
|
||||||
isEdit ? `/api/credentials/${credentialId}` : "/api/credentials",
|
|
||||||
{
|
|
||||||
method: isEdit ? "PUT" : "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!resp.ok) {
|
|
||||||
throw new Error(data?.error || `Request failed (${resp.status})`);
|
|
||||||
}
|
|
||||||
onSaved && onSaved(data?.credential || null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(String(err.message || err));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const title = isEdit ? "Edit Credential" : "Create Credential";
|
|
||||||
const helperStyle = { fontSize: 12, color: "#8a8a8a", mt: 0.5 };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onClose={handleCancel}
|
|
||||||
maxWidth="md"
|
|
||||||
fullWidth
|
|
||||||
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
|
|
||||||
>
|
|
||||||
<DialogTitle sx={{ pb: 1 }}>{title}</DialogTitle>
|
|
||||||
<DialogContent dividers sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
||||||
{fetchingDetail && (
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, color: "#aaa" }}>
|
|
||||||
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
|
|
||||||
<Typography variant="body2">Loading credential details…</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<Box sx={{ bgcolor: "#2c1c1c", border: "1px solid #663939", borderRadius: 1, p: 1 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: "#ff8080" }}>{error}</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<TextField
|
|
||||||
label="Name"
|
|
||||||
value={form.name}
|
|
||||||
onChange={updateField("name")}
|
|
||||||
required
|
|
||||||
disabled={disableSave}
|
|
||||||
sx={{
|
|
||||||
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
|
|
||||||
"& label": { color: "#888" }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Description"
|
|
||||||
value={form.description}
|
|
||||||
onChange={updateField("description")}
|
|
||||||
disabled={disableSave}
|
|
||||||
multiline
|
|
||||||
minRows={2}
|
|
||||||
sx={{
|
|
||||||
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
|
|
||||||
"& label": { color: "#888" }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 2 }}>
|
|
||||||
<FormControl sx={{ minWidth: 220 }} size="small" disabled={disableSave}>
|
|
||||||
<InputLabel sx={{ color: "#aaa" }}>Site</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={form.site_id}
|
|
||||||
label="Site"
|
|
||||||
onChange={updateField("site_id")}
|
|
||||||
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
|
|
||||||
>
|
|
||||||
<MenuItem value="">(None)</MenuItem>
|
|
||||||
{sites.map((site) => (
|
|
||||||
<MenuItem key={site.id} value={String(site.id)}>
|
|
||||||
{site.name}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormControl sx={{ minWidth: 180 }} size="small" disabled={disableSave}>
|
|
||||||
<InputLabel sx={{ color: "#aaa" }}>Credential Type</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={form.credential_type}
|
|
||||||
label="Credential Type"
|
|
||||||
onChange={updateField("credential_type")}
|
|
||||||
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
|
|
||||||
>
|
|
||||||
{CREDENTIAL_TYPES.map((opt) => (
|
|
||||||
<MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormControl sx={{ minWidth: 180 }} size="small" disabled={disableSave}>
|
|
||||||
<InputLabel sx={{ color: "#aaa" }}>Connection</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={form.connection_type}
|
|
||||||
label="Connection"
|
|
||||||
onChange={updateField("connection_type")}
|
|
||||||
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
|
|
||||||
>
|
|
||||||
{CONNECTION_TYPES.map((opt) => (
|
|
||||||
<MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</Box>
|
|
||||||
<TextField
|
|
||||||
label="Username"
|
|
||||||
value={form.username}
|
|
||||||
onChange={updateField("username")}
|
|
||||||
disabled={disableSave}
|
|
||||||
sx={{
|
|
||||||
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
|
|
||||||
"& label": { color: "#888" }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
|
||||||
<TextField
|
|
||||||
label="Password"
|
|
||||||
type="password"
|
|
||||||
value={form.password}
|
|
||||||
onChange={updateField("password")}
|
|
||||||
disabled={disableSave}
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
|
|
||||||
"& label": { color: "#888" }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{isEdit && currentCredentialFlags.hasPassword && !passwordDirty && !clearPassword && (
|
|
||||||
<Tooltip title="Clear stored password">
|
|
||||||
<IconButton size="small" onClick={() => setClearPassword(true)} sx={{ color: "#ff8080" }}>
|
|
||||||
<ClearIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
{isEdit && currentCredentialFlags.hasPassword && !passwordDirty && !clearPassword && (
|
|
||||||
<Typography sx={helperStyle}>Stored password will remain unless you change or clear it.</Typography>
|
|
||||||
)}
|
|
||||||
{clearPassword && (
|
|
||||||
<Typography sx={{ ...helperStyle, color: "#ffaaaa" }}>Password will be removed when saving.</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", gap: 1, alignItems: "flex-start" }}>
|
|
||||||
<TextField
|
|
||||||
label="SSH Private Key"
|
|
||||||
value={form.private_key}
|
|
||||||
onChange={updateField("private_key")}
|
|
||||||
disabled={disableSave}
|
|
||||||
multiline
|
|
||||||
minRows={4}
|
|
||||||
maxRows={12}
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff", fontFamily: "monospace" },
|
|
||||||
"& label": { color: "#888" }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
component="label"
|
|
||||||
startIcon={<UploadIcon />}
|
|
||||||
disabled={disableSave}
|
|
||||||
sx={{ alignSelf: "center", borderColor: "#58a6ff", color: "#58a6ff" }}
|
|
||||||
>
|
|
||||||
Upload
|
|
||||||
<input type="file" hidden accept=".pem,.key,.txt" onChange={handlePrivateKeyUpload} />
|
|
||||||
</Button>
|
|
||||||
{isEdit && currentCredentialFlags.hasPrivateKey && !privateKeyDirty && !clearPrivateKey && (
|
|
||||||
<Tooltip title="Clear stored private key">
|
|
||||||
<IconButton size="small" onClick={() => setClearPrivateKey(true)} sx={{ color: "#ff8080" }}>
|
|
||||||
<ClearIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
{isEdit && currentCredentialFlags.hasPrivateKey && !privateKeyDirty && !clearPrivateKey && (
|
|
||||||
<Typography sx={helperStyle}>Private key is stored. Upload or paste a new one to replace, or clear it.</Typography>
|
|
||||||
)}
|
|
||||||
{clearPrivateKey && (
|
|
||||||
<Typography sx={{ ...helperStyle, color: "#ffaaaa" }}>Private key will be removed when saving.</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
|
||||||
<TextField
|
|
||||||
label="Private Key Passphrase"
|
|
||||||
type="password"
|
|
||||||
value={form.private_key_passphrase}
|
|
||||||
onChange={updateField("private_key_passphrase")}
|
|
||||||
disabled={disableSave}
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
|
|
||||||
"& label": { color: "#888" }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{isEdit && currentCredentialFlags.hasPrivateKeyPassphrase && !passphraseDirty && !clearPassphrase && (
|
|
||||||
<Tooltip title="Clear stored passphrase">
|
|
||||||
<IconButton size="small" onClick={() => setClearPassphrase(true)} sx={{ color: "#ff8080" }}>
|
|
||||||
<ClearIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
{isEdit && currentCredentialFlags.hasPrivateKeyPassphrase && !passphraseDirty && !clearPassphrase && (
|
|
||||||
<Typography sx={helperStyle}>A passphrase is stored for this key.</Typography>
|
|
||||||
)}
|
|
||||||
{clearPassphrase && (
|
|
||||||
<Typography sx={{ ...helperStyle, color: "#ffaaaa" }}>Key passphrase will be removed when saving.</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
|
||||||
<FormControl sx={{ minWidth: 180 }} size="small" disabled={disableSave}>
|
|
||||||
<InputLabel sx={{ color: "#aaa" }}>Privilege Escalation</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={form.become_method}
|
|
||||||
label="Privilege Escalation"
|
|
||||||
onChange={updateField("become_method")}
|
|
||||||
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
|
|
||||||
>
|
|
||||||
{BECOME_METHODS.map((opt) => (
|
|
||||||
<MenuItem key={opt.value || "none"} value={opt.value}>{opt.label}</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<TextField
|
|
||||||
label="Escalation Username"
|
|
||||||
value={form.become_username}
|
|
||||||
onChange={updateField("become_username")}
|
|
||||||
disabled={disableSave}
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 200,
|
|
||||||
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
|
|
||||||
"& label": { color: "#888" }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
|
||||||
<TextField
|
|
||||||
label="Escalation Password"
|
|
||||||
type="password"
|
|
||||||
value={form.become_password}
|
|
||||||
onChange={updateField("become_password")}
|
|
||||||
disabled={disableSave}
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
|
|
||||||
"& label": { color: "#888" }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{isEdit && currentCredentialFlags.hasBecomePassword && !becomePasswordDirty && !clearBecomePassword && (
|
|
||||||
<Tooltip title="Clear stored escalation password">
|
|
||||||
<IconButton size="small" onClick={() => setClearBecomePassword(true)} sx={{ color: "#ff8080" }}>
|
|
||||||
<ClearIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
{isEdit && currentCredentialFlags.hasBecomePassword && !becomePasswordDirty && !clearBecomePassword && (
|
|
||||||
<Typography sx={helperStyle}>Escalation password is stored.</Typography>
|
|
||||||
)}
|
|
||||||
{clearBecomePassword && (
|
|
||||||
<Typography sx={{ ...helperStyle, color: "#ffaaaa" }}>Escalation password will be removed when saving.</Typography>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
|
||||||
<Button onClick={handleCancel} sx={{ color: "#58a6ff" }} disabled={loading}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSave}
|
|
||||||
variant="outlined"
|
|
||||||
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
|
|
||||||
disabled={disableSave}
|
|
||||||
>
|
|
||||||
{loading ? <CircularProgress size={18} sx={{ color: "#58a6ff" }} /> : "Save"}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
IconButton,
|
|
||||||
Menu,
|
|
||||||
MenuItem,
|
|
||||||
Paper,
|
|
||||||
Typography,
|
|
||||||
CircularProgress,
|
|
||||||
} from "@mui/material";
|
|
||||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
|
||||||
import AddIcon from "@mui/icons-material/Add";
|
|
||||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
|
||||||
import LockIcon from "@mui/icons-material/Lock";
|
|
||||||
import WifiIcon from "@mui/icons-material/Wifi";
|
|
||||||
import ComputerIcon from "@mui/icons-material/Computer";
|
|
||||||
import { AgGridReact } from "ag-grid-react";
|
|
||||||
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
|
|
||||||
import CredentialEditor from "./Credential_Editor.jsx";
|
|
||||||
import { ConfirmDeleteDialog } from "../Dialogs.jsx";
|
|
||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
|
||||||
|
|
||||||
const myTheme = themeQuartz.withParams({
|
|
||||||
accentColor: "#FFA6FF",
|
|
||||||
backgroundColor: "#1f2836",
|
|
||||||
browserColorScheme: "dark",
|
|
||||||
chromeBackgroundColor: {
|
|
||||||
ref: "foregroundColor",
|
|
||||||
mix: 0.07,
|
|
||||||
onto: "backgroundColor"
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
googleFont: "IBM Plex Sans"
|
|
||||||
},
|
|
||||||
foregroundColor: "#FFF",
|
|
||||||
headerFontSize: 14
|
|
||||||
});
|
|
||||||
|
|
||||||
const themeClassName = myTheme.themeName || "ag-theme-quartz";
|
|
||||||
const gridFontFamily = '"IBM Plex Sans", "Helvetica Neue", Arial, sans-serif';
|
|
||||||
const iconFontFamily = '"Quartz Regular"';
|
|
||||||
|
|
||||||
function formatTs(ts) {
|
|
||||||
if (!ts) return "-";
|
|
||||||
const date = new Date(Number(ts) * 1000);
|
|
||||||
if (Number.isNaN(date?.getTime())) return "-";
|
|
||||||
return `${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function titleCase(value) {
|
|
||||||
if (!value) return "-";
|
|
||||||
const lower = String(value).toLowerCase();
|
|
||||||
return lower.replace(/(^|\s)\w/g, (c) => c.toUpperCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
function connectionIcon(connection) {
|
|
||||||
const val = (connection || "").toLowerCase();
|
|
||||||
if (val === "ssh") return <LockIcon fontSize="small" sx={{ mr: 0.6, color: "#58a6ff" }} />;
|
|
||||||
if (val === "winrm") return <WifiIcon fontSize="small" sx={{ mr: 0.6, color: "#58a6ff" }} />;
|
|
||||||
return <ComputerIcon fontSize="small" sx={{ mr: 0.6, color: "#58a6ff" }} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CredentialList({ isAdmin = false }) {
|
|
||||||
const [rows, setRows] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [menuAnchor, setMenuAnchor] = useState(null);
|
|
||||||
const [menuRow, setMenuRow] = useState(null);
|
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
|
||||||
const [editorMode, setEditorMode] = useState("create");
|
|
||||||
const [editingCredential, setEditingCredential] = useState(null);
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
|
||||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
|
||||||
const gridApiRef = useRef(null);
|
|
||||||
|
|
||||||
const openMenu = useCallback((event, row) => {
|
|
||||||
setMenuAnchor(event.currentTarget);
|
|
||||||
setMenuRow(row);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeMenu = useCallback(() => {
|
|
||||||
setMenuAnchor(null);
|
|
||||||
setMenuRow(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const connectionCellRenderer = useCallback((params) => {
|
|
||||||
const row = params.data || {};
|
|
||||||
const label = titleCase(row.connection_type);
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", fontFamily: gridFontFamily }}>
|
|
||||||
{connectionIcon(row.connection_type)}
|
|
||||||
<Box component="span" sx={{ color: "#f5f7fa" }}>
|
|
||||||
{label}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const actionCellRenderer = useCallback(
|
|
||||||
(params) => {
|
|
||||||
const row = params.data;
|
|
||||||
if (!row) return null;
|
|
||||||
const handleClick = (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
openMenu(event, row);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<IconButton size="small" onClick={handleClick} sx={{ color: "#7db7ff" }}>
|
|
||||||
<MoreVertIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[openMenu]
|
|
||||||
);
|
|
||||||
|
|
||||||
const columnDefs = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
headerName: "Name",
|
|
||||||
field: "name",
|
|
||||||
sort: "asc",
|
|
||||||
cellRenderer: (params) => params.value || "-"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: "Credential Type",
|
|
||||||
field: "credential_type",
|
|
||||||
valueGetter: (params) => titleCase(params.data?.credential_type)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: "Connection",
|
|
||||||
field: "connection_type",
|
|
||||||
cellRenderer: connectionCellRenderer
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: "Site",
|
|
||||||
field: "site_name",
|
|
||||||
cellRenderer: (params) => params.value || "-"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: "Username",
|
|
||||||
field: "username",
|
|
||||||
cellRenderer: (params) => params.value || "-"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: "Updated",
|
|
||||||
field: "updated_at",
|
|
||||||
valueGetter: (params) =>
|
|
||||||
formatTs(params.data?.updated_at || params.data?.created_at)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: "",
|
|
||||||
field: "__actions__",
|
|
||||||
minWidth: 70,
|
|
||||||
maxWidth: 80,
|
|
||||||
sortable: false,
|
|
||||||
filter: false,
|
|
||||||
resizable: false,
|
|
||||||
suppressMenu: true,
|
|
||||||
cellRenderer: actionCellRenderer,
|
|
||||||
pinned: "right"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[actionCellRenderer, connectionCellRenderer]
|
|
||||||
);
|
|
||||||
|
|
||||||
const defaultColDef = useMemo(
|
|
||||||
() => ({
|
|
||||||
sortable: true,
|
|
||||||
filter: "agTextColumnFilter",
|
|
||||||
resizable: true,
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 140,
|
|
||||||
cellStyle: {
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
color: "#f5f7fa",
|
|
||||||
fontFamily: gridFontFamily,
|
|
||||||
fontSize: "13px"
|
|
||||||
},
|
|
||||||
headerClass: "credential-grid-header"
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const getRowId = useCallback(
|
|
||||||
(params) =>
|
|
||||||
params.data?.id ||
|
|
||||||
params.data?.name ||
|
|
||||||
params.data?.username ||
|
|
||||||
String(params.rowIndex ?? ""),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const fetchCredentials = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const resp = await fetch("/api/credentials");
|
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
const list = Array.isArray(data?.credentials) ? data.credentials : [];
|
|
||||||
list.sort((a, b) => String(a?.name || "").localeCompare(String(b?.name || "")));
|
|
||||||
setRows(list);
|
|
||||||
} catch (err) {
|
|
||||||
setRows([]);
|
|
||||||
setError(String(err.message || err));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchCredentials();
|
|
||||||
}, [fetchCredentials]);
|
|
||||||
|
|
||||||
const handleCreate = () => {
|
|
||||||
setEditorMode("create");
|
|
||||||
setEditingCredential(null);
|
|
||||||
setEditorOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (row) => {
|
|
||||||
closeMenu();
|
|
||||||
setEditorMode("edit");
|
|
||||||
setEditingCredential(row);
|
|
||||||
setEditorOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (row) => {
|
|
||||||
closeMenu();
|
|
||||||
setDeleteTarget(row);
|
|
||||||
};
|
|
||||||
|
|
||||||
const doDelete = async () => {
|
|
||||||
if (!deleteTarget?.id) return;
|
|
||||||
setDeleteBusy(true);
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/credentials/${deleteTarget.id}`, { method: "DELETE" });
|
|
||||||
if (!resp.ok) {
|
|
||||||
const data = await resp.json().catch(() => ({}));
|
|
||||||
throw new Error(data?.error || `HTTP ${resp.status}`);
|
|
||||||
}
|
|
||||||
setDeleteTarget(null);
|
|
||||||
await fetchCredentials();
|
|
||||||
} catch (err) {
|
|
||||||
setError(String(err.message || err));
|
|
||||||
} finally {
|
|
||||||
setDeleteBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditorSaved = async () => {
|
|
||||||
setEditorOpen(false);
|
|
||||||
setEditingCredential(null);
|
|
||||||
await fetchCredentials();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGridReady = useCallback((params) => {
|
|
||||||
gridApiRef.current = params.api;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const api = gridApiRef.current;
|
|
||||||
if (!api) return;
|
|
||||||
if (loading) {
|
|
||||||
api.showLoadingOverlay();
|
|
||||||
} else if (!rows.length) {
|
|
||||||
api.showNoRowsOverlay();
|
|
||||||
} else {
|
|
||||||
api.hideOverlay();
|
|
||||||
}
|
|
||||||
}, [loading, rows]);
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
|
||||||
return (
|
|
||||||
<Paper sx={{ m: 2, p: 3, bgcolor: "#1e1e1e" }}>
|
|
||||||
<Typography variant="h6" sx={{ color: "#ff8080" }}>
|
|
||||||
Access denied
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: "#bbb" }}>
|
|
||||||
You do not have permission to manage credentials.
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Paper
|
|
||||||
sx={{
|
|
||||||
m: 2,
|
|
||||||
p: 0,
|
|
||||||
bgcolor: "#1e1e1e",
|
|
||||||
fontFamily: gridFontFamily,
|
|
||||||
color: "#f5f7fa",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
flexGrow: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
minHeight: 420
|
|
||||||
}}
|
|
||||||
elevation={2}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
p: 2,
|
|
||||||
borderBottom: "1px solid #2a2a2a"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0.3 }}>
|
|
||||||
Credentials
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
|
||||||
Stored credentials for remote automation tasks and Ansible playbook runs.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: "flex", gap: 1 }}>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
startIcon={<RefreshIcon />}
|
|
||||||
sx={{ borderColor: "#58a6ff", color: "#58a6ff" }}
|
|
||||||
onClick={fetchCredentials}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
size="small"
|
|
||||||
startIcon={<AddIcon />}
|
|
||||||
sx={{ bgcolor: "#58a6ff", color: "#0b0f19" }}
|
|
||||||
onClick={handleCreate}
|
|
||||||
>
|
|
||||||
New Credential
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
{loading && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 1,
|
|
||||||
color: "#7db7ff",
|
|
||||||
px: 2,
|
|
||||||
py: 1.5,
|
|
||||||
borderBottom: "1px solid #2a2a2a"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
|
|
||||||
<Typography variant="body2">Loading credentials…</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<Box sx={{ px: 2, py: 1.5, color: "#ff8080", borderBottom: "1px solid #2a2a2a" }}>
|
|
||||||
<Typography variant="body2">{error}</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
flexGrow: 1,
|
|
||||||
minHeight: 0,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
mt: "10px",
|
|
||||||
px: 2,
|
|
||||||
pb: 2
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
className={themeClassName}
|
|
||||||
sx={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
flexGrow: 1,
|
|
||||||
fontFamily: gridFontFamily,
|
|
||||||
"--ag-font-family": gridFontFamily,
|
|
||||||
"--ag-icon-font-family": iconFontFamily,
|
|
||||||
"--ag-row-border-style": "solid",
|
|
||||||
"--ag-row-border-color": "#2a2a2a",
|
|
||||||
"--ag-row-border-width": "1px",
|
|
||||||
"& .ag-root-wrapper": {
|
|
||||||
borderRadius: 1,
|
|
||||||
minHeight: 320
|
|
||||||
},
|
|
||||||
"& .ag-root, & .ag-header, & .ag-center-cols-container, & .ag-paging-panel": {
|
|
||||||
fontFamily: gridFontFamily
|
|
||||||
},
|
|
||||||
"& .ag-icon": {
|
|
||||||
fontFamily: iconFontFamily
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{ color: "#f5f7fa" }}
|
|
||||||
>
|
|
||||||
<AgGridReact
|
|
||||||
rowData={rows}
|
|
||||||
columnDefs={columnDefs}
|
|
||||||
defaultColDef={defaultColDef}
|
|
||||||
animateRows
|
|
||||||
rowHeight={46}
|
|
||||||
headerHeight={44}
|
|
||||||
getRowId={getRowId}
|
|
||||||
overlayNoRowsTemplate="<span class='ag-overlay-no-rows-center'>No credentials have been created yet.</span>"
|
|
||||||
onGridReady={handleGridReady}
|
|
||||||
suppressCellFocus
|
|
||||||
theme={myTheme}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
fontFamily: gridFontFamily,
|
|
||||||
"--ag-icon-font-family": iconFontFamily
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Menu
|
|
||||||
anchorEl={menuAnchor}
|
|
||||||
open={Boolean(menuAnchor)}
|
|
||||||
onClose={closeMenu}
|
|
||||||
elevation={2}
|
|
||||||
PaperProps={{ sx: { bgcolor: "#1f1f1f", color: "#f5f5f5" } }}
|
|
||||||
>
|
|
||||||
<MenuItem onClick={() => handleEdit(menuRow)}>Edit</MenuItem>
|
|
||||||
<MenuItem onClick={() => handleDelete(menuRow)} sx={{ color: "#ff8080" }}>
|
|
||||||
Delete
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
|
|
||||||
<CredentialEditor
|
|
||||||
open={editorOpen}
|
|
||||||
mode={editorMode}
|
|
||||||
credential={editingCredential}
|
|
||||||
onClose={() => {
|
|
||||||
setEditorOpen(false);
|
|
||||||
setEditingCredential(null);
|
|
||||||
}}
|
|
||||||
onSaved={handleEditorSaved}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDeleteDialog
|
|
||||||
open={Boolean(deleteTarget)}
|
|
||||||
onCancel={() => setDeleteTarget(null)}
|
|
||||||
onConfirm={doDelete}
|
|
||||||
confirmDisabled={deleteBusy}
|
|
||||||
message={
|
|
||||||
deleteTarget
|
|
||||||
? `Delete credential '${deleteTarget.name || ""}'? Any jobs referencing it will require an update.`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
CircularProgress,
|
|
||||||
InputAdornment,
|
|
||||||
Link,
|
|
||||||
Paper,
|
|
||||||
TextField,
|
|
||||||
Typography
|
|
||||||
} from "@mui/material";
|
|
||||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
|
||||||
import SaveIcon from "@mui/icons-material/Save";
|
|
||||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
|
||||||
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
|
|
||||||
|
|
||||||
const paperSx = {
|
|
||||||
m: 2,
|
|
||||||
p: 0,
|
|
||||||
bgcolor: "#1e1e1e",
|
|
||||||
color: "#f5f7fa",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
flexGrow: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
minHeight: 320
|
|
||||||
};
|
|
||||||
|
|
||||||
const fieldSx = {
|
|
||||||
mt: 2,
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
bgcolor: "#181818",
|
|
||||||
color: "#f5f7fa",
|
|
||||||
"& fieldset": { borderColor: "#2a2a2a" },
|
|
||||||
"&:hover fieldset": { borderColor: "#58a6ff" },
|
|
||||||
"&.Mui-focused fieldset": { borderColor: "#58a6ff" }
|
|
||||||
},
|
|
||||||
"& .MuiInputLabel-root": { color: "#bbb" },
|
|
||||||
"& .MuiInputLabel-root.Mui-focused": { color: "#7db7ff" }
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function GithubAPIToken({ isAdmin = false }) {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [token, setToken] = useState("");
|
|
||||||
const [inputValue, setInputValue] = useState("");
|
|
||||||
const [fetchError, setFetchError] = useState("");
|
|
||||||
const [showToken, setShowToken] = useState(false);
|
|
||||||
const [verification, setVerification] = useState({
|
|
||||||
message: "",
|
|
||||||
valid: null,
|
|
||||||
status: "",
|
|
||||||
rateLimit: null,
|
|
||||||
error: ""
|
|
||||||
});
|
|
||||||
|
|
||||||
const hydrate = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setFetchError("");
|
|
||||||
try {
|
|
||||||
const resp = await fetch("/api/github/token");
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!resp.ok) {
|
|
||||||
throw new Error(data?.error || `HTTP ${resp.status}`);
|
|
||||||
}
|
|
||||||
const storedToken = typeof data?.token === "string" ? data.token : "";
|
|
||||||
setToken(storedToken);
|
|
||||||
setInputValue(storedToken);
|
|
||||||
setShowToken(false);
|
|
||||||
setVerification({
|
|
||||||
message: typeof data?.message === "string" ? data.message : "",
|
|
||||||
valid: data?.valid === true,
|
|
||||||
status: typeof data?.status === "string" ? data.status : "",
|
|
||||||
rateLimit: typeof data?.rate_limit === "number" ? data.rate_limit : null,
|
|
||||||
error: typeof data?.error === "string" ? data.error : ""
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const message = err && typeof err.message === "string" ? err.message : String(err);
|
|
||||||
setFetchError(message);
|
|
||||||
setToken("");
|
|
||||||
setInputValue("");
|
|
||||||
setVerification({ message: "", valid: null, status: "", rateLimit: null, error: "" });
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isAdmin) return;
|
|
||||||
hydrate();
|
|
||||||
}, [hydrate, isAdmin]);
|
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
|
||||||
setSaving(true);
|
|
||||||
setFetchError("");
|
|
||||||
try {
|
|
||||||
const resp = await fetch("/api/github/token", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ token: inputValue })
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!resp.ok) {
|
|
||||||
throw new Error(data?.error || `HTTP ${resp.status}`);
|
|
||||||
}
|
|
||||||
const storedToken = typeof data?.token === "string" ? data.token : "";
|
|
||||||
setToken(storedToken);
|
|
||||||
setInputValue(storedToken);
|
|
||||||
setShowToken(false);
|
|
||||||
setVerification({
|
|
||||||
message: typeof data?.message === "string" ? data.message : "",
|
|
||||||
valid: data?.valid === true,
|
|
||||||
status: typeof data?.status === "string" ? data.status : "",
|
|
||||||
rateLimit: typeof data?.rate_limit === "number" ? data.rate_limit : null,
|
|
||||||
error: typeof data?.error === "string" ? data.error : ""
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const message = err && typeof err.message === "string" ? err.message : String(err);
|
|
||||||
setFetchError(message);
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}, [inputValue]);
|
|
||||||
|
|
||||||
const dirty = useMemo(() => inputValue !== token, [inputValue, token]);
|
|
||||||
|
|
||||||
const verificationMessage = useMemo(() => {
|
|
||||||
if (dirty) {
|
|
||||||
return { text: "Token has not been saved yet — Save to verify.", color: "#f0c36d" };
|
|
||||||
}
|
|
||||||
const message = verification.message || "";
|
|
||||||
if (!message) {
|
|
||||||
return { text: "", color: "#bbb" };
|
|
||||||
}
|
|
||||||
if (verification.valid) {
|
|
||||||
return { text: message, color: "#7dffac" };
|
|
||||||
}
|
|
||||||
if ((verification.status || "").toLowerCase() === "missing") {
|
|
||||||
return { text: message, color: "#bbb" };
|
|
||||||
}
|
|
||||||
return { text: message, color: "#ff8080" };
|
|
||||||
}, [dirty, verification]);
|
|
||||||
|
|
||||||
const toggleReveal = useCallback(() => {
|
|
||||||
setShowToken((prev) => !prev);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
|
||||||
return (
|
|
||||||
<Paper sx={{ m: 2, p: 3, bgcolor: "#1e1e1e" }}>
|
|
||||||
<Typography variant="h6" sx={{ color: "#ff8080" }}>
|
|
||||||
Access denied
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: "#bbb" }}>
|
|
||||||
You do not have permission to manage the GitHub API token.
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper sx={paperSx} elevation={2}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
p: 2,
|
|
||||||
borderBottom: "1px solid #2a2a2a"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0.3 }}>
|
|
||||||
Github API Token
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
|
||||||
Using a Github "Personal Access Token" increases the Github API rate limits from 60/hr to 5,000/hr. This is important for production Borealis usage as it likes to hit its unauthenticated API limits sometimes despite my best efforts.
|
|
||||||
<br></br>Navigate to{' '}
|
|
||||||
<Link
|
|
||||||
href="https://github.com/settings/tokens"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
sx={{ color: "#7db7ff" }}
|
|
||||||
>
|
|
||||||
https://github.com/settings/tokens
|
|
||||||
</Link>{' '}
|
|
||||||
❯ <b>Personal Access Tokens ❯ Tokens (Classic) ❯ Generate New Token ❯ New Personal Access Token (Classic)</b>
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<br></br>
|
|
||||||
<Typography variant="body2" sx={{ color: "#ccc" }}>
|
|
||||||
<Box component="span" sx={{ fontWeight: 600 }}>Note:</Box>{' '}
|
|
||||||
<Box component="code" sx={{ bgcolor: "#222", px: 0.75, py: 0.25, borderRadius: 1, fontSize: "0.85rem" }}>
|
|
||||||
Borealis Automation Platform
|
|
||||||
</Box>
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: "#ccc" }}>
|
|
||||||
<Box component="span" sx={{ fontWeight: 600 }}>Scope:</Box>{' '}
|
|
||||||
<Box component="code" sx={{ bgcolor: "#222", px: 0.75, py: 0.25, borderRadius: 1, fontSize: "0.85rem" }}>
|
|
||||||
public_repo
|
|
||||||
</Box>
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: "#ccc" }}>
|
|
||||||
<Box component="span" sx={{ fontWeight: 600 }}>Expiration:</Box>{' '}
|
|
||||||
<Box component="code" sx={{ bgcolor: "#222", px: 0.75, py: 0.25, borderRadius: 1, fontSize: "0.85rem" }}>
|
|
||||||
No Expiration
|
|
||||||
</Box>
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ px: 2, py: 2, display: "flex", flexDirection: "column", gap: 1.5 }}>
|
|
||||||
<TextField
|
|
||||||
label="Personal Access Token"
|
|
||||||
value={inputValue}
|
|
||||||
onChange={(event) => setInputValue(event.target.value)}
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
sx={fieldSx}
|
|
||||||
disabled={saving || loading}
|
|
||||||
type={showToken ? "text" : "password"}
|
|
||||||
InputProps={{
|
|
||||||
endAdornment: (
|
|
||||||
<InputAdornment
|
|
||||||
position="end"
|
|
||||||
sx={{ mr: -1, display: "flex", alignItems: "center", gap: 1 }}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
size="small"
|
|
||||||
onClick={toggleReveal}
|
|
||||||
disabled={loading || saving}
|
|
||||||
startIcon={showToken ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
|
||||||
sx={{
|
|
||||||
bgcolor: "#3a3a3a",
|
|
||||||
color: "#f5f7fa",
|
|
||||||
minWidth: 96,
|
|
||||||
mr: 0.5,
|
|
||||||
"&:hover": { bgcolor: "#4a4a4a" }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showToken ? "Hide" : "Reveal"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
size="small"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving || loading}
|
|
||||||
startIcon={!saving ? <SaveIcon /> : null}
|
|
||||||
sx={{
|
|
||||||
bgcolor: "#58a6ff",
|
|
||||||
color: "#0b0f19",
|
|
||||||
minWidth: 88,
|
|
||||||
mr: 1,
|
|
||||||
"&:hover": { bgcolor: "#7db7ff" }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{saving ? <CircularProgress size={16} sx={{ color: "#0b0f19" }} /> : "Save"}
|
|
||||||
</Button>
|
|
||||||
</InputAdornment>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: 2
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
startIcon={<RefreshIcon />}
|
|
||||||
sx={{ borderColor: "#58a6ff", color: "#58a6ff" }}
|
|
||||||
onClick={hydrate}
|
|
||||||
disabled={loading || saving}
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
{(verificationMessage.text || (!dirty && verification.rateLimit)) && (
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
color: verificationMessage.color || "#7db7ff",
|
|
||||||
textAlign: "right"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{verificationMessage.text && `${verificationMessage.text} `}
|
|
||||||
{!dirty &&
|
|
||||||
verification.rateLimit &&
|
|
||||||
`- Hourly Request Rate Limit: ${verification.rateLimit.toLocaleString()}`}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{loading && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 1,
|
|
||||||
color: "#7db7ff",
|
|
||||||
px: 2,
|
|
||||||
py: 1.5,
|
|
||||||
borderBottom: "1px solid #2a2a2a"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
|
|
||||||
<Typography variant="body2">Loading token…</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{fetchError && (
|
|
||||||
<Box sx={{ px: 2, py: 1.5, color: "#ff8080", borderBottom: "1px solid #2a2a2a" }}>
|
|
||||||
<Typography variant="body2">{fetchError}</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,680 +0,0 @@
|
|||||||
import React, { useEffect, useMemo, useState, useCallback } from "react";
|
|
||||||
import {
|
|
||||||
Paper,
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
TableSortLabel,
|
|
||||||
IconButton,
|
|
||||||
Menu,
|
|
||||||
MenuItem,
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogContentText,
|
|
||||||
DialogActions,
|
|
||||||
TextField,
|
|
||||||
Select,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
Checkbox,
|
|
||||||
Popover
|
|
||||||
} from "@mui/material";
|
|
||||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
|
||||||
import FilterListIcon from "@mui/icons-material/FilterList";
|
|
||||||
import { ConfirmDeleteDialog } from "../Dialogs.jsx";
|
|
||||||
|
|
||||||
/* ---------- Formatting helpers to keep this page in lockstep with Device_List ---------- */
|
|
||||||
const tablePaperSx = { m: 2, p: 0, bgcolor: "#1e1e1e" };
|
|
||||||
const tableSx = {
|
|
||||||
minWidth: 820,
|
|
||||||
"& th, & td": {
|
|
||||||
color: "#ddd",
|
|
||||||
borderColor: "#2a2a2a",
|
|
||||||
fontSize: 13,
|
|
||||||
py: 0.75
|
|
||||||
},
|
|
||||||
"& th .MuiTableSortLabel-root": { color: "#ddd" },
|
|
||||||
"& th .MuiTableSortLabel-root.Mui-active": { color: "#ddd" }
|
|
||||||
};
|
|
||||||
const menuPaperSx = { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" };
|
|
||||||
const filterFieldSx = {
|
|
||||||
input: { color: "#fff" },
|
|
||||||
minWidth: 220,
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
"& fieldset": { borderColor: "#555" },
|
|
||||||
"&:hover fieldset": { borderColor: "#888" }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
/* -------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
function formatTs(tsSec) {
|
|
||||||
if (!tsSec) return "-";
|
|
||||||
const d = new Date((tsSec || 0) * 1000);
|
|
||||||
const date = d.toLocaleDateString("en-US", { month: "2-digit", day: "2-digit", year: "numeric" });
|
|
||||||
const time = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
|
|
||||||
return `${date} @ ${time}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sha512(text) {
|
|
||||||
const enc = new TextEncoder();
|
|
||||||
const data = enc.encode(text || "");
|
|
||||||
const buf = await crypto.subtle.digest("SHA-512", data);
|
|
||||||
const arr = Array.from(new Uint8Array(buf));
|
|
||||||
return arr.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UserManagement({ isAdmin = false }) {
|
|
||||||
const [rows, setRows] = useState([]); // {username, display_name, role, last_login}
|
|
||||||
const [orderBy, setOrderBy] = useState("username");
|
|
||||||
const [order, setOrder] = useState("asc");
|
|
||||||
const [menuAnchor, setMenuAnchor] = useState(null);
|
|
||||||
const [menuUser, setMenuUser] = useState(null);
|
|
||||||
const [resetOpen, setResetOpen] = useState(false);
|
|
||||||
const [resetTarget, setResetTarget] = useState(null);
|
|
||||||
const [newPassword, setNewPassword] = useState("");
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
const [createForm, setCreateForm] = useState({ username: "", display_name: "", password: "", role: "User" });
|
|
||||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
|
||||||
const [confirmChangeRoleOpen, setConfirmChangeRoleOpen] = useState(false);
|
|
||||||
const [changeRoleTarget, setChangeRoleTarget] = useState(null);
|
|
||||||
const [changeRoleNext, setChangeRoleNext] = useState(null);
|
|
||||||
const [warnOpen, setWarnOpen] = useState(false);
|
|
||||||
const [warnMessage, setWarnMessage] = useState("");
|
|
||||||
const [me, setMe] = useState(null);
|
|
||||||
const [mfaBusyUser, setMfaBusyUser] = useState(null);
|
|
||||||
const [resetMfaOpen, setResetMfaOpen] = useState(false);
|
|
||||||
const [resetMfaTarget, setResetMfaTarget] = useState(null);
|
|
||||||
|
|
||||||
// Columns and filters
|
|
||||||
const columns = useMemo(() => ([
|
|
||||||
{ id: "display_name", label: "Display Name" },
|
|
||||||
{ id: "username", label: "User Name" },
|
|
||||||
{ id: "last_login", label: "Last Login" },
|
|
||||||
{ id: "role", label: "User Role" },
|
|
||||||
{ id: "mfa_enabled", label: "MFA" },
|
|
||||||
{ id: "actions", label: "" }
|
|
||||||
]), []);
|
|
||||||
const [filters, setFilters] = useState({}); // id -> string
|
|
||||||
const [filterAnchor, setFilterAnchor] = useState(null); // { id, anchorEl }
|
|
||||||
const openFilter = (id) => (e) => setFilterAnchor({ id, anchorEl: e.currentTarget });
|
|
||||||
const closeFilter = () => setFilterAnchor(null);
|
|
||||||
const onFilterChange = (id) => (e) => setFilters((prev) => ({ ...prev, [id]: e.target.value }));
|
|
||||||
|
|
||||||
const fetchUsers = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/users", { credentials: "include" });
|
|
||||||
const data = await res.json();
|
|
||||||
if (Array.isArray(data?.users)) {
|
|
||||||
setRows(
|
|
||||||
data.users.map((u) => ({
|
|
||||||
...u,
|
|
||||||
mfa_enabled: u && typeof u.mfa_enabled !== "undefined" ? (u.mfa_enabled ? 1 : 0) : 0
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setRows([]);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setRows([]);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isAdmin) return;
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch("/api/auth/me", { credentials: "include" });
|
|
||||||
if (resp.ok) {
|
|
||||||
const who = await resp.json();
|
|
||||||
setMe(who);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
})();
|
|
||||||
fetchUsers();
|
|
||||||
}, [fetchUsers, isAdmin]);
|
|
||||||
|
|
||||||
const handleSort = (col) => {
|
|
||||||
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
|
|
||||||
else { setOrderBy(col); setOrder("asc"); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredSorted = useMemo(() => {
|
|
||||||
const applyFilters = (r) => {
|
|
||||||
for (const [key, val] of Object.entries(filters || {})) {
|
|
||||||
if (!val) continue;
|
|
||||||
const needle = String(val).toLowerCase();
|
|
||||||
let hay = "";
|
|
||||||
if (key === "last_login") hay = String(formatTs(r.last_login));
|
|
||||||
else hay = String(r[key] ?? "");
|
|
||||||
if (!hay.toLowerCase().includes(needle)) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const dir = order === "asc" ? 1 : -1;
|
|
||||||
const arr = rows.filter(applyFilters);
|
|
||||||
arr.sort((a, b) => {
|
|
||||||
if (orderBy === "last_login") return ((a.last_login || 0) - (b.last_login || 0)) * dir;
|
|
||||||
if (orderBy === "mfa_enabled") return ((a.mfa_enabled ? 1 : 0) - (b.mfa_enabled ? 1 : 0)) * dir;
|
|
||||||
return String(a[orderBy] ?? "").toLowerCase()
|
|
||||||
.localeCompare(String(b[orderBy] ?? "").toLowerCase()) * dir;
|
|
||||||
});
|
|
||||||
return arr;
|
|
||||||
}, [rows, filters, orderBy, order]);
|
|
||||||
|
|
||||||
const openMenu = (evt, user) => {
|
|
||||||
setMenuAnchor({ mouseX: evt.clientX, mouseY: evt.clientY, anchorEl: evt.currentTarget });
|
|
||||||
setMenuUser(user);
|
|
||||||
};
|
|
||||||
const closeMenu = () => { setMenuAnchor(null); setMenuUser(null); };
|
|
||||||
|
|
||||||
const confirmDelete = (user) => {
|
|
||||||
if (!user) return;
|
|
||||||
if (me && user.username && String(me.username).toLowerCase() === String(user.username).toLowerCase()) {
|
|
||||||
setWarnMessage("You cannot delete the user you are currently logged in as.");
|
|
||||||
setWarnOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDeleteTarget(user);
|
|
||||||
setConfirmDeleteOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const doDelete = async () => {
|
|
||||||
const user = deleteTarget;
|
|
||||||
setConfirmDeleteOpen(false);
|
|
||||||
if (!user) return;
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/users/${encodeURIComponent(user.username)}`, { method: "DELETE", credentials: "include" });
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!resp.ok) {
|
|
||||||
setWarnMessage(data?.error || "Failed to delete user");
|
|
||||||
setWarnOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await fetchUsers();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
setWarnMessage("Failed to delete user");
|
|
||||||
setWarnOpen(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openChangeRole = (user) => {
|
|
||||||
if (!user) return;
|
|
||||||
if (me && user.username && String(me.username).toLowerCase() === String(user.username).toLowerCase()) {
|
|
||||||
setWarnMessage("You cannot change your own role.");
|
|
||||||
setWarnOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextRole = (String(user.role || "User").toLowerCase() === "admin") ? "User" : "Admin";
|
|
||||||
setChangeRoleTarget(user);
|
|
||||||
setChangeRoleNext(nextRole);
|
|
||||||
setConfirmChangeRoleOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const doChangeRole = async () => {
|
|
||||||
const user = changeRoleTarget;
|
|
||||||
const nextRole = changeRoleNext;
|
|
||||||
setConfirmChangeRoleOpen(false);
|
|
||||||
if (!user || !nextRole) return;
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/users/${encodeURIComponent(user.username)}/role`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({ role: nextRole })
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!resp.ok) {
|
|
||||||
setWarnMessage(data?.error || "Failed to change role");
|
|
||||||
setWarnOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await fetchUsers();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
setWarnMessage("Failed to change role");
|
|
||||||
setWarnOpen(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openResetMfa = (user) => {
|
|
||||||
if (!user) return;
|
|
||||||
setResetMfaTarget(user);
|
|
||||||
setResetMfaOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const doResetMfa = async () => {
|
|
||||||
const user = resetMfaTarget;
|
|
||||||
setResetMfaOpen(false);
|
|
||||||
setResetMfaTarget(null);
|
|
||||||
if (!user) return;
|
|
||||||
const username = user.username;
|
|
||||||
const keepEnabled = Boolean(user.mfa_enabled);
|
|
||||||
setMfaBusyUser(username);
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/users/${encodeURIComponent(username)}/mfa`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({ enabled: keepEnabled, reset_secret: true })
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!resp.ok) {
|
|
||||||
setWarnMessage(data?.error || "Failed to reset MFA for this user.");
|
|
||||||
setWarnOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await fetchUsers();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
setWarnMessage("Failed to reset MFA for this user.");
|
|
||||||
setWarnOpen(true);
|
|
||||||
} finally {
|
|
||||||
setMfaBusyUser(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleMfa = async (user, enabled) => {
|
|
||||||
if (!user) return;
|
|
||||||
const previous = Boolean(user.mfa_enabled);
|
|
||||||
const nextFlag = enabled ? 1 : 0;
|
|
||||||
setRows((prev) =>
|
|
||||||
prev.map((r) =>
|
|
||||||
String(r.username).toLowerCase() === String(user.username).toLowerCase()
|
|
||||||
? { ...r, mfa_enabled: nextFlag }
|
|
||||||
: r
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setMfaBusyUser(user.username);
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/users/${encodeURIComponent(user.username)}/mfa`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({ enabled })
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!resp.ok) {
|
|
||||||
setRows((prev) =>
|
|
||||||
prev.map((r) =>
|
|
||||||
String(r.username).toLowerCase() === String(user.username).toLowerCase()
|
|
||||||
? { ...r, mfa_enabled: previous ? 1 : 0 }
|
|
||||||
: r
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setWarnMessage(data?.error || "Failed to update MFA settings.");
|
|
||||||
setWarnOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await fetchUsers();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
setRows((prev) =>
|
|
||||||
prev.map((r) =>
|
|
||||||
String(r.username).toLowerCase() === String(user.username).toLowerCase()
|
|
||||||
? { ...r, mfa_enabled: previous ? 1 : 0 }
|
|
||||||
: r
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setWarnMessage("Failed to update MFA settings.");
|
|
||||||
setWarnOpen(true);
|
|
||||||
} finally {
|
|
||||||
setMfaBusyUser(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const doResetPassword = async () => {
|
|
||||||
const user = resetTarget;
|
|
||||||
if (!user) return;
|
|
||||||
const pw = newPassword || "";
|
|
||||||
if (!pw.trim()) return;
|
|
||||||
try {
|
|
||||||
const hash = await sha512(pw);
|
|
||||||
const resp = await fetch(`/api/users/${encodeURIComponent(user.username)}/reset_password`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({ password_sha512: hash })
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!resp.ok) {
|
|
||||||
alert(data?.error || "Failed to reset password");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setResetOpen(false);
|
|
||||||
setResetTarget(null);
|
|
||||||
setNewPassword("");
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
alert("Failed to reset password");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openReset = (user) => {
|
|
||||||
if (!user) return;
|
|
||||||
setResetTarget(user);
|
|
||||||
setResetOpen(true);
|
|
||||||
setNewPassword("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCreate = () => { setCreateOpen(true); setCreateForm({ username: "", display_name: "", password: "", role: "User" }); };
|
|
||||||
const doCreate = async () => {
|
|
||||||
const u = (createForm.username || "").trim();
|
|
||||||
const dn = (createForm.display_name || u).trim();
|
|
||||||
const pw = (createForm.password || "").trim();
|
|
||||||
const role = (createForm.role || "User");
|
|
||||||
if (!u || !pw) return;
|
|
||||||
try {
|
|
||||||
const hash = await sha512(pw);
|
|
||||||
const resp = await fetch("/api/users", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({ username: u, display_name: dn, password_sha512: hash, role })
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!resp.ok) {
|
|
||||||
alert(data?.error || "Failed to create user");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCreateOpen(false);
|
|
||||||
await fetchUsers();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
alert("Failed to create user");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isAdmin) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Paper sx={tablePaperSx} elevation={2}>
|
|
||||||
<Box sx={{ p: 2, pb: 1, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
|
|
||||||
User Management
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
|
||||||
Manage authorized users of the Borealis Automation Platform.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
onClick={openCreate}
|
|
||||||
sx={{ color: "#58a6ff", borderColor: "#58a6ff", textTransform: "none" }}
|
|
||||||
>
|
|
||||||
Create User
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Table size="small" sx={tableSx}>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
{/* Leading checkbox gutter to match Devices table rhythm */}
|
|
||||||
<TableCell padding="checkbox" />
|
|
||||||
{columns.map((col) => (
|
|
||||||
<TableCell
|
|
||||||
key={col.id}
|
|
||||||
sortDirection={["actions"].includes(col.id) ? false : (orderBy === col.id ? order : false)}
|
|
||||||
>
|
|
||||||
{col.id !== "actions" ? (
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
|
||||||
<TableSortLabel
|
|
||||||
active={orderBy === col.id}
|
|
||||||
direction={orderBy === col.id ? order : "asc"}
|
|
||||||
onClick={() => handleSort(col.id)}
|
|
||||||
>
|
|
||||||
{col.label}
|
|
||||||
</TableSortLabel>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={openFilter(col.id)}
|
|
||||||
sx={{ color: filters[col.id] ? "#58a6ff" : "#888" }}
|
|
||||||
>
|
|
||||||
<FilterListIcon fontSize="inherit" />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
|
|
||||||
<TableBody>
|
|
||||||
{filteredSorted.map((u) => (
|
|
||||||
<TableRow key={u.username} hover>
|
|
||||||
{/* Body gutter to stay aligned with header */}
|
|
||||||
<TableCell padding="checkbox" />
|
|
||||||
<TableCell>{u.display_name || u.username}</TableCell>
|
|
||||||
<TableCell>{u.username}</TableCell>
|
|
||||||
<TableCell>{formatTs(u.last_login)}</TableCell>
|
|
||||||
<TableCell>{u.role || "User"}</TableCell>
|
|
||||||
<TableCell align="center">
|
|
||||||
<Checkbox
|
|
||||||
size="small"
|
|
||||||
checked={Boolean(u.mfa_enabled)}
|
|
||||||
disabled={Boolean(mfaBusyUser && String(mfaBusyUser).toLowerCase() === String(u.username).toLowerCase())}
|
|
||||||
onChange={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
toggleMfa(u, event.target.checked);
|
|
||||||
}}
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
sx={{
|
|
||||||
color: "#888",
|
|
||||||
"&.Mui-checked": { color: "#58a6ff" }
|
|
||||||
}}
|
|
||||||
inputProps={{ "aria-label": `Toggle MFA for ${u.username}` }}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="right">
|
|
||||||
<IconButton size="small" onClick={(e) => openMenu(e, u)} sx={{ color: "#ccc" }}>
|
|
||||||
<MoreVertIcon fontSize="inherit" />
|
|
||||||
</IconButton>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
{filteredSorted.length === 0 && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={columns.length + 1} sx={{ color: "#888" }}>
|
|
||||||
No users found.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
|
|
||||||
{/* Filter popover (styled to match Device_List) */}
|
|
||||||
<Popover
|
|
||||||
open={Boolean(filterAnchor)}
|
|
||||||
anchorEl={filterAnchor?.anchorEl || null}
|
|
||||||
onClose={closeFilter}
|
|
||||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
|
||||||
PaperProps={{ sx: { bgcolor: "#1e1e1e", p: 1 } }}
|
|
||||||
>
|
|
||||||
{filterAnchor && (
|
|
||||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
|
||||||
<TextField
|
|
||||||
autoFocus
|
|
||||||
size="small"
|
|
||||||
placeholder={`Filter ${columns.find((c) => c.id === filterAnchor.id)?.label || ""}`}
|
|
||||||
value={filters[filterAnchor.id] || ""}
|
|
||||||
onChange={onFilterChange(filterAnchor.id)}
|
|
||||||
onKeyDown={(e) => { if (e.key === "Escape") closeFilter(); }}
|
|
||||||
sx={filterFieldSx}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
onClick={() => {
|
|
||||||
setFilters((prev) => ({ ...prev, [filterAnchor.id]: "" }));
|
|
||||||
closeFilter();
|
|
||||||
}}
|
|
||||||
sx={{ textTransform: "none", borderColor: "#555", color: "#bbb" }}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
<Menu
|
|
||||||
anchorEl={menuAnchor?.anchorEl}
|
|
||||||
open={Boolean(menuAnchor)}
|
|
||||||
onClose={closeMenu}
|
|
||||||
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
|
||||||
transformOrigin={{ vertical: "top", horizontal: "right" }}
|
|
||||||
PaperProps={{ sx: menuPaperSx }}
|
|
||||||
>
|
|
||||||
<MenuItem
|
|
||||||
disabled={me && menuUser && String(me.username).toLowerCase() === String(menuUser.username).toLowerCase()}
|
|
||||||
onClick={() => { const u = menuUser; closeMenu(); confirmDelete(u); }}
|
|
||||||
>
|
|
||||||
Delete User
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem onClick={() => { const u = menuUser; closeMenu(); openReset(u); }}>Reset Password</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
disabled={me && menuUser && String(me.username).toLowerCase() === String(menuUser.username).toLowerCase()}
|
|
||||||
onClick={() => { const u = menuUser; closeMenu(); openChangeRole(u); }}
|
|
||||||
>
|
|
||||||
Change Role
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem onClick={() => { const u = menuUser; closeMenu(); openResetMfa(u); }}>
|
|
||||||
Reset MFA
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
|
|
||||||
<Dialog open={resetOpen} onClose={() => setResetOpen(false)} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
|
||||||
<DialogTitle>Reset Password</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogContentText sx={{ color: "#ccc" }}>
|
|
||||||
Enter a new password for {resetTarget?.username}.
|
|
||||||
</DialogContentText>
|
|
||||||
<TextField
|
|
||||||
autoFocus
|
|
||||||
margin="dense"
|
|
||||||
fullWidth
|
|
||||||
label="New Password"
|
|
||||||
type="password"
|
|
||||||
variant="outlined"
|
|
||||||
value={newPassword}
|
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "#2a2a2a",
|
|
||||||
color: "#ccc",
|
|
||||||
"& fieldset": { borderColor: "#444" },
|
|
||||||
"&:hover fieldset": { borderColor: "#666" }
|
|
||||||
},
|
|
||||||
label: { color: "#aaa" },
|
|
||||||
mt: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => { setResetOpen(false); setResetTarget(null); }} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
|
||||||
<Button onClick={doResetPassword} sx={{ color: "#58a6ff" }}>OK</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
|
||||||
<DialogTitle>Create User</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<TextField
|
|
||||||
autoFocus
|
|
||||||
margin="dense"
|
|
||||||
fullWidth
|
|
||||||
label="Username"
|
|
||||||
variant="outlined"
|
|
||||||
value={createForm.username}
|
|
||||||
onChange={(e) => setCreateForm((p) => ({ ...p, username: e.target.value }))}
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": { backgroundColor: "#2a2a2a", color: "#ccc", "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } },
|
|
||||||
label: { color: "#aaa" }, mt: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
margin="dense"
|
|
||||||
fullWidth
|
|
||||||
label="Display Name (optional)"
|
|
||||||
variant="outlined"
|
|
||||||
value={createForm.display_name}
|
|
||||||
onChange={(e) => setCreateForm((p) => ({ ...p, display_name: e.target.value }))}
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": { backgroundColor: "#2a2a2a", color: "#ccc", "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } },
|
|
||||||
label: { color: "#aaa" }, mt: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
margin="dense"
|
|
||||||
fullWidth
|
|
||||||
label="Password"
|
|
||||||
type="password"
|
|
||||||
variant="outlined"
|
|
||||||
value={createForm.password}
|
|
||||||
onChange={(e) => setCreateForm((p) => ({ ...p, password: e.target.value }))}
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": { backgroundColor: "#2a2a2a", color: "#ccc", "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } },
|
|
||||||
label: { color: "#aaa" }, mt: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormControl fullWidth sx={{ mt: 2 }}>
|
|
||||||
<InputLabel sx={{ color: "#aaa" }}>Role</InputLabel>
|
|
||||||
<Select
|
|
||||||
native
|
|
||||||
value={createForm.role}
|
|
||||||
onChange={(e) => setCreateForm((p) => ({ ...p, role: e.target.value }))}
|
|
||||||
sx={{
|
|
||||||
backgroundColor: "#2a2a2a",
|
|
||||||
color: "#ccc",
|
|
||||||
borderColor: "#444"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="User">User</option>
|
|
||||||
<option value="Admin">Admin</option>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setCreateOpen(false)} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
|
||||||
<Button onClick={doCreate} sx={{ color: "#58a6ff" }}>Create</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<ConfirmDeleteDialog
|
|
||||||
open={confirmDeleteOpen}
|
|
||||||
message={`Are you sure you want to delete user '${deleteTarget?.username || ""}'?`}
|
|
||||||
onCancel={() => setConfirmDeleteOpen(false)}
|
|
||||||
onConfirm={doDelete}
|
|
||||||
/>
|
|
||||||
<ConfirmDeleteDialog
|
|
||||||
open={confirmChangeRoleOpen}
|
|
||||||
message={changeRoleTarget ? `Change role for '${changeRoleTarget.username}' to ${changeRoleNext}?` : ""}
|
|
||||||
onCancel={() => setConfirmChangeRoleOpen(false)}
|
|
||||||
onConfirm={doChangeRole}
|
|
||||||
/>
|
|
||||||
<ConfirmDeleteDialog
|
|
||||||
open={resetMfaOpen}
|
|
||||||
message={resetMfaTarget ? `Reset MFA enrollment for '${resetMfaTarget.username}'? This clears their existing authenticator.` : ""}
|
|
||||||
onCancel={() => { setResetMfaOpen(false); setResetMfaTarget(null); }}
|
|
||||||
onConfirm={doResetMfa}
|
|
||||||
/>
|
|
||||||
<ConfirmDeleteDialog
|
|
||||||
open={warnOpen}
|
|
||||||
message={warnMessage}
|
|
||||||
onCancel={() => setWarnOpen(false)}
|
|
||||||
onConfirm={() => setWarnOpen(false)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { Paper, Box, Typography, Button } from "@mui/material";
|
|
||||||
import { GitHub as GitHubIcon, InfoOutlined as InfoIcon } from "@mui/icons-material";
|
|
||||||
import { CreditsDialog } from "../Dialogs.jsx";
|
|
||||||
|
|
||||||
export default function ServerInfo({ isAdmin = false }) {
|
|
||||||
const [serverTime, setServerTime] = useState(null);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [aboutOpen, setAboutOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isAdmin) return;
|
|
||||||
let isMounted = true;
|
|
||||||
const fetchTime = async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/server/time');
|
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (isMounted) {
|
|
||||||
setServerTime(data?.display || data?.iso || null);
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (isMounted) setError(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchTime();
|
|
||||||
const id = setInterval(fetchTime, 60000); // update once per minute
|
|
||||||
return () => { isMounted = false; clearInterval(id); };
|
|
||||||
}, [isAdmin]);
|
|
||||||
|
|
||||||
if (!isAdmin) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
|
|
||||||
<Box sx={{ p: 2 }}>
|
|
||||||
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 1 }}>Server Info</Typography>
|
|
||||||
<Typography sx={{ color: '#aaa', mb: 1 }}>Basic server information will appear here for informative and debug purposes.</Typography>
|
|
||||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'baseline' }}>
|
|
||||||
<Typography sx={{ color: '#ccc', fontWeight: 600, minWidth: 120 }}>Server Time</Typography>
|
|
||||||
<Typography sx={{ color: error ? '#ff6b6b' : '#ddd', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' }}>
|
|
||||||
{error ? `Error: ${error}` : (serverTime || 'Loading...')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ mt: 3 }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ color: "#58a6ff", mb: 1 }}>Project Links</Typography>
|
|
||||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
startIcon={<GitHubIcon />}
|
|
||||||
onClick={() => window.open("https://github.com/bunny-lab-io/Borealis", "_blank")}
|
|
||||||
sx={{ borderColor: '#3a3a3a', color: '#7db7ff' }}
|
|
||||||
>
|
|
||||||
GitHub Project
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="inherit"
|
|
||||||
startIcon={<InfoIcon />}
|
|
||||||
onClick={() => setAboutOpen(true)}
|
|
||||||
sx={{ borderColor: '#3a3a3a', color: '#ddd' }}
|
|
||||||
>
|
|
||||||
About Borealis
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<CreditsDialog open={aboutOpen} onClose={() => setAboutOpen(false)} />
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,777 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
|
||||||
import { Paper, Box, Typography, Menu, MenuItem, Button } from "@mui/material";
|
|
||||||
import { Folder as FolderIcon, Description as DescriptionIcon, Polyline as WorkflowsIcon, Code as ScriptIcon, MenuBook as BookIcon } from "@mui/icons-material";
|
|
||||||
import {
|
|
||||||
SimpleTreeView,
|
|
||||||
TreeItem,
|
|
||||||
useTreeViewApiRef
|
|
||||||
} from "@mui/x-tree-view";
|
|
||||||
import {
|
|
||||||
RenameWorkflowDialog,
|
|
||||||
RenameFolderDialog,
|
|
||||||
NewWorkflowDialog,
|
|
||||||
ConfirmDeleteDialog
|
|
||||||
} from "../Dialogs";
|
|
||||||
|
|
||||||
// Generic Island wrapper with large icon, stacked title/description, and actions on the right
|
|
||||||
const Island = ({ title, description, icon, actions, children, sx }) => (
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{ p: 1.5, borderRadius: 2, bgcolor: '#1c1c1c', border: '1px solid #2a2a2a', mb: 1.5, ...(sx || {}) }}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
|
||||||
{icon ? (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
color: '#58a6ff',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
minWidth: 48,
|
|
||||||
mr: 1.0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
<Box>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{ color: '#58a6ff', fontWeight: 400, fontSize: '14px', letterSpacing: 0.2 }}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
{description ? (
|
|
||||||
<Typography variant="body2" sx={{ color: '#aaa' }}>
|
|
||||||
{description}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
{actions ? (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
{actions}
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
{children}
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------------- Workflows Island -----------------
|
|
||||||
const sortTree = (node) => {
|
|
||||||
if (!node || !Array.isArray(node.children)) return;
|
|
||||||
node.children.sort((a, b) => {
|
|
||||||
const aFolder = Boolean(a.isFolder);
|
|
||||||
const bFolder = Boolean(b.isFolder);
|
|
||||||
if (aFolder !== bFolder) return aFolder ? -1 : 1;
|
|
||||||
return String(a.label || "").localeCompare(String(b.label || ""), undefined, {
|
|
||||||
sensitivity: "base"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
node.children.forEach(sortTree);
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildWorkflowTree(workflows, folders) {
|
|
||||||
const map = {};
|
|
||||||
const rootNode = { id: "root", label: "Workflows", path: "", isFolder: true, children: [] };
|
|
||||||
map[rootNode.id] = rootNode;
|
|
||||||
(folders || []).forEach((f) => {
|
|
||||||
const parts = (f || "").split("/");
|
|
||||||
let children = rootNode.children;
|
|
||||||
let parentPath = "";
|
|
||||||
parts.forEach((part) => {
|
|
||||||
const path = parentPath ? `${parentPath}/${part}` : part;
|
|
||||||
let node = children.find((n) => n.id === path);
|
|
||||||
if (!node) {
|
|
||||||
node = { id: path, label: part, path, isFolder: true, children: [] };
|
|
||||||
children.push(node);
|
|
||||||
map[path] = node;
|
|
||||||
}
|
|
||||||
children = node.children;
|
|
||||||
parentPath = path;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
(workflows || []).forEach((w) => {
|
|
||||||
const parts = (w.rel_path || "").split("/");
|
|
||||||
let children = rootNode.children;
|
|
||||||
let parentPath = "";
|
|
||||||
parts.forEach((part, idx) => {
|
|
||||||
const path = parentPath ? `${parentPath}/${part}` : part;
|
|
||||||
const isFile = idx === parts.length - 1;
|
|
||||||
let node = children.find((n) => n.id === path);
|
|
||||||
if (!node) {
|
|
||||||
node = {
|
|
||||||
id: path,
|
|
||||||
label: isFile ? ((w.tab_name && w.tab_name.trim()) || w.file_name) : part,
|
|
||||||
path,
|
|
||||||
isFolder: !isFile,
|
|
||||||
fileName: w.file_name,
|
|
||||||
workflow: isFile ? w : null,
|
|
||||||
children: []
|
|
||||||
};
|
|
||||||
children.push(node);
|
|
||||||
map[path] = node;
|
|
||||||
}
|
|
||||||
if (!isFile) {
|
|
||||||
children = node.children;
|
|
||||||
parentPath = path;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
sortTree(rootNode);
|
|
||||||
return { root: [rootNode], map };
|
|
||||||
}
|
|
||||||
|
|
||||||
function WorkflowsIsland({ onOpenWorkflow }) {
|
|
||||||
const [tree, setTree] = useState([]);
|
|
||||||
const [nodeMap, setNodeMap] = useState({});
|
|
||||||
const [contextMenu, setContextMenu] = useState(null);
|
|
||||||
const [selectedNode, setSelectedNode] = useState(null);
|
|
||||||
const [renameValue, setRenameValue] = useState("");
|
|
||||||
const [renameOpen, setRenameOpen] = useState(false);
|
|
||||||
const [renameFolderOpen, setRenameFolderOpen] = useState(false);
|
|
||||||
const [folderDialogMode, setFolderDialogMode] = useState("rename");
|
|
||||||
const [newWorkflowOpen, setNewWorkflowOpen] = useState(false);
|
|
||||||
const [newWorkflowName, setNewWorkflowName] = useState("");
|
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
||||||
const apiRef = useTreeViewApiRef();
|
|
||||||
const [dragNode, setDragNode] = useState(null);
|
|
||||||
|
|
||||||
const handleDrop = async (target) => {
|
|
||||||
if (!dragNode || !target.isFolder) return;
|
|
||||||
if (dragNode.path === target.path || target.path.startsWith(`${dragNode.path}/`)) {
|
|
||||||
setDragNode(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newPath = target.path ? `${target.path}/${dragNode.fileName}` : dragNode.fileName;
|
|
||||||
try {
|
|
||||||
await fetch("/api/assembly/move", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ island: 'workflows', kind: 'file', path: dragNode.path, new_path: newPath })
|
|
||||||
});
|
|
||||||
loadTree();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to move workflow:", err);
|
|
||||||
}
|
|
||||||
setDragNode(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadTree = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/assembly/list?island=workflows`);
|
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
const { root, map } = buildWorkflowTree(data.items || [], data.folders || []);
|
|
||||||
setTree(root);
|
|
||||||
setNodeMap(map);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load workflows:", err);
|
|
||||||
setTree([]);
|
|
||||||
setNodeMap({});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { loadTree(); }, [loadTree]);
|
|
||||||
|
|
||||||
const handleContextMenu = (e, node) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedNode(node);
|
|
||||||
setContextMenu(
|
|
||||||
contextMenu === null ? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 } : null
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRename = () => {
|
|
||||||
setContextMenu(null);
|
|
||||||
if (!selectedNode) return;
|
|
||||||
setRenameValue(selectedNode.label);
|
|
||||||
if (selectedNode.isFolder) {
|
|
||||||
setFolderDialogMode("rename");
|
|
||||||
setRenameFolderOpen(true);
|
|
||||||
} else setRenameOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = () => {
|
|
||||||
setContextMenu(null);
|
|
||||||
if (selectedNode && !selectedNode.isFolder && onOpenWorkflow) {
|
|
||||||
onOpenWorkflow(selectedNode.workflow);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
setContextMenu(null);
|
|
||||||
if (!selectedNode) return;
|
|
||||||
setDeleteOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNewFolder = () => {
|
|
||||||
if (!selectedNode) return;
|
|
||||||
setContextMenu(null);
|
|
||||||
setFolderDialogMode("create");
|
|
||||||
setRenameValue("");
|
|
||||||
setRenameFolderOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNewWorkflow = () => {
|
|
||||||
if (!selectedNode) return;
|
|
||||||
setContextMenu(null);
|
|
||||||
setNewWorkflowName("");
|
|
||||||
setNewWorkflowOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveRenameWorkflow = async () => {
|
|
||||||
if (!selectedNode) return;
|
|
||||||
try {
|
|
||||||
await fetch("/api/assembly/rename", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ island: 'workflows', kind: 'file', path: selectedNode.path, new_name: renameValue })
|
|
||||||
});
|
|
||||||
loadTree();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to rename workflow:", err);
|
|
||||||
}
|
|
||||||
setRenameOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveRenameFolder = async () => {
|
|
||||||
try {
|
|
||||||
if (folderDialogMode === "rename" && selectedNode) {
|
|
||||||
await fetch("/api/assembly/rename", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ island: 'workflows', kind: 'folder', path: selectedNode.path, new_name: renameValue })
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const basePath = selectedNode ? selectedNode.path : "";
|
|
||||||
const newPath = basePath ? `${basePath}/${renameValue}` : renameValue;
|
|
||||||
await fetch("/api/assembly/create", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ island: 'workflows', kind: 'folder', path: newPath })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
loadTree();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Folder operation failed:", err);
|
|
||||||
}
|
|
||||||
setRenameFolderOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNodeSelect = (_event, itemId) => {
|
|
||||||
const node = nodeMap[itemId];
|
|
||||||
if (node && !node.isFolder && onOpenWorkflow) {
|
|
||||||
onOpenWorkflow(node.workflow);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
|
||||||
if (!selectedNode) return;
|
|
||||||
try {
|
|
||||||
if (selectedNode.isFolder) {
|
|
||||||
await fetch("/api/assembly/delete", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ island: 'workflows', kind: 'folder', path: selectedNode.path })
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await fetch("/api/assembly/delete", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ island: 'workflows', kind: 'file', path: selectedNode.path })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
loadTree();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to delete:", err);
|
|
||||||
}
|
|
||||||
setDeleteOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderItems = (nodes) =>
|
|
||||||
(nodes || []).map((n) => (
|
|
||||||
<TreeItem
|
|
||||||
key={n.id}
|
|
||||||
itemId={n.id}
|
|
||||||
label={
|
|
||||||
<Box
|
|
||||||
sx={{ display: "flex", alignItems: "center" }}
|
|
||||||
draggable={!n.isFolder}
|
|
||||||
onDragStart={() => !n.isFolder && setDragNode(n)}
|
|
||||||
onDragOver={(e) => { if (dragNode && n.isFolder) e.preventDefault(); }}
|
|
||||||
onDrop={(e) => { e.preventDefault(); handleDrop(n); }}
|
|
||||||
onContextMenu={(e) => handleContextMenu(e, n)}
|
|
||||||
>
|
|
||||||
{n.isFolder ? (
|
|
||||||
<FolderIcon sx={{ mr: 1, color: "#0475c2" }} />
|
|
||||||
) : (
|
|
||||||
<DescriptionIcon sx={{ mr: 1, color: "#0475c2" }} />
|
|
||||||
)}
|
|
||||||
<Typography sx={{ flexGrow: 1, color: "#e6edf3" }}>{n.label}</Typography>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{n.children && n.children.length > 0 ? renderItems(n.children) : null}
|
|
||||||
</TreeItem>
|
|
||||||
));
|
|
||||||
|
|
||||||
const rootChildIds = tree[0]?.children?.map((c) => c.id) || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Island
|
|
||||||
title="Workflows"
|
|
||||||
description="Node-Based Automation Pipelines"
|
|
||||||
icon={<WorkflowsIcon sx={{ fontSize: 40 }} />}
|
|
||||||
actions={
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => { setSelectedNode({ id: 'root', path: '', isFolder: true }); setNewWorkflowName(''); setNewWorkflowOpen(true); }}
|
|
||||||
sx={{
|
|
||||||
color: '#58a6ff',
|
|
||||||
borderColor: '#2f81f7',
|
|
||||||
textTransform: 'none',
|
|
||||||
'&:hover': { borderColor: '#58a6ff' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
New Workflow
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{ p: 1 }}
|
|
||||||
onDragOver={(e) => { if (dragNode) e.preventDefault(); }}
|
|
||||||
onDrop={(e) => { e.preventDefault(); handleDrop({ path: "", isFolder: true }); }}
|
|
||||||
>
|
|
||||||
<SimpleTreeView
|
|
||||||
key={rootChildIds.join(",")}
|
|
||||||
sx={{ color: "#e6edf3" }}
|
|
||||||
onNodeSelect={handleNodeSelect}
|
|
||||||
apiRef={apiRef}
|
|
||||||
defaultExpandedItems={["root", ...rootChildIds]}
|
|
||||||
>
|
|
||||||
{renderItems(tree)}
|
|
||||||
</SimpleTreeView>
|
|
||||||
</Box>
|
|
||||||
<Menu
|
|
||||||
open={contextMenu !== null}
|
|
||||||
onClose={() => setContextMenu(null)}
|
|
||||||
anchorReference="anchorPosition"
|
|
||||||
anchorPosition={contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined}
|
|
||||||
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
|
|
||||||
>
|
|
||||||
{selectedNode?.isFolder && (
|
|
||||||
<>
|
|
||||||
<MenuItem onClick={handleNewWorkflow}>New Workflow</MenuItem>
|
|
||||||
<MenuItem onClick={handleNewFolder}>New Subfolder</MenuItem>
|
|
||||||
{selectedNode.id !== "root" && (<MenuItem onClick={handleRename}>Rename</MenuItem>)}
|
|
||||||
{selectedNode.id !== "root" && (<MenuItem onClick={handleDelete}>Delete</MenuItem>)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!selectedNode?.isFolder && (
|
|
||||||
<>
|
|
||||||
<MenuItem onClick={handleEdit}>Edit</MenuItem>
|
|
||||||
<MenuItem onClick={handleRename}>Rename</MenuItem>
|
|
||||||
<MenuItem onClick={handleDelete}>Delete</MenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Menu>
|
|
||||||
<RenameWorkflowDialog open={renameOpen} value={renameValue} onChange={setRenameValue} onCancel={() => setRenameOpen(false)} onSave={saveRenameWorkflow} />
|
|
||||||
<RenameFolderDialog open={renameFolderOpen} value={renameValue} onChange={setRenameValue} onCancel={() => setRenameFolderOpen(false)} onSave={saveRenameFolder} title={folderDialogMode === "rename" ? "Rename Folder" : "New Folder"} confirmText={folderDialogMode === "rename" ? "Save" : "Create"} />
|
|
||||||
<NewWorkflowDialog open={newWorkflowOpen} value={newWorkflowName} onChange={setNewWorkflowName} onCancel={() => setNewWorkflowOpen(false)} onCreate={() => { setNewWorkflowOpen(false); onOpenWorkflow && onOpenWorkflow(null, selectedNode?.path || "", newWorkflowName); }} />
|
|
||||||
<ConfirmDeleteDialog open={deleteOpen} message="If you delete this, there is no undo button, are you sure you want to proceed?" onCancel={() => setDeleteOpen(false)} onConfirm={confirmDelete} />
|
|
||||||
</Island>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------- Generic Scripts-like Islands (used for Scripts and Ansible) -----------------
|
|
||||||
function buildFileTree(rootLabel, items, folders) {
|
|
||||||
// Some backends (e.g. /api/scripts) return paths relative to
|
|
||||||
// the Assemblies root, which prefixes items with a top-level
|
|
||||||
// folder like "Scripts". Others (e.g. /api/ansible) already
|
|
||||||
// return paths relative to their specific root. Normalize by
|
|
||||||
// stripping a matching top-level segment so the UI shows
|
|
||||||
// "Scripts/<...>" rather than "Scripts/Scripts/<...>".
|
|
||||||
const normalize = (p) => {
|
|
||||||
const candidates = [
|
|
||||||
String(rootLabel || "").trim(),
|
|
||||||
String(rootLabel || "").replace(/\s+/g, "_")
|
|
||||||
].filter(Boolean);
|
|
||||||
const parts = String(p || "").replace(/\\/g, "/").split("/").filter(Boolean);
|
|
||||||
if (parts.length && candidates.includes(parts[0])) parts.shift();
|
|
||||||
return parts;
|
|
||||||
};
|
|
||||||
|
|
||||||
const map = {};
|
|
||||||
const rootNode = { id: "root", label: rootLabel, path: "", isFolder: true, children: [] };
|
|
||||||
map[rootNode.id] = rootNode;
|
|
||||||
|
|
||||||
(folders || []).forEach((f) => {
|
|
||||||
const parts = normalize(f);
|
|
||||||
let children = rootNode.children;
|
|
||||||
let parentPath = "";
|
|
||||||
parts.forEach((part) => {
|
|
||||||
const path = parentPath ? `${parentPath}/${part}` : part;
|
|
||||||
let node = children.find((n) => n.id === path);
|
|
||||||
if (!node) {
|
|
||||||
node = { id: path, label: part, path, isFolder: true, children: [] };
|
|
||||||
children.push(node);
|
|
||||||
map[path] = node;
|
|
||||||
}
|
|
||||||
children = node.children;
|
|
||||||
parentPath = path;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
(items || []).forEach((s) => {
|
|
||||||
const parts = normalize(s?.rel_path);
|
|
||||||
let children = rootNode.children;
|
|
||||||
let parentPath = "";
|
|
||||||
parts.forEach((part, idx) => {
|
|
||||||
const path = parentPath ? `${parentPath}/${part}` : part;
|
|
||||||
const isFile = idx === parts.length - 1;
|
|
||||||
let node = children.find((n) => n.id === path);
|
|
||||||
if (!node) {
|
|
||||||
node = {
|
|
||||||
id: path,
|
|
||||||
label: isFile ? (s.name || s.display_name || s.file_name || part) : part,
|
|
||||||
path,
|
|
||||||
isFolder: !isFile,
|
|
||||||
fileName: s.file_name,
|
|
||||||
meta: isFile ? s : null,
|
|
||||||
children: []
|
|
||||||
};
|
|
||||||
children.push(node);
|
|
||||||
map[path] = node;
|
|
||||||
}
|
|
||||||
if (!isFile) {
|
|
||||||
children = node.children;
|
|
||||||
parentPath = path;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
sortTree(rootNode);
|
|
||||||
return { root: [rootNode], map };
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScriptsLikeIsland({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
rootLabel,
|
|
||||||
baseApi, // e.g. '/api/scripts' or '/api/ansible'
|
|
||||||
newItemLabel = "New Script",
|
|
||||||
onEdit // (rel_path) => void
|
|
||||||
}) {
|
|
||||||
const [tree, setTree] = useState([]);
|
|
||||||
const [nodeMap, setNodeMap] = useState({});
|
|
||||||
const [contextMenu, setContextMenu] = useState(null);
|
|
||||||
const [selectedNode, setSelectedNode] = useState(null);
|
|
||||||
const [renameValue, setRenameValue] = useState("");
|
|
||||||
const [renameOpen, setRenameOpen] = useState(false);
|
|
||||||
const [renameFolderOpen, setRenameFolderOpen] = useState(false);
|
|
||||||
const [folderDialogMode, setFolderDialogMode] = useState("rename");
|
|
||||||
const [newItemOpen, setNewItemOpen] = useState(false);
|
|
||||||
const [newItemName, setNewItemName] = useState("");
|
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
||||||
const apiRef = useTreeViewApiRef();
|
|
||||||
const [dragNode, setDragNode] = useState(null);
|
|
||||||
|
|
||||||
const island = React.useMemo(() => {
|
|
||||||
const b = String(baseApi || '').toLowerCase();
|
|
||||||
return b.endsWith('/api/ansible') ? 'ansible' : 'scripts';
|
|
||||||
}, [baseApi]);
|
|
||||||
|
|
||||||
const loadTree = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/assembly/list?island=${encodeURIComponent(island)}`);
|
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
const { root, map } = buildFileTree(rootLabel, data.items || [], data.folders || []);
|
|
||||||
setTree(root);
|
|
||||||
setNodeMap(map);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to load ${title}:`, err);
|
|
||||||
setTree([]);
|
|
||||||
setNodeMap({});
|
|
||||||
}
|
|
||||||
}, [island, title, rootLabel]);
|
|
||||||
|
|
||||||
useEffect(() => { loadTree(); }, [loadTree]);
|
|
||||||
|
|
||||||
const handleContextMenu = (e, node) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedNode(node);
|
|
||||||
setContextMenu(
|
|
||||||
contextMenu === null ? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 } : null
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = async (target) => {
|
|
||||||
if (!dragNode || !target.isFolder) return;
|
|
||||||
if (dragNode.path === target.path || target.path.startsWith(`${dragNode.path}/`)) {
|
|
||||||
setDragNode(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newPath = target.path ? `${target.path}/${dragNode.fileName}` : dragNode.fileName;
|
|
||||||
try {
|
|
||||||
await fetch(`/api/assembly/move`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ island, kind: 'file', path: dragNode.path, new_path: newPath })
|
|
||||||
});
|
|
||||||
loadTree();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to move:", err);
|
|
||||||
}
|
|
||||||
setDragNode(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNodeSelect = async (_e, itemId) => {
|
|
||||||
const node = nodeMap[itemId];
|
|
||||||
if (node && !node.isFolder) {
|
|
||||||
setContextMenu(null);
|
|
||||||
onEdit && onEdit(node.path);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveRenameFile = async () => {
|
|
||||||
try {
|
|
||||||
const payload = { island, kind: 'file', path: selectedNode.path, new_name: renameValue };
|
|
||||||
// preserve extension for scripts when no extension provided
|
|
||||||
if (selectedNode?.meta?.type) payload.type = selectedNode.meta.type;
|
|
||||||
const res = await fetch(`/api/assembly/rename`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
|
|
||||||
setRenameOpen(false);
|
|
||||||
loadTree();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to rename file:", err);
|
|
||||||
setRenameOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveRenameFolder = async () => {
|
|
||||||
try {
|
|
||||||
if (folderDialogMode === "rename" && selectedNode) {
|
|
||||||
await fetch(`/api/assembly/rename`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ island, kind: 'folder', path: selectedNode.path, new_name: renameValue })
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const basePath = selectedNode ? selectedNode.path : "";
|
|
||||||
const newPath = basePath ? `${basePath}/${renameValue}` : renameValue;
|
|
||||||
await fetch(`/api/assembly/create`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ island, kind: 'folder', path: newPath })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setRenameFolderOpen(false);
|
|
||||||
loadTree();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Folder operation failed:", err);
|
|
||||||
setRenameFolderOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
|
||||||
if (!selectedNode) return;
|
|
||||||
try {
|
|
||||||
if (selectedNode.isFolder) {
|
|
||||||
await fetch(`/api/assembly/delete`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ island, kind: 'folder', path: selectedNode.path })
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await fetch(`/api/assembly/delete`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ island, kind: 'file', path: selectedNode.path })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setDeleteOpen(false);
|
|
||||||
loadTree();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to delete:", err);
|
|
||||||
setDeleteOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createNewItem = () => {
|
|
||||||
const trimmedName = (newItemName || '').trim();
|
|
||||||
const folder = selectedNode?.isFolder
|
|
||||||
? selectedNode.path
|
|
||||||
: (selectedNode?.path?.split("/").slice(0, -1).join("/") || "");
|
|
||||||
const context = {
|
|
||||||
folder,
|
|
||||||
suggestedFileName: trimmedName,
|
|
||||||
defaultType: island === 'ansible' ? 'ansible' : 'powershell',
|
|
||||||
type: island === 'ansible' ? 'ansible' : 'powershell',
|
|
||||||
category: island === 'ansible' ? 'application' : 'script'
|
|
||||||
};
|
|
||||||
setNewItemOpen(false);
|
|
||||||
setNewItemName("");
|
|
||||||
onEdit && onEdit(null, context);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderItems = (nodes) =>
|
|
||||||
(nodes || []).map((n) => (
|
|
||||||
<TreeItem
|
|
||||||
key={n.id}
|
|
||||||
itemId={n.id}
|
|
||||||
label={
|
|
||||||
<Box
|
|
||||||
sx={{ display: "flex", alignItems: "center" }}
|
|
||||||
draggable={!n.isFolder}
|
|
||||||
onDragStart={() => !n.isFolder && setDragNode(n)}
|
|
||||||
onDragOver={(e) => { if (dragNode && n.isFolder) e.preventDefault(); }}
|
|
||||||
onDrop={(e) => { e.preventDefault(); handleDrop(n); }}
|
|
||||||
onContextMenu={(e) => handleContextMenu(e, n)}
|
|
||||||
onDoubleClick={() => { if (!n.isFolder) onEdit && onEdit(n.path); }}
|
|
||||||
>
|
|
||||||
{n.isFolder ? (
|
|
||||||
<FolderIcon sx={{ mr: 1, color: "#0475c2" }} />
|
|
||||||
) : (
|
|
||||||
<DescriptionIcon sx={{ mr: 1, color: "#0475c2" }} />
|
|
||||||
)}
|
|
||||||
<Typography sx={{ flexGrow: 1, color: "#e6edf3" }}>{n.label}</Typography>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{n.children && n.children.length > 0 ? renderItems(n.children) : null}
|
|
||||||
</TreeItem>
|
|
||||||
));
|
|
||||||
|
|
||||||
const rootChildIds = tree[0]?.children?.map((c) => c.id) || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Island
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
icon={title === 'Scripts' ? <ScriptIcon sx={{ fontSize: 40 }} /> : <BookIcon sx={{ fontSize: 40 }} />}
|
|
||||||
actions={
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => { setNewItemName(''); setNewItemOpen(true); }}
|
|
||||||
sx={{ color: '#58a6ff', borderColor: '#2f81f7', textTransform: 'none', '&:hover': { borderColor: '#58a6ff' } }}
|
|
||||||
>
|
|
||||||
{newItemLabel}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{ p: 1 }}
|
|
||||||
onDragOver={(e) => { if (dragNode) e.preventDefault(); }}
|
|
||||||
onDrop={(e) => { e.preventDefault(); handleDrop({ path: "", isFolder: true }); }}
|
|
||||||
>
|
|
||||||
<SimpleTreeView
|
|
||||||
key={rootChildIds.join(",")}
|
|
||||||
sx={{ color: "#e6edf3" }}
|
|
||||||
onNodeSelect={handleNodeSelect}
|
|
||||||
apiRef={apiRef}
|
|
||||||
defaultExpandedItems={["root", ...rootChildIds]}
|
|
||||||
>
|
|
||||||
{renderItems(tree)}
|
|
||||||
</SimpleTreeView>
|
|
||||||
</Box>
|
|
||||||
<Menu
|
|
||||||
open={contextMenu !== null}
|
|
||||||
onClose={() => setContextMenu(null)}
|
|
||||||
anchorReference="anchorPosition"
|
|
||||||
anchorPosition={contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined}
|
|
||||||
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
|
|
||||||
>
|
|
||||||
{selectedNode?.isFolder && (
|
|
||||||
<>
|
|
||||||
<MenuItem onClick={() => { setContextMenu(null); setNewItemOpen(true); }}>{newItemLabel}</MenuItem>
|
|
||||||
<MenuItem onClick={() => { setContextMenu(null); setFolderDialogMode("create"); setRenameValue(""); setRenameFolderOpen(true); }}>New Subfolder</MenuItem>
|
|
||||||
{selectedNode.id !== "root" && (<MenuItem onClick={() => { setContextMenu(null); setRenameValue(selectedNode.label); setRenameOpen(true); }}>Rename</MenuItem>)}
|
|
||||||
{selectedNode.id !== "root" && (<MenuItem onClick={() => { setContextMenu(null); setDeleteOpen(true); }}>Delete</MenuItem>)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!selectedNode?.isFolder && (
|
|
||||||
<>
|
|
||||||
<MenuItem onClick={() => { setContextMenu(null); onEdit && onEdit(selectedNode.path); }}>Edit</MenuItem>
|
|
||||||
<MenuItem onClick={() => { setContextMenu(null); setRenameValue(selectedNode.label); setRenameOpen(true); }}>Rename</MenuItem>
|
|
||||||
<MenuItem onClick={() => { setContextMenu(null); setDeleteOpen(true); }}>Delete</MenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Menu>
|
|
||||||
{/* Simple inline dialogs using shared components */}
|
|
||||||
<RenameFolderDialog open={renameFolderOpen} value={renameValue} onChange={setRenameValue} onCancel={() => setRenameFolderOpen(false)} onSave={saveRenameFolder} title={folderDialogMode === "rename" ? "Rename Folder" : "New Folder"} confirmText={folderDialogMode === "rename" ? "Save" : "Create"} />
|
|
||||||
{/* File rename */}
|
|
||||||
<Paper component={(p) => <div {...p} />} sx={{ display: renameOpen ? 'block' : 'none' }}>
|
|
||||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999 }}>
|
|
||||||
<Paper sx={{ bgcolor: '#121212', color: '#fff', p: 2, minWidth: 360 }}>
|
|
||||||
<Typography variant="h6" sx={{ mb: 1 }}>Rename</Typography>
|
|
||||||
<input autoFocus value={renameValue} onChange={(e) => setRenameValue(e.target.value)} style={{ width: '100%', padding: 8, background: '#2a2a2a', color: '#ccc', border: '1px solid #444', borderRadius: 4 }} />
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
|
|
||||||
<Button onClick={() => setRenameOpen(false)} sx={{ color: '#58a6ff' }}>Cancel</Button>
|
|
||||||
<Button onClick={saveRenameFile} sx={{ color: '#58a6ff' }}>Save</Button>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
</div>
|
|
||||||
</Paper>
|
|
||||||
<Paper component={(p) => <div {...p} />} sx={{ display: newItemOpen ? 'block' : 'none' }}>
|
|
||||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999 }}>
|
|
||||||
<Paper sx={{ bgcolor: '#121212', color: '#fff', p: 2, minWidth: 360 }}>
|
|
||||||
<Typography variant="h6" sx={{ mb: 1 }}>{newItemLabel}</Typography>
|
|
||||||
<input autoFocus value={newItemName} onChange={(e) => setNewItemName(e.target.value)} placeholder="Name" style={{ width: '100%', padding: 8, background: '#2a2a2a', color: '#ccc', border: '1px solid #444', borderRadius: 4 }} />
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
|
|
||||||
<Button onClick={() => setNewItemOpen(false)} sx={{ color: '#58a6ff' }}>Cancel</Button>
|
|
||||||
<Button onClick={createNewItem} sx={{ color: '#58a6ff' }}>Create</Button>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
</div>
|
|
||||||
</Paper>
|
|
||||||
<ConfirmDeleteDialog open={deleteOpen} message="If you delete this, there is no undo button, are you sure you want to proceed?" onCancel={() => setDeleteOpen(false)} onConfirm={confirmDelete} />
|
|
||||||
</Island>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
|
|
||||||
return (
|
|
||||||
<Paper sx={{ m: 2, p: 0, bgcolor: '#1e1e1e' }} elevation={2}>
|
|
||||||
<Box sx={{ p: 2, pb: 1 }}>
|
|
||||||
<Typography variant="h6" sx={{ color: '#58a6ff', mb: 0 }}>Assemblies</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: '#aaa' }}>Collections of various types of components used to perform various automations upon targeted devices.</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ px: 2, pb: 2 }}>
|
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1.2fr 1fr 1fr' }, gap: 2 }}>
|
|
||||||
{/* Left: Workflows */}
|
|
||||||
<WorkflowsIsland onOpenWorkflow={onOpenWorkflow} />
|
|
||||||
|
|
||||||
{/* Middle: Scripts */}
|
|
||||||
<ScriptsLikeIsland
|
|
||||||
title="Scripts"
|
|
||||||
description="Powershell, Batch, and Bash Scripts"
|
|
||||||
rootLabel="Scripts"
|
|
||||||
baseApi="/api/scripts"
|
|
||||||
newItemLabel="New Script"
|
|
||||||
onEdit={(rel, ctx) => onOpenScript && onOpenScript(rel, 'scripts', ctx)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Right: Ansible Playbooks */}
|
|
||||||
<ScriptsLikeIsland
|
|
||||||
title="Ansible Playbooks"
|
|
||||||
description="Declarative Instructions for Consistent Automation"
|
|
||||||
rootLabel="Ansible Playbooks"
|
|
||||||
baseApi="/api/ansible"
|
|
||||||
newItemLabel="New Playbook"
|
|
||||||
onEdit={(rel, ctx) => onOpenScript && onOpenScript(rel, 'ansible', ctx)}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
/* ///////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Borealis.css
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif;
|
|
||||||
background-color: #0b0f19;
|
|
||||||
color: #f5f7fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================================= */
|
|
||||||
/* FLOW EDITOR */
|
|
||||||
/* ======================================= */
|
|
||||||
|
|
||||||
/* FlowEditor background container */
|
|
||||||
.flow-editor-container {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Blue Gradient Overlay */
|
|
||||||
.flow-editor-container::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
pointer-events: none;
|
|
||||||
background: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
rgba(9, 44, 68, 0.9) 0%,
|
|
||||||
rgba(30, 30, 30, 0) 45%,
|
|
||||||
rgba(30, 30, 30, 0) 75%,
|
|
||||||
rgba(9, 44, 68, 0.7) 100%
|
|
||||||
);
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* helper lines for snapping */
|
|
||||||
.helper-line {
|
|
||||||
position: absolute;
|
|
||||||
background: #0074ff;
|
|
||||||
z-index: 10;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.helper-line-vertical {
|
|
||||||
width: 1px;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.helper-line-horizontal {
|
|
||||||
height: 1px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================================= */
|
|
||||||
/* NODE SIDEBAR */
|
|
||||||
/* ======================================= */
|
|
||||||
|
|
||||||
/* Emphasize Drag & Drop Node Functionality */
|
|
||||||
.sidebar-button:hover {
|
|
||||||
background-color: #2a2a2a !important;
|
|
||||||
box-shadow: 0 0 5px rgba(88, 166, 255, 0.3);
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================================= */
|
|
||||||
/* NODES */
|
|
||||||
/* ======================================= */
|
|
||||||
|
|
||||||
/* Borealis Node Styling */
|
|
||||||
.borealis-node {
|
|
||||||
background: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
#2c2c2c 60%,
|
|
||||||
#232323 100%
|
|
||||||
);
|
|
||||||
border: 1px solid #3a3a3a;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #ccc;
|
|
||||||
font-size: 12px;
|
|
||||||
min-width: 160px;
|
|
||||||
max-width: 260px;
|
|
||||||
position: relative;
|
|
||||||
box-shadow: 0 0 5px rgba(88, 166, 255, 0.15),
|
|
||||||
0 0 10px rgba(88, 166, 255, 0.15);
|
|
||||||
transition: box-shadow 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
.borealis-node::before {
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
width: 3px;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
var(--borealis-accent, #58a6ff) 0%,
|
|
||||||
var(--borealis-accent-dark, #0475c2) 100%
|
|
||||||
);
|
|
||||||
border-top-left-radius: 4px;
|
|
||||||
border-bottom-left-radius: 4px;
|
|
||||||
}
|
|
||||||
.borealis-node-header {
|
|
||||||
background: #232323;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-top-left-radius: 4px;
|
|
||||||
border-top-right-radius: 4px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--borealis-title, #58a6ff);
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
.borealis-node-content {
|
|
||||||
padding: 10px;
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
.borealis-handle {
|
|
||||||
background: #58a6ff;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Global dark form inputs */
|
|
||||||
input,
|
|
||||||
select,
|
|
||||||
button {
|
|
||||||
background-color: #1d1d1d;
|
|
||||||
color: #ccc;
|
|
||||||
border: 1px solid #444;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Label / Dark Text styling */
|
|
||||||
label {
|
|
||||||
color: #aaa;
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Node Header - Shows drag handle cursor */
|
|
||||||
.borealis-node-header {
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Optional: when actively dragging */
|
|
||||||
.borealis-node-header:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Node Body - Just pointer, not draggable */
|
|
||||||
.borealis-node-content {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================================= */
|
|
||||||
/* FLOW TABS */
|
|
||||||
/* ======================================= */
|
|
||||||
|
|
||||||
/* Multi-Tab Bar Adjustments */
|
|
||||||
.MuiTabs-root {
|
|
||||||
min-height: 32px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.MuiTab-root {
|
|
||||||
min-height: 32px !important;
|
|
||||||
padding: 6px 12px !important;
|
|
||||||
color: #58a6ff !important;
|
|
||||||
text-transform: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Highlight tab on hover if it's not active */
|
|
||||||
.MuiTab-root:hover:not(.Mui-selected) {
|
|
||||||
background-color: #2C2C2C !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* We rely on the TabIndicatorProps to show the underline highlight for active tabs. */
|
|
||||||
|
|
||||||
/* ======================================= */
|
|
||||||
/* REACT-SIMPLE-KEYBOARD */
|
|
||||||
/* ======================================= */
|
|
||||||
|
|
||||||
/* Make the keyboard max width like the demo */
|
|
||||||
.simple-keyboard {
|
|
||||||
max-width: 950px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background: #181c23;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 24px 24px 30px 24px;
|
|
||||||
box-shadow: 0 2px 24px 0 #000a;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Set dark background and color for the keyboard and its keys */
|
|
||||||
.simple-keyboard .hg-button {
|
|
||||||
background: #23262e;
|
|
||||||
color: #b0d0ff;
|
|
||||||
border: 1px solid #333;
|
|
||||||
font-size: 1.1em;
|
|
||||||
min-width: 48px;
|
|
||||||
min-height: 48px;
|
|
||||||
margin: 5px;
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: background 0.1s, color 0.1s;
|
|
||||||
padding-top: 6px;
|
|
||||||
padding-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.simple-keyboard .hg-button[data-skbtn="space"] {
|
|
||||||
min-width: 380px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.simple-keyboard .hg-button[data-skbtn="tab"],
|
|
||||||
.simple-keyboard .hg-button[data-skbtn="caps"],
|
|
||||||
.simple-keyboard .hg-button[data-skbtn="shift"],
|
|
||||||
.simple-keyboard .hg-button[data-skbtn="enter"],
|
|
||||||
.simple-keyboard .hg-button[data-skbtn="bksp"] {
|
|
||||||
min-width: 82px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.simple-keyboard .hg-button:hover {
|
|
||||||
background: #58a6ff;
|
|
||||||
color: #000;
|
|
||||||
border-color: #58a6ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Make sure rows aren't squashed */
|
|
||||||
.simple-keyboard .hg-row {
|
|
||||||
display: flex !important;
|
|
||||||
flex-flow: row wrap;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Remove any unwanted shrink/stretch */
|
|
||||||
.simple-keyboard .hg-button {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Optional: on-screen keyboard input field (if you ever show it) */
|
|
||||||
input[type="text"].simple-keyboard-input {
|
|
||||||
width: 100%;
|
|
||||||
height: 48px;
|
|
||||||
padding: 10px 20px;
|
|
||||||
font-size: 20px;
|
|
||||||
border: none;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: #181818;
|
|
||||||
color: #f5f7fa;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogActions,
|
|
||||||
TextField,
|
|
||||||
Button,
|
|
||||||
MenuItem,
|
|
||||||
Typography
|
|
||||||
} from "@mui/material";
|
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
|
||||||
{ value: "ssh", label: "SSH" },
|
|
||||||
{ value: "winrm", label: "WinRM" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const initialForm = {
|
|
||||||
hostname: "",
|
|
||||||
address: "",
|
|
||||||
description: "",
|
|
||||||
operating_system: ""
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AddDevice({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
defaultType = null,
|
|
||||||
onCreated
|
|
||||||
}) {
|
|
||||||
const [type, setType] = useState(defaultType || "ssh");
|
|
||||||
const [form, setForm] = useState(initialForm);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setType(defaultType || "ssh");
|
|
||||||
setForm(initialForm);
|
|
||||||
setError("");
|
|
||||||
}
|
|
||||||
}, [open, defaultType]);
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (submitting) return;
|
|
||||||
onClose && onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (field) => (event) => {
|
|
||||||
const value = event.target.value;
|
|
||||||
setForm((prev) => ({ ...prev, [field]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (submitting) return;
|
|
||||||
const trimmedHostname = form.hostname.trim();
|
|
||||||
const trimmedAddress = form.address.trim();
|
|
||||||
if (!trimmedHostname) {
|
|
||||||
setError("Hostname is required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!type) {
|
|
||||||
setError("Select a device type.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!trimmedAddress) {
|
|
||||||
setError("Address is required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSubmitting(true);
|
|
||||||
setError("");
|
|
||||||
const payload = {
|
|
||||||
hostname: trimmedHostname,
|
|
||||||
address: trimmedAddress,
|
|
||||||
description: form.description.trim(),
|
|
||||||
operating_system: form.operating_system.trim()
|
|
||||||
};
|
|
||||||
const apiBase = type === "winrm" ? "/api/winrm_devices" : "/api/ssh_devices";
|
|
||||||
try {
|
|
||||||
const resp = await fetch(apiBase, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
const data = await resp.json().catch(() => ({}));
|
|
||||||
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
|
|
||||||
onCreated && onCreated(data.device || null);
|
|
||||||
onClose && onClose();
|
|
||||||
} catch (err) {
|
|
||||||
setError(String(err.message || err));
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const dialogTitle = defaultType
|
|
||||||
? `Add ${defaultType.toUpperCase()} Device`
|
|
||||||
: "Add Device";
|
|
||||||
|
|
||||||
const typeLabel = (TYPE_OPTIONS.find((opt) => opt.value === type) || TYPE_OPTIONS[0]).label;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onClose={handleClose}
|
|
||||||
fullWidth
|
|
||||||
maxWidth="sm"
|
|
||||||
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
|
|
||||||
>
|
|
||||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
|
||||||
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1 }}>
|
|
||||||
{!defaultType && (
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
label="Device Type"
|
|
||||||
size="small"
|
|
||||||
value={type}
|
|
||||||
onChange={(e) => setType(e.target.value)}
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "#1f1f1f",
|
|
||||||
color: "#fff",
|
|
||||||
"& fieldset": { borderColor: "#555" },
|
|
||||||
"&:hover fieldset": { borderColor: "#888" }
|
|
||||||
},
|
|
||||||
"& .MuiInputLabel-root": { color: "#aaa" }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{TYPE_OPTIONS.map((opt) => (
|
|
||||||
<MenuItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
)}
|
|
||||||
<TextField
|
|
||||||
label="Hostname"
|
|
||||||
value={form.hostname}
|
|
||||||
onChange={handleChange("hostname")}
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "#1f1f1f",
|
|
||||||
color: "#fff",
|
|
||||||
"& fieldset": { borderColor: "#555" },
|
|
||||||
"&:hover fieldset": { borderColor: "#888" }
|
|
||||||
},
|
|
||||||
"& .MuiInputLabel-root": { color: "#aaa" }
|
|
||||||
}}
|
|
||||||
helperText="Name used inside Borealis."
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label={`${typeLabel} Address`}
|
|
||||||
value={form.address}
|
|
||||||
onChange={handleChange("address")}
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "#1f1f1f",
|
|
||||||
color: "#fff",
|
|
||||||
"& fieldset": { borderColor: "#555" },
|
|
||||||
"&:hover fieldset": { borderColor: "#888" }
|
|
||||||
},
|
|
||||||
"& .MuiInputLabel-root": { color: "#aaa" }
|
|
||||||
}}
|
|
||||||
helperText="IP or FQDN reachable from the Borealis server."
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Description"
|
|
||||||
value={form.description}
|
|
||||||
onChange={handleChange("description")}
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "#1f1f1f",
|
|
||||||
color: "#fff",
|
|
||||||
"& fieldset": { borderColor: "#555" },
|
|
||||||
"&:hover fieldset": { borderColor: "#888" }
|
|
||||||
},
|
|
||||||
"& .MuiInputLabel-root": { color: "#aaa" }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Operating System"
|
|
||||||
value={form.operating_system}
|
|
||||||
onChange={handleChange("operating_system")}
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "#1f1f1f",
|
|
||||||
color: "#fff",
|
|
||||||
"& fieldset": { borderColor: "#555" },
|
|
||||||
"&:hover fieldset": { borderColor: "#888" }
|
|
||||||
},
|
|
||||||
"& .MuiInputLabel-root": { color: "#aaa" }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{error && (
|
|
||||||
<Typography variant="body2" sx={{ color: "#ff8080" }}>
|
|
||||||
{error}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
|
||||||
<Button onClick={handleClose} sx={{ color: "#58a6ff" }} disabled={submitting}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
variant="outlined"
|
|
||||||
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
{submitting ? "Saving..." : "Save"}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import DeviceList from "./Device_List.jsx";
|
|
||||||
|
|
||||||
export default function AgentDevices(props) {
|
|
||||||
return (
|
|
||||||
<DeviceList
|
|
||||||
{...props}
|
|
||||||
filterMode="agent"
|
|
||||||
title="Agent Devices"
|
|
||||||
showAddButton={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,505 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/Server/WebUI/src/Admin/Device_Approvals.jsx
|
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Chip,
|
|
||||||
CircularProgress,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogContentText,
|
|
||||||
DialogTitle,
|
|
||||||
FormControl,
|
|
||||||
IconButton,
|
|
||||||
InputLabel,
|
|
||||||
MenuItem,
|
|
||||||
Paper,
|
|
||||||
Select,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
TextField,
|
|
||||||
Tooltip,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import {
|
|
||||||
CheckCircleOutline as ApproveIcon,
|
|
||||||
HighlightOff as DenyIcon,
|
|
||||||
Refresh as RefreshIcon,
|
|
||||||
Security as SecurityIcon,
|
|
||||||
} from "@mui/icons-material";
|
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
|
||||||
{ value: "all", label: "All" },
|
|
||||||
{ value: "pending", label: "Pending" },
|
|
||||||
{ value: "approved", label: "Approved" },
|
|
||||||
{ value: "completed", label: "Completed" },
|
|
||||||
{ value: "denied", label: "Denied" },
|
|
||||||
{ value: "expired", label: "Expired" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const statusChipColor = {
|
|
||||||
pending: "warning",
|
|
||||||
approved: "info",
|
|
||||||
completed: "success",
|
|
||||||
denied: "default",
|
|
||||||
expired: "default",
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDateTime = (value) => {
|
|
||||||
if (!value) return "—";
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return value;
|
|
||||||
return date.toLocaleString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatFingerprint = (fp) => {
|
|
||||||
if (!fp) return "—";
|
|
||||||
const normalized = fp.replace(/[^a-f0-9]/gi, "").toLowerCase();
|
|
||||||
if (!normalized) return fp;
|
|
||||||
return normalized.match(/.{1,4}/g)?.join(" ") ?? normalized;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeStatus = (status) => {
|
|
||||||
if (!status) return "pending";
|
|
||||||
if (status === "completed") return "completed";
|
|
||||||
return status.toLowerCase();
|
|
||||||
};
|
|
||||||
|
|
||||||
function DeviceApprovals() {
|
|
||||||
const [approvals, setApprovals] = useState([]);
|
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [feedback, setFeedback] = useState(null);
|
|
||||||
const [guidInputs, setGuidInputs] = useState({});
|
|
||||||
const [actioningId, setActioningId] = useState(null);
|
|
||||||
const [conflictPrompt, setConflictPrompt] = useState(null);
|
|
||||||
|
|
||||||
const loadApprovals = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const query = statusFilter === "all" ? "" : `?status=${encodeURIComponent(statusFilter)}`;
|
|
||||||
const resp = await fetch(`/api/admin/device-approvals${query}`, { credentials: "include" });
|
|
||||||
if (!resp.ok) {
|
|
||||||
const body = await resp.json().catch(() => ({}));
|
|
||||||
throw new Error(body.error || `Request failed (${resp.status})`);
|
|
||||||
}
|
|
||||||
const data = await resp.json();
|
|
||||||
setApprovals(Array.isArray(data.approvals) ? data.approvals : []);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message || "Unable to load device approvals");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [statusFilter]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadApprovals();
|
|
||||||
}, [loadApprovals]);
|
|
||||||
|
|
||||||
const dedupedApprovals = useMemo(() => {
|
|
||||||
const normalized = approvals
|
|
||||||
.map((record) => ({ ...record, status: normalizeStatus(record.status) }))
|
|
||||||
.sort((a, b) => {
|
|
||||||
const left = new Date(a.created_at || 0).getTime();
|
|
||||||
const right = new Date(b.created_at || 0).getTime();
|
|
||||||
return left - right;
|
|
||||||
});
|
|
||||||
if (statusFilter !== "pending") {
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
const seen = new Set();
|
|
||||||
const unique = [];
|
|
||||||
for (const record of normalized) {
|
|
||||||
const key = record.ssl_key_fingerprint_claimed || record.hostname_claimed || record.id;
|
|
||||||
if (seen.has(key)) continue;
|
|
||||||
seen.add(key);
|
|
||||||
unique.push(record);
|
|
||||||
}
|
|
||||||
return unique;
|
|
||||||
}, [approvals, statusFilter]);
|
|
||||||
|
|
||||||
const handleGuidChange = useCallback((id, value) => {
|
|
||||||
setGuidInputs((prev) => ({ ...prev, [id]: value }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const submitApproval = useCallback(
|
|
||||||
async (record, overrides = {}) => {
|
|
||||||
if (!record?.id) return;
|
|
||||||
setActioningId(record.id);
|
|
||||||
setFeedback(null);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const manualGuid = (guidInputs[record.id] || "").trim();
|
|
||||||
const payload = {};
|
|
||||||
const overrideGuidRaw = overrides.guid;
|
|
||||||
let overrideGuid = "";
|
|
||||||
if (typeof overrideGuidRaw === "string") {
|
|
||||||
overrideGuid = overrideGuidRaw.trim();
|
|
||||||
} else if (overrideGuidRaw != null) {
|
|
||||||
overrideGuid = String(overrideGuidRaw).trim();
|
|
||||||
}
|
|
||||||
if (overrideGuid) {
|
|
||||||
payload.guid = overrideGuid;
|
|
||||||
} else if (manualGuid) {
|
|
||||||
payload.guid = manualGuid;
|
|
||||||
}
|
|
||||||
const resolutionRaw = overrides.conflictResolution || overrides.resolution;
|
|
||||||
if (typeof resolutionRaw === "string" && resolutionRaw.trim()) {
|
|
||||||
payload.conflict_resolution = resolutionRaw.trim().toLowerCase();
|
|
||||||
}
|
|
||||||
const resp = await fetch(`/api/admin/device-approvals/${encodeURIComponent(record.id)}/approve`, {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "include",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(Object.keys(payload).length ? payload : {}),
|
|
||||||
});
|
|
||||||
const body = await resp.json().catch(() => ({}));
|
|
||||||
if (!resp.ok) {
|
|
||||||
if (resp.status === 409 && body.error === "conflict_resolution_required") {
|
|
||||||
const conflict = record.hostname_conflict;
|
|
||||||
const fallbackAlternate =
|
|
||||||
record.alternate_hostname ||
|
|
||||||
(record.hostname_claimed ? `${record.hostname_claimed}-1` : "");
|
|
||||||
if (conflict) {
|
|
||||||
setConflictPrompt({
|
|
||||||
record,
|
|
||||||
conflict,
|
|
||||||
alternate: fallbackAlternate || "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new Error(body.error || `Approval failed (${resp.status})`);
|
|
||||||
}
|
|
||||||
const appliedResolution = (body.conflict_resolution || payload.conflict_resolution || "").toLowerCase();
|
|
||||||
let successMessage = "Enrollment approved";
|
|
||||||
if (appliedResolution === "overwrite") {
|
|
||||||
successMessage = "Enrollment approved; existing device overwritten";
|
|
||||||
} else if (appliedResolution === "coexist") {
|
|
||||||
successMessage = "Enrollment approved; devices will co-exist";
|
|
||||||
} else if (appliedResolution === "auto_merge_fingerprint") {
|
|
||||||
successMessage = "Enrollment approved; device reconnected with its existing identity";
|
|
||||||
}
|
|
||||||
setFeedback({ type: "success", message: successMessage });
|
|
||||||
await loadApprovals();
|
|
||||||
} catch (err) {
|
|
||||||
setFeedback({ type: "error", message: err.message || "Unable to approve request" });
|
|
||||||
} finally {
|
|
||||||
setActioningId(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[guidInputs, loadApprovals]
|
|
||||||
);
|
|
||||||
|
|
||||||
const startApprove = useCallback(
|
|
||||||
(record) => {
|
|
||||||
if (!record?.id) return;
|
|
||||||
const status = normalizeStatus(record.status);
|
|
||||||
if (status !== "pending") return;
|
|
||||||
const manualGuid = (guidInputs[record.id] || "").trim();
|
|
||||||
const conflict = record.hostname_conflict;
|
|
||||||
const requiresPrompt = Boolean(conflict?.requires_prompt ?? record.conflict_requires_prompt);
|
|
||||||
if (requiresPrompt && !manualGuid) {
|
|
||||||
const fallbackAlternate =
|
|
||||||
record.alternate_hostname ||
|
|
||||||
(record.hostname_claimed ? `${record.hostname_claimed}-1` : "");
|
|
||||||
setConflictPrompt({
|
|
||||||
record,
|
|
||||||
conflict,
|
|
||||||
alternate: fallbackAlternate || "",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
submitApproval(record);
|
|
||||||
},
|
|
||||||
[guidInputs, submitApproval]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleConflictCancel = useCallback(() => {
|
|
||||||
setConflictPrompt(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleConflictOverwrite = useCallback(() => {
|
|
||||||
if (!conflictPrompt?.record) {
|
|
||||||
setConflictPrompt(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { record, conflict } = conflictPrompt;
|
|
||||||
setConflictPrompt(null);
|
|
||||||
const conflictGuid = conflict?.guid != null ? String(conflict.guid).trim() : "";
|
|
||||||
submitApproval(record, {
|
|
||||||
guid: conflictGuid,
|
|
||||||
conflictResolution: "overwrite",
|
|
||||||
});
|
|
||||||
}, [conflictPrompt, submitApproval]);
|
|
||||||
|
|
||||||
const handleConflictCoexist = useCallback(() => {
|
|
||||||
if (!conflictPrompt?.record) {
|
|
||||||
setConflictPrompt(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { record } = conflictPrompt;
|
|
||||||
setConflictPrompt(null);
|
|
||||||
submitApproval(record, {
|
|
||||||
conflictResolution: "coexist",
|
|
||||||
});
|
|
||||||
}, [conflictPrompt, submitApproval]);
|
|
||||||
|
|
||||||
const conflictRecord = conflictPrompt?.record;
|
|
||||||
const conflictInfo = conflictPrompt?.conflict;
|
|
||||||
const conflictHostname = conflictRecord?.hostname_claimed || conflictRecord?.hostname || "";
|
|
||||||
const conflictSiteName = conflictInfo?.site_name || "";
|
|
||||||
const conflictSiteDescriptor = conflictInfo
|
|
||||||
? conflictSiteName
|
|
||||||
? `under site ${conflictSiteName}`
|
|
||||||
: "under site (not assigned)"
|
|
||||||
: "under site (not assigned)";
|
|
||||||
const conflictAlternate =
|
|
||||||
conflictPrompt?.alternate ||
|
|
||||||
(conflictHostname ? `${conflictHostname}-1` : "hostname-1");
|
|
||||||
const conflictGuidDisplay = conflictInfo?.guid || "";
|
|
||||||
|
|
||||||
const handleDeny = useCallback(
|
|
||||||
async (record) => {
|
|
||||||
if (!record?.id) return;
|
|
||||||
const confirmDeny = window.confirm("Deny this enrollment request?");
|
|
||||||
if (!confirmDeny) return;
|
|
||||||
setActioningId(record.id);
|
|
||||||
setFeedback(null);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/admin/device-approvals/${encodeURIComponent(record.id)}/deny`, {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
const body = await resp.json().catch(() => ({}));
|
|
||||||
throw new Error(body.error || `Deny failed (${resp.status})`);
|
|
||||||
}
|
|
||||||
setFeedback({ type: "success", message: "Enrollment denied" });
|
|
||||||
await loadApprovals();
|
|
||||||
} catch (err) {
|
|
||||||
setFeedback({ type: "error", message: err.message || "Unable to deny request" });
|
|
||||||
} finally {
|
|
||||||
setActioningId(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[loadApprovals]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 3, display: "flex", flexDirection: "column", gap: 3 }}>
|
|
||||||
<Stack direction="row" alignItems="center" spacing={2}>
|
|
||||||
<SecurityIcon color="primary" />
|
|
||||||
<Typography variant="h5">Device Approval Queue</Typography>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Paper sx={{ p: 2, display: "flex", flexDirection: "column", gap: 2 }}>
|
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
|
|
||||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
|
||||||
<InputLabel id="approval-status-filter-label">Status</InputLabel>
|
|
||||||
<Select
|
|
||||||
labelId="approval-status-filter-label"
|
|
||||||
label="Status"
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(event) => setStatusFilter(event.target.value)}
|
|
||||||
>
|
|
||||||
{STATUS_OPTIONS.map((option) => (
|
|
||||||
<MenuItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<RefreshIcon />}
|
|
||||||
onClick={loadApprovals}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{feedback ? (
|
|
||||||
<Alert severity={feedback.type} variant="outlined" onClose={() => setFeedback(null)}>
|
|
||||||
{feedback.message}
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<Alert severity="error" variant="outlined">
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 480 }}>
|
|
||||||
<Table size="small" stickyHeader>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>Status</TableCell>
|
|
||||||
<TableCell>Hostname</TableCell>
|
|
||||||
<TableCell>Fingerprint</TableCell>
|
|
||||||
<TableCell>Enrollment Code</TableCell>
|
|
||||||
<TableCell>Created</TableCell>
|
|
||||||
<TableCell>Updated</TableCell>
|
|
||||||
<TableCell>Approved By</TableCell>
|
|
||||||
<TableCell align="right">Actions</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{loading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={8} align="center">
|
|
||||||
<Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
|
|
||||||
<CircularProgress size={20} />
|
|
||||||
<Typography variant="body2">Loading approvals…</Typography>
|
|
||||||
</Stack>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : dedupedApprovals.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={8} align="center">
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
No enrollment requests match this filter.
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
dedupedApprovals.map((record) => {
|
|
||||||
const status = normalizeStatus(record.status);
|
|
||||||
const showActions = status === "pending";
|
|
||||||
const guidValue = guidInputs[record.id] || "";
|
|
||||||
const approverDisplay = record.approved_by_username || record.approved_by_user_id;
|
|
||||||
return (
|
|
||||||
<TableRow hover key={record.id}>
|
|
||||||
<TableCell>
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
label={status}
|
|
||||||
color={statusChipColor[status] || "default"}
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{record.hostname_claimed || "—"}</TableCell>
|
|
||||||
<TableCell sx={{ fontFamily: "monospace", whiteSpace: "nowrap" }}>
|
|
||||||
{formatFingerprint(record.ssl_key_fingerprint_claimed)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell sx={{ fontFamily: "monospace" }}>
|
|
||||||
{record.enrollment_code_id || "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{formatDateTime(record.created_at)}</TableCell>
|
|
||||||
<TableCell>{formatDateTime(record.updated_at)}</TableCell>
|
|
||||||
<TableCell>{approverDisplay || "—"}</TableCell>
|
|
||||||
<TableCell align="right">
|
|
||||||
{showActions ? (
|
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems="center">
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
label="Optional GUID"
|
|
||||||
placeholder="Leave empty to auto-generate"
|
|
||||||
value={guidValue}
|
|
||||||
onChange={(event) => handleGuidChange(record.id, event.target.value)}
|
|
||||||
sx={{ minWidth: 200 }}
|
|
||||||
/>
|
|
||||||
<Stack direction="row" spacing={1}>
|
|
||||||
<Tooltip title="Approve enrollment">
|
|
||||||
<span>
|
|
||||||
<IconButton
|
|
||||||
color="success"
|
|
||||||
onClick={() => startApprove(record)}
|
|
||||||
disabled={actioningId === record.id}
|
|
||||||
>
|
|
||||||
{actioningId === record.id ? (
|
|
||||||
<CircularProgress color="success" size={20} />
|
|
||||||
) : (
|
|
||||||
<ApproveIcon fontSize="small" />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="Deny enrollment">
|
|
||||||
<span>
|
|
||||||
<IconButton
|
|
||||||
color="error"
|
|
||||||
onClick={() => handleDeny(record)}
|
|
||||||
disabled={actioningId === record.id}
|
|
||||||
>
|
|
||||||
<DenyIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
) : (
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
No actions available
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
</Paper>
|
|
||||||
<Dialog
|
|
||||||
open={Boolean(conflictPrompt)}
|
|
||||||
onClose={handleConflictCancel}
|
|
||||||
maxWidth="sm"
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
<DialogTitle>Hostname Conflict</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
|
||||||
<Stack spacing={2}>
|
|
||||||
<DialogContentText>
|
|
||||||
{conflictHostname
|
|
||||||
? `Device ${conflictHostname} already exists in the database ${conflictSiteDescriptor}.`
|
|
||||||
: `A device with this hostname already exists in the database ${conflictSiteDescriptor}.`}
|
|
||||||
</DialogContentText>
|
|
||||||
<DialogContentText>
|
|
||||||
Do you want this device to overwrite the existing device, or allow both to co-exist?
|
|
||||||
</DialogContentText>
|
|
||||||
<DialogContentText>
|
|
||||||
{`Device will be renamed ${conflictAlternate} if you choose to allow both to co-exist.`}
|
|
||||||
</DialogContentText>
|
|
||||||
{conflictGuidDisplay ? (
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Existing device GUID: {conflictGuidDisplay}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
</Stack>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={handleConflictCancel}>Cancel</Button>
|
|
||||||
<Button onClick={handleConflictCoexist} color="info" variant="outlined">
|
|
||||||
Allow Both
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleConflictOverwrite}
|
|
||||||
color="primary"
|
|
||||||
variant="contained"
|
|
||||||
disabled={!conflictGuidDisplay}
|
|
||||||
>
|
|
||||||
Overwrite Existing
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default React.memo(DeviceApprovals);
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,371 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/Server/WebUI/src/Admin/Enrollment_Codes.jsx
|
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Chip,
|
|
||||||
CircularProgress,
|
|
||||||
FormControl,
|
|
||||||
IconButton,
|
|
||||||
InputLabel,
|
|
||||||
MenuItem,
|
|
||||||
Paper,
|
|
||||||
Select,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
Tooltip,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import {
|
|
||||||
ContentCopy as CopyIcon,
|
|
||||||
DeleteOutline as DeleteIcon,
|
|
||||||
Refresh as RefreshIcon,
|
|
||||||
Key as KeyIcon,
|
|
||||||
} from "@mui/icons-material";
|
|
||||||
|
|
||||||
const TTL_PRESETS = [
|
|
||||||
{ value: 1, label: "1 hour" },
|
|
||||||
{ value: 3, label: "3 hours" },
|
|
||||||
{ value: 6, label: "6 hours" },
|
|
||||||
{ value: 12, label: "12 hours" },
|
|
||||||
{ value: 24, label: "24 hours" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const statusColor = {
|
|
||||||
active: "success",
|
|
||||||
used: "default",
|
|
||||||
expired: "warning",
|
|
||||||
};
|
|
||||||
|
|
||||||
const maskCode = (code) => {
|
|
||||||
if (!code) return "—";
|
|
||||||
const parts = code.split("-");
|
|
||||||
if (parts.length <= 1) {
|
|
||||||
const prefix = code.slice(0, 4);
|
|
||||||
return `${prefix}${"•".repeat(Math.max(0, code.length - prefix.length))}`;
|
|
||||||
}
|
|
||||||
return parts
|
|
||||||
.map((part, idx) => (idx === 0 || idx === parts.length - 1 ? part : "•".repeat(part.length)))
|
|
||||||
.join("-");
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDateTime = (value) => {
|
|
||||||
if (!value) return "—";
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return value;
|
|
||||||
return date.toLocaleString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const determineStatus = (record) => {
|
|
||||||
if (!record) return "expired";
|
|
||||||
const maxUses = Number.isFinite(record?.max_uses) ? record.max_uses : 1;
|
|
||||||
const useCount = Number.isFinite(record?.use_count) ? record.use_count : 0;
|
|
||||||
if (useCount >= Math.max(1, maxUses || 1)) return "used";
|
|
||||||
if (!record.expires_at) return "expired";
|
|
||||||
const expires = new Date(record.expires_at);
|
|
||||||
if (Number.isNaN(expires.getTime())) return "expired";
|
|
||||||
return expires.getTime() > Date.now() ? "active" : "expired";
|
|
||||||
};
|
|
||||||
|
|
||||||
function EnrollmentCodes() {
|
|
||||||
const [codes, setCodes] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [feedback, setFeedback] = useState(null);
|
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
|
||||||
const [ttlHours, setTtlHours] = useState(6);
|
|
||||||
const [generating, setGenerating] = useState(false);
|
|
||||||
const [maxUses, setMaxUses] = useState(2);
|
|
||||||
|
|
||||||
const filteredCodes = useMemo(() => {
|
|
||||||
if (statusFilter === "all") return codes;
|
|
||||||
return codes.filter((code) => determineStatus(code) === statusFilter);
|
|
||||||
}, [codes, statusFilter]);
|
|
||||||
|
|
||||||
const fetchCodes = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const query = statusFilter === "all" ? "" : `?status=${encodeURIComponent(statusFilter)}`;
|
|
||||||
const resp = await fetch(`/api/admin/enrollment-codes${query}`, {
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
const body = await resp.json().catch(() => ({}));
|
|
||||||
throw new Error(body.error || `Request failed (${resp.status})`);
|
|
||||||
}
|
|
||||||
const data = await resp.json();
|
|
||||||
setCodes(Array.isArray(data.codes) ? data.codes : []);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message || "Unable to load enrollment codes");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [statusFilter]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchCodes();
|
|
||||||
}, [fetchCodes]);
|
|
||||||
|
|
||||||
const handleGenerate = useCallback(async () => {
|
|
||||||
setGenerating(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const resp = await fetch("/api/admin/enrollment-codes", {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "include",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ ttl_hours: ttlHours, max_uses: maxUses }),
|
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
const body = await resp.json().catch(() => ({}));
|
|
||||||
throw new Error(body.error || `Request failed (${resp.status})`);
|
|
||||||
}
|
|
||||||
const created = await resp.json();
|
|
||||||
setFeedback({ type: "success", message: `Installer code ${created.code} created` });
|
|
||||||
await fetchCodes();
|
|
||||||
} catch (err) {
|
|
||||||
setFeedback({ type: "error", message: err.message || "Failed to create code" });
|
|
||||||
} finally {
|
|
||||||
setGenerating(false);
|
|
||||||
}
|
|
||||||
}, [fetchCodes, ttlHours, maxUses]);
|
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
|
||||||
async (id) => {
|
|
||||||
if (!id) return;
|
|
||||||
const confirmDelete = window.confirm("Delete this unused installer code?");
|
|
||||||
if (!confirmDelete) return;
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/admin/enrollment-codes/${encodeURIComponent(id)}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
const body = await resp.json().catch(() => ({}));
|
|
||||||
throw new Error(body.error || `Request failed (${resp.status})`);
|
|
||||||
}
|
|
||||||
setFeedback({ type: "success", message: "Installer code deleted" });
|
|
||||||
await fetchCodes();
|
|
||||||
} catch (err) {
|
|
||||||
setFeedback({ type: "error", message: err.message || "Failed to delete code" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[fetchCodes]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCopy = useCallback((code) => {
|
|
||||||
if (!code) return;
|
|
||||||
try {
|
|
||||||
if (navigator.clipboard?.writeText) {
|
|
||||||
navigator.clipboard.writeText(code);
|
|
||||||
setFeedback({ type: "success", message: "Code copied to clipboard" });
|
|
||||||
} else {
|
|
||||||
const textArea = document.createElement("textarea");
|
|
||||||
textArea.value = code;
|
|
||||||
textArea.style.position = "fixed";
|
|
||||||
textArea.style.opacity = "0";
|
|
||||||
document.body.appendChild(textArea);
|
|
||||||
textArea.select();
|
|
||||||
document.execCommand("copy");
|
|
||||||
document.body.removeChild(textArea);
|
|
||||||
setFeedback({ type: "success", message: "Code copied to clipboard" });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setFeedback({ type: "error", message: err.message || "Unable to copy code" });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const renderStatusChip = (record) => {
|
|
||||||
const status = determineStatus(record);
|
|
||||||
return <Chip size="small" label={status} color={statusColor[status] || "default"} variant="outlined" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 3, display: "flex", flexDirection: "column", gap: 3 }}>
|
|
||||||
<Stack direction="row" alignItems="center" spacing={2}>
|
|
||||||
<KeyIcon color="primary" />
|
|
||||||
<Typography variant="h5">Enrollment Installer Codes</Typography>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Paper sx={{ p: 2, display: "flex", flexDirection: "column", gap: 2 }}>
|
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
|
|
||||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
|
||||||
<InputLabel id="status-filter-label">Filter</InputLabel>
|
|
||||||
<Select
|
|
||||||
labelId="status-filter-label"
|
|
||||||
label="Filter"
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(event) => setStatusFilter(event.target.value)}
|
|
||||||
>
|
|
||||||
<MenuItem value="all">All</MenuItem>
|
|
||||||
<MenuItem value="active">Active</MenuItem>
|
|
||||||
<MenuItem value="used">Used</MenuItem>
|
|
||||||
<MenuItem value="expired">Expired</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
|
||||||
<InputLabel id="ttl-select-label">Duration</InputLabel>
|
|
||||||
<Select
|
|
||||||
labelId="ttl-select-label"
|
|
||||||
label="Duration"
|
|
||||||
value={ttlHours}
|
|
||||||
onChange={(event) => setTtlHours(Number(event.target.value))}
|
|
||||||
>
|
|
||||||
{TTL_PRESETS.map((preset) => (
|
|
||||||
<MenuItem key={preset.value} value={preset.value}>
|
|
||||||
{preset.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
|
||||||
<InputLabel id="uses-select-label">Allowed Uses</InputLabel>
|
|
||||||
<Select
|
|
||||||
labelId="uses-select-label"
|
|
||||||
label="Allowed Uses"
|
|
||||||
value={maxUses}
|
|
||||||
onChange={(event) => setMaxUses(Number(event.target.value))}
|
|
||||||
>
|
|
||||||
{[1, 2, 3, 5].map((uses) => (
|
|
||||||
<MenuItem key={uses} value={uses}>
|
|
||||||
{uses === 1 ? "Single use" : `${uses} uses`}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
onClick={handleGenerate}
|
|
||||||
disabled={generating}
|
|
||||||
startIcon={generating ? <CircularProgress size={16} color="inherit" /> : null}
|
|
||||||
>
|
|
||||||
{generating ? "Generating…" : "Generate Code"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<RefreshIcon />}
|
|
||||||
onClick={fetchCodes}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{feedback ? (
|
|
||||||
<Alert
|
|
||||||
severity={feedback.type}
|
|
||||||
onClose={() => setFeedback(null)}
|
|
||||||
variant="outlined"
|
|
||||||
>
|
|
||||||
{feedback.message}
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<Alert severity="error" variant="outlined">
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 420 }}>
|
|
||||||
<Table size="small" stickyHeader>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>Status</TableCell>
|
|
||||||
<TableCell>Installer Code</TableCell>
|
|
||||||
<TableCell>Expires At</TableCell>
|
|
||||||
<TableCell>Created By</TableCell>
|
|
||||||
<TableCell>Usage</TableCell>
|
|
||||||
<TableCell>Last Used</TableCell>
|
|
||||||
<TableCell>Consumed At</TableCell>
|
|
||||||
<TableCell>Used By GUID</TableCell>
|
|
||||||
<TableCell align="right">Actions</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{loading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={7} align="center">
|
|
||||||
<Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
|
|
||||||
<CircularProgress size={20} />
|
|
||||||
<Typography variant="body2">Loading installer codes…</Typography>
|
|
||||||
</Stack>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : filteredCodes.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={7} align="center">
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
No installer codes match this filter.
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
filteredCodes.map((record) => {
|
|
||||||
const status = determineStatus(record);
|
|
||||||
const maxAllowed = Math.max(1, Number.isFinite(record?.max_uses) ? record.max_uses : 1);
|
|
||||||
const usageCount = Math.max(0, Number.isFinite(record?.use_count) ? record.use_count : 0);
|
|
||||||
const disableDelete = usageCount !== 0;
|
|
||||||
return (
|
|
||||||
<TableRow hover key={record.id}>
|
|
||||||
<TableCell>{renderStatusChip(record)}</TableCell>
|
|
||||||
<TableCell sx={{ fontFamily: "monospace" }}>{maskCode(record.code)}</TableCell>
|
|
||||||
<TableCell>{formatDateTime(record.expires_at)}</TableCell>
|
|
||||||
<TableCell>{record.created_by_user_id || "—"}</TableCell>
|
|
||||||
<TableCell sx={{ fontFamily: "monospace" }}>{`${usageCount} / ${maxAllowed}`}</TableCell>
|
|
||||||
<TableCell>{formatDateTime(record.last_used_at)}</TableCell>
|
|
||||||
<TableCell>{formatDateTime(record.used_at)}</TableCell>
|
|
||||||
<TableCell sx={{ fontFamily: "monospace" }}>
|
|
||||||
{record.used_by_guid || "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="right">
|
|
||||||
<Tooltip title="Copy code">
|
|
||||||
<span>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => handleCopy(record.code)}
|
|
||||||
disabled={!record.code}
|
|
||||||
>
|
|
||||||
<CopyIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={disableDelete ? "Only unused codes can be deleted" : "Delete code"}>
|
|
||||||
<span>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => handleDelete(record.id)}
|
|
||||||
disabled={disableDelete}
|
|
||||||
>
|
|
||||||
<DeleteIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default React.memo(EnrollmentCodes);
|
|
||||||
@@ -1,480 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
Paper,
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Button,
|
|
||||||
IconButton,
|
|
||||||
Table,
|
|
||||||
TableHead,
|
|
||||||
TableBody,
|
|
||||||
TableRow,
|
|
||||||
TableCell,
|
|
||||||
TableSortLabel,
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogActions,
|
|
||||||
TextField,
|
|
||||||
CircularProgress
|
|
||||||
} from "@mui/material";
|
|
||||||
import AddIcon from "@mui/icons-material/Add";
|
|
||||||
import EditIcon from "@mui/icons-material/Edit";
|
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
|
||||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
|
||||||
import { ConfirmDeleteDialog } from "../Dialogs.jsx";
|
|
||||||
import AddDevice from "./Add_Device.jsx";
|
|
||||||
|
|
||||||
const tableStyles = {
|
|
||||||
"& th, & td": {
|
|
||||||
color: "#ddd",
|
|
||||||
borderColor: "#2a2a2a",
|
|
||||||
fontSize: 13,
|
|
||||||
py: 0.75
|
|
||||||
},
|
|
||||||
"& th": {
|
|
||||||
fontWeight: 600
|
|
||||||
},
|
|
||||||
"& th .MuiTableSortLabel-root": { color: "#ddd" },
|
|
||||||
"& th .MuiTableSortLabel-root.Mui-active": { color: "#ddd" }
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultForm = {
|
|
||||||
hostname: "",
|
|
||||||
address: "",
|
|
||||||
description: "",
|
|
||||||
operating_system: ""
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SSHDevices({ type = "ssh" }) {
|
|
||||||
const typeLabel = type === "winrm" ? "WinRM" : "SSH";
|
|
||||||
const apiBase = type === "winrm" ? "/api/winrm_devices" : "/api/ssh_devices";
|
|
||||||
const pageTitle = `${typeLabel} Devices`;
|
|
||||||
const addButtonLabel = `Add ${typeLabel} Device`;
|
|
||||||
const addressLabel = `${typeLabel} Address`;
|
|
||||||
const loadingLabel = `Loading ${typeLabel} devices…`;
|
|
||||||
const emptyLabel = `No ${typeLabel} devices have been added yet.`;
|
|
||||||
const descriptionText = type === "winrm"
|
|
||||||
? "Manage remote endpoints reachable via WinRM for playbook execution."
|
|
||||||
: "Manage remote endpoints reachable via SSH for playbook execution.";
|
|
||||||
const editDialogTitle = `Edit ${typeLabel} Device`;
|
|
||||||
const newDialogTitle = `New ${typeLabel} Device`;
|
|
||||||
const [rows, setRows] = useState([]);
|
|
||||||
const [orderBy, setOrderBy] = useState("hostname");
|
|
||||||
const [order, setOrder] = useState("asc");
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const [form, setForm] = useState(defaultForm);
|
|
||||||
const [formError, setFormError] = useState("");
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [editTarget, setEditTarget] = useState(null);
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
|
||||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
|
||||||
const [addDeviceOpen, setAddDeviceOpen] = useState(false);
|
|
||||||
|
|
||||||
const isEdit = Boolean(editTarget);
|
|
||||||
|
|
||||||
const loadDevices = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const resp = await fetch(apiBase);
|
|
||||||
if (!resp.ok) {
|
|
||||||
const data = await resp.json().catch(() => ({}));
|
|
||||||
throw new Error(data?.error || `HTTP ${resp.status}`);
|
|
||||||
}
|
|
||||||
const data = await resp.json();
|
|
||||||
const list = Array.isArray(data?.devices) ? data.devices : [];
|
|
||||||
setRows(list);
|
|
||||||
} catch (err) {
|
|
||||||
setError(String(err.message || err));
|
|
||||||
setRows([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [apiBase]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadDevices();
|
|
||||||
}, [loadDevices]);
|
|
||||||
|
|
||||||
const sortedRows = useMemo(() => {
|
|
||||||
const list = [...rows];
|
|
||||||
list.sort((a, b) => {
|
|
||||||
const getKey = (row) => {
|
|
||||||
switch (orderBy) {
|
|
||||||
case "created_at":
|
|
||||||
return Number(row.created_at || 0);
|
|
||||||
case "address":
|
|
||||||
return (row.connection_endpoint || "").toLowerCase();
|
|
||||||
case "description":
|
|
||||||
return (row.description || "").toLowerCase();
|
|
||||||
default:
|
|
||||||
return (row.hostname || "").toLowerCase();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const aKey = getKey(a);
|
|
||||||
const bKey = getKey(b);
|
|
||||||
if (aKey < bKey) return order === "asc" ? -1 : 1;
|
|
||||||
if (aKey > bKey) return order === "asc" ? 1 : -1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
return list;
|
|
||||||
}, [rows, order, orderBy]);
|
|
||||||
|
|
||||||
const handleSort = (column) => () => {
|
|
||||||
if (orderBy === column) {
|
|
||||||
setOrder((prev) => (prev === "asc" ? "desc" : "asc"));
|
|
||||||
} else {
|
|
||||||
setOrderBy(column);
|
|
||||||
setOrder("asc");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCreate = () => {
|
|
||||||
setAddDeviceOpen(true);
|
|
||||||
setFormError("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEdit = (row) => {
|
|
||||||
setEditTarget(row);
|
|
||||||
setForm({
|
|
||||||
hostname: row.hostname || "",
|
|
||||||
address: row.connection_endpoint || "",
|
|
||||||
description: row.description || "",
|
|
||||||
operating_system: row.summary?.operating_system || ""
|
|
||||||
});
|
|
||||||
setDialogOpen(true);
|
|
||||||
setFormError("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDialogClose = () => {
|
|
||||||
if (submitting) return;
|
|
||||||
setDialogOpen(false);
|
|
||||||
setForm(defaultForm);
|
|
||||||
setEditTarget(null);
|
|
||||||
setFormError("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (submitting) return;
|
|
||||||
const payload = {
|
|
||||||
hostname: form.hostname.trim(),
|
|
||||||
address: form.address.trim(),
|
|
||||||
description: form.description.trim(),
|
|
||||||
operating_system: form.operating_system.trim()
|
|
||||||
};
|
|
||||||
if (!payload.hostname) {
|
|
||||||
setFormError("Hostname is required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!payload.address) {
|
|
||||||
setFormError("Address is required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSubmitting(true);
|
|
||||||
setFormError("");
|
|
||||||
try {
|
|
||||||
const endpoint = isEdit
|
|
||||||
? `${apiBase}/${encodeURIComponent(editTarget.hostname)}`
|
|
||||||
: apiBase;
|
|
||||||
const resp = await fetch(endpoint, {
|
|
||||||
method: isEdit ? "PUT" : "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
const data = await resp.json().catch(() => ({}));
|
|
||||||
if (!resp.ok) {
|
|
||||||
throw new Error(data?.error || `HTTP ${resp.status}`);
|
|
||||||
}
|
|
||||||
setDialogOpen(false);
|
|
||||||
setForm(defaultForm);
|
|
||||||
setEditTarget(null);
|
|
||||||
setFormError("");
|
|
||||||
setRows((prev) => {
|
|
||||||
const next = [...prev];
|
|
||||||
if (data?.device) {
|
|
||||||
const idx = next.findIndex((row) => row.hostname === data.device.hostname);
|
|
||||||
if (idx >= 0) next[idx] = data.device;
|
|
||||||
else next.push(data.device);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
// Ensure latest ordering by triggering refresh
|
|
||||||
loadDevices();
|
|
||||||
} catch (err) {
|
|
||||||
setFormError(String(err.message || err));
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!deleteTarget) return;
|
|
||||||
setDeleteBusy(true);
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`${apiBase}/${encodeURIComponent(deleteTarget.hostname)}`, {
|
|
||||||
method: "DELETE"
|
|
||||||
});
|
|
||||||
const data = await resp.json().catch(() => ({}));
|
|
||||||
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
|
|
||||||
setRows((prev) => prev.filter((row) => row.hostname !== deleteTarget.hostname));
|
|
||||||
setDeleteTarget(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(String(err.message || err));
|
|
||||||
} finally {
|
|
||||||
setDeleteBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
p: 2,
|
|
||||||
borderBottom: "1px solid #2a2a2a"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
|
|
||||||
{pageTitle}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
|
||||||
{descriptionText}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<RefreshIcon />}
|
|
||||||
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
|
|
||||||
onClick={loadDevices}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="contained"
|
|
||||||
startIcon={<AddIcon />}
|
|
||||||
sx={{ bgcolor: "#58a6ff", color: "#0b0f19" }}
|
|
||||||
onClick={openCreate}
|
|
||||||
>
|
|
||||||
{addButtonLabel}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Box sx={{ px: 2, py: 1.5, color: "#ff8080" }}>
|
|
||||||
<Typography variant="body2">{error}</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{loading && (
|
|
||||||
<Box sx={{ px: 2, py: 1.5, display: "flex", alignItems: "center", gap: 1, color: "#7db7ff" }}>
|
|
||||||
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
|
|
||||||
<Typography variant="body2">{loadingLabel}</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Table size="small" sx={tableStyles}>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell sortDirection={orderBy === "hostname" ? order : false}>
|
|
||||||
<TableSortLabel
|
|
||||||
active={orderBy === "hostname"}
|
|
||||||
direction={orderBy === "hostname" ? order : "asc"}
|
|
||||||
onClick={handleSort("hostname")}
|
|
||||||
>
|
|
||||||
Hostname
|
|
||||||
</TableSortLabel>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell sortDirection={orderBy === "address" ? order : false}>
|
|
||||||
<TableSortLabel
|
|
||||||
active={orderBy === "address"}
|
|
||||||
direction={orderBy === "address" ? order : "asc"}
|
|
||||||
onClick={handleSort("address")}
|
|
||||||
>
|
|
||||||
{addressLabel}
|
|
||||||
</TableSortLabel>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell sortDirection={orderBy === "description" ? order : false}>
|
|
||||||
<TableSortLabel
|
|
||||||
active={orderBy === "description"}
|
|
||||||
direction={orderBy === "description" ? order : "asc"}
|
|
||||||
onClick={handleSort("description")}
|
|
||||||
>
|
|
||||||
Description
|
|
||||||
</TableSortLabel>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell sortDirection={orderBy === "created_at" ? order : false}>
|
|
||||||
<TableSortLabel
|
|
||||||
active={orderBy === "created_at"}
|
|
||||||
direction={orderBy === "created_at" ? order : "asc"}
|
|
||||||
onClick={handleSort("created_at")}
|
|
||||||
>
|
|
||||||
Added
|
|
||||||
</TableSortLabel>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="right">Actions</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{sortedRows.map((row) => {
|
|
||||||
const createdTs = Number(row.created_at || 0) * 1000;
|
|
||||||
const createdDisplay = createdTs
|
|
||||||
? new Date(createdTs).toLocaleString()
|
|
||||||
: (row.summary?.created || "");
|
|
||||||
return (
|
|
||||||
<TableRow key={row.hostname}>
|
|
||||||
<TableCell>{row.hostname}</TableCell>
|
|
||||||
<TableCell>{row.connection_endpoint || ""}</TableCell>
|
|
||||||
<TableCell>{row.description || ""}</TableCell>
|
|
||||||
<TableCell>{createdDisplay}</TableCell>
|
|
||||||
<TableCell align="right">
|
|
||||||
<IconButton size="small" sx={{ color: "#7db7ff" }} onClick={() => openEdit(row)}>
|
|
||||||
<EditIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton size="small" sx={{ color: "#ff8080" }} onClick={() => setDeleteTarget(row)}>
|
|
||||||
<DeleteIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{!sortedRows.length && !loading && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={5} sx={{ textAlign: "center", color: "#888" }}>
|
|
||||||
{emptyLabel}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={dialogOpen}
|
|
||||||
onClose={handleDialogClose}
|
|
||||||
fullWidth
|
|
||||||
maxWidth="sm"
|
|
||||||
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
|
|
||||||
>
|
|
||||||
<DialogTitle>{isEdit ? editDialogTitle : newDialogTitle}</DialogTitle>
|
|
||||||
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1 }}>
|
|
||||||
<TextField
|
|
||||||
label="Hostname"
|
|
||||||
value={form.hostname}
|
|
||||||
disabled={isEdit}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, hostname: e.target.value }))}
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "#1f1f1f",
|
|
||||||
color: "#fff",
|
|
||||||
"& fieldset": { borderColor: "#555" },
|
|
||||||
"&:hover fieldset": { borderColor: "#888" }
|
|
||||||
},
|
|
||||||
"& .MuiInputLabel-root": { color: "#aaa" }
|
|
||||||
}}
|
|
||||||
helperText="Hostname used within Borealis (unique)."
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label={addressLabel}
|
|
||||||
value={form.address}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, address: e.target.value }))}
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "#1f1f1f",
|
|
||||||
color: "#fff",
|
|
||||||
"& fieldset": { borderColor: "#555" },
|
|
||||||
"&:hover fieldset": { borderColor: "#888" }
|
|
||||||
},
|
|
||||||
"& .MuiInputLabel-root": { color: "#aaa" }
|
|
||||||
}}
|
|
||||||
helperText={`IP or FQDN Borealis can reach over ${typeLabel}.`}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Description"
|
|
||||||
value={form.description}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "#1f1f1f",
|
|
||||||
color: "#fff",
|
|
||||||
"& fieldset": { borderColor: "#555" },
|
|
||||||
"&:hover fieldset": { borderColor: "#888" }
|
|
||||||
},
|
|
||||||
"& .MuiInputLabel-root": { color: "#aaa" }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Operating System"
|
|
||||||
value={form.operating_system}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, operating_system: e.target.value }))}
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "#1f1f1f",
|
|
||||||
color: "#fff",
|
|
||||||
"& fieldset": { borderColor: "#555" },
|
|
||||||
"&:hover fieldset": { borderColor: "#888" }
|
|
||||||
},
|
|
||||||
"& .MuiInputLabel-root": { color: "#aaa" }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{error && (
|
|
||||||
<Typography variant="body2" sx={{ color: "#ff8080" }}>
|
|
||||||
{error}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
|
||||||
<Button onClick={handleDialogClose} sx={{ color: "#58a6ff" }} disabled={submitting}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
variant="outlined"
|
|
||||||
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
{submitting ? "Saving..." : "Save"}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<ConfirmDeleteDialog
|
|
||||||
open={Boolean(deleteTarget)}
|
|
||||||
message={
|
|
||||||
deleteTarget
|
|
||||||
? `Remove ${typeLabel} device '${deleteTarget.hostname}' from inventory?`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
onCancel={() => setDeleteTarget(null)}
|
|
||||||
onConfirm={handleDelete}
|
|
||||||
confirmDisabled={deleteBusy}
|
|
||||||
/>
|
|
||||||
<AddDevice
|
|
||||||
open={addDeviceOpen}
|
|
||||||
defaultType={type}
|
|
||||||
onClose={() => setAddDeviceOpen(false)}
|
|
||||||
onCreated={() => {
|
|
||||||
setAddDeviceOpen(false);
|
|
||||||
loadDevices();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import SSHDevices from "./SSH_Devices.jsx";
|
|
||||||
|
|
||||||
export default function WinRMDevices(props) {
|
|
||||||
return <SSHDevices {...props} type="winrm" />;
|
|
||||||
}
|
|
||||||
@@ -1,514 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Dialogs.jsx
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogContentText,
|
|
||||||
DialogActions,
|
|
||||||
Button,
|
|
||||||
Menu,
|
|
||||||
MenuItem,
|
|
||||||
TextField
|
|
||||||
} from "@mui/material";
|
|
||||||
|
|
||||||
export function CloseAllDialog({ open, onClose, onConfirm }) {
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={onClose} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
|
||||||
<DialogTitle>Close All Flow Tabs?</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogContentText sx={{ color: "#ccc" }}>
|
|
||||||
This will remove all existing flow tabs and create a fresh tab named Flow 1.
|
|
||||||
</DialogContentText>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onClose} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
|
||||||
<Button onClick={onConfirm} sx={{ color: "#ff4f4f" }}>Close All</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NotAuthorizedDialog({ open, onClose }) {
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
|
|
||||||
>
|
|
||||||
<DialogTitle>Not Authorized</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogContentText sx={{ color: "#ccc" }}>
|
|
||||||
You are not authorized to access this section.
|
|
||||||
</DialogContentText>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onClose} sx={{ color: "#58a6ff" }}>OK</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreditsDialog({ open, onClose }) {
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={onClose} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
|
||||||
<DialogContent sx={{ textAlign: "center", pt: 3 }}>
|
|
||||||
<img
|
|
||||||
src="/Borealis_Logo.png"
|
|
||||||
alt="Borealis Logo"
|
|
||||||
style={{ width: "120px", marginBottom: "12px" }}
|
|
||||||
/>
|
|
||||||
<DialogTitle sx={{ p: 0, mb: 1 }}>Borealis - Automation Platform</DialogTitle>
|
|
||||||
<DialogContentText sx={{ color: "#ccc" }}>
|
|
||||||
Designed by Nicole Rappe @{" "}
|
|
||||||
<a
|
|
||||||
href="https://bunny-lab.io"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{ color: "#58a6ff", textDecoration: "none" }}
|
|
||||||
>
|
|
||||||
Bunny Lab
|
|
||||||
</a>
|
|
||||||
</DialogContentText>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onClose} sx={{ color: "#58a6ff" }}>Close</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RenameTabDialog({ open, value, onChange, onCancel, onSave }) {
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
|
||||||
<DialogTitle>Rename Tab</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<TextField
|
|
||||||
autoFocus
|
|
||||||
margin="dense"
|
|
||||||
label="Tab Name"
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "#2a2a2a",
|
|
||||||
color: "#ccc",
|
|
||||||
"& fieldset": {
|
|
||||||
borderColor: "#444"
|
|
||||||
},
|
|
||||||
"&:hover fieldset": {
|
|
||||||
borderColor: "#666"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
label: { color: "#aaa" },
|
|
||||||
mt: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
|
||||||
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RenameWorkflowDialog({ open, value, onChange, onCancel, onSave }) {
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
|
||||||
<DialogTitle>Rename Workflow</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<TextField
|
|
||||||
autoFocus
|
|
||||||
margin="dense"
|
|
||||||
label="Workflow Name"
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "#2a2a2a",
|
|
||||||
color: "#ccc",
|
|
||||||
"& fieldset": {
|
|
||||||
borderColor: "#444"
|
|
||||||
},
|
|
||||||
"&:hover fieldset": {
|
|
||||||
borderColor: "#666"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
label: { color: "#aaa" },
|
|
||||||
mt: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
|
||||||
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RenameFolderDialog({
|
|
||||||
open,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
onCancel,
|
|
||||||
onSave,
|
|
||||||
title = "Folder Name",
|
|
||||||
confirmText = "Save"
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
|
||||||
<DialogTitle>{title}</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<TextField
|
|
||||||
autoFocus
|
|
||||||
margin="dense"
|
|
||||||
label="Folder Name"
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "#2a2a2a",
|
|
||||||
color: "#ccc",
|
|
||||||
"& fieldset": { borderColor: "#444" },
|
|
||||||
"&:hover fieldset": { borderColor: "#666" }
|
|
||||||
},
|
|
||||||
label: { color: "#aaa" },
|
|
||||||
mt: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
|
||||||
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>{confirmText}</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NewWorkflowDialog({ open, value, onChange, onCancel, onCreate }) {
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
|
||||||
<DialogTitle>New Workflow</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<TextField
|
|
||||||
autoFocus
|
|
||||||
margin="dense"
|
|
||||||
label="Workflow Name"
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "#2a2a2a",
|
|
||||||
color: "#ccc",
|
|
||||||
"& fieldset": { borderColor: "#444" },
|
|
||||||
"&:hover fieldset": { borderColor: "#666" }
|
|
||||||
},
|
|
||||||
label: { color: "#aaa" },
|
|
||||||
mt: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
|
||||||
<Button onClick={onCreate} sx={{ color: "#58a6ff" }}>Create</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ClearDeviceActivityDialog({ open, onCancel, onConfirm }) {
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
|
||||||
<DialogTitle>Clear Device Activity</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogContentText sx={{ color: "#ccc" }}>
|
|
||||||
All device activity history will be cleared, are you sure?
|
|
||||||
</DialogContentText>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
|
||||||
<Button onClick={onConfirm} sx={{ color: "#ff4f4f" }}>Clear</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SaveWorkflowDialog({ open, value, onChange, onCancel, onSave }) {
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
|
||||||
<DialogTitle>Save Workflow</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<TextField
|
|
||||||
autoFocus
|
|
||||||
margin="dense"
|
|
||||||
label="Workflow Name"
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "#2a2a2a",
|
|
||||||
color: "#ccc",
|
|
||||||
"& fieldset": { borderColor: "#444" },
|
|
||||||
"&:hover fieldset": { borderColor: "#666" }
|
|
||||||
},
|
|
||||||
label: { color: "#aaa" },
|
|
||||||
mt: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
|
||||||
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConfirmDeleteDialog({ open, message, onCancel, onConfirm }) {
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
|
||||||
<DialogTitle>Confirm Delete</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogContentText sx={{ color: "#ccc" }}>{message}</DialogContentText>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
|
||||||
<Button onClick={onConfirm} sx={{ color: "#58a6ff" }}>Confirm</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeleteDeviceDialog({ open, onCancel, onConfirm }) {
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
|
||||||
<DialogTitle>Remove Device</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogContentText sx={{ color: "#ccc" }}>
|
|
||||||
Are you sure you want to remove this device? If the agent is still running, it will automatically re-enroll the device.
|
|
||||||
</DialogContentText>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
|
||||||
<Button
|
|
||||||
onClick={onConfirm}
|
|
||||||
sx={{ bgcolor: "#ff4f4f", color: "#fff", "&:hover": { bgcolor: "#e04444" } }}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TabContextMenu({ anchor, onClose, onRename, onCloseTab }) {
|
|
||||||
return (
|
|
||||||
<Menu
|
|
||||||
open={Boolean(anchor)}
|
|
||||||
onClose={onClose}
|
|
||||||
anchorReference="anchorPosition"
|
|
||||||
anchorPosition={anchor ? { top: anchor.y, left: anchor.x } : undefined}
|
|
||||||
PaperProps={{
|
|
||||||
sx: {
|
|
||||||
bgcolor: "#1e1e1e",
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: "13px"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MenuItem onClick={onRename}>Rename</MenuItem>
|
|
||||||
<MenuItem onClick={onCloseTab}>Close Workflow</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateCustomViewDialog({ open, value, onChange, onCancel, onSave }) {
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
|
||||||
<DialogTitle>Create a New Custom View</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogContentText sx={{ color: "#ccc", mb: 1 }}>
|
|
||||||
Saving a view will save column order, visibility, and filters.
|
|
||||||
</DialogContentText>
|
|
||||||
<TextField
|
|
||||||
autoFocus
|
|
||||||
fullWidth
|
|
||||||
margin="dense"
|
|
||||||
label="View Name"
|
|
||||||
variant="outlined"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
placeholder="Add a name for this custom view"
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "#2a2a2a",
|
|
||||||
color: "#ccc",
|
|
||||||
"& fieldset": { borderColor: "#444" },
|
|
||||||
"&:hover fieldset": { borderColor: "#666" }
|
|
||||||
},
|
|
||||||
label: { color: "#aaa" },
|
|
||||||
mt: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
|
||||||
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RenameCustomViewDialog({ open, value, onChange, onCancel, onSave }) {
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
|
||||||
<DialogTitle>Rename Custom View</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<TextField
|
|
||||||
autoFocus
|
|
||||||
fullWidth
|
|
||||||
margin="dense"
|
|
||||||
label="View Name"
|
|
||||||
variant="outlined"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "#2a2a2a",
|
|
||||||
color: "#ccc",
|
|
||||||
"& fieldset": { borderColor: "#444" },
|
|
||||||
"&:hover fieldset": { borderColor: "#666" }
|
|
||||||
},
|
|
||||||
label: { color: "#aaa" },
|
|
||||||
mt: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
|
||||||
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateSiteDialog({ open, onCancel, onCreate }) {
|
|
||||||
const [name, setName] = React.useState("");
|
|
||||||
const [description, setDescription] = React.useState("");
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setName("");
|
|
||||||
setDescription("");
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
|
||||||
<DialogTitle>Create Site</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogContentText sx={{ color: "#ccc", mb: 1 }}>
|
|
||||||
Create a new site and optionally add a description.
|
|
||||||
</DialogContentText>
|
|
||||||
<TextField
|
|
||||||
autoFocus
|
|
||||||
fullWidth
|
|
||||||
margin="dense"
|
|
||||||
label="Site Name"
|
|
||||||
variant="outlined"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "#2a2a2a",
|
|
||||||
color: "#ccc",
|
|
||||||
"& fieldset": { borderColor: "#444" },
|
|
||||||
"&:hover fieldset": { borderColor: "#666" }
|
|
||||||
},
|
|
||||||
label: { color: "#aaa" },
|
|
||||||
mt: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
multiline
|
|
||||||
minRows={3}
|
|
||||||
margin="dense"
|
|
||||||
label="Description"
|
|
||||||
variant="outlined"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "#2a2a2a",
|
|
||||||
color: "#ccc",
|
|
||||||
"& fieldset": { borderColor: "#444" },
|
|
||||||
"&:hover fieldset": { borderColor: "#666" }
|
|
||||||
},
|
|
||||||
label: { color: "#aaa" },
|
|
||||||
mt: 2
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
const nm = (name || '').trim();
|
|
||||||
if (!nm) return;
|
|
||||||
onCreate && onCreate(nm, description || '');
|
|
||||||
}}
|
|
||||||
sx={{ color: "#58a6ff" }}
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RenameSiteDialog({ open, value, onChange, onCancel, onSave }) {
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
|
||||||
<DialogTitle>Rename Site</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<TextField
|
|
||||||
autoFocus
|
|
||||||
fullWidth
|
|
||||||
margin="dense"
|
|
||||||
label="Site Name"
|
|
||||||
variant="outlined"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "#2a2a2a",
|
|
||||||
color: "#ccc",
|
|
||||||
"& fieldset": { borderColor: "#444" },
|
|
||||||
"&:hover fieldset": { borderColor: "#666" }
|
|
||||||
},
|
|
||||||
label: { color: "#aaa" },
|
|
||||||
mt: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
|
||||||
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { Box, Typography, Tabs, Tab, TextField, MenuItem, Button, Slider, IconButton, Tooltip } from "@mui/material";
|
|
||||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
|
||||||
import ContentPasteIcon from "@mui/icons-material/ContentPaste";
|
|
||||||
import RestoreIcon from "@mui/icons-material/Restore";
|
|
||||||
import { SketchPicker } from "react-color";
|
|
||||||
|
|
||||||
const SIDEBAR_WIDTH = 400;
|
|
||||||
|
|
||||||
const DEFAULT_EDGE_STYLE = {
|
|
||||||
type: "bezier",
|
|
||||||
animated: true,
|
|
||||||
style: { strokeDasharray: "6 3", stroke: "#58a6ff", strokeWidth: 1 },
|
|
||||||
label: "",
|
|
||||||
labelStyle: { fill: "#fff", fontWeight: "bold" },
|
|
||||||
labelBgStyle: { fill: "#2c2c2c", fillOpacity: 0.85, rx: 16, ry: 16 },
|
|
||||||
labelBgPadding: [8, 4],
|
|
||||||
};
|
|
||||||
|
|
||||||
let globalEdgeClipboard = null;
|
|
||||||
|
|
||||||
function clone(obj) {
|
|
||||||
return JSON.parse(JSON.stringify(obj));
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Context_Menu_Sidebar({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
edge,
|
|
||||||
updateEdge,
|
|
||||||
}) {
|
|
||||||
const [activeTab, setActiveTab] = useState(0);
|
|
||||||
const [editState, setEditState] = useState(() => (edge ? clone(edge) : {}));
|
|
||||||
const [colorPicker, setColorPicker] = useState({ field: null, anchor: null });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (edge && edge.id !== editState.id) setEditState(clone(edge));
|
|
||||||
// eslint-disable-next-line
|
|
||||||
}, [edge]);
|
|
||||||
|
|
||||||
const handleChange = (field, value) => {
|
|
||||||
setEditState((prev) => {
|
|
||||||
const updated = { ...prev };
|
|
||||||
if (field === "label") updated.label = value;
|
|
||||||
else if (field === "labelStyle.fill") updated.labelStyle = { ...updated.labelStyle, fill: value };
|
|
||||||
else if (field === "labelBgStyle.fill") updated.labelBgStyle = { ...updated.labelBgStyle, fill: value };
|
|
||||||
else if (field === "labelBgStyle.rx") updated.labelBgStyle = { ...updated.labelBgStyle, rx: value, ry: value };
|
|
||||||
else if (field === "labelBgPadding") updated.labelBgPadding = value;
|
|
||||||
else if (field === "labelBgStyle.fillOpacity") updated.labelBgStyle = { ...updated.labelBgStyle, fillOpacity: value };
|
|
||||||
else if (field === "type") updated.type = value;
|
|
||||||
else if (field === "animated") updated.animated = value;
|
|
||||||
else if (field === "style.stroke") updated.style = { ...updated.style, stroke: value };
|
|
||||||
else if (field === "style.strokeDasharray") updated.style = { ...updated.style, strokeDasharray: value };
|
|
||||||
else if (field === "style.strokeWidth") updated.style = { ...updated.style, strokeWidth: value };
|
|
||||||
else if (field === "labelStyle.fontWeight") updated.labelStyle = { ...updated.labelStyle, fontWeight: value };
|
|
||||||
else updated[field] = value;
|
|
||||||
|
|
||||||
if (field === "style.strokeDasharray") {
|
|
||||||
if (value === "") {
|
|
||||||
updated.animated = false;
|
|
||||||
updated.style = { ...updated.style, strokeDasharray: "" };
|
|
||||||
} else {
|
|
||||||
updated.animated = true;
|
|
||||||
updated.style = { ...updated.style, strokeDasharray: value };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateEdge({ ...updated, id: prev.id });
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Color Picker with right alignment
|
|
||||||
const openColorPicker = (field, event) => {
|
|
||||||
setColorPicker({ field, anchor: event.currentTarget });
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeColorPicker = () => {
|
|
||||||
setColorPicker({ field: null, anchor: null });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleColorChange = (color) => {
|
|
||||||
handleChange(colorPicker.field, color.hex);
|
|
||||||
closeColorPicker();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reset, Copy, Paste logic
|
|
||||||
const handleReset = () => {
|
|
||||||
setEditState(clone({ ...DEFAULT_EDGE_STYLE, id: edge.id }));
|
|
||||||
updateEdge({ ...DEFAULT_EDGE_STYLE, id: edge.id });
|
|
||||||
};
|
|
||||||
const handleCopy = () => { globalEdgeClipboard = clone(editState); };
|
|
||||||
const handlePaste = () => {
|
|
||||||
if (globalEdgeClipboard) {
|
|
||||||
setEditState(clone({ ...globalEdgeClipboard, id: edge.id }));
|
|
||||||
updateEdge({ ...globalEdgeClipboard, id: edge.id });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderColorButton = (label, field, value) => (
|
|
||||||
<span style={{ display: "inline-block", verticalAlign: "middle", position: "relative" }}>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => openColorPicker(field, e)}
|
|
||||||
sx={{
|
|
||||||
ml: 1,
|
|
||||||
borderColor: "#444",
|
|
||||||
color: "#ccc",
|
|
||||||
minWidth: 0,
|
|
||||||
width: 32,
|
|
||||||
height: 24,
|
|
||||||
p: 0,
|
|
||||||
bgcolor: "#232323",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{
|
|
||||||
display: "inline-block",
|
|
||||||
width: 20,
|
|
||||||
height: 16,
|
|
||||||
background: value,
|
|
||||||
borderRadius: 3,
|
|
||||||
border: "1px solid #888",
|
|
||||||
}} />
|
|
||||||
</Button>
|
|
||||||
{colorPicker.field === field && (
|
|
||||||
<Box sx={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "32px",
|
|
||||||
right: 0,
|
|
||||||
zIndex: 1302,
|
|
||||||
boxShadow: "0 2px 16px rgba(0,0,0,0.24)"
|
|
||||||
}}>
|
|
||||||
<SketchPicker
|
|
||||||
color={value}
|
|
||||||
onChange={handleColorChange}
|
|
||||||
disableAlpha
|
|
||||||
presetColors={[
|
|
||||||
"#fff", "#000", "#58a6ff", "#ff4f4f", "#2c2c2c", "#00d18c",
|
|
||||||
"#e3e3e3", "#0475c2", "#ff8c00", "#6b21a8", "#0e7490"
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Label tab
|
|
||||||
const renderLabelTab = () => (
|
|
||||||
<Box sx={{ px: 2, pt: 1, pb: 2 }}>
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Label</Typography>
|
|
||||||
</Box>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
value={editState.label || ""}
|
|
||||||
onChange={e => handleChange("label", e.target.value)}
|
|
||||||
sx={{
|
|
||||||
mb: 2,
|
|
||||||
input: { color: "#fff", bgcolor: "#1e1e1e", fontSize: "0.95rem" },
|
|
||||||
"& fieldset": { borderColor: "#444" },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Text Color</Typography>
|
|
||||||
{renderColorButton("Label Text Color", "labelStyle.fill", editState.labelStyle?.fill || "#fff")}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Background</Typography>
|
|
||||||
{renderColorButton("Label Background Color", "labelBgStyle.fill", editState.labelBgStyle?.fill || "#2c2c2c")}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Padding</Typography>
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
type="text"
|
|
||||||
value={editState.labelBgPadding ? editState.labelBgPadding.join(",") : "8,4"}
|
|
||||||
onChange={e => {
|
|
||||||
const val = e.target.value.split(",").map(x => parseInt(x.trim())).filter(x => !isNaN(x));
|
|
||||||
if (val.length === 2) handleChange("labelBgPadding", val);
|
|
||||||
}}
|
|
||||||
sx={{ width: 80, input: { color: "#fff", bgcolor: "#1e1e1e", fontSize: "0.95rem" } }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Background Style</Typography>
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
size="small"
|
|
||||||
value={(editState.labelBgStyle?.rx ?? 11) >= 11 ? "rounded" : "square"}
|
|
||||||
onChange={e => {
|
|
||||||
handleChange("labelBgStyle.rx", e.target.value === "rounded" ? 11 : 0);
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
width: 150,
|
|
||||||
bgcolor: "#1e1e1e",
|
|
||||||
"& .MuiSelect-select": { color: "#fff" }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MenuItem value="rounded">Rounded</MenuItem>
|
|
||||||
<MenuItem value="square">Square</MenuItem>
|
|
||||||
</TextField>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Background Opacity</Typography>
|
|
||||||
<Slider
|
|
||||||
value={editState.labelBgStyle?.fillOpacity ?? 0.85}
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.05}
|
|
||||||
onChange={(_, v) => handleChange("labelBgStyle.fillOpacity", v)}
|
|
||||||
sx={{ width: 100, ml: 2 }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
type="number"
|
|
||||||
value={editState.labelBgStyle?.fillOpacity ?? 0.85}
|
|
||||||
onChange={e => handleChange("labelBgStyle.fillOpacity", parseFloat(e.target.value) || 0)}
|
|
||||||
sx={{ width: 60, ml: 2, input: { color: "#fff", bgcolor: "#1e1e1e", fontSize: "0.95rem" } }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderStyleTab = () => (
|
|
||||||
<Box sx={{ px: 2, pt: 1, pb: 2 }}>
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Edge Style</Typography>
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
size="small"
|
|
||||||
value={editState.type || "bezier"}
|
|
||||||
onChange={e => handleChange("type", e.target.value)}
|
|
||||||
sx={{
|
|
||||||
width: 200,
|
|
||||||
bgcolor: "#1e1e1e",
|
|
||||||
"& .MuiSelect-select": { color: "#fff" }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MenuItem value="step">Step</MenuItem>
|
|
||||||
<MenuItem value="bezier">Curved (Bezier)</MenuItem>
|
|
||||||
<MenuItem value="straight">Straight</MenuItem>
|
|
||||||
<MenuItem value="smoothstep">Smoothstep</MenuItem>
|
|
||||||
</TextField>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Edge Animation</Typography>
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
size="small"
|
|
||||||
value={
|
|
||||||
editState.style?.strokeDasharray === "6 3" ? "dashes"
|
|
||||||
: editState.style?.strokeDasharray === "2 4" ? "dots"
|
|
||||||
: "solid"
|
|
||||||
}
|
|
||||||
onChange={e => {
|
|
||||||
const val = e.target.value;
|
|
||||||
handleChange("style.strokeDasharray",
|
|
||||||
val === "dashes" ? "6 3" :
|
|
||||||
val === "dots" ? "2 4" : ""
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
width: 200,
|
|
||||||
bgcolor: "#1e1e1e",
|
|
||||||
"& .MuiSelect-select": { color: "#fff" }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MenuItem value="dashes">Dashes</MenuItem>
|
|
||||||
<MenuItem value="dots">Dots</MenuItem>
|
|
||||||
<MenuItem value="solid">Solid</MenuItem>
|
|
||||||
</TextField>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Color</Typography>
|
|
||||||
{renderColorButton("Edge Color", "style.stroke", editState.style?.stroke || "#58a6ff")}
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Edge Width</Typography>
|
|
||||||
<Slider
|
|
||||||
value={editState.style?.strokeWidth ?? 2}
|
|
||||||
min={1}
|
|
||||||
max={10}
|
|
||||||
step={1}
|
|
||||||
onChange={(_, v) => handleChange("style.strokeWidth", v)}
|
|
||||||
sx={{ width: 100, ml: 2 }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
type="number"
|
|
||||||
value={editState.style?.strokeWidth ?? 2}
|
|
||||||
onChange={e => handleChange("style.strokeWidth", parseInt(e.target.value) || 1)}
|
|
||||||
sx={{ width: 60, ml: 2, input: { color: "#fff", bgcolor: "#1e1e1e", fontSize: "0.95rem" } }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Always render the sidebar for animation!
|
|
||||||
if (!edge) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Overlay */}
|
|
||||||
<Box
|
|
||||||
onClick={onClose}
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.3)",
|
|
||||||
opacity: open ? 1 : 0,
|
|
||||||
pointerEvents: open ? "auto" : "none",
|
|
||||||
transition: "opacity 0.6s ease",
|
|
||||||
zIndex: 10
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: SIDEBAR_WIDTH,
|
|
||||||
bgcolor: "#2C2C2C",
|
|
||||||
color: "#ccc",
|
|
||||||
borderLeft: "1px solid #333",
|
|
||||||
padding: 0,
|
|
||||||
zIndex: 11,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
height: "100%",
|
|
||||||
transform: open ? "translateX(0)" : `translateX(${SIDEBAR_WIDTH}px)`,
|
|
||||||
transition: "transform 0.3s cubic-bezier(.77,0,.18,1)"
|
|
||||||
}}
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Box sx={{ backgroundColor: "#232323", borderBottom: "1px solid #333" }}>
|
|
||||||
<Box sx={{ padding: "12px 16px", display: "flex", alignItems: "center" }}>
|
|
||||||
<Typography variant="h7" sx={{ color: "#0475c2", fontWeight: "bold", flex: 1 }}>
|
|
||||||
Edit Edge Properties
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Tabs
|
|
||||||
value={activeTab}
|
|
||||||
onChange={(_, v) => setActiveTab(v)}
|
|
||||||
variant="fullWidth"
|
|
||||||
textColor="inherit"
|
|
||||||
TabIndicatorProps={{ style: { backgroundColor: "#ccc" } }}
|
|
||||||
sx={{
|
|
||||||
borderTop: "1px solid #333",
|
|
||||||
borderBottom: "1px solid #333",
|
|
||||||
minHeight: "36px",
|
|
||||||
height: "36px"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tab label="Label" sx={{
|
|
||||||
color: "#ccc",
|
|
||||||
"&.Mui-selected": { color: "#ccc" },
|
|
||||||
minHeight: "36px",
|
|
||||||
height: "36px",
|
|
||||||
textTransform: "none"
|
|
||||||
}} />
|
|
||||||
<Tab label="Style" sx={{
|
|
||||||
color: "#ccc",
|
|
||||||
"&.Mui-selected": { color: "#ccc" },
|
|
||||||
minHeight: "36px",
|
|
||||||
height: "36px",
|
|
||||||
textTransform: "none"
|
|
||||||
}} />
|
|
||||||
</Tabs>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Main fields scrollable */}
|
|
||||||
<Box sx={{ flex: 1, overflowY: "auto" }}>
|
|
||||||
{activeTab === 0 && renderLabelTab()}
|
|
||||||
{activeTab === 1 && renderStyleTab()}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Sticky footer bar */}
|
|
||||||
<Box sx={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
px: 2, py: 1,
|
|
||||||
borderTop: "1px solid #333",
|
|
||||||
backgroundColor: "#232323",
|
|
||||||
flexShrink: 0
|
|
||||||
}}>
|
|
||||||
<Box>
|
|
||||||
<Tooltip title="Copy Style"><IconButton onClick={handleCopy}><ContentCopyIcon /></IconButton></Tooltip>
|
|
||||||
<Tooltip title="Paste Style"><IconButton onClick={handlePaste}><ContentPasteIcon /></IconButton></Tooltip>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Tooltip title="Reset to Default"><Button variant="outlined" size="small" startIcon={<RestoreIcon />} onClick={handleReset} sx={{
|
|
||||||
color: "#58a6ff", borderColor: "#58a6ff", textTransform: "none"
|
|
||||||
}}>Reset to Default</Button></Tooltip>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,374 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Flow_Editor.jsx
|
|
||||||
// Import Node Configuration Sidebar and new Context Menu Sidebar
|
|
||||||
import NodeConfigurationSidebar from "./Node_Configuration_Sidebar";
|
|
||||||
import ContextMenuSidebar from "./Context_Menu_Sidebar";
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
||||||
import ReactFlow, {
|
|
||||||
Background,
|
|
||||||
addEdge,
|
|
||||||
applyNodeChanges,
|
|
||||||
applyEdgeChanges,
|
|
||||||
useReactFlow
|
|
||||||
} from "reactflow";
|
|
||||||
|
|
||||||
import { Menu, MenuItem, Box } from "@mui/material";
|
|
||||||
import {
|
|
||||||
Polyline as PolylineIcon,
|
|
||||||
DeleteForever as DeleteForeverIcon,
|
|
||||||
Edit as EditIcon
|
|
||||||
} from "@mui/icons-material";
|
|
||||||
|
|
||||||
import "reactflow/dist/style.css";
|
|
||||||
|
|
||||||
export default function FlowEditor({
|
|
||||||
flowId,
|
|
||||||
nodes,
|
|
||||||
edges,
|
|
||||||
setNodes,
|
|
||||||
setEdges,
|
|
||||||
nodeTypes,
|
|
||||||
categorizedNodes
|
|
||||||
}) {
|
|
||||||
// Node Configuration Sidebar State
|
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState(null);
|
|
||||||
|
|
||||||
// Edge Properties Sidebar State
|
|
||||||
const [edgeSidebarOpen, setEdgeSidebarOpen] = useState(false);
|
|
||||||
const [edgeSidebarEdgeId, setEdgeSidebarEdgeId] = useState(null);
|
|
||||||
|
|
||||||
// Context Menus
|
|
||||||
const [nodeContextMenu, setNodeContextMenu] = useState(null); // { mouseX, mouseY, nodeId }
|
|
||||||
const [edgeContextMenu, setEdgeContextMenu] = useState(null); // { mouseX, mouseY, edgeId }
|
|
||||||
|
|
||||||
// Drag/snap helpers (untouched)
|
|
||||||
const wrapperRef = useRef(null);
|
|
||||||
const { project } = useReactFlow();
|
|
||||||
const [guides, setGuides] = useState([]);
|
|
||||||
const [activeGuides, setActiveGuides] = useState([]);
|
|
||||||
const movingFlowSize = useRef({ width: 0, height: 0 });
|
|
||||||
|
|
||||||
// ----- Node/Edge Definitions -----
|
|
||||||
const selectedNode = nodes.find((n) => n.id === selectedNodeId);
|
|
||||||
const selectedEdge = edges.find((e) => e.id === edgeSidebarEdgeId);
|
|
||||||
|
|
||||||
// --------- Context Menu Handlers ----------
|
|
||||||
const handleRightClick = (e, node) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setNodeContextMenu({ mouseX: e.clientX + 2, mouseY: e.clientY - 6, nodeId: node.id });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdgeRightClick = (e, edge) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setEdgeContextMenu({ mouseX: e.clientX + 2, mouseY: e.clientY - 6, edgeId: edge.id });
|
|
||||||
};
|
|
||||||
|
|
||||||
// --------- Node Context Menu Actions ---------
|
|
||||||
const handleDisconnectAllEdges = (nodeId) => {
|
|
||||||
setEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId));
|
|
||||||
setNodeContextMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveNode = (nodeId) => {
|
|
||||||
setNodes((nds) => nds.filter((n) => n.id !== nodeId));
|
|
||||||
setEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId));
|
|
||||||
setNodeContextMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditNodeProps = (nodeId) => {
|
|
||||||
setSelectedNodeId(nodeId);
|
|
||||||
setDrawerOpen(true);
|
|
||||||
setNodeContextMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// --------- Edge Context Menu Actions ---------
|
|
||||||
const handleUnlinkEdge = (edgeId) => {
|
|
||||||
setEdges((eds) => eds.filter((e) => e.id !== edgeId));
|
|
||||||
setEdgeContextMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditEdgeProps = (edgeId) => {
|
|
||||||
setEdgeSidebarEdgeId(edgeId);
|
|
||||||
setEdgeSidebarOpen(true);
|
|
||||||
setEdgeContextMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ----- Sidebar Closing -----
|
|
||||||
const handleCloseNodeSidebar = () => {
|
|
||||||
setDrawerOpen(false);
|
|
||||||
setSelectedNodeId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseEdgeSidebar = () => {
|
|
||||||
setEdgeSidebarOpen(false);
|
|
||||||
setEdgeSidebarEdgeId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ----- Update Edge Callback for Sidebar -----
|
|
||||||
const updateEdge = (updatedEdgeObj) => {
|
|
||||||
setEdges((eds) =>
|
|
||||||
eds.map((e) => (e.id === updatedEdgeObj.id ? { ...e, ...updatedEdgeObj } : e))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ----- Drag/Drop, Guides, Node Snap Logic (unchanged) -----
|
|
||||||
const computeGuides = useCallback((dragNode) => {
|
|
||||||
if (!wrapperRef.current) return;
|
|
||||||
const parentRect = wrapperRef.current.getBoundingClientRect();
|
|
||||||
const dragEl = wrapperRef.current.querySelector(
|
|
||||||
`.react-flow__node[data-id="${dragNode.id}"]`
|
|
||||||
);
|
|
||||||
if (dragEl) {
|
|
||||||
const dr = dragEl.getBoundingClientRect();
|
|
||||||
const relLeft = dr.left - parentRect.left;
|
|
||||||
const relTop = dr.top - parentRect.top;
|
|
||||||
const relRight = relLeft + dr.width;
|
|
||||||
const relBottom = relTop + dr.height;
|
|
||||||
const pTL = project({ x: relLeft, y: relTop });
|
|
||||||
const pTR = project({ x: relRight, y: relTop });
|
|
||||||
const pBL = project({ x: relLeft, y: relBottom });
|
|
||||||
movingFlowSize.current = { width: pTR.x - pTL.x, height: pBL.y - pTL.y };
|
|
||||||
}
|
|
||||||
const lines = [];
|
|
||||||
nodes.forEach((n) => {
|
|
||||||
if (n.id === dragNode.id) return;
|
|
||||||
const el = wrapperRef.current.querySelector(
|
|
||||||
`.react-flow__node[data-id="${n.id}"]`
|
|
||||||
);
|
|
||||||
if (!el) return;
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
const relLeft = r.left - parentRect.left;
|
|
||||||
const relTop = r.top - parentRect.top;
|
|
||||||
const relRight = relLeft + r.width;
|
|
||||||
const relBottom = relTop + r.height;
|
|
||||||
const pTL = project({ x: relLeft, y: relTop });
|
|
||||||
const pTR = project({ x: relRight, y: relTop });
|
|
||||||
const pBL = project({ x: relLeft, y: relBottom });
|
|
||||||
lines.push({ xFlow: pTL.x, xPx: relLeft });
|
|
||||||
lines.push({ xFlow: pTR.x, xPx: relRight });
|
|
||||||
lines.push({ yFlow: pTL.y, yPx: relTop });
|
|
||||||
lines.push({ yFlow: pBL.y, yPx: relBottom });
|
|
||||||
});
|
|
||||||
setGuides(lines);
|
|
||||||
}, [nodes, project]);
|
|
||||||
|
|
||||||
const onNodeDrag = useCallback((_, node) => {
|
|
||||||
const threshold = 5;
|
|
||||||
let snapX = null, snapY = null;
|
|
||||||
const show = [];
|
|
||||||
const { width: fw, height: fh } = movingFlowSize.current;
|
|
||||||
guides.forEach((ln) => {
|
|
||||||
if (ln.xFlow != null) {
|
|
||||||
if (Math.abs(node.position.x - ln.xFlow) < threshold) { snapX = ln.xFlow; show.push({ xPx: ln.xPx }); }
|
|
||||||
else if (Math.abs(node.position.x + fw - ln.xFlow) < threshold) { snapX = ln.xFlow - fw; show.push({ xPx: ln.xPx }); }
|
|
||||||
}
|
|
||||||
if (ln.yFlow != null) {
|
|
||||||
if (Math.abs(node.position.y - ln.yFlow) < threshold) { snapY = ln.yFlow; show.push({ yPx: ln.yPx }); }
|
|
||||||
else if (Math.abs(node.position.y + fh - ln.yFlow) < threshold) { snapY = ln.yFlow - fh; show.push({ yPx: ln.yPx }); }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (snapX !== null || snapY !== null) {
|
|
||||||
setNodes((nds) =>
|
|
||||||
applyNodeChanges(
|
|
||||||
[{
|
|
||||||
id: node.id,
|
|
||||||
type: "position",
|
|
||||||
position: {
|
|
||||||
x: snapX !== null ? snapX : node.position.x,
|
|
||||||
y: snapY !== null ? snapY : node.position.y
|
|
||||||
}
|
|
||||||
}],
|
|
||||||
nds
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setActiveGuides(show);
|
|
||||||
} else {
|
|
||||||
setActiveGuides([]);
|
|
||||||
}
|
|
||||||
}, [guides, setNodes]);
|
|
||||||
|
|
||||||
const onDrop = useCallback((event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const type = event.dataTransfer.getData("application/reactflow");
|
|
||||||
if (!type) return;
|
|
||||||
const bounds = wrapperRef.current.getBoundingClientRect();
|
|
||||||
const position = project({
|
|
||||||
x: event.clientX - bounds.left,
|
|
||||||
y: event.clientY - bounds.top
|
|
||||||
});
|
|
||||||
const id = "node-" + Date.now();
|
|
||||||
const nodeMeta = Object.values(categorizedNodes).flat().find((n) => n.type === type);
|
|
||||||
// Seed config defaults:
|
|
||||||
const configDefaults = {};
|
|
||||||
(nodeMeta?.config || []).forEach(cfg => {
|
|
||||||
if (cfg.defaultValue !== undefined) {
|
|
||||||
configDefaults[cfg.key] = cfg.defaultValue;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const newNode = {
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
position,
|
|
||||||
data: {
|
|
||||||
label: nodeMeta?.label || type,
|
|
||||||
content: nodeMeta?.content,
|
|
||||||
...configDefaults
|
|
||||||
},
|
|
||||||
dragHandle: ".borealis-node-header"
|
|
||||||
};
|
|
||||||
setNodes((nds) => [...nds, newNode]);
|
|
||||||
|
|
||||||
}, [project, setNodes, categorizedNodes]);
|
|
||||||
|
|
||||||
const onDragOver = useCallback((event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.dataTransfer.dropEffect = "move";
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onConnect = useCallback((params) => {
|
|
||||||
setEdges((eds) =>
|
|
||||||
addEdge({
|
|
||||||
...params,
|
|
||||||
type: "bezier",
|
|
||||||
animated: true,
|
|
||||||
style: { strokeDasharray: "6 3", stroke: "#58a6ff" }
|
|
||||||
}, eds)
|
|
||||||
);
|
|
||||||
}, [setEdges]);
|
|
||||||
|
|
||||||
const onNodesChange = useCallback((changes) => {
|
|
||||||
setNodes((nds) => applyNodeChanges(changes, nds));
|
|
||||||
}, [setNodes]);
|
|
||||||
|
|
||||||
const onEdgesChange = useCallback((changes) => {
|
|
||||||
setEdges((eds) => applyEdgeChanges(changes, eds));
|
|
||||||
}, [setEdges]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const nodeCountEl = document.getElementById("nodeCount");
|
|
||||||
if (nodeCountEl) nodeCountEl.innerText = nodes.length;
|
|
||||||
}, [nodes]);
|
|
||||||
|
|
||||||
const nodeDef = selectedNode
|
|
||||||
? Object.values(categorizedNodes).flat().find((def) => def.type === selectedNode.type)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// --------- MAIN RENDER ----------
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flow-editor-container"
|
|
||||||
ref={wrapperRef}
|
|
||||||
style={{ position: "relative" }}
|
|
||||||
>
|
|
||||||
{/* Node Config Sidebar */}
|
|
||||||
<NodeConfigurationSidebar
|
|
||||||
drawerOpen={drawerOpen}
|
|
||||||
setDrawerOpen={setDrawerOpen}
|
|
||||||
title={selectedNode ? selectedNode.data?.label || selectedNode.id : ""}
|
|
||||||
nodeData={
|
|
||||||
selectedNode && nodeDef
|
|
||||||
? {
|
|
||||||
config: nodeDef.config,
|
|
||||||
usage_documentation: nodeDef.usage_documentation,
|
|
||||||
...selectedNode.data,
|
|
||||||
nodeId: selectedNode.id
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
setNodes={setNodes}
|
|
||||||
selectedNode={selectedNode}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Edge Properties Sidebar */}
|
|
||||||
<ContextMenuSidebar
|
|
||||||
open={edgeSidebarOpen}
|
|
||||||
onClose={handleCloseEdgeSidebar}
|
|
||||||
edge={selectedEdge ? { ...selectedEdge } : null}
|
|
||||||
updateEdge={edge => {
|
|
||||||
// Provide id if missing
|
|
||||||
if (!edge.id && edgeSidebarEdgeId) edge.id = edgeSidebarEdgeId;
|
|
||||||
updateEdge(edge);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ReactFlow
|
|
||||||
nodes={nodes}
|
|
||||||
edges={edges}
|
|
||||||
nodeTypes={nodeTypes}
|
|
||||||
onNodesChange={onNodesChange}
|
|
||||||
onEdgesChange={onEdgesChange}
|
|
||||||
onConnect={onConnect}
|
|
||||||
onDrop={onDrop}
|
|
||||||
onDragOver={onDragOver}
|
|
||||||
onNodeContextMenu={handleRightClick}
|
|
||||||
onEdgeContextMenu={handleEdgeRightClick}
|
|
||||||
defaultViewport={{ x: 0, y: 0, zoom: 1.5 }}
|
|
||||||
edgeOptions={{ type: "bezier", animated: true, style: { strokeDasharray: "6 3", stroke: "#58a6ff" } }}
|
|
||||||
proOptions={{ hideAttribution: true }}
|
|
||||||
onNodeDragStart={(_, node) => computeGuides(node)}
|
|
||||||
onNodeDrag={onNodeDrag}
|
|
||||||
onNodeDragStop={() => { setGuides([]); setActiveGuides([]); }}
|
|
||||||
>
|
|
||||||
<Background id={flowId} variant="lines" gap={65} size={1} color="rgba(255,255,255,0.2)" />
|
|
||||||
</ReactFlow>
|
|
||||||
|
|
||||||
{/* Helper lines for snapping */}
|
|
||||||
{activeGuides.map((ln, i) =>
|
|
||||||
ln.xPx != null ? (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="helper-line helper-line-vertical"
|
|
||||||
style={{ left: ln.xPx + "px", top: 0 }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="helper-line helper-line-horizontal"
|
|
||||||
style={{ top: ln.yPx + "px", left: 0 }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Node Context Menu */}
|
|
||||||
<Menu
|
|
||||||
open={Boolean(nodeContextMenu)}
|
|
||||||
onClose={() => setNodeContextMenu(null)}
|
|
||||||
anchorReference="anchorPosition"
|
|
||||||
anchorPosition={nodeContextMenu ? { top: nodeContextMenu.mouseY, left: nodeContextMenu.mouseX } : undefined}
|
|
||||||
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
|
|
||||||
>
|
|
||||||
<MenuItem onClick={() => handleEditNodeProps(nodeContextMenu.nodeId)}>
|
|
||||||
<EditIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} />
|
|
||||||
Edit Properties
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem onClick={() => handleDisconnectAllEdges(nodeContextMenu.nodeId)}>
|
|
||||||
<PolylineIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} />
|
|
||||||
Disconnect All Edges
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem onClick={() => handleRemoveNode(nodeContextMenu.nodeId)}>
|
|
||||||
<DeleteForeverIcon sx={{ fontSize: 18, color: "#ff4f4f", mr: 1 }} />
|
|
||||||
Remove Node
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
|
|
||||||
{/* Edge Context Menu */}
|
|
||||||
<Menu
|
|
||||||
open={Boolean(edgeContextMenu)}
|
|
||||||
onClose={() => setEdgeContextMenu(null)}
|
|
||||||
anchorReference="anchorPosition"
|
|
||||||
anchorPosition={edgeContextMenu ? { top: edgeContextMenu.mouseY, left: edgeContextMenu.mouseX } : undefined}
|
|
||||||
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
|
|
||||||
>
|
|
||||||
<MenuItem onClick={() => handleEditEdgeProps(edgeContextMenu.edgeId)}>
|
|
||||||
<EditIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} />
|
|
||||||
Edit Properties
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem onClick={() => handleUnlinkEdge(edgeContextMenu.edgeId)}>
|
|
||||||
<DeleteForeverIcon sx={{ fontSize: 18, color: "#ff4f4f", mr: 1 }} />
|
|
||||||
Unlink Edge
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Flow_Tabs.jsx
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { Box, Tabs, Tab, Tooltip } from "@mui/material";
|
|
||||||
import { Add as AddIcon } from "@mui/icons-material";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the tab bar (including the "add tab" button).
|
|
||||||
*
|
|
||||||
* Props:
|
|
||||||
* - tabs (array of {id, tab_name, nodes, edges})
|
|
||||||
* - activeTabId (string)
|
|
||||||
* - onTabChange(newActiveTabId: string)
|
|
||||||
* - onAddTab()
|
|
||||||
* - onTabRightClick(evt: MouseEvent, tabId: string)
|
|
||||||
*/
|
|
||||||
export default function FlowTabs({
|
|
||||||
tabs,
|
|
||||||
activeTabId,
|
|
||||||
onTabChange,
|
|
||||||
onAddTab,
|
|
||||||
onTabRightClick
|
|
||||||
}) {
|
|
||||||
// Determine the currently active tab index
|
|
||||||
const activeIndex = (() => {
|
|
||||||
const idx = tabs.findIndex((t) => t.id === activeTabId);
|
|
||||||
return idx >= 0 ? idx : 0;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Handle tab clicks
|
|
||||||
const handleChange = (event, newValue) => {
|
|
||||||
if (newValue === "__addtab__") {
|
|
||||||
// The "plus" tab
|
|
||||||
onAddTab();
|
|
||||||
} else {
|
|
||||||
// normal tab index
|
|
||||||
const newTab = tabs[newValue];
|
|
||||||
if (newTab) {
|
|
||||||
onTabChange(newTab.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
backgroundColor: "#232323",
|
|
||||||
borderBottom: "1px solid #333",
|
|
||||||
height: "36px"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tabs
|
|
||||||
value={activeIndex}
|
|
||||||
onChange={handleChange}
|
|
||||||
variant="scrollable"
|
|
||||||
scrollButtons="auto"
|
|
||||||
textColor="inherit"
|
|
||||||
TabIndicatorProps={{
|
|
||||||
style: { backgroundColor: "#58a6ff" }
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
minHeight: "36px",
|
|
||||||
height: "36px",
|
|
||||||
flexGrow: 1
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tabs.map((tab, index) => (
|
|
||||||
<Tab
|
|
||||||
key={tab.id}
|
|
||||||
label={tab.tab_name}
|
|
||||||
value={index}
|
|
||||||
onContextMenu={(evt) => onTabRightClick(evt, tab.id)}
|
|
||||||
sx={{
|
|
||||||
minHeight: "36px",
|
|
||||||
height: "36px",
|
|
||||||
textTransform: "none",
|
|
||||||
backgroundColor: tab.id === activeTabId ? "#2C2C2C" : "transparent",
|
|
||||||
color: "#58a6ff"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{/* The "plus" tab has a special value */}
|
|
||||||
<Tooltip title="Create a New Concurrent Tab" arrow>
|
|
||||||
<Tab
|
|
||||||
icon={<AddIcon />}
|
|
||||||
value="__addtab__"
|
|
||||||
sx={{
|
|
||||||
minHeight: "36px",
|
|
||||||
height: "36px",
|
|
||||||
color: "#58a6ff",
|
|
||||||
textTransform: "none"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Tabs>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,485 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Node_Configuration_Sidebar.jsx
|
|
||||||
import { Box, Typography, Tabs, Tab, TextField, MenuItem, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, Button, Tooltip } from "@mui/material";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { useReactFlow } from "reactflow";
|
|
||||||
import ReactMarkdown from "react-markdown"; // Used for Node Usage Documentation
|
|
||||||
import EditIcon from "@mui/icons-material/Edit";
|
|
||||||
import PaletteIcon from "@mui/icons-material/Palette";
|
|
||||||
import { SketchPicker } from "react-color";
|
|
||||||
|
|
||||||
// ---- NEW: Brightness utility for gradient ----
|
|
||||||
function darkenColor(hex, percent = 0.7) {
|
|
||||||
if (!/^#[0-9A-Fa-f]{6}$/.test(hex)) return hex;
|
|
||||||
let r = parseInt(hex.slice(1, 3), 16);
|
|
||||||
let g = parseInt(hex.slice(3, 5), 16);
|
|
||||||
let b = parseInt(hex.slice(5, 7), 16);
|
|
||||||
r = Math.round(r * percent);
|
|
||||||
g = Math.round(g * percent);
|
|
||||||
b = Math.round(b * percent);
|
|
||||||
return `#${r.toString(16).padStart(2,"0")}${g.toString(16).padStart(2,"0")}${b.toString(16).padStart(2,"0")}`;
|
|
||||||
}
|
|
||||||
// --------------------------------------------
|
|
||||||
|
|
||||||
export default function NodeConfigurationSidebar({ drawerOpen, setDrawerOpen, title, nodeData, setNodes, selectedNode }) {
|
|
||||||
const [activeTab, setActiveTab] = useState(0);
|
|
||||||
const contextSetNodes = useReactFlow().setNodes;
|
|
||||||
// Use setNodes from props if provided, else fallback to context (for backward compatibility)
|
|
||||||
const effectiveSetNodes = setNodes || contextSetNodes;
|
|
||||||
const handleTabChange = (_, newValue) => setActiveTab(newValue);
|
|
||||||
|
|
||||||
// Rename dialog state
|
|
||||||
const [renameOpen, setRenameOpen] = useState(false);
|
|
||||||
const [renameValue, setRenameValue] = useState(title || "");
|
|
||||||
|
|
||||||
// ---- NEW: Accent Color Picker ----
|
|
||||||
const [colorDialogOpen, setColorDialogOpen] = useState(false);
|
|
||||||
const accentColor = selectedNode?.data?.accentColor || "#58a6ff";
|
|
||||||
// ----------------------------------
|
|
||||||
|
|
||||||
const renderConfigFields = () => {
|
|
||||||
const config = nodeData?.config || [];
|
|
||||||
const nodeId = nodeData?.nodeId;
|
|
||||||
|
|
||||||
const normalizeOptions = (opts = []) =>
|
|
||||||
opts.map((opt) => {
|
|
||||||
if (typeof opt === "string") {
|
|
||||||
return { value: opt, label: opt, disabled: false };
|
|
||||||
}
|
|
||||||
if (opt && typeof opt === "object") {
|
|
||||||
const val =
|
|
||||||
opt.value ??
|
|
||||||
opt.id ??
|
|
||||||
opt.handle ??
|
|
||||||
(typeof opt.label === "string" ? opt.label : "");
|
|
||||||
const label =
|
|
||||||
opt.label ??
|
|
||||||
opt.name ??
|
|
||||||
opt.title ??
|
|
||||||
(typeof val !== "undefined" ? String(val) : "");
|
|
||||||
return {
|
|
||||||
value: typeof val === "undefined" ? "" : String(val),
|
|
||||||
label: typeof label === "undefined" ? "" : String(label),
|
|
||||||
disabled: Boolean(opt.disabled)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { value: String(opt ?? ""), label: String(opt ?? ""), disabled: false };
|
|
||||||
});
|
|
||||||
|
|
||||||
return config.map((field, index) => {
|
|
||||||
const value = nodeData?.[field.key] ?? "";
|
|
||||||
const isReadOnly = Boolean(field.readOnly);
|
|
||||||
|
|
||||||
// ---- DYNAMIC DROPDOWN SUPPORT ----
|
|
||||||
if (field.type === "select") {
|
|
||||||
let options = field.options || [];
|
|
||||||
|
|
||||||
if (field.optionsKey && Array.isArray(nodeData?.[field.optionsKey])) {
|
|
||||||
options = nodeData[field.optionsKey];
|
|
||||||
} else if (field.dynamicOptions && nodeData?.windowList && Array.isArray(nodeData.windowList)) {
|
|
||||||
options = nodeData.windowList
|
|
||||||
.map((win) => ({
|
|
||||||
value: String(win.handle),
|
|
||||||
label: `${win.title} (${win.handle})`
|
|
||||||
}))
|
|
||||||
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }));
|
|
||||||
}
|
|
||||||
|
|
||||||
options = normalizeOptions(options);
|
|
||||||
|
|
||||||
// Handle dynamic options for things like Target Window
|
|
||||||
if (field.dynamicOptions && (!nodeData?.windowList || !Array.isArray(nodeData.windowList))) {
|
|
||||||
options = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box key={index} sx={{ mb: 2 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: "#ccc", mb: 0.5 }}>
|
|
||||||
{field.label || field.key}
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (isReadOnly) return;
|
|
||||||
const newValue = e.target.value;
|
|
||||||
if (!nodeId) return;
|
|
||||||
effectiveSetNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === nodeId
|
|
||||||
? { ...n, data: { ...n.data, [field.key]: newValue } }
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
window.BorealisValueBus[nodeId] = newValue;
|
|
||||||
}}
|
|
||||||
SelectProps={{
|
|
||||||
MenuProps: {
|
|
||||||
PaperProps: {
|
|
||||||
sx: {
|
|
||||||
bgcolor: "#1e1e1e",
|
|
||||||
color: "#ccc",
|
|
||||||
border: "1px solid #58a6ff",
|
|
||||||
"& .MuiMenuItem-root": {
|
|
||||||
color: "#ccc",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor: "#2a2a2a"
|
|
||||||
},
|
|
||||||
"&.Mui-selected": {
|
|
||||||
backgroundColor: "#2c2c2c !important",
|
|
||||||
color: "#58a6ff"
|
|
||||||
},
|
|
||||||
"&.Mui-selected:hover": {
|
|
||||||
backgroundColor: "#2a2a2a !important"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "#1e1e1e",
|
|
||||||
color: "#ccc",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
"& fieldset": {
|
|
||||||
borderColor: "#444"
|
|
||||||
},
|
|
||||||
"&:hover fieldset": {
|
|
||||||
borderColor: "#58a6ff"
|
|
||||||
},
|
|
||||||
"&.Mui-focused fieldset": {
|
|
||||||
borderColor: "#58a6ff"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"& .MuiSelect-select": {
|
|
||||||
backgroundColor: "#1e1e1e"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{options.length === 0 ? (
|
|
||||||
<MenuItem disabled value="">
|
|
||||||
{field.label === "Target Window"
|
|
||||||
? "No windows detected"
|
|
||||||
: "No options"}
|
|
||||||
</MenuItem>
|
|
||||||
) : (
|
|
||||||
options.map((opt, idx) => (
|
|
||||||
<MenuItem key={idx} value={opt.value} disabled={opt.disabled}>
|
|
||||||
{opt.label}
|
|
||||||
</MenuItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TextField>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// ---- END DYNAMIC DROPDOWN SUPPORT ----
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box key={index} sx={{ mb: 2 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: "#ccc", mb: 0.5 }}>
|
|
||||||
{field.label || field.key}
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
fullWidth
|
|
||||||
value={value}
|
|
||||||
disabled={isReadOnly}
|
|
||||||
InputProps={{
|
|
||||||
readOnly: isReadOnly,
|
|
||||||
sx: {
|
|
||||||
color: "#ccc",
|
|
||||||
backgroundColor: "#1e1e1e",
|
|
||||||
"& fieldset": { borderColor: "#444" },
|
|
||||||
"&:hover fieldset": { borderColor: "#666" },
|
|
||||||
"&.Mui-focused fieldset": { borderColor: "#58a6ff" }
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (isReadOnly) return;
|
|
||||||
const newValue = e.target.value;
|
|
||||||
if (!nodeId) return;
|
|
||||||
effectiveSetNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === nodeId
|
|
||||||
? { ...n, data: { ...n.data, [field.key]: newValue } }
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
window.BorealisValueBus[nodeId] = newValue;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- NEW: Accent Color Button ----
|
|
||||||
const renderAccentColorButton = () => (
|
|
||||||
<Tooltip title="Override Node Header/Accent Color">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label="Override Node Color"
|
|
||||||
onClick={() => setColorDialogOpen(true)}
|
|
||||||
sx={{
|
|
||||||
ml: 1,
|
|
||||||
border: "1px solid #58a6ff",
|
|
||||||
background: accentColor,
|
|
||||||
color: "#222",
|
|
||||||
width: 28, height: 28, p: 0
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PaletteIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
// ----------------------------------
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Box
|
|
||||||
onClick={() => setDrawerOpen(false)}
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.3)",
|
|
||||||
opacity: drawerOpen ? 1 : 0,
|
|
||||||
pointerEvents: drawerOpen ? "auto" : "none",
|
|
||||||
transition: "opacity 0.6s ease",
|
|
||||||
zIndex: 10
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: 400,
|
|
||||||
bgcolor: "#2C2C2C",
|
|
||||||
color: "#ccc",
|
|
||||||
borderLeft: "1px solid #333",
|
|
||||||
padding: 0,
|
|
||||||
zIndex: 11,
|
|
||||||
overflowY: "auto",
|
|
||||||
transform: drawerOpen ? "translateX(0)" : "translateX(100%)",
|
|
||||||
transition: "transform 0.3s ease"
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Box sx={{ backgroundColor: "#232323", borderBottom: "1px solid #333" }}>
|
|
||||||
<Box sx={{ padding: "12px 16px" }}>
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
|
||||||
<Typography variant="h7" sx={{ color: "#0475c2", fontWeight: "bold" }}>
|
|
||||||
{"Edit " + (title || "Node")}
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label="Rename Node"
|
|
||||||
onClick={() => {
|
|
||||||
setRenameValue(title || "");
|
|
||||||
setRenameOpen(true);
|
|
||||||
}}
|
|
||||||
sx={{ ml: 1, color: "#58a6ff" }}
|
|
||||||
>
|
|
||||||
<EditIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
{/* ---- NEW: Accent Color Picker button next to pencil ---- */}
|
|
||||||
{renderAccentColorButton()}
|
|
||||||
{/* ------------------------------------------------------ */}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Tabs
|
|
||||||
value={activeTab}
|
|
||||||
onChange={handleTabChange}
|
|
||||||
variant="fullWidth"
|
|
||||||
textColor="inherit"
|
|
||||||
TabIndicatorProps={{ style: { backgroundColor: "#ccc" } }}
|
|
||||||
sx={{
|
|
||||||
borderTop: "1px solid #333",
|
|
||||||
borderBottom: "1px solid #333",
|
|
||||||
minHeight: "36px",
|
|
||||||
height: "36px"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tab
|
|
||||||
label="Config"
|
|
||||||
sx={{
|
|
||||||
color: "#ccc",
|
|
||||||
"&.Mui-selected": { color: "#ccc" },
|
|
||||||
minHeight: "36px",
|
|
||||||
height: "36px",
|
|
||||||
textTransform: "none"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tab
|
|
||||||
label="Usage Docs"
|
|
||||||
sx={{
|
|
||||||
color: "#ccc",
|
|
||||||
"&.Mui-selected": { color: "#ccc" },
|
|
||||||
minHeight: "36px",
|
|
||||||
height: "36px",
|
|
||||||
textTransform: "none"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</Tabs>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ padding: 2 }}>
|
|
||||||
{activeTab === 0 && renderConfigFields()}
|
|
||||||
{activeTab === 1 && (
|
|
||||||
<Box sx={{ fontSize: "0.85rem", color: "#aaa" }}>
|
|
||||||
<ReactMarkdown
|
|
||||||
children={nodeData?.usage_documentation || "No usage documentation provided for this node."}
|
|
||||||
components={{
|
|
||||||
h3: ({ node, ...props }) => (
|
|
||||||
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 1 }} {...props} />
|
|
||||||
),
|
|
||||||
p: ({ node, ...props }) => (
|
|
||||||
<Typography paragraph sx={{ mb: 1.5 }} {...props} />
|
|
||||||
),
|
|
||||||
ul: ({ node, ...props }) => (
|
|
||||||
<ul style={{ marginBottom: "1em", paddingLeft: "1.2em" }} {...props} />
|
|
||||||
),
|
|
||||||
li: ({ node, ...props }) => (
|
|
||||||
<li style={{ marginBottom: "0.5em" }} {...props} />
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Rename Node Dialog */}
|
|
||||||
<Dialog
|
|
||||||
open={renameOpen}
|
|
||||||
onClose={() => setRenameOpen(false)}
|
|
||||||
PaperProps={{ sx: { bgcolor: "#232323" } }}
|
|
||||||
>
|
|
||||||
<DialogTitle>Rename Node</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<TextField
|
|
||||||
autoFocus
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
label="Node Title"
|
|
||||||
value={renameValue}
|
|
||||||
onChange={(e) => setRenameValue(e.target.value)}
|
|
||||||
sx={{
|
|
||||||
mt: 1,
|
|
||||||
bgcolor: "#1e1e1e",
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
color: "#ccc",
|
|
||||||
backgroundColor: "#1e1e1e",
|
|
||||||
"& fieldset": { borderColor: "#444" }
|
|
||||||
},
|
|
||||||
label: { color: "#aaa" }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button sx={{ color: "#aaa" }} onClick={() => setRenameOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
sx={{ color: "#58a6ff" }}
|
|
||||||
onClick={() => {
|
|
||||||
// Use selectedNode (passed as prop) or nodeData?.nodeId as fallback
|
|
||||||
const nodeId = selectedNode?.id || nodeData?.nodeId;
|
|
||||||
if (!nodeId) {
|
|
||||||
setRenameOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
effectiveSetNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === nodeId
|
|
||||||
? { ...n, data: { ...n.data, label: renameValue } }
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setRenameOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* ---- Accent Color Picker Dialog ---- */}
|
|
||||||
<Dialog
|
|
||||||
open={colorDialogOpen}
|
|
||||||
onClose={() => setColorDialogOpen(false)}
|
|
||||||
PaperProps={{ sx: { bgcolor: "#232323" } }}
|
|
||||||
>
|
|
||||||
<DialogTitle>Pick Node Header/Accent Color</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<SketchPicker
|
|
||||||
color={accentColor}
|
|
||||||
onChangeComplete={(color) => {
|
|
||||||
const nodeId = selectedNode?.id || nodeData?.nodeId;
|
|
||||||
if (!nodeId) return;
|
|
||||||
const accent = color.hex;
|
|
||||||
const accentDark = darkenColor(accent, 0.7);
|
|
||||||
effectiveSetNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === nodeId
|
|
||||||
? {
|
|
||||||
...n,
|
|
||||||
data: { ...n.data, accentColor: accent },
|
|
||||||
style: {
|
|
||||||
...n.style,
|
|
||||||
"--borealis-accent": accent,
|
|
||||||
"--borealis-accent-dark": accentDark,
|
|
||||||
"--borealis-title": accent,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
disableAlpha
|
|
||||||
presetColors={[
|
|
||||||
"#58a6ff", "#0475c2", "#00d18c", "#ff4f4f", "#ff8c00",
|
|
||||||
"#6b21a8", "#0e7490", "#888", "#fff", "#000"
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Box sx={{ mt: 2 }}>
|
|
||||||
<Typography variant="body2">
|
|
||||||
The node's header text and accent gradient will use your selected color.<br />
|
|
||||||
The accent gradient fades to a slightly darker version.
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ mt: 2, display: "flex", alignItems: "center" }}>
|
|
||||||
<span style={{
|
|
||||||
display: "inline-block",
|
|
||||||
width: 48,
|
|
||||||
height: 22,
|
|
||||||
borderRadius: 4,
|
|
||||||
border: "1px solid #888",
|
|
||||||
background: `linear-gradient(to bottom, ${accentColor} 0%, ${darkenColor(accentColor, 0.7)} 100%)`
|
|
||||||
}} />
|
|
||||||
<span style={{ marginLeft: 10, color: accentColor, fontWeight: "bold" }}>
|
|
||||||
{accentColor}
|
|
||||||
</span>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setColorDialogOpen(false)} sx={{ color: "#aaa" }}>Close</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
{/* ---- END ACCENT COLOR PICKER DIALOG ---- */}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Node_Sidebar.jsx
|
|
||||||
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionSummary,
|
|
||||||
AccordionDetails,
|
|
||||||
Button,
|
|
||||||
Tooltip,
|
|
||||||
Typography,
|
|
||||||
Box
|
|
||||||
} from "@mui/material";
|
|
||||||
import {
|
|
||||||
ExpandMore as ExpandMoreIcon,
|
|
||||||
SaveAlt as SaveAltIcon,
|
|
||||||
Save as SaveIcon,
|
|
||||||
FileOpen as FileOpenIcon,
|
|
||||||
DeleteForever as DeleteForeverIcon,
|
|
||||||
DragIndicator as DragIndicatorIcon,
|
|
||||||
Polyline as PolylineIcon,
|
|
||||||
ChevronLeft as ChevronLeftIcon,
|
|
||||||
ChevronRight as ChevronRightIcon
|
|
||||||
} from "@mui/icons-material";
|
|
||||||
import { SaveWorkflowDialog } from "../Dialogs";
|
|
||||||
|
|
||||||
export default function NodeSidebar({
|
|
||||||
categorizedNodes,
|
|
||||||
handleExportFlow,
|
|
||||||
handleImportFlow,
|
|
||||||
handleSaveFlow,
|
|
||||||
handleOpenCloseAllDialog,
|
|
||||||
fileInputRef,
|
|
||||||
onFileInputChange,
|
|
||||||
currentTabName
|
|
||||||
}) {
|
|
||||||
const [expandedCategory, setExpandedCategory] = useState(null);
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
|
||||||
const [saveOpen, setSaveOpen] = useState(false);
|
|
||||||
const [saveName, setSaveName] = useState("");
|
|
||||||
|
|
||||||
const handleAccordionChange = (category) => (_, isExpanded) => {
|
|
||||||
setExpandedCategory(isExpanded ? category : null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: collapsed ? 40 : 300,
|
|
||||||
backgroundColor: "#121212",
|
|
||||||
borderRight: "1px solid #333",
|
|
||||||
overflow: "hidden",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
height: "100%"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ flex: 1, overflowY: "auto" }}>
|
|
||||||
{!collapsed && (
|
|
||||||
<>
|
|
||||||
{/* Workflows Section */}
|
|
||||||
<Accordion
|
|
||||||
defaultExpanded
|
|
||||||
square
|
|
||||||
disableGutters
|
|
||||||
sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
|
|
||||||
>
|
|
||||||
<AccordionSummary
|
|
||||||
expandIcon={<ExpandMoreIcon />}
|
|
||||||
sx={{
|
|
||||||
backgroundColor: "#2c2c2c",
|
|
||||||
minHeight: "36px",
|
|
||||||
"& .MuiAccordionSummary-content": { margin: 0 }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography sx={{ fontSize: "0.9rem", color: "#0475c2" }}>
|
|
||||||
<b>Workflows</b>
|
|
||||||
</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
|
|
||||||
<Tooltip title="Save Current Flow to Workflows Folder" placement="right" arrow>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
startIcon={<SaveIcon />}
|
|
||||||
onClick={() => {
|
|
||||||
setSaveName(currentTabName || "workflow");
|
|
||||||
setSaveOpen(true);
|
|
||||||
}}
|
|
||||||
sx={buttonStyle}
|
|
||||||
>
|
|
||||||
Save Workflow
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="Import JSON File into New Flow Tab" placement="right" arrow>
|
|
||||||
<Button fullWidth startIcon={<FileOpenIcon />} onClick={handleImportFlow} sx={buttonStyle}>
|
|
||||||
Import Workflow (JSON)
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="Export Current Tab to a JSON File" placement="right" arrow>
|
|
||||||
<Button fullWidth startIcon={<SaveAltIcon />} onClick={handleExportFlow} sx={buttonStyle}>
|
|
||||||
Export Workflow (JSON)
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
{/* Nodes Section */}
|
|
||||||
<Accordion
|
|
||||||
defaultExpanded
|
|
||||||
square
|
|
||||||
disableGutters
|
|
||||||
sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
|
|
||||||
>
|
|
||||||
<AccordionSummary
|
|
||||||
expandIcon={<ExpandMoreIcon />}
|
|
||||||
sx={{
|
|
||||||
backgroundColor: "#2c2c2c",
|
|
||||||
minHeight: "36px",
|
|
||||||
"& .MuiAccordionSummary-content": { margin: 0 }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography sx={{ fontSize: "0.9rem", color: "#0475c2" }}>
|
|
||||||
<b>Nodes</b>
|
|
||||||
</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails sx={{ p: 0 }}>
|
|
||||||
{Object.entries(categorizedNodes).map(([category, items]) => (
|
|
||||||
<Accordion
|
|
||||||
key={category}
|
|
||||||
square
|
|
||||||
expanded={expandedCategory === category}
|
|
||||||
onChange={handleAccordionChange(category)}
|
|
||||||
disableGutters
|
|
||||||
sx={{
|
|
||||||
bgcolor: "#232323",
|
|
||||||
"&:before": { display: "none" },
|
|
||||||
margin: 0,
|
|
||||||
border: 0
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AccordionSummary
|
|
||||||
expandIcon={<ExpandMoreIcon />}
|
|
||||||
sx={{
|
|
||||||
bgcolor: "#1e1e1e",
|
|
||||||
px: 2,
|
|
||||||
minHeight: "32px",
|
|
||||||
"& .MuiAccordionSummary-content": { margin: 0 }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography sx={{ color: "#888", fontSize: "0.75rem" }}>
|
|
||||||
{category}
|
|
||||||
</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails sx={{ px: 1, py: 0 }}>
|
|
||||||
{items.map((nodeDef) => (
|
|
||||||
<Tooltip
|
|
||||||
key={`${category}-${nodeDef.type}`}
|
|
||||||
title={
|
|
||||||
<span style={{ whiteSpace: "pre-line", wordWrap: "break-word", maxWidth: 220 }}>
|
|
||||||
{nodeDef.description || "Drag & Drop into Editor"}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
placement="right"
|
|
||||||
arrow
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
sx={nodeButtonStyle}
|
|
||||||
draggable
|
|
||||||
onDragStart={(event) => {
|
|
||||||
event.dataTransfer.setData("application/reactflow", nodeDef.type);
|
|
||||||
event.dataTransfer.effectAllowed = "move";
|
|
||||||
}}
|
|
||||||
startIcon={<DragIndicatorIcon sx={{ color: "#666", fontSize: 18 }} />}
|
|
||||||
>
|
|
||||||
<span style={{ flexGrow: 1, textAlign: "left" }}>{nodeDef.label}</span>
|
|
||||||
<PolylineIcon sx={{ color: "#58a6ff", fontSize: 18, ml: 1 }} />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
))}
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
{/* Hidden file input */}
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".json,application/json"
|
|
||||||
style={{ display: "none" }}
|
|
||||||
ref={fileInputRef}
|
|
||||||
onChange={onFileInputChange}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom toggle button */}
|
|
||||||
<Tooltip title={collapsed ? "Expand Sidebar" : "Collapse Sidebar"} placement="left">
|
|
||||||
<Box
|
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
|
||||||
sx={{
|
|
||||||
height: "36px",
|
|
||||||
borderTop: "1px solid #333",
|
|
||||||
cursor: "pointer",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
color: "#888",
|
|
||||||
backgroundColor: "#121212",
|
|
||||||
transition: "background-color 0.2s ease",
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor: "#1e1e1e"
|
|
||||||
},
|
|
||||||
"&:active": {
|
|
||||||
backgroundColor: "#2a2a2a"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
<SaveWorkflowDialog
|
|
||||||
open={saveOpen}
|
|
||||||
value={saveName}
|
|
||||||
onChange={setSaveName}
|
|
||||||
onCancel={() => setSaveOpen(false)}
|
|
||||||
onSave={() => {
|
|
||||||
setSaveOpen(false);
|
|
||||||
handleSaveFlow(saveName);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const buttonStyle = {
|
|
||||||
color: "#ccc",
|
|
||||||
backgroundColor: "#232323",
|
|
||||||
justifyContent: "flex-start",
|
|
||||||
pl: 2,
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
textTransform: "none",
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor: "#2a2a2a"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const nodeButtonStyle = {
|
|
||||||
color: "#ccc",
|
|
||||||
backgroundColor: "#232323",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
pl: 2,
|
|
||||||
pr: 1,
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
textTransform: "none",
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor: "#2a2a2a"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
|
||||||
import { Box, TextField, Button, Typography } from "@mui/material";
|
|
||||||
|
|
||||||
export default function Login({ onLogin }) {
|
|
||||||
const [username, setUsername] = useState("admin");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [step, setStep] = useState("credentials"); // 'credentials' | 'mfa'
|
|
||||||
const [pendingToken, setPendingToken] = useState("");
|
|
||||||
const [mfaStage, setMfaStage] = useState(null);
|
|
||||||
const [mfaCode, setMfaCode] = useState("");
|
|
||||||
const [setupSecret, setSetupSecret] = useState("");
|
|
||||||
const [setupQr, setSetupQr] = useState("");
|
|
||||||
const [setupUri, setSetupUri] = useState("");
|
|
||||||
|
|
||||||
const formattedSecret = useMemo(() => {
|
|
||||||
if (!setupSecret) return "";
|
|
||||||
return setupSecret.replace(/(.{4})/g, "$1 ").trim();
|
|
||||||
}, [setupSecret]);
|
|
||||||
|
|
||||||
const sha512 = async (text) => {
|
|
||||||
try {
|
|
||||||
if (window.crypto && window.crypto.subtle && window.isSecureContext) {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const data = encoder.encode(text);
|
|
||||||
const hashBuffer = await window.crypto.subtle.digest("SHA-512", data);
|
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
||||||
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// fall through to return null
|
|
||||||
}
|
|
||||||
// Not a secure context or subtle crypto unavailable
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetMfaState = () => {
|
|
||||||
setStep("credentials");
|
|
||||||
setPendingToken("");
|
|
||||||
setMfaStage(null);
|
|
||||||
setMfaCode("");
|
|
||||||
setSetupSecret("");
|
|
||||||
setSetupQr("");
|
|
||||||
setSetupUri("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCredentialsSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const hash = await sha512(password);
|
|
||||||
const body = hash
|
|
||||||
? { username, password_sha512: hash }
|
|
||||||
: { username, password };
|
|
||||||
const resp = await fetch("/api/auth/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!resp.ok) {
|
|
||||||
throw new Error(data?.error || "Invalid username or password");
|
|
||||||
}
|
|
||||||
if (data?.status === "mfa_required") {
|
|
||||||
setPendingToken(data.pending_token || "");
|
|
||||||
setMfaStage(data.stage || "verify");
|
|
||||||
setStep("mfa");
|
|
||||||
setMfaCode("");
|
|
||||||
setSetupSecret(data.secret || "");
|
|
||||||
setSetupQr(data.qr_image || "");
|
|
||||||
setSetupUri(data.otpauth_url || "");
|
|
||||||
setError("");
|
|
||||||
setPassword("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data?.token) {
|
|
||||||
try {
|
|
||||||
document.cookie = `borealis_auth=${data.token}; Path=/; SameSite=Lax`;
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
onLogin({ username: data.username, role: data.role });
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err?.message || "Unable to log in";
|
|
||||||
setError(msg);
|
|
||||||
resetMfaState();
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMfaSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!pendingToken) {
|
|
||||||
setError("Your MFA session expired. Please log in again.");
|
|
||||||
resetMfaState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!mfaCode || mfaCode.trim().length < 6) {
|
|
||||||
setError("Enter the 6-digit code from your authenticator app.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const resp = await fetch("/api/auth/mfa/verify", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({ pending_token: pendingToken, code: mfaCode })
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!resp.ok) {
|
|
||||||
const errKey = data?.error;
|
|
||||||
if (errKey === "expired" || errKey === "invalid_session" || errKey === "mfa_pending") {
|
|
||||||
setError("Your MFA session expired. Please log in again.");
|
|
||||||
resetMfaState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const msgMap = {
|
|
||||||
invalid_code: "Incorrect code. Please try again.",
|
|
||||||
mfa_not_configured: "MFA is not configured for this account."
|
|
||||||
};
|
|
||||||
setError(msgMap[errKey] || data?.error || "Failed to verify code.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data?.token) {
|
|
||||||
try {
|
|
||||||
document.cookie = `borealis_auth=${data.token}; Path=/; SameSite=Lax`;
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
setError("");
|
|
||||||
onLogin({ username: data.username, role: data.role });
|
|
||||||
} catch (err) {
|
|
||||||
setError("Failed to verify code.");
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBackToLogin = () => {
|
|
||||||
resetMfaState();
|
|
||||||
setPassword("");
|
|
||||||
setError("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCodeChange = (event) => {
|
|
||||||
const raw = event.target.value || "";
|
|
||||||
const digits = raw.replace(/\D/g, "").slice(0, 6);
|
|
||||||
setMfaCode(digits);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formTitle = step === "mfa"
|
|
||||||
? "Multi-Factor Authentication"
|
|
||||||
: "Borealis - Automation Platform";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
height: "100vh",
|
|
||||||
backgroundColor: "#2b2b2b",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
component="form"
|
|
||||||
onSubmit={step === "mfa" ? handleMfaSubmit : handleCredentialsSubmit}
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
width: 320,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src="/Borealis_Logo.png"
|
|
||||||
alt="Borealis Logo"
|
|
||||||
style={{ width: "120px", marginBottom: "16px" }}
|
|
||||||
/>
|
|
||||||
<Typography variant="h6" sx={{ mb: 2, textAlign: "center" }}>
|
|
||||||
{formTitle}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{step === "credentials" ? (
|
|
||||||
<>
|
|
||||||
<TextField
|
|
||||||
label="Username"
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
value={username}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Password"
|
|
||||||
type="password"
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
value={password}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
{error && (
|
|
||||||
<Typography color="error" sx={{ mt: 1 }}>
|
|
||||||
{error}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="contained"
|
|
||||||
fullWidth
|
|
||||||
disabled={isSubmitting}
|
|
||||||
sx={{ mt: 2, bgcolor: "#58a6ff", "&:hover": { bgcolor: "#1d82d3" } }}
|
|
||||||
>
|
|
||||||
{isSubmitting ? "Signing In..." : "Login"}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{mfaStage === "setup" ? (
|
|
||||||
<>
|
|
||||||
<Typography variant="body2" sx={{ color: "#ccc", textAlign: "center", mb: 2 }}>
|
|
||||||
Scan the QR code with your authenticator app, then enter the 6-digit code to complete setup for {username}.
|
|
||||||
</Typography>
|
|
||||||
{setupQr ? (
|
|
||||||
<img
|
|
||||||
src={setupQr}
|
|
||||||
alt="MFA enrollment QR code"
|
|
||||||
style={{ width: "180px", height: "180px", marginBottom: "12px" }}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{formattedSecret ? (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
bgcolor: "#1d1d1d",
|
|
||||||
borderRadius: 1,
|
|
||||||
px: 2,
|
|
||||||
py: 1,
|
|
||||||
mb: 1.5,
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="caption" sx={{ color: "#999" }}>
|
|
||||||
Manual code
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="body1"
|
|
||||||
sx={{
|
|
||||||
fontFamily: "monospace",
|
|
||||||
letterSpacing: "0.3rem",
|
|
||||||
color: "#fff",
|
|
||||||
mt: 0.5,
|
|
||||||
textAlign: "center",
|
|
||||||
wordBreak: "break-word",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formattedSecret}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
{setupUri ? (
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
color: "#888",
|
|
||||||
mb: 2,
|
|
||||||
wordBreak: "break-all",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{setupUri}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Typography variant="body2" sx={{ color: "#ccc", textAlign: "center", mb: 2 }}>
|
|
||||||
Enter the 6-digit code from your authenticator app for {username}.
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="6-digit code"
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
value={mfaCode}
|
|
||||||
onChange={onCodeChange}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
margin="normal"
|
|
||||||
inputProps={{
|
|
||||||
inputMode: "numeric",
|
|
||||||
pattern: "[0-9]*",
|
|
||||||
maxLength: 6,
|
|
||||||
style: { letterSpacing: "0.4rem", textAlign: "center", fontSize: "1.2rem" }
|
|
||||||
}}
|
|
||||||
autoComplete="one-time-code"
|
|
||||||
/>
|
|
||||||
{error && (
|
|
||||||
<Typography color="error" sx={{ mt: 1, textAlign: "center" }}>
|
|
||||||
{error}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="contained"
|
|
||||||
fullWidth
|
|
||||||
disabled={isSubmitting || mfaCode.length < 6}
|
|
||||||
sx={{ mt: 2, bgcolor: "#58a6ff", "&:hover": { bgcolor: "#1d82d3" } }}
|
|
||||||
>
|
|
||||||
{isSubmitting ? "Verifying..." : "Verify Code"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="text"
|
|
||||||
fullWidth
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onClick={handleBackToLogin}
|
|
||||||
sx={{ mt: 1, color: "#58a6ff" }}
|
|
||||||
>
|
|
||||||
Use a different account
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,409 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Navigation_Sidebar.jsx
|
|
||||||
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionSummary,
|
|
||||||
AccordionDetails,
|
|
||||||
Typography,
|
|
||||||
Box,
|
|
||||||
ListItemButton,
|
|
||||||
ListItemText
|
|
||||||
} from "@mui/material";
|
|
||||||
import {
|
|
||||||
ExpandMore as ExpandMoreIcon,
|
|
||||||
Devices as DevicesIcon,
|
|
||||||
FilterAlt as FilterIcon,
|
|
||||||
Groups as GroupsIcon,
|
|
||||||
Work as JobsIcon,
|
|
||||||
Polyline as WorkflowsIcon,
|
|
||||||
Code as ScriptIcon,
|
|
||||||
PeopleOutline as CommunityIcon,
|
|
||||||
Apps as AssembliesIcon
|
|
||||||
} from "@mui/icons-material";
|
|
||||||
import { LocationCity as SitesIcon } from "@mui/icons-material";
|
|
||||||
import {
|
|
||||||
Dns as ServerInfoIcon,
|
|
||||||
VpnKey as CredentialIcon,
|
|
||||||
PersonOutline as UserIcon,
|
|
||||||
GitHub as GitHubIcon,
|
|
||||||
Key as KeyIcon,
|
|
||||||
AdminPanelSettings as AdminPanelSettingsIcon
|
|
||||||
} from "@mui/icons-material";
|
|
||||||
|
|
||||||
function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
|
|
||||||
const [expandedNav, setExpandedNav] = useState({
|
|
||||||
sites: true,
|
|
||||||
devices: true,
|
|
||||||
automation: true,
|
|
||||||
filters: true,
|
|
||||||
access: true,
|
|
||||||
admin: true
|
|
||||||
});
|
|
||||||
|
|
||||||
const NavItem = ({ icon, label, pageKey, indent = 0 }) => {
|
|
||||||
const active = currentPage === pageKey;
|
|
||||||
return (
|
|
||||||
<ListItemButton
|
|
||||||
onClick={() => onNavigate(pageKey)}
|
|
||||||
sx={{
|
|
||||||
pl: indent ? 4 : 2,
|
|
||||||
py: 1,
|
|
||||||
color: active ? "#e6f2ff" : "#ccc",
|
|
||||||
position: "relative",
|
|
||||||
background: active
|
|
||||||
? "linear-gradient(90deg, rgba(88,166,255,0.10) 0%, rgba(88,166,255,0.03) 60%, rgba(88,166,255,0.00) 100%)"
|
|
||||||
: "transparent",
|
|
||||||
borderTopRightRadius: 0,
|
|
||||||
borderBottomRightRadius: 0,
|
|
||||||
boxShadow: active
|
|
||||||
? "inset 0 0 0 1px rgba(88,166,255,0.25)"
|
|
||||||
: "none",
|
|
||||||
transition: "background 160ms ease, box-shadow 160ms ease, color 160ms ease",
|
|
||||||
"&:hover": {
|
|
||||||
background: active
|
|
||||||
? "linear-gradient(90deg, rgba(88,166,255,0.14) 0%, rgba(88,166,255,0.06) 60%, rgba(88,166,255,0.00) 100%)"
|
|
||||||
: "#2c2c2c"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
selected={active}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: active ? 3 : 0,
|
|
||||||
bgcolor: "#58a6ff",
|
|
||||||
borderTopRightRadius: 2,
|
|
||||||
borderBottomRightRadius: 2,
|
|
||||||
boxShadow: active ? "0 0 6px rgba(88,166,255,0.35)" : "none",
|
|
||||||
transition: "width 180ms ease, box-shadow 200ms ease"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{icon && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
mr: 1,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
color: active ? "#7db7ff" : "#58a6ff",
|
|
||||||
transition: "color 160ms ease"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<ListItemText
|
|
||||||
primary={label}
|
|
||||||
primaryTypographyProps={{ fontSize: "0.75rem", fontWeight: active ? 600 : 400 }}
|
|
||||||
/>
|
|
||||||
</ListItemButton>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: 260,
|
|
||||||
bgcolor: "#121212",
|
|
||||||
borderRight: "1px solid #333",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
overflow: "hidden"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ flex: 1, overflowY: "auto" }}>
|
|
||||||
{/* Sites */}
|
|
||||||
{(() => {
|
|
||||||
const groupActive = currentPage === "sites";
|
|
||||||
return (
|
|
||||||
<Accordion
|
|
||||||
expanded={expandedNav.sites}
|
|
||||||
onChange={(_, e) => setExpandedNav((s) => ({ ...s, sites: e }))}
|
|
||||||
square
|
|
||||||
disableGutters
|
|
||||||
sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
|
|
||||||
>
|
|
||||||
<AccordionSummary
|
|
||||||
expandIcon={<ExpandMoreIcon />}
|
|
||||||
sx={{
|
|
||||||
position: "relative",
|
|
||||||
background: groupActive
|
|
||||||
? "linear-gradient(90deg, rgba(88,166,255,0.08) 0%, rgba(88,166,255,0.00) 100%)"
|
|
||||||
: "#2c2c2c",
|
|
||||||
minHeight: "36px",
|
|
||||||
"& .MuiAccordionSummary-content": { margin: 0 },
|
|
||||||
"&::before": {
|
|
||||||
content: '""',
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: groupActive ? 3 : 0,
|
|
||||||
bgcolor: "#58a6ff",
|
|
||||||
borderTopRightRadius: 2,
|
|
||||||
borderBottomRightRadius: 2,
|
|
||||||
transition: "width 160ms ease"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography sx={{ fontSize: "0.85rem", color: "#58a6ff" }}>
|
|
||||||
<b>Sites</b>
|
|
||||||
</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
|
|
||||||
<NavItem icon={<SitesIcon fontSize="small" />} label="All Sites" pageKey="sites" />
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
{/* Inventory */}
|
|
||||||
{(() => {
|
|
||||||
const groupActive = ["devices", "ssh_devices", "winrm_devices", "agent_devices"].includes(currentPage);
|
|
||||||
return (
|
|
||||||
<Accordion
|
|
||||||
expanded={expandedNav.devices}
|
|
||||||
onChange={(_, e) => setExpandedNav((s) => ({ ...s, devices: e }))}
|
|
||||||
square
|
|
||||||
disableGutters
|
|
||||||
sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
|
|
||||||
>
|
|
||||||
<AccordionSummary
|
|
||||||
expandIcon={<ExpandMoreIcon />}
|
|
||||||
sx={{
|
|
||||||
position: "relative",
|
|
||||||
background: groupActive
|
|
||||||
? "linear-gradient(90deg, rgba(88,166,255,0.08) 0%, rgba(88,166,255,0.00) 100%)"
|
|
||||||
: "#2c2c2c",
|
|
||||||
minHeight: "36px",
|
|
||||||
"& .MuiAccordionSummary-content": { margin: 0 },
|
|
||||||
"&::before": {
|
|
||||||
content: '""',
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: groupActive ? 3 : 0,
|
|
||||||
bgcolor: "#58a6ff",
|
|
||||||
borderTopRightRadius: 2,
|
|
||||||
borderBottomRightRadius: 2,
|
|
||||||
transition: "width 160ms ease"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography sx={{ fontSize: "0.85rem", color: "#58a6ff" }}>
|
|
||||||
<b>Inventory</b>
|
|
||||||
</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
|
|
||||||
<NavItem icon={<AdminPanelSettingsIcon fontSize="small" />} label="Device Approvals" pageKey="admin_device_approvals" />
|
|
||||||
<NavItem icon={<KeyIcon fontSize="small" />} label="Enrollment Codes" pageKey="admin_enrollment_codes" indent />
|
|
||||||
<NavItem icon={<DevicesIcon fontSize="small" />} label="Devices" pageKey="devices" />
|
|
||||||
<NavItem icon={<DevicesIcon fontSize="small" />} label="Agent Devices" pageKey="agent_devices" indent />
|
|
||||||
<NavItem icon={<DevicesIcon fontSize="small" />} label="SSH Devices" pageKey="ssh_devices" indent />
|
|
||||||
<NavItem icon={<DevicesIcon fontSize="small" />} label="WinRM Devices" pageKey="winrm_devices" indent />
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Automation */}
|
|
||||||
{(() => {
|
|
||||||
const groupActive = ["jobs", "assemblies", "community"].includes(currentPage);
|
|
||||||
return (
|
|
||||||
<Accordion
|
|
||||||
expanded={expandedNav.automation}
|
|
||||||
onChange={(_, e) => setExpandedNav((s) => ({ ...s, automation: e }))}
|
|
||||||
square
|
|
||||||
disableGutters
|
|
||||||
sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
|
|
||||||
>
|
|
||||||
<AccordionSummary
|
|
||||||
expandIcon={<ExpandMoreIcon />}
|
|
||||||
sx={{
|
|
||||||
position: "relative",
|
|
||||||
background: groupActive
|
|
||||||
? "linear-gradient(90deg, rgba(88,166,255,0.08) 0%, rgba(88,166,255,0.00) 100%)"
|
|
||||||
: "#2c2c2c",
|
|
||||||
minHeight: "36px",
|
|
||||||
"& .MuiAccordionSummary-content": { margin: 0 },
|
|
||||||
"&::before": {
|
|
||||||
content: '""',
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: groupActive ? 3 : 0,
|
|
||||||
bgcolor: "#58a6ff",
|
|
||||||
borderTopRightRadius: 2,
|
|
||||||
borderBottomRightRadius: 2,
|
|
||||||
transition: "width 160ms ease"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography sx={{ fontSize: "0.85rem", color: "#58a6ff" }}>
|
|
||||||
<b>Automation</b>
|
|
||||||
</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
|
|
||||||
<NavItem icon={<AssembliesIcon fontSize="small" />} label="Assemblies" pageKey="assemblies" />
|
|
||||||
<NavItem icon={<JobsIcon fontSize="small" />} label="Scheduled Jobs" pageKey="jobs" />
|
|
||||||
<NavItem icon={<CommunityIcon fontSize="small" />} label="Community Content" pageKey="community" />
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Filters & Groups */}
|
|
||||||
{(() => {
|
|
||||||
const groupActive = currentPage === "filters" || currentPage === "groups";
|
|
||||||
return (
|
|
||||||
<Accordion
|
|
||||||
expanded={expandedNav.filters}
|
|
||||||
onChange={(_, e) => setExpandedNav((s) => ({ ...s, filters: e }))}
|
|
||||||
square
|
|
||||||
disableGutters
|
|
||||||
sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
|
|
||||||
>
|
|
||||||
<AccordionSummary
|
|
||||||
expandIcon={<ExpandMoreIcon />}
|
|
||||||
sx={{
|
|
||||||
position: "relative",
|
|
||||||
background: groupActive
|
|
||||||
? "linear-gradient(90deg, rgba(88,166,255,0.08) 0%, rgba(88,166,255,0.00) 100%)"
|
|
||||||
: "#2c2c2c",
|
|
||||||
minHeight: "36px",
|
|
||||||
"& .MuiAccordionSummary-content": { margin: 0 },
|
|
||||||
"&::before": {
|
|
||||||
content: '""',
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: groupActive ? 3 : 0,
|
|
||||||
bgcolor: "#58a6ff",
|
|
||||||
borderTopRightRadius: 2,
|
|
||||||
borderBottomRightRadius: 2,
|
|
||||||
transition: "width 160ms ease"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography sx={{ fontSize: "0.85rem", color: "#58a6ff" }}>
|
|
||||||
<b>Filters & Groups</b>
|
|
||||||
</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
|
|
||||||
<NavItem icon={<FilterIcon fontSize="small" />} label="Filters" pageKey="filters" />
|
|
||||||
<NavItem icon={<GroupsIcon fontSize="small" />} label="Groups" pageKey="groups" />
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Access Management */}
|
|
||||||
{(() => {
|
|
||||||
if (!isAdmin) return null;
|
|
||||||
const groupActive =
|
|
||||||
currentPage === "access_credentials" ||
|
|
||||||
currentPage === "access_users" ||
|
|
||||||
currentPage === "access_github_token";
|
|
||||||
return (
|
|
||||||
<Accordion
|
|
||||||
expanded={expandedNav.access}
|
|
||||||
onChange={(_, e) => setExpandedNav((s) => ({ ...s, access: e }))}
|
|
||||||
square
|
|
||||||
disableGutters
|
|
||||||
sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
|
|
||||||
>
|
|
||||||
<AccordionSummary
|
|
||||||
expandIcon={<ExpandMoreIcon />}
|
|
||||||
sx={{
|
|
||||||
position: "relative",
|
|
||||||
background: groupActive
|
|
||||||
? "linear-gradient(90deg, rgba(88,166,255,0.08) 0%, rgba(88,166,255,0.00) 100%)"
|
|
||||||
: "#2c2c2c",
|
|
||||||
minHeight: "36px",
|
|
||||||
"& .MuiAccordionSummary-content": { margin: 0 },
|
|
||||||
"&::before": {
|
|
||||||
content: '""',
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: groupActive ? 3 : 0,
|
|
||||||
bgcolor: "#58a6ff",
|
|
||||||
borderTopRightRadius: 2,
|
|
||||||
borderBottomRightRadius: 2,
|
|
||||||
transition: "width 160ms ease"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography sx={{ fontSize: "0.85rem", color: "#58a6ff" }}>
|
|
||||||
<b>Access Management</b>
|
|
||||||
</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
|
|
||||||
<NavItem icon={<CredentialIcon fontSize="small" />} label="Credentials" pageKey="access_credentials" />
|
|
||||||
<NavItem icon={<GitHubIcon fontSize="small" />} label="GitHub API Token" pageKey="access_github_token" />
|
|
||||||
<NavItem icon={<UserIcon fontSize="small" />} label="Users" pageKey="access_users" />
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Admin */}
|
|
||||||
{(() => {
|
|
||||||
if (!isAdmin) return null;
|
|
||||||
const groupActive =
|
|
||||||
currentPage === "server_info" ||
|
|
||||||
currentPage === "admin_enrollment_codes" ||
|
|
||||||
currentPage === "admin_device_approvals";
|
|
||||||
return (
|
|
||||||
<Accordion
|
|
||||||
expanded={expandedNav.admin}
|
|
||||||
onChange={(_, e) => setExpandedNav((s) => ({ ...s, admin: e }))}
|
|
||||||
square
|
|
||||||
disableGutters
|
|
||||||
sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
|
|
||||||
>
|
|
||||||
<AccordionSummary
|
|
||||||
expandIcon={<ExpandMoreIcon />}
|
|
||||||
sx={{
|
|
||||||
position: "relative",
|
|
||||||
background: groupActive
|
|
||||||
? "linear-gradient(90deg, rgba(88,166,255,0.08) 0%, rgba(88,166,255,0.00) 100%)"
|
|
||||||
: "#2c2c2c",
|
|
||||||
minHeight: "36px",
|
|
||||||
"& .MuiAccordionSummary-content": { margin: 0 },
|
|
||||||
"&::before": {
|
|
||||||
content: '""',
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: groupActive ? 3 : 0,
|
|
||||||
bgcolor: "#58a6ff",
|
|
||||||
borderTopRightRadius: 2,
|
|
||||||
borderBottomRightRadius: 2,
|
|
||||||
transition: "width 160ms ease"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography sx={{ fontSize: "0.85rem", color: "#58a6ff" }}>
|
|
||||||
<b>Admin Settings</b>
|
|
||||||
</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
|
|
||||||
<NavItem icon={<ServerInfoIcon fontSize="small" />} label="Server Info" pageKey="server_info" />
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default React.memo(NavigationSidebar);
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,593 +0,0 @@
|
|||||||
import React, { useEffect, useState, useCallback } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogActions,
|
|
||||||
Button,
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Paper,
|
|
||||||
FormControlLabel,
|
|
||||||
Checkbox,
|
|
||||||
TextField,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
Select,
|
|
||||||
MenuItem,
|
|
||||||
CircularProgress
|
|
||||||
} from "@mui/material";
|
|
||||||
import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material";
|
|
||||||
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
|
|
||||||
|
|
||||||
function buildTree(items, folders, rootLabel = "Scripts") {
|
|
||||||
const map = {};
|
|
||||||
const rootNode = {
|
|
||||||
id: "root",
|
|
||||||
label: rootLabel,
|
|
||||||
path: "",
|
|
||||||
isFolder: true,
|
|
||||||
children: []
|
|
||||||
};
|
|
||||||
map[rootNode.id] = rootNode;
|
|
||||||
|
|
||||||
(folders || []).forEach((f) => {
|
|
||||||
const parts = (f || "").split("/");
|
|
||||||
let children = rootNode.children;
|
|
||||||
let parentPath = "";
|
|
||||||
parts.forEach((part) => {
|
|
||||||
const path = parentPath ? `${parentPath}/${part}` : part;
|
|
||||||
let node = children.find((n) => n.id === path);
|
|
||||||
if (!node) {
|
|
||||||
node = { id: path, label: part, path, isFolder: true, children: [] };
|
|
||||||
children.push(node);
|
|
||||||
map[path] = node;
|
|
||||||
}
|
|
||||||
children = node.children;
|
|
||||||
parentPath = path;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
(items || []).forEach((s) => {
|
|
||||||
const parts = (s.rel_path || "").split("/");
|
|
||||||
let children = rootNode.children;
|
|
||||||
let parentPath = "";
|
|
||||||
parts.forEach((part, idx) => {
|
|
||||||
const path = parentPath ? `${parentPath}/${part}` : part;
|
|
||||||
const isFile = idx === parts.length - 1;
|
|
||||||
let node = children.find((n) => n.id === path);
|
|
||||||
if (!node) {
|
|
||||||
node = {
|
|
||||||
id: path,
|
|
||||||
label: isFile ? (s.name || s.file_name || part) : part,
|
|
||||||
path,
|
|
||||||
isFolder: !isFile,
|
|
||||||
fileName: s.file_name,
|
|
||||||
script: isFile ? s : null,
|
|
||||||
children: []
|
|
||||||
};
|
|
||||||
children.push(node);
|
|
||||||
map[path] = node;
|
|
||||||
}
|
|
||||||
if (!isFile) {
|
|
||||||
children = node.children;
|
|
||||||
parentPath = path;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return { root: [rootNode], map };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function QuickJob({ open, onClose, hostnames = [] }) {
|
|
||||||
const [tree, setTree] = useState([]);
|
|
||||||
const [nodeMap, setNodeMap] = useState({});
|
|
||||||
const [selectedPath, setSelectedPath] = useState("");
|
|
||||||
const [running, setRunning] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [runAsCurrentUser, setRunAsCurrentUser] = useState(false);
|
|
||||||
const [mode, setMode] = useState("scripts"); // 'scripts' | 'ansible'
|
|
||||||
const [credentials, setCredentials] = useState([]);
|
|
||||||
const [credentialsLoading, setCredentialsLoading] = useState(false);
|
|
||||||
const [credentialsError, setCredentialsError] = useState("");
|
|
||||||
const [selectedCredentialId, setSelectedCredentialId] = useState("");
|
|
||||||
const [useSvcAccount, setUseSvcAccount] = useState(true);
|
|
||||||
const [variables, setVariables] = useState([]);
|
|
||||||
const [variableValues, setVariableValues] = useState({});
|
|
||||||
const [variableErrors, setVariableErrors] = useState({});
|
|
||||||
const [variableStatus, setVariableStatus] = useState({ loading: false, error: "" });
|
|
||||||
|
|
||||||
const loadTree = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const island = mode === 'ansible' ? 'ansible' : 'scripts';
|
|
||||||
const resp = await fetch(`/api/assembly/list?island=${island}`);
|
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
const { root, map } = buildTree(data.items || [], data.folders || [], mode === 'ansible' ? 'Ansible Playbooks' : 'Scripts');
|
|
||||||
setTree(root);
|
|
||||||
setNodeMap(map);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load scripts:", err);
|
|
||||||
setTree([]);
|
|
||||||
setNodeMap({});
|
|
||||||
}
|
|
||||||
}, [mode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setSelectedPath("");
|
|
||||||
setError("");
|
|
||||||
setVariables([]);
|
|
||||||
setVariableValues({});
|
|
||||||
setVariableErrors({});
|
|
||||||
setVariableStatus({ loading: false, error: "" });
|
|
||||||
setUseSvcAccount(true);
|
|
||||||
setSelectedCredentialId("");
|
|
||||||
loadTree();
|
|
||||||
}
|
|
||||||
}, [open, loadTree]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || mode !== "ansible") return;
|
|
||||||
let canceled = false;
|
|
||||||
setCredentialsLoading(true);
|
|
||||||
setCredentialsError("");
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch("/api/credentials");
|
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (canceled) return;
|
|
||||||
const list = Array.isArray(data?.credentials)
|
|
||||||
? data.credentials.filter((cred) => {
|
|
||||||
const conn = String(cred.connection_type || "").toLowerCase();
|
|
||||||
return conn === "ssh" || conn === "winrm";
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
list.sort((a, b) => String(a?.name || "").localeCompare(String(b?.name || "")));
|
|
||||||
setCredentials(list);
|
|
||||||
} catch (err) {
|
|
||||||
if (!canceled) {
|
|
||||||
setCredentials([]);
|
|
||||||
setCredentialsError(String(err.message || err));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!canceled) setCredentialsLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
canceled = true;
|
|
||||||
};
|
|
||||||
}, [open, mode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
setSelectedCredentialId("");
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode !== "ansible" || useSvcAccount) return;
|
|
||||||
if (!credentials.length) {
|
|
||||||
setSelectedCredentialId("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!selectedCredentialId || !credentials.some((cred) => String(cred.id) === String(selectedCredentialId))) {
|
|
||||||
setSelectedCredentialId(String(credentials[0].id));
|
|
||||||
}
|
|
||||||
}, [mode, credentials, selectedCredentialId, useSvcAccount]);
|
|
||||||
|
|
||||||
const renderNodes = (nodes = []) =>
|
|
||||||
nodes.map((n) => (
|
|
||||||
<TreeItem
|
|
||||||
key={n.id}
|
|
||||||
itemId={n.id}
|
|
||||||
label={
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
|
||||||
{n.isFolder ? (
|
|
||||||
<FolderIcon fontSize="small" sx={{ mr: 1, color: "#ccc" }} />
|
|
||||||
) : (
|
|
||||||
<DescriptionIcon fontSize="small" sx={{ mr: 1, color: "#ccc" }} />
|
|
||||||
)}
|
|
||||||
<Typography variant="body2" sx={{ color: "#e6edf3" }}>{n.label}</Typography>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{n.children && n.children.length ? renderNodes(n.children) : null}
|
|
||||||
</TreeItem>
|
|
||||||
));
|
|
||||||
|
|
||||||
const onItemSelect = (_e, itemId) => {
|
|
||||||
const node = nodeMap[itemId];
|
|
||||||
if (node && !node.isFolder) {
|
|
||||||
setSelectedPath(node.path);
|
|
||||||
setError("");
|
|
||||||
setVariableErrors({});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeVariables = (list) => {
|
|
||||||
if (!Array.isArray(list)) return [];
|
|
||||||
return list
|
|
||||||
.map((raw) => {
|
|
||||||
if (!raw || typeof raw !== "object") return null;
|
|
||||||
const name = typeof raw.name === "string" ? raw.name.trim() : typeof raw.key === "string" ? raw.key.trim() : "";
|
|
||||||
if (!name) return null;
|
|
||||||
const type = typeof raw.type === "string" ? raw.type.toLowerCase() : "string";
|
|
||||||
const label = typeof raw.label === "string" && raw.label.trim() ? raw.label.trim() : name;
|
|
||||||
const description = typeof raw.description === "string" ? raw.description : "";
|
|
||||||
const required = Boolean(raw.required);
|
|
||||||
const defaultValue = raw.hasOwnProperty("default")
|
|
||||||
? raw.default
|
|
||||||
: raw.hasOwnProperty("defaultValue")
|
|
||||||
? raw.defaultValue
|
|
||||||
: raw.hasOwnProperty("default_value")
|
|
||||||
? raw.default_value
|
|
||||||
: "";
|
|
||||||
return { name, label, type, description, required, default: defaultValue };
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
};
|
|
||||||
|
|
||||||
const deriveInitialValue = (variable) => {
|
|
||||||
const { type, default: defaultValue } = variable;
|
|
||||||
if (type === "boolean") {
|
|
||||||
if (typeof defaultValue === "boolean") return defaultValue;
|
|
||||||
if (defaultValue == null) return false;
|
|
||||||
const str = String(defaultValue).trim().toLowerCase();
|
|
||||||
if (!str) return false;
|
|
||||||
return ["true", "1", "yes", "on"].includes(str);
|
|
||||||
}
|
|
||||||
if (type === "number") {
|
|
||||||
if (defaultValue == null || defaultValue === "") return "";
|
|
||||||
if (typeof defaultValue === "number" && Number.isFinite(defaultValue)) {
|
|
||||||
return String(defaultValue);
|
|
||||||
}
|
|
||||||
const parsed = Number(defaultValue);
|
|
||||||
return Number.isFinite(parsed) ? String(parsed) : "";
|
|
||||||
}
|
|
||||||
return defaultValue == null ? "" : String(defaultValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedPath) {
|
|
||||||
setVariables([]);
|
|
||||||
setVariableValues({});
|
|
||||||
setVariableErrors({});
|
|
||||||
setVariableStatus({ loading: false, error: "" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let canceled = false;
|
|
||||||
const loadAssembly = async () => {
|
|
||||||
setVariableStatus({ loading: true, error: "" });
|
|
||||||
try {
|
|
||||||
const island = mode === "ansible" ? "ansible" : "scripts";
|
|
||||||
const trimmed = (selectedPath || "").replace(/\\/g, "/").replace(/^\/+/, "").trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
setVariables([]);
|
|
||||||
setVariableValues({});
|
|
||||||
setVariableErrors({});
|
|
||||||
setVariableStatus({ loading: false, error: "" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let relPath = trimmed;
|
|
||||||
if (island === "scripts" && relPath.toLowerCase().startsWith("scripts/")) {
|
|
||||||
relPath = relPath.slice("Scripts/".length);
|
|
||||||
} else if (island === "ansible" && relPath.toLowerCase().startsWith("ansible_playbooks/")) {
|
|
||||||
relPath = relPath.slice("Ansible_Playbooks/".length);
|
|
||||||
}
|
|
||||||
const resp = await fetch(`/api/assembly/load?island=${island}&path=${encodeURIComponent(relPath)}`);
|
|
||||||
if (!resp.ok) throw new Error(`Failed to load assembly (HTTP ${resp.status})`);
|
|
||||||
const data = await resp.json();
|
|
||||||
const defs = normalizeVariables(data?.assembly?.variables || []);
|
|
||||||
if (!canceled) {
|
|
||||||
setVariables(defs);
|
|
||||||
const initialValues = {};
|
|
||||||
defs.forEach((v) => {
|
|
||||||
initialValues[v.name] = deriveInitialValue(v);
|
|
||||||
});
|
|
||||||
setVariableValues(initialValues);
|
|
||||||
setVariableErrors({});
|
|
||||||
setVariableStatus({ loading: false, error: "" });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (!canceled) {
|
|
||||||
setVariables([]);
|
|
||||||
setVariableValues({});
|
|
||||||
setVariableErrors({});
|
|
||||||
setVariableStatus({ loading: false, error: err?.message || String(err) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadAssembly();
|
|
||||||
return () => {
|
|
||||||
canceled = true;
|
|
||||||
};
|
|
||||||
}, [selectedPath, mode]);
|
|
||||||
|
|
||||||
const handleVariableChange = (variable, rawValue) => {
|
|
||||||
const { name, type } = variable;
|
|
||||||
if (!name) return;
|
|
||||||
setVariableValues((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[name]: type === "boolean" ? Boolean(rawValue) : rawValue
|
|
||||||
}));
|
|
||||||
setVariableErrors((prev) => {
|
|
||||||
if (!prev[name]) return prev;
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[name];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildVariablePayload = () => {
|
|
||||||
const payload = {};
|
|
||||||
variables.forEach((variable) => {
|
|
||||||
if (!variable?.name) return;
|
|
||||||
const { name, type } = variable;
|
|
||||||
const hasOverride = Object.prototype.hasOwnProperty.call(variableValues, name);
|
|
||||||
const raw = hasOverride ? variableValues[name] : deriveInitialValue(variable);
|
|
||||||
if (type === "boolean") {
|
|
||||||
payload[name] = Boolean(raw);
|
|
||||||
} else if (type === "number") {
|
|
||||||
if (raw === "" || raw === null || raw === undefined) {
|
|
||||||
payload[name] = "";
|
|
||||||
} else {
|
|
||||||
const num = Number(raw);
|
|
||||||
payload[name] = Number.isFinite(num) ? num : "";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
payload[name] = raw == null ? "" : String(raw);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return payload;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onRun = async () => {
|
|
||||||
if (!selectedPath) {
|
|
||||||
setError(mode === 'ansible' ? "Please choose a playbook to run." : "Please choose a script to run.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mode === 'ansible' && !useSvcAccount && !selectedCredentialId) {
|
|
||||||
setError("Select a credential to run this playbook.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (variables.length) {
|
|
||||||
const errors = {};
|
|
||||||
variables.forEach((variable) => {
|
|
||||||
if (!variable) return;
|
|
||||||
if (!variable.required) return;
|
|
||||||
if (variable.type === "boolean") return;
|
|
||||||
const hasOverride = Object.prototype.hasOwnProperty.call(variableValues, variable.name);
|
|
||||||
const raw = hasOverride ? variableValues[variable.name] : deriveInitialValue(variable);
|
|
||||||
if (raw == null || raw === "") {
|
|
||||||
errors[variable.name] = "Required";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (Object.keys(errors).length) {
|
|
||||||
setVariableErrors(errors);
|
|
||||||
setError("Please fill in all required variable values.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setRunning(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
let resp;
|
|
||||||
const variableOverrides = buildVariablePayload();
|
|
||||||
if (mode === 'ansible') {
|
|
||||||
const playbook_path = selectedPath; // relative to ansible island
|
|
||||||
resp = await fetch("/api/ansible/quick_run", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
playbook_path,
|
|
||||||
hostnames,
|
|
||||||
variable_values: variableOverrides,
|
|
||||||
credential_id: !useSvcAccount && selectedCredentialId ? Number(selectedCredentialId) : null,
|
|
||||||
use_service_account: Boolean(useSvcAccount)
|
|
||||||
})
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// quick_run expects a path relative to Assemblies root with 'Scripts/' prefix
|
|
||||||
const script_path = selectedPath.startsWith('Scripts/') ? selectedPath : `Scripts/${selectedPath}`;
|
|
||||||
resp = await fetch("/api/scripts/quick_run", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
script_path,
|
|
||||||
hostnames,
|
|
||||||
run_mode: runAsCurrentUser ? "current_user" : "system",
|
|
||||||
variable_values: variableOverrides
|
|
||||||
})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
|
|
||||||
onClose && onClose();
|
|
||||||
} catch (err) {
|
|
||||||
setError(String(err.message || err));
|
|
||||||
} finally {
|
|
||||||
setRunning(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const credentialRequired = mode === "ansible" && !useSvcAccount;
|
|
||||||
const disableRun =
|
|
||||||
running ||
|
|
||||||
!selectedPath ||
|
|
||||||
(credentialRequired && (!selectedCredentialId || !credentials.length));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={running ? undefined : onClose} fullWidth maxWidth="md"
|
|
||||||
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
|
|
||||||
>
|
|
||||||
<DialogTitle>Quick Job</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
|
||||||
<Button size="small" variant={mode === 'scripts' ? 'outlined' : 'text'} onClick={() => setMode('scripts')} sx={{ textTransform: 'none', color: '#58a6ff', borderColor: '#58a6ff' }}>Scripts</Button>
|
|
||||||
<Button size="small" variant={mode === 'ansible' ? 'outlined' : 'text'} onClick={() => setMode('ansible')} sx={{ textTransform: 'none', color: '#58a6ff', borderColor: '#58a6ff' }}>Ansible</Button>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2" sx={{ color: "#aaa", mb: 1 }}>
|
|
||||||
Select a {mode === 'ansible' ? 'playbook' : 'script'} to run on {hostnames.length} device{hostnames.length !== 1 ? "s" : ""}.
|
|
||||||
</Typography>
|
|
||||||
{mode === 'ansible' && (
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap", mb: 2 }}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={useSvcAccount}
|
|
||||||
onChange={(e) => {
|
|
||||||
const checked = e.target.checked;
|
|
||||||
setUseSvcAccount(checked);
|
|
||||||
if (checked) {
|
|
||||||
setSelectedCredentialId("");
|
|
||||||
} else if (!selectedCredentialId && credentials.length) {
|
|
||||||
setSelectedCredentialId(String(credentials[0].id));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Use Configured svcBorealis Account"
|
|
||||||
sx={{ mr: 2 }}
|
|
||||||
/>
|
|
||||||
<FormControl
|
|
||||||
size="small"
|
|
||||||
sx={{ minWidth: 260 }}
|
|
||||||
disabled={useSvcAccount || credentialsLoading || !credentials.length}
|
|
||||||
>
|
|
||||||
<InputLabel sx={{ color: "#aaa" }}>Credential</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={selectedCredentialId}
|
|
||||||
label="Credential"
|
|
||||||
onChange={(e) => setSelectedCredentialId(e.target.value)}
|
|
||||||
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
|
|
||||||
>
|
|
||||||
{credentials.map((cred) => {
|
|
||||||
const conn = String(cred.connection_type || "").toUpperCase();
|
|
||||||
return (
|
|
||||||
<MenuItem key={cred.id} value={String(cred.id)}>
|
|
||||||
{cred.name}
|
|
||||||
{conn ? ` (${conn})` : ""}
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
{useSvcAccount && (
|
|
||||||
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
|
||||||
Runs with the agent's svcBorealis account.
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{credentialsLoading && <CircularProgress size={18} sx={{ color: "#58a6ff" }} />}
|
|
||||||
{!credentialsLoading && credentialsError && (
|
|
||||||
<Typography variant="body2" sx={{ color: "#ff8080" }}>{credentialsError}</Typography>
|
|
||||||
)}
|
|
||||||
{!useSvcAccount && !credentialsLoading && !credentialsError && !credentials.length && (
|
|
||||||
<Typography variant="body2" sx={{ color: "#ff8080" }}>
|
|
||||||
No SSH or WinRM credentials available. Create one under Access Management.
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Box sx={{ display: "flex", gap: 2 }}>
|
|
||||||
<Paper sx={{ flex: 1, p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
|
|
||||||
<SimpleTreeView sx={{ color: "#e6edf3" }} onItemSelectionToggle={onItemSelect}>
|
|
||||||
{tree.length ? renderNodes(tree) : (
|
|
||||||
<Typography variant="body2" sx={{ color: "#888", p: 1 }}>
|
|
||||||
{mode === 'ansible' ? 'No playbooks found.' : 'No scripts found.'}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</SimpleTreeView>
|
|
||||||
</Paper>
|
|
||||||
<Box sx={{ width: 320 }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 1 }}>Selection</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: selectedPath ? "#e6edf3" : "#888" }}>
|
|
||||||
{selectedPath || (mode === 'ansible' ? 'No playbook selected' : 'No script selected')}
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ mt: 2 }}>
|
|
||||||
{mode !== 'ansible' && (
|
|
||||||
<>
|
|
||||||
<FormControlLabel
|
|
||||||
control={<Checkbox size="small" checked={runAsCurrentUser} onChange={(e) => setRunAsCurrentUser(e.target.checked)} />}
|
|
||||||
label={<Typography variant="body2">Run as currently logged-in user</Typography>}
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" sx={{ color: "#888" }}>
|
|
||||||
Unchecked = Run-As BUILTIN\SYSTEM
|
|
||||||
</Typography>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ mt: 3 }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 1 }}>Variables</Typography>
|
|
||||||
{variableStatus.loading ? (
|
|
||||||
<Typography variant="body2" sx={{ color: "#888" }}>Loading variables…</Typography>
|
|
||||||
) : variableStatus.error ? (
|
|
||||||
<Typography variant="body2" sx={{ color: "#ff4f4f" }}>{variableStatus.error}</Typography>
|
|
||||||
) : variables.length ? (
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
|
||||||
{variables.map((variable) => (
|
|
||||||
<Box key={variable.name}>
|
|
||||||
{variable.type === "boolean" ? (
|
|
||||||
<FormControlLabel
|
|
||||||
control={(
|
|
||||||
<Checkbox
|
|
||||||
size="small"
|
|
||||||
checked={Boolean(variableValues[variable.name])}
|
|
||||||
onChange={(e) => handleVariableChange(variable, e.target.checked)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
label={
|
|
||||||
<Typography variant="body2">
|
|
||||||
{variable.label}
|
|
||||||
{variable.required ? " *" : ""}
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
label={`${variable.label}${variable.required ? " *" : ""}`}
|
|
||||||
type={variable.type === "number" ? "number" : variable.type === "credential" ? "password" : "text"}
|
|
||||||
value={variableValues[variable.name] ?? ""}
|
|
||||||
onChange={(e) => handleVariableChange(variable, e.target.value)}
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": { bgcolor: "#1b1b1b", color: "#e6edf3" },
|
|
||||||
"& .MuiInputBase-input": { color: "#e6edf3" }
|
|
||||||
}}
|
|
||||||
error={Boolean(variableErrors[variable.name])}
|
|
||||||
helperText={variableErrors[variable.name] || variable.description || ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{variable.type === "boolean" && variable.description ? (
|
|
||||||
<Typography variant="caption" sx={{ color: "#888", ml: 3 }}>
|
|
||||||
{variable.description}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Typography variant="body2" sx={{ color: "#888" }}>No variables defined for this assembly.</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
{error && (
|
|
||||||
<Typography variant="body2" sx={{ color: "#ff4f4f", mt: 1 }}>{error}</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onClose} disabled={running} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
|
||||||
<Button onClick={onRun} disabled={disableRun}
|
|
||||||
sx={{ color: disableRun ? "#666" : "#58a6ff" }}
|
|
||||||
>
|
|
||||||
Run
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,685 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Scheduled_Jobs_List.jsx
|
|
||||||
|
|
||||||
import React, {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState
|
|
||||||
} from "react";
|
|
||||||
import {
|
|
||||||
Paper,
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Button,
|
|
||||||
Switch,
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogActions,
|
|
||||||
CircularProgress
|
|
||||||
} from "@mui/material";
|
|
||||||
import { AgGridReact } from "ag-grid-react";
|
|
||||||
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
|
|
||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
|
||||||
|
|
||||||
const myTheme = themeQuartz.withParams({
|
|
||||||
accentColor: "#FFA6FF",
|
|
||||||
backgroundColor: "#1f2836",
|
|
||||||
browserColorScheme: "dark",
|
|
||||||
chromeBackgroundColor: {
|
|
||||||
ref: "foregroundColor",
|
|
||||||
mix: 0.07,
|
|
||||||
onto: "backgroundColor"
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
googleFont: "IBM Plex Sans"
|
|
||||||
},
|
|
||||||
foregroundColor: "#FFF",
|
|
||||||
headerFontSize: 14
|
|
||||||
});
|
|
||||||
|
|
||||||
const themeClassName = myTheme.themeName || "ag-theme-quartz";
|
|
||||||
const gridFontFamily = '"IBM Plex Sans", "Helvetica Neue", Arial, sans-serif';
|
|
||||||
const iconFontFamily = '"Quartz Regular"';
|
|
||||||
|
|
||||||
function ResultsBar({ counts }) {
|
|
||||||
const total = Math.max(1, Number(counts?.total_targets || 0));
|
|
||||||
const sections = [
|
|
||||||
{ key: "success", color: "#00d18c" },
|
|
||||||
{ key: "running", color: "#58a6ff" },
|
|
||||||
{ key: "failed", color: "#ff4f4f" },
|
|
||||||
{ key: "timed_out", color: "#b36ae2" },
|
|
||||||
{ key: "expired", color: "#777777" },
|
|
||||||
{ key: "pending", color: "#999999" }
|
|
||||||
];
|
|
||||||
const labelFor = (key) =>
|
|
||||||
key === "pending"
|
|
||||||
? "Scheduled"
|
|
||||||
: key
|
|
||||||
.replace(/_/g, " ")
|
|
||||||
.replace(/^./, (c) => c.toUpperCase());
|
|
||||||
|
|
||||||
const hasNonPending = sections
|
|
||||||
.filter((section) => section.key !== "pending")
|
|
||||||
.some((section) => Number(counts?.[section.key] || 0) > 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 0.25,
|
|
||||||
lineHeight: 1.7,
|
|
||||||
fontFamily: gridFontFamily
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
borderRadius: 1,
|
|
||||||
overflow: "hidden",
|
|
||||||
width: 220,
|
|
||||||
height: 6
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sections.map((section) => {
|
|
||||||
const value = Number(counts?.[section.key] || 0);
|
|
||||||
if (!value) return null;
|
|
||||||
const width = `${Math.round((value / total) * 100)}%`;
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
key={section.key}
|
|
||||||
component="span"
|
|
||||||
sx={{ display: "block", height: "100%", width, backgroundColor: section.color }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
columnGap: 0.75,
|
|
||||||
rowGap: 0.25,
|
|
||||||
color: "#aaa",
|
|
||||||
fontSize: 11,
|
|
||||||
fontFamily: gridFontFamily
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
if (!hasNonPending && Number(counts?.pending || 0) > 0) {
|
|
||||||
return <Box component="span">Scheduled</Box>;
|
|
||||||
}
|
|
||||||
return sections
|
|
||||||
.filter((section) => Number(counts?.[section.key] || 0) > 0)
|
|
||||||
.map((section) => (
|
|
||||||
<Box
|
|
||||||
key={section.key}
|
|
||||||
component="span"
|
|
||||||
sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
sx={{
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
borderRadius: 1,
|
|
||||||
backgroundColor: section.color
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{counts?.[section.key]} {labelFor(section.key)}
|
|
||||||
</Box>
|
|
||||||
));
|
|
||||||
})()}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken }) {
|
|
||||||
const [rows, setRows] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
|
||||||
const [selectedIds, setSelectedIds] = useState(() => new Set());
|
|
||||||
const gridApiRef = useRef(null);
|
|
||||||
|
|
||||||
const loadJobs = useCallback(
|
|
||||||
async ({ showLoading = false } = {}) => {
|
|
||||||
if (showLoading) {
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const resp = await fetch("/api/scheduled_jobs");
|
|
||||||
const data = await resp.json().catch(() => ({}));
|
|
||||||
if (!resp.ok) {
|
|
||||||
throw new Error(data?.error || `HTTP ${resp.status}`);
|
|
||||||
}
|
|
||||||
const pretty = (st) => {
|
|
||||||
const s = String(st || "").toLowerCase();
|
|
||||||
const map = {
|
|
||||||
immediately: "Immediately",
|
|
||||||
once: "Once",
|
|
||||||
every_5_minutes: "Every 5 Minutes",
|
|
||||||
every_10_minutes: "Every 10 Minutes",
|
|
||||||
every_15_minutes: "Every 15 Minutes",
|
|
||||||
every_30_minutes: "Every 30 Minutes",
|
|
||||||
every_hour: "Every Hour",
|
|
||||||
daily: "Daily",
|
|
||||||
weekly: "Weekly",
|
|
||||||
monthly: "Monthly",
|
|
||||||
yearly: "Yearly"
|
|
||||||
};
|
|
||||||
if (map[s]) return map[s];
|
|
||||||
try {
|
|
||||||
return s.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
|
|
||||||
} catch {
|
|
||||||
return String(st || "");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const fmt = (ts) => {
|
|
||||||
if (!ts) return "";
|
|
||||||
try {
|
|
||||||
const d = new Date(Number(ts) * 1000);
|
|
||||||
if (Number.isNaN(d?.getTime())) return "";
|
|
||||||
return d.toLocaleString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit"
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const mappedRows = (data?.jobs || []).map((j) => {
|
|
||||||
const compName = (Array.isArray(j.components) && j.components[0]?.name) || "Demonstration Component";
|
|
||||||
const targetText = Array.isArray(j.targets)
|
|
||||||
? `${j.targets.length} device${j.targets.length !== 1 ? "s" : ""}`
|
|
||||||
: "";
|
|
||||||
const occurrence = pretty(j.schedule_type || "immediately");
|
|
||||||
const resultsCounts = {
|
|
||||||
total_targets: Array.isArray(j.targets) ? j.targets.length : 0,
|
|
||||||
pending: Array.isArray(j.targets) ? j.targets.length : 0,
|
|
||||||
...(j.result_counts || {})
|
|
||||||
};
|
|
||||||
if (resultsCounts && resultsCounts.total_targets == null) {
|
|
||||||
resultsCounts.total_targets = Array.isArray(j.targets) ? j.targets.length : 0;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: j.id,
|
|
||||||
name: j.name,
|
|
||||||
scriptWorkflow: compName,
|
|
||||||
target: targetText,
|
|
||||||
occurrence,
|
|
||||||
lastRun: fmt(j.last_run_ts),
|
|
||||||
nextRun: fmt(j.next_run_ts || j.start_ts),
|
|
||||||
result: j.last_status || (j.next_run_ts ? "Scheduled" : ""),
|
|
||||||
resultsCounts,
|
|
||||||
enabled: Boolean(j.enabled),
|
|
||||||
raw: j
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setRows(mappedRows);
|
|
||||||
setError("");
|
|
||||||
setSelectedIds((prev) => {
|
|
||||||
if (!prev.size) return prev;
|
|
||||||
const valid = new Set(
|
|
||||||
mappedRows.map((row, index) => row.id ?? row.name ?? String(index))
|
|
||||||
);
|
|
||||||
let changed = false;
|
|
||||||
const next = new Set();
|
|
||||||
prev.forEach((value) => {
|
|
||||||
if (valid.has(value)) {
|
|
||||||
next.add(value);
|
|
||||||
} else {
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return changed ? next : prev;
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
setRows([]);
|
|
||||||
setSelectedIds(() => new Set());
|
|
||||||
setError(String(err?.message || err || "Failed to load scheduled jobs"));
|
|
||||||
} finally {
|
|
||||||
if (showLoading) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let timer;
|
|
||||||
let isMounted = true;
|
|
||||||
(async () => {
|
|
||||||
if (!isMounted) return;
|
|
||||||
await loadJobs({ showLoading: true });
|
|
||||||
})();
|
|
||||||
timer = setInterval(() => {
|
|
||||||
loadJobs();
|
|
||||||
}, 5000);
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
if (timer) clearInterval(timer);
|
|
||||||
};
|
|
||||||
}, [loadJobs, refreshToken]);
|
|
||||||
|
|
||||||
const handleGridReady = useCallback((params) => {
|
|
||||||
gridApiRef.current = params.api;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const api = gridApiRef.current;
|
|
||||||
if (!api) return;
|
|
||||||
if (loading) {
|
|
||||||
api.showLoadingOverlay();
|
|
||||||
} else if (!rows.length) {
|
|
||||||
api.showNoRowsOverlay();
|
|
||||||
} else {
|
|
||||||
api.hideOverlay();
|
|
||||||
}
|
|
||||||
}, [loading, rows]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const api = gridApiRef.current;
|
|
||||||
if (!api) return;
|
|
||||||
api.forEachNode((node) => {
|
|
||||||
const shouldSelect = selectedIds.has(node.id);
|
|
||||||
if (node.isSelected() !== shouldSelect) {
|
|
||||||
node.setSelected(shouldSelect);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [rows, selectedIds]);
|
|
||||||
|
|
||||||
const anySelected = selectedIds.size > 0;
|
|
||||||
|
|
||||||
const handleSelectionChanged = useCallback(() => {
|
|
||||||
const api = gridApiRef.current;
|
|
||||||
if (!api) return;
|
|
||||||
const selectedNodes = api.getSelectedNodes();
|
|
||||||
const next = new Set();
|
|
||||||
selectedNodes.forEach((node) => {
|
|
||||||
if (node?.id != null) {
|
|
||||||
next.add(String(node.id));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setSelectedIds(next);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getRowId = useCallback((params) => {
|
|
||||||
return (
|
|
||||||
params?.data?.id ??
|
|
||||||
params?.data?.name ??
|
|
||||||
String(params?.rowIndex ?? "")
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const nameCellRenderer = useCallback(
|
|
||||||
(params) => {
|
|
||||||
const row = params.data;
|
|
||||||
if (!row) return null;
|
|
||||||
const handleClick = (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
if (typeof onEditJob === "function") {
|
|
||||||
onEditJob(row.raw);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
onClick={handleClick}
|
|
||||||
sx={{
|
|
||||||
color: "#58a6ff",
|
|
||||||
textTransform: "none",
|
|
||||||
p: 0,
|
|
||||||
minWidth: 0,
|
|
||||||
fontFamily: gridFontFamily
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{row.name || "-"}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[onEditJob]
|
|
||||||
);
|
|
||||||
|
|
||||||
const resultsCellRenderer = useCallback((params) => {
|
|
||||||
return <ResultsBar counts={params?.data?.resultsCounts} />;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const enabledCellRenderer = useCallback(
|
|
||||||
(params) => {
|
|
||||||
const row = params.data;
|
|
||||||
if (!row) return null;
|
|
||||||
const handleToggle = async (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
const nextEnabled = event.target.checked;
|
|
||||||
try {
|
|
||||||
await fetch(`/api/scheduled_jobs/${row.id}/toggle`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ enabled: nextEnabled })
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// ignore network errors for toggle
|
|
||||||
}
|
|
||||||
setRows((prev) =>
|
|
||||||
prev.map((job) => {
|
|
||||||
if ((job.id ?? job.name) === (row.id ?? row.name)) {
|
|
||||||
const updatedRaw = { ...(job.raw || {}), enabled: nextEnabled };
|
|
||||||
return { ...job, enabled: nextEnabled, raw: updatedRaw };
|
|
||||||
}
|
|
||||||
return job;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Switch
|
|
||||||
size="small"
|
|
||||||
checked={Boolean(row.enabled)}
|
|
||||||
onChange={handleToggle}
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
sx={{
|
|
||||||
"& .MuiSwitch-switchBase.Mui-checked": {
|
|
||||||
color: "#58a6ff"
|
|
||||||
},
|
|
||||||
"& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track": {
|
|
||||||
bgcolor: "#58a6ff"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const columnDefs = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
headerName: "",
|
|
||||||
field: "__checkbox__",
|
|
||||||
checkboxSelection: true,
|
|
||||||
headerCheckboxSelection: true,
|
|
||||||
maxWidth: 60,
|
|
||||||
minWidth: 60,
|
|
||||||
sortable: false,
|
|
||||||
filter: false,
|
|
||||||
resizable: false,
|
|
||||||
suppressMenu: true,
|
|
||||||
pinned: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: "Name",
|
|
||||||
field: "name",
|
|
||||||
cellRenderer: nameCellRenderer,
|
|
||||||
sort: "asc"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: "Assembly(s)",
|
|
||||||
field: "scriptWorkflow",
|
|
||||||
valueGetter: (params) => params.data?.scriptWorkflow || "Demonstration Component"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: "Target",
|
|
||||||
field: "target"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: "Recurrence",
|
|
||||||
field: "occurrence"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: "Last Run",
|
|
||||||
field: "lastRun"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: "Next Run",
|
|
||||||
field: "nextRun"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: "Results",
|
|
||||||
field: "resultsCounts",
|
|
||||||
minWidth: 280,
|
|
||||||
cellRenderer: resultsCellRenderer,
|
|
||||||
sortable: false,
|
|
||||||
filter: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: "Enabled",
|
|
||||||
field: "enabled",
|
|
||||||
minWidth: 140,
|
|
||||||
maxWidth: 160,
|
|
||||||
cellRenderer: enabledCellRenderer,
|
|
||||||
sortable: false,
|
|
||||||
filter: false,
|
|
||||||
resizable: false,
|
|
||||||
suppressMenu: true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[enabledCellRenderer, nameCellRenderer, resultsCellRenderer]
|
|
||||||
);
|
|
||||||
|
|
||||||
const defaultColDef = useMemo(
|
|
||||||
() => ({
|
|
||||||
sortable: true,
|
|
||||||
filter: "agTextColumnFilter",
|
|
||||||
resizable: true,
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 140,
|
|
||||||
cellStyle: {
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
color: "#f5f7fa",
|
|
||||||
fontFamily: gridFontFamily,
|
|
||||||
fontSize: "13px"
|
|
||||||
},
|
|
||||||
headerClass: "scheduled-jobs-grid-header"
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper
|
|
||||||
sx={{
|
|
||||||
m: 2,
|
|
||||||
p: 0,
|
|
||||||
bgcolor: "#1e1e1e",
|
|
||||||
color: "#f5f7fa",
|
|
||||||
fontFamily: gridFontFamily,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
flexGrow: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
minHeight: 420
|
|
||||||
}}
|
|
||||||
elevation={2}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
p: 2,
|
|
||||||
borderBottom: "1px solid #2a2a2a"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0.3 }}>
|
|
||||||
Scheduled Jobs
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
|
||||||
List of automation jobs with schedules, results, and actions.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
disabled={!anySelected}
|
|
||||||
sx={{
|
|
||||||
color: anySelected ? "#ff8080" : "#666",
|
|
||||||
borderColor: anySelected ? "#ff8080" : "#333",
|
|
||||||
textTransform: "none",
|
|
||||||
fontFamily: gridFontFamily,
|
|
||||||
"&:hover": {
|
|
||||||
borderColor: anySelected ? "#ff8080" : "#333"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={() => setBulkDeleteOpen(true)}
|
|
||||||
>
|
|
||||||
Delete Job
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
bgcolor: "#58a6ff",
|
|
||||||
color: "#0b0f19",
|
|
||||||
textTransform: "none",
|
|
||||||
fontFamily: gridFontFamily,
|
|
||||||
"&:hover": {
|
|
||||||
bgcolor: "#7db7ff"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={() => onCreateJob && onCreateJob()}
|
|
||||||
>
|
|
||||||
Create Job
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{loading && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 1,
|
|
||||||
color: "#7db7ff",
|
|
||||||
px: 2,
|
|
||||||
py: 1.5,
|
|
||||||
borderBottom: "1px solid #2a2a2a"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
|
|
||||||
<Typography variant="body2">Loading scheduled jobs…</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Box sx={{ px: 2, py: 1.5, color: "#ff8080", borderBottom: "1px solid #2a2a2a" }}>
|
|
||||||
<Typography variant="body2">{error}</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
flexGrow: 1,
|
|
||||||
minHeight: 0,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
mt: "10px",
|
|
||||||
px: 2,
|
|
||||||
pb: 2
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
className={themeClassName}
|
|
||||||
sx={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
flexGrow: 1,
|
|
||||||
fontFamily: gridFontFamily,
|
|
||||||
"--ag-font-family": gridFontFamily,
|
|
||||||
"--ag-icon-font-family": iconFontFamily,
|
|
||||||
"--ag-row-border-style": "solid",
|
|
||||||
"--ag-row-border-color": "#2a2a2a",
|
|
||||||
"--ag-row-border-width": "1px",
|
|
||||||
"& .ag-root-wrapper": {
|
|
||||||
borderRadius: 1,
|
|
||||||
minHeight: 320
|
|
||||||
},
|
|
||||||
"& .ag-root, & .ag-header, & .ag-center-cols-container, & .ag-paging-panel": {
|
|
||||||
fontFamily: gridFontFamily
|
|
||||||
},
|
|
||||||
"& .ag-icon": {
|
|
||||||
fontFamily: iconFontFamily
|
|
||||||
},
|
|
||||||
"& .scheduled-jobs-grid-header": {
|
|
||||||
fontFamily: gridFontFamily,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "#f5f7fa"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AgGridReact
|
|
||||||
rowData={rows}
|
|
||||||
columnDefs={columnDefs}
|
|
||||||
defaultColDef={defaultColDef}
|
|
||||||
animateRows
|
|
||||||
rowHeight={46}
|
|
||||||
headerHeight={44}
|
|
||||||
suppressCellFocus
|
|
||||||
rowSelection="multiple"
|
|
||||||
rowMultiSelectWithClick
|
|
||||||
suppressRowClickSelection
|
|
||||||
getRowId={getRowId}
|
|
||||||
overlayNoRowsTemplate="<span class='ag-overlay-no-rows-center'>No scheduled jobs found.</span>"
|
|
||||||
onGridReady={handleGridReady}
|
|
||||||
onSelectionChanged={handleSelectionChanged}
|
|
||||||
theme={myTheme}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
fontFamily: gridFontFamily,
|
|
||||||
"--ag-icon-font-family": iconFontFamily
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={bulkDeleteOpen}
|
|
||||||
onClose={() => setBulkDeleteOpen(false)}
|
|
||||||
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
|
|
||||||
>
|
|
||||||
<DialogTitle>Are you sure you want to delete this job(s)?</DialogTitle>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setBulkDeleteOpen(false)} sx={{ color: "#58a6ff" }}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
const ids = Array.from(selectedIds);
|
|
||||||
const idSet = new Set(ids);
|
|
||||||
await Promise.allSettled(
|
|
||||||
ids.map((id) => fetch(`/api/scheduled_jobs/${id}`, { method: "DELETE" }))
|
|
||||||
);
|
|
||||||
setRows((prev) =>
|
|
||||||
prev.filter((job, index) => {
|
|
||||||
const key = getRowId({ data: job, rowIndex: index });
|
|
||||||
return !idSet.has(key);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
setSelectedIds(() => new Set());
|
|
||||||
} catch {
|
|
||||||
// ignore delete errors here; a fresh load will surface them
|
|
||||||
}
|
|
||||||
setBulkDeleteOpen(false);
|
|
||||||
await loadJobs({ showLoading: true });
|
|
||||||
}}
|
|
||||||
variant="outlined"
|
|
||||||
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,385 +0,0 @@
|
|||||||
import React, { useEffect, useMemo, useState, useCallback, useRef } from "react";
|
|
||||||
import {
|
|
||||||
Paper,
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
TableSortLabel,
|
|
||||||
Checkbox,
|
|
||||||
Button,
|
|
||||||
IconButton,
|
|
||||||
Popover,
|
|
||||||
TextField,
|
|
||||||
MenuItem
|
|
||||||
} from "@mui/material";
|
|
||||||
import AddIcon from "@mui/icons-material/Add";
|
|
||||||
import DeleteIcon from "@mui/icons-material/DeleteOutline";
|
|
||||||
import EditIcon from "@mui/icons-material/Edit";
|
|
||||||
import FilterListIcon from "@mui/icons-material/FilterList";
|
|
||||||
import ViewColumnIcon from "@mui/icons-material/ViewColumn";
|
|
||||||
import { CreateSiteDialog, ConfirmDeleteDialog, RenameSiteDialog } from "../Dialogs.jsx";
|
|
||||||
|
|
||||||
export default function SiteList({ onOpenDevicesForSite }) {
|
|
||||||
const [rows, setRows] = useState([]); // {id, name, description, device_count}
|
|
||||||
const [orderBy, setOrderBy] = useState("name");
|
|
||||||
const [order, setOrder] = useState("asc");
|
|
||||||
const [selectedIds, setSelectedIds] = useState(() => new Set());
|
|
||||||
|
|
||||||
// Columns configuration (similar style to Device_List)
|
|
||||||
const COL_LABELS = useMemo(() => ({
|
|
||||||
name: "Name",
|
|
||||||
description: "Description",
|
|
||||||
device_count: "Devices",
|
|
||||||
}), []);
|
|
||||||
const defaultColumns = useMemo(
|
|
||||||
() => [
|
|
||||||
{ id: "name", label: COL_LABELS.name },
|
|
||||||
{ id: "description", label: COL_LABELS.description },
|
|
||||||
{ id: "device_count", label: COL_LABELS.device_count },
|
|
||||||
],
|
|
||||||
[COL_LABELS]
|
|
||||||
);
|
|
||||||
const [columns, setColumns] = useState(defaultColumns);
|
|
||||||
const dragColId = useRef(null);
|
|
||||||
const [colChooserAnchor, setColChooserAnchor] = useState(null);
|
|
||||||
|
|
||||||
const [filters, setFilters] = useState({});
|
|
||||||
const [filterAnchor, setFilterAnchor] = useState(null); // { id, anchorEl }
|
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
||||||
const [renameOpen, setRenameOpen] = useState(false);
|
|
||||||
const [renameValue, setRenameValue] = useState("");
|
|
||||||
|
|
||||||
const fetchSites = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/sites");
|
|
||||||
const data = await res.json();
|
|
||||||
setRows(Array.isArray(data?.sites) ? data.sites : []);
|
|
||||||
} catch {
|
|
||||||
setRows([]);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { fetchSites(); }, [fetchSites]);
|
|
||||||
|
|
||||||
// Apply initial filters from global search
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
const json = localStorage.getItem('site_list_initial_filters');
|
|
||||||
if (json) {
|
|
||||||
const obj = JSON.parse(json);
|
|
||||||
if (obj && typeof obj === 'object') setFilters((prev) => ({ ...prev, ...obj }));
|
|
||||||
localStorage.removeItem('site_list_initial_filters');
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSort = (col) => {
|
|
||||||
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
|
|
||||||
else { setOrderBy(col); setOrder("asc"); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
if (!filters || Object.keys(filters).length === 0) return rows;
|
|
||||||
return rows.filter((r) =>
|
|
||||||
Object.entries(filters).every(([k, v]) => {
|
|
||||||
const val = String(v || "").toLowerCase();
|
|
||||||
if (!val) return true;
|
|
||||||
return String(r[k] ?? "").toLowerCase().includes(val);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}, [rows, filters]);
|
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
|
||||||
const dir = order === "asc" ? 1 : -1;
|
|
||||||
const arr = [...filtered];
|
|
||||||
arr.sort((a, b) => {
|
|
||||||
if (orderBy === "device_count") return ((a.device_count||0) - (b.device_count||0)) * dir;
|
|
||||||
return String(a[orderBy] ?? "").localeCompare(String(b[orderBy] ?? "")) * dir;
|
|
||||||
});
|
|
||||||
return arr;
|
|
||||||
}, [filtered, orderBy, order]);
|
|
||||||
|
|
||||||
const onHeaderDragStart = (colId) => (e) => { dragColId.current = colId; try { e.dataTransfer.setData("text/plain", colId); } catch {} };
|
|
||||||
const onHeaderDragOver = (e) => { e.preventDefault(); };
|
|
||||||
const onHeaderDrop = (targetColId) => (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const fromId = dragColId.current; if (!fromId || fromId === targetColId) return;
|
|
||||||
setColumns((prev) => {
|
|
||||||
const cur = [...prev];
|
|
||||||
const fromIdx = cur.findIndex((c) => c.id === fromId);
|
|
||||||
const toIdx = cur.findIndex((c) => c.id === targetColId);
|
|
||||||
if (fromIdx < 0 || toIdx < 0) return prev;
|
|
||||||
const [moved] = cur.splice(fromIdx, 1);
|
|
||||||
cur.splice(toIdx, 0, moved);
|
|
||||||
return cur;
|
|
||||||
});
|
|
||||||
dragColId.current = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const openFilter = (id) => (e) => setFilterAnchor({ id, anchorEl: e.currentTarget });
|
|
||||||
const closeFilter = () => setFilterAnchor(null);
|
|
||||||
const onFilterChange = (id) => (e) => setFilters((prev) => ({ ...prev, [id]: e.target.value }));
|
|
||||||
|
|
||||||
const isAllChecked = sorted.length > 0 && sorted.every((r) => selectedIds.has(r.id));
|
|
||||||
const isIndeterminate = selectedIds.size > 0 && !isAllChecked;
|
|
||||||
const toggleAll = (e) => {
|
|
||||||
const checked = e.target.checked;
|
|
||||||
setSelectedIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (checked) sorted.forEach((r) => next.add(r.id));
|
|
||||||
else next.clear();
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const toggleOne = (id) => (e) => {
|
|
||||||
const checked = e.target.checked;
|
|
||||||
setSelectedIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (checked) next.add(id); else next.delete(id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
|
|
||||||
<Box sx={{ p: 2, pb: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
||||||
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>Sites</Typography>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
startIcon={<EditIcon />}
|
|
||||||
disabled={selectedIds.size !== 1}
|
|
||||||
onClick={() => {
|
|
||||||
// Prefill with the currently selected site's name
|
|
||||||
const selId = selectedIds.size === 1 ? Array.from(selectedIds)[0] : null;
|
|
||||||
if (selId != null) {
|
|
||||||
const site = rows.find((r) => r.id === selId);
|
|
||||||
setRenameValue(site?.name || "");
|
|
||||||
setRenameOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
sx={{ color: selectedIds.size === 1 ? '#58a6ff' : '#666', borderColor: selectedIds.size === 1 ? '#58a6ff' : '#333', textTransform: 'none' }}
|
|
||||||
>
|
|
||||||
Rename
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
startIcon={<DeleteIcon />}
|
|
||||||
disabled={selectedIds.size === 0}
|
|
||||||
onClick={() => setDeleteOpen(true)}
|
|
||||||
sx={{ color: selectedIds.size ? '#ff8a8a' : '#666', borderColor: selectedIds.size ? '#ff4f4f' : '#333', textTransform: 'none' }}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
startIcon={<AddIcon />}
|
|
||||||
onClick={() => setCreateOpen(true)}
|
|
||||||
sx={{ color: '#58a6ff', borderColor: '#58a6ff', textTransform: 'none' }}
|
|
||||||
>
|
|
||||||
Create Site
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Table size="small" sx={{ minWidth: 700 }}>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell padding="checkbox">
|
|
||||||
<Checkbox indeterminate={isIndeterminate} checked={isAllChecked} onChange={toggleAll} sx={{ color: '#777' }} />
|
|
||||||
</TableCell>
|
|
||||||
{columns.map((col) => (
|
|
||||||
<TableCell key={col.id} sortDirection={orderBy === col.id ? order : false} draggable onDragStart={onHeaderDragStart(col.id)} onDragOver={onHeaderDragOver} onDrop={onHeaderDrop(col.id)}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<TableSortLabel active={orderBy === col.id} direction={orderBy === col.id ? order : 'asc'} onClick={() => handleSort(col.id)}>
|
|
||||||
{col.label}
|
|
||||||
</TableSortLabel>
|
|
||||||
<IconButton size="small" onClick={openFilter(col.id)} sx={{ color: filters[col.id] ? '#58a6ff' : '#888' }}>
|
|
||||||
<FilterListIcon fontSize="inherit" />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{sorted.map((r) => (
|
|
||||||
<TableRow key={r.id} hover>
|
|
||||||
<TableCell padding="checkbox" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Checkbox checked={selectedIds.has(r.id)} onChange={toggleOne(r.id)} sx={{ color: '#777' }} />
|
|
||||||
</TableCell>
|
|
||||||
{columns.map((col) => {
|
|
||||||
switch (col.id) {
|
|
||||||
case 'name':
|
|
||||||
return (
|
|
||||||
<TableCell
|
|
||||||
key={col.id}
|
|
||||||
onClick={() => {
|
|
||||||
if (onOpenDevicesForSite) onOpenDevicesForSite(r.name);
|
|
||||||
}}
|
|
||||||
sx={{ color: '#58a6ff', '&:hover': { cursor: 'pointer', textDecoration: 'underline' } }}
|
|
||||||
>
|
|
||||||
{r.name}
|
|
||||||
</TableCell>
|
|
||||||
);
|
|
||||||
case 'description':
|
|
||||||
return <TableCell key={col.id}>{r.description || ''}</TableCell>;
|
|
||||||
case 'device_count':
|
|
||||||
return <TableCell key={col.id}>{r.device_count ?? 0}</TableCell>;
|
|
||||||
default:
|
|
||||||
return <TableCell key={col.id} />;
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
{sorted.length === 0 && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={columns.length + 1} sx={{ color: '#888' }}>No sites defined.</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
|
|
||||||
{/* Column chooser */}
|
|
||||||
<Popover
|
|
||||||
open={Boolean(colChooserAnchor)}
|
|
||||||
anchorEl={colChooserAnchor}
|
|
||||||
onClose={() => setColChooserAnchor(null)}
|
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
|
||||||
PaperProps={{ sx: { bgcolor: '#1e1e1e', color: '#fff', p: 1 } }}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, p: 1 }}>
|
|
||||||
{[
|
|
||||||
{ id: 'name', label: 'Name' },
|
|
||||||
{ id: 'description', label: 'Description' },
|
|
||||||
{ id: 'device_count', label: 'Devices' },
|
|
||||||
].map((opt) => (
|
|
||||||
<MenuItem key={opt.id} disableRipple onClick={(e) => e.stopPropagation()} sx={{ gap: 1 }}>
|
|
||||||
<Checkbox
|
|
||||||
size="small"
|
|
||||||
checked={columns.some((c) => c.id === opt.id)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const checked = e.target.checked;
|
|
||||||
setColumns((prev) => {
|
|
||||||
const exists = prev.some((c) => c.id === opt.id);
|
|
||||||
if (checked) {
|
|
||||||
if (exists) return prev;
|
|
||||||
return [...prev, { id: opt.id, label: opt.label }];
|
|
||||||
}
|
|
||||||
return prev.filter((c) => c.id !== opt.id);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
sx={{ p: 0.3, color: '#bbb' }}
|
|
||||||
/>
|
|
||||||
<Typography variant="body2" sx={{ color: '#ddd' }}>{opt.label}</Typography>
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, pt: 0.5 }}>
|
|
||||||
<Button size="small" variant="outlined" onClick={() => setColumns(defaultColumns)} sx={{ textTransform: 'none', borderColor: '#555', color: '#bbb' }}>
|
|
||||||
Reset Default
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
{/* Filter popover */}
|
|
||||||
<Popover
|
|
||||||
open={Boolean(filterAnchor)}
|
|
||||||
anchorEl={filterAnchor?.anchorEl || null}
|
|
||||||
onClose={closeFilter}
|
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
|
||||||
PaperProps={{ sx: { bgcolor: '#1e1e1e', p: 1 } }}
|
|
||||||
>
|
|
||||||
{filterAnchor && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
|
||||||
<TextField
|
|
||||||
autoFocus
|
|
||||||
size="small"
|
|
||||||
placeholder={`Filter ${columns.find((c) => c.id === filterAnchor.id)?.label || ''}`}
|
|
||||||
value={filters[filterAnchor.id] || ''}
|
|
||||||
onChange={onFilterChange(filterAnchor.id)}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Escape') closeFilter(); }}
|
|
||||||
sx={{
|
|
||||||
input: { color: '#fff' },
|
|
||||||
minWidth: 220,
|
|
||||||
'& .MuiOutlinedInput-root': { '& fieldset': { borderColor: '#555' }, '&:hover fieldset': { borderColor: '#888' } },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button variant="outlined" size="small" onClick={() => { setFilters((prev) => ({ ...prev, [filterAnchor.id]: '' })); closeFilter(); }} sx={{ textTransform: 'none', borderColor: '#555', color: '#bbb' }}>
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
{/* Create site dialog */}
|
|
||||||
<CreateSiteDialog
|
|
||||||
open={createOpen}
|
|
||||||
onCancel={() => setCreateOpen(false)}
|
|
||||||
onCreate={async (name, description) => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/sites', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, description }) });
|
|
||||||
if (!res.ok) return;
|
|
||||||
setCreateOpen(false);
|
|
||||||
await fetchSites();
|
|
||||||
} catch {}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Delete confirmation */}
|
|
||||||
<ConfirmDeleteDialog
|
|
||||||
open={deleteOpen}
|
|
||||||
message={`Delete ${selectedIds.size} selected site(s)? This cannot be undone.`}
|
|
||||||
onCancel={() => setDeleteOpen(false)}
|
|
||||||
onConfirm={async () => {
|
|
||||||
try {
|
|
||||||
const ids = Array.from(selectedIds);
|
|
||||||
await fetch('/api/sites/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) });
|
|
||||||
} catch {}
|
|
||||||
setDeleteOpen(false);
|
|
||||||
setSelectedIds(new Set());
|
|
||||||
await fetchSites();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Rename site dialog */}
|
|
||||||
<RenameSiteDialog
|
|
||||||
open={renameOpen}
|
|
||||||
value={renameValue}
|
|
||||||
onChange={setRenameValue}
|
|
||||||
onCancel={() => setRenameOpen(false)}
|
|
||||||
onSave={async () => {
|
|
||||||
const newName = (renameValue || '').trim();
|
|
||||||
if (!newName) return;
|
|
||||||
const selId = selectedIds.size === 1 ? Array.from(selectedIds)[0] : null;
|
|
||||||
if (selId == null) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/sites/rename', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ id: selId, new_name: newName })
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
// Keep dialog open on error; optionally log
|
|
||||||
try { const err = await res.json(); console.warn('Rename failed', err); } catch {}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setRenameOpen(false);
|
|
||||||
await fetchSites();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Rename error', e);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Status_Bar.jsx
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { Box, Button, Divider } from "@mui/material";
|
|
||||||
|
|
||||||
export default function StatusBar() {
|
|
||||||
const [apiStatus, setApiStatus] = useState("checking");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch("/health")
|
|
||||||
.then((res) => (res.ok ? setApiStatus("online") : setApiStatus("offline")))
|
|
||||||
.catch(() => setApiStatus("offline"));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const applyRate = () => {
|
|
||||||
const val = parseInt(
|
|
||||||
document.getElementById("updateRateInput")?.value
|
|
||||||
);
|
|
||||||
if (!isNaN(val) && val >= 50) {
|
|
||||||
window.BorealisUpdateRate = val;
|
|
||||||
console.log("Global update rate set to", val + "ms");
|
|
||||||
} else {
|
|
||||||
alert("Please enter a valid number (min 50).");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
component="footer"
|
|
||||||
sx={{
|
|
||||||
bgcolor: "#1e1e1e",
|
|
||||||
color: "white",
|
|
||||||
px: 2,
|
|
||||||
py: 1,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
|
||||||
<b>Nodes</b>: <span id="nodeCount">0</span>
|
|
||||||
<Divider orientation="vertical" flexItem sx={{ borderColor: "#444" }} />
|
|
||||||
<b>Update Rate (ms):</b>
|
|
||||||
<input
|
|
||||||
id="updateRateInput"
|
|
||||||
type="number"
|
|
||||||
min="50"
|
|
||||||
step="50"
|
|
||||||
defaultValue={window.BorealisUpdateRate}
|
|
||||||
style={{
|
|
||||||
width: "80px",
|
|
||||||
background: "#121212",
|
|
||||||
color: "#fff",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "3px",
|
|
||||||
padding: "3px",
|
|
||||||
fontSize: "0.8rem"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
onClick={applyRate}
|
|
||||||
sx={{
|
|
||||||
color: "#58a6ff",
|
|
||||||
borderColor: "#58a6ff",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
textTransform: "none",
|
|
||||||
px: 1.5
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Apply Rate
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ fontSize: "1.0rem", display: "flex", alignItems: "center", gap: 1 }}>
|
|
||||||
<strong style={{ color: "#58a6ff" }}>Backend API Server</strong>:
|
|
||||||
<a
|
|
||||||
href="http://localhost:5000/health"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{
|
|
||||||
color: apiStatus === "online" ? "#00d18c" : "#ff4f4f",
|
|
||||||
textDecoration: "none",
|
|
||||||
fontWeight: "bold"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{apiStatus === "checking" ? "..." : apiStatus.charAt(0).toUpperCase() + apiStatus.slice(1)}
|
|
||||||
</a>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/index.js
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom/client';
|
|
||||||
|
|
||||||
// Global Styles
|
|
||||||
import "normalize.css/normalize.css";
|
|
||||||
import "@fontsource/ibm-plex-sans/400.css";
|
|
||||||
import "@fontsource/ibm-plex-sans/500.css";
|
|
||||||
import "@fontsource/ibm-plex-sans/600.css";
|
|
||||||
import "@fortawesome/fontawesome-free/css/all.min.css";
|
|
||||||
import './Borealis.css'; // Global Theming for All of Borealis
|
|
||||||
|
|
||||||
import App from './App.jsx';
|
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
||||||
root.render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
@@ -1,554 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Agent/Node_Agent.jsx
|
|
||||||
import React, { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
|
||||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
|
||||||
|
|
||||||
// Modern Node: Borealis Agent (Sidebar Config Enabled)
|
|
||||||
const BorealisAgentNode = ({ id, data }) => {
|
|
||||||
const { getNodes, setNodes } = useReactFlow();
|
|
||||||
const edges = useStore((state) => state.edges);
|
|
||||||
const [agents, setAgents] = useState({});
|
|
||||||
const [sites, setSites] = useState([]);
|
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
|
||||||
const [siteMapping, setSiteMapping] = useState({});
|
|
||||||
const prevRolesRef = useRef([]);
|
|
||||||
const selectionRef = useRef({ host: "", mode: "", agentId: "", siteId: "" });
|
|
||||||
|
|
||||||
const selectedSiteId = data?.agent_site_id ? String(data.agent_site_id) : "";
|
|
||||||
const selectedHost = data?.agent_host || "";
|
|
||||||
const selectedMode =
|
|
||||||
(data?.agent_mode || "currentuser").toString().toLowerCase() === "system"
|
|
||||||
? "system"
|
|
||||||
: "currentuser";
|
|
||||||
const selectedAgent = data?.agent_id || "";
|
|
||||||
|
|
||||||
// Group agents by hostname and execution context
|
|
||||||
const agentsByHostname = useMemo(() => {
|
|
||||||
if (!agents || typeof agents !== "object") return {};
|
|
||||||
const grouped = {};
|
|
||||||
Object.entries(agents).forEach(([aid, info]) => {
|
|
||||||
if (!info || typeof info !== "object") return;
|
|
||||||
const status = (info.status || "").toString().toLowerCase();
|
|
||||||
if (status === "offline") return;
|
|
||||||
const host = (info.hostname || info.agent_hostname || "").trim() || "unknown";
|
|
||||||
const modeRaw = (info.service_mode || "").toString().toLowerCase();
|
|
||||||
const mode = modeRaw === "system" ? "system" : "currentuser";
|
|
||||||
if (!grouped[host]) {
|
|
||||||
grouped[host] = { currentuser: null, system: null };
|
|
||||||
}
|
|
||||||
grouped[host][mode] = {
|
|
||||||
agent_id: aid,
|
|
||||||
status: info.status || "offline",
|
|
||||||
last_seen: info.last_seen || 0,
|
|
||||||
info,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return grouped;
|
|
||||||
}, [agents]);
|
|
||||||
|
|
||||||
// Locale-aware, case-insensitive, numeric-friendly sorter (e.g., "host2" < "host10")
|
|
||||||
const hostCollator = useMemo(
|
|
||||||
() => new Intl.Collator(undefined, { sensitivity: "base", numeric: true }),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const hostOptions = useMemo(() => {
|
|
||||||
const entries = Object.entries(agentsByHostname)
|
|
||||||
.map(([host, contexts]) => {
|
|
||||||
const candidates = [contexts.currentuser, contexts.system].filter(Boolean);
|
|
||||||
if (!candidates.length) return null;
|
|
||||||
|
|
||||||
// Label is just the hostname (you already simplified this earlier)
|
|
||||||
const label = host;
|
|
||||||
|
|
||||||
// Keep latest around if you use it elsewhere, but it no longer affects ordering
|
|
||||||
const latest = Math.max(...candidates.map((r) => r.last_seen || 0));
|
|
||||||
|
|
||||||
return { host, label, contexts, latest };
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
// Always alphabetical, case-insensitive, numeric-aware
|
|
||||||
.sort((a, b) => hostCollator.compare(a.host, b.host));
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}, [agentsByHostname, hostCollator]);
|
|
||||||
|
|
||||||
// Fetch Agents Periodically
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchAgents = () => {
|
|
||||||
fetch("/api/agents")
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then(setAgents)
|
|
||||||
.catch(() => {});
|
|
||||||
};
|
|
||||||
fetchAgents();
|
|
||||||
const interval = setInterval(fetchAgents, 10000); // Update Agent List Every 10 Seconds
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch sites list
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchSites = () => {
|
|
||||||
fetch("/api/sites")
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((data) => {
|
|
||||||
const siteEntries = Array.isArray(data?.sites) ? data.sites : [];
|
|
||||||
setSites(siteEntries);
|
|
||||||
})
|
|
||||||
.catch(() => setSites([]));
|
|
||||||
};
|
|
||||||
fetchSites();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch site mapping for current host options
|
|
||||||
useEffect(() => {
|
|
||||||
const hostnames = hostOptions.map(({ host }) => host).filter(Boolean);
|
|
||||||
if (!hostnames.length) {
|
|
||||||
setSiteMapping({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const query = hostnames.map(encodeURIComponent).join(",");
|
|
||||||
fetch(`/api/sites/device_map?hostnames=${query}`)
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((data) => {
|
|
||||||
const mapping = data?.mapping && typeof data.mapping === "object" ? data.mapping : {};
|
|
||||||
setSiteMapping(mapping);
|
|
||||||
})
|
|
||||||
.catch(() => setSiteMapping({}));
|
|
||||||
}, [hostOptions]);
|
|
||||||
|
|
||||||
const filteredHostOptions = useMemo(() => {
|
|
||||||
if (!selectedSiteId) return hostOptions;
|
|
||||||
return hostOptions.filter(({ host }) => {
|
|
||||||
const mapping = siteMapping[host];
|
|
||||||
if (!mapping || typeof mapping.site_id === "undefined" || mapping.site_id === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return String(mapping.site_id) === selectedSiteId;
|
|
||||||
});
|
|
||||||
}, [hostOptions, selectedSiteId, siteMapping]);
|
|
||||||
|
|
||||||
// Align selected site with known host mapping when available
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedSiteId || !selectedHost) return;
|
|
||||||
const mapping = siteMapping[selectedHost];
|
|
||||||
if (!mapping || typeof mapping.site_id === "undefined" || mapping.site_id === null) return;
|
|
||||||
const mappedId = String(mapping.site_id);
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id
|
|
||||||
? {
|
|
||||||
...n,
|
|
||||||
data: {
|
|
||||||
...n.data,
|
|
||||||
agent_site_id: mappedId,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, [selectedHost, selectedSiteId, siteMapping, id, setNodes]);
|
|
||||||
|
|
||||||
// Ensure host selection stays aligned with available agents
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedHost) return;
|
|
||||||
|
|
||||||
const hostExists = filteredHostOptions.some((opt) => opt.host === selectedHost);
|
|
||||||
if (hostExists) return;
|
|
||||||
|
|
||||||
if (selectedAgent && agents[selectedAgent]) {
|
|
||||||
const info = agents[selectedAgent];
|
|
||||||
const inferredHost = (info?.hostname || info?.agent_hostname || "").trim() || "unknown";
|
|
||||||
const allowed = filteredHostOptions.some((opt) => opt.host === inferredHost);
|
|
||||||
if (allowed && inferredHost && inferredHost !== selectedHost) {
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id
|
|
||||||
? {
|
|
||||||
...n,
|
|
||||||
data: {
|
|
||||||
...n.data,
|
|
||||||
agent_host: inferredHost,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id
|
|
||||||
? {
|
|
||||||
...n,
|
|
||||||
data: {
|
|
||||||
...n.data,
|
|
||||||
agent_host: "",
|
|
||||||
agent_id: "",
|
|
||||||
agent_mode: "currentuser",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, [filteredHostOptions, selectedHost, selectedAgent, agents, id, setNodes]);
|
|
||||||
|
|
||||||
const siteSelectOptions = useMemo(() => {
|
|
||||||
const entries = Array.isArray(sites) ? [...sites] : [];
|
|
||||||
entries.sort((a, b) =>
|
|
||||||
(a?.name || "").localeCompare(b?.name || "", undefined, { sensitivity: "base" })
|
|
||||||
);
|
|
||||||
const mapped = entries.map((site) => ({
|
|
||||||
value: String(site.id),
|
|
||||||
label: site.name || `Site ${site.id}`,
|
|
||||||
}));
|
|
||||||
return [{ value: "", label: "All Sites" }, ...mapped];
|
|
||||||
}, [sites]);
|
|
||||||
|
|
||||||
const hostSelectOptions = useMemo(() => {
|
|
||||||
const mapped = filteredHostOptions.map(({ host, label }) => ({
|
|
||||||
value: host,
|
|
||||||
label,
|
|
||||||
}));
|
|
||||||
return [{ value: "", label: "-- Select --" }, ...mapped];
|
|
||||||
}, [filteredHostOptions]);
|
|
||||||
|
|
||||||
const activeHostContexts = selectedHost ? agentsByHostname[selectedHost] : null;
|
|
||||||
|
|
||||||
const modeSelectOptions = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
value: "currentuser",
|
|
||||||
label: "CURRENTUSER (Screen Capture / Macros)",
|
|
||||||
disabled: !activeHostContexts?.currentuser,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "system",
|
|
||||||
label: "SYSTEM (Scripts)",
|
|
||||||
disabled: !activeHostContexts?.system,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[activeHostContexts]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id
|
|
||||||
? {
|
|
||||||
...n,
|
|
||||||
data: {
|
|
||||||
...n.data,
|
|
||||||
siteOptions: siteSelectOptions,
|
|
||||||
hostOptions: hostSelectOptions,
|
|
||||||
modeOptions: modeSelectOptions,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, [id, setNodes, siteSelectOptions, hostSelectOptions, modeSelectOptions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedHost) {
|
|
||||||
if (selectedAgent || selectedMode !== "currentuser") {
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id
|
|
||||||
? {
|
|
||||||
...n,
|
|
||||||
data: {
|
|
||||||
...n.data,
|
|
||||||
agent_id: "",
|
|
||||||
agent_mode: "currentuser",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contexts = agentsByHostname[selectedHost];
|
|
||||||
if (!contexts) {
|
|
||||||
if (selectedAgent || selectedMode !== "currentuser") {
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id
|
|
||||||
? {
|
|
||||||
...n,
|
|
||||||
data: {
|
|
||||||
...n.data,
|
|
||||||
agent_id: "",
|
|
||||||
agent_mode: "currentuser",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!contexts[selectedMode]) {
|
|
||||||
const fallbackMode = contexts.currentuser
|
|
||||||
? "currentuser"
|
|
||||||
: contexts.system
|
|
||||||
? "system"
|
|
||||||
: "currentuser";
|
|
||||||
const fallbackAgentId = contexts[fallbackMode]?.agent_id || "";
|
|
||||||
if (fallbackMode !== selectedMode || fallbackAgentId !== selectedAgent) {
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id
|
|
||||||
? {
|
|
||||||
...n,
|
|
||||||
data: {
|
|
||||||
...n.data,
|
|
||||||
agent_mode: fallbackMode,
|
|
||||||
agent_id: fallbackAgentId,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetAgentId = contexts[selectedMode]?.agent_id || "";
|
|
||||||
if (targetAgentId !== selectedAgent) {
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id
|
|
||||||
? {
|
|
||||||
...n,
|
|
||||||
data: {
|
|
||||||
...n.data,
|
|
||||||
agent_id: targetAgentId,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [selectedHost, selectedMode, agentsByHostname, selectedAgent, id, setNodes]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const prev = selectionRef.current;
|
|
||||||
const changed =
|
|
||||||
prev.host !== selectedHost ||
|
|
||||||
prev.mode !== selectedMode ||
|
|
||||||
prev.agentId !== selectedAgent ||
|
|
||||||
prev.siteId !== selectedSiteId;
|
|
||||||
if (!changed) return;
|
|
||||||
|
|
||||||
const selectionChangedAgent =
|
|
||||||
prev.agentId &&
|
|
||||||
(prev.agentId !== selectedAgent || prev.host !== selectedHost || prev.mode !== selectedMode);
|
|
||||||
if (selectionChangedAgent) {
|
|
||||||
setIsConnected(false);
|
|
||||||
prevRolesRef.current = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
selectionRef.current = {
|
|
||||||
host: selectedHost,
|
|
||||||
mode: selectedMode,
|
|
||||||
agentId: selectedAgent,
|
|
||||||
siteId: selectedSiteId,
|
|
||||||
};
|
|
||||||
}, [selectedHost, selectedMode, selectedAgent, selectedSiteId]);
|
|
||||||
|
|
||||||
// Attached Roles logic
|
|
||||||
const attachedRoleIds = useMemo(
|
|
||||||
() =>
|
|
||||||
edges
|
|
||||||
.filter((e) => e.source === id && e.sourceHandle === "provisioner")
|
|
||||||
.map((e) => e.target),
|
|
||||||
[edges, id]
|
|
||||||
);
|
|
||||||
const getAttachedRoles = useCallback(() => {
|
|
||||||
const allNodes = getNodes();
|
|
||||||
return attachedRoleIds
|
|
||||||
.map((nid) => {
|
|
||||||
const fn = window.__BorealisInstructionNodes?.[nid];
|
|
||||||
return typeof fn === "function" ? fn() : null;
|
|
||||||
})
|
|
||||||
.filter((r) => r);
|
|
||||||
}, [attachedRoleIds, getNodes]);
|
|
||||||
|
|
||||||
// Provision Roles to Agent
|
|
||||||
const provisionRoles = useCallback((roles) => {
|
|
||||||
if (!selectedAgent) return;
|
|
||||||
fetch("/api/agent/provision", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ agent_id: selectedAgent, roles })
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
setIsConnected(true);
|
|
||||||
prevRolesRef.current = roles;
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, [selectedAgent]);
|
|
||||||
const handleConnect = useCallback(() => {
|
|
||||||
const roles = getAttachedRoles();
|
|
||||||
provisionRoles(roles);
|
|
||||||
}, [getAttachedRoles, provisionRoles]);
|
|
||||||
const handleDisconnect = useCallback(() => {
|
|
||||||
if (!selectedAgent) return;
|
|
||||||
fetch("/api/agent/provision", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ agent_id: selectedAgent, roles: [] })
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
setIsConnected(false);
|
|
||||||
prevRolesRef.current = [];
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, [selectedAgent]);
|
|
||||||
|
|
||||||
// Auto-provision on role change
|
|
||||||
useEffect(() => {
|
|
||||||
const newRoles = getAttachedRoles();
|
|
||||||
const prevSerialized = JSON.stringify(prevRolesRef.current || []);
|
|
||||||
const newSerialized = JSON.stringify(newRoles);
|
|
||||||
if (isConnected && newSerialized !== prevSerialized) {
|
|
||||||
provisionRoles(newRoles);
|
|
||||||
}
|
|
||||||
}, [attachedRoleIds, isConnected, getAttachedRoles, provisionRoles]);
|
|
||||||
|
|
||||||
// Status Label
|
|
||||||
const selectedAgentStatus = useMemo(() => {
|
|
||||||
if (!selectedHost) return "Unassigned";
|
|
||||||
const contexts = agentsByHostname[selectedHost];
|
|
||||||
if (!contexts) return "Offline";
|
|
||||||
const activeContext = contexts[selectedMode];
|
|
||||||
if (!selectedAgent || !activeContext) return "Unavailable";
|
|
||||||
const status = (activeContext.status || "").toString().toLowerCase();
|
|
||||||
if (status === "provisioned") return "Connected";
|
|
||||||
if (status === "orphaned") return "Available";
|
|
||||||
if (!status) return "Available";
|
|
||||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
|
||||||
}, [agentsByHostname, selectedHost, selectedMode, selectedAgent]);
|
|
||||||
|
|
||||||
// Render (Sidebar handles config)
|
|
||||||
return (
|
|
||||||
<div className="borealis-node">
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Bottom}
|
|
||||||
id="provisioner"
|
|
||||||
className="borealis-handle"
|
|
||||||
style={{ top: "100%", background: "#58a6ff" }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="borealis-node-header">Device Agent</div>
|
|
||||||
<div
|
|
||||||
className="borealis-node-content"
|
|
||||||
style={{
|
|
||||||
fontSize: "9px",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
textAlign: "center",
|
|
||||||
minHeight: "80px",
|
|
||||||
gap: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: "8px", color: "#666" }}>Right-Click to Configure Agent</div>
|
|
||||||
<button
|
|
||||||
onClick={isConnected ? handleDisconnect : handleConnect}
|
|
||||||
style={{
|
|
||||||
padding: "6px 14px",
|
|
||||||
fontSize: "10px",
|
|
||||||
background: isConnected ? "#3a3a3a" : "#0475c2",
|
|
||||||
color: "#fff",
|
|
||||||
border: "1px solid #0475c2",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor: selectedAgent ? "pointer" : "not-allowed",
|
|
||||||
opacity: selectedAgent ? 1 : 0.5,
|
|
||||||
minWidth: "150px",
|
|
||||||
}}
|
|
||||||
disabled={!selectedAgent}
|
|
||||||
>
|
|
||||||
{isConnected ? "Disconnect" : "Connect to Device"}
|
|
||||||
</button>
|
|
||||||
<div style={{ fontSize: "8px", color: "#777" }}>
|
|
||||||
{selectedHost ? `${selectedHost} · ${selectedMode.toUpperCase()}` : "No device selected"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Node Registration Object with sidebar config and docs
|
|
||||||
export default {
|
|
||||||
type: "Borealis_Agent",
|
|
||||||
label: "Device Agent",
|
|
||||||
description: `
|
|
||||||
Select and connect to a remote Borealis Agent.
|
|
||||||
- Assign roles to agent dynamically by connecting "Agent Role" nodes.
|
|
||||||
- Auto-provisions agent as role assignments change.
|
|
||||||
- See live agent status and re-connect/disconnect easily.
|
|
||||||
- Choose between CURRENTUSER and SYSTEM contexts for each device.
|
|
||||||
`.trim(),
|
|
||||||
content: "Select and manage an Agent with dynamic roles",
|
|
||||||
component: BorealisAgentNode,
|
|
||||||
config: [
|
|
||||||
{
|
|
||||||
key: "agent_site_id",
|
|
||||||
label: "Site",
|
|
||||||
type: "select",
|
|
||||||
optionsKey: "siteOptions",
|
|
||||||
defaultValue: ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "agent_host",
|
|
||||||
label: "Device",
|
|
||||||
type: "select",
|
|
||||||
optionsKey: "hostOptions",
|
|
||||||
defaultValue: ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "agent_mode",
|
|
||||||
label: "Agent Context",
|
|
||||||
type: "select",
|
|
||||||
optionsKey: "modeOptions",
|
|
||||||
defaultValue: "currentuser"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "agent_id",
|
|
||||||
label: "Agent ID",
|
|
||||||
type: "text",
|
|
||||||
readOnly: true,
|
|
||||||
defaultValue: ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
usage_documentation: `
|
|
||||||
### Borealis Agent Node
|
|
||||||
|
|
||||||
This node allows you to establish a connection with a device running a Borealis "Agent", so you can instruct the agent to do things from your workflow.
|
|
||||||
|
|
||||||
#### Features
|
|
||||||
- **Select** a site, then a device, then finally an agent context (CURRENTUSER vs SYSTEM).
|
|
||||||
- **Connect/Disconnect** from the agent at any time.
|
|
||||||
- **Attach roles** (by connecting "Agent Role" nodes to this node's output handle) to assign behaviors dynamically.
|
|
||||||
|
|
||||||
#### How to Use
|
|
||||||
1. **Drag and drop in a Borealis Agent node.**
|
|
||||||
2. **Pick an agent** from the dropdown list (auto-populates from API backend).
|
|
||||||
3. **Click "Connect to Agent"**.
|
|
||||||
4. **Attach Agent Role Nodes** (e.g., Screenshot, Macro Keypress) to the "provisioner" output handle to define what the agent should do.
|
|
||||||
5. Agent will automatically update its roles as you change connected Role Nodes.
|
|
||||||
|
|
||||||
#### Good to Know
|
|
||||||
- If an agent disconnects or goes offline, its status will show "Reconnecting..." until it returns.
|
|
||||||
- **Roles update LIVE**: Any time you change attached roles, the agent gets updated instantly.
|
|
||||||
|
|
||||||
`.trim()
|
|
||||||
};
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Agent Roles/Node_Agent_Role_Macro.jsx
|
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
|
||||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
|
||||||
import "react-simple-keyboard/build/css/index.css";
|
|
||||||
|
|
||||||
// Default update interval for window list refresh (in ms)
|
|
||||||
const WINDOW_LIST_REFRESH_MS = 4000;
|
|
||||||
|
|
||||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
|
||||||
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
|
|
||||||
|
|
||||||
const DEFAULT_OPERATION_MODE = "Continuous";
|
|
||||||
const OPERATION_MODES = [
|
|
||||||
"Run Once",
|
|
||||||
"Continuous",
|
|
||||||
"Trigger-Once",
|
|
||||||
"Trigger-Continuous"
|
|
||||||
];
|
|
||||||
|
|
||||||
const MACRO_TYPES = [
|
|
||||||
"keypress",
|
|
||||||
"typed_text"
|
|
||||||
];
|
|
||||||
|
|
||||||
const statusColors = {
|
|
||||||
idle: "#333",
|
|
||||||
running: "#00d18c",
|
|
||||||
error: "#ff4f4f",
|
|
||||||
success: "#00d18c"
|
|
||||||
};
|
|
||||||
|
|
||||||
const MacroKeyPressNode = ({ id, data }) => {
|
|
||||||
const { setNodes, getNodes } = useReactFlow();
|
|
||||||
const edges = useStore((state) => state.edges);
|
|
||||||
const [windowList, setWindowList] = useState([]);
|
|
||||||
const [status, setStatus] = useState({ state: "idle", message: "" });
|
|
||||||
const socketRef = useRef(null);
|
|
||||||
|
|
||||||
// Determine if agent is connected
|
|
||||||
const agentEdge = edges.find((e) => e.target === id && e.targetHandle === "agent");
|
|
||||||
const agentNode = agentEdge && getNodes().find((n) => n.id === agentEdge.source);
|
|
||||||
const agentConnection = !!(agentNode && agentNode.data && agentNode.data.agent_id);
|
|
||||||
const agent_id = agentNode && agentNode.data && agentNode.data.agent_id;
|
|
||||||
|
|
||||||
// Macro run/trigger state (sidebar sets this via config, but node UI just shows status)
|
|
||||||
const running = data?.active === true || data?.active === "true";
|
|
||||||
|
|
||||||
// Store for last macro error/status
|
|
||||||
const [lastMacroStatus, setLastMacroStatus] = useState({ success: true, message: "", timestamp: null });
|
|
||||||
|
|
||||||
// Setup WebSocket for agent macro status updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (!window.BorealisSocket) return;
|
|
||||||
const socket = window.BorealisSocket;
|
|
||||||
socketRef.current = socket;
|
|
||||||
|
|
||||||
function handleMacroStatus(payload) {
|
|
||||||
if (
|
|
||||||
payload &&
|
|
||||||
payload.agent_id === agent_id &&
|
|
||||||
payload.node_id === id
|
|
||||||
) {
|
|
||||||
setLastMacroStatus({
|
|
||||||
success: !!payload.success,
|
|
||||||
message: payload.message || "",
|
|
||||||
timestamp: payload.timestamp || Date.now()
|
|
||||||
});
|
|
||||||
setStatus({
|
|
||||||
state: payload.success ? "success" : "error",
|
|
||||||
message: payload.message || (payload.success ? "Success" : "Error")
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.on("macro_status", handleMacroStatus);
|
|
||||||
return () => {
|
|
||||||
socket.off("macro_status", handleMacroStatus);
|
|
||||||
};
|
|
||||||
}, [agent_id, id]);
|
|
||||||
|
|
||||||
// Auto-refresh window list from agent
|
|
||||||
useEffect(() => {
|
|
||||||
let intervalId = null;
|
|
||||||
async function fetchWindows() {
|
|
||||||
if (window.BorealisSocket && agentConnection) {
|
|
||||||
window.BorealisSocket.emit("list_agent_windows", {
|
|
||||||
agent_id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetchWindows();
|
|
||||||
intervalId = setInterval(fetchWindows, WINDOW_LIST_REFRESH_MS);
|
|
||||||
|
|
||||||
// Listen for agent_window_list updates
|
|
||||||
function handleAgentWindowList(payload) {
|
|
||||||
if (payload?.agent_id === agent_id && Array.isArray(payload.windows)) {
|
|
||||||
setWindowList(payload.windows);
|
|
||||||
|
|
||||||
// Store windowList in node data for sidebar dynamic dropdowns
|
|
||||||
setNodes(nds =>
|
|
||||||
nds.map(n =>
|
|
||||||
n.id === id
|
|
||||||
? { ...n, data: { ...n.data, windowList: payload.windows } }
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (window.BorealisSocket) {
|
|
||||||
window.BorealisSocket.on("agent_window_list", handleAgentWindowList);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
if (window.BorealisSocket) {
|
|
||||||
window.BorealisSocket.off("agent_window_list", handleAgentWindowList);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [agent_id, agentConnection, setNodes, id]);
|
|
||||||
|
|
||||||
// UI: Start/Pause Button
|
|
||||||
const handleToggleMacro = () => {
|
|
||||||
setNodes(nds =>
|
|
||||||
nds.map(n =>
|
|
||||||
n.id === id
|
|
||||||
? {
|
|
||||||
...n,
|
|
||||||
data: {
|
|
||||||
...n.data,
|
|
||||||
active: n.data?.active === true || n.data?.active === "true" ? "false" : "true"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Optional: Show which window is targeted by name
|
|
||||||
const selectedWindow = (windowList || []).find(w => String(w.handle) === String(data?.window_handle));
|
|
||||||
|
|
||||||
// Node UI (no config fields, only status + window list)
|
|
||||||
return (
|
|
||||||
<div className="borealis-node" style={{ minWidth: 280, position: "relative" }}>
|
|
||||||
{/* --- INPUT LABELS & HANDLES --- */}
|
|
||||||
<div style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: -30,
|
|
||||||
top: 26,
|
|
||||||
fontSize: "8px",
|
|
||||||
color: "#6ef9fb",
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
pointerEvents: "none"
|
|
||||||
}}>
|
|
||||||
Agent
|
|
||||||
</div>
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Left}
|
|
||||||
id="agent"
|
|
||||||
style={{
|
|
||||||
top: 25,
|
|
||||||
}}
|
|
||||||
className="borealis-handle"
|
|
||||||
/>
|
|
||||||
<div style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: -34,
|
|
||||||
top: 70,
|
|
||||||
fontSize: "8px",
|
|
||||||
color: "#6ef9fb",
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
pointerEvents: "none"
|
|
||||||
}}>
|
|
||||||
Trigger
|
|
||||||
</div>
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Left}
|
|
||||||
id="trigger"
|
|
||||||
style={{
|
|
||||||
top: 68,
|
|
||||||
}}
|
|
||||||
className="borealis-handle"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="borealis-node-header" style={{ position: "relative" }}>
|
|
||||||
Agent Role: Macro
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "50%",
|
|
||||||
right: "8px",
|
|
||||||
width: "10px",
|
|
||||||
transform: "translateY(-50%)",
|
|
||||||
height: "10px",
|
|
||||||
borderRadius: "50%",
|
|
||||||
backgroundColor:
|
|
||||||
status.state === "error"
|
|
||||||
? statusColors.error
|
|
||||||
: running
|
|
||||||
? statusColors.running
|
|
||||||
: statusColors.idle,
|
|
||||||
border: "1px solid #222"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="borealis-node-content">
|
|
||||||
<strong>Status</strong>:{" "}
|
|
||||||
{status.state === "error"
|
|
||||||
? (
|
|
||||||
<span style={{ color: "#ff4f4f" }}>
|
|
||||||
Error{lastMacroStatus.message ? `: ${lastMacroStatus.message}` : ""}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
: running
|
|
||||||
? (
|
|
||||||
<span style={{ color: "#00d18c" }}>
|
|
||||||
Running{lastMacroStatus.message ? ` (${lastMacroStatus.message})` : ""}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
: "Idle"}
|
|
||||||
<br />
|
|
||||||
<strong>Agent Connection</strong>: {agentConnection ? "Connected" : "Not Connected"}
|
|
||||||
<br />
|
|
||||||
<strong>Target Window</strong>:{" "}
|
|
||||||
{selectedWindow
|
|
||||||
? `${selectedWindow.title} (${selectedWindow.handle})`
|
|
||||||
: data?.window_handle
|
|
||||||
? `Handle: ${data.window_handle}`
|
|
||||||
: <span style={{ color: "#888" }}>Not set</span>}
|
|
||||||
<br />
|
|
||||||
<strong>Mode</strong>: {data?.operation_mode || DEFAULT_OPERATION_MODE}
|
|
||||||
<br />
|
|
||||||
<strong>Macro Type</strong>: {data?.macro_type || "keypress"}
|
|
||||||
<br />
|
|
||||||
<button
|
|
||||||
onClick={handleToggleMacro}
|
|
||||||
style={{
|
|
||||||
marginTop: 8,
|
|
||||||
padding: "4px 10px",
|
|
||||||
background: running ? "#3a3a3a" : "#0475c2",
|
|
||||||
color: running ? "#fff" : "#fff",
|
|
||||||
border: "1px solid #0475c2",
|
|
||||||
borderRadius: 3,
|
|
||||||
fontSize: "11px",
|
|
||||||
cursor: "pointer"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{running ? "Pause Macro" : "Start Macro"}
|
|
||||||
</button>
|
|
||||||
<br />
|
|
||||||
<span style={{ fontSize: "9px", color: "#aaa" }}>
|
|
||||||
{lastMacroStatus.timestamp
|
|
||||||
? `Last event: ${new Date(lastMacroStatus.timestamp).toLocaleTimeString()}`
|
|
||||||
: ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ----- Node Catalog Export -----
|
|
||||||
export default {
|
|
||||||
type: "Macro_KeyPress",
|
|
||||||
label: "Agent Role: Macro",
|
|
||||||
description: `
|
|
||||||
Send automated key presses or typed text to any open application window on the connected agent.
|
|
||||||
Supports manual, continuous, trigger, and one-shot modes for automation and event-driven workflows.
|
|
||||||
`,
|
|
||||||
content: "Send Key Press or Typed Text to Window via Agent",
|
|
||||||
component: MacroKeyPressNode,
|
|
||||||
config: [
|
|
||||||
{ key: "window_handle", label: "Target Window", type: "select", dynamicOptions: true, defaultValue: "" },
|
|
||||||
{ key: "macro_type", label: "Macro Type", type: "select", options: ["keypress", "typed_text"], defaultValue: "keypress" },
|
|
||||||
{ key: "key", label: "Key", type: "text", defaultValue: "" },
|
|
||||||
{ key: "text", label: "Typed Text", type: "text", defaultValue: "" },
|
|
||||||
{ key: "interval_ms", label: "Interval (ms)", type: "text", defaultValue: "1000" },
|
|
||||||
{ key: "randomize_interval", label: "Randomize Interval", type: "select", options: ["true", "false"], defaultValue: "false" },
|
|
||||||
{ key: "random_min", label: "Random Min (ms)", type: "text", defaultValue: "750" },
|
|
||||||
{ key: "random_max", label: "Random Max (ms)", type: "text", defaultValue: "950" },
|
|
||||||
{ key: "operation_mode", label: "Operation Mode", type: "select", options: OPERATION_MODES, defaultValue: "Continuous" },
|
|
||||||
{ key: "active", label: "Macro Enabled", type: "select", options: ["true", "false"], defaultValue: "false" },
|
|
||||||
{ key: "trigger", label: "Trigger Value", type: "text", defaultValue: "0" }
|
|
||||||
],
|
|
||||||
usage_documentation: `
|
|
||||||
### Agent Role: Macro
|
|
||||||
|
|
||||||
**Modes:**
|
|
||||||
- **Continuous**: Macro sends input non-stop when started by button.
|
|
||||||
- **Trigger-Continuous**: Macro sends input as long as upstream trigger is "1".
|
|
||||||
- **Trigger-Once**: Macro fires once per upstream "1" (one-shot edge).
|
|
||||||
- **Run Once**: Macro runs only once when started by button.
|
|
||||||
|
|
||||||
**Macro Types:**
|
|
||||||
- **Single Keypress**: Press a single key.
|
|
||||||
- **Typed Text**: Types out a string.
|
|
||||||
|
|
||||||
**Window Target:**
|
|
||||||
- Dropdown of live windows from agent, stays updated.
|
|
||||||
|
|
||||||
**Event-Driven Support:**
|
|
||||||
- Chain with other Borealis nodes (text recognition, event triggers, etc).
|
|
||||||
|
|
||||||
**Live Status:**
|
|
||||||
- Displays last agent macro event and error feedback in node.
|
|
||||||
|
|
||||||
---
|
|
||||||
`.trim()
|
|
||||||
};
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Agent/Node_Agent_Role_Screenshot.jsx
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
|
||||||
import ShareIcon from "@mui/icons-material/Share";
|
|
||||||
import IconButton from "@mui/material/IconButton";
|
|
||||||
|
|
||||||
/*
|
|
||||||
Agent Role: Screenshot Node (Modern, Sidebar Config Enabled)
|
|
||||||
|
|
||||||
- Defines a screenshot region to be captured by a remote Borealis Agent.
|
|
||||||
- Pushes live base64 PNG data to downstream nodes.
|
|
||||||
- Region coordinates (x, y, w, h), visibility, overlay label, and interval are all persisted and synchronized.
|
|
||||||
- All configuration is moved to the right sidebar (Node Properties).
|
|
||||||
- Maintains full bi-directional write-back of coordinates and overlay settings.
|
|
||||||
*/
|
|
||||||
|
|
||||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
|
||||||
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
|
|
||||||
|
|
||||||
const AgentScreenshotNode = ({ id, data }) => {
|
|
||||||
const { setNodes, getNodes } = useReactFlow();
|
|
||||||
const edges = useStore(state => state.edges);
|
|
||||||
|
|
||||||
const resolveAgentData = useCallback(() => {
|
|
||||||
try {
|
|
||||||
const agentEdge = edges.find(e => e.target === id && e.sourceHandle === "provisioner");
|
|
||||||
const agentNode = getNodes().find(n => n.id === agentEdge?.source);
|
|
||||||
return agentNode?.data || null;
|
|
||||||
} catch (err) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, [edges, getNodes, id]);
|
|
||||||
|
|
||||||
|
|
||||||
// Core config values pulled from sidebar config (with defaults)
|
|
||||||
const interval = parseInt(data?.interval || 1000, 10) || 1000;
|
|
||||||
const region = {
|
|
||||||
x: parseInt(data?.x ?? 250, 10),
|
|
||||||
y: parseInt(data?.y ?? 100, 10),
|
|
||||||
w: parseInt(data?.w ?? 300, 10),
|
|
||||||
h: parseInt(data?.h ?? 200, 10)
|
|
||||||
};
|
|
||||||
const visible = (data?.visible ?? "true") === "true";
|
|
||||||
const alias = data?.alias || "";
|
|
||||||
const [imageBase64, setImageBase64] = useState(data?.value || "");
|
|
||||||
const agentData = resolveAgentData();
|
|
||||||
const targetModeLabel = ((agentData?.agent_mode || "").toString().toLowerCase() === "system")
|
|
||||||
? "SYSTEM Agent"
|
|
||||||
: "CURRENTUSER Agent";
|
|
||||||
const targetHostLabel = (agentData?.agent_host || "").toString();
|
|
||||||
|
|
||||||
// Always push current imageBase64 into BorealisValueBus at the global update rate
|
|
||||||
useEffect(() => {
|
|
||||||
const intervalId = setInterval(() => {
|
|
||||||
if (imageBase64) {
|
|
||||||
window.BorealisValueBus[id] = imageBase64;
|
|
||||||
setNodes(nds =>
|
|
||||||
nds.map(n =>
|
|
||||||
n.id === id ? { ...n, data: { ...n.data, value: imageBase64 } } : n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, window.BorealisUpdateRate || 100);
|
|
||||||
return () => clearInterval(intervalId);
|
|
||||||
}, [id, imageBase64, setNodes]);
|
|
||||||
|
|
||||||
// Listen for agent screenshot and overlay region updates
|
|
||||||
useEffect(() => {
|
|
||||||
const socket = window.BorealisSocket;
|
|
||||||
if (!socket) return;
|
|
||||||
|
|
||||||
const handleScreenshot = (payload) => {
|
|
||||||
if (payload?.node_id !== id) return;
|
|
||||||
// Additionally ensure payload is from the agent connected upstream of this node
|
|
||||||
const agentData = resolveAgentData();
|
|
||||||
const selectedAgentId = agentData?.agent_id;
|
|
||||||
if (!selectedAgentId || payload?.agent_id !== selectedAgentId) return;
|
|
||||||
|
|
||||||
if (payload.image_base64) {
|
|
||||||
setImageBase64(payload.image_base64);
|
|
||||||
window.BorealisValueBus[id] = payload.image_base64;
|
|
||||||
}
|
|
||||||
const { x, y, w, h } = payload;
|
|
||||||
if (
|
|
||||||
x !== undefined &&
|
|
||||||
y !== undefined &&
|
|
||||||
w !== undefined &&
|
|
||||||
h !== undefined
|
|
||||||
) {
|
|
||||||
setNodes(nds =>
|
|
||||||
nds.map(n =>
|
|
||||||
n.id === id ? { ...n, data: { ...n.data, x, y, w, h } } : n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on("agent_screenshot_task", handleScreenshot);
|
|
||||||
return () => socket.off("agent_screenshot_task", handleScreenshot);
|
|
||||||
}, [id, setNodes, resolveAgentData]);
|
|
||||||
|
|
||||||
// Register this node for the agent provisioning sync
|
|
||||||
window.__BorealisInstructionNodes = window.__BorealisInstructionNodes || {};
|
|
||||||
window.__BorealisInstructionNodes[id] = () => {
|
|
||||||
const agentData = resolveAgentData() || {};
|
|
||||||
const modeRaw = (agentData.agent_mode || "").toString().toLowerCase();
|
|
||||||
const targetMode = modeRaw === "system" ? "system" : "currentuser";
|
|
||||||
return {
|
|
||||||
node_id: id,
|
|
||||||
role: "screenshot",
|
|
||||||
interval,
|
|
||||||
visible,
|
|
||||||
alias,
|
|
||||||
target_agent_mode: targetMode,
|
|
||||||
target_agent_host: agentData.agent_host || "",
|
|
||||||
...region
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Manual live view copy button
|
|
||||||
const handleCopyLiveViewLink = () => {
|
|
||||||
const agentData = resolveAgentData();
|
|
||||||
const selectedAgentId = agentData?.agent_id;
|
|
||||||
|
|
||||||
if (!selectedAgentId) {
|
|
||||||
alert("No valid agent connection found.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const liveUrl = `${window.location.origin}/api/agent/${selectedAgentId}/node/${id}/screenshot/live`;
|
|
||||||
navigator.clipboard.writeText(liveUrl)
|
|
||||||
.then(() => console.log(`[Clipboard] Live View URL copied: ${liveUrl}`))
|
|
||||||
.catch(err => console.error("Clipboard copy failed:", err));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Node card UI - config handled in sidebar
|
|
||||||
return (
|
|
||||||
<div className="borealis-node" style={{ position: "relative" }}>
|
|
||||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
|
||||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
|
||||||
|
|
||||||
<div className="borealis-node-header">
|
|
||||||
{data?.label || "Agent Role: Screenshot"}
|
|
||||||
</div>
|
|
||||||
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
|
|
||||||
<div>
|
|
||||||
<b>Region:</b> X:{region.x} Y:{region.y} W:{region.w} H:{region.h}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<b>Interval:</b> {interval} ms
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<b>Agent Context:</b> {targetModeLabel}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<b>Target Host:</b>{" "}
|
|
||||||
{targetHostLabel ? (
|
|
||||||
targetHostLabel
|
|
||||||
) : (
|
|
||||||
<span style={{ color: "#666" }}>unknown</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<b>Overlay:</b> {visible ? "Yes" : "No"}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<b>Label:</b> {alias || <span style={{ color: "#666" }}>none</span>}
|
|
||||||
</div>
|
|
||||||
<div style={{ textAlign: "center", fontSize: "8px", color: "#aaa" }}>
|
|
||||||
{imageBase64
|
|
||||||
? `Last image: ${Math.round(imageBase64.length / 1024)} KB`
|
|
||||||
: "Awaiting Screenshot Data..."}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ position: "absolute", top: 4, right: 4 }}>
|
|
||||||
<IconButton size="small" onClick={handleCopyLiveViewLink}>
|
|
||||||
<ShareIcon style={{ fontSize: 14 }} />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Node registration for Borealis catalog (sidebar config enabled)
|
|
||||||
export default {
|
|
||||||
type: "Agent_Role_Screenshot",
|
|
||||||
label: "Agent Role: Screenshot",
|
|
||||||
description: `
|
|
||||||
Capture a live screenshot of a defined region from a remote Borealis Agent.
|
|
||||||
|
|
||||||
- Define region (X, Y, Width, Height)
|
|
||||||
- Select update interval (ms)
|
|
||||||
- Optionally show a visual overlay with a label
|
|
||||||
- Pushes base64 PNG stream to downstream nodes
|
|
||||||
- Use copy button to share live view URL
|
|
||||||
- Targets the CURRENTUSER or SYSTEM agent context selected upstream
|
|
||||||
`.trim(),
|
|
||||||
content: "Capture screenshot region via agent",
|
|
||||||
component: AgentScreenshotNode,
|
|
||||||
config: [
|
|
||||||
{
|
|
||||||
key: "interval",
|
|
||||||
label: "Update Interval (ms)",
|
|
||||||
type: "text",
|
|
||||||
defaultValue: "1000"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "x",
|
|
||||||
label: "Region X",
|
|
||||||
type: "text",
|
|
||||||
defaultValue: "250"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "y",
|
|
||||||
label: "Region Y",
|
|
||||||
type: "text",
|
|
||||||
defaultValue: "100"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "w",
|
|
||||||
label: "Region Width",
|
|
||||||
type: "text",
|
|
||||||
defaultValue: "300"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "h",
|
|
||||||
label: "Region Height",
|
|
||||||
type: "text",
|
|
||||||
defaultValue: "200"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "visible",
|
|
||||||
label: "Show Overlay on Agent",
|
|
||||||
type: "select",
|
|
||||||
options: ["true", "false"],
|
|
||||||
defaultValue: "true"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "alias",
|
|
||||||
label: "Overlay Label",
|
|
||||||
type: "text",
|
|
||||||
defaultValue: ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
usage_documentation: `
|
|
||||||
### Agent Role: Screenshot Node
|
|
||||||
|
|
||||||
This node defines a screenshot-capture role for a Borealis Agent.
|
|
||||||
|
|
||||||
**How It Works**
|
|
||||||
- The region (X, Y, W, H) is sent to the Agent for real-time screenshot capture.
|
|
||||||
- The interval determines how often the Agent captures and pushes new images.
|
|
||||||
- Optionally, an overlay with a label can be displayed on the Agent's screen for visual feedback.
|
|
||||||
- The captured screenshot (as a base64 PNG) is available to downstream nodes.
|
|
||||||
- Use the share button to copy a live viewing URL for the screenshot stream.
|
|
||||||
|
|
||||||
**Configuration**
|
|
||||||
- All fields are edited via the right sidebar.
|
|
||||||
- Coordinates update live if region is changed from the Agent.
|
|
||||||
|
|
||||||
**Warning**
|
|
||||||
- Changing region from the Agent UI will update this node's coordinates.
|
|
||||||
- Do not remove the bi-directional region write-back: if the region moves, this node updates immediately.
|
|
||||||
|
|
||||||
**Example Use Cases**
|
|
||||||
- Automated visual QA (comparing regions of apps)
|
|
||||||
- OCR on live application windows
|
|
||||||
- Remote monitoring dashboards
|
|
||||||
|
|
||||||
`.trim()
|
|
||||||
};
|
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: Node_Alert_Sound.jsx
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ==================================================
|
|
||||||
* Borealis - Alert Sound Node (with Base64 Restore)
|
|
||||||
* ==================================================
|
|
||||||
*
|
|
||||||
* COMPONENT ROLE:
|
|
||||||
* Plays a sound when input = "1". Provides a visual indicator:
|
|
||||||
* - Green dot: input is 0
|
|
||||||
* - Red dot: input is 1
|
|
||||||
*
|
|
||||||
* Modes:
|
|
||||||
* - "Once": Triggers once when going 0 -> 1
|
|
||||||
* - "Constant": Triggers repeatedly every X ms while input = 1
|
|
||||||
*
|
|
||||||
* Supports embedding base64 audio directly into the workflow.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
|
||||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
|
||||||
|
|
||||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
|
||||||
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
|
|
||||||
|
|
||||||
const AlertSoundNode = ({ id, data }) => {
|
|
||||||
const edges = useStore(state => state.edges);
|
|
||||||
const { setNodes } = useReactFlow();
|
|
||||||
|
|
||||||
const [alertType, setAlertType] = useState(data?.alertType || "Once");
|
|
||||||
const [intervalMs, setIntervalMs] = useState(data?.interval || 1000);
|
|
||||||
const [prevInput, setPrevInput] = useState("0");
|
|
||||||
const [customAudioBase64, setCustomAudioBase64] = useState(data?.audio || null);
|
|
||||||
const [currentInput, setCurrentInput] = useState("0");
|
|
||||||
|
|
||||||
const audioRef = useRef(null);
|
|
||||||
|
|
||||||
const playSound = () => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
console.log(`[Alert Node ${id}] Attempting to play sound`);
|
|
||||||
try {
|
|
||||||
audioRef.current.pause();
|
|
||||||
audioRef.current.currentTime = 0;
|
|
||||||
audioRef.current.load();
|
|
||||||
audioRef.current.play().then(() => {
|
|
||||||
console.log(`[Alert Node ${id}] Sound played successfully`);
|
|
||||||
}).catch((err) => {
|
|
||||||
console.warn(`[Alert Node ${id}] Audio play blocked or errored:`, err);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[Alert Node ${id}] Failed to play sound:`, err);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(`[Alert Node ${id}] No audioRef loaded`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileUpload = (event) => {
|
|
||||||
const file = event.target.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
console.log(`[Alert Node ${id}] File selected:`, file.name, file.type);
|
|
||||||
|
|
||||||
const supportedTypes = ["audio/wav", "audio/mp3", "audio/mpeg", "audio/ogg"];
|
|
||||||
if (!supportedTypes.includes(file.type)) {
|
|
||||||
console.warn(`[Alert Node ${id}] Unsupported audio type: ${file.type}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => {
|
|
||||||
const base64 = e.target.result;
|
|
||||||
const mimeType = file.type || "audio/mpeg";
|
|
||||||
const safeURL = base64.startsWith("data:")
|
|
||||||
? base64
|
|
||||||
: `data:${mimeType};base64,${base64}`;
|
|
||||||
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.pause();
|
|
||||||
audioRef.current.src = "";
|
|
||||||
audioRef.current.load();
|
|
||||||
audioRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newAudio = new Audio();
|
|
||||||
newAudio.src = safeURL;
|
|
||||||
|
|
||||||
let readyFired = false;
|
|
||||||
|
|
||||||
newAudio.addEventListener("canplaythrough", () => {
|
|
||||||
if (readyFired) return;
|
|
||||||
readyFired = true;
|
|
||||||
console.log(`[Alert Node ${id}] Audio is decodable and ready: ${file.name}`);
|
|
||||||
|
|
||||||
setCustomAudioBase64(safeURL);
|
|
||||||
audioRef.current = newAudio;
|
|
||||||
newAudio.load();
|
|
||||||
|
|
||||||
setNodes(nds =>
|
|
||||||
nds.map(n =>
|
|
||||||
n.id === id
|
|
||||||
? { ...n, data: { ...n.data, audio: safeURL } }
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!readyFired) {
|
|
||||||
console.warn(`[Alert Node ${id}] WARNING: Audio not marked ready in time. May fail silently.`);
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.onerror = (e) => {
|
|
||||||
console.error(`[Alert Node ${id}] File read error:`, e);
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Restore embedded audio from saved workflow
|
|
||||||
useEffect(() => {
|
|
||||||
if (customAudioBase64) {
|
|
||||||
console.log(`[Alert Node ${id}] Loading embedded audio from workflow`);
|
|
||||||
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.pause();
|
|
||||||
audioRef.current.src = "";
|
|
||||||
audioRef.current.load();
|
|
||||||
audioRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadedAudio = new Audio(customAudioBase64);
|
|
||||||
loadedAudio.addEventListener("canplaythrough", () => {
|
|
||||||
console.log(`[Alert Node ${id}] Embedded audio ready`);
|
|
||||||
});
|
|
||||||
|
|
||||||
audioRef.current = loadedAudio;
|
|
||||||
loadedAudio.load();
|
|
||||||
} else {
|
|
||||||
console.log(`[Alert Node ${id}] No custom audio, using fallback silent wav`);
|
|
||||||
audioRef.current = new Audio("data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAESsAACJWAAACABAAZGF0YRAAAAAA");
|
|
||||||
audioRef.current.load();
|
|
||||||
}
|
|
||||||
}, [customAudioBase64]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let currentRate = window.BorealisUpdateRate;
|
|
||||||
let intervalId = null;
|
|
||||||
|
|
||||||
const runLogic = () => {
|
|
||||||
const inputEdge = edges.find(e => e.target === id);
|
|
||||||
const sourceId = inputEdge?.source || null;
|
|
||||||
const val = sourceId ? (window.BorealisValueBus[sourceId] || "0") : "0";
|
|
||||||
|
|
||||||
setCurrentInput(val);
|
|
||||||
|
|
||||||
if (alertType === "Once") {
|
|
||||||
if (val === "1" && prevInput !== "1") {
|
|
||||||
console.log(`[Alert Node ${id}] Triggered ONCE playback`);
|
|
||||||
playSound();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setPrevInput(val);
|
|
||||||
};
|
|
||||||
|
|
||||||
const start = () => {
|
|
||||||
if (alertType === "Constant") {
|
|
||||||
intervalId = setInterval(() => {
|
|
||||||
const inputEdge = edges.find(e => e.target === id);
|
|
||||||
const sourceId = inputEdge?.source || null;
|
|
||||||
const val = sourceId ? (window.BorealisValueBus[sourceId] || "0") : "0";
|
|
||||||
setCurrentInput(val);
|
|
||||||
if (String(val) === "1") {
|
|
||||||
console.log(`[Alert Node ${id}] Triggered CONSTANT playback`);
|
|
||||||
playSound();
|
|
||||||
}
|
|
||||||
}, intervalMs);
|
|
||||||
} else {
|
|
||||||
intervalId = setInterval(runLogic, currentRate);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
start();
|
|
||||||
|
|
||||||
const monitor = setInterval(() => {
|
|
||||||
const newRate = window.BorealisUpdateRate;
|
|
||||||
if (newRate !== currentRate && alertType === "Once") {
|
|
||||||
currentRate = newRate;
|
|
||||||
clearInterval(intervalId);
|
|
||||||
start();
|
|
||||||
}
|
|
||||||
}, 250);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
clearInterval(monitor);
|
|
||||||
};
|
|
||||||
}, [edges, alertType, intervalMs, prevInput]);
|
|
||||||
|
|
||||||
const indicatorColor = currentInput === "1" ? "#ff4444" : "#44ff44";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="borealis-node" style={{ position: "relative" }}>
|
|
||||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
|
||||||
|
|
||||||
{/* Header with indicator dot */}
|
|
||||||
<div className="borealis-node-header" style={{ position: "relative" }}>
|
|
||||||
{data?.label || "Alert Sound"}
|
|
||||||
<div style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "50%",
|
|
||||||
right: "8px",
|
|
||||||
transform: "translateY(-50%)",
|
|
||||||
width: "10px",
|
|
||||||
height: "10px",
|
|
||||||
borderRadius: "50%",
|
|
||||||
backgroundColor: indicatorColor,
|
|
||||||
border: "1px solid #222"
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="borealis-node-content">
|
|
||||||
<div style={{ marginBottom: "8px", fontSize: "9px", color: "#ccc" }}>
|
|
||||||
Play a sound alert when input is "1"
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label style={{ fontSize: "9px", display: "block", marginBottom: "4px" }}>
|
|
||||||
Alerting Type:
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={alertType}
|
|
||||||
onChange={(e) => setAlertType(e.target.value)}
|
|
||||||
style={dropdownStyle}
|
|
||||||
>
|
|
||||||
<option value="Once">Once</option>
|
|
||||||
<option value="Constant">Constant</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label style={{ fontSize: "9px", display: "block", marginBottom: "4px" }}>
|
|
||||||
Alert Interval (ms):
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="100"
|
|
||||||
step="100"
|
|
||||||
value={intervalMs}
|
|
||||||
onChange={(e) => setIntervalMs(parseInt(e.target.value))}
|
|
||||||
disabled={alertType === "Once"}
|
|
||||||
style={{
|
|
||||||
...inputStyle,
|
|
||||||
background: alertType === "Once" ? "#2a2a2a" : "#1e1e1e"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label style={{ fontSize: "9px", display: "block", marginTop: "6px", marginBottom: "4px" }}>
|
|
||||||
Custom Sound:
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "4px" }}>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".wav,.mp3,.mpeg,.ogg"
|
|
||||||
onChange={handleFileUpload}
|
|
||||||
style={{ ...inputStyle, marginBottom: 0, flex: 1 }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
fontSize: "9px",
|
|
||||||
padding: "4px 8px",
|
|
||||||
backgroundColor: "#1e1e1e",
|
|
||||||
color: "#ccc",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "2px",
|
|
||||||
cursor: "pointer"
|
|
||||||
}}
|
|
||||||
onClick={playSound}
|
|
||||||
title="Test playback"
|
|
||||||
>
|
|
||||||
Test
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const dropdownStyle = {
|
|
||||||
fontSize: "9px",
|
|
||||||
padding: "4px",
|
|
||||||
background: "#1e1e1e",
|
|
||||||
color: "#ccc",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "2px",
|
|
||||||
width: "100%",
|
|
||||||
marginBottom: "8px"
|
|
||||||
};
|
|
||||||
|
|
||||||
const inputStyle = {
|
|
||||||
fontSize: "9px",
|
|
||||||
padding: "4px",
|
|
||||||
color: "#ccc",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "2px",
|
|
||||||
width: "100%",
|
|
||||||
marginBottom: "8px"
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
|
||||||
type: "AlertSoundNode",
|
|
||||||
label: "Alert Sound",
|
|
||||||
description: `
|
|
||||||
Plays a sound alert when input = "1"
|
|
||||||
|
|
||||||
- "Once" = Only when 0 -> 1 transition
|
|
||||||
- "Constant" = Repeats every X ms while input stays 1
|
|
||||||
- Custom audio supported (MP3/WAV/OGG)
|
|
||||||
- Base64 audio embedded in workflow and restored
|
|
||||||
- Visual status indicator (green = 0, red = 1)
|
|
||||||
- Manual "Test" button for validation
|
|
||||||
`.trim(),
|
|
||||||
content: "Sound alert when input value = 1",
|
|
||||||
component: AlertSoundNode
|
|
||||||
};
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
|
||||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
|
||||||
|
|
||||||
// Ensure Borealis shared memory exists
|
|
||||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
|
||||||
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
|
|
||||||
|
|
||||||
const ArrayIndexExtractorNode = ({ id, data }) => {
|
|
||||||
const edges = useStore((state) => state.edges);
|
|
||||||
const { setNodes } = useReactFlow();
|
|
||||||
const [result, setResult] = useState("Line Does Not Exist");
|
|
||||||
const valueRef = useRef(result);
|
|
||||||
|
|
||||||
// Use config field, always 1-based for UX, fallback to 1
|
|
||||||
const lineNumber = parseInt(data?.lineNumber, 10) || 1;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let intervalId = null;
|
|
||||||
let currentRate = window.BorealisUpdateRate;
|
|
||||||
|
|
||||||
const runNodeLogic = () => {
|
|
||||||
const inputEdge = edges.find((e) => e.target === id);
|
|
||||||
if (!inputEdge) {
|
|
||||||
valueRef.current = "Line Does Not Exist";
|
|
||||||
setResult("Line Does Not Exist");
|
|
||||||
window.BorealisValueBus[id] = "Line Does Not Exist";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const upstreamValue = window.BorealisValueBus[inputEdge.source];
|
|
||||||
if (!Array.isArray(upstreamValue)) {
|
|
||||||
valueRef.current = "Line Does Not Exist";
|
|
||||||
setResult("Line Does Not Exist");
|
|
||||||
window.BorealisValueBus[id] = "Line Does Not Exist";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const index = Math.max(0, lineNumber - 1); // 1-based to 0-based
|
|
||||||
const selected = upstreamValue[index] ?? "Line Does Not Exist";
|
|
||||||
|
|
||||||
if (selected !== valueRef.current) {
|
|
||||||
valueRef.current = selected;
|
|
||||||
setResult(selected);
|
|
||||||
window.BorealisValueBus[id] = selected;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
intervalId = setInterval(runNodeLogic, currentRate);
|
|
||||||
|
|
||||||
// Monitor update rate live
|
|
||||||
const monitor = setInterval(() => {
|
|
||||||
const newRate = window.BorealisUpdateRate;
|
|
||||||
if (newRate !== currentRate) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
currentRate = newRate;
|
|
||||||
intervalId = setInterval(runNodeLogic, currentRate);
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
clearInterval(monitor);
|
|
||||||
};
|
|
||||||
}, [id, edges, lineNumber]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="borealis-node">
|
|
||||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
|
||||||
<div className="borealis-node-header">
|
|
||||||
{data?.label || "Array Index Extractor"}
|
|
||||||
</div>
|
|
||||||
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
|
|
||||||
<div style={{ marginBottom: "6px", color: "#ccc" }}>
|
|
||||||
Output a specific line from an upstream array.
|
|
||||||
</div>
|
|
||||||
<div style={{ color: "#888", marginBottom: 4 }}>
|
|
||||||
Line Number: <b>{lineNumber}</b>
|
|
||||||
</div>
|
|
||||||
<label style={{ display: "block", marginBottom: "2px" }}>Output:</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={result}
|
|
||||||
disabled
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
fontSize: "9px",
|
|
||||||
background: "#2a2a2a",
|
|
||||||
color: "#ccc",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "2px",
|
|
||||||
padding: "3px"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- Node Registration Object with Sidebar Config & Markdown Docs ----
|
|
||||||
export default {
|
|
||||||
type: "ArrayIndexExtractor",
|
|
||||||
label: "Array Index Extractor",
|
|
||||||
description: `
|
|
||||||
Outputs a specific line from an upstream array, such as the result of OCR multi-line extraction.
|
|
||||||
|
|
||||||
- Specify the **line number** (1 = first line)
|
|
||||||
- Outputs the value at that index if present
|
|
||||||
- If index is out of bounds, outputs "Line Does Not Exist"
|
|
||||||
`.trim(),
|
|
||||||
content: "Output a Specific Array Index's Value",
|
|
||||||
component: ArrayIndexExtractorNode,
|
|
||||||
config: [
|
|
||||||
{
|
|
||||||
key: "lineNumber",
|
|
||||||
label: "Line Number (1 = First Line)",
|
|
||||||
type: "text",
|
|
||||||
defaultValue: "1"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
usage_documentation: `
|
|
||||||
### Array Index Extractor Node
|
|
||||||
|
|
||||||
This node allows you to extract a specific line or item from an upstream array value.
|
|
||||||
|
|
||||||
**Typical Use:**
|
|
||||||
- Used after OCR or any node that outputs an array of lines or items.
|
|
||||||
- Set the **Line Number** (1-based, so "1" = first line).
|
|
||||||
|
|
||||||
**Behavior:**
|
|
||||||
- If the line exists, outputs the value at that position.
|
|
||||||
- If not, outputs: \`Line Does Not Exist\`.
|
|
||||||
|
|
||||||
**Input:**
|
|
||||||
- Connect an upstream node that outputs an array (such as OCR Text Extraction).
|
|
||||||
|
|
||||||
**Sidebar Config:**
|
|
||||||
- Set the desired line number from the configuration sidebar for live updates.
|
|
||||||
|
|
||||||
---
|
|
||||||
`.trim()
|
|
||||||
};
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Data Analysis & Manipulation/Node_JSON_Display.jsx
|
|
||||||
|
|
||||||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
|
||||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
|
||||||
// For syntax highlighting, ensure prismjs is installed: npm install prismjs
|
|
||||||
import Prism from "prismjs";
|
|
||||||
import "prismjs/components/prism-json";
|
|
||||||
import "prismjs/themes/prism-okaidia.css";
|
|
||||||
|
|
||||||
const JSONPrettyDisplayNode = ({ id, data }) => {
|
|
||||||
const { setNodes } = useReactFlow();
|
|
||||||
const edges = useStore((state) => state.edges);
|
|
||||||
const containerRef = useRef(null);
|
|
||||||
const resizingRef = useRef(false);
|
|
||||||
const startPosRef = useRef({ x: 0, y: 0 });
|
|
||||||
const startDimRef = useRef({ width: 0, height: 0 });
|
|
||||||
|
|
||||||
const [jsonData, setJsonData] = useState(data?.jsonData || {});
|
|
||||||
const initW = parseInt(data?.width || "300", 10);
|
|
||||||
const initH = parseInt(data?.height || "150", 10);
|
|
||||||
const [dimensions, setDimensions] = useState({ width: initW, height: initH });
|
|
||||||
const jsonRef = useRef(jsonData);
|
|
||||||
|
|
||||||
const persistDimensions = useCallback(() => {
|
|
||||||
const w = `${Math.round(dimensions.width)}px`;
|
|
||||||
const h = `${Math.round(dimensions.height)}px`;
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id
|
|
||||||
? { ...n, data: { ...n.data, width: w, height: h } }
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, [dimensions, id, setNodes]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onMouseMove = (e) => {
|
|
||||||
if (!resizingRef.current) return;
|
|
||||||
const dx = e.clientX - startPosRef.current.x;
|
|
||||||
const dy = e.clientY - startPosRef.current.y;
|
|
||||||
setDimensions({
|
|
||||||
width: Math.max(100, startDimRef.current.width + dx),
|
|
||||||
height: Math.max(60, startDimRef.current.height + dy)
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const onMouseUp = () => {
|
|
||||||
if (resizingRef.current) {
|
|
||||||
resizingRef.current = false;
|
|
||||||
persistDimensions();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("mousemove", onMouseMove);
|
|
||||||
window.addEventListener("mouseup", onMouseUp);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("mousemove", onMouseMove);
|
|
||||||
window.removeEventListener("mouseup", onMouseUp);
|
|
||||||
};
|
|
||||||
}, [persistDimensions]);
|
|
||||||
|
|
||||||
const onResizeMouseDown = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
resizingRef.current = true;
|
|
||||||
startPosRef.current = { x: e.clientX, y: e.clientY };
|
|
||||||
startDimRef.current = { ...dimensions };
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let rate = window.BorealisUpdateRate;
|
|
||||||
const tick = () => {
|
|
||||||
const edge = edges.find((e) => e.target === id);
|
|
||||||
if (edge && edge.source) {
|
|
||||||
const upstream = window.BorealisValueBus[edge.source];
|
|
||||||
if (typeof upstream === "object") {
|
|
||||||
if (JSON.stringify(upstream) !== JSON.stringify(jsonRef.current)) {
|
|
||||||
jsonRef.current = upstream;
|
|
||||||
setJsonData(upstream);
|
|
||||||
window.BorealisValueBus[id] = upstream;
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id ? { ...n, data: { ...n.data, jsonData: upstream } } : n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
window.BorealisValueBus[id] = jsonRef.current;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const iv = setInterval(tick, rate);
|
|
||||||
const monitor = setInterval(() => {
|
|
||||||
if (window.BorealisUpdateRate !== rate) {
|
|
||||||
clearInterval(iv);
|
|
||||||
clearInterval(monitor);
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
return () => { clearInterval(iv); clearInterval(monitor); };
|
|
||||||
}, [id, edges, setNodes]);
|
|
||||||
|
|
||||||
// Generate highlighted HTML
|
|
||||||
const pretty = JSON.stringify(jsonData, null, 2);
|
|
||||||
const highlighted = Prism.highlight(pretty, Prism.languages.json, "json");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="borealis-node"
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
width: dimensions.width,
|
|
||||||
height: dimensions.height,
|
|
||||||
overflow: "visible",
|
|
||||||
position: "relative",
|
|
||||||
boxSizing: "border-box"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
|
||||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
|
||||||
|
|
||||||
<div className="borealis-node-header">Display JSON Data</div>
|
|
||||||
<div
|
|
||||||
className="borealis-node-content"
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: "4px",
|
|
||||||
fontSize: "9px",
|
|
||||||
color: "#ccc",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
overflow: "hidden"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ marginBottom: "4px" }}>
|
|
||||||
Display prettified JSON from upstream.
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
width: "100%",
|
|
||||||
background: "#1e1e1e",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "2px",
|
|
||||||
padding: "4px",
|
|
||||||
overflowY: "auto",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
fontSize: "9px"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<pre
|
|
||||||
dangerouslySetInnerHTML={{ __html: highlighted }}
|
|
||||||
style={{ margin: 0 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
onMouseDown={onResizeMouseDown}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
width: "20px",
|
|
||||||
height: "20px",
|
|
||||||
right: "-4px",
|
|
||||||
bottom: "-4px",
|
|
||||||
cursor: "nwse-resize",
|
|
||||||
background: "transparent",
|
|
||||||
zIndex: 10
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
|
||||||
type: "Node_JSON_Pretty_Display",
|
|
||||||
label: "Display JSON Data",
|
|
||||||
description: "Display upstream JSON object as prettified JSON with syntax highlighting.",
|
|
||||||
content: "Display prettified multi-line JSON from upstream node.",
|
|
||||||
component: JSONPrettyDisplayNode
|
|
||||||
};
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Data Analysis & Manipulation/Node_JSON_Value_Extractor.jsx
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { Handle, Position, useReactFlow } from "reactflow";
|
|
||||||
|
|
||||||
const JSONValueExtractorNode = ({ id, data }) => {
|
|
||||||
const { setNodes, getEdges } = useReactFlow();
|
|
||||||
const [keyName, setKeyName] = useState(data?.keyName || "");
|
|
||||||
const [value, setValue] = useState(data?.result || "");
|
|
||||||
|
|
||||||
const handleKeyChange = (e) => {
|
|
||||||
const newKey = e.target.value;
|
|
||||||
setKeyName(newKey);
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id
|
|
||||||
? { ...n, data: { ...n.data, keyName: newKey } }
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let currentRate = window.BorealisUpdateRate;
|
|
||||||
let intervalId;
|
|
||||||
|
|
||||||
const runNodeLogic = () => {
|
|
||||||
const edges = getEdges();
|
|
||||||
const incoming = edges.filter((e) => e.target === id);
|
|
||||||
const sourceId = incoming[0]?.source;
|
|
||||||
let newValue = "Key Not Found";
|
|
||||||
|
|
||||||
if (sourceId && window.BorealisValueBus[sourceId] !== undefined) {
|
|
||||||
let upstream = window.BorealisValueBus[sourceId];
|
|
||||||
if (upstream && typeof upstream === "object" && keyName) {
|
|
||||||
const pathSegments = keyName.split(".");
|
|
||||||
let nodeVal = upstream;
|
|
||||||
for (let segment of pathSegments) {
|
|
||||||
if (
|
|
||||||
nodeVal != null &&
|
|
||||||
(typeof nodeVal === "object" || Array.isArray(nodeVal)) &&
|
|
||||||
segment in nodeVal
|
|
||||||
) {
|
|
||||||
nodeVal = nodeVal[segment];
|
|
||||||
} else {
|
|
||||||
nodeVal = undefined;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (nodeVal !== undefined) {
|
|
||||||
newValue = String(nodeVal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newValue !== value) {
|
|
||||||
setValue(newValue);
|
|
||||||
window.BorealisValueBus[id] = newValue;
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id
|
|
||||||
? { ...n, data: { ...n.data, result: newValue } }
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
runNodeLogic();
|
|
||||||
intervalId = setInterval(runNodeLogic, currentRate);
|
|
||||||
|
|
||||||
const monitor = setInterval(() => {
|
|
||||||
const newRate = window.BorealisUpdateRate;
|
|
||||||
if (newRate !== currentRate) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
currentRate = newRate;
|
|
||||||
intervalId = setInterval(runNodeLogic, currentRate);
|
|
||||||
}
|
|
||||||
}, 250);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
clearInterval(monitor);
|
|
||||||
};
|
|
||||||
}, [keyName, id, setNodes, getEdges, value]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="borealis-node">
|
|
||||||
<div className="borealis-node-header">JSON Value Extractor</div>
|
|
||||||
<div className="borealis-node-content">
|
|
||||||
<label style={{ fontSize: "9px", display: "block", marginBottom: "4px" }}>
|
|
||||||
Key:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={keyName}
|
|
||||||
onChange={handleKeyChange}
|
|
||||||
placeholder="e.g. name.en"
|
|
||||||
style={{
|
|
||||||
fontSize: "9px", padding: "4px", width: "100%",
|
|
||||||
background: "#1e1e1e", color: "#ccc",
|
|
||||||
border: "1px solid #444", borderRadius: "2px"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<label style={{ fontSize: "9px", display: "block", margin: "8px 0 4px" }}>
|
|
||||||
Value:
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
readOnly
|
|
||||||
value={value}
|
|
||||||
rows={2}
|
|
||||||
style={{
|
|
||||||
fontSize: "9px", padding: "4px", width: "100%",
|
|
||||||
background: "#1e1e1e", color: "#ccc",
|
|
||||||
border: "1px solid #444", borderRadius: "2px",
|
|
||||||
resize: "none"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
|
||||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
|
||||||
type: "JSON_Value_Extractor",
|
|
||||||
label: "JSON Value Extractor",
|
|
||||||
description: "Extract a nested value by dot-delimited path from upstream JSON data.",
|
|
||||||
content: "Provide a dot-separated key path (e.g. 'name.en'); outputs the extracted string or 'Key Not Found'.",
|
|
||||||
component: JSONValueExtractorNode
|
|
||||||
};
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_OCR_Text_Extraction.jsx
|
|
||||||
|
|
||||||
import React, { useEffect, useState, useRef } from "react";
|
|
||||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
|
||||||
|
|
||||||
// Lightweight hash for image change detection
|
|
||||||
const getHashScore = (str = "") => {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < str.length; i += 101) {
|
|
||||||
const char = str.charCodeAt(i);
|
|
||||||
hash = (hash << 5) - hash + char;
|
|
||||||
hash |= 0;
|
|
||||||
}
|
|
||||||
return Math.abs(hash);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
|
||||||
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
|
|
||||||
|
|
||||||
const OCRNode = ({ id, data }) => {
|
|
||||||
const edges = useStore((state) => state.edges);
|
|
||||||
const { setNodes } = useReactFlow();
|
|
||||||
|
|
||||||
const [ocrOutput, setOcrOutput] = useState("");
|
|
||||||
const valueRef = useRef("");
|
|
||||||
const lastUsed = useRef({ engine: "", backend: "", dataType: "" });
|
|
||||||
const lastProcessedAt = useRef(0);
|
|
||||||
const lastImageHash = useRef(0);
|
|
||||||
|
|
||||||
// Always get config from props (sidebar sets these in node.data)
|
|
||||||
const engine = data?.engine || "None";
|
|
||||||
const backend = data?.backend || "CPU";
|
|
||||||
const dataType = data?.dataType || "Mixed";
|
|
||||||
const customRateEnabled = data?.customRateEnabled ?? true;
|
|
||||||
const customRateMs = data?.customRateMs || 1000;
|
|
||||||
const changeThreshold = data?.changeThreshold || 0;
|
|
||||||
|
|
||||||
// OCR API Call
|
|
||||||
const sendToOCRAPI = async (base64) => {
|
|
||||||
const cleanBase64 = base64.replace(/^data:image\/[a-zA-Z]+;base64,/, "");
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/ocr", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ image_base64: cleanBase64, engine, backend })
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
return response.ok && Array.isArray(result.lines)
|
|
||||||
? result.lines
|
|
||||||
: [`[ERROR] ${result.error || "Invalid OCR response."}`];
|
|
||||||
} catch (err) {
|
|
||||||
return [`[ERROR] OCR API request failed: ${err.message}`];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter lines based on user type
|
|
||||||
const filterLines = (lines) => {
|
|
||||||
if (dataType === "Numerical") {
|
|
||||||
return lines.map(line => line.replace(/[^\d.%\s]/g, '').replace(/\s+/g, ' ').trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
if (dataType === "String") {
|
|
||||||
return lines.map(line => line.replace(/[^a-zA-Z\s]/g, '').replace(/\s+/g, ' ').trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
return lines;
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let intervalId = null;
|
|
||||||
let currentRate = window.BorealisUpdateRate || 100;
|
|
||||||
|
|
||||||
const runNodeLogic = async () => {
|
|
||||||
const inputEdge = edges.find((e) => e.target === id);
|
|
||||||
if (!inputEdge) {
|
|
||||||
window.BorealisValueBus[id] = [];
|
|
||||||
setOcrOutput("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const upstreamValue = window.BorealisValueBus[inputEdge.source] || "";
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
const effectiveRate = customRateEnabled ? customRateMs : window.BorealisUpdateRate || 100;
|
|
||||||
const configChanged =
|
|
||||||
lastUsed.current.engine !== engine ||
|
|
||||||
lastUsed.current.backend !== backend ||
|
|
||||||
lastUsed.current.dataType !== dataType;
|
|
||||||
|
|
||||||
const upstreamHash = getHashScore(upstreamValue);
|
|
||||||
const hashDelta = Math.abs(upstreamHash - lastImageHash.current);
|
|
||||||
const hashThreshold = (changeThreshold / 100) * 1000000000;
|
|
||||||
|
|
||||||
const imageChanged = hashDelta > hashThreshold;
|
|
||||||
|
|
||||||
if (!configChanged && (!imageChanged || (now - lastProcessedAt.current < effectiveRate))) return;
|
|
||||||
|
|
||||||
lastUsed.current = { engine, backend, dataType };
|
|
||||||
lastProcessedAt.current = now;
|
|
||||||
lastImageHash.current = upstreamHash;
|
|
||||||
valueRef.current = upstreamValue;
|
|
||||||
|
|
||||||
const lines = await sendToOCRAPI(upstreamValue);
|
|
||||||
const filtered = filterLines(lines);
|
|
||||||
setOcrOutput(filtered.join("\n"));
|
|
||||||
window.BorealisValueBus[id] = filtered;
|
|
||||||
};
|
|
||||||
|
|
||||||
intervalId = setInterval(runNodeLogic, currentRate);
|
|
||||||
|
|
||||||
const monitor = setInterval(() => {
|
|
||||||
const newRate = window.BorealisUpdateRate || 100;
|
|
||||||
if (newRate !== currentRate) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
intervalId = setInterval(runNodeLogic, newRate);
|
|
||||||
currentRate = newRate;
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
clearInterval(monitor);
|
|
||||||
};
|
|
||||||
}, [id, engine, backend, dataType, customRateEnabled, customRateMs, changeThreshold, edges]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="borealis-node" style={{ minWidth: "200px" }}>
|
|
||||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
|
||||||
<div className="borealis-node-header">OCR-Based Text Extraction</div>
|
|
||||||
<div className="borealis-node-content">
|
|
||||||
<div style={{ fontSize: "9px", marginBottom: "8px", color: "#ccc" }}>
|
|
||||||
Extract Multi-Line Text from Upstream Image Node
|
|
||||||
</div>
|
|
||||||
<label style={labelStyle}>OCR Output:</label>
|
|
||||||
<textarea
|
|
||||||
readOnly
|
|
||||||
value={ocrOutput}
|
|
||||||
rows={6}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
fontSize: "9px",
|
|
||||||
background: "#1e1e1e",
|
|
||||||
color: "#ccc",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "2px",
|
|
||||||
padding: "4px",
|
|
||||||
resize: "vertical"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const labelStyle = {
|
|
||||||
fontSize: "9px",
|
|
||||||
display: "block",
|
|
||||||
marginTop: "6px",
|
|
||||||
marginBottom: "2px"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Node registration for Borealis (modern, sidebar-enabled)
|
|
||||||
export default {
|
|
||||||
type: "OCR_Text_Extraction",
|
|
||||||
label: "OCR Text Extraction",
|
|
||||||
description: `Extract text from upstream image using backend OCR engine via API. Includes rate limiting and sensitivity detection for smart processing.`,
|
|
||||||
content: "Extract Multi-Line Text from Upstream Image Node",
|
|
||||||
component: OCRNode,
|
|
||||||
config: [
|
|
||||||
{
|
|
||||||
key: "engine",
|
|
||||||
label: "OCR Engine",
|
|
||||||
type: "select",
|
|
||||||
options: ["None", "TesseractOCR", "EasyOCR"],
|
|
||||||
defaultValue: "None"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "backend",
|
|
||||||
label: "Compute Backend",
|
|
||||||
type: "select",
|
|
||||||
options: ["CPU", "GPU"],
|
|
||||||
defaultValue: "CPU"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "dataType",
|
|
||||||
label: "Data Type Filter",
|
|
||||||
type: "select",
|
|
||||||
options: ["Mixed", "Numerical", "String"],
|
|
||||||
defaultValue: "Mixed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "customRateEnabled",
|
|
||||||
label: "Custom API Rate Limit Enabled",
|
|
||||||
type: "select",
|
|
||||||
options: ["true", "false"],
|
|
||||||
defaultValue: "true"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "customRateMs",
|
|
||||||
label: "Custom API Rate Limit (ms)",
|
|
||||||
type: "text",
|
|
||||||
defaultValue: "1000"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "changeThreshold",
|
|
||||||
label: "Change Detection Sensitivity (0-100)",
|
|
||||||
type: "text",
|
|
||||||
defaultValue: "0"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
usage_documentation: `
|
|
||||||
### OCR Text Extraction Node
|
|
||||||
|
|
||||||
Extracts text (lines) from an **upstream image node** using a selectable backend OCR engine (Tesseract or EasyOCR). Designed for screenshots, scanned forms, and live image data pipelines.
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- **Engine:** Select between None, TesseractOCR, or EasyOCR
|
|
||||||
- **Backend:** Choose CPU or GPU (if supported)
|
|
||||||
- **Data Type Filter:** Post-processes recognized lines for numerical-only or string-only content
|
|
||||||
- **Custom API Rate Limit:** When enabled, you can set a custom polling rate for OCR requests (in ms)
|
|
||||||
- **Change Detection Sensitivity:** Node will only re-OCR if the input image changes significantly (hash-based, 0 disables)
|
|
||||||
|
|
||||||
**Outputs:**
|
|
||||||
- Array of recognized lines, pushed to downstream nodes
|
|
||||||
- Output is displayed in the node (read-only)
|
|
||||||
|
|
||||||
**Usage:**
|
|
||||||
- Connect an image node (base64 output) to this node's input
|
|
||||||
- Configure OCR engine and options in the sidebar
|
|
||||||
- Useful for extracting values from screen regions, live screenshots, PDF scans, etc.
|
|
||||||
|
|
||||||
**Notes:**
|
|
||||||
- Setting Engine to 'None' disables OCR
|
|
||||||
- Use numerical/string filter for precise downstream parsing
|
|
||||||
- Polling rate too fast may cause backend overload
|
|
||||||
- Change threshold is a 0-100 scale (0 = always run, 100 = image must change completely)
|
|
||||||
|
|
||||||
`.trim()
|
|
||||||
};
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Data Manipulation/Node_Regex_Replace.jsx
|
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
|
||||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
|
||||||
|
|
||||||
// Shared memory bus setup
|
|
||||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
|
||||||
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
|
|
||||||
|
|
||||||
// -- Modern Regex Replace Node -- //
|
|
||||||
const RegexReplaceNode = ({ id, data }) => {
|
|
||||||
const edges = useStore((state) => state.edges);
|
|
||||||
const { setNodes } = useReactFlow();
|
|
||||||
|
|
||||||
// Maintain output live value
|
|
||||||
const [result, setResult] = useState("");
|
|
||||||
const [original, setOriginal] = useState("");
|
|
||||||
const valueRef = useRef("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let intervalId = null;
|
|
||||||
let currentRate = window.BorealisUpdateRate;
|
|
||||||
|
|
||||||
const runNodeLogic = () => {
|
|
||||||
const inputEdge = edges.find((e) => e.target === id);
|
|
||||||
const inputValue = inputEdge
|
|
||||||
? window.BorealisValueBus[inputEdge.source] || ""
|
|
||||||
: "";
|
|
||||||
|
|
||||||
setOriginal(inputValue);
|
|
||||||
|
|
||||||
let newVal = inputValue;
|
|
||||||
try {
|
|
||||||
if ((data?.enabled ?? true) && data?.pattern) {
|
|
||||||
const regex = new RegExp(data.pattern, data.flags || "g");
|
|
||||||
let safeReplacement = (data.replacement ?? "").trim();
|
|
||||||
// Remove quotes if user adds them
|
|
||||||
if (
|
|
||||||
safeReplacement.startsWith('"') &&
|
|
||||||
safeReplacement.endsWith('"')
|
|
||||||
) {
|
|
||||||
safeReplacement = safeReplacement.slice(1, -1);
|
|
||||||
}
|
|
||||||
newVal = inputValue.replace(regex, safeReplacement);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
newVal = `[Error] ${err.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newVal !== valueRef.current) {
|
|
||||||
valueRef.current = newVal;
|
|
||||||
setResult(newVal);
|
|
||||||
window.BorealisValueBus[id] = newVal;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
intervalId = setInterval(runNodeLogic, currentRate);
|
|
||||||
|
|
||||||
// Monitor update rate changes
|
|
||||||
const monitor = setInterval(() => {
|
|
||||||
const newRate = window.BorealisUpdateRate;
|
|
||||||
if (newRate !== currentRate) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
intervalId = setInterval(runNodeLogic, newRate);
|
|
||||||
currentRate = newRate;
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
clearInterval(monitor);
|
|
||||||
};
|
|
||||||
}, [id, edges, data?.pattern, data?.replacement, data?.flags, data?.enabled]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="borealis-node">
|
|
||||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
|
||||||
|
|
||||||
<div className="borealis-node-header">
|
|
||||||
{data?.label || "Regex Replace"}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="borealis-node-content">
|
|
||||||
<div style={{ marginBottom: "6px", fontSize: "9px", color: "#ccc" }}>
|
|
||||||
Performs live regex-based find/replace on incoming string value.
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: "9px", color: "#ccc", marginBottom: 2 }}>
|
|
||||||
<b>Pattern:</b> {data?.pattern || <i>(not set)</i>}<br />
|
|
||||||
<b>Flags:</b> {data?.flags || "g"}<br />
|
|
||||||
<b>Enabled:</b> {(data?.enabled ?? true) ? "Yes" : "No"}
|
|
||||||
</div>
|
|
||||||
<label style={{ fontSize: "8px", color: "#888" }}>Original:</label>
|
|
||||||
<textarea
|
|
||||||
readOnly
|
|
||||||
value={original}
|
|
||||||
rows={2}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
fontSize: "9px",
|
|
||||||
background: "#222",
|
|
||||||
color: "#ccc",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "2px",
|
|
||||||
padding: "3px",
|
|
||||||
resize: "vertical",
|
|
||||||
marginBottom: "6px"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<label style={{ fontSize: "8px", color: "#888" }}>Output:</label>
|
|
||||||
<textarea
|
|
||||||
readOnly
|
|
||||||
value={result}
|
|
||||||
rows={2}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
fontSize: "9px",
|
|
||||||
background: "#2a2a2a",
|
|
||||||
color: "#ccc",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "2px",
|
|
||||||
padding: "3px",
|
|
||||||
resize: "vertical"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Modern Node Export: Sidebar config, usage docs, sensible defaults
|
|
||||||
export default {
|
|
||||||
type: "RegexReplace",
|
|
||||||
label: "Regex Replace",
|
|
||||||
description: `
|
|
||||||
Live regex-based string find/replace node.
|
|
||||||
|
|
||||||
- Runs a JavaScript regular expression on every input update.
|
|
||||||
- Useful for cleanup, format fixes, redacting, token extraction.
|
|
||||||
- Configurable flags, replacement text, and enable toggle.
|
|
||||||
- Handles errors gracefully, shows live preview in the sidebar.
|
|
||||||
`.trim(),
|
|
||||||
content: "Perform regex replacement on incoming string",
|
|
||||||
component: RegexReplaceNode,
|
|
||||||
config: [
|
|
||||||
{
|
|
||||||
key: "pattern",
|
|
||||||
label: "Regex Pattern",
|
|
||||||
type: "text",
|
|
||||||
defaultValue: "\\d+"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "replacement",
|
|
||||||
label: "Replacement",
|
|
||||||
type: "text",
|
|
||||||
defaultValue: ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "flags",
|
|
||||||
label: "Regex Flags",
|
|
||||||
type: "text",
|
|
||||||
defaultValue: "g"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "enabled",
|
|
||||||
label: "Enable Replacement",
|
|
||||||
type: "select",
|
|
||||||
options: ["true", "false"],
|
|
||||||
defaultValue: "true"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
usage_documentation: `
|
|
||||||
### Regex Replace Node
|
|
||||||
|
|
||||||
**Purpose:**
|
|
||||||
Perform flexible find-and-replace on strings using JavaScript-style regular expressions.
|
|
||||||
|
|
||||||
#### Typical Use Cases
|
|
||||||
- Clean up text, numbers, or IDs in a data stream
|
|
||||||
- Mask or redact sensitive info (emails, credit cards, etc)
|
|
||||||
- Extract tokens, words, or reformat content
|
|
||||||
|
|
||||||
#### Configuration (see "Config" tab):
|
|
||||||
- **Regex Pattern**: The search pattern (supports capture groups)
|
|
||||||
- **Replacement**: The replacement string. You can use \`$1, $2\` for capture groups.
|
|
||||||
- **Regex Flags**: Default \`g\` (global). Add \`i\` (case-insensitive), \`m\` (multiline), etc.
|
|
||||||
- **Enable Replacement**: On/Off toggle (for easy debugging)
|
|
||||||
|
|
||||||
#### Behavior
|
|
||||||
- Upstream value is live-updated.
|
|
||||||
- When enabled, node applies the regex and emits the result downstream.
|
|
||||||
- Shows both input and output in the sidebar for debugging.
|
|
||||||
- If the regex is invalid, error is displayed as output.
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
- Emits the transformed string to all downstream nodes.
|
|
||||||
- Updates in real time at the global Borealis update rate.
|
|
||||||
|
|
||||||
#### Example
|
|
||||||
Pattern: \`(\\d+)\`
|
|
||||||
Replacement: \`[number:$1]\`
|
|
||||||
Input: \`abc 123 def 456\`
|
|
||||||
Output: \`abc [number:123] def [number:456]\`
|
|
||||||
|
|
||||||
---
|
|
||||||
**Tips:**
|
|
||||||
- Use double backslashes (\\) in patterns when needed (e.g. \`\\\\d+\`).
|
|
||||||
- Flags can be any combination (e.g. \`gi\`).
|
|
||||||
|
|
||||||
`.trim()
|
|
||||||
};
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Data Analysis/Node_Regex_Search.jsx
|
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
|
||||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
|
||||||
|
|
||||||
// Modern Regex Search Node: Config via Sidebar
|
|
||||||
|
|
||||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
|
||||||
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
|
|
||||||
|
|
||||||
const RegexSearchNode = ({ id, data }) => {
|
|
||||||
const { setNodes } = useReactFlow();
|
|
||||||
const edges = useStore((state) => state.edges);
|
|
||||||
|
|
||||||
// Pattern/flags always come from sidebar config (with defaults)
|
|
||||||
const pattern = data?.pattern ?? "";
|
|
||||||
const flags = data?.flags ?? "i";
|
|
||||||
|
|
||||||
const valueRef = useRef("0");
|
|
||||||
const [matched, setMatched] = useState("0");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let intervalId = null;
|
|
||||||
let currentRate = window.BorealisUpdateRate;
|
|
||||||
|
|
||||||
const runNodeLogic = () => {
|
|
||||||
const inputEdge = edges.find((e) => e.target === id);
|
|
||||||
const inputVal = inputEdge ? window.BorealisValueBus[inputEdge.source] || "" : "";
|
|
||||||
|
|
||||||
let matchResult = false;
|
|
||||||
try {
|
|
||||||
if (pattern) {
|
|
||||||
const regex = new RegExp(pattern, flags);
|
|
||||||
matchResult = regex.test(inputVal);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
matchResult = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = matchResult ? "1" : "0";
|
|
||||||
|
|
||||||
if (result !== valueRef.current) {
|
|
||||||
valueRef.current = result;
|
|
||||||
setMatched(result);
|
|
||||||
window.BorealisValueBus[id] = result;
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id ? { ...n, data: { ...n.data, match: result } } : n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
intervalId = setInterval(runNodeLogic, currentRate);
|
|
||||||
|
|
||||||
const monitor = setInterval(() => {
|
|
||||||
const newRate = window.BorealisUpdateRate;
|
|
||||||
if (newRate !== currentRate) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
intervalId = setInterval(runNodeLogic, newRate);
|
|
||||||
currentRate = newRate;
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
clearInterval(monitor);
|
|
||||||
};
|
|
||||||
}, [id, edges, pattern, flags, setNodes]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="borealis-node">
|
|
||||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
|
||||||
<div className="borealis-node-header">
|
|
||||||
{data?.label || "Regex Search"}
|
|
||||||
</div>
|
|
||||||
<div className="borealis-node-content" style={{ fontSize: "9px", color: "#ccc" }}>
|
|
||||||
Match: {matched}
|
|
||||||
</div>
|
|
||||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
|
||||||
type: "RegexSearch",
|
|
||||||
label: "Regex Search",
|
|
||||||
description: `
|
|
||||||
Test for text matches with a regular expression pattern.
|
|
||||||
|
|
||||||
- Accepts a regex pattern and flags (e.g. "i", "g", "m")
|
|
||||||
- Connect any node to the input to test its value.
|
|
||||||
- Outputs "1" if the regex matches, otherwise "0".
|
|
||||||
- Useful for input validation, filtering, or text triggers.
|
|
||||||
`.trim(),
|
|
||||||
content: "Outputs '1' if regex matches input, otherwise '0'",
|
|
||||||
component: RegexSearchNode,
|
|
||||||
config: [
|
|
||||||
{
|
|
||||||
key: "pattern",
|
|
||||||
label: "Regex Pattern",
|
|
||||||
type: "text",
|
|
||||||
defaultValue: "",
|
|
||||||
placeholder: "e.g. World"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "flags",
|
|
||||||
label: "Regex Flags",
|
|
||||||
type: "text",
|
|
||||||
defaultValue: "i",
|
|
||||||
placeholder: "e.g. i"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
usage_documentation: `
|
|
||||||
### Regex Search Node
|
|
||||||
|
|
||||||
This node tests its input value against a user-supplied regular expression pattern.
|
|
||||||
|
|
||||||
**Configuration (Sidebar):**
|
|
||||||
- **Regex Pattern**: Standard JavaScript regex pattern.
|
|
||||||
- **Regex Flags**: Any combination of \`i\` (ignore case), \`g\` (global), \`m\` (multiline), etc.
|
|
||||||
|
|
||||||
**Input:**
|
|
||||||
- Accepts a string from any upstream node.
|
|
||||||
|
|
||||||
**Output:**
|
|
||||||
- Emits "1" if the pattern matches the input string.
|
|
||||||
- Emits "0" if there is no match or the pattern/flags are invalid.
|
|
||||||
|
|
||||||
**Common Uses:**
|
|
||||||
- Search for words/phrases in extracted text.
|
|
||||||
- Filter values using custom patterns.
|
|
||||||
- Create triggers based on input structure (e.g. validate an email, detect phone numbers, etc).
|
|
||||||
|
|
||||||
#### Example:
|
|
||||||
- **Pattern:** \`World\`
|
|
||||||
- **Flags:** \`i\`
|
|
||||||
- **Input:** \`Hello world!\`
|
|
||||||
- **Output:** \`1\` (matched, case-insensitive)
|
|
||||||
`.trim()
|
|
||||||
};
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/nodes/Data Analysis/Node_TextArray_Display.jsx
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display Multi-Line Array Node
|
|
||||||
* --------------------------------------------------
|
|
||||||
* A node to display upstream multi-line text arrays.
|
|
||||||
* Has one input edge on left and passthrough output on right.
|
|
||||||
* Custom drag-resize handle for width & height adjustments.
|
|
||||||
* Inner textarea scrolls vertically; container overflow visible.
|
|
||||||
*/
|
|
||||||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
|
||||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
|
||||||
|
|
||||||
const TextArrayDisplayNode = ({ id, data }) => {
|
|
||||||
const { setNodes } = useReactFlow();
|
|
||||||
const edges = useStore((state) => state.edges);
|
|
||||||
const containerRef = useRef(null);
|
|
||||||
const resizingRef = useRef(false);
|
|
||||||
const startPosRef = useRef({ x: 0, y: 0 });
|
|
||||||
const startDimRef = useRef({ width: 0, height: 0 });
|
|
||||||
|
|
||||||
// Initialize lines and dimensions
|
|
||||||
const [lines, setLines] = useState(data?.lines || []);
|
|
||||||
const linesRef = useRef(lines);
|
|
||||||
const initW = parseInt(data?.width || "300", 10);
|
|
||||||
const initH = parseInt(data?.height || "150", 10);
|
|
||||||
const [dimensions, setDimensions] = useState({ width: initW, height: initH });
|
|
||||||
|
|
||||||
// Persist dimensions to node data
|
|
||||||
const persistDimensions = useCallback(() => {
|
|
||||||
const w = `${Math.round(dimensions.width)}px`;
|
|
||||||
const h = `${Math.round(dimensions.height)}px`;
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id
|
|
||||||
? { ...n, data: { ...n.data, width: w, height: h } }
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, [dimensions, id, setNodes]);
|
|
||||||
|
|
||||||
// Mouse handlers for custom resize
|
|
||||||
useEffect(() => {
|
|
||||||
const onMouseMove = (e) => {
|
|
||||||
if (!resizingRef.current) return;
|
|
||||||
const dx = e.clientX - startPosRef.current.x;
|
|
||||||
const dy = e.clientY - startPosRef.current.y;
|
|
||||||
setDimensions({
|
|
||||||
width: Math.max(100, startDimRef.current.width + dx),
|
|
||||||
height: Math.max(60, startDimRef.current.height + dy)
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const onMouseUp = () => {
|
|
||||||
if (resizingRef.current) {
|
|
||||||
resizingRef.current = false;
|
|
||||||
persistDimensions();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("mousemove", onMouseMove);
|
|
||||||
window.addEventListener("mouseup", onMouseUp);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("mousemove", onMouseMove);
|
|
||||||
window.removeEventListener("mouseup", onMouseUp);
|
|
||||||
};
|
|
||||||
}, [persistDimensions]);
|
|
||||||
|
|
||||||
// Start drag
|
|
||||||
const onResizeMouseDown = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
resizingRef.current = true;
|
|
||||||
startPosRef.current = { x: e.clientX, y: e.clientY };
|
|
||||||
startDimRef.current = { ...dimensions };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Polling for upstream data
|
|
||||||
useEffect(() => {
|
|
||||||
let rate = window.BorealisUpdateRate;
|
|
||||||
const tick = () => {
|
|
||||||
const edge = edges.find((e) => e.target === id);
|
|
||||||
if (edge && edge.source) {
|
|
||||||
const arr = window.BorealisValueBus[edge.source] || [];
|
|
||||||
if (JSON.stringify(arr) !== JSON.stringify(linesRef.current)) {
|
|
||||||
linesRef.current = arr;
|
|
||||||
setLines(arr);
|
|
||||||
window.BorealisValueBus[id] = arr;
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id ? { ...n, data: { ...n.data, lines: arr } } : n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
window.BorealisValueBus[id] = linesRef.current;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const iv = setInterval(tick, rate);
|
|
||||||
const monitor = setInterval(() => {
|
|
||||||
if (window.BorealisUpdateRate !== rate) {
|
|
||||||
clearInterval(iv);
|
|
||||||
clearInterval(monitor);
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
return () => { clearInterval(iv); clearInterval(monitor); };
|
|
||||||
}, [id, edges, setNodes]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="borealis-node"
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
width: dimensions.width,
|
|
||||||
height: dimensions.height,
|
|
||||||
overflow: "visible",
|
|
||||||
position: "relative",
|
|
||||||
boxSizing: "border-box"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Connectors */}
|
|
||||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
|
||||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="borealis-node-header">
|
|
||||||
{data?.label || "Display Multi-Line Array"}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div
|
|
||||||
className="borealis-node-content"
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: "4px",
|
|
||||||
fontSize: "9px",
|
|
||||||
color: "#ccc",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
overflow: "hidden"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ marginBottom: "4px" }}>
|
|
||||||
{data?.content || "Display upstream multi-line text arrays."}
|
|
||||||
</div>
|
|
||||||
<label style={{ marginBottom: "4px" }}>Upstream Text Data:</label>
|
|
||||||
<textarea
|
|
||||||
value={lines.join("\n")}
|
|
||||||
readOnly
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
width: "100%",
|
|
||||||
fontSize: "9px",
|
|
||||||
background: "#1e1e1e",
|
|
||||||
color: "#ccc",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "2px",
|
|
||||||
padding: "4px",
|
|
||||||
resize: "none",
|
|
||||||
overflowY: "auto",
|
|
||||||
boxSizing: "border-box"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Invisible drag-resize handle */}
|
|
||||||
<div
|
|
||||||
onMouseDown={onResizeMouseDown}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
width: "20px",
|
|
||||||
height: "20px",
|
|
||||||
right: "-4px",
|
|
||||||
bottom: "-4px",
|
|
||||||
cursor: "nwse-resize",
|
|
||||||
background: "transparent",
|
|
||||||
zIndex: 10
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Export node metadata
|
|
||||||
export default {
|
|
||||||
type: "Node_TextArray_Display",
|
|
||||||
label: "Display Multi-Line Array",
|
|
||||||
description: "Display upstream multi-line text arrays.",
|
|
||||||
content: "Display upstream multi-line text arrays.",
|
|
||||||
component: TextArrayDisplayNode
|
|
||||||
};
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Data Collection/Node_API_Request.jsx
|
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
|
||||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
|
||||||
|
|
||||||
// API Request Node (Modern, Sidebar Config Enabled)
|
|
||||||
const APIRequestNode = ({ id, data }) => {
|
|
||||||
const { setNodes } = useReactFlow();
|
|
||||||
const edges = useStore((state) => state.edges);
|
|
||||||
|
|
||||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
|
||||||
|
|
||||||
// Use config values, but coerce types
|
|
||||||
const url = data?.url || "http://localhost:5000/health";
|
|
||||||
// Note: Store useProxy as a string ("true"/"false"), convert to boolean for logic
|
|
||||||
const useProxy = (data?.useProxy ?? "true") === "true";
|
|
||||||
const body = data?.body || "";
|
|
||||||
const intervalSec = parseInt(data?.intervalSec || "10", 10) || 10;
|
|
||||||
|
|
||||||
// Status State
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [statusCode, setStatusCode] = useState(null);
|
|
||||||
const [statusText, setStatusText] = useState("");
|
|
||||||
const resultRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
const runNodeLogic = async () => {
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// Allow dynamic URL override from upstream node (if present)
|
|
||||||
const inputEdge = edges.find((e) => e.target === id);
|
|
||||||
const upstreamUrl = inputEdge ? window.BorealisValueBus[inputEdge.source] : null;
|
|
||||||
const resolvedUrl = upstreamUrl || url;
|
|
||||||
let target = useProxy ? `/api/proxy?url=${encodeURIComponent(resolvedUrl)}` : resolvedUrl;
|
|
||||||
|
|
||||||
const options = {};
|
|
||||||
if (body.trim()) {
|
|
||||||
options.method = "POST";
|
|
||||||
options.headers = { "Content-Type": "application/json" };
|
|
||||||
options.body = body;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(target, options);
|
|
||||||
setStatusCode(res.status);
|
|
||||||
setStatusText(res.statusText);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
resultRef.current = null;
|
|
||||||
window.BorealisValueBus[id] = undefined;
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id ? { ...n, data: { ...n.data, result: undefined } } : n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
throw new Error(`HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await res.json();
|
|
||||||
const pretty = JSON.stringify(json, null, 2);
|
|
||||||
if (!cancelled && resultRef.current !== pretty) {
|
|
||||||
resultRef.current = pretty;
|
|
||||||
window.BorealisValueBus[id] = json;
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id ? { ...n, data: { ...n.data, result: pretty } } : n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("API Request node fetch error:", err);
|
|
||||||
setError(err.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
runNodeLogic();
|
|
||||||
const ms = Math.max(intervalSec, 1) * 1000;
|
|
||||||
const iv = setInterval(runNodeLogic, ms);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearInterval(iv);
|
|
||||||
};
|
|
||||||
}, [url, body, intervalSec, useProxy, id, setNodes, edges]);
|
|
||||||
|
|
||||||
// Upstream disables direct editing of URL in the UI
|
|
||||||
const inputEdge = edges.find((e) => e.target === id);
|
|
||||||
const hasUpstream = Boolean(inputEdge && inputEdge.source);
|
|
||||||
|
|
||||||
// -- Node Card Render (minimal: sidebar handles config) --
|
|
||||||
return (
|
|
||||||
<div className="borealis-node">
|
|
||||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
|
||||||
|
|
||||||
<div className="borealis-node-header">
|
|
||||||
{data?.label || "API Request"}
|
|
||||||
</div>
|
|
||||||
<div className="borealis-node-content" style={{ fontSize: "9px", color: "#ccc" }}>
|
|
||||||
<div>
|
|
||||||
<b>Status:</b>{" "}
|
|
||||||
{error ? (
|
|
||||||
<span style={{ color: "#f66" }}>{error}</span>
|
|
||||||
) : statusCode !== null ? (
|
|
||||||
<span style={{ color: "#6f6" }}>{statusCode} {statusText}</span>
|
|
||||||
) : (
|
|
||||||
"N/A"
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: "4px" }}>
|
|
||||||
<b>Result:</b>
|
|
||||||
<pre style={{
|
|
||||||
background: "#181818",
|
|
||||||
color: "#b6ffb4",
|
|
||||||
fontSize: "8px",
|
|
||||||
maxHeight: 62,
|
|
||||||
overflow: "auto",
|
|
||||||
margin: 0,
|
|
||||||
padding: "4px",
|
|
||||||
borderRadius: "2px"
|
|
||||||
}}>{data?.result ? String(data.result).slice(0, 350) : "No data"}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Node Registration Object with sidebar config + docs
|
|
||||||
export default {
|
|
||||||
type: "API_Request",
|
|
||||||
label: "API Request",
|
|
||||||
description: "Fetch JSON from an API endpoint with optional POST body, polling, and proxy toggle. Accepts URL from upstream.",
|
|
||||||
content: "Fetch JSON from HTTP or remote API endpoint, with CORS proxy option.",
|
|
||||||
component: APIRequestNode,
|
|
||||||
config: [
|
|
||||||
{
|
|
||||||
key: "url",
|
|
||||||
label: "Request URL",
|
|
||||||
type: "text",
|
|
||||||
defaultValue: "http://localhost:5000/health"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "useProxy",
|
|
||||||
label: "Use Proxy (bypass CORS)",
|
|
||||||
type: "select",
|
|
||||||
options: ["true", "false"],
|
|
||||||
defaultValue: "true"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "body",
|
|
||||||
label: "Request Body (JSON)",
|
|
||||||
type: "textarea",
|
|
||||||
defaultValue: ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "intervalSec",
|
|
||||||
label: "Polling Interval (sec)",
|
|
||||||
type: "text",
|
|
||||||
defaultValue: "10"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
usage_documentation: `
|
|
||||||
### API Request Node
|
|
||||||
|
|
||||||
Fetches JSON from an HTTP or HTTPS API endpoint, with an option to POST a JSON body and control polling interval.
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- **URL**: You can set a static URL, or connect an upstream node to dynamically control the API endpoint.
|
|
||||||
- **Use Proxy**: When enabled, requests route through the Borealis backend proxy to bypass CORS/browser restrictions.
|
|
||||||
- **Request Body**: POST JSON data (leave blank for GET).
|
|
||||||
- **Polling Interval**: Set how often (in seconds) to re-fetch the API.
|
|
||||||
|
|
||||||
**Outputs:**
|
|
||||||
- The downstream value is the parsed JSON object from the API response.
|
|
||||||
|
|
||||||
**Typical Use Cases:**
|
|
||||||
- Poll external APIs (weather, status, data, etc)
|
|
||||||
- Connect to local/internal REST endpoints
|
|
||||||
- Build data pipelines with API triggers
|
|
||||||
|
|
||||||
**Input & UI Behavior:**
|
|
||||||
- If an upstream node is connected, its output value will override the Request URL.
|
|
||||||
- All config is handled in the right sidebar (Node Properties).
|
|
||||||
|
|
||||||
**Error Handling:**
|
|
||||||
- If the fetch fails, the node displays the error in the UI.
|
|
||||||
- Only 2xx status codes are considered successful.
|
|
||||||
|
|
||||||
**Security Note:**
|
|
||||||
- Use Proxy mode for APIs requiring CORS bypass or additional privacy.
|
|
||||||
|
|
||||||
`.trim()
|
|
||||||
};
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Data/Node_Upload_Text.jsx
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload Text File Node
|
|
||||||
* --------------------------------------------------
|
|
||||||
* A node to upload a text file (TXT/LOG/INI/ETC) and store it as a multi-line text array.
|
|
||||||
* No input edges. Outputs an array of text lines via the shared value bus.
|
|
||||||
*/
|
|
||||||
import React, { useEffect, useState, useRef } from "react";
|
|
||||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
|
||||||
|
|
||||||
const UploadTextFileNode = ({ id, data }) => {
|
|
||||||
const { setNodes } = useReactFlow();
|
|
||||||
const edges = useStore((state) => state.edges);
|
|
||||||
|
|
||||||
// Initialize lines from persisted data or empty
|
|
||||||
const initialLines = data?.lines || [];
|
|
||||||
const [lines, setLines] = useState(initialLines);
|
|
||||||
const linesRef = useRef(initialLines);
|
|
||||||
|
|
||||||
const fileInputRef = useRef(null);
|
|
||||||
|
|
||||||
// Handle file selection and reading
|
|
||||||
const handleFileChange = (e) => {
|
|
||||||
const file = e.target.files && e.target.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
file.text().then((text) => {
|
|
||||||
const arr = text.split(/\r\n|\n/);
|
|
||||||
linesRef.current = arr;
|
|
||||||
setLines(arr);
|
|
||||||
// Broadcast to shared bus
|
|
||||||
window.BorealisValueBus[id] = arr;
|
|
||||||
// Persist data for workflow serialization
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id
|
|
||||||
? { ...n, data: { ...n.data, lines: arr } }
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Trigger file input click
|
|
||||||
const handleUploadClick = () => {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Periodically broadcast current lines to bus
|
|
||||||
useEffect(() => {
|
|
||||||
let currentRate = window.BorealisUpdateRate;
|
|
||||||
const intervalId = setInterval(() => {
|
|
||||||
window.BorealisValueBus[id] = linesRef.current;
|
|
||||||
}, currentRate);
|
|
||||||
|
|
||||||
// Monitor for rate changes
|
|
||||||
const monitorId = setInterval(() => {
|
|
||||||
const newRate = window.BorealisUpdateRate;
|
|
||||||
if (newRate !== currentRate) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
clearInterval(monitorId);
|
|
||||||
}
|
|
||||||
}, 250);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
clearInterval(monitorId);
|
|
||||||
};
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="borealis-node">
|
|
||||||
{/* No input handle for this node */}
|
|
||||||
<div className="borealis-node-header">
|
|
||||||
{data?.label || "Upload Text File"}
|
|
||||||
</div>
|
|
||||||
<div className="borealis-node-content">
|
|
||||||
<div style={{ marginBottom: "8px", fontSize: "9px", color: "#ccc" }}>
|
|
||||||
{data?.content ||
|
|
||||||
"Upload a text-based file, output a multi-line string array."}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleUploadClick}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "6px",
|
|
||||||
fontSize: "9px",
|
|
||||||
background: "#1e1e1e",
|
|
||||||
color: "#ccc",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "2px",
|
|
||||||
cursor: "pointer"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Select File...
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".txt,.log,.ini,text/*"
|
|
||||||
style={{ display: "none" }}
|
|
||||||
ref={fileInputRef}
|
|
||||||
onChange={handleFileChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Output connector on right */}
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
className="borealis-handle"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Export node metadata for Borealis
|
|
||||||
export default {
|
|
||||||
type: "Upload_Text_File",
|
|
||||||
label: "Upload Text File",
|
|
||||||
description: "A node to upload a text file (TXT/LOG/INI/ETC) and store it as a multi-line text array.",
|
|
||||||
content: "Upload a text-based file, output a multi-line string array.",
|
|
||||||
component: UploadTextFileNode
|
|
||||||
};
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/General Purpose/Node_Edge_Toggle.jsx
|
|
||||||
import React, { useEffect, useState, useRef } from "react";
|
|
||||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
|
||||||
import Switch from "@mui/material/Switch";
|
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
|
||||||
|
|
||||||
/*
|
|
||||||
Borealis - Edge Toggle Node
|
|
||||||
===========================
|
|
||||||
Allows users to toggle data flow between upstream and downstream nodes.
|
|
||||||
- When enabled: propagates upstream value.
|
|
||||||
- When disabled: outputs "0" (or null/blank) so downstream sees a cleared value.
|
|
||||||
|
|
||||||
Fully captures and restores toggle state ("enabled"/"disabled") from imported workflow JSON,
|
|
||||||
so state is always restored as last persisted.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Init shared value bus if needed
|
|
||||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
|
||||||
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
|
|
||||||
|
|
||||||
const EdgeToggleNode = ({ id, data }) => {
|
|
||||||
const { setNodes } = useReactFlow();
|
|
||||||
const edges = useStore((state) => state.edges);
|
|
||||||
|
|
||||||
// === CAPTURE persisted toggle state on load/rehydrate ===
|
|
||||||
// Restore "enabled" from node data if present, otherwise true
|
|
||||||
const [enabled, setEnabled] = useState(
|
|
||||||
typeof data?.enabled === "boolean"
|
|
||||||
? data.enabled
|
|
||||||
: data?.enabled === "false"
|
|
||||||
? false
|
|
||||||
: data?.enabled === "true"
|
|
||||||
? true
|
|
||||||
: data?.enabled !== undefined
|
|
||||||
? !!data.enabled
|
|
||||||
: true
|
|
||||||
);
|
|
||||||
// Store last output value
|
|
||||||
const [outputValue, setOutputValue] = useState(
|
|
||||||
typeof data?.value !== "undefined" ? data.value : undefined
|
|
||||||
);
|
|
||||||
const outputRef = useRef(outputValue);
|
|
||||||
|
|
||||||
// === Persist toggle state back to node data when toggled ===
|
|
||||||
useEffect(() => {
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id ? { ...n, data: { ...n.data, enabled } } : n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, [enabled, id, setNodes]);
|
|
||||||
|
|
||||||
// === On mount: restore BorealisValueBus from loaded node data if present ===
|
|
||||||
useEffect(() => {
|
|
||||||
// Only run on first mount
|
|
||||||
if (typeof data?.value !== "undefined") {
|
|
||||||
window.BorealisValueBus[id] = data.value;
|
|
||||||
setOutputValue(data.value);
|
|
||||||
outputRef.current = data.value;
|
|
||||||
}
|
|
||||||
}, [id, data?.value]);
|
|
||||||
|
|
||||||
// === Main interval logic: live propagate upstream/clear if off ===
|
|
||||||
useEffect(() => {
|
|
||||||
let interval = null;
|
|
||||||
let currentRate = window.BorealisUpdateRate || 100;
|
|
||||||
|
|
||||||
const runNodeLogic = () => {
|
|
||||||
const inputEdge = edges.find((e) => e.target === id);
|
|
||||||
const hasInput = Boolean(inputEdge && inputEdge.source);
|
|
||||||
|
|
||||||
if (enabled && hasInput) {
|
|
||||||
const upstreamValue = window.BorealisValueBus[inputEdge.source];
|
|
||||||
if (upstreamValue !== outputRef.current) {
|
|
||||||
outputRef.current = upstreamValue;
|
|
||||||
setOutputValue(upstreamValue);
|
|
||||||
window.BorealisValueBus[id] = upstreamValue;
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id
|
|
||||||
? { ...n, data: { ...n.data, value: upstreamValue } }
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (!enabled) {
|
|
||||||
// Always push zero (or blank/null) when disabled
|
|
||||||
if (outputRef.current !== 0) {
|
|
||||||
outputRef.current = 0;
|
|
||||||
setOutputValue(0);
|
|
||||||
window.BorealisValueBus[id] = 0;
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id ? { ...n, data: { ...n.data, value: 0 } } : n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
interval = setInterval(runNodeLogic, currentRate);
|
|
||||||
|
|
||||||
const monitor = setInterval(() => {
|
|
||||||
const newRate = window.BorealisUpdateRate;
|
|
||||||
if (newRate !== currentRate) {
|
|
||||||
clearInterval(interval);
|
|
||||||
currentRate = newRate;
|
|
||||||
interval = setInterval(runNodeLogic, currentRate);
|
|
||||||
}
|
|
||||||
}, 250);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(interval);
|
|
||||||
clearInterval(monitor);
|
|
||||||
};
|
|
||||||
}, [id, edges, enabled, setNodes]);
|
|
||||||
|
|
||||||
// Edge input detection
|
|
||||||
const inputEdge = edges.find((e) => e.target === id);
|
|
||||||
const hasInput = Boolean(inputEdge && inputEdge.source);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="borealis-node">
|
|
||||||
{/* Input handle */}
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Left}
|
|
||||||
className="borealis-handle"
|
|
||||||
/>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="borealis-node-header">
|
|
||||||
{data?.label || "Edge Toggle"}
|
|
||||||
</div>
|
|
||||||
{/* Content */}
|
|
||||||
<div className="borealis-node-content">
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<Tooltip
|
|
||||||
title={enabled ? "Turn Off / Send Zero" : "Turn On / Allow Data"}
|
|
||||||
arrow
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
checked={enabled}
|
|
||||||
size="small"
|
|
||||||
onChange={() => setEnabled((e) => !e)}
|
|
||||||
sx={{
|
|
||||||
"& .MuiSwitch-switchBase.Mui-checked": {
|
|
||||||
color: "#58a6ff",
|
|
||||||
},
|
|
||||||
"& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track": {
|
|
||||||
backgroundColor: "#58a6ff",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 9,
|
|
||||||
color: enabled ? "#00d18c" : "#ff4f4f",
|
|
||||||
fontWeight: "bold",
|
|
||||||
marginLeft: 4,
|
|
||||||
userSelect: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{enabled ? "Flow Enabled" : "Flow Disabled"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Output handle */}
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
className="borealis-handle"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Node Export for Borealis
|
|
||||||
export default {
|
|
||||||
type: "Edge_Toggle",
|
|
||||||
label: "Edge Toggle",
|
|
||||||
description: `
|
|
||||||
Toggles edge data flow ON/OFF using a switch.
|
|
||||||
- When enabled, passes upstream value to downstream.
|
|
||||||
- When disabled, sends zero (0) so downstream always sees a cleared value.
|
|
||||||
- Use to quickly enable/disable parts of your workflow without unlinking edges.
|
|
||||||
`.trim(),
|
|
||||||
content: "Toggle ON/OFF to allow or send zero downstream",
|
|
||||||
component: EdgeToggleNode,
|
|
||||||
config: [
|
|
||||||
{
|
|
||||||
key: "enabled",
|
|
||||||
label: "Toggle Enabled",
|
|
||||||
type: "select",
|
|
||||||
options: ["true", "false"],
|
|
||||||
defaultValue: "true"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
usage_documentation: `
|
|
||||||
### Edge Toggle Node
|
|
||||||
|
|
||||||
**Purpose:**
|
|
||||||
Allows you to control data flow along a workflow edge without disconnecting the wire.
|
|
||||||
|
|
||||||
**Behavior:**
|
|
||||||
- When **Enabled**: passes upstream value downstream as usual.
|
|
||||||
- When **Disabled**: pushes \`0\` (zero) so that downstream logic always sees a cleared value (acts as an instant "mute" switch).
|
|
||||||
|
|
||||||
**Persistence:**
|
|
||||||
- Toggle state is saved in the workflow and restored on load/import.
|
|
||||||
|
|
||||||
**Tips:**
|
|
||||||
- Use for debug toggling, feature gating, or for rapid workflow prototyping.
|
|
||||||
|
|
||||||
---
|
|
||||||
`.trim()
|
|
||||||
};
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/General Purpose/Node_Data.jsx
|
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
|
||||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
|
||||||
import { IconButton } from "@mui/material";
|
|
||||||
import { Settings as SettingsIcon } from "@mui/icons-material";
|
|
||||||
|
|
||||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
|
||||||
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
|
|
||||||
|
|
||||||
const DataNode = ({ id, data }) => {
|
|
||||||
const { setNodes } = useReactFlow();
|
|
||||||
const edges = useStore((state) => state.edges);
|
|
||||||
const [renderValue, setRenderValue] = useState(data?.value || "");
|
|
||||||
const valueRef = useRef(renderValue);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
valueRef.current = data?.value || "";
|
|
||||||
setRenderValue(valueRef.current);
|
|
||||||
window.BorealisValueBus[id] = valueRef.current;
|
|
||||||
}, [data?.value, id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let currentRate = window.BorealisUpdateRate || 100;
|
|
||||||
let intervalId = null;
|
|
||||||
|
|
||||||
const runNodeLogic = () => {
|
|
||||||
const inputEdge = edges.find((e) => e?.target === id);
|
|
||||||
const hasInput = Boolean(inputEdge?.source);
|
|
||||||
|
|
||||||
if (hasInput) {
|
|
||||||
const upstreamValue = window.BorealisValueBus[inputEdge.source] ?? "";
|
|
||||||
if (upstreamValue !== valueRef.current) {
|
|
||||||
valueRef.current = upstreamValue;
|
|
||||||
setRenderValue(upstreamValue);
|
|
||||||
window.BorealisValueBus[id] = upstreamValue;
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id ? { ...n, data: { ...n.data, value: upstreamValue } } : n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
window.BorealisValueBus[id] = valueRef.current;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
intervalId = setInterval(runNodeLogic, currentRate);
|
|
||||||
const monitor = setInterval(() => {
|
|
||||||
const newRate = window.BorealisUpdateRate || 100;
|
|
||||||
if (newRate !== currentRate) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
currentRate = newRate;
|
|
||||||
intervalId = setInterval(runNodeLogic, currentRate);
|
|
||||||
}
|
|
||||||
}, 250);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
clearInterval(monitor);
|
|
||||||
};
|
|
||||||
}, [id, setNodes, edges]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="borealis-node">
|
|
||||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
|
||||||
|
|
||||||
<div className="borealis-node-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
||||||
<span>{data?.label || "Data Node"}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="borealis-node-content" style={{ fontSize: "9px", color: "#ccc", marginTop: 4 }}>
|
|
||||||
Value: {renderValue}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
|
||||||
type: "DataNode",
|
|
||||||
label: "String / Number",
|
|
||||||
description: "Foundational node for live value propagation.\n\n- Accepts input or manual value\n- Pushes downstream\n- Uses shared memory",
|
|
||||||
content: "Store a String or Number",
|
|
||||||
component: DataNode,
|
|
||||||
config: [
|
|
||||||
{ key: "value", label: "Value", type: "text" }
|
|
||||||
],
|
|
||||||
usage_documentation: `
|
|
||||||
### Description:
|
|
||||||
This node acts as a basic live data emitter. When connected to an upstream node, it inherits its value, otherwise it accepts user-defined input of either a number or a string.
|
|
||||||
|
|
||||||
**Acceptable Inputs**:
|
|
||||||
- **Static Value** (*Number or String*)
|
|
||||||
|
|
||||||
**Behavior**:
|
|
||||||
- **Pass-through Conduit** (*If Upstream Node is Connected*) > Value cannot be manually changed while connected to an upstream node.
|
|
||||||
- Uses global Borealis "**Update Rate**" for updating value if connected to an upstream node.
|
|
||||||
`.trim()
|
|
||||||
};
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/General Purpose/Node_Logical_Operators.jsx
|
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
|
||||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
|
||||||
import { IconButton } from "@mui/material";
|
|
||||||
import SettingsIcon from "@mui/icons-material/Settings";
|
|
||||||
|
|
||||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
|
||||||
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
|
|
||||||
|
|
||||||
const ComparisonNode = ({ id, data }) => {
|
|
||||||
const { setNodes } = useReactFlow();
|
|
||||||
const edges = useStore(state => state.edges);
|
|
||||||
const [renderValue, setRenderValue] = useState("0");
|
|
||||||
const valueRef = useRef("0");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let currentRate = window.BorealisUpdateRate;
|
|
||||||
let intervalId = null;
|
|
||||||
|
|
||||||
const runNodeLogic = () => {
|
|
||||||
let inputType = data?.inputType || "Number";
|
|
||||||
let operator = data?.operator || "Equal (==)";
|
|
||||||
let rangeStart = data?.rangeStart;
|
|
||||||
let rangeEnd = data?.rangeEnd;
|
|
||||||
|
|
||||||
// String mode disables all but equality ops
|
|
||||||
if (inputType === "String" && !["Equal (==)", "Not Equal (!=)"].includes(operator)) {
|
|
||||||
operator = "Equal (==)";
|
|
||||||
setNodes(nds =>
|
|
||||||
nds.map(n =>
|
|
||||||
n.id === id ? { ...n, data: { ...n.data, operator } } : n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const edgeInputsA = edges.filter(e => e?.target === id && e.targetHandle === "a");
|
|
||||||
const edgeInputsB = edges.filter(e => e?.target === id && e.targetHandle === "b");
|
|
||||||
|
|
||||||
const extractValues = (edgeList) => {
|
|
||||||
const values = edgeList.map(e => window.BorealisValueBus[e.source]).filter(v => v !== undefined);
|
|
||||||
if (inputType === "Number") {
|
|
||||||
return values.reduce((sum, v) => sum + (parseFloat(v) || 0), 0);
|
|
||||||
}
|
|
||||||
return values.join("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const a = extractValues(edgeInputsA);
|
|
||||||
const b = extractValues(edgeInputsB);
|
|
||||||
|
|
||||||
let result = "0";
|
|
||||||
if (operator === "Within Range") {
|
|
||||||
// Only valid for Number mode
|
|
||||||
const aNum = parseFloat(a);
|
|
||||||
const startNum = parseFloat(rangeStart);
|
|
||||||
const endNum = parseFloat(rangeEnd);
|
|
||||||
if (
|
|
||||||
!isNaN(aNum) &&
|
|
||||||
!isNaN(startNum) &&
|
|
||||||
!isNaN(endNum) &&
|
|
||||||
startNum <= endNum
|
|
||||||
) {
|
|
||||||
result = (aNum >= startNum && aNum <= endNum) ? "1" : "0";
|
|
||||||
} else {
|
|
||||||
result = "0";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const resultMap = {
|
|
||||||
"Equal (==)": a === b,
|
|
||||||
"Not Equal (!=)": a !== b,
|
|
||||||
"Greater Than (>)": a > b,
|
|
||||||
"Less Than (<)": a < b,
|
|
||||||
"Greater Than or Equal (>=)": a >= b,
|
|
||||||
"Less Than or Equal (<=)": a <= b
|
|
||||||
};
|
|
||||||
result = resultMap[operator] ? "1" : "0";
|
|
||||||
}
|
|
||||||
|
|
||||||
valueRef.current = result;
|
|
||||||
setRenderValue(result);
|
|
||||||
window.BorealisValueBus[id] = result;
|
|
||||||
|
|
||||||
setNodes(nds =>
|
|
||||||
nds.map(n =>
|
|
||||||
n.id === id ? { ...n, data: { ...n.data, value: result } } : n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
intervalId = setInterval(runNodeLogic, currentRate);
|
|
||||||
|
|
||||||
const monitor = setInterval(() => {
|
|
||||||
const newRate = window.BorealisUpdateRate;
|
|
||||||
if (newRate !== currentRate) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
currentRate = newRate;
|
|
||||||
intervalId = setInterval(runNodeLogic, currentRate);
|
|
||||||
}
|
|
||||||
}, 250);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
clearInterval(monitor);
|
|
||||||
};
|
|
||||||
}, [id, edges, data?.inputType, data?.operator, data?.rangeStart, data?.rangeEnd, setNodes]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="borealis-node">
|
|
||||||
<div style={{ position: "absolute", left: -16, top: 12, fontSize: "8px", color: "#ccc" }}>A</div>
|
|
||||||
<div style={{ position: "absolute", left: -16, top: 50, fontSize: "8px", color: "#ccc" }}>B</div>
|
|
||||||
<Handle type="target" position={Position.Left} id="a" style={{ top: 12 }} className="borealis-handle" />
|
|
||||||
<Handle type="target" position={Position.Left} id="b" style={{ top: 50 }} className="borealis-handle" />
|
|
||||||
|
|
||||||
<div className="borealis-node-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
||||||
<span>{data?.label || "Logic Comparison"}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="borealis-node-content" style={{ fontSize: "9px", color: "#ccc", marginTop: 4 }}>
|
|
||||||
Result: {renderValue}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
|
||||||
type: "ComparisonNode",
|
|
||||||
label: "Logic Comparison",
|
|
||||||
description: "Compare A vs B using logic operators, with range support.",
|
|
||||||
content: "Compare A and B using Logic, with new range operator.",
|
|
||||||
component: ComparisonNode,
|
|
||||||
config: [
|
|
||||||
{
|
|
||||||
key: "inputType",
|
|
||||||
label: "Input Type",
|
|
||||||
type: "select",
|
|
||||||
options: ["Number", "String"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "operator",
|
|
||||||
label: "Operator",
|
|
||||||
type: "select",
|
|
||||||
options: [
|
|
||||||
"Equal (==)",
|
|
||||||
"Not Equal (!=)",
|
|
||||||
"Greater Than (>)",
|
|
||||||
"Less Than (<)",
|
|
||||||
"Greater Than or Equal (>=)",
|
|
||||||
"Less Than or Equal (<=)",
|
|
||||||
"Within Range"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
// These two fields will show up in the sidebar config for ALL operator choices
|
|
||||||
// Sidebar UI will ignore/hide if operator != Within Range, but the config is always present
|
|
||||||
{
|
|
||||||
key: "rangeStart",
|
|
||||||
label: "Range Start",
|
|
||||||
type: "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "rangeEnd",
|
|
||||||
label: "Range End",
|
|
||||||
type: "text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
usage_documentation: `
|
|
||||||
### Logic Comparison Node
|
|
||||||
|
|
||||||
This node compares two inputs (A and B) using the selected operator, including a numeric range.
|
|
||||||
|
|
||||||
**Modes:**
|
|
||||||
- **Number**: Sums all connected inputs and compares.
|
|
||||||
- **String**: Concatenates all inputs for comparison.
|
|
||||||
- Only **Equal (==)** and **Not Equal (!=)** are valid for strings.
|
|
||||||
- **Within Range**: If operator is "Within Range", compares if input A is within [Range Start, Range End] (inclusive).
|
|
||||||
|
|
||||||
**Output:**
|
|
||||||
- Returns \`1\` if comparison is true.
|
|
||||||
- Returns \`0\` if comparison is false.
|
|
||||||
|
|
||||||
**Input Notes:**
|
|
||||||
- A and B can each have multiple inputs.
|
|
||||||
- Input order matters for strings (concatenation).
|
|
||||||
- Input handles:
|
|
||||||
- **A** = Top left
|
|
||||||
- **B** = Middle left
|
|
||||||
|
|
||||||
**"Within Range" Operator:**
|
|
||||||
- Only works for **Number** input type.
|
|
||||||
- Enter "Range Start" and "Range End" in the right sidebar.
|
|
||||||
- The result is \`1\` if A >= Range Start AND A <= Range End (inclusive).
|
|
||||||
- Result is \`0\` if out of range or values are invalid.
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
- Range Start: 33
|
|
||||||
- Range End: 77
|
|
||||||
- A: 44 -> 1 (true, in range)
|
|
||||||
- A: 88 -> 0 (false, out of range)
|
|
||||||
`.trim()
|
|
||||||
};
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/General Purpose/Node_Math_Operations.jsx
|
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
|
||||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
|
||||||
import { IconButton } from "@mui/material";
|
|
||||||
import SettingsIcon from "@mui/icons-material/Settings";
|
|
||||||
|
|
||||||
// Init shared memory bus if not already set
|
|
||||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
|
||||||
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
|
|
||||||
|
|
||||||
const MathNode = ({ id, data }) => {
|
|
||||||
const { setNodes } = useReactFlow();
|
|
||||||
const edges = useStore(state => state.edges);
|
|
||||||
const [renderResult, setRenderResult] = useState(data?.value || "0");
|
|
||||||
const resultRef = useRef(renderResult);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let intervalId = null;
|
|
||||||
let currentRate = window.BorealisUpdateRate;
|
|
||||||
|
|
||||||
const runLogic = () => {
|
|
||||||
const operator = data?.operator || "Add";
|
|
||||||
const inputsA = edges.filter(e => e.target === id && e.targetHandle === "a");
|
|
||||||
const inputsB = edges.filter(e => e.target === id && e.targetHandle === "b");
|
|
||||||
|
|
||||||
const sum = (list) =>
|
|
||||||
list
|
|
||||||
.map(e => parseFloat(window.BorealisValueBus[e.source]) || 0)
|
|
||||||
.reduce((a, b) => a + b, 0);
|
|
||||||
|
|
||||||
const valA = sum(inputsA);
|
|
||||||
const valB = sum(inputsB);
|
|
||||||
|
|
||||||
let value = 0;
|
|
||||||
switch (operator) {
|
|
||||||
case "Add":
|
|
||||||
value = valA + valB;
|
|
||||||
break;
|
|
||||||
case "Subtract":
|
|
||||||
value = valA - valB;
|
|
||||||
break;
|
|
||||||
case "Multiply":
|
|
||||||
value = valA * valB;
|
|
||||||
break;
|
|
||||||
case "Divide":
|
|
||||||
value = valB !== 0 ? valA / valB : 0;
|
|
||||||
break;
|
|
||||||
case "Average":
|
|
||||||
const totalInputs = inputsA.length + inputsB.length;
|
|
||||||
const totalSum = valA + valB;
|
|
||||||
value = totalInputs > 0 ? totalSum / totalInputs : 0;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
resultRef.current = value;
|
|
||||||
setRenderResult(value.toString());
|
|
||||||
window.BorealisValueBus[id] = value.toString();
|
|
||||||
|
|
||||||
setNodes(nds =>
|
|
||||||
nds.map(n =>
|
|
||||||
n.id === id
|
|
||||||
? { ...n, data: { ...n.data, value: value.toString() } }
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
intervalId = setInterval(runLogic, currentRate);
|
|
||||||
|
|
||||||
// Watch for update rate changes
|
|
||||||
const monitor = setInterval(() => {
|
|
||||||
const newRate = window.BorealisUpdateRate;
|
|
||||||
if (newRate !== currentRate) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
currentRate = newRate;
|
|
||||||
intervalId = setInterval(runLogic, currentRate);
|
|
||||||
}
|
|
||||||
}, 250);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
clearInterval(monitor);
|
|
||||||
};
|
|
||||||
}, [id, edges, setNodes, data?.operator]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="borealis-node">
|
|
||||||
<div style={{ position: "absolute", left: -16, top: 12, fontSize: "8px", color: "#ccc" }}>A</div>
|
|
||||||
<div style={{ position: "absolute", left: -16, top: 50, fontSize: "8px", color: "#ccc" }}>B</div>
|
|
||||||
<Handle type="target" position={Position.Left} id="a" style={{ top: 12 }} className="borealis-handle" />
|
|
||||||
<Handle type="target" position={Position.Left} id="b" style={{ top: 50 }} className="borealis-handle" />
|
|
||||||
|
|
||||||
<div className="borealis-node-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
||||||
<span>{data?.label || "Math Operation"}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="borealis-node-content" style={{ fontSize: "9px", color: "#ccc", marginTop: 4 }}>
|
|
||||||
Result: {renderResult}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
|
||||||
type: "MathNode",
|
|
||||||
label: "Math Operation",
|
|
||||||
description: `
|
|
||||||
Live math node for computing on two grouped inputs.
|
|
||||||
|
|
||||||
- Sums all A and B handle inputs separately
|
|
||||||
- Performs selected math operation: Add, Subtract, Multiply, Divide, Average
|
|
||||||
- Emits result as string via BorealisValueBus
|
|
||||||
- Updates at the global update rate
|
|
||||||
|
|
||||||
**Common Uses:**
|
|
||||||
Live dashboard math, sensor fusion, calculation chains, dynamic thresholds
|
|
||||||
`.trim(),
|
|
||||||
content: "Perform Math Operations",
|
|
||||||
component: MathNode,
|
|
||||||
config: [
|
|
||||||
{
|
|
||||||
key: "operator",
|
|
||||||
label: "Operator",
|
|
||||||
type: "select",
|
|
||||||
options: [
|
|
||||||
"Add",
|
|
||||||
"Subtract",
|
|
||||||
"Multiply",
|
|
||||||
"Divide",
|
|
||||||
"Average"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
usage_documentation: `
|
|
||||||
### Math Operation Node
|
|
||||||
|
|
||||||
Performs live math between two groups of inputs (**A** and **B**).
|
|
||||||
|
|
||||||
#### Usage
|
|
||||||
|
|
||||||
- Connect any number of nodes to the **A** and **B** input handles.
|
|
||||||
- The node **sums all values** from A and from B before applying the operator.
|
|
||||||
- Select the math operator in the sidebar config:
|
|
||||||
- **Add**: A + B
|
|
||||||
- **Subtract**: A - B
|
|
||||||
- **Multiply**: A * B
|
|
||||||
- **Divide**: A / B (0 if B=0)
|
|
||||||
- **Average**: (A + B) / total number of inputs
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
- The computed result is pushed as a string to downstream nodes every update tick.
|
|
||||||
|
|
||||||
#### Input Handles
|
|
||||||
|
|
||||||
- **A** (Top Left)
|
|
||||||
- **B** (Middle Left)
|
|
||||||
|
|
||||||
#### Example
|
|
||||||
|
|
||||||
If three nodes outputting 5, 10, 15 are connected to A,
|
|
||||||
and one node outputs 2 is connected to B,
|
|
||||||
and operator is Multiply:
|
|
||||||
|
|
||||||
- **A** = 5 + 10 + 15 = 30
|
|
||||||
- **B** = 2
|
|
||||||
- **Result** = 30 * 2 = 60
|
|
||||||
|
|
||||||
`.trim()
|
|
||||||
};
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_Adjust_Contrast.jsx
|
|
||||||
|
|
||||||
import React, { useEffect, useState, useRef } from "react";
|
|
||||||
import { Handle, Position, useStore } from "reactflow";
|
|
||||||
|
|
||||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
|
||||||
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
|
|
||||||
|
|
||||||
const ContrastNode = ({ id }) => {
|
|
||||||
const edges = useStore((state) => state.edges);
|
|
||||||
const [contrast, setContrast] = useState(100);
|
|
||||||
const valueRef = useRef("");
|
|
||||||
const [renderValue, setRenderValue] = useState("");
|
|
||||||
|
|
||||||
const applyContrast = (base64Data, contrastVal) => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.crossOrigin = "anonymous";
|
|
||||||
|
|
||||||
img.onload = () => {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = img.width;
|
|
||||||
canvas.height = img.height;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
ctx.drawImage(img, 0, 0);
|
|
||||||
|
|
||||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
||||||
const data = imageData.data;
|
|
||||||
|
|
||||||
const factor = (259 * (contrastVal + 255)) / (255 * (259 - contrastVal));
|
|
||||||
|
|
||||||
for (let i = 0; i < data.length; i += 4) {
|
|
||||||
data[i] = factor * (data[i] - 128) + 128;
|
|
||||||
data[i + 1] = factor * (data[i + 1] - 128) + 128;
|
|
||||||
data[i + 2] = factor * (data[i + 2] - 128) + 128;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.putImageData(imageData, 0, 0);
|
|
||||||
resolve(canvas.toDataURL("image/png").replace(/^data:image\/png;base64,/, ""));
|
|
||||||
};
|
|
||||||
|
|
||||||
img.onerror = () => resolve(base64Data);
|
|
||||||
img.src = `data:image/png;base64,${base64Data}`;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const inputEdge = edges.find(e => e.target === id);
|
|
||||||
if (!inputEdge?.source) return;
|
|
||||||
|
|
||||||
const input = window.BorealisValueBus[inputEdge.source] ?? "";
|
|
||||||
if (!input) return;
|
|
||||||
|
|
||||||
applyContrast(input, contrast).then((output) => {
|
|
||||||
setRenderValue(output);
|
|
||||||
window.BorealisValueBus[id] = output;
|
|
||||||
});
|
|
||||||
}, [contrast, edges, id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let interval = null;
|
|
||||||
const tick = async () => {
|
|
||||||
const edge = edges.find(e => e.target === id);
|
|
||||||
const input = edge ? window.BorealisValueBus[edge.source] : "";
|
|
||||||
|
|
||||||
if (input && input !== valueRef.current) {
|
|
||||||
const result = await applyContrast(input, contrast);
|
|
||||||
valueRef.current = input;
|
|
||||||
setRenderValue(result);
|
|
||||||
window.BorealisValueBus[id] = result;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
interval = setInterval(tick, window.BorealisUpdateRate);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [id, contrast, edges]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="borealis-node">
|
|
||||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
|
||||||
<div className="borealis-node-header">Adjust Contrast</div>
|
|
||||||
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
|
|
||||||
<label>Contrast (1–255):</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="255"
|
|
||||||
value={contrast}
|
|
||||||
onChange={(e) => setContrast(parseInt(e.target.value) || 100)}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
fontSize: "9px",
|
|
||||||
padding: "4px",
|
|
||||||
background: "#1e1e1e",
|
|
||||||
color: "#ccc",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "2px",
|
|
||||||
marginTop: "4px"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
|
||||||
type: "ContrastNode",
|
|
||||||
label: "Adjust Contrast",
|
|
||||||
description: "Modify contrast of base64 image using a contrast multiplier.",
|
|
||||||
content: "Adjusts contrast of image using canvas pixel transform.",
|
|
||||||
component: ContrastNode
|
|
||||||
};
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_BW_Threshold.jsx
|
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
|
||||||
import { Handle, Position, useStore } from "reactflow";
|
|
||||||
|
|
||||||
// Ensure BorealisValueBus exists
|
|
||||||
if (!window.BorealisValueBus) {
|
|
||||||
window.BorealisValueBus = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!window.BorealisUpdateRate) {
|
|
||||||
window.BorealisUpdateRate = 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BWThresholdNode = ({ id, data }) => {
|
|
||||||
const edges = useStore((state) => state.edges);
|
|
||||||
|
|
||||||
// Attempt to parse threshold from data.value (if present),
|
|
||||||
// otherwise default to 128.
|
|
||||||
const initial = parseInt(data?.value, 10);
|
|
||||||
const [threshold, setThreshold] = useState(
|
|
||||||
isNaN(initial) ? 128 : initial
|
|
||||||
);
|
|
||||||
|
|
||||||
const [renderValue, setRenderValue] = useState("");
|
|
||||||
const valueRef = useRef("");
|
|
||||||
const lastUpstreamRef = useRef("");
|
|
||||||
|
|
||||||
// If the node is reimported and data.value changes externally,
|
|
||||||
// update the threshold accordingly.
|
|
||||||
useEffect(() => {
|
|
||||||
const newVal = parseInt(data?.value, 10);
|
|
||||||
if (!isNaN(newVal)) {
|
|
||||||
setThreshold(newVal);
|
|
||||||
}
|
|
||||||
}, [data?.value]);
|
|
||||||
|
|
||||||
const handleThresholdInput = (e) => {
|
|
||||||
let val = parseInt(e.target.value, 10);
|
|
||||||
if (isNaN(val)) {
|
|
||||||
val = 128;
|
|
||||||
}
|
|
||||||
val = Math.max(0, Math.min(255, val));
|
|
||||||
|
|
||||||
// Keep the Node's data.value updated
|
|
||||||
data.value = val;
|
|
||||||
|
|
||||||
setThreshold(val);
|
|
||||||
window.BorealisValueBus[id] = val;
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyThreshold = async (base64Data, cutoff) => {
|
|
||||||
if (!base64Data || typeof base64Data !== "string") {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.crossOrigin = "anonymous";
|
|
||||||
|
|
||||||
img.onload = () => {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = img.width;
|
|
||||||
canvas.height = img.height;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
ctx.drawImage(img, 0, 0);
|
|
||||||
|
|
||||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
||||||
const dataArr = imageData.data;
|
|
||||||
|
|
||||||
for (let i = 0; i < dataArr.length; i += 4) {
|
|
||||||
const avg = (dataArr[i] + dataArr[i + 1] + dataArr[i + 2]) / 3;
|
|
||||||
const color = avg < cutoff ? 0 : 255;
|
|
||||||
dataArr[i] = color;
|
|
||||||
dataArr[i + 1] = color;
|
|
||||||
dataArr[i + 2] = color;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.putImageData(imageData, 0, 0);
|
|
||||||
resolve(canvas.toDataURL("image/png").replace(/^data:image\/png;base64,/, ""));
|
|
||||||
};
|
|
||||||
|
|
||||||
img.onerror = () => resolve(base64Data);
|
|
||||||
img.src = "data:image/png;base64," + base64Data;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Main polling logic
|
|
||||||
useEffect(() => {
|
|
||||||
let currentRate = window.BorealisUpdateRate;
|
|
||||||
let intervalId = null;
|
|
||||||
|
|
||||||
const runNodeLogic = async () => {
|
|
||||||
const inputEdge = edges.find(e => e.target === id);
|
|
||||||
if (inputEdge?.source) {
|
|
||||||
const upstreamValue = window.BorealisValueBus[inputEdge.source] ?? "";
|
|
||||||
|
|
||||||
if (upstreamValue !== lastUpstreamRef.current) {
|
|
||||||
const transformed = await applyThreshold(upstreamValue, threshold);
|
|
||||||
lastUpstreamRef.current = upstreamValue;
|
|
||||||
valueRef.current = transformed;
|
|
||||||
setRenderValue(transformed);
|
|
||||||
window.BorealisValueBus[id] = transformed;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
lastUpstreamRef.current = "";
|
|
||||||
valueRef.current = "";
|
|
||||||
setRenderValue("");
|
|
||||||
window.BorealisValueBus[id] = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
intervalId = setInterval(runNodeLogic, currentRate);
|
|
||||||
|
|
||||||
const monitor = setInterval(() => {
|
|
||||||
const newRate = window.BorealisUpdateRate;
|
|
||||||
if (newRate !== currentRate) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
currentRate = newRate;
|
|
||||||
intervalId = setInterval(runNodeLogic, currentRate);
|
|
||||||
}
|
|
||||||
}, 250);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
clearInterval(monitor);
|
|
||||||
};
|
|
||||||
}, [id, edges, threshold]);
|
|
||||||
|
|
||||||
// Reapply when threshold changes (even if image didn't)
|
|
||||||
useEffect(() => {
|
|
||||||
const inputEdge = edges.find(e => e.target === id);
|
|
||||||
if (!inputEdge?.source) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const upstreamValue = window.BorealisValueBus[inputEdge.source] ?? "";
|
|
||||||
if (!upstreamValue) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
applyThreshold(upstreamValue, threshold).then((result) => {
|
|
||||||
valueRef.current = result;
|
|
||||||
setRenderValue(result);
|
|
||||||
window.BorealisValueBus[id] = result;
|
|
||||||
});
|
|
||||||
}, [threshold, edges, id]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="borealis-node">
|
|
||||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
|
||||||
|
|
||||||
<div className="borealis-node-header">BW Threshold</div>
|
|
||||||
|
|
||||||
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
|
|
||||||
<div style={{ marginBottom: "6px", color: "#ccc" }}>
|
|
||||||
Threshold Strength (0–255):
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="255"
|
|
||||||
value={threshold}
|
|
||||||
onChange={handleThresholdInput}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
fontSize: "9px",
|
|
||||||
padding: "4px",
|
|
||||||
background: "#1e1e1e",
|
|
||||||
color: "#ccc",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "2px",
|
|
||||||
marginBottom: "6px"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
|
||||||
type: "BWThresholdNode",
|
|
||||||
label: "BW Threshold",
|
|
||||||
description: `
|
|
||||||
Black & White Threshold (Stateless)
|
|
||||||
|
|
||||||
- Converts a base64 image to black & white using a user-defined threshold value
|
|
||||||
- Reapplies threshold when the number changes, even if image stays the same
|
|
||||||
- Outputs a new base64 PNG with BW transformation
|
|
||||||
`.trim(),
|
|
||||||
content: "Applies black & white threshold to base64 image input.",
|
|
||||||
component: BWThresholdNode
|
|
||||||
};
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_Convert_to_Grayscale.jsx
|
|
||||||
|
|
||||||
import React, { useEffect, useState, useRef } from "react";
|
|
||||||
import { Handle, Position, useStore } from "reactflow";
|
|
||||||
|
|
||||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
|
||||||
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
|
|
||||||
|
|
||||||
const GrayscaleNode = ({ id }) => {
|
|
||||||
const edges = useStore((state) => state.edges);
|
|
||||||
const [grayscaleLevel, setGrayscaleLevel] = useState(100); // percentage (0–100)
|
|
||||||
const [renderValue, setRenderValue] = useState("");
|
|
||||||
const valueRef = useRef("");
|
|
||||||
|
|
||||||
const applyGrayscale = (base64Data, level) => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.crossOrigin = "anonymous";
|
|
||||||
|
|
||||||
img.onload = () => {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = img.width;
|
|
||||||
canvas.height = img.height;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
ctx.drawImage(img, 0, 0);
|
|
||||||
|
|
||||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
||||||
const data = imageData.data;
|
|
||||||
const alpha = level / 100;
|
|
||||||
|
|
||||||
for (let i = 0; i < data.length; i += 4) {
|
|
||||||
const r = data[i];
|
|
||||||
const g = data[i + 1];
|
|
||||||
const b = data[i + 2];
|
|
||||||
const avg = (r + g + b) / 3;
|
|
||||||
|
|
||||||
data[i] = r * (1 - alpha) + avg * alpha;
|
|
||||||
data[i + 1] = g * (1 - alpha) + avg * alpha;
|
|
||||||
data[i + 2] = b * (1 - alpha) + avg * alpha;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.putImageData(imageData, 0, 0);
|
|
||||||
resolve(canvas.toDataURL("image/png").replace(/^data:image\/png;base64,/, ""));
|
|
||||||
};
|
|
||||||
|
|
||||||
img.onerror = () => resolve(base64Data);
|
|
||||||
img.src = `data:image/png;base64,${base64Data}`;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const inputEdge = edges.find(e => e.target === id);
|
|
||||||
if (!inputEdge?.source) return;
|
|
||||||
|
|
||||||
const input = window.BorealisValueBus[inputEdge.source] ?? "";
|
|
||||||
if (!input) return;
|
|
||||||
|
|
||||||
applyGrayscale(input, grayscaleLevel).then((output) => {
|
|
||||||
valueRef.current = input;
|
|
||||||
setRenderValue(output);
|
|
||||||
window.BorealisValueBus[id] = output;
|
|
||||||
});
|
|
||||||
}, [grayscaleLevel, edges, id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let interval = null;
|
|
||||||
|
|
||||||
const run = async () => {
|
|
||||||
const edge = edges.find(e => e.target === id);
|
|
||||||
const input = edge ? window.BorealisValueBus[edge.source] : "";
|
|
||||||
|
|
||||||
if (input && input !== valueRef.current) {
|
|
||||||
const result = await applyGrayscale(input, grayscaleLevel);
|
|
||||||
valueRef.current = input;
|
|
||||||
setRenderValue(result);
|
|
||||||
window.BorealisValueBus[id] = result;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
interval = setInterval(run, window.BorealisUpdateRate);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [id, edges, grayscaleLevel]);
|
|
||||||
|
|
||||||
const handleLevelChange = (e) => {
|
|
||||||
let val = parseInt(e.target.value, 10);
|
|
||||||
if (isNaN(val)) val = 100;
|
|
||||||
val = Math.min(100, Math.max(0, val));
|
|
||||||
setGrayscaleLevel(val);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="borealis-node">
|
|
||||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
|
||||||
<div className="borealis-node-header">Convert to Grayscale</div>
|
|
||||||
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
|
|
||||||
<label style={{ display: "block", marginBottom: "4px" }}>
|
|
||||||
Grayscale Intensity (0–100):
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
step="1"
|
|
||||||
value={grayscaleLevel}
|
|
||||||
onChange={handleLevelChange}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
fontSize: "9px",
|
|
||||||
padding: "4px",
|
|
||||||
background: "#1e1e1e",
|
|
||||||
color: "#ccc",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "2px"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
|
||||||
type: "GrayscaleNode",
|
|
||||||
label: "Convert to Grayscale",
|
|
||||||
description: `
|
|
||||||
Adjustable Grayscale Conversion
|
|
||||||
|
|
||||||
- Accepts base64 image input
|
|
||||||
- Applies grayscale effect using a % level
|
|
||||||
- 0% = no change, 100% = full grayscale
|
|
||||||
- Outputs result downstream as base64
|
|
||||||
`.trim(),
|
|
||||||
content: "Convert image to grayscale with adjustable intensity.",
|
|
||||||
component: GrayscaleNode
|
|
||||||
};
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_Export_Image.jsx
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { Handle, Position, useReactFlow } from "reactflow";
|
|
||||||
|
|
||||||
const ExportImageNode = ({ id }) => {
|
|
||||||
const { getEdges } = useReactFlow();
|
|
||||||
const [imageData, setImageData] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
const edges = getEdges();
|
|
||||||
const inputEdge = edges.find(e => e.target === id);
|
|
||||||
if (inputEdge) {
|
|
||||||
const base64 = window.BorealisValueBus?.[inputEdge.source];
|
|
||||||
if (typeof base64 === "string") {
|
|
||||||
setImageData(base64);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [id, getEdges]);
|
|
||||||
|
|
||||||
const handleDownload = async () => {
|
|
||||||
const blob = await (async () => {
|
|
||||||
const res = await fetch(`data:image/png;base64,${imageData}`);
|
|
||||||
return await res.blob();
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (window.showSaveFilePicker) {
|
|
||||||
try {
|
|
||||||
const fileHandle = await window.showSaveFilePicker({
|
|
||||||
suggestedName: "image.png",
|
|
||||||
types: [{
|
|
||||||
description: "PNG Image",
|
|
||||||
accept: { "image/png": [".png"] }
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
const writable = await fileHandle.createWritable();
|
|
||||||
await writable.write(blob);
|
|
||||||
await writable.close();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Save cancelled:", e);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = URL.createObjectURL(blob);
|
|
||||||
a.download = "image.png";
|
|
||||||
a.style.display = "none";
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(a.href);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="borealis-node">
|
|
||||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
|
||||||
<div className="borealis-node-header">Export Image</div>
|
|
||||||
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
|
|
||||||
Export upstream base64-encoded image data as a PNG on-disk.
|
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
marginTop: "6px",
|
|
||||||
width: "100%",
|
|
||||||
fontSize: "9px",
|
|
||||||
padding: "4px",
|
|
||||||
background: "#1e1e1e",
|
|
||||||
color: "#ccc",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "2px"
|
|
||||||
}}
|
|
||||||
onClick={handleDownload}
|
|
||||||
disabled={!imageData}
|
|
||||||
>
|
|
||||||
Download PNG
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
|
||||||
type: "ExportImageNode",
|
|
||||||
label: "Export Image",
|
|
||||||
description: "Lets the user download the base64 PNG image to disk.",
|
|
||||||
content: "Save base64 PNG to disk as a file.",
|
|
||||||
component: ExportImageNode
|
|
||||||
};
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_Image_Viewer.jsx
|
|
||||||
|
|
||||||
import React, { useEffect, useState, useRef } from "react";
|
|
||||||
import { Handle, Position, useReactFlow } from "reactflow";
|
|
||||||
|
|
||||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
|
||||||
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
|
|
||||||
|
|
||||||
const ImageViewerNode = ({ id }) => {
|
|
||||||
const { getEdges } = useReactFlow();
|
|
||||||
const [imageBase64, setImageBase64] = useState("");
|
|
||||||
const canvasRef = useRef(null);
|
|
||||||
const overlayDivRef = useRef(null);
|
|
||||||
const [isZoomed, setIsZoomed] = useState(false);
|
|
||||||
|
|
||||||
// Poll upstream for base64 image and propagate
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
const edges = getEdges();
|
|
||||||
const inp = edges.find(e => e.target === id);
|
|
||||||
if (inp) {
|
|
||||||
const val = window.BorealisValueBus[inp.source] || "";
|
|
||||||
setImageBase64(val);
|
|
||||||
window.BorealisValueBus[id] = val;
|
|
||||||
} else {
|
|
||||||
setImageBase64("");
|
|
||||||
window.BorealisValueBus[id] = "";
|
|
||||||
}
|
|
||||||
}, window.BorealisUpdateRate);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [getEdges, id]);
|
|
||||||
|
|
||||||
// Draw the image into canvas for high performance
|
|
||||||
useEffect(() => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas) return;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
if (imageBase64) {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => {
|
|
||||||
canvas.width = img.width;
|
|
||||||
canvas.height = img.height;
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
ctx.drawImage(img, 0, 0);
|
|
||||||
};
|
|
||||||
img.src = "data:image/png;base64," + imageBase64;
|
|
||||||
} else {
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
}
|
|
||||||
}, [imageBase64]);
|
|
||||||
|
|
||||||
// Manage zoom overlay on image click
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isZoomed || !imageBase64) return;
|
|
||||||
const div = document.createElement("div");
|
|
||||||
overlayDivRef.current = div;
|
|
||||||
Object.assign(div.style, {
|
|
||||||
position: "fixed",
|
|
||||||
top: "0",
|
|
||||||
left: "0",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
backgroundColor: "rgba(0,0,0,0.8)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
zIndex: "1000",
|
|
||||||
cursor: "zoom-out",
|
|
||||||
transition: "opacity 0.3s ease"
|
|
||||||
});
|
|
||||||
const handleOverlayClick = () => setIsZoomed(false);
|
|
||||||
div.addEventListener("click", handleOverlayClick);
|
|
||||||
|
|
||||||
const ovCanvas = document.createElement("canvas");
|
|
||||||
const ctx = ovCanvas.getContext("2d");
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => {
|
|
||||||
let w = img.width;
|
|
||||||
let h = img.height;
|
|
||||||
const maxW = window.innerWidth * 0.8;
|
|
||||||
const maxH = window.innerHeight * 0.8;
|
|
||||||
const scale = Math.min(1, maxW / w, maxH / h);
|
|
||||||
ovCanvas.width = w;
|
|
||||||
ovCanvas.height = h;
|
|
||||||
ctx.clearRect(0, 0, w, h);
|
|
||||||
ctx.drawImage(img, 0, 0);
|
|
||||||
ovCanvas.style.width = `${w * scale}px`;
|
|
||||||
ovCanvas.style.height = `${h * scale}px`;
|
|
||||||
ovCanvas.style.transition = "transform 0.3s ease";
|
|
||||||
};
|
|
||||||
img.src = "data:image/png;base64," + imageBase64;
|
|
||||||
div.appendChild(ovCanvas);
|
|
||||||
document.body.appendChild(div);
|
|
||||||
|
|
||||||
// Cleanup when unzooming
|
|
||||||
return () => {
|
|
||||||
div.removeEventListener("click", handleOverlayClick);
|
|
||||||
if (overlayDivRef.current) {
|
|
||||||
document.body.removeChild(overlayDivRef.current);
|
|
||||||
overlayDivRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [isZoomed, imageBase64]);
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
if (imageBase64) setIsZoomed(z => !z);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="borealis-node">
|
|
||||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
|
||||||
<div className="borealis-node-header">Image Viewer</div>
|
|
||||||
<div
|
|
||||||
className="borealis-node-content"
|
|
||||||
style={{ cursor: imageBase64 ? "zoom-in" : "default" }}
|
|
||||||
>
|
|
||||||
{imageBase64 ? (
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
onClick={handleClick}
|
|
||||||
style={{ width: "100%", border: "1px solid #333", marginTop: "6px", marginBottom: "6px" }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div style={{ fontSize: "9px", color: "#888", marginTop: "6px" }}>
|
|
||||||
Waiting for image...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
|
||||||
type: "Image_Viewer",
|
|
||||||
label: "Image Viewer",
|
|
||||||
description: `
|
|
||||||
Displays base64 image via canvas for high performance
|
|
||||||
|
|
||||||
- Accepts upstream base64 image
|
|
||||||
- Renders with canvas for speed
|
|
||||||
- Click to zoom/unzoom overlay with smooth transition
|
|
||||||
`.trim(),
|
|
||||||
content: "Visual preview of base64 image with zoom overlay.",
|
|
||||||
component: ImageViewerNode
|
|
||||||
};
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_Upload_Image.jsx
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ==================================================
|
|
||||||
* Borealis - Image Upload Node (Raw Base64 Output)
|
|
||||||
* ==================================================
|
|
||||||
*
|
|
||||||
* COMPONENT ROLE:
|
|
||||||
* This node lets the user upload an image file (JPG/JPEG/PNG),
|
|
||||||
* reads it as a data URL, then strips off the "data:image/*;base64,"
|
|
||||||
* prefix, storing only the raw base64 data in BorealisValueBus.
|
|
||||||
*
|
|
||||||
* IMPORTANT:
|
|
||||||
* - No upstream connector (target handle) is provided.
|
|
||||||
* - The raw base64 is pushed out to downstream nodes via source handle.
|
|
||||||
* - Your viewer (or other downstream node) must prepend "data:image/png;base64,"
|
|
||||||
* or the appropriate MIME string for display.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
|
||||||
import { Handle, Position, useReactFlow } from "reactflow";
|
|
||||||
|
|
||||||
// Global Shared Bus for Node Data Propagation
|
|
||||||
if (!window.BorealisValueBus) {
|
|
||||||
window.BorealisValueBus = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global Update Rate (ms) for All Data Nodes
|
|
||||||
if (!window.BorealisUpdateRate) {
|
|
||||||
window.BorealisUpdateRate = 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ImageUploadNode = ({ id, data }) => {
|
|
||||||
const { setNodes } = useReactFlow();
|
|
||||||
|
|
||||||
const [renderValue, setRenderValue] = useState(data?.value || "");
|
|
||||||
const valueRef = useRef(renderValue);
|
|
||||||
|
|
||||||
// Handler for file uploads
|
|
||||||
const handleFileUpload = (event) => {
|
|
||||||
console.log("handleFileUpload triggered for node:", id);
|
|
||||||
|
|
||||||
// Get the file list
|
|
||||||
const files = event.target.files || event.currentTarget.files;
|
|
||||||
if (!files || files.length === 0) {
|
|
||||||
console.log("No files selected or files array is empty");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = files[0];
|
|
||||||
if (!file) {
|
|
||||||
console.log("File object not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debugging info
|
|
||||||
console.log("Selected file:", file.name, file.type, file.size);
|
|
||||||
|
|
||||||
// Validate file type
|
|
||||||
const validTypes = ["image/jpeg", "image/png"];
|
|
||||||
if (!validTypes.includes(file.type)) {
|
|
||||||
console.warn("Unsupported file type in node:", id, file.type);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup FileReader
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (loadEvent) => {
|
|
||||||
console.log("FileReader onload in node:", id);
|
|
||||||
const base64DataUrl = loadEvent?.target?.result || "";
|
|
||||||
|
|
||||||
// Strip off the data:image/...;base64, prefix to store raw base64
|
|
||||||
const rawBase64 = base64DataUrl.replace(/^data:image\/[a-zA-Z]+;base64,/, "");
|
|
||||||
console.log("Raw Base64 (truncated):", rawBase64.substring(0, 50));
|
|
||||||
|
|
||||||
valueRef.current = rawBase64;
|
|
||||||
setRenderValue(rawBase64);
|
|
||||||
window.BorealisValueBus[id] = rawBase64;
|
|
||||||
|
|
||||||
// Update node data
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id
|
|
||||||
? { ...n, data: { ...n.data, value: rawBase64 } }
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
reader.onerror = (errorEvent) => {
|
|
||||||
console.error("FileReader error in node:", id, errorEvent);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Read the file as a data URL
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Poll-based output (no upstream)
|
|
||||||
useEffect(() => {
|
|
||||||
let currentRate = window.BorealisUpdateRate || 100;
|
|
||||||
let intervalId = null;
|
|
||||||
|
|
||||||
const runNodeLogic = () => {
|
|
||||||
// Simply emit current value (raw base64) to the bus
|
|
||||||
window.BorealisValueBus[id] = valueRef.current;
|
|
||||||
};
|
|
||||||
|
|
||||||
const startInterval = () => {
|
|
||||||
intervalId = setInterval(runNodeLogic, currentRate);
|
|
||||||
};
|
|
||||||
|
|
||||||
startInterval();
|
|
||||||
|
|
||||||
// Monitor for global update rate changes
|
|
||||||
const monitor = setInterval(() => {
|
|
||||||
const newRate = window.BorealisUpdateRate || 100;
|
|
||||||
if (newRate !== currentRate) {
|
|
||||||
currentRate = newRate;
|
|
||||||
clearInterval(intervalId);
|
|
||||||
startInterval();
|
|
||||||
}
|
|
||||||
}, 250);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
clearInterval(monitor);
|
|
||||||
};
|
|
||||||
}, [id, setNodes]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="borealis-node" style={{ minWidth: "160px" }}>
|
|
||||||
{/* No target handle because we don't accept upstream data */}
|
|
||||||
<div className="borealis-node-header">
|
|
||||||
{data?.label || "Raw Base64 Image Upload"}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="borealis-node-content">
|
|
||||||
<div style={{ marginBottom: "8px", fontSize: "9px", color: "#ccc" }}>
|
|
||||||
{data?.content || "Upload a JPG/PNG, store only the raw base64 in ValueBus."}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label style={{ fontSize: "9px", display: "block", marginBottom: "4px" }}>
|
|
||||||
Upload Image File
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".jpg,.jpeg,.png"
|
|
||||||
onChange={handleFileUpload}
|
|
||||||
style={{
|
|
||||||
fontSize: "9px",
|
|
||||||
padding: "4px",
|
|
||||||
background: "#1e1e1e",
|
|
||||||
color: "#ccc",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "2px",
|
|
||||||
width: "100%",
|
|
||||||
marginBottom: "8px"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
|
||||||
type: "ImageUploadNode_RawBase64", // Unique ID for the node type
|
|
||||||
label: "Upload Image",
|
|
||||||
description: `
|
|
||||||
A node to upload an image (JPG/PNG) and store it in base64 format for later use downstream.
|
|
||||||
`.trim(),
|
|
||||||
content: "Upload an image, output only the raw base64 string.",
|
|
||||||
component: ImageUploadNode
|
|
||||||
};
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Organization/Node_Backdrop_Group_Box.jsx
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ===========================================
|
|
||||||
* Borealis - Backdrop Group Box Node
|
|
||||||
* ===========================================
|
|
||||||
*
|
|
||||||
* COMPONENT ROLE:
|
|
||||||
* This node functions as a backdrop or grouping box.
|
|
||||||
* It's resizable and can be renamed by clicking its title.
|
|
||||||
* It doesn't connect to other nodes or pass data<74>it's purely visual.
|
|
||||||
*
|
|
||||||
* BEHAVIOR:
|
|
||||||
* - Allows renaming via single-click on the header text.
|
|
||||||
* - Can be resized by dragging from the bottom-right corner.
|
|
||||||
*
|
|
||||||
* NOTE:
|
|
||||||
* - No inputs/outputs: purely cosmetic for grouping and labeling.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
|
||||||
import { Handle, Position } from "reactflow";
|
|
||||||
import { ResizableBox } from "react-resizable";
|
|
||||||
import "react-resizable/css/styles.css";
|
|
||||||
|
|
||||||
const BackdropGroupBoxNode = ({ id, data }) => {
|
|
||||||
const [title, setTitle] = useState(data?.label || "Backdrop Group Box");
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const inputRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isEditing && inputRef.current) {
|
|
||||||
inputRef.current.focus();
|
|
||||||
}
|
|
||||||
}, [isEditing]);
|
|
||||||
|
|
||||||
const handleTitleClick = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsEditing(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTitleChange = (e) => {
|
|
||||||
const newTitle = e.target.value;
|
|
||||||
setTitle(newTitle);
|
|
||||||
window.BorealisValueBus[id] = newTitle;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBlur = () => {
|
|
||||||
setIsEditing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ pointerEvents: "auto" }}>
|
|
||||||
<ResizableBox
|
|
||||||
width={200}
|
|
||||||
height={120}
|
|
||||||
minConstraints={[120, 80]}
|
|
||||||
maxConstraints={[600, 600]}
|
|
||||||
resizeHandles={["se"]}
|
|
||||||
className="borealis-node"
|
|
||||||
handle={(h) => (
|
|
||||||
<span
|
|
||||||
className={`react-resizable-handle react-resizable-handle-${h}`}
|
|
||||||
style={{ pointerEvents: "auto" }}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
backgroundColor: "rgba(44, 44, 44, 0.5)",
|
|
||||||
border: "1px solid #3a3a3a",
|
|
||||||
borderRadius: "4px",
|
|
||||||
boxShadow: "0 0 5px rgba(88, 166, 255, 0.15)",
|
|
||||||
overflow: "hidden",
|
|
||||||
position: "relative",
|
|
||||||
zIndex: 0
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={handleTitleClick}
|
|
||||||
style={{
|
|
||||||
backgroundColor: "rgba(35, 35, 35, 0.5)",
|
|
||||||
padding: "6px 10px",
|
|
||||||
fontWeight: "bold",
|
|
||||||
fontSize: "10px",
|
|
||||||
cursor: "pointer",
|
|
||||||
userSelect: "none"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isEditing ? (
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={title}
|
|
||||||
onChange={handleTitleChange}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
fontSize: "10px",
|
|
||||||
padding: "2px",
|
|
||||||
background: "#1e1e1e",
|
|
||||||
color: "#ccc",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "2px",
|
|
||||||
width: "100%"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span>{title}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: "10px", fontSize: "9px", height: "100%" }}>
|
|
||||||
{/* Empty space for grouping */}
|
|
||||||
</div>
|
|
||||||
</ResizableBox>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
|
||||||
type: "BackdropGroupBoxNode",
|
|
||||||
label: "Backdrop Group Box",
|
|
||||||
description: `
|
|
||||||
Resizable Grouping Node
|
|
||||||
|
|
||||||
- Purely cosmetic, for grouping related nodes
|
|
||||||
- Resizable by dragging bottom-right corner
|
|
||||||
- Rename by clicking on title bar
|
|
||||||
`.trim(),
|
|
||||||
content: "Use as a visual group label",
|
|
||||||
component: BackdropGroupBoxNode
|
|
||||||
};
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Reporting/Node_Export_to_CSV.jsx
|
|
||||||
|
|
||||||
import React, { useRef, useState } from "react";
|
|
||||||
import { Handle, Position } from "reactflow";
|
|
||||||
import { Button, Snackbar } from "@mui/material";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ExportToCSVNode
|
|
||||||
* ----------------
|
|
||||||
* Simplified version:
|
|
||||||
* - No output connector
|
|
||||||
* - Removed "Export to Disk" checkbox
|
|
||||||
* - Only function is export to disk (manual trigger)
|
|
||||||
*/
|
|
||||||
const ExportToCSVNode = ({ data }) => {
|
|
||||||
const [exportPath, setExportPath] = useState("");
|
|
||||||
const [appendMode, setAppendMode] = useState(false);
|
|
||||||
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
|
||||||
const fileInputRef = useRef(null);
|
|
||||||
|
|
||||||
const handleExportClick = () => setSnackbarOpen(true);
|
|
||||||
const handleSnackbarClose = () => setSnackbarOpen(false);
|
|
||||||
|
|
||||||
const handlePathClick = async () => {
|
|
||||||
if (window.showDirectoryPicker) {
|
|
||||||
try {
|
|
||||||
const dirHandle = await window.showDirectoryPicker();
|
|
||||||
setExportPath(dirHandle.name || "Selected Directory");
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Directory Selection Cancelled:", err);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFakePicker = (event) => {
|
|
||||||
const files = event.target.files;
|
|
||||||
if (files.length > 0) {
|
|
||||||
const fakePath = files[0].webkitRelativePath?.split("/")[0];
|
|
||||||
setExportPath(fakePath || "Selected Folder");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="borealis-node">
|
|
||||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
|
||||||
|
|
||||||
<div className="borealis-node-header">
|
|
||||||
{data.label}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="borealis-node-content">
|
|
||||||
<div style={{ marginBottom: "8px" }}>
|
|
||||||
{data.content}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label style={{ fontSize: "9px", display: "block", marginTop: "6px" }}>
|
|
||||||
Export Path:
|
|
||||||
</label>
|
|
||||||
<div style={{ display: "flex", gap: "4px", alignItems: "center", marginBottom: "6px" }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
readOnly
|
|
||||||
value={exportPath}
|
|
||||||
placeholder="Click to Select Folder"
|
|
||||||
onClick={handlePathClick}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
fontSize: "9px",
|
|
||||||
padding: "3px",
|
|
||||||
background: "#1e1e1e",
|
|
||||||
color: "#ccc",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "2px",
|
|
||||||
cursor: "pointer"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
onClick={handleExportClick}
|
|
||||||
sx={{
|
|
||||||
fontSize: "9px",
|
|
||||||
padding: "2px 8px",
|
|
||||||
minWidth: "unset",
|
|
||||||
borderColor: "#58a6ff",
|
|
||||||
color: "#58a6ff"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Export
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label style={{ fontSize: "9px", display: "block", marginTop: "4px" }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={appendMode}
|
|
||||||
onChange={(e) => setAppendMode(e.target.checked)}
|
|
||||||
style={{ marginRight: "4px" }}
|
|
||||||
/>
|
|
||||||
Append CSV Data if Headers Match
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
webkitdirectory="true"
|
|
||||||
directory=""
|
|
||||||
multiple
|
|
||||||
style={{ display: "none" }}
|
|
||||||
onChange={handleFakePicker}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Snackbar
|
|
||||||
open={snackbarOpen}
|
|
||||||
autoHideDuration={1000}
|
|
||||||
onClose={handleSnackbarClose}
|
|
||||||
message="Feature Coming Soon..."
|
|
||||||
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
|
||||||
type: "ExportToCSVNode",
|
|
||||||
label: "Export to CSV",
|
|
||||||
description: `
|
|
||||||
Reporting Node
|
|
||||||
This node lets the user choose a folder to export CSV data to disk.
|
|
||||||
|
|
||||||
When the "Export" button is clicked, CSV content (from upstream logic) is intended to be saved
|
|
||||||
to the selected directory. This is a placeholder for future file system interaction.
|
|
||||||
|
|
||||||
Inputs:
|
|
||||||
- Structured Table Data (via upstream node)
|
|
||||||
|
|
||||||
Outputs:
|
|
||||||
- None (writes directly to disk in future)
|
|
||||||
`.trim(),
|
|
||||||
content: "Export Input Data to CSV File",
|
|
||||||
component: ExportToCSVNode
|
|
||||||
};
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Templates/Node_Template.jsx
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ==================================================
|
|
||||||
* Borealis - Node Template (Golden Template)
|
|
||||||
* ==================================================
|
|
||||||
*
|
|
||||||
* COMPONENT ROLE:
|
|
||||||
* Serves as a comprehensive template for creating new
|
|
||||||
* Borealis nodes. This file includes detailed commentary
|
|
||||||
* for human developers and AI systems, explaining every
|
|
||||||
* aspect of a node's structure, state management, data flow,
|
|
||||||
* and integration with the shared memory bus.
|
|
||||||
*
|
|
||||||
* METADATA:
|
|
||||||
* - type: unique identifier for the node type (Data entry)
|
|
||||||
* - label: display name in Borealis UI
|
|
||||||
* - description: explanatory tooltip shown to users
|
|
||||||
* - content: short summary of node functionality
|
|
||||||
*
|
|
||||||
* USAGE:
|
|
||||||
* Copy and rename this file when building a new node.
|
|
||||||
* Update the metadata and customize logic inside runNodeLogic().
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect, useState, useRef } from "react";
|
|
||||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TemplateNode Component
|
|
||||||
* ----------------------
|
|
||||||
* A single-input, single-output node that propagates a string value.
|
|
||||||
*
|
|
||||||
* @param {Object} props
|
|
||||||
* @param {string} props.id - Unique node identifier in the flow
|
|
||||||
* @param {Object} props.data - Node-specific data and settings
|
|
||||||
*/
|
|
||||||
const TemplateNode = ({ id, data }) => {
|
|
||||||
const { setNodes } = useReactFlow();
|
|
||||||
const edges = useStore((state) => state.edges);
|
|
||||||
|
|
||||||
// Local state holds the current string value shown in the textbox
|
|
||||||
// AI Note: valueRef.current tracks the last emitted value to prevent redundant bus writes
|
|
||||||
const [renderValue, setRenderValue] = useState(data?.value || "/Data/Server/WebUI/src/Nodes/Templates/Node_Template.jsx");
|
|
||||||
const valueRef = useRef(renderValue);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* handleManualInput
|
|
||||||
* -----------------
|
|
||||||
* Called when the user types into the textbox (only when no input edge).
|
|
||||||
* Writes the new value to the shared bus and updates node state.
|
|
||||||
*/
|
|
||||||
const handleManualInput = (e) => {
|
|
||||||
const newValue = e.target.value;
|
|
||||||
|
|
||||||
// Update local ref and component state
|
|
||||||
valueRef.current = newValue;
|
|
||||||
setRenderValue(newValue);
|
|
||||||
|
|
||||||
// Broadcast value on the shared bus
|
|
||||||
window.BorealisValueBus[id] = newValue;
|
|
||||||
|
|
||||||
// Persist the new value in node.data for workflow serialization
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id
|
|
||||||
? { ...n, data: { ...n.data, value: newValue } }
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Polling effect: runNodeLogic
|
|
||||||
* ----------------------------
|
|
||||||
* On mount, start an interval that:
|
|
||||||
* - Checks for an upstream connection
|
|
||||||
* - If connected, reads from bus and updates state/bus
|
|
||||||
* - If not, broadcasts manual input value
|
|
||||||
* - Monitors for global rate changes and reconfigures
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
let currentRate = window.BorealisUpdateRate;
|
|
||||||
let intervalId;
|
|
||||||
|
|
||||||
const runNodeLogic = () => {
|
|
||||||
// Detect if a source edge is connected to this node's input
|
|
||||||
const inputEdge = edges.find((e) => e.target === id);
|
|
||||||
const hasInput = Boolean(inputEdge && inputEdge.source);
|
|
||||||
|
|
||||||
if (hasInput) {
|
|
||||||
// Read upstream value
|
|
||||||
const upstreamValue = window.BorealisValueBus[inputEdge.source] || "";
|
|
||||||
|
|
||||||
// Only update if value changed
|
|
||||||
if (upstreamValue !== valueRef.current) {
|
|
||||||
valueRef.current = upstreamValue;
|
|
||||||
setRenderValue(upstreamValue);
|
|
||||||
window.BorealisValueBus[id] = upstreamValue;
|
|
||||||
|
|
||||||
// Persist to node.data for serialization
|
|
||||||
setNodes((nds) =>
|
|
||||||
nds.map((n) =>
|
|
||||||
n.id === id
|
|
||||||
? { ...n, data: { ...n.data, value: upstreamValue } }
|
|
||||||
: n
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No upstream: broadcast manual input value
|
|
||||||
window.BorealisValueBus[id] = valueRef.current;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Start polling
|
|
||||||
intervalId = setInterval(runNodeLogic, currentRate);
|
|
||||||
|
|
||||||
// Watch for global rate changes
|
|
||||||
const monitor = setInterval(() => {
|
|
||||||
const newRate = window.BorealisUpdateRate;
|
|
||||||
if (newRate !== currentRate) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
currentRate = newRate;
|
|
||||||
intervalId = setInterval(runNodeLogic, currentRate);
|
|
||||||
}
|
|
||||||
}, 250);
|
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
return () => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
clearInterval(monitor);
|
|
||||||
};
|
|
||||||
}, [id, edges, setNodes]);
|
|
||||||
|
|
||||||
// Determine connection status for UI control disabling
|
|
||||||
const inputEdge = edges.find((e) => e.target === id);
|
|
||||||
const hasInput = Boolean(inputEdge && inputEdge.source);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="borealis-node">
|
|
||||||
{/* Input connector on left */}
|
|
||||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
|
||||||
|
|
||||||
{/* Header: displays node title */}
|
|
||||||
<div className="borealis-node-header">
|
|
||||||
{data?.label || "Node Template"}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content area: description and input control */}
|
|
||||||
<div className="borealis-node-content">
|
|
||||||
{/* Description: guideline for human users */}
|
|
||||||
<div style={{ marginBottom: "8px", fontSize: "9px", color: "#ccc" }}>
|
|
||||||
{data?.content || "Template acting as a design scaffold for designing nodes for Borealis."}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Label for the textbox */}
|
|
||||||
<label style={{ fontSize: "9px", display: "block", marginBottom: "4px" }}>
|
|
||||||
Template Location:
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Textbox: disabled if upstream data present */}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={renderValue}
|
|
||||||
onChange={handleManualInput}
|
|
||||||
disabled={hasInput}
|
|
||||||
style={{
|
|
||||||
fontSize: "9px",
|
|
||||||
padding: "4px",
|
|
||||||
background: hasInput ? "#2a2a2a" : "#1e1e1e",
|
|
||||||
color: "#ccc",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "2px",
|
|
||||||
width: "100%"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Output connector on right */}
|
|
||||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Export node metadata for Borealis
|
|
||||||
export default {
|
|
||||||
type: "Node_Template", // Unique node type identifier
|
|
||||||
label: "Node Template", // Display name in UI
|
|
||||||
description: `Node structure template to be used as a scaffold when building new nodes for Borealis.`, // Node Sidebar Tooltip Description
|
|
||||||
content: "Template acting as a design scaffold for designing nodes for Borealis.", // Short summary
|
|
||||||
component: TemplateNode // React component
|
|
||||||
};
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ESNext",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
|
||||||
"allowJs": false,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"esModuleInterop": false,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"strict": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "Node",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["src"],
|
|
||||||
"exclude": ["node_modules", "build", "dist"]
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/Server/WebUI/vite.config.ts
|
|
||||||
import { defineConfig } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
import path from 'path';
|
|
||||||
import fs from 'fs';
|
|
||||||
|
|
||||||
const runtimeCertDir = process.env.BOREALIS_CERT_DIR;
|
|
||||||
|
|
||||||
const certCandidates = [
|
|
||||||
process.env.BOREALIS_TLS_CERT,
|
|
||||||
runtimeCertDir && path.resolve(runtimeCertDir, 'borealis-server-cert.pem'),
|
|
||||||
path.resolve(__dirname, '../certs/borealis-server-cert.pem'),
|
|
||||||
path.resolve(__dirname, '../../../Server/Borealis/certs/borealis-server-cert.pem'),
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const keyCandidates = [
|
|
||||||
process.env.BOREALIS_TLS_KEY,
|
|
||||||
runtimeCertDir && path.resolve(runtimeCertDir, 'borealis-server-key.pem'),
|
|
||||||
path.resolve(__dirname, '../certs/borealis-server-key.pem'),
|
|
||||||
path.resolve(__dirname, '../../../Server/Borealis/certs/borealis-server-key.pem'),
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const pickFirst = (candidates: readonly (string | undefined)[]) => {
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (!candidate) continue;
|
|
||||||
if (fs.existsSync(candidate)) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const certPath = pickFirst(certCandidates);
|
|
||||||
const keyPath = pickFirst(keyCandidates);
|
|
||||||
|
|
||||||
const httpsOptions = certPath && keyPath
|
|
||||||
? {
|
|
||||||
cert: fs.readFileSync(certPath),
|
|
||||||
key: fs.readFileSync(keyPath),
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
server: {
|
|
||||||
open: true,
|
|
||||||
host: true,
|
|
||||||
strictPort: true,
|
|
||||||
// Allow LAN/IP access during dev (so other devices can reach Vite)
|
|
||||||
// If you want to restrict, replace `true` with an explicit allowlist.
|
|
||||||
allowedHosts: true,
|
|
||||||
https: httpsOptions,
|
|
||||||
proxy: {
|
|
||||||
// Ensure cookies/headers are forwarded correctly to Flask over TLS
|
|
||||||
'/api': {
|
|
||||||
target: 'https://127.0.0.1:5000',
|
|
||||||
changeOrigin: true,
|
|
||||||
secure: false,
|
|
||||||
},
|
|
||||||
'/socket.io': {
|
|
||||||
target: 'wss://127.0.0.1:5000',
|
|
||||||
ws: true,
|
|
||||||
changeOrigin: true,
|
|
||||||
secure: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
outDir: 'build',
|
|
||||||
emptyOutDir: true,
|
|
||||||
chunkSizeWarningLimit: 1000,
|
|
||||||
rollupOptions: {
|
|
||||||
output: {
|
|
||||||
// split each npm package into its own chunk
|
|
||||||
manualChunks(id) {
|
|
||||||
if (id.includes('node_modules')) {
|
|
||||||
return id.toString()
|
|
||||||
.split('node_modules/')[1]
|
|
||||||
.split('/')[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
alias: { '@': path.resolve(__dirname, 'src') },
|
|
||||||
extensions: ['.js','.jsx','.ts','.tsx']
|
|
||||||
}
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,40 +0,0 @@
|
|||||||
#////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/Server/server-requirements.txt
|
|
||||||
# PyTorch and related deep learning libraries (GPU Supported Functionality)
|
|
||||||
torch --index-url https://download.pytorch.org/whl/cu121
|
|
||||||
torchvision --index-url https://download.pytorch.org/whl/cu121
|
|
||||||
torchaudio --index-url https://download.pytorch.org/whl/cu121
|
|
||||||
|
|
||||||
# Flask for API / WebSockets Handling
|
|
||||||
Flask
|
|
||||||
requests
|
|
||||||
flask_socketio
|
|
||||||
flask-cors
|
|
||||||
eventlet
|
|
||||||
cryptography
|
|
||||||
PyJWT[crypto]
|
|
||||||
pyotp
|
|
||||||
qrcode
|
|
||||||
|
|
||||||
# GUI-related dependencies (Qt for GUI components)
|
|
||||||
Qt.py
|
|
||||||
qtpy
|
|
||||||
PyQt5
|
|
||||||
|
|
||||||
# Computer Vision & OCR Dependencies
|
|
||||||
numpy # Numerical operations
|
|
||||||
opencv-python # Computer vision processing
|
|
||||||
pytesseract # OCR engine
|
|
||||||
easyocr # Deep-learning-based OCR
|
|
||||||
Pillow # Image processing (Windows)
|
|
||||||
###mss # Fast cross-platform screen capture
|
|
||||||
|
|
||||||
# WebRTC Video Libraries
|
|
||||||
###aiortc # Python library for WebRTC in async environments
|
|
||||||
###av # Required by aiortc for video/audio codecs
|
|
||||||
|
|
||||||
# Ansible Execution (server-side playbooks)
|
|
||||||
ansible-core
|
|
||||||
ansible-compat
|
|
||||||
ansible-runner
|
|
||||||
paramiko
|
|
||||||
pywinrm
|
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user