Removed Legacy Server Codebase

This commit is contained in:
2025-11-01 03:58:43 -06:00
parent bec43418c1
commit da37098d91
106 changed files with 6 additions and 36891 deletions

View File

@@ -1,51 +0,0 @@
# Migration Prompt
You are working in the Borealis Automation Platform repo (root: <ProjectRoot>). The legacy runtime lives under Data/Server/server.py. Your objective is to introduce a new Engine runtime under Data/Engine that will progressively take over responsibilities (API first, then WebUI, then WebSocket). Execute the migration in the stages seen below (be sure to not overstep stages, we only want to work on one stage at a time, until I give approval to move onto the next stage):
Everytime you do work, you indicate the current stage you are on by writing to the file in <ProjectRoot>/Data/Engine/CODE_MIGRATION_TRACKER.md, inside of this file, you will keep an up-to-date ledger of the overall task list seen below, as well as the current stage you are on, and what task within that stage you are working on. You will keep this file up-to-date at all times whenever you make progress, and you will reference this file whenever making changes in case you forget where you were last at in the codebase migration work. You will never make modifications to the "# Migration Prompt" section, only the "# Borealis Engine Migration Tracker" section.
Lastly, everytime that you complete a stage, you will create a pull request named "Stage <number> - <Stage Description> Implemented" I will merge your pull request associated with that stage into the "main" branch of the codebase, then I will create a new gpt-5-codex conversation to keep teh conversation fresh and relevant, instructing the agent to work from the next stage in-line, and I expect the Codex agent to read the aforementioned <ProjectRoot>/Data/Engine/CODE_MIGRATION_TRACKER.md to understand what it has already done thus far, and what it needs to work on next. Every time that I start the new conversation, I will instruct gpt-5-codex to read <ProjectRoot>/Data/Engine/CODE_MIGRATION_TRACKER.md to understand it's tasks to determine what to do.
# Borealis Engine Migration Tracker
## Task Ledger
- [x] **Stage 1 — Establish the Engine skeleton and bootstrapper**
- [x] Add Data/Engine/__init__.py plus service subpackages with placeholder modules and docstrings.
- [x] Scaffold Data/Engine/server.py with the create_app(config) factory and stub service registration hooks.
- [x] Return a shared context object containing handles such as the database path, logger, and scheduler.
- [x] Update project tooling so the Engine runtime can be launched alongside the legacy path.
- [x] **Stage 2 — Port configuration and dependency loading into the Engine factory**
- [x] Extract configuration loading logic from Data/Server/server.py into Data/Engine/config.py helpers.
- [x] Verify context parity between Engine and legacy startup.
- [x] Initialize logging to Logs/Server/server.log when Engine mode is active.
- [x] Document Engine launch paths and configuration requirements in module docstrings.
- [x] **Stage 3 — Introduce API blueprints and service adapters**
- [x] Create domain-focused API blueprints and register_api entry point.
- [x] Mirror route behaviour from the legacy server via service adapters.
- [x] Add configuration toggles for enabling API groups incrementally.
- [x] **Stage 4 — Build unit and smoke tests for Engine APIs**
- [x] Add pytest modules under Data/Engine/Unit_Tests exercising API blueprints.
- [x] Provide fixtures that mirror the legacy SQLite schema and seed data.
- [x] Assert HTTP status codes, payloads, and side effects for parity.
- [x] Integrate Engine API tests into CI/local workflows.
- [x] **Stage 5 — Bridge the legacy server to Engine APIs**
- [x] Delegate API blueprint registration to the Engine factory from the legacy server.
- [x] Replace legacy API routes with Engine-provided blueprints gated by a flag.
- [x] Emit transitional logging when Engine handles requests.
- [ ] **Stage 6 — Plan WebUI migration**
- [x] Move static/template handling into Data/Engine/services/WebUI.
- [x] Ensure that data from /Data/Server/WebUI is copied into /Engine/web-interface during engine Deployment via Borealis.ps1
- [x] Preserve TLS-aware URL generation and caching.
- [ ] Add migration switch in the legacy server for WebUI delegation.
- [x] Extend tests to cover critical WebUI routes.
- [ ] Port device API endpoints into Engine services (device + admin coverage in progress).
- [x] Move authentication/token stack onto Engine services without legacy fallbacks.
- [x] Port enrollment request/poll flows to Engine services and drop legacy imports.
- [ ] **Stage 7 — Plan WebSocket migration**
- [ ] Extract Socket.IO handlers into Data/Engine/services/WebSocket.
- [ ] Provide register_realtime hook for the Engine factory.
- [ ] Add integration tests or smoke checks for key events.
- [ ] Update legacy server to consume Engine WebSocket registration.
## Current Status
- **Stage:** Stage 6 — Plan WebUI migration
- **Active Task:** Continue Stage 6 device/admin API migration (focus on remaining device and admin endpoints now that auth, token, and enrollment paths are Engine-native).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@

View File

@@ -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))

View File

@@ -1 +0,0 @@

View File

@@ -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)

View File

@@ -1 +0,0 @@

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -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)

View File

@@ -1 +0,0 @@

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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()

View File

@@ -1 +0,0 @@

View File

@@ -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

View File

@@ -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:]}"

View File

@@ -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()

View File

@@ -1 +0,0 @@

View File

@@ -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")

View File

@@ -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

View File

@@ -1 +0,0 @@

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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()]

View File

@@ -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.

View File

@@ -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>

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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.`
: ""
}
/>
</>
);
}

View File

@@ -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>{' '}
&#10095; <b>Personal Access Tokens &#10095; Tokens (Classic) &#10095; Generate New Token &#10095; 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>
);
}

View File

@@ -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)}
/>
</>
);
}

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -1,6 +0,0 @@
import React from "react";
import SSHDevices from "./SSH_Devices.jsx";
export default function WinRMDevices(props) {
return <SSHDevices {...props} type="winrm" />;
}

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 ---- */}
</>
);
}

View File

@@ -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"
}
};

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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&apos;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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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()
};

View File

@@ -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()
};

View File

@@ -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()
};

View File

@@ -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
};

View File

@@ -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()
};

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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()
};

View File

@@ -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()
};

View File

@@ -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()
};

View File

@@ -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
};

View File

@@ -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()
};

View File

@@ -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
};

View File

@@ -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()
};

View File

@@ -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()
};

View File

@@ -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()
};

View File

@@ -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()
};

View File

@@ -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 (1255):</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
};

View File

@@ -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 (0255):
</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
};

View File

@@ -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 (0100)
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 (0100):
</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
};

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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
};

Some files were not shown because too many files have changed in this diff Show More