Compare commits

..

No commits in common. "main" and "v0.3.0" have entirely different histories.
main ... v0.3.0

61 changed files with 680 additions and 10620 deletions

View file

@ -120,16 +120,8 @@ 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,11 +22,10 @@ jobs:
with: with:
python-version: "3.12" python-version: "3.12"
- name: Build release stage - name: Build release zip
shell: pwsh shell: pwsh
run: | run: |
# Use pwsh -File (UTF-8). Nested Windows PowerShell 5.1 mis-parses UTF-8 scripts without BOM. powershell -ExecutionPolicy Bypass -File ./packaging/build_release.ps1 -CreateVenv
& ./packaging/build_release.ps1 -CreateVenv
- name: Install Inno Setup - name: Install Inno Setup
shell: pwsh shell: pwsh
@ -44,5 +43,6 @@ 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,18 +11,13 @@ 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 (optional) ### Windows installer / portable package (optional)
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`. Fool-proof Windows delivery (bundled or external Postgres, program/data split, manual update) lives under [`packaging/`](packaging/README.md). **Linux installs are unchanged** — keep using your own Postgres and `scripts/start_netx.sh`.
### 6) MCP(Cursor / oclaw / Claude) ### 6) MCP(Cursor / oclaw / Claude)

View file

@ -15,7 +15,6 @@ 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
@ -43,8 +42,6 @@ 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",
@ -159,17 +156,6 @@ 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,7 +2,6 @@
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
@ -12,53 +11,6 @@ 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],
@ -153,28 +105,12 @@ 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 Diff kinds: added | removed | changed | unchanged | duplicate
Pipeline: iface normalize (both sides) → port map (before) → ordered Pipeline: iface normalize (both sides) → port map (before) → match.
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
@ -182,8 +118,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.
``summary.duplicate`` is always 0. ``duplicate_keys_*`` count match keys Duplicate match keys are not silently discarded: extras become ``duplicate``
that appear more than once on a side (diagnostic only). diffs and ``summary.duplicate_key_list`` lists the colliding keys.
``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).
@ -230,166 +166,127 @@ 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
] ]
before_groups: dict[tuple[str, ...], list[tuple[dict[str, Any], dict[str, Any]]]] = ( after_index: dict[tuple[str, ...], dict[str, Any]] = {}
defaultdict(list) after_dup = 0
) after_dup_keys: list[tuple[str, ...]] = []
after_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list) after_dup_rows: list[tuple[tuple[str, ...], dict[str, Any]]] = []
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:
after_groups[row_key(r, match_keys)].append(r) k = row_key(r, match_keys)
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 = 0 added = removed = changed = unchanged = duplicate = 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}
def _row_id(row: dict[str, Any] | None) -> str: for orig, mapped in zip(before_rows, before_mapped):
if not isinstance(row, dict): k = row_key(mapped, match_keys)
return "" if k in before_keys:
netx = row.get("_netx") before_dup += 1
if isinstance(netx, dict): before_dup_keys.append(k)
return str(netx.get("row_id") or "") duplicate += 1
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": "unchanged", "kind": "duplicate",
"side": "before",
"key": _key_obj(mapped), "key": _key_obj(mapped),
"before": {}, "before": orig,
"after": {}, "after": after_index.get(k),
"mapped_before": {}, "mapped_before": mapped,
"changes": {}, "changes": {},
"compact": True, }
"before_row_id": before_rid, )
"after_row_id": after_rid, continue # only first before row participates in match
before_keys.add(k)
after = after_index.get(k)
if after is None:
removed += 1
diffs.append(
{
"kind": "removed",
"key": _key_obj(mapped),
"before": orig,
"after": None,
"mapped_before": mapped,
"changes": {},
}
)
continue
# Empty compare_fields = presence-only: keyed rows that exist on both
# sides are unchanged (no value checks).
field_changes: dict[str, dict[str, Any]] = {}
for f in compare_fields:
bv = mapped.get(f, "")
av = after.get(f, "")
rule = rules.get(f)
if not values_equal(bv, av, rule=rule):
entry: dict[str, Any] = {"before": bv, "after": av}
reason = explain_diff(bv, av, rule=rule)
if reason:
entry["reason"] = reason
field_changes[f] = entry
if field_changes:
changed += 1
diffs.append(
{
"kind": "changed",
"key": _key_obj(mapped),
"before": orig,
"after": after,
"mapped_before": mapped,
"changes": field_changes,
} }
) )
else: else:
unchanged += 1
diffs.append( diffs.append(
{ {
"kind": "unchanged", "kind": "unchanged",
"key": _key_obj(mapped), "key": _key_obj(mapped),
"before": orig, "before": orig,
"after": after_row, "after": after,
"mapped_before": mapped, "mapped_before": mapped,
"changes": {}, "changes": {},
"before_row_id": before_rid,
"after_row_id": after_rid,
} }
) )
# Stable key order: before encounter order, then after-only keys for k, after in after_index.items():
seen_keys: set[tuple[str, ...]] = set() if k in before_keys:
ordered_keys: list[tuple[str, ...]] = [] continue
for orig, mapped in zip(before_rows, before_mapped): added += 1
k = row_key(mapped, match_keys) diffs.append(
if k not in seen_keys: {
seen_keys.add(k) "kind": "added",
ordered_keys.append(k) "key": _key_obj(after),
for r in after_norm: "before": None,
k = row_key(r, match_keys) "after": after,
if k not in seen_keys: "mapped_before": None,
seen_keys.add(k) "changes": {},
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
diffs.append(
{
"kind": "removed",
"key": _key_obj(mapped),
"before": orig,
"after": None,
"mapped_before": mapped,
"changes": {},
"before_row_id": _row_id(orig),
"after_row_id": "",
}
)
continue
orig, mapped = b_list[i]
after = a_list[i]
field_changes: dict[str, dict[str, Any]] = {}
for f in compare_fields:
bv = mapped.get(f, "")
av = after.get(f, "")
rule = rules.get(f)
if not values_equal(bv, av, rule=rule):
entry: dict[str, Any] = {"before": bv, "after": av}
reason = explain_diff(bv, av, rule=rule)
if reason:
entry["reason"] = reason
field_changes[f] = entry
if field_changes:
changed += 1
diffs.append(
{
"kind": "changed",
"key": _key_obj(mapped),
"before": orig,
"after": after,
"mapped_before": mapped,
"changes": field_changes,
"before_row_id": _row_id(orig),
"after_row_id": _row_id(after),
}
)
else:
unchanged += 1
if include_unchanged:
unchanged_candidates.append((orig, mapped, after))
if include_unchanged and unchanged_candidates: for k, after in after_dup_rows:
if limit_n is None: duplicate += 1
picked = unchanged_candidates diffs.append(
else: {
picked = stratify_take( "kind": "duplicate",
unchanged_candidates, "side": "after",
limit_n, "key": _key_obj(after),
key_fn=lambda t: stratum_key(t[1]), "before": None,
) "after": after,
for orig, mapped, after_row in picked: "mapped_before": None,
_append_unchanged_diff(orig, mapped, after_row) "changes": {},
}
)
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()
@ -399,7 +296,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[:64] return out
stats = mapping_stats( stats = mapping_stats(
before_rows=before_norm, before_rows=before_norm,
@ -417,21 +314,11 @@ def compare_rows(
"removed": removed, "removed": removed,
"changed": changed, "changed": changed,
"unchanged": unchanged, "unchanged": unchanged,
"duplicate": 0, "duplicate": duplicate,
"match_key_fields": match_keys, "match_key_fields": match_keys,
"duplicate_keys_before": len(multi_before_keys), "duplicate_keys_before": before_dup,
"duplicate_keys_after": len(multi_after_keys), "duplicate_keys_after": after_dup,
"duplicate_key_list": _fmt_keys(multi_before_keys + multi_after_keys), "duplicate_key_list": _fmt_keys(before_dup_keys + after_dup_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

@ -1,753 +0,0 @@
"""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,7 +23,6 @@ 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)",
@ -32,8 +31,6 @@ 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,8 +633,6 @@ 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 = ""
@ -725,15 +723,8 @@ 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( def api_run_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
job_id: str, return cmp_svc.run_compare(db, job_id)
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.enqueue_compare(db, job_id)
@router.get("/compare/jobs/{job_id}/runs") @router.get("/compare/jobs/{job_id}/runs")
@ -746,12 +737,6 @@ 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)
@ -763,7 +748,6 @@ 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),
@ -774,7 +758,6 @@ 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,9 +80,8 @@ 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). Empty = auto-generate & persist to credential_secret_file. # Managed NE credentials (Fernet key; generate with cryptography.fernet.Fernet.generate_key())
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,8 +303,6 @@ 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)
@ -349,7 +347,4 @@ 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,73 +1,18 @@
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 = ensure_credential_secret_key() key = str(settings.credential_secret_key or "").strip()
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:
@ -92,7 +37,4 @@ def decrypt_secret(value: str) -> str:
def credentials_configured() -> bool: def credentials_configured() -> bool:
try: return bool(str(settings.credential_secret_key or "").strip())
return bool(ensure_credential_secret_key())
except Exception:
return False

View file

@ -7,22 +7,14 @@ 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 + check/apply updates (GitHub Releases or custom manifest) - Manual update script + auto-update **manifest contract** (fetch not implemented yet)
- Optional system tray + start-at-logon
## What users get ## What users get
| Artifact | How | | Artifact | How |
|----------|-----| |----------|-----|
| `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-x.y.z-win64.zip` | `build_release.ps1` |
| `NetX-Setup-x.y.z.exe` | Compile `installer/netx.iss` with [Inno Setup](https://jrsoftware.org/isinfo.php) after staging |
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)
@ -33,13 +25,14 @@ 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 (-CreateVenv ships a ready .venv; large). No zip by default. # Build web + stage + zip (-CreateVenv ships a ready .venv; large)
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 (input to Inno Setup) - `packaging/release/netx-win64/` — stage tree
- `packaging/release/NetX-<ver>-win64.zip`
Inno Setup: Inno Setup:
@ -48,26 +41,28 @@ 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. **First install:** Database page — choose built-in (offline) or external PostgreSQL (host/port/user/password/db; connection must succeed to continue). 2. Files → `%ProgramFiles%\NetX\` (program root).
3. **Already installed:** if `%ProgramData%\NetX\.env` already has `NETX_DB_MODE`, Setup shows an **Install mode** page: 3. Data → `%ProgramData%\NetX\` (`.env`, `pgdata`, spool, secrets).
- **Update** (default) — skip Database page; stop NetX; replace program files; **keep** ProgramData / DB settings. 4. Optional post-install task runs `setup_first_run.ps1` (choose bundled vs external DB).
- **Reinstall** — same Database wizard as first install (`ApplyDatabaseConfig`); does **not** delete ProgramData (use Uninstall → delete data for a wipe). 5. Start menu: **Start NetX** / **Stop NetX** / **Open NetX UI**.
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**.
Silent first install (bundled default): `/SILENT /DbMode=bundled` ### Zip (portable)
Silent external: `/SILENT /DbMode=external /DbHost=... /DbPort=5432 /DbUser=... /DbPassword=... /DbName=...`
Silent update over existing data: `/VERYSILENT /NORESTART /InstallMode=update` (also `/SkipDbPage=1`) 1. Unpack anywhere.
Silent reinstall (DB wizard via params): `/VERYSILENT /InstallMode=reinstall /DbMode=bundled` (or external `/DbHost`…) — also `/ForceDbPage=1` 2. Marker `.portable` → data root is sibling `NetXData\`.
Optional credential key (reuse encrypted NE passwords from another install): `/CredentialSecretKey=...` — omit to auto-generate. 3. Target needs **Python 3.11+** on PATH unless the zip was built with `-CreateVenv`.
4. Run:
```powershell
.\packaging\setup_first_run.ps1
.\packaging\start_netx_app.ps1
```
## Database modes ## Database modes
@ -81,122 +76,15 @@ 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.4.0-win64.zip .\packaging\update_netx.ps1 -PackagePath .\NetX-0.3.0-win64.zip
``` ```
Stops services, replaces program folders (`netx_api`, `web`, `packaging`, `postgres`, …), **keeps** the data root, restarts. Schema migrations still run via Alembic on API start. Stops services, replaces program folders (`netx_api`, `web`, `packaging`, `postgres`, …), **keeps** the data root, restarts. Schema migrations still run via Alembic on API start.
## Check / apply updates ## Auto-update (reserved)
**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. 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.
```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,33 +114,7 @@ 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
} }
$text = ($lines -join "`r`n") Set-Content -Path $Path -Value ($lines -join "`r`n") -Encoding utf8
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 {
@ -161,382 +135,3 @@ 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

@ -1,6 +0,0 @@
# 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

@ -1,56 +0,0 @@
"""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.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 575 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View file

@ -1,14 +1,10 @@
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,
# Default on: offline Setup must ship .venv so target PCs never pip-install. [switch]$CreateVenv = $false
[switch]$CreateVenv = $true
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@ -55,20 +51,6 @@ 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
@ -84,70 +66,26 @@ $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", "check_update.ps1", "stop_netx_app.ps1", "update_netx.ps1", "build_release.ps1",
"launch_shortcut.ps1", "netx_tray.ps1", "install_autostart.ps1", "install_service.ps1", "README.md", "manifest.example.json"
"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 (bin/lib/share; skip doc/)' Write-Host "==> Copying bundled PostgreSQL"
$pgStage = Join-Path $stage "postgres\pgsql" New-Item -ItemType Directory -Path (Join-Path $stage "postgres") -Force | Out-Null
New-Item -ItemType Directory -Path $pgStage -Force | Out-Null Copy-Item -Path $pgsql -Destination (Join-Path $stage "postgres\pgsql") -Recurse -Force
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")
@ -161,93 +99,26 @@ $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 portable python/runtime + .venv (must not reference build-machine paths)" Write-Host "==> Creating .venv in stage (requires Python 3.11+ on PATH)"
$pyPath = Get-NetxSystemPython $py = (Get-Command python -ErrorAction SilentlyContinue)
if (-not $pyPath) { throw "python_not_found_for_venv: need Python 3.11+ (not WindowsApps stub)" } if (-not $py) { throw "python_not_found_for_venv" }
Write-Host " Build Python: $pyPath" & $py.Source -m venv (Join-Path $stage ".venv")
& (Join-Path $stage ".venv\Scripts\python.exe") -m pip install --upgrade pip
$prefix = (& $pyPath -c "import sys; print(sys.base_prefix)" 2>$null | Out-String).Trim() & (Join-Path $stage ".venv\Scripts\python.exe") -m pip install -r (Join-Path $stage "requirements.txt")
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 ($SkipZip -and $CreateZip) { if (-not $SkipZip) {
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 (optional/dev): $zip" -ForegroundColor Yellow Write-Host "==> Zip: $zip" -ForegroundColor Green
} }
Write-Host "" Write-Host ""
Write-Host "Ship:" Write-Host "Ship options:"
Write-Host " Zip: user unpacks, runs packaging\setup_first_run.ps1 then start_netx_app.ps1"
Write-Host " Exe: compile packaging\installer\netx.iss with Inno Setup (needs stage above)" Write-Host " 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"

View file

@ -1,436 +0,0 @@
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

@ -1,28 +0,0 @@
@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

@ -1,7 +0,0 @@
@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

@ -1,7 +0,0 @@
@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

@ -1,7 +0,0 @@
@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,11 +5,5 @@
# 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

@ -1,25 +0,0 @@
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

@ -1,141 +0,0 @@
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

@ -1,57 +0,0 @@
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

@ -1,417 +0,0 @@
; *** 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

@ -1,104 +0,0 @@
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

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

View file

@ -1,747 +0,0 @@
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

@ -1,129 +0,0 @@
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,10 +1,9 @@
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"
@ -16,6 +15,7 @@ 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,33 +25,29 @@ 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) {
throw "innosetup_not_found: install with winget install JRSoftware.InnoSetup" Write-Host "[WARN] Inno Setup (ISCC) not found. Install: winget install JRSoftware.InnoSetup" -ForegroundColor Yellow
Write-Host " Skipping Setup.exe; zip-only release."
} else {
Write-Host "==> Compiling installer: $iscc"
& $iscc "/DMyAppVersion=$Version" (Join-Path $PSScriptRoot "installer\netx.iss")
if (-not (Test-Path $setup)) {
throw "installer_build_failed: expected $setup"
}
Write-Host "==> Setup.exe: $setup" -ForegroundColor Green
} }
Write-Host "==> Compiling installer: $iscc"
& $iscc "/DMyAppVersion=$Version" (Join-Path $PSScriptRoot "installer\netx.iss")
if (-not (Test-Path $setup)) {
throw "installer_build_failed: expected $setup"
}
Write-Host "==> Setup.exe: $setup" -ForegroundColor Green
}
if (-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) {
@ -64,32 +60,30 @@ 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
$viewCode = $LASTEXITCODE if ($LASTEXITCODE -eq 0) {
$ErrorActionPreference = $prevEap
if ($viewCode -eq 0) {
Write-Host "==> Release $tag exists; uploading assets" Write-Host "==> Release $tag exists; uploading assets"
gh release upload $tag $setup --clobber gh release upload $tag $zip --clobber
if (Test-Path $setup) {
gh release upload $tag $setup --clobber
}
} else { } else {
$notes = @" $notes = @"
## NetX $Version ## NetX $Version — first Windows installable release
### Downloads ### Downloads
- **NetX-Setup-$Version.exe** — Windows installer (Program Files + ProgramData) - **NetX-Setup-$Version.exe** — recommended installer (Program Files + ProgramData)
- **NetX-$Version-win64.zip** — portable directory package
### Requirements ### Requirements
- Windows 10/11 x64 (or Windows Server 2016+) - Windows 10/11 x64
- No separate Python or PostgreSQL install required (bundled) - No separate Python or PostgreSQL install required (bundled in package)
### Quick start ### Quick start (installer)
1. Run ``NetX-Setup-$Version.exe`` as administrator. 1. Run ``NetX-Setup-$Version.exe`` as administrator.
2. **First install:** choose built-in or external PostgreSQL. 2. Complete first-time setup (choose **bundled** or **external** PostgreSQL).
3. **Already installed:** Setup detects existing data and updates in place (keeps DB settings). 3. Start menu → **Start NetX** → browser opens ``http://127.0.0.1:8890/``
4. Start menu → **Start NetX** → ``http://127.0.0.1:8890/`` 4. Default login: ``admin`` / ``admin123`` (change after first login)
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``.
@ -98,7 +92,8 @@ 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 += $setup $args += $zip
if (Test-Path $setup) { $args += $setup }
& gh @args & gh @args
} }

View file

@ -1,25 +0,0 @@
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

View file

@ -1,100 +0,0 @@
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,16 +1,12 @@
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"
@ -19,19 +15,20 @@ $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-)')
function T([string]$En, [string]$Zh) { Write-Host "==> Program root: $prog"
if ($zh) { return $Zh } else { return $En } Write-Host "==> Data root: $data"
Write-Host "==> Env file: $envPath"
if (-not (Test-Path $data)) {
New-Item -ItemType Directory -Path $data -Force | Out-Null
}
foreach ($sub in @("data", "data\auth", "data\runtime", "backups", "pgdata")) {
$p = Join-Path $data $sub
if (-not (Test-Path $p)) {
New-Item -ItemType Directory -Path $p -Force | Out-Null
}
} }
try {
Write-Host ("==> " + (T "Program root:" "程序目录:") + " $prog")
Write-Host ("==> " + (T "Data root:" "数据目录:") + " $data")
Write-Host ("==> " + (T "Env file:" "环境文件:") + " $envPath")
Ensure-NetxDataDirectories -DataRoot $data
$existing = Read-DotEnv -Path $envPath $existing = Read-DotEnv -Path $envPath
if (-not $DbMode) { if (-not $DbMode) {
@ -45,11 +42,10 @@ if (-not $DbMode) {
} }
} else { } else {
Write-Host "" Write-Host ""
Write-Host (T "Choose database mode:" "选择数据库模式:") -ForegroundColor Cyan Write-Host "Choose database mode:"
Write-Host (T " 1) bundled — NetX portable PostgreSQL (offline / default)" " 1) bundled — NetX 内置便携 PostgreSQL(可离线,默认)") Write-Host " 1) bundled — use NetX portable PostgreSQL (default for new installs)"
Write-Host (T " 2) external — existing PostgreSQL (you provide connection URL)" " 2) external — 已有外置 PostgreSQL(需填写连接串)") Write-Host " 2) external — connect to an existing PostgreSQL (Linux / already deployed)"
Write-Host "" $choice = Read-Host "Enter 1 or 2"
$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" }
} }
} }
@ -62,44 +58,28 @@ $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"
# Absolute UI path when present; else relative for source trees. $values["NETX_UI_DIST_DIR"] = "web/dist"
$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"
}
# All runtime data under data root (never under Program Files). # Point runtime data dirs into the data root (absolute).
$dataEnv = Get-NetxDataEnvMap -DataRoot $data $values["NETX_AUTH_MCP_TOKEN_FILE"] = ((Join-Path $data "data\auth\mcp_token") -replace '\\', '/')
foreach ($k in $dataEnv.Keys) { $values[$k] = $dataEnv[$k] } $values["NETX_SCHEDULER_HEARTBEAT_PATH"] = ((Join-Path $data "data\runtime\scheduler_heartbeat.json") -replace '\\', '/')
# 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 (T ` throw "bundled_postgres_missing: run packaging\download_postgres.ps1 first (expected $pgsql)"
"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 (T "Password for bundled role 'netx' (empty = auto-generate)" "内置数据库用户 netx 的密码(回车=自动生成)") -AsSecureString $sec = Read-Host -Prompt "Password for bundled role 'netx' (empty = auto-generate)" -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)
@ -108,22 +88,24 @@ 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 (T "Generated password (saved in .env only)." "已自动生成密码(仅保存在 .env)。") Write-Host "Generated password (saved in .env only)."
} }
} }
} }
$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"] = (ConvertTo-NetxFsPath $pgData) $values["NETX_BUNDLED_PG_DATA_DIR"] = ($pgData -replace '\\', '/')
$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"
$sqlPw = ConvertTo-NetxSqlLiteral $BundledPassword $createdb = Join-Path $pgsql "bin\createdb.exe"
$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"
@ -133,6 +115,7 @@ 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) {
@ -143,8 +126,6 @@ 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
} }
@ -156,216 +137,49 @@ if ($DbMode -eq "bundled") {
} }
} }
$status = & $pgCtl -D $pgData status 2>&1 | Out-String Write-Host "==> Starting bundled PostgreSQL for bootstrap"
if ($status -notmatch "server is running") { & $pgCtl -D $pgData -l (Join-Path $data "pgdata\pg.log") start
if (-not (Test-NetxTcpPortFree -HostName "127.0.0.1" -Port $BundledPort)) { Start-Sleep -Seconds 2
throw "port_in_use: 127.0.0.1:$BundledPort already occupied (bundled Postgres)"
}
Write-Host "==> Starting bundled PostgreSQL for bootstrap"
& $pgCtl -D $pgData -l (Join-Path $pgData "pg.log") start
if ($LASTEXITCODE -ne 0) { throw "pg_start_failed" }
Start-Sleep -Seconds 2
}
$env:PGPASSWORD = $BundledPassword $env:PGPASSWORD = $BundledPassword
try { try {
function Invoke-NetxPsql { $role = & $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname='netx'"
param([string]$Sql, [string]$Database = "postgres") if ($role -notmatch "1") {
$out = & $psql -h 127.0.0.1 -p $BundledPort -U postgres -d $Database -v ON_ERROR_STOP=1 -tAc $Sql 2>&1 & $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -v ON_ERROR_STOP=1 `
if ($LASTEXITCODE -ne 0) { -c "CREATE ROLE netx LOGIN PASSWORD '$BundledPassword';"
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 {
Invoke-NetxPsql -Sql "CREATE ROLE netx LOGIN PASSWORD '$sqlPw'" | Out-Null & $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -v ON_ERROR_STOP=1 `
-c "ALTER ROLE netx WITH LOGIN PASSWORD '$BundledPassword';"
} }
$db = Invoke-NetxPsql -Sql "SELECT 1 FROM pg_database WHERE datname='netx'" $db = & $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='netx'"
if ($db -ne "1") { if ($db -notmatch "1") {
Invoke-NetxPsql -Sql "CREATE DATABASE netx OWNER netx" | Out-Null & $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -v ON_ERROR_STOP=1 `
} -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) {
} else { throw "external_url_required"
throw "external_url_required"
}
} else { } else {
# Interactive: always ask. Empty = keep existing URL (never silent). $ExternalDatabaseUrl = Read-Host "NETX_DATABASE_URL (postgresql+psycopg://user:pass@host:5432/netx)"
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" -ForegroundColor Green Write-Host "==> External Postgres configured (ensure role/db exist; see scripts\init_pg.ps1)" -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

@ -1,78 +0,0 @@
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,9 +1,8 @@
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"
@ -22,39 +21,25 @@ 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"),
@ -69,20 +54,13 @@ 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"] -replace '/', '\' $map["NETX_BUNDLED_PG_DATA_DIR"]
} 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
@ -96,48 +74,38 @@ if ($mode -eq "bundled") {
} }
} }
try { # Ensure venv exists for start_netx.ps1
$null = Ensure-NetxVenv -ProgramRoot $prog $venvPy = Join-Path $prog ".venv\Scripts\python.exe"
} catch { if (-not (Test-Path $venvPy)) {
Write-Host "[ERR] $($_.Exception.Message)" -ForegroundColor Red Write-Host "==> Creating .venv (one-time)"
Write-Host " Tip: run Start Menu → NetX → First-time setup once as Administrator," -ForegroundColor Yellow $pyCmd = Get-Command python -ErrorAction SilentlyContinue
Write-Host " or install a Setup built with packaging\\build_release.ps1 -CreateVenv." -ForegroundColor Yellow if (-not $pyCmd) { throw "python_not_found: install Python 3.11+ and re-run" }
exit 1 & $pyCmd.Source -m venv (Join-Path $prog ".venv")
& $venvPy -m pip install --upgrade pip
& $venvPy -m pip install -r (Join-Path $prog "requirements.txt")
if ($LASTEXITCODE -ne 0) { throw "pip_install_failed" }
} }
if (-not (Test-NetxTcpPortFree -HostName $hostBind -Port $port)) { $hostBind = if ($env:NETX_HOST) { $env:NETX_HOST } else { "127.0.0.1" }
if (Test-NetxApiHealthy -HostName $hostBind -Port $port) { $port = if ($env:NETX_PORT) { [int]$env:NETX_PORT } else { 8890 }
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)"
# Run in-process with -Background so this console exits after /health (nested & powershell @psArgs
# Start-Process -Wait on a Hidden child often never returns under UAC update). if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$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,8 +1,7 @@
param( param(
[string]$ProgramRoot = "", [string]$ProgramRoot = "",
[string]$DataRoot = "", [string]$DataRoot = "",
[switch]$KeepPostgres = $false, [switch]$KeepPostgres = $false
[switch]$KillTray = $false
) )
$ErrorActionPreference = "Continue" $ErrorActionPreference = "Continue"
@ -22,17 +21,11 @@ if (-not (Test-Path $stopScript)) {
} }
if (Test-Path $stopScript) { if (Test-Path $stopScript) {
Write-Host "==> Stopping NetX processes" Write-Host "==> Stopping NetX processes"
Start-NetxPowerShell -File $stopScript -Arguments @("-Force") ` & powershell -ExecutionPolicy Bypass -File $stopScript -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

@ -1,53 +0,0 @@
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

@ -1,71 +0,0 @@
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,14 +22,10 @@ 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 from build_release.ps1 -CreateZip, or NetX-Setup-*.exe" throw "unsupported_package: use a .zip built by build_release.ps1"
} }
$src = $work $src = $work
@ -58,35 +54,7 @@ try {
if (Test-Path $envFile) { if (Test-Path $envFile) {
Copy-Item $envFile (Join-Path $bak ".env") Copy-Item $envFile (Join-Path $bak ".env")
} }
# Best-effort logical backup when psql is available (bundled or PATH). Write-Host "==> Backup marker: $bak (data/pgdata left in place; optional pg_dump not run)"
$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"
@ -94,8 +62,7 @@ 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.
# Include python/ (portable runtime) — required with shipped .venv since 0.4.6. $replaceDirs = @("netx_api", "alembic", "web", "packaging", "scripts", "postgres", "packages")
$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
@ -112,7 +79,7 @@ try {
Copy-Item -Path $from -Destination (Join-Path $prog $f) -Force Copy-Item -Path $from -Destination (Join-Path $prog $f) -Force
} }
} }
# Legacy top-level runtime/ (older packages) + shipped .venv # Optional runtime / .venv shipped in package
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 }
@ -125,21 +92,11 @@ 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) {
# Same process (no nested powershell) so the update console can exit cleanly. & powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "start_netx_app.ps1") `
& (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.4.12" version = "0.3.0"
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,4 +43,3 @@ 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,24 +23,9 @@ if ($Backgtound -and -not $Background) {
$projectRoot = Split-Path -Parent $PSScriptRoot $projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location $projectRoot Set-Location $projectRoot
$packCommon = Join-Path $projectRoot "packaging\_common.ps1" $runDir = Join-Path $PSScriptRoot ".run"
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 -Force | Out-Null New-Item -ItemType Directory -Path $runDir | Out-Null
} }
$pidFile = Join-Path $runDir "netx.pid" $pidFile = Join-Path $runDir "netx.pid"
@ -54,17 +39,13 @@ $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 (Get-Command Test-NetxVenvRunnable -ErrorAction SilentlyContinue) { if (-not (Test-Path $venvPython)) {
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)) { }
throw "failed_to_create_venv" if (-not (Test-Path $venvPython)) {
} throw "failed_to_create_venv"
} }
$pythonExe = $venvPython $pythonExe = $venvPython
@ -272,11 +253,7 @@ if ($Background) {
Show-LogTail -Path $logFile Show-LogTail -Path $logFile
exit 1 exit 1
} }
# Fresh installs run Alembic upgrades on first boot; 45s is often too short. $healthWaitSec = 45
$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,13 +9,7 @@
$ErrorActionPreference = "Continue" $ErrorActionPreference = "Continue"
$useForce = if ($NoForce) { $false } else { [bool]$Force } $useForce = if ($NoForce) { $false } else { [bool]$Force }
if ($env:NETX_RUN_DIR) { $runDir = Join-Path $PSScriptRoot ".run"
$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,8 +5,7 @@ from __future__ import annotations
import copy import copy
import unittest import unittest
from netx_api.biz_state.compare_engine import compare_rows, mapping_stats, stratify_take from netx_api.biz_state.compare_engine import compare_rows, mapping_stats
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,
@ -97,18 +96,7 @@ 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"]}
# Default: unchanged counted but not listed (million-row safe) self.assertIn("unchanged", kinds)
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)."""
@ -161,107 +149,10 @@ 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"}]
@ -418,57 +309,6 @@ 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
@ -514,43 +354,25 @@ 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)
# Packaged IOH default: filtered bgp_route ipv4 + percent pfx_rcd detail = next(s for s in sheets if sheet_key(s) == "interface_detail")
route4 = next(s for s in sheets if sheet_key(s) == "bgp_route") self.assertEqual(detail["compare_fields"], ["port_status"])
self.assertEqual(route4["metric_id"], "bgp_route") self.assertIn("input_bps", detail["display_fields"])
self.assertTrue( optical = next(s for s in sheets if sheet_key(s) == "optical_brief")
any( self.assertEqual(optical["compare_fields"], ["status"])
f.get("field") == "afi" and f.get("value") == "ipv4" self.assertIn("rx_power", optical["display_fields"])
for f in (route4.get("row_filters") or []) bgp4 = next(s for s in sheets if sheet_key(s) == "bgp_peer.ipv4")
) 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( self.assertEqual(vpnv4["row_filters"], [{"field": "afi", "op": "eq", "value": "vpnv4"}])
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_ordered_same_key_pairing_2v2(self) -> None: def test_duplicate_match_keys_are_reported(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"},
@ -567,47 +389,15 @@ 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.assertNotIn("duplicate", kinds) self.assertEqual(kinds.count("duplicate"), 2)
# Pair 0: 1==1 unchanged; pair 1: 9!=2 changed # First before wins → matches first after → unchanged (same remote_ip)
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

@ -1,98 +0,0 @@
"""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

@ -1,228 +0,0 @@
"""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,53 +494,6 @@ 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",
@ -549,25 +502,15 @@ 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: "Free text, or direction:out network:1.1.1.1", resultFilterPh: "Filter key / values…",
kindAll: "All", kindAll: "All",
kindDiff: "Fail", kindDiff: "Fail",
kindAdded: "Added", kindAdded: "Added",
@ -599,7 +542,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: "Free text, or direction:out network:1.1.1.1", resultFilterPh: "Filter identity / values…",
diffCount: "{{n}} failed", diffCount: "{{n}} failed",
passRate: "Pass rate", passRate: "Pass rate",
passOk: "All passed", passOk: "All passed",

View file

@ -493,53 +493,6 @@ 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: "比对任务已删除",
@ -548,25 +501,15 @@ 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: "自由词,或 direction:out network:1.1.1.1", resultFilterPh: "筛选身份 / 字段值…",
kindAll: "全部", kindAll: "全部",
kindDiff: "失败", kindDiff: "失败",
kindAdded: "新增", kindAdded: "新增",

View file

@ -11698,164 +11698,6 @@ 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 {
@ -12287,15 +12129,6 @@ 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;
@ -12639,36 +12472,6 @@ 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;
@ -12970,6 +12773,10 @@ 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;
@ -12977,10 +12784,6 @@ 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);
@ -13005,20 +12808,6 @@ 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;
@ -13026,63 +12815,6 @@ 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,12 +2154,6 @@ 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)}`,
@ -2170,8 +2164,6 @@ 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;
}) => { }) => {
@ -2179,15 +2171,6 @@ 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<{
@ -2196,10 +2179,6 @@ 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()}`);
}; };