mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-15 00:35:47 -07:00
Revert from Gitea Mirror Due to Catastrophic Destruction in Github
This commit is contained in:
@@ -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."
|
||||
}
|
||||
|
||||
52
Data/Engine/CODE_MIGRATION_TRACKER.md
Normal file
52
Data/Engine/CODE_MIGRATION_TRACKER.md
Normal 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).
|
||||
66
Data/Engine/Unit_Tests/test_access_management_api.py
Normal file
66
Data/Engine/Unit_Tests/test_access_management_api.py
Normal 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"
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -7,4 +7,5 @@ cryptography
|
||||
PyJWT[crypto]
|
||||
pyotp
|
||||
qrcode
|
||||
Pillow
|
||||
requests
|
||||
|
||||
12
Data/Engine/integrations/__init__.py
Normal file
12
Data/Engine/integrations/__init__.py
Normal 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"]
|
||||
605
Data/Engine/integrations/github.py
Normal file
605
Data/Engine/integrations/github.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
146
Data/Engine/services/API/access_management/github.py
Normal file
146
Data/Engine/services/API/access_management/github.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
BIN
Data/Server/Borealis.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
1
Data/Server/Modules/__init__.py
Normal file
1
Data/Server/Modules/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
Data/Server/Modules/admin/__init__.py
Normal file
1
Data/Server/Modules/admin/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
496
Data/Server/Modules/admin/routes.py
Normal file
496
Data/Server/Modules/admin/routes.py
Normal 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))
|
||||
1
Data/Server/Modules/agents/__init__.py
Normal file
1
Data/Server/Modules/agents/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
218
Data/Server/Modules/agents/routes.py
Normal file
218
Data/Server/Modules/agents/routes.py
Normal 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)
|
||||
1
Data/Server/Modules/auth/__init__.py
Normal file
1
Data/Server/Modules/auth/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
310
Data/Server/Modules/auth/device_auth.py
Normal file
310
Data/Server/Modules/auth/device_auth.py
Normal 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
|
||||
109
Data/Server/Modules/auth/dpop.py
Normal file
109
Data/Server/Modules/auth/dpop.py
Normal 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")
|
||||
140
Data/Server/Modules/auth/jwt_service.py
Normal file
140
Data/Server/Modules/auth/jwt_service.py
Normal 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
|
||||
|
||||
41
Data/Server/Modules/auth/rate_limit.py
Normal file
41
Data/Server/Modules/auth/rate_limit.py
Normal 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)
|
||||
1
Data/Server/Modules/crypto/__init__.py
Normal file
1
Data/Server/Modules/crypto/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
372
Data/Server/Modules/crypto/certificates.py
Normal file
372
Data/Server/Modules/crypto/certificates.py
Normal 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)
|
||||
71
Data/Server/Modules/crypto/keys.py
Normal file
71
Data/Server/Modules/crypto/keys.py
Normal 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,
|
||||
)
|
||||
125
Data/Server/Modules/crypto/signing.py
Normal file
125
Data/Server/Modules/crypto/signing.py
Normal 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
|
||||
488
Data/Server/Modules/db_migrations.py
Normal file
488
Data/Server/Modules/db_migrations.py
Normal 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()
|
||||
1
Data/Server/Modules/enrollment/__init__.py
Normal file
1
Data/Server/Modules/enrollment/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
35
Data/Server/Modules/enrollment/nonce_store.py
Normal file
35
Data/Server/Modules/enrollment/nonce_store.py
Normal 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
|
||||
759
Data/Server/Modules/enrollment/routes.py
Normal file
759
Data/Server/Modules/enrollment/routes.py
Normal 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:]}"
|
||||
26
Data/Server/Modules/guid_utils.py
Normal file
26
Data/Server/Modules/guid_utils.py
Normal 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()
|
||||
1
Data/Server/Modules/jobs/__init__.py
Normal file
1
Data/Server/Modules/jobs/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
110
Data/Server/Modules/jobs/prune.py
Normal file
110
Data/Server/Modules/jobs/prune.py
Normal 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")
|
||||
168
Data/Server/Modules/runtime.py
Normal file
168
Data/Server/Modules/runtime.py
Normal 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
|
||||
1
Data/Server/Modules/tokens/__init__.py
Normal file
1
Data/Server/Modules/tokens/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
138
Data/Server/Modules/tokens/routes.py
Normal file
138
Data/Server/Modules/tokens/routes.py
Normal 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)
|
||||
88
Data/Server/Package-Borealis-Server.ps1
Normal file
88
Data/Server/Package-Borealis-Server.ps1
Normal 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
|
||||
}
|
||||
104
Data/Server/Python_API_Endpoints/ocr_engines.py
Normal file
104
Data/Server/Python_API_Endpoints/ocr_engines.py
Normal 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()]
|
||||
57
Data/Server/Python_API_Endpoints/script_engines.py
Normal file
57
Data/Server/Python_API_Endpoints/script_engines.py
Normal 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 ""
|
||||
|
||||
BIN
Data/Server/Sounds/Short_Beep.wav
Normal file
BIN
Data/Server/Sounds/Short_Beep.wav
Normal file
Binary file not shown.
22
Data/Server/WebUI/index.html
Normal file
22
Data/Server/WebUI/index.html
Normal 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>
|
||||
50
Data/Server/WebUI/package.json
Normal file
50
Data/Server/WebUI/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
Data/Server/WebUI/public/Borealis_Logo.png
Normal file
BIN
Data/Server/WebUI/public/Borealis_Logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 327 KiB |
BIN
Data/Server/WebUI/public/Borealis_Logo_Full.png
Normal file
BIN
Data/Server/WebUI/public/Borealis_Logo_Full.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 388 KiB |
BIN
Data/Server/WebUI/public/favicon.ico
Normal file
BIN
Data/Server/WebUI/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
549
Data/Server/WebUI/src/Access_Management/Credential_Editor.jsx
Normal file
549
Data/Server/WebUI/src/Access_Management/Credential_Editor.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
464
Data/Server/WebUI/src/Access_Management/Credential_List.jsx
Normal file
464
Data/Server/WebUI/src/Access_Management/Credential_List.jsx
Normal 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.`
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
325
Data/Server/WebUI/src/Access_Management/Github_API_Token.jsx
Normal file
325
Data/Server/WebUI/src/Access_Management/Github_API_Token.jsx
Normal 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>{' '}
|
||||
❯ <b>Personal Access Tokens ❯ Tokens (Classic) ❯ Generate New Token ❯ New Personal Access Token (Classic)</b>
|
||||
</Typography>
|
||||
|
||||
<br></br>
|
||||
<Typography variant="body2" sx={{ color: "#ccc" }}>
|
||||
<Box component="span" sx={{ fontWeight: 600 }}>Note:</Box>{' '}
|
||||
<Box component="code" sx={{ bgcolor: "#222", px: 0.75, py: 0.25, borderRadius: 1, fontSize: "0.85rem" }}>
|
||||
Borealis Automation Platform
|
||||
</Box>
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#ccc" }}>
|
||||
<Box component="span" sx={{ fontWeight: 600 }}>Scope:</Box>{' '}
|
||||
<Box component="code" sx={{ bgcolor: "#222", px: 0.75, py: 0.25, borderRadius: 1, fontSize: "0.85rem" }}>
|
||||
public_repo
|
||||
</Box>
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#ccc" }}>
|
||||
<Box component="span" sx={{ fontWeight: 600 }}>Expiration:</Box>{' '}
|
||||
<Box component="code" sx={{ bgcolor: "#222", px: 0.75, py: 0.25, borderRadius: 1, fontSize: "0.85rem" }}>
|
||||
No Expiration
|
||||
</Box>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ px: 2, py: 2, display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
<TextField
|
||||
label="Personal Access Token"
|
||||
value={inputValue}
|
||||
onChange={(event) => setInputValue(event.target.value)}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
sx={fieldSx}
|
||||
disabled={saving || loading}
|
||||
type={showToken ? "text" : "password"}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment
|
||||
position="end"
|
||||
sx={{ mr: -1, display: "flex", alignItems: "center", gap: 1 }}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={toggleReveal}
|
||||
disabled={loading || saving}
|
||||
startIcon={showToken ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
||||
sx={{
|
||||
bgcolor: "#3a3a3a",
|
||||
color: "#f5f7fa",
|
||||
minWidth: 96,
|
||||
mr: 0.5,
|
||||
"&:hover": { bgcolor: "#4a4a4a" }
|
||||
}}
|
||||
>
|
||||
{showToken ? "Hide" : "Reveal"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleSave}
|
||||
disabled={saving || loading}
|
||||
startIcon={!saving ? <SaveIcon /> : null}
|
||||
sx={{
|
||||
bgcolor: "#58a6ff",
|
||||
color: "#0b0f19",
|
||||
minWidth: 88,
|
||||
mr: 1,
|
||||
"&:hover": { bgcolor: "#7db7ff" }
|
||||
}}
|
||||
>
|
||||
{saving ? <CircularProgress size={16} sx={{ color: "#0b0f19" }} /> : "Save"}
|
||||
</Button>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<RefreshIcon />}
|
||||
sx={{ borderColor: "#58a6ff", color: "#58a6ff" }}
|
||||
onClick={hydrate}
|
||||
disabled={loading || saving}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
{(verificationMessage.text || (!dirty && verification.rateLimit)) && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
color: verificationMessage.color || "#7db7ff",
|
||||
textAlign: "right"
|
||||
}}
|
||||
>
|
||||
{verificationMessage.text && `${verificationMessage.text} `}
|
||||
{!dirty &&
|
||||
verification.rateLimit &&
|
||||
`- Hourly Request Rate Limit: ${verification.rateLimit.toLocaleString()}`}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{loading && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
color: "#7db7ff",
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
borderBottom: "1px solid #2a2a2a"
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
|
||||
<Typography variant="body2">Loading token…</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{fetchError && (
|
||||
<Box sx={{ px: 2, py: 1.5, color: "#ff8080", borderBottom: "1px solid #2a2a2a" }}>
|
||||
<Typography variant="body2">{fetchError}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
680
Data/Server/WebUI/src/Access_Management/Users.jsx
Normal file
680
Data/Server/WebUI/src/Access_Management/Users.jsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
73
Data/Server/WebUI/src/Admin/Server_Info.jsx
Normal file
73
Data/Server/WebUI/src/Admin/Server_Info.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1392
Data/Server/WebUI/src/App.jsx
Normal file
1392
Data/Server/WebUI/src/App.jsx
Normal file
File diff suppressed because it is too large
Load Diff
1269
Data/Server/WebUI/src/Assemblies/Assembly_Editor.jsx
Normal file
1269
Data/Server/WebUI/src/Assemblies/Assembly_Editor.jsx
Normal file
File diff suppressed because it is too large
Load Diff
777
Data/Server/WebUI/src/Assemblies/Assembly_List.jsx
Normal file
777
Data/Server/WebUI/src/Assemblies/Assembly_List.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
252
Data/Server/WebUI/src/Borealis.css
Normal file
252
Data/Server/WebUI/src/Borealis.css
Normal 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;
|
||||
}
|
||||
219
Data/Server/WebUI/src/Devices/Add_Device.jsx
Normal file
219
Data/Server/WebUI/src/Devices/Add_Device.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
Data/Server/WebUI/src/Devices/Agent_Devices.jsx
Normal file
13
Data/Server/WebUI/src/Devices/Agent_Devices.jsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
505
Data/Server/WebUI/src/Devices/Device_Approvals.jsx
Normal file
505
Data/Server/WebUI/src/Devices/Device_Approvals.jsx
Normal 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);
|
||||
1383
Data/Server/WebUI/src/Devices/Device_Details.jsx
Normal file
1383
Data/Server/WebUI/src/Devices/Device_Details.jsx
Normal file
File diff suppressed because it is too large
Load Diff
1832
Data/Server/WebUI/src/Devices/Device_List.jsx
Normal file
1832
Data/Server/WebUI/src/Devices/Device_List.jsx
Normal file
File diff suppressed because it is too large
Load Diff
371
Data/Server/WebUI/src/Devices/Enrollment_Codes.jsx
Normal file
371
Data/Server/WebUI/src/Devices/Enrollment_Codes.jsx
Normal 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);
|
||||
480
Data/Server/WebUI/src/Devices/SSH_Devices.jsx
Normal file
480
Data/Server/WebUI/src/Devices/SSH_Devices.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
Data/Server/WebUI/src/Devices/WinRM_Devices.jsx
Normal file
6
Data/Server/WebUI/src/Devices/WinRM_Devices.jsx
Normal 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" />;
|
||||
}
|
||||
514
Data/Server/WebUI/src/Dialogs.jsx
Normal file
514
Data/Server/WebUI/src/Dialogs.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
415
Data/Server/WebUI/src/Flow_Editor/Context_Menu_Sidebar.jsx
Normal file
415
Data/Server/WebUI/src/Flow_Editor/Context_Menu_Sidebar.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
374
Data/Server/WebUI/src/Flow_Editor/Flow_Editor.jsx
Normal file
374
Data/Server/WebUI/src/Flow_Editor/Flow_Editor.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
Data/Server/WebUI/src/Flow_Editor/Flow_Tabs.jsx
Normal file
100
Data/Server/WebUI/src/Flow_Editor/Flow_Tabs.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
485
Data/Server/WebUI/src/Flow_Editor/Node_Configuration_Sidebar.jsx
Normal file
485
Data/Server/WebUI/src/Flow_Editor/Node_Configuration_Sidebar.jsx
Normal 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 ---- */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
260
Data/Server/WebUI/src/Flow_Editor/Node_Sidebar.jsx
Normal file
260
Data/Server/WebUI/src/Flow_Editor/Node_Sidebar.jsx
Normal 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"
|
||||
}
|
||||
};
|
||||
332
Data/Server/WebUI/src/Login.jsx
Normal file
332
Data/Server/WebUI/src/Login.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
409
Data/Server/WebUI/src/Navigation_Sidebar.jsx
Normal file
409
Data/Server/WebUI/src/Navigation_Sidebar.jsx
Normal 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);
|
||||
2141
Data/Server/WebUI/src/Scheduling/Create_Job.jsx
Normal file
2141
Data/Server/WebUI/src/Scheduling/Create_Job.jsx
Normal file
File diff suppressed because it is too large
Load Diff
593
Data/Server/WebUI/src/Scheduling/Quick_Job.jsx
Normal file
593
Data/Server/WebUI/src/Scheduling/Quick_Job.jsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
685
Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx
Normal file
685
Data/Server/WebUI/src/Scheduling/Scheduled_Jobs_List.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
385
Data/Server/WebUI/src/Sites/Site_List.jsx
Normal file
385
Data/Server/WebUI/src/Sites/Site_List.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
Data/Server/WebUI/src/Status_Bar.jsx
Normal file
93
Data/Server/WebUI/src/Status_Bar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
Data/Server/WebUI/src/index.jsx
Normal file
21
Data/Server/WebUI/src/index.jsx
Normal 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>
|
||||
);
|
||||
554
Data/Server/WebUI/src/nodes/Agent/Node_Agent.jsx
Normal file
554
Data/Server/WebUI/src/nodes/Agent/Node_Agent.jsx
Normal 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()
|
||||
};
|
||||
310
Data/Server/WebUI/src/nodes/Agent/Node_Agent_Role_Macro.jsx
Normal file
310
Data/Server/WebUI/src/nodes/Agent/Node_Agent_Role_Macro.jsx
Normal 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()
|
||||
};
|
||||
271
Data/Server/WebUI/src/nodes/Agent/Node_Agent_Role_Screenshot.jsx
Normal file
271
Data/Server/WebUI/src/nodes/Agent/Node_Agent_Role_Screenshot.jsx
Normal 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()
|
||||
};
|
||||
326
Data/Server/WebUI/src/nodes/Alerting/Node_Alert_Sound.jsx
Normal file
326
Data/Server/WebUI/src/nodes/Alerting/Node_Alert_Sound.jsx
Normal 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
|
||||
};
|
||||
@@ -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()
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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()
|
||||
};
|
||||
@@ -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()
|
||||
};
|
||||
@@ -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()
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
193
Data/Server/WebUI/src/nodes/Data Collection/Node_API_Request.jsx
Normal file
193
Data/Server/WebUI/src/nodes/Data Collection/Node_API_Request.jsx
Normal 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()
|
||||
};
|
||||
123
Data/Server/WebUI/src/nodes/Data Collection/Node_Upload_Text.jsx
Normal file
123
Data/Server/WebUI/src/nodes/Data Collection/Node_Upload_Text.jsx
Normal 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
|
||||
};
|
||||
218
Data/Server/WebUI/src/nodes/Flow Control/Node_Edge_Toggle.jsx
Normal file
218
Data/Server/WebUI/src/nodes/Flow Control/Node_Edge_Toggle.jsx
Normal 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()
|
||||
};
|
||||
100
Data/Server/WebUI/src/nodes/General Purpose/Node_Data.jsx
Normal file
100
Data/Server/WebUI/src/nodes/General Purpose/Node_Data.jsx
Normal 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()
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user