mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
Compare commits
3 commits
8bdaaaacbf
...
54c6bc9298
| Author | SHA1 | Date | |
|---|---|---|---|
| 54c6bc9298 | |||
| d9b52e35e5 | |||
| 477ec4b120 |
25 changed files with 1335 additions and 12 deletions
|
|
@ -113,6 +113,15 @@ NETX_UME_NOTIFICATION_TOPIC=ALARM
|
|||
# NETX_OCLAW_FORWARD_MAX_RETRIES=3
|
||||
# NETX_UME_RAW_JSON_MAX_BYTES=65536
|
||||
# NETX_NE_COLLECTION_KEEP_DAYS=14
|
||||
# Optional: serve built UI from API (same origin). Default web/dist; empty disables.
|
||||
# NETX_UI_DIST_DIR=web/dist
|
||||
# --- Windows packaging only (Linux ignores; always use NETX_DATABASE_URL) ---
|
||||
# NETX_DB_MODE=external
|
||||
# bundled | external; unset = external (compatible with existing deploys)
|
||||
# NETX_BUNDLED_PG_PORT=15432
|
||||
# NETX_BUNDLED_PG_DATA_DIR=
|
||||
# NETX_UPDATE_URL=
|
||||
# NETX_UPDATE_CHANNEL=stable
|
||||
# Heavier fleets: raise CLI/DB together; also ensure Postgres max_connections and bastion session limits.
|
||||
# One-click start (recommended): .\scripts\start_netx.ps1 -Background -WithWeb
|
||||
# -> API + netx_api.worker + N× netx_api.biz_state_worker + optional Vite
|
||||
|
|
|
|||
48
.github/workflows/release-windows.yml
vendored
Normal file
48
.github/workflows/release-windows.yml
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
name: Release Windows
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Build release zip
|
||||
shell: pwsh
|
||||
run: |
|
||||
powershell -ExecutionPolicy Bypass -File ./packaging/build_release.ps1 -CreateVenv
|
||||
|
||||
- name: Install Inno Setup
|
||||
shell: pwsh
|
||||
run: |
|
||||
choco install innosetup -y --no-progress
|
||||
echo "C:\Program Files (x86)\Inno Setup 6" >> $env:GITHUB_PATH
|
||||
|
||||
- name: Compile installer
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ver = (Get-Content pyproject.toml | Select-String '^\s*version\s*=').Line -replace '.*"(.+)"','$1'
|
||||
ISCC.exe "/DMyAppVersion=$ver" packaging/installer/netx.iss
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
packaging/release/NetX-*-win64.zip
|
||||
packaging/release/NetX-Setup-*.exe
|
||||
generate_release_notes: true
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -16,3 +16,8 @@ data/ne_collections/
|
|||
data/webcrt/
|
||||
data/auth/
|
||||
data/runtime/
|
||||
# Windows packaging artifacts (binaries / release trees)
|
||||
packaging/cache/
|
||||
packaging/release/
|
||||
packaging/postgres/pgsql/
|
||||
*.exe
|
||||
|
|
|
|||
|
|
@ -33,3 +33,4 @@
|
|||
- Validate after restart:
|
||||
- `GET /v1/integrations/status` returns all major components as `up`.
|
||||
- Keep parser config and importer changes versioned and reviewed before release.
|
||||
- **Windows packaged installs:** program under `%ProgramFiles%\NetX`, data under `%ProgramData%\NetX` (see [packaging/README.md](packaging/README.md)). Prefer `update_netx.ps1` or Setup upgrade so `pgdata` / `.env` are preserved. Linux continues to manage Postgres separately.
|
||||
|
|
|
|||
|
|
@ -156,6 +156,12 @@ powershell -ExecutionPolicy Bypass -File .\scripts\stop_netx.ps1 -Force
|
|||
Primary web UI (Vite): `http://127.0.0.1:5173/`
|
||||
API base: `http://127.0.0.1:8890/`
|
||||
|
||||
After `npm run build` in `web/`, the API can also serve the UI at `http://127.0.0.1:8890/` (same origin). See `NETX_UI_DIST_DIR` in `.env.example`.
|
||||
|
||||
### Windows installer / portable package (optional)
|
||||
|
||||
Fool-proof Windows delivery (bundled or external Postgres, program/data split, manual update) lives under [`packaging/`](packaging/README.md). **Linux installs are unchanged** — keep using your own Postgres and `scripts/start_netx.sh`.
|
||||
|
||||
### 6) MCP(Cursor / oclaw / Claude)
|
||||
|
||||
先启动 netx API(§5),再在 **MCP 宿主同机** 安装轻量客户端并配置。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
97
packaging/README.md
Normal file
97
packaging/README.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# NetX Windows packaging
|
||||
|
||||
**Linux is unchanged:** keep using your own PostgreSQL and `NETX_DATABASE_URL` with `scripts/start_netx.sh`. Nothing under this folder is required on Linux.
|
||||
|
||||
This directory builds a Windows deliverable with:
|
||||
|
||||
- Optional **bundled** portable PostgreSQL **or** **external** existing Postgres
|
||||
- API-hosted UI (`web/dist`) — no separate Vite process for end users
|
||||
- Program / data split so upgrades do not wipe the database
|
||||
- Manual update script + auto-update **manifest contract** (fetch not implemented yet)
|
||||
|
||||
## What users get
|
||||
|
||||
| Artifact | How |
|
||||
|----------|-----|
|
||||
| `NetX-x.y.z-win64.zip` | `build_release.ps1` |
|
||||
| `NetX-Setup-x.y.z.exe` | Compile `installer/netx.iss` with [Inno Setup](https://jrsoftware.org/isinfo.php) after staging |
|
||||
|
||||
## Build (developer machine)
|
||||
|
||||
Prerequisites: Python 3.11+, Node 20+, PowerShell, network (to download PG binaries once).
|
||||
|
||||
```powershell
|
||||
cd netx
|
||||
# Optional: download portable Postgres into packaging\postgres\pgsql
|
||||
powershell -ExecutionPolicy Bypass -File .\packaging\download_postgres.ps1
|
||||
|
||||
# Build web + stage + zip (-CreateVenv ships a ready .venv; large)
|
||||
powershell -ExecutionPolicy Bypass -File .\packaging\build_release.ps1 -CreateVenv
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
- `packaging/release/netx-win64/` — stage tree
|
||||
- `packaging/release/NetX-<ver>-win64.zip`
|
||||
|
||||
Inno Setup:
|
||||
|
||||
```text
|
||||
ISCC.exe packaging\installer\netx.iss
|
||||
```
|
||||
|
||||
Override version in the `.iss` or edit `#define MyAppVersion`.
|
||||
|
||||
## End-user install
|
||||
|
||||
### Setup.exe
|
||||
|
||||
1. Run `NetX-Setup-x.y.z.exe` (admin).
|
||||
2. Files → `%ProgramFiles%\NetX\` (program root).
|
||||
3. Data → `%ProgramData%\NetX\` (`.env`, `pgdata`, spool, secrets).
|
||||
4. Optional post-install task runs `setup_first_run.ps1` (choose bundled vs external DB).
|
||||
5. Start menu: **Start NetX** / **Stop NetX** / **Open NetX UI**.
|
||||
|
||||
### Zip (portable)
|
||||
|
||||
1. Unpack anywhere.
|
||||
2. Marker `.portable` → data root is sibling `NetXData\`.
|
||||
3. Target needs **Python 3.11+** on PATH unless the zip was built with `-CreateVenv`.
|
||||
4. Run:
|
||||
|
||||
```powershell
|
||||
.\packaging\setup_first_run.ps1
|
||||
.\packaging\start_netx_app.ps1
|
||||
```
|
||||
|
||||
## Database modes
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `bundled` | Start portable PG on `127.0.0.1:15432`, data in data-root `pgdata\` |
|
||||
| `external` | Do not start PG; use `NETX_DATABASE_URL` (same as today / Linux) |
|
||||
| unset | Treated as **external** |
|
||||
|
||||
Existing Windows deploys that only set `NETX_DATABASE_URL` keep working: never set `NETX_DB_MODE=bundled` unless you want the portable engine.
|
||||
|
||||
## Manual update
|
||||
|
||||
```powershell
|
||||
.\packaging\update_netx.ps1 -PackagePath .\NetX-0.3.0-win64.zip
|
||||
```
|
||||
|
||||
Stops services, replaces program folders (`netx_api`, `web`, `packaging`, `postgres`, …), **keeps** the data root, restarts. Schema migrations still run via Alembic on API start.
|
||||
|
||||
## Auto-update (reserved)
|
||||
|
||||
See `manifest.example.json`. Future: set `NETX_UPDATE_URL` + `NETX_UPDATE_CHANNEL`; a checker will download the zip and call `update_netx.ps1`. Not implemented in this phase.
|
||||
|
||||
## Layout reminder
|
||||
|
||||
```
|
||||
Program root (replaceable) Data root (never wiped by update)
|
||||
netx_api\ web\dist\ .env
|
||||
postgres\pgsql\ pgdata\ (bundled only)
|
||||
packaging\ scripts\ data\auth\ data\runtime\
|
||||
version.json backups\
|
||||
```
|
||||
137
packaging/_common.ps1
Normal file
137
packaging/_common.ps1
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# Shared path helpers for Windows packaging scripts.
|
||||
# Dot-source: . "$PSScriptRoot\_common.ps1"
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Get-NetxRepoRoot {
|
||||
return (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
}
|
||||
|
||||
function Get-NetxProgramRoot {
|
||||
param([string]$Override = "")
|
||||
if ($Override) { return (Resolve-Path $Override).Path }
|
||||
if ($env:NETX_PROGRAM_ROOT) { return $env:NETX_PROGRAM_ROOT }
|
||||
# Packaging scripts live in <program>\packaging when installed; in-repo they live under netx\packaging.
|
||||
$parent = Split-Path -Parent $PSScriptRoot
|
||||
return $parent
|
||||
}
|
||||
|
||||
function Get-NetxDataRoot {
|
||||
param(
|
||||
[string]$ProgramRoot,
|
||||
[string]$Override = ""
|
||||
)
|
||||
if ($Override) { return $Override }
|
||||
if ($env:NETX_DATA_ROOT) { return $env:NETX_DATA_ROOT }
|
||||
$programData = [Environment]::GetFolderPath("CommonApplicationData")
|
||||
$defaultPd = Join-Path $programData "NetX"
|
||||
# Portable layout: prefer sibling NetXData next to program root when marker exists or Program Files not writable.
|
||||
$portable = Join-Path (Split-Path -Parent $ProgramRoot) "NetXData"
|
||||
if (Test-Path (Join-Path $ProgramRoot ".portable")) {
|
||||
return $portable
|
||||
}
|
||||
if (Test-Path $defaultPd) {
|
||||
return $defaultPd
|
||||
}
|
||||
# Dev / first run from repo: use repo-local data packaging area under ProgramData if possible.
|
||||
try {
|
||||
if (-not (Test-Path $defaultPd)) {
|
||||
New-Item -ItemType Directory -Path $defaultPd -Force -ErrorAction Stop | Out-Null
|
||||
}
|
||||
return $defaultPd
|
||||
} catch {
|
||||
return $portable
|
||||
}
|
||||
}
|
||||
|
||||
function Get-NetxVersion {
|
||||
param([string]$ProgramRoot)
|
||||
$vj = Join-Path $ProgramRoot "version.json"
|
||||
if (Test-Path $vj) {
|
||||
try {
|
||||
$j = Get-Content -Raw -Path $vj | ConvertFrom-Json
|
||||
if ($j.version) { return [string]$j.version }
|
||||
} catch {}
|
||||
}
|
||||
$toml = Join-Path $ProgramRoot "pyproject.toml"
|
||||
if (-not (Test-Path $toml)) {
|
||||
$toml = Join-Path (Get-NetxRepoRoot) "pyproject.toml"
|
||||
}
|
||||
if (Test-Path $toml) {
|
||||
$m = Select-String -Path $toml -Pattern '^\s*version\s*=\s*"([^"]+)"' | Select-Object -First 1
|
||||
if ($m) { return $m.Matches[0].Groups[1].Value }
|
||||
}
|
||||
return "0.0.0"
|
||||
}
|
||||
|
||||
function Read-DotEnv {
|
||||
param([string]$Path)
|
||||
$map = @{}
|
||||
if (-not (Test-Path $Path)) { return $map }
|
||||
Get-Content -Path $Path -Encoding utf8 | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if (-not $line -or $line.StartsWith("#")) { return }
|
||||
$i = $line.IndexOf("=")
|
||||
if ($i -lt 1) { return }
|
||||
$k = $line.Substring(0, $i).Trim()
|
||||
$v = $line.Substring($i + 1).Trim()
|
||||
if (($v.StartsWith('"') -and $v.EndsWith('"')) -or ($v.StartsWith("'") -and $v.EndsWith("'"))) {
|
||||
$v = $v.Substring(1, $v.Length - 2)
|
||||
}
|
||||
$map[$k] = $v
|
||||
}
|
||||
return $map
|
||||
}
|
||||
|
||||
function Write-DotEnvValue {
|
||||
param(
|
||||
[string]$Path,
|
||||
[hashtable]$Values
|
||||
)
|
||||
$lines = @()
|
||||
$seen = @{}
|
||||
if (Test-Path $Path) {
|
||||
Get-Content -Path $Path -Encoding utf8 | ForEach-Object {
|
||||
$line = $_
|
||||
$t = $line.Trim()
|
||||
if ($t -and -not $t.StartsWith("#") -and $t.Contains("=")) {
|
||||
$k = $t.Substring(0, $t.IndexOf("=")).Trim()
|
||||
if ($Values.ContainsKey($k)) {
|
||||
$lines += "$k=$($Values[$k])"
|
||||
$seen[$k] = $true
|
||||
return
|
||||
}
|
||||
}
|
||||
$lines += $line
|
||||
}
|
||||
}
|
||||
foreach ($k in $Values.Keys) {
|
||||
if (-not $seen.ContainsKey($k)) {
|
||||
$lines += "$k=$($Values[$k])"
|
||||
}
|
||||
}
|
||||
$dir = Split-Path -Parent $Path
|
||||
if (-not (Test-Path $dir)) {
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
}
|
||||
Set-Content -Path $Path -Value ($lines -join "`r`n") -Encoding utf8
|
||||
}
|
||||
|
||||
function Get-DbMode {
|
||||
param([hashtable]$EnvMap)
|
||||
$m = ""
|
||||
if ($EnvMap.ContainsKey("NETX_DB_MODE")) { $m = [string]$EnvMap["NETX_DB_MODE"] }
|
||||
if (-not $m -and $env:NETX_DB_MODE) { $m = $env:NETX_DB_MODE }
|
||||
$m = $m.Trim().ToLowerInvariant()
|
||||
if ($m -eq "bundled") { return "bundled" }
|
||||
return "external"
|
||||
}
|
||||
|
||||
function Import-NetxEnvFile {
|
||||
param([string]$Path)
|
||||
$map = Read-DotEnv -Path $Path
|
||||
foreach ($k in $map.Keys) {
|
||||
Set-Item -Path "Env:$k" -Value $map[$k]
|
||||
}
|
||||
return $map
|
||||
}
|
||||
124
packaging/build_release.ps1
Normal file
124
packaging/build_release.ps1
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
param(
|
||||
[string]$Version = "",
|
||||
[string]$OutDir = "",
|
||||
[switch]$SkipWebBuild = $false,
|
||||
[switch]$SkipPostgresDownload = $false,
|
||||
[switch]$SkipZip = $false,
|
||||
[switch]$CreateVenv = $false
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. "$PSScriptRoot\_common.ps1"
|
||||
|
||||
$repo = Get-NetxRepoRoot
|
||||
if (-not $Version) {
|
||||
$Version = Get-NetxVersion -ProgramRoot $repo
|
||||
}
|
||||
$stage = if ($OutDir) { $OutDir } else { Join-Path $PSScriptRoot "release\netx-win64" }
|
||||
|
||||
Write-Host "==> Building NetX Windows release $Version"
|
||||
Write-Host " Repo: $repo"
|
||||
Write-Host " Stage: $stage"
|
||||
|
||||
if (Test-Path $stage) {
|
||||
Remove-Item -Recurse -Force $stage
|
||||
}
|
||||
New-Item -ItemType Directory -Path $stage -Force | Out-Null
|
||||
|
||||
$webRoot = Join-Path $repo "web"
|
||||
$dist = Join-Path $webRoot "dist"
|
||||
if (-not $SkipWebBuild) {
|
||||
if (-not (Get-Command npm.cmd -ErrorAction SilentlyContinue)) {
|
||||
throw "npm_not_found"
|
||||
}
|
||||
Write-Host "==> npm build"
|
||||
if (-not (Test-Path (Join-Path $webRoot "node_modules"))) {
|
||||
& npm.cmd install --prefix $webRoot
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm_install_failed" }
|
||||
}
|
||||
# Release packaging skips tsc (dev CI still uses `npm run build`).
|
||||
& npm.cmd run build:release --prefix $webRoot
|
||||
if ($LASTEXITCODE -ne 0) { throw "web_build_failed" }
|
||||
}
|
||||
if (-not (Test-Path (Join-Path $dist "index.html"))) {
|
||||
throw "web_dist_missing: build web/ or drop -SkipWebBuild"
|
||||
}
|
||||
|
||||
$pgsql = Join-Path $PSScriptRoot "postgres\pgsql"
|
||||
if (-not $SkipPostgresDownload) {
|
||||
if (-not (Test-Path (Join-Path $pgsql "bin\pg_ctl.exe"))) {
|
||||
& powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "download_postgres.ps1")
|
||||
}
|
||||
}
|
||||
|
||||
# Flat layout = same as repo so scripts\start_netx.ps1 works unchanged.
|
||||
foreach ($d in @("netx_api", "alembic", "scripts")) {
|
||||
Copy-Item -Path (Join-Path $repo $d) -Destination (Join-Path $stage $d) -Recurse -Force
|
||||
}
|
||||
foreach ($f in @("pyproject.toml", "requirements.txt", "alembic.ini", "README.md", "LICENSE", "CONTRIBUTING.md", "SECURITY.md")) {
|
||||
$src = Join-Path $repo $f
|
||||
if (Test-Path $src) {
|
||||
Copy-Item -Path $src -Destination (Join-Path $stage $f) -Force
|
||||
}
|
||||
}
|
||||
|
||||
$webOut = Join-Path $stage "web\dist"
|
||||
New-Item -ItemType Directory -Path (Split-Path $webOut -Parent) -Force | Out-Null
|
||||
Copy-Item -Path $dist -Destination $webOut -Recurse -Force
|
||||
|
||||
$packOut = Join-Path $stage "packaging"
|
||||
New-Item -ItemType Directory -Path $packOut -Force | Out-Null
|
||||
Copy-Item -Path (Join-Path $PSScriptRoot "_common.ps1") -Destination $packOut -Force
|
||||
foreach ($name in @(
|
||||
"download_postgres.ps1", "setup_first_run.ps1", "start_netx_app.ps1",
|
||||
"stop_netx_app.ps1", "update_netx.ps1", "build_release.ps1",
|
||||
"README.md", "manifest.example.json"
|
||||
)) {
|
||||
$src = Join-Path $PSScriptRoot $name
|
||||
if (Test-Path $src) { Copy-Item $src (Join-Path $packOut $name) -Force }
|
||||
}
|
||||
Copy-Item -Path (Join-Path $PSScriptRoot "config") -Destination (Join-Path $packOut "config") -Recurse -Force
|
||||
Copy-Item -Path (Join-Path $PSScriptRoot "installer") -Destination (Join-Path $packOut "installer") -Recurse -Force
|
||||
New-Item -ItemType Directory -Path (Join-Path $packOut "postgres") -Force | Out-Null
|
||||
Copy-Item -Path (Join-Path $PSScriptRoot "postgres\README.md") -Destination (Join-Path $packOut "postgres\README.md") -Force
|
||||
|
||||
if (Test-Path (Join-Path $pgsql "bin\pg_ctl.exe")) {
|
||||
Write-Host "==> Copying bundled PostgreSQL"
|
||||
New-Item -ItemType Directory -Path (Join-Path $stage "postgres") -Force | Out-Null
|
||||
Copy-Item -Path $pgsql -Destination (Join-Path $stage "postgres\pgsql") -Recurse -Force
|
||||
}
|
||||
|
||||
$builtAt = (Get-Date).ToUniversalTime().ToString("o")
|
||||
@{
|
||||
version = $Version
|
||||
channel = "stable"
|
||||
min_data_layout = 1
|
||||
built_at = $builtAt
|
||||
platform = "win64"
|
||||
} | ConvertTo-Json | Set-Content -Path (Join-Path $stage "version.json") -Encoding utf8
|
||||
|
||||
Set-Content -Path (Join-Path $stage ".portable") -Value "1" -Encoding ascii
|
||||
|
||||
if ($CreateVenv) {
|
||||
Write-Host "==> Creating .venv in stage (requires Python 3.11+ on PATH)"
|
||||
$py = (Get-Command python -ErrorAction SilentlyContinue)
|
||||
if (-not $py) { throw "python_not_found_for_venv" }
|
||||
& $py.Source -m venv (Join-Path $stage ".venv")
|
||||
& (Join-Path $stage ".venv\Scripts\python.exe") -m pip install --upgrade pip
|
||||
& (Join-Path $stage ".venv\Scripts\python.exe") -m pip install -r (Join-Path $stage "requirements.txt")
|
||||
}
|
||||
|
||||
Write-Host "==> Stage ready: $stage" -ForegroundColor Green
|
||||
|
||||
if (-not $SkipZip) {
|
||||
$zip = Join-Path (Split-Path -Parent $stage) "NetX-$Version-win64.zip"
|
||||
if (Test-Path $zip) { Remove-Item -Force $zip }
|
||||
Compress-Archive -Path $stage -DestinationPath $zip -Force
|
||||
Write-Host "==> Zip: $zip" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Ship options:"
|
||||
Write-Host " Zip: user unpacks, runs packaging\setup_first_run.ps1 then start_netx_app.ps1"
|
||||
Write-Host " Exe: compile packaging\installer\netx.iss with Inno Setup (needs stage above)"
|
||||
Write-Host "Python: install 3.11+ on target, or rebuild with -CreateVenv and ship .venv"
|
||||
9
packaging/config/database.example.env
Normal file
9
packaging/config/database.example.env
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# NETX_DB_MODE=bundled
|
||||
# NETX_DATABASE_URL=postgresql+psycopg://netx:CHANGE_ME@127.0.0.1:15432/netx
|
||||
# NETX_BUNDLED_PG_PORT=15432
|
||||
# NETX_BUNDLED_PG_DATA_DIR=
|
||||
# NETX_HOST=127.0.0.1
|
||||
# NETX_PORT=8890
|
||||
# NETX_UI_DIST_DIR=web/dist
|
||||
# NETX_UPDATE_URL=
|
||||
# NETX_UPDATE_CHANNEL=stable
|
||||
57
packaging/download_postgres.ps1
Normal file
57
packaging/download_postgres.ps1
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
param(
|
||||
[string]$Version = "16.4-1",
|
||||
[string]$OutDir = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. "$PSScriptRoot\_common.ps1"
|
||||
|
||||
# EnterpriseDB binary zip (Windows x86-64). Override with -Version if the URL 404s.
|
||||
$ver = $Version
|
||||
$url = "https://get.enterprisedb.com/postgresql/postgresql-$ver-windows-x64-binaries.zip"
|
||||
|
||||
$cache = if ($OutDir) { $OutDir } else { Join-Path $PSScriptRoot "cache" }
|
||||
$zipPath = Join-Path $cache "postgresql-$ver-windows-x64-binaries.zip"
|
||||
$extractRoot = Join-Path $cache "pgsql-$ver"
|
||||
$destPgsql = Join-Path $PSScriptRoot "postgres\pgsql"
|
||||
|
||||
if (-not (Test-Path $cache)) {
|
||||
New-Item -ItemType Directory -Path $cache -Force | Out-Null
|
||||
}
|
||||
|
||||
Write-Host "==> PostgreSQL binaries URL: $url"
|
||||
if (-not (Test-Path $zipPath)) {
|
||||
Write-Host "==> Downloading (large; may take several minutes)..."
|
||||
Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing
|
||||
} else {
|
||||
Write-Host "==> Using cached zip: $zipPath"
|
||||
}
|
||||
|
||||
if (Test-Path $extractRoot) {
|
||||
Remove-Item -Recurse -Force $extractRoot
|
||||
}
|
||||
New-Item -ItemType Directory -Path $extractRoot -Force | Out-Null
|
||||
Write-Host "==> Extracting..."
|
||||
Expand-Archive -Path $zipPath -DestinationPath $extractRoot -Force
|
||||
|
||||
# Zip usually contains a top-level "pgsql" folder.
|
||||
$found = Get-ChildItem -Path $extractRoot -Recurse -Filter "pg_ctl.exe" -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if (-not $found) {
|
||||
throw "pg_ctl.exe not found after extract; check zip layout"
|
||||
}
|
||||
$pgsqlSrc = Split-Path -Parent (Split-Path -Parent $found.FullName)
|
||||
Write-Host "==> Found pgsql tree: $pgsqlSrc"
|
||||
|
||||
if (Test-Path $destPgsql) {
|
||||
Remove-Item -Recurse -Force $destPgsql
|
||||
}
|
||||
New-Item -ItemType Directory -Path (Split-Path -Parent $destPgsql) -Force | Out-Null
|
||||
Copy-Item -Path $pgsqlSrc -Destination $destPgsql -Recurse -Force
|
||||
|
||||
$pgCtl = Join-Path $destPgsql "bin\pg_ctl.exe"
|
||||
if (-not (Test-Path $pgCtl)) {
|
||||
throw "install_failed: missing $pgCtl"
|
||||
}
|
||||
Write-Host "==> Bundled PostgreSQL ready: $destPgsql" -ForegroundColor Green
|
||||
Write-Host " Tip: data directory is NOT here; setup_first_run / start scripts use the NetX data root."
|
||||
69
packaging/installer/netx.iss
Normal file
69
packaging/installer/netx.iss
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
; NetX Windows installer (Inno Setup 6+)
|
||||
; Compile after: packaging\build_release.ps1
|
||||
; ISCC.exe packaging\installer\netx.iss
|
||||
|
||||
#define MyAppName "NetX"
|
||||
#ifndef MyAppVersion
|
||||
#define MyAppVersion "0.3.0"
|
||||
#endif
|
||||
#define MyAppPublisher "NetX"
|
||||
#define MyAppURL "https://github.com/hansjone/netx"
|
||||
#define MyAppExeName "packaging\start_netx_app.ps1"
|
||||
|
||||
; Stage directory produced by build_release.ps1 (relative to this .iss)
|
||||
#define StageDir "..\release\netx-win64"
|
||||
|
||||
[Setup]
|
||||
AppId={{A7E3C2D1-9F40-4B8E-9C1A-NETXWIN64001}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
AppPublisherURL={#MyAppURL}
|
||||
DefaultDirName={autopf}\NetX
|
||||
DefaultGroupName=NetX
|
||||
DisableProgramGroupPage=yes
|
||||
LicenseFile={#StageDir}\LICENSE
|
||||
OutputDir=..\release
|
||||
OutputBaseFilename=NetX-Setup-{#MyAppVersion}
|
||||
Compression=lzma2
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
PrivilegesRequired=admin
|
||||
; Data lives under {commonappdata}\NetX — not overwritten by upgrades
|
||||
CloseApplications=yes
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional icons:"; Flags: unchecked
|
||||
Name: "firstrun"; Description: "Run first-time setup (database mode) after install"; GroupDescription: "Setup:"; Flags: checkedonce
|
||||
|
||||
[Files]
|
||||
; Entire release stage → {app}. Exclude .portable so installed builds use ProgramData.
|
||||
Source: "{#StageDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Excludes: ".portable"
|
||||
|
||||
[Dirs]
|
||||
Name: "{commonappdata}\NetX"
|
||||
Name: "{commonappdata}\NetX\data"
|
||||
Name: "{commonappdata}\NetX\data\auth"
|
||||
Name: "{commonappdata}\NetX\data\runtime"
|
||||
Name: "{commonappdata}\NetX\pgdata"
|
||||
Name: "{commonappdata}\NetX\backups"
|
||||
|
||||
[Icons]
|
||||
Name: "{group}\Start NetX"; Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\start_netx_app.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"
|
||||
Name: "{group}\Stop NetX"; Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\stop_netx_app.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"
|
||||
Name: "{group}\Open NetX UI"; Filename: "http://127.0.0.1:8890/"
|
||||
Name: "{group}\First-time setup"; Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\setup_first_run.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"
|
||||
Name: "{autodesktop}\Start NetX"; Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\start_netx_app.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\setup_first_run.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"; Flags: postinstall skipifsilent; Tasks: firstrun
|
||||
Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\start_netx_app.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"; Description: "Start NetX now"; Flags: postinstall nowait skipifsilent unchecked
|
||||
|
||||
[UninstallDelete]
|
||||
; Do NOT delete {commonappdata}\NetX — preserves DB and secrets across reinstall
|
||||
Type: filesandordirs; Name: "{app}"
|
||||
11
packaging/manifest.example.json
Normal file
11
packaging/manifest.example.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"channel": "stable",
|
||||
"latest": "0.3.0",
|
||||
"min_compatible": "0.3.0",
|
||||
"notes_url": "",
|
||||
"windows": {
|
||||
"url": "https://example.com/netx/NetX-0.2.0-win64.zip",
|
||||
"sha256": "REPLACE_WITH_SHA256",
|
||||
"size": 0
|
||||
}
|
||||
}
|
||||
17
packaging/postgres/README.md
Normal file
17
packaging/postgres/README.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Windows packaging — portable PostgreSQL binaries
|
||||
|
||||
Place an extracted EnterpriseDB Windows x64 PostgreSQL tree here as `pgsql/` so that:
|
||||
|
||||
```
|
||||
packaging/postgres/pgsql/bin/pg_ctl.exe
|
||||
packaging/postgres/pgsql/bin/initdb.exe
|
||||
packaging/postgres/pgsql/bin/psql.exe
|
||||
```
|
||||
|
||||
Do not commit the binaries. Use:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\packaging\download_postgres.ps1
|
||||
```
|
||||
|
||||
Bundled mode uses a separate data directory under the NetX **data root** (never under `pgsql/`), so upgrading the binary tree does not wipe the database.
|
||||
102
packaging/publish_release.ps1
Normal file
102
packaging/publish_release.ps1
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
param(
|
||||
[string]$Version = "",
|
||||
[switch]$SkipBuild = $false,
|
||||
[switch]$SkipInstaller = $false,
|
||||
[switch]$SkipGitHub = $false,
|
||||
[switch]$Draft = $false
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. "$PSScriptRoot\_common.ps1"
|
||||
|
||||
$repo = Get-NetxRepoRoot
|
||||
if (-not $Version) {
|
||||
$Version = Get-NetxVersion -ProgramRoot $repo
|
||||
}
|
||||
$tag = "v$Version"
|
||||
$releaseDir = Join-Path $PSScriptRoot "release"
|
||||
$zip = Join-Path $releaseDir "NetX-$Version-win64.zip"
|
||||
$setup = Join-Path $releaseDir "NetX-Setup-$Version.exe"
|
||||
|
||||
Write-Host "==> NetX publish $tag"
|
||||
|
||||
if (-not $SkipBuild) {
|
||||
& powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "build_release.ps1") `
|
||||
-Version $Version -CreateVenv
|
||||
}
|
||||
|
||||
if (-not (Test-Path $zip)) {
|
||||
throw "missing_zip: $zip"
|
||||
}
|
||||
|
||||
if (-not $SkipInstaller) {
|
||||
$isccCmd = Get-Command ISCC.exe -ErrorAction SilentlyContinue
|
||||
$isccCandidates = @(
|
||||
$(if ($isccCmd) { $isccCmd.Source }),
|
||||
"$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe",
|
||||
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
|
||||
"$env:ProgramFiles\Inno Setup 6\ISCC.exe"
|
||||
) | Where-Object { $_ -and (Test-Path $_) }
|
||||
$iscc = $isccCandidates | Select-Object -First 1
|
||||
if (-not $iscc) {
|
||||
Write-Host "[WARN] Inno Setup (ISCC) not found. Install: winget install JRSoftware.InnoSetup" -ForegroundColor Yellow
|
||||
Write-Host " Skipping Setup.exe; zip-only release."
|
||||
} else {
|
||||
Write-Host "==> Compiling installer: $iscc"
|
||||
& $iscc "/DMyAppVersion=$Version" (Join-Path $PSScriptRoot "installer\netx.iss")
|
||||
if (-not (Test-Path $setup)) {
|
||||
throw "installer_build_failed: expected $setup"
|
||||
}
|
||||
Write-Host "==> Setup.exe: $setup" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
if ($SkipGitHub) {
|
||||
Write-Host "==> Skip GitHub release (-SkipGitHub)"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
|
||||
throw "gh_cli_not_found"
|
||||
}
|
||||
|
||||
Set-Location $repo
|
||||
$existing = gh release view $tag 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "==> Release $tag exists; uploading assets"
|
||||
gh release upload $tag $zip --clobber
|
||||
if (Test-Path $setup) {
|
||||
gh release upload $tag $setup --clobber
|
||||
}
|
||||
} else {
|
||||
$notes = @"
|
||||
## NetX $Version — first Windows installable release
|
||||
|
||||
### Downloads
|
||||
- **NetX-Setup-$Version.exe** — recommended installer (Program Files + ProgramData)
|
||||
- **NetX-$Version-win64.zip** — portable directory package
|
||||
|
||||
### Requirements
|
||||
- Windows 10/11 x64
|
||||
- No separate Python or PostgreSQL install required (bundled in package)
|
||||
|
||||
### Quick start (installer)
|
||||
1. Run ``NetX-Setup-$Version.exe`` as administrator.
|
||||
2. Complete first-time setup (choose **bundled** or **external** PostgreSQL).
|
||||
3. Start menu → **Start NetX** → browser opens ``http://127.0.0.1:8890/``
|
||||
4. Default login: ``admin`` / ``admin123`` (change after first login)
|
||||
|
||||
### Linux
|
||||
Unchanged — use your own PostgreSQL and ``scripts/start_netx.sh``.
|
||||
|
||||
See [packaging/README.md](https://github.com/hansjone/netx/blob/main/packaging/README.md) for details.
|
||||
"@
|
||||
$args = @("release", "create", $tag, "--title", "NetX $Version", "--notes", $notes)
|
||||
if ($Draft) { $args += "--draft" }
|
||||
$args += $zip
|
||||
if (Test-Path $setup) { $args += $setup }
|
||||
& gh @args
|
||||
}
|
||||
|
||||
Write-Host "==> Published $tag" -ForegroundColor Green
|
||||
gh release view $tag --web 2>$null
|
||||
185
packaging/setup_first_run.ps1
Normal file
185
packaging/setup_first_run.ps1
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
param(
|
||||
[ValidateSet("bundled", "external", "")]
|
||||
[string]$DbMode = "",
|
||||
[string]$ProgramRoot = "",
|
||||
[string]$DataRoot = "",
|
||||
[string]$ExternalDatabaseUrl = "",
|
||||
[string]$BundledPassword = "",
|
||||
[int]$BundledPort = 15432,
|
||||
[switch]$NonInteractive = $false
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. "$PSScriptRoot\_common.ps1"
|
||||
|
||||
$prog = Get-NetxProgramRoot -Override $ProgramRoot
|
||||
$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot
|
||||
$envPath = Join-Path $data ".env"
|
||||
|
||||
Write-Host "==> Program root: $prog"
|
||||
Write-Host "==> Data root: $data"
|
||||
Write-Host "==> Env file: $envPath"
|
||||
|
||||
if (-not (Test-Path $data)) {
|
||||
New-Item -ItemType Directory -Path $data -Force | Out-Null
|
||||
}
|
||||
foreach ($sub in @("data", "data\auth", "data\runtime", "backups", "pgdata")) {
|
||||
$p = Join-Path $data $sub
|
||||
if (-not (Test-Path $p)) {
|
||||
New-Item -ItemType Directory -Path $p -Force | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
$existing = Read-DotEnv -Path $envPath
|
||||
if (-not $DbMode) {
|
||||
if ($NonInteractive) {
|
||||
if ($existing.ContainsKey("NETX_DB_MODE")) {
|
||||
$DbMode = $existing["NETX_DB_MODE"]
|
||||
} elseif ($existing.ContainsKey("NETX_DATABASE_URL")) {
|
||||
$DbMode = "external"
|
||||
} else {
|
||||
$DbMode = "bundled"
|
||||
}
|
||||
} else {
|
||||
Write-Host ""
|
||||
Write-Host "Choose database mode:"
|
||||
Write-Host " 1) bundled — use NetX portable PostgreSQL (default for new installs)"
|
||||
Write-Host " 2) external — connect to an existing PostgreSQL (Linux / already deployed)"
|
||||
$choice = Read-Host "Enter 1 or 2"
|
||||
if ($choice -eq "2") { $DbMode = "external" } else { $DbMode = "bundled" }
|
||||
}
|
||||
}
|
||||
$DbMode = $DbMode.Trim().ToLowerInvariant()
|
||||
if ($DbMode -ne "bundled" -and $DbMode -ne "external") {
|
||||
throw "invalid_db_mode: $DbMode"
|
||||
}
|
||||
|
||||
$values = @{}
|
||||
$values["NETX_DB_MODE"] = $DbMode
|
||||
$values["NETX_HOST"] = "127.0.0.1"
|
||||
$values["NETX_PORT"] = "8890"
|
||||
$values["NETX_UI_DIST_DIR"] = "web/dist"
|
||||
|
||||
# Point runtime data dirs into the data root (absolute).
|
||||
$values["NETX_AUTH_MCP_TOKEN_FILE"] = ((Join-Path $data "data\auth\mcp_token") -replace '\\', '/')
|
||||
$values["NETX_SCHEDULER_HEARTBEAT_PATH"] = ((Join-Path $data "data\runtime\scheduler_heartbeat.json") -replace '\\', '/')
|
||||
|
||||
if ($DbMode -eq "bundled") {
|
||||
$pgsql = Join-Path $prog "postgres\pgsql"
|
||||
if (-not (Test-Path (Join-Path $pgsql "bin\initdb.exe"))) {
|
||||
# In-repo layout
|
||||
$alt = Join-Path $PSScriptRoot "postgres\pgsql"
|
||||
if (Test-Path (Join-Path $alt "bin\initdb.exe")) {
|
||||
$pgsql = $alt
|
||||
} else {
|
||||
throw "bundled_postgres_missing: run packaging\download_postgres.ps1 first (expected $pgsql)"
|
||||
}
|
||||
}
|
||||
if (-not $BundledPassword) {
|
||||
if ($NonInteractive) {
|
||||
$BundledPassword = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char]$_ })
|
||||
} else {
|
||||
$sec = Read-Host -Prompt "Password for bundled role 'netx' (empty = auto-generate)" -AsSecureString
|
||||
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)
|
||||
try {
|
||||
$BundledPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
|
||||
} finally {
|
||||
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
|
||||
}
|
||||
if (-not $BundledPassword) {
|
||||
$BundledPassword = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char]$_ })
|
||||
Write-Host "Generated password (saved in .env only)."
|
||||
}
|
||||
}
|
||||
}
|
||||
$pgData = Join-Path $data "pgdata"
|
||||
$values["NETX_BUNDLED_PG_PORT"] = "$BundledPort"
|
||||
$values["NETX_BUNDLED_PG_DATA_DIR"] = ($pgData -replace '\\', '/')
|
||||
$pwFile = Join-Path $data "pg_netx.pw"
|
||||
Set-Content -Path $pwFile -Value $BundledPassword -Encoding ascii -NoNewline
|
||||
$encPw = [uri]::EscapeDataString($BundledPassword)
|
||||
$values["NETX_DATABASE_URL"] = "postgresql+psycopg://netx:${encPw}@127.0.0.1:${BundledPort}/netx"
|
||||
|
||||
# Initialize cluster if needed
|
||||
$initdb = Join-Path $pgsql "bin\initdb.exe"
|
||||
$pgCtl = Join-Path $pgsql "bin\pg_ctl.exe"
|
||||
$psql = Join-Path $pgsql "bin\psql.exe"
|
||||
$createdb = Join-Path $pgsql "bin\createdb.exe"
|
||||
$createuser = Join-Path $pgsql "bin\createuser.exe"
|
||||
|
||||
if (-not (Test-Path (Join-Path $pgData "PG_VERSION"))) {
|
||||
Write-Host "==> initdb $pgData"
|
||||
$pwSuper = Join-Path $data "pg_super.pw"
|
||||
Set-Content -Path $pwSuper -Value $BundledPassword -Encoding ascii -NoNewline
|
||||
& $initdb -D $pgData -U postgres -A password --pwfile=$pwSuper -E UTF8 --locale=C
|
||||
if ($LASTEXITCODE -ne 0) { throw "initdb_failed" }
|
||||
}
|
||||
|
||||
# Ensure listen / port in postgresql.conf
|
||||
$conf = Join-Path $pgData "postgresql.conf"
|
||||
$hba = Join-Path $pgData "pg_hba.conf"
|
||||
if (Test-Path $conf) {
|
||||
$confText = Get-Content -Raw -Path $conf
|
||||
if ($confText -notmatch "(?m)^\s*port\s*=") {
|
||||
Add-Content -Path $conf -Value "`nport = $BundledPort`nlisten_addresses = '127.0.0.1'`n"
|
||||
} else {
|
||||
$confText = $confText -replace '(?m)^\s*port\s*=\s*\d+', "port = $BundledPort"
|
||||
if ($confText -notmatch "(?m)^\s*listen_addresses\s*=") {
|
||||
$confText += "`nlisten_addresses = '127.0.0.1'`n"
|
||||
}
|
||||
Set-Content -Path $conf -Value $confText -Encoding utf8
|
||||
}
|
||||
}
|
||||
if (Test-Path $hba) {
|
||||
$hbaText = Get-Content -Raw -Path $hba
|
||||
if ($hbaText -notmatch "127\.0\.0\.1/32") {
|
||||
Add-Content -Path $hba -Value "`nhost all all 127.0.0.1/32 scram-sha-256`n"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "==> Starting bundled PostgreSQL for bootstrap"
|
||||
& $pgCtl -D $pgData -l (Join-Path $data "pgdata\pg.log") start
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
$env:PGPASSWORD = $BundledPassword
|
||||
try {
|
||||
$role = & $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname='netx'"
|
||||
if ($role -notmatch "1") {
|
||||
& $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -v ON_ERROR_STOP=1 `
|
||||
-c "CREATE ROLE netx LOGIN PASSWORD '$BundledPassword';"
|
||||
} else {
|
||||
& $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -v ON_ERROR_STOP=1 `
|
||||
-c "ALTER ROLE netx WITH LOGIN PASSWORD '$BundledPassword';"
|
||||
}
|
||||
$db = & $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='netx'"
|
||||
if ($db -notmatch "1") {
|
||||
& $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -v ON_ERROR_STOP=1 `
|
||||
-c "CREATE DATABASE netx OWNER netx;"
|
||||
}
|
||||
& $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -v ON_ERROR_STOP=1 `
|
||||
-c "GRANT ALL PRIVILEGES ON DATABASE netx TO netx;"
|
||||
} finally {
|
||||
Remove-Item Env:PGPASSWORD -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Host "==> Bundled Postgres ready on 127.0.0.1:$BundledPort" -ForegroundColor Green
|
||||
} else {
|
||||
if (-not $ExternalDatabaseUrl) {
|
||||
if ($existing.ContainsKey("NETX_DATABASE_URL") -and $existing["NETX_DATABASE_URL"]) {
|
||||
$ExternalDatabaseUrl = $existing["NETX_DATABASE_URL"]
|
||||
} elseif ($NonInteractive) {
|
||||
throw "external_url_required"
|
||||
} else {
|
||||
$ExternalDatabaseUrl = Read-Host "NETX_DATABASE_URL (postgresql+psycopg://user:pass@host:5432/netx)"
|
||||
}
|
||||
}
|
||||
if (-not $ExternalDatabaseUrl) {
|
||||
throw "external_url_required"
|
||||
}
|
||||
$values["NETX_DATABASE_URL"] = $ExternalDatabaseUrl
|
||||
Write-Host "==> External Postgres configured (ensure role/db exist; see scripts\init_pg.ps1)" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-DotEnvValue -Path $envPath -Values $values
|
||||
Write-Host ""
|
||||
Write-Host "Wrote $envPath" -ForegroundColor Green
|
||||
Write-Host "Next: .\packaging\start_netx_app.ps1"
|
||||
112
packaging/start_netx_app.ps1
Normal file
112
packaging/start_netx_app.ps1
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
param(
|
||||
[string]$ProgramRoot = "",
|
||||
[string]$DataRoot = "",
|
||||
[switch]$SkipBrowser = $false,
|
||||
[switch]$InlineSchedulers = $false
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. "$PSScriptRoot\_common.ps1"
|
||||
|
||||
$prog = Get-NetxProgramRoot -Override $ProgramRoot
|
||||
$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot
|
||||
$envPath = Join-Path $data ".env"
|
||||
|
||||
if (-not (Test-Path $envPath)) {
|
||||
Write-Host "[ERR] Missing $envPath — run setup_first_run.ps1 first." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path (Join-Path $prog "netx_api"))) {
|
||||
throw "netx_api not found under program root: $prog"
|
||||
}
|
||||
|
||||
$map = Import-NetxEnvFile -Path $envPath
|
||||
Set-Location $prog
|
||||
$env:PYTHONPATH = $prog
|
||||
|
||||
# Keep runtime artifacts in the data root (not under Program Files).
|
||||
$env:NETX_AUTH_MCP_TOKEN_FILE = Join-Path $data "data\auth\mcp_token"
|
||||
$env:NETX_SCHEDULER_HEARTBEAT_PATH = Join-Path $data "data\runtime\scheduler_heartbeat.json"
|
||||
$env:NETX_AUTH_SECRET_FILE = Join-Path $data "data\auth\jwt_secret"
|
||||
|
||||
$dist = Join-Path $prog "web\dist"
|
||||
if (Test-Path (Join-Path $dist "index.html")) {
|
||||
$env:NETX_UI_DIST_DIR = $dist
|
||||
}
|
||||
|
||||
$mode = Get-DbMode -EnvMap $map
|
||||
Write-Host "==> Program: $prog"
|
||||
Write-Host "==> Data: $data"
|
||||
Write-Host "==> DB mode: $mode"
|
||||
|
||||
function Get-PgsqlBin {
|
||||
foreach ($b in @(
|
||||
(Join-Path $prog "postgres\pgsql\bin"),
|
||||
(Join-Path $PSScriptRoot "postgres\pgsql\bin")
|
||||
)) {
|
||||
if (Test-Path (Join-Path $b "pg_ctl.exe")) { return $b }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($mode -eq "bundled") {
|
||||
$pgBin = Get-PgsqlBin
|
||||
if (-not $pgBin) { throw "bundled_postgres_missing" }
|
||||
$pgData = if ($map["NETX_BUNDLED_PG_DATA_DIR"]) {
|
||||
$map["NETX_BUNDLED_PG_DATA_DIR"]
|
||||
} else {
|
||||
Join-Path $data "pgdata"
|
||||
}
|
||||
$pgCtl = Join-Path $pgBin "pg_ctl.exe"
|
||||
$status = & $pgCtl -D $pgData status 2>&1 | Out-String
|
||||
if ($status -notmatch "server is running") {
|
||||
Write-Host "==> Starting bundled PostgreSQL"
|
||||
if (-not (Test-Path $pgData)) {
|
||||
New-Item -ItemType Directory -Path $pgData -Force | Out-Null
|
||||
}
|
||||
$log = Join-Path $pgData "pg.log"
|
||||
& $pgCtl -D $pgData -l $log start
|
||||
if ($LASTEXITCODE -ne 0) { throw "pg_start_failed" }
|
||||
Start-Sleep -Seconds 2
|
||||
} else {
|
||||
Write-Host "==> Bundled PostgreSQL already running"
|
||||
}
|
||||
}
|
||||
|
||||
# Ensure venv exists for start_netx.ps1
|
||||
$venvPy = Join-Path $prog ".venv\Scripts\python.exe"
|
||||
if (-not (Test-Path $venvPy)) {
|
||||
Write-Host "==> Creating .venv (one-time)"
|
||||
$pyCmd = Get-Command python -ErrorAction SilentlyContinue
|
||||
if (-not $pyCmd) { throw "python_not_found: install Python 3.11+ and re-run" }
|
||||
& $pyCmd.Source -m venv (Join-Path $prog ".venv")
|
||||
& $venvPy -m pip install --upgrade pip
|
||||
& $venvPy -m pip install -r (Join-Path $prog "requirements.txt")
|
||||
if ($LASTEXITCODE -ne 0) { throw "pip_install_failed" }
|
||||
}
|
||||
|
||||
$hostBind = if ($env:NETX_HOST) { $env:NETX_HOST } else { "127.0.0.1" }
|
||||
$port = if ($env:NETX_PORT) { [int]$env:NETX_PORT } else { 8890 }
|
||||
|
||||
$startScript = Join-Path $prog "scripts\start_netx.ps1"
|
||||
if (-not (Test-Path $startScript)) {
|
||||
throw "start_netx.ps1 not found at $startScript"
|
||||
}
|
||||
|
||||
$psArgs = @(
|
||||
"-ExecutionPolicy", "Bypass", "-File", $startScript,
|
||||
"-SkipInstall", "-Background",
|
||||
"-BindHost", $hostBind, "-Port", "$port"
|
||||
)
|
||||
if ($InlineSchedulers) { $psArgs += "-InlineSchedulers" }
|
||||
|
||||
Write-Host "==> Starting NetX (API + workers; UI via API on :$port)"
|
||||
& powershell @psArgs
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
$url = "http://${hostBind}:${port}/"
|
||||
Write-Host "==> Open: $url" -ForegroundColor Green
|
||||
if (-not $SkipBrowser) {
|
||||
try { Start-Process $url } catch {}
|
||||
}
|
||||
46
packaging/stop_netx_app.ps1
Normal file
46
packaging/stop_netx_app.ps1
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
param(
|
||||
[string]$ProgramRoot = "",
|
||||
[string]$DataRoot = "",
|
||||
[switch]$KeepPostgres = $false
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
. "$PSScriptRoot\_common.ps1"
|
||||
|
||||
$prog = Get-NetxProgramRoot -Override $ProgramRoot
|
||||
$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot
|
||||
$envPath = Join-Path $data ".env"
|
||||
$map = @{}
|
||||
if (Test-Path $envPath) {
|
||||
$map = Read-DotEnv -Path $envPath
|
||||
}
|
||||
|
||||
$stopScript = Join-Path $prog "scripts\stop_netx.ps1"
|
||||
if (-not (Test-Path $stopScript)) {
|
||||
$stopScript = Join-Path (Get-NetxRepoRoot) "scripts\stop_netx.ps1"
|
||||
}
|
||||
if (Test-Path $stopScript) {
|
||||
Write-Host "==> Stopping NetX processes"
|
||||
& powershell -ExecutionPolicy Bypass -File $stopScript -Force
|
||||
} else {
|
||||
Write-Host "[WARN] stop_netx.ps1 not found"
|
||||
}
|
||||
|
||||
$mode = Get-DbMode -EnvMap $map
|
||||
if ($mode -eq "bundled" -and -not $KeepPostgres) {
|
||||
$pgBinCandidates = @(
|
||||
(Join-Path $prog "postgres\pgsql\bin\pg_ctl.exe"),
|
||||
(Join-Path $PSScriptRoot "postgres\pgsql\bin\pg_ctl.exe")
|
||||
)
|
||||
$pgCtl = $null
|
||||
foreach ($c in $pgBinCandidates) {
|
||||
if (Test-Path $c) { $pgCtl = $c; break }
|
||||
}
|
||||
$pgData = if ($map["NETX_BUNDLED_PG_DATA_DIR"]) { $map["NETX_BUNDLED_PG_DATA_DIR"] } else { Join-Path $data "pgdata" }
|
||||
if ($pgCtl -and (Test-Path $pgData)) {
|
||||
Write-Host "==> Stopping bundled PostgreSQL"
|
||||
& $pgCtl -D $pgData stop -m fast
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "==> Stop done"
|
||||
104
packaging/update_netx.ps1
Normal file
104
packaging/update_netx.ps1
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$PackagePath,
|
||||
[string]$ProgramRoot = "",
|
||||
[string]$DataRoot = "",
|
||||
[switch]$SkipBackup = $false,
|
||||
[switch]$NoStart = $false
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. "$PSScriptRoot\_common.ps1"
|
||||
|
||||
$prog = Get-NetxProgramRoot -Override $ProgramRoot
|
||||
$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot
|
||||
|
||||
if (-not (Test-Path $PackagePath)) {
|
||||
throw "package_not_found: $PackagePath"
|
||||
}
|
||||
|
||||
$work = Join-Path $env:TEMP ("netx-update-" + [guid]::NewGuid().ToString("n"))
|
||||
New-Item -ItemType Directory -Path $work -Force | Out-Null
|
||||
|
||||
try {
|
||||
Write-Host "==> Extracting update package"
|
||||
if ($PackagePath.ToLowerInvariant().EndsWith(".zip")) {
|
||||
Expand-Archive -Path $PackagePath -DestinationPath $work -Force
|
||||
} else {
|
||||
throw "unsupported_package: use a .zip built by build_release.ps1"
|
||||
}
|
||||
|
||||
$src = $work
|
||||
# If zip has a single top-level folder, use it.
|
||||
$kids = @(Get-ChildItem -Path $work -Directory)
|
||||
if ($kids.Count -eq 1 -and (Test-Path (Join-Path $kids[0].FullName "version.json"))) {
|
||||
$src = $kids[0].FullName
|
||||
} elseif (-not (Test-Path (Join-Path $work "version.json"))) {
|
||||
$nested = Get-ChildItem -Path $work -Recurse -Filter "version.json" | Select-Object -First 1
|
||||
if ($nested) { $src = Split-Path -Parent $nested.FullName }
|
||||
}
|
||||
|
||||
$newVerFile = Join-Path $src "version.json"
|
||||
if (-not (Test-Path $newVerFile)) {
|
||||
throw "version.json missing in package"
|
||||
}
|
||||
$newVer = (Get-Content -Raw $newVerFile | ConvertFrom-Json).version
|
||||
$oldVer = Get-NetxVersion -ProgramRoot $prog
|
||||
Write-Host "==> Updating $oldVer -> $newVer"
|
||||
|
||||
if (-not $SkipBackup) {
|
||||
$stamp = Get-Date -Format "yyyyMMdd_HHmmss"
|
||||
$bak = Join-Path $data "backups\pre-update-$stamp"
|
||||
New-Item -ItemType Directory -Path $bak -Force | Out-Null
|
||||
$envFile = Join-Path $data ".env"
|
||||
if (Test-Path $envFile) {
|
||||
Copy-Item $envFile (Join-Path $bak ".env")
|
||||
}
|
||||
Write-Host "==> Backup marker: $bak (data/pgdata left in place; optional pg_dump not run)"
|
||||
}
|
||||
|
||||
Write-Host "==> Stopping services"
|
||||
& powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "stop_netx_app.ps1") `
|
||||
-ProgramRoot $prog -DataRoot $data
|
||||
|
||||
# Replace program layers only — never wipe data root.
|
||||
$replaceDirs = @("netx_api", "alembic", "web", "packaging", "scripts", "postgres", "packages")
|
||||
foreach ($name in $replaceDirs) {
|
||||
$from = Join-Path $src $name
|
||||
$to = Join-Path $prog $name
|
||||
if (-not (Test-Path $from)) { continue }
|
||||
Write-Host "==> Replacing $name"
|
||||
if (Test-Path $to) {
|
||||
Remove-Item -Recurse -Force $to
|
||||
}
|
||||
Copy-Item -Path $from -Destination $to -Recurse -Force
|
||||
}
|
||||
foreach ($f in @("requirements.txt", "pyproject.toml", "alembic.ini", "version.json", "README.md", "LICENSE")) {
|
||||
$from = Join-Path $src $f
|
||||
if (Test-Path $from) {
|
||||
Copy-Item -Path $from -Destination (Join-Path $prog $f) -Force
|
||||
}
|
||||
}
|
||||
# Optional runtime / .venv shipped in package
|
||||
if (Test-Path (Join-Path $src "runtime")) {
|
||||
$rt = Join-Path $prog "runtime"
|
||||
if (Test-Path $rt) { Remove-Item -Recurse -Force $rt }
|
||||
Copy-Item -Path (Join-Path $src "runtime") -Destination $rt -Recurse -Force
|
||||
}
|
||||
if (Test-Path (Join-Path $src ".venv")) {
|
||||
Write-Host "==> Replacing .venv from package"
|
||||
$venvTo = Join-Path $prog ".venv"
|
||||
if (Test-Path $venvTo) { Remove-Item -Recurse -Force $venvTo }
|
||||
Copy-Item -Path (Join-Path $src ".venv") -Destination $venvTo -Recurse -Force
|
||||
}
|
||||
|
||||
Write-Host "==> Update files applied" -ForegroundColor Green
|
||||
if (-not $NoStart) {
|
||||
& powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "start_netx_app.ps1") `
|
||||
-ProgramRoot $prog -DataRoot $data -SkipBrowser
|
||||
}
|
||||
} finally {
|
||||
if (Test-Path $work) {
|
||||
Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||
|
||||
[project]
|
||||
name = "netx-ops"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
description = "netx operations tool: alarm-centric workflows with REST API and stdio MCP"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
|
|
|||
75
tests/test_ui_static.py
Normal file
75
tests/test_ui_static.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Tests for optional SPA static hosting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_api_only_root_when_no_dist(monkeypatch, tmp_path: Path):
|
||||
monkeypatch.setenv("NETX_UI_DIST_DIR", str(tmp_path / "missing"))
|
||||
# Re-import settings + app with patched env is heavy; call helpers directly.
|
||||
from netx_api.ui_static import resolve_ui_dist
|
||||
from netx_api import config as cfg
|
||||
|
||||
monkeypatch.setattr(cfg.settings, "ui_dist_dir", str(tmp_path / "missing"))
|
||||
assert resolve_ui_dist() is None
|
||||
|
||||
|
||||
def test_resolve_ui_dist_when_index_present(monkeypatch, tmp_path: Path):
|
||||
dist = tmp_path / "dist"
|
||||
assets = dist / "assets"
|
||||
assets.mkdir(parents=True)
|
||||
(dist / "index.html").write_text("<!doctype html><title>netx</title>", encoding="utf-8")
|
||||
(assets / "app.js").write_text("console.log(1)", encoding="utf-8")
|
||||
|
||||
from netx_api import config as cfg
|
||||
from netx_api.ui_static import resolve_ui_dist
|
||||
|
||||
monkeypatch.setattr(cfg.settings, "ui_dist_dir", str(dist))
|
||||
assert resolve_ui_dist() == dist.resolve()
|
||||
|
||||
|
||||
def test_mount_ui_serves_index(monkeypatch, tmp_path: Path):
|
||||
dist = tmp_path / "dist"
|
||||
assets = dist / "assets"
|
||||
assets.mkdir(parents=True)
|
||||
(dist / "index.html").write_text("<!doctype html><html><body>ui</body></html>", encoding="utf-8")
|
||||
(assets / "x.js").write_text("ok", encoding="utf-8")
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from netx_api import config as cfg
|
||||
from netx_api.ui_static import mount_ui_if_present
|
||||
|
||||
monkeypatch.setattr(cfg.settings, "ui_dist_dir", str(dist))
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
assert mount_ui_if_present(app) is True
|
||||
client = TestClient(app)
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert "ui" in r.text
|
||||
r2 = client.get("/assets/x.js")
|
||||
assert r2.status_code == 200
|
||||
assert r2.text == "ok"
|
||||
r3 = client.get("/login")
|
||||
assert r3.status_code == 200
|
||||
assert "ui" in r3.text
|
||||
assert client.get("/health").json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_spa_paths_are_public():
|
||||
from netx_api.auth_middleware import _is_public
|
||||
|
||||
assert _is_public("/login") is True
|
||||
assert _is_public("/topology") is True
|
||||
assert _is_public("/assets/app.js") is True
|
||||
assert _is_public("/v1/managed-ne") is False
|
||||
assert _is_public("/health") is True
|
||||
assert _is_public("/v1/auth/login") is True
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build:release": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue