mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 04:20:45 +08:00
Compare commits
No commits in common. "main" and "v0.3.0" have entirely different histories.
61 changed files with 680 additions and 10620 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
6
.github/workflows/release-windows.yml
vendored
6
.github/workflows/release-windows.yml
vendored
|
|
@ -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
5
.gitignore
vendored
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,109 +166,49 @@ 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:
|
|
||||||
if not isinstance(row, dict):
|
|
||||||
return ""
|
|
||||||
netx = row.get("_netx")
|
|
||||||
if isinstance(netx, dict):
|
|
||||||
return str(netx.get("row_id") or "")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def _append_unchanged_diff(
|
|
||||||
orig: dict[str, Any], mapped: dict[str, Any], after_row: dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
nonlocal unchanged_listed
|
|
||||||
unchanged_listed += 1
|
|
||||||
before_rid = _row_id(orig)
|
|
||||||
after_rid = _row_id(after_row)
|
|
||||||
if compact_unchanged:
|
|
||||||
diffs.append(
|
|
||||||
{
|
|
||||||
"kind": "unchanged",
|
|
||||||
"key": _key_obj(mapped),
|
|
||||||
"before": {},
|
|
||||||
"after": {},
|
|
||||||
"mapped_before": {},
|
|
||||||
"changes": {},
|
|
||||||
"compact": True,
|
|
||||||
"before_row_id": before_rid,
|
|
||||||
"after_row_id": after_rid,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
diffs.append(
|
|
||||||
{
|
|
||||||
"kind": "unchanged",
|
|
||||||
"key": _key_obj(mapped),
|
|
||||||
"before": orig,
|
|
||||||
"after": after_row,
|
|
||||||
"mapped_before": mapped,
|
|
||||||
"changes": {},
|
|
||||||
"before_row_id": before_rid,
|
|
||||||
"after_row_id": after_rid,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Stable key order: before encounter order, then after-only keys
|
|
||||||
seen_keys: set[tuple[str, ...]] = set()
|
|
||||||
ordered_keys: list[tuple[str, ...]] = []
|
|
||||||
for orig, mapped in zip(before_rows, before_mapped):
|
for orig, mapped in zip(before_rows, before_mapped):
|
||||||
k = row_key(mapped, match_keys)
|
k = row_key(mapped, match_keys)
|
||||||
if k not in seen_keys:
|
if k in before_keys:
|
||||||
seen_keys.add(k)
|
before_dup += 1
|
||||||
ordered_keys.append(k)
|
before_dup_keys.append(k)
|
||||||
for r in after_norm:
|
duplicate += 1
|
||||||
k = row_key(r, match_keys)
|
|
||||||
if k not in seen_keys:
|
|
||||||
seen_keys.add(k)
|
|
||||||
ordered_keys.append(k)
|
|
||||||
for k in ordered_keys:
|
|
||||||
b_list = before_groups.get(k) or []
|
|
||||||
a_list = after_groups.get(k) or []
|
|
||||||
if len(b_list) > 1:
|
|
||||||
multi_before_keys.append(k)
|
|
||||||
if len(a_list) > 1:
|
|
||||||
multi_after_keys.append(k)
|
|
||||||
n = max(len(b_list), len(a_list))
|
|
||||||
for i in range(n):
|
|
||||||
if i >= len(b_list):
|
|
||||||
after = a_list[i]
|
|
||||||
added += 1
|
|
||||||
diffs.append(
|
diffs.append(
|
||||||
{
|
{
|
||||||
"kind": "added",
|
"kind": "duplicate",
|
||||||
"key": _key_obj(after),
|
"side": "before",
|
||||||
"before": None,
|
"key": _key_obj(mapped),
|
||||||
"after": after,
|
"before": orig,
|
||||||
"mapped_before": None,
|
"after": after_index.get(k),
|
||||||
|
"mapped_before": mapped,
|
||||||
"changes": {},
|
"changes": {},
|
||||||
"before_row_id": "",
|
|
||||||
"after_row_id": _row_id(after),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
continue
|
continue # only first before row participates in match
|
||||||
if i >= len(a_list):
|
before_keys.add(k)
|
||||||
orig, mapped = b_list[i]
|
after = after_index.get(k)
|
||||||
|
if after is None:
|
||||||
removed += 1
|
removed += 1
|
||||||
diffs.append(
|
diffs.append(
|
||||||
{
|
{
|
||||||
|
|
@ -342,13 +218,11 @@ def compare_rows(
|
||||||
"after": None,
|
"after": None,
|
||||||
"mapped_before": mapped,
|
"mapped_before": mapped,
|
||||||
"changes": {},
|
"changes": {},
|
||||||
"before_row_id": _row_id(orig),
|
|
||||||
"after_row_id": "",
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
orig, mapped = b_list[i]
|
# Empty compare_fields = presence-only: keyed rows that exist on both
|
||||||
after = a_list[i]
|
# sides are unchanged (no value checks).
|
||||||
field_changes: dict[str, dict[str, Any]] = {}
|
field_changes: dict[str, dict[str, Any]] = {}
|
||||||
for f in compare_fields:
|
for f in compare_fields:
|
||||||
bv = mapped.get(f, "")
|
bv = mapped.get(f, "")
|
||||||
|
|
@ -370,26 +244,49 @@ def compare_rows(
|
||||||
"after": after,
|
"after": after,
|
||||||
"mapped_before": mapped,
|
"mapped_before": mapped,
|
||||||
"changes": field_changes,
|
"changes": field_changes,
|
||||||
"before_row_id": _row_id(orig),
|
|
||||||
"after_row_id": _row_id(after),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
unchanged += 1
|
unchanged += 1
|
||||||
if include_unchanged:
|
diffs.append(
|
||||||
unchanged_candidates.append((orig, mapped, after))
|
{
|
||||||
|
"kind": "unchanged",
|
||||||
if include_unchanged and unchanged_candidates:
|
"key": _key_obj(mapped),
|
||||||
if limit_n is None:
|
"before": orig,
|
||||||
picked = unchanged_candidates
|
"after": after,
|
||||||
else:
|
"mapped_before": mapped,
|
||||||
picked = stratify_take(
|
"changes": {},
|
||||||
unchanged_candidates,
|
}
|
||||||
limit_n,
|
)
|
||||||
key_fn=lambda t: stratum_key(t[1]),
|
|
||||||
|
for k, after in after_index.items():
|
||||||
|
if k in before_keys:
|
||||||
|
continue
|
||||||
|
added += 1
|
||||||
|
diffs.append(
|
||||||
|
{
|
||||||
|
"kind": "added",
|
||||||
|
"key": _key_obj(after),
|
||||||
|
"before": None,
|
||||||
|
"after": after,
|
||||||
|
"mapped_before": None,
|
||||||
|
"changes": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for k, after in after_dup_rows:
|
||||||
|
duplicate += 1
|
||||||
|
diffs.append(
|
||||||
|
{
|
||||||
|
"kind": "duplicate",
|
||||||
|
"side": "after",
|
||||||
|
"key": _key_obj(after),
|
||||||
|
"before": None,
|
||||||
|
"after": after,
|
||||||
|
"mapped_before": None,
|
||||||
|
"changes": {},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
for orig, mapped, after_row in picked:
|
|
||||||
_append_unchanged_diff(orig, mapped, after_row)
|
|
||||||
|
|
||||||
def _fmt_keys(keys: list[tuple[str, ...]]) -> list[str]:
|
def _fmt_keys(keys: list[tuple[str, ...]]) -> list[str]:
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -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)",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
sync: bool = Query(False, description="If true, block until compare finishes (tests/debug)"),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Start a compare run. Default is async (returns status=running immediately)."""
|
|
||||||
if sync:
|
|
||||||
return cmp_svc.run_compare(db, job_id)
|
return cmp_svc.run_compare(db, job_id)
|
||||||
return cmp_svc.enqueue_compare(db, job_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/compare/jobs/{job_id}/runs")
|
@router.get("/compare/jobs/{job_id}/runs")
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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="")
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 "'", "''")
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
|
||||||
|
|
@ -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 |
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
param(
|
param(
|
||||||
[string]$Version = "16.4-1",
|
[string]$Version = "16.4-1",
|
||||||
[string]$OutDir = ""
|
[string]$OutDir = ""
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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,18 +25,22 @@ 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"
|
Write-Host "==> Compiling installer: $iscc"
|
||||||
& $iscc "/DMyAppVersion=$Version" (Join-Path $PSScriptRoot "installer\netx.iss")
|
& $iscc "/DMyAppVersion=$Version" (Join-Path $PSScriptRoot "installer\netx.iss")
|
||||||
if (-not (Test-Path $setup)) {
|
if (-not (Test-Path $setup)) {
|
||||||
|
|
@ -44,14 +48,6 @@ if (-not $SkipInstaller) {
|
||||||
}
|
}
|
||||||
Write-Host "==> Setup.exe: $setup" -ForegroundColor Green
|
Write-Host "==> Setup.exe: $setup" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not (Test-Path $setup)) {
|
|
||||||
throw "missing_setup: $setup"
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($Sign) {
|
|
||||||
Write-Host "==> Signing release artifacts"
|
|
||||||
& powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "sign_release.ps1") -Files @($setup)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($SkipGitHub) {
|
if ($SkipGitHub) {
|
||||||
|
|
@ -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 $zip --clobber
|
||||||
|
if (Test-Path $setup) {
|
||||||
gh release upload $tag $setup --clobber
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
|
|
@ -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.
|
|
||||||
$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"
|
$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
|
|
||||||
if ($status -notmatch "server is running") {
|
|
||||||
if (-not (Test-NetxTcpPortFree -HostName "127.0.0.1" -Port $BundledPort)) {
|
|
||||||
throw "port_in_use: 127.0.0.1:$BundledPort already occupied (bundled Postgres)"
|
|
||||||
}
|
|
||||||
Write-Host "==> Starting bundled PostgreSQL for bootstrap"
|
Write-Host "==> Starting bundled PostgreSQL for bootstrap"
|
||||||
& $pgCtl -D $pgData -l (Join-Path $pgData "pg.log") start
|
& $pgCtl -D $pgData -l (Join-Path $data "pgdata\pg.log") start
|
||||||
if ($LASTEXITCODE -ne 0) { throw "pg_start_failed" }
|
|
||||||
Start-Sleep -Seconds 2
|
Start-Sleep -Seconds 2
|
||||||
}
|
|
||||||
|
|
||||||
$env:PGPASSWORD = $BundledPassword
|
$env:PGPASSWORD = $BundledPassword
|
||||||
try {
|
try {
|
||||||
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"]
|
||||||
} else {
|
} elseif ($NonInteractive) {
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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."
|
|
||||||
|
|
@ -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 {}
|
||||||
|
|
|
||||||
|
|
@ -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 = @(
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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*"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
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"
|
$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
|
||||||
|
|
|
||||||
|
|
@ -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 = $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"
|
$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"
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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: "新增",
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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()}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue