mirror of
https://github.com/hansjone/netx.git
synced 2026-10-10 04:40:45 +08:00
Add Windows packaging with bundled UI and dual-mode PostgreSQL.
Ship installable releases via zip/Inno Setup with optional bundled Postgres, API-hosted SPA, manual update path, and CI workflow for future tag builds. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8bdaaaacbf
commit
477ec4b120
24 changed files with 1332 additions and 12 deletions
|
|
@ -35,6 +35,18 @@ _PUBLIC_PREFIXES = (
|
|||
"/assets",
|
||||
)
|
||||
|
||||
# Authenticated API / infra only. Everything else (SPA routes, favicon, …) is public.
|
||||
def _is_api_auth_path(path: str) -> bool:
|
||||
p = str(path or "")
|
||||
if p.startswith("/v1/"):
|
||||
return True
|
||||
if p in ("/metrics", "/metrics/json", "/v1/metrics/json", "/openapi.json"):
|
||||
return True
|
||||
if p.startswith("/docs") or p.startswith("/redoc"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# While must_change_password is true, only these authenticated endpoints are allowed.
|
||||
_PASSWORD_CHANGE_ALLOW = frozenset(
|
||||
{
|
||||
|
|
@ -60,7 +72,12 @@ def _is_public(path: str) -> bool:
|
|||
or p.startswith("/redoc")
|
||||
):
|
||||
return True
|
||||
return any(p.startswith(pref) for pref in _PUBLIC_PREFIXES)
|
||||
if any(p.startswith(pref) for pref in _PUBLIC_PREFIXES):
|
||||
return True
|
||||
# Bundled UI routes (/login, /topology, …) must load without a token.
|
||||
if not _is_api_auth_path(p):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ class Settings(BaseSettings):
|
|||
source_type: str = "gateway_export_excel"
|
||||
parser_config: str = "netx_api/config/parsers/zte_alarm_monitor_v1.yaml"
|
||||
frontend_url: str = "http://127.0.0.1:5173"
|
||||
# Built Vite UI (web/dist). Empty / missing → API-only root JSON (dev with Vite).
|
||||
ui_dist_dir: str = "web/dist"
|
||||
oclaw_analyze_url: str = "http://127.0.0.1:8787/admin/api/ops-ai/analyze-sync"
|
||||
oclaw_analyze_token: str = ""
|
||||
oclaw_health_url: str = "http://127.0.0.1:8787/admin/api/ops-ai/health"
|
||||
|
|
|
|||
|
|
@ -95,16 +95,21 @@ def health() -> dict[str, str]:
|
|||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root() -> dict:
|
||||
return {
|
||||
"ok": True,
|
||||
"mode": "api_only",
|
||||
"message": "netx UI is served by Vite frontend only",
|
||||
"frontend_url": settings.frontend_url,
|
||||
"api_health": "/health",
|
||||
"api_status": "/v1/integrations/status",
|
||||
}
|
||||
from .ui_static import mount_ui_if_present # noqa: E402
|
||||
|
||||
_UI_MOUNTED = mount_ui_if_present(app)
|
||||
if not _UI_MOUNTED:
|
||||
|
||||
@app.get("/")
|
||||
def root() -> dict:
|
||||
return {
|
||||
"ok": True,
|
||||
"mode": "api_only",
|
||||
"message": "netx UI is served by Vite frontend only",
|
||||
"frontend_url": settings.frontend_url,
|
||||
"api_health": "/health",
|
||||
"api_status": "/v1/integrations/status",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
84
netx_api/ui_static.py
Normal file
84
netx_api/ui_static.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""Optional SPA hosting from a Vite build (web/dist)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from .config import settings
|
||||
|
||||
_log = logging.getLogger("netx.ui")
|
||||
|
||||
# Paths that must never fall through to index.html.
|
||||
_API_PREFIXES = (
|
||||
"/v1/",
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
"/metrics",
|
||||
"/health",
|
||||
)
|
||||
|
||||
|
||||
def resolve_ui_dist() -> Path | None:
|
||||
raw = str(getattr(settings, "ui_dist_dir", "") or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
p = Path(raw)
|
||||
if not p.is_absolute():
|
||||
p = Path(__file__).resolve().parents[1] / p
|
||||
index = p / "index.html"
|
||||
if index.is_file():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _is_api_or_infra(path: str) -> bool:
|
||||
p = path or "/"
|
||||
if p == "/health" or p.startswith("/health/"):
|
||||
return True
|
||||
if p in ("/metrics", "/metrics/json", "/openapi.json"):
|
||||
return True
|
||||
return any(p == pref.rstrip("/") or p.startswith(pref) for pref in _API_PREFIXES)
|
||||
|
||||
|
||||
def mount_ui_if_present(app: FastAPI) -> bool:
|
||||
"""Mount /assets + SPA fallback when ui_dist_dir has index.html. Returns True if mounted."""
|
||||
dist = resolve_ui_dist()
|
||||
if dist is None:
|
||||
return False
|
||||
|
||||
assets = dist / "assets"
|
||||
if assets.is_dir():
|
||||
app.mount("/assets", StaticFiles(directory=str(assets)), name="ui_assets")
|
||||
|
||||
index_path = dist / "index.html"
|
||||
|
||||
@app.get("/")
|
||||
async def spa_root() -> FileResponse:
|
||||
return FileResponse(index_path)
|
||||
|
||||
@app.api_route("/{full_path:path}", methods=["GET", "HEAD"], include_in_schema=False)
|
||||
async def spa_fallback(full_path: str, request: Request) -> Response:
|
||||
# Let API / infra 404s stay as API 404s (this route is last).
|
||||
path = "/" + (full_path or "").lstrip("/")
|
||||
if _is_api_or_infra(path):
|
||||
return Response(status_code=404, content=b'{"detail":"Not Found"}', media_type="application/json")
|
||||
# Prefer real files under dist (favicon, robots, etc.)
|
||||
candidate = dist / full_path
|
||||
try:
|
||||
candidate.resolve().relative_to(dist.resolve())
|
||||
except ValueError:
|
||||
return FileResponse(index_path)
|
||||
if candidate.is_file():
|
||||
return FileResponse(candidate)
|
||||
return FileResponse(index_path)
|
||||
|
||||
_log.info("UI mounted from %s", dist)
|
||||
return True
|
||||
Loading…
Add table
Add a link
Reference in a new issue