Revert from Gitea Mirror Due to Catastrophic Destruction in Github

This commit is contained in:
2025-11-01 05:17:42 -06:00
parent 02eae72c0d
commit 6df391f21a
115 changed files with 37093 additions and 332 deletions

View File

@@ -15,7 +15,7 @@
"name": "server_url",
"label": "Borealis Server URL",
"type": "string",
"default": "https://localhost:5000",
"default": "http://localhost:5000",
"required": true,
"description": "URL of where the agent is going to reach-out to moving forward."
}

View File

@@ -0,0 +1,52 @@
# 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.
- [x] Ported quick_job_result handler to keep device activity statuses in sync.
- [ ] 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).

View File

@@ -0,0 +1,66 @@
# ======================================================
# Data\Engine\Unit_Tests\test_access_management_api.py
# Description: Exercises access-management endpoints covering GitHub API token administration.
#
# API Endpoints (if applicable): None
# ======================================================
from __future__ import annotations
from typing import Any, Dict
import pytest
from Data.Engine.integrations import github as github_integration
from .conftest import EngineTestHarness
def _admin_client(harness: EngineTestHarness):
client = harness.app.test_client()
with client.session_transaction() as sess:
sess["username"] = "admin"
sess["role"] = "Admin"
return client
def test_github_token_get_without_value(engine_harness: EngineTestHarness) -> None:
client = _admin_client(engine_harness)
response = client.get("/api/github/token")
assert response.status_code == 200
payload = response.get_json()
assert payload["has_token"] is False
assert payload["status"] == "missing"
assert payload["token"] == ""
def test_github_token_update(engine_harness: EngineTestHarness, monkeypatch: pytest.MonkeyPatch) -> None:
class DummyResponse:
def __init__(self, status_code: int, payload: Dict[str, Any]):
self.status_code = status_code
self._payload = payload
self.headers = {"X-RateLimit-Limit": "5000"}
self.text = ""
def json(self) -> Dict[str, Any]:
return self._payload
def fake_get(url: str, headers: Any = None, timeout: Any = None) -> DummyResponse:
return DummyResponse(200, {"commit": {"sha": "abc123"}})
monkeypatch.setattr(github_integration.requests, "get", fake_get)
client = _admin_client(engine_harness)
response = client.post("/api/github/token", json={"token": "ghp_test"})
assert response.status_code == 200
payload = response.get_json()
assert payload["has_token"] is True
assert payload["valid"] is True
assert payload["status"] == "ok"
assert payload["token"] == "ghp_test"
verify_response = client.get("/api/github/token")
assert verify_response.status_code == 200
verify_payload = verify_response.get_json()
assert verify_payload["has_token"] is True
assert verify_payload["token"] == "ghp_test"

View File

@@ -10,6 +10,7 @@ from __future__ import annotations
from typing import Any
import pytest
from Data.Engine.integrations import github as github_integration
from Data.Engine.services.API.devices import management as device_management
from .conftest import EngineTestHarness
@@ -98,13 +99,15 @@ def test_repo_current_hash_uses_cache(engine_harness: EngineTestHarness, monkeyp
def json(self) -> Any:
return self._payload
request_exception = getattr(github_integration.requests, "RequestException", RuntimeError)
def fake_get(url: str, headers: Any, timeout: int) -> DummyResponse:
calls["count"] += 1
if calls["count"] == 1:
return DummyResponse(200, {"commit": {"sha": "abc123"}})
raise device_management.requests.RequestException("network error")
raise request_exception("network error")
monkeypatch.setattr(device_management.requests, "get", fake_get)
monkeypatch.setattr(github_integration.requests, "get", fake_get)
client = engine_harness.app.test_client()
first = client.get("/api/repo/current_hash?repo=test/test&branch=main")

View File

@@ -77,11 +77,11 @@ def _stage_web_interface_assets(logger: Optional[logging.Logger] = None, *, forc
project_root = _project_root()
engine_web_root = project_root / "Engine" / "web-interface"
stage_source = project_root / "Data" / "Engine" / "web-interface"
legacy_source = project_root / "Data" / "Server" / "WebUI"
if not stage_source.is_dir():
if not legacy_source.is_dir():
raise RuntimeError(
f"Engine web interface source missing: {stage_source}"
f"Engine web interface source missing: {legacy_source}"
)
index_path = engine_web_root / "index.html"
@@ -92,14 +92,14 @@ def _stage_web_interface_assets(logger: Optional[logging.Logger] = None, *, forc
if engine_web_root.exists():
shutil.rmtree(engine_web_root)
shutil.copytree(stage_source, engine_web_root)
shutil.copytree(legacy_source, engine_web_root)
if not index_path.is_file():
raise RuntimeError(
f"Engine web interface staging failed; missing {index_path}"
)
logger.info("Engine web interface staged from %s to %s", stage_source, engine_web_root)
logger.info("Engine web interface staged from %s to %s", legacy_source, engine_web_root)
return engine_web_root

View File

@@ -7,4 +7,5 @@ cryptography
PyJWT[crypto]
pyotp
qrcode
Pillow
requests

View File

@@ -0,0 +1,12 @@
# ======================================================
# Data\Engine\integrations\__init__.py
# Description: Integration namespace exposing helper utilities for external service adapters.
#
# API Endpoints (if applicable): None
# ======================================================
"""Integration namespace for the Borealis Engine runtime."""
from .github import GitHubIntegration
__all__ = ["GitHubIntegration"]

View File

@@ -0,0 +1,605 @@
# ======================================================
# Data\Engine\integrations\github.py
# Description: GitHub REST integration providing cached repository head lookups for Engine services.
#
# API Endpoints (if applicable): None
# ======================================================
"""GitHub integration helpers for the Borealis Engine runtime."""
from __future__ import annotations
import base64
import json
import logging
import os
import sqlite3
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Any, Callable, Dict, Optional, Tuple
from flask import has_request_context, request
try: # pragma: no cover - import guard mirrors legacy runtime behaviour
import requests # type: ignore
except ImportError: # pragma: no cover - graceful fallback for minimal environments
class _RequestsStub:
class RequestException(RuntimeError):
"""Raised when the ``requests`` library is unavailable."""
def get(self, *args: Any, **kwargs: Any) -> Any:
raise self.RequestException("The 'requests' library is required for GitHub integrations.")
requests = _RequestsStub() # type: ignore
try: # pragma: no cover - optional dependency for green thread integration
from eventlet import tpool as _eventlet_tpool # type: ignore
except Exception: # pragma: no cover - optional dependency
_eventlet_tpool = None # type: ignore
try: # pragma: no cover - optional dependency for retrieving original modules
from eventlet import patcher as _eventlet_patcher # type: ignore
except Exception: # pragma: no cover - optional dependency
_eventlet_patcher = None # type: ignore
__all__ = ["GitHubIntegration"]
class GitHubIntegration:
"""Lightweight cache for GitHub repository head lookups."""
MIN_TTL_SECONDS = 30
MAX_TTL_SECONDS = 3600
DEFAULT_TTL_SECONDS = 60
DEFAULT_REPO = "bunny-lab-io/Borealis"
DEFAULT_BRANCH = "main"
def __init__(
self,
*,
cache_file: Path,
db_conn_factory: Callable[[], sqlite3.Connection],
service_log: Callable[[str, str, Optional[str]], None],
logger: Optional[logging.Logger] = None,
default_repo: Optional[str] = None,
default_branch: Optional[str] = None,
default_ttl_seconds: Optional[int] = None,
) -> None:
self._cache_file = cache_file
self._cache_file.parent.mkdir(parents=True, exist_ok=True)
self._db_conn_factory = db_conn_factory
self._service_log = service_log
self._logger = logger or logging.getLogger(__name__)
self._lock = threading.Lock()
self._token_lock = threading.Lock()
self._cache: Dict[Tuple[str, str], Tuple[str, float]] = {}
self._token_cache: Dict[str, Any] = {"value": None, "loaded_at": 0.0, "known": False}
self._default_repo = self._determine_default_repo(default_repo)
self._default_branch = self._determine_default_branch(default_branch)
self._default_ttl = self._determine_default_ttl(default_ttl_seconds)
self._load_cache()
@property
def default_repo(self) -> str:
return self._default_repo
@property
def default_branch(self) -> str:
return self._default_branch
def current_repo_hash(
self,
repo: Optional[str],
branch: Optional[str],
*,
ttl: Optional[Any] = None,
force_refresh: bool = False,
) -> Tuple[Dict[str, Any], int]:
owner_repo = (repo or self._default_repo).strip()
target_branch = (branch or self._default_branch).strip()
if "/" not in owner_repo:
return {"error": "repo must be in the form owner/name"}, 400
ttl_seconds = self._normalise_ttl(ttl)
return self._resolve(owner_repo, target_branch, ttl_seconds=ttl_seconds, force_refresh=force_refresh)
def _determine_default_repo(self, override: Optional[str]) -> str:
candidate = (override or os.environ.get("BOREALIS_REPO") or self.DEFAULT_REPO).strip()
if "/" not in candidate:
return self.DEFAULT_REPO
return candidate
def _determine_default_branch(self, override: Optional[str]) -> str:
candidate = (override or os.environ.get("BOREALIS_REPO_BRANCH") or self.DEFAULT_BRANCH).strip()
return candidate or self.DEFAULT_BRANCH
def _determine_default_ttl(self, override: Optional[int]) -> int:
env_value = os.environ.get("BOREALIS_REPO_HASH_REFRESH")
candidate: Optional[int] = None
if override is not None:
candidate = override
else:
try:
candidate = int(env_value) if env_value else None
except (TypeError, ValueError):
candidate = None
if candidate is None:
candidate = self.DEFAULT_TTL_SECONDS
return self._normalise_ttl(candidate)
def _normalise_ttl(self, ttl: Optional[Any]) -> int:
value: Optional[int] = None
if isinstance(ttl, str):
ttl = ttl.strip()
if not ttl:
ttl = None
if ttl is None:
value = self._default_ttl
else:
try:
value = int(ttl)
except (TypeError, ValueError):
value = self._default_ttl
value = value if value is not None else self._default_ttl
return max(self.MIN_TTL_SECONDS, min(value, self.MAX_TTL_SECONDS))
def _load_cache(self) -> None:
try:
if not self._cache_file.is_file():
return
payload = json.loads(self._cache_file.read_text(encoding="utf-8"))
entries = payload.get("entries")
if not isinstance(entries, dict):
return
now = time.time()
with self._lock:
for key, data in entries.items():
if not isinstance(data, dict):
continue
sha = (data.get("sha") or "").strip()
if not sha:
continue
ts_raw = data.get("ts")
try:
ts = float(ts_raw)
except (TypeError, ValueError):
ts = now
repo, _, branch = key.partition(":")
if repo and branch:
self._cache[(repo, branch)] = (sha, ts)
except Exception: # pragma: no cover - defensive logging
self._logger.debug("Failed to hydrate GitHub repo hash cache", exc_info=True)
def _persist_cache(self) -> None:
with self._lock:
snapshot = {
f"{repo}:{branch}": {"sha": sha, "ts": ts}
for (repo, branch), (sha, ts) in self._cache.items()
if sha
}
try:
if not snapshot:
try:
if self._cache_file.exists():
self._cache_file.unlink()
except FileNotFoundError:
return
except Exception:
self._logger.debug("Failed to remove GitHub repo hash cache file", exc_info=True)
return
payload = {"version": 1, "entries": snapshot}
tmp_path = self._cache_file.with_suffix(".tmp")
self._cache_file.parent.mkdir(parents=True, exist_ok=True)
tmp_path.write_text(json.dumps(payload), encoding="utf-8")
tmp_path.replace(self._cache_file)
except Exception: # pragma: no cover - defensive logging
self._logger.debug("Failed to persist GitHub repo hash cache", exc_info=True)
def _resolve(
self,
repo: str,
branch: str,
*,
ttl_seconds: int,
force_refresh: bool,
) -> Tuple[Dict[str, Any], int]:
key = (repo, branch)
now = time.time()
with self._lock:
cached = self._cache.get(key)
cached_sha: Optional[str] = None
cached_ts: Optional[float] = None
cached_age: Optional[float] = None
if cached:
cached_sha, cached_ts = cached
cached_age = max(0.0, now - cached_ts)
if cached_sha and not force_refresh and cached_age is not None and cached_age < ttl_seconds:
return self._build_payload(repo, branch, cached_sha, True, cached_age, "cache", None), 200
sha, error = self._fetch_repo_head(repo, branch, force_refresh=force_refresh)
if sha:
with self._lock:
self._cache[key] = (sha, now)
self._persist_cache()
return self._build_payload(repo, branch, sha, False, 0.0, "github", None), 200
if error:
self._service_log("server", f"/api/repo/current_hash error: {error}")
if cached_sha is not None:
payload = self._build_payload(
repo,
branch,
cached_sha or None,
True,
cached_age,
"cache-stale",
error or "using cached value",
)
return payload, (200 if cached_sha else 503)
payload = self._build_payload(
repo,
branch,
None,
False,
None,
"github",
error or "unable to resolve repository head",
)
return payload, 503
def _build_payload(
self,
repo: str,
branch: str,
sha: Optional[str],
cached: bool,
age_seconds: Optional[float],
source: str,
error: Optional[str],
) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"repo": repo,
"branch": branch,
"sha": (sha.strip() if isinstance(sha, str) else None) or None,
"cached": cached,
"age_seconds": age_seconds,
"source": source,
}
if error:
payload["error"] = error
return payload
def _fetch_repo_head(self, repo: str, branch: str, *, force_refresh: bool) -> Tuple[Optional[str], Optional[str]]:
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "Borealis-Engine",
}
token = self._github_token(force_refresh=force_refresh)
if token:
headers["Authorization"] = f"Bearer {token}"
try:
response = self._http_get(
f"https://api.github.com/repos/{repo}/branches/{branch}",
headers=headers,
timeout=20,
)
status = getattr(response, "status_code", None)
if status == 200:
try:
data = response.json()
except Exception as exc:
return None, f"GitHub REST API repo head decode error: {exc}"
sha = ((data.get("commit") or {}).get("sha") or "").strip()
if sha:
return sha, None
return None, "GitHub REST API repo head missing commit SHA"
snippet = ""
try:
text = getattr(response, "text", "")
snippet = text[:200] if isinstance(text, str) else ""
except Exception:
snippet = ""
error = f"GitHub REST API repo head lookup failed: HTTP {status}"
if snippet:
error = f"{error} {snippet}"
return None, error
except requests.RequestException as exc: # type: ignore[attr-defined]
return None, f"GitHub REST API repo head lookup raised: {exc}"
except RecursionError as exc: # pragma: no cover - defensive guard
return None, f"GitHub REST API repo head lookup recursion error: {exc}"
except Exception as exc: # pragma: no cover - defensive guard
return None, f"GitHub REST API repo head lookup unexpected error: {exc}"
def _github_token(self, *, force_refresh: bool) -> Optional[str]:
if has_request_context():
header_token = (request.headers.get("X-GitHub-Token") or "").strip()
if header_token:
return header_token
if not force_refresh:
auth_header = request.headers.get("Authorization") or ""
if auth_header.lower().startswith("bearer "):
candidate = auth_header.split(" ", 1)[1].strip()
if candidate:
return candidate
now = time.time()
with self._token_lock:
if (
not force_refresh
and self._token_cache.get("known")
and now - (self._token_cache.get("loaded_at") or 0.0) < 15.0
):
cached_token = self._token_cache.get("value")
return cached_token if cached_token else None
token = self._load_token_from_db(force_refresh=force_refresh)
self._set_cached_token(token)
if token:
return token
fallback = os.environ.get("BOREALIS_GITHUB_TOKEN") or os.environ.get("GITHUB_TOKEN")
fallback = (fallback or "").strip()
return fallback or None
def _set_cached_token(self, token: Optional[str]) -> None:
with self._token_lock:
self._token_cache["value"] = token if token else None
self._token_cache["loaded_at"] = time.time()
self._token_cache["known"] = True
def load_token(self, *, force_refresh: bool = False) -> Optional[str]:
token = self._load_token_from_db(force_refresh=force_refresh)
self._set_cached_token(token)
return token
def store_token(self, token: Optional[str]) -> None:
conn: Optional[sqlite3.Connection] = None
try:
conn = self._db_conn_factory()
cur = conn.cursor()
cur.execute("DELETE FROM github_token")
if token:
cur.execute("INSERT INTO github_token (token) VALUES (?)", (token,))
conn.commit()
except Exception as exc:
if conn is not None:
try:
conn.rollback()
except Exception:
pass
raise RuntimeError(f"Failed to store token: {exc}") from exc
finally:
if conn is not None:
try:
conn.close()
except Exception:
pass
self._set_cached_token(token if token else None)
def verify_token(self, token: Optional[str]) -> Dict[str, Any]:
if not token:
return {
"valid": False,
"message": "API Token Not Configured",
"status": "missing",
"rate_limit": None,
}
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "Borealis-Engine",
"Authorization": f"Bearer {token}",
}
try:
response = self._http_get(
f"https://api.github.com/repos/{self._default_repo}/branches/{self._default_branch}",
headers=headers,
timeout=20,
)
limit_header = response.headers.get("X-RateLimit-Limit")
try:
limit_value = int(limit_header) if limit_header is not None else None
except (TypeError, ValueError):
limit_value = None
if response.status_code == 200:
if limit_value is not None and limit_value >= 5000:
return {
"valid": True,
"message": "API Authentication Successful",
"status": "ok",
"rate_limit": limit_value,
}
return {
"valid": False,
"message": "API Token Invalid",
"status": "insufficient",
"rate_limit": limit_value,
"error": "Authenticated request did not elevate GitHub rate limits",
}
if response.status_code == 401:
return {
"valid": False,
"message": "API Token Invalid",
"status": "invalid",
"rate_limit": limit_value,
"error": getattr(response, "text", "")[:200],
}
return {
"valid": False,
"message": f"GitHub API error (HTTP {response.status_code})",
"status": "error",
"rate_limit": limit_value,
"error": getattr(response, "text", "")[:200],
}
except Exception as exc:
return {
"valid": False,
"message": f"API Token validation error: {exc}",
"status": "error",
"rate_limit": None,
"error": str(exc),
}
def refresh_default_repo_hash(self, *, force: bool = False) -> Tuple[Dict[str, Any], int]:
return self._resolve(
self._default_repo,
self._default_branch,
ttl_seconds=self._default_ttl,
force_refresh=force,
)
def _http_get(self, url: str, *, headers: Dict[str, str], timeout: int) -> Any:
try:
if _eventlet_tpool is not None:
try:
return _eventlet_tpool.execute(requests.get, url, headers=headers, timeout=timeout)
except Exception:
pass
return requests.get(url, headers=headers, timeout=timeout)
except Exception:
return self._http_get_subprocess(url, headers=headers, timeout=timeout)
def _http_get_subprocess(self, url: str, *, headers: Dict[str, str], timeout: int) -> Any:
script = """
import base64
import json
import sys
import urllib.request
url = sys.argv[1]
headers = json.loads(sys.argv[2])
timeout = float(sys.argv[3])
req = urllib.request.Request(url, headers=headers, method="GET")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read()
payload = {
"status": resp.status,
"headers": dict(resp.getheaders()),
"body": base64.b64encode(body).decode("ascii"),
"encoding": "base64",
}
sys.stdout.write(json.dumps(payload))
except Exception as exc:
error_payload = {"error": str(exc)}
sys.stdout.write(json.dumps(error_payload))
sys.exit(1)
"""
proc = subprocess.run(
[sys.executable, "-c", script, url, json.dumps(headers), str(float(timeout))],
capture_output=True,
text=True,
)
output = proc.stdout.strip() or proc.stderr.strip()
try:
data = json.loads(output or "{}")
except json.JSONDecodeError as exc:
raise RuntimeError(f"GitHub subprocess returned invalid JSON: {output!r}") from exc
if proc.returncode != 0:
error_msg = data.get("error") if isinstance(data, dict) else output
raise RuntimeError(f"GitHub subprocess request failed: {error_msg}")
status_code = data.get("status")
raw_headers = data.get("headers") or {}
body_encoded = data.get("body") or ""
encoding = data.get("encoding")
if encoding == "base64":
body_bytes = base64.b64decode(body_encoded.encode("ascii"))
else:
body_bytes = (body_encoded or "").encode("utf-8")
class _SubprocessResponse:
def __init__(self, status: int, headers: Dict[str, str], body: bytes):
self.status_code = status
self.headers = headers
self._body = body
self.text = body.decode("utf-8", errors="replace")
def json(self) -> Any:
if not self._body:
return {}
return json.loads(self.text)
if status_code is None:
raise RuntimeError(f"GitHub subprocess returned no status code: {data}")
return _SubprocessResponse(int(status_code), {str(k): str(v) for k, v in raw_headers.items()}, body_bytes)
def _resolve_original_ssl_module(self):
if _eventlet_patcher is not None:
try:
original = _eventlet_patcher.original("ssl")
if original is not None:
return original
except Exception:
pass
return ssl
def _resolve_original_socket_module(self):
if _eventlet_patcher is not None:
try:
original = _eventlet_patcher.original("socket")
if original is not None:
return original
except Exception:
pass
import socket as socket_module # Local import for fallback
return socket_module
def _resolve_original_raw_socket_module(self):
if _eventlet_patcher is not None:
try:
original = _eventlet_patcher.original("_socket")
if original is not None:
return original
except Exception:
pass
try:
import _socket as raw_socket_module # type: ignore
return raw_socket_module
except Exception:
return self._resolve_original_socket_module()
def _load_token_from_db(self, *, force_refresh: bool = False) -> Optional[str]:
if force_refresh:
with self._token_lock:
self._token_cache["known"] = False
conn: Optional[sqlite3.Connection] = None
try:
conn = self._db_conn_factory()
cursor = conn.cursor()
cursor.execute("SELECT token FROM github_token LIMIT 1")
row = cursor.fetchone()
if row and row[0]:
candidate = str(row[0]).strip()
return candidate or None
return None
except sqlite3.OperationalError:
return None
except Exception as exc:
self._service_log("server", f"github token lookup failed: {exc}")
return None
finally:
if conn is not None:
try:
conn.close()
except Exception:
pass

View File

@@ -27,6 +27,7 @@ from ...auth.rate_limit import SlidingWindowRateLimiter
from ...database import initialise_engine_database
from ...security import signing
from ...enrollment import NonceCache
from ...integrations import GitHubIntegration
from .enrollment import routes as enrollment_routes
from .tokens import routes as token_routes
@@ -151,6 +152,7 @@ class EngineServiceAdapters:
script_signer: Any = field(init=False)
service_log: Callable[[str, str, Optional[str]], None] = field(init=False)
device_auth_manager: DeviceAuthManager = field(init=False)
github_integration: GitHubIntegration = field(init=False)
def __post_init__(self) -> None:
self.db_conn_factory = _make_db_conn_factory(self.context.database_path)
@@ -181,6 +183,32 @@ class EngineServiceAdapters:
rate_limiter=self.device_rate_limiter,
)
config = self.context.config or {}
cache_root_value = config.get("cache_dir") or config.get("CACHE_DIR")
if cache_root_value:
cache_root = Path(str(cache_root_value))
else:
cache_root = Path(self.context.database_path).resolve().parent / "cache"
cache_file = cache_root / "repo_hash_cache.json"
default_repo = config.get("default_repo") or config.get("DEFAULT_REPO")
default_branch = config.get("default_branch") or config.get("DEFAULT_BRANCH")
ttl_raw = config.get("repo_hash_refresh") or config.get("REPO_HASH_REFRESH")
try:
default_ttl_seconds = int(ttl_raw) if ttl_raw is not None else None
except (TypeError, ValueError):
default_ttl_seconds = None
self.github_integration = GitHubIntegration(
cache_file=cache_file,
db_conn_factory=self.db_conn_factory,
service_log=self.service_log,
logger=self.context.logger,
default_repo=default_repo,
default_branch=default_branch,
default_ttl_seconds=default_ttl_seconds,
)
def _register_tokens(app: Flask, adapters: EngineServiceAdapters) -> None:
token_routes.register(

View File

@@ -0,0 +1,146 @@
# ======================================================
# Data\Engine\services\API\access_management\github.py
# Description: GitHub API token management endpoints for Engine access-management parity.
#
# API Endpoints (if applicable):
# - GET /api/github/token (Token Authenticated (Admin)) - Returns stored GitHub API token details and verification status.
# - POST /api/github/token (Token Authenticated (Admin)) - Updates the stored GitHub API token and triggers verification.
# ======================================================
"""GitHub token administration endpoints for the Borealis Engine."""
from __future__ import annotations
import os
import time
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple
from flask import Blueprint, Flask, jsonify, request, session
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
if TYPE_CHECKING: # pragma: no cover - typing helper
from .. import EngineServiceAdapters
def _now_ts() -> int:
return int(time.time())
class GitHubTokenService:
"""Admin endpoints for storing and validating GitHub REST API tokens."""
def __init__(self, app: Flask, adapters: "EngineServiceAdapters") -> None:
self.app = app
self.adapters = adapters
self.github = adapters.github_integration
self.logger = adapters.context.logger
def _token_serializer(self) -> URLSafeTimedSerializer:
secret = self.app.secret_key or "borealis-dev-secret"
return URLSafeTimedSerializer(secret, salt="borealis-auth")
def _current_user(self) -> Optional[Dict[str, Any]]:
username = session.get("username")
role = session.get("role") or "User"
if username:
return {"username": username, "role": role}
token = None
auth_header = request.headers.get("Authorization") or ""
if auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
if not token:
token = request.cookies.get("borealis_auth")
if not token:
return None
try:
data = self._token_serializer().loads(
token,
max_age=int(os.environ.get("BOREALIS_TOKEN_TTL_SECONDS", 60 * 60 * 24 * 30)),
)
username = data.get("u")
role = data.get("r") or "User"
if username:
return {"username": username, "role": role}
except (BadSignature, SignatureExpired, Exception):
return None
return None
def _require_admin(self) -> Optional[Tuple[Dict[str, Any], int]]:
user = self._current_user()
if not user:
return {"error": "unauthorized"}, 401
if (user.get("role") or "").lower() != "admin":
return {"error": "forbidden"}, 403
return None
def get_token(self):
requirement = self._require_admin()
if requirement:
payload, status = requirement
return jsonify(payload), status
token = self.github.load_token(force_refresh=True)
verification = self.github.verify_token(token)
message = verification.get("message") or ("API Token Invalid" if token else "API Token Not Configured")
payload = {
"token": token or "",
"has_token": bool(token),
"valid": bool(verification.get("valid")),
"message": message,
"status": verification.get("status") or ("missing" if not token else "unknown"),
"rate_limit": verification.get("rate_limit"),
"error": verification.get("error"),
"checked_at": _now_ts(),
}
return jsonify(payload)
def update_token(self):
requirement = self._require_admin()
if requirement:
payload, status = requirement
return jsonify(payload), status
data = request.get_json(silent=True) or {}
token = str(data.get("token") or "").strip()
try:
self.github.store_token(token or None)
except RuntimeError as exc:
self.logger.debug("Failed to store GitHub token", exc_info=True)
return jsonify({"error": str(exc)}), 500
verification = self.github.verify_token(token or None)
message = verification.get("message") or ("API Token Invalid" if token else "API Token Not Configured")
try:
self.github.refresh_default_repo_hash(force=True)
except Exception:
self.logger.debug("Failed to refresh default repo hash after token update", exc_info=True)
payload = {
"token": token,
"has_token": bool(token),
"valid": bool(verification.get("valid")),
"message": message,
"status": verification.get("status") or ("missing" if not token else "unknown"),
"rate_limit": verification.get("rate_limit"),
"error": verification.get("error"),
"checked_at": _now_ts(),
}
return jsonify(payload)
def register_github_token_management(app: Flask, adapters: "EngineServiceAdapters") -> None:
"""Register GitHub API token administration endpoints."""
service = GitHubTokenService(app, adapters)
blueprint = Blueprint("github_access", __name__)
@blueprint.route("/api/github/token", methods=["GET"])
def _github_token_get():
return service.get_token()
@blueprint.route("/api/github/token", methods=["POST"])
def _github_token_post():
return service.update_token()
app.register_blueprint(blueprint)

View File

@@ -15,6 +15,7 @@ from __future__ import annotations
import base64
import hashlib
import io
import logging
import os
import sqlite3
import time
@@ -37,6 +38,13 @@ except Exception: # pragma: no cover - optional dependency
if TYPE_CHECKING: # pragma: no cover - typing helper
from Data.Engine.services.API import EngineServiceAdapters
from .github import register_github_token_management
from .multi_factor_authentication import register_mfa_management
from .users import register_user_management
_logger = logging.getLogger(__name__)
_qr_logger_warning_emitted = False
def _now_ts() -> int:
return int(time.time())
@@ -71,7 +79,13 @@ def _totp_provisioning_uri(secret: str, username: str) -> Optional[str]:
def _totp_qr_data_uri(payload: str) -> Optional[str]:
if not payload or qrcode is None:
global _qr_logger_warning_emitted
if not payload:
return None
if qrcode is None:
if not _qr_logger_warning_emitted:
_logger.warning("MFA QR generation skipped: 'qrcode' dependency not available.")
_qr_logger_warning_emitted = True
return None
try:
image = qrcode.make(payload, box_size=6, border=4)
@@ -79,7 +93,10 @@ def _totp_qr_data_uri(payload: str) -> Optional[str]:
image.save(buffer, format="PNG")
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
return f"data:image/png;base64,{encoded}"
except Exception:
except Exception as exc:
if not _qr_logger_warning_emitted:
_logger.warning("Failed to generate MFA QR code: %s", exc, exc_info=True)
_qr_logger_warning_emitted = True
return None
@@ -416,4 +433,7 @@ def register_auth(app: Flask, adapters: "EngineServiceAdapters") -> None:
return service.me()
app.register_blueprint(blueprint)
register_user_management(app, adapters)
register_mfa_management(app, adapters)
register_github_token_management(app, adapters)

View File

@@ -0,0 +1,150 @@
# ======================================================
# Data\Engine\services\API\access_management\multi_factor_authentication.py
# Description: Multifactor administration endpoints for enabling, disabling, or resetting operator MFA state.
#
# API Endpoints (if applicable):
# - POST /api/users/<username>/mfa (Token Authenticated (Admin)) - Toggles MFA and optionally resets shared secrets.
# ======================================================
"""Multifactor administrative endpoints for the Borealis Engine."""
from __future__ import annotations
import os
import sqlite3
import time
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple
from flask import Blueprint, Flask, jsonify, request, session
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
if TYPE_CHECKING: # pragma: no cover - typing helper
from .. import EngineServiceAdapters
def _now_ts() -> int:
return int(time.time())
class MultiFactorAdministrationService:
"""Admin-focused MFA utility wrapper."""
def __init__(self, app: Flask, adapters: "EngineServiceAdapters") -> None:
self.app = app
self.adapters = adapters
self.db_conn_factory = adapters.db_conn_factory
self.logger = adapters.context.logger
def _db_conn(self) -> sqlite3.Connection:
return self.db_conn_factory()
def _token_serializer(self) -> URLSafeTimedSerializer:
secret = self.app.secret_key or "borealis-dev-secret"
return URLSafeTimedSerializer(secret, salt="borealis-auth")
def _current_user(self) -> Optional[Dict[str, Any]]:
username = session.get("username")
role = session.get("role") or "User"
if username:
return {"username": username, "role": role}
token = None
auth_header = request.headers.get("Authorization") or ""
if auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
if not token:
token = request.cookies.get("borealis_auth")
if not token:
return None
try:
data = self._token_serializer().loads(
token,
max_age=int(os.environ.get("BOREALIS_TOKEN_TTL_SECONDS", 60 * 60 * 24 * 30)),
)
username = data.get("u")
role = data.get("r") or "User"
if username:
return {"username": username, "role": role}
except (BadSignature, SignatureExpired, Exception):
return None
return None
def _require_admin(self) -> Optional[Tuple[Dict[str, Any], int]]:
user = self._current_user()
if not user:
return {"error": "unauthorized"}, 401
if (user.get("role") or "").lower() != "admin":
return {"error": "forbidden"}, 403
return None
def toggle_mfa(self, username: str):
requirement = self._require_admin()
if requirement:
payload, status = requirement
return jsonify(payload), status
username_norm = (username or "").strip()
if not username_norm:
return jsonify({"error": "invalid username"}), 400
payload = request.get_json(silent=True) or {}
enabled = bool(payload.get("enabled"))
reset_secret = bool(payload.get("reset_secret", False))
conn: Optional[sqlite3.Connection] = None
try:
conn = self._db_conn()
cur = conn.cursor()
now_ts = _now_ts()
if enabled:
if reset_secret:
cur.execute(
"UPDATE users SET mfa_enabled=1, mfa_secret=NULL, updated_at=? WHERE LOWER(username)=LOWER(?)",
(now_ts, username_norm),
)
else:
cur.execute(
"UPDATE users SET mfa_enabled=1, updated_at=? WHERE LOWER(username)=LOWER(?)",
(now_ts, username_norm),
)
else:
if reset_secret:
cur.execute(
"UPDATE users SET mfa_enabled=0, mfa_secret=NULL, updated_at=? WHERE LOWER(username)=LOWER(?)",
(now_ts, username_norm),
)
else:
cur.execute(
"UPDATE users SET mfa_enabled=0, updated_at=? WHERE LOWER(username)=LOWER(?)",
(now_ts, username_norm),
)
if cur.rowcount == 0:
return jsonify({"error": "user not found"}), 404
conn.commit()
me = self._current_user()
if me and me.get("username", "").lower() == username_norm.lower() and not enabled:
session.pop("mfa_pending", None)
return jsonify({"status": "ok"})
except Exception as exc:
self.logger.debug("Failed to update MFA for %s", username_norm, exc_info=True)
return jsonify({"error": str(exc)}), 500
finally:
if conn:
conn.close()
def register_mfa_management(app: Flask, adapters: "EngineServiceAdapters") -> None:
"""Register MFA administration endpoints."""
service = MultiFactorAdministrationService(app, adapters)
blueprint = Blueprint("access_mgmt_mfa", __name__)
@blueprint.route("/api/users/<username>/mfa", methods=["POST"])
def _toggle_mfa(username: str):
return service.toggle_mfa(username)
app.register_blueprint(blueprint)

View File

@@ -1,8 +1,317 @@
# ======================================================
# Data\Engine\services\API\access_management\users.py
# Description: Placeholder for operator user management endpoints (not yet implemented).
# Description: Operator user CRUD endpoints for the Engine auth group, mirroring the legacy server behaviour.
#
# API Endpoints (if applicable): None
# API Endpoints (if applicable):
# - GET /api/users (Token Authenticated (Admin)) - Lists operator accounts.
# - POST /api/users (Token Authenticated (Admin)) - Creates a new operator account.
# - DELETE /api/users/<username> (Token Authenticated (Admin)) - Deletes an operator account.
# - POST /api/users/<username>/reset_password (Token Authenticated (Admin)) - Resets an operator password hash.
# - POST /api/users/<username>/role (Token Authenticated (Admin)) - Updates an operator role.
# ======================================================
"""Placeholder for users API module."""
"""Operator user management endpoints for the Borealis Engine."""
from __future__ import annotations
import os
import sqlite3
import time
from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Sequence, Tuple
from flask import Blueprint, Flask, jsonify, request, session
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
if TYPE_CHECKING: # pragma: no cover - typing helper
from .. import EngineServiceAdapters
def _now_ts() -> int:
return int(time.time())
def _row_to_user(row: Sequence[Any]) -> Mapping[str, Any]:
"""Convert a database row into a user payload."""
return {
"id": row[0],
"username": row[1],
"display_name": row[2] or row[1],
"role": row[3] or "User",
"last_login": row[4] or 0,
"created_at": row[5] or 0,
"updated_at": row[6] or 0,
"mfa_enabled": 1 if (row[7] or 0) else 0,
}
class UserManagementService:
"""Utility wrapper that performs admin-authenticated user CRUD operations."""
def __init__(self, app: Flask, adapters: "EngineServiceAdapters") -> None:
self.app = app
self.adapters = adapters
self.db_conn_factory = adapters.db_conn_factory
self.logger = adapters.context.logger
def _db_conn(self) -> sqlite3.Connection:
return self.db_conn_factory()
def _token_serializer(self) -> URLSafeTimedSerializer:
secret = self.app.secret_key or "borealis-dev-secret"
return URLSafeTimedSerializer(secret, salt="borealis-auth")
def _current_user(self) -> Optional[Dict[str, Any]]:
username = session.get("username")
role = session.get("role") or "User"
if username:
return {"username": username, "role": role}
token = None
auth_header = request.headers.get("Authorization") or ""
if auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
if not token:
token = request.cookies.get("borealis_auth")
if not token:
return None
try:
data = self._token_serializer().loads(
token,
max_age=int(os.environ.get("BOREALIS_TOKEN_TTL_SECONDS", 60 * 60 * 24 * 30)),
)
username = data.get("u")
role = data.get("r") or "User"
if username:
return {"username": username, "role": role}
except (BadSignature, SignatureExpired, Exception):
return None
return None
def _require_admin(self) -> Optional[Tuple[Dict[str, Any], int]]:
user = self._current_user()
if not user:
return {"error": "unauthorized"}, 401
if (user.get("role") or "").lower() != "admin":
return {"error": "forbidden"}, 403
return None
# ------------------------------------------------------------------ #
# Endpoint implementations
# ------------------------------------------------------------------ #
def list_users(self):
requirement = self._require_admin()
if requirement:
payload, status = requirement
return jsonify(payload), status
conn: Optional[sqlite3.Connection] = None
try:
conn = self._db_conn()
cur = conn.cursor()
cur.execute(
"SELECT id, username, display_name, role, last_login, created_at, updated_at, "
"COALESCE(mfa_enabled, 0) FROM users ORDER BY LOWER(username) ASC"
)
rows = cur.fetchall()
users: List[Mapping[str, Any]] = [_row_to_user(row) for row in rows]
return jsonify({"users": users})
except Exception as exc:
self.logger.debug("Failed to list users", exc_info=True)
return jsonify({"error": str(exc)}), 500
finally:
if conn:
conn.close()
def create_user(self):
requirement = self._require_admin()
if requirement:
payload, status = requirement
return jsonify(payload), status
data = request.get_json(silent=True) or {}
username = (data.get("username") or "").strip()
display_name = (data.get("display_name") or username).strip()
role = (data.get("role") or "User").strip().title()
password_sha512 = (data.get("password_sha512") or "").strip().lower()
if not username or not password_sha512:
return jsonify({"error": "username and password_sha512 are required"}), 400
if role not in ("User", "Admin"):
return jsonify({"error": "invalid role"}), 400
now_ts = _now_ts()
conn: Optional[sqlite3.Connection] = None
try:
conn = self._db_conn()
cur = conn.cursor()
cur.execute(
"INSERT INTO users(username, display_name, password_sha512, role, created_at, updated_at) "
"VALUES(?,?,?,?,?,?)",
(username, display_name or username, password_sha512, role, now_ts, now_ts),
)
conn.commit()
return jsonify({"status": "ok"})
except sqlite3.IntegrityError:
return jsonify({"error": "username already exists"}), 409
except Exception as exc:
self.logger.debug("Failed to create user %s", username, exc_info=True)
return jsonify({"error": str(exc)}), 500
finally:
if conn:
conn.close()
def delete_user(self, username: str):
requirement = self._require_admin()
if requirement:
payload, status = requirement
return jsonify(payload), status
username_norm = (username or "").strip()
if not username_norm:
return jsonify({"error": "invalid username"}), 400
conn: Optional[sqlite3.Connection] = None
try:
conn = self._db_conn()
cur = conn.cursor()
me = self._current_user()
if me and (me.get("username", "").lower() == username_norm.lower()):
return (
jsonify({"error": "You cannot delete the user you are currently logged in as."}),
400,
)
cur.execute("SELECT COUNT(*) FROM users")
total_users = cur.fetchone()[0] or 0
if total_users <= 1:
return (
jsonify(
{
"error": "There is only one user currently configured, you cannot delete this user until you have created another."
}
),
400,
)
cur.execute("DELETE FROM users WHERE LOWER(username)=LOWER(?)", (username_norm,))
deleted = cur.rowcount or 0
conn.commit()
if deleted == 0:
return jsonify({"error": "user not found"}), 404
return jsonify({"status": "ok"})
except Exception as exc:
self.logger.debug("Failed to delete user %s", username_norm, exc_info=True)
return jsonify({"error": str(exc)}), 500
finally:
if conn:
conn.close()
def reset_password(self, username: str):
requirement = self._require_admin()
if requirement:
payload, status = requirement
return jsonify(payload), status
data = request.get_json(silent=True) or {}
password_sha512 = (data.get("password_sha512") or "").strip().lower()
if not password_sha512 or len(password_sha512) != 128:
return jsonify({"error": "invalid password hash"}), 400
conn: Optional[sqlite3.Connection] = None
try:
conn = self._db_conn()
cur = conn.cursor()
now_ts = _now_ts()
cur.execute(
"UPDATE users SET password_sha512=?, updated_at=? WHERE LOWER(username)=LOWER(?)",
(password_sha512, now_ts, username),
)
if cur.rowcount == 0:
return jsonify({"error": "user not found"}), 404
conn.commit()
return jsonify({"status": "ok"})
except Exception as exc:
self.logger.debug("Failed to reset password for %s", username, exc_info=True)
return jsonify({"error": str(exc)}), 500
finally:
if conn:
conn.close()
def change_role(self, username: str):
requirement = self._require_admin()
if requirement:
payload, status = requirement
return jsonify(payload), status
data = request.get_json(silent=True) or {}
role = (data.get("role") or "").strip().title()
if role not in ("User", "Admin"):
return jsonify({"error": "invalid role"}), 400
conn: Optional[sqlite3.Connection] = None
try:
conn = self._db_conn()
cur = conn.cursor()
if role == "User":
cur.execute("SELECT COUNT(*) FROM users WHERE LOWER(role)='admin'")
admin_count = cur.fetchone()[0] or 0
cur.execute(
"SELECT LOWER(role) FROM users WHERE LOWER(username)=LOWER(?)",
(username,),
)
row = cur.fetchone()
current_role = (row[0] or "").lower() if row else ""
if current_role == "admin" and admin_count <= 1:
return jsonify({"error": "cannot demote the last admin"}), 400
now_ts = _now_ts()
cur.execute(
"UPDATE users SET role=?, updated_at=? WHERE LOWER(username)=LOWER(?)",
(role, now_ts, username),
)
if cur.rowcount == 0:
return jsonify({"error": "user not found"}), 404
conn.commit()
me = self._current_user()
if me and me.get("username", "").lower() == (username or "").lower():
session["role"] = role
return jsonify({"status": "ok"})
except Exception as exc:
self.logger.debug("Failed to update role for %s", username, exc_info=True)
return jsonify({"error": str(exc)}), 500
finally:
if conn:
conn.close()
def register_user_management(app: Flask, adapters: "EngineServiceAdapters") -> None:
"""Register user management endpoints."""
service = UserManagementService(app, adapters)
blueprint = Blueprint("access_mgmt_users", __name__)
@blueprint.route("/api/users", methods=["GET"])
def _list_users():
return service.list_users()
@blueprint.route("/api/users", methods=["POST"])
def _create_user():
return service.create_user()
@blueprint.route("/api/users/<username>", methods=["DELETE"])
def _delete_user(username: str):
return service.delete_user(username)
@blueprint.route("/api/users/<username>/reset_password", methods=["POST"])
def _reset_password(username: str):
return service.reset_password(username)
@blueprint.route("/api/users/<username>/role", methods=["POST"])
def _change_role(username: str):
return service.change_role(username)
app.register_blueprint(blueprint)

View File

@@ -13,20 +13,367 @@
from __future__ import annotations
import base64
import json
import os
import re
import time
from typing import TYPE_CHECKING, Any, Dict, List
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from flask import Blueprint, jsonify, request
from ..scheduled_jobs.management import ensure_scheduler, get_scheduler
if TYPE_CHECKING: # pragma: no cover - typing aide
from flask import Flask
from .. import EngineServiceAdapters
def _assemblies_root() -> Path:
base = Path(__file__).resolve()
search_roots = (base, *base.parents)
for candidate in search_roots:
engine_dir: Optional[Path]
if candidate.name.lower() == "engine":
engine_dir = candidate
else:
tentative = candidate / "Engine"
engine_dir = tentative if tentative.is_dir() else None
if not engine_dir:
continue
assemblies_dir = engine_dir / "Assemblies"
if assemblies_dir.is_dir():
return assemblies_dir.resolve()
raise RuntimeError("Engine assemblies directory not found; expected Engine/Assemblies.")
def _scripts_root() -> Path:
assemblies_root = _assemblies_root()
scripts_dir = assemblies_root / "Scripts"
if not scripts_dir.is_dir():
raise RuntimeError("Engine scripts directory not found; expected Engine/Assemblies/Scripts.")
return scripts_dir.resolve()
def _normalize_script_relpath(rel_path: Any) -> Optional[str]:
"""Return a canonical Scripts-relative path or ``None`` when invalid."""
if not isinstance(rel_path, str):
return None
raw = rel_path.replace("\\", "/").strip()
if not raw:
return None
segments: List[str] = []
for part in raw.split("/"):
candidate = part.strip()
if not candidate or candidate == ".":
continue
if candidate == "..":
return None
segments.append(candidate)
if not segments:
return None
first = segments[0]
if first.lower() != "scripts":
segments.insert(0, "Scripts")
else:
segments[0] = "Scripts"
return "/".join(segments)
def _decode_base64_text(value: Any) -> Optional[str]:
if not isinstance(value, str):
return None
stripped = value.strip()
if not stripped:
return ""
try:
cleaned = re.sub(r"\s+", "", stripped)
except Exception:
cleaned = stripped
try:
decoded = base64.b64decode(cleaned, validate=True)
except Exception:
return None
try:
return decoded.decode("utf-8")
except Exception:
return decoded.decode("utf-8", errors="replace")
def _decode_script_content(value: Any, encoding_hint: str = "") -> str:
encoding = (encoding_hint or "").strip().lower()
if isinstance(value, str):
if encoding in {"base64", "b64", "base-64"}:
decoded = _decode_base64_text(value)
if decoded is not None:
return decoded.replace("\r\n", "\n")
decoded = _decode_base64_text(value)
if decoded is not None:
return decoded.replace("\r\n", "\n")
return value.replace("\r\n", "\n")
return ""
def _canonical_env_key(name: Any) -> str:
try:
return re.sub(r"[^A-Za-z0-9_]", "_", str(name or "").strip()).upper()
except Exception:
return ""
def _env_string(value: Any) -> str:
if isinstance(value, bool):
return "True" if value else "False"
if value is None:
return ""
return str(value)
def _powershell_literal(value: Any, var_type: str) -> str:
typ = str(var_type or "string").lower()
if typ == "boolean":
if isinstance(value, bool):
truthy = value
elif value is None:
truthy = False
elif isinstance(value, (int, float)):
truthy = value != 0
else:
s = str(value).strip().lower()
if s in {"true", "1", "yes", "y", "on"}:
truthy = True
elif s in {"false", "0", "no", "n", "off", ""}:
truthy = False
else:
truthy = bool(s)
return "$true" if truthy else "$false"
if typ == "number":
if value is None or value == "":
return "0"
return str(value)
s = "" if value is None else str(value)
return "'" + s.replace("'", "''") + "'"
def _expand_env_aliases(env_map: Dict[str, str], variables: List[Dict[str, Any]]) -> Dict[str, str]:
expanded: Dict[str, str] = dict(env_map or {})
if not isinstance(variables, list):
return expanded
for var in variables:
if not isinstance(var, dict):
continue
name = str(var.get("name") or "").strip()
if not name:
continue
canonical = _canonical_env_key(name)
if not canonical or canonical not in expanded:
continue
value = expanded[canonical]
alias = re.sub(r"[^A-Za-z0-9_]", "_", name)
if alias and alias not in expanded:
expanded[alias] = value
if alias != name and re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name) and name not in expanded:
expanded[name] = value
return expanded
def _extract_variable_default(var: Dict[str, Any]) -> Any:
for key in ("value", "default", "defaultValue", "default_value"):
if key in var:
val = var.get(key)
return "" if val is None else val
return ""
def prepare_variable_context(doc_variables: List[Dict[str, Any]], overrides: Dict[str, Any]):
env_map: Dict[str, str] = {}
variables: List[Dict[str, Any]] = []
literal_lookup: Dict[str, str] = {}
doc_names: Dict[str, bool] = {}
overrides = overrides or {}
if not isinstance(doc_variables, list):
doc_variables = []
for var in doc_variables:
if not isinstance(var, dict):
continue
name = str(var.get("name") or "").strip()
if not name:
continue
doc_names[name] = True
canonical = _canonical_env_key(name)
var_type = str(var.get("type") or "string").lower()
default_val = _extract_variable_default(var)
final_val = overrides[name] if name in overrides else default_val
if canonical:
env_map[canonical] = _env_string(final_val)
literal_lookup[canonical] = _powershell_literal(final_val, var_type)
if name in overrides:
new_var = dict(var)
new_var["value"] = overrides[name]
variables.append(new_var)
else:
variables.append(var)
for name, val in overrides.items():
if name in doc_names:
continue
canonical = _canonical_env_key(name)
if canonical:
env_map[canonical] = _env_string(val)
literal_lookup[canonical] = _powershell_literal(val, "string")
variables.append({"name": name, "value": val, "type": "string"})
env_map = _expand_env_aliases(env_map, variables)
return env_map, variables, literal_lookup
_ENV_VAR_PATTERN = re.compile(r"(?i)\$env:(\{)?([A-Za-z0-9_\-]+)(?(1)\})")
def rewrite_powershell_script(content: str, literal_lookup: Dict[str, str]) -> str:
if not content or not literal_lookup:
return content
def _replace(match: Any) -> str:
name = match.group(2)
canonical = _canonical_env_key(name)
if not canonical:
return match.group(0)
literal = literal_lookup.get(canonical)
if literal is None:
return match.group(0)
return literal
return _ENV_VAR_PATTERN.sub(_replace, content)
def _load_assembly_document(abs_path: str, default_type: str) -> Dict[str, Any]:
abs_path_str = os.fspath(abs_path)
base_name = os.path.splitext(os.path.basename(abs_path_str))[0]
doc: Dict[str, Any] = {
"name": base_name,
"description": "",
"category": "application" if default_type == "ansible" else "script",
"type": default_type,
"script": "",
"variables": [],
"files": [],
"timeout_seconds": 3600,
}
if abs_path_str.lower().endswith(".json") and os.path.isfile(abs_path_str):
try:
with open(abs_path_str, "r", encoding="utf-8") as fh:
data = json.load(fh)
except Exception:
data = {}
if isinstance(data, dict):
doc["name"] = str(data.get("name") or doc["name"])
doc["description"] = str(data.get("description") or "")
cat = str(data.get("category") or doc["category"]).strip().lower()
if cat in {"application", "script"}:
doc["category"] = cat
typ = str(data.get("type") or data.get("script_type") or default_type).strip().lower()
if typ in {"powershell", "batch", "bash", "ansible"}:
doc["type"] = typ
script_val = data.get("script")
content_val = data.get("content")
script_lines = data.get("script_lines")
if isinstance(script_lines, list):
try:
doc["script"] = "\n".join(str(line) for line in script_lines)
except Exception:
doc["script"] = ""
elif isinstance(script_val, str):
doc["script"] = script_val
else:
if isinstance(content_val, str):
doc["script"] = content_val
encoding_hint = str(
data.get("script_encoding") or data.get("scriptEncoding") or ""
).strip().lower()
doc["script"] = _decode_script_content(doc.get("script"), encoding_hint)
if encoding_hint in {"base64", "b64", "base-64"}:
doc["script_encoding"] = "base64"
else:
probe_source = ""
if isinstance(script_val, str) and script_val:
probe_source = script_val
elif isinstance(content_val, str) and content_val:
probe_source = content_val
decoded_probe = _decode_base64_text(probe_source) if probe_source else None
if decoded_probe is not None:
doc["script_encoding"] = "base64"
doc["script"] = decoded_probe.replace("\r\n", "\n")
else:
doc["script_encoding"] = "plain"
try:
timeout_raw = data.get("timeout_seconds", data.get("timeout"))
if timeout_raw is None:
doc["timeout_seconds"] = 3600
else:
doc["timeout_seconds"] = max(0, int(timeout_raw))
except Exception:
doc["timeout_seconds"] = 3600
vars_in = data.get("variables") if isinstance(data.get("variables"), list) else []
doc["variables"] = []
for item in vars_in:
if not isinstance(item, dict):
continue
name = str(item.get("name") or item.get("key") or "").strip()
if not name:
continue
vtype = str(item.get("type") or "string").strip().lower()
if vtype not in {"string", "number", "boolean", "credential"}:
vtype = "string"
doc["variables"].append(
{
"name": name,
"label": str(item.get("label") or ""),
"type": vtype,
"default": item.get("default", item.get("default_value")),
"required": bool(item.get("required")),
"description": str(item.get("description") or ""),
}
)
files_in = data.get("files") if isinstance(data.get("files"), list) else []
doc["files"] = []
for file_item in files_in:
if not isinstance(file_item, dict):
continue
fname = file_item.get("file_name") or file_item.get("name")
if not fname or not isinstance(file_item.get("data"), str):
continue
try:
size_val = int(file_item.get("size") or 0)
except Exception:
size_val = 0
doc["files"].append(
{
"file_name": str(fname),
"size": size_val,
"mime_type": str(file_item.get("mime_type") or file_item.get("mimeType") or ""),
"data": file_item.get("data"),
}
)
return doc
try:
with open(abs_path_str, "r", encoding="utf-8", errors="replace") as fh:
content = fh.read()
except Exception:
content = ""
normalized_script = (content or "").replace("\r\n", "\n")
doc["script"] = normalized_script
return doc
def _normalize_hostnames(value: Any) -> List[str]:
if not isinstance(value, list):
return []
@@ -41,31 +388,52 @@ def _normalize_hostnames(value: Any) -> List[str]:
def register_execution(app: "Flask", adapters: "EngineServiceAdapters") -> None:
"""Register quick execution endpoints for assemblies."""
ensure_scheduler(app, adapters)
blueprint = Blueprint("assemblies_execution", __name__)
service_log = adapters.service_log
@blueprint.route("/api/scripts/quick_run", methods=["POST"])
def scripts_quick_run():
scheduler = get_scheduler(adapters)
data = request.get_json(silent=True) or {}
rel_path = (data.get("script_path") or "").strip()
rel_path_input = data.get("script_path")
rel_path_normalized = _normalize_script_relpath(rel_path_input)
hostnames = _normalize_hostnames(data.get("hostnames"))
run_mode = (data.get("run_mode") or "system").strip().lower()
admin_user = str(data.get("admin_user") or "").strip()
admin_pass = str(data.get("admin_pass") or "").strip()
if not rel_path or not hostnames:
if not rel_path_normalized or not hostnames:
return jsonify({"error": "Missing script_path or hostnames[]"}), 400
scripts_root = scheduler._scripts_root() # type: ignore[attr-defined]
abs_path = os.path.abspath(os.path.join(scripts_root, rel_path))
if (
not abs_path.startswith(scripts_root)
or not scheduler._is_valid_scripts_relpath(rel_path) # type: ignore[attr-defined]
or not os.path.isfile(abs_path)
):
rel_path_canonical = rel_path_normalized
try:
scripts_root = _scripts_root()
assemblies_root = scripts_root.parent.resolve()
abs_path = (assemblies_root / rel_path_canonical).resolve()
except Exception as exc: # pragma: no cover - defensive guard
service_log(
"assemblies",
f"quick job failed to resolve script path={rel_path_input!r}: {exc}",
level="ERROR",
)
return jsonify({"error": "Failed to resolve script path"}), 500
scripts_root_str = str(scripts_root)
abs_path_str = str(abs_path)
try:
within_scripts = os.path.commonpath([scripts_root_str, abs_path_str]) == scripts_root_str
except ValueError:
within_scripts = False
if not within_scripts or not os.path.isfile(abs_path_str):
service_log(
"assemblies",
f"quick job requested missing or out-of-scope script input={rel_path_input!r} normalized={rel_path_canonical}",
level="WARNING",
)
return jsonify({"error": "Script not found"}), 404
doc = scheduler._load_assembly_document(abs_path, "scripts") # type: ignore[attr-defined]
doc = _load_assembly_document(abs_path, "powershell")
script_type = (doc.get("type") or "powershell").lower()
if script_type != "powershell":
return jsonify({"error": f"Unsupported script type '{script_type}'. Only PowerShell is supported."}), 400
@@ -81,8 +449,8 @@ def register_execution(app: "Flask", adapters: "EngineServiceAdapters") -> None:
continue
overrides[name] = val
env_map, variables, literal_lookup = scheduler._prepare_variable_context(doc_variables, overrides) # type: ignore[attr-defined]
content = scheduler._rewrite_powershell_script(content, literal_lookup) # type: ignore[attr-defined]
env_map, variables, literal_lookup = prepare_variable_context(doc_variables, overrides)
content = rewrite_powershell_script(content, literal_lookup)
normalized_script = (content or "").replace("\r\n", "\n")
script_bytes = normalized_script.encode("utf-8")
encoded_content = (
@@ -127,7 +495,7 @@ def register_execution(app: "Flask", adapters: "EngineServiceAdapters") -> None:
""",
(
host,
rel_path.replace(os.sep, "/"),
rel_path_canonical.replace(os.sep, "/"),
friendly_name,
script_type,
now,
@@ -144,7 +512,7 @@ def register_execution(app: "Flask", adapters: "EngineServiceAdapters") -> None:
"target_hostname": host,
"script_type": script_type,
"script_name": friendly_name,
"script_path": rel_path.replace(os.sep, "/"),
"script_path": rel_path_canonical.replace(os.sep, "/"),
"script_content": encoded_content,
"script_encoding": "base64",
"environment": env_map,
@@ -152,6 +520,8 @@ def register_execution(app: "Flask", adapters: "EngineServiceAdapters") -> None:
"timeout_seconds": timeout_seconds,
"files": doc.get("files") if isinstance(doc.get("files"), list) else [],
"run_mode": run_mode,
"admin_user": admin_user,
"admin_pass": admin_pass,
}
if signature_b64:
payload["signature"] = signature_b64
@@ -176,7 +546,7 @@ def register_execution(app: "Flask", adapters: "EngineServiceAdapters") -> None:
results.append({"hostname": host, "job_id": job_id, "status": "Running"})
service_log(
"assemblies",
f"quick job queued hostname={host} path={rel_path} run_mode={run_mode}",
f"quick job queued hostname={host} path={rel_path_canonical} run_mode={run_mode}",
)
except Exception as exc:
if conn is not None:

View File

@@ -15,6 +15,7 @@ from __future__ import annotations
import base64
import hashlib
import io
import logging
import os
import sqlite3
import time
@@ -37,6 +38,9 @@ except Exception: # pragma: no cover - optional dependency
if TYPE_CHECKING: # pragma: no cover - typing helper
from . import EngineServiceAdapters
_logger = logging.getLogger(__name__)
_qr_logger_warning_emitted = False
def _now_ts() -> int:
return int(time.time())
@@ -71,7 +75,13 @@ def _totp_provisioning_uri(secret: str, username: str) -> Optional[str]:
def _totp_qr_data_uri(payload: str) -> Optional[str]:
if not payload or qrcode is None:
global _qr_logger_warning_emitted
if not payload:
return None
if qrcode is None:
if not _qr_logger_warning_emitted:
_logger.warning("MFA QR generation skipped: 'qrcode' dependency not available.")
_qr_logger_warning_emitted = True
return None
try:
image = qrcode.make(payload, box_size=6, border=4)
@@ -79,7 +89,10 @@ def _totp_qr_data_uri(payload: str) -> Optional[str]:
image.save(buffer, format="PNG")
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
return f"data:image/png;base64,{encoded}"
except Exception:
except Exception as exc:
if not _qr_logger_warning_emitted:
_logger.warning("Failed to generate MFA QR code: %s", exc, exc_info=True)
_qr_logger_warning_emitted = True
return None

View File

@@ -29,9 +29,7 @@ from __future__ import annotations
import json
import logging
import os
import ssl
import sqlite3
import threading
import time
import uuid
from datetime import datetime, timezone
@@ -41,20 +39,8 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
from flask import Blueprint, jsonify, request, session, g
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from ....auth.device_auth import require_device_auth
from ....auth.guid_utils import normalize_guid
try:
import requests # type: ignore
except ImportError: # pragma: no cover - fallback for minimal test environments
class _RequestsStub:
class RequestException(RuntimeError):
"""Stand-in exception when the requests module is unavailable."""
def get(self, *args: Any, **kwargs: Any) -> Any:
raise self.RequestException("The 'requests' library is required for repository hash lookups.")
requests = _RequestsStub() # type: ignore
from ....auth.device_auth import require_device_auth
if TYPE_CHECKING: # pragma: no cover - typing aide
from .. import EngineServiceAdapters
@@ -96,6 +82,25 @@ def _status_from_last_seen(last_seen: Optional[int]) -> str:
return "Offline"
def _normalize_service_mode(value: Any, agent_id: Optional[str] = None) -> str:
try:
text = str(value or "").strip().lower()
except Exception:
text = ""
if not text and agent_id:
try:
aid = agent_id.lower()
if "-svc-" in aid or aid.endswith("-svc"):
return "system"
except Exception:
pass
if text in {"system", "svc", "service", "system_service"}:
return "system"
if text in {"interactive", "currentuser", "user", "current_user"}:
return "currentuser"
return "currentuser"
def _is_internal_request(remote_addr: Optional[str]) -> bool:
addr = (remote_addr or "").strip()
if not addr:
@@ -337,257 +342,6 @@ def _device_upsert(
cur.execute(sql, params)
class RepositoryHashCache:
"""Lightweight GitHub head cache with on-disk persistence."""
def __init__(self, adapters: "EngineServiceAdapters") -> None:
self._db_conn_factory = adapters.db_conn_factory
self._service_log = adapters.service_log
self._logger = adapters.context.logger
config = adapters.context.config or {}
default_root = Path(adapters.context.database_path).resolve().parent / "cache"
cache_root = Path(config.get("cache_dir") or default_root)
cache_root.mkdir(parents=True, exist_ok=True)
self._cache_file = cache_root / "repo_hash_cache.json"
self._cache: Dict[Tuple[str, str], Tuple[str, float]] = {}
self._lock = threading.Lock()
self._load_cache()
def _load_cache(self) -> None:
try:
if not self._cache_file.is_file():
return
data = json.loads(self._cache_file.read_text(encoding="utf-8"))
entries = data.get("entries") or {}
for key, payload in entries.items():
sha = payload.get("sha")
ts = payload.get("ts")
if not sha or ts is None:
continue
repo, _, branch = key.partition(":")
if not repo or not branch:
continue
self._cache[(repo, branch)] = (str(sha), float(ts))
except Exception:
self._logger.debug("Failed to hydrate repository hash cache", exc_info=True)
def _persist_cache(self) -> None:
try:
snapshot = {
f"{repo}:{branch}": {"sha": sha, "ts": ts}
for (repo, branch), (sha, ts) in self._cache.items()
if sha
}
payload = {"version": 1, "entries": snapshot}
tmp_path = self._cache_file.with_suffix(".tmp")
tmp_path.write_text(json.dumps(payload), encoding="utf-8")
tmp_path.replace(self._cache_file)
except Exception:
self._logger.debug("Failed to persist repository hash cache", exc_info=True)
def _resolve_original_ssl_module(self):
try:
from eventlet import patcher # type: ignore
original_ssl = patcher.original("ssl")
if original_ssl is not None:
return original_ssl
except Exception:
pass
module_name = getattr(ssl.SSLContext, "__module__", "")
if module_name != "eventlet.green.ssl":
return ssl
return None
def _build_requests_session(self):
if isinstance(requests, _RequestsStub):
return None
try:
from requests import Session # type: ignore
from requests.adapters import HTTPAdapter # type: ignore
except Exception:
return None
original_ssl = self._resolve_original_ssl_module()
if original_ssl is None:
return None
try:
context = original_ssl.create_default_context()
except Exception:
return None
tls_version = getattr(original_ssl, "TLSVersion", None)
if tls_version is not None and hasattr(context, "minimum_version"):
try:
context.minimum_version = tls_version.TLSv1_2
except Exception:
pass
class _ContextAdapter(HTTPAdapter):
def init_poolmanager(self, *args, **kwargs):
kwargs.setdefault("ssl_context", context)
return super().init_poolmanager(*args, **kwargs)
def proxy_manager_for(self, *args, **kwargs):
kwargs.setdefault("ssl_context", context)
return super().proxy_manager_for(*args, **kwargs)
session = Session()
adapter = _ContextAdapter()
session.mount("https://", adapter)
return session
def _github_token(self, *, force_refresh: bool = False) -> Optional[str]:
env_token = (request.headers.get("X-GitHub-Token") or "").strip()
if env_token:
return env_token
token = None
if not force_refresh:
token = request.headers.get("Authorization")
if token and token.lower().startswith("bearer "):
return token.split(" ", 1)[1].strip()
conn: Optional[sqlite3.Connection] = None
try:
conn = self._db_conn_factory()
cur = conn.cursor()
cur.execute("SELECT token FROM github_token LIMIT 1")
row = cur.fetchone()
if row and row[0]:
candidate = str(row[0]).strip()
if candidate:
token = candidate
except sqlite3.Error:
token = None
except Exception as exc:
self._service_log("server", f"github token lookup failed: {exc}")
token = None
finally:
if conn:
conn.close()
if token:
return token
fallback = os.environ.get("BOREALIS_GITHUB_TOKEN") or os.environ.get("GITHUB_TOKEN")
return fallback.strip() if fallback else None
def resolve(
self,
repo: str,
branch: str,
*,
ttl: int = 60,
force_refresh: bool = False,
) -> Tuple[Dict[str, Any], int]:
ttl = max(30, min(int(ttl or 60), 3600))
key = (repo, branch)
now = time.time()
with self._lock:
cached = self._cache.get(key)
if cached and not force_refresh:
sha, ts = cached
if sha and (now - ts) < ttl:
return (
{
"repo": repo,
"branch": branch,
"sha": sha,
"cached": True,
"age_seconds": now - ts,
"source": "cache",
},
200,
)
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "Borealis-Engine",
}
token = self._github_token(force_refresh=force_refresh)
if token:
headers["Authorization"] = f"Bearer {token}"
sha: Optional[str] = None
error: Optional[str] = None
session = None
try:
session = self._build_requests_session()
except Exception:
session = None
try:
target = session if session is not None else requests
resp = target.get(
f"https://api.github.com/repos/{repo}/branches/{branch}",
headers=headers,
timeout=20,
)
if resp.status_code == 200:
data = resp.json()
sha = ((data.get("commit") or {}).get("sha") or "").strip()
else:
error = f"GitHub head lookup failed: HTTP {resp.status_code}"
except RecursionError as exc:
error = f"GitHub head lookup recursion error: {exc}"
except requests.RequestException as exc:
error = f"GitHub head lookup raised: {exc}"
except Exception as exc:
error = f"GitHub head lookup unexpected error: {exc}"
finally:
if session is not None:
try:
session.close()
except Exception:
pass
if sha:
with self._lock:
self._cache[key] = (sha, now)
self._persist_cache()
return (
{
"repo": repo,
"branch": branch,
"sha": sha,
"cached": False,
"age_seconds": 0.0,
"source": "github",
},
200,
)
if error:
self._service_log("server", f"/api/repo/current_hash error: {error}")
if cached:
cached_sha, ts = cached
return (
{
"repo": repo,
"branch": branch,
"sha": cached_sha or None,
"cached": True,
"age_seconds": now - ts,
"error": error or "using cached value",
"source": "cache-stale",
},
200 if cached_sha else 503,
)
return (
{
"repo": repo,
"branch": branch,
"sha": None,
"cached": False,
"age_seconds": None,
"error": error or "unable to resolve repository head",
"source": "github",
},
503,
)
class DeviceManagementService:
"""Encapsulates database access for device-focused API routes."""
@@ -623,7 +377,7 @@ class DeviceManagementService:
self.db_conn_factory = adapters.db_conn_factory
self.service_log = adapters.service_log
self.logger = adapters.context.logger or logging.getLogger(__name__)
self.repo_cache = RepositoryHashCache(adapters)
self.repo_cache = adapters.github_integration
def _db_conn(self) -> sqlite3.Connection:
return self.db_conn_factory()
@@ -795,6 +549,76 @@ class DeviceManagementService:
self.logger.debug("Failed to list devices", exc_info=True)
return {"error": str(exc)}, 500
def list_agents(self) -> Tuple[Dict[str, Any], int]:
try:
devices = self._fetch_devices(only_agents=True)
grouped: Dict[str, Dict[str, Dict[str, Any]]] = {}
now = time.time()
for record in devices:
hostname = (record.get("hostname") or "").strip() or "unknown"
agent_id = (record.get("agent_id") or "").strip()
mode = _normalize_service_mode(record.get("service_mode"), agent_id)
if mode != "currentuser":
lowered = agent_id.lower()
if lowered.endswith("-script"):
continue
last_seen_raw = record.get("last_seen") or 0
try:
last_seen = int(last_seen_raw)
except Exception:
last_seen = 0
collector_active = bool(last_seen and (now - float(last_seen)) < 130)
agent_guid = normalize_guid(record.get("agent_guid")) if record.get("agent_guid") else ""
status_value = record.get("status")
if status_value in (None, ""):
status = "Online" if collector_active else "Offline"
else:
status = str(status_value)
payload = {
"hostname": hostname,
"agent_hostname": hostname,
"service_mode": mode,
"collector_active": collector_active,
"collector_active_ts": last_seen,
"last_seen": last_seen,
"status": status,
"agent_id": agent_id,
"agent_guid": agent_guid or "",
"agent_hash": record.get("agent_hash") or "",
"connection_type": record.get("connection_type") or "",
"connection_endpoint": record.get("connection_endpoint") or "",
"device_type": record.get("device_type") or "",
"domain": record.get("domain") or "",
"external_ip": record.get("external_ip") or "",
"internal_ip": record.get("internal_ip") or "",
"last_reboot": record.get("last_reboot") or "",
"last_user": record.get("last_user") or "",
"operating_system": record.get("operating_system") or "",
"uptime": record.get("uptime") or 0,
"site_id": record.get("site_id"),
"site_name": record.get("site_name") or "",
"site_description": record.get("site_description") or "",
}
bucket = grouped.setdefault(hostname, {})
existing = bucket.get(mode)
if not existing or last_seen >= existing.get("last_seen", 0):
bucket[mode] = payload
agents: Dict[str, Dict[str, Any]] = {}
for bucket in grouped.values():
for payload in bucket.values():
agent_key = payload.get("agent_id") or payload.get("agent_guid")
if not agent_key:
agent_key = f"{payload['hostname']}|{payload['service_mode']}"
if not payload.get("agent_id"):
payload["agent_id"] = agent_key
agents[agent_key] = payload
return {"agents": agents}, 200
except Exception as exc:
self.logger.debug("Failed to list agents", exc_info=True)
return {"error": str(exc)}, 500
def get_device_by_guid(self, guid: str) -> Tuple[Dict[str, Any], int]:
normalized_guid = normalize_guid(guid)
if not normalized_guid:
@@ -1465,18 +1289,14 @@ class DeviceManagementService:
conn.close()
def repo_current_hash(self) -> Tuple[Dict[str, Any], int]:
repo = (request.args.get("repo") or "bunny-lab-io/Borealis").strip()
branch = (request.args.get("branch") or "main").strip()
refresh_flag = (request.args.get("refresh") or "").strip().lower()
ttl_raw = request.args.get("ttl")
if "/" not in repo:
return {"error": "repo must be in the form owner/name"}, 400
try:
ttl = int(ttl_raw) if ttl_raw else 60
except ValueError:
ttl = 60
force_refresh = refresh_flag in {"1", "true", "yes", "force", "refresh"}
payload, status = self.repo_cache.resolve(repo, branch, ttl=ttl, force_refresh=force_refresh)
payload, status = self.repo_cache.current_repo_hash(
request.args.get("repo"),
request.args.get("branch"),
ttl=request.args.get("ttl"),
force_refresh=force_refresh,
)
return payload, status
def agent_hash_list(self) -> Tuple[Dict[str, Any], int]:
@@ -1525,6 +1345,11 @@ def register_management(app, adapters: "EngineServiceAdapters") -> None:
payload, status = service.save_agent_details()
return jsonify(payload), status
@blueprint.route("/api/agents", methods=["GET"])
def _list_agents():
payload, status = service.list_agents()
return jsonify(payload), status
@blueprint.route("/api/devices", methods=["GET"])
def _list_devices():
payload, status = service.list_devices()
@@ -1679,4 +1504,3 @@ def register_management(app, adapters: "EngineServiceAdapters") -> None:
return jsonify(payload), status
app.register_blueprint(blueprint)

View File

@@ -17,10 +17,10 @@
"""Scheduled job management integration for the Borealis Engine runtime."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
try: # pragma: no cover - Engine-local legacy scheduler shim
from . import legacy_job_scheduler # type: ignore
try: # pragma: no cover - legacy module import guard
import job_scheduler as legacy_job_scheduler # type: ignore
except Exception as exc: # pragma: no cover - runtime guard
legacy_job_scheduler = None # type: ignore
_SCHEDULER_IMPORT_ERROR = exc
@@ -36,8 +36,8 @@ if TYPE_CHECKING: # pragma: no cover - typing aide
def _raise_scheduler_import() -> None:
if _SCHEDULER_IMPORT_ERROR is not None:
raise RuntimeError(
"Legacy job scheduler module could not be imported; ensure "
"Data/Engine/services/API/scheduled_jobs/legacy_job_scheduler.py remains available."
"Legacy job scheduler module could not be imported; ensure Data/Server/job_scheduler.py "
"remains available during the Engine migration."
) from _SCHEDULER_IMPORT_ERROR
@@ -79,3 +79,4 @@ def register_management(app: "Flask", adapters: "EngineServiceAdapters") -> None
"""Ensure scheduled job routes are registered via the legacy scheduler."""
ensure_scheduler(app, adapters)

View File

@@ -1,24 +1,187 @@
# ======================================================
# Data\Engine\services\WebSocket\__init__.py
# Description: Placeholder hook for registering Engine Socket.IO namespaces.
# Description: Socket.IO handlers for Engine runtime quick job updates and realtime notifications.
#
# API Endpoints (if applicable): None
# ======================================================
"""WebSocket service stubs for the Borealis Engine runtime.
Future stages will move Socket.IO namespaces and event handlers here. Stage 1
only keeps a placeholder so the Engine bootstrapper can stub registration
without touching legacy behaviour.
"""
"""WebSocket service registration for the Borealis Engine runtime."""
from __future__ import annotations
import sqlite3
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Dict, Optional
from flask_socketio import SocketIO
from ...database import initialise_engine_database
from ...server import EngineContext
from ..API import _make_db_conn_factory, _make_service_logger
def _now_ts() -> int:
return int(time.time())
def _normalize_text(value: Any) -> str:
if value is None:
return ""
if isinstance(value, bytes):
try:
return value.decode("utf-8")
except Exception:
return value.decode("utf-8", errors="replace")
return str(value)
@dataclass
class EngineRealtimeAdapters:
context: EngineContext
db_conn_factory: Callable[[], sqlite3.Connection] = field(init=False)
service_log: Callable[[str, str, Optional[str]], None] = field(init=False)
def __post_init__(self) -> None:
initialise_engine_database(self.context.database_path, logger=self.context.logger)
self.db_conn_factory = _make_db_conn_factory(self.context.database_path)
log_file = str(
self.context.config.get("log_file")
or self.context.config.get("LOG_FILE")
or ""
).strip()
if log_file:
base = Path(log_file).resolve().parent
else:
base = Path(self.context.database_path).resolve().parent
self.service_log = _make_service_logger(base, self.context.logger)
def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
"""Placeholder hook for Socket.IO namespace registration."""
"""Register Socket.IO event handlers for the Engine runtime."""
context.logger.debug("Engine WebSocket services are not yet implemented.")
adapters = EngineRealtimeAdapters(context)
logger = context.logger.getChild("realtime.quick_jobs")
@socket_server.on("quick_job_result")
def _handle_quick_job_result(data: Any) -> None:
if not isinstance(data, dict):
logger.debug("quick_job_result payload ignored (non-dict): %r", data)
return
job_id_raw = data.get("job_id")
try:
job_id = int(job_id_raw)
except (TypeError, ValueError):
logger.debug("quick_job_result missing valid job_id: %r", job_id_raw)
return
status = str(data.get("status") or "").strip() or "Failed"
stdout = _normalize_text(data.get("stdout"))
stderr = _normalize_text(data.get("stderr"))
conn: Optional[sqlite3.Connection] = None
cursor = None
broadcast_payload: Optional[Dict[str, Any]] = None
try:
conn = adapters.db_conn_factory()
cursor = conn.cursor()
cursor.execute(
"UPDATE activity_history SET status=?, stdout=?, stderr=? WHERE id=?",
(status, stdout, stderr, job_id),
)
if cursor.rowcount == 0:
logger.debug("quick_job_result missing activity_history row for job_id=%s", job_id)
conn.commit()
try:
cursor.execute(
"SELECT run_id FROM scheduled_job_run_activity WHERE activity_id=?",
(job_id,),
)
link = cursor.fetchone()
except sqlite3.Error:
link = None
if link:
try:
run_id = int(link[0])
ts_now = _now_ts()
if status.lower() == "running":
cursor.execute(
"UPDATE scheduled_job_runs SET status='Running', updated_at=? WHERE id=?",
(ts_now, run_id),
)
else:
cursor.execute(
"""
UPDATE scheduled_job_runs
SET status=?,
finished_ts=COALESCE(finished_ts, ?),
updated_at=?
WHERE id=?
""",
(status, ts_now, ts_now, run_id),
)
conn.commit()
except Exception as exc: # pragma: no cover - defensive guard
logger.debug(
"quick_job_result failed to update scheduled_job_runs for job_id=%s: %s",
job_id,
exc,
)
try:
cursor.execute(
"SELECT id, hostname, status FROM activity_history WHERE id=?",
(job_id,),
)
row = cursor.fetchone()
except sqlite3.Error:
row = None
if row:
hostname = (row[1] or "").strip()
if hostname:
broadcast_payload = {
"activity_id": int(row[0]),
"hostname": hostname,
"status": row[2] or status,
"change": "updated",
"source": "quick_job",
}
adapters.service_log(
"assemblies",
f"quick_job_result processed job_id={job_id} status={status}",
)
except Exception as exc: # pragma: no cover - defensive guard
logger.warning(
"quick_job_result handler error for job_id=%s: %s",
job_id,
exc,
exc_info=True,
)
finally:
if cursor is not None:
try:
cursor.close()
except Exception:
pass
if conn is not None:
try:
conn.close()
except Exception:
pass
if broadcast_payload:
try:
socket_server.emit("device_activity_changed", broadcast_payload)
except Exception as exc: # pragma: no cover - defensive guard
logger.debug(
"Failed to emit device_activity_changed for job_id=%s: %s",
job_id,
exc,
)

View File

@@ -229,11 +229,13 @@ export default function Login({ onLogin }) {
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" }}
/>
<Box sx={{ display: "flex", justifyContent: "center", mb: 1.5 }}>
<img
src={setupQr}
alt="MFA enrollment QR code"
style={{ width: "180px", height: "180px" }}
/>
</Box>
) : null}
{formattedSecret ? (
<Box

BIN
Data/Server/Borealis.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,496 @@
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

@@ -0,0 +1 @@

View File

@@ -0,0 +1,218 @@
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

@@ -0,0 +1 @@

View File

@@ -0,0 +1,310 @@
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

@@ -0,0 +1,109 @@
"""
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

@@ -0,0 +1,140 @@
"""
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

@@ -0,0 +1,41 @@
"""
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

@@ -0,0 +1 @@

View File

@@ -0,0 +1,372 @@
"""
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

@@ -0,0 +1,71 @@
"""
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

@@ -0,0 +1,125 @@
"""
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

@@ -0,0 +1,488 @@
"""
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

@@ -0,0 +1 @@

View File

@@ -0,0 +1,35 @@
"""
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

@@ -0,0 +1,759 @@
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

@@ -0,0 +1,26 @@
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

@@ -0,0 +1 @@

View File

@@ -0,0 +1,110 @@
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

@@ -0,0 +1,168 @@
"""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

@@ -0,0 +1 @@

View File

@@ -0,0 +1,138 @@
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

@@ -0,0 +1,88 @@
#////////// 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

@@ -0,0 +1,104 @@
#////////// 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

@@ -0,0 +1,57 @@
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

@@ -0,0 +1,22 @@
<!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

@@ -0,0 +1,50 @@
{
"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.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,549 @@
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

@@ -0,0 +1,464 @@
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

@@ -0,0 +1,325 @@
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

@@ -0,0 +1,680 @@
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

@@ -0,0 +1,73 @@
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

@@ -0,0 +1,777 @@
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

@@ -0,0 +1,252 @@
/* ///////// 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

@@ -0,0 +1,219 @@
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

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,505 @@
////////// 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

@@ -0,0 +1,371 @@
////////// 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

@@ -0,0 +1,480 @@
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

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

View File

@@ -0,0 +1,514 @@
////////// 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

@@ -0,0 +1,415 @@
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

@@ -0,0 +1,374 @@
////////// 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

@@ -0,0 +1,100 @@
////////// 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

@@ -0,0 +1,485 @@
////////// 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

@@ -0,0 +1,260 @@
////////// 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

@@ -0,0 +1,332 @@
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

@@ -0,0 +1,409 @@
////////// 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

@@ -0,0 +1,593 @@
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

@@ -0,0 +1,685 @@
////////// 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

@@ -0,0 +1,385 @@
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

@@ -0,0 +1,93 @@
////////// 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

@@ -0,0 +1,21 @@
////////// 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

@@ -0,0 +1,554 @@
////////// 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

@@ -0,0 +1,310 @@
////////// 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

@@ -0,0 +1,271 @@
////////// 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

@@ -0,0 +1,326 @@
////////// 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

@@ -0,0 +1,142 @@
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

@@ -0,0 +1,179 @@
////////// 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

@@ -0,0 +1,132 @@
////////// 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

@@ -0,0 +1,238 @@
////////// 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

@@ -0,0 +1,211 @@
////////// 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

@@ -0,0 +1,140 @@
////////// 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

@@ -0,0 +1,190 @@
////////// 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

@@ -0,0 +1,193 @@
////////// 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

@@ -0,0 +1,123 @@
////////// 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

@@ -0,0 +1,218 @@
////////// 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

@@ -0,0 +1,100 @@
////////// 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

@@ -0,0 +1,200 @@
////////// 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()
};

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