Compare commits

...

39 commits
v0.3.0 ... main

Author SHA1 Message Date
8a79e962a8 Tighten biz compare fullscreen board for cutover scanning.
Collapse equal pair cells, hide display columns by default, fold field filters, and keep failed sheets first so diffs stand out.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 23:37:54 +08:00
ee3ed26c88 Polish biz compare batch list and progress UI.
Show localized phases with progress bars on the runs tab and result board, and make cancel/status easier to act on during long compares.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 23:16:41 +08:00
ccd75a9c09 Release 0.4.12: update/reinstall mode and post-install venv repair.
Interactive Setup asks update vs reinstall; elevated install relinks .venv so tray start works under Program Files.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 22:17:18 +08:00
372138d9a1 Make Forgejo asset upload resilient to schannel revocation-offline errors.
Some checks are pending
Release Windows / build (push) Waiting to run
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 21:11:54 +08:00
7e78485396 Fix Windows release CI parsing and publish Forgejo via git.avelo.top.
Use pwsh -File with UTF-8 BOM scripts, and default Forgejo uploads to the public domain when LAN IP is unreachable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 21:06:47 +08:00
a9c9d17550 Release 0.4.11: Setup-only updates that preserve existing DB config.
Offline Setup and check_update now upgrade in place without the database wizard; zip packages are no longer published.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 21:01:04 +08:00
7b5dd3eada Add per-field compare result filters for live source lookup.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 19:46:41 +08:00
b8c0f96982 Unify compare search via live source lookup; stratify success samples.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 17:47:40 +08:00
dd4195cdbc Compare same-key multipath by order; heartbeat during persist.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 17:14:33 +08:00
3d92a74fac Ship IOH CN migration as ZTE status default; push filters and percent into SQL compare.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 16:38:58 +08:00
85e5f95838 Fix SQL compare stall: detach progress commits and skip small sheets.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 16:09:15 +08:00
2df2f50510 Show compare engine in progress and SQL skip reasons.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 15:56:20 +08:00
ba16167c8e Add SQL JOIN compare for pushdown-safe sheets and mid-run result viewing.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 15:16:40 +08:00
958942c5b6 Add compare batch cancel and restart recovery so stuck runs unblock.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 14:48:10 +08:00
9adf9f2764 Improve compare progress for large BGP sheets.
Show pending check items instead of a fake 100% pass, publish sheet results as each finishes, and use keyset chunk loads with rows-loaded progress.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 14:17:54 +08:00
c5efbbd016 Fix compare load crash from ORM yield_per with unique().
Load metric rows in LIMIT/OFFSET chunks so large-table compares no longer hit the SQLAlchemy yield_per/unique conflict.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 11:17:29 +08:00
227959b9a2 Make biz compare async and cutover-friendly for large tables.
Run compares in the background with progress, persist fails first, and store success as sampled key+row_id hydrated from source so million-row sheets no longer block the API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 10:47:39 +08:00
3e2cb4165c Release 0.4.10: keep tray responsive during update/reconfigure.
Poll external processes instead of Start-Process -Wait on the UI thread, and restart the tray after a successful update.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 18:13:19 +08:00
e14f983aeb Release 0.4.9: update console exits cleanly; tray refreshes version.
Start NetX in-process after apply (no nested Wait hang) and reload version.json in the tray tip/menu.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 17:54:37 +08:00
66f9fa9782 Release 0.4.8: fix in-place update (elevate + ship python/runtime).
Apply updates as Administrator for Program Files, copy portable python with .venv, and wait for tray apply to finish.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 17:33:34 +08:00
4e582dc4a5 Release 0.4.7: ProgramData ACL fix and tray menu polish.
Some checks failed
Release Windows / build (push) Has been cancelled
Allow non-admin reconfigure of .env, harden setup/tray flows, and refresh the context menu with hover selection.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 16:43:28 +08:00
5429c07342 Fix portable Python: ship python/runtime and relink .venv on install (0.4.6).
Some checks failed
Release Windows / build (push) Has been cancelled
0.4.5 venv pointed at the build machine user profile; other PCs failed to start API.
Bundle stdlib under python/runtime, repair pyvenv.cfg, and health-check before start.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 15:48:57 +08:00
3a89438e5b Fix bundled install auto-config: always run wizard DB setup, block tray until .env exists.
Selecting built-in PostgreSQL now always applies setup_first_run after files copy;
failed config shows a clear error instead of a misleading first-time tip.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 15:06:17 +08:00
97393cd407 Release 0.4.5: installer DB wizard, credential key, lean offline Setup.
Choose bundled or external PostgreSQL in the Setup wizard with connection
validation; optional NETX_CREDENTIAL_SECRET_KEY; single desktop shortcut;
faster uninstall and tray startup for offline Windows packages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 14:44:40 +08:00
86dfd41f44 Fix Windows install start: writable runtime logs and robust Postgres bootstrap.
Some checks failed
Release Windows / build (push) Has been cancelled
API logs go under ProgramData; first-run verifies CREATE ROLE/DATABASE and login.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 11:22:01 +08:00
ca97a2a597 Add Explorer .cmd launchers and always-on desktop First-time setup shortcuts.
Server 2012 Start Menu often hides PowerShell .lnk entries; .cmd targets are visible.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 11:03:38 +08:00
41ab4921a0 Fix Windows PowerShell parsing on Server 2012: UTF-8 BOM scripts.
Chinese packaging scripts without BOM were misread as ANSI and failed at first-run.
Document that bundled Python 3.13+ needs Server 2016+ / Win10+.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 10:53:29 +08:00
8c35922432 Release 0.4.2: visible shortcut launcher and auto first-run setup.
Some checks failed
Release Windows / build (push) Has been cancelled
Shortcuts no longer fail silently; missing .env triggers bundled setup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 10:38:36 +08:00
d150a060f2 Fix GitHub publish when release tag does not exist yet.
Some checks failed
Release Windows / build (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 10:24:38 +08:00
f19840ac7e Bump version to 0.4.1 for offline bilingual Windows Setup.
Some checks failed
Release Windows / build (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 10:18:17 +08:00
b016037270 Make Windows Setup bilingual, offline-capable, and use NetX branding.
Ship WinSW + icon assets, default first-run without network, EN/ZH installer UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 10:15:40 +08:00
6603afd007 Ignore IDE, local tmp probes, and ne_exec job dumps.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 00:57:44 +08:00
022de81eb0 Add Forgejo LAN release publish script and maintainer checklist.
Default upload target is home Forgejo (10.0.0.131); mirror sync does not copy GitHub assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 00:56:53 +08:00
d10d47e8f5 Harden Windows packaging: data-root paths, service health, safer updates.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 00:12:14 +08:00
d0b260b67d Default update probe: GitHub plus Forgejo mirror, pick newest reachable.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 00:00:31 +08:00
07a7e17e22 Support Forgejo as primary update source with GitHub fallback.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 23:55:12 +08:00
b639f01c0f Add Windows service, silent auto-update, and code-signing hooks.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 23:53:07 +08:00
fe9df32024 Add update checker, tray controller, and slimmer release venv.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 23:40:43 +08:00
54c6bc9298 Find Inno Setup under LocalAppData for per-user installs.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 23:23:20 +08:00
61 changed files with 10622 additions and 682 deletions

View file

@ -120,8 +120,16 @@ NETX_UME_NOTIFICATION_TOPIC=ALARM
# bundled | external; unset = external (compatible with existing deploys) # bundled | external; unset = external (compatible with existing deploys)
# NETX_BUNDLED_PG_PORT=15432 # NETX_BUNDLED_PG_PORT=15432
# NETX_BUNDLED_PG_DATA_DIR= # NETX_BUNDLED_PG_DATA_DIR=
# Update: GitHub primary + Forgejo mirror (git.avelo.top); pick newest reachable
# NETX_UPDATE_SOURCES=github,forgejo
# NETX_UPDATE_FORGEJO_URL=https://git.avelo.top/api/v1/repos/hansjone/netx/releases/latest
# NETX_UPDATE_GITHUB_REPO=hansjone/netx
# NETX_UPDATE_URL= # NETX_UPDATE_URL=
# NETX_UPDATE_FALLBACK_URL=
# NETX_UPDATE_TOKEN=
# NETX_UPDATE_CHANNEL=stable # NETX_UPDATE_CHANNEL=stable
# NETX_UPDATE_AUTO=false
# When true: tray/scheduled task may download+apply zip updates silently.
# Heavier fleets: raise CLI/DB together; also ensure Postgres max_connections and bastion session limits. # 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 # One-click start (recommended): .\scripts\start_netx.ps1 -Background -WithWeb
# -> API + netx_api.worker + N× netx_api.biz_state_worker + optional Vite # -> API + netx_api.worker + N× netx_api.biz_state_worker + optional Vite

View file

@ -22,10 +22,11 @@ jobs:
with: with:
python-version: "3.12" python-version: "3.12"
- name: Build release zip - name: Build release stage
shell: pwsh shell: pwsh
run: | run: |
powershell -ExecutionPolicy Bypass -File ./packaging/build_release.ps1 -CreateVenv # Use pwsh -File (UTF-8). Nested Windows PowerShell 5.1 mis-parses UTF-8 scripts without BOM.
& ./packaging/build_release.ps1 -CreateVenv
- name: Install Inno Setup - name: Install Inno Setup
shell: pwsh shell: pwsh
@ -43,6 +44,5 @@ jobs:
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
files: | files: |
packaging/release/NetX-*-win64.zip
packaging/release/NetX-Setup-*.exe packaging/release/NetX-Setup-*.exe
generate_release_notes: true generate_release_notes: true

5
.gitignore vendored
View file

@ -11,13 +11,18 @@ scripts/.run/
scripts/_* scripts/_*
scripts/archive/ scripts/archive/
packages/netx-topology-mcp/tests/_* packages/netx-topology-mcp/tests/_*
_tmp_*
.idea/
ume/ ume/
data/ne_collections/ data/ne_collections/
data/webcrt/ data/webcrt/
data/auth/ data/auth/
data/runtime/ data/runtime/
data/ne_exec_jobs/
# Windows packaging artifacts (binaries / release trees) # Windows packaging artifacts (binaries / release trees)
packaging/cache/ packaging/cache/
packaging/release/ packaging/release/
packaging/postgres/pgsql/ packaging/postgres/pgsql/
packaging/winsw/
*.exe *.exe
!packaging/installer/*.iss

View file

@ -158,9 +158,9 @@ 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`. 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) ### Windows installer (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`. Fool-proof Windows delivery via `NetX-Setup-*.exe` (bundled or external Postgres, program/data split, offline upgrade over existing installs) 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) ### 6) MCP(Cursor / oclaw / Claude)

View file

@ -15,6 +15,7 @@ from .schema_patches import (
apply_topology_schema_safety_net, apply_topology_schema_safety_net,
run_alembic_upgrade_to_head, run_alembic_upgrade_to_head,
) )
from .ne_crypto import ensure_credential_secret_key
from .security_bootstrap import assert_secure_defaults_or_exit from .security_bootstrap import assert_secure_defaults_or_exit
from .ume_runtime import start_api_sideband_threads, start_device_schedulers from .ume_runtime import start_api_sideband_threads, start_device_schedulers
import netx_api.ume_support as ume_support import netx_api.ume_support as ume_support
@ -42,6 +43,8 @@ def _configure_ume_diag_logging() -> None:
def run_api_startup() -> None: def run_api_startup() -> None:
"""Full API boot sequence previously inlined in ``main.on_startup``.""" """Full API boot sequence previously inlined in ``main.on_startup``."""
assert_secure_defaults_or_exit() assert_secure_defaults_or_exit()
# Persist Fernet key for managed-NE passwords (same idea as JWT secret file).
ensure_credential_secret_key()
_configure_ume_diag_logging() _configure_ume_diag_logging()
_log.info( _log.info(
"startup: ne_exec_policy_enabled=%s", "startup: ne_exec_policy_enabled=%s",
@ -156,6 +159,17 @@ def run_api_startup() -> None:
) )
except Exception: except Exception:
_log.exception("startup: biz_state collect recovery failed") _log.exception("startup: biz_state collect recovery failed")
try:
from .biz_state.compare_service import recover_interrupted_compares_on_startup
cmp_rec = recover_interrupted_compares_on_startup(db)
if cmp_rec.get("runs"):
_log.info(
"startup: cancelled %s interrupted biz compare run(s)",
cmp_rec.get("runs"),
)
except Exception:
_log.exception("startup: biz compare recovery failed")
try: try:
from .port_traffic_migrate import backfill_port_traffic_series from .port_traffic_migrate import backfill_port_traffic_series

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict, deque
from typing import Any, Mapping, Sequence from typing import Any, Mapping, Sequence
from .compare_rules import field_rule_map, values_equal, explain_diff from .compare_rules import field_rule_map, values_equal, explain_diff
@ -11,6 +12,53 @@ from .iface_normalize import (
resolve_mapped_iface, resolve_mapped_iface,
) )
# Prefer these fields when stratifying success samples (BGP multipath / multi-cmd).
_STRATUM_FIELDS = ("neighbor", "direction", "afi", "vrf")
def stratum_key(row: Mapping[str, Any] | None) -> str:
"""Bucket key for stratified unchanged sampling."""
if not isinstance(row, Mapping):
return "_"
parts: list[str] = []
for f in _STRATUM_FIELDS:
v = str(row.get(f) or "").strip()
if v:
parts.append(f"{f}={v}")
return "|".join(parts) if parts else "_"
def stratify_take(items: list[Any], limit: int, *, key_fn) -> list[Any]:
"""Round-robin across strata so one neighbor/direction cannot consume the whole sample."""
lim = max(0, int(limit))
if lim <= 0 or not items:
return []
if len(items) <= lim:
return list(items)
buckets: dict[str, deque[Any]] = defaultdict(deque)
order: list[str] = []
for it in items:
sk = str(key_fn(it) or "_")
if sk not in buckets:
order.append(sk)
buckets[sk].append(it)
out: list[Any] = []
while len(out) < lim and buckets:
drained: list[str] = []
for sk in order:
q = buckets.get(sk)
if not q:
drained.append(sk)
continue
out.append(q.popleft())
if len(out) >= lim:
break
for sk in drained:
buckets.pop(sk, None)
if sk in order:
order = [x for x in order if x != sk]
return out
def apply_port_map( def apply_port_map(
row: dict[str, Any], row: dict[str, Any],
@ -105,12 +153,28 @@ def compare_rows(
field_rules: Sequence[Mapping[str, Any]] | None = None, field_rules: Sequence[Mapping[str, Any]] | None = None,
iface_normalize_rules: Sequence[Mapping[str, str]] | None = None, iface_normalize_rules: Sequence[Mapping[str, str]] | None = None,
ignore_port_changes: bool | None = None, ignore_port_changes: bool | None = None,
include_unchanged: bool = False,
unchanged_limit: int | None = None,
compact_unchanged: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return summary + diffs list. """Return summary + diffs list.
Diff kinds: added | removed | changed | unchanged | duplicate Diff kinds: added | removed | changed | unchanged
Pipeline: iface normalize (both sides) → port map (before) → match. Pipeline: iface normalize (both sides) → port map (before) → ordered
same-key pairing (load / list order within each match key).
Same match key with N before and M after rows: zip by index
``0..min(N,M)-1`` for field compare; extras become ``removed`` (before)
or ``added`` (after). Example: 5 vs 2 → 2 compared + 3 removed.
``include_unchanged``: when False, matching rows still increment
``summary.unchanged`` but are omitted from ``diffs``.
``unchanged_limit``: max unchanged diffs to emit (None = no cap). Use with
large sheets so the UI can browse a sample without writing millions of rows.
``compact_unchanged``: emit key only (empty before/after) to cut storage.
``ignore_port_changes``: ``ignore_port_changes``:
- ``None`` (default): auto — drop iface from match key only when remaining - ``None`` (default): auto — drop iface from match key only when remaining
@ -118,8 +182,8 @@ def compare_rows(
- ``True``: force drop iface from match key when a non-empty candidate exists. - ``True``: force drop iface from match key when a non-empty candidate exists.
- ``False``: never drop iface from match key. - ``False``: never drop iface from match key.
Duplicate match keys are not silently discarded: extras become ``duplicate`` ``summary.duplicate`` is always 0. ``duplicate_keys_*`` count match keys
diffs and ``summary.duplicate_key_list`` lists the colliding keys. that appear more than once on a side (diagnostic only).
``field_rules`` drives normalize / numeric tolerance / per-field compare mode ``field_rules`` drives normalize / numeric tolerance / per-field compare mode
(template-driven; no metric-specific branches here). (template-driven; no metric-specific branches here).
@ -166,49 +230,109 @@ def compare_rows(
apply_port_map(r, iface_fields=iface_list, port_map=pmap) for r in before_norm apply_port_map(r, iface_fields=iface_list, port_map=pmap) for r in before_norm
] ]
after_index: dict[tuple[str, ...], dict[str, Any]] = {} before_groups: dict[tuple[str, ...], list[tuple[dict[str, Any], dict[str, Any]]]] = (
after_dup = 0 defaultdict(list)
after_dup_keys: list[tuple[str, ...]] = [] )
after_dup_rows: list[tuple[tuple[str, ...], dict[str, Any]]] = [] after_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
for orig, mapped in zip(before_rows, before_mapped):
before_groups[row_key(mapped, match_keys)].append((orig, mapped))
for r in after_norm: for r in after_norm:
k = row_key(r, match_keys) after_groups[row_key(r, match_keys)].append(r)
if k in after_index:
after_dup += 1
after_dup_keys.append(k)
after_dup_rows.append((k, r))
continue # first wins — do not overwrite
after_index[k] = r
before_keys: set[tuple[str, ...]] = set()
before_dup = 0
before_dup_keys: list[tuple[str, ...]] = []
diffs: list[dict[str, Any]] = [] diffs: list[dict[str, Any]] = []
added = removed = changed = unchanged = duplicate = 0 added = removed = changed = unchanged = 0
unchanged_listed = 0
limit_n = None if unchanged_limit is None else max(0, int(unchanged_limit))
multi_before_keys: list[tuple[str, ...]] = []
multi_after_keys: list[tuple[str, ...]] = []
unchanged_candidates: list[tuple[dict[str, Any], dict[str, Any], dict[str, Any]]] = []
def _key_obj(row: dict[str, Any]) -> dict[str, Any]: def _key_obj(row: dict[str, Any]) -> dict[str, Any]:
return {f: row.get(f, "") for f in key_fields} return {f: row.get(f, "") for f in key_fields}
for orig, mapped in zip(before_rows, before_mapped): def _row_id(row: dict[str, Any] | None) -> str:
k = row_key(mapped, match_keys) if not isinstance(row, dict):
if k in before_keys: return ""
before_dup += 1 netx = row.get("_netx")
before_dup_keys.append(k) if isinstance(netx, dict):
duplicate += 1 return str(netx.get("row_id") or "")
return ""
def _append_unchanged_diff(
orig: dict[str, Any], mapped: dict[str, Any], after_row: dict[str, Any]
) -> None:
nonlocal unchanged_listed
unchanged_listed += 1
before_rid = _row_id(orig)
after_rid = _row_id(after_row)
if compact_unchanged:
diffs.append( diffs.append(
{ {
"kind": "duplicate", "kind": "unchanged",
"side": "before",
"key": _key_obj(mapped), "key": _key_obj(mapped),
"before": orig, "before": {},
"after": after_index.get(k), "after": {},
"mapped_before": mapped, "mapped_before": {},
"changes": {}, "changes": {},
"compact": True,
"before_row_id": before_rid,
"after_row_id": after_rid,
} }
) )
continue # only first before row participates in match else:
before_keys.add(k) diffs.append(
after = after_index.get(k) {
if after is None: "kind": "unchanged",
"key": _key_obj(mapped),
"before": orig,
"after": after_row,
"mapped_before": mapped,
"changes": {},
"before_row_id": before_rid,
"after_row_id": after_rid,
}
)
# Stable key order: before encounter order, then after-only keys
seen_keys: set[tuple[str, ...]] = set()
ordered_keys: list[tuple[str, ...]] = []
for orig, mapped in zip(before_rows, before_mapped):
k = row_key(mapped, match_keys)
if k not in seen_keys:
seen_keys.add(k)
ordered_keys.append(k)
for r in after_norm:
k = row_key(r, match_keys)
if k not in seen_keys:
seen_keys.add(k)
ordered_keys.append(k)
for k in ordered_keys:
b_list = before_groups.get(k) or []
a_list = after_groups.get(k) or []
if len(b_list) > 1:
multi_before_keys.append(k)
if len(a_list) > 1:
multi_after_keys.append(k)
n = max(len(b_list), len(a_list))
for i in range(n):
if i >= len(b_list):
after = a_list[i]
added += 1
diffs.append(
{
"kind": "added",
"key": _key_obj(after),
"before": None,
"after": after,
"mapped_before": None,
"changes": {},
"before_row_id": "",
"after_row_id": _row_id(after),
}
)
continue
if i >= len(a_list):
orig, mapped = b_list[i]
removed += 1 removed += 1
diffs.append( diffs.append(
{ {
@ -218,11 +342,13 @@ def compare_rows(
"after": None, "after": None,
"mapped_before": mapped, "mapped_before": mapped,
"changes": {}, "changes": {},
"before_row_id": _row_id(orig),
"after_row_id": "",
} }
) )
continue continue
# Empty compare_fields = presence-only: keyed rows that exist on both orig, mapped = b_list[i]
# sides are unchanged (no value checks). after = a_list[i]
field_changes: dict[str, dict[str, Any]] = {} field_changes: dict[str, dict[str, Any]] = {}
for f in compare_fields: for f in compare_fields:
bv = mapped.get(f, "") bv = mapped.get(f, "")
@ -244,49 +370,26 @@ def compare_rows(
"after": after, "after": after,
"mapped_before": mapped, "mapped_before": mapped,
"changes": field_changes, "changes": field_changes,
"before_row_id": _row_id(orig),
"after_row_id": _row_id(after),
} }
) )
else: else:
unchanged += 1 unchanged += 1
diffs.append( if include_unchanged:
{ unchanged_candidates.append((orig, mapped, after))
"kind": "unchanged",
"key": _key_obj(mapped),
"before": orig,
"after": after,
"mapped_before": mapped,
"changes": {},
}
)
for k, after in after_index.items(): if include_unchanged and unchanged_candidates:
if k in before_keys: if limit_n is None:
continue picked = unchanged_candidates
added += 1 else:
diffs.append( picked = stratify_take(
{ unchanged_candidates,
"kind": "added", limit_n,
"key": _key_obj(after), key_fn=lambda t: stratum_key(t[1]),
"before": None,
"after": after,
"mapped_before": None,
"changes": {},
}
)
for k, after in after_dup_rows:
duplicate += 1
diffs.append(
{
"kind": "duplicate",
"side": "after",
"key": _key_obj(after),
"before": None,
"after": after,
"mapped_before": None,
"changes": {},
}
) )
for orig, mapped, after_row in picked:
_append_unchanged_diff(orig, mapped, after_row)
def _fmt_keys(keys: list[tuple[str, ...]]) -> list[str]: def _fmt_keys(keys: list[tuple[str, ...]]) -> list[str]:
seen: set[str] = set() seen: set[str] = set()
@ -296,7 +399,7 @@ def compare_rows(
if s not in seen: if s not in seen:
seen.add(s) seen.add(s)
out.append(s) out.append(s)
return out return out[:64]
stats = mapping_stats( stats = mapping_stats(
before_rows=before_norm, before_rows=before_norm,
@ -314,11 +417,21 @@ def compare_rows(
"removed": removed, "removed": removed,
"changed": changed, "changed": changed,
"unchanged": unchanged, "unchanged": unchanged,
"duplicate": duplicate, "duplicate": 0,
"match_key_fields": match_keys, "match_key_fields": match_keys,
"duplicate_keys_before": before_dup, "duplicate_keys_before": len(multi_before_keys),
"duplicate_keys_after": after_dup, "duplicate_keys_after": len(multi_after_keys),
"duplicate_key_list": _fmt_keys(before_dup_keys + after_dup_keys), "duplicate_key_list": _fmt_keys(multi_before_keys + multi_after_keys),
"unchanged_listed": unchanged_listed,
"unchanged_truncated": bool(
include_unchanged and limit_n is not None and unchanged > unchanged_listed
),
"unchanged_compact": bool(compact_unchanged and unchanged_listed > 0),
"unchanged_sample_mode": (
"stratified"
if include_unchanged and limit_n is not None and unchanged > unchanged_listed
else ("full" if include_unchanged else "none")
),
}, },
"diffs": diffs, "diffs": diffs,
"mapping_stats": stats, "mapping_stats": stats,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,753 @@
"""PostgreSQL-backed sheet compare (FULL OUTER JOIN) for pushdown-safe sheets.
Falls back to the Python engine when port-map / complex rules cannot be expressed
in SQL. See ``can_sql_compare``.
"""
from __future__ import annotations
import logging
import re
from typing import Any, Callable, Mapping, Sequence
from uuid import uuid4
from sqlalchemy import text
from sqlalchemy.orm import Session
from .compare_rules import effective_compare_fields, field_rule_map
_log = logging.getLogger("netx.biz_state.compare_sql")
# Below this, Python hash-join is faster and avoids TEMP CTAS / progress races.
_SQL_MIN_ROWS = 20_000
# Cap a single SQL statement so a stuck planner/lock surfaces as fallback.
_SQL_STATEMENT_TIMEOUT_MS = 180_000
_FIELD_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
class SqlCompareSkip(Exception):
"""Soft skip — caller should use the Python engine (not an error)."""
def __init__(self, reason: str) -> None:
super().__init__(reason)
self.reason = str(reason or "skip")
_SQL_FILTER_OPS = frozenset(
{"eq", "==", "ne", "!=", "in", "not_in", "nin", "contains", "empty", "not_empty", "nonempty", "ci_eq"}
)
_SQL_NORMALIZE = frozenset({"", "none", "strip", "lower", "upper", "empty_as_blank"})
_SQL_COMPARE_MODES = frozenset(
{
"",
"eq",
"ignore",
"skip",
"off",
"numeric",
"number",
"int",
"float",
"percent",
"pct",
"rel",
}
)
_EMPTY_AS_BLANK = ("n/a", "na", "-", "--", "none", "null")
_NUM_RE_SQL = r"^-?[0-9]+(\.[0-9]+)?([eE][-+]?[0-9]+)?$"
def _dialect_is_postgres(db: Session) -> bool:
bind = db.get_bind()
if bind is None:
return False
return str(getattr(bind.dialect, "name", "") or "").lower() in ("postgresql", "postgres")
def _safe_field(name: str) -> str:
f = str(name or "").strip()
if not f or not _FIELD_RE.match(f):
raise ValueError(f"unsafe_json_field:{name!r}")
return f
def _json_text_expr(alias: str, field: str) -> str:
"""SQL expression: trimmed text from JSONB column ``alias.data`` / ``data_json``."""
f = _safe_field(field)
# alias is caller-controlled (_b / data_json) — not user input
return f"trim(both from coalesce({alias}->>'{f}', ''))"
def _norm_expr(alias: str, field: str, normalize: str) -> str:
base = _json_text_expr(alias, field)
mode = str(normalize or "strip").strip().lower() or "strip"
if mode in ("", "none", "strip"):
return base
if mode == "lower":
return f"lower({base})"
if mode == "upper":
return f"upper({base})"
if mode == "empty_as_blank":
opts = ", ".join(f"'{x}'" for x in _EMPTY_AS_BLANK)
return (
f"CASE WHEN lower({base}) IN ({opts}) THEN '' ELSE {base} END"
)
raise ValueError(f"unsupported_normalize:{mode}")
def _filters_sql_compatible(filters: Sequence[Mapping[str, Any]] | None) -> bool:
fl = [f for f in (filters or []) if isinstance(f, dict)]
return all(_filter_node_compatible(f) for f in fl)
def _filter_node_compatible(filt: Mapping[str, Any]) -> bool:
if "any" in filt:
kids = filt.get("any") or []
return isinstance(kids, list) and all(
isinstance(k, dict) and _filter_node_compatible(k) for k in kids
)
if "all" in filt:
kids = filt.get("all") or []
return isinstance(kids, list) and all(
isinstance(k, dict) and _filter_node_compatible(k) for k in kids
)
field = str(filt.get("field") or "").strip()
op = str(filt.get("op") or "eq").strip().lower()
if op not in _SQL_FILTER_OPS:
return False
if op in ("empty", "not_empty", "nonempty"):
return bool(field) and bool(_FIELD_RE.match(field))
if not field or not _FIELD_RE.match(field):
return False
if op in ("in", "not_in", "nin"):
vals = filt.get("value")
if vals is None:
return True
if not isinstance(vals, (list, tuple)):
vals = [vals]
return all(isinstance(x, (str, int, float, bool)) or x is None for x in vals)
return True
def _field_rules_sql_compatible(rules: Sequence[Mapping[str, Any]] | None) -> bool:
for raw in rules or []:
if not isinstance(raw, dict):
return False
name = str(raw.get("field") or "").strip()
if name and not _FIELD_RE.match(name):
return False
mode = str(raw.get("compare") or "eq").strip().lower() or "eq"
if mode not in _SQL_COMPARE_MODES:
return False
if raw.get("ignore") is True:
continue
if mode in ("ignore", "skip", "off"):
continue
norm = str(raw.get("normalize") or "strip").strip().lower() or "strip"
if norm not in _SQL_NORMALIZE:
return False
return True
def sql_compare_skip_reason(
db: Session,
sheet: Mapping[str, Any],
*,
port_map: Mapping[str, str] | None = None,
iface_normalize_rules: Sequence[Mapping[str, str]] | None = None,
) -> str:
"""Empty string when SQL is allowed; otherwise a short reason for progress/logs.
Simple ``row_filters`` (eq/in/contains/…) used for BGP sheet splits are fine —
they do **not** force Python by themselves.
"""
if not _dialect_is_postgres(db):
return "not_postgres"
if port_map:
return "port_map"
key_fields = [str(k).strip() for k in (sheet.get("key_fields") or []) if str(k).strip()]
if not key_fields:
return "no_key_fields"
if any(not _FIELD_RE.match(k) for k in key_fields):
return "unsafe_key_field"
iface_fields = [str(f).strip() for f in (sheet.get("iface_fields") or []) if str(f).strip()]
iface_set = set(iface_fields)
ignore_ports = sheet.get("ignore_port_changes")
if ignore_ports is True:
return "ignore_port_changes"
if ignore_ports is None and iface_set and any(k in iface_set for k in key_fields):
return "auto_ignore_ports"
field_rules = list(sheet.get("field_rules") or [])
if not _field_rules_sql_compatible(field_rules):
return "field_rules"
compare_fields = effective_compare_fields(
list(sheet.get("compare_fields") or []),
field_rules,
)
if any(not _FIELD_RE.match(str(f).strip()) for f in compare_fields if str(f).strip()):
return "unsafe_compare_field"
used = set(key_fields) | {str(f).strip() for f in compare_fields if str(f).strip()}
if iface_normalize_rules and (used & iface_set):
return "iface_normalize"
if not _filters_sql_compatible(list(sheet.get("row_filters") or [])):
return "row_filters"
if not str(sheet.get("metric_id") or "").strip():
return "no_metric_id"
return ""
def can_sql_compare(
db: Session,
sheet: Mapping[str, Any],
*,
port_map: Mapping[str, str] | None = None,
iface_normalize_rules: Sequence[Mapping[str, str]] | None = None,
) -> bool:
"""True when this sheet can run entirely as a PostgreSQL JOIN."""
return not bool(
sql_compare_skip_reason(
db,
sheet,
port_map=port_map,
iface_normalize_rules=iface_normalize_rules,
)
)
def compile_row_filters_sql(
filters: Sequence[Mapping[str, Any]] | None,
*,
json_col: str = "data_json",
param_prefix: str = "f",
) -> tuple[str, dict[str, Any]]:
"""Compile template row_filters to SQL AND-clause + bind params.
Returns ``(sql_fragment, params)``. Empty filters → ``(\"TRUE\", {})``.
``json_col`` is the JSONB column/expression name (trusted).
"""
fl = [f for f in (filters or []) if isinstance(f, dict)]
if not fl:
return "TRUE", {}
params: dict[str, Any] = {}
counter = {"n": 0}
def _next(name: str) -> str:
counter["n"] += 1
return f"{param_prefix}_{counter['n']}_{name}"
def _node(filt: Mapping[str, Any]) -> str:
if "any" in filt:
kids = [k for k in (filt.get("any") or []) if isinstance(k, dict)]
if not kids:
return "TRUE"
return "(" + " OR ".join(_node(k) for k in kids) + ")"
if "all" in filt:
kids = [k for k in (filt.get("all") or []) if isinstance(k, dict)]
if not kids:
return "TRUE"
return "(" + " AND ".join(_node(k) for k in kids) + ")"
field = _safe_field(str(filt.get("field") or ""))
op = str(filt.get("op") or "eq").strip().lower()
expr = _json_text_expr(json_col, field)
# _json_text_expr uses alias->> ; for bare column use data_json directly
if json_col == "data_json":
expr = f"trim(both from coalesce(data_json->>'{field}', ''))"
if op in ("empty",):
return f"({expr} = '')"
if op in ("not_empty", "nonempty"):
return f"({expr} <> '')"
expect = filt.get("value")
if op in ("eq", "==", "ci_eq"):
key = _next("v")
params[key] = str(expect or "").strip()
if op == "ci_eq" or op in ("eq", "=="):
# Python eq is case-insensitive via .lower()
params[key] = str(expect or "").strip().lower()
return f"(lower({expr}) = :{key})"
if op in ("ne", "!="):
key = _next("v")
params[key] = str(expect or "").strip().lower()
return f"(lower({expr}) <> :{key})"
if op == "contains":
key = _next("v")
needle = str(expect or "").strip().lower()
params[key] = f"%{needle}%"
return f"(lower({expr}) LIKE :{key})"
if op in ("in", "not_in", "nin"):
vals = expect
if vals is None:
vals = []
if not isinstance(vals, (list, tuple)):
vals = [vals]
clean = [str(x).strip().lower() for x in vals if str(x).strip()]
if not clean:
return "FALSE" if op == "in" else "TRUE"
keys = []
for i, v in enumerate(clean):
k = _next(f"in{i}")
params[k] = v
keys.append(f":{k}")
inside = f"lower({expr}) IN ({', '.join(keys)})"
return f"({inside})" if op == "in" else f"(NOT {inside})"
raise ValueError(f"unsupported_filter_op:{op}")
parts = [_node(f) for f in fl]
return "(" + " AND ".join(parts) + ")", params
def _rk_sql(key_fields: list[str], *, json_col: str = "data_json") -> str:
parts = []
for k in key_fields:
f = _safe_field(k)
if json_col == "data_json":
parts.append(f"trim(both from coalesce(data_json->>'{f}', ''))")
else:
parts.append(f"trim(both from coalesce({json_col}->>'{f}', ''))")
if len(parts) == 1:
return parts[0]
return "concat_ws('|', " + ", ".join(parts) + ")"
def _field_differs_sql(bv: str, av: str, rule: Mapping[str, Any]) -> str:
"""SQL boolean: True when before/after values differ under the field rule."""
mode = str(rule.get("compare") or "eq").strip().lower() or "eq"
if mode in ("ignore", "skip", "off") or rule.get("ignore") is True:
return "FALSE"
try:
tol = float(rule.get("tolerance") or 0)
except (TypeError, ValueError):
tol = 0.0
if mode in ("numeric", "number", "int", "float", "percent", "pct", "rel"):
# Match Python values_equal: parseable → numeric compare; else string eq
num_b = f"(({bv}) ~ '{_NUM_RE_SQL}')"
num_a = f"(({av}) ~ '{_NUM_RE_SQL}')"
bn = f"({bv})::double precision"
an = f"({av})::double precision"
if mode in ("percent", "pct", "rel"):
# differ when relative % > tol (before==0 → after must be 0)
num_diff = (
f"(CASE WHEN {bn} = 0 THEN {an} IS DISTINCT FROM 0 "
f"ELSE (abs({an} - {bn}) / abs({bn}) * 100.0) > {tol} END)"
)
else:
num_diff = f"(abs({an} - {bn}) > {tol})"
return (
f"(CASE WHEN {num_b} AND {num_a} THEN {num_diff} "
f"ELSE ({bv} IS DISTINCT FROM {av}) END)"
)
return f"({bv} IS DISTINCT FROM {av})"
def _changed_predicate(
compare_fields: list[str],
rules: dict[str, dict[str, Any]],
*,
before_alias: str = "b",
after_alias: str = "a",
) -> str:
"""SQL boolean: True when any compare field differs (IS DISTINCT FROM)."""
if not compare_fields:
return "FALSE"
clauses: list[str] = []
for f in compare_fields:
name = str(f).strip()
if not name:
continue
rule = rules.get(name) or {}
mode = str(rule.get("compare") or "eq").strip().lower() or "eq"
if mode in ("ignore", "skip", "off") or rule.get("ignore") is True:
continue
norm = str(rule.get("normalize") or "strip").strip().lower() or "strip"
bv = _norm_expr(f"{before_alias}.data", name, norm)
av = _norm_expr(f"{after_alias}.data", name, norm)
clauses.append(_field_differs_sql(bv, av, rule))
if not clauses:
return "FALSE"
return "(" + " OR ".join(clauses) + ")"
def _key_obj_from_data(data: dict[str, Any] | None, key_fields: list[str]) -> dict[str, Any]:
row = data if isinstance(data, dict) else {}
return {f: row.get(f, "") for f in key_fields}
def _changes_from_rows(
before: dict[str, Any] | None,
after: dict[str, Any] | None,
compare_fields: list[str],
rules: dict[str, dict[str, Any]],
) -> dict[str, dict[str, Any]]:
from .compare_rules import explain_diff, values_equal
out: dict[str, dict[str, Any]] = {}
b = before or {}
a = after or {}
for f in compare_fields:
rule = rules.get(f)
bv = b.get(f, "")
av = a.get(f, "")
if values_equal(bv, av, rule=rule):
continue
entry: dict[str, Any] = {"before": bv, "after": av}
reason = explain_diff(bv, av, rule=rule)
if reason:
entry["reason"] = reason
out[f] = entry
return out
def run_sql_sheet_compare(
db: Session,
*,
sheet: Mapping[str, Any],
before_batch_id: str,
after_batch_id: str,
store_unchanged: str = "auto",
on_progress: Callable[..., None] | None = None,
) -> dict[str, Any]:
"""Compare one sheet via PostgreSQL TEMP tables + FULL OUTER JOIN.
Same-key multipath: rows get ``dup_rn`` by ``seq,id`` and join on
``(rk, dup_rn)`` (ordered zip). Extras are added/removed, not duplicate.
Returns the same ``{summary, diffs, mapping_stats}`` shape as ``compare_rows``.
``on_progress(side, n, *, engine=\"sql\", note=..., phase=...)``.
"""
if not _dialect_is_postgres(db):
raise RuntimeError("sql_compare_requires_postgres")
def _prog(side: str, n: int, *, phase: str = "sql_count", note: str = "") -> None:
if not on_progress:
return
try:
on_progress(side, n, engine="sql", note=note, phase=phase)
except TypeError:
on_progress(side, n)
key_fields = [str(k).strip() for k in (sheet.get("key_fields") or []) if str(k).strip()]
field_rules = list(sheet.get("field_rules") or [])
rules = field_rule_map(field_rules)
compare_fields = effective_compare_fields(
list(sheet.get("compare_fields") or []),
field_rules,
)
row_filters = list(sheet.get("row_filters") or [])
mid = str(sheet.get("metric_id") or "").strip()
bid_b = str(before_batch_id or "").strip()
bid_a = str(after_batch_id or "").strip()
if not mid or not bid_b or not bid_a or not key_fields:
raise ValueError("sql_compare_missing_args")
filter_sql, filter_params = compile_row_filters_sql(row_filters)
rk = _rk_sql(key_fields)
tag = uuid4().hex[:8]
tb = f"_netx_cmp_b_{tag}"
ta = f"_netx_cmp_a_{tag}"
# Keep worker transaction open across CTAS — progress must NOT commit this session.
db.execute(text(f"SET LOCAL statement_timeout = '{int(_SQL_STATEMENT_TIMEOUT_MS)}'"))
_prog("before", 0, phase="sql_count", note="count")
# Raw counts (no row_filters)
raw_b = int(
db.execute(
text(
"SELECT count(*) FROM biz_state_metric_row "
"WHERE batch_id = :bid AND metric_id = :mid"
),
{"bid": bid_b, "mid": mid},
).scalar()
or 0
)
_prog("before", raw_b, phase="sql_count")
raw_a = int(
db.execute(
text(
"SELECT count(*) FROM biz_state_metric_row "
"WHERE batch_id = :bid AND metric_id = :mid"
),
{"bid": bid_a, "mid": mid},
).scalar()
or 0
)
_prog("after", raw_a, phase="sql_count")
if max(raw_b, raw_a) < int(_SQL_MIN_ROWS):
raise SqlCompareSkip("small_sheet")
base_params = {"bid": bid_b, "mid": mid, **filter_params}
# Build TEMP sides (one transaction — no mid-flight commits on ``db``)
_prog("before", raw_b, phase="sql_project", note="temp_before")
for tname, batch_id, side, note in (
(tb, bid_b, "before", "temp_before"),
(ta, bid_a, "after", "temp_after"),
):
db.execute(text(f"DROP TABLE IF EXISTS {tname}"))
params = {**base_params, "bid": batch_id}
if side == "after":
_prog(side, raw_a, phase="sql_project", note=note)
db.execute(
text(
f"""
CREATE TEMP TABLE {tname} ON COMMIT PRESERVE ROWS AS
SELECT
id,
({rk}) AS rk,
data_json AS data,
row_number() OVER (
PARTITION BY ({rk})
ORDER BY seq ASC, id ASC
) AS dup_rn
FROM biz_state_metric_row
WHERE batch_id = :bid
AND metric_id = :mid
AND ({filter_sql})
"""
),
params,
)
db.execute(text(f"CREATE INDEX IF NOT EXISTS {tname}_rk ON {tname} (rk, dup_rn)"))
before_n = int(
db.execute(text(f"SELECT count(*) FROM {tb}")).scalar() or 0
)
after_n = int(
db.execute(text(f"SELECT count(*) FROM {ta}")).scalar() or 0
)
_prog("after", after_n, phase="sql_join", note="join")
changed_pred = _changed_predicate(compare_fields, rules)
kind_expr = f"""
CASE
WHEN b.id IS NULL THEN 'added'
WHEN a.id IS NULL THEN 'removed'
WHEN {changed_pred} THEN 'changed'
ELSE 'unchanged'
END
"""
# Ordered same-key pairing: join on (rk, dup_rn) so 5 vs 2 → 2 compared + 3 removed
agg_rows = db.execute(
text(
f"""
SELECT {kind_expr} AS kind, count(*)::bigint AS n
FROM {tb} b
FULL OUTER JOIN {ta} a
ON b.rk = a.rk AND b.dup_rn = a.dup_rn
GROUP BY 1
"""
)
).mappings().all()
counts = {str(r["kind"]): int(r["n"] or 0) for r in agg_rows}
added = counts.get("added", 0)
removed = counts.get("removed", 0)
changed = counts.get("changed", 0)
unchanged = counts.get("unchanged", 0)
# Diagnostic: count of match keys with >1 row (not fail rows)
multi_b = int(
db.execute(
text(f"SELECT count(*) FROM (SELECT rk FROM {tb} GROUP BY rk HAVING count(*) > 1) t")
).scalar()
or 0
)
multi_a = int(
db.execute(
text(f"SELECT count(*) FROM (SELECT rk FROM {ta} GROUP BY rk HAVING count(*) > 1) t")
).scalar()
or 0
)
# Lazy import — avoid circular import with compare_service
from .compare_service import resolve_unchanged_policy
policy = resolve_unchanged_policy(
store_unchanged, before_n=before_n, after_n=after_n
)
diffs: list[dict[str, Any]] = []
# Fail rows (stream into Python — should be << million)
fail_sql = text(
f"""
SELECT
{kind_expr} AS kind,
b.id AS before_row_id,
a.id AS after_row_id,
b.data AS before_data,
a.data AS after_data,
COALESCE(b.rk, a.rk) AS rk
FROM {tb} b
FULL OUTER JOIN {ta} a
ON b.rk = a.rk AND b.dup_rn = a.dup_rn
WHERE {kind_expr} IN ('added', 'removed', 'changed')
"""
)
fail_n = 0
_prog("after", after_n, phase="sql_fail_fetch", note="fetch_fails")
for row in db.execute(fail_sql).mappings():
kind = str(row["kind"] or "")
before = dict(row["before_data"] or {}) if row["before_data"] is not None else None
after = dict(row["after_data"] or {}) if row["after_data"] is not None else None
key_src = after if kind == "added" else (before or after or {})
item: dict[str, Any] = {
"kind": kind,
"key": _key_obj_from_data(key_src, key_fields),
"before": before,
"after": after,
"mapped_before": before,
"changes": {},
"before_row_id": str(row["before_row_id"] or ""),
"after_row_id": str(row["after_row_id"] or ""),
}
if kind == "changed":
item["changes"] = _changes_from_rows(before, after, compare_fields, rules)
diffs.append(item)
fail_n += 1
if fail_n == 1 or fail_n % 25_000 == 0:
_prog("fail", fail_n, phase="sql_fail_fetch", note="fetch_fails")
if fail_n:
_prog("fail", fail_n, phase="sql_fail_fetch", note="fetch_fails")
unchanged_listed = 0
include_u = bool(policy.get("include"))
compact = bool(policy.get("compact"))
limit_n = policy.get("limit")
if include_u and unchanged > 0:
params_u: dict[str, Any] = {}
# Stratified sample: round-robin by neighbor|direction|afi|vrf so one
# BGP command cannot consume the whole success sample.
stratum_expr = """
concat_ws('|',
coalesce(nullif(trim(both from coalesce(b.data->>'neighbor','')), ''), '_'),
coalesce(nullif(trim(both from coalesce(b.data->>'direction','')), ''), '_'),
coalesce(nullif(trim(both from coalesce(b.data->>'afi','')), ''), '_'),
coalesce(nullif(trim(both from coalesce(b.data->>'vrf','')), ''), '_')
)
"""
if limit_n is not None:
params_u["lim"] = max(0, int(limit_n))
u_sql = text(
f"""
WITH u AS (
SELECT
b.id AS before_row_id,
a.id AS after_row_id,
b.data AS before_data,
a.data AS after_data,
{stratum_expr} AS stratum
FROM {tb} b
INNER JOIN {ta} a
ON b.rk = a.rk AND b.dup_rn = a.dup_rn
WHERE NOT ({changed_pred})
),
ranked AS (
SELECT *,
row_number() OVER (
PARTITION BY stratum ORDER BY before_row_id ASC
) AS rn_in
FROM u
),
picked AS (
SELECT *,
row_number() OVER (
ORDER BY rn_in ASC, stratum ASC, before_row_id ASC
) AS pick_ord
FROM ranked
)
SELECT before_row_id, after_row_id, before_data, after_data
FROM picked
WHERE pick_ord <= :lim
"""
)
else:
u_sql = text(
f"""
SELECT
b.id AS before_row_id,
a.id AS after_row_id,
b.data AS before_data,
a.data AS after_data
FROM {tb} b
INNER JOIN {ta} a
ON b.rk = a.rk AND b.dup_rn = a.dup_rn
WHERE NOT ({changed_pred})
"""
)
for row in db.execute(u_sql, params_u).mappings():
before = dict(row["before_data"] or {})
after = dict(row["after_data"] or {})
unchanged_listed += 1
if compact:
diffs.append(
{
"kind": "unchanged",
"key": _key_obj_from_data(before, key_fields),
"before": {},
"after": {},
"mapped_before": {},
"changes": {},
"compact": True,
"before_row_id": str(row["before_row_id"] or ""),
"after_row_id": str(row["after_row_id"] or ""),
}
)
else:
diffs.append(
{
"kind": "unchanged",
"key": _key_obj_from_data(before, key_fields),
"before": before,
"after": after,
"mapped_before": before,
"changes": {},
"before_row_id": str(row["before_row_id"] or ""),
"after_row_id": str(row["after_row_id"] or ""),
}
)
# Cleanup (also ON COMMIT DROP)
db.execute(text(f"DROP TABLE IF EXISTS {tb}"))
db.execute(text(f"DROP TABLE IF EXISTS {ta}"))
summary = {
"before_count": before_n,
"after_count": after_n,
"added": added,
"removed": removed,
"changed": changed,
"unchanged": unchanged,
"duplicate": 0,
"match_key_fields": list(key_fields),
"duplicate_keys_before": multi_b,
"duplicate_keys_after": multi_a,
"duplicate_key_list": [],
"unchanged_listed": unchanged_listed,
"unchanged_truncated": bool(
include_u and limit_n is not None and unchanged > unchanged_listed
),
"unchanged_compact": bool(compact and unchanged_listed > 0),
"unchanged_sample_mode": (
"stratified"
if include_u and limit_n is not None and unchanged > unchanged_listed
else ("full" if include_u else "none")
),
"engine": "sql",
"before_raw_count": raw_b,
"after_raw_count": raw_a,
"row_filters": len(row_filters),
"unchanged_policy": policy,
}
mapping_stats = {
"before_iface_count": 0,
"after_iface_count": 0,
"map_pairs": 0,
"hit_before": [],
"miss_before": [],
"hit_after": [],
"miss_after": [],
"unused_before_keys": [],
"ok": True,
"ignore_port_changes": False,
"engine": "sql",
}
return {"summary": summary, "diffs": diffs, "mapping_stats": mapping_stats}

File diff suppressed because it is too large Load diff

View file

@ -23,6 +23,7 @@ def apply_biz_state_schema(conn: Connection) -> None:
"CREATE INDEX IF NOT EXISTS ix_biz_state_batch_command_batch_id ON biz_state_batch_command (batch_id)", "CREATE INDEX IF NOT EXISTS ix_biz_state_batch_command_batch_id ON biz_state_batch_command (batch_id)",
"CREATE INDEX IF NOT EXISTS ix_biz_state_lldp_neighbor_batch_id ON biz_state_lldp_neighbor (batch_id)", "CREATE INDEX IF NOT EXISTS ix_biz_state_lldp_neighbor_batch_id ON biz_state_lldp_neighbor (batch_id)",
"ALTER TABLE biz_compare_job ADD COLUMN IF NOT EXISTS enabled_sheet_ids JSON DEFAULT '[]'", "ALTER TABLE biz_compare_job ADD COLUMN IF NOT EXISTS enabled_sheet_ids JSON DEFAULT '[]'",
"ALTER TABLE biz_compare_job ADD COLUMN IF NOT EXISTS store_unchanged VARCHAR(16) DEFAULT 'auto'",
"CREATE INDEX IF NOT EXISTS ix_biz_compare_job_status ON biz_compare_job (status)", "CREATE INDEX IF NOT EXISTS ix_biz_compare_job_status ON biz_compare_job (status)",
"CREATE INDEX IF NOT EXISTS ix_biz_compare_run_job_id ON biz_compare_run (job_id)", "CREATE INDEX IF NOT EXISTS ix_biz_compare_run_job_id ON biz_compare_run (job_id)",
"CREATE INDEX IF NOT EXISTS ix_biz_state_vrf_route_batch_id ON biz_state_vrf_route_summary (batch_id)", "CREATE INDEX IF NOT EXISTS ix_biz_state_vrf_route_batch_id ON biz_state_vrf_route_summary (batch_id)",
@ -31,6 +32,8 @@ def apply_biz_state_schema(conn: Connection) -> None:
"CREATE INDEX IF NOT EXISTS ix_biz_compare_diff_run_id ON biz_compare_diff (run_id)", "CREATE INDEX IF NOT EXISTS ix_biz_compare_diff_run_id ON biz_compare_diff (run_id)",
"CREATE INDEX IF NOT EXISTS ix_biz_compare_diff_run_metric_kind ON biz_compare_diff (run_id, metric_id, kind)", "CREATE INDEX IF NOT EXISTS ix_biz_compare_diff_run_metric_kind ON biz_compare_diff (run_id, metric_id, kind)",
"CREATE INDEX IF NOT EXISTS ix_biz_compare_diff_run_metric_seq ON biz_compare_diff (run_id, metric_id, seq)", "CREATE INDEX IF NOT EXISTS ix_biz_compare_diff_run_metric_seq ON biz_compare_diff (run_id, metric_id, seq)",
"ALTER TABLE biz_compare_diff ADD COLUMN IF NOT EXISTS before_row_id VARCHAR(64) DEFAULT ''",
"ALTER TABLE biz_compare_diff ADD COLUMN IF NOT EXISTS after_row_id VARCHAR(64) DEFAULT ''",
"CREATE INDEX IF NOT EXISTS ix_biz_migration_project_status ON biz_migration_project (status)", "CREATE INDEX IF NOT EXISTS ix_biz_migration_project_status ON biz_migration_project (status)",
"CREATE INDEX IF NOT EXISTS ix_biz_migration_batch_project_id ON biz_migration_batch (project_id)", "CREATE INDEX IF NOT EXISTS ix_biz_migration_batch_project_id ON biz_migration_batch (project_id)",
"CREATE INDEX IF NOT EXISTS ix_biz_migration_run_batch_id ON biz_migration_run (batch_id)", "CREATE INDEX IF NOT EXISTS ix_biz_migration_run_batch_id ON biz_migration_run (batch_id)",

View file

@ -633,6 +633,8 @@ class CompareJobIn(BaseModel):
mode: str = "manual" mode: str = "manual"
# Empty = all template sheets; non-empty = only these sheet_id values # Empty = all template sheets; non-empty = only these sheet_id values
enabled_sheet_ids: list[str] = Field(default_factory=list) enabled_sheet_ids: list[str] = Field(default_factory=list)
# auto|always|never|sample|keys — how to persist matching (success) rows
store_unchanged: str = "auto"
note: str = "" note: str = ""
@ -723,8 +725,15 @@ def api_delete_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]
@router.post("/compare/jobs/{job_id}/run") @router.post("/compare/jobs/{job_id}/run")
def api_run_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: def api_run_job(
job_id: str,
sync: bool = Query(False, description="If true, block until compare finishes (tests/debug)"),
db: Session = Depends(get_db),
) -> dict[str, Any]:
"""Start a compare run. Default is async (returns status=running immediately)."""
if sync:
return cmp_svc.run_compare(db, job_id) return cmp_svc.run_compare(db, job_id)
return cmp_svc.enqueue_compare(db, job_id)
@router.get("/compare/jobs/{job_id}/runs") @router.get("/compare/jobs/{job_id}/runs")
@ -737,6 +746,12 @@ def api_get_run(run_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.get_run(db, run_id) return cmp_svc.get_run(db, run_id)
@router.post("/compare/runs/{run_id}/cancel")
def api_cancel_run(run_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
"""Cancel a stuck/running compare so a new run can start."""
return cmp_svc.cancel_compare_run(db, run_id)
@router.delete("/compare/runs/{run_id}") @router.delete("/compare/runs/{run_id}")
def api_delete_run(run_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: def api_delete_run(run_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.delete_run(db, run_id) return cmp_svc.delete_run(db, run_id)
@ -748,6 +763,7 @@ def api_list_run_diffs(
metric_id: str = "", metric_id: str = "",
kind: str = "diff", kind: str = "diff",
kw: str = "", kw: str = "",
qf: str = "",
page: int = 1, page: int = 1,
page_size: int = 100, page_size: int = 100,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@ -758,6 +774,7 @@ def api_list_run_diffs(
metric_id=metric_id, metric_id=metric_id,
kind=kind, kind=kind,
kw=kw, kw=kw,
qf=qf,
page=page, page=page,
page_size=page_size, page_size=page_size,
) )

View file

@ -80,8 +80,9 @@ class Settings(BaseSettings):
ume_topo_nodes_path: str = "/restconf/data/zte-resources-module:TopoNodes" ume_topo_nodes_path: str = "/restconf/data/zte-resources-module:TopoNodes"
ume_topological_links_path: str = "/restconf/data/zte-resources-module:TopologicalLinks" ume_topological_links_path: str = "/restconf/data/zte-resources-module:TopologicalLinks"
ume_sync_alarms_history_every_hours: int = 24 ume_sync_alarms_history_every_hours: int = 24
# Managed NE credentials (Fernet key; generate with cryptography.fernet.Fernet.generate_key()) # Managed NE credentials (Fernet). Empty = auto-generate & persist to credential_secret_file.
credential_secret_key: str = "" credential_secret_key: str = ""
credential_secret_file: str = "data/auth/credential_secret"
# Shared-server worker caps (sized for multi-operator use; raise if bastion/DB allow). # Shared-server worker caps (sized for multi-operator use; raise if bastion/DB allow).
ne_connect_max_workers: int = 8 ne_connect_max_workers: int = 8
ne_connect_timeout_sec: int = 30 ne_connect_timeout_sec: int = 30

View file

@ -303,6 +303,8 @@ class BizCompareJob(Base):
status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|ready|auto status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|ready|auto
# Empty = all template sheets; non-empty = only these sheet_id values # Empty = all template sheets; non-empty = only these sheet_id values
enabled_sheet_ids: Mapped[list] = mapped_column(_JsonType, default=list) enabled_sheet_ids: Mapped[list] = mapped_column(_JsonType, default=list)
# auto|always|never|sample — how to persist matching (success) rows
store_unchanged: Mapped[str] = mapped_column(String(16), default="auto")
note: Mapped[str] = mapped_column(String(512), default="") note: Mapped[str] = mapped_column(String(512), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive) created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive) updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
@ -347,4 +349,7 @@ class BizCompareDiff(Base):
after_json: Mapped[dict] = mapped_column(_JsonType, default=dict) after_json: Mapped[dict] = mapped_column(_JsonType, default=dict)
mapped_before_json: Mapped[dict] = mapped_column(_JsonType, default=dict) mapped_before_json: Mapped[dict] = mapped_column(_JsonType, default=dict)
changes_json: Mapped[dict] = mapped_column(_JsonType, default=dict) changes_json: Mapped[dict] = mapped_column(_JsonType, default=dict)
# Pointers into BizStateMetricRow / LLDP tables for compact success hydrate
before_row_id: Mapped[str] = mapped_column(String(64), default="")
after_row_id: Mapped[str] = mapped_column(String(64), default="")
search_text: Mapped[str] = mapped_column(Text, default="") search_text: Mapped[str] = mapped_column(Text, default="")

View file

@ -1,18 +1,73 @@
from __future__ import annotations from __future__ import annotations
import logging
from pathlib import Path
from cryptography.fernet import Fernet, InvalidToken from cryptography.fernet import Fernet, InvalidToken
from .config import settings from .config import settings
_log = logging.getLogger("netx.crypto")
_cached_credential_key: str | None = None
class CredentialCryptoError(RuntimeError): class CredentialCryptoError(RuntimeError):
pass pass
def credential_secret_file_path() -> Path:
raw = str(getattr(settings, "credential_secret_file", None) or "data/auth/credential_secret").strip()
path = Path(raw)
if not path.is_absolute():
path = Path.cwd() / path
return path
def ensure_credential_secret_key() -> str:
"""Resolve Fernet key: env ``NETX_CREDENTIAL_SECRET_KEY`` > file > generate once.
Packaged installs should also write the key into ``.env`` via setup_first_run;
this startup/path fallback covers upgrades and missed first-run keys.
"""
global _cached_credential_key
configured = str(settings.credential_secret_key or "").strip()
if configured:
return configured
if _cached_credential_key:
return _cached_credential_key
path = credential_secret_file_path()
try:
if path.is_file():
existing = path.read_text(encoding="utf-8").strip()
if existing:
_cached_credential_key = existing
settings.credential_secret_key = existing
return existing
except Exception:
_log.exception("read credential secret file failed path=%s", path)
generated = Fernet.generate_key().decode("ascii")
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(generated + "\n", encoding="utf-8")
try:
path.chmod(0o600)
except Exception:
pass
_log.info("wrote per-install credential Fernet key to %s", path)
except Exception:
_log.exception(
"write credential secret file failed path=%s; using in-memory key only",
path,
)
_cached_credential_key = generated
settings.credential_secret_key = generated
return generated
def _fernet() -> Fernet: def _fernet() -> Fernet:
key = str(settings.credential_secret_key or "").strip() key = ensure_credential_secret_key()
if not key:
raise CredentialCryptoError("credential_secret_key_not_configured")
try: try:
return Fernet(key.encode("ascii")) return Fernet(key.encode("ascii"))
except Exception as exc: except Exception as exc:
@ -37,4 +92,7 @@ def decrypt_secret(value: str) -> str:
def credentials_configured() -> bool: def credentials_configured() -> bool:
return bool(str(settings.credential_secret_key or "").strip()) try:
return bool(ensure_credential_secret_key())
except Exception:
return False

View file

@ -7,14 +7,22 @@ This directory builds a Windows deliverable with:
- Optional **bundled** portable PostgreSQL **or** **external** existing Postgres - Optional **bundled** portable PostgreSQL **or** **external** existing Postgres
- API-hosted UI (`web/dist`) — no separate Vite process for end users - API-hosted UI (`web/dist`) — no separate Vite process for end users
- Program / data split so upgrades do not wipe the database - Program / data split so upgrades do not wipe the database
- Manual update script + auto-update **manifest contract** (fetch not implemented yet) - Manual update script + check/apply updates (GitHub Releases or custom manifest)
- Optional system tray + start-at-logon
## What users get ## What users get
| Artifact | How | | Artifact | How |
|----------|-----| |----------|-----|
| `NetX-x.y.z-win64.zip` | `build_release.ps1` | | `NetX-Setup-x.y.z.exe` | Stage with `build_release.ps1`, then compile `installer/netx.iss` with [Inno Setup](https://jrsoftware.org/isinfo.php) |
| `NetX-Setup-x.y.z.exe` | Compile `installer/netx.iss` with [Inno Setup](https://jrsoftware.org/isinfo.php) after staging |
Zip packages are **not** published. Releases ship **Setup.exe only**.
**Installer:** English + 简体中文 (language dialog). Icons use `packaging/assets/netx.ico`.
**Offline:** Setup ships portable PostgreSQL, **`python/runtime` + `.venv`** (portable; not tied to the build PC’s user profile), and WinSW. **First install** shows the Database page (built-in or external PostgreSQL; external credentials validated with `psql SELECT 1`). **Re-running Setup when `%ProgramData%\NetX\.env` already has `NETX_DB_MODE`** skips the DB wizard and updates program files in place — **no GitHub/EDB download on the target PC**. Service install uses bundled WinSW (pass `-AllowDownload` only on a build/dev machine if the binary is missing). Auto-update still needs network later, and is unchecked by default.
**OS:** Windows 10/11 or Windows Server **2016+** recommended. Packaging scripts are UTF-8 **with BOM** so Chinese UI works on Windows PowerShell 5.x. Bundled Python in current releases is **3.13+**, which does **not** support Windows Server 2012 R2 — use Server 2016+ or a newer desktop OS.
## Build (developer machine) ## Build (developer machine)
@ -25,14 +33,13 @@ cd netx
# Optional: download portable Postgres into packaging\postgres\pgsql # Optional: download portable Postgres into packaging\postgres\pgsql
powershell -ExecutionPolicy Bypass -File .\packaging\download_postgres.ps1 powershell -ExecutionPolicy Bypass -File .\packaging\download_postgres.ps1
# Build web + stage + zip (-CreateVenv ships a ready .venv; large) # Build web + stage (-CreateVenv ships a ready .venv; large). No zip by default.
powershell -ExecutionPolicy Bypass -File .\packaging\build_release.ps1 -CreateVenv powershell -ExecutionPolicy Bypass -File .\packaging\build_release.ps1 -CreateVenv
``` ```
Output: Output:
- `packaging/release/netx-win64/` — stage tree - `packaging/release/netx-win64/` — stage tree (input to Inno Setup)
- `packaging/release/NetX-<ver>-win64.zip`
Inno Setup: Inno Setup:
@ -41,28 +48,26 @@ ISCC.exe packaging\installer\netx.iss
``` ```
Override version in the `.iss` or edit `#define MyAppVersion`. Override version in the `.iss` or edit `#define MyAppVersion`.
Optional local zip only: `build_release.ps1 -CreateZip` (not for release upload).
## End-user install ## End-user install
### Setup.exe ### Setup.exe
1. Run `NetX-Setup-x.y.z.exe` (admin). 1. Run `NetX-Setup-x.y.z.exe` (admin).
2. Files → `%ProgramFiles%\NetX\` (program root). 2. **First install:** Database page — choose built-in (offline) or external PostgreSQL (host/port/user/password/db; connection must succeed to continue).
3. Data → `%ProgramData%\NetX\` (`.env`, `pgdata`, spool, secrets). 3. **Already installed:** if `%ProgramData%\NetX\.env` already has `NETX_DB_MODE`, Setup shows an **Install mode** page:
4. Optional post-install task runs `setup_first_run.ps1` (choose bundled vs external DB). - **Update** (default) — skip Database page; stop NetX; replace program files; **keep** ProgramData / DB settings.
5. Start menu: **Start NetX** / **Stop NetX** / **Open NetX UI**. - **Reinstall** — same Database wizard as first install (`ApplyDatabaseConfig`); does **not** delete ProgramData (use Uninstall → delete data for a wipe).
4. Files → `%ProgramFiles%\NetX\` (program root).
5. Data → `%ProgramData%\NetX\` (`.env`, `pgdata`, spool, secrets).
6. Start menu: **Start NetX** / **Stop NetX** / **Open NetX UI** / **Reconfigure database**.
### Zip (portable) Silent first install (bundled default): `/SILENT /DbMode=bundled`
Silent external: `/SILENT /DbMode=external /DbHost=... /DbPort=5432 /DbUser=... /DbPassword=... /DbName=...`
1. Unpack anywhere. Silent update over existing data: `/VERYSILENT /NORESTART /InstallMode=update` (also `/SkipDbPage=1`)
2. Marker `.portable` → data root is sibling `NetXData\`. Silent reinstall (DB wizard via params): `/VERYSILENT /InstallMode=reinstall /DbMode=bundled` (or external `/DbHost`…) — also `/ForceDbPage=1`
3. Target needs **Python 3.11+** on PATH unless the zip was built with `-CreateVenv`. Optional credential key (reuse encrypted NE passwords from another install): `/CredentialSecretKey=...` — omit to auto-generate.
4. Run:
```powershell
.\packaging\setup_first_run.ps1
.\packaging\start_netx_app.ps1
```
## Database modes ## Database modes
@ -76,15 +81,122 @@ Existing Windows deploys that only set `NETX_DATABASE_URL` keep working: never s
## Manual update ## Manual update
Preferred: run a newer `NetX-Setup-*.exe` and choose **Update** (or silent `/InstallMode=update`) so ProgramData is kept. Setup (elevated) always relinks `.venv` → `python/runtime` after file copy so tray start works without write access under Program Files.
Legacy/dev zip (only if you built with `-CreateZip`):
```powershell ```powershell
.\packaging\update_netx.ps1 -PackagePath .\NetX-0.3.0-win64.zip .\packaging\update_netx.ps1 -PackagePath .\NetX-0.4.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. 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) ## Check / apply updates
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. **Defaults (no `.env` needed):** probe **GitHub** (`hansjone/netx`) and Forgejo mirror (`https://git.avelo.top/hansjone/netx`), then use the **newest reachable** version. Same version → prefer GitHub.
```env
# Optional overrides in ProgramData\NetX\.env
# NETX_UPDATE_SOURCES=github,forgejo
# NETX_UPDATE_FORGEJO_URL=https://git.avelo.top/api/v1/repos/hansjone/netx/releases/latest
# NETX_UPDATE_GITHUB_REPO=hansjone/netx
# NETX_UPDATE_URL=https://cdn.example.com/netx/manifest.json
# NETX_UPDATE_TOKEN=******
```
| Variable | Role |
|----------|------|
| `NETX_UPDATE_FORGEJO_URL` | Forgejo/Gitea `releases/latest` API (default: git.avelo.top mirror) |
| `NETX_UPDATE_GITHUB_REPO` | GitHub `owner/repo` (default `hansjone/netx`) |
| `NETX_UPDATE_SOURCES` | Probe order / tie-break order, e.g. `github,forgejo` |
| `NETX_UPDATE_URL` | Optional custom JSON manifest |
```powershell
.\packaging\check_update.ps1
.\packaging\check_update.ps1 -Apply
```
Both sides should publish the same **Setup.exe**. `check_update.ps1` prefers `NetX-Setup-*.exe` (legacy `win64.zip` still works if present). **Code mirror alone is not enough** — Forgejo pull-mirror syncs git/tags only; Release assets must be uploaded separately (or clients can only fall back to GitHub).
### Maintainer release checklist (Windows)
Do this on the **dev PC on the home LAN** after bumping version:
1. **Build** — `.\packaging\build_release.ps1 -CreateVenv` + Inno Setup → `NetX-Setup-*.exe`
2. **GitHub** — `.\packaging\publish_release.ps1 -Version x.y.z` (uploads Setup.exe only)
3. **Forgejo** — upload the same Setup.exe (default: public domain `https://git.avelo.top`):
```powershell
# One-time: User env var (never commit the token)
# Forgejo → Settings → Applications → Generate New Token (repo write)
[Environment]::SetEnvironmentVariable("NETX_FORGEJO_TOKEN", "your_token", "User")
# Restart Cursor / open a new terminal so the agent/scripts see it
# Default: https://git.avelo.top
.\packaging\publish_forgejo_release.ps1 -Version 0.4.11
# Optional when LAN IP is reachable:
# .\packaging\publish_forgejo_release.ps1 -Version 0.4.11 -ForgejoBase "http://10.0.0.131:3000"
```
| Role | URL |
|------|-----|
| Publish default | `https://git.avelo.top` (`-ForgejoBase` default) |
| Optional LAN | `http://10.0.0.131:3000` |
| Update probe (default) | GitHub + `https://git.avelo.top/.../releases/latest` |
Verify:
- https://git.avelo.top/hansjone/netx/releases
- Probe: `.\packaging\check_update.ps1` → both `github` and `forgejo` reachable with the new version
Token: `NETX_FORGEJO_TOKEN` (or `FORGEJO_TOKEN`).
## Tray & autostart
```powershell
# System tray: Start / Stop / Open UI / Check updates
.\packaging\netx_tray.ps1 -StartOnLaunch
# Start tray at Windows logon (current user)
.\packaging\install_autostart.ps1
# Remove: .\packaging\install_autostart.ps1 -Remove
```
## Windows Service (admin)
Uses [WinSW](https://github.com/winsw/winsw) (downloaded on first install into `packaging/cache`).
`service_run.ps1` probes `/health` and restarts children after consecutive failures.
```powershell
# Elevated PowerShell
.\packaging\install_service.ps1 -Start
# Uninstall: .\packaging\install_service.ps1 -Uninstall
# Fallback without WinSW binary management: SYSTEM scheduled task at startup
.\packaging\install_service.ps1 -Mode task -Start
```
## Silent auto-update
```powershell
# Writes NETX_UPDATE_AUTO=true and registers a daily task (default 03:30)
.\packaging\install_update_task.ps1
# Manual silent path (only applies when NETX_UPDATE_AUTO=true)
.\packaging\check_update.ps1 -Apply -Quiet -AutoOnly
```
Tray also auto-applies on launch when `NETX_UPDATE_AUTO=true`.
## Code signing (optional, publisher machine)
```powershell
.\packaging\sign_release.ps1 -Files .\packaging\release\NetX-Setup-0.4.0.exe -Thumbprint <cert-sha1>
# or: -PfxPath .\certs\code.pfx -PfxPassword ***
```
Needs `signtool.exe` (Windows SDK) and a real code-signing certificate. Without a trusted cert, SmartScreen may still warn.
## Layout reminder ## Layout reminder

View file

@ -1,4 +1,4 @@
# Shared path helpers for Windows packaging scripts. # Shared path helpers for Windows packaging scripts.
# Dot-source: . "$PSScriptRoot\_common.ps1" # Dot-source: . "$PSScriptRoot\_common.ps1"
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@ -114,7 +114,33 @@ function Write-DotEnvValue {
if (-not (Test-Path $dir)) { if (-not (Test-Path $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null New-Item -ItemType Directory -Path $dir -Force | Out-Null
} }
Set-Content -Path $Path -Value ($lines -join "`r`n") -Encoding utf8 $text = ($lines -join "`r`n")
try {
Set-Content -LiteralPath $Path -Value $text -Encoding utf8
} catch {
# Admin-created .env is often Users:RX only — repair ACL then retry once.
$root = $dir
if ((Split-Path -Leaf $dir) -eq "NetX" -or $dir -match '\\NetX$') {
$root = $dir
} else {
$parent = Split-Path -Parent $dir
if ($parent -and ((Split-Path -Leaf $parent) -eq "NetX")) { $root = $parent }
}
$null = Ensure-NetxDataAcl -DataRoot $root -Quiet
try {
# Reset .env ACL explicitly if still blocked.
$icacls = Join-Path $env:SystemRoot "System32\icacls.exe"
if (Test-Path -LiteralPath $icacls) {
& $icacls $Path /grant "*S-1-5-32-545:M" /C /Q 2>$null | Out-Null
}
Set-Content -LiteralPath $Path -Value $text -Encoding utf8
} catch {
throw (New-Object System.UnauthorizedAccessException(
("access_denied: cannot write {0}. Run Start Menu → NetX → Reconfigure database as Administrator, or: icacls `"{1}`" /grant Users:(OI)(CI)M /T" -f $Path, $root),
$_.Exception
))
}
}
} }
function Get-DbMode { function Get-DbMode {
@ -135,3 +161,382 @@ function Import-NetxEnvFile {
} }
return $map return $map
} }
function ConvertTo-NetxFsPath([string]$Path) {
# Forward slashes work in .env and most Windows APIs via Python pathlib.
return ($Path -replace '\\', '/')
}
function Get-NetxDataEnvMap {
param([string]$DataRoot)
$d = $DataRoot
return @{
"NETX_AUTH_MCP_TOKEN_FILE" = (ConvertTo-NetxFsPath (Join-Path $d "data\auth\mcp_token"))
"NETX_AUTH_SECRET_FILE" = (ConvertTo-NetxFsPath (Join-Path $d "data\auth\jwt_secret"))
"NETX_CREDENTIAL_SECRET_FILE" = (ConvertTo-NetxFsPath (Join-Path $d "data\auth\credential_secret"))
"NETX_SCHEDULER_HEARTBEAT_PATH" = (ConvertTo-NetxFsPath (Join-Path $d "data\runtime\scheduler_heartbeat.json"))
"NETX_RUN_DIR" = (ConvertTo-NetxFsPath (Join-Path $d "data\runtime"))
"NETX_BIZ_STATE_SPOOL_DIR" = (ConvertTo-NetxFsPath (Join-Path $d "data\biz_state_spool"))
"NETX_NE_COLLECTION_DATA_DIR" = (ConvertTo-NetxFsPath (Join-Path $d "data\ne_collections"))
"NETX_NE_EXEC_JOB_DIR" = (ConvertTo-NetxFsPath (Join-Path $d "data\ne_exec_jobs"))
"NETX_WEBCRT_DATA_DIR" = (ConvertTo-NetxFsPath (Join-Path $d "data\webcrt"))
}
}
function New-NetxFernetKey {
# cryptography.fernet.Fernet.generate_key() == urlsafe_b64encode(os.urandom(32))
$bytes = New-Object byte[] 32
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try {
$rng.GetBytes($bytes)
} finally {
$rng.Dispose()
}
return [Convert]::ToBase64String($bytes).Replace('+', '-').Replace('/', '_')
}
function Ensure-NetxDataAcl {
param(
[Parameter(Mandatory = $true)][string]$DataRoot,
[switch]$Quiet = $false
)
# Installer creates %ProgramData%\NetX as Administrators. Tray / normal users must
# be able to update .env and runtime files when reconfiguring DB.
if (-not (Test-Path -LiteralPath $DataRoot)) { return $false }
try {
$acl = Get-Acl -LiteralPath $DataRoot
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
"BUILTIN\Users",
"Modify",
"ContainerInherit,ObjectInherit",
"None",
"Allow"
)
$acl.SetAccessRule($rule)
Set-Acl -LiteralPath $DataRoot -AclObject $acl
# Also fix already-created files that only inherited RX for Users.
$icacls = Join-Path $env:SystemRoot "System32\icacls.exe"
if (Test-Path -LiteralPath $icacls) {
& $icacls $DataRoot /grant "*S-1-5-32-545:(OI)(CI)M" /T /C /Q 2>$null | Out-Null
}
if (-not $Quiet) {
Write-Host "==> Data ACL: BUILTIN\Users Modify on $DataRoot" -ForegroundColor DarkGray
}
return $true
} catch {
if (-not $Quiet) {
Write-Host "[WARN] Ensure-NetxDataAcl: $($_.Exception.Message)" -ForegroundColor Yellow
}
return $false
}
}
function Ensure-NetxDataDirectories {
param([string]$DataRoot)
if (-not (Test-Path -LiteralPath $DataRoot)) {
New-Item -ItemType Directory -Path $DataRoot -Force | Out-Null
}
$subs = @(
"data", "data\auth", "data\runtime", "data\biz_state_spool",
"data\ne_collections", "data\ne_exec_jobs", "data\webcrt",
"backups", "pgdata"
)
foreach ($sub in $subs) {
$p = Join-Path $DataRoot $sub
if (-not (Test-Path $p)) {
New-Item -ItemType Directory -Path $p -Force | Out-Null
}
}
# Best-effort; succeeds when running elevated (installer / first-run as admin).
$null = Ensure-NetxDataAcl -DataRoot $DataRoot -Quiet
}
function Test-NetxTcpPortFree {
param([string]$HostName = "127.0.0.1", [int]$Port)
try {
$client = New-Object System.Net.Sockets.TcpClient
$iar = $client.BeginConnect($HostName, $Port, $null, $null)
$ok = $iar.AsyncWaitHandle.WaitOne(400)
if ($ok -and $client.Connected) {
$client.Close()
return $false
}
$client.Close()
return $true
} catch {
return $true
}
}
function Test-NetxApiHealthy {
param([string]$HostName = "127.0.0.1", [int]$Port = 8890, [int]$TimeoutSec = 2)
try {
$url = "http://${HostName}:${Port}/health"
$r = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec $TimeoutSec -MaximumRedirection 0
if ($r.StatusCode -ne 200) { return $false }
$body = $r.Content | ConvertFrom-Json -ErrorAction Stop
return ($body.status -eq "ok")
} catch {
return $false
}
}
function Wait-NetxApiHealthy {
param(
[string]$HostName = "127.0.0.1",
[int]$Port = 8890,
[int]$TimeoutSec = 180,
[int]$PollMs = 500
)
$deadline = (Get-Date).AddSeconds($TimeoutSec)
while ((Get-Date) -lt $deadline) {
if (Test-NetxApiHealthy -HostName $HostName -Port $Port -TimeoutSec 2) {
return $true
}
Start-Sleep -Milliseconds $PollMs
}
return $false
}
function ConvertTo-NetxProcessArgument {
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value)
# Start-Process joins string[] args with spaces and does NOT quote values that
# contain spaces (e.g. C:\Program Files\NetX). Always emit one argv token.
if ($Value -match '[\s"]') {
return '"' + ($Value -replace '"', '\"') + '"'
}
return $Value
}
function ConvertTo-NetxProcessArgumentString {
param([Parameter(Mandatory = $true)][string[]]$Arguments)
return (($Arguments | ForEach-Object { ConvertTo-NetxProcessArgument -Value "$_" }) -join " ")
}
function Start-NetxPowerShell {
param(
[Parameter(Mandatory = $true)][string]$File,
[string[]]$Arguments = @(),
[string]$WorkingDirectory = "",
[ValidateSet("Hidden", "Normal", "Minimized")]
[string]$WindowStyle = "Hidden",
[switch]$Wait = $false,
[switch]$PassThru = $false
)
$parts = @(
"-NoProfile",
"-WindowStyle", $WindowStyle,
"-ExecutionPolicy", "Bypass",
"-File", $File
) + $Arguments
$sp = @{
FilePath = "powershell.exe"
ArgumentList = (ConvertTo-NetxProcessArgumentString -Arguments $parts)
WindowStyle = $WindowStyle
}
if ($WorkingDirectory) { $sp.WorkingDirectory = $WorkingDirectory }
if ($Wait) { $sp.Wait = $true }
if ($PassThru -or $Wait) { $sp.PassThru = $true }
return Start-Process @sp
}
function Get-NetxSystemPython {
# Prefer a real interpreter; skip the WindowsApps Store stub.
$candidates = @()
foreach ($cmd in @("python", "python3")) {
$c = Get-Command $cmd -ErrorAction SilentlyContinue
if ($c -and $c.Source -and ($c.Source -notmatch '\\WindowsApps\\')) {
$candidates += $c.Source
}
}
$pyLauncher = Get-Command "py" -ErrorAction SilentlyContinue
if ($pyLauncher -and $pyLauncher.Source -and ($pyLauncher.Source -notmatch '\\WindowsApps\\')) {
try {
$resolved = (& $pyLauncher.Source -3 -c "import sys; print(sys.executable)" 2>$null | Out-String).Trim()
if ($resolved -and (Test-Path -LiteralPath $resolved)) {
$candidates += $resolved
}
} catch {}
}
foreach ($p in @(
"$env:LOCALAPPDATA\Python\pythoncore-3.14-64\python.exe",
"$env:LOCALAPPDATA\Programs\Python\Python312\python.exe",
"$env:LOCALAPPDATA\Programs\Python\Python311\python.exe",
"$env:ProgramFiles\Python312\python.exe",
"$env:ProgramFiles\Python311\python.exe"
)) {
if ($p -and (Test-Path $p)) { $candidates += $p }
}
foreach ($p in ($candidates | Select-Object -Unique)) {
try {
$v = & $p -c "import sys; print('%d.%d'%sys.version_info[:2])" 2>$null
if ($v -match '^(3\.(1[1-9]|[2-9]\d))$') { return $p }
} catch {}
}
return $null
}
function Get-NetxBundledPythonRoot {
param([Parameter(Mandatory = $true)][string]$ProgramRoot)
return Join-Path $ProgramRoot "python\runtime"
}
function Repair-NetxShippedVenv {
param([Parameter(Mandatory = $true)][string]$ProgramRoot)
$cfgPath = Join-Path $ProgramRoot ".venv\pyvenv.cfg"
$rtRoot = Get-NetxBundledPythonRoot -ProgramRoot $ProgramRoot
$rtPy = Join-Path $rtRoot "python.exe"
if (-not (Test-Path -LiteralPath $rtPy)) { return $false }
if (-not (Test-Path -LiteralPath $cfgPath)) { return $false }
$rtRootAbs = (Resolve-Path -LiteralPath $rtRoot).Path
$rtPyAbs = (Resolve-Path -LiteralPath $rtPy).Path
$ver = "3.14.0"
try {
$verOut = & $rtPyAbs -c "import sys; print('%d.%d.%d' % sys.version_info[:3])" 2>$null
if ($verOut) { $ver = ($verOut | Out-String).Trim() }
} catch {}
$lines = @(
"home = $rtRootAbs",
"include-system-site-packages = false",
"version = $ver",
"executable = $rtPyAbs"
)
$desired = ($lines -join "`r`n")
# Skip write when already correct — Users cannot modify Program Files after Setup.
try {
$cur = ([IO.File]::ReadAllText($cfgPath) -replace "`r`n", "`n" -replace "`r", "`n").Trim()
$want = ($desired -replace "`r`n", "`n" -replace "`r", "`n").Trim()
if ($cur -eq $want) {
return $true
}
} catch {}
try {
Set-Content -LiteralPath $cfgPath -Value $desired -Encoding ascii -ErrorAction Stop
return $true
} catch {
# Non-elevated tray/start: leave cfg as-is; caller may still run if paths happen to work.
return $false
}
}
function Test-NetxVenvRunnable {
param([Parameter(Mandatory = $true)][string]$VenvPython)
if (-not (Test-Path -LiteralPath $VenvPython)) { return $false }
$outFile = Join-Path $env:TEMP ("netx-venv-out-" + [Guid]::NewGuid().ToString("n") + ".txt")
$errFile = Join-Path $env:TEMP ("netx-venv-err-" + [Guid]::NewGuid().ToString("n") + ".txt")
try {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $VenvPython
$psi.Arguments = '-c "import encodings, sys; sys.exit(0 if sys.prefix else 1)"'
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$p = [Diagnostics.Process]::Start($psi)
$stderr = $p.StandardError.ReadToEnd()
$null = $p.StandardOutput.ReadToEnd()
$p.WaitForExit()
if ($stderr -match 'did not find executable|No Python at|Fatal Python error') {
return $false
}
if ($p.ExitCode -ne 0) { return $false }
return $true
} finally {
Remove-Item -LiteralPath $outFile, $errFile -Force -ErrorAction SilentlyContinue
}
}
function Ensure-NetxVenv {
param(
[Parameter(Mandatory = $true)][string]$ProgramRoot,
[switch]$ForcePip = $false
)
$venvPy = Join-Path $ProgramRoot ".venv\Scripts\python.exe"
$req = Join-Path $ProgramRoot "requirements.txt"
$rtPy = Join-Path (Get-NetxBundledPythonRoot -ProgramRoot $ProgramRoot) "python.exe"
if (Test-Path -LiteralPath $rtPy) {
try {
$null = Repair-NetxShippedVenv -ProgramRoot $ProgramRoot
} catch {
# Access denied under Program Files when not elevated — ignore if venv already runs.
}
}
if ((Test-Path -LiteralPath $venvPy) -and -not $ForcePip) {
if (Test-NetxVenvRunnable -VenvPython $venvPy) {
return $venvPy
}
Write-Host "[WARN] Shipped .venv exists but Python cannot start (broken pyvenv.cfg or missing runtime)." -ForegroundColor Yellow
if (Test-Path -LiteralPath $rtPy) {
$repaired = $false
try {
$repaired = [bool](Repair-NetxShippedVenv -ProgramRoot $ProgramRoot)
} catch {
$repaired = $false
}
if ($repaired -and (Test-NetxVenvRunnable -VenvPython $venvPy)) {
Write-Host "==> Repaired .venv to use bundled python/runtime" -ForegroundColor Green
return $venvPy
}
if (-not $repaired) {
throw "venv_needs_admin_repair: pyvenv.cfg still points at the build PC. Re-run Setup (Update) as Administrator, or Start Menu → Reconfigure database once elevated."
}
}
if (-not $ForcePip) {
throw "venv_not_runnable: reinstall NetX Setup (ships python/runtime + .venv) or run repair as admin"
}
}
if (-not (Test-Path $venvPy)) {
$py = Get-NetxSystemPython
if (-not $py) {
throw "python_not_found: install Python 3.11+ (or ship Setup built with -CreateVenv)"
}
Write-Host "==> Creating .venv with $py"
$venvDir = Join-Path $ProgramRoot ".venv"
try {
& $py -m venv $venvDir
} catch {
throw "venv_create_failed: cannot write $venvDir (need admin / ship -CreateVenv). $($_.Exception.Message)"
}
if (-not (Test-Path $venvPy)) {
throw "venv_create_failed: missing $venvPy (Program Files may be read-only without elevation)"
}
$ForcePip = $true
}
if ($ForcePip) {
if (-not (Test-Path $req)) { throw "requirements_missing: $req" }
Write-Host "==> pip install -r requirements.txt"
& $venvPy -m pip install --upgrade pip
& $venvPy -m pip install -r $req
if ($LASTEXITCODE -ne 0) { throw "pip_install_failed" }
}
return $venvPy
}
function Stop-NetxTrayProcesses {
param([string]$ProgramRoot = "")
$hits = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {
$_.CommandLine -and $_.CommandLine -match 'netx_tray\.ps1'
})
if ($ProgramRoot) {
$esc = [regex]::Escape($ProgramRoot)
$hits = @($hits | Where-Object { $_.CommandLine -match $esc -or $_.CommandLine -match 'netx_tray\.ps1' })
}
foreach ($p in $hits) {
try {
Stop-Process -Id $p.ProcessId -Force -ErrorAction Stop
Write-Host "Stopped NetX tray PID=$($p.ProcessId)"
} catch {}
}
}
function ConvertTo-NetxSqlLiteral([string]$Value) {
# Single-quote escaping for PostgreSQL string literals.
return ($Value -replace "'", "''")
}

View file

@ -0,0 +1,6 @@
# NetX packaging assets
- `netx.ico` — installer / shortcuts / tray (regenerate with `python gen_icon.py`)
- `netx-256.png` / `netx-64.png` — previews
Mark matches `web/public/favicon.svg` (network “N” nodes on navy).

View file

@ -0,0 +1,56 @@
"""Generate NetX Windows .ico from brand mark (matches web/public/favicon.svg)."""
from __future__ import annotations
import os
from PIL import Image, ImageDraw
OUT_DIR = os.path.dirname(os.path.abspath(__file__))
BG = (15, 39, 68, 255)
LINE = (126, 182, 232, 255)
NODE = (232, 242, 252, 255)
ACCENT = (61, 130, 247, 255)
def make(size: int) -> Image.Image:
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
radius = max(2, int(size * 0.22))
d.rounded_rectangle([0, 0, size - 1, size - 1], radius=radius, fill=BG)
s = size / 32.0
def xy(x: float, y: float) -> tuple[float, float]:
return (x * s, y * s)
stroke = max(2, int(round(2.2 * s)))
for a, b in (
(xy(9.5, 8.5), xy(9.5, 23.5)),
(xy(9.5, 8.5), xy(22.5, 23.5)),
(xy(22.5, 8.5), xy(22.5, 23.5)),
):
d.line([a, b], fill=LINE, width=stroke)
def circle(cx: float, cy: float, rad: float, fill: tuple[int, int, int, int]) -> None:
x, y = xy(cx, cy)
rr = rad * s
d.ellipse([x - rr, y - rr, x + rr, y + rr], fill=fill)
circle(9.5, 8.5, 2.35, NODE)
circle(9.5, 23.5, 2.35, NODE)
circle(22.5, 8.5, 2.35, NODE)
circle(22.5, 23.5, 2.35, NODE)
circle(16, 16, 1.7, ACCENT)
return img
def main() -> None:
sizes = [16, 24, 32, 48, 64, 128, 256]
images = [make(s) for s in sizes]
ico_path = os.path.join(OUT_DIR, "netx.ico")
images[-1].save(ico_path, format="ICO", sizes=[(s, s) for s in sizes])
images[-1].save(os.path.join(OUT_DIR, "netx-256.png"))
make(64).save(os.path.join(OUT_DIR, "netx-64.png"))
print(f"wrote {ico_path} ({os.path.getsize(ico_path)} bytes)")
if __name__ == "__main__":
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 B

BIN
packaging/assets/netx.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -1,10 +1,14 @@
param( param(
[string]$Version = "", [string]$Version = "",
[string]$OutDir = "", [string]$OutDir = "",
[switch]$SkipWebBuild = $false, [switch]$SkipWebBuild = $false,
[switch]$SkipPostgresDownload = $false, [switch]$SkipPostgresDownload = $false,
# Zip is no longer published; stage + Setup.exe only. Opt-in for local/dev testing.
[switch]$CreateZip = $false,
# Deprecated no-op (zip is off by default). Kept so older scripts that pass -SkipZip still run.
[switch]$SkipZip = $false, [switch]$SkipZip = $false,
[switch]$CreateVenv = $false # Default on: offline Setup must ship .venv so target PCs never pip-install.
[switch]$CreateVenv = $true
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@ -51,6 +55,20 @@ if (-not $SkipPostgresDownload) {
} }
} }
# Bundle WinSW so offline installs can register a Windows Service without GitHub.
$winswCache = Join-Path $PSScriptRoot "cache\WinSW-x64.exe"
$winswShip = Join-Path $PSScriptRoot "winsw\WinSW-x64.exe"
if (-not (Test-Path $winswShip)) {
if (-not (Test-Path $winswCache)) {
$winswUrl = "https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW-x64.exe"
Write-Host "==> Downloading WinSW for offline bundle: $winswUrl"
New-Item -ItemType Directory -Path (Split-Path $winswCache -Parent) -Force | Out-Null
Invoke-WebRequest -Uri $winswUrl -OutFile $winswCache -UseBasicParsing
}
New-Item -ItemType Directory -Path (Split-Path $winswShip -Parent) -Force | Out-Null
Copy-Item -Path $winswCache -Destination $winswShip -Force
}
# Flat layout = same as repo so scripts\start_netx.ps1 works unchanged. # Flat layout = same as repo so scripts\start_netx.ps1 works unchanged.
foreach ($d in @("netx_api", "alembic", "scripts")) { foreach ($d in @("netx_api", "alembic", "scripts")) {
Copy-Item -Path (Join-Path $repo $d) -Destination (Join-Path $stage $d) -Recurse -Force Copy-Item -Path (Join-Path $repo $d) -Destination (Join-Path $stage $d) -Recurse -Force
@ -66,26 +84,70 @@ $webOut = Join-Path $stage "web\dist"
New-Item -ItemType Directory -Path (Split-Path $webOut -Parent) -Force | Out-Null New-Item -ItemType Directory -Path (Split-Path $webOut -Parent) -Force | Out-Null
Copy-Item -Path $dist -Destination $webOut -Recurse -Force Copy-Item -Path $dist -Destination $webOut -Recurse -Force
function Write-Utf8BomFile {
param([string]$Path)
# Windows PowerShell 4/5 (Server 2012+) mis-parses UTF-8 without BOM when scripts contain CJK.
$utf8Bom = New-Object System.Text.UTF8Encoding $true
$bytes = [IO.File]::ReadAllBytes($Path)
$hasBom = ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF)
$text = if ($hasBom) {
[Text.Encoding]::UTF8.GetString($bytes, 3, $bytes.Length - 3)
} else {
[Text.Encoding]::UTF8.GetString($bytes)
}
$text = $text -replace "`r`n", "`n" -replace "`n", "`r`n"
if (-not $text.EndsWith("`r`n")) { $text += "`r`n" }
[IO.File]::WriteAllText($Path, $text, $utf8Bom)
}
$packOut = Join-Path $stage "packaging" $packOut = Join-Path $stage "packaging"
New-Item -ItemType Directory -Path $packOut -Force | Out-Null New-Item -ItemType Directory -Path $packOut -Force | Out-Null
Copy-Item -Path (Join-Path $PSScriptRoot "_common.ps1") -Destination $packOut -Force Copy-Item -Path (Join-Path $PSScriptRoot "_common.ps1") -Destination $packOut -Force
foreach ($name in @( foreach ($name in @(
"download_postgres.ps1", "setup_first_run.ps1", "start_netx_app.ps1", "download_postgres.ps1", "setup_first_run.ps1", "start_netx_app.ps1",
"stop_netx_app.ps1", "update_netx.ps1", "build_release.ps1", "stop_netx_app.ps1", "update_netx.ps1", "check_update.ps1",
"README.md", "manifest.example.json" "launch_shortcut.ps1", "netx_tray.ps1", "install_autostart.ps1", "install_service.ps1",
"service_run.ps1", "install_update_task.ps1", "uninstall_prepare.ps1", "uninstall_delete_data.ps1", "sign_release.ps1",
"build_release.ps1", "publish_release.ps1", "publish_forgejo_release.ps1", "README.md", "manifest.example.json"
)) { )) {
$src = Join-Path $PSScriptRoot $name $src = Join-Path $PSScriptRoot $name
if (Test-Path $src) { Copy-Item $src (Join-Path $packOut $name) -Force } if (Test-Path $src) { Copy-Item $src (Join-Path $packOut $name) -Force }
} }
Get-ChildItem -Path $packOut -Filter *.ps1 -File | ForEach-Object { Write-Utf8BomFile -Path $_.FullName }
Get-ChildItem -Path (Join-Path $stage "scripts") -Filter *.ps1 -File -ErrorAction SilentlyContinue |
ForEach-Object { Write-Utf8BomFile -Path $_.FullName }
Copy-Item -Path (Join-Path $PSScriptRoot "config") -Destination (Join-Path $packOut "config") -Recurse -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 Copy-Item -Path (Join-Path $PSScriptRoot "installer") -Destination (Join-Path $packOut "installer") -Recurse -Force
$assetsSrc = Join-Path $PSScriptRoot "assets"
if (Test-Path $assetsSrc) {
Copy-Item -Path $assetsSrc -Destination (Join-Path $packOut "assets") -Recurse -Force
}
$winswOut = Join-Path $packOut "winsw"
New-Item -ItemType Directory -Path $winswOut -Force | Out-Null
if (Test-Path $winswShip) {
Copy-Item -Path $winswShip -Destination (Join-Path $winswOut "WinSW-x64.exe") -Force
Write-Host "==> Bundled WinSW for offline service install"
} else {
Write-Host "[WARN] WinSW missing - offline service install will fail" -ForegroundColor Yellow
}
New-Item -ItemType Directory -Path (Join-Path $packOut "postgres") -Force | Out-Null 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 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")) { if (Test-Path (Join-Path $pgsql "bin\pg_ctl.exe")) {
Write-Host "==> Copying bundled PostgreSQL" Write-Host '==> Copying bundled PostgreSQL (bin/lib/share; skip doc/)'
New-Item -ItemType Directory -Path (Join-Path $stage "postgres") -Force | Out-Null $pgStage = Join-Path $stage "postgres\pgsql"
Copy-Item -Path $pgsql -Destination (Join-Path $stage "postgres\pgsql") -Recurse -Force New-Item -ItemType Directory -Path $pgStage -Force | Out-Null
foreach ($sub in @("bin", "lib", "share")) {
$srcSub = Join-Path $pgsql $sub
if (Test-Path $srcSub) {
Copy-Item -Path $srcSub -Destination (Join-Path $pgStage $sub) -Recurse -Force
}
}
Get-ChildItem -Path $pgsql -File -ErrorAction SilentlyContinue | ForEach-Object {
Copy-Item -Path $_.FullName -Destination (Join-Path $pgStage $_.Name) -Force
}
} else {
throw "bundled_postgres_missing: Setup.exe must ship postgres for offline install. Run download_postgres.ps1 (build machine only)."
} }
$builtAt = (Get-Date).ToUniversalTime().ToString("o") $builtAt = (Get-Date).ToUniversalTime().ToString("o")
@ -99,26 +161,93 @@ $builtAt = (Get-Date).ToUniversalTime().ToString("o")
Set-Content -Path (Join-Path $stage ".portable") -Value "1" -Encoding ascii Set-Content -Path (Join-Path $stage ".portable") -Value "1" -Encoding ascii
# Root .cmd launchers (Explorer / Start Menu friendly; ASCII-only).
$cmdSrc = Join-Path $PSScriptRoot "cmd"
foreach ($cmdName in @("NetX-FirstRun.cmd", "NetX-Start.cmd", "NetX-Tray.cmd", "NetX-Stop.cmd")) {
$c = Join-Path $cmdSrc $cmdName
if (Test-Path $c) {
Copy-Item -Path $c -Destination (Join-Path $stage $cmdName) -Force
}
}
if ($CreateVenv) { if ($CreateVenv) {
Write-Host "==> Creating .venv in stage (requires Python 3.11+ on PATH)" Write-Host "==> Creating portable python/runtime + .venv (must not reference build-machine paths)"
$py = (Get-Command python -ErrorAction SilentlyContinue) $pyPath = Get-NetxSystemPython
if (-not $py) { throw "python_not_found_for_venv" } if (-not $pyPath) { throw "python_not_found_for_venv: need Python 3.11+ (not WindowsApps stub)" }
& $py.Source -m venv (Join-Path $stage ".venv") Write-Host " Build Python: $pyPath"
& (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") $prefix = (& $pyPath -c "import sys; print(sys.base_prefix)" 2>$null | Out-String).Trim()
if (-not $prefix -or -not (Test-Path -LiteralPath $prefix)) {
throw "python_base_prefix_not_found: $prefix"
}
$rtDir = Join-Path $stage "python\runtime"
if (Test-Path $rtDir) { Remove-Item -Recurse -Force $rtDir }
New-Item -ItemType Directory -Path $rtDir -Force | Out-Null
Write-Host "==> Copying Python stdlib/runtime to $rtDir (exclude base site-packages)"
$spEx = Join-Path $prefix "Lib\site-packages"
$robocopy = Join-Path $env:SystemRoot "System32\robocopy.exe"
if (-not (Test-Path -LiteralPath $robocopy)) { throw "robocopy_not_found" }
& $robocopy $prefix $rtDir /E /XD $spEx __pycache__ /XF *.pyc /NFL /NDL /NJH /NJS /nc /ns /np | Out-Null
if ($LASTEXITCODE -ge 8) { throw "robocopy_python_runtime_failed: exit $LASTEXITCODE" }
$rtPy = Join-Path $rtDir "python.exe"
if (-not (Test-Path -LiteralPath $rtPy)) { throw "python_runtime_missing: $rtPy" }
$venvDir = Join-Path $stage ".venv"
if (Test-Path $venvDir) { Remove-Item -Recurse -Force $venvDir }
Write-Host '==> Creating .venv from shipped runtime (--copies)'
& $rtPy -m venv $venvDir --copies
if ($LASTEXITCODE -ne 0) { throw "venv_create_failed" }
$venvPy = Join-Path $venvDir "Scripts\python.exe"
$null = Repair-NetxShippedVenv -ProgramRoot $stage
if (-not (Test-NetxVenvRunnable -VenvPython $venvPy)) {
throw "venv_not_runnable_after_build: check python/runtime copy"
}
& $venvPy -m pip install --upgrade pip
& $venvPy -m pip install -r (Join-Path $stage "requirements.txt")
if ($LASTEXITCODE -ne 0) { throw "pip_install_failed" }
$null = Repair-NetxShippedVenv -ProgramRoot $stage
if (-not (Test-NetxVenvRunnable -VenvPython $venvPy)) {
throw "venv_not_runnable_after_pip"
}
Write-Host "==> Slimming .venv (drop tests / __pycache__ / *.pyc)"
$site = Join-Path $stage ".venv\Lib\site-packages"
if (Test-Path $site) {
Get-ChildItem -Path $site -Recurse -Directory -Force -ErrorAction SilentlyContinue |
Where-Object {
$_.Name -in @('__pycache__', 'tests', 'test', 'testing', 'Tests') -or
$_.FullName -match '\\pandas\\tests(\\|$)' -or
$_.FullName -match '\\numpy\\(_*tests|tests)(\\|$)' -or
$_.FullName -match '\\scipy\\(_*tests|tests)(\\|$)'
} |
Sort-Object { $_.FullName.Length } -Descending |
ForEach-Object {
Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue
}
Get-ChildItem -Path $site -Recurse -Include *.pyc,*.pyo -Force -ErrorAction SilentlyContinue |
Remove-Item -Force -ErrorAction SilentlyContinue
}
} }
Write-Host "==> Stage ready: $stage" -ForegroundColor Green Write-Host "==> Stage ready: $stage" -ForegroundColor Green
if (-not $SkipZip) { if ($SkipZip -and $CreateZip) {
Write-Host "[WARN] -SkipZip ignored because -CreateZip was set" -ForegroundColor Yellow
}
if ($CreateZip) {
$zip = Join-Path (Split-Path -Parent $stage) "NetX-$Version-win64.zip" $zip = Join-Path (Split-Path -Parent $stage) "NetX-$Version-win64.zip"
if (Test-Path $zip) { Remove-Item -Force $zip } if (Test-Path $zip) { Remove-Item -Force $zip }
Compress-Archive -Path $stage -DestinationPath $zip -Force Compress-Archive -Path $stage -DestinationPath $zip -Force
Write-Host "==> Zip: $zip" -ForegroundColor Green Write-Host "==> Zip (optional/dev): $zip" -ForegroundColor Yellow
} }
Write-Host "" Write-Host ""
Write-Host "Ship options:" Write-Host "Ship:"
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 " Exe: compile packaging\installer\netx.iss with Inno Setup (needs stage above)"
Write-Host " (Zip packages are not published; use -CreateZip only for local testing.)"
Write-Host "Python: install 3.11+ on target, or rebuild with -CreateVenv and ship .venv" Write-Host "Python: install 3.11+ on target, or rebuild with -CreateVenv and ship .venv"

436
packaging/check_update.ps1 Normal file
View file

@ -0,0 +1,436 @@
param(
[string]$ProgramRoot = "",
[string]$DataRoot = "",
[string]$UpdateUrl = "",
[string]$FallbackUrl = "",
[string]$ForgejoUrl = "",
[string]$GithubRepo = "",
[string]$Channel = "",
[string]$Sources = "",
[switch]$Apply = $false,
[switch]$Quiet = $false,
# Only apply when NETX_UPDATE_AUTO=true (scheduled silent updates).
[switch]$AutoOnly = $false
)
# Check for a newer NetX Windows package.
#
# Default (no config needed):
# GitHub primary + Forgejo mirror fallback (git.avelo.top).
# Probe every reachable source and pick the highest version;
# on equal versions prefer GitHub (listed first).
#
# Optional overrides:
# NETX_UPDATE_SOURCES=github,forgejo
# NETX_UPDATE_FORGEJO_URL=https://git.avelo.top/api/v1/repos/hansjone/netx/releases/latest
# NETX_UPDATE_GITHUB_REPO=hansjone/netx
# NETX_UPDATE_URL=... manifest JSON
# NETX_UPDATE_TOKEN=... private API token
$ErrorActionPreference = "Stop"
. "$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 }
function Get-Cfg([string]$Key, [string]$ParamVal = "") {
if ($ParamVal) { return $ParamVal }
if ($map.ContainsKey($Key) -and $map[$Key]) { return [string]$map[$Key] }
$envVal = [Environment]::GetEnvironmentVariable($Key)
if ($envVal) { return $envVal }
return ""
}
$UpdateUrl = Get-Cfg "NETX_UPDATE_URL" $UpdateUrl
$FallbackUrl = Get-Cfg "NETX_UPDATE_FALLBACK_URL" $FallbackUrl
$ForgejoUrl = Get-Cfg "NETX_UPDATE_FORGEJO_URL" $ForgejoUrl
if (-not $ForgejoUrl) {
$ForgejoUrl = "https://git.avelo.top/api/v1/repos/hansjone/netx/releases/latest"
}
$GithubRepo = Get-Cfg "NETX_UPDATE_GITHUB_REPO" $GithubRepo
if (-not $GithubRepo) { $GithubRepo = "hansjone/netx" }
$Channel = Get-Cfg "NETX_UPDATE_CHANNEL" $Channel
if (-not $Channel) { $Channel = "stable" }
$Sources = Get-Cfg "NETX_UPDATE_SOURCES" $Sources
$UpdateToken = Get-Cfg "NETX_UPDATE_TOKEN" ""
$current = Get-NetxVersion -ProgramRoot $prog
function Compare-SemVer {
param([string]$A, [string]$B)
$pa = @($A.TrimStart('v', 'V').Split('.') | ForEach-Object { try { [int]$_ } catch { 0 } })
$pb = @($B.TrimStart('v', 'V').Split('.') | ForEach-Object { try { [int]$_ } catch { 0 } })
while ($pa.Count -lt 3) { $pa += 0 }
while ($pb.Count -lt 3) { $pb += 0 }
for ($i = 0; $i -lt 3; $i++) {
if ($pa[$i] -lt $pb[$i]) { return -1 }
if ($pa[$i] -gt $pb[$i]) { return 1 }
}
return 0
}
function Get-AuthHeaders {
param([string]$Style = "bearer")
$h = @{ "User-Agent" = "NetX-UpdateCheck" }
if (-not $UpdateToken) { return $h }
if ($Style -eq "token") {
$h["Authorization"] = "token $UpdateToken"
} else {
$h["Authorization"] = "Bearer $UpdateToken"
}
return $h
}
function Resolve-AssetUrl {
param($Asset, $Rel, [string]$SourceName, [string]$ApiLatestUrl = "")
$url = [string]$Asset.browser_download_url
if ($url) { return $url }
# Forgejo/Gitea sometimes omit browser_download_url; synthesize release download URL.
if ($SourceName -eq "forgejo" -and $ApiLatestUrl -and $Rel.tag_name -and $Asset.name) {
if ($ApiLatestUrl -match '^(https?://[^/]+)/api/v1/repos/([^/]+)/([^/]+)/releases/latest') {
$base = $Matches[1]
$owner = $Matches[2]
$repo = $Matches[3]
$tag = [string]$Rel.tag_name
$name = [uri]::EscapeDataString([string]$Asset.name).Replace('%2F', '/')
# EscapeDataString encodes too much; use raw name (assets shouldn't need query encoding).
$name = [string]$Asset.name
return "$base/$owner/$repo/releases/download/$tag/$name"
}
}
return ""
}
function Convert-ReleaseToManifest {
param($Rel, [string]$SourceName, [string]$ApiLatestUrl = "")
$tag = [string]$Rel.tag_name
$ver = $tag.TrimStart('v', 'V')
$assets = @($Rel.assets)
# Prefer Setup.exe (current ship format). Legacy win64.zip still accepted.
$setupAsset = $assets | Where-Object { $_.name -match 'Setup-.*\.exe$' } | Select-Object -First 1
$zipAsset = $assets | Where-Object { $_.name -match 'win64\.zip$' } | Select-Object -First 1
if (-not $setupAsset -and -not $zipAsset) {
throw "${SourceName}_release_missing_setup_or_zip"
}
$setupUrl = ""
if ($setupAsset) {
$setupUrl = Resolve-AssetUrl -Asset $setupAsset -Rel $Rel -SourceName $SourceName -ApiLatestUrl $ApiLatestUrl
if (-not $setupUrl) { throw "${SourceName}_setup_url_missing" }
}
$zipUrl = ""
if ($zipAsset) {
$zipUrl = Resolve-AssetUrl -Asset $zipAsset -Rel $Rel -SourceName $SourceName -ApiLatestUrl $ApiLatestUrl
}
if ($setupUrl) {
$primaryUrl = $setupUrl
$packageKind = "setup"
$primarySize = [int64]$(if ($setupAsset.size) { $setupAsset.size } else { 0 })
} else {
if (-not $zipUrl) { throw "${SourceName}_zip_url_missing" }
$primaryUrl = $zipUrl
$packageKind = "zip"
$primarySize = [int64]$(if ($zipAsset.size) { $zipAsset.size } else { 0 })
}
return [pscustomobject]@{
channel = "stable"
latest = $ver
min_compatible = $ver
notes_url = [string]$Rel.html_url
source = $SourceName
windows = [pscustomobject]@{
url = $primaryUrl
sha256 = ""
size = $primarySize
setup_url = $setupUrl
package = $packageKind
}
}
}
function Get-ManifestFromUrl {
param([string]$Url)
$headers = Get-AuthHeaders -Style "bearer"
$resp = Invoke-WebRequest -Uri $Url -Headers $headers -UseBasicParsing -TimeoutSec 30
$m = $resp.Content | ConvertFrom-Json
if (-not $m.source) {
$m | Add-Member -NotePropertyName source -NotePropertyValue "manifest" -Force
}
return $m
}
function Get-FromGitHubReleases {
$api = "https://api.github.com/repos/$GithubRepo/releases/latest"
$headers = Get-AuthHeaders -Style "bearer"
$headers["Accept"] = "application/vnd.github+json"
$rel = Invoke-RestMethod -Uri $api -Headers $headers -TimeoutSec 30
return Convert-ReleaseToManifest -Rel $rel -SourceName "github"
}
function Get-FromForgejoReleases {
param([string]$ApiUrl)
if (-not $ApiUrl) { throw "forgejo_url_empty" }
$headers = Get-AuthHeaders -Style "token"
$headers["Accept"] = "application/json"
$rel = Invoke-RestMethod -Uri $ApiUrl -Headers $headers -TimeoutSec 30
return Convert-ReleaseToManifest -Rel $rel -SourceName "forgejo" -ApiLatestUrl $ApiUrl
}
function Get-UpdateSourceList {
if ($Sources) {
return @($Sources.Split(',') | ForEach-Object { $_.Trim().ToLowerInvariant() } | Where-Object { $_ })
}
# Default: GitHub primary, Forgejo mirror backup. Optional manifests prepended if configured.
$list = [System.Collections.Generic.List[string]]::new()
if ($UpdateUrl) { [void]$list.Add("manifest") }
[void]$list.Add("github")
[void]$list.Add("forgejo")
if ($FallbackUrl) { [void]$list.Add("manifest_fallback") }
return @($list)
}
function Get-ManifestFromSource([string]$src) {
switch ($src) {
"manifest" {
if (-not $UpdateUrl) { throw "NETX_UPDATE_URL empty" }
Write-Host "==> Probing manifest: $UpdateUrl"
return Get-ManifestFromUrl -Url $UpdateUrl
}
"manifest_fallback" {
if (-not $FallbackUrl) { throw "NETX_UPDATE_FALLBACK_URL empty" }
Write-Host "==> Probing fallback manifest: $FallbackUrl"
$m = Get-ManifestFromUrl -Url $FallbackUrl
if ($m -and -not $m.source) {
$m | Add-Member -NotePropertyName source -NotePropertyValue "manifest_fallback" -Force
}
return $m
}
"forgejo" {
Write-Host "==> Probing Forgejo: $ForgejoUrl"
return Get-FromForgejoReleases -ApiUrl $ForgejoUrl
}
"github" {
Write-Host "==> Probing GitHub: $GithubRepo"
return Get-FromGitHubReleases
}
default { throw "unknown_source: $src" }
}
}
Write-Host "==> Current version: $current"
$sourceList = Get-UpdateSourceList
Write-Host "==> Probe sources (pick newest reachable; tie → earlier in list): $($sourceList -join ', ')"
$candidates = @()
$errors = @()
foreach ($src in $sourceList) {
try {
$m = Get-ManifestFromSource -src $src
if ($m.channel -and $Channel -and ($m.channel -ne $Channel)) {
Write-Host "[WARN] $src channel=$($m.channel) requested=$Channel"
}
if (-not $m.windows -or -not $m.windows.url) {
throw "missing_windows_download_url"
}
Write-Host " OK $($m.source) latest=$($m.latest)" -ForegroundColor DarkGreen
$candidates += $m
} catch {
$msg = $_.Exception.Message
$errors += "${src}: $msg"
Write-Host "[WARN] source '$src' unreachable/failed: $msg" -ForegroundColor Yellow
}
}
if ($candidates.Count -eq 0) {
$joined = ($errors -join "; ")
if ($Quiet) { exit 2 }
throw "update_check_failed: all sources failed ($joined)"
}
# Prefer highest semver; on tie keep earlier source in $sourceList (GitHub before Forgejo by default).
$manifest = $candidates[0]
foreach ($c in $candidates) {
$cmpCand = Compare-SemVer -A ([string]$manifest.latest) -B ([string]$c.latest)
if ($cmpCand -lt 0) {
$manifest = $c
}
}
Write-Host "==> Selected source=$($manifest.source) latest=$($manifest.latest) (from $($candidates.Count) reachable)" -ForegroundColor Green
# Prefer Setup.exe when a manifest still lists a legacy zip as windows.url but also has setup_url.
if ($manifest.windows -and $manifest.windows.setup_url) {
$setupCandidate = [string]$manifest.windows.setup_url
$urlNow = [string]$manifest.windows.url
$pkgNow = if ($manifest.windows.package) { [string]$manifest.windows.package } else { "" }
if ($setupCandidate -and ($pkgNow -eq "zip" -or $urlNow -match '\.zip(\?|$)' -or -not $urlNow)) {
$manifest.windows | Add-Member -NotePropertyName url -NotePropertyValue $setupCandidate -Force
$manifest.windows | Add-Member -NotePropertyName package -NotePropertyValue "setup" -Force
}
}
$latest = [string]$manifest.latest
$cmp = Compare-SemVer -A $current -B $latest
$packageKind = "setup"
if ($manifest.windows.package) {
$packageKind = [string]$manifest.windows.package
} elseif ([string]$manifest.windows.url -match '\.zip(\?|$)') {
$packageKind = "zip"
}
$result = [pscustomobject]@{
current = $current
latest = $latest
update_available = ($cmp -lt 0)
source = $(if ($manifest.source) { [string]$manifest.source } else { "unknown" })
download_url = [string]$manifest.windows.url
setup_url = $(if ($manifest.windows.setup_url) { [string]$manifest.windows.setup_url } else { "" })
package = $packageKind
notes_url = $(if ($manifest.notes_url) { [string]$manifest.notes_url } else { "" })
sha256 = $(if ($manifest.windows.sha256) { [string]$manifest.windows.sha256 } else { "" })
reachable = @($candidates | ForEach-Object { "$($_.source)=$($_.latest)" })
}
if (-not $result.update_available) {
Write-Host "==> Up to date (latest=$latest, source=$($result.source))" -ForegroundColor Green
if (-not $Quiet) {
$result | ConvertTo-Json -Compress | Write-Output
}
exit 0
}
Write-Host "==> Update available: $current -> $latest (source=$($result.source))" -ForegroundColor Cyan
if ($result.notes_url) { Write-Host " Notes: $($result.notes_url)" }
$autoEnabled = $false
$autoVal = Get-Cfg "NETX_UPDATE_AUTO" ""
if ($autoVal -match '^(1|true|yes|on)$') { $autoEnabled = $true }
if (-not $Apply) {
Write-Host " Download ($($result.package)): $($result.download_url)"
Write-Host " To apply: .\packaging\check_update.ps1 -Apply"
if (-not $Quiet) {
$result | ConvertTo-Json -Compress | Write-Output
}
exit 10
}
if ($AutoOnly -and -not $autoEnabled) {
Write-Host "==> Update available but NETX_UPDATE_AUTO is not enabled; skip apply"
if (-not $Quiet) {
$result | ConvertTo-Json -Compress | Write-Output
}
exit 10
}
$lockFile = Join-Path $data "data\runtime\update.lock"
$lockDir = Split-Path -Parent $lockFile
if (-not (Test-Path $lockDir)) {
New-Item -ItemType Directory -Path $lockDir -Force | Out-Null
}
if (Test-Path $lockFile) {
$ageHrs = ((Get-Date) - (Get-Item $lockFile).LastWriteTime).TotalHours
if ($ageHrs -lt 2) {
Write-Host "==> Another update appears in progress ($lockFile); abort"
exit 3
}
Remove-Item -Force $lockFile -ErrorAction SilentlyContinue
}
Set-Content -Path $lockFile -Value (Get-Date).ToString("o") -Encoding ascii
try {
$dlDir = Join-Path $data "backups\downloads"
if (-not (Test-Path $dlDir)) {
New-Item -ItemType Directory -Path $dlDir -Force | Out-Null
}
$isSetup = ($result.package -eq "setup") -or ($result.download_url -match '\.exe(\?|$)')
if ($isSetup) {
$pkgName = "NetX-Setup-$latest.exe"
} else {
$pkgName = "NetX-$latest-win64.zip"
}
$pkgPath = Join-Path $dlDir $pkgName
$needDownload = $true
if ((Test-Path -LiteralPath $pkgPath) -and ((Get-Item -LiteralPath $pkgPath).Length -gt 1MB)) {
Write-Host "==> Using existing download: $pkgPath ($([math]::Round((Get-Item $pkgPath).Length/1MB,1)) MB)"
$needDownload = $false
}
if ($needDownload) {
Write-Host "==> Downloading $pkgName from $($result.source) ..."
# Only attach token for non-GitHub hosts (Forgejo/private). Public GitHub assets need no auth;
# a Forgejo token would break anonymous GitHub downloads.
$dlHeaders = @{ "User-Agent" = "NetX-UpdateCheck" }
$dlUri = [uri]$result.download_url
$isGithub = ($dlUri.Host -match '(^|\.)github\.com$' -or $dlUri.Host -match '(^|\.)githubusercontent\.com$')
if ($UpdateToken -and -not $isGithub) {
$dlHeaders["Authorization"] = "token $UpdateToken"
}
Invoke-WebRequest -Uri $result.download_url -OutFile $pkgPath -Headers $dlHeaders -UseBasicParsing
}
if ($result.sha256 -and $result.sha256 -notmatch 'REPLACE' -and $result.sha256.Trim()) {
$hash = (Get-FileHash -Path $pkgPath -Algorithm SHA256).Hash.ToLowerInvariant()
$expect = $result.sha256.ToLowerInvariant()
if ($hash -ne $expect) {
throw "sha256_mismatch: got $hash expected $expect"
}
Write-Host "==> SHA256 OK"
}
# Program Files installs need admin; elevate once (avoid loop via NETX_UPDATE_ELEVATED).
$progWritable = $false
try {
$probe = Join-Path $prog (".netx_w_" + [guid]::NewGuid().ToString("n"))
[IO.File]::WriteAllText($probe, "x")
Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue
$progWritable = $true
} catch {
$progWritable = $false
}
if (-not $progWritable -and -not $env:NETX_UPDATE_ELEVATED) {
Write-Host "==> Program directory is not writable; relaunching update as Administrator (UAC)..." -ForegroundColor Yellow
$arg = "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`" -ProgramRoot `"$prog`" -DataRoot `"$data`" -Apply"
$ep = Start-Process -FilePath "powershell.exe" -ArgumentList $arg -WorkingDirectory $prog `
-Verb RunAs -Wait -PassThru
if ($null -eq $ep.ExitCode) { exit 1 }
exit $ep.ExitCode
}
$env:NETX_UPDATE_ELEVATED = "1"
if ($isSetup) {
# Silent Setup update mode: skip DB wizard, keep ProgramData
# (see installer/netx.iss /InstallMode=update).
Write-Host "==> Applying update via silent Setup.exe"
$setupArgs = "/VERYSILENT /NORESTART /SUPPRESSMSGBOXES /DIR=`"$prog`" /InstallMode=update"
$sp = Start-Process -FilePath $pkgPath -ArgumentList $setupArgs -Wait -PassThru
if ($null -eq $sp.ExitCode -or $sp.ExitCode -ne 0) {
$code = if ($null -eq $sp.ExitCode) { "null" } else { $sp.ExitCode }
throw "setup_update_failed: exit $code"
}
# Setup post-install also repairs; belt-and-suspenders when running elevated apply.
$repairPs1 = Join-Path $prog "packaging\repair_venv.ps1"
if (Test-Path -LiteralPath $repairPs1) {
Write-Host "==> Relinking .venv to bundled python/runtime"
& powershell -NoProfile -ExecutionPolicy Bypass -File $repairPs1 `
-ProgramRoot $prog -DataRoot $data
}
Write-Host "==> Restarting NetX after Setup"
& (Join-Path $PSScriptRoot "start_netx_app.ps1") `
-ProgramRoot $prog -DataRoot $data -SkipBrowser
if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
} else {
Write-Host "==> Applying legacy zip update via update_netx.ps1"
& powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "update_netx.ps1") `
-PackagePath $pkgPath -ProgramRoot $prog -DataRoot $data
}
Write-Host "==> Update applied to $latest" -ForegroundColor Green
} finally {
Remove-Item -Force $lockFile -ErrorAction SilentlyContinue
}
exit 0

View file

@ -0,0 +1,28 @@
@echo off
setlocal
set "ROOT=%~dp0"
if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%"
cd /d "%ROOT%"
title NetX — Reconfigure database
echo.
echo NetX database reconfigure / 重新配置数据库
echo Prefer the installer wizard for first-time setup.
echo 首次安装请用安装向导选择内置或外置数据库。
echo This window is for repair / reconfigure only.
echo 本窗口仅用于修复或重新配置。
echo.
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%ROOT%\packaging\setup_first_run.ps1" -ProgramRoot "%ROOT%" -DataRoot "%ProgramData%\NetX"
set "EC=%ERRORLEVEL%"
if not "%EC%"=="0" (
echo.
echo Setup failed / 配置失败 exit=%EC%
pause
exit /b %EC%
)
echo.
echo Starting NetX tray... / 正在启动托盘...
start "" powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "%ROOT%\packaging\netx_tray.ps1" -ProgramRoot "%ROOT%" -DataRoot "%ProgramData%\NetX" -StartOnLaunch
echo.
echo Done. You can close this window. / 完成,可关闭本窗口。
pause
exit /b 0

View file

@ -0,0 +1,7 @@
@echo off
setlocal
set "ROOT=%~dp0"
if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%"
cd /d "%ROOT%"
start "" powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "%ROOT%\packaging\launch_shortcut.ps1" -Action start -ProgramRoot "%ROOT%" -DataRoot "%ProgramData%\NetX"
exit /b 0

View file

@ -0,0 +1,7 @@
@echo off
setlocal
set "ROOT=%~dp0"
if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%"
cd /d "%ROOT%"
start "" powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "%ROOT%\packaging\launch_shortcut.ps1" -Action stop -ProgramRoot "%ROOT%" -DataRoot "%ProgramData%\NetX"
exit /b 0

View file

@ -0,0 +1,7 @@
@echo off
setlocal
set "ROOT=%~dp0"
if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%"
cd /d "%ROOT%"
start "" powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "%ROOT%\packaging\launch_shortcut.ps1" -Action tray -ProgramRoot "%ROOT%" -DataRoot "%ProgramData%\NetX" -StartOnLaunch
exit /b 0

View file

@ -5,5 +5,11 @@
# NETX_HOST=127.0.0.1 # NETX_HOST=127.0.0.1
# NETX_PORT=8890 # NETX_PORT=8890
# NETX_UI_DIST_DIR=web/dist # NETX_UI_DIST_DIR=web/dist
# Defaults already: github + forgejo mirror git.avelo.top (newest reachable wins)
# NETX_UPDATE_SOURCES=github,forgejo
# NETX_UPDATE_FORGEJO_URL=https://git.avelo.top/api/v1/repos/hansjone/netx/releases/latest
# NETX_UPDATE_GITHUB_REPO=hansjone/netx
# NETX_UPDATE_URL= # NETX_UPDATE_URL=
# NETX_UPDATE_TOKEN=
# NETX_UPDATE_CHANNEL=stable # NETX_UPDATE_CHANNEL=stable
# NETX_UPDATE_AUTO=false

View file

@ -1,4 +1,4 @@
param( param(
[string]$Version = "16.4-1", [string]$Version = "16.4-1",
[string]$OutDir = "" [string]$OutDir = ""
) )

View file

@ -0,0 +1,25 @@
param(
[string]$ProgramRoot = "",
[switch]$Remove = $false
)
# Register / unregister NetX tray at current-user logon.
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
$prog = Get-NetxProgramRoot -Override $ProgramRoot
$tray = Join-Path $PSScriptRoot "netx_tray.ps1"
$runKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run"
$name = "NetX"
if ($Remove) {
Remove-ItemProperty -Path $runKey -Name $name -ErrorAction SilentlyContinue
Write-Host "==> Removed NetX from startup"
exit 0
}
$cmd = "powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$tray`" -ProgramRoot `"$prog`" -StartOnLaunch"
New-ItemProperty -Path $runKey -Name $name -Value $cmd -PropertyType String -Force | Out-Null
Write-Host "==> NetX tray will start at logon"
Write-Host " $cmd"

View file

@ -0,0 +1,141 @@
param(
[string]$ProgramRoot = "",
[string]$DataRoot = "",
[ValidateSet("winsw", "task")]
[string]$Mode = "winsw",
[switch]$Uninstall = $false,
[switch]$Start = $false,
# Offline-safe by default: use WinSW shipped in the package. Opt-in to download from GitHub.
[switch]$AllowDownload = $false
)
# Install NetX as a Windows Service (WinSW) or as a SYSTEM startup Scheduled Task.
# Requires elevation for winsw / task modes that run as SYSTEM.
# WinSW binary is bundled at packaging\winsw\WinSW-x64.exe by build_release.ps1 — no network needed.
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
$prog = Get-NetxProgramRoot -Override $ProgramRoot
$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot
$svcName = "NetX"
$winswDir = Join-Path $prog "packaging\winsw"
$winswExe = Join-Path $winswDir "NetX.exe"
$winswXml = Join-Path $winswDir "NetX.xml"
$cacheDir = Join-Path $PSScriptRoot "cache"
function Test-IsAdmin {
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
$p = New-Object Security.Principal.WindowsPrincipal($id)
return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function ConvertTo-WinSWPath([string]$Path) {
return ($Path -replace '\\', '/')
}
function Ensure-WinSW {
if (-not (Test-Path $winswDir)) {
New-Item -ItemType Directory -Path $winswDir -Force | Out-Null
}
if (-not (Test-Path $cacheDir)) {
New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
}
$bundled = Join-Path $PSScriptRoot "winsw\WinSW-x64.exe"
$dl = Join-Path $cacheDir "WinSW-x64.exe"
$src = $null
if (Test-Path $bundled) {
$src = $bundled
Write-Host "==> Using bundled WinSW: $bundled"
} elseif (Test-Path $dl) {
$src = $dl
Write-Host "==> Using cached WinSW: $dl"
} elseif ($AllowDownload) {
$url = "https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW-x64.exe"
Write-Host "==> Downloading WinSW: $url"
Invoke-WebRequest -Uri $url -OutFile $dl -UseBasicParsing
$src = $dl
} else {
throw "winsw_missing_offline: expected $bundled (rebuild with build_release.ps1). Or pass -AllowDownload when online."
}
Copy-Item -Path $src -Destination $winswExe -Force
$runPs1 = ConvertTo-WinSWPath (Join-Path $PSScriptRoot "service_run.ps1")
$stopPs1 = ConvertTo-WinSWPath (Join-Path $PSScriptRoot "stop_netx_app.ps1")
$progFs = ConvertTo-WinSWPath $prog
$dataFs = ConvertTo-WinSWPath $data
$logPath = ConvertTo-WinSWPath (Join-Path $data "data\runtime")
if (-not (Test-Path (Join-Path $data "data\runtime"))) {
New-Item -ItemType Directory -Path (Join-Path $data "data\runtime") -Force | Out-Null
}
$xml = @"
<service>
<id>$svcName</id>
<name>NetX Ops</name>
<description>NetX API, workers, and optional bundled PostgreSQL</description>
<executable>powershell.exe</executable>
<arguments>-NoProfile -ExecutionPolicy Bypass -File "$runPs1" -ProgramRoot "$progFs" -DataRoot "$dataFs"</arguments>
<logpath>$logPath</logpath>
<log mode="roll-by-size">
<sizeThreshold>10240</sizeThreshold>
<keepFiles>4</keepFiles>
</log>
<onfailure action="restart" delay="10 sec"/>
<onfailure action="restart" delay="30 sec"/>
<resetfailure>1 hour</resetfailure>
<stoptimeout>60sec</stoptimeout>
<stopexecutable>powershell.exe</stopexecutable>
<stoparguments>-NoProfile -ExecutionPolicy Bypass -File "$stopPs1" -ProgramRoot "$progFs" -DataRoot "$dataFs"</stoparguments>
<workingdirectory>$progFs</workingdirectory>
</service>
"@
# WinSW expects UTF-8 without BOM issues; ASCII-compatible paths preferred.
[System.IO.File]::WriteAllText($winswXml, $xml)
}
if ($Uninstall) {
if (-not (Test-IsAdmin)) { throw "admin_required_for_uninstall" }
if (Test-Path $winswExe) {
Write-Host "==> Stopping/uninstalling WinSW service"
& $winswExe stop 2>$null
& $winswExe uninstall 2>$null
}
Unregister-ScheduledTask -TaskName "NetXService" -TaskPath "\NetX\" -Confirm:$false -ErrorAction SilentlyContinue
Write-Host "==> Service/task removed"
exit 0
}
if (-not (Test-IsAdmin)) {
throw "admin_required: run elevated PowerShell to install the NetX service"
}
if ($Mode -eq "winsw") {
Ensure-WinSW
Write-Host "==> Installing Windows Service via WinSW"
& $winswExe stop 2>$null
& $winswExe uninstall 2>$null
& $winswExe install
if ($LASTEXITCODE -ne 0) { throw "winsw_install_failed" }
if ($Start) {
& $winswExe start
Write-Host "==> Service started" -ForegroundColor Green
} else {
Write-Host "==> Installed. Start with: Start-Service NetX or `"$winswExe`" start"
}
} else {
Write-Host "==> Registering Scheduled Task NetX\NetXService (At startup, SYSTEM)"
$runPs1 = Join-Path $PSScriptRoot "service_run.ps1"
$arg = "-NoProfile -ExecutionPolicy Bypass -File `"$runPs1`" -ProgramRoot `"$prog`" -DataRoot `"$data`""
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $arg -WorkingDirectory $prog
$trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
Register-ScheduledTask -TaskName "NetXService" -TaskPath "\NetX\" -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
if ($Start) {
Start-ScheduledTask -TaskName "NetXService" -TaskPath "\NetX\"
Write-Host "==> Task started" -ForegroundColor Green
} else {
Write-Host "==> Task registered. Start with: Start-ScheduledTask -TaskPath '\NetX\' -TaskName NetXService"
}
}

View file

@ -0,0 +1,57 @@
param(
[string]$ProgramRoot = "",
[string]$DataRoot = "",
[ValidateSet("Daily", "AtLogOn", "Hourly")]
[string]$Trigger = "Daily",
[string]$At = "03:30",
[switch]$Remove = $false,
[switch]$EnableAutoEnv = $true
)
# Schedule silent update checks. With NETX_UPDATE_AUTO=true, applies updates when available.
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
$prog = Get-NetxProgramRoot -Override $ProgramRoot
$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot
$taskName = "NetXUpdate"
$taskPath = "\NetX\"
$checkPs1 = Join-Path $PSScriptRoot "check_update.ps1"
if ($Remove) {
Unregister-ScheduledTask -TaskName $taskName -TaskPath $taskPath -Confirm:$false -ErrorAction SilentlyContinue
Write-Host "==> Update task removed"
exit 0
}
if ($EnableAutoEnv) {
$envFile = Join-Path $data ".env"
if (-not (Test-Path $data)) {
New-Item -ItemType Directory -Path $data -Force | Out-Null
}
Write-DotEnvValue -Path $envFile -Values @{ "NETX_UPDATE_AUTO" = "true" }
Write-Host "==> Set NETX_UPDATE_AUTO=true in $envFile"
}
$arg = "-NoProfile -ExecutionPolicy Bypass -File `"$checkPs1`" -ProgramRoot `"$prog`" -DataRoot `"$data`" -Apply -Quiet -AutoOnly"
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $arg -WorkingDirectory $prog
switch ($Trigger) {
"Hourly" {
$trig = New-ScheduledTaskTrigger -Once -At (Get-Date).Date.AddHours((Get-Date).Hour + 1) `
-RepetitionInterval (New-TimeSpan -Hours 1) -RepetitionDuration ([TimeSpan]::MaxValue)
}
"AtLogOn" {
$trig = New-ScheduledTaskTrigger -AtLogOn
}
default {
$trig = New-ScheduledTaskTrigger -Daily -At $At
}
}
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable
$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Limited
Register-ScheduledTask -TaskName $taskName -TaskPath $taskPath -Action $action -Trigger $trig -Settings $settings -Principal $principal -Force | Out-Null
Write-Host "==> Scheduled update task: $taskPath$taskName ($Trigger)" -ForegroundColor Green
Write-Host " Manual run: .\packaging\check_update.ps1 -Apply -Quiet -AutoOnly"

View file

@ -0,0 +1,417 @@
; *** Inno Setup version 6.5.0+ Chinese Simplified messages ***
;
; To download user-contributed translations of this file, go to:
; https://jrsoftware.org/files/istrans/
;
; Note: When translating this text, do not add periods (.) to the end of
; messages that didn't have them already, because on those messages Inno
; Setup adds the periods automatically (appending a period would result in
; two periods being displayed).
;
; Maintainer: Zhenghan Yang (Kira)
; Email: 847320916@QQ.com
; Github: https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
; Encoding: UTF-8
; Translation based on network resource
;
[LangOptions]
; The following three entries are very important. Be sure to read and
; understand the '[LangOptions] section' topic in the help file.
LanguageName=简体中文
; About LanguageID, to reference link:
; https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c
LanguageID=$0804
; LanguageCodePage should always be set if possible, even if this file is Unicode
; For English it's set to zero anyway because English only uses ASCII characters
LanguageCodePage=936
; If the language you are translating to requires special font faces or
; sizes, uncomment any of the following entries and change them accordingly.
;DialogFontName=
;DialogFontSize=9
;DialogFontBaseScaleWidth=7
;DialogFontBaseScaleHeight=15
;WelcomeFontName=Segoe UI
;WelcomeFontSize=14
[Messages]
; *** Application titles
SetupAppTitle=安装
SetupWindowTitle=安装 - %1
UninstallAppTitle=卸载
UninstallAppFullTitle=%1 卸载
; *** Misc. common
InformationTitle=信息
ConfirmTitle=确认
ErrorTitle=错误
; *** SetupLdr messages
SetupLdrStartupMessage=现在将安装 %1。您想要继续吗?
LdrCannotCreateTemp=无法创建临时文件。安装程序已中止
LdrCannotExecTemp=无法执行临时目录中的文件。安装程序已中止
HelpTextNote=
; *** Startup error messages
LastErrorMessage=%1。%n%n错误 %2: %3
SetupFileMissing=安装目录中缺少文件 %1。请修正这个问题或者获取程序的新副本。
SetupFileCorrupt=安装文件已损坏。请获取程序的新副本。
SetupFileCorruptOrWrongVer=安装文件已损坏,或是与这个安装程序的版本不兼容。请修正这个问题或获取新的程序副本。
InvalidParameter=无效的命令行参数:%n%n%1
SetupAlreadyRunning=安装程序已在运行。
WindowsVersionNotSupported=此程序不支持当前计算机运行的 Windows 版本。
WindowsServicePackRequired=此程序需要 %1 服务包 %2 或更高版本。
NotOnThisPlatform=此程序不能在 %1 上运行。
OnlyOnThisPlatform=此程序只能在 %1 上运行。
OnlyOnTheseArchitectures=此程序只能安装到为下列处理器架构设计的 Windows 版本中:%n%n%1
WinVersionTooLowError=此程序需要 %1 版本 %2 或更高。
WinVersionTooHighError=此程序不能安装于 %1 版本 %2 或更高。
AdminPrivilegesRequired=在安装此程序时您必须以管理员身份登录。
PowerUserPrivilegesRequired=在安装此程序时您必须以管理员身份或高级用户组身份登录。
SetupAppRunningError=安装程序检测到 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
UninstallAppRunningError=卸载程序检测到 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
; *** Startup questions
PrivilegesRequiredOverrideTitle=选择安装程序安装模式
PrivilegesRequiredOverrideInstruction=选择安装模式
PrivilegesRequiredOverrideText1=%1 可以为所有用户安装(需要管理员权限),或仅为您安装。
PrivilegesRequiredOverrideText2=%1 可以仅为您安装,或为所有用户安装(需要管理员权限)。
PrivilegesRequiredOverrideAllUsers=为所有用户安装(&A)
PrivilegesRequiredOverrideAllUsersRecommended=为所有用户安装(&A)(推荐)
PrivilegesRequiredOverrideCurrentUser=仅为我安装(&M)
PrivilegesRequiredOverrideCurrentUserRecommended=仅为我安装(&M)(推荐)
; *** Misc. errors
ErrorCreatingDir=安装程序无法创建目录“%1”
ErrorTooManyFilesInDir=无法在目录“%1”中创建文件,因为里面包含太多文件。
; *** Setup common messages
ExitSetupTitle=退出安装程序
ExitSetupMessage=安装程序尚未完成。如果现在退出,将不会安装该程序。%n%n您之后可以再次运行安装程序完成安装。%n%n现在退出安装程序吗?
AboutSetupMenuItem=关于安装程序(&A)...
AboutSetupTitle=关于安装程序
AboutSetupMessage=%1 版本 %2%n%3%n%n%1 主页:%n%4
AboutSetupNote=
TranslatorNote=简体中文翻译由 Kira(847320916@qq.com)维护。项目地址:https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
; *** Buttons
ButtonBack=< 上一步(&B)
ButtonNext=下一步(&N) >
ButtonInstall=安装(&I)
ButtonOK=确定
ButtonCancel=取消
ButtonYes=是(&Y)
ButtonYesToAll=全是(&A)
ButtonNo=否(&N)
ButtonNoToAll=全否(&O)
ButtonFinish=完成(&F)
ButtonBrowse=浏览(&B)...
ButtonWizardBrowse=浏览(&R)...
ButtonNewFolder=新建文件夹(&M)
; *** "Select Language" dialog messages
SelectLanguageTitle=选择安装语言
SelectLanguageLabel=选择安装时使用的语言。
; *** Common wizard text
ClickNext=点击“下一步”继续,或点击“取消”退出安装程序。
BeveledLabel=
BrowseDialogTitle=浏览文件夹
BrowseDialogLabel=在下面的列表中选择一个文件夹,然后点击“确定”。
NewFolderName=新建文件夹
; *** "Welcome" wizard page
WelcomeLabel1=欢迎使用 [name] 安装向导
WelcomeLabel2=即将在您的计算机上安装 [name/ver]。%n%n建议您在继续安装前关闭所有其他应用程序。
; *** "Password" wizard page
WizardPassword=密码
PasswordLabel1=此安装程序需要密码验证。
PasswordLabel3=请输入密码,然后点击“下一步”继续。密码区分大小写。
PasswordEditLabel=密码(&P):
IncorrectPassword=您输入的密码不正确,请重新输入。
; *** "License Agreement" wizard page
WizardLicense=许可协议
LicenseLabel=请在继续安装前阅读以下重要信息。
LicenseLabel3=请阅读下列许可协议。在继续安装前您必须同意这些协议条款。
LicenseAccepted=我同意此协议(&A)
LicenseNotAccepted=我不同意此协议(&D)
; *** "Information" wizard pages
WizardInfoBefore=信息
InfoBeforeLabel=请在继续安装前阅读以下重要信息。
InfoBeforeClickLabel=准备好继续安装后,点击“下一步”。
WizardInfoAfter=信息
InfoAfterLabel=请在继续安装前阅读以下重要信息。
InfoAfterClickLabel=准备好继续安装后,点击“下一步”。
; *** "User Information" wizard page
WizardUserInfo=用户信息
UserInfoDesc=请输入您的信息。
UserInfoName=用户名(&U):
UserInfoOrg=组织(&O):
UserInfoSerial=序列号(&S):
UserInfoNameRequired=请输入用户名。
; *** "Select Destination Location" wizard page
WizardSelectDir=选择目标位置
SelectDirDesc=您想将 [name] 安装在哪里?
SelectDirLabel3=安装程序将安装 [name] 到下面的文件夹中。
SelectDirBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
DiskSpaceGBLabel=至少需要有 [gb] GB 的可用磁盘空间。
DiskSpaceMBLabel=至少需要有 [mb] MB 的可用磁盘空间。
CannotInstallToNetworkDrive=安装程序无法安装到一个网络驱动器。
CannotInstallToUNCPath=安装程序无法安装到一个 UNC 路径。
InvalidPath=您必须输入一个带驱动器盘符的完整路径,例如:%n%nC:\App%n%n或UNC路径:%n%n\\server\share
InvalidDrive=您选定的驱动器或 UNC 共享不存在或不能访问。请选择其他位置。
DiskSpaceWarningTitle=磁盘空间不足
DiskSpaceWarning=安装程序至少需要 %1 KB 的可用空间才能安装,但选定驱动器只有 %2 KB 的可用空间。%n%n您确定要继续吗?
DirNameTooLong=文件夹名称或路径太长。
InvalidDirName=文件夹名称无效。
BadDirName32=文件夹名称不能包含下列任何字符:%n%n%1
DirExistsTitle=文件夹已存在
DirExists=文件夹:%n%n%1%n%n已经存在。您确定安装到这个文件夹中吗?
DirDoesntExistTitle=文件夹不存在
DirDoesntExist=文件夹:%n%n%1%n%n不存在。您想要创建此文件夹吗?
; *** "Select Components" wizard page
WizardSelectComponents=选择组件
SelectComponentsDesc=您想安装哪些程序组件?
SelectComponentsLabel2=选中您想安装的组件;取消您不想安装的组件。然后点击“下一步”继续。
FullInstallation=完全安装
; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language)
CompactInstallation=简洁安装
CustomInstallation=自定义安装
NoUninstallWarningTitle=组件已存在
NoUninstallWarning=安装程序检测到下列组件已安装在您的计算机中:%n%n%1%n%n取消选中这些组件不会卸载它们。%n%n您确定要继续吗?
ComponentSize1=%1 KB
ComponentSize2=%1 MB
ComponentsDiskSpaceGBLabel=当前选择的组件需要至少 [gb] GB 的磁盘空间。
ComponentsDiskSpaceMBLabel=当前选择的组件需要至少 [mb] MB 的磁盘空间。
; *** "Select Additional Tasks" wizard page
WizardSelectTasks=选择附加任务
SelectTasksDesc=您想要安装程序执行哪些附加任务?
SelectTasksLabel2=选择您想要安装程序在安装 [name] 时执行的附加任务,然后点击“下一步”。
; *** "Select Start Menu Folder" wizard page
WizardSelectProgramGroup=选择开始菜单文件夹
SelectStartMenuFolderDesc=安装程序应该在哪里放置程序的快捷方式?
SelectStartMenuFolderLabel3=安装程序将在下列“开始”菜单文件夹中创建程序的快捷方式。
SelectStartMenuFolderBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
MustEnterGroupName=您必须输入一个文件夹名称。
GroupNameTooLong=文件夹名称或路径太长。
InvalidGroupName=文件夹名称无效。
BadGroupName=文件夹名称不能包含下列任何字符:%n%n%1
NoProgramGroupCheck2=不创建开始菜单文件夹(&D)
; *** "Ready to Install" wizard page
WizardReady=准备安装
ReadyLabel1=安装程序准备就绪,现在可以开始安装 [name] 到您的计算机。
ReadyLabel2a=点击“安装”继续此安装程序。如果您想重新查看或修改任何设置,点击“上一步”。
ReadyLabel2b=点击“安装”继续此安装程序。
ReadyMemoUserInfo=用户信息:
ReadyMemoDir=目标位置:
ReadyMemoType=安装类型:
ReadyMemoComponents=已选择组件:
ReadyMemoGroup=开始菜单文件夹:
ReadyMemoTasks=附加任务:
; *** TDownloadWizardPage wizard page and DownloadTemporaryFile
DownloadingLabel2=正在下载文件...
ButtonStopDownload=停止下载(&S)
StopDownload=您确定要停止下载吗?
ErrorDownloadAborted=下载已中止。
ErrorDownloadFailed=下载失败:%1 %2。
ErrorDownloadSizeFailed=获取大小失败:%1 %2。
ErrorProgress=无效的进度:%1 / %2。
ErrorFileSize=文件大小错误:预期 %1,实际 %2。
; *** TExtractionWizardPage wizard page and ExtractArchive
ExtractingLabel=正在提取文件...
ButtonStopExtraction=停止提取(&S)
StopExtraction=您确定要停止提取吗?
ErrorExtractionAborted=提取已中止。
ErrorExtractionFailed=提取失败:%1
; *** Archive extraction failure details
ArchiveIncorrectPassword=密码不正确。
ArchiveIsCorrupted=压缩包已损坏。
ArchiveUnsupportedFormat=不支持的压缩包格式。
; *** "Preparing to Install" wizard page
WizardPreparing=正在准备安装
PreparingDesc=安装程序正在准备安装 [name] 到您的计算机。
PreviousInstallNotCompleted=先前的程序安装或卸载未完成,需要您重启计算机以完成该安装。%n%n在重启计算机后,再次运行安装程序以完成 [name] 的安装。
CannotContinue=安装程序不能继续。请点击“取消”退出。
ApplicationsFound=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。
ApplicationsFound2=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。安装完成后,安装程序将尝试重新启动这些应用程序。
CloseApplications=自动关闭应用程序(&A)
DontCloseApplications=不要关闭应用程序(&D)
ErrorCloseApplications=安装程序无法自动关闭所有应用程序。建议您在继续之前,关闭所有在使用需要由安装程序更新的文件的应用程序。
PrepareToInstallNeedsRestart=安装程序必须重启您的计算机。计算机重启后,请再次运行安装程序以完成 [name] 的安装。%n%n要立即重启吗?
; *** "Installing" wizard page
WizardInstalling=正在安装
InstallingLabel=安装程序正在安装 [name] 到您的计算机,请稍候。
; *** "Setup Completed" wizard page
FinishedHeadingLabel=完成 [name] 安装向导
FinishedLabelNoIcons=安装程序已在您的计算机中安装了 [name]。
FinishedLabel=安装程序已在您的计算机中安装了 [name]。您可以通过已安装的快捷方式运行此应用程序。
ClickFinish=点击“完成”退出安装程序。
FinishedRestartLabel=为完成 [name] 的安装,安装程序必须重新启动您的计算机。要立即重启吗?
FinishedRestartMessage=为完成 [name] 的安装,安装程序必须重新启动您的计算机。%n%n要立即重启吗?
ShowReadmeCheck=是,我想查阅自述文件
YesRadio=是,立即重启计算机(&Y)
NoRadio=否,稍后重启计算机(&N)
; used for example as 'Run MyProg.exe'
RunEntryExec=运行 %1
; used for example as 'View Readme.txt'
RunEntryShellExec=查阅 %1
; *** "Setup Needs the Next Disk" stuff
ChangeDiskTitle=安装程序需要下一张磁盘
SelectDiskLabel2=请插入磁盘 %1 并点击“确定”。%n%n如果这个磁盘中的文件可以在下列文件夹之外的文件夹中找到,请输入正确的路径或点击“浏览”。
PathLabel=路径(&P):
FileNotInDir2=“%2”中找不到文件“%1”。请插入正确的磁盘或选择其他文件夹。
SelectDirectoryLabel=请指定下一张磁盘的位置。
; *** Installation phase messages
SetupAborted=安装程序未完成安装。%n%n请修正这个问题并重新运行安装程序。
AbortRetryIgnoreSelectAction=选择操作
AbortRetryIgnoreRetry=重试(&T)
AbortRetryIgnoreIgnore=忽略错误并继续(&I)
AbortRetryIgnoreCancel=取消安装
RetryCancelSelectAction=选择操作
RetryCancelRetry=重试(&T)
RetryCancelCancel=取消
; *** Installation status messages
StatusClosingApplications=正在关闭应用程序...
StatusCreateDirs=正在创建目录...
StatusExtractFiles=正在提取文件...
StatusDownloadFiles=正在下载文件...
StatusCreateIcons=正在创建快捷方式...
StatusCreateIniEntries=正在创建 INI 条目...
StatusCreateRegistryEntries=正在创建注册表条目...
StatusRegisterFiles=正在注册文件...
StatusSavingUninstall=正在保存卸载信息...
StatusRunProgram=正在完成安装...
StatusRestartingApplications=正在重启应用程序...
StatusRollback=正在撤销更改...
; *** Misc. errors
ErrorInternal2=内部错误:%1。
ErrorFunctionFailedNoCode=%1 失败。
ErrorFunctionFailed=%1 失败;错误代码 %2。
ErrorFunctionFailedWithMessage=%1 失败;错误代码 %2。%n%3
ErrorExecutingProgram=无法执行文件:%n%1
; *** Registry errors
ErrorRegOpenKey=打开注册表项时出错:%n%1\%2
ErrorRegCreateKey=创建注册表项时出错:%n%1\%2
ErrorRegWriteKey=写入注册表项时出错:%n%1\%2
; *** INI errors
ErrorIniEntry=在文件“%1”中创建 INI 条目时出错。
; *** File copying errors
FileAbortRetryIgnoreSkipNotRecommended=跳过此文件(&S)(不推荐)
FileAbortRetryIgnoreIgnoreNotRecommended=忽略错误并继续(&I)(不推荐)
SourceIsCorrupted=源文件已损坏。
SourceDoesntExist=源文件“%1”不存在。
SourceVerificationFailed=源文件验证失败:%1
VerificationSignatureDoesntExist=签名文件“%1”不存在。
VerificationSignatureInvalid=签名文件“%1”无效。
VerificationKeyNotFound=签名文件“%1”使用了未知的密钥。
VerificationFileNameIncorrect=文件名不正确。
VerificationFileTagIncorrect=文件标签不正确。
VerificationFileSizeIncorrect=文件大小不正确。
VerificationFileHashIncorrect=文件哈希值不正确。
ExistingFileReadOnly2=无法替换已存在的文件,它是只读的。
ExistingFileReadOnlyRetry=移除只读属性并重试(&R)
ExistingFileReadOnlyKeepExisting=保留已存在的文件(&K)
ErrorReadingExistingDest=尝试读取已存在的文件时出错:
FileExistsSelectAction=选择操作
FileExists2=文件已经存在。
FileExistsOverwriteExisting=覆盖已存在的文件(&O)
FileExistsKeepExisting=保留已存在的文件(&K)
FileExistsOverwriteOrKeepAll=为接下来的冲突文件执行此操作(&D)
ExistingFileNewerSelectAction=选择操作
ExistingFileNewer2=已存在的文件比安装程序将要安装的文件还要新。
ExistingFileNewerOverwriteExisting=覆盖已存在的文件(&O)
ExistingFileNewerKeepExisting=保留已存在的文件(&K)(推荐)
ExistingFileNewerOverwriteOrKeepAll=为接下来的冲突文件执行此操作(&D)
ErrorChangingAttr=尝试更改下列已存在的文件属性时出错:
ErrorCreatingTemp=尝试在目标目录创建文件时出错:
ErrorReadingSource=尝试读取下列源文件时出错:
ErrorCopying=尝试复制下列文件时出错:
ErrorDownloading=尝试下载文件时出错:
ErrorExtracting=尝试提取压缩包时出错:
ErrorReplacingExistingFile=尝试替换已存在的文件时出错:
ErrorRestartReplace=重启并替换失败:
ErrorRenamingTemp=尝试重命名下列目标目录中的一个文件时出错:
ErrorRegisterServer=无法注册 DLL/OCX:%1
ErrorRegSvr32Failed=RegSvr32 失败;退出代码 %1。
ErrorRegisterTypeLib=无法注册类型库:%1
; *** Uninstall display name markings
; used for example as 'My Program (32-bit)'
UninstallDisplayNameMark=%1 (%2)
; used for example as 'My Program (32-bit, All users)'
UninstallDisplayNameMarks=%1 (%2, %3)
UninstallDisplayNameMark32Bit=32 位
UninstallDisplayNameMark64Bit=64 位
UninstallDisplayNameMarkAllUsers=所有用户
UninstallDisplayNameMarkCurrentUser=当前用户
; *** Post-installation errors
ErrorOpeningReadme=尝试打开自述文件时出错。
ErrorRestartingComputer=安装程序无法重启计算机,请手动重启。
; *** Uninstaller messages
UninstallNotFound=文件“%1”不存在。无法卸载。
UninstallOpenError=文件“%1”不能被打开。无法卸载
UninstallUnsupportedVer=此版本的卸载程序无法识别卸载日志文件“%1”的格式。无法卸载。
UninstallUnknownEntry=卸载日志中遇到一个未知条目(%1)。
ConfirmUninstall=您确认要完全移除 %1 及其所有组件吗?
UninstallOnlyOnWin64=仅允许在 64 位 Windows 中卸载此程序。
OnlyAdminCanUninstall=仅使用管理员权限的用户能完成此卸载。
UninstallStatusLabel=正在从您的计算机中移除 %1,请稍候。
UninstalledAll=已顺利从您的计算机中移除 %1。
UninstalledMost=%1 卸载完成。%n%n有部分内容未能被删除,但您可以手动删除它们。
UninstalledAndNeedsRestart=为完成 %1 的卸载,需要重启您的计算机。%n%n要立即重启吗?
UninstallDataCorrupted=文件“%1”已损坏。无法卸载。
; *** Uninstallation phase messages
ConfirmDeleteSharedFileTitle=删除共享文件?
ConfirmDeleteSharedFile2=系统表示下列共享文件已不再有任何程序使用。您希望卸载程序删除此共享文件吗?%n%n如果仍有程序正在使用此文件,删除后这些程序可能无法正常运行。如果您不能确定,请选择“否”,保留此文件在系统中不会造成任何损害。
SharedFileNameLabel=文件名:
SharedFileLocationLabel=位置:
WizardUninstalling=卸载状态
StatusUninstalling=正在卸载 %1...
; *** Shutdown block reasons
ShutdownBlockReasonInstallingApp=正在安装 %1。
ShutdownBlockReasonUninstallingApp=正在卸载 %1。
; The custom messages below aren't used by Setup itself, but if you make
; use of them in your scripts, you'll want to translate them.
[CustomMessages]
NameAndVersion=%1 版本 %2
AdditionalIcons=附加快捷方式:
CreateDesktopIcon=创建桌面快捷方式(&D)
CreateQuickLaunchIcon=创建快速启动栏快捷方式(&Q)
ProgramOnTheWeb=%1 网站
UninstallProgram=卸载 %1
LaunchProgram=运行 %1
AssocFileExtension=将 %2 文件扩展名与 %1 建立关联(&A)
AssocingFileExtension=正在将 %2 文件扩展名与 %1 建立关联...
AutoStartProgramGroupDescription=启动:
AutoStartProgram=自动启动 %1
AddonHostProgramNotFound=您选择的文件夹中无法找到 %1。%n%n您确定要继续吗?

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,104 @@
param(
[Parameter(Mandatory = $true)][string]$PgHost,
[int]$Port = 5432,
[Parameter(Mandatory = $true)][string]$User,
[string]$Password = "",
[string]$PasswordFile = "",
[Parameter(Mandatory = $true)][string]$Database,
[Parameter(Mandatory = $true)][string]$PsqlPath,
[string]$OutUrlFile = "",
[string]$OutErrFile = ""
)
$ErrorActionPreference = "Stop"
function Write-ErrFile([string]$Message) {
if ($OutErrFile) {
Set-Content -LiteralPath $OutErrFile -Value $Message -Encoding utf8
}
[Console]::Error.WriteLine($Message)
}
if (-not (Test-Path -LiteralPath $PsqlPath)) {
Write-ErrFile "psql_not_found: $PsqlPath"
exit 2
}
if ($PasswordFile) {
if (-not (Test-Path -LiteralPath $PasswordFile)) {
Write-ErrFile "password_file_missing"
exit 3
}
$Password = [IO.File]::ReadAllText($PasswordFile).TrimEnd("`r", "`n")
}
if ([string]::IsNullOrWhiteSpace($PgHost)) {
Write-ErrFile "host_required"
exit 3
}
if ([string]::IsNullOrWhiteSpace($User) -or [string]::IsNullOrWhiteSpace($Database)) {
Write-ErrFile "user_and_database_required"
exit 3
}
if ([string]::IsNullOrWhiteSpace($Password)) {
Write-ErrFile "password_required"
exit 3
}
if ($Port -lt 1 -or $Port -gt 65535) {
Write-ErrFile "invalid_port: $Port"
exit 3
}
$encUser = [uri]::EscapeDataString($User)
$encPw = [uri]::EscapeDataString($Password)
$encDb = [uri]::EscapeDataString($Database)
$url = "postgresql+psycopg://${encUser}:${encPw}@${PgHost}:${Port}/${encDb}"
$psqlDir = Split-Path -Parent $PsqlPath
$prevPath = $env:PATH
$prevPw = $env:PGPASSWORD
try {
$env:PATH = "$psqlDir;$env:PATH"
$env:PGPASSWORD = $Password
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $PsqlPath
$psi.Arguments = "-h `"$PgHost`" -p $Port -U `"$User`" -d `"$Database`" -v ON_ERROR_STOP=1 -t -A -c `"SELECT 1`""
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$psi.WorkingDirectory = $psqlDir
$p = [Diagnostics.Process]::Start($psi)
$stdout = $p.StandardOutput.ReadToEnd()
$stderr = $p.StandardError.ReadToEnd()
$p.WaitForExit()
if ($p.ExitCode -ne 0) {
$msg = (($stderr + "`n" + $stdout).Trim())
if (-not $msg) { $msg = "psql_exit_$($p.ExitCode)" }
Write-ErrFile "connection_failed: $msg"
exit 1
}
if (($stdout.Trim()) -notmatch '1') {
Write-ErrFile "unexpected_result: $($stdout.Trim())"
exit 1
}
} catch {
Write-ErrFile ("connection_failed: " + $_.Exception.Message)
exit 1
} finally {
$env:PATH = $prevPath
if ($null -eq $prevPw) {
Remove-Item Env:PGPASSWORD -ErrorAction SilentlyContinue
} else {
$env:PGPASSWORD = $prevPw
}
}
if ($OutUrlFile) {
$dir = Split-Path -Parent $OutUrlFile
if ($dir -and -not (Test-Path -LiteralPath $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
Set-Content -LiteralPath $OutUrlFile -Value $url -Encoding ascii -NoNewline
}
Write-Output "ok"
exit 0

View file

@ -0,0 +1,127 @@
param(
[ValidateSet("tray", "start", "stop", "setup", "update")]
[string]$Action = "tray",
[string]$ProgramRoot = "",
[string]$DataRoot = "",
[switch]$StartOnLaunch = $true
)
# Visible entrypoint for Start Menu / desktop shortcuts.
# Nested PowerShell runs are hidden; only failures show a message box.
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
Add-Type -AssemblyName System.Windows.Forms
$zh = ([cultureinfo]::CurrentUICulture.Name -match '^(zh|zh-)')
function T([string]$En, [string]$Zh) {
if ($zh) { return $Zh } else { return $En }
}
$prog = Get-NetxProgramRoot -Override $ProgramRoot
$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot
$logDir = Join-Path $data "data\runtime"
if (-not (Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
}
$log = Join-Path $logDir "launch.log"
function Write-LaunchLog([string]$Msg) {
$line = "[{0}] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Msg
Add-Content -Path $log -Value $line -Encoding utf8
}
function Show-NetxError([string]$Msg) {
Write-LaunchLog "ERROR $Msg"
[void][System.Windows.Forms.MessageBox]::Show(
$Msg,
"NetX",
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Error
)
}
function Invoke-NetxHiddenPs1 {
param(
[string]$File,
[string[]]$ExtraArgs = @(),
[switch]$NoWait = $false
)
return Start-NetxPowerShell -File $File `
-Arguments (@("-ProgramRoot", $prog, "-DataRoot", $data) + $ExtraArgs) `
-WorkingDirectory $prog -WindowStyle Hidden -PassThru -Wait:(-not $NoWait)
}
function Ensure-NetxEnv {
$envPath = Join-Path $data ".env"
if (Test-Path $envPath) { return }
$cmd = Join-Path $prog "NetX-FirstRun.cmd"
throw (T `
"NetX is not configured yet (missing $envPath).`nRe-run Setup and choose built-in or external DB,`nor Start Menu → Reconfigure database:`n$cmd" `
"尚未完成数据库配置(缺少 $envPath)。`n请重新运行安装向导选择内置/外置库,`n或开始菜单 → 重新配置数据库:`n$cmd")
}
try {
Write-LaunchLog "action=$Action prog=$prog data=$data"
switch ($Action) {
"setup" {
# Visible console so user can pick bundled vs external DB.
$p = Start-NetxPowerShell -File (Join-Path $PSScriptRoot "setup_first_run.ps1") `
-Arguments @("-ProgramRoot", $prog, "-DataRoot", $data) `
-WorkingDirectory $prog -WindowStyle Normal -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "setup_exit_$($p.ExitCode)" }
if (Test-Path (Join-Path $data ".env")) {
Start-NetxPowerShell -File (Join-Path $PSScriptRoot "netx_tray.ps1") `
-Arguments @("-ProgramRoot", $prog, "-DataRoot", $data, "-StartOnLaunch") `
-WorkingDirectory $prog -WindowStyle Hidden | Out-Null
}
}
"stop" {
$p = Invoke-NetxHiddenPs1 -File (Join-Path $PSScriptRoot "stop_netx_app.ps1")
if ($null -ne $p.ExitCode -and $p.ExitCode -ne 0) { throw "stop_exit_$($p.ExitCode)" }
}
"update" {
Ensure-NetxEnv
# Update UI may need a visible window for prompts.
$p = Start-NetxPowerShell -File (Join-Path $PSScriptRoot "check_update.ps1") `
-Arguments @("-ProgramRoot", $prog, "-DataRoot", $data) `
-WorkingDirectory $prog -WindowStyle Normal -Wait -PassThru
if ($null -ne $p.ExitCode -and $p.ExitCode -ne 0 -and $p.ExitCode -ne 10) {
throw "update_exit_$($p.ExitCode)"
}
}
"start" {
Ensure-NetxEnv
$hostBind = "127.0.0.1"
$port = 8890
$envPath = Join-Path $data ".env"
if (Test-Path $envPath) {
$map = Read-DotEnv -Path $envPath
if ($map["NETX_HOST"]) { $hostBind = $map["NETX_HOST"] }
if ($map["NETX_PORT"]) { try { $port = [int]$map["NETX_PORT"] } catch {} }
}
$p = Invoke-NetxHiddenPs1 -File (Join-Path $PSScriptRoot "start_netx_app.ps1") -ExtraArgs @("-SkipBrowser")
if ($p.ExitCode -ne 0) { throw "start_exit_$($p.ExitCode)" }
if (Wait-NetxApiHealthy -HostName $hostBind -Port $port -TimeoutSec 180) {
try { Start-Process "http://${hostBind}:${port}/" } catch {}
} else {
throw (T `
"NetX started but /health not ready within 180s.`nSee: $logDir\netx.err.log" `
"NetX 已启动但 /health 在 180 秒内未就绪。`n请查看: $logDir\netx.err.log")
}
}
"tray" {
Ensure-NetxEnv
$extra = @("-ProgramRoot", $prog, "-DataRoot", $data)
if ($StartOnLaunch) { $extra += "-StartOnLaunch" }
# Detach tray; do not leave a success MessageBox (keeps a console open).
Start-NetxPowerShell -File (Join-Path $PSScriptRoot "netx_tray.ps1") `
-Arguments $extra -WorkingDirectory $prog -WindowStyle Hidden | Out-Null
}
}
Write-LaunchLog "action=$Action ok"
} catch {
Show-NetxError ((T "NetX failed to start:`n$($_.Exception.Message)" "NetX 启动失败:`n$($_.Exception.Message)") + "`n`n$log")
exit 1
}

View file

@ -1,11 +1,13 @@
{ {
"channel": "stable", "channel": "stable",
"latest": "0.3.0", "latest": "0.4.12",
"min_compatible": "0.3.0", "min_compatible": "0.3.0",
"notes_url": "", "notes_url": "https://github.com/hansjone/netx/releases/tag/v0.4.12",
"windows": { "windows": {
"url": "https://example.com/netx/NetX-0.2.0-win64.zip", "url": "https://github.com/hansjone/netx/releases/download/v0.4.12/NetX-Setup-0.4.12.exe",
"sha256": "REPLACE_WITH_SHA256", "setup_url": "https://github.com/hansjone/netx/releases/download/v0.4.12/NetX-Setup-0.4.12.exe",
"package": "setup",
"sha256": "",
"size": 0 "size": 0
} }
} }

747
packaging/netx_tray.ps1 Normal file
View file

@ -0,0 +1,747 @@
param(
[string]$ProgramRoot = "",
[string]$DataRoot = "",
[switch]$StartOnLaunch = $true,
[switch]$CheckUpdateOnLaunch = $false,
[switch]$AutoUpdate = $false
)
# System-tray controller for NetX (Windows packaged installs).
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
# Custom tray menu: hover/selected highlight + flat light chrome (default OS menu looks dead).
if (-not ("NetxMenuRenderer" -as [type])) {
Add-Type -ReferencedAssemblies System.Windows.Forms, System.Drawing -TypeDefinition @"
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Windows.Forms;
public sealed class NetxMenuColorTable : ProfessionalColorTable {
static readonly Color Hover = Color.FromArgb(232, 240, 252);
static readonly Color Press = Color.FromArgb(214, 228, 248);
static readonly Color Border = Color.FromArgb(170, 198, 240);
static readonly Color Edge = Color.FromArgb(210, 214, 220);
static readonly Color Sep = Color.FromArgb(228, 230, 235);
static readonly Color Bg = Color.FromArgb(252, 252, 253);
public override Color MenuItemSelected { get { return Hover; } }
public override Color MenuItemSelectedGradientBegin { get { return Hover; } }
public override Color MenuItemSelectedGradientEnd { get { return Hover; } }
public override Color MenuItemBorder { get { return Border; } }
public override Color MenuItemPressedGradientBegin { get { return Press; } }
public override Color MenuItemPressedGradientMiddle { get { return Press; } }
public override Color MenuItemPressedGradientEnd { get { return Press; } }
public override Color MenuBorder { get { return Edge; } }
public override Color ToolStripDropDownBackground { get { return Bg; } }
public override Color ImageMarginGradientBegin { get { return Bg; } }
public override Color ImageMarginGradientMiddle { get { return Bg; } }
public override Color ImageMarginGradientEnd { get { return Bg; } }
public override Color SeparatorDark { get { return Sep; } }
public override Color SeparatorLight { get { return Sep; } }
}
public sealed class NetxMenuRenderer : ToolStripProfessionalRenderer {
public NetxMenuRenderer() : base(new NetxMenuColorTable()) {
RoundedEdges = false;
}
protected override void OnRenderMenuItemBackground(ToolStripItemRenderEventArgs e) {
ToolStripMenuItem item = e.Item as ToolStripMenuItem;
if (item == null || !item.Selected || !item.Enabled) {
base.OnRenderMenuItemBackground(e);
return;
}
Rectangle r = new Rectangle(2, 0, e.Item.Width - 4, e.Item.Height);
using (SolidBrush brush = new SolidBrush(Color.FromArgb(232, 240, 252)))
using (Pen pen = new Pen(Color.FromArgb(170, 198, 240))) {
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
e.Graphics.FillRectangle(brush, r);
e.Graphics.DrawRectangle(pen, r.X, r.Y, r.Width - 1, r.Height - 1);
}
}
protected override void OnRenderToolStripBorder(ToolStripRenderEventArgs e) {
Rectangle r = new Rectangle(0, 0, e.AffectedBounds.Width - 1, e.AffectedBounds.Height - 1);
using (Pen pen = new Pen(Color.FromArgb(210, 214, 220))) {
e.Graphics.DrawRectangle(pen, r);
}
}
protected override void OnRenderSeparator(ToolStripSeparatorRenderEventArgs e) {
int y = e.Item.Height / 2;
using (Pen pen = new Pen(Color.FromArgb(228, 230, 235))) {
e.Graphics.DrawLine(pen, 10, y, e.Item.Width - 10, y);
}
}
}
"@
}
$prog = Get-NetxProgramRoot -Override $ProgramRoot
$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot
$hostBind = "127.0.0.1"
$port = 8890
$envPath = Join-Path $data ".env"
$map = @{}
if (Test-Path $envPath) {
$map = Read-DotEnv -Path $envPath
if ($map["NETX_HOST"]) { $hostBind = $map["NETX_HOST"] }
if ($map["NETX_PORT"]) { try { $port = [int]$map["NETX_PORT"] } catch {} }
}
$autoVal = ""
if ($map["NETX_UPDATE_AUTO"]) { $autoVal = $map["NETX_UPDATE_AUTO"] }
elseif ($env:NETX_UPDATE_AUTO) { $autoVal = $env:NETX_UPDATE_AUTO }
if ($autoVal -match '^(1|true|yes|on)$') {
$AutoUpdate = $true
$CheckUpdateOnLaunch = $true
}
$uiUrl = "http://${hostBind}:${port}/"
$script:ver = Get-NetxVersion -ProgramRoot $prog
$ver = $script:ver
$dbMode = Get-DbMode -EnvMap $map
$dbModeLabel = if ($dbMode -eq "bundled") {
if (([cultureinfo]::CurrentUICulture.Name -match '^(zh|zh-)')) { "内置库" } else { "built-in DB" }
} else {
if (([cultureinfo]::CurrentUICulture.Name -match '^(zh|zh-)')) { "外置库" } else { "external DB" }
}
$zh = ([cultureinfo]::CurrentUICulture.Name -match '^(zh|zh-)')
function T([string]$En, [string]$Zh) {
if ($zh) { return $Zh } else { return $En }
}
if (-not (Test-Path $envPath)) {
[void][System.Windows.Forms.MessageBox]::Show(
(T `
"NetX is not configured yet (missing $envPath).`nRe-run the installer and choose built-in or external DB,`nor use Start Menu → NetX → Reconfigure database." `
"尚未完成数据库配置(缺少 $envPath)。`n请重新运行安装向导并选择内置或外置数据库,`n或使用「开始菜单 → NetX → 重新配置数据库」。"),
"NetX",
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Warning
)
exit 1
}
# Only one tray icon per machine session (desktop + postinstall + autostart can race).
$script:netxTrayMutex = $null
try {
$createdNew = $false
$script:netxTrayMutex = New-Object System.Threading.Mutex($true, "Local\NetX.WinTray.SingleInstance", [ref]$createdNew)
if (-not $createdNew) {
if (Test-NetxApiHealthy -HostName $hostBind -Port $port -TimeoutSec 2) {
try { Start-Process $uiUrl } catch {}
} elseif ($StartOnLaunch) {
Start-NetxPowerShell -File (Join-Path $PSScriptRoot "start_netx_app.ps1") `
-Arguments @("-ProgramRoot", $prog, "-DataRoot", $data, "-SkipBrowser") `
-WorkingDirectory $prog -WindowStyle Hidden | Out-Null
}
exit 0
}
} catch {
# If mutex fails, continue with a tray (better than no UI control).
}
$startPs1 = Join-Path $PSScriptRoot "start_netx_app.ps1"
$stopPs1 = Join-Path $PSScriptRoot "stop_netx_app.ps1"
$checkPs1 = Join-Path $PSScriptRoot "check_update.ps1"
$setupPs1 = Join-Path $PSScriptRoot "setup_first_run.ps1"
function Invoke-NetxScript {
param(
[string]$File,
[string[]]$ExtraArgs = @(),
[switch]$Wait = $false
)
return Start-NetxPowerShell -File $File -Arguments (@("-ProgramRoot", $prog, "-DataRoot", $data) + $ExtraArgs) `
-WorkingDirectory $prog -WindowStyle Hidden -PassThru -Wait:$Wait
}
function Update-NetxTrayTip {
# Refresh version after in-place updates (tray process may outlive version.json).
try {
$nowVer = Get-NetxVersion -ProgramRoot $prog
if ($nowVer -and $nowVer -ne $script:ver) {
$script:ver = $nowVer
if ($null -ne $script:miHeader) { $script:miHeader.Text = "NetX $script:ver" }
}
} catch {}
$running = Test-NetxApiHealthy -HostName $hostBind -Port $port -TimeoutSec 1
$state = if ($running) {
(T "Running" "运行中")
} else {
(T "Stopped" "已停止")
}
$v = $script:ver
$tip = "NetX $v · $state · $dbModeLabel`n$uiUrl"
if ($tip.Length -gt 63) {
# NotifyIcon.Text max ~63 chars on older Windows.
$tip = "NetX $v · $state · $dbModeLabel"
}
$notify.Text = $tip
}
function Close-NetxTrayMenu {
# ContextMenuStrip stays modal if Click handlers block; close before dialogs/waits.
try { $menu.Close() } catch {}
try { [System.Windows.Forms.Application]::DoEvents() } catch {}
}
function Start-NetxTrayDeferred {
param([scriptblock]$Work)
Close-NetxTrayMenu
$timer = New-Object System.Windows.Forms.Timer
$timer.Interval = 50
$script:netxTrayDeferredWork = $Work
$timer.add_Tick({
$t = $script:netxTrayDeferredTimer
try { if ($t) { $t.Stop(); $t.Dispose() } } catch {}
$script:netxTrayDeferredTimer = $null
$w = $script:netxTrayDeferredWork
$script:netxTrayDeferredWork = $null
if ($w) { & $w }
})
$script:netxTrayDeferredTimer = $timer
$timer.Start()
}
function Start-NetxTrayWatchProcess {
# Never Start-Process -Wait on the WinForms UI thread — it freezes the tray menu.
param(
[Parameter(Mandatory = $true)]$Process,
[scriptblock]$OnExit
)
if ($null -eq $Process) {
if ($OnExit) { & $OnExit $null }
return
}
$script:netxWatchProc = $Process
$script:netxWatchOnExit = $OnExit
if ($script:netxWatchTimer) {
try { $script:netxWatchTimer.Stop(); $script:netxWatchTimer.Dispose() } catch {}
}
$timer = New-Object System.Windows.Forms.Timer
$timer.Interval = 400
$timer.add_Tick({
$p = $script:netxWatchProc
if ($null -eq $p) {
try { $script:netxWatchTimer.Stop(); $script:netxWatchTimer.Dispose() } catch {}
$script:netxWatchTimer = $null
return
}
$done = $false
try { $done = [bool]$p.HasExited } catch { $done = $true }
if (-not $done) { return }
try { $script:netxWatchTimer.Stop(); $script:netxWatchTimer.Dispose() } catch {}
$script:netxWatchTimer = $null
$cb = $script:netxWatchOnExit
$script:netxWatchOnExit = $null
$script:netxWatchProc = $null
if ($cb) { & $cb $p }
})
$script:netxWatchTimer = $timer
$timer.Start()
}
function Restart-NetxTraySelf {
# Fresh process picks up new version.json / scripts after in-place update.
try {
$notify.ShowBalloonTip(
2500, "NetX",
(T "Restarting tray…" "正在重启托盘…"),
[System.Windows.Forms.ToolTipIcon]::Info
)
} catch {}
$trayFile = Join-Path $PSScriptRoot "netx_tray.ps1"
$arg = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$trayFile`" -ProgramRoot `"$prog`" -DataRoot `"$data`""
try {
Start-Process -FilePath "powershell.exe" -ArgumentList $arg -WindowStyle Hidden | Out-Null
} catch {}
try { $notify.Visible = $false } catch {}
try { $form.Close() } catch {}
[System.Windows.Forms.Application]::Exit()
}
function Test-NetxSetupCancelled {
param([AllowNull()][Nullable[int]]$ExitCode)
if ($null -eq $ExitCode) { return $false }
# STATUS_CONTROL_C_EXIT (0xC000013A): console closed / Ctrl+C — not a setup failure.
return ([int]$ExitCode -eq -1073741510)
}
function Complete-NetxTrayReconfig {
# Reload .env after reconfigure (host/port/mode may change).
if (Test-Path $envPath) {
$map = Read-DotEnv -Path $envPath
if ($map["NETX_HOST"]) { $script:hostBind = $map["NETX_HOST"]; $hostBind = $script:hostBind }
if ($map["NETX_PORT"]) {
try {
$script:port = [int]$map["NETX_PORT"]
$port = $script:port
} catch {}
}
$script:uiUrl = "http://${hostBind}:${port}/"
$uiUrl = $script:uiUrl
$dbMode = Get-DbMode -EnvMap $map
$script:dbModeLabel = if ($dbMode -eq "bundled") {
(T "built-in DB" "内置库")
} else {
(T "external DB" "外置库")
}
$dbModeLabel = $script:dbModeLabel
if ($miSub) { $miSub.Text = "$dbModeLabel · ${hostBind}:$port" }
}
$restart = [System.Windows.Forms.MessageBox]::Show(
(T "Database configuration saved.`nStart NetX now?" "数据库配置已保存。`n是否立即启动 NetX?"),
"NetX",
[System.Windows.Forms.MessageBoxButtons]::YesNo,
[System.Windows.Forms.MessageBoxIcon]::Information
)
if ($restart -eq [System.Windows.Forms.DialogResult]::Yes) {
Invoke-NetxScript -File $startPs1 -ExtraArgs @("-SkipBrowser") | Out-Null
Open-NetxUiWhenReady
} else {
Update-NetxTrayTip
}
}
function Open-NetxUiWhenReady {
param([int]$TimeoutSec = 180)
$notify.ShowBalloonTip(
5000,
"NetX",
(T "Starting… first run may take a minute." "正在启动…首次迁移可能需要一分钟。"),
[System.Windows.Forms.ToolTipIcon]::Info
)
# Poll on a timer — do not block the tray UI thread with Wait-NetxApiHealthy.
if ($script:netxUiReadyTimer) {
try { $script:netxUiReadyTimer.Stop(); $script:netxUiReadyTimer.Dispose() } catch {}
}
$script:netxUiReadyTicks = 0
$script:netxUiReadyMax = [Math]::Max(1, [int]($TimeoutSec * 2))
$timer = New-Object System.Windows.Forms.Timer
$timer.Interval = 500
$timer.add_Tick({
$script:netxUiReadyTicks++
if (Test-NetxApiHealthy -HostName $hostBind -Port $port -TimeoutSec 2) {
try { $script:netxUiReadyTimer.Stop(); $script:netxUiReadyTimer.Dispose() } catch {}
$script:netxUiReadyTimer = $null
try { Start-Process $uiUrl } catch {}
Update-NetxTrayTip
return
}
if ($script:netxUiReadyTicks -ge $script:netxUiReadyMax) {
try { $script:netxUiReadyTimer.Stop(); $script:netxUiReadyTimer.Dispose() } catch {}
$script:netxUiReadyTimer = $null
$notify.ShowBalloonTip(
8000,
"NetX",
(T "UI not ready yet. Use tray → Open UI when ready, or check data\runtime\netx.err.log." "界面尚未就绪。就绪后请用托盘「打开界面」,或查看 data\runtime\netx.err.log。"),
[System.Windows.Forms.ToolTipIcon]::Warning
)
Update-NetxTrayTip
}
})
$script:netxUiReadyTimer = $timer
$timer.Start()
}
$form = New-Object System.Windows.Forms.Form
$form.Text = "NetX"
$form.ShowInTaskbar = $false
$form.WindowState = "Minimized"
$form.Visible = $false
$form.Size = New-Object System.Drawing.Size(0, 0)
$notify = New-Object System.Windows.Forms.NotifyIcon
$notify.Visible = $true
$iconPath = Join-Path $PSScriptRoot "assets\netx.ico"
try {
if (Test-Path $iconPath) {
$notify.Icon = New-Object System.Drawing.Icon $iconPath
} else {
$notify.Icon = [System.Drawing.SystemIcons]::Application
}
} catch {
try { $notify.Icon = [System.Drawing.SystemIcons]::Application } catch {}
}
Update-NetxTrayTip
function New-NetxMenuItem {
param(
[string]$Text,
[switch]$Header,
[switch]$Primary,
[switch]$Muted
)
$item = New-Object System.Windows.Forms.ToolStripMenuItem
$item.Text = $Text
$item.AutoSize = $true
$item.Padding = New-Object System.Windows.Forms.Padding(10, 5, 28, 5)
$item.Margin = New-Object System.Windows.Forms.Padding(0)
if ($Header) {
$item.Enabled = $false
$item.Font = New-Object System.Drawing.Font("Segoe UI", 9.0, [System.Drawing.FontStyle]::Bold)
$item.ForeColor = [System.Drawing.Color]::FromArgb(55, 65, 80)
$item.Padding = New-Object System.Windows.Forms.Padding(10, 6, 28, 2)
} elseif ($Muted) {
$item.Enabled = $false
$item.Font = New-Object System.Drawing.Font("Segoe UI", 8.25)
$item.ForeColor = [System.Drawing.Color]::FromArgb(120, 128, 140)
$item.Padding = New-Object System.Windows.Forms.Padding(10, 1, 28, 6)
} elseif ($Primary) {
$item.Font = New-Object System.Drawing.Font("Segoe UI", 9.0, [System.Drawing.FontStyle]::Bold)
$item.ForeColor = [System.Drawing.Color]::FromArgb(20, 40, 70)
} else {
$item.Font = New-Object System.Drawing.Font("Segoe UI", 9.0)
$item.ForeColor = [System.Drawing.Color]::FromArgb(35, 40, 48)
}
return $item
}
function New-NetxMenuSeparator {
$sep = New-Object System.Windows.Forms.ToolStripSeparator
$sep.Margin = New-Object System.Windows.Forms.Padding(0, 3, 0, 3)
$sep.Padding = New-Object System.Windows.Forms.Padding(0)
return $sep
}
$menu = New-Object System.Windows.Forms.ContextMenuStrip
$menu.ShowImageMargin = $false
$menu.ShowCheckMargin = $false
$menu.Font = New-Object System.Drawing.Font("Segoe UI", 9.0)
$menu.BackColor = [System.Drawing.Color]::FromArgb(252, 252, 253)
$menu.ForeColor = [System.Drawing.Color]::FromArgb(35, 40, 48)
$menu.Padding = New-Object System.Windows.Forms.Padding(4, 4, 4, 4)
$menu.Renderer = New-Object NetxMenuRenderer
# --- header (info only) ---
$script:miHeader = New-NetxMenuItem -Text "NetX $script:ver" -Header
$miHeader = $script:miHeader
[void]$menu.Items.Add($miHeader)
$miSub = New-NetxMenuItem -Text "$dbModeLabel · ${hostBind}:$port" -Muted
[void]$menu.Items.Add($miSub)
[void]$menu.Items.Add((New-NetxMenuSeparator))
# --- primary ---
$miOpen = New-NetxMenuItem -Text (T "Open UI" "打开界面") -Primary
$miOpen.add_Click({
Start-NetxTrayDeferred {
if (Test-NetxApiHealthy -HostName $hostBind -Port $port -TimeoutSec 2) {
Start-Process $uiUrl
} else {
$notify.ShowBalloonTip(4000, "NetX", (T "NetX is not running. Starting…" "NetX 未运行,正在启动…"), [System.Windows.Forms.ToolTipIcon]::Info)
Invoke-NetxScript -File $startPs1 -ExtraArgs @("-SkipBrowser") | Out-Null
Open-NetxUiWhenReady
}
}
})
[void]$menu.Items.Add($miOpen)
[void]$menu.Items.Add((New-NetxMenuSeparator))
# --- service control ---
$miStart = New-NetxMenuItem -Text (T "Start" "启动")
$miStart.add_Click({
Start-NetxTrayDeferred {
Invoke-NetxScript -File $startPs1 -ExtraArgs @("-SkipBrowser") | Out-Null
Open-NetxUiWhenReady
}
})
[void]$menu.Items.Add($miStart)
$miStop = New-NetxMenuItem -Text (T "Stop" "停止")
$miStop.add_Click({
Start-NetxTrayDeferred {
$notify.ShowBalloonTip(3000, "NetX", (T "Stopping…" "正在停止…"), [System.Windows.Forms.ToolTipIcon]::Info)
Invoke-NetxScript -File $stopPs1 | Out-Null
Update-NetxTrayTip
}
})
[void]$menu.Items.Add($miStop)
[void]$menu.Items.Add((New-NetxMenuSeparator))
# --- setup / update ---
$miReconfig = New-NetxMenuItem -Text (T "Reconfigure database…" "重新配置数据库…")
$miReconfig.add_Click({
Start-NetxTrayDeferred {
$ans = [System.Windows.Forms.MessageBox]::Show(
(T `
"NetX will stop while you reconfigure the database.`n`nChoose built-in or external PostgreSQL in the console window.`nContinue?" `
"重新配置数据库时会先停止 NetX。`n`n请在随后的控制台窗口中选择内置或外置 PostgreSQL。`n是否继续?"),
(T "Reconfigure database" "重新配置数据库"),
[System.Windows.Forms.MessageBoxButtons]::YesNo,
[System.Windows.Forms.MessageBoxIcon]::Question
)
if ($ans -ne [System.Windows.Forms.DialogResult]::Yes) { return }
$notify.ShowBalloonTip(3000, "NetX", (T "Stopping…" "正在停止…"), [System.Windows.Forms.ToolTipIcon]::Info)
Invoke-NetxScript -File $stopPs1 | Out-Null
# ProgramData\.env is often admin-owned after Setup; repair ACL before reconfigure.
$null = Ensure-NetxDataAcl -DataRoot $data -Quiet
try {
$p = Start-NetxPowerShell -File $setupPs1 `
-Arguments @("-ProgramRoot", $prog, "-DataRoot", $data) `
-WorkingDirectory $prog -WindowStyle Normal -PassThru
Start-NetxTrayWatchProcess -Process $p -OnExit {
param($sp)
if ($null -eq $sp) { Update-NetxTrayTip; return }
if (Test-NetxSetupCancelled -ExitCode $sp.ExitCode) {
$notify.ShowBalloonTip(
4000, "NetX",
(T "Database setup cancelled." "已取消数据库配置。"),
[System.Windows.Forms.ToolTipIcon]::Info
)
Update-NetxTrayTip
return
}
if ($null -ne $sp.ExitCode -and $sp.ExitCode -ne 0) {
$elev = [System.Windows.Forms.MessageBox]::Show(
(T `
"Database setup failed (exit $($sp.ExitCode)).`n`nIf this was a permission error on %ProgramData%\NetX\.env, click Yes to retry as Administrator." `
"数据库配置失败(退出码 $($sp.ExitCode))。`n`n若是 %ProgramData%\NetX\.env 权限问题,点「是」将以管理员身份重试。"),
"NetX",
[System.Windows.Forms.MessageBoxButtons]::YesNo,
[System.Windows.Forms.MessageBoxIcon]::Warning
)
if ($elev -ne [System.Windows.Forms.DialogResult]::Yes) {
Update-NetxTrayTip
return
}
$arg = "-NoProfile -ExecutionPolicy Bypass -File `"$setupPs1`" -ProgramRoot `"$prog`" -DataRoot `"$data`""
try {
$ep = Start-Process -FilePath "powershell.exe" -ArgumentList $arg `
-WorkingDirectory $prog -Verb RunAs -PassThru
} catch {
Update-NetxTrayTip
return
}
Start-NetxTrayWatchProcess -Process $ep -OnExit {
param($ep2)
if ($null -eq $ep2 -or (Test-NetxSetupCancelled -ExitCode $ep2.ExitCode)) {
Update-NetxTrayTip
return
}
if ($null -ne $ep2.ExitCode -and $ep2.ExitCode -ne 0) {
[System.Windows.Forms.MessageBox]::Show(
(T "Elevated database setup still failed (exit $($ep2.ExitCode))." "管理员配置仍失败(退出码 $($ep2.ExitCode))。"),
"NetX",
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Error
)
Update-NetxTrayTip
return
}
Complete-NetxTrayReconfig
}
return
}
Complete-NetxTrayReconfig
}
} catch {
[System.Windows.Forms.MessageBox]::Show(
(T "Database setup failed: $($_.Exception.Message)" "数据库配置失败:$($_.Exception.Message)"),
"NetX",
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Error
)
Update-NetxTrayTip
}
}
})
[void]$menu.Items.Add($miReconfig)
$miUpdate = New-NetxMenuItem -Text (T "Check for updates…" "检查更新…")
$miUpdate.add_Click({
Start-NetxTrayDeferred {
try {
$notify.ShowBalloonTip(
3000, "NetX",
(T "Checking for updates…" "正在检查更新…"),
[System.Windows.Forms.ToolTipIcon]::Info
)
# No -Wait on UI thread (freezes ContextMenuStrip / tray).
$p = Start-NetxPowerShell -File $checkPs1 -Arguments @("-ProgramRoot", $prog, "-DataRoot", $data) `
-WorkingDirectory $prog -WindowStyle Normal -PassThru
Start-NetxTrayWatchProcess -Process $p -OnExit {
param($cp)
try {
if ($null -eq $cp) { return }
if (Test-NetxSetupCancelled -ExitCode $cp.ExitCode) { return }
if ($cp.ExitCode -eq 10) {
$ans = [System.Windows.Forms.MessageBox]::Show(
(T "A newer NetX is available. Download and apply now?" "发现新版本,是否立即下载并更新?"),
(T "NetX update" "NetX 更新"),
[System.Windows.Forms.MessageBoxButtons]::YesNo,
[System.Windows.Forms.MessageBoxIcon]::Question
)
if ($ans -ne [System.Windows.Forms.DialogResult]::Yes) { return }
$arg = "-NoProfile -ExecutionPolicy Bypass -File `"$checkPs1`" -ProgramRoot `"$prog`" -DataRoot `"$data`" -Apply"
$notify.ShowBalloonTip(
5000, "NetX",
(T "Downloading / applying update… keep the console open." "正在下载/应用更新…请勿关闭控制台窗口。"),
[System.Windows.Forms.ToolTipIcon]::Info
)
try {
$ap = Start-Process -FilePath "powershell.exe" -ArgumentList $arg `
-WorkingDirectory $prog -WindowStyle Normal -Verb RunAs -PassThru
} catch {
$notify.ShowBalloonTip(4000, "NetX", (T "Update cancelled (UAC)." "已取消更新(UAC)。"), [System.Windows.Forms.ToolTipIcon]::Info)
return
}
Start-NetxTrayWatchProcess -Process $ap -OnExit {
param($ap2)
if ($null -eq $ap2) { return }
if (Test-NetxSetupCancelled -ExitCode $ap2.ExitCode) {
$notify.ShowBalloonTip(4000, "NetX", (T "Update cancelled." "已取消更新。"), [System.Windows.Forms.ToolTipIcon]::Info)
return
}
if ($null -ne $ap2.ExitCode -and $ap2.ExitCode -eq 0) {
$newVer = Get-NetxVersion -ProgramRoot $prog
[System.Windows.Forms.MessageBox]::Show(
(T "Updated to $newVer.`nTray will restart." "已更新到 $newVer。`n即将重启托盘。"),
(T "NetX update" "NetX 更新"),
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Information
)
Restart-NetxTraySelf
return
}
[System.Windows.Forms.MessageBox]::Show(
(T "Update failed (exit $($ap2.ExitCode)). Re-run Check for updates, or install NetX-Setup manually." "更新失败(退出码 $($ap2.ExitCode))。请重试「检查更新」,或手动运行 Setup 安装包。"),
(T "NetX update" "NetX 更新"),
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Warning
)
Update-NetxTrayTip
}
} elseif ($cp.ExitCode -eq 0) {
[System.Windows.Forms.MessageBox]::Show(
(T "NetX is up to date ($script:ver)." "已是最新版本 ($script:ver)。"),
(T "NetX update" "NetX 更新")
)
}
} catch {
[System.Windows.Forms.MessageBox]::Show(
(T "Update check failed: $($_.Exception.Message)" "检查更新失败:$($_.Exception.Message)"),
"NetX"
)
}
}
} catch {
[System.Windows.Forms.MessageBox]::Show(
(T "Update check failed: $($_.Exception.Message)" "检查更新失败:$($_.Exception.Message)"),
"NetX"
)
}
}
})
[void]$menu.Items.Add($miUpdate)
[void]$menu.Items.Add((New-NetxMenuSeparator))
# --- exit ---
$miExit = New-NetxMenuItem -Text (T "Exit tray" "退出托盘")
$miExit.ToolTipText = (T "Leave NetX services running" "NetX 服务继续运行")
$miExit.add_Click({
Close-NetxTrayMenu
$notify.Visible = $false
$form.Close()
[System.Windows.Forms.Application]::Exit()
})
[void]$menu.Items.Add($miExit)
$miStopExit = New-NetxMenuItem -Text (T "Stop and exit" "停止并退出")
$miStopExit.add_Click({
Start-NetxTrayDeferred {
$notify.ShowBalloonTip(3000, "NetX", (T "Stopping…" "正在停止…"), [System.Windows.Forms.ToolTipIcon]::Info)
Invoke-NetxScript -File $stopPs1 -Wait | Out-Null
$notify.Visible = $false
$form.Close()
[System.Windows.Forms.Application]::Exit()
}
})
[void]$menu.Items.Add($miStopExit)
$notify.ContextMenuStrip = $menu
$notify.add_DoubleClick({
if (Test-NetxApiHealthy -HostName $hostBind -Port $port -TimeoutSec 2) {
Start-Process $uiUrl
} else {
Invoke-NetxScript -File $startPs1 -ExtraArgs @("-SkipBrowser") | Out-Null
Open-NetxUiWhenReady
}
})
# Refresh tooltip periodically (running / stopped).
$script:tipTimer = New-Object System.Windows.Forms.Timer
$script:tipTimer.Interval = 5000
$script:tipTimer.add_Tick({ Update-NetxTrayTip })
$script:tipTimer.Start()
$form.add_Shown({
if ($AutoUpdate) {
$notify.ShowBalloonTip(4000, "NetX", (T "Checking for updates…" "正在检查更新…"), [System.Windows.Forms.ToolTipIcon]::Info)
Start-NetxPowerShell -File $checkPs1 `
-Arguments @("-ProgramRoot", $prog, "-DataRoot", $data, "-Apply", "-Quiet", "-AutoOnly") `
-WorkingDirectory $prog -WindowStyle Hidden -Wait | Out-Null
} elseif ($CheckUpdateOnLaunch) {
Start-NetxPowerShell -File $checkPs1 `
-Arguments @("-ProgramRoot", $prog, "-DataRoot", $data, "-Quiet") `
-WorkingDirectory $prog -WindowStyle Hidden | Out-Null
}
if ($StartOnLaunch) {
Invoke-NetxScript -File $startPs1 -ExtraArgs @("-SkipBrowser") | Out-Null
$script:netxUiWaitTicks = 0
$script:netxUiWaitMax = 360 # 360 * 500ms = 180s
$script:netxUiTimer = New-Object System.Windows.Forms.Timer
$script:netxUiTimer.Interval = 500
$script:netxUiTimer.add_Tick({
$script:netxUiWaitTicks++
if (Test-NetxApiHealthy -HostName $hostBind -Port $port -TimeoutSec 2) {
$script:netxUiTimer.Stop()
$script:netxUiTimer.Dispose()
try { Start-Process $uiUrl } catch {}
Update-NetxTrayTip
return
}
if ($script:netxUiWaitTicks -eq 1) {
$notify.ShowBalloonTip(
5000,
"NetX",
(T "Starting… first run may take a minute." "正在启动…首次迁移可能需要一分钟。"),
[System.Windows.Forms.ToolTipIcon]::Info
)
}
if ($script:netxUiWaitTicks -ge $script:netxUiWaitMax) {
$script:netxUiTimer.Stop()
$script:netxUiTimer.Dispose()
$notify.ShowBalloonTip(
8000,
"NetX",
(T "UI not ready yet. Use tray → Open UI later, or check data\runtime\netx.err.log." "界面尚未就绪。请稍后用托盘「打开界面」,或查看 data\runtime\netx.err.log。"),
[System.Windows.Forms.ToolTipIcon]::Warning
)
Update-NetxTrayTip
}
})
$script:netxUiTimer.Start()
}
})
$form.add_FormClosing({
try { $script:tipTimer.Stop(); $script:tipTimer.Dispose() } catch {}
$notify.Visible = $false
$notify.Dispose()
})
[System.Windows.Forms.Application]::Run($form)

View file

@ -0,0 +1,129 @@
param(
[string]$Version = "",
[string]$ForgejoBase = "https://git.avelo.top",
[string]$Owner = "hansjone",
[string]$Repo = "netx",
[string]$Token = "",
[string]$ReleaseDir = ""
)
# Publish Windows Setup.exe to Forgejo/Gitea (mirror sync does NOT copy GitHub Release files).
# Requires a Forgejo token with repo write (Settings → Applications → Generate New Token).
# Default: https://git.avelo.top (public domain). LAN IP only when explicitly reachable.
#
# Example:
# $env:NETX_FORGEJO_TOKEN = "..." # or User env NETX_FORGEJO_TOKEN
# .\packaging\publish_forgejo_release.ps1 -Version 0.4.11
#
# Optional LAN (when 10.0.0.131 is reachable):
# .\packaging\publish_forgejo_release.ps1 -Version 0.4.11 -ForgejoBase "http://10.0.0.131:3000"
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
$repoRoot = Get-NetxRepoRoot
if (-not $Version) {
$Version = Get-NetxVersion -ProgramRoot $repoRoot
}
$tag = "v$Version"
$relDir = if ($ReleaseDir) { $ReleaseDir } else { Join-Path $PSScriptRoot "release" }
$setup = Join-Path $relDir "NetX-Setup-$Version.exe"
if (-not $Token) {
$Token = $env:NETX_FORGEJO_TOKEN
if (-not $Token) { $Token = $env:FORGEJO_TOKEN }
}
if (-not $Token) {
throw "forgejo_token_required: set NETX_FORGEJO_TOKEN or pass -Token"
}
if (-not (Test-Path $setup)) {
throw "missing_setup: $setup (run build_release.ps1 + ISCC first)"
}
$apiBase = "$ForgejoBase/api/v1/repos/$Owner/$Repo"
$headers = @{
"Authorization" = "token $Token"
"Accept" = "application/json"
}
function Invoke-ForgejoJson {
param(
[string]$Method,
[string]$Url,
[object]$Body = $null
)
$params = @{
Method = $Method
Uri = $Url
Headers = $headers
ContentType = "application/json"
}
if ($null -ne $Body) {
$params["Body"] = ($Body | ConvertTo-Json -Depth 5)
}
return Invoke-RestMethod @params
}
Write-Host "==> Forgejo publish $tag -> $ForgejoBase/$Owner/$Repo"
# Ensure git tag exists on Forgejo (mirror usually already has it).
try {
$null = Invoke-ForgejoJson -Method GET -Url "$apiBase/git/refs/tags/$tag"
Write-Host " tag $tag present on Forgejo"
} catch {
Write-Host "[WARN] tag $tag not found on Forgejo yet — run mirror-sync or push tag first" -ForegroundColor Yellow
}
$existing = $null
try {
$existing = Invoke-ForgejoJson -Method GET -Url "$apiBase/releases/tags/$tag"
} catch {
$existing = $null
}
$notes = @"
NetX $Version Windows installer (published from GitHub release build).
- NetX-Setup-$Version.exe
"@
if ($existing -and $existing.id) {
Write-Host "==> Release $tag already exists (id=$($existing.id)); deleting old release to re-upload assets"
Invoke-ForgejoJson -Method DELETE -Url "$apiBase/releases/$($existing.id)" | Out-Null
}
Write-Host "==> Creating release $tag"
$rel = Invoke-ForgejoJson -Method POST -Url "$apiBase/releases" -Body @{
tag_name = $tag
target = "main"
name = "NetX $Version"
body = $notes
draft = $false
prerelease = $false
}
$relId = $rel.id
if (-not $relId) { throw "forgejo_create_release_failed" }
function Upload-ForgejoAsset {
param([string]$Path)
if (-not (Test-Path $Path)) { return }
$name = Split-Path -Leaf $Path
Write-Host "==> Uploading $name ..."
$url = "$apiBase/releases/$relId/assets"
$curl = Get-Command curl.exe -ErrorAction SilentlyContinue
if (-not $curl) { throw "curl.exe required for asset upload" }
# --ssl-no-revoke: schannel CRYPT_E_REVOCATION_OFFLINE often fails via proxy/offline CA.
& $curl.Source -f -sS --ssl-no-revoke -X POST `
-H "Authorization: token $Token" `
-F "attachment=@$Path" `
$url
if ($LASTEXITCODE -ne 0) { throw "upload_failed: $name" }
Write-Host " OK $name" -ForegroundColor Green
}
Upload-ForgejoAsset -Path $setup
$releaseUrl = "$ForgejoBase/$Owner/$Repo/releases/tag/$tag"
Write-Host ""
Write-Host "==> Forgejo release ready: $releaseUrl" -ForegroundColor Green
Write-Host " Update API: $ForgejoBase/api/v1/repos/$Owner/$Repo/releases/latest"

View file

@ -1,9 +1,10 @@
param( param(
[string]$Version = "", [string]$Version = "",
[switch]$SkipBuild = $false, [switch]$SkipBuild = $false,
[switch]$SkipInstaller = $false, [switch]$SkipInstaller = $false,
[switch]$SkipGitHub = $false, [switch]$SkipGitHub = $false,
[switch]$Draft = $false [switch]$Draft = $false,
[switch]$Sign = $false
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@ -15,7 +16,6 @@ if (-not $Version) {
} }
$tag = "v$Version" $tag = "v$Version"
$releaseDir = Join-Path $PSScriptRoot "release" $releaseDir = Join-Path $PSScriptRoot "release"
$zip = Join-Path $releaseDir "NetX-$Version-win64.zip"
$setup = Join-Path $releaseDir "NetX-Setup-$Version.exe" $setup = Join-Path $releaseDir "NetX-Setup-$Version.exe"
Write-Host "==> NetX publish $tag" Write-Host "==> NetX publish $tag"
@ -25,29 +25,33 @@ if (-not $SkipBuild) {
-Version $Version -CreateVenv -Version $Version -CreateVenv
} }
if (-not (Test-Path $zip)) {
throw "missing_zip: $zip"
}
if (-not $SkipInstaller) { if (-not $SkipInstaller) {
$isccCmd = Get-Command ISCC.exe -ErrorAction SilentlyContinue $isccCmd = Get-Command ISCC.exe -ErrorAction SilentlyContinue
$isccCandidates = @( $isccCandidates = @(
$(if ($isccCmd) { $isccCmd.Source }), $(if ($isccCmd) { $isccCmd.Source }),
"$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe",
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
"$env:ProgramFiles\Inno Setup 6\ISCC.exe" "$env:ProgramFiles\Inno Setup 6\ISCC.exe"
) | Where-Object { $_ -and (Test-Path $_) } ) | Where-Object { $_ -and (Test-Path $_) }
$iscc = $isccCandidates | Select-Object -First 1 $iscc = $isccCandidates | Select-Object -First 1
if (-not $iscc) { if (-not $iscc) {
Write-Host "[WARN] Inno Setup (ISCC) not found. Install: winget install JRSoftware.InnoSetup" -ForegroundColor Yellow throw "innosetup_not_found: install with winget install JRSoftware.InnoSetup"
Write-Host " Skipping Setup.exe; zip-only release." }
} else {
Write-Host "==> Compiling installer: $iscc" Write-Host "==> Compiling installer: $iscc"
& $iscc "/DMyAppVersion=$Version" (Join-Path $PSScriptRoot "installer\netx.iss") & $iscc "/DMyAppVersion=$Version" (Join-Path $PSScriptRoot "installer\netx.iss")
if (-not (Test-Path $setup)) { if (-not (Test-Path $setup)) {
throw "installer_build_failed: expected $setup" throw "installer_build_failed: expected $setup"
} }
Write-Host "==> Setup.exe: $setup" -ForegroundColor Green Write-Host "==> Setup.exe: $setup" -ForegroundColor Green
} }
if (-not (Test-Path $setup)) {
throw "missing_setup: $setup"
}
if ($Sign) {
Write-Host "==> Signing release artifacts"
& powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "sign_release.ps1") -Files @($setup)
} }
if ($SkipGitHub) { if ($SkipGitHub) {
@ -60,30 +64,32 @@ if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
} }
Set-Location $repo Set-Location $repo
# gh writes "release not found" to stderr; keep Stop for the rest of the script.
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$existing = gh release view $tag 2>$null $existing = gh release view $tag 2>$null
if ($LASTEXITCODE -eq 0) { $viewCode = $LASTEXITCODE
$ErrorActionPreference = $prevEap
if ($viewCode -eq 0) {
Write-Host "==> Release $tag exists; uploading assets" Write-Host "==> Release $tag exists; uploading assets"
gh release upload $tag $zip --clobber
if (Test-Path $setup) {
gh release upload $tag $setup --clobber gh release upload $tag $setup --clobber
}
} else { } else {
$notes = @" $notes = @"
## NetX $Version — first Windows installable release ## NetX $Version
### Downloads ### Downloads
- **NetX-Setup-$Version.exe** — recommended installer (Program Files + ProgramData) - **NetX-Setup-$Version.exe** — Windows installer (Program Files + ProgramData)
- **NetX-$Version-win64.zip** — portable directory package
### Requirements ### Requirements
- Windows 10/11 x64 - Windows 10/11 x64 (or Windows Server 2016+)
- No separate Python or PostgreSQL install required (bundled in package) - No separate Python or PostgreSQL install required (bundled)
### Quick start (installer) ### Quick start
1. Run ``NetX-Setup-$Version.exe`` as administrator. 1. Run ``NetX-Setup-$Version.exe`` as administrator.
2. Complete first-time setup (choose **bundled** or **external** PostgreSQL). 2. **First install:** choose built-in or external PostgreSQL.
3. Start menu → **Start NetX** → browser opens ``http://127.0.0.1:8890/`` 3. **Already installed:** Setup detects existing data and updates in place (keeps DB settings).
4. Default login: ``admin`` / ``admin123`` (change after first login) 4. Start menu → **Start NetX** → ``http://127.0.0.1:8890/``
5. Default login: ``admin`` / ``admin123`` (change after first login)
### Linux ### Linux
Unchanged — use your own PostgreSQL and ``scripts/start_netx.sh``. Unchanged — use your own PostgreSQL and ``scripts/start_netx.sh``.
@ -92,8 +98,7 @@ See [packaging/README.md](https://github.com/hansjone/netx/blob/main/packaging/R
"@ "@
$args = @("release", "create", $tag, "--title", "NetX $Version", "--notes", $notes) $args = @("release", "create", $tag, "--title", "NetX $Version", "--notes", $notes)
if ($Draft) { $args += "--draft" } if ($Draft) { $args += "--draft" }
$args += $zip $args += $setup
if (Test-Path $setup) { $args += $setup }
& gh @args & gh @args
} }

25
packaging/repair_venv.ps1 Normal file
View file

@ -0,0 +1,25 @@
param(
[string]$ProgramRoot = "",
[string]$DataRoot = ""
)
# Relink shipped .venv to local python/runtime (run elevated after Setup/update).
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
$prog = Get-NetxProgramRoot -Override $ProgramRoot
$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot
Write-Host "==> Repairing .venv under $prog"
if (Repair-NetxShippedVenv -ProgramRoot $prog) {
$venvPy = Join-Path $prog ".venv\Scripts\python.exe"
if (Test-NetxVenvRunnable -VenvPython $venvPy) {
Write-Host "==> .venv OK -> bundled python/runtime" -ForegroundColor Green
Get-Content (Join-Path $prog ".venv\pyvenv.cfg")
exit 0
}
Write-Host "[ERR] pyvenv.cfg written but venv still not runnable" -ForegroundColor Red
exit 2
}
Write-Host "[ERR] repair failed (missing runtime/.venv or access denied)" -ForegroundColor Red
exit 1

100
packaging/service_run.ps1 Normal file
View file

@ -0,0 +1,100 @@
param(
[string]$ProgramRoot = "",
[string]$DataRoot = "",
[int]$HealthFailLimit = 6,
[int]$HealthIntervalSec = 10
)
# Long-running supervisor for Windows Service / Task Scheduler.
# Starts NetX, probes /health; repeated failures trigger restart (or exit for WinSW).
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
$prog = Get-NetxProgramRoot -Override $ProgramRoot
$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot
$logDir = Join-Path $data "data\runtime"
if (-not (Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
}
$logFile = Join-Path $logDir "service_run.log"
$stopFlag = Join-Path $logDir "service.stop"
function Write-SvcLog([string]$Msg) {
$line = "{0} {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Msg
Add-Content -Path $logFile -Value $line -Encoding utf8
Write-Host $line
}
function Get-BindInfo {
$envPath = Join-Path $data ".env"
$hostBind = "127.0.0.1"
$port = 8890
if (Test-Path $envPath) {
$map = Read-DotEnv -Path $envPath
if ($map["NETX_HOST"]) { $hostBind = $map["NETX_HOST"] }
if ($map["NETX_PORT"]) { try { $port = [int]$map["NETX_PORT"] } catch {} }
}
return @{ Host = $hostBind; Port = $port }
}
Remove-Item -Force $stopFlag -ErrorAction SilentlyContinue
Write-SvcLog "service_run starting program=$prog data=$data"
$startPs1 = Join-Path $PSScriptRoot "start_netx_app.ps1"
$stopPs1 = Join-Path $PSScriptRoot "stop_netx_app.ps1"
$bind = Get-BindInfo
$failStreak = 0
$restartCount = 0
function Start-NetxChildren {
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $startPs1 `
-ProgramRoot $prog -DataRoot $data -SkipBrowser -Force
if ($LASTEXITCODE -ne 0) {
throw "start_netx_app_failed exit=$LASTEXITCODE"
}
}
function Stop-NetxChildren {
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $stopPs1 `
-ProgramRoot $prog -DataRoot $data
}
try {
Start-NetxChildren
Write-SvcLog "NetX started; health watch host=$($bind.Host) port=$($bind.Port)"
while ($true) {
if (Test-Path $stopFlag) {
Write-SvcLog "stop flag detected"
break
}
if (Test-NetxApiHealthy -HostName $bind.Host -Port $bind.Port -TimeoutSec 3) {
$failStreak = 0
} else {
$failStreak++
Write-SvcLog "health fail streak=$failStreak/$HealthFailLimit"
if ($failStreak -ge $HealthFailLimit) {
$restartCount++
Write-SvcLog "restarting NetX (attempt=$restartCount)"
try { Stop-NetxChildren } catch { Write-SvcLog "stop warning: $($_.Exception.Message)" }
Start-Sleep -Seconds 3
Start-NetxChildren
$failStreak = 0
# Give API time to come up before counting failures again.
Start-Sleep -Seconds ([Math]::Max(15, $HealthIntervalSec))
continue
}
}
Start-Sleep -Seconds $HealthIntervalSec
}
} catch {
Write-SvcLog "ERROR: $($_.Exception.Message)"
throw
} finally {
Write-SvcLog "stopping NetX"
try { Stop-NetxChildren } catch { Write-SvcLog "stop warning: $($_.Exception.Message)" }
Remove-Item -Force $stopFlag -ErrorAction SilentlyContinue
Write-SvcLog "service_run exited"
}

View file

@ -1,12 +1,16 @@
param( param(
[ValidateSet("bundled", "external", "")] [ValidateSet("bundled", "external", "")]
[string]$DbMode = "", [string]$DbMode = "",
[string]$ProgramRoot = "", [string]$ProgramRoot = "",
[string]$DataRoot = "", [string]$DataRoot = "",
[string]$ExternalDatabaseUrl = "", [string]$ExternalDatabaseUrl = "",
[string]$ExternalDatabaseUrlFile = "",
[string]$CredentialSecretKey = "",
[string]$CredentialSecretKeyFile = "",
[string]$BundledPassword = "", [string]$BundledPassword = "",
[int]$BundledPort = 15432, [int]$BundledPort = 15432,
[switch]$NonInteractive = $false [switch]$NonInteractive = $false,
[switch]$SkipExternalProbe = $false
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@ -15,20 +19,19 @@ $ErrorActionPreference = "Stop"
$prog = Get-NetxProgramRoot -Override $ProgramRoot $prog = Get-NetxProgramRoot -Override $ProgramRoot
$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot $data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot
$envPath = Join-Path $data ".env" $envPath = Join-Path $data ".env"
$zh = ([cultureinfo]::CurrentUICulture.Name -match '^(zh|zh-)')
Write-Host "==> Program root: $prog" function T([string]$En, [string]$Zh) {
Write-Host "==> Data root: $data" if ($zh) { return $Zh } else { return $En }
Write-Host "==> Env file: $envPath" }
if (-not (Test-Path $data)) { try {
New-Item -ItemType Directory -Path $data -Force | Out-Null
} Write-Host ("==> " + (T "Program root:" "程序目录:") + " $prog")
foreach ($sub in @("data", "data\auth", "data\runtime", "backups", "pgdata")) { Write-Host ("==> " + (T "Data root:" "数据目录:") + " $data")
$p = Join-Path $data $sub Write-Host ("==> " + (T "Env file:" "环境文件:") + " $envPath")
if (-not (Test-Path $p)) {
New-Item -ItemType Directory -Path $p -Force | Out-Null Ensure-NetxDataDirectories -DataRoot $data
}
}
$existing = Read-DotEnv -Path $envPath $existing = Read-DotEnv -Path $envPath
if (-not $DbMode) { if (-not $DbMode) {
@ -42,10 +45,11 @@ if (-not $DbMode) {
} }
} else { } else {
Write-Host "" Write-Host ""
Write-Host "Choose database mode:" Write-Host (T "Choose database mode:" "选择数据库模式:") -ForegroundColor Cyan
Write-Host " 1) bundled — use NetX portable PostgreSQL (default for new installs)" Write-Host (T " 1) bundled — NetX portable PostgreSQL (offline / default)" " 1) bundled — NetX 内置便携 PostgreSQL(可离线,默认)")
Write-Host " 2) external — connect to an existing PostgreSQL (Linux / already deployed)" Write-Host (T " 2) external — existing PostgreSQL (you provide connection URL)" " 2) external — 已有外置 PostgreSQL(需填写连接串)")
$choice = Read-Host "Enter 1 or 2" Write-Host ""
$choice = Read-Host (T "Enter 1 or 2" "请输入 1 或 2")
if ($choice -eq "2") { $DbMode = "external" } else { $DbMode = "bundled" } if ($choice -eq "2") { $DbMode = "external" } else { $DbMode = "bundled" }
} }
} }
@ -58,28 +62,44 @@ $values = @{}
$values["NETX_DB_MODE"] = $DbMode $values["NETX_DB_MODE"] = $DbMode
$values["NETX_HOST"] = "127.0.0.1" $values["NETX_HOST"] = "127.0.0.1"
$values["NETX_PORT"] = "8890" $values["NETX_PORT"] = "8890"
$values["NETX_UI_DIST_DIR"] = "web/dist" # Absolute UI path when present; else relative for source trees.
$distAbs = Join-Path $prog "web\dist"
if (Test-Path (Join-Path $distAbs "index.html")) {
$values["NETX_UI_DIST_DIR"] = (ConvertTo-NetxFsPath $distAbs)
} else {
$values["NETX_UI_DIST_DIR"] = "web/dist"
}
# Point runtime data dirs into the data root (absolute). # All runtime data under data root (never under Program Files).
$values["NETX_AUTH_MCP_TOKEN_FILE"] = ((Join-Path $data "data\auth\mcp_token") -replace '\\', '/') $dataEnv = Get-NetxDataEnvMap -DataRoot $data
$values["NETX_SCHEDULER_HEARTBEAT_PATH"] = ((Join-Path $data "data\runtime\scheduler_heartbeat.json") -replace '\\', '/') foreach ($k in $dataEnv.Keys) { $values[$k] = $dataEnv[$k] }
# Preload credential key from file/CLI only; interactive prompt comes AFTER DB settings
# so users are not left thinking the key alone finished setup.
if ($CredentialSecretKeyFile) {
if (-not (Test-Path -LiteralPath $CredentialSecretKeyFile)) {
throw "credential_secret_key_file_missing: $CredentialSecretKeyFile"
}
$CredentialSecretKey = [IO.File]::ReadAllText($CredentialSecretKeyFile).Trim()
}
if ($DbMode -eq "bundled") { if ($DbMode -eq "bundled") {
$pgsql = Join-Path $prog "postgres\pgsql" $pgsql = Join-Path $prog "postgres\pgsql"
if (-not (Test-Path (Join-Path $pgsql "bin\initdb.exe"))) { if (-not (Test-Path (Join-Path $pgsql "bin\initdb.exe"))) {
# In-repo layout
$alt = Join-Path $PSScriptRoot "postgres\pgsql" $alt = Join-Path $PSScriptRoot "postgres\pgsql"
if (Test-Path (Join-Path $alt "bin\initdb.exe")) { if (Test-Path (Join-Path $alt "bin\initdb.exe")) {
$pgsql = $alt $pgsql = $alt
} else { } else {
throw "bundled_postgres_missing: run packaging\download_postgres.ps1 first (expected $pgsql)" throw (T `
"bundled_postgres_missing: incomplete package (expected $pgsql). Offline Setup must include postgres; rebuild with build_release.ps1 on a machine that already ran download_postgres.ps1." `
"缺少内置 PostgreSQL(期望路径 $pgsql)。离线安装包必须自带数据库;请在已运行过 download_postgres.ps1 的机器上重新 build_release。")
} }
} }
if (-not $BundledPassword) { if (-not $BundledPassword) {
if ($NonInteractive) { if ($NonInteractive) {
$BundledPassword = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char]$_ }) $BundledPassword = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char]$_ })
} else { } else {
$sec = Read-Host -Prompt "Password for bundled role 'netx' (empty = auto-generate)" -AsSecureString $sec = Read-Host -Prompt (T "Password for bundled role 'netx' (empty = auto-generate)" "内置数据库用户 netx 的密码(回车=自动生成)") -AsSecureString
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec) $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)
try { try {
$BundledPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) $BundledPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
@ -88,24 +108,22 @@ if ($DbMode -eq "bundled") {
} }
if (-not $BundledPassword) { if (-not $BundledPassword) {
$BundledPassword = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char]$_ }) $BundledPassword = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char]$_ })
Write-Host "Generated password (saved in .env only)." Write-Host (T "Generated password (saved in .env only)." "已自动生成密码(仅保存在 .env)。")
} }
} }
} }
$pgData = Join-Path $data "pgdata" $pgData = Join-Path $data "pgdata"
$values["NETX_BUNDLED_PG_PORT"] = "$BundledPort" $values["NETX_BUNDLED_PG_PORT"] = "$BundledPort"
$values["NETX_BUNDLED_PG_DATA_DIR"] = ($pgData -replace '\\', '/') $values["NETX_BUNDLED_PG_DATA_DIR"] = (ConvertTo-NetxFsPath $pgData)
$pwFile = Join-Path $data "pg_netx.pw" $pwFile = Join-Path $data "pg_netx.pw"
Set-Content -Path $pwFile -Value $BundledPassword -Encoding ascii -NoNewline Set-Content -Path $pwFile -Value $BundledPassword -Encoding ascii -NoNewline
$encPw = [uri]::EscapeDataString($BundledPassword) $encPw = [uri]::EscapeDataString($BundledPassword)
$values["NETX_DATABASE_URL"] = "postgresql+psycopg://netx:${encPw}@127.0.0.1:${BundledPort}/netx" $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" $initdb = Join-Path $pgsql "bin\initdb.exe"
$pgCtl = Join-Path $pgsql "bin\pg_ctl.exe" $pgCtl = Join-Path $pgsql "bin\pg_ctl.exe"
$psql = Join-Path $pgsql "bin\psql.exe" $psql = Join-Path $pgsql "bin\psql.exe"
$createdb = Join-Path $pgsql "bin\createdb.exe" $sqlPw = ConvertTo-NetxSqlLiteral $BundledPassword
$createuser = Join-Path $pgsql "bin\createuser.exe"
if (-not (Test-Path (Join-Path $pgData "PG_VERSION"))) { if (-not (Test-Path (Join-Path $pgData "PG_VERSION"))) {
Write-Host "==> initdb $pgData" Write-Host "==> initdb $pgData"
@ -115,7 +133,6 @@ if ($DbMode -eq "bundled") {
if ($LASTEXITCODE -ne 0) { throw "initdb_failed" } if ($LASTEXITCODE -ne 0) { throw "initdb_failed" }
} }
# Ensure listen / port in postgresql.conf
$conf = Join-Path $pgData "postgresql.conf" $conf = Join-Path $pgData "postgresql.conf"
$hba = Join-Path $pgData "pg_hba.conf" $hba = Join-Path $pgData "pg_hba.conf"
if (Test-Path $conf) { if (Test-Path $conf) {
@ -126,6 +143,8 @@ if ($DbMode -eq "bundled") {
$confText = $confText -replace '(?m)^\s*port\s*=\s*\d+', "port = $BundledPort" $confText = $confText -replace '(?m)^\s*port\s*=\s*\d+', "port = $BundledPort"
if ($confText -notmatch "(?m)^\s*listen_addresses\s*=") { if ($confText -notmatch "(?m)^\s*listen_addresses\s*=") {
$confText += "`nlisten_addresses = '127.0.0.1'`n" $confText += "`nlisten_addresses = '127.0.0.1'`n"
} else {
$confText = $confText -replace "(?m)^\s*listen_addresses\s*=\s*'[^']*'", "listen_addresses = '127.0.0.1'"
} }
Set-Content -Path $conf -Value $confText -Encoding utf8 Set-Content -Path $conf -Value $confText -Encoding utf8
} }
@ -137,49 +156,216 @@ if ($DbMode -eq "bundled") {
} }
} }
$status = & $pgCtl -D $pgData status 2>&1 | Out-String
if ($status -notmatch "server is running") {
if (-not (Test-NetxTcpPortFree -HostName "127.0.0.1" -Port $BundledPort)) {
throw "port_in_use: 127.0.0.1:$BundledPort already occupied (bundled Postgres)"
}
Write-Host "==> Starting bundled PostgreSQL for bootstrap" Write-Host "==> Starting bundled PostgreSQL for bootstrap"
& $pgCtl -D $pgData -l (Join-Path $data "pgdata\pg.log") start & $pgCtl -D $pgData -l (Join-Path $pgData "pg.log") start
if ($LASTEXITCODE -ne 0) { throw "pg_start_failed" }
Start-Sleep -Seconds 2 Start-Sleep -Seconds 2
}
$env:PGPASSWORD = $BundledPassword $env:PGPASSWORD = $BundledPassword
try { try {
$role = & $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname='netx'" function Invoke-NetxPsql {
if ($role -notmatch "1") { param([string]$Sql, [string]$Database = "postgres")
& $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -v ON_ERROR_STOP=1 ` $out = & $psql -h 127.0.0.1 -p $BundledPort -U postgres -d $Database -v ON_ERROR_STOP=1 -tAc $Sql 2>&1
-c "CREATE ROLE netx LOGIN PASSWORD '$BundledPassword';" if ($LASTEXITCODE -ne 0) {
throw "psql_failed ($LASTEXITCODE): $Sql`n$out"
}
return (($out | Out-String).Trim())
}
$role = Invoke-NetxPsql -Sql "SELECT 1 FROM pg_roles WHERE rolname='netx'"
if ($role -eq "1") {
Invoke-NetxPsql -Sql "ALTER ROLE netx WITH LOGIN PASSWORD '$sqlPw'" | Out-Null
} else { } else {
& $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -v ON_ERROR_STOP=1 ` Invoke-NetxPsql -Sql "CREATE ROLE netx LOGIN PASSWORD '$sqlPw'" | Out-Null
-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'" $db = Invoke-NetxPsql -Sql "SELECT 1 FROM pg_database WHERE datname='netx'"
if ($db -notmatch "1") { if ($db -ne "1") {
& $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -v ON_ERROR_STOP=1 ` Invoke-NetxPsql -Sql "CREATE DATABASE netx OWNER netx" | Out-Null
-c "CREATE DATABASE netx OWNER netx;" }
Invoke-NetxPsql -Sql "GRANT ALL PRIVILEGES ON DATABASE netx TO netx" | Out-Null
# Verify login as app role (catches auth/hba mismatches early).
$prev = $env:PGPASSWORD
$env:PGPASSWORD = $BundledPassword
try {
$ping = & $psql -h 127.0.0.1 -p $BundledPort -U netx -d netx -v ON_ERROR_STOP=1 -tAc "SELECT 1" 2>&1
if ($LASTEXITCODE -ne 0 -or (($ping | Out-String).Trim()) -ne "1") {
throw "netx_role_login_failed: $ping"
}
} finally {
$env:PGPASSWORD = $prev
} }
& $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 { } finally {
Remove-Item Env:PGPASSWORD -ErrorAction SilentlyContinue Remove-Item Env:PGPASSWORD -ErrorAction SilentlyContinue
} }
Write-Host "==> Bundled Postgres ready on 127.0.0.1:$BundledPort" -ForegroundColor Green Write-Host "==> Bundled Postgres ready on 127.0.0.1:$BundledPort" -ForegroundColor Green
} else { } else {
if ($ExternalDatabaseUrlFile) {
if (-not (Test-Path -LiteralPath $ExternalDatabaseUrlFile)) {
throw "external_url_file_missing: $ExternalDatabaseUrlFile"
}
$ExternalDatabaseUrl = [IO.File]::ReadAllText($ExternalDatabaseUrlFile).Trim()
}
if (-not $ExternalDatabaseUrl) { if (-not $ExternalDatabaseUrl) {
if ($NonInteractive) {
if ($existing.ContainsKey("NETX_DATABASE_URL") -and $existing["NETX_DATABASE_URL"]) { if ($existing.ContainsKey("NETX_DATABASE_URL") -and $existing["NETX_DATABASE_URL"]) {
$ExternalDatabaseUrl = $existing["NETX_DATABASE_URL"] $ExternalDatabaseUrl = $existing["NETX_DATABASE_URL"]
} elseif ($NonInteractive) {
throw "external_url_required"
} else { } else {
$ExternalDatabaseUrl = Read-Host "NETX_DATABASE_URL (postgresql+psycopg://user:pass@host:5432/netx)" throw "external_url_required"
}
} else {
# Interactive: always ask. Empty = keep existing URL (never silent).
Write-Host ""
Write-Host (T `
"Enter PostgreSQL URL (database/role must already exist):" `
"请输入 PostgreSQL 连接串(库和用户需事先建好):") -ForegroundColor Cyan
Write-Host " postgresql+psycopg://USER:PASSWORD@HOST:5432/DBNAME"
Write-Host (T `
"Example: postgresql+psycopg://netx:secret@10.0.0.8:5432/netx" `
"示例: postgresql+psycopg://netx:secret@10.0.0.8:5432/netx")
if ($existing.ContainsKey("NETX_DATABASE_URL") -and $existing["NETX_DATABASE_URL"]) {
Write-Host (T `
"Current: $($existing['NETX_DATABASE_URL'])" `
"当前: $($existing['NETX_DATABASE_URL'])") -ForegroundColor DarkGray
Write-Host (T `
"Press Enter to keep the current URL, or paste a new one." `
"直接回车 = 保留当前连接串;或粘贴新的连接串。")
}
$entered = (Read-Host "NETX_DATABASE_URL").Trim()
if ($entered) {
$ExternalDatabaseUrl = $entered
} elseif ($existing.ContainsKey("NETX_DATABASE_URL") -and $existing["NETX_DATABASE_URL"]) {
$ExternalDatabaseUrl = $existing["NETX_DATABASE_URL"]
Write-Host (T "==> Keeping existing NETX_DATABASE_URL" "==> 保留已有 NETX_DATABASE_URL") -ForegroundColor Green
}
} }
} }
if (-not $ExternalDatabaseUrl) { if (-not $ExternalDatabaseUrl) {
throw "external_url_required" throw "external_url_required"
} }
$ExternalDatabaseUrl = $ExternalDatabaseUrl.Trim()
$values["NETX_DATABASE_URL"] = $ExternalDatabaseUrl $values["NETX_DATABASE_URL"] = $ExternalDatabaseUrl
Write-Host "==> External Postgres configured (ensure role/db exist; see scripts\init_pg.ps1)" -ForegroundColor Green Write-Host "==> External Postgres configured" -ForegroundColor Green
if (-not $SkipExternalProbe) {
# Probe with bundled psql when available (wizard already tested; this covers CLI reconfigure).
$psqlProbe = Join-Path $prog "postgres\pgsql\bin\psql.exe"
$testHelper = Join-Path $PSScriptRoot "installer\test_pg_conn.ps1"
if (-not (Test-Path $testHelper)) {
$testHelper = Join-Path $PSScriptRoot "test_pg_conn.ps1"
}
if ((Test-Path $psqlProbe) -and ($ExternalDatabaseUrl -match '^(?:postgresql(?:\+psycopg)?|postgres)://([^:]+):([^@]*)@([^:/]+):?(\d+)?/([^?\s]+)')) {
$u = [uri]::UnescapeDataString($Matches[1])
$pw = [uri]::UnescapeDataString($Matches[2])
$h = $Matches[3]
$po = if ($Matches[4]) { [int]$Matches[4] } else { 5432 }
$dbn = [uri]::UnescapeDataString($Matches[5])
Write-Host "==> Probing external PostgreSQL ${h}:${po}/${dbn} ..."
if (Test-Path $testHelper) {
$probeErr = Join-Path $env:TEMP ("netx-pg-probe-" + [Guid]::NewGuid().ToString("n") + ".txt")
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $testHelper `
-PgHost $h -Port $po -User $u -Password $pw -Database $dbn `
-PsqlPath $psqlProbe -OutErrFile $probeErr
if ($LASTEXITCODE -ne 0) {
$detail = if (Test-Path $probeErr) { (Get-Content -LiteralPath $probeErr -Raw) } else { "exit $LASTEXITCODE" }
Remove-Item -LiteralPath $probeErr -Force -ErrorAction SilentlyContinue
throw (T "external_db_probe_failed: $detail" "外置数据库连接失败: $detail")
}
Remove-Item -LiteralPath $probeErr -Force -ErrorAction SilentlyContinue
Write-Host "==> External PostgreSQL OK" -ForegroundColor Green
} else {
$env:PGPASSWORD = $pw
try {
$ping = & $psqlProbe -h $h -p $po -U $u -d $dbn -t -A -c "SELECT 1" 2>&1
if ($LASTEXITCODE -ne 0 -or (($ping | Out-String).Trim()) -notmatch '1') {
throw (T "external_db_probe_failed: $ping" "外置数据库连接失败: $ping")
}
} finally {
Remove-Item Env:PGPASSWORD -ErrorAction SilentlyContinue
}
}
} else {
Write-Host (T `
"[WARN] Skipped connection probe (no bundled psql or URL parse failed)." `
"[WARN] 已跳过连接探测(缺少捆绑 psql 或连接串无法解析)。") -ForegroundColor Yellow
}
}
}
# Fernet key AFTER database prompts (interactive UX).
# Priority: CLI/file > interactive prompt > existing .env > generate.
if (-not $CredentialSecretKey -and -not $NonInteractive) {
Write-Host ""
Write-Host (T `
"Optional: paste NETX_CREDENTIAL_SECRET_KEY from an existing install" `
"可选: 粘贴已有安装的 NETX_CREDENTIAL_SECRET_KEY(便于沿用已加密凭据)") -ForegroundColor Cyan
Write-Host (T `
"Leave empty to keep existing .env key, or auto-generate if none." `
"留空则保留已有 .env 中的密钥;若没有则自动生成。")
$CredentialSecretKey = (Read-Host "NETX_CREDENTIAL_SECRET_KEY").Trim()
}
if ($CredentialSecretKey) {
$values["NETX_CREDENTIAL_SECRET_KEY"] = $CredentialSecretKey.Trim()
Write-Host "==> Using provided NETX_CREDENTIAL_SECRET_KEY" -ForegroundColor Green
} elseif ($existing.ContainsKey("NETX_CREDENTIAL_SECRET_KEY") -and $existing["NETX_CREDENTIAL_SECRET_KEY"]) {
$values["NETX_CREDENTIAL_SECRET_KEY"] = $existing["NETX_CREDENTIAL_SECRET_KEY"]
Write-Host "==> Keeping existing NETX_CREDENTIAL_SECRET_KEY from .env" -ForegroundColor Green
} else {
$values["NETX_CREDENTIAL_SECRET_KEY"] = New-NetxFernetKey
Write-Host "==> Generated NETX_CREDENTIAL_SECRET_KEY (saved in .env)" -ForegroundColor Green
} }
Write-DotEnvValue -Path $envPath -Values $values Write-DotEnvValue -Path $envPath -Values $values
Write-Host "" Write-Host ""
Write-Host "Wrote $envPath" -ForegroundColor Green Write-Host "Wrote $envPath" -ForegroundColor Green
Write-Host "Next: .\packaging\start_netx_app.ps1"
# Official Setup ships python/runtime + .venv (build_release -CreateVenv).
$venvPy = Join-Path $prog ".venv\Scripts\python.exe"
try {
$null = Ensure-NetxVenv -ProgramRoot $prog
Write-Host "==> Bundled Python venv OK: $venvPy" -ForegroundColor Green
} catch {
if (Test-Path $venvPy) {
throw
}
Write-Host "==> Bundled .venv missing — creating one (Setup should normally ship it)." -ForegroundColor Yellow
Write-Host "[WARN] $($_.Exception.Message)" -ForegroundColor Yellow
Write-Host " Install a Setup built with: packaging\build_release.ps1 -CreateVenv" -ForegroundColor Yellow
}
Write-Host ""
Write-Host (T "Setup complete." "首次配置完成。") -ForegroundColor Green
if (-not $NonInteractive) {
Write-Host (T `
"Next: Start Menu → NetX Tray (or press Y below)." `
"下一步: 开始菜单 → NetX 托盘 (或在下方按 Y 立即启动)。")
$ans = Read-Host (T "Start NetX tray now? [Y/n]" "现在启动 NetX 托盘?[Y/n]")
if ($ans -notmatch '^[Nn]') {
try {
Start-NetxPowerShell -File (Join-Path $PSScriptRoot "netx_tray.ps1") `
-Arguments @("-ProgramRoot", $prog, "-DataRoot", $data, "-StartOnLaunch") `
-WorkingDirectory $prog -WindowStyle Hidden | Out-Null
Write-Host (T "Tray started." "托盘已启动。") -ForegroundColor Green
} catch {
Write-Host "[WARN] $($_.Exception.Message)" -ForegroundColor Yellow
}
}
} else {
Write-Host "Next: .\packaging\start_netx_app.ps1 or NetX-Tray.cmd"
}
} catch {
Write-Host ""
Write-Host ("[ERR] " + $_.Exception.Message) -ForegroundColor Red
if (-not $NonInteractive) {
try {
[void](Read-Host (T "Press Enter to close" "按回车关闭窗口"))
} catch {}
}
exit 1
}

View file

@ -0,0 +1,78 @@
param(
[Parameter(Mandatory = $true)]
[string[]]$Files,
[string]$Thumbprint = "",
[string]$PfxPath = "",
[string]$PfxPassword = "",
[string]$TimestampUrl = "http://timestamp.digicert.com",
[string]$Description = "NetX"
)
# Sign release artifacts with signtool (Authenticode).
# Requires Windows SDK / signtool.exe and a code-signing certificate.
#
# Examples:
# .\sign_release.ps1 -Files .\packaging\release\NetX-Setup-0.4.0.exe -Thumbprint ABCDEF...
# .\sign_release.ps1 -Files .\packaging\release\*.exe -PfxPath .\certs\netx.pfx -PfxPassword ***
$ErrorActionPreference = "Stop"
function Find-SignTool {
$cmd = Get-Command signtool.exe -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
$roots = @(
"${env:ProgramFiles(x86)}\Windows Kits\10\bin",
"${env:ProgramFiles}\Windows Kits\10\bin"
)
foreach ($root in $roots) {
if (-not (Test-Path $root)) { continue }
$hit = Get-ChildItem -Path $root -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } |
Select-Object -First 1
if ($hit) { return $hit.FullName }
}
return $null
}
$signtool = Find-SignTool
if (-not $signtool) {
throw "signtool_not_found: install Windows SDK or add signtool.exe to PATH"
}
if (-not $Thumbprint -and -not $PfxPath) {
if ($env:NETX_SIGN_THUMBPRINT) { $Thumbprint = $env:NETX_SIGN_THUMBPRINT }
if ($env:NETX_SIGN_PFX) { $PfxPath = $env:NETX_SIGN_PFX }
if ($env:NETX_SIGN_PFX_PASSWORD) { $PfxPassword = $env:NETX_SIGN_PFX_PASSWORD }
}
if (-not $Thumbprint -and -not $PfxPath) {
throw "provide -Thumbprint or -PfxPath (or NETX_SIGN_THUMBPRINT / NETX_SIGN_PFX)"
}
$resolved = @()
foreach ($pattern in $Files) {
$resolved += @(Resolve-Path -Path $pattern -ErrorAction Stop)
}
if ($resolved.Count -eq 0) { throw "no_files_to_sign" }
foreach ($f in $resolved) {
$path = $f.Path
Write-Host "==> Signing $path"
$args = @(
"sign", "/fd", "SHA256", "/td", "SHA256", "/tr", $TimestampUrl,
"/d", $Description
)
if ($PfxPath) {
$args += @("/f", $PfxPath)
if ($PfxPassword) { $args += @("/p", $PfxPassword) }
} else {
$args += @("/sha1", $Thumbprint)
}
$args += $path
& $signtool @args
if ($LASTEXITCODE -ne 0) { throw "sign_failed: $path" }
& $signtool verify /pa $path
if ($LASTEXITCODE -ne 0) { throw "verify_failed: $path" }
Write-Host " OK" -ForegroundColor Green
}
Write-Host "==> Done. Without a trusted CA certificate, Windows SmartScreen may still warn."

View file

@ -1,8 +1,9 @@
param( param(
[string]$ProgramRoot = "", [string]$ProgramRoot = "",
[string]$DataRoot = "", [string]$DataRoot = "",
[switch]$SkipBrowser = $false, [switch]$SkipBrowser = $false,
[switch]$InlineSchedulers = $false [switch]$InlineSchedulers = $false,
[switch]$Force = $false
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@ -21,25 +22,39 @@ if (-not (Test-Path (Join-Path $prog "netx_api"))) {
throw "netx_api not found under program root: $prog" throw "netx_api not found under program root: $prog"
} }
Ensure-NetxDataDirectories -DataRoot $data
$map = Import-NetxEnvFile -Path $envPath $map = Import-NetxEnvFile -Path $envPath
# Force data-root paths even if an older .env missed them.
$dataEnv = Get-NetxDataEnvMap -DataRoot $data
foreach ($k in $dataEnv.Keys) {
Set-Item -Path "Env:$k" -Value $dataEnv[$k]
}
Set-Location $prog Set-Location $prog
$env:PYTHONPATH = $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" $dist = Join-Path $prog "web\dist"
if (Test-Path (Join-Path $dist "index.html")) { if (Test-Path (Join-Path $dist "index.html")) {
$env:NETX_UI_DIST_DIR = $dist $env:NETX_UI_DIST_DIR = $dist
} }
$mode = Get-DbMode -EnvMap $map $mode = Get-DbMode -EnvMap $map
$hostBind = if ($env:NETX_HOST) { $env:NETX_HOST } else { "127.0.0.1" }
$port = if ($env:NETX_PORT) { [int]$env:NETX_PORT } else { 8890 }
Write-Host "==> Program: $prog" Write-Host "==> Program: $prog"
Write-Host "==> Data: $data" Write-Host "==> Data: $data"
Write-Host "==> DB mode: $mode" Write-Host "==> DB mode: $mode"
if (-not $Force -and (Test-NetxApiHealthy -HostName $hostBind -Port $port)) {
Write-Host "==> NetX already healthy at http://${hostBind}:${port}/ — skip start (use -Force to restart)" -ForegroundColor Green
if (-not $SkipBrowser) {
try { Start-Process "http://${hostBind}:${port}/" } catch {}
}
exit 0
}
function Get-PgsqlBin { function Get-PgsqlBin {
foreach ($b in @( foreach ($b in @(
(Join-Path $prog "postgres\pgsql\bin"), (Join-Path $prog "postgres\pgsql\bin"),
@ -54,13 +69,20 @@ if ($mode -eq "bundled") {
$pgBin = Get-PgsqlBin $pgBin = Get-PgsqlBin
if (-not $pgBin) { throw "bundled_postgres_missing" } if (-not $pgBin) { throw "bundled_postgres_missing" }
$pgData = if ($map["NETX_BUNDLED_PG_DATA_DIR"]) { $pgData = if ($map["NETX_BUNDLED_PG_DATA_DIR"]) {
$map["NETX_BUNDLED_PG_DATA_DIR"] $map["NETX_BUNDLED_PG_DATA_DIR"] -replace '/', '\'
} else { } else {
Join-Path $data "pgdata" Join-Path $data "pgdata"
} }
$pgPort = 15432
if ($map["NETX_BUNDLED_PG_PORT"]) {
try { $pgPort = [int]$map["NETX_BUNDLED_PG_PORT"] } catch {}
}
$pgCtl = Join-Path $pgBin "pg_ctl.exe" $pgCtl = Join-Path $pgBin "pg_ctl.exe"
$status = & $pgCtl -D $pgData status 2>&1 | Out-String $status = & $pgCtl -D $pgData status 2>&1 | Out-String
if ($status -notmatch "server is running") { if ($status -notmatch "server is running") {
if (-not (Test-NetxTcpPortFree -HostName "127.0.0.1" -Port $pgPort)) {
throw "port_in_use: 127.0.0.1:$pgPort (bundled Postgres)"
}
Write-Host "==> Starting bundled PostgreSQL" Write-Host "==> Starting bundled PostgreSQL"
if (-not (Test-Path $pgData)) { if (-not (Test-Path $pgData)) {
New-Item -ItemType Directory -Path $pgData -Force | Out-Null New-Item -ItemType Directory -Path $pgData -Force | Out-Null
@ -74,38 +96,48 @@ if ($mode -eq "bundled") {
} }
} }
# Ensure venv exists for start_netx.ps1 try {
$venvPy = Join-Path $prog ".venv\Scripts\python.exe" $null = Ensure-NetxVenv -ProgramRoot $prog
if (-not (Test-Path $venvPy)) { } catch {
Write-Host "==> Creating .venv (one-time)" Write-Host "[ERR] $($_.Exception.Message)" -ForegroundColor Red
$pyCmd = Get-Command python -ErrorAction SilentlyContinue Write-Host " Tip: run Start Menu → NetX → First-time setup once as Administrator," -ForegroundColor Yellow
if (-not $pyCmd) { throw "python_not_found: install Python 3.11+ and re-run" } Write-Host " or install a Setup built with packaging\\build_release.ps1 -CreateVenv." -ForegroundColor Yellow
& $pyCmd.Source -m venv (Join-Path $prog ".venv") exit 1
& $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" } if (-not (Test-NetxTcpPortFree -HostName $hostBind -Port $port)) {
$port = if ($env:NETX_PORT) { [int]$env:NETX_PORT } else { 8890 } if (Test-NetxApiHealthy -HostName $hostBind -Port $port) {
Write-Host "==> Port $port already serves NetX — skip start" -ForegroundColor Green
exit 0
}
throw "port_in_use: ${hostBind}:${port} occupied by non-NetX process"
}
$startScript = Join-Path $prog "scripts\start_netx.ps1" $startScript = Join-Path $prog "scripts\start_netx.ps1"
if (-not (Test-Path $startScript)) { if (-not (Test-Path $startScript)) {
throw "start_netx.ps1 not found at $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)" Write-Host "==> Starting NetX (API + workers; UI via API on :$port)"
& powershell @psArgs # Run in-process with -Background so this console exits after /health (nested
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # Start-Process -Wait on a Hidden child often never returns under UAC update).
$startArgs = @{
SkipInstall = $true
Background = $true
BindHost = $hostBind
Port = $port
}
if ($InlineSchedulers) { $startArgs["InlineSchedulers"] = $true }
& $startScript @startArgs
if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$url = "http://${hostBind}:${port}/" $url = "http://${hostBind}:${port}/"
if (-not (Test-NetxApiHealthy -HostName $hostBind -Port $port -TimeoutSec 2)) {
if (-not (Wait-NetxApiHealthy -HostName $hostBind -Port $port -TimeoutSec 30)) {
Write-Host "[ERR] NetX started but /health not ready: $url" -ForegroundColor Red
exit 1
}
}
Write-Host "==> Open: $url" -ForegroundColor Green Write-Host "==> Open: $url" -ForegroundColor Green
if (-not $SkipBrowser) { if (-not $SkipBrowser) {
try { Start-Process $url } catch {} try { Start-Process $url } catch {}

View file

@ -1,7 +1,8 @@
param( param(
[string]$ProgramRoot = "", [string]$ProgramRoot = "",
[string]$DataRoot = "", [string]$DataRoot = "",
[switch]$KeepPostgres = $false [switch]$KeepPostgres = $false,
[switch]$KillTray = $false
) )
$ErrorActionPreference = "Continue" $ErrorActionPreference = "Continue"
@ -21,11 +22,17 @@ if (-not (Test-Path $stopScript)) {
} }
if (Test-Path $stopScript) { if (Test-Path $stopScript) {
Write-Host "==> Stopping NetX processes" Write-Host "==> Stopping NetX processes"
& powershell -ExecutionPolicy Bypass -File $stopScript -Force Start-NetxPowerShell -File $stopScript -Arguments @("-Force") `
-WorkingDirectory $prog -WindowStyle Hidden -Wait | Out-Null
} else { } else {
Write-Host "[WARN] stop_netx.ps1 not found" Write-Host "[WARN] stop_netx.ps1 not found"
} }
if ($KillTray) {
Write-Host "==> Stopping NetX tray icons"
Stop-NetxTrayProcesses -ProgramRoot $prog
}
$mode = Get-DbMode -EnvMap $map $mode = Get-DbMode -EnvMap $map
if ($mode -eq "bundled" -and -not $KeepPostgres) { if ($mode -eq "bundled" -and -not $KeepPostgres) {
$pgBinCandidates = @( $pgBinCandidates = @(

View file

@ -0,0 +1,53 @@
param(
[string]$DataRoot = ""
)
# Post-uninstall optional data wipe with retries (handles briefly locked runtime logs).
$ErrorActionPreference = "Continue"
if (-not $DataRoot) {
$DataRoot = Join-Path ([Environment]::GetFolderPath("CommonApplicationData")) "NetX"
}
if (-not (Test-Path -LiteralPath $DataRoot)) {
Write-Host "==> Data root already gone: $DataRoot"
exit 0
}
Write-Host "==> Deleting data root: $DataRoot"
# Kill anything still holding files under data root.
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {
$_.CommandLine -and ($_.CommandLine.IndexOf($DataRoot, [StringComparison]::OrdinalIgnoreCase) -ge 0)
} | ForEach-Object {
try { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } catch {}
try { & taskkill.exe /F /PID $_.ProcessId 2>$null | Out-Null } catch {}
}
$ok = $false
for ($i = 1; $i -le 5; $i++) {
try {
cmd /c "rmdir /s /q `"$DataRoot`""
} catch {}
if (-not (Test-Path -LiteralPath $DataRoot)) {
$ok = $true
break
}
Start-Sleep -Seconds 1
}
if (-not $ok -and (Test-Path -LiteralPath $DataRoot)) {
try {
takeown /F $DataRoot /R /D Y | Out-Null
icacls $DataRoot /grant Administrators:F /T | Out-Null
cmd /c "rmdir /s /q `"$DataRoot`""
} catch {}
}
if (Test-Path -LiteralPath $DataRoot) {
Write-Host "[WARN] Could not fully delete $DataRoot"
exit 1
}
Write-Host "==> Data root deleted"
exit 0

View file

@ -0,0 +1,71 @@
param(
[string]$ProgramRoot = "",
[string]$DataRoot = ""
)
# Fast, non-blocking uninstall prep for Inno [UninstallRun].
# Must return within a few seconds and never kill unins000.exe / _unins.tmp.
$ErrorActionPreference = "Continue"
$prog = if ($ProgramRoot) { $ProgramRoot } else { "C:\Program Files\NetX" }
$data = if ($DataRoot) { $DataRoot } else { Join-Path $env:ProgramData "NetX" }
$prog = ($prog -replace '/', '\').TrimEnd('\')
$data = ($data -replace '/', '\').TrimEnd('\')
function Test-Protected([string]$Name, [string]$Cmd) {
if ($Name -match '^(unins\d*|_unins\.tmp|setup)\.exe$') { return $true }
if ($Cmd -match 'unins\d*\.exe|_unins\.tmp|uninstall_prepare\.ps1|uninstall_delete_data\.ps1') { return $true }
return $false
}
function Stop-Pid([int]$Id, [string]$Label) {
if ($Id -le 4 -or $Id -eq $PID) { return }
Write-Host "stop $Id $Label"
try { Stop-Process -Id $Id -Force -ErrorAction SilentlyContinue } catch {}
}
Write-Host "==> uninstall_prepare fast-stop prog=$prog"
$patterns = @(
'netx_tray\.ps1',
'netx_api\.(main|worker|biz_state_worker)',
'start_netx_app\.ps1',
'stop_netx_app\.ps1',
'launch_shortcut\.ps1',
[regex]::Escape((Join-Path $prog '.venv\Scripts\python.exe')),
[regex]::Escape((Join-Path $prog 'postgres\pgsql\bin'))
)
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | ForEach-Object {
$cl = [string]$_.CommandLine
$name = [string]$_.Name
if (-not $cl) { return }
if (Test-Protected -Name $name -Cmd $cl) { return }
foreach ($pat in $patterns) {
if ($cl -match $pat) {
Stop-Pid -Id ([int]$_.ProcessId) -Label $name
break
}
}
}
# Best-effort postgres stop (no hang: immediate + ignore)
$pgCtl = Join-Path $prog "postgres\pgsql\bin\pg_ctl.exe"
$pgData = Join-Path $data "pgdata"
if ((Test-Path $pgCtl) -and (Test-Path $pgData)) {
try {
$p = Start-Process -FilePath $pgCtl -ArgumentList @('-D', $pgData, 'stop', '-m', 'immediate') `
-WindowStyle Hidden -PassThru
if (-not $p.WaitForExit(5000)) {
try { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } catch {}
}
} catch {}
}
try {
Remove-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" -Name "NetX" -ErrorAction SilentlyContinue
} catch {}
Write-Host "==> uninstall_prepare done"
exit 0

View file

@ -1,4 +1,4 @@
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$PackagePath, [string]$PackagePath,
[string]$ProgramRoot = "", [string]$ProgramRoot = "",
@ -22,10 +22,14 @@ New-Item -ItemType Directory -Path $work -Force | Out-Null
try { try {
Write-Host "==> Extracting update package" Write-Host "==> Extracting update package"
# Preferred end-user path is NetX-Setup-*.exe (check_update / offline installer).
# This script remains for legacy/dev .zip packages (build_release.ps1 -CreateZip).
if ($PackagePath.ToLowerInvariant().EndsWith(".zip")) { if ($PackagePath.ToLowerInvariant().EndsWith(".zip")) {
Expand-Archive -Path $PackagePath -DestinationPath $work -Force Expand-Archive -Path $PackagePath -DestinationPath $work -Force
} elseif ($PackagePath.ToLowerInvariant().EndsWith(".exe")) {
throw "unsupported_package: run NetX-Setup-*.exe (upgrade keeps DB), or use check_update.ps1 -Apply"
} else { } else {
throw "unsupported_package: use a .zip built by build_release.ps1" throw "unsupported_package: use a .zip from build_release.ps1 -CreateZip, or NetX-Setup-*.exe"
} }
$src = $work $src = $work
@ -54,7 +58,35 @@ try {
if (Test-Path $envFile) { if (Test-Path $envFile) {
Copy-Item $envFile (Join-Path $bak ".env") Copy-Item $envFile (Join-Path $bak ".env")
} }
Write-Host "==> Backup marker: $bak (data/pgdata left in place; optional pg_dump not run)" # Best-effort logical backup when psql is available (bundled or PATH).
$map = Read-DotEnv -Path $envFile
$pgDump = $null
foreach ($c in @(
(Join-Path $prog "postgres\pgsql\bin\pg_dump.exe"),
(Join-Path $PSScriptRoot "postgres\pgsql\bin\pg_dump.exe")
)) {
if (Test-Path $c) { $pgDump = $c; break }
}
if (-not $pgDump) {
$cmd = Get-Command pg_dump -ErrorAction SilentlyContinue
if ($cmd) { $pgDump = $cmd.Source }
}
if ($pgDump -and $map["NETX_DATABASE_URL"] -match 'postgresql\+?[^:]*://([^:]+):([^@]+)@([^:/]+):?(\d+)?/([^?\s]+)') {
$u = $Matches[1]; $p = [uri]::UnescapeDataString($Matches[2]); $h = $Matches[3]
$pt = if ($Matches[4]) { $Matches[4] } else { "5432" }
$dbn = $Matches[5]
$dumpOut = Join-Path $bak "netx.dump"
Write-Host "==> pg_dump -> $dumpOut"
$env:PGPASSWORD = $p
try {
& $pgDump -h $h -p $pt -U $u -d $dbn -Fc -f $dumpOut
} catch {
Write-Host "[WARN] pg_dump failed: $($_.Exception.Message)" -ForegroundColor Yellow
} finally {
Remove-Item Env:PGPASSWORD -ErrorAction SilentlyContinue
}
}
Write-Host "==> Backup: $bak"
} }
Write-Host "==> Stopping services" Write-Host "==> Stopping services"
@ -62,7 +94,8 @@ try {
-ProgramRoot $prog -DataRoot $data -ProgramRoot $prog -DataRoot $data
# Replace program layers only — never wipe data root. # Replace program layers only — never wipe data root.
$replaceDirs = @("netx_api", "alembic", "web", "packaging", "scripts", "postgres", "packages") # Include python/ (portable runtime) — required with shipped .venv since 0.4.6.
$replaceDirs = @("netx_api", "alembic", "web", "packaging", "scripts", "postgres", "packages", "python")
foreach ($name in $replaceDirs) { foreach ($name in $replaceDirs) {
$from = Join-Path $src $name $from = Join-Path $src $name
$to = Join-Path $prog $name $to = Join-Path $prog $name
@ -79,7 +112,7 @@ try {
Copy-Item -Path $from -Destination (Join-Path $prog $f) -Force Copy-Item -Path $from -Destination (Join-Path $prog $f) -Force
} }
} }
# Optional runtime / .venv shipped in package # Legacy top-level runtime/ (older packages) + shipped .venv
if (Test-Path (Join-Path $src "runtime")) { if (Test-Path (Join-Path $src "runtime")) {
$rt = Join-Path $prog "runtime" $rt = Join-Path $prog "runtime"
if (Test-Path $rt) { Remove-Item -Recurse -Force $rt } if (Test-Path $rt) { Remove-Item -Recurse -Force $rt }
@ -92,11 +125,21 @@ try {
Copy-Item -Path (Join-Path $src ".venv") -Destination $venvTo -Recurse -Force Copy-Item -Path (Join-Path $src ".venv") -Destination $venvTo -Recurse -Force
} }
# Builder-path pyvenv.cfg → local python/runtime (same as first install).
if (Get-Command Repair-NetxShippedVenv -ErrorAction SilentlyContinue) {
if (Repair-NetxShippedVenv -ProgramRoot $prog) {
Write-Host "==> Relinked .venv to bundled python/runtime" -ForegroundColor Green
}
}
Write-Host "==> Update files applied" -ForegroundColor Green Write-Host "==> Update files applied" -ForegroundColor Green
if (-not $NoStart) { if (-not $NoStart) {
& powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "start_netx_app.ps1") ` # Same process (no nested powershell) so the update console can exit cleanly.
& (Join-Path $PSScriptRoot "start_netx_app.ps1") `
-ProgramRoot $prog -DataRoot $data -SkipBrowser -ProgramRoot $prog -DataRoot $data -SkipBrowser
if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
} }
Write-Host "==> Update complete. You can close this window." -ForegroundColor Green
} finally { } finally {
if (Test-Path $work) { if (Test-Path $work) {
Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "netx-ops" name = "netx-ops"
version = "0.3.0" version = "0.4.12"
description = "netx operations tool: alarm-centric workflows with REST API and stdio MCP" description = "netx operations tool: alarm-centric workflows with REST API and stdio MCP"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
@ -43,3 +43,4 @@ netx-topology-mcp = "netx_topology_mcp.server:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] where = ["."]
include = ["netx_api*"] include = ["netx_api*"]

View file

@ -1,4 +1,4 @@
param( param(
[string]$PgHost = "127.0.0.1", [string]$PgHost = "127.0.0.1",
[int]$PgPort = 5432, [int]$PgPort = 5432,
[string]$SuperUser = "postgres", [string]$SuperUser = "postgres",

View file

@ -23,9 +23,24 @@ if ($Backgtound -and -not $Background) {
$projectRoot = Split-Path -Parent $PSScriptRoot $projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location $projectRoot Set-Location $projectRoot
$runDir = Join-Path $PSScriptRoot ".run" $packCommon = Join-Path $projectRoot "packaging\_common.ps1"
if (Test-Path -LiteralPath $packCommon) {
. $packCommon
if (Get-Command Repair-NetxShippedVenv -ErrorAction SilentlyContinue) {
$null = Repair-NetxShippedVenv -ProgramRoot $projectRoot
}
}
# Prefer writable data-root runtime dir (Program Files is not writable after Setup install).
if ($env:NETX_RUN_DIR) {
$runDir = $env:NETX_RUN_DIR
} elseif ($env:NETX_SCHEDULER_HEARTBEAT_PATH) {
$runDir = Split-Path -Parent ($env:NETX_SCHEDULER_HEARTBEAT_PATH -replace '/', '\')
} else {
$runDir = Join-Path $PSScriptRoot ".run"
}
if (-not (Test-Path $runDir)) { if (-not (Test-Path $runDir)) {
New-Item -ItemType Directory -Path $runDir | Out-Null New-Item -ItemType Directory -Path $runDir -Force | Out-Null
} }
$pidFile = Join-Path $runDir "netx.pid" $pidFile = Join-Path $runDir "netx.pid"
@ -39,13 +54,17 @@ $webPidFile = Join-Path $runDir "web.pid"
$webLogFile = Join-Path $runDir "web.out.log" $webLogFile = Join-Path $runDir "web.out.log"
$webErrFile = Join-Path $runDir "web.err.log" $webErrFile = Join-Path $runDir "web.err.log"
$venvPython = Join-Path $projectRoot ".venv\\Scripts\\python.exe" $venvPython = Join-Path $projectRoot ".venv\Scripts\python.exe"
if (-not (Test-Path $venvPython)) { if (Get-Command Test-NetxVenvRunnable -ErrorAction SilentlyContinue) {
if (-not (Test-NetxVenvRunnable -VenvPython $venvPython)) {
throw "venv_not_runnable: reinstall NetX or run setup as administrator"
}
} elseif (-not (Test-Path -LiteralPath $venvPython)) {
Write-Host "==> .venv not found, creating virtual environment" Write-Host "==> .venv not found, creating virtual environment"
python -m venv (Join-Path $projectRoot ".venv") python -m venv (Join-Path $projectRoot ".venv")
} if (-not (Test-Path -LiteralPath $venvPython)) {
if (-not (Test-Path $venvPython)) {
throw "failed_to_create_venv" throw "failed_to_create_venv"
}
} }
$pythonExe = $venvPython $pythonExe = $venvPython
@ -253,7 +272,11 @@ if ($Background) {
Show-LogTail -Path $logFile Show-LogTail -Path $logFile
exit 1 exit 1
} }
$healthWaitSec = 45 # Fresh installs run Alembic upgrades on first boot; 45s is often too short.
$healthWaitSec = 180
if ($env:NETX_START_HEALTH_WAIT_SEC) {
try { $healthWaitSec = [int]$env:NETX_START_HEALTH_WAIT_SEC } catch {}
}
if (-not (Test-NetxApiListening -HostName $BindHost -LocalPort $Port -WaitSec $healthWaitSec)) { if (-not (Test-NetxApiListening -HostName $BindHost -LocalPort $Port -WaitSec $healthWaitSec)) {
Write-Host "[ERR] Port $Port not listening /health not OK within ${healthWaitSec}s (process may be stuck on DB or schema migration)." -ForegroundColor Red Write-Host "[ERR] Port $Port not listening /health not OK within ${healthWaitSec}s (process may be stuck on DB or schema migration)." -ForegroundColor Red
Show-LogTail -Path $errFile Show-LogTail -Path $errFile

View file

@ -1,4 +1,4 @@
param( param(
[int]$Port = 8890, [int]$Port = 8890,
[int]$WebPort = 5173, [int]$WebPort = 5173,
# Windows often ignores graceful Stop-Process on python; default hard-kill. # Windows often ignores graceful Stop-Process on python; default hard-kill.
@ -9,7 +9,13 @@ param(
$ErrorActionPreference = "Continue" $ErrorActionPreference = "Continue"
$useForce = if ($NoForce) { $false } else { [bool]$Force } $useForce = if ($NoForce) { $false } else { [bool]$Force }
$runDir = Join-Path $PSScriptRoot ".run" if ($env:NETX_RUN_DIR) {
$runDir = $env:NETX_RUN_DIR
} elseif ($env:NETX_SCHEDULER_HEARTBEAT_PATH) {
$runDir = Split-Path -Parent ($env:NETX_SCHEDULER_HEARTBEAT_PATH -replace '/', '\')
} else {
$runDir = Join-Path $PSScriptRoot ".run"
}
$pidFile = Join-Path $runDir "netx.pid" $pidFile = Join-Path $runDir "netx.pid"
$workerPidFile = Join-Path $runDir "worker.pid" $workerPidFile = Join-Path $runDir "worker.pid"
$webPidFile = Join-Path $runDir "web.pid" $webPidFile = Join-Path $runDir "web.pid"

View file

@ -5,7 +5,8 @@ from __future__ import annotations
import copy import copy
import unittest import unittest
from netx_api.biz_state.compare_engine import compare_rows, mapping_stats from netx_api.biz_state.compare_engine import compare_rows, mapping_stats, stratify_take
from netx_api.biz_state.compare_service import _kind_allows
from netx_api.biz_state.compare_rules import ( from netx_api.biz_state.compare_rules import (
apply_row_filters, apply_row_filters,
arp_dynamic_row_filters, arp_dynamic_row_filters,
@ -96,7 +97,18 @@ class CompareEngineTests(unittest.TestCase):
self.assertEqual(s["removed"], 1) self.assertEqual(s["removed"], 1)
self.assertEqual(s["added"], 1) self.assertEqual(s["added"], 1)
kinds = {d["kind"] for d in out["diffs"]} kinds = {d["kind"] for d in out["diffs"]}
self.assertIn("unchanged", kinds) # Default: unchanged counted but not listed (million-row safe)
self.assertNotIn("unchanged", kinds)
out_full = compare_rows(
before_rows=before,
after_rows=after,
key_fields=["local_if", "remote_sys", "remote_if"],
iface_fields=["local_if"],
compare_fields=[],
port_map={},
include_unchanged=True,
)
self.assertIn("unchanged", {d["kind"] for d in out_full["diffs"]})
def test_empty_port_map_ignores_iface_in_key(self) -> None: def test_empty_port_map_ignores_iface_in_key(self) -> None:
"""No port map → ignore local_if when matching (same neighbor, renamed port).""" """No port map → ignore local_if when matching (same neighbor, renamed port)."""
@ -149,10 +161,107 @@ class CompareEngineTests(unittest.TestCase):
iface_fields=["local_if"], iface_fields=["local_if"],
compare_fields=["remote_ip"], compare_fields=["remote_ip"],
port_map={}, port_map={},
include_unchanged=True,
) )
self.assertEqual(out["summary"]["unchanged"], 1) self.assertEqual(out["summary"]["unchanged"], 1)
self.assertEqual(len(out["diffs"]), 1) self.assertEqual(len(out["diffs"]), 1)
self.assertEqual(out["diffs"][0]["kind"], "unchanged") self.assertEqual(out["diffs"][0]["kind"], "unchanged")
slim = compare_rows(
before_rows=before,
after_rows=after,
key_fields=["local_if", "remote_sys", "remote_if"],
iface_fields=["local_if"],
compare_fields=["remote_ip"],
port_map={},
)
self.assertEqual(slim["summary"]["unchanged"], 1)
self.assertEqual(slim["diffs"], [])
def test_unchanged_sample_limit_and_compact(self) -> None:
before = [
{"k": str(i), "v": "1", "_netx": {"row_id": f"b{i}"}} for i in range(5)
]
after = [
{"k": str(i), "v": "1", "_netx": {"row_id": f"a{i}"}} for i in range(5)
]
out = compare_rows(
before_rows=before,
after_rows=after,
key_fields=["k"],
iface_fields=[],
compare_fields=["v"],
port_map={},
include_unchanged=True,
unchanged_limit=2,
compact_unchanged=True,
)
self.assertEqual(out["summary"]["unchanged"], 5)
self.assertEqual(out["summary"]["unchanged_listed"], 2)
self.assertTrue(out["summary"]["unchanged_truncated"])
self.assertTrue(out["summary"]["unchanged_compact"])
self.assertEqual(out["summary"]["unchanged_sample_mode"], "stratified")
self.assertEqual(len(out["diffs"]), 2)
self.assertEqual(out["diffs"][0]["before"], {})
self.assertEqual(out["diffs"][0]["after"], {})
self.assertEqual(out["diffs"][0]["before_row_id"], "b0")
self.assertEqual(out["diffs"][0]["after_row_id"], "a0")
def test_stratify_take_round_robin(self) -> None:
items = [("a", i) for i in range(5)] + [("b", i) for i in range(5)]
got = stratify_take(items, 4, key_fn=lambda x: x[0])
self.assertEqual([x[0] for x in got], ["a", "b", "a", "b"])
def test_kind_allows_tabs(self) -> None:
self.assertTrue(_kind_allows("all", "unchanged"))
self.assertTrue(_kind_allows("diff", "removed"))
self.assertTrue(_kind_allows("diff", "changed"))
self.assertFalse(_kind_allows("diff", "added"))
self.assertFalse(_kind_allows("diff", "unchanged"))
self.assertTrue(_kind_allows("unchanged", "unchanged"))
self.assertFalse(_kind_allows("unchanged", "removed"))
self.assertTrue(_kind_allows("removed", "removed"))
self.assertTrue(_kind_allows("changed", "changed"))
def test_unchanged_sample_stratified_across_neighbors(self) -> None:
"""Sample must cover multiple neighbors, not only the first command's rows."""
before = []
after = []
for n_i, neigh in enumerate(("1.1.1.1", "2.2.2.2", "3.3.3.3")):
for j in range(10):
net = f"10.{n_i}.{j}.0/24"
before.append(
{
"neighbor": neigh,
"direction": "in",
"network": net,
"v": "1",
"_netx": {"row_id": f"b-{neigh}-{j}"},
}
)
after.append(
{
"neighbor": neigh,
"direction": "in",
"network": net,
"v": "1",
"_netx": {"row_id": f"a-{neigh}-{j}"},
}
)
out = compare_rows(
before_rows=before,
after_rows=after,
key_fields=["neighbor", "direction", "network"],
iface_fields=[],
compare_fields=["v"],
port_map={},
include_unchanged=True,
unchanged_limit=6,
compact_unchanged=True,
)
self.assertEqual(out["summary"]["unchanged"], 30)
self.assertEqual(out["summary"]["unchanged_listed"], 6)
keys = [d["key"]["neighbor"] for d in out["diffs"]]
self.assertEqual(set(keys), {"1.1.1.1", "2.2.2.2", "3.3.3.3"})
def test_mac_normalize_via_field_rules(self) -> None: def test_mac_normalize_via_field_rules(self) -> None:
before = [{"ip": "1.1.1.1", "mac": "00:11:22:33:44:55", "iface": "gei-0/1"}] before = [{"ip": "1.1.1.1", "mac": "00:11:22:33:44:55", "iface": "gei-0/1"}]
@ -309,6 +418,57 @@ class CompareRulesTests(unittest.TestCase):
class CompareDiffPagingTests(unittest.TestCase): class CompareDiffPagingTests(unittest.TestCase):
def test_resolve_unchanged_policy(self) -> None:
from netx_api.biz_state.compare_service import resolve_unchanged_policy
small = resolve_unchanged_policy("auto", before_n=100, after_n=100)
self.assertTrue(small["include"])
self.assertIsNone(small["limit"])
self.assertFalse(small["compact"])
large = resolve_unchanged_policy("auto", before_n=1_500_000, after_n=1_500_000)
self.assertTrue(large["include"])
self.assertEqual(large["limit"], 5000)
self.assertTrue(large["compact"])
self.assertFalse(resolve_unchanged_policy("never", before_n=10, after_n=10)["include"])
keys = resolve_unchanged_policy("keys", before_n=1_500_000, after_n=1_500_000)
self.assertTrue(keys["include"])
self.assertIsNone(keys["limit"])
self.assertTrue(keys["compact"])
def test_hydrate_diff_rows_fills_sides(self) -> None:
from netx_api.biz_state.compare_service import _hydrate_diff_rows
class _FakeDb:
pass
items = [
{
"kind": "unchanged",
"key": {"k": "1"},
"before": {},
"after": {},
"mapped_before": {},
"changes": {},
"before_row_id": "b1",
"after_row_id": "a1",
}
]
# Patch loader
import netx_api.biz_state.compare_service as cs
orig = cs._metric_rows_by_ids
try:
cs._metric_rows_by_ids = lambda _db, ids: { # type: ignore[assignment]
"b1": {"k": "1", "v": "pre"},
"a1": {"k": "1", "v": "post"},
}
out = _hydrate_diff_rows(_FakeDb(), items)
finally:
cs._metric_rows_by_ids = orig
self.assertEqual(out[0]["before"]["v"], "pre")
self.assertEqual(out[0]["after"]["v"], "post")
self.assertEqual(out[0]["mapped_before"]["v"], "pre")
def test_filter_inline_diffs_kind_and_kw(self) -> None: def test_filter_inline_diffs_kind_and_kw(self) -> None:
from netx_api.biz_state.compare_service import _filter_inline_diffs from netx_api.biz_state.compare_service import _filter_inline_diffs
@ -354,25 +514,43 @@ class CompareSheetDefaultsTests(unittest.TestCase):
self.assertIn("isis_adjacency.ipv6", ids) self.assertIn("isis_adjacency.ipv6", ids)
# Same source metric may appear multiple times (afi splits) # Same source metric may appear multiple times (afi splits)
self.assertEqual(sum(1 for s in sheets if s["metric_id"] == "bgp_peer"), 6) self.assertEqual(sum(1 for s in sheets if s["metric_id"] == "bgp_peer"), 6)
self.assertGreaterEqual(sum(1 for s in sheets if s["metric_id"] == "bgp_route"), 4)
self.assertIn("bgp_peer.evpn", ids) self.assertIn("bgp_peer.evpn", ids)
self.assertIn("bgp_peer.vpls", ids) self.assertIn("bgp_peer.vpls", ids)
self.assertIn("vrrp.ipv4", ids) self.assertIn("vrrp.ipv4", ids)
self.assertIn("lldp_neighbor", ids) self.assertIn("lldp_neighbor", ids)
detail = next(s for s in sheets if sheet_key(s) == "interface_detail") # Packaged IOH default: filtered bgp_route ipv4 + percent pfx_rcd
self.assertEqual(detail["compare_fields"], ["port_status"]) route4 = next(s for s in sheets if sheet_key(s) == "bgp_route")
self.assertIn("input_bps", detail["display_fields"]) self.assertEqual(route4["metric_id"], "bgp_route")
optical = next(s for s in sheets if sheet_key(s) == "optical_brief") self.assertTrue(
self.assertEqual(optical["compare_fields"], ["status"]) any(
self.assertIn("rx_power", optical["display_fields"]) f.get("field") == "afi" and f.get("value") == "ipv4"
bgp4 = next(s for s in sheets if sheet_key(s) == "bgp_peer.ipv4") for f in (route4.get("row_filters") or [])
self.assertEqual(bgp4["compare_fields"], ["as_num", "state"]) )
self.assertIn("pfx_rcd", bgp4["display_fields"]) )
self.assertTrue(
any(
r.get("field") == "pfx_rcd" and r.get("compare") == "percent"
for r in (route4.get("field_rules") or [])
)
)
route_vpn = next(s for s in sheets if sheet_key(s) == "bgp_route.vpnv4")
self.assertTrue(
any(
f.get("field") == "afi" and f.get("value") == "vpnv4"
for f in (route_vpn.get("row_filters") or [])
)
)
vpnv4 = next(s for s in sheets if sheet_key(s) == "bgp_peer.vpnv4") vpnv4 = next(s for s in sheets if sheet_key(s) == "bgp_peer.vpnv4")
self.assertEqual(vpnv4["row_filters"], [{"field": "afi", "op": "eq", "value": "vpnv4"}]) self.assertEqual(
vpnv4["row_filters"],
[{"field": "afi", "op": "eq", "value": "vpnv4"}],
)
isis4 = next(s for s in sheets if sheet_key(s) == "isis_adjacency.ipv4") isis4 = next(s for s in sheets if sheet_key(s) == "isis_adjacency.ipv4")
self.assertEqual(isis4["row_filters"][0]["op"], "contains") self.assertEqual(isis4["row_filters"][0]["op"], "contains")
def test_duplicate_match_keys_are_reported(self) -> None: def test_ordered_same_key_pairing_2v2(self) -> None:
"""Same key, two rows each: zip by order (not first-wins + duplicate)."""
before = [ before = [
{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "1"}, {"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "1"},
{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "9"}, {"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "9"},
@ -389,15 +567,47 @@ class CompareSheetDefaultsTests(unittest.TestCase):
compare_fields=["remote_ip"], compare_fields=["remote_ip"],
port_map={"a": "a"}, port_map={"a": "a"},
) )
self.assertEqual(out["summary"]["before_count"], 2)
self.assertEqual(out["summary"]["after_count"], 2)
self.assertEqual(out["summary"]["duplicate"], 0)
self.assertEqual(out["summary"]["duplicate_keys_before"], 1) self.assertEqual(out["summary"]["duplicate_keys_before"], 1)
self.assertEqual(out["summary"]["duplicate_keys_after"], 1) self.assertEqual(out["summary"]["duplicate_keys_after"], 1)
self.assertEqual(out["summary"]["duplicate"], 2)
self.assertIn("a|X|1", out["summary"]["duplicate_key_list"]) self.assertIn("a|X|1", out["summary"]["duplicate_key_list"])
kinds = [d["kind"] for d in out["diffs"]] kinds = [d["kind"] for d in out["diffs"]]
self.assertEqual(kinds.count("duplicate"), 2) self.assertNotIn("duplicate", kinds)
# First before wins → matches first after → unchanged (same remote_ip) # Pair 0: 1==1 unchanged; pair 1: 9!=2 changed
self.assertEqual(out["summary"]["unchanged"], 1) self.assertEqual(out["summary"]["unchanged"], 1)
self.assertEqual(out["summary"]["changed"], 1)
self.assertEqual(out["summary"]["added"], 0)
self.assertEqual(out["summary"]["removed"], 0)
def test_ordered_multipath_5_vs_2(self) -> None:
"""5 before / 2 after same key → 2 compared + 3 removed failures."""
key = {"local_if": "a", "remote_sys": "X", "remote_if": "1"}
before = [{**key, "remote_ip": str(i)} for i in range(5)]
after = [{**key, "remote_ip": "0"}, {**key, "remote_ip": "1"}]
out = compare_rows(
before_rows=before,
after_rows=after,
key_fields=["local_if", "remote_sys", "remote_if"],
iface_fields=["local_if"],
compare_fields=["remote_ip"],
port_map={"a": "a"},
)
self.assertEqual(out["summary"]["before_count"], 5)
self.assertEqual(out["summary"]["after_count"], 2)
self.assertEqual(out["summary"]["removed"], 3)
self.assertEqual(out["summary"]["added"], 0)
self.assertEqual(
out["summary"]["unchanged"] + out["summary"]["changed"],
2,
)
self.assertEqual(out["summary"]["unchanged"], 2)
self.assertEqual(out["summary"]["changed"], 0) self.assertEqual(out["summary"]["changed"], 0)
self.assertEqual(out["summary"]["duplicate"], 0)
kinds = [d["kind"] for d in out["diffs"]]
self.assertEqual(kinds.count("removed"), 3)
self.assertNotIn("duplicate", kinds)
def test_ignore_port_changes_false_keeps_iface(self) -> None: def test_ignore_port_changes_false_keeps_iface(self) -> None:
before = [ before = [

View file

@ -0,0 +1,98 @@
"""Startup recovery for interrupted biz-state compare runs."""
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from netx_api.biz_state.compare_service import (
cancel_compare_run,
recover_interrupted_compares_on_startup,
)
from netx_api.db import Base
from netx_api.models import BizCompareJob, BizCompareRun
class BizStateCompareRecoveryTests(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
TestingSession = sessionmaker(
bind=engine, autoflush=False, autocommit=False, expire_on_commit=False
)
Base.metadata.create_all(bind=engine)
self.db = TestingSession()
self.job = BizCompareJob(
id="j1",
name="cutover",
template_id="tpl1",
status="active",
)
self.db.add(self.job)
self.running = BizCompareRun(
id="r_run",
job_id="j1",
status="running",
message="loading 1/2 · BGP",
summary_json={
"progress": {"phase": "loading", "sheet_index": 1, "sheet_total": 2},
"sheets": [
{"sheet_id": "bgp", "status": "running"},
{"sheet_id": "isis", "status": "pending"},
],
},
)
self.queued = BizCompareRun(
id="r_q",
job_id="j1",
status="queued",
message="queued",
summary_json={"sheets": [{"sheet_id": "bgp", "status": "pending"}]},
)
self.ok = BizCompareRun(
id="r_ok",
job_id="j1",
status="success",
message="done",
summary_json={"added": 0, "removed": 0, "changed": 0},
)
self.db.add_all([self.running, self.queued, self.ok])
self.db.commit()
def tearDown(self) -> None:
self.db.close()
def test_startup_cancels_running_and_queued(self) -> None:
out = recover_interrupted_compares_on_startup(self.db)
self.assertEqual(out["runs"], 2)
self.db.refresh(self.running)
self.db.refresh(self.queued)
self.db.refresh(self.ok)
self.assertEqual(self.running.status, "cancelled")
self.assertIn("interrupted_by_restart", self.running.message or "")
self.assertEqual(
(self.running.summary_json or {}).get("progress", {}).get("phase"),
"cancelled",
)
sheets = list((self.running.summary_json or {}).get("sheets") or [])
self.assertEqual(sheets[0].get("status"), "cancelled")
self.assertEqual(sheets[1].get("status"), "cancelled")
self.assertEqual(self.queued.status, "cancelled")
self.assertEqual(self.ok.status, "success")
def test_cancel_compare_run_user(self) -> None:
out = cancel_compare_run(self.db, "r_run")
self.assertEqual(out["status"], "cancelled")
self.db.refresh(self.running)
self.assertEqual(self.running.status, "cancelled")
self.assertIn("cancelled_by_user", self.running.message or "")
def test_cancel_idempotent_on_success(self) -> None:
out = cancel_compare_run(self.db, "r_ok")
self.assertEqual(out["status"], "success")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,228 @@
"""Unit tests for SQL compare eligibility and filter compilation."""
from __future__ import annotations
import unittest
from unittest.mock import MagicMock
from netx_api.biz_state.compare_sql import (
can_sql_compare,
compile_row_filters_sql,
sql_compare_skip_reason,
_field_rules_sql_compatible,
_filters_sql_compatible,
_safe_field,
)
class _FakeDialect:
def __init__(self, name: str) -> None:
self.name = name
class _FakeBind:
def __init__(self, name: str) -> None:
self.dialect = _FakeDialect(name)
def _db(dialect: str = "postgresql") -> MagicMock:
db = MagicMock()
db.get_bind.return_value = _FakeBind(dialect)
return db
class CompareSqlGateTests(unittest.TestCase):
def test_safe_field_rejects_injection(self) -> None:
with self.assertRaises(ValueError):
_safe_field("a'; drop table x;--")
with self.assertRaises(ValueError):
_safe_field("x.y")
self.assertEqual(_safe_field("network"), "network")
def test_requires_postgres(self) -> None:
sheet = {
"metric_id": "bgp_route",
"key_fields": ["network", "next_hop"],
"compare_fields": ["path"],
"iface_fields": [],
"field_rules": [],
"row_filters": [],
}
self.assertFalse(can_sql_compare(_db("sqlite"), sheet, port_map={}))
self.assertTrue(can_sql_compare(_db("postgresql"), sheet, port_map={}))
def test_rejects_port_map(self) -> None:
sheet = {
"metric_id": "lldp_neighbor",
"key_fields": ["local_if", "remote_sys"],
"compare_fields": [],
"iface_fields": ["local_if"],
"ignore_port_changes": False,
"field_rules": [],
"row_filters": [],
}
self.assertFalse(
can_sql_compare(_db(), sheet, port_map={"gei-0/1": "gei-0/2"})
)
def test_rejects_auto_ignore_ports_with_iface_key(self) -> None:
sheet = {
"metric_id": "lldp_neighbor",
"key_fields": ["local_if", "remote_sys"],
"compare_fields": [],
"iface_fields": ["local_if"],
"ignore_port_changes": None,
"field_rules": [],
"row_filters": [],
}
self.assertFalse(can_sql_compare(_db(), sheet, port_map={}))
def test_rejects_mac_normalize(self) -> None:
sheet = {
"metric_id": "arp",
"key_fields": ["ip", "vrf"],
"compare_fields": ["mac"],
"iface_fields": [],
"field_rules": [{"field": "mac", "normalize": "mac"}],
"row_filters": [],
}
self.assertFalse(can_sql_compare(_db(), sheet, port_map={}))
def test_rejects_age_timer_filter(self) -> None:
sheet = {
"metric_id": "arp",
"key_fields": ["ip"],
"compare_fields": [],
"iface_fields": [],
"field_rules": [],
"row_filters": [{"field": "age", "op": "age_timer"}],
}
self.assertFalse(can_sql_compare(_db(), sheet, port_map={}))
def test_accepts_bgp_route_style(self) -> None:
sheet = {
"metric_id": "bgp_route",
"key_fields": [
"local_as",
"afi",
"vrf",
"neighbor",
"direction",
"rd",
"network",
"next_hop",
],
"compare_fields": ["path", "as_num", "pfx_rcd"],
"iface_fields": [],
"field_rules": [
{"field": "pfx_rcd", "compare": "percent", "tolerance": 5},
],
"row_filters": [
{"field": "vrf", "op": "empty"},
{"field": "afi", "op": "eq", "value": "ipv4"},
],
}
self.assertTrue(can_sql_compare(_db(), sheet, port_map={}))
# BGP afi/vrf sheet splits + percent rules must not force Python
self.assertEqual(sql_compare_skip_reason(_db(), sheet, port_map={}), "")
def test_rejects_iface_normalize_when_key_uses_iface(self) -> None:
sheet = {
"metric_id": "interface_brief",
"key_fields": ["interface"],
"compare_fields": ["admin"],
"iface_fields": ["interface"],
"ignore_port_changes": False,
"field_rules": [],
"row_filters": [],
}
self.assertFalse(
can_sql_compare(
_db(),
sheet,
port_map={},
iface_normalize_rules=[{"from": "GE", "to": "gei"}],
)
)
def test_accepts_ignore_port_changes_false_without_normalize(self) -> None:
sheet = {
"metric_id": "interface_brief",
"key_fields": ["interface"],
"compare_fields": ["admin"],
"iface_fields": ["interface"],
"ignore_port_changes": False,
"field_rules": [],
"row_filters": [],
}
self.assertTrue(can_sql_compare(_db(), sheet, port_map={}, iface_normalize_rules=[]))
class CompareSqlFilterCompileTests(unittest.TestCase):
def test_empty_filters(self) -> None:
sql, params = compile_row_filters_sql([])
self.assertEqual(sql, "TRUE")
self.assertEqual(params, {})
def test_eq_case_insensitive(self) -> None:
sql, params = compile_row_filters_sql(
[{"field": "afi", "op": "eq", "value": "IPv4"}]
)
self.assertIn("lower(", sql)
self.assertIn("afi", sql)
self.assertEqual(list(params.values()), ["ipv4"])
def test_contains(self) -> None:
sql, params = compile_row_filters_sql(
[{"field": "af", "op": "contains", "value": "IPv4"}]
)
self.assertIn("LIKE", sql)
self.assertEqual(list(params.values()), ["%ipv4%"])
def test_any_all_nesting(self) -> None:
sql, params = compile_row_filters_sql(
[
{
"any": [
{"field": "entry_type", "op": "eq", "value": "dynamic"},
{
"all": [
{"field": "entry_type", "op": "empty"},
{"field": "ip", "op": "not_empty"},
]
},
]
}
]
)
self.assertIn(" OR ", sql)
self.assertIn(" AND ", sql)
self.assertTrue(_filters_sql_compatible([{"any": [{"field": "a", "op": "eq", "value": "1"}]}]))
def test_in_list(self) -> None:
sql, params = compile_row_filters_sql(
[{"field": "afi", "op": "in", "value": ["ipv4", "ipv6"]}]
)
self.assertIn(" IN (", sql)
self.assertEqual(sorted(params.values()), ["ipv4", "ipv6"])
def test_field_rules_matrix(self) -> None:
self.assertTrue(_field_rules_sql_compatible([{"field": "mac", "normalize": "lower"}]))
self.assertFalse(_field_rules_sql_compatible([{"field": "mac", "normalize": "mac"}]))
self.assertTrue(
_field_rules_sql_compatible(
[{"field": "rx", "compare": "numeric", "tolerance": 1}]
)
)
self.assertTrue(
_field_rules_sql_compatible(
[{"field": "pfx_rcd", "compare": "percent", "tolerance": 5}]
)
)
self.assertTrue(
_field_rules_sql_compatible([{"field": "x", "compare": "ignore"}])
)
if __name__ == "__main__":
unittest.main()

View file

@ -494,6 +494,53 @@ const en = {
needBeforeBatch: "Select before task and batch", needBeforeBatch: "Select before task and batch",
needAfterBatch: "Select after batch", needAfterBatch: "Select after batch",
runNow: "Run now", runNow: "Run now",
runStarted: "Compare started in background — you can close this page; check run history for progress",
runFailed: "Compare failed",
runStatusRunning: "Running",
runStatusFailed: "Failed",
runStatusDoneSec: "Done {{s}}s",
runProgress: "{{phase}} · sheet {{i}}/{{n}} · {{sheet}} · {{s}}s elapsed",
runSheetProgress: "sheet {{i}}/{{n}}",
runElapsed: "{{s}}s elapsed",
runRowsLoaded: "Loaded {{side}} {{n}} rows",
runRowsSqlCount: "DB count {{side}} {{n}} rows",
runPersisting: "Wrote {{done}}/{{total}} rows",
runEngineSql: "engine SQL",
runEnginePython: "engine Python",
runEngineNote: "reason {{note}}",
phaseQueued: "Queued",
phaseLoading: "Loading",
phaseSqlCount: "Counting",
phaseSqlProject: "Preparing tables",
phaseSqlJoin: "Joining",
phaseSqlFailFetch: "Fetching fails",
phaseComparing: "Comparing",
phasePersisting: "Writing",
phasePersistingFail: "Writing fails",
phasePersistingOk: "Writing passes",
phaseDone: "Done",
phaseFailed: "Failed",
phaseCancelled: "Cancelled",
sheetPending: "Pending",
colProgress: "Progress / result",
passRateScope: "all rows",
keyFiltersToggle: "Field filters",
hideDisplayCols: "Hide display cols",
showDisplayCols: "Show display cols",
hideDisplayColsHint: "Compact: hide display-only columns so keys and diffs stand out",
ranWithDuration: "Compare finished ({{s}}s)",
unchangedNotStored: "Success rows were counted but not stored. Set “Store success rows” to sample and re-run for spot-check.",
unchangedSampleHint: "{{total}} success rows total; browsing a stratified sample of {{listed}}. Search any route for live source lookup. Pass rate uses all {{total}}.",
liveSearchHint: "Field filters look up source batches live; tabs only filter kind. E.g. direction=out, network=1.1.1.1.",
liveSearchTruncatedHint: "Search results truncated — narrow the field filters.",
clearKeyFilters: "Clear fields",
storeUnchanged: "Store success rows",
storeUnchangedAuto: "Auto (full if small / sample 5k + hydrate if large)",
storeUnchangedSample: "Sample only (up to 5k keys + hydrate)",
storeUnchangedKeys: "All success keys (full browse; large write still slow)",
storeUnchangedAlways: "Always full JSON (slow on huge sheets)",
storeUnchangedNever: "Never (counts only)",
storeUnchangedHint: "Cutover focuses on fails and pass rate; success defaults to a stratified sample. Type a filter to look up any route from source tables. Pass rate uses the full success count.",
saveJob: "Save config", saveJob: "Save config",
jobSaved: "Job config saved", jobSaved: "Job config saved",
jobDeleted: "Compare job deleted", jobDeleted: "Compare job deleted",
@ -502,15 +549,25 @@ const en = {
edit: "Edit", edit: "Edit",
delete: "Delete", delete: "Delete",
tabConfig: "Job config", tabConfig: "Job config",
tabRuns: "Compare batches",
tabResult: "Result", tabResult: "Result",
runs: "Run history", runs: "Run history",
runsHint: "Each “Run now” creates a batch. Stuck “Running” after restart is auto-cancelled; you can also cancel manually and re-run.",
noRuns: "No runs yet — run a compare first", noRuns: "No runs yet — run a compare first",
result: "Result", result: "Result",
viewResult: "View result",
cancelRun: "Cancel",
confirmCancelRun: "Cancel this compare batch? You can start a new run afterward.",
runCancelled: "Compare cancelled",
runStatusCancelled: "Cancelled",
compareAlreadyRunning: "A compare is already running — cancel it under Compare batches or wait",
runBatchSummary: "Fail {{fail}} · Pass {{ok}} · Added {{added}}",
colTime: "Time",
pickBatchRun: "Select compare run", pickBatchRun: "Select compare run",
pickRun: "Select run…", pickRun: "Select run…",
runCount: "{{n}} runs", runCount: "{{n}} runs",
resultEmpty: "No matching diff rows", resultEmpty: "No matching diff rows",
resultFilterPh: "Filter key / values…", resultFilterPh: "Free text, or direction:out network:1.1.1.1",
kindAll: "All", kindAll: "All",
kindDiff: "Fail", kindDiff: "Fail",
kindAdded: "Added", kindAdded: "Added",
@ -542,7 +599,7 @@ const en = {
filterFailField: "Failed compare field", filterFailField: "Failed compare field",
filterFailFieldAll: "Any failed field", filterFailFieldAll: "Any failed field",
resultEmpty: "No matching rows", resultEmpty: "No matching rows",
resultFilterPh: "Filter identity / values…", resultFilterPh: "Free text, or direction:out network:1.1.1.1",
diffCount: "{{n}} failed", diffCount: "{{n}} failed",
passRate: "Pass rate", passRate: "Pass rate",
passOk: "All passed", passOk: "All passed",

View file

@ -493,6 +493,53 @@ const zh = {
needBeforeBatch: "请选择操作前任务与批次", needBeforeBatch: "请选择操作前任务与批次",
needAfterBatch: "请选择操作后批次", needAfterBatch: "请选择操作后批次",
runNow: "立即比对", runNow: "立即比对",
runStarted: "比对已在后台启动,可关闭本页;进度见历史记录",
runFailed: "比对失败",
runStatusRunning: "比对中",
runStatusFailed: "失败",
runStatusDoneSec: "完成 {{s}}s",
runProgress: "{{phase}} · 表 {{i}}/{{n}} · {{sheet}} · 已用 {{s}}s",
runSheetProgress: "表 {{i}}/{{n}}",
runElapsed: "已用 {{s}}s",
runRowsLoaded: "已加载 {{side}} {{n}} 行",
runRowsSqlCount: "库内统计 {{side}} {{n}} 行",
runPersisting: "已写入 {{done}}/{{total}} 行",
runEngineSql: "引擎 SQL",
runEnginePython: "引擎 Python",
runEngineNote: "原因 {{note}}",
phaseQueued: "排队中",
phaseLoading: "加载数据",
phaseSqlCount: "库内统计",
phaseSqlProject: "准备比对表",
phaseSqlJoin: "库内比对",
phaseSqlFailFetch: "拉取失败行",
phaseComparing: "比对中",
phasePersisting: "写入结果",
phasePersistingFail: "写入失败行",
phasePersistingOk: "写入成功行",
phaseDone: "完成",
phaseFailed: "失败",
phaseCancelled: "已取消",
sheetPending: "等待中",
colProgress: "进度 / 结果",
passRateScope: "全表",
keyFiltersToggle: "字段筛选",
hideDisplayCols: "隐藏展示列",
showDisplayCols: "显示展示列",
hideDisplayColsHint: "紧凑模式:默认隐藏不参与比对的展示列,突出 Key 与差异",
ranWithDuration: "比对完成(耗时 {{s}} 秒)",
unchangedNotStored: "成功行仅统计数量未落库。可在任务配置将「成功行保存」改为抽样后重新比对(抽查用)。",
unchangedSampleHint: "成功共 {{total}} 条,明细抽样 {{listed}} 条(分层抽查)。要查任意路由请输入筛选条件——将按原表即时判定。通过率按全部 {{total}} 计。",
liveSearchHint: "按字段回查原表即时判定;页签只过滤种类。例:direction=out,network=1.1.1.1。",
liveSearchTruncatedHint: "搜索结果已截断,请再收窄字段条件。",
clearKeyFilters: "清空字段",
storeUnchanged: "成功行保存",
storeUnchangedAuto: "自动(小表全量 / 大表抽样 5000+原表补全)",
storeUnchangedSample: "仅抽样(最多 5000,身份键+原表补全)",
storeUnchangedKeys: "全量身份键(可翻完全部成功,大表写入仍较久)",
storeUnchangedAlways: "始终全量 JSON(大表很慢,慎用)",
storeUnchangedNever: "不保存(只计数量)",
storeUnchangedHint: "割接优先看失败与通过率;成功默认分层抽样。输入筛选可回查原表(不依赖抽样)。通过率按全部成功计数。",
saveJob: "保存配置", saveJob: "保存配置",
jobSaved: "任务配置已保存", jobSaved: "任务配置已保存",
jobDeleted: "比对任务已删除", jobDeleted: "比对任务已删除",
@ -501,15 +548,25 @@ const zh = {
edit: "编辑", edit: "编辑",
delete: "删除", delete: "删除",
tabConfig: "任务配置", tabConfig: "任务配置",
tabRuns: "对比批次",
tabResult: "比对结果", tabResult: "比对结果",
runs: "历史比对", runs: "历史比对",
runsHint: "每次「立即比对」生成一条批次。重启后卡住的「比对中」会自动标为已取消;也可手动终止后重跑。",
noRuns: "尚无比对记录,请先执行比对", noRuns: "尚无比对记录,请先执行比对",
result: "比对结果", result: "比对结果",
viewResult: "查看结果",
cancelRun: "终止",
confirmCancelRun: "确定终止该比对批次?终止后可重新发起比对。",
runCancelled: "比对已取消",
runStatusCancelled: "已取消",
compareAlreadyRunning: "已有比对在进行中,请先在「对比批次」中终止或等待完成",
runBatchSummary: "失败 {{fail}} · 成功 {{ok}} · 新增 {{added}}",
colTime: "时间",
pickBatchRun: "选择比对记录", pickBatchRun: "选择比对记录",
pickRun: "选择比对记录…", pickRun: "选择比对记录…",
runCount: "{{n}} 次", runCount: "{{n}} 次",
resultEmpty: "无匹配失败/结果行", resultEmpty: "无匹配失败/结果行",
resultFilterPh: "筛选身份 / 字段值…", resultFilterPh: "自由词,或 direction:out network:1.1.1.1",
kindAll: "全部", kindAll: "全部",
kindDiff: "失败", kindDiff: "失败",
kindAdded: "新增", kindAdded: "新增",

View file

@ -11698,6 +11698,164 @@ html.login-page--paused .login-page__flare {
rgba(8, 13, 24, 0.4); rgba(8, 13, 24, 0.4);
} }
.bs-cmp-progress {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid rgba(59, 130, 246, 0.35);
background: rgba(37, 99, 235, 0.12);
color: var(--bs-cmp-ink, #e2e8f0);
font-size: 0.875rem;
}
.bs-cmp-progress.is-failed {
border-color: rgba(239, 68, 68, 0.4);
background: rgba(239, 68, 68, 0.12);
}
.bs-cmp-progress.is-cancelled {
border-color: rgba(245, 158, 11, 0.4);
background: rgba(245, 158, 11, 0.1);
}
.bs-cmp-progress__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
}
.bs-cmp-progress__actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
}
.bs-cmp-progress__engine,
.bs-cmp-progress__elapsed {
font-size: 0.8125rem;
}
.bs-cmp-progress__track,
.bs-cmp-run-prog__track {
height: 6px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.28);
overflow: hidden;
}
.bs-cmp-progress__fill,
.bs-cmp-run-prog__fill {
height: 100%;
width: 0;
border-radius: inherit;
background: linear-gradient(90deg, #2563eb, #60a5fa);
transition: width 0.35s ease;
}
.bs-cmp-progress__fill.is-indeterminate,
.bs-cmp-run-prog__fill.is-indeterminate {
width: 36%;
animation: bs-cmp-prog-slide 1.25s ease-in-out infinite;
}
@keyframes bs-cmp-prog-slide {
0% {
transform: translateX(-120%);
}
100% {
transform: translateX(320%);
}
}
.bs-cmp-progress__meta {
font-size: 0.8125rem;
color: var(--bs-cmp-muted, #94a3b8);
line-height: 1.4;
word-break: break-word;
}
.bs-cmp-progress__msg {
flex: 1 1 100%;
font-size: 0.8125rem;
}
/* Compare batch list (job detail → 对比批次) */
.bs-cmp-runs {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 260px;
}
.bs-cmp-runs__toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 10px 14px;
}
.bs-cmp-runs__hint {
margin: 0;
flex: 1 1 220px;
line-height: 1.45;
}
.bs-cmp-runs__table tbody tr.is-selected td {
background: rgba(37, 99, 235, 0.08);
}
.bs-cmp-runs__elapsed {
margin-top: 4px;
font-size: 0.75rem;
color: var(--muted, #64748b);
font-variant-numeric: tabular-nums;
}
.bs-cmp-runs__sides {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 2px 6px;
font-size: 0.8125rem;
line-height: 1.35;
max-width: 22rem;
}
.bs-cmp-runs__arrow {
color: var(--muted, #94a3b8);
flex: 0 0 auto;
}
.bs-cmp-runs__summary {
font-size: 0.8125rem;
line-height: 1.4;
color: var(--ink, #334155);
max-width: 18rem;
}
.bs-cmp-run-prog {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 160px;
max-width: 22rem;
}
.bs-cmp-run-prog__meta {
font-size: 0.75rem;
line-height: 1.35;
color: var(--muted, #64748b);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Native :fullscreen + CSS immersive fallback (class only when FS API blocked) */ /* Native :fullscreen + CSS immersive fallback (class only when FS API blocked) */
.bs-cmp-board.is-fullscreen, .bs-cmp-board.is-fullscreen,
.bs-cmp-board:fullscreen { .bs-cmp-board:fullscreen {
@ -12129,6 +12287,15 @@ body:has(.bs-cmp-board:fullscreen) {
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.22); box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.22);
} }
.bs-cmp-nav__item.is-pending .bs-cmp-nav__dot {
background: #64748b;
box-shadow: 0 0 0 2px rgba(100, 116, 139, 0.25);
}
.bs-cmp-nav__item.is-pending .bs-cmp-nav__name {
opacity: 0.75;
}
.bs-cmp-nav__name { .bs-cmp-nav__name {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
@ -12472,6 +12639,36 @@ body:has(.bs-cmp-board:fullscreen) {
white-space: nowrap; white-space: nowrap;
} }
.bs-cmp-key-filters {
display: flex;
flex-wrap: wrap;
gap: 8px 10px;
align-items: flex-end;
width: 100%;
}
.bs-cmp-key-filter {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 110px;
max-width: 160px;
flex: 0 1 140px;
}
.bs-cmp-key-filter > span {
font-size: 11px;
line-height: 1.2;
}
.bs-cmp-key-filter .input,
.bs-cmp-key-filter [data-slot="input"],
.bs-cmp-key-filter input {
min-width: 0;
width: 100%;
font-size: 12px;
}
.bs-cmp-kind-pills { .bs-cmp-kind-pills {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -12773,10 +12970,6 @@ body:has(.bs-cmp-board:fullscreen) {
vertical-align: middle; vertical-align: middle;
} }
.bs-cmp-val-cell--diff {
background: rgba(245, 158, 11, 0.08);
}
.bs-cmp-pair { .bs-cmp-pair {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -12784,6 +12977,10 @@ body:has(.bs-cmp-board:fullscreen) {
min-width: 4.5rem; min-width: 4.5rem;
} }
.bs-cmp-pair--same {
min-width: 3rem;
}
.bs-cmp-pair__row { .bs-cmp-pair__row {
display: grid; display: grid;
grid-template-columns: 1.6em minmax(0, 1fr); grid-template-columns: 1.6em minmax(0, 1fr);
@ -12808,6 +13005,20 @@ body:has(.bs-cmp-board:fullscreen) {
color: #86efac; color: #86efac;
} }
.bs-cmp-val-cell--pair.is-same {
background: transparent;
}
.bs-cmp-val--same {
color: #cbd5e1;
opacity: 0.92;
}
.bs-cmp-val-cell--diff {
background: rgba(245, 158, 11, 0.1);
box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.22);
}
.bs-cmp-val { .bs-cmp-val {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
color: #e2e8f0; color: #e2e8f0;
@ -12815,6 +13026,63 @@ body:has(.bs-cmp-board:fullscreen) {
line-height: 1.35; line-height: 1.35;
} }
.bs-cmp-progress.is-compact {
padding: 6px 10px;
gap: 4px;
}
.bs-cmp-progress.is-compact .bs-cmp-progress__track {
height: 4px;
}
.bs-cmp-progress.is-compact .bs-cmp-progress__meta {
font-size: 0.75rem;
}
.bs-cmp-strip__pass-scope {
margin-left: 4px;
font-size: 9px;
font-weight: 600;
color: #64748b;
text-transform: none;
}
.bs-cmp-strip__toggle {
flex: 0 0 auto;
height: 28px;
padding: 0 8px;
border-radius: 6px;
border: 1px solid rgba(148, 163, 184, 0.28);
background: rgba(15, 23, 42, 0.45);
color: #cbd5e1;
font-size: 11px;
cursor: pointer;
white-space: nowrap;
}
.bs-cmp-strip__toggle:hover {
border-color: rgba(96, 165, 250, 0.45);
color: #f1f5f9;
}
.bs-cmp-strip__toggle.is-active {
border-color: rgba(59, 130, 246, 0.5);
background: rgba(37, 99, 235, 0.18);
color: #93c5fd;
}
.bs-cmp-strip__hint {
flex: 1 1 100%;
margin: 0;
font-size: 0.75rem;
line-height: 1.35;
}
.bs-cmp-strip .bs-cmp-key-filters {
flex: 1 1 100%;
margin-top: 0;
}
.bs-cmp-val.is-empty { .bs-cmp-val.is-empty {
color: #64748b; color: #64748b;
font-style: italic; font-style: italic;

File diff suppressed because it is too large Load diff

View file

@ -2154,6 +2154,12 @@ export const bizCompareListRuns = (jobId: string, limit = 20) =>
export const bizCompareGetRun = (runId: string) => export const bizCompareGetRun = (runId: string) =>
apiGet<Record<string, unknown>>(`/v1/biz-state/compare/runs/${encodeURIComponent(runId)}`); apiGet<Record<string, unknown>>(`/v1/biz-state/compare/runs/${encodeURIComponent(runId)}`);
export const bizCompareCancelRun = (runId: string) =>
apiPost<Record<string, unknown>>(
`/v1/biz-state/compare/runs/${encodeURIComponent(runId)}/cancel`,
{},
);
export const bizCompareDeleteRun = (runId: string) => export const bizCompareDeleteRun = (runId: string) =>
apiDelete<{ ok: boolean; job_id?: string; run_id?: string }>( apiDelete<{ ok: boolean; job_id?: string; run_id?: string }>(
`/v1/biz-state/compare/runs/${encodeURIComponent(runId)}`, `/v1/biz-state/compare/runs/${encodeURIComponent(runId)}`,
@ -2164,6 +2170,8 @@ export const bizCompareListRunDiffs = (params: {
metricId?: string; metricId?: string;
kind?: string; kind?: string;
kw?: string; kw?: string;
/** Field filters e.g. { direction: "out", network: "1.1.1.1" } */
qf?: Record<string, string>;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
}) => { }) => {
@ -2171,6 +2179,15 @@ export const bizCompareListRunDiffs = (params: {
if (params.metricId) p.set("metric_id", params.metricId); if (params.metricId) p.set("metric_id", params.metricId);
if (params.kind) p.set("kind", params.kind); if (params.kind) p.set("kind", params.kind);
if (params.kw) p.set("kw", params.kw); if (params.kw) p.set("kw", params.kw);
if (params.qf && Object.keys(params.qf).length) {
const cleaned: Record<string, string> = {};
for (const [k, v] of Object.entries(params.qf)) {
const key = String(k || "").trim();
const val = String(v || "").trim();
if (key && val) cleaned[key] = val;
}
if (Object.keys(cleaned).length) p.set("qf", JSON.stringify(cleaned));
}
p.set("page", String(Math.max(1, Number(params.page || 1)))); p.set("page", String(Math.max(1, Number(params.page || 1))));
p.set("page_size", String(Math.max(1, Math.min(500, Number(params.pageSize || 100))))); p.set("page_size", String(Math.max(1, Math.min(500, Number(params.pageSize || 100)))));
return apiGet<{ return apiGet<{
@ -2179,6 +2196,10 @@ export const bizCompareListRunDiffs = (params: {
page_size: number; page_size: number;
metric_id: string; metric_id: string;
items: Record<string, unknown>[]; items: Record<string, unknown>[];
source?: string;
truncated?: boolean;
live_before_matched?: number;
live_after_matched?: number;
}>(`/v1/biz-state/compare/runs/${encodeURIComponent(params.runId)}/diffs?${p.toString()}`); }>(`/v1/biz-state/compare/runs/${encodeURIComponent(params.runId)}/diffs?${p.toString()}`);
}; };