Fixed Engine Flask Server Accessibility

This commit is contained in:
2025-10-26 21:46:23 -06:00
parent d3e728c127
commit 95b3e55bc7
10 changed files with 392 additions and 57 deletions

30
.vscode/tasks.json vendored
View File

@@ -31,6 +31,36 @@
}, },
"problemMatcher": [] "problemMatcher": []
}, },
{
"label": "Borealis - Engine (Production)",
"type": "shell",
"command": "powershell.exe",
"args": [
"-ExecutionPolicy", "Bypass",
"-File", "${workspaceFolder}/Borealis.ps1",
"-EngineProduction"
],
"presentation": {
"reveal": "always",
"panel": "shared"
},
"problemMatcher": []
},
{
"label": "Borealis - Engine (Dev)",
"type": "shell",
"command": "powershell.exe",
"args": [
"-ExecutionPolicy", "Bypass",
"-File", "${workspaceFolder}/Borealis.ps1",
"-EngineDev"
],
"presentation": {
"reveal": "always",
"panel": "shared"
},
"problemMatcher": []
},
{ {
"label": "Borealis - Agent - Deploy", "label": "Borealis - Agent - Deploy",
"type": "shell", "type": "shell",

View File

@@ -10,6 +10,8 @@ param(
[switch]$Flask, [switch]$Flask,
[switch]$Quick, [switch]$Quick,
[switch]$EngineTests, [switch]$EngineTests,
[switch]$EngineProduction,
[switch]$EngineDev,
[string]$InstallerCode = '' [string]$InstallerCode = ''
) )
@@ -17,6 +19,7 @@ param(
$choice = $null $choice = $null
$modeChoice = $null $modeChoice = $null
$agentSubChoice = $null $agentSubChoice = $null
$engineModeChoice = $null
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent $scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
@@ -48,6 +51,16 @@ if ($Vite -and $Flask) {
exit 1 exit 1
} }
if ($EngineProduction -and $EngineDev) {
Write-Host "Cannot combine -EngineProduction and -EngineDev." -ForegroundColor Red
exit 1
}
if (($EngineProduction -or $EngineDev) -and ($Server -or $Agent)) {
Write-Host "Engine automation switches cannot be combined with -Server or -Agent." -ForegroundColor Red
exit 1
}
if ($Server) { if ($Server) {
# Auto-select main menu option for Server when -Server flag is provided # Auto-select main menu option for Server when -Server flag is provided
$choice = '1' $choice = '1'
@@ -60,6 +73,10 @@ if ($Server) {
'launch' { $agentSubChoice = '4' } 'launch' { $agentSubChoice = '4' }
default { $agentSubChoice = '1' } default { $agentSubChoice = '1' }
} }
} elseif ($EngineProduction -or $EngineDev) {
$choice = '5'
if ($EngineProduction) { $engineModeChoice = '1' }
if ($EngineDev) { $engineModeChoice = '3' }
} }
if ($Server) { if ($Server) {
@@ -1078,7 +1095,11 @@ switch ($choice) {
foreach ($tool in @($pythonExe, $nodeExe, $npmCmd, $npxCmd)) { foreach ($tool in @($pythonExe, $nodeExe, $npmCmd, $npxCmd)) {
if (-not (Test-Path $tool)) { Write-Host "`r$($symbols.Fail) Bundled executable not found at '$tool'." -ForegroundColor Red; exit 1 } if (-not (Test-Path $tool)) { Write-Host "`r$($symbols.Fail) Bundled executable not found at '$tool'." -ForegroundColor Red; exit 1 }
} }
$env:PATH = '{0};{1};{2}' -f (Split-Path $pythonExe), (Split-Path $nodeExe), $env:PATH $nodeDir = Split-Path $nodeExe
$env:BOREALIS_NODE_DIR = $nodeDir
$env:BOREALIS_NPM_CMD = $npmCmd
$env:BOREALIS_NPX_CMD = $npxCmd
$env:PATH = '{0};{1};{2}' -f (Split-Path $pythonExe), $nodeDir, $env:PATH
if (-not $modeChoice) { if (-not $modeChoice) {
Write-Host " " Write-Host " "
@@ -1315,14 +1336,22 @@ switch ($choice) {
exit 1 exit 1
} }
} }
$env:PATH = '{0};{1};{2}' -f (Split-Path $pythonExe), (Split-Path $nodeExe), $env:PATH $nodeDir = Split-Path $nodeExe
$env:BOREALIS_NODE_DIR = $nodeDir
$env:BOREALIS_NPM_CMD = $npmCmd
$env:BOREALIS_NPX_CMD = $npxCmd
$env:PATH = '{0};{1};{2}' -f (Split-Path $pythonExe), $nodeDir, $env:PATH
Write-Host " " if (-not $engineModeChoice) {
Write-Host "Configure Borealis Engine Mode:" -ForegroundColor DarkYellow Write-Host " "
Write-Host " 1) Build & Launch > Production Flask Server @ http://localhost:5001" -ForegroundColor DarkCyan Write-Host "Configure Borealis Engine Mode:" -ForegroundColor DarkYellow
Write-Host " 2) [Skip Build] & Immediately Launch > Production Flask Server @ http://localhost:5001" -ForegroundColor DarkCyan Write-Host " 1) Build & Launch > Production Flask Server @ https://localhost:5000" -ForegroundColor DarkCyan
Write-Host " 3) Launch > [Hotload-Ready] Vite Dev Server @ http://localhost:5173" -ForegroundColor DarkCyan Write-Host " 2) [Skip Build] & Immediately Launch > Production Flask Server @ https://localhost:5000" -ForegroundColor DarkCyan
$engineModeChoice = Read-Host "Enter choice [1/2/3]" Write-Host " 3) Launch > [Hotload-Ready] Vite Dev Server @ http://localhost:5173" -ForegroundColor DarkCyan
$engineModeChoice = Read-Host "Enter choice [1/2/3]"
} else {
Write-Host "Auto-selecting Borealis Engine mode option $engineModeChoice." -ForegroundColor DarkYellow
}
$engineOperationMode = "production" $engineOperationMode = "production"
$engineImmediateLaunch = $false $engineImmediateLaunch = $false
@@ -1347,7 +1376,7 @@ switch ($choice) {
$previousEngineMode = $env:BOREALIS_ENGINE_MODE $previousEngineMode = $env:BOREALIS_ENGINE_MODE
$previousEnginePort = $env:BOREALIS_ENGINE_PORT $previousEnginePort = $env:BOREALIS_ENGINE_PORT
$env:BOREALIS_ENGINE_MODE = $engineOperationMode $env:BOREALIS_ENGINE_MODE = $engineOperationMode
$env:BOREALIS_ENGINE_PORT = "5001" $env:BOREALIS_ENGINE_PORT = "5000"
Write-Host "`nLaunching Borealis Engine..." -ForegroundColor Green Write-Host "`nLaunching Borealis Engine..." -ForegroundColor Green
Write-Host "====================================================================================" Write-Host "===================================================================================="
Write-Host "$($symbols.Running) Engine Socket Server Started..." Write-Host "$($symbols.Running) Engine Socket Server Started..."
@@ -1448,10 +1477,13 @@ switch ($choice) {
$webUIDestinationAbsolute = Join-Path $scriptDir $webUIDestination $webUIDestinationAbsolute = Join-Path $scriptDir $webUIDestination
if (Test-Path $webUIDestinationAbsolute) { if (Test-Path $webUIDestinationAbsolute) {
Push-Location $webUIDestinationAbsolute Push-Location $webUIDestinationAbsolute
if ($engineOperationMode -eq "developer") { $viteSubCommand = "dev" } else { $viteSubCommand = "build" }
$certRoot = Join-Path $scriptDir 'Certificates\Server' $certRoot = Join-Path $scriptDir 'Certificates\Server'
Ensure-EngineTlsMaterial -PythonPath $venvPython -CertificateRoot $certRoot Ensure-EngineTlsMaterial -PythonPath $venvPython -CertificateRoot $certRoot
Start-Process -NoNewWindow -FilePath $npmCmd -ArgumentList @("run", $viteSubCommand) if ($engineOperationMode -eq "developer") {
Start-Process -NoNewWindow -FilePath $npmCmd -ArgumentList @("run", "dev")
} else {
& $npmCmd run build
}
Pop-Location Pop-Location
} }
} }
@@ -1462,7 +1494,7 @@ switch ($choice) {
$previousEngineMode = $env:BOREALIS_ENGINE_MODE $previousEngineMode = $env:BOREALIS_ENGINE_MODE
$previousEnginePort = $env:BOREALIS_ENGINE_PORT $previousEnginePort = $env:BOREALIS_ENGINE_PORT
$env:BOREALIS_ENGINE_MODE = $engineOperationMode $env:BOREALIS_ENGINE_MODE = $engineOperationMode
$env:BOREALIS_ENGINE_PORT = "5001" $env:BOREALIS_ENGINE_PORT = "5000"
Write-Host "`nLaunching Borealis Engine..." -ForegroundColor Green Write-Host "`nLaunching Borealis Engine..." -ForegroundColor Green
Write-Host "====================================================================================" Write-Host "===================================================================================="
Write-Host "$($symbols.Running) Engine Socket Server Started..." Write-Host "$($symbols.Running) Engine Socket Server Started..."

View File

@@ -32,11 +32,11 @@ Lastly, everytime that you complete a stage, you will create a pull request name
- [x] Replace legacy API routes with Engine-provided blueprints gated by a flag. - [x] Replace legacy API routes with Engine-provided blueprints gated by a flag.
- [x] Emit transitional logging when Engine handles requests. - [x] Emit transitional logging when Engine handles requests.
- [ ] **Stage 6 — Plan WebUI migration** - [ ] **Stage 6 — Plan WebUI migration**
- [ ] Move static/template handling into Data/Engine/services/WebUI. - [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] Ensure that data from /Data/Server/WebUI is copied into /Engine/web-interface during engine Deployment via Borealis.ps1
- [ ] Preserve TLS-aware URL generation and caching. - [x] Preserve TLS-aware URL generation and caching.
- [ ] Add migration switch in the legacy server for WebUI delegation. - [ ] Add migration switch in the legacy server for WebUI delegation.
- [ ] Extend tests to cover critical WebUI routes. - [x] Extend tests to cover critical WebUI routes.
- [ ] **Stage 7 — Plan WebSocket migration** - [ ] **Stage 7 — Plan WebSocket migration**
- [ ] Extract Socket.IO handlers into Data/Engine/services/WebSocket. - [ ] Extract Socket.IO handlers into Data/Engine/services/WebSocket.
- [ ] Provide register_realtime hook for the Engine factory. - [ ] Provide register_realtime hook for the Engine factory.
@@ -44,5 +44,5 @@ Lastly, everytime that you complete a stage, you will create a pull request name
- [ ] Update legacy server to consume Engine WebSocket registration. - [ ] Update legacy server to consume Engine WebSocket registration.
## Current Status ## Current Status
- **Stage:** Stage 5Bridge the legacy server to Engine APIs (completed) - **Stage:** Stage 6Plan WebUI migration
- **Active Task:** Awaiting next stage instructions. - **Active Task:** Prepare legacy WebUI delegation switch (pending approval to touch legacy server).

View File

@@ -117,6 +117,13 @@ def engine_harness(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[
log_path = logs_dir / "server.log" log_path = logs_dir / "server.log"
error_log_path = logs_dir / "error.log" error_log_path = logs_dir / "error.log"
static_dir = tmp_path / "static"
static_dir.mkdir(parents=True, exist_ok=True)
(static_dir / "index.html").write_text("<html><body>Engine Test UI</body></html>", encoding="utf-8")
assets_dir = static_dir / "assets"
assets_dir.mkdir(parents=True, exist_ok=True)
(assets_dir / "example.txt").write_text("asset", encoding="utf-8")
config = { config = {
"DATABASE_PATH": str(db_path), "DATABASE_PATH": str(db_path),
"TLS_CERT_PATH": str(cert_path), "TLS_CERT_PATH": str(cert_path),
@@ -124,7 +131,8 @@ def engine_harness(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[
"TLS_BUNDLE_PATH": str(bundle_path), "TLS_BUNDLE_PATH": str(bundle_path),
"LOG_FILE": str(log_path), "LOG_FILE": str(log_path),
"ERROR_LOG_FILE": str(error_log_path), "ERROR_LOG_FILE": str(error_log_path),
"API_GROUPS": ("tokens", "enrollment"), "STATIC_FOLDER": str(static_dir),
"API_GROUPS": ("core", "tokens", "enrollment"),
} }
app, _socketio, _context = create_app(config) app, _socketio, _context = create_app(config)

View File

@@ -0,0 +1,9 @@
from __future__ import annotations
def test_health_endpoint(engine_harness):
client = engine_harness.app.test_client()
response = client.get("/health")
assert response.status_code == 200
payload = response.get_json()
assert payload == {"status": "ok"}

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
def test_web_ui_root_serves_index(engine_harness):
client = engine_harness.app.test_client()
response = client.get("/")
assert response.status_code == 200
body = response.get_data(as_text=True)
assert "Engine Test UI" in body
def test_web_ui_serves_static_assets(engine_harness):
client = engine_harness.app.test_client()
response = client.get("/assets/example.txt")
assert response.status_code == 200
assert response.get_data(as_text=True) == "asset"
def test_web_ui_spa_fallback(engine_harness):
client = engine_harness.app.test_client()
response = client.get("/devices")
assert response.status_code == 200
assert "Engine Test UI" in response.get_data(as_text=True)

View File

@@ -2,7 +2,7 @@
The bootstrapper assembles configuration via :func:`Data.Engine.config.load_runtime_config` The bootstrapper assembles configuration via :func:`Data.Engine.config.load_runtime_config`
before delegating to :func:`Data.Engine.server.create_app`. It mirrors the before delegating to :func:`Data.Engine.server.create_app`. It mirrors the
legacy server defaults by binding to ``0.0.0.0:5001`` and honouring the legacy server defaults by binding to ``0.0.0.0:5000`` and honouring the
``BOREALIS_ENGINE_*`` environment overrides for bind host/port. ``BOREALIS_ENGINE_*`` environment overrides for bind host/port.
""" """
@@ -11,14 +11,16 @@ from __future__ import annotations
import logging import logging
import os import os
import shutil import shutil
import subprocess
import time
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict, Optional
from .server import EngineContext, create_app from .server import EngineContext, create_app
DEFAULT_HOST = "0.0.0.0" DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 5001 DEFAULT_PORT = 5000
def _project_root() -> Path: def _project_root() -> Path:
@@ -37,16 +39,34 @@ def _project_root() -> Path:
def _build_runtime_config() -> Dict[str, Any]: def _build_runtime_config() -> Dict[str, Any]:
api_groups_override = os.environ.get("BOREALIS_ENGINE_API_GROUPS")
if api_groups_override:
api_groups: Any = api_groups_override
else:
api_groups = ("core", "tokens", "enrollment")
return { return {
"HOST": os.environ.get("BOREALIS_ENGINE_HOST", DEFAULT_HOST), "HOST": os.environ.get("BOREALIS_ENGINE_HOST", DEFAULT_HOST),
"PORT": int(os.environ.get("BOREALIS_ENGINE_PORT", DEFAULT_PORT)), "PORT": int(os.environ.get("BOREALIS_ENGINE_PORT", DEFAULT_PORT)),
"TLS_CERT_PATH": os.environ.get("BOREALIS_TLS_CERT"), "TLS_CERT_PATH": os.environ.get("BOREALIS_TLS_CERT"),
"TLS_KEY_PATH": os.environ.get("BOREALIS_TLS_KEY"), "TLS_KEY_PATH": os.environ.get("BOREALIS_TLS_KEY"),
"TLS_BUNDLE_PATH": os.environ.get("BOREALIS_TLS_BUNDLE"), "TLS_BUNDLE_PATH": os.environ.get("BOREALIS_TLS_BUNDLE"),
"API_GROUPS": api_groups,
} }
def _stage_web_interface_assets(logger: logging.Logger) -> None: def _stage_web_interface_assets(logger: Optional[logging.Logger] = None, *, force: bool = False) -> Path:
"""Ensure Engine web interface assets are staged and return the staging root."""
if logger is None:
logger = logging.getLogger("borealis.engine.bootstrap")
if not logger.handlers:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(asctime)s-%(name)s-%(levelname)s: %(message)s"))
logger.addHandler(handler)
logger.propagate = False
logger.setLevel(logging.INFO)
project_root = _project_root() project_root = _project_root()
engine_web_root = project_root / "Engine" / "web-interface" engine_web_root = project_root / "Engine" / "web-interface"
legacy_source = project_root / "Data" / "Server" / "WebUI" legacy_source = project_root / "Data" / "Server" / "WebUI"
@@ -56,20 +76,114 @@ def _stage_web_interface_assets(logger: logging.Logger) -> None:
f"Engine web interface source missing: {legacy_source}" f"Engine web interface source missing: {legacy_source}"
) )
index_path = engine_web_root / "index.html"
if engine_web_root.exists() and index_path.is_file() and not force:
logger.info("Engine web interface already staged at %s; skipping copy.", engine_web_root)
return engine_web_root
if engine_web_root.exists(): if engine_web_root.exists():
shutil.rmtree(engine_web_root) shutil.rmtree(engine_web_root)
shutil.copytree(legacy_source, engine_web_root) shutil.copytree(legacy_source, engine_web_root)
index_path = engine_web_root / "index.html"
if not index_path.is_file(): if not index_path.is_file():
raise RuntimeError( raise RuntimeError(
f"Engine web interface staging failed; missing {index_path}" f"Engine web interface staging failed; missing {index_path}"
) )
logger.info( logger.info("Engine web interface staged from %s to %s", legacy_source, engine_web_root)
"Engine web interface staged from %s to %s", legacy_source, engine_web_root return engine_web_root
)
def _determine_static_folder(staging_root: Path) -> str:
for candidate in (staging_root / "build", staging_root / "dist", staging_root):
if candidate.is_dir():
return str(candidate)
return str(staging_root)
def _resolve_npm_executable() -> str:
env_cmd = os.environ.get("BOREALIS_NPM_CMD")
if env_cmd:
candidate = Path(env_cmd).expanduser()
if candidate.is_file():
return str(candidate)
node_dir = os.environ.get("BOREALIS_NODE_DIR")
if node_dir:
candidate = Path(node_dir) / "npm.cmd"
if candidate.is_file():
return str(candidate)
candidate = Path(node_dir) / "npm.exe"
if candidate.is_file():
return str(candidate)
candidate = Path(node_dir) / "npm"
if candidate.is_file():
return str(candidate)
if os.name == "nt":
return "npm.cmd"
return "npm"
def _run_npm(args: list[str], cwd: Path, logger: logging.Logger) -> None:
command = [_resolve_npm_executable()] + args
logger.info("Running npm command: %s", " ".join(command))
start = time.time()
try:
completed = subprocess.run(command, cwd=str(cwd), capture_output=True, text=True, check=False)
except FileNotFoundError as exc:
raise RuntimeError("npm executable not found; ensure Node.js dependencies are installed.") from exc
duration = time.time() - start
if completed.returncode != 0:
logger.error(
"npm command failed (%ss): %s\nstdout: %s\nstderr: %s",
f"{duration:.2f}",
" ".join(command),
completed.stdout.strip(),
completed.stderr.strip(),
)
raise RuntimeError(f"npm command {' '.join(command)} failed with exit code {completed.returncode}")
stdout = completed.stdout.strip()
if stdout:
logger.debug("npm stdout: %s", stdout)
stderr = completed.stderr.strip()
if stderr:
logger.debug("npm stderr: %s", stderr)
logger.info("npm command completed in %.2fs", duration)
def _ensure_web_ui_build(staging_root: Path, logger: logging.Logger, *, mode: str) -> str:
package_json = staging_root / "package.json"
node_modules = staging_root / "node_modules"
build_dir = staging_root / "build"
needs_install = not node_modules.is_dir()
needs_build = not build_dir.is_dir()
if not needs_build and package_json.is_file():
build_index = build_dir / "index.html"
if build_index.is_file():
needs_build = build_index.stat().st_mtime < package_json.stat().st_mtime
else:
needs_build = True
if mode == "developer":
logger.info("Engine launched in developer mode; skipping WebUI build step.")
return _determine_static_folder(staging_root)
if not package_json.is_file():
logger.warning("WebUI package.json not found at %s; continuing without rebuild.", package_json)
return _determine_static_folder(staging_root)
if needs_install:
_run_npm(["install", "--silent", "--no-fund", "--audit=false"], staging_root, logger)
if needs_build:
_run_npm(["run", "build"], staging_root, logger)
else:
logger.info("Existing WebUI build found at %s; reuse.", build_dir)
return _determine_static_folder(staging_root)
def _ensure_tls_material(context: EngineContext) -> None: def _ensure_tls_material(context: EngineContext) -> None:
@@ -137,13 +251,24 @@ def _prepare_tls_run_kwargs(context: EngineContext) -> Dict[str, Any]:
def main() -> None: def main() -> None:
config = _build_runtime_config() config = _build_runtime_config()
mode = os.environ.get("BOREALIS_ENGINE_MODE", "production").strip().lower() or "production"
try:
staging_root = _stage_web_interface_assets()
except Exception as exc:
logging.getLogger("borealis.engine.bootstrap").error(
"Failed to stage Engine web interface: %s", exc
)
raise
if staging_root:
bootstrap_logger = logging.getLogger("borealis.engine.bootstrap")
static_folder = _ensure_web_ui_build(staging_root, bootstrap_logger, mode=mode)
config.setdefault("STATIC_FOLDER", static_folder)
app, socketio, context = create_app(config) app, socketio, context = create_app(config)
try: if staging_root:
_stage_web_interface_assets(context.logger) context.logger.info("Engine WebUI assets ready at %s", config["STATIC_FOLDER"])
except Exception as exc:
context.logger.error("Failed to stage Engine web interface: %s", exc)
raise
host = config.get("HOST", DEFAULT_HOST) host = config.get("HOST", DEFAULT_HOST)
port = int(config.get("PORT", DEFAULT_PORT)) port = int(config.get("PORT", DEFAULT_PORT))

View File

@@ -47,7 +47,26 @@ except Exception: # pragma: no-cover - Engine configuration still works without
ENGINE_DIR = Path(__file__).resolve().parent ENGINE_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = ENGINE_DIR.parent.parent
def _discover_project_root() -> Path:
"""Locate the project root by searching for Borealis.ps1 or using overrides."""
env_override = os.environ.get("BOREALIS_PROJECT_ROOT")
if env_override:
env_path = Path(env_override).expanduser().resolve()
if env_path.is_dir():
return env_path
current = ENGINE_DIR
for candidate in (current, *current.parents):
if (candidate / "Borealis.ps1").is_file():
return candidate
return ENGINE_DIR.parent.parent
PROJECT_ROOT = _discover_project_root()
DEFAULT_DATABASE_PATH = PROJECT_ROOT / "database.db" DEFAULT_DATABASE_PATH = PROJECT_ROOT / "database.db"
LOG_ROOT = PROJECT_ROOT / "Logs" / "Engine" LOG_ROOT = PROJECT_ROOT / "Logs" / "Engine"
LOG_FILE_PATH = LOG_ROOT / "engine.log" LOG_FILE_PATH = LOG_ROOT / "engine.log"
@@ -66,26 +85,28 @@ def _ensure_parent(path: Path) -> None:
def _resolve_static_folder() -> str: def _resolve_static_folder() -> str:
candidate_roots = [ candidate_roots = [
PROJECT_ROOT / "Engine" / "web-interface", PROJECT_ROOT / "Engine" / "web-interface",
PROJECT_ROOT / "web-interface",
ENGINE_DIR.parent / "Engine" / "web-interface",
ENGINE_DIR.parent / "web-interface",
ENGINE_DIR / "web-interface", ENGINE_DIR / "web-interface",
PROJECT_ROOT / "Data" / "Server" / "web-interface", PROJECT_ROOT / "Data" / "Server" / "web-interface",
] ]
candidates = [] resolved_roots: List[Path] = []
for root in candidate_roots: for root in candidate_roots:
absolute_root = root.resolve() absolute_root = root.expanduser().resolve()
candidates.extend( if absolute_root not in resolved_roots:
[ resolved_roots.append(absolute_root)
absolute_root / "build",
absolute_root / "dist",
absolute_root,
]
)
for candidate in candidates: for root in resolved_roots:
if candidate.is_dir(): for candidate in (root / "build", root / "dist", root):
return str(candidate) if candidate.is_dir():
return str(candidate)
return str(candidates[0]) if resolved_roots:
return str(resolved_roots[0])
return str((PROJECT_ROOT / "Engine" / "web-interface").resolve())
def _parse_origins(raw: Optional[Any]) -> Optional[List[str]]: def _parse_origins(raw: Optional[Any]) -> Optional[List[str]]:

View File

@@ -17,7 +17,7 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Iterable, Mapping, Optional, Sequence from typing import Any, Callable, Iterable, Mapping, Optional, Sequence
from flask import Flask from flask import Blueprint, Flask, jsonify
from Modules.auth import jwt_service as jwt_service_module from Modules.auth import jwt_service as jwt_service_module
from Modules.auth.dpop import DPoPValidator from Modules.auth.dpop import DPoPValidator
@@ -191,18 +191,36 @@ _GROUP_REGISTRARS: Mapping[str, Callable[[Flask, LegacyServiceAdapters], None]]
} }
def _register_core(app: Flask, context: EngineContext) -> None:
"""Register core utility endpoints that do not require legacy adapters."""
blueprint = Blueprint("engine_core", __name__)
@blueprint.route("/health", methods=["GET"])
def health() -> Any:
return jsonify({"status": "ok"})
app.register_blueprint(blueprint)
context.logger.info("Engine registered API group 'core'.")
def register_api(app: Flask, context: EngineContext) -> None: def register_api(app: Flask, context: EngineContext) -> None:
"""Register Engine API blueprints based on the enabled groups.""" """Register Engine API blueprints based on the enabled groups."""
enabled_groups: Iterable[str] = context.api_groups or DEFAULT_API_GROUPS enabled_groups: Iterable[str] = context.api_groups or DEFAULT_API_GROUPS
normalized = [group.strip().lower() for group in enabled_groups if group] normalized = [group.strip().lower() for group in enabled_groups if group]
adapters = LegacyServiceAdapters(context) adapters: Optional[LegacyServiceAdapters] = None
for group in normalized: for group in normalized:
if group == "core":
_register_core(app, context)
continue
if adapters is None:
adapters = LegacyServiceAdapters(context)
registrar = _GROUP_REGISTRARS.get(group) registrar = _GROUP_REGISTRARS.get(group)
if registrar is None: if registrar is None:
context.logger.info("Engine API group '%s' is not implemented; skipping.", group) context.logger.info("Engine API group '%s' is not implemented; skipping.", group)
continue continue
registrar(app, adapters) registrar(app, adapters)
context.logger.info("Engine registered API group '%s'.", group) context.logger.info("Engine registered API group '%s'.", group)

View File

@@ -1,17 +1,86 @@
"""WebUI service stubs for the Borealis Engine runtime. """WebUI static asset handling for the Borealis Engine runtime."""
The future WebUI migration will centralise static asset serving, template
rendering, and dev-server proxying here. Stage 1 keeps the placeholder so the
application factory can stub out registration calls.
"""
from __future__ import annotations from __future__ import annotations
from flask import Flask import logging
from pathlib import Path
from typing import Optional
from flask import Blueprint, Flask, request, send_from_directory
from werkzeug.exceptions import NotFound
from ...server import EngineContext from ...server import EngineContext
_WEBUI_BLUEPRINT_NAME = "engine_webui"
def _resolve_static_root(app: Flask, context: EngineContext) -> Optional[Path]:
static_folder = app.static_folder
if not static_folder:
context.logger.error("Engine WebUI static folder is not configured.")
return None
static_path = Path(static_folder).resolve()
if not static_path.is_dir():
context.logger.error("Engine WebUI static folder missing: %s", static_path)
return None
index_path = static_path / "index.html"
if not index_path.is_file():
context.logger.error("Engine WebUI missing index.html at %s", index_path)
return None
return static_path
def _register_spa_routes(app: Flask, static_root: Path, logger: logging.Logger) -> None:
blueprint = Blueprint(
_WEBUI_BLUEPRINT_NAME,
__name__,
static_folder=str(static_root),
static_url_path="",
)
def send_index():
return send_from_directory(str(static_root), "index.html")
@blueprint.route("/", defaults={"requested_path": ""})
@blueprint.route("/<path:requested_path>")
def spa_entry(requested_path: str) -> object:
if requested_path:
try:
return send_from_directory(str(static_root), requested_path)
except NotFound:
logger.debug("Engine WebUI asset not found: %s", requested_path)
return send_index()
app.register_blueprint(blueprint)
if getattr(app, "_engine_webui_fallback_installed", False):
return
def _spa_fallback(error):
request_path = (request.path or "").strip()
if request_path.startswith("/api") or request_path.startswith("/socket.io"):
return error
if "." in Path(request_path).name:
return error
if request.method not in {"GET", "HEAD"}:
return error
try:
return send_index()
except Exception:
return error
app.register_error_handler(404, _spa_fallback)
setattr(app, "_engine_webui_fallback_installed", True)
def register_web_ui(app: Flask, context: EngineContext) -> None: def register_web_ui(app: Flask, context: EngineContext) -> None:
"""Placeholder hook for WebUI route registration.""" """Register static asset routes for the Engine WebUI."""
context.logger.debug("Engine WebUI services are not yet implemented.") static_root = _resolve_static_root(app, context)
if static_root is None:
return
_register_spa_routes(app, static_root, context.logger)
context.logger.info("Engine WebUI registered static assets from %s", static_root)