Compare commits

..

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

123 changed files with 1805 additions and 18233 deletions

View file

@ -120,16 +120,8 @@ NETX_UME_NOTIFICATION_TOPIC=ALARM
# bundled | external; unset = external (compatible with existing deploys)
# NETX_BUNDLED_PG_PORT=15432
# 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_FALLBACK_URL=
# NETX_UPDATE_TOKEN=
# 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.
# One-click start (recommended): .\scripts\start_netx.ps1 -Background -WithWeb
# -> API + netx_api.worker + N× netx_api.biz_state_worker + optional Vite

View file

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

5
.gitignore vendored
View file

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

View file

@ -158,9 +158,9 @@ API base: `http://127.0.0.1:8890/`
After `npm run build` in `web/`, the API can also serve the UI at `http://127.0.0.1:8890/` (same origin). See `NETX_UI_DIST_DIR` in `.env.example`.
### 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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

View file

@ -1,29 +0,0 @@
{
"date": "2026-10-10",
"baseline_ref": "afa8e06b2de64100f963ae30b3babfcb5d7009f4",
"python": "3.14.3",
"dataset": {
"rows_per_side": 1000000,
"fields": ["id", "neighbor", "state", "afi"],
"strata": 50,
"unchanged_limit": 5000,
"compact_unchanged": true,
"interface_transforms": false,
"all_rows_unchanged": true
},
"measurement": "tracemalloc peak allocated during compare_rows; excludes input allocations, DB loading and process overhead",
"results": [
{
"engine": "baseline",
"peak_engine_mib": 1144.43,
"seconds_with_tracemalloc": 92.4
},
{
"engine": "optimized",
"peak_engine_mib": 420.49,
"seconds_with_tracemalloc": 63.681
}
],
"results_and_sample_order_identical": true,
"test_result": "1 passed in 157.88s"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

View file

@ -1,131 +0,0 @@
# 业务对比检查与优化记录
检查日期:2026-10-10。范围:网络管理 → 割接对比 → 业务对比,包括前端任务/批次/结果界面、Python 与 PostgreSQL 对比路径、结果分页与搜索、短期列表缓存。
本次直接修改项目代码并添加回归测试,没有连接设备执行采集,也没有修改生产数据库。浏览器截图与交互测试使用固定 API 响应。
## 已修复的问题
### 1. 搜索会改变业务判定(P1)
原实现分别按搜索条件过滤割接前、后的原表,再重新对比。例如 `state=up → down`,搜索 `up` 后只剩前侧,原本的“变化”就可能变成“删除”。重复身份键的业务还可能在筛选后错位配对。
现在:已存储的判定直接在结果表分页筛选;需要回查未保存全量明细的成功行时,先找到候选身份键,再补齐两侧的完整分组,按原有配对规则对比,最后筛选。达到读取上限的分组整体跳过,并返回截断标记,避免产生虚假的新增或删除。
### 2. PostgreSQL 搜索漏用部分模板过滤条件(P1)
原搜索只下推 SQL 支持的过滤规则,对 `age_timer` 等不支持的规则直接忽略,可能将静态 ARP 等不属于模板范围的数据纳入结果。现在不支持下推的规则走流式读取并在 Python 中完整过滤。
### 3. 快速切换时旧请求覆盖当前任务(P1)
前后采集任务的批次列表、打开任务、加载历史结果以及轮询缺少完整的过期保护。现在对批次请求使用清理标记,对任务与结果使用请求序号;关闭弹窗、切换路由或打开其他任务后,旧响应不能覆盖当前界面。
### 4. 结果加载错误容易被误读(P2)
原查询失败时保留上一组明细,界面已经显示新筛选条件;切到未完成的表后,加载状态也可能无法结束。现在加载和失败都有独立状态,错误时清除旧明细并提供重试入口;切换表/页签后清理加载状态。
### 5. LLDP 搜索漏查(P2)
LLDP 数据存于 `biz_state_lldp_neighbor`,原搜索只读 `biz_state_metric_row`。现在原表回查与精简结果的字段搜索都支持 LLDP 表。
### 6. 其他已修复边界
- 恰好命中读取上限时,原非 PostgreSQL 路径错误地报告“已截断”;现在多读一条判断。
- 无效或有歧义的表标识可能落到其他表;现在返回 `sheet_not_found`。
- `%`、`_` 等搜索字符按字面值匹配,避免不同查询路径含义不一致。
- 关闭最后一项检查后,空数组被解释为“启用全部”;现在阻止关闭最后一项并提示至少保留一项。
- 两个 HeroUI Input 使用了 `isDisabled`,实际需要 `disabled`;过滤值与容差输入的禁用状态已修正。
- 列表缓存缺少并发合并,慢请求可以覆盖强制刷新结果或在失效后重新填充缓存;现在共享请求、保护失效状态并抑制过期的后台通知。
- 清理中英文文案中的重复键,解决相关类型检查错误。
- 操作后任务选择“同操作前”时原来没有加载批次列表;现在按实际使用的任务读取并共享缓存。切换采集任务时清除原批次选择,避免把上一个任务的批次带入配置。
- 原通用下拉框嵌套了两个 label,标签不能正确关联控件;现在使用明确的 `htmlFor/id`,并关联提示文本。
## 性能改动与实际收益范围
- 合并两套轮询,下一轮在本轮请求完成后启动。模拟浏览器测试中,运行任务在 5.2 秒内历史列表和详情各请求 2–3 次(包含可能尚未完成的初始加载),没有重复轮询,也不会因慢请求持续叠加。
- 已保存的失败、新增、变化等结果在数据库中筛选并分页,避免每次翻页重新加载和对比原始批次。回归测试明确验证这一查询路径不调用 `compare_rows`。
- 同一前后采集任务的批次请求共用短期缓存,列表后台刷新合并同一请求;6 项缓存测试覆盖并发、强制刷新、失败重试与失效竞态。
- 模板查找使用 Map,避免列表搜索与绘制逐项线性查找模板。
- 修改筛选后先回到第一页,避免先请求旧页码再重复请求第一页。
这些改动减少了重复工作。以下后续基准使用合成数据,没有测量生产环境延迟。
## 后续优化(2026-10-10)
### 历史执行配置与精简行还原
新运行在创建时将模板、启用的检查项、字段规则/过滤、接口归一化、端口映射、成功行保存策略和名称存入 `summary_json.config_snapshot`。后台执行、成功行回查、精简行还原以及导出共用该配置;排队之后修改配置,甚至删除原模板/映射,也不会改变这一运行使用的配置。无需数据库结构迁移。
修复了精简存储明细还原时将 `mapped_before` 直接设为原始 `before` 的问题。现在前侧依次归一化和映射,后侧归一化,保持与完整结果的显示及 CSV 一致。ZIP 新增 `config_snapshot.json`,便于核对当时配置;详情接口只返回快照模板与版本,不将完整端口映射加入轮询响应。
页面显示“执行配置已保存”;旧记录显示“旧记录:未保存执行配置”。旧运行无法可靠补造当时的配置,仍保留兼容查询,成功行回查及精简行还原会使用当前配置。快照也不保存原始采集行的副本,若原始批次数据被清理,依赖行 ID 的还原仍会缺少内容。
### Python 大表内存
成功样本按“层内序号、层首次出现顺序”排序,只保留样本上限内的候选和分层状态,避免先积累全部成功行。保留原分层轮询顺序;未截断的小表仍保持采集顺序。不需要接口变换时复用输入行;有变换时创建新行,不修改输入数据。稳定身份键顺序直接使用分组字典,省去再次计算全表键、额外键列表与集合。
每侧 1,000,000 行、50 个邻居分层、全部成功、保存 5,000 条精简样本的测试:优化前引擎新增分配峰值 **1144.43 MiB**,优化后 **420.49 MiB**,下降约 **63.3%**。完整结果与样本顺序的 SHA-256 一致。开启 `tracemalloc` 时耗时为 92.400 秒和 63.681 秒;该计时含内存追踪开销,不代表生产耗时。输入行在追踪开始前创建,峰值不包含输入行、数据库加载或 Python 进程的总内存。
基线为 `afa8e06b2de64100f963ae30b3babfcb5d7009f4` 中的对比引擎。原始指标见 [百万行测试结果](assets/biz-compare-memory-benchmark.json)。身份键分组仍需要随原表行数增长的内存;这次不是完全流式对比。
### 大表导出
导出改为按 `(seq, id)` 游标分批查询、分批还原精简行、逐行将 UTF-8 CSV 写入 ZIP,避免将全部明细与完整 CSV 同时保存在内存。相同 `seq` 的多条结果按 `id` 连续读取,不漏掉跨批次的同行序号记录。保留 BOM、字段顺序及逗号/引号/换行转义。最终压缩包仍由现有接口在内存中返回,压缩文件本身的大小仍计入内存。
## UI 改动
任务列表、创建向导、任务配置、对比批次和结果区统一使用深色平面、细边框、小圆角、低饱和蓝色和等宽数字。任务配置按作业参数、前后数据源、检查范围、端口映射四个区块组织;数据源左右配对,显示设备地址和采集数量。保存配置作为配置页主操作,删除操作降低视觉权重。
批次页增加最近 50 条记录的统计、刷新、加载、错误重试;刷新失败保留已载入记录。已完成只表示执行结束,业务失败/成功/新增数量单独显示。排队与运行使用不同文案,未知进度使用不确定进度条;时间、批次标识和前后采集信息便于核查。页签支持方向键及 Home/End,窄屏配置改为单列,批次表在独立容器中横向滚动。
结果区分为当前检查项/全表通过率、结果类型、搜索与辅助操作三层。增加明确的分页范围、清空筛选、加载状态、错误重试和搜索无结果提示。筛选按钮增加按下状态,字段筛选增加展开状态与键盘焦点提示。后续增加了执行配置保存状态及旧记录限制提示。
窄屏检查项导航改为紧凑的横向排列,避免一个检查项占据整行的大块区域。浏览器检查覆盖 1440px、768px、390px;筛选栏没有横向溢出。结果表保留必要的横向滚动。
- [任务列表](assets/biz-compare-jobs.png)
- [桌面任务配置](assets/biz-compare-config.png)
- [390px 任务配置](assets/biz-compare-config-mobile.png)
- [桌面对比批次](assets/biz-compare-runs.png)
- [390px 对比批次](assets/biz-compare-runs-mobile.png)
- [桌面结果区](assets/biz-compare-desktop.png)
- [768px 结果区](assets/biz-compare-narrow.png)
- [加载失败与重试](assets/biz-compare-error.png)
- [旧记录执行配置提示](assets/biz-compare-legacy.png)
## 验证
- Python:87 项通过,1 项百万行基准默认跳过;单独启用百万行基准后通过。新增测试覆盖搜索、历史配置快照、有界抽样与游标导出。
- 缓存:6 项 Node 测试通过。
- 浏览器:27 个场景通过,包括任务列表文案、配置保存数据、同任务批次加载与切换清理、自动模式、检查项开关、创建向导、键盘页签、批次统计/状态/刷新/错误重试、配置保存状态、旧记录提示、结果布局、筛选、窄屏布局、旧任务响应保护、单套轮询、无运行时异常。
- Vite 生产打包成功。
- 本次业务对比页面的类型错误已经修复。全项目 `tsc -b` 仍被 `BizStatePage.tsx`、`BizMigrationPage.tsx` 中原有的类型/未使用变量错误阻塞。
- 页面 ESLint 原有 34 个错误,主要是 `any` 与现有 React Hook 写法;批次列表增加类型后减少了 `any`,本次未增加错误。结果请求依赖警告已修正。通用下拉框和缓存模块没有 lint 错误。
- PostgreSQL 路径验证了 SQL 编译与规则降级分支,没有在真实 PostgreSQL 数据库上执行查询或测量执行计划。
可重复执行的检查:
```powershell
# 项目根目录
.venv\Scripts\python.exe -m pytest tests/test_biz_state_compare_export.py tests/test_biz_state_compare_snapshot.py tests/test_biz_state_compare_memory.py tests/test_biz_state_compare_search.py tests/test_biz_state_compare.py tests/test_biz_state_compare_sql.py tests/test_biz_state_compare_recovery.py -q
# 可选:百万行基准,耗时约数分钟,内存占用较大
$env:NETX_BENCH_ROWS = '1000000'
$env:NETX_BENCH_REF = 'afa8e06b2de64100f963ae30b3babfcb5d7009f4'
.venv\Scripts\python.exe -m pytest tests/test_biz_state_compare_memory.py::test_memory_benchmark_against_git_baseline -q -s
Remove-Item Env:NETX_BENCH_ROWS, Env:NETX_BENCH_REF
# web 目录
node --experimental-strip-types --test tests/cutoverDataCache.test.mjs
node node_modules/vite/bin/vite.js build
node node_modules/typescript/bin/tsc -b
```
浏览器脚本为 `web/tests/bizCompare.browser.mjs`。先启动本地 Vite 服务,再设置 `NETX_PLAYWRIGHT_MODULE` 为可用的 Playwright 包路径;`NETX_TEST_URL` 默认是 `http://127.0.0.1:5179`,浏览器默认使用已安装的 Chrome。所有 `/v1` 请求由脚本提供固定响应,不会访问实际设备或数据库。
## 后续值得优化的部分
1. **Python 大表分组。** 有界抽样和冗余复制优化已完成,百万行基准下降约 63% 的引擎新增分配峰值。分组与输入仍随原表规模增长;进一步降低内存可研究排序流式配对或分区执行,并保持重复键配对语义。
2. **PostgreSQL 搜索与深分页。** JSON 字段包含搜索和精确总数统计仍可能扫描大量结果。需要用真实数据查看 `EXPLAIN (ANALYZE, BUFFERS)`,再决定常用字段索引、搜索文本索引或游标分页;本次没有盲目添加生产索引。
3. **历史配置与原始数据保留。** 新运行的执行配置快照已完成。旧运行仍无法还原未记录的当时配置;精简结果还原依赖原批次行,后续可为批次清理增加引用保护或保留策略。
4. **重复键的业务语义。** 引擎按采集顺序配对同键行。如果业务应按集合判断,CLI 行顺序改变可能产生多条“不一致”。当前保留原有语义;可优先完善身份键,或另行实现并测试 Python/SQL 一致的集合匹配。
5. **原表回查的数量上限。** 每侧候选命中最多 2000 条,完整分组补齐每侧预算 10000 条,最终展示最多 200 对;这不是全表匹配总数。界面会提示截断,大范围检索需要收窄条件。

View file

@ -1,54 +0,0 @@
# 业务监控分析与优化记录
检查日期:2026-10-11。范围:网络管理 → 割接对比 → 业务监控,包含任务配置、周期采集状态、批次保护、工作簿、原始日志、筛选与导出。
本次修改项目代码并使用内存 SQLite 和固定浏览器 API 响应验证,没有连接设备执行采集,也没有修改生产数据库。未新增解析模板或数据库字段。前后端应一起更新,前端轮询使用新增的 `/v1/biz-state/tasks/{id}/progress` 接口。
## 已修复的问题
1. **旧请求覆盖当前内容。** 任务、用途筛选、批次工作簿、指标表、采集详情、参数发现和原始日志增加请求序号或取消保护。关闭弹窗、切换任务/表后,慢响应不能重新打开弹窗或覆盖新内容。原始日志下载保留其实际批次 ID,避免根据当前另一层弹窗推断来源。
2. **后台采集不能及时反映、慢轮询叠加。** 同时观察服务端 `collect_running` 与当前页面启动的任务;周期任务和打开的任务保留低频查询,能够发现后台启动的采集。活动采集每轮完成后等待 4 秒,空闲等待 15 秒,再发起下一轮。跨 effect 重建共享在途标记,避免慢请求叠加。用途筛选隐藏的本地采集任务单独读取进度,避免误报采集结束。
3. **刷新覆盖未保存配置、批次数突然缩减。** 轮询只合并运行标志和时间,不重新写入周期、保留策略或监控项。进度刷新和首次加载统一读取最多 200 个最近批次,界面明确显示已载入数量与上限。
4. **加载失败看起来像空表或上一份数据。** 任务列表、任务配置、批次和指标表增加加载/错误/重试状态。指标查询失败清除旧行,不显示“本表无数据”;配置未就绪时操作区域不可交互。列表刷新失败保留当前行并显示错误。
5. **正在采集的批次可能被删除或清理。** `queued/running` 批次加入 `active_collection` 保护,单条删除、批量删除、保留清理均保留它们;采集中的任务返回 409。前端同步禁用选择与删除,保留基线、业务对比及割接引用保护。
6. **查询与导出边界错误。** `%`、`_`、反斜杠按字面值搜索;JSON 单列提取和 LLDP 分页排序更稳定。早于主命令保存的辅助命令失败保持可见。CSV 保留 `0`、`False`,正确引用逗号、双引号和 CR/LF;重复 `seq` 的导出不遗漏跨分块行,完整保留后续行才出现的列。
## 性能改动与验证范围
- 任务详情将逐监控项读取绑定改为一次批量读取。测试中 30 个监控项、60 个绑定由原来的 32 次查询降为 **3 次**;轻量进度接口为 **1 次**,不返回监控项和绑定。
- 批次保护只读取引用 ID 列,按本次批次范围限制查询,不再加载所有对比/割接运行的配置和汇总 JSON。
- 工作簿摘要不将原始 CLI 日志传输到 Python;只读取 SQL 计算的行数与是否存在日志。已有完整行数优先使用,原始日志在查看或导出时加载。回归使用 1 MB 日志,确认摘要结果集没有原文列。
- 通用指标 CSV 先分块扫描列名,再按 `(seq, id)` 游标每块 1000 行写入 ZIP;LLDP 分批写入。减少指标表的 ORM 对象、字典和完整 CSV 同时驻留。**最终 ZIP 仍驻留内存,命令原文导出仍需要原文;这不是端到端流式下载。** 两次扫描保留完整列名,正在写入的批次仍不保证导出为一致性快照。
- 同一厂商/型号的采集模板缓存 30 秒;轮询不重新读取模板。任务列表默认每页渲染 50 行,可选 20/50/100/200;列名去重使用 Set,命令搜索只计算一次。
- 指标搜索先回到第一页并等待防抖值一致,避免先请求旧页码、旧关键字。
以上是查询次数、请求并发及读取方式的验证,没有测量真实设备或生产 PostgreSQL 的延迟与内存。任务列表 API 仍返回当前用途下的完整任务集合;客户端分页减少 DOM,不减少列表响应大小。JSON 全文模糊搜索、旧日志的 SQL 行数计算、非常大的 ZIP 内存仍是后续基准重点。
## 界面调整
统一深色工业风:连续指标栏、细边框、小圆角、等宽任务标识、紧凑表头及明确的异常提示。任务配置使用响应式网格;采集周期数值与单位并排;工作簿横向页签、过滤工具栏和表格支持窄屏滚动;底部按钮可换行,避免被裁切。任务和工作簿页签支持方向键、Home/End,过滤控件增加可访问名称。
“采集成功 / 部分完成 / 采集失败”描述采集流程,不能据此判断业务验收通过;业务状态需要查看数据表或业务对比结果。
- [任务列表](assets/biz-state-tasks.png)
- [任务配置](assets/biz-state-config.png) · [390px 配置](assets/biz-state-config-mobile.png)
- [采集批次](assets/biz-state-batches.png)
- [工作簿](assets/biz-state-workbook.png) · [390px 工作簿](assets/biz-state-workbook-mobile.png)
- [查询失败与重试](assets/biz-state-error.png)
## 验证
- 后端相关回归 **124 项通过**:新增监控服务、保留策略、日志导入、命令导出,以及停止/恢复/收尾/认领采集、双车道、spool、解析池与业务对比相关回归。
- `web/tests/bizState.browser.mjs`:**30 项回归通过**。固定 120 个任务、54 个批次、120 行指标,覆盖分页、配置保存、模板缓存、过期响应、搜索、重试、日志下载、后台采集和窄屏布局。4500ms 列表延迟下最大并发轮询为 1;轮询阶段任务完整详情仅首次打开请求 1 次,未保存的周期值保持不变。全部 `/v1` 请求被固定响应拦截。
- `node node_modules/vite/bin/vite.js build` 通过。项目原有主包超过 500 kB 提示仍在。
- `node node_modules/typescript/bin/tsc -b`:本页已有类型问题已修复;全项目仍被 `BizMigrationPage.tsx` 的 4 个既有错误阻断(两处未使用变量、两处字符串传给数值控件)。
- 本页 ESLint 从 16 个错误降为 15 个,保留既有 `any` 和 effect 状态同步问题及 1 个依赖警告;不宣称全项目 lint 通过。
浏览器回归运行方法(先启动 Vite,可通过环境变量指定 URL、截图目录和浏览器):
```powershell
cd web
node node_modules/vite/bin/vite.js --host 127.0.0.1 --port 5179
# 另一个终端;Playwright 已安装时直接运行,否则设置 NETX_PLAYWRIGHT_MODULE 为其模块路径。
node tests/bizState.browser.mjs
```

View file

@ -1,46 +0,0 @@
# 对比模板与业务对比评估、优化记录
日期:2026-10-11。范围:网络管理 → 割接对比 → 对比模板、业务对比。延续此前的业务对比和业务监控优化。本轮使用内存 SQLite、SQL 编译断言和本地浏览器固定 API 响应验证,没有连接真实设备,也没有改动生产数据库。
## 修复的结果准确性问题
1. **模板导入可能只保存有效部分。** 前后端检查全部检查项,空 Key、重复 ID、无效规则不再被静默丢弃。服务器在修改模板之前校验,错误响应包括配置路径和原因。执行时也检查历史模板的原始检查项,防止旧的无效配置被归一化后跳过。
2. **错误过滤规则可能放行数据。** 新写入与执行入口拒绝未知运算符、混合分组/叶节点、空分组、无效正则及超过 12 层的嵌套。集合过滤要求非空数组;绝对/相对容差必须为有限非负数。无效归一模式、重复字段规则和接口别名同样报错。
3. **复杂嵌套过滤被可视化编辑器截断。** 编辑器只接管能够完整表达的 OR/AND 结构,其余结构完整保留并显示 JSON。保存、导入、导出不删除嵌套条件。修改复杂结构可导出 JSON 后编辑并重新导入。
4. **端口变化策略在 API 和导入导出中丢失。** `ignore_port_changes` 贯通请求模型、兼容单表配置、编辑、复制、保存、导入与导出。界面明确提供自动判断、接口必须匹配、允许端口变化三种选项;显式 `false` 不再丢失。
5. **零、布尔和空值混淆。** Python 行键与过滤保留 `0`、`False`,布尔文本与 JSON 提取一致;数值比对不再把空值当成 0。Python/SQL 使用同一数值格式,支持 `+.5`、`1.`、科学计数法;解析不了的值按文本比较。
6. **SQL 过滤与行键误匹配。** `contains` 的 `%`、`_`、反斜杠按字面值处理,空包含条件与 Python 一致。复合键改为 JSON 数组文本,避免 `('a|b','c')` 和 `('a','b|c')` 被拼成相同键。
7. **任务范围失效导致隐式扩大或缩小对比。** 编辑任务时保留已移除的检查项 ID,显示错误并禁用保存、执行;用户可明确重新选择或启用当前全部检查项。服务器在保存和执行时都检查 ID,部分有效、部分失效的范围也会被拒绝。
8. **不完整采集被解释成业务变化。** 比对入口只接受两侧状态为 `success` 的源批次;排队、执行中、失败、取消和部分完成批次返回 `source_batch_not_complete`。界面禁用这些选项。成功批次没有某指标或没有行的原有处理保持不变;本轮没有新增逐指标采集完备性判定。
9. **删除破坏引用或在途对比。** 已被任务引用的模板不能删除;存在排队/执行中记录的任务及活动记录不能删除。模板页显示任务引用数量。这些是 API 状态检查,未新增数据库外键或锁,不宣称消除跨请求的全部并发窗口。
10. **重新启用忽略字段仍然无效。** 勾选参与比对时清除遗留的 ignore/skip/off 规则。字段表保留模板配置的自定义字段;过滤专用字段也能显示。兼容数值、相对偏差和归一规则别名的显示。
## 性能与界面
- 检查项排序从逐表、逐批次查询改为 **一次按批次/metric 聚合**。相同 metric 的拆分表共享统计,SQL 不投影原始 CLI 日志;回归验证查询次数为 1。
- `batch_metric_collect_ok` 只读取解析状态和行数,不加载命令原文。
- 模板引用数量由一次 grouped 查询计算,避免每行请求。默认模板初始化/升级仍执行原来的查询。
- 模板页面只请求模板和字段目录,省去业务监控任务、端口映射、对比任务三个列表;字段目录缓存 60 秒。业务对比与模板列表使用独立缓存键。
- 两个列表默认每页渲染 50 行,可选 20/50/100/200;模板 ID 查找使用 Map。**API 仍返回完整列表,客户端分页减少 DOM,不减少响应体。**
- 模板编辑器使用深色平面面板、细边框、小圆角、蓝色选中态、等宽字段和固定表头。接口归一化折叠,检查项导航与规则区并排;窄屏导航横向滚动,操作按钮独立一行,过滤控件可换行,底部保存始终可见。
- 导航和规则页签支持方向键、Home/End;规则控件增加可访问名称。保存期间编辑区不可交互,并阻止关闭后打开新模板造成保存响应串扰。
没有测量生产 PostgreSQL 的耗时或内存;本轮 SQL 变更由生成 SQL/绑定参数断言验证,未执行真实 PostgreSQL 集成回归。极大数值指数的 SQL 浮点转换、Python 与 PostgreSQL 正则方言差异、全文 JSON 搜索和大型 ZIP 内存仍需单独基准。既有回退机制不等于所有 SQL/Python 语义已完全一致。
## 验证与预览
- 后端相关回归:**120 项通过,1 项跳过**。跳过的是需显式开启的百万行压力测试。覆盖模板校验、原子更新、旧记录执行保护、端口策略、过滤、数值、复合键、查询次数、删除保护、范围失效、源批次状态,以及既有对比、搜索、配置快照、导出、恢复与内存相关回归。
- Node 测试:**14 项通过**(8 项模板模型、6 项缓存)。
- 浏览器:**53 个场景通过**(原业务对比 27 项、模板新增 26 项)。全部 `/v1` 请求使用固定响应,120 个模板列表验证默认 50 行上限;覆盖导入导出、复杂过滤、端口策略、负容差、忽略字段恢复、保存期间交互锁定、范围修复、键盘和 390px 布局。
- Vite 生产构建通过;原有主包超过 500 kB 提示仍在。
- 全项目 TypeScript 检查仍有 `BizMigrationPage.tsx` 的 4 个既有错误:2 个未使用变量、2 处字符串传给数值控件;本轮没有新增错误。
- 新模板模型 ESLint 通过;业务对比页保留原来的 **23 个错误**(17 个 any、6 个 effect 状态同步),与修改前数量一致。不宣称全项目 lint 通过。
前后端应一起更新;新增 API 字段为模板引用数量与端口策略,无数据库迁移。原来使用标量 `in/not_in`、未知规则、负容差的模板需要修正再保存/执行;部分完成的源批次需要重新完成采集后再对比。
- [桌面模板编辑器](assets/compare-template-editor.png)
- [390px 模板编辑器](assets/compare-template-editor-mobile.png)
- [业务对比列表](assets/biz-compare-jobs.png)
- [此前业务对比记录](biz-compare-review-2026-10-10.md)
复现:先在 `web` 启动 `node node_modules/vite/bin/vite.js --host 127.0.0.1 --port 5179`,另一个终端运行 `node tests/bizCompareTemplates.browser.mjs` 和 `node tests/bizCompare.browser.mjs`。可用 `NETX_PLAYWRIGHT_MODULE` 指定已有 Playwright,`NETX_TEST_URL` 指定本地地址,`NETX_TEST_OUTPUT` 指定截图目录。

View file

@ -15,7 +15,6 @@ from .schema_patches import (
apply_topology_schema_safety_net,
run_alembic_upgrade_to_head,
)
from .ne_crypto import ensure_credential_secret_key
from .security_bootstrap import assert_secure_defaults_or_exit
from .ume_runtime import start_api_sideband_threads, start_device_schedulers
import netx_api.ume_support as ume_support
@ -43,8 +42,6 @@ def _configure_ume_diag_logging() -> None:
def run_api_startup() -> None:
"""Full API boot sequence previously inlined in ``main.on_startup``."""
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()
_log.info(
"startup: ne_exec_policy_enabled=%s",
@ -159,17 +156,6 @@ def run_api_startup() -> None:
)
except Exception:
_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:
from .port_traffic_migrate import backfill_port_traffic_series

View file

@ -1,114 +0,0 @@
"""Coalesced collect-completion events for live cutover evaluation.
One worker per process bounds concurrency. Project row locks in run_evaluate
serialize persistence across processes. No device collection runs here.
"""
from __future__ import annotations
import hashlib
import json
import logging
import threading
from sqlalchemy.orm import Session
from ..models import BizMigrationBatch, BizMigrationProject, BizMigrationRun, BizMonitorTemplate, BizStateBatch
from . import service
_log = logging.getLogger(__name__)
_lock = threading.Lock()
_pending: set[str] = set()
_worker_running = False
def try_auto_monitor_for_task(db: Session, task_id: str) -> int:
count = 0
# Bindings are JSON for multiple sampling intervals; match in Python after
# limiting to projects with an active operation batch.
projects = db.query(BizMigrationProject).filter(
BizMigrationProject.status != "done",
BizMigrationProject.id.in_(db.query(BizMigrationBatch.project_id).filter(
BizMigrationBatch.status == "active")),
).all()
for candidate in projects:
tids = service._all_hf_task_ids(candidate, "old") + service._all_hf_task_ids(candidate, "new")
if task_id not in tids or not candidate.old_baseline_batch_id or not candidate.new_baseline_batch_id:
continue
# Lock before deduplication so two worker processes cannot save the same
# snapshot. run_evaluate holds the same lock through the transaction.
proj = db.query(BizMigrationProject).filter(
BizMigrationProject.id == candidate.id).with_for_update().populate_existing().first()
batch = db.query(BizMigrationBatch).filter(
BizMigrationBatch.project_id == proj.id, BizMigrationBatch.status == "active"
).order_by(BizMigrationBatch.started_at.desc()).first()
if not batch or proj.status == "done":
db.rollback()
continue
mt = db.get(BizMonitorTemplate, proj.monitor_template_id) if proj.monitor_template_id else None
if not mt:
db.rollback()
_log.error("cutover monitor template missing project=%s", proj.id)
continue
sheets, overrides, defaults, norms = service.resolve_evaluate_sheets(db, mt)
samples = []
for tid in tids:
sample = db.query(BizStateBatch).filter(
BizStateBatch.task_id == tid, BizStateBatch.status.notin_(("running", "queued"))
).order_by(BizStateBatch.started_at.desc(), BizStateBatch.id.desc()).first()
samples.append([tid, sample.id if sample else "", sample.status if sample else ""])
payload = [samples, proj.old_baseline_batch_id, proj.new_baseline_batch_id,
batch.expect_set_json, service._port_map_dict(db, proj.mapping_id),
sheets, overrides, defaults, norms, service.resolve_collect_metric_ids(db, proj)]
fingerprint = hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
last = db.query(BizMigrationRun).filter(BizMigrationRun.batch_id == batch.id).order_by(
BizMigrationRun.created_at.desc()).first()
if last and (last.summary_json or {}).get("auto_fingerprint") == fingerprint:
db.rollback()
continue
previous_samples = {s[0]: (s[1], s[2]) for s in (last.summary_json or {}).get("auto_samples", [])} if last else {}
if last and not previous_samples:
for sample in (last.summary_json or {}).get("metric_batches", {}).values():
for side in ("old", "new"):
previous_samples[sample.get(f"{side}_task_id", "")] = (
sample.get(f"{side}_batch_id", ""), sample.get(f"{side}_status", "success"))
changed_tasks = {tid for tid, bid, status in samples if previous_samples.get(tid) != (bid, status)}
changed_metrics = {mid for side in ("old", "new") for binding in service._hf_bindings(proj, side)
if binding["task_id"] in changed_tasks for mid in binding.get("metric_ids") or payload[-1]}
try:
result = service.run_evaluate(db, batch_id=batch.id, purpose="auto", _commit=False,
_metric_ids=changed_metrics)
run = db.get(BizMigrationRun, result["id"])
run.summary_json = {**run.summary_json, "auto_fingerprint": fingerprint, "auto_samples": samples}
db.commit()
count += 1
except Exception:
db.rollback()
_log.exception("cutover monitor evaluate failed project=%s", proj.id)
return count
def schedule_auto_monitor_for_task(task_id: str) -> None:
global _worker_running
with _lock:
_pending.add(str(task_id))
if _worker_running:
return
_worker_running = True
def work() -> None:
global _worker_running
from ..db import SessionLocal
while True:
with _lock:
if not _pending:
_worker_running = False
return
tid = _pending.pop()
try:
with SessionLocal() as db:
try_auto_monitor_for_task(db, tid)
except Exception:
_log.exception("cutover monitor worker failed task=%s", tid)
threading.Thread(target=work, name="biz-cutover-monitor", daemon=True).start()

View file

@ -5,9 +5,6 @@ from __future__ import annotations
from typing import Any
from ..biz_state.compare_engine import apply_port_map, compare_rows
from ..biz_state.compare_rules import (
effective_compare_fields, field_rule_map, scalar_text, values_equal,
)
PORT_METRIC_ID = "interface_brief"
PORT_STATUS_FIELDS = ("admin", "phy", "prot")
@ -25,7 +22,7 @@ def normalize_expect_key(raw: str) -> str:
Accepts legacy ``a|b`` multi-field keys and already-normalized ``\\x1f`` keys.
"""
s = scalar_text(raw).strip()
s = str(raw or "").strip()
if not s:
return ""
if KEY_SEP in s:
@ -68,13 +65,13 @@ def classify_status(row: dict[str, Any] | None, sheet_override: dict[str, Any] |
return "none"
down_vals = {str(x).lower() for x in (ov.get("down_values") or ["down"])}
up_vals = {str(x).lower() for x in (ov.get("up_values") or ["up"])}
vals = [scalar_text(row.get(f)).strip().lower() for f in fields]
vals = [str(row.get(f) or "").strip().lower() for f in fields]
vals = [v for v in vals if v]
if not vals:
return "none"
if any(v in down_vals for v in vals):
return "down"
if len(vals) == len(fields) and all(v in up_vals for v in vals):
if vals and all(v in up_vals for v in vals):
return "up"
return "other"
@ -108,11 +105,11 @@ def parse_expect_set(raw: dict[str, Any] | None) -> dict[str, set[str]]:
if it.get("key") is not None:
k = it.get("key")
if isinstance(k, (list, tuple)):
joined = KEY_SEP.join(scalar_text(p).strip() for p in k)
if joined.strip(KEY_SEP):
joined = KEY_SEP.join(str(p).strip() for p in k if str(p).strip())
if joined:
bucket.add(joined)
else:
s = normalize_expect_key(scalar_text(k))
s = normalize_expect_key(str(k or ""))
if s:
bucket.add(s)
keys = it.get("keys")
@ -120,14 +117,14 @@ def parse_expect_set(raw: dict[str, Any] | None) -> dict[str, set[str]]:
# Flat list of segments → one composite key; else each entry is a key
# (string or nested list/tuple of segments).
if keys and all(not isinstance(x, (list, tuple, dict)) for x in keys):
joined = KEY_SEP.join(scalar_text(x).strip() for x in keys)
if joined.strip(KEY_SEP):
joined = KEY_SEP.join(str(x).strip() for x in keys if str(x).strip())
if joined:
bucket.add(joined)
else:
for x in keys:
if isinstance(x, (list, tuple)):
joined = KEY_SEP.join(scalar_text(p).strip() for p in x)
if joined.strip(KEY_SEP):
joined = KEY_SEP.join(str(p).strip() for p in x if str(p).strip())
if joined:
bucket.add(joined)
else:
s = normalize_expect_key(str(x or ""))
@ -222,7 +219,7 @@ def _field_tokens_from_row(row: dict[str, Any] | None, fields: set[str]) -> set[
return set()
out: set[str] = set()
for f in fields:
v = scalar_text(row.get(f)).strip()
v = str(row.get(f) or "").strip()
if v:
out.add(field_token(f, v))
return out
@ -248,8 +245,8 @@ def _side_tokens(
def _norm_list_values(raw: Any) -> list[str]:
if isinstance(raw, list):
return [scalar_text(x).strip().lower() for x in raw if scalar_text(x).strip()]
s = scalar_text(raw).strip()
return [str(x).strip().lower() for x in raw if str(x).strip()]
s = str(raw or "").strip()
if not s:
return []
if "," in s:
@ -269,22 +266,18 @@ def _eval_field_op(
f = str(field or "").strip()
if not f:
return False
raw = "" if not row else scalar_text(row.get(f)).strip()
raw = "" if not row else str(row.get(f) or "").strip()
val = raw.lower()
o = str(op or "eq").strip().lower()
if row and f not in row:
# A missing parser field is not evidence for a negative condition such as
# state != down. Only an explicit empty check may match that absence.
return o == "empty"
if o in ("changed", "diff"):
base = "" if not base_row else scalar_text(base_row.get(f)).strip().lower()
base = "" if not base_row else str(base_row.get(f) or "").strip().lower()
if not row:
return False
return base != val
if o in ("unchanged", "same"):
if not row:
return False
base = "" if not base_row else scalar_text(base_row.get(f)).strip().lower()
base = "" if not base_row else str(base_row.get(f) or "").strip().lower()
return base == val
if o == "empty":
return not val
@ -575,7 +568,7 @@ def _match_field_token_rules(
if not field or not allowed:
continue
row = old_row if side == "old" else new_row if side == "new" else None
val = scalar_text((row or {}).get(field)).strip().lower()
val = str((row or {}).get(field) or "").strip().lower()
if val not in allowed:
return False
return True
@ -865,14 +858,6 @@ def _port_identity(key_fields: list[str], iface_fields: list[str]) -> bool:
return all(str(f) in iface_set for f in key_fields)
def _iface_selected(value: str, ports: set[str]) -> bool:
"""Set lookup plus parent interface prefixes; independent of scope size."""
if value in ports:
return True
parts = value.split(".")
return any(".".join(parts[:i]) in ports for i in range(len(parts) - 1, 0, -1))
def _unmapped_same_iface_anomaly(
*,
old_kind: str,
@ -907,8 +892,6 @@ def _remap_key_str(
return key_str
if not port_map:
return key_str
if key_fields and not set(key_fields).intersection(iface_fields):
return key_str
if (not key_fields or len(key_fields) == 1) and (
key_str in port_map
or any(
@ -945,8 +928,6 @@ def _reverse_remap_key_str(
return key_str
if not rev_map:
return key_str
if key_fields and not set(key_fields).intersection(iface_fields):
return key_str
if (not key_fields or len(key_fields) == 1) and (
key_str in rev_map
or any(
@ -1028,7 +1009,7 @@ def row_match_key(
data = strip_netx(row)
if iface_fields and rules:
data = apply_iface_normalize(dict(data), iface_fields=iface_fields, rules=rules)
return KEY_SEP.join(scalar_text(data.get(k)).strip() for k in key_fields)
return KEY_SEP.join(str(data.get(k) or "").strip() for k in key_fields)
def index_raw_by_match_key(
@ -1052,7 +1033,7 @@ def display_key_from_raw(raw: dict[str, Any] | None, key_fields: list[str]) -> s
if not raw or not key_fields:
return ""
data = strip_netx(raw)
return "|".join(scalar_text(data.get(k)).strip() for k in key_fields)
return "|".join(str(data.get(k) or "").strip() for k in key_fields)
def iface_lineage(
@ -1072,7 +1053,7 @@ def iface_lineage(
pmap = port_map or {}
out: list[dict[str, Any]] = []
for f in iface_fields:
raw_v = scalar_text(data.get(f)).strip()
raw_v = str(data.get(f) or "").strip()
norm_v = normalize_iface_name(raw_v, rules) if rules else raw_v
mapped = False
map_to = ""
@ -1111,8 +1092,6 @@ def evaluate_metric_dual(
out_of_expect: str = "strict",
sheet_id: str = "",
iface_normalize_rules: list[dict[str, str]] | None = None,
previous_expect: dict[str, set[str]] | None = None,
allow_mapping_scope: bool = True,
) -> dict[str, Any]:
"""Run old vs old-baseline, new vs new-baseline (or mapped old baseline), dual merge.
@ -1123,16 +1102,12 @@ def evaluate_metric_dual(
from ..biz_state.iface_normalize import (
apply_iface_normalize_rows,
normalize_iface_rules,
normalize_iface_name,
)
sid = str(sheet_id or "").strip() or str(metric_id or "").strip()
expect_keys = expect_keys_for_metric(
expect, metric_id=metric_id, iface_fields=iface_fields, sheet_id=sid
)
previous_keys = expect_keys_for_metric(
previous_expect or {}, metric_id=metric_id, iface_fields=iface_fields, sheet_id=sid
)
ov = sheet_override or {}
if ov.get("skip_dual"):
return {
@ -1160,15 +1135,6 @@ def evaluate_metric_dual(
)
norm_rules = normalize_iface_rules(iface_normalize_rules)
def normalized_key(key: str) -> str:
parts = key.split(KEY_SEP) if KEY_SEP in key else ([key] if len(key_fields) == 1 else key.split("|"))
if len(parts) != len(key_fields):
return key
return KEY_SEP.join(normalize_iface_name(p, norm_rules) if f in iface_fields else p
for p, f in zip(parts, key_fields))
expect_keys = {normalized_key(key) for key in expect_keys}
previous_keys = {normalized_key(key) for key in previous_keys}
old_baseline_rows = apply_iface_normalize_rows(
[strip_netx(r) for r in raw_old_base], iface_fields=iface_fields, rules=norm_rules
)
@ -1212,7 +1178,6 @@ def evaluate_metric_dual(
port_map=None,
field_rules=field_rules,
iface_normalize_rules=None, # already applied
include_unchanged=True,
)
old_idx = build_diff_index_from_compare(old_cmp)
@ -1221,8 +1186,18 @@ def evaluate_metric_dual(
if new_baseline_rows is not None:
new_before = list(new_baseline_rows)
new_baseline_mode = "provided"
# A successfully collected empty baseline is valid on a replacement NE.
# Collection completeness is checked by the service, not inferred from rows.
# Explicit empty batch is as unusable as "no baseline" for presence semantics.
if not new_before and (old_baseline_rows or new_current_rows):
if port_map and iface_fields:
new_before = [
apply_port_map(r, iface_fields=iface_fields, port_map=port_map)
for r in old_baseline_rows
]
new_baseline_mode = "port_mapped"
new_baseline_missing = False
else:
new_baseline_missing = True
new_baseline_mode = "empty"
elif port_map and iface_fields:
new_before = [
apply_port_map(r, iface_fields=iface_fields, port_map=port_map)
@ -1243,22 +1218,12 @@ def evaluate_metric_dual(
compare_fields=compare_fields,
port_map=None,
field_rules=field_rules,
include_unchanged=True,
)
new_idx = build_diff_index_from_compare(new_cmp)
rev_map = {v: k for k, v in port_map.items()}
map_scoped = _map_defines_iface_expect(metric_id, iface_fields, port_map)
# Mapping describes identity; an explicit batch selection defines scope.
# Port selection also covers business rows attached to those interfaces.
for scope, keys in ((expect, expect_keys), (previous_expect or {}, previous_keys)):
ports = {normalize_iface_name(p, norm_rules) for p in scope.get("_ports") or set()}
if ports and not keys and iface_fields:
for ks, diff in old_idx.items():
row = diff.get("before") or diff.get("after") or {}
if any(_iface_selected(scalar_text(row.get(f)), ports) for f in iface_fields):
keys.add(ks)
if allow_mapping_scope and map_scoped and not any(expect.values()):
if map_scoped:
scoped: set[str] = set()
for ks in old_idx:
if _key_in_port_map(
@ -1288,8 +1253,6 @@ def evaluate_metric_dual(
for ok in sorted(expect_keys):
_add(ok)
for ok in sorted(previous_keys):
_add(ok)
for ks in sorted(old_idx):
_add(ks)
for ks in sorted(new_idx):
@ -1307,11 +1270,6 @@ def evaluate_metric_dual(
progress_total = 0
anomaly = 0
anomaly_in_expect = 0
steady_outside = 0
rules_by_field = field_rule_map(field_rules)
integrity_fields = effective_compare_fields(compare_fields, field_rules)
# Status transitions are interpreted by the monitor's correction rules.
integrity_fields = [f for f in integrity_fields if f not in (ov.get("status_fields") or [])]
for old_ks in canon_keys:
new_ks = _remap_key_str(
@ -1321,12 +1279,11 @@ def evaluate_metric_dual(
port_map=port_map,
)
in_exp = old_ks in expect_keys
previous = old_ks in previous_keys and not in_exp
if in_exp:
progress_total += 1
od = old_idx.get(old_ks)
nd = new_idx.get(new_ks)
nd = new_idx.get(new_ks) or new_idx.get(old_ks)
old_kind = str((od or {}).get("kind") or "")
new_kind = str((nd or {}).get("kind") or "")
@ -1344,7 +1301,11 @@ def evaluate_metric_dual(
old_st = classify_status(old_cur, ov)
new_st = classify_status(new_cur, ov)
if map_scoped and not (in_exp or previous) and (
# Mapped iface rows are the expect set. Unmapped rows stay out of the
# migration compare, except same-name ports where old went down and the
# new device's same port is up — that is anomaly, not a successful cutover.
if map_scoped and not in_exp:
if not (
_port_identity(key_fields, iface_fields)
and _unmapped_same_iface_anomaly(
old_kind=old_kind,
@ -1353,63 +1314,26 @@ def evaluate_metric_dual(
new_status=new_st,
)
):
continue
verdict, color, rule_hit = "anomaly", "red", "unmapped_same_iface"
else:
verdict, color, rule_hit = dual_verdict_ex(
old_kind=old_kind or "",
new_kind=new_kind or "",
in_expect=in_exp or previous,
in_expect=in_exp,
window_active=window_active,
acceptance=acceptance,
old_status=old_st,
new_status=new_st,
success_patterns=success_patterns or None,
anomaly_patterns=anomaly_patterns or None,
# Business drift outside this batch must remain visible.
out_of_expect="strict",
out_of_expect=out_of_expect,
old_row=old_cur or None,
new_row=new_cur or None,
old_base=old_base or None,
new_base=new_base or None,
sheet_override=ov,
)
integrity_changes: list[dict[str, Any]] = []
if in_exp or previous:
old_departed = old_kind == "removed" or old_st == "down"
new_healthy = bool(new_cur) and (
new_st == "up" if ov.get("status_fields") else True
)
if old_departed and not new_healthy:
verdict, color, rule_hit = "lost", "red", "service_not_received"
elif verdict == "migrated":
if not new_healthy:
verdict, color, rule_hit = "anomaly", "red", "target_unhealthy"
elif not old_base:
verdict, color, rule_hit = "anomaly", "red", "old_service_not_in_baseline"
else:
reference = apply_port_map(old_base, iface_fields=iface_fields, port_map=port_map)
integrity_changes = [
{"field": f, "before": reference.get(f), "after": new_cur.get(f)}
for f in integrity_fields
if not values_equal(reference.get(f), new_cur.get(f), rule=rules_by_field.get(f))
]
if integrity_changes:
verdict, color, rule_hit = "anomaly", "red", "service_content_changed"
elif not old_departed and not success_patterns:
verdict, color, rule_hit = (
("unfinished", "red", "old_service_still_present") if acceptance
else ("migrating", "yellow", "old_service_still_present")
)
if previous:
if verdict == "migrated":
verdict, rule_hit = "migrated_previous", "previous_batch_received"
elif color != "red":
verdict, color, rule_hit = "unfinished_previous", "red", "previous_batch_not_received"
# Stable inventory is covered by the comparison and counted, but does
# not need a full persisted evidence card on every high-frequency run.
if not in_exp and verdict == "not_involved":
steady_outside += 1
continue
if in_exp and verdict == "migrated" and color == "green":
progress_ok += 1
if color == "red":
@ -1420,9 +1344,9 @@ def evaluate_metric_dual(
# Prefer live raw rows; fall back to baseline raw. Never use port-mapped
# synthetic "before" as the new-side display row.
raw_old = old_raw_cur_idx.get(old_ks) or old_raw_base_idx.get(old_ks)
raw_new = new_raw_cur_idx.get(new_ks)
raw_new = new_raw_cur_idx.get(new_ks) or new_raw_cur_idx.get(old_ks)
if not raw_new:
raw_new = new_raw_base_idx.get(new_ks)
raw_new = new_raw_base_idx.get(new_ks) or new_raw_base_idx.get(old_ks)
display_old = display_key_from_raw(raw_old, key_fields) or old_ks
display_new = display_key_from_raw(raw_new, key_fields) or (
@ -1436,8 +1360,8 @@ def evaluate_metric_dual(
new_disp = strip_netx(raw_new) if raw_new else {}
old_base_raw = strip_netx(old_raw_base_idx.get(old_ks)) if old_raw_base_idx.get(old_ks) else {}
new_base_raw = (
strip_netx(new_raw_base_idx.get(new_ks))
if new_raw_base_idx.get(new_ks)
strip_netx(new_raw_base_idx.get(new_ks) or new_raw_base_idx.get(old_ks))
if (new_raw_base_idx.get(new_ks) or new_raw_base_idx.get(old_ks))
else {}
)
@ -1487,9 +1411,7 @@ def evaluate_metric_dual(
"verdict": verdict,
"color": color,
"rule_hit": rule_hit,
"integrity_changes": integrity_changes,
"in_expect": in_exp,
"previous_batch": previous,
"old_kind": old_kind,
"new_kind": new_kind,
"old": old_disp,
@ -1528,7 +1450,6 @@ def evaluate_metric_dual(
"progress_total": progress_total,
"anomaly": anomaly,
"anomaly_in_expect": anomaly_in_expect,
"steady_outside": steady_outside,
"rows": rows_out,
"new_baseline_mode": new_baseline_mode,
"new_baseline_missing": new_baseline_missing,

View file

@ -8,8 +8,7 @@ from uuid import uuid4
from fastapi import HTTPException
from sqlalchemy.orm import Session
from ..models import BizCompareTemplate, BizMigrationProject, BizMonitorTemplate
from .validation import validate_monitor_rules
from ..models import BizCompareTemplate, BizMonitorTemplate
from ..timeutil import utcnow_naive
from ..biz_state import compare_service as cmp_svc
from ..biz_state.iface_normalize import default_zte_iface_normalize_rules
@ -34,8 +33,6 @@ def _out(row: BizMonitorTemplate, compare_name: str = "", *, db: Session | None
effective = seen
if not effective:
effective = [PORT_METRIC_ID]
ct = db.get(BizCompareTemplate, row.compare_template_id) if db and row.compare_template_id else None
available = list(dict.fromkeys(str(s.get("metric_id") or "") for s in cmp_svc.template_metrics(ct))) if ct else effective
out: dict[str, Any] = {
"id": row.id,
"name": row.name,
@ -43,7 +40,6 @@ def _out(row: BizMonitorTemplate, compare_name: str = "", *, db: Session | None
"compare_template_name": compare_name,
"collect_metric_ids": collect,
"collect_metric_ids_effective": effective,
"available_metric_ids": [m for m in available if m],
"defaults": dict(row.defaults_json or {}),
"sheet_overrides": list(row.sheet_overrides_json or []),
"note": row.note or "",
@ -460,7 +456,6 @@ def create_monitor_template(db: Session, body: dict[str, Any]) -> dict[str, Any]
overrides = body.get("sheet_overrides")
if not isinstance(overrides, list):
overrides = []
validate_monitor_rules(defaults, overrides)
row = BizMonitorTemplate(
id=uuid4().hex,
name=name[:256],
@ -484,10 +479,6 @@ def update_monitor_template(db: Session, template_id: str, body: dict[str, Any])
row = db.get(BizMonitorTemplate, template_id)
if not row:
raise HTTPException(status_code=404, detail="monitor_template_not_found")
validate_monitor_rules(body.get("defaults", row.defaults_json or {}),
body.get("sheet_overrides", row.sheet_overrides_json or []))
if body.get("compare_template_id") and not db.get(BizCompareTemplate, body["compare_template_id"]):
raise HTTPException(status_code=404, detail="compare_template_not_found")
if "name" in body and body["name"] is not None:
name = str(body["name"] or "").strip()
if not name:
@ -524,7 +515,5 @@ def delete_monitor_template(db: Session, template_id: str) -> None:
row = db.get(BizMonitorTemplate, template_id)
if not row:
raise HTTPException(status_code=404, detail="monitor_template_not_found")
if db.query(BizMigrationProject.id).filter(BizMigrationProject.monitor_template_id == template_id).first():
raise HTTPException(status_code=409, detail="monitor_template_in_use")
db.delete(row)
db.commit()

File diff suppressed because it is too large Load diff

View file

@ -1,70 +0,0 @@
"""Validate monitor correction rules before they can affect live verdicts."""
from typing import Any
from fastapi import HTTPException
from .evaluate import _groups_for_side, _normalize_cond_type
def validate_monitor_rules(defaults: Any, overrides: Any) -> None:
def fail(path: str) -> None:
raise HTTPException(status_code=400, detail=f"monitor_rule_invalid:{path}")
if not isinstance(defaults, dict) or not isinstance(overrides, list):
fail("defaults/sheet_overrides")
if defaults.get("out_of_expect", "strict") not in ("strict", "warn", "ignore"):
fail("defaults.out_of_expect")
seen = set()
for i, ov in enumerate(overrides):
path = f"sheet_overrides[{i}]"
if not isinstance(ov, dict) or not (ov.get("sheet_id") or ov.get("metric_id")):
fail(path)
ident = ov.get("sheet_id") or ov.get("metric_id")
if ident in seen:
fail(path + ".duplicate")
seen.add(ident)
for key in ("status_fields", "up_values", "down_values"):
if key in ov and (not isinstance(ov[key], list) or any(not str(v).strip() for v in ov[key])):
fail(path + "." + key)
for key in ("success", "anomaly"):
patterns = ov.get(key, [])
if not isinstance(patterns, list):
fail(path + "." + key)
for j, pat in enumerate(patterns):
pp = f"{path}.{key}[{j}]"
if not isinstance(pat, dict):
fail(pp)
for side in ("old", "new"):
for attr in (side, side + "_conds", side + "_groups"):
if attr in pat and not isinstance(pat[attr], list):
fail(pp + "." + attr)
if any(isinstance(g, list) and not g for g in pat.get(side + "_groups", [])):
fail(pp + "." + side + "_groups.empty_group")
groups = _groups_for_side(pat, side)
if key == "success" and not groups:
fail(pp + "." + side + ".required")
for group in groups:
for cond in group:
cp = pp + "." + side
if isinstance(cond, str):
if cond not in ("added", "removed", "changed", "unchanged", "up", "down", "other", "none") and not (cond.startswith("field:") and cond.count(":") >= 2):
fail(cp + ".token")
elif isinstance(cond, dict):
typ = _normalize_cond_type(cond)
if typ not in ("presence", "status", "value"):
fail(cp + ".type")
val = cond.get("value", cond.get("kind", cond.get("status")))
if typ == "presence" and val not in ("added", "removed", "changed", "unchanged"):
fail(cp + ".presence")
if typ == "status" and val not in ("up", "down", "other", "none"):
fail(cp + ".status")
if typ == "value":
op = cond.get("op", "eq")
if op not in ("eq", "ne", "in", "not_in", "empty", "not_empty", "changed", "unchanged", "same", "diff"):
fail(cp + ".op")
if op in ("eq", "ne", "in", "not_in") and (val is None or val == "" or val == []):
fail(cp + ".value")
else:
fail(cp + ".condition")
if key == "anomaly" and not any(_groups_for_side(pat, s) for s in ("old", "new")):
fail(pp + ".empty")

View file

@ -285,8 +285,6 @@ def api_get_run(run_id: str, db: Session = Depends(get_db)):
def api_list_diffs(
run_id: str,
metric_id: str = "",
sheet_id: str = "",
only_expect: bool = False,
verdict: str = "",
color: str = "",
kw: str = "",
@ -298,8 +296,6 @@ def api_list_diffs(
db,
run_id,
metric_id=metric_id,
sheet_id=sheet_id,
only_expect=only_expect,
verdict=verdict,
color=color,
kw=kw,

View file

@ -3,7 +3,7 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from datetime import datetime
from typing import Any
from uuid import uuid4
@ -39,10 +39,6 @@ def enqueue_collect(task_id: str, *, manual: bool = False) -> dict[str, Any]:
task = db.get(BizStateTask, tid)
if not task:
return {"ok": False, "queued": False, "reason": "task_not_found", "task_id": tid}
if task.collect_group_id:
group_id = task.collect_group_id
db.close()
return enqueue_collect_group(group_id, manual=manual, requested_task_id=tid)
if str(task.source or "").strip().lower() == "import":
return {
"ok": False,
@ -103,7 +99,6 @@ def enqueue_collect(task_id: str, *, manual: bool = False) -> dict[str, Any]:
vendor=task.vendor,
status="queued",
started_at=now,
queued_at=now,
message="queued" if not manual else "queued_manual",
)
db.add(batch)
@ -126,61 +121,6 @@ def enqueue_collect(task_id: str, *, manual: bool = False) -> dict[str, Any]:
db.close()
def enqueue_collect_group(group_id: str, *, manual: bool = False,
requested_task_id: str = "") -> dict[str, Any]:
"""Atomically enqueue both devices with one round identity, or neither."""
db = SessionLocal()
try:
tasks = db.query(BizStateTask).filter(BizStateTask.collect_group_id == group_id).order_by(
BizStateTask.id).with_for_update().populate_existing().all()
result: dict[str, Any] = {"ok": False, "queued": False, "task_id": requested_task_id,
"collect_group_id": group_id}
if len(tasks) != 2 or len({t.ne_id for t in tasks}) != 2:
return {**result, "reason": "collect_pair_invalid"}
if max_concurrent_tasks() < 2:
return {**result, "reason": "collect_pair_capacity"}
from ..cli_budget import clamp_cli_workers
worker_cap = clamp_cli_workers(int(getattr(settings, "biz_state_worker_collect_threads", None)
or getattr(settings, "biz_state_dispatch_workers", 8) or 8))
if worker_cap < 2:
return {**result, "reason": "collect_pair_capacity"}
if any(t.collect_running for t in tasks):
return {**result, "reason": "collect_pair_busy"}
for task in tasks:
if task.source == "import" or (not manual and (task.status != "running" or task.collect_manual_only)):
return {**result, "reason": "not_scheduled"}
if task.status in ("", "deleted"):
return {**result, "reason": "bad_status"}
if not db.query(BizStateTaskItem.id).filter(BizStateTaskItem.task_id == task.id,
BizStateTaskItem.enabled.is_(True)).first():
return {**result, "reason": "no_enabled_items"}
now, round_id = _utcnow(), uuid4().hex
batches = []
for task in tasks:
task.collect_running = True
task.collect_queued_at = now
task.last_error = ""
task.updated_at = now
batch = BizStateBatch(id=uuid4().hex, task_id=task.id, source=task.source,
ne_id=task.ne_id, ne_name=task.ne_name, vendor=task.vendor, status="queued",
started_at=now, queued_at=now, collect_group_id=group_id,
collect_round_id=round_id, collect_priority=10 if task.collect_manual_only else 20,
message="queued_manual" if manual else "queued")
db.add(batch)
batches.append({"batch_id": batch.id, "task_id": task.id})
db.commit()
selected = next((b for b in batches if b["task_id"] == requested_task_id), batches[0])
return {**result, **selected, "ok": True, "queued": True, "collect_round_id": round_id,
"batches": batches, "manual": manual}
except Exception:
db.rollback()
_log.exception("enqueue collect pair failed group=%s", group_id)
return {"ok": False, "queued": False, "reason": "enqueue_error", "task_id": requested_task_id}
finally:
db.close()
def _dialect_supports_skip_locked(db: Session) -> bool:
try:
bind = db.get_bind()
@ -211,16 +151,11 @@ def claim_queued_batches(limit: int | None = None) -> list[dict[str, Any]]:
Global ceiling is always ``max_concurrent_tasks()``; ``limit`` only caps
how many this caller wants in one call.
"""
from sqlalchemy import and_, case, text
from sqlalchemy import text
global_cap = max_concurrent_tasks()
db = SessionLocal()
try:
pg = _dialect_supports_skip_locked(db)
if pg:
# A short transaction lock makes capacity reservation and pair claims
# atomic across worker processes. Never held during device I/O.
db.execute(text("SELECT pg_advisory_xact_lock(hashtext('biz-state-claim-capacity'))"))
running_n = (
db.query(BizStateBatch).filter(BizStateBatch.status == "running").count()
)
@ -238,54 +173,50 @@ def claim_queued_batches(limit: int | None = None) -> list[dict[str, Any]]:
if str(r[0] or "").strip()
}
# Manual route rounds yield to fresh monitoring, but must eventually
# run even when a busy device always has regular metrics queued.
priority = case((and_(BizStateBatch.collect_priority == 10,
BizStateBatch.queued_at <= _utcnow() - timedelta(seconds=300)), 30),
else_=BizStateBatch.collect_priority)
q = (
db.query(BizStateBatch)
.filter(BizStateBatch.status == "queued")
.order_by(priority.desc(), BizStateBatch.started_at.asc(), BizStateBatch.id.asc())
.order_by(BizStateBatch.started_at.asc())
)
pg = _dialect_supports_skip_locked(db)
if pg:
q = q.with_for_update(skip_locked=True)
else:
q = q.with_for_update()
candidates = q.limit(max(slots * 8, 64)).all()
candidates = q.limit(max(slots * 4, slots)).all()
claimed_rows: list[BizStateBatch] = []
visited: set[str] = set()
for batch in candidates:
if batch.id in visited:
continue
if len(claimed_rows) >= slots:
break
group = [batch]
if batch.collect_round_id:
peers = db.query(BizStateBatch).filter(
BizStateBatch.collect_round_id == batch.collect_round_id,
).with_for_update(skip_locked=pg).all()
if len(peers) != 2 or any(p.status != "queued" for p in peers):
ne = str(batch.ne_id or "").strip()
if ne and ne in busy_nes:
continue
# Serialize claims per NE across workers (Postgres).
if ne and pg:
try:
db.execute(
text("SELECT pg_advisory_xact_lock(hashtext(:ne))"),
{"ne": ne},
)
except Exception:
_log.exception("advisory lock failed ne=%s", ne)
conflict = (
db.query(BizStateBatch.id)
.filter(
BizStateBatch.status == "running",
BizStateBatch.ne_id == ne,
BizStateBatch.id != batch.id,
)
.first()
)
if conflict:
continue
group = peers
visited.update(p.id for p in group)
if len(group) > slots - len(claimed_rows):
continue
nes = {str(p.ne_id or "").strip() for p in group}
if nes & busy_nes:
continue
now = _utcnow()
for member in group:
member.status = "running"
member.message = ""
if member.collect_round_id:
member.started_at = now
task = db.get(BizStateTask, member.task_id)
if task:
task.last_collect_started_at = now
claimed_rows.append(member)
busy_nes.update(nes - {""})
batch.status = "running"
batch.message = ""
claimed_rows.append(batch)
if ne:
busy_nes.add(ne)
if not claimed_rows:
db.rollback()
@ -300,13 +231,10 @@ def claim_queued_batches(limit: int | None = None) -> list[dict[str, Any]]:
db.query(BizStateBatch).filter(BizStateBatch.status == "running").count()
)
while n_running > global_cap and claimed_rows:
last = claimed_rows[-1]
demoted = [b for b in claimed_rows if b.collect_round_id == last.collect_round_id] if last.collect_round_id else [last]
for demote in demoted:
claimed_rows.remove(demote)
demote.status = "queued"
demote.message = "queued"
n_running -= 1
demote = claimed_rows.pop()
demote.status = "queued"
demote.message = "queued"
n_running -= 1
if not claimed_rows:
db.rollback()

View file

@ -784,12 +784,6 @@ def dispatch_collect(task_id: str, *, manual: bool = False) -> dict[str, Any]:
result = enqueue_collect(task_id, manual=manual)
if not result.get("queued"):
return result
if result.get("collect_group_id"):
if _should_execute_inline():
from ..biz_state_scheduler import _claim_and_dispatch
_claim_and_dispatch()
return result
batch_id = str(result.get("batch_id") or "")
if not batch_id:
return result
@ -876,11 +870,6 @@ def execute_claimed_batch(
task = db.get(BizStateTask, task_id) if task_id else None
if not batch:
return
if batch.collect_round_id:
batch.started_at = _utcnow()
if task:
task.last_collect_started_at = batch.started_at
db.commit()
if not task_id:
task_id = str(batch.task_id or "")
task = db.get(BizStateTask, task_id) if task_id else None
@ -1731,14 +1720,6 @@ def _finalize_batch_status(
batch.message = ""
status = str(batch.status or "")
db.commit()
# Failed/partial receipts also update cutover coverage; never keep an old
# success looking healthy after the latest collection has failed.
try:
from ..biz_migration.auto_monitor import schedule_auto_monitor_for_task
schedule_auto_monitor_for_task(task_id)
except Exception:
_log.exception("cutover monitor schedule failed task=%s", task_id)
# Only full success triggers auto compare; never block the collect thread.
if status == "success" and not stopped:
try:

View file

@ -131,7 +131,7 @@ def _force_close_holders(batch_id: str) -> int:
def request_stop_collect(task_id: str) -> dict[str, Any]:
"""Stop a task, including its paired collector, without stranding a half round."""
"""Cancel queued batches and signal running collect for this task to abort."""
tid = str(task_id or "").strip()
if not tid:
return {"ok": False, "reason": "missing_task_id"}
@ -141,37 +141,26 @@ def request_stop_collect(task_id: str) -> dict[str, Any]:
signaled_ids: list[str] = []
closed = 0
try:
from sqlalchemy import text
from .claim import _dialect_supports_skip_locked
if _dialect_supports_skip_locked(db):
db.execute(text("SELECT pg_advisory_xact_lock(hashtext('biz-state-claim-capacity'))"))
task = db.get(BizStateTask, tid)
if not task:
return {"ok": False, "reason": "task_not_found", "task_id": tid}
task_ids = [tid]
if task.collect_group_id:
task_ids = [r[0] for r in db.query(BizStateTask.id).filter(
BizStateTask.collect_group_id == task.collect_group_id).all()]
active = (
db.query(BizStateBatch)
.filter(
BizStateBatch.task_id.in_(task_ids),
BizStateBatch.task_id == tid,
BizStateBatch.status.in_(("queued", "running")),
)
.order_by(BizStateBatch.id).with_for_update().all()
.all()
)
tasks = db.query(BizStateTask).filter(BizStateTask.id.in_(task_ids)).order_by(
BizStateTask.id).with_for_update().populate_existing().all()
if not active:
# Nothing to stop — clear sticky collect_running if orphaned.
for member in tasks:
if member.collect_running:
member.collect_running = False
member.collect_queued_at = None
member.updated_at = utcnow_naive()
db.commit()
if bool(task.collect_running):
task.collect_running = False
if hasattr(task, "collect_queued_at"):
task.collect_queued_at = None
task.updated_at = utcnow_naive()
db.commit()
return {
"ok": True,
"task_id": tid,
@ -182,7 +171,7 @@ def request_stop_collect(task_id: str) -> dict[str, Any]:
}
now = utcnow_naive()
running_tasks: set[str] = set()
still_running = False
for batch in active:
bid = str(batch.id)
mark_stop_requested(bid)
@ -194,16 +183,16 @@ def request_stop_collect(task_id: str) -> dict[str, Any]:
else:
batch.message = STOP_REQUEST_TOKEN
signaled_ids.append(bid)
running_tasks.add(batch.task_id)
still_running = True
closed += _force_close_holders(bid)
for member in tasks:
if member.id not in running_tasks:
member.collect_running = False
member.collect_queued_at = None
member.last_collect_ended_at = now
member.last_error = STOP_USER_MESSAGE
member.updated_at = now
if not still_running:
task.collect_running = False
if hasattr(task, "collect_queued_at"):
task.collect_queued_at = None
task.last_collect_ended_at = now
task.last_error = STOP_USER_MESSAGE
task.updated_at = now
db.commit()
_log.info(

View file

@ -2,81 +2,15 @@
from __future__ import annotations
from collections import defaultdict
from heapq import heappush, heapreplace
from itertools import chain
from typing import Any, Iterable, Mapping, Sequence
from typing import Any, Mapping, Sequence
from .compare_rules import field_rule_map, values_equal, explain_diff, scalar_text
from .compare_rules import field_rule_map, values_equal, explain_diff
from .iface_normalize import (
apply_iface_normalize_rows,
normalize_iface_rules,
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 "_"
class _StratifiedSample:
"""Keep the first N round-robin ranks in O(N) space, even with many strata."""
def __init__(self, limit: int) -> None:
self.limit = max(0, int(limit))
self.count = 0
self.strata: dict[str, tuple[int, int]] = {}
self.heap: list[tuple[int, int, int, Any]] = []
def add(self, item: Any, key: str) -> None:
serial = self.count
self.count += 1
if not self.limit:
return
state = self.strata.get(key)
if state is None:
# Later strata cannot beat the first item of N earlier strata.
if len(self.strata) >= self.limit:
return
index, round_n = len(self.strata), 0
else:
index, round_n = state
self.strata[key] = (index, round_n + 1)
entry = (-round_n, -index, serial, item)
if len(self.heap) < self.limit:
heappush(self.heap, entry)
elif entry[:2] > self.heap[0][:2]:
heapreplace(self.heap, entry)
def picked(self) -> list[Any]:
# The legacy helper preserves encounter order when no truncation occurs.
if self.count <= self.limit:
entries = sorted(self.heap, key=lambda e: e[2])
else:
entries = sorted(self.heap, key=lambda e: (-e[0], -e[1]))
return [entry[3] for entry in entries]
def stratify_take(items: Iterable[Any], limit: int, *, key_fn) -> list[Any]:
"""Round-robin across strata without retaining every candidate."""
sample = _StratifiedSample(limit)
if not sample.limit:
return []
for item in items:
sample.add(item, str(key_fn(item) or "_"))
return sample.picked()
def apply_port_map(
row: dict[str, Any],
@ -94,7 +28,7 @@ def apply_port_map(
def row_key(row: dict[str, Any], key_fields: list[str]) -> tuple[str, ...]:
return tuple(scalar_text(row.get(k)).strip() for k in key_fields)
return tuple(str(row.get(k) or "").strip() for k in key_fields)
def mapping_stats(
@ -171,28 +105,12 @@ def compare_rows(
field_rules: Sequence[Mapping[str, Any]] | None = None,
iface_normalize_rules: Sequence[Mapping[str, str]] | None = None,
ignore_port_changes: bool | None = None,
include_unchanged: bool = False,
unchanged_limit: int | None = None,
compact_unchanged: bool = False,
) -> dict[str, Any]:
"""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
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.
Pipeline: iface normalize (both sides) → port map (before) → match.
``ignore_port_changes``:
- ``None`` (default): auto — drop iface from match key only when remaining
@ -200,8 +118,8 @@ def compare_rows(
- ``True``: force drop iface from match key when a non-empty candidate exists.
- ``False``: never drop iface from match key.
``summary.duplicate`` is always 0. ``duplicate_keys_*`` count match keys
that appear more than once on a side (diagnostic only).
Duplicate match keys are not silently discarded: extras become ``duplicate``
diffs and ``summary.duplicate_key_list`` lists the colliding keys.
``field_rules`` drives normalize / numeric tolerance / per-field compare mode
(template-driven; no metric-specific branches here).
@ -216,10 +134,10 @@ def compare_rows(
before_norm = apply_iface_normalize_rows(
before_rows, iface_fields=iface_list, rules=norm_rules
) if norm_rules and iface_list else before_rows
)
after_norm = apply_iface_normalize_rows(
after_rows, iface_fields=iface_list, rules=norm_rules
) if norm_rules and iface_list else after_rows
)
ignore_ports = False
# No map → optionally ignore port renames by dropping iface from match key.
@ -234,16 +152,9 @@ def compare_rows(
match_keys = list(key_fields)
else:
# Auto heuristic (legacy default)
def unique_keys(rows: list[dict[str, Any]]) -> bool:
seen: set[tuple[str, ...]] = set()
for row in rows:
key = row_key(row, candidate)
if key in seen:
return False
seen.add(key)
return True
if unique_keys(before_norm) and unique_keys(after_norm):
before_c = [row_key(r, candidate) for r in before_norm]
after_c = [row_key(r, candidate) for r in after_norm]
if len(before_c) == len(set(before_c)) and len(after_c) == len(set(after_c)):
match_keys = candidate
ignore_ports = True
else:
@ -251,154 +162,131 @@ def compare_rows(
else:
match_keys = list(key_fields)
before_groups: dict[tuple[str, ...], list[tuple[dict[str, Any], dict[str, Any]]]] = (
defaultdict(list)
)
after_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
for orig, norm in zip(before_rows, before_norm):
mapped = apply_port_map(norm, iface_fields=iface_list, port_map=pmap) if iface_list else norm
before_groups[row_key(mapped, match_keys)].append((orig, mapped))
for r in after_norm:
after_groups[row_key(r, match_keys)].append(r)
before_mapped: list[dict[str, Any]] = [
apply_port_map(r, iface_fields=iface_list, port_map=pmap) for r in before_norm
]
after_index: dict[tuple[str, ...], dict[str, Any]] = {}
after_dup = 0
after_dup_keys: list[tuple[str, ...]] = []
after_dup_rows: list[tuple[tuple[str, ...], dict[str, Any]]] = []
for r in after_norm:
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]] = []
added = removed = changed = unchanged = 0
unchanged_listed = 0
limit_n = None if unchanged_limit is None else max(0, int(unchanged_limit))
multi_before_keys: list[tuple[str, ...]] = []
multi_after_keys: list[tuple[str, ...]] = []
unchanged_candidates: list[tuple[dict[str, Any], dict[str, Any], dict[str, Any]]] = []
sample = _StratifiedSample(limit_n) if include_unchanged and limit_n is not None else None
added = removed = changed = unchanged = duplicate = 0
def _key_obj(row: dict[str, Any]) -> dict[str, Any]:
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:
for orig, mapped in zip(before_rows, before_mapped):
k = row_key(mapped, match_keys)
if k in before_keys:
before_dup += 1
before_dup_keys.append(k)
duplicate += 1
diffs.append(
{
"kind": "unchanged",
"kind": "duplicate",
"side": "before",
"key": _key_obj(mapped),
"before": {},
"after": {},
"mapped_before": {},
"before": orig,
"after": after_index.get(k),
"mapped_before": mapped,
"changes": {},
"compact": True,
"before_row_id": before_rid,
"after_row_id": after_rid,
}
)
continue # only first before row participates in match
before_keys.add(k)
after = after_index.get(k)
if after is None:
removed += 1
diffs.append(
{
"kind": "removed",
"key": _key_obj(mapped),
"before": orig,
"after": None,
"mapped_before": mapped,
"changes": {},
}
)
continue
# Empty compare_fields = presence-only: keyed rows that exist on both
# sides are unchanged (no value checks).
field_changes: dict[str, dict[str, Any]] = {}
for f in compare_fields:
bv = mapped.get(f, "")
av = after.get(f, "")
rule = rules.get(f)
if not values_equal(bv, av, rule=rule):
entry: dict[str, Any] = {"before": bv, "after": av}
reason = explain_diff(bv, av, rule=rule)
if reason:
entry["reason"] = reason
field_changes[f] = entry
if field_changes:
changed += 1
diffs.append(
{
"kind": "changed",
"key": _key_obj(mapped),
"before": orig,
"after": after,
"mapped_before": mapped,
"changes": field_changes,
}
)
else:
unchanged += 1
diffs.append(
{
"kind": "unchanged",
"key": _key_obj(mapped),
"before": orig,
"after": after_row,
"after": after,
"mapped_before": mapped,
"changes": {},
"before_row_id": before_rid,
"after_row_id": after_rid,
}
)
# Stable key order: before encounter order, then after-only keys
ordered_keys = chain(before_groups, (k for k in after_groups if k not in before_groups))
for k in ordered_keys:
b_list = before_groups.get(k) or []
a_list = after_groups.get(k) or []
if len(b_list) > 1:
multi_before_keys.append(k)
if len(a_list) > 1:
multi_after_keys.append(k)
n = max(len(b_list), len(a_list))
for i in range(n):
if i >= len(b_list):
after = a_list[i]
added += 1
diffs.append(
{
"kind": "added",
"key": _key_obj(after),
"before": None,
"after": after,
"mapped_before": None,
"changes": {},
"before_row_id": "",
"after_row_id": _row_id(after),
}
)
continue
if i >= len(a_list):
orig, mapped = b_list[i]
removed += 1
diffs.append(
{
"kind": "removed",
"key": _key_obj(mapped),
"before": orig,
"after": None,
"mapped_before": mapped,
"changes": {},
"before_row_id": _row_id(orig),
"after_row_id": "",
}
)
continue
orig, mapped = b_list[i]
after = a_list[i]
field_changes: dict[str, dict[str, Any]] = {}
for f in compare_fields:
bv = mapped.get(f, "")
av = after.get(f, "")
rule = rules.get(f)
if not values_equal(bv, av, rule=rule):
entry: dict[str, Any] = {"before": bv, "after": av}
reason = explain_diff(bv, av, rule=rule)
if reason:
entry["reason"] = reason
field_changes[f] = entry
if field_changes:
changed += 1
diffs.append(
{
"kind": "changed",
"key": _key_obj(mapped),
"before": orig,
"after": after,
"mapped_before": mapped,
"changes": field_changes,
"before_row_id": _row_id(orig),
"after_row_id": _row_id(after),
}
)
else:
unchanged += 1
if include_unchanged:
if sample is not None:
if sample.limit:
sample.add((orig, mapped, after), stratum_key(mapped))
else:
unchanged_candidates.append((orig, mapped, after))
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": {},
}
)
if include_unchanged:
picked = sample.picked() if sample is not None else unchanged_candidates
for orig, mapped, after_row in picked:
_append_unchanged_diff(orig, mapped, after_row)
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": {},
}
)
def _fmt_keys(keys: list[tuple[str, ...]]) -> list[str]:
seen: set[str] = set()
@ -408,7 +296,7 @@ def compare_rows(
if s not in seen:
seen.add(s)
out.append(s)
return out[:64]
return out
stats = mapping_stats(
before_rows=before_norm,
@ -426,21 +314,11 @@ def compare_rows(
"removed": removed,
"changed": changed,
"unchanged": unchanged,
"duplicate": 0,
"duplicate": duplicate,
"match_key_fields": match_keys,
"duplicate_keys_before": len(multi_before_keys),
"duplicate_keys_after": len(multi_after_keys),
"duplicate_key_list": _fmt_keys(multi_before_keys + multi_after_keys),
"unchanged_listed": unchanged_listed,
"unchanged_truncated": bool(
include_unchanged and limit_n is not None and unchanged > unchanged_listed
),
"unchanged_compact": bool(compact_unchanged and unchanged_listed > 0),
"unchanged_sample_mode": (
"stratified"
if include_unchanged and limit_n is not None and unchanged > unchanged_listed
else ("full" if include_unchanged else "none")
),
"duplicate_keys_before": before_dup,
"duplicate_keys_after": after_dup,
"duplicate_key_list": _fmt_keys(before_dup_keys + after_dup_keys),
},
"diffs": diffs,
"mapping_stats": stats,

View file

@ -7,21 +7,10 @@ All metric-specific compare behavior belongs in the sheet template
from __future__ import annotations
import re
import math
from typing import Any, Mapping, Sequence
_AGE_TIMER_RE = re.compile(r"^\d{1,2}:\d{2}:\d{2}$")
NUMBER_PATTERN = r"^[+-]?([0-9]+(\.[0-9]*)?|\.[0-9]+)([eE][+-]?[0-9]+)?$"
def scalar_text(value: Any) -> str:
"""Match JSON text extraction: only null is blank; keep zero and booleans."""
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def _as_list(raw: Any) -> list[Any]:
@ -33,7 +22,7 @@ def _as_list(raw: Any) -> list[Any]:
def _field_val(row: Mapping[str, Any], field: str) -> str:
return scalar_text((row or {}).get(field)).strip()
return str((row or {}).get(field) or "").strip()
def eval_leaf_filter(row: Mapping[str, Any], filt: Mapping[str, Any]) -> bool:
@ -46,17 +35,17 @@ def eval_leaf_filter(row: Mapping[str, Any], filt: Mapping[str, Any]) -> bool:
expect = filt.get("value")
if op in ("eq", "=="):
return raw.lower() == scalar_text(expect).strip().lower()
return raw.lower() == str(expect or "").strip().lower()
if op in ("ne", "!="):
return raw.lower() != scalar_text(expect).strip().lower()
return raw.lower() != str(expect or "").strip().lower()
if op == "in":
opts = {scalar_text(x).strip().lower() for x in _as_list(expect) if scalar_text(x).strip()}
opts = {str(x).strip().lower() for x in _as_list(expect) if str(x).strip()}
return raw.lower() in opts
if op in ("not_in", "nin"):
opts = {scalar_text(x).strip().lower() for x in _as_list(expect) if scalar_text(x).strip()}
opts = {str(x).strip().lower() for x in _as_list(expect) if str(x).strip()}
return raw.lower() not in opts
if op == "contains":
needle = scalar_text(expect).strip().lower()
needle = str(expect or "").strip().lower()
return bool(needle) and needle in raw.lower()
if op == "empty":
return not raw
@ -74,7 +63,7 @@ def eval_leaf_filter(row: Mapping[str, Any], filt: Mapping[str, Any]) -> bool:
# HH:MM:SS dynamic ARP age
return bool(_AGE_TIMER_RE.match(raw))
if op == "ci_eq":
return raw.lower() == scalar_text(expect).strip().lower()
return raw.lower() == str(expect or "").strip().lower()
return True
@ -110,7 +99,7 @@ def apply_row_filters(
def normalize_value(value: Any, how: str) -> str:
text = scalar_text(value).strip()
text = str(value if value is not None else "").strip()
mode = str(how or "").strip().lower()
if not mode or mode in ("none", "strip"):
return text
@ -201,11 +190,8 @@ def effective_display_fields(
def _parse_float(text: str) -> float | None:
if not re.fullmatch(NUMBER_PATTERN, text):
return None
try:
value = float(text)
return value if math.isfinite(value) else None
return float(text) if text else 0.0
except ValueError:
return None

File diff suppressed because it is too large Load diff

View file

@ -1,757 +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, NUMBER_PATTERN, scalar_text
_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 = NUMBER_PATTERN
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] = scalar_text(expect).strip()
if op == "ci_eq" or op in ("eq", "=="):
# Python eq is case-insensitive via .lower()
params[key] = scalar_text(expect).strip().lower()
return f"(lower({expr}) = :{key})"
if op in ("ne", "!="):
key = _next("v")
params[key] = scalar_text(expect).strip().lower()
return f"(lower({expr}) <> :{key})"
if op == "contains":
key = _next("v")
needle = scalar_text(expect).strip().lower()
if not needle:
return "FALSE"
escaped = needle.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
params[key] = f"%{escaped}%"
return f"(lower({expr}) LIKE :{key} ESCAPE E'\\\\')"
if op in ("in", "not_in", "nin"):
vals = expect
if vals is None:
vals = []
if not isinstance(vals, (list, tuple)):
vals = [vals]
clean = [scalar_text(x).strip().lower() for x in vals if scalar_text(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]
# Delimiter concatenation conflates ('a|b', 'c') with ('a', 'b|c').
return "jsonb_build_array(" + ", ".join(parts) + ")::text"
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}

View file

@ -1,130 +0,0 @@
"""Validate template writes without silently dropping parts of the comparison."""
from __future__ import annotations
import math
import re
from typing import Any
from fastapi import HTTPException
FILTER_OPS = {"eq", "==", "ci_eq", "ne", "!=", "in", "not_in", "nin", "contains", "empty", "not_empty", "nonempty", "regex", "age_timer"}
COMPARE_MODES = {"", "eq", "ignore", "skip", "off", "numeric", "number", "int", "float", "percent", "pct", "rel"}
NORMALIZE_MODES = {"", "none", "strip", "lower", "upper", "mac", "empty_as_blank"}
def invalid(path: str, reason: str) -> None:
raise HTTPException(status_code=400, detail={"error": "invalid_template", "path": path, "reason": reason})
def _strings(value: Any, path: str, *, required: bool = False) -> None:
if not isinstance(value, list) or any(not isinstance(x, str) or not x.strip() for x in value):
invalid(path, "nonempty_strings_required")
if required and not value:
invalid(path, "key_fields_required")
if len({x.strip() for x in value}) != len(value):
invalid(path, "duplicate_field")
def validate_filters(value: Any, path: str, depth: int = 0) -> None:
if not isinstance(value, list):
invalid(path, "list_required")
if depth > 12:
invalid(path, "filter_nesting_too_deep")
for i, node in enumerate(value):
here = f"{path}[{i}]"
if not isinstance(node, dict) or not node:
invalid(here, "filter_required")
groups = [key for key in ("any", "all") if key in node]
if groups:
if len(groups) != 1 or any(node.get(key) is not None for key in ("field", "op", "value")):
invalid(here, "group_or_leaf_required")
kids = node[groups[0]]
if not isinstance(kids, list) or not kids:
invalid(here, "nonempty_group_required")
validate_filters(kids, f"{here}.{groups[0]}", depth + 1)
continue
if not isinstance(node.get("field"), str) or not node["field"].strip():
invalid(here, "filter_field_required")
op = str(node.get("op") or "eq").strip().lower()
if op not in FILTER_OPS:
invalid(here, "unknown_filter_operator")
expect = node.get("value")
if op in ("in", "not_in", "nin"):
if not isinstance(expect, list) or not expect or any(not isinstance(x, (str, int, float, bool)) for x in expect):
invalid(here, "nonempty_value_list_required")
elif op not in ("empty", "not_empty", "nonempty", "age_timer"):
if not isinstance(expect, (str, int, float, bool)):
invalid(here, "scalar_value_required")
if op in ("contains", "regex") and not str(expect).strip():
invalid(here, "value_required")
if op == "regex":
try:
re.compile(str(expect), re.I)
except re.error:
invalid(here, "invalid_regex")
def validate_sheet(sheet: Any, path: str, *, partial: bool = False) -> None:
if not isinstance(sheet, dict):
invalid(path, "sheet_required")
if not partial or "metric_id" in sheet:
if not isinstance(sheet.get("metric_id"), str) or not sheet["metric_id"].strip():
invalid(path, "metric_id_required")
for key in ("key_fields", "iface_fields", "compare_fields", "display_fields", "ignore_fields"):
if key in sheet or (key == "key_fields" and not partial):
_strings(sheet.get(key), f"{path}.{key}", required=key == "key_fields")
if "ignore_port_changes" in sheet and sheet["ignore_port_changes"] is not None and not isinstance(sheet["ignore_port_changes"], bool):
invalid(path, "boolean_port_policy_required")
if "row_filters" in sheet:
validate_filters(sheet["row_filters"], f"{path}.row_filters")
if "field_rules" in sheet:
rules = sheet["field_rules"]
if not isinstance(rules, list):
invalid(path, "field_rules_list_required")
seen = set()
for i, rule in enumerate(rules):
here = f"{path}.field_rules[{i}]"
if not isinstance(rule, dict) or not isinstance(rule.get("field"), str) or not rule["field"].strip():
invalid(here, "rule_field_required")
field = rule["field"].strip()
if field in seen:
invalid(here, "duplicate_field_rule")
seen.add(field)
if str(rule.get("compare") or "").strip().lower() not in COMPARE_MODES:
invalid(here, "unknown_compare_mode")
if str(rule.get("normalize") or "").strip().lower() not in NORMALIZE_MODES:
invalid(here, "unknown_normalize_mode")
tol = rule.get("tolerance")
if tol is not None:
if isinstance(tol, bool) or not isinstance(tol, (int, float)) or not math.isfinite(tol) or tol < 0:
invalid(here, "finite_nonnegative_tolerance_required")
def validate_template_body(body: dict[str, Any], *, partial: bool = False) -> None:
metrics = body.get("metrics")
if metrics is not None:
if not isinstance(metrics, list) or not metrics:
invalid("metrics", "metrics_required")
seen = set()
for i, sheet in enumerate(metrics):
path = f"metrics[{i}]"
validate_sheet(sheet, path)
sid = str(sheet.get("sheet_id") or sheet["metric_id"]).strip()
if sid in seen:
invalid(path, "duplicate_sheet_id")
seen.add(sid)
else:
validate_sheet(body, "template", partial=partial)
if "iface_normalize_rules" in body and body["iface_normalize_rules"] is not None:
rules = body["iface_normalize_rules"]
if not isinstance(rules, list):
invalid("iface_normalize_rules", "list_required")
seen = set()
for i, rule in enumerate(rules):
path = f"iface_normalize_rules[{i}]"
if not isinstance(rule, dict) or any(not isinstance(rule.get(k), str) or not rule[k].strip() for k in ("from", "to")):
invalid(path, "alias_pair_required")
key = rule["from"].strip().lower()
if key in seen:
invalid(path, "duplicate_alias")
seen.add(key)

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,6 @@ from collections import defaultdict
from datetime import datetime, timedelta
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from ..models import (
@ -27,54 +26,38 @@ def _utcnow() -> datetime:
return datetime.utcnow()
def protected_batch_map(
db: Session, *, task_id: str = "", batch_ids: list[str] | None = None,
) -> dict[str, list[str]]:
"""Only read reference columns, scoped to the requested task/batches when given."""
def protected_batch_map(db: Session, *, task_id: str = "") -> dict[str, list[str]]:
"""batch_id → reason codes. Empty task_id = all tasks."""
out: dict[str, list[str]] = defaultdict(list)
wanted = set(batch_ids) if batch_ids is not None else None
if wanted == set():
return {}
scope = select(BizStateBatch.id)
q = db.query(BizStateBatch.id, BizStateBatch.is_baseline, BizStateBatch.status)
if task_id:
scope = scope.where(BizStateBatch.task_id == task_id)
q = q.filter(BizStateBatch.task_id == task_id)
if wanted is not None:
scope = scope.where(BizStateBatch.id.in_(wanted))
q = q.filter(BizStateBatch.id.in_(wanted))
batch_rows = q.all() if task_id or wanted is not None else q.filter(or_(
BizStateBatch.is_baseline.is_(True), BizStateBatch.status.in_(("queued", "running")),
)).all()
if task_id:
wanted = {row.id for row in batch_rows}
def add(bid: str, reason: str) -> None:
b = str(bid or "").strip()
if not b or (wanted is not None and b not in wanted):
if not b:
return
if reason not in out[b]:
out[b].append(reason)
for b in batch_rows:
if b.is_baseline:
add(b.id, "manual_baseline")
if b.status in ("queued", "running"):
add(b.id, "active_collection")
q = db.query(BizStateBatch)
if task_id:
q = q.filter(BizStateBatch.task_id == task_id)
for b in q.filter(BizStateBatch.is_baseline.is_(True)).all():
add(b.id, "manual_baseline")
references = (
(BizCompareJob.before_batch_id, BizCompareJob.after_batch_id, "compare_job_before", "compare_job_after"),
(BizCompareRun.before_batch_id, BizCompareRun.after_batch_id, "compare_run_before", "compare_run_after"),
(BizMigrationProject.old_baseline_batch_id, BizMigrationProject.new_baseline_batch_id, "migration_old_baseline", "migration_new_baseline"),
(BizMigrationRun.old_batch_id, BizMigrationRun.new_batch_id, "migration_run_old", "migration_run_new"),
)
for before, after, before_reason, after_reason in references:
refs = db.query(before, after)
if task_id or batch_ids is not None:
refs = refs.filter(or_(before.in_(scope), after.in_(scope)))
for before_id, after_id in refs:
add(before_id, before_reason)
add(after_id, after_reason)
for j in db.query(BizCompareJob).all():
add(j.before_batch_id, "compare_job_before")
add(j.after_batch_id, "compare_job_after")
for r in db.query(BizCompareRun).all():
add(r.before_batch_id, "compare_run_before")
add(r.after_batch_id, "compare_run_after")
for p in db.query(BizMigrationProject).all():
add(p.old_baseline_batch_id, "migration_old_baseline")
add(p.new_baseline_batch_id, "migration_new_baseline")
for r in db.query(BizMigrationRun).all():
add(r.old_batch_id, "migration_run_old")
add(r.new_batch_id, "migration_run_new")
return dict(out)
@ -84,7 +67,7 @@ def protected_batch_ids(db: Session, *, task_id: str = "") -> set[str]:
def batch_protect_info(db: Session, batch_id: str) -> dict[str, Any]:
reasons = protected_batch_map(db, batch_ids=[batch_id]).get(batch_id, [])
reasons = protected_batch_map(db).get(batch_id, [])
b = db.get(BizStateBatch, batch_id)
if b and bool(getattr(b, "is_baseline", False)) and "manual_baseline" not in reasons:
reasons = ["manual_baseline", *reasons]

View file

@ -23,7 +23,6 @@ def apply_biz_state_schema(conn: Connection) -> None:
"CREATE INDEX IF NOT EXISTS ix_biz_state_batch_command_batch_id ON biz_state_batch_command (batch_id)",
"CREATE INDEX IF NOT EXISTS ix_biz_state_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 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_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)",
@ -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_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)",
"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_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)",
@ -82,15 +79,6 @@ def apply_biz_state_schema(conn: Connection) -> None:
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS new_hf_bindings_json JSON DEFAULT '[]'",
"ALTER TABLE biz_state_task ADD COLUMN IF NOT EXISTS purpose VARCHAR(32) DEFAULT ''",
"CREATE INDEX IF NOT EXISTS ix_biz_state_task_purpose ON biz_state_task (purpose)",
"ALTER TABLE biz_state_task ADD COLUMN IF NOT EXISTS collect_group_id VARCHAR(128) DEFAULT ''",
"ALTER TABLE biz_state_task ADD COLUMN IF NOT EXISTS collect_manual_only BOOLEAN DEFAULT FALSE",
"CREATE INDEX IF NOT EXISTS ix_biz_state_task_collect_group_id ON biz_state_task (collect_group_id)",
"ALTER TABLE biz_state_batch ADD COLUMN IF NOT EXISTS collect_group_id VARCHAR(128) DEFAULT ''",
"ALTER TABLE biz_state_batch ADD COLUMN IF NOT EXISTS collect_round_id VARCHAR(64) DEFAULT ''",
"ALTER TABLE biz_state_batch ADD COLUMN IF NOT EXISTS collect_priority INTEGER DEFAULT 0",
"ALTER TABLE biz_state_batch ADD COLUMN IF NOT EXISTS queued_at TIMESTAMP",
"CREATE INDEX IF NOT EXISTS ix_biz_state_batch_collect_group_id ON biz_state_batch (collect_group_id)",
"CREATE INDEX IF NOT EXISTS ix_biz_state_batch_collect_round_id ON biz_state_batch (collect_round_id)",
"ALTER TABLE biz_state_batch_command ADD COLUMN IF NOT EXISTS raw_line_count INTEGER DEFAULT 0",
"ALTER TABLE biz_state_batch_command ADD COLUMN IF NOT EXISTS declared_total INTEGER DEFAULT 0",
"ALTER TABLE biz_migration_red_ticket ADD COLUMN IF NOT EXISTS match_key_str VARCHAR(256) DEFAULT ''",

View file

@ -9,8 +9,8 @@ from typing import Any
from uuid import uuid4
from fastapi import HTTPException
from sqlalchemy import String, case, cast, func, or_
from sqlalchemy.orm import Session, defer
from sqlalchemy import String, cast, func, or_
from sqlalchemy.orm import Session
from ..lldp_shared import resolve_vendor_key
from ..models import (
@ -354,15 +354,13 @@ def get_task(db: Session, task_id: str) -> dict[str, Any]:
.order_by(BizStateTaskItem.sort_order.asc())
.all()
)
bindings_by_item: dict[str, list[Any]] = {}
if items:
for binding in db.query(BizStateTaskItemBinding).filter(
BizStateTaskItemBinding.item_id.in_([it.id for it in items])
).all():
bindings_by_item.setdefault(binding.item_id, []).append(binding)
item_out = []
for it in items:
binds = bindings_by_item.get(it.id, [])
binds = (
db.query(BizStateTaskItemBinding)
.filter(BizStateTaskItemBinding.item_id == it.id)
.all()
)
item_out.append(
{
"id": it.id,
@ -375,11 +373,6 @@ def get_task(db: Session, task_id: str) -> dict[str, Any]:
"bindings": [{"placeholder": b.placeholder, "value": b.value} for b in binds],
}
)
return {**_task_summary(task), "items": item_out}
def _task_summary(task: BizStateTask) -> dict[str, Any]:
"""Task metadata without command items or bindings (safe for frequent polling)."""
return {
"id": task.id,
"source": task.source,
@ -396,8 +389,6 @@ def _task_summary(task: BizStateTask) -> dict[str, Any]:
"daily_keep_enabled": bool(getattr(task, "daily_keep_enabled", False)),
"daily_keep_count": int(getattr(task, "daily_keep_count", None) or 10),
"collect_running": bool(task.collect_running),
"collect_group_id": task.collect_group_id or "",
"collect_manual_only": bool(task.collect_manual_only),
"last_collect_started_at": task.last_collect_started_at.isoformat() + "Z"
if task.last_collect_started_at
else None,
@ -405,16 +396,10 @@ def _task_summary(task: BizStateTask) -> dict[str, Any]:
if task.last_collect_ended_at
else None,
"last_error": task.last_error,
"items": item_out,
}
def get_task_progress(db: Session, task_id: str) -> dict[str, Any]:
task = db.get(BizStateTask, task_id)
if not task:
raise HTTPException(status_code=404, detail="task_not_found")
return _task_summary(task)
def list_tasks(db: Session, *, purpose: str | None = None) -> list[dict[str, Any]]:
from sqlalchemy import or_
@ -446,8 +431,6 @@ def list_tasks(db: Session, *, purpose: str | None = None) -> list[dict[str, Any
"status": t.status,
"interval_sec": t.interval_sec,
"collect_running": bool(t.collect_running),
"collect_group_id": t.collect_group_id or "",
"collect_manual_only": bool(t.collect_manual_only),
"last_error": t.last_error,
"last_collect_started_at": t.last_collect_started_at.isoformat() + "Z"
if t.last_collect_started_at
@ -464,8 +447,6 @@ def delete_task(db: Session, task_id: str) -> None:
task = db.get(BizStateTask, task_id)
if not task:
raise HTTPException(status_code=404, detail="task_not_found")
if task.collect_running:
raise HTTPException(status_code=409, detail="task_collecting")
batches = db.query(BizStateBatch).filter(BizStateBatch.task_id == task_id).all()
# Refuse if any batch is still referenced by compare/migration (manual baseline is OK to drop with task)
pmap = protected_batch_map(db, task_id=task_id)
@ -519,7 +500,7 @@ def list_batches(db: Session, task_id: str, *, limit: int = 50) -> list[dict[str
.limit(max(1, min(500, int(limit))))
.all()
)
pmap = protected_batch_map(db, batch_ids=[b.id for b in rows])
pmap = protected_batch_map(db, task_id=task_id)
out: list[dict[str, Any]] = []
for b in rows:
reasons = list(pmap.get(b.id, []))
@ -641,29 +622,12 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]:
b = db.get(BizStateBatch, batch_id)
if not b:
raise HTTPException(status_code=404, detail="batch_not_found")
raw = func.coalesce(BizStateBatchCommand.raw_text, "")
raw_len = func.length(raw)
# Compute legacy counts in SQL; workbook summaries never transfer raw CLI text.
line_count = case(
(BizStateBatchCommand.raw_line_count > 0, BizStateBatchCommand.raw_line_count),
(raw_len == 0, 0),
else_=raw_len - func.length(func.replace(raw, "\n", ""))
+ case((func.substr(raw, raw_len, 1) == "\n", 0), else_=1),
)
whitespace = " \t\r\n\v\f"
trimmed = (func.btrim(raw, whitespace) if db.get_bind().dialect.name == "postgresql"
else func.trim(raw, whitespace))
cmd_raw_stats: dict[str, tuple[int, bool]] = {}
cmds = []
for c, lines, has_raw in (
db.query(BizStateBatchCommand, line_count, func.length(trimmed) > 0)
.options(defer(BizStateBatchCommand.raw_text))
cmds = (
db.query(BizStateBatchCommand)
.filter(BizStateBatchCommand.batch_id == batch_id)
.order_by(BizStateBatchCommand.created_at.asc(), BizStateBatchCommand.id.asc())
.order_by(BizStateBatchCommand.created_at.asc())
.all()
):
cmds.append(c)
cmd_raw_stats[c.id] = (int(lines or 0), bool(has_raw))
)
protect = batch_protect_info(db, batch_id)
# Per-metric row counts (generic table)
@ -704,14 +668,11 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]:
# Prefer primary collect rows over aux / aux_cached for the same CLI
sheet_cmds[id_].append(cmd_info)
# Aux failures must remain visible even if their primary command was stored later.
primary_cmds_by_cli = {
normalize_command(str(c.raw_command or "")) for c in cmds
if not str(c.parse_status or "").strip().lower().startswith("aux")
}
primary_cmds_by_cli: dict[str, dict[str, Any]] = {}
for c in cmds:
status = str(c.parse_status or "").strip().lower()
is_aux = status.startswith("aux")
raw = c.raw_text or ""
info = {
"id": c.id,
"profile_id": c.profile_id,
@ -721,13 +682,15 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]:
"params": c.params_json or {},
"parse_status": c.parse_status,
"row_count": c.row_count,
"raw_line_count": cmd_raw_stats[c.id][0],
"raw_line_count": _cmd_raw_line_count(c),
"declared_total": _cmd_declared_total(c),
"message": c.message,
"has_raw": cmd_raw_stats[c.id][1],
"has_raw": bool(str(raw).strip()),
"is_aux": is_aux,
}
cmd_n = normalize_command(str(c.raw_command or ""))
if not is_aux and cmd_n:
primary_cmds_by_cli.setdefault(cmd_n, info)
# Commands sheet: hide successful aux when the same CLI already has a primary row;
# keep failed/skipped aux visible so partial reasons are not hidden.
if is_aux and cmd_n and cmd_n in primary_cmds_by_cli:
@ -783,10 +746,10 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]:
"params": c.params_json or {},
"parse_status": c.parse_status,
"row_count": c.row_count,
"raw_line_count": cmd_raw_stats[c.id][0],
"raw_line_count": _cmd_raw_line_count(c),
"declared_total": _cmd_declared_total(c),
"message": c.message,
"has_raw": cmd_raw_stats[c.id][1],
"has_raw": bool(str(c.raw_text or "").strip()),
"is_aux": True,
}
)
@ -881,7 +844,6 @@ def list_batch_metric_rows(
size_n = max(1, min(200, int(page_size or 50)))
kw_n = str(kw or "").strip()
col_n = str(column or "").strip()
like = "%" + kw_n.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
fields = metric_field_map().get(mid) or []
columns = [
@ -897,30 +859,30 @@ def list_batch_metric_rows(
if mid == "lldp_neighbor":
q = db.query(BizStateLldpNeighbor).filter(BizStateLldpNeighbor.batch_id == batch_id)
if kw_n:
like = f"%{kw_n}%"
if col_n == "local_if":
q = q.filter(BizStateLldpNeighbor.local_if.ilike(like, escape="\\"))
q = q.filter(BizStateLldpNeighbor.local_if.ilike(like))
elif col_n == "remote_sys":
q = q.filter(BizStateLldpNeighbor.remote_sys.ilike(like, escape="\\"))
q = q.filter(BizStateLldpNeighbor.remote_sys.ilike(like))
elif col_n == "remote_if":
q = q.filter(BizStateLldpNeighbor.remote_if.ilike(like, escape="\\"))
q = q.filter(BizStateLldpNeighbor.remote_if.ilike(like))
elif col_n == "remote_ip":
q = q.filter(BizStateLldpNeighbor.remote_ip.ilike(like, escape="\\"))
q = q.filter(BizStateLldpNeighbor.remote_ip.ilike(like))
elif col_n == "protocol":
q = q.filter(BizStateLldpNeighbor.protocol.ilike(like, escape="\\"))
q = q.filter(BizStateLldpNeighbor.protocol.ilike(like))
else:
q = q.filter(
or_(
BizStateLldpNeighbor.local_if.ilike(like, escape="\\"),
BizStateLldpNeighbor.remote_sys.ilike(like, escape="\\"),
BizStateLldpNeighbor.remote_if.ilike(like, escape="\\"),
BizStateLldpNeighbor.remote_ip.ilike(like, escape="\\"),
BizStateLldpNeighbor.protocol.ilike(like, escape="\\"),
BizStateLldpNeighbor.local_if.ilike(like),
BizStateLldpNeighbor.remote_sys.ilike(like),
BizStateLldpNeighbor.remote_if.ilike(like),
BizStateLldpNeighbor.remote_ip.ilike(like),
BizStateLldpNeighbor.protocol.ilike(like),
)
)
total = int(q.count() or 0)
rows_db = (
q.order_by(BizStateLldpNeighbor.local_if.asc(), BizStateLldpNeighbor.remote_sys.asc(),
BizStateLldpNeighbor.remote_if.asc(), BizStateLldpNeighbor.id.asc())
q.order_by(BizStateLldpNeighbor.local_if.asc())
.offset((page_n - 1) * size_n)
.limit(size_n)
.all()
@ -949,11 +911,12 @@ def list_batch_metric_rows(
BizStateMetricRow.metric_id == mid,
)
if kw_n:
like = f"%{kw_n}%"
if col_n:
# JSON path as text — works on Postgres JSONB and SQLite JSON
q = q.filter(cast(BizStateMetricRow.data_json[col_n].as_string(), String).ilike(like, escape="\\"))
q = q.filter(cast(BizStateMetricRow.data_json[col_n], String).ilike(like))
else:
q = q.filter(cast(BizStateMetricRow.data_json, String).ilike(like, escape="\\"))
q = q.filter(cast(BizStateMetricRow.data_json, String).ilike(like))
total = int(q.count() or 0)
rows_db = (
q.order_by(BizStateMetricRow.seq.asc(), BizStateMetricRow.id.asc())
@ -1056,64 +1019,56 @@ def export_batch_zip(db: Session, batch_id: str) -> bytes:
db.query(BizStateLldpNeighbor)
.filter(BizStateLldpNeighbor.batch_id == batch_id)
.order_by(BizStateLldpNeighbor.local_if.asc())
.yield_per(1000)
.all()
)
with zf.open("tables/lldp_neighbor.csv", "w") as dest:
dest.write(b"local_if,remote_sys,remote_if,remote_ip,protocol\n")
for n in neighbors:
line = ",".join(_csv(v) for v in (
n.local_if, n.remote_sys, n.remote_if, n.remote_ip, n.protocol,
)) + "\n"
dest.write(line.encode("utf-8"))
csv_lines = ["local_if,remote_sys,remote_if,remote_ip,protocol"]
for n in neighbors:
csv_lines.append(
",".join(
[
_csv(n.local_if),
_csv(n.remote_sys),
_csv(n.remote_if),
_csv(n.remote_ip),
_csv(n.protocol),
]
)
)
zf.writestr("tables/lldp_neighbor.csv", "\n".join(csv_lines) + "\n")
# Generic metrics CSV (stream by metric_id)
for sheet in detail.get("sheets") or []:
mid = str(sheet.get("metric_id") or "").strip()
if not mid or mid == "lldp_neighbor":
continue
# First scan discovers the same complete, ordered header as the legacy export.
# Second scan writes rows without materializing the table or CSV.
col_keys: dict[str, None] = {}
has_rows = False
for rec in _iter_metric_export_rows(db, batch_id, mid):
has_rows = True
for k in rec.keys():
col_keys.setdefault(str(k), None)
if not has_rows:
rows = (
db.query(BizStateMetricRow)
.filter(
BizStateMetricRow.batch_id == batch_id,
BizStateMetricRow.metric_id == mid,
)
.order_by(BizStateMetricRow.seq.asc(), BizStateMetricRow.id.asc())
.all()
)
if not rows:
continue
cols = list(col_keys)
recs = [dict(r.data_json or {}) for r in rows]
cols: list[str] = []
for rec in recs:
for k in rec.keys():
if k not in cols:
cols.append(str(k))
out_lines = [",".join(_csv(c) for c in cols)]
for rec in recs:
out_lines.append(",".join(_csv(str(rec.get(c, "") or "")) for c in cols))
safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in mid)[:80] or "metric"
with zf.open(f"tables/{safe}.csv", "w") as dest:
dest.write((",".join(_csv(c) for c in cols) + "\n").encode("utf-8"))
for rec in _iter_metric_export_rows(db, batch_id, mid):
line = ",".join(_csv(rec.get(c)) for c in cols) + "\n"
dest.write(line.encode("utf-8"))
zf.writestr(f"tables/{safe}.csv", "\n".join(out_lines) + "\n")
return buf.getvalue()
def _iter_metric_export_rows(db: Session, batch_id: str, metric_id: str, chunk_size: int = 1000):
cursor: tuple[int, str] | None = None
while True:
query = db.query(BizStateMetricRow.seq, BizStateMetricRow.id, BizStateMetricRow.data_json).filter(
BizStateMetricRow.batch_id == batch_id, BizStateMetricRow.metric_id == metric_id,
)
if cursor is not None:
seq, row_id = cursor
query = query.filter(or_(
BizStateMetricRow.seq > seq,
(BizStateMetricRow.seq == seq) & (BizStateMetricRow.id > row_id),
))
chunk = query.order_by(BizStateMetricRow.seq.asc(), BizStateMetricRow.id.asc()).limit(chunk_size).all()
if not chunk:
return
for row in chunk:
yield row.data_json or {}
cursor = (chunk[-1].seq, chunk[-1].id)
def _csv(v: Any) -> str:
s = "" if v is None else str(v)
if any(ch in s for ch in ",\"\r\n"):
def _csv(v: str) -> str:
s = str(v or "")
if any(ch in s for ch in ",\"\n"):
return '"' + s.replace('"', '""') + '"'
return s

View file

@ -173,11 +173,6 @@ def api_get_task(task_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return svc.get_task(db, task_id)
@router.get("/tasks/{task_id}/progress")
def api_task_progress(task_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return svc.get_task_progress(db, task_id)
@router.get("/tasks/{task_id}/commands")
def api_plan_task_commands(
task_id: str,
@ -574,7 +569,6 @@ class TemplateMetricIn(BaseModel):
display_fields: list[str] = Field(default_factory=list)
row_filters: list[dict[str, Any]] = Field(default_factory=list)
field_rules: list[FieldRuleIn] = Field(default_factory=list)
ignore_port_changes: bool | None = None
class TemplateIn(BaseModel):
@ -593,7 +587,6 @@ class TemplateIn(BaseModel):
display_fields: list[str] = Field(default_factory=list)
row_filters: list[dict[str, Any]] = Field(default_factory=list)
field_rules: list[FieldRuleIn] = Field(default_factory=list)
ignore_port_changes: bool | None = None
class TemplatePatchIn(BaseModel):
@ -609,7 +602,6 @@ class TemplatePatchIn(BaseModel):
display_fields: list[str] | None = None
row_filters: list[dict[str, Any]] | None = None
field_rules: list[FieldRuleIn] | None = None
ignore_port_changes: bool | None = None
class MappingRowIn(BaseModel):
@ -641,8 +633,6 @@ class CompareJobIn(BaseModel):
mode: str = "manual"
# Empty = all template sheets; non-empty = only these sheet_id values
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 = ""
@ -733,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")
def api_run_job(
job_id: str,
sync: bool = Query(False, description="If true, block until compare finishes (tests/debug)"),
db: Session = Depends(get_db),
) -> dict[str, Any]:
"""Start a compare run. Default is async (returns status=running immediately)."""
if sync:
return cmp_svc.run_compare(db, job_id)
return cmp_svc.enqueue_compare(db, job_id)
def api_run_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.run_compare(db, job_id)
@router.get("/compare/jobs/{job_id}/runs")
@ -754,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)
@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}")
def api_delete_run(run_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.delete_run(db, run_id)
@ -771,7 +748,6 @@ def api_list_run_diffs(
metric_id: str = "",
kind: str = "diff",
kw: str = "",
qf: str = "",
page: int = 1,
page_size: int = 100,
db: Session = Depends(get_db),
@ -782,7 +758,6 @@ def api_list_run_diffs(
metric_id=metric_id,
kind=kind,
kw=kw,
qf=qf,
page=page,
page_size=page_size,
)

View file

@ -110,21 +110,12 @@ def _sync_cutover_hf_windows() -> None:
projects = db.query(BizMigrationProject).all()
for proj in projects:
tids = _all_hf_task_ids(proj, "old") + _all_hf_task_ids(proj, "new")
if tids and any((t := db.get(BizStateTask, tid)) and not t.collect_group_id for tid in tids):
try:
from .biz_migration.service import ensure_highfreq
ensure_highfreq(db, proj.id, collect_now=False)
except Exception:
_log.exception("upgrade cutover collection pairs failed project=%s", proj.id)
window_status = _hf_window_status(proj)
want = _hf_window_status(proj)
tids = _all_hf_task_ids(proj, "old") + _all_hf_task_ids(proj, "new")
for tid in tids:
task = db.get(BizStateTask, tid)
if not task:
continue
want = "paused" if task.collect_manual_only else window_status
purpose = str(getattr(task, "purpose", None) or "").strip()
if purpose and purpose != PURPOSE_CUTOVER_HF:
continue
@ -158,26 +149,12 @@ def _enqueue_due_tasks() -> int:
.filter(
BizStateTask.status == "running",
BizStateTask.collect_running.is_(False),
BizStateTask.collect_manual_only.is_(False),
)
.all()
)
due_ids: list[str] = []
seen_groups: set[str] = set()
now = _utcnow()
for task in tasks:
if task.collect_group_id:
if task.collect_group_id in seen_groups:
continue
seen_groups.add(task.collect_group_id)
peers = db.query(BizStateTask).filter(BizStateTask.collect_group_id == task.collect_group_id).all()
if len(peers) != 2 or any(p.collect_running or p.status != "running" or p.collect_manual_only for p in peers):
continue
anchors = [p.last_collect_started_at or p.last_collect_ended_at for p in peers]
anchor = max((a for a in anchors if a), default=None)
if anchor is None or (now - anchor).total_seconds() >= max(p.interval_sec or 60 for p in peers):
due_ids.append(task.id)
continue
interval = max(60, int(task.interval_sec or 300))
# Prefer start-based interval to avoid drift when collect duration
# approaches the interval (end-based: 40s collect + 60s → 100s cycle).

View file

@ -80,9 +80,8 @@ class Settings(BaseSettings):
ume_topo_nodes_path: str = "/restconf/data/zte-resources-module:TopoNodes"
ume_topological_links_path: str = "/restconf/data/zte-resources-module:TopologicalLinks"
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_file: str = "data/auth/credential_secret"
# Shared-server worker caps (sized for multi-operator use; raise if bastion/DB allow).
ne_connect_max_workers: int = 8
ne_connect_timeout_sec: int = 30

View file

@ -32,9 +32,6 @@ class BizStateTask(Base):
note: Mapped[str] = mapped_column(String(256), default="")
# "" | portrait | cutover_hf — filter cutover HF in biz-state list
purpose: Mapped[str] = mapped_column(String(32), default="", index=True)
# Optional paired sampling; ordinary per-device tasks remain independent.
collect_group_id: Mapped[str] = mapped_column(String(128), default="", index=True)
collect_manual_only: Mapped[bool] = mapped_column(Boolean, default=False)
status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|running|paused|stopped
interval_sec: Mapped[int] = mapped_column(Integer, default=300)
# Keep snapshots for N calendar days (protected baselines/refs never auto-deleted)
@ -89,10 +86,6 @@ class BizStateBatch(Base):
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
task_id: Mapped[str] = mapped_column(String(64), default="", index=True)
collect_group_id: Mapped[str] = mapped_column(String(128), default="", index=True)
collect_round_id: Mapped[str] = mapped_column(String(64), default="", index=True)
collect_priority: Mapped[int] = mapped_column(Integer, default=0)
queued_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
source: Mapped[str] = mapped_column(String(32), default="managed", index=True)
ne_id: Mapped[str] = mapped_column(String(128), default="", index=True)
ne_name: Mapped[str] = mapped_column(String(256), default="")
@ -310,8 +303,6 @@ class BizCompareJob(Base):
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
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="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
@ -356,7 +347,4 @@ class BizCompareDiff(Base):
after_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)
# 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="")

View file

@ -1,73 +1,18 @@
from __future__ import annotations
import logging
from pathlib import Path
from cryptography.fernet import Fernet, InvalidToken
from .config import settings
_log = logging.getLogger("netx.crypto")
_cached_credential_key: str | None = None
class CredentialCryptoError(RuntimeError):
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:
key = ensure_credential_secret_key()
key = str(settings.credential_secret_key or "").strip()
if not key:
raise CredentialCryptoError("credential_secret_key_not_configured")
try:
return Fernet(key.encode("ascii"))
except Exception as exc:
@ -92,7 +37,4 @@ def decrypt_secret(value: str) -> str:
def credentials_configured() -> bool:
try:
return bool(ensure_credential_secret_key())
except Exception:
return False
return bool(str(settings.credential_secret_key or "").strip())

View file

@ -7,22 +7,14 @@ This directory builds a Windows deliverable with:
- Optional **bundled** portable PostgreSQL **or** **external** existing Postgres
- API-hosted UI (`web/dist`) — no separate Vite process for end users
- Program / data split so upgrades do not wipe the database
- Manual update script + check/apply updates (GitHub Releases or custom manifest)
- Optional system tray + start-at-logon
- Manual update script + auto-update **manifest contract** (fetch not implemented yet)
## What users get
| 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) |
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.
| `NetX-x.y.z-win64.zip` | `build_release.ps1` |
| `NetX-Setup-x.y.z.exe` | Compile `installer/netx.iss` with [Inno Setup](https://jrsoftware.org/isinfo.php) after staging |
## Build (developer machine)
@ -33,13 +25,14 @@ cd netx
# Optional: download portable Postgres into packaging\postgres\pgsql
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
```
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:
@ -48,28 +41,28 @@ ISCC.exe packaging\installer\netx.iss
```
Override version in the `.iss` or edit `#define MyAppVersion`.
Optional local zip only: `build_release.ps1 -CreateZip` (not for release upload).
## End-user install
### Setup.exe
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).
3. **Already installed:** if `%ProgramData%\NetX\.env` already has `NETX_DB_MODE`, Setup shows an **Install mode** page:
- **Update** (default) — skip Database page; stop NetX; replace program files; **keep** ProgramData / DB settings.
- **Reinstall** — same Database wizard as first install (`ApplyDatabaseConfig`); does **not** delete ProgramData (use Uninstall → delete data for a wipe).
4. Files → `%ProgramFiles%\NetX\` (program root).
5. Data → `%ProgramData%\NetX\` (`.env`, `pgdata`, spool, secrets).
6. Start menu: **Start NetX** / **Stop NetX** / **Open NetX UI** / **Reconfigure database**.
2. Files → `%ProgramFiles%\NetX\` (program root).
3. Data → `%ProgramData%\NetX\` (`.env`, `pgdata`, spool, secrets).
4. Optional post-install task runs `setup_first_run.ps1` (choose bundled vs external DB).
5. Start menu: **Start NetX** / **Stop NetX** / **Open NetX UI**.
**Elevation (required on fresh PCs):** all end-user entry points (`NetX-Start.cmd`, `NetX-Tray.cmd`, `NetX-Stop.cmd`, `NetX-FirstRun.cmd`, tray, start/stop/setup/update) **self-elevate via UAC**. Program Files installs cannot start reliably as a normal user (`.venv` repair / writes). Approve the UAC prompt once per launch (tray stays elevated afterward).
### Zip (portable)
Silent first install (bundled default): `/SILENT /DbMode=bundled`
Silent external: `/SILENT /DbMode=external /DbHost=... /DbPort=5432 /DbUser=... /DbPassword=... /DbName=...`
Silent update over existing data: `/VERYSILENT /NORESTART /InstallMode=update` (also `/SkipDbPage=1`)
Silent reinstall (DB wizard via params): `/VERYSILENT /InstallMode=reinstall /DbMode=bundled` (or external `/DbHost`…) — also `/ForceDbPage=1`
Optional credential key (reuse encrypted NE passwords from another install): `/CredentialSecretKey=...` — omit to auto-generate.
1. Unpack anywhere.
2. Marker `.portable` → data root is sibling `NetXData\`.
3. Target needs **Python 3.11+** on PATH unless the zip was built with `-CreateVenv`.
4. Run:
```powershell
.\packaging\setup_first_run.ps1
.\packaging\start_netx_app.ps1
```
## Database modes
@ -83,123 +76,15 @@ Existing Windows deploys that only set `NETX_DATABASE_URL` keep working: never s
## 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; runtime scripts also self-elevate so Start/Tray work on locked-down fresh PCs.
Legacy/dev zip (only if you built with `-CreateZip`):
```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.
## 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.
```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 (self-elevates)
.\packaging\netx_tray.ps1 -StartOnLaunch
# Start tray at Windows logon — Scheduled Task with RunLevel Highest
# (replaces legacy HKCU\Run which cannot elevate on fresh installs)
.\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, RunLevel Highest)
.\packaging\install_update_task.ps1
# Manual silent path (only applies when NETX_UPDATE_AUTO=true; self-elevates)
.\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.
See `manifest.example.json`. Future: set `NETX_UPDATE_URL` + `NETX_UPDATE_CHANNEL`; a checker will download the zip and call `update_netx.ps1`. Not implemented in this phase.
## Layout reminder

View file

@ -1,4 +1,4 @@
# Shared path helpers for Windows packaging scripts.
# Shared path helpers for Windows packaging scripts.
# Dot-source: . "$PSScriptRoot\_common.ps1"
$ErrorActionPreference = "Stop"
@ -114,33 +114,7 @@ function Write-DotEnvValue {
if (-not (Test-Path $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
$text = ($lines -join "`r`n")
try {
Set-Content -LiteralPath $Path -Value $text -Encoding utf8
} catch {
# Admin-created .env is often Users:RX only — repair ACL then retry once.
$root = $dir
if ((Split-Path -Leaf $dir) -eq "NetX" -or $dir -match '\\NetX$') {
$root = $dir
} else {
$parent = Split-Path -Parent $dir
if ($parent -and ((Split-Path -Leaf $parent) -eq "NetX")) { $root = $parent }
}
$null = Ensure-NetxDataAcl -DataRoot $root -Quiet
try {
# Reset .env ACL explicitly if still blocked.
$icacls = Join-Path $env:SystemRoot "System32\icacls.exe"
if (Test-Path -LiteralPath $icacls) {
& $icacls $Path /grant "*S-1-5-32-545:M" /C /Q 2>$null | Out-Null
}
Set-Content -LiteralPath $Path -Value $text -Encoding utf8
} catch {
throw (New-Object System.UnauthorizedAccessException(
("access_denied: cannot write {0}. Run Start Menu → NetX → Reconfigure database as Administrator, or: icacls `"{1}`" /grant Users:(OI)(CI)M /T" -f $Path, $root),
$_.Exception
))
}
}
Set-Content -Path $Path -Value ($lines -join "`r`n") -Encoding utf8
}
function Get-DbMode {
@ -161,473 +135,3 @@ function Import-NetxEnvFile {
}
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,
[switch]$RunAs = $false
)
$parts = @(
"-NoProfile",
"-WindowStyle", $WindowStyle,
"-ExecutionPolicy", "Bypass",
"-File", $File
) + $Arguments
$sp = @{
FilePath = "powershell.exe"
ArgumentList = (ConvertTo-NetxProcessArgumentString -Arguments $parts)
}
# -Verb RunAs implies UseShellExecute; cannot combine with -WindowStyle.
if (-not $RunAs) {
$sp.WindowStyle = $WindowStyle
}
if ($WorkingDirectory) { $sp.WorkingDirectory = $WorkingDirectory }
if ($Wait) { $sp.Wait = $true }
if ($PassThru -or $Wait) { $sp.PassThru = $true }
if ($RunAs) { $sp.Verb = "RunAs" }
return Start-Process @sp
}
function Test-NetxIsAdmin {
try {
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
$p = New-Object Security.Principal.WindowsPrincipal($id)
return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
} catch {
return $false
}
}
function ConvertTo-NetxRelaunchArgumentList {
param([System.Collections.IDictionary]$BoundParameters)
$parts = [System.Collections.Generic.List[string]]::new()
if (-not $BoundParameters) { return @() }
foreach ($key in $BoundParameters.Keys) {
$val = $BoundParameters[$key]
if ($val -is [System.Management.Automation.SwitchParameter]) {
if ($val.IsPresent) { [void]$parts.Add("-$key") }
continue
}
if ($val -is [bool]) {
if ($val) { [void]$parts.Add("-$key") }
continue
}
if ($null -eq $val) { continue }
$s = [string]$val
if ($s -eq "") { continue }
[void]$parts.Add("-$key")
[void]$parts.Add($s)
}
return @($parts)
}
function Assert-NetxAdminOrRelaunch {
<#
.SYNOPSIS
End-user packaged scripts must run elevated (Program Files + ProgramData).
If not admin, re-launch the same script via UAC and exit with child exit code.
#>
param(
[Parameter(Mandatory = $true)][string]$ScriptPath,
[System.Collections.IDictionary]$BoundParameters = @{},
[string]$WorkingDirectory = "",
[ValidateSet("Hidden", "Normal", "Minimized")]
[string]$WindowStyle = "Normal"
)
if (Test-NetxIsAdmin) { return }
if ($env:NETX_ELEVATION_RELAUNCH -eq "1") {
throw "admin_required: still not elevated after UAC. Run NetX-Start / NetX-Tray as Administrator."
}
if (-not (Test-Path -LiteralPath $ScriptPath)) {
throw "elevation_script_missing: $ScriptPath"
}
$wd = $WorkingDirectory
if (-not $wd) { $wd = Split-Path -Parent $ScriptPath }
# Window style belongs on powershell.exe argv, not Start-Process (incompatible with -Verb RunAs).
$parts = @(
"-NoProfile",
"-WindowStyle", $WindowStyle,
"-ExecutionPolicy", "Bypass",
"-File", $ScriptPath
) + (ConvertTo-NetxRelaunchArgumentList -BoundParameters $BoundParameters)
$prevRel = $env:NETX_ELEVATION_RELAUNCH
$env:NETX_ELEVATION_RELAUNCH = "1"
try {
$p = Start-Process -FilePath "powershell.exe" `
-ArgumentList (ConvertTo-NetxProcessArgumentString -Arguments $parts) `
-WorkingDirectory $wd `
-Verb RunAs -Wait -PassThru
} catch {
if ($null -ne $prevRel) { $env:NETX_ELEVATION_RELAUNCH = $prevRel } else { Remove-Item Env:NETX_ELEVATION_RELAUNCH -ErrorAction SilentlyContinue }
throw ("admin_required: UAC cancelled or elevation failed. Run NetX as Administrator. {0}" -f $_.Exception.Message)
}
if ($null -ne $prevRel) { $env:NETX_ELEVATION_RELAUNCH = $prevRel } else { Remove-Item Env:NETX_ELEVATION_RELAUNCH -ErrorAction SilentlyContinue }
if ($null -eq $p) {
throw "admin_required: elevation failed (no process)"
}
$code = 0
if ($null -ne $p.ExitCode) { $code = [int]$p.ExitCode }
exit $code
}
function Get-NetxSystemPython {
# Prefer a real interpreter; skip the WindowsApps Store stub.
$candidates = @()
foreach ($cmd in @("python", "python3")) {
$c = Get-Command $cmd -ErrorAction SilentlyContinue
if ($c -and $c.Source -and ($c.Source -notmatch '\\WindowsApps\\')) {
$candidates += $c.Source
}
}
$pyLauncher = Get-Command "py" -ErrorAction SilentlyContinue
if ($pyLauncher -and $pyLauncher.Source -and ($pyLauncher.Source -notmatch '\\WindowsApps\\')) {
try {
$resolved = (& $pyLauncher.Source -3 -c "import sys; print(sys.executable)" 2>$null | Out-String).Trim()
if ($resolved -and (Test-Path -LiteralPath $resolved)) {
$candidates += $resolved
}
} catch {}
}
foreach ($p in @(
"$env:LOCALAPPDATA\Python\pythoncore-3.14-64\python.exe",
"$env:LOCALAPPDATA\Programs\Python\Python312\python.exe",
"$env:LOCALAPPDATA\Programs\Python\Python311\python.exe",
"$env:ProgramFiles\Python312\python.exe",
"$env:ProgramFiles\Python311\python.exe"
)) {
if ($p -and (Test-Path $p)) { $candidates += $p }
}
foreach ($p in ($candidates | Select-Object -Unique)) {
try {
$v = & $p -c "import sys; print('%d.%d'%sys.version_info[:2])" 2>$null
if ($v -match '^(3\.(1[1-9]|[2-9]\d))$') { return $p }
} catch {}
}
return $null
}
function Get-NetxBundledPythonRoot {
param([Parameter(Mandatory = $true)][string]$ProgramRoot)
return Join-Path $ProgramRoot "python\runtime"
}
function Repair-NetxShippedVenv {
param([Parameter(Mandatory = $true)][string]$ProgramRoot)
$cfgPath = Join-Path $ProgramRoot ".venv\pyvenv.cfg"
$rtRoot = Get-NetxBundledPythonRoot -ProgramRoot $ProgramRoot
$rtPy = Join-Path $rtRoot "python.exe"
if (-not (Test-Path -LiteralPath $rtPy)) { return $false }
if (-not (Test-Path -LiteralPath $cfgPath)) { return $false }
$rtRootAbs = (Resolve-Path -LiteralPath $rtRoot).Path
$rtPyAbs = (Resolve-Path -LiteralPath $rtPy).Path
$ver = "3.14.0"
try {
$verOut = & $rtPyAbs -c "import sys; print('%d.%d.%d' % sys.version_info[:3])" 2>$null
if ($verOut) { $ver = ($verOut | Out-String).Trim() }
} catch {}
$lines = @(
"home = $rtRootAbs",
"include-system-site-packages = false",
"version = $ver",
"executable = $rtPyAbs"
)
$desired = ($lines -join "`r`n")
# Skip write when already correct — Users cannot modify Program Files after Setup.
try {
$cur = ([IO.File]::ReadAllText($cfgPath) -replace "`r`n", "`n" -replace "`r", "`n").Trim()
$want = ($desired -replace "`r`n", "`n" -replace "`r", "`n").Trim()
if ($cur -eq $want) {
return $true
}
} catch {}
try {
Set-Content -LiteralPath $cfgPath -Value $desired -Encoding ascii -ErrorAction Stop
return $true
} catch {
# Non-elevated tray/start: leave cfg as-is; caller may still run if paths happen to work.
return $false
}
}
function Test-NetxVenvRunnable {
param([Parameter(Mandatory = $true)][string]$VenvPython)
if (-not (Test-Path -LiteralPath $VenvPython)) { return $false }
$outFile = Join-Path $env:TEMP ("netx-venv-out-" + [Guid]::NewGuid().ToString("n") + ".txt")
$errFile = Join-Path $env:TEMP ("netx-venv-err-" + [Guid]::NewGuid().ToString("n") + ".txt")
try {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $VenvPython
$psi.Arguments = '-c "import encodings, sys; sys.exit(0 if sys.prefix else 1)"'
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$p = [Diagnostics.Process]::Start($psi)
$stderr = $p.StandardError.ReadToEnd()
$null = $p.StandardOutput.ReadToEnd()
$p.WaitForExit()
if ($stderr -match 'did not find executable|No Python at|Fatal Python error') {
return $false
}
if ($p.ExitCode -ne 0) { return $false }
return $true
} finally {
Remove-Item -LiteralPath $outFile, $errFile -Force -ErrorAction SilentlyContinue
}
}
function Ensure-NetxVenv {
param(
[Parameter(Mandatory = $true)][string]$ProgramRoot,
[switch]$ForcePip = $false
)
$venvPy = Join-Path $ProgramRoot ".venv\Scripts\python.exe"
$req = Join-Path $ProgramRoot "requirements.txt"
$rtPy = Join-Path (Get-NetxBundledPythonRoot -ProgramRoot $ProgramRoot) "python.exe"
if (Test-Path -LiteralPath $rtPy) {
try {
$null = Repair-NetxShippedVenv -ProgramRoot $ProgramRoot
} catch {
# Access denied under Program Files when not elevated — ignore if venv already runs.
}
}
if ((Test-Path -LiteralPath $venvPy) -and -not $ForcePip) {
if (Test-NetxVenvRunnable -VenvPython $venvPy) {
return $venvPy
}
Write-Host "[WARN] Shipped .venv exists but Python cannot start (broken pyvenv.cfg or missing runtime)." -ForegroundColor Yellow
if (Test-Path -LiteralPath $rtPy) {
$repaired = $false
try {
$repaired = [bool](Repair-NetxShippedVenv -ProgramRoot $ProgramRoot)
} catch {
$repaired = $false
}
if ($repaired -and (Test-NetxVenvRunnable -VenvPython $venvPy)) {
Write-Host "==> Repaired .venv to use bundled python/runtime" -ForegroundColor Green
return $venvPy
}
if (-not $repaired) {
throw "venv_needs_admin_repair: pyvenv.cfg still points at the build PC. Re-run Setup (Update) as Administrator, or Start Menu → Reconfigure database once elevated."
}
}
if (-not $ForcePip) {
throw "venv_not_runnable: reinstall NetX Setup (ships python/runtime + .venv) or run repair as admin"
}
}
if (-not (Test-Path $venvPy)) {
$py = Get-NetxSystemPython
if (-not $py) {
throw "python_not_found: install Python 3.11+ (or ship Setup built with -CreateVenv)"
}
Write-Host "==> Creating .venv with $py"
$venvDir = Join-Path $ProgramRoot ".venv"
try {
& $py -m venv $venvDir
} catch {
throw "venv_create_failed: cannot write $venvDir (need admin / ship -CreateVenv). $($_.Exception.Message)"
}
if (-not (Test-Path $venvPy)) {
throw "venv_create_failed: missing $venvPy (Program Files may be read-only without elevation)"
}
$ForcePip = $true
}
if ($ForcePip) {
if (-not (Test-Path $req)) { throw "requirements_missing: $req" }
Write-Host "==> pip install -r requirements.txt"
& $venvPy -m pip install --upgrade pip
& $venvPy -m pip install -r $req
if ($LASTEXITCODE -ne 0) { throw "pip_install_failed" }
}
return $venvPy
}
function Stop-NetxTrayProcesses {
param([string]$ProgramRoot = "")
$hits = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {
$_.CommandLine -and $_.CommandLine -match 'netx_tray\.ps1'
})
if ($ProgramRoot) {
$esc = [regex]::Escape($ProgramRoot)
$hits = @($hits | Where-Object { $_.CommandLine -match $esc -or $_.CommandLine -match 'netx_tray\.ps1' })
}
foreach ($p in $hits) {
try {
Stop-Process -Id $p.ProcessId -Force -ErrorAction Stop
Write-Host "Stopped NetX tray PID=$($p.ProcessId)"
} catch {}
}
}
function ConvertTo-NetxSqlLiteral([string]$Value) {
# Single-quote escaping for PostgreSQL string literals.
return ($Value -replace "'", "''")
}

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 575 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View file

@ -1,14 +1,10 @@
param(
param(
[string]$Version = "",
[string]$OutDir = "",
[switch]$SkipWebBuild = $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,
# Default on: offline Setup must ship .venv so target PCs never pip-install.
[switch]$CreateVenv = $true
[switch]$CreateVenv = $false
)
$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.
foreach ($d in @("netx_api", "alembic", "scripts")) {
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
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"
New-Item -ItemType Directory -Path $packOut -Force | Out-Null
Copy-Item -Path (Join-Path $PSScriptRoot "_common.ps1") -Destination $packOut -Force
foreach ($name in @(
"download_postgres.ps1", "setup_first_run.ps1", "start_netx_app.ps1",
"stop_netx_app.ps1", "update_netx.ps1", "check_update.ps1",
"launch_shortcut.ps1", "netx_tray.ps1", "install_autostart.ps1", "install_service.ps1",
"service_run.ps1", "install_update_task.ps1", "uninstall_prepare.ps1", "uninstall_delete_data.ps1", "sign_release.ps1",
"build_release.ps1", "publish_release.ps1", "publish_forgejo_release.ps1", "README.md", "manifest.example.json"
"stop_netx_app.ps1", "update_netx.ps1", "build_release.ps1",
"README.md", "manifest.example.json"
)) {
$src = Join-Path $PSScriptRoot $name
if (Test-Path $src) { Copy-Item $src (Join-Path $packOut $name) -Force }
}
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 "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
Copy-Item -Path (Join-Path $PSScriptRoot "postgres\README.md") -Destination (Join-Path $packOut "postgres\README.md") -Force
if (Test-Path (Join-Path $pgsql "bin\pg_ctl.exe")) {
Write-Host '==> Copying bundled PostgreSQL (bin/lib/share; skip doc/)'
$pgStage = Join-Path $stage "postgres\pgsql"
New-Item -ItemType Directory -Path $pgStage -Force | Out-Null
foreach ($sub in @("bin", "lib", "share")) {
$srcSub = Join-Path $pgsql $sub
if (Test-Path $srcSub) {
Copy-Item -Path $srcSub -Destination (Join-Path $pgStage $sub) -Recurse -Force
}
}
Get-ChildItem -Path $pgsql -File -ErrorAction SilentlyContinue | ForEach-Object {
Copy-Item -Path $_.FullName -Destination (Join-Path $pgStage $_.Name) -Force
}
} else {
throw "bundled_postgres_missing: Setup.exe must ship postgres for offline install. Run download_postgres.ps1 (build machine only)."
Write-Host "==> Copying bundled PostgreSQL"
New-Item -ItemType Directory -Path (Join-Path $stage "postgres") -Force | Out-Null
Copy-Item -Path $pgsql -Destination (Join-Path $stage "postgres\pgsql") -Recurse -Force
}
$builtAt = (Get-Date).ToUniversalTime().ToString("o")
@ -161,93 +99,26 @@ $builtAt = (Get-Date).ToUniversalTime().ToString("o")
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) {
Write-Host "==> Creating portable python/runtime + .venv (must not reference build-machine paths)"
$pyPath = Get-NetxSystemPython
if (-not $pyPath) { throw "python_not_found_for_venv: need Python 3.11+ (not WindowsApps stub)" }
Write-Host " Build Python: $pyPath"
$prefix = (& $pyPath -c "import sys; print(sys.base_prefix)" 2>$null | Out-String).Trim()
if (-not $prefix -or -not (Test-Path -LiteralPath $prefix)) {
throw "python_base_prefix_not_found: $prefix"
}
$rtDir = Join-Path $stage "python\runtime"
if (Test-Path $rtDir) { Remove-Item -Recurse -Force $rtDir }
New-Item -ItemType Directory -Path $rtDir -Force | Out-Null
Write-Host "==> Copying Python stdlib/runtime to $rtDir (exclude base site-packages)"
$spEx = Join-Path $prefix "Lib\site-packages"
$robocopy = Join-Path $env:SystemRoot "System32\robocopy.exe"
if (-not (Test-Path -LiteralPath $robocopy)) { throw "robocopy_not_found" }
& $robocopy $prefix $rtDir /E /XD $spEx __pycache__ /XF *.pyc /NFL /NDL /NJH /NJS /nc /ns /np | Out-Null
if ($LASTEXITCODE -ge 8) { throw "robocopy_python_runtime_failed: exit $LASTEXITCODE" }
$rtPy = Join-Path $rtDir "python.exe"
if (-not (Test-Path -LiteralPath $rtPy)) { throw "python_runtime_missing: $rtPy" }
$venvDir = Join-Path $stage ".venv"
if (Test-Path $venvDir) { Remove-Item -Recurse -Force $venvDir }
Write-Host '==> Creating .venv from shipped runtime (--copies)'
& $rtPy -m venv $venvDir --copies
if ($LASTEXITCODE -ne 0) { throw "venv_create_failed" }
$venvPy = Join-Path $venvDir "Scripts\python.exe"
$null = Repair-NetxShippedVenv -ProgramRoot $stage
if (-not (Test-NetxVenvRunnable -VenvPython $venvPy)) {
throw "venv_not_runnable_after_build: check python/runtime copy"
}
& $venvPy -m pip install --upgrade pip
& $venvPy -m pip install -r (Join-Path $stage "requirements.txt")
if ($LASTEXITCODE -ne 0) { throw "pip_install_failed" }
$null = Repair-NetxShippedVenv -ProgramRoot $stage
if (-not (Test-NetxVenvRunnable -VenvPython $venvPy)) {
throw "venv_not_runnable_after_pip"
}
Write-Host "==> Slimming .venv (drop tests / __pycache__ / *.pyc)"
$site = Join-Path $stage ".venv\Lib\site-packages"
if (Test-Path $site) {
Get-ChildItem -Path $site -Recurse -Directory -Force -ErrorAction SilentlyContinue |
Where-Object {
$_.Name -in @('__pycache__', 'tests', 'test', 'testing', 'Tests') -or
$_.FullName -match '\\pandas\\tests(\\|$)' -or
$_.FullName -match '\\numpy\\(_*tests|tests)(\\|$)' -or
$_.FullName -match '\\scipy\\(_*tests|tests)(\\|$)'
} |
Sort-Object { $_.FullName.Length } -Descending |
ForEach-Object {
Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue
}
Get-ChildItem -Path $site -Recurse -Include *.pyc,*.pyo -Force -ErrorAction SilentlyContinue |
Remove-Item -Force -ErrorAction SilentlyContinue
}
Write-Host "==> Creating .venv in stage (requires Python 3.11+ on PATH)"
$py = (Get-Command python -ErrorAction SilentlyContinue)
if (-not $py) { throw "python_not_found_for_venv" }
& $py.Source -m venv (Join-Path $stage ".venv")
& (Join-Path $stage ".venv\Scripts\python.exe") -m pip install --upgrade pip
& (Join-Path $stage ".venv\Scripts\python.exe") -m pip install -r (Join-Path $stage "requirements.txt")
}
Write-Host "==> Stage ready: $stage" -ForegroundColor Green
if ($SkipZip -and $CreateZip) {
Write-Host "[WARN] -SkipZip ignored because -CreateZip was set" -ForegroundColor Yellow
}
if ($CreateZip) {
if (-not $SkipZip) {
$zip = Join-Path (Split-Path -Parent $stage) "NetX-$Version-win64.zip"
if (Test-Path $zip) { Remove-Item -Force $zip }
Compress-Archive -Path $stage -DestinationPath $zip -Force
Write-Host "==> Zip (optional/dev): $zip" -ForegroundColor Yellow
Write-Host "==> Zip: $zip" -ForegroundColor Green
}
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 " (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"

View file

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

View file

@ -1,33 +0,0 @@
@echo off
setlocal EnableDelayedExpansion
net session >nul 2>&1
if not "%errorLevel%"=="0" (
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "try { $p = Start-Process -FilePath '%~f0' -Verb RunAs -Wait -PassThru; if ($null -eq $p -or $null -eq $p.ExitCode) { exit 1 }; exit $p.ExitCode } catch { exit 1 }"
exit /b !ERRORLEVEL!
)
set "ROOT=%~dp0"
if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%"
cd /d "%ROOT%"
title NetX - Reconfigure database
echo.
echo NetX database reconfigure / 重新配置数据库
echo Prefer the installer wizard for first-time setup.
echo 首次安装请用安装向导选择内置或外置数据库。
echo This window is for repair / reconfigure only.
echo 本窗口仅用于修复或重新配置。
echo.
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%ROOT%\packaging\setup_first_run.ps1" -ProgramRoot "%ROOT%" -DataRoot "%ProgramData%\NetX"
set "EC=!ERRORLEVEL!"
if not "!EC!"=="0" (
echo.
echo Setup failed / 配置失败 exit=!EC!
pause
exit /b !EC!
)
echo.
echo Starting NetX tray... / 正在启动托盘...
start "" powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "%ROOT%\packaging\netx_tray.ps1" -ProgramRoot "%ROOT%" -DataRoot "%ProgramData%\NetX" -StartOnLaunch
echo.
echo Done. You can close this window. / 完成,可关闭本窗口。
pause
exit /b 0

View file

@ -1,13 +0,0 @@
@echo off
setlocal EnableDelayedExpansion
REM Always elevate: Program Files install needs admin to start API / repair venv.
net session >nul 2>&1
if not "%errorLevel%"=="0" (
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "try { $p = Start-Process -FilePath '%~f0' -Verb RunAs -Wait -PassThru; if ($null -eq $p -or $null -eq $p.ExitCode) { exit 1 }; exit $p.ExitCode } catch { exit 1 }"
exit /b !ERRORLEVEL!
)
set "ROOT=%~dp0"
if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%"
cd /d "%ROOT%"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%ROOT%\packaging\launch_shortcut.ps1" -Action start -ProgramRoot "%ROOT%" -DataRoot "%ProgramData%\NetX"
exit /b !ERRORLEVEL!

View file

@ -1,12 +0,0 @@
@echo off
setlocal EnableDelayedExpansion
net session >nul 2>&1
if not "%errorLevel%"=="0" (
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "try { $p = Start-Process -FilePath '%~f0' -Verb RunAs -Wait -PassThru; if ($null -eq $p -or $null -eq $p.ExitCode) { exit 1 }; exit $p.ExitCode } catch { exit 1 }"
exit /b !ERRORLEVEL!
)
set "ROOT=%~dp0"
if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%"
cd /d "%ROOT%"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%ROOT%\packaging\launch_shortcut.ps1" -Action stop -ProgramRoot "%ROOT%" -DataRoot "%ProgramData%\NetX"
exit /b !ERRORLEVEL!

View file

@ -1,13 +0,0 @@
@echo off
setlocal EnableDelayedExpansion
REM Elevate once, then start tray (inherits admin for Start/Stop/Update).
net session >nul 2>&1
if not "%errorLevel%"=="0" (
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "try { $p = Start-Process -FilePath '%~f0' -Verb RunAs -Wait -PassThru; if ($null -eq $p -or $null -eq $p.ExitCode) { exit 1 }; exit $p.ExitCode } catch { exit 1 }"
exit /b !ERRORLEVEL!
)
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\netx_tray.ps1" -ProgramRoot "%ROOT%" -DataRoot "%ProgramData%\NetX" -StartOnLaunch
exit /b 0

View file

@ -5,11 +5,5 @@
# NETX_HOST=127.0.0.1
# NETX_PORT=8890
# 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_TOKEN=
# NETX_UPDATE_CHANNEL=stable
# NETX_UPDATE_AUTO=false

View file

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

View file

@ -1,41 +0,0 @@
param(
[string]$ProgramRoot = "",
[string]$DataRoot = "",
[switch]$Remove = $false
)
# Register / unregister NetX tray at logon with highest privileges (UAC admin).
# HKCU\Run cannot elevate reliably on fresh PCs; use a Scheduled Task instead.
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
Assert-NetxAdminOrRelaunch -ScriptPath $PSCommandPath -BoundParameters $PSBoundParameters -WindowStyle Normal
$prog = Get-NetxProgramRoot -Override $ProgramRoot
$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot
$tray = Join-Path $PSScriptRoot "netx_tray.ps1"
$runKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run"
$legacyName = "NetX"
$taskName = "NetXTray"
$taskPath = "\NetX\"
# Always clear legacy per-user Run key (non-elevated).
Remove-ItemProperty -Path $runKey -Name $legacyName -ErrorAction SilentlyContinue
if ($Remove) {
Unregister-ScheduledTask -TaskName $taskName -TaskPath $taskPath -Confirm:$false -ErrorAction SilentlyContinue
Write-Host "==> Removed NetX tray autostart task"
exit 0
}
$arg = "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$tray`" -ProgramRoot `"$prog`" -DataRoot `"$data`" -StartOnLaunch"
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $arg -WorkingDirectory $prog
$trig = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest
Register-ScheduledTask -TaskName $taskName -TaskPath $taskPath `
-Action $action -Trigger $trig -Settings $settings -Principal $principal -Force | Out-Null
Write-Host "==> NetX tray will start at logon (Scheduled Task, RunLevel Highest)" -ForegroundColor Green
Write-Host " $taskPath$taskName"
Write-Host " $arg"

View file

@ -1,140 +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"
Assert-NetxAdminOrRelaunch -ScriptPath $PSCommandPath -BoundParameters $PSBoundParameters -WindowStyle Normal
$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 {
return Test-NetxIsAdmin
}
function ConvertTo-WinSWPath([string]$Path) {
return ($Path -replace '\\', '/')
}
function Ensure-WinSW {
if (-not (Test-Path $winswDir)) {
New-Item -ItemType Directory -Path $winswDir -Force | Out-Null
}
if (-not (Test-Path $cacheDir)) {
New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
}
$bundled = Join-Path $PSScriptRoot "winsw\WinSW-x64.exe"
$dl = Join-Path $cacheDir "WinSW-x64.exe"
$src = $null
if (Test-Path $bundled) {
$src = $bundled
Write-Host "==> Using bundled WinSW: $bundled"
} elseif (Test-Path $dl) {
$src = $dl
Write-Host "==> Using cached WinSW: $dl"
} elseif ($AllowDownload) {
$url = "https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW-x64.exe"
Write-Host "==> Downloading WinSW: $url"
Invoke-WebRequest -Uri $url -OutFile $dl -UseBasicParsing
$src = $dl
} else {
throw "winsw_missing_offline: expected $bundled (rebuild with build_release.ps1). Or pass -AllowDownload when online."
}
Copy-Item -Path $src -Destination $winswExe -Force
$runPs1 = ConvertTo-WinSWPath (Join-Path $PSScriptRoot "service_run.ps1")
$stopPs1 = ConvertTo-WinSWPath (Join-Path $PSScriptRoot "stop_netx_app.ps1")
$progFs = ConvertTo-WinSWPath $prog
$dataFs = ConvertTo-WinSWPath $data
$logPath = ConvertTo-WinSWPath (Join-Path $data "data\runtime")
if (-not (Test-Path (Join-Path $data "data\runtime"))) {
New-Item -ItemType Directory -Path (Join-Path $data "data\runtime") -Force | Out-Null
}
$xml = @"
<service>
<id>$svcName</id>
<name>NetX Ops</name>
<description>NetX API, workers, and optional bundled PostgreSQL</description>
<executable>powershell.exe</executable>
<arguments>-NoProfile -ExecutionPolicy Bypass -File "$runPs1" -ProgramRoot "$progFs" -DataRoot "$dataFs"</arguments>
<logpath>$logPath</logpath>
<log mode="roll-by-size">
<sizeThreshold>10240</sizeThreshold>
<keepFiles>4</keepFiles>
</log>
<onfailure action="restart" delay="10 sec"/>
<onfailure action="restart" delay="30 sec"/>
<resetfailure>1 hour</resetfailure>
<stoptimeout>60sec</stoptimeout>
<stopexecutable>powershell.exe</stopexecutable>
<stoparguments>-NoProfile -ExecutionPolicy Bypass -File "$stopPs1" -ProgramRoot "$progFs" -DataRoot "$dataFs"</stoparguments>
<workingdirectory>$progFs</workingdirectory>
</service>
"@
# WinSW expects UTF-8 without BOM issues; ASCII-compatible paths preferred.
[System.IO.File]::WriteAllText($winswXml, $xml)
}
if ($Uninstall) {
if (-not (Test-IsAdmin)) { throw "admin_required_for_uninstall" }
if (Test-Path $winswExe) {
Write-Host "==> Stopping/uninstalling WinSW service"
& $winswExe stop 2>$null
& $winswExe uninstall 2>$null
}
Unregister-ScheduledTask -TaskName "NetXService" -TaskPath "\NetX\" -Confirm:$false -ErrorAction SilentlyContinue
Write-Host "==> Service/task removed"
exit 0
}
if (-not (Test-IsAdmin)) {
throw "admin_required: run elevated PowerShell to install the NetX service"
}
if ($Mode -eq "winsw") {
Ensure-WinSW
Write-Host "==> Installing Windows Service via WinSW"
& $winswExe stop 2>$null
& $winswExe uninstall 2>$null
& $winswExe install
if ($LASTEXITCODE -ne 0) { throw "winsw_install_failed" }
if ($Start) {
& $winswExe start
Write-Host "==> Service started" -ForegroundColor Green
} else {
Write-Host "==> Installed. Start with: Start-Service NetX or `"$winswExe`" start"
}
} else {
Write-Host "==> Registering Scheduled Task NetX\NetXService (At startup, SYSTEM)"
$runPs1 = Join-Path $PSScriptRoot "service_run.ps1"
$arg = "-NoProfile -ExecutionPolicy Bypass -File `"$runPs1`" -ProgramRoot `"$prog`" -DataRoot `"$data`""
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $arg -WorkingDirectory $prog
$trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
Register-ScheduledTask -TaskName "NetXService" -TaskPath "\NetX\" -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
if ($Start) {
Start-ScheduledTask -TaskName "NetXService" -TaskPath "\NetX\"
Write-Host "==> Task started" -ForegroundColor Green
} else {
Write-Host "==> Task registered. Start with: Start-ScheduledTask -TaskPath '\NetX\' -TaskName NetXService"
}
}

View file

@ -1,59 +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"
Assert-NetxAdminOrRelaunch -ScriptPath $PSCommandPath -BoundParameters $PSBoundParameters -WindowStyle Normal
$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
# Highest: silent apply must write Program Files / run Setup.
$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest
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, RunLevel Highest)" -ForegroundColor Green
Write-Host " Manual run: .\packaging\check_update.ps1 -Apply -Quiet -AutoOnly"

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -1,749 +0,0 @@
param(
[string]$ProgramRoot = "",
[string]$DataRoot = "",
[switch]$StartOnLaunch = $true,
[switch]$CheckUpdateOnLaunch = $false,
[switch]$AutoUpdate = $false
)
# System-tray controller for NetX (Windows packaged installs).
# Always elevated: Start/Stop/Update touch Program Files + services.
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
Assert-NetxAdminOrRelaunch -ScriptPath $PSCommandPath -BoundParameters $PSBoundParameters -WindowStyle Hidden
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 -Verb RunAs -PassThru
} catch {
$notify.ShowBalloonTip(4000, "NetX", (T "Update cancelled (UAC)." "已取消更新(UAC)。"), [System.Windows.Forms.ToolTipIcon]::Info)
return
}
Start-NetxTrayWatchProcess -Process $ap -OnExit {
param($ap2)
if ($null -eq $ap2) { return }
if (Test-NetxSetupCancelled -ExitCode $ap2.ExitCode) {
$notify.ShowBalloonTip(4000, "NetX", (T "Update cancelled." "已取消更新。"), [System.Windows.Forms.ToolTipIcon]::Info)
return
}
if ($null -ne $ap2.ExitCode -and $ap2.ExitCode -eq 0) {
$newVer = Get-NetxVersion -ProgramRoot $prog
[System.Windows.Forms.MessageBox]::Show(
(T "Updated to $newVer.`nTray will restart." "已更新到 $newVer。`n即将重启托盘。"),
(T "NetX update" "NetX 更新"),
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Information
)
Restart-NetxTraySelf
return
}
[System.Windows.Forms.MessageBox]::Show(
(T "Update failed (exit $($ap2.ExitCode)). Re-run Check for updates, or install NetX-Setup manually." "更新失败(退出码 $($ap2.ExitCode))。请重试「检查更新」,或手动运行 Setup 安装包。"),
(T "NetX update" "NetX 更新"),
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Warning
)
Update-NetxTrayTip
}
} elseif ($cp.ExitCode -eq 0) {
[System.Windows.Forms.MessageBox]::Show(
(T "NetX is up to date ($script:ver)." "已是最新版本 ($script:ver)。"),
(T "NetX update" "NetX 更新")
)
}
} catch {
[System.Windows.Forms.MessageBox]::Show(
(T "Update check failed: $($_.Exception.Message)" "检查更新失败:$($_.Exception.Message)"),
"NetX"
)
}
}
} catch {
[System.Windows.Forms.MessageBox]::Show(
(T "Update check failed: $($_.Exception.Message)" "检查更新失败:$($_.Exception.Message)"),
"NetX"
)
}
}
})
[void]$menu.Items.Add($miUpdate)
[void]$menu.Items.Add((New-NetxMenuSeparator))
# --- exit ---
$miExit = New-NetxMenuItem -Text (T "Exit tray" "退出托盘")
$miExit.ToolTipText = (T "Leave NetX services running" "NetX 服务继续运行")
$miExit.add_Click({
Close-NetxTrayMenu
$notify.Visible = $false
$form.Close()
[System.Windows.Forms.Application]::Exit()
})
[void]$menu.Items.Add($miExit)
$miStopExit = New-NetxMenuItem -Text (T "Stop and exit" "停止并退出")
$miStopExit.add_Click({
Start-NetxTrayDeferred {
$notify.ShowBalloonTip(3000, "NetX", (T "Stopping…" "正在停止…"), [System.Windows.Forms.ToolTipIcon]::Info)
Invoke-NetxScript -File $stopPs1 -Wait | Out-Null
$notify.Visible = $false
$form.Close()
[System.Windows.Forms.Application]::Exit()
}
})
[void]$menu.Items.Add($miStopExit)
$notify.ContextMenuStrip = $menu
$notify.add_DoubleClick({
if (Test-NetxApiHealthy -HostName $hostBind -Port $port -TimeoutSec 2) {
Start-Process $uiUrl
} else {
Invoke-NetxScript -File $startPs1 -ExtraArgs @("-SkipBrowser") | Out-Null
Open-NetxUiWhenReady
}
})
# Refresh tooltip periodically (running / stopped).
$script:tipTimer = New-Object System.Windows.Forms.Timer
$script:tipTimer.Interval = 5000
$script:tipTimer.add_Tick({ Update-NetxTrayTip })
$script:tipTimer.Start()
$form.add_Shown({
if ($AutoUpdate) {
$notify.ShowBalloonTip(4000, "NetX", (T "Checking for updates…" "正在检查更新…"), [System.Windows.Forms.ToolTipIcon]::Info)
Start-NetxPowerShell -File $checkPs1 `
-Arguments @("-ProgramRoot", $prog, "-DataRoot", $data, "-Apply", "-Quiet", "-AutoOnly") `
-WorkingDirectory $prog -WindowStyle Hidden -Wait | Out-Null
} elseif ($CheckUpdateOnLaunch) {
Start-NetxPowerShell -File $checkPs1 `
-Arguments @("-ProgramRoot", $prog, "-DataRoot", $data, "-Quiet") `
-WorkingDirectory $prog -WindowStyle Hidden | Out-Null
}
if ($StartOnLaunch) {
Invoke-NetxScript -File $startPs1 -ExtraArgs @("-SkipBrowser") | Out-Null
$script:netxUiWaitTicks = 0
$script:netxUiWaitMax = 360 # 360 * 500ms = 180s
$script:netxUiTimer = New-Object System.Windows.Forms.Timer
$script:netxUiTimer.Interval = 500
$script:netxUiTimer.add_Tick({
$script:netxUiWaitTicks++
if (Test-NetxApiHealthy -HostName $hostBind -Port $port -TimeoutSec 2) {
$script:netxUiTimer.Stop()
$script:netxUiTimer.Dispose()
try { Start-Process $uiUrl } catch {}
Update-NetxTrayTip
return
}
if ($script:netxUiWaitTicks -eq 1) {
$notify.ShowBalloonTip(
5000,
"NetX",
(T "Starting… first run may take a minute." "正在启动…首次迁移可能需要一分钟。"),
[System.Windows.Forms.ToolTipIcon]::Info
)
}
if ($script:netxUiWaitTicks -ge $script:netxUiWaitMax) {
$script:netxUiTimer.Stop()
$script:netxUiTimer.Dispose()
$notify.ShowBalloonTip(
8000,
"NetX",
(T "UI not ready yet. Use tray → Open UI later, or check data\runtime\netx.err.log." "界面尚未就绪。请稍后用托盘「打开界面」,或查看 data\runtime\netx.err.log。"),
[System.Windows.Forms.ToolTipIcon]::Warning
)
Update-NetxTrayTip
}
})
$script:netxUiTimer.Start()
}
})
$form.add_FormClosing({
try { $script:tipTimer.Stop(); $script:tipTimer.Dispose() } catch {}
$notify.Visible = $false
$notify.Dispose()
})
[System.Windows.Forms.Application]::Run($form)

View file

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

View file

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

View file

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

View file

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

View file

@ -4,35 +4,31 @@ param(
[string]$ProgramRoot = "",
[string]$DataRoot = "",
[string]$ExternalDatabaseUrl = "",
[string]$ExternalDatabaseUrlFile = "",
[string]$CredentialSecretKey = "",
[string]$CredentialSecretKeyFile = "",
[string]$BundledPassword = "",
[int]$BundledPort = 15432,
[switch]$NonInteractive = $false,
[switch]$SkipExternalProbe = $false
[switch]$NonInteractive = $false
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
Assert-NetxAdminOrRelaunch -ScriptPath $PSCommandPath -BoundParameters $PSBoundParameters -WindowStyle Normal
$prog = Get-NetxProgramRoot -Override $ProgramRoot
$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot
$envPath = Join-Path $data ".env"
$zh = ([cultureinfo]::CurrentUICulture.Name -match '^(zh|zh-)')
function T([string]$En, [string]$Zh) {
if ($zh) { return $Zh } else { return $En }
Write-Host "==> Program root: $prog"
Write-Host "==> Data root: $data"
Write-Host "==> Env file: $envPath"
if (-not (Test-Path $data)) {
New-Item -ItemType Directory -Path $data -Force | Out-Null
}
foreach ($sub in @("data", "data\auth", "data\runtime", "backups", "pgdata")) {
$p = Join-Path $data $sub
if (-not (Test-Path $p)) {
New-Item -ItemType Directory -Path $p -Force | Out-Null
}
}
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
if (-not $DbMode) {
@ -46,11 +42,10 @@ if (-not $DbMode) {
}
} else {
Write-Host ""
Write-Host (T "Choose database mode:" "选择数据库模式:") -ForegroundColor Cyan
Write-Host (T " 1) bundled — NetX portable PostgreSQL (offline / default)" " 1) bundled — NetX 内置便携 PostgreSQL(可离线,默认)")
Write-Host (T " 2) external — existing PostgreSQL (you provide connection URL)" " 2) external — 已有外置 PostgreSQL(需填写连接串)")
Write-Host ""
$choice = Read-Host (T "Enter 1 or 2" "请输入 1 或 2")
Write-Host "Choose database mode:"
Write-Host " 1) bundled — use NetX portable PostgreSQL (default for new installs)"
Write-Host " 2) external — connect to an existing PostgreSQL (Linux / already deployed)"
$choice = Read-Host "Enter 1 or 2"
if ($choice -eq "2") { $DbMode = "external" } else { $DbMode = "bundled" }
}
}
@ -63,44 +58,28 @@ $values = @{}
$values["NETX_DB_MODE"] = $DbMode
$values["NETX_HOST"] = "127.0.0.1"
$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).
$dataEnv = Get-NetxDataEnvMap -DataRoot $data
foreach ($k in $dataEnv.Keys) { $values[$k] = $dataEnv[$k] }
# Preload credential key from file/CLI only; interactive prompt comes AFTER DB settings
# so users are not left thinking the key alone finished setup.
if ($CredentialSecretKeyFile) {
if (-not (Test-Path -LiteralPath $CredentialSecretKeyFile)) {
throw "credential_secret_key_file_missing: $CredentialSecretKeyFile"
}
$CredentialSecretKey = [IO.File]::ReadAllText($CredentialSecretKeyFile).Trim()
}
# Point runtime data dirs into the data root (absolute).
$values["NETX_AUTH_MCP_TOKEN_FILE"] = ((Join-Path $data "data\auth\mcp_token") -replace '\\', '/')
$values["NETX_SCHEDULER_HEARTBEAT_PATH"] = ((Join-Path $data "data\runtime\scheduler_heartbeat.json") -replace '\\', '/')
if ($DbMode -eq "bundled") {
$pgsql = Join-Path $prog "postgres\pgsql"
if (-not (Test-Path (Join-Path $pgsql "bin\initdb.exe"))) {
# In-repo layout
$alt = Join-Path $PSScriptRoot "postgres\pgsql"
if (Test-Path (Join-Path $alt "bin\initdb.exe")) {
$pgsql = $alt
} else {
throw (T `
"bundled_postgres_missing: incomplete package (expected $pgsql). Offline Setup must include postgres; rebuild with build_release.ps1 on a machine that already ran download_postgres.ps1." `
"缺少内置 PostgreSQL(期望路径 $pgsql)。离线安装包必须自带数据库;请在已运行过 download_postgres.ps1 的机器上重新 build_release。")
throw "bundled_postgres_missing: run packaging\download_postgres.ps1 first (expected $pgsql)"
}
}
if (-not $BundledPassword) {
if ($NonInteractive) {
$BundledPassword = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char]$_ })
} else {
$sec = Read-Host -Prompt (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)
try {
$BundledPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
@ -109,22 +88,24 @@ if ($DbMode -eq "bundled") {
}
if (-not $BundledPassword) {
$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"
$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"
Set-Content -Path $pwFile -Value $BundledPassword -Encoding ascii -NoNewline
$encPw = [uri]::EscapeDataString($BundledPassword)
$values["NETX_DATABASE_URL"] = "postgresql+psycopg://netx:${encPw}@127.0.0.1:${BundledPort}/netx"
# Initialize cluster if needed
$initdb = Join-Path $pgsql "bin\initdb.exe"
$pgCtl = Join-Path $pgsql "bin\pg_ctl.exe"
$psql = Join-Path $pgsql "bin\psql.exe"
$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"))) {
Write-Host "==> initdb $pgData"
@ -134,6 +115,7 @@ if ($DbMode -eq "bundled") {
if ($LASTEXITCODE -ne 0) { throw "initdb_failed" }
}
# Ensure listen / port in postgresql.conf
$conf = Join-Path $pgData "postgresql.conf"
$hba = Join-Path $pgData "pg_hba.conf"
if (Test-Path $conf) {
@ -144,8 +126,6 @@ if ($DbMode -eq "bundled") {
$confText = $confText -replace '(?m)^\s*port\s*=\s*\d+', "port = $BundledPort"
if ($confText -notmatch "(?m)^\s*listen_addresses\s*=") {
$confText += "`nlisten_addresses = '127.0.0.1'`n"
} else {
$confText = $confText -replace "(?m)^\s*listen_addresses\s*=\s*'[^']*'", "listen_addresses = '127.0.0.1'"
}
Set-Content -Path $conf -Value $confText -Encoding utf8
}
@ -157,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"
& $pgCtl -D $pgData -l (Join-Path $pgData "pg.log") start
if ($LASTEXITCODE -ne 0) { throw "pg_start_failed" }
Start-Sleep -Seconds 2
}
Write-Host "==> Starting bundled PostgreSQL for bootstrap"
& $pgCtl -D $pgData -l (Join-Path $data "pgdata\pg.log") start
Start-Sleep -Seconds 2
$env:PGPASSWORD = $BundledPassword
try {
function Invoke-NetxPsql {
param([string]$Sql, [string]$Database = "postgres")
$out = & $psql -h 127.0.0.1 -p $BundledPort -U postgres -d $Database -v ON_ERROR_STOP=1 -tAc $Sql 2>&1
if ($LASTEXITCODE -ne 0) {
throw "psql_failed ($LASTEXITCODE): $Sql`n$out"
}
return (($out | Out-String).Trim())
}
$role = Invoke-NetxPsql -Sql "SELECT 1 FROM pg_roles WHERE rolname='netx'"
if ($role -eq "1") {
Invoke-NetxPsql -Sql "ALTER ROLE netx WITH LOGIN PASSWORD '$sqlPw'" | Out-Null
$role = & $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname='netx'"
if ($role -notmatch "1") {
& $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -v ON_ERROR_STOP=1 `
-c "CREATE ROLE netx LOGIN PASSWORD '$BundledPassword';"
} else {
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'"
if ($db -ne "1") {
Invoke-NetxPsql -Sql "CREATE DATABASE netx OWNER netx" | Out-Null
}
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
$db = & $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='netx'"
if ($db -notmatch "1") {
& $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -v ON_ERROR_STOP=1 `
-c "CREATE DATABASE netx OWNER netx;"
}
& $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -v ON_ERROR_STOP=1 `
-c "GRANT ALL PRIVILEGES ON DATABASE netx TO netx;"
} finally {
Remove-Item Env:PGPASSWORD -ErrorAction SilentlyContinue
}
Write-Host "==> Bundled Postgres ready on 127.0.0.1:$BundledPort" -ForegroundColor Green
} else {
if ($ExternalDatabaseUrlFile) {
if (-not (Test-Path -LiteralPath $ExternalDatabaseUrlFile)) {
throw "external_url_file_missing: $ExternalDatabaseUrlFile"
}
$ExternalDatabaseUrl = [IO.File]::ReadAllText($ExternalDatabaseUrlFile).Trim()
}
if (-not $ExternalDatabaseUrl) {
if ($NonInteractive) {
if ($existing.ContainsKey("NETX_DATABASE_URL") -and $existing["NETX_DATABASE_URL"]) {
$ExternalDatabaseUrl = $existing["NETX_DATABASE_URL"]
} else {
throw "external_url_required"
}
if ($existing.ContainsKey("NETX_DATABASE_URL") -and $existing["NETX_DATABASE_URL"]) {
$ExternalDatabaseUrl = $existing["NETX_DATABASE_URL"]
} elseif ($NonInteractive) {
throw "external_url_required"
} else {
# Interactive: always ask. Empty = keep existing URL (never silent).
Write-Host ""
Write-Host (T `
"Enter PostgreSQL URL (database/role must already exist):" `
"请输入 PostgreSQL 连接串(库和用户需事先建好):") -ForegroundColor Cyan
Write-Host " postgresql+psycopg://USER:PASSWORD@HOST:5432/DBNAME"
Write-Host (T `
"Example: postgresql+psycopg://netx:secret@10.0.0.8:5432/netx" `
"示例: postgresql+psycopg://netx:secret@10.0.0.8:5432/netx")
if ($existing.ContainsKey("NETX_DATABASE_URL") -and $existing["NETX_DATABASE_URL"]) {
Write-Host (T `
"Current: $($existing['NETX_DATABASE_URL'])" `
"当前: $($existing['NETX_DATABASE_URL'])") -ForegroundColor DarkGray
Write-Host (T `
"Press Enter to keep the current URL, or paste a new one." `
"直接回车 = 保留当前连接串;或粘贴新的连接串。")
}
$entered = (Read-Host "NETX_DATABASE_URL").Trim()
if ($entered) {
$ExternalDatabaseUrl = $entered
} elseif ($existing.ContainsKey("NETX_DATABASE_URL") -and $existing["NETX_DATABASE_URL"]) {
$ExternalDatabaseUrl = $existing["NETX_DATABASE_URL"]
Write-Host (T "==> Keeping existing NETX_DATABASE_URL" "==> 保留已有 NETX_DATABASE_URL") -ForegroundColor Green
}
$ExternalDatabaseUrl = Read-Host "NETX_DATABASE_URL (postgresql+psycopg://user:pass@host:5432/netx)"
}
}
if (-not $ExternalDatabaseUrl) {
throw "external_url_required"
}
$ExternalDatabaseUrl = $ExternalDatabaseUrl.Trim()
$values["NETX_DATABASE_URL"] = $ExternalDatabaseUrl
Write-Host "==> External Postgres configured" -ForegroundColor Green
if (-not $SkipExternalProbe) {
# Probe with bundled psql when available (wizard already tested; this covers CLI reconfigure).
$psqlProbe = Join-Path $prog "postgres\pgsql\bin\psql.exe"
$testHelper = Join-Path $PSScriptRoot "installer\test_pg_conn.ps1"
if (-not (Test-Path $testHelper)) {
$testHelper = Join-Path $PSScriptRoot "test_pg_conn.ps1"
}
if ((Test-Path $psqlProbe) -and ($ExternalDatabaseUrl -match '^(?:postgresql(?:\+psycopg)?|postgres)://([^:]+):([^@]*)@([^:/]+):?(\d+)?/([^?\s]+)')) {
$u = [uri]::UnescapeDataString($Matches[1])
$pw = [uri]::UnescapeDataString($Matches[2])
$h = $Matches[3]
$po = if ($Matches[4]) { [int]$Matches[4] } else { 5432 }
$dbn = [uri]::UnescapeDataString($Matches[5])
Write-Host "==> Probing external PostgreSQL ${h}:${po}/${dbn} ..."
if (Test-Path $testHelper) {
$probeErr = Join-Path $env:TEMP ("netx-pg-probe-" + [Guid]::NewGuid().ToString("n") + ".txt")
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $testHelper `
-PgHost $h -Port $po -User $u -Password $pw -Database $dbn `
-PsqlPath $psqlProbe -OutErrFile $probeErr
if ($LASTEXITCODE -ne 0) {
$detail = if (Test-Path $probeErr) { (Get-Content -LiteralPath $probeErr -Raw) } else { "exit $LASTEXITCODE" }
Remove-Item -LiteralPath $probeErr -Force -ErrorAction SilentlyContinue
throw (T "external_db_probe_failed: $detail" "外置数据库连接失败: $detail")
}
Remove-Item -LiteralPath $probeErr -Force -ErrorAction SilentlyContinue
Write-Host "==> External PostgreSQL OK" -ForegroundColor Green
} else {
$env:PGPASSWORD = $pw
try {
$ping = & $psqlProbe -h $h -p $po -U $u -d $dbn -t -A -c "SELECT 1" 2>&1
if ($LASTEXITCODE -ne 0 -or (($ping | Out-String).Trim()) -notmatch '1') {
throw (T "external_db_probe_failed: $ping" "外置数据库连接失败: $ping")
}
} finally {
Remove-Item Env:PGPASSWORD -ErrorAction SilentlyContinue
}
}
} else {
Write-Host (T `
"[WARN] Skipped connection probe (no bundled psql or URL parse failed)." `
"[WARN] 已跳过连接探测(缺少捆绑 psql 或连接串无法解析)。") -ForegroundColor Yellow
}
}
}
# Fernet key AFTER database prompts (interactive UX).
# Priority: CLI/file > interactive prompt > existing .env > generate.
if (-not $CredentialSecretKey -and -not $NonInteractive) {
Write-Host ""
Write-Host (T `
"Optional: paste NETX_CREDENTIAL_SECRET_KEY from an existing install" `
"可选: 粘贴已有安装的 NETX_CREDENTIAL_SECRET_KEY(便于沿用已加密凭据)") -ForegroundColor Cyan
Write-Host (T `
"Leave empty to keep existing .env key, or auto-generate if none." `
"留空则保留已有 .env 中的密钥;若没有则自动生成。")
$CredentialSecretKey = (Read-Host "NETX_CREDENTIAL_SECRET_KEY").Trim()
}
if ($CredentialSecretKey) {
$values["NETX_CREDENTIAL_SECRET_KEY"] = $CredentialSecretKey.Trim()
Write-Host "==> Using provided NETX_CREDENTIAL_SECRET_KEY" -ForegroundColor Green
} elseif ($existing.ContainsKey("NETX_CREDENTIAL_SECRET_KEY") -and $existing["NETX_CREDENTIAL_SECRET_KEY"]) {
$values["NETX_CREDENTIAL_SECRET_KEY"] = $existing["NETX_CREDENTIAL_SECRET_KEY"]
Write-Host "==> Keeping existing NETX_CREDENTIAL_SECRET_KEY from .env" -ForegroundColor Green
} else {
$values["NETX_CREDENTIAL_SECRET_KEY"] = New-NetxFernetKey
Write-Host "==> Generated NETX_CREDENTIAL_SECRET_KEY (saved in .env)" -ForegroundColor Green
Write-Host "==> External Postgres configured (ensure role/db exist; see scripts\init_pg.ps1)" -ForegroundColor Green
}
Write-DotEnvValue -Path $envPath -Values $values
Write-Host ""
Write-Host "Wrote $envPath" -ForegroundColor Green
# 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
}
Write-Host "Next: .\packaging\start_netx_app.ps1"

View file

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

View file

@ -2,13 +2,11 @@ param(
[string]$ProgramRoot = "",
[string]$DataRoot = "",
[switch]$SkipBrowser = $false,
[switch]$InlineSchedulers = $false,
[switch]$Force = $false
[switch]$InlineSchedulers = $false
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
Assert-NetxAdminOrRelaunch -ScriptPath $PSCommandPath -BoundParameters $PSBoundParameters -WindowStyle Normal
$prog = Get-NetxProgramRoot -Override $ProgramRoot
$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot
@ -23,39 +21,25 @@ if (-not (Test-Path (Join-Path $prog "netx_api"))) {
throw "netx_api not found under program root: $prog"
}
Ensure-NetxDataDirectories -DataRoot $data
$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
$env:PYTHONPATH = $prog
# Keep runtime artifacts in the data root (not under Program Files).
$env:NETX_AUTH_MCP_TOKEN_FILE = Join-Path $data "data\auth\mcp_token"
$env:NETX_SCHEDULER_HEARTBEAT_PATH = Join-Path $data "data\runtime\scheduler_heartbeat.json"
$env:NETX_AUTH_SECRET_FILE = Join-Path $data "data\auth\jwt_secret"
$dist = Join-Path $prog "web\dist"
if (Test-Path (Join-Path $dist "index.html")) {
$env:NETX_UI_DIST_DIR = $dist
}
$mode = Get-DbMode -EnvMap $map
$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 "==> Data: $data"
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 {
foreach ($b in @(
(Join-Path $prog "postgres\pgsql\bin"),
@ -70,20 +54,13 @@ if ($mode -eq "bundled") {
$pgBin = Get-PgsqlBin
if (-not $pgBin) { throw "bundled_postgres_missing" }
$pgData = if ($map["NETX_BUNDLED_PG_DATA_DIR"]) {
$map["NETX_BUNDLED_PG_DATA_DIR"] -replace '/', '\'
$map["NETX_BUNDLED_PG_DATA_DIR"]
} else {
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"
$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 $pgPort)) {
throw "port_in_use: 127.0.0.1:$pgPort (bundled Postgres)"
}
Write-Host "==> Starting bundled PostgreSQL"
if (-not (Test-Path $pgData)) {
New-Item -ItemType Directory -Path $pgData -Force | Out-Null
@ -97,48 +74,38 @@ if ($mode -eq "bundled") {
}
}
try {
$null = Ensure-NetxVenv -ProgramRoot $prog
} catch {
Write-Host "[ERR] $($_.Exception.Message)" -ForegroundColor Red
Write-Host " Tip: run Start Menu → NetX → First-time setup once as Administrator," -ForegroundColor Yellow
Write-Host " or install a Setup built with packaging\\build_release.ps1 -CreateVenv." -ForegroundColor Yellow
exit 1
# Ensure venv exists for start_netx.ps1
$venvPy = Join-Path $prog ".venv\Scripts\python.exe"
if (-not (Test-Path $venvPy)) {
Write-Host "==> Creating .venv (one-time)"
$pyCmd = Get-Command python -ErrorAction SilentlyContinue
if (-not $pyCmd) { throw "python_not_found: install Python 3.11+ and re-run" }
& $pyCmd.Source -m venv (Join-Path $prog ".venv")
& $venvPy -m pip install --upgrade pip
& $venvPy -m pip install -r (Join-Path $prog "requirements.txt")
if ($LASTEXITCODE -ne 0) { throw "pip_install_failed" }
}
if (-not (Test-NetxTcpPortFree -HostName $hostBind -Port $port)) {
if (Test-NetxApiHealthy -HostName $hostBind -Port $port) {
Write-Host "==> Port $port already serves NetX — skip start" -ForegroundColor Green
exit 0
}
throw "port_in_use: ${hostBind}:${port} occupied by non-NetX process"
}
$hostBind = if ($env:NETX_HOST) { $env:NETX_HOST } else { "127.0.0.1" }
$port = if ($env:NETX_PORT) { [int]$env:NETX_PORT } else { 8890 }
$startScript = Join-Path $prog "scripts\start_netx.ps1"
if (-not (Test-Path $startScript)) {
throw "start_netx.ps1 not found at $startScript"
}
$psArgs = @(
"-ExecutionPolicy", "Bypass", "-File", $startScript,
"-SkipInstall", "-Background",
"-BindHost", $hostBind, "-Port", "$port"
)
if ($InlineSchedulers) { $psArgs += "-InlineSchedulers" }
Write-Host "==> Starting NetX (API + workers; UI via API on :$port)"
# Run in-process with -Background so this console exits after /health (nested
# Start-Process -Wait on a Hidden child often never returns under UAC update).
$startArgs = @{
SkipInstall = $true
Background = $true
BindHost = $hostBind
Port = $port
}
if ($InlineSchedulers) { $startArgs["InlineSchedulers"] = $true }
& $startScript @startArgs
if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
& powershell @psArgs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$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
if (-not $SkipBrowser) {
try { Start-Process $url } catch {}

View file

@ -1,13 +1,11 @@
param(
[string]$ProgramRoot = "",
[string]$DataRoot = "",
[switch]$KeepPostgres = $false,
[switch]$KillTray = $false
[switch]$KeepPostgres = $false
)
$ErrorActionPreference = "Continue"
. "$PSScriptRoot\_common.ps1"
Assert-NetxAdminOrRelaunch -ScriptPath $PSCommandPath -BoundParameters $PSBoundParameters -WindowStyle Hidden
$prog = Get-NetxProgramRoot -Override $ProgramRoot
$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot
@ -23,17 +21,11 @@ if (-not (Test-Path $stopScript)) {
}
if (Test-Path $stopScript) {
Write-Host "==> Stopping NetX processes"
Start-NetxPowerShell -File $stopScript -Arguments @("-Force") `
-WorkingDirectory $prog -WindowStyle Hidden -Wait | Out-Null
& powershell -ExecutionPolicy Bypass -File $stopScript -Force
} else {
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
if ($mode -eq "bundled" -and -not $KeepPostgres) {
$pgBinCandidates = @(

View file

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

View file

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

View file

@ -9,7 +9,6 @@ param(
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
Assert-NetxAdminOrRelaunch -ScriptPath $PSCommandPath -BoundParameters $PSBoundParameters -WindowStyle Normal
$prog = Get-NetxProgramRoot -Override $ProgramRoot
$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot
@ -23,14 +22,10 @@ New-Item -ItemType Directory -Path $work -Force | Out-Null
try {
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")) {
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 {
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
@ -59,35 +54,7 @@ try {
if (Test-Path $envFile) {
Copy-Item $envFile (Join-Path $bak ".env")
}
# Best-effort logical backup when psql is available (bundled or PATH).
$map = Read-DotEnv -Path $envFile
$pgDump = $null
foreach ($c in @(
(Join-Path $prog "postgres\pgsql\bin\pg_dump.exe"),
(Join-Path $PSScriptRoot "postgres\pgsql\bin\pg_dump.exe")
)) {
if (Test-Path $c) { $pgDump = $c; break }
}
if (-not $pgDump) {
$cmd = Get-Command pg_dump -ErrorAction SilentlyContinue
if ($cmd) { $pgDump = $cmd.Source }
}
if ($pgDump -and $map["NETX_DATABASE_URL"] -match 'postgresql\+?[^:]*://([^:]+):([^@]+)@([^:/]+):?(\d+)?/([^?\s]+)') {
$u = $Matches[1]; $p = [uri]::UnescapeDataString($Matches[2]); $h = $Matches[3]
$pt = if ($Matches[4]) { $Matches[4] } else { "5432" }
$dbn = $Matches[5]
$dumpOut = Join-Path $bak "netx.dump"
Write-Host "==> pg_dump -> $dumpOut"
$env:PGPASSWORD = $p
try {
& $pgDump -h $h -p $pt -U $u -d $dbn -Fc -f $dumpOut
} catch {
Write-Host "[WARN] pg_dump failed: $($_.Exception.Message)" -ForegroundColor Yellow
} finally {
Remove-Item Env:PGPASSWORD -ErrorAction SilentlyContinue
}
}
Write-Host "==> Backup: $bak"
Write-Host "==> Backup marker: $bak (data/pgdata left in place; optional pg_dump not run)"
}
Write-Host "==> Stopping services"
@ -95,8 +62,7 @@ try {
-ProgramRoot $prog -DataRoot $data
# 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", "python")
$replaceDirs = @("netx_api", "alembic", "web", "packaging", "scripts", "postgres", "packages")
foreach ($name in $replaceDirs) {
$from = Join-Path $src $name
$to = Join-Path $prog $name
@ -113,7 +79,7 @@ try {
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")) {
$rt = Join-Path $prog "runtime"
if (Test-Path $rt) { Remove-Item -Recurse -Force $rt }
@ -126,21 +92,11 @@ try {
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
if (-not $NoStart) {
# Same process (no nested powershell) so the update console can exit cleanly.
& (Join-Path $PSScriptRoot "start_netx_app.ps1") `
& powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "start_netx_app.ps1") `
-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 {
if (Test-Path $work) {
Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue

View file

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

View file

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

View file

@ -23,24 +23,9 @@ if ($Backgtound -and -not $Background) {
$projectRoot = Split-Path -Parent $PSScriptRoot
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)) {
New-Item -ItemType Directory -Path $runDir -Force | Out-Null
New-Item -ItemType Directory -Path $runDir | Out-Null
}
$pidFile = Join-Path $runDir "netx.pid"
@ -54,17 +39,13 @@ $webPidFile = Join-Path $runDir "web.pid"
$webLogFile = Join-Path $runDir "web.out.log"
$webErrFile = Join-Path $runDir "web.err.log"
$venvPython = Join-Path $projectRoot ".venv\Scripts\python.exe"
if (Get-Command Test-NetxVenvRunnable -ErrorAction SilentlyContinue) {
if (-not (Test-NetxVenvRunnable -VenvPython $venvPython)) {
throw "venv_not_runnable: reinstall NetX or run setup as administrator"
}
} elseif (-not (Test-Path -LiteralPath $venvPython)) {
$venvPython = Join-Path $projectRoot ".venv\\Scripts\\python.exe"
if (-not (Test-Path $venvPython)) {
Write-Host "==> .venv not found, creating virtual environment"
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
@ -272,11 +253,7 @@ if ($Background) {
Show-LogTail -Path $logFile
exit 1
}
# Fresh installs run Alembic upgrades on first boot; 45s is often too short.
$healthWaitSec = 180
if ($env:NETX_START_HEALTH_WAIT_SEC) {
try { $healthWaitSec = [int]$env:NETX_START_HEALTH_WAIT_SEC } catch {}
}
$healthWaitSec = 45
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
Show-LogTail -Path $errFile

View file

@ -1,4 +1,4 @@
param(
param(
[int]$Port = 8890,
[int]$WebPort = 5173,
# Windows often ignores graceful Stop-Process on python; default hard-kill.
@ -9,13 +9,7 @@
$ErrorActionPreference = "Continue"
$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"
$workerPidFile = Join-Path $runDir "worker.pid"
$webPidFile = Join-Path $runDir "web.pid"

View file

@ -1,190 +0,0 @@
"""Template validation, rule semantics, protected references and bounded queries."""
from copy import deepcopy
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from netx_api.db import Base
from netx_api.models import BizCompareJob, BizCompareRun, BizCompareTemplate, BizStateBatchCommand, BizStateBatch
from netx_api.biz_state import compare_service as svc
from netx_api.biz_state.compare_engine import compare_rows, row_key
from netx_api.biz_state.compare_rules import eval_leaf_filter, values_equal
from netx_api.biz_state.compare_sql import compile_row_filters_sql, _rk_sql, _field_differs_sql
from netx_api.biz_state_router import TemplateIn, TemplatePatchIn
@pytest.fixture
def db():
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine, expire_on_commit=False)()
yield session
session.close()
engine.dispose()
def body():
return {"name": "Test", "metrics": [{"sheet_id": "s", "metric_id": "custom", "key_fields": ["id"],
"compare_fields": ["count"], "iface_fields": ["port"], "ignore_port_changes": False,
"row_filters": [], "field_rules": []}]}
@pytest.mark.parametrize("bad", [
{"key_fields": []}, {"key_fields": ["id", "id"]}, {"metric_id": ""},
{"row_filters": [{"field": "state", "op": "typo", "value": "up"}]},
{"row_filters": [{"field": "state", "op": "regex", "value": "["}]},
{"row_filters": [{"any": []}]}, {"row_filters": [{"field": "id", "op": "in", "value": "a,b"}]},
{"field_rules": [{"field": "count", "compare": "typo"}]},
{"field_rules": [{"field": "count", "normalize": "typo"}]},
{"field_rules": [{"field": "count", "tolerance": -1}]},
{"field_rules": [{"field": "count", "tolerance": float("inf")}]},
{"field_rules": [{"field": "count", "tolerance": float("nan")}]},
{"field_rules": [{"field": "count"}, {"field": "count"}]},
])
def test_invalid_sheet_never_silently_drops_or_saves(db, bad):
data = body()
data["metrics"].append({**deepcopy(data["metrics"][0]), "sheet_id": "second", **bad})
with pytest.raises(HTTPException) as exc:
svc.create_template(db, data)
assert exc.value.status_code == 400
assert "metrics[1]" in exc.value.detail["path"]
assert db.query(BizCompareTemplate).count() == 0
def test_template_validation_precedes_mutation_and_rejects_duplicate_alias(db):
tpl = svc.create_template(db, body())
data = body()
data["name"] = "Wrong name"
data["metrics"][0]["key_fields"] = []
with pytest.raises(HTTPException):
svc.update_template(db, tpl["id"], data)
assert db.get(BizCompareTemplate, tpl["id"]).name == "Test"
with pytest.raises(HTTPException):
svc.create_template(db, {**body(), "iface_normalize_rules": [{"from": "GE", "to": "a"}, {"from": "ge", "to": "b"}]})
def test_api_port_policy_roundtrip_and_legacy_patch(db):
saved = svc.create_template(db, TemplateIn.model_validate(body()).model_dump())
assert saved["metrics"][0]["ignore_port_changes"] is False
updated = svc.update_template(db, saved["id"], TemplatePatchIn(ignore_port_changes=True).model_dump(exclude_unset=True))
assert updated["metrics"][0]["ignore_port_changes"] is True
legacy = svc.create_template(db, {"name": "legacy", "metric_id": "custom", "key_fields": ["id"], "ignore_port_changes": False})
assert legacy["metrics"][0]["ignore_port_changes"] is False
@pytest.mark.parametrize("value", [0, False])
def test_filter_and_identity_preserve_false_and_zero(value):
assert not eval_leaf_filter({"x": value}, {"field": "x", "op": "empty"})
assert eval_leaf_filter({"x": value}, {"field": "x", "op": "eq", "value": value})
assert row_key({"id": value}, ["id"]) != row_key({"id": None}, ["id"])
result = compare_rows(before_rows=[{"id": value}], after_rows=[{"id": None}], key_fields=["id"], iface_fields=[], compare_fields=[], port_map={})
assert result["summary"]["removed"] == 1 and result["summary"]["added"] == 1
_, params = compile_row_filters_sql([{"field": "x", "op": "eq", "value": value}])
assert list(params.values()) == ["0" if value is not False else "false"]
@pytest.mark.parametrize("mode", ["numeric", "percent"])
def test_missing_numeric_value_is_not_zero(mode):
assert not values_equal(None, 0, rule={"compare": mode, "tolerance": 5})
assert values_equal(None, "", rule={"compare": mode})
assert values_equal("+.5", "0.5", rule={"compare": mode})
assert values_equal("1.", "1", rule={"compare": mode})
assert values_equal("NaN", "NaN", rule={"compare": mode}) # textual fallback
def test_sql_filter_literal_contains_and_collision_free_composite_keys():
sql, params = compile_row_filters_sql([{"field": "x", "op": "contains", "value": "a%_\\b"}])
assert "ESCAPE" in sql
assert list(params.values()) == ["%a\\%\\_\\\\b%"]
sql, _ = compile_row_filters_sql([{"field": "x", "op": "contains", "value": ""}])
assert "FALSE" in sql
assert "jsonb_build_array" in _rk_sql(["a", "b"])
assert "concat_ws" not in _rk_sql(["a", "b"])
assert "[+-]?" in _field_differs_sql("b", "a", {"compare": "numeric"})
def test_sheet_order_uses_one_aggregate_query_without_raw_text(db):
for bid, metric, count in (("b", "large", 20), ("a", "large", 30), ("b", "small", 3)):
db.add(BizStateBatchCommand(id=f"{bid}-{metric}", batch_id=bid, metric_id=metric, row_count=count, raw_text="x" * 100000))
db.commit()
sql = []
event.listen(db.get_bind(), "before_cursor_execute", lambda c, cur, statement, p, ctx, many: sql.append(statement))
sheets = [{"metric_id": "large", "sheet_id": "l1"}, {"metric_id": "small", "sheet_id": "s"}, {"metric_id": "large", "sheet_id": "l2"}]
ordered = svc._order_sheets_small_first(db, sheets, before_batch_id="b", after_batch_id="a")
assert [s["sheet_id"] for s in ordered] == ["s", "l1", "l2"]
assert len(sql) == 1 and "raw_text" not in sql[0]
def test_referenced_template_missing_sheets_and_running_delete_protected(db):
tpl = svc.create_template(db, body())
db.add(BizCompareJob(id="j", template_id=tpl["id"], enabled_sheet_ids=["s"]))
db.add(BizCompareRun(id="r", job_id="j", status="running"))
db.commit()
for action in (lambda: svc.delete_template(db, tpl["id"]), lambda: svc.delete_job(db, "j"), lambda: svc.delete_run(db, "r")):
with pytest.raises(HTTPException) as exc:
action()
assert exc.value.status_code == 409
with pytest.raises(HTTPException) as exc:
svc.update_job(db, "j", {"name": "invalid", "enabled_sheet_ids": ["missing"]})
assert exc.value.detail["error"] == "unknown_enabled_sheets"
assert db.get(BizCompareJob, "j").name != "invalid"
def test_template_usage_counts_are_batched(db, monkeypatch):
monkeypatch.setattr(svc, "ensure_default_templates", lambda db: None)
monkeypatch.setattr(svc, "upgrade_builtin_split_sheets", lambda db: None)
used = svc.create_template(db, body())
unused = svc.create_template(db, {**body(), "name": "Unused"})
db.add_all([BizCompareJob(id="j1", template_id=used["id"]), BizCompareJob(id="j2", template_id=used["id"])])
db.commit()
sql = []
event.listen(db.get_bind(), "before_cursor_execute", lambda c, cur, statement, p, ctx, many: sql.append(statement))
counts = {t["id"]: t["job_count"] for t in svc.list_templates(db)}
assert counts == {used["id"]: 2, unused["id"]: 0}
assert len(sql) == 2 # one template query and one grouped reference query
@pytest.mark.parametrize("status", ["queued", "running", "failed", "cancelled", "partial"])
def test_compare_rejects_incomplete_sources_before_enqueue(db, status):
tpl = svc.create_template(db, body())
db.add(BizStateBatch(id="before", status="success"))
db.add(BizStateBatch(id="after", status=status))
db.add(BizCompareJob(id="j", template_id=tpl["id"], before_batch_id="before", after_batch_id="after"))
db.commit()
with pytest.raises(HTTPException) as exc:
svc._validate_compare_job(db, "j")
assert exc.value.detail == "source_batch_not_complete"
assert db.query(BizCompareRun).count() == 0
def test_compare_rejects_partially_stale_sheet_scope_after_template_edit(db):
tpl = svc.create_template(db, body())
db.add_all([BizStateBatch(id="before", status="success"), BizStateBatch(id="after", status="success"),
BizCompareJob(id="j", template_id=tpl["id"], before_batch_id="before", after_batch_id="after", enabled_sheet_ids=["s", "removed"])])
db.commit()
with pytest.raises(HTTPException) as exc:
svc._validate_compare_job(db, "j")
assert exc.value.detail == {"error": "unknown_enabled_sheets", "sheet_ids": ["removed"]}
assert db.query(BizCompareRun).count() == 0
@pytest.mark.parametrize("bad", [
{"key_fields": []},
{"row_filters": [{"field": "id", "op": "typo", "value": "a"}]},
{"row_filters": [{"field": "id", "op": "regex", "value": "["}]},
{"field_rules": [{"field": "count", "compare": "typo"}]},
])
def test_legacy_invalid_template_cannot_execute_as_partial_or_fail_open(db, bad):
tpl = svc.create_template(db, body())
row = db.get(BizCompareTemplate, tpl["id"])
row.metrics_json = [*row.metrics_json, {**body()["metrics"][0], "sheet_id": "invalid", **bad}]
db.add_all([BizStateBatch(id="before", status="success"), BizStateBatch(id="after", status="success"),
BizCompareJob(id="j", template_id=tpl["id"], before_batch_id="before", after_batch_id="after")])
db.commit()
with pytest.raises(HTTPException) as exc:
svc._validate_compare_job(db, "j")
assert exc.value.detail["error"] == "invalid_template"
assert "metrics[1]" in exc.value.detail["path"]
assert db.query(BizCompareRun).count() == 0

View file

@ -733,7 +733,7 @@ class EvaluateMetricDualTests(unittest.TestCase):
migrated = [r for r in out["rows"] if r["verdict"] == "migrated"]
self.assertTrue(migrated, out["rows"])
def test_out_of_expect_ignore_cannot_hide_business_drift(self):
def test_out_of_expect_ignore_no_red(self):
old_base = [{"interface": "gei-keep", "admin": "up", "phy": "up", "prot": "up"}]
old_cur: list[dict] = []
new_cur = [{"interface": "gei-keep", "admin": "up", "phy": "up", "prot": "up"}]
@ -753,9 +753,9 @@ class EvaluateMetricDualTests(unittest.TestCase):
out_of_expect="ignore",
)
reds = [r for r in out["rows"] if r["color"] == "red"]
self.assertTrue(reds, out["rows"])
self.assertFalse(reds, out["rows"])
involved = [r for r in out["rows"] if r["verdict"] != "not_involved"]
self.assertTrue(involved, out["rows"])
self.assertFalse(involved, out["rows"])
def test_unexpected_loss_is_anomaly(self):
old_base = [{"interface": "gei-keep", "admin": "up", "phy": "up", "prot": "up"}]
@ -823,7 +823,7 @@ class EvaluateMetricDualTests(unittest.TestCase):
self.assertFalse(out["new_baseline_missing"])
self.assertEqual(out["new_baseline_mode"], "port_mapped")
def test_empty_collected_new_baseline_is_valid(self):
def test_empty_provided_new_baseline_falls_back_or_missing(self):
old_base = [{"peer": "1.1.1.1", "state": "Established"}]
new_cur = [{"peer": "1.1.1.1", "state": "Established"}]
expect = parse_expect_set({"items": [{"metric_id": "bgp_peer", "key": "1.1.1.1"}]})
@ -841,8 +841,8 @@ class EvaluateMetricDualTests(unittest.TestCase):
window_active=True,
sheet_override={},
)
self.assertFalse(out["new_baseline_missing"])
self.assertEqual(out["new_baseline_mode"], "provided")
self.assertTrue(out["new_baseline_missing"])
self.assertEqual(out["new_baseline_mode"], "empty")
# Still reports presence as added vs empty before — callers should heed the flag.
kinds = {r["new_kind"] for r in out["rows"] if r.get("in_expect")}
self.assertIn("added", kinds)
@ -1153,10 +1153,7 @@ class PortMapScopeTests(unittest.TestCase):
)
keys = {r["key_str"] for r in out["rows"]}
self.assertIn("10.0.0.1|gei-old", keys)
self.assertIn("10.0.0.2|gei-keep", keys)
outside = next(r for r in out["rows"] if r["key_str"] == "10.0.0.2|gei-keep")
self.assertEqual(outside["color"], "red")
self.assertFalse(outside["in_expect"])
self.assertNotIn("10.0.0.2|gei-keep", keys)
migrated = [r for r in out["rows"] if r["verdict"] == "migrated"]
self.assertTrue(migrated, out["rows"])
self.assertEqual(migrated[0]["new_key_str"], "10.0.0.1|gei-new")

View file

@ -138,13 +138,10 @@ class EvidencePersistTests(unittest.TestCase):
return t
def _mk_batch(self, bid: str, task_id: str) -> BizStateBatch:
task = self.db.get(BizStateTask, task_id)
now = _utcnow()
b = BizStateBatch(
id=bid,
task_id=task_id,
collect_group_id=task.collect_group_id or "",
collect_round_id="test-" + task.collect_group_id if task.collect_group_id else "",
status="success",
started_at=now,
ended_at=now,
@ -152,8 +149,6 @@ class EvidencePersistTests(unittest.TestCase):
command_count=1,
)
self.db.add(b)
self.db.add(BizStateBatchCommand(id=f"receipt-{bid}", batch_id=bid,
metric_id="interface_brief", parse_status="ok", row_count=0))
self.db.commit()
return b
@ -238,7 +233,7 @@ class EvidencePersistTests(unittest.TestCase):
raw_command="show interface brief",
interface="GE-old",
)
# Successfully collected empty new baseline is valid
# New baseline empty → port_mapped mode uses mapped old
# Old current: gone (no rows) — migration off old
# New current: xgei-new up
self._seed_if_row(

View file

@ -14,7 +14,6 @@ from netx_api.models import (
BizMigrationProject,
BizMonitorTemplate,
BizStateBatch,
BizStateBatchCommand,
BizStateTask,
)
from netx_api.biz_migration import service as mig
@ -47,7 +46,6 @@ class HfDecoupleFlowTests(unittest.TestCase):
)
Base.metadata.create_all(bind=engine)
self.db = TestingSession()
self.Session = TestingSession
self.mt = BizMonitorTemplate(
id="mt1",
@ -95,22 +93,16 @@ class HfDecoupleFlowTests(unittest.TestCase):
return t
def _mk_batch(self, bid: str, task_id: str) -> BizStateBatch:
task = self.db.get(BizStateTask, task_id)
now = datetime.utcnow()
b = BizStateBatch(
id=bid,
task_id=task_id,
collect_group_id=task.collect_group_id or "",
collect_round_id="test-" + task.collect_group_id if task.collect_group_id else "",
status="success",
started_at=now,
ended_at=now,
row_count=1,
)
self.db.add(b)
for mid in ("interface_brief", "arp"):
self.db.add(BizStateBatchCommand(id=f"receipt-{bid}-{mid}", batch_id=bid,
metric_id=mid, parse_status="ok", row_count=0))
self.db.commit()
return b
@ -144,64 +136,6 @@ class HfDecoupleFlowTests(unittest.TestCase):
self.assertNotEqual(out["old_hf_task_id"], "old_p")
self.assertNotEqual(out["new_hf_task_id"], "new_p")
def test_same_interval_metrics_are_separate_paired_tasks(self) -> None:
out = self._create_project(collect_metric_ids=["interface_brief", "arp"])
old, new = out["old_hf_bindings"], out["new_hf_bindings"]
self.assertEqual(len(old), 2)
self.assertEqual({b["interval_sec"] for b in old}, {60})
for o, n in zip(old, new):
self.assertEqual(len(o["metric_ids"]), 1)
self.assertTrue(o["collect_group_id"])
self.assertEqual(o["collect_group_id"], n["collect_group_id"])
self.assertEqual(o["metric_ids"], n["metric_ids"])
def test_dynamic_routes_manual_only_and_removal_cancels_queued_pairs(self) -> None:
from netx_api.models import BizStateTaskItem, BizStateTaskItemBinding
for tid in ("old_p", "new_p"):
self.db.add(BizStateTaskItem(id="route-item-" + tid, task_id=tid, source_profile_id="zte.ip_route_vrf", kind="catalog", enabled=True))
self.db.add(BizStateTaskItemBinding(id="route-bind-" + tid, item_id="route-item-" + tid, placeholder="vrf", value="customer-A"))
self.db.commit()
out = self._create_project()
with mock.patch.object(biz_svc, "_ne_meta", side_effect=_stub_ne_meta):
updated = mig.patch_project(self.db, out["id"], {"collect_metric_ids": ["interface_brief", "ip_route"]})
routes = [b for side in ("old", "new") for b in updated[f"{side}_hf_bindings"] if "ip_route" in b["metric_ids"]]
self.assertEqual(len(routes), 2)
for idx, binding in enumerate(routes):
task = self.db.get(BizStateTask, binding["task_id"])
self.assertTrue(task.collect_manual_only)
self.assertEqual(task.status, "paused")
task.collect_running = True
self.db.add(BizStateBatch(id=f"queued-route-{idx}", task_id=task.id, status="queued", collect_round_id="route-round"))
self.db.commit()
with mock.patch("netx_api.biz_state.collect_stop.SessionLocal", self.Session), mock.patch.object(biz_svc, "_ne_meta", side_effect=_stub_ne_meta):
shrunk = mig.patch_project(self.db, out["id"], {"collect_metric_ids": ["interface_brief"]})
self.db.expire_all()
self.assertEqual(len(shrunk["old_hf_bindings"]), 1)
for idx in range(2):
self.assertEqual(self.db.get(BizStateBatch, f"queued-route-{idx}").status, "cancelled")
def test_route_profile_preserves_portrait_cli_variant_and_bindings(self) -> None:
from netx_api.models import BizStateTaskItem, BizStateTaskItemBinding
self.db.add(BizStateTaskItem(id="vrf-item", task_id="old_p", source_profile_id="zte.ip_route_vrf", kind="catalog", enabled=True))
self.db.add(BizStateTaskItemBinding(id="vrf-bind", item_id="vrf-item", placeholder="vrf", value="customer-A"))
self.db.commit()
out = self._create_project(collect_metric_ids=["interface_brief", "ip_route"])
binding = next(b for b in out["old_hf_bindings"] if b["metric_ids"] == ["ip_route"])
item = self.db.query(BizStateTaskItem).filter(BizStateTaskItem.task_id == binding["task_id"]).one()
self.assertEqual(item.source_profile_id, "zte.ip_route_vrf")
copied = self.db.query(BizStateTaskItemBinding).filter(BizStateTaskItemBinding.item_id == item.id).one()
self.assertEqual((copied.placeholder, copied.value), ("vrf", "customer-A"))
def test_invalid_new_metric_does_not_save_broken_selection(self) -> None:
out = self._create_project()
with self.assertRaises(Exception):
mig.patch_project(self.db, out["id"], {"collect_metric_ids": ["interface_brief", "not-a-metric"]})
self.db.rollback()
current = self.db.get(BizMigrationProject, out["id"])
self.assertEqual(mig.resolve_collect_metric_ids(self.db, current), ["interface_brief"])
def test_ensure_highfreq_does_not_overwrite_portrait(self) -> None:
proj = self._create_project()
res = self._ensure(proj["id"], interval_sec=60)
@ -236,7 +170,7 @@ class HfDecoupleFlowTests(unittest.TestCase):
batch = mig.create_batch(
self.db, pid, {"batch_label": "n1", "expect_set": {"ports": ["gei-0/1"]}}
)
with mock.patch.object(mig, "resolve_evaluate_sheets", return_value=([mig.port_sheet_def()], [], {}, [])):
with mock.patch.object(mig, "resolve_evaluate_sheets", return_value=([], [], {}, [])):
out = mig.run_evaluate(self.db, batch_id=batch["id"])
self.assertEqual(out["old_batch_id"], "cur_old")
self.assertEqual(out["new_batch_id"], "cur_new")
@ -275,7 +209,7 @@ class HfDecoupleFlowTests(unittest.TestCase):
called = {c.args[0] for c in disp.call_args_list}
self.assertEqual(
called,
{proj["old_hf_task_id"]}, # one dispatch atomically enqueues the pair
{proj["old_hf_task_id"], proj["new_hf_task_id"]},
)
self.assertNotIn("old_p", called)

View file

@ -1,408 +0,0 @@
"""Business continuity, wave scope, sample uncertainty and live-monitor regressions."""
from datetime import timedelta
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from netx_api.db import Base
from netx_api.models import (BizCompareTemplate, BizMonitorTemplate, BizMigrationProject,
BizMigrationBatch, BizMigrationRun, BizMigrationDiff, BizStateTask, BizStateBatch,
BizStateBatchCommand, BizStateMetricRow)
from netx_api.timeutil import utcnow_naive
from netx_api.biz_migration import service as svc
from netx_api.biz_migration.auto_monitor import try_auto_monitor_for_task
from netx_api.biz_migration.evaluate import evaluate_metric_dual, parse_expect_set, _eval_field_op
from netx_api.biz_migration.validation import validate_monitor_rules
def evaluate(**changes):
args = dict(metric_id="arp", sheet_id="arp", key_fields=["ip"], iface_fields=["port"],
compare_fields=["mac", "port"], old_baseline_rows=[{"ip": "a", "mac": "good", "port": "old"}],
old_current_rows=[], new_baseline_rows=[],
new_current_rows=[{"ip": "a", "mac": "good", "port": "new"}],
port_map={"old": "new"}, expect=parse_expect_set({"items": [{"metric_id": "arp", "key": "a"}]}),
window_active=True, allow_mapping_scope=False)
return evaluate_metric_dual(**{**args, **changes})
def test_target_content_checked_against_old_business():
out = evaluate(new_current_rows=[{"ip": "a", "mac": "wrong", "port": "new"}])
assert out["progress_ok"] == 0
assert out["rows"][0]["rule_hit"] == "service_content_changed"
assert out["rows"][0]["integrity_changes"][0]["field"] == "mac"
def test_content_normalization_and_ignore_follow_compare_template():
out = evaluate(new_current_rows=[{"ip": "a", "mac": "GOOD", "port": "new"}],
field_rules=[{"field": "mac", "normalize": "upper"}])
assert out["progress_ok"] == 1
def test_down_target_cannot_pass_permissive_success_rule():
out = evaluate(new_current_rows=[{"ip": "a", "state": "down"}],
sheet_override={"status_fields": ["state"], "up_values": ["up"], "down_values": ["down"],
"success": [{"old": ["removed"], "new": ["added"]}]})
assert out["rows"][0]["verdict"] == "lost"
assert out["rows"][0]["color"] == "red"
def test_mapping_does_not_expand_wave_selection():
out = evaluate(old_baseline_rows=[{"ip": "a", "port": "old"}, {"ip": "b", "port": "old2"}],
new_current_rows=[{"ip": "a", "port": "new"}], compare_fields=[],
port_map={"old": "new", "old2": "new2"})
assert out["progress_total"] == 1
assert next(r for r in out["rows"] if r["old_key"] == "b")["color"] == "red"
def test_port_selection_covers_business_with_nonkey_interface():
out = evaluate(expect=parse_expect_set({"ports": ["old"]}))
assert out["progress_total"] == out["progress_ok"] == 1
@pytest.mark.parametrize("healthy", [True, False])
def test_previous_wave_remains_protected_without_changing_current_progress(healthy):
out = evaluate(expect={}, previous_expect=parse_expect_set({"items": [{"metric_id": "arp", "key": "a"}]}),
new_current_rows=[{"ip": "a", "mac": "good", "port": "new"}] if healthy else [])
assert out["progress_total"] == 0
assert out["rows"][0]["verdict"] == ("migrated_previous" if healthy else "lost")
assert out["anomaly"] == (0 if healthy else 1)
@pytest.mark.parametrize("value", [0, False])
def test_zero_false_are_not_blank_in_keys_or_conditions(value):
assert _eval_field_op({"id": value}, "id", "eq", value)
out = evaluate(old_baseline_rows=[{"ip": value}], new_current_rows=[{"ip": value}],
compare_fields=[], expect=parse_expect_set({"items": [{"metric_id": "arp", "key": [str(value).lower()]}]}))
assert out["rows"][0]["old_key"] == str(value).lower()
@pytest.fixture
def db():
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
with sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)() as session:
now = utcnow_naive() - timedelta(seconds=1)
sheet = {"metric_id": "arp", "sheet_id": "s", "key_fields": ["ip"], "compare_fields": ["mac"]}
session.add(BizCompareTemplate(id="ct", name="cmp", metrics_json=[sheet]))
session.add(BizMonitorTemplate(id="mt", name="mon", compare_template_id="ct",
collect_metric_ids_json=["arp"], defaults_json={}, sheet_overrides_json=[]))
for tid in ("op", "np", "oh", "nh"):
session.add(BizStateTask(id=tid, ne_id=tid, ne_name=tid, purpose="cutover_hf" if tid.endswith("h") else ""))
session.add(BizMigrationProject(id="p", name="p", old_task_id="op", new_task_id="np",
old_hf_task_id="oh", new_hf_task_id="nh", old_baseline_batch_id="ob", new_baseline_batch_id="nb",
monitor_template_id="mt", hf_interval_sec=60, status="active"))
session.add(BizMigrationBatch(id="wave", project_id="p", batch_label="wave", status="active",
expect_set_json={"items": [{"metric_id": "arp", "sheet_id": "s", "key": "a"}]}))
for i, (bid, tid) in enumerate((("ob", "op"), ("nb", "np"), ("oc", "oh"), ("nc", "nh"))):
at = now - timedelta(milliseconds=100 * (4 - i))
session.add(BizStateBatch(id=bid, task_id=tid, status="success", started_at=at, ended_at=at))
session.add(BizStateBatchCommand(id="cmd-" + bid, batch_id=bid, metric_id="arp", parse_status="ok", raw_text="secret CLI payload"))
for bid in ("ob", "nc"):
session.add(BizStateMetricRow(id="row-" + bid, batch_id=bid, task_id="op" if bid == "ob" else "nh",
batch_command_id="cmd-" + bid, metric_id="arp", seq=0, data_json={"ip": "a", "mac": "good"}))
session.commit()
yield session
def test_collected_empty_baseline_can_pass_acceptance(db):
finished = svc.finish_batch(db, "wave")
assert finished["accept_summary"]["passed"]
assert finished["run"]["summary"]["config_snapshot"]["expect_set"]
def paired_samples(db):
for tid in ("oh", "nh"):
db.get(BizStateTask, tid).collect_group_id = "pair"
for bid in ("oc", "nc"):
sample = db.get(BizStateBatch, bid)
sample.collect_group_id = "pair"
sample.collect_round_id = "round"
sample.queued_at = sample.started_at
db.commit()
@pytest.mark.parametrize("problem", ["running", "queued", "failed", "round_mismatch"])
def test_paired_samples_cannot_mix_rounds_or_use_old_success(db, problem):
paired_samples(db)
if problem == "round_mismatch":
db.get(BizStateBatch, "nc").collect_round_id = "another-round"
else:
db.get(BizStateBatch, "nc").status = problem
db.commit()
run = svc.run_evaluate(db, batch_id="wave")
assert not run["summary"]["coverage_complete"]
assert not run["summary"]["anomaly"] # incomplete pair is unknown, not service loss
def test_paired_samples_expose_duration_queue_and_real_start_skew(db):
paired_samples(db)
for bid in ("oc", "nc"):
sample = db.get(BizStateBatch, bid)
sample.queued_at = sample.started_at - timedelta(seconds=15)
sample.started_at -= timedelta(seconds=5)
db.commit()
card = svc.run_evaluate(db, batch_id="wave")["summary"]["sheet_cards"][0]
assert card["collect_round_id"] == "round"
assert card["old_queue_sec"] == 10 and card["old_duration_sec"] == 5
assert card["start_skew_sec"] < 1
def test_unselected_route_does_not_block_regular_acceptance(db):
ct = db.get(BizCompareTemplate, "ct")
ct.metrics_json = [*ct.metrics_json, {"metric_id": "ip_route", "sheet_id": "route", "key_fields": ["prefix"]}]
db.commit()
assert svc.finish_batch(db, "wave")["accept_summary"]["passed"]
def test_incremental_evaluation_reuses_route_rows_and_paged_evidence(db):
from unittest.mock import patch
ct = db.get(BizCompareTemplate, "ct")
ct.metrics_json = [*ct.metrics_json, {"metric_id": "ip_route", "sheet_id": "route", "key_fields": ["prefix"], "compare_fields": ["nexthop"]}]
db.get(BizMonitorTemplate, "mt").collect_metric_ids_json = ["arp", "ip_route"]
# Legacy combined samples also exercise compatibility with existing projects.
for bid in ("ob", "nb", "oc", "nc"):
db.add(BizStateBatchCommand(id="route-cmd-" + bid, batch_id=bid, metric_id="ip_route", parse_status="ok"))
for i in range(2000):
db.add(BizStateMetricRow(id=f"route-{i}", batch_id="ob", metric_id="ip_route", data_json={"prefix": str(i), "nexthop": "old"}))
db.commit()
first = svc.run_evaluate(db, batch_id="wave", purpose="auto")
with patch.object(svc, "_load_metric_rows", wraps=svc._load_metric_rows) as load:
second = svc.run_evaluate(db, batch_id="wave", purpose="auto", _metric_ids={"arp"})
assert all(call.kwargs["metric_id"] == "arp" for call in load.call_args_list)
assert svc.list_run_diffs(db, second["id"], sheet_id="route", color="red", limit=100)["total"] == 2000
assert db.query(BizMigrationDiff).filter(BizMigrationDiff.run_id == second["id"]).count() == 1
assert second["summary"]["anomaly"] == first["summary"]["anomaly"]
assert svc.list_run_diffs(db, second["id"], only_expect=True)["total"] == 1
# Template correction invalidates cached verdicts and evidence.
ct.metrics_json = [ct.metrics_json[0], {**ct.metrics_json[1], "row_filters": [{"field": "prefix", "op": "eq", "value": "1"}]}]
db.commit()
third = svc.run_evaluate(db, batch_id="wave", purpose="auto", _metric_ids={"arp"})
assert svc.list_run_diffs(db, third["id"], sheet_id="route")["total"] == 1
def test_baseline_picker_never_loads_route_inventory(db):
from unittest.mock import patch
ct = db.get(BizCompareTemplate, "ct")
ct.metrics_json = [*ct.metrics_json, {"metric_id": "ip_route", "sheet_id": "route", "key_fields": ["prefix"]}]
db.get(BizMonitorTemplate, "mt").collect_metric_ids_json = ["arp", "ip_route"]
db.commit()
with patch.object(svc, "_load_metric_rows", wraps=svc._load_metric_rows) as load:
sheets = svc.list_baseline_expect_objects(db, "p")["sheets"]
assert {s["metric_id"] for s in sheets} == {"arp"}
assert all(c.kwargs["metric_id"] == "arp" for c in load.call_args_list)
def test_legacy_auto_monitor_refreshes_changed_samples_without_explicit_metric_bindings(db):
assert try_auto_monitor_for_task(db, "nh") == 1
db.get(BizStateBatch, "nc").status = "failed"
db.commit()
assert try_auto_monitor_for_task(db, "nh") == 1
last = db.query(BizMigrationRun).order_by(BizMigrationRun.created_at.desc()).first()
assert not last.summary_json["coverage_complete"]
@pytest.mark.parametrize("problem", ["missing_receipt", "failed_receipt", "stale", "skew", "duplicate", "skipped", "unknown_scope"])
def test_uncertain_coverage_cannot_pass_acceptance(db, problem):
if problem == "missing_receipt":
db.delete(db.get(BizStateBatchCommand, "cmd-nb"))
elif problem == "failed_receipt":
db.get(BizStateBatchCommand, "cmd-nc").parse_status = "failed"
elif problem in ("stale", "skew"):
batch = db.get(BizStateBatch, "nc")
batch.started_at = batch.ended_at = utcnow_naive() - timedelta(minutes=10)
elif problem == "duplicate":
db.add(BizStateMetricRow(id="dup", batch_id="nc", metric_id="arp", data_json={"ip": "a", "mac": "good"}))
elif problem == "skipped":
db.get(BizMigrationProject, "p").collect_metric_ids_json = ["other"]
else:
db.get(BizMigrationBatch, "wave").expect_set_json = {"items": [{"sheet_id": "deleted", "metric_id": "arp", "key": "a"}]}
db.commit()
assert not svc.finish_batch(db, "wave")["accept_summary"]["passed"]
def test_invalid_baseline_and_pinned_batch_do_not_fallback(db):
with pytest.raises(HTTPException) as error:
svc.run_evaluate(db, batch_id="wave", old_batch_id="nb")
assert error.value.detail == "old_current_batch_task_mismatch"
db.get(BizMigrationProject, "p").old_baseline_batch_id = "nc"
db.commit()
with pytest.raises(HTTPException):
svc.finish_batch(db, "wave")
assert db.get(BizMigrationBatch, "wave").status == "active"
assert db.query(BizMigrationRun).count() == 0
def test_latest_failed_collection_does_not_reuse_previous_success(db):
db.add(BizStateBatch(id="latest-failed", task_id="nh", status="failed", started_at=utcnow_naive(), ended_at=utcnow_naive()))
db.commit()
run = svc.run_evaluate(db, batch_id="wave")
assert run["summary"]["current_missing_metrics"] == ["s"]
assert not run["summary"]["coverage_complete"]
def test_target_older_than_source_is_uncertain_instead_of_false_loss(db):
db.delete(db.get(BizStateMetricRow, "row-nc"))
db.get(BizStateBatch, "oc").started_at = utcnow_naive()
db.get(BizStateBatch, "oc").ended_at = utcnow_naive()
db.commit()
run = svc.run_evaluate(db, batch_id="wave")
row = svc.list_run_diffs(db, run["id"])["items"][0]
assert row["verdict"] == "awaiting_peer"
assert run["summary"]["anomaly"] == 0
assert not run["summary"]["coverage_complete"]
def test_auto_monitor_deduplicates_receipts_and_respects_closed_window(db):
assert try_auto_monitor_for_task(db, "oh") == 1
assert try_auto_monitor_for_task(db, "nh") == 0
assert db.query(BizMigrationRun).one().purpose == "auto"
db.get(BizMigrationBatch, "wave").status = "done"
db.commit()
assert try_auto_monitor_for_task(db, "oh") == 0
def test_two_active_waves_are_rejected(db):
with pytest.raises(HTTPException) as error:
svc.create_batch(db, "p", {"status": "active", "batch_label": "overlap"})
assert error.value.detail == "another_batch_active"
assert db.query(BizMigrationBatch).count() == 1
def test_sheets_with_same_key_keep_separate_red_tickets(db):
run = svc.run_evaluate(db, batch_id="wave")
for sid in ("one", "two"):
db.add(BizMigrationDiff(id=sid, run_id=run["id"], metric_id="arp", color="red",
key_json={"sheet_id": sid, "match_old_key": "a"}, verdict="lost"))
db.flush()
tickets = svc._persist_red_tickets_from_run(db, project_id="p", batch_id="wave", run_id=run["id"])
assert len(tickets) == 2
assert {t.detail_json["sheet_id"] for t in tickets} == {"one", "two"}
def test_port_mapping_does_not_remap_business_key_with_same_text():
out = evaluate(old_baseline_rows=[{"ip": "old", "port": "old"}],
new_current_rows=[{"ip": "old", "port": "new"}], compare_fields=["port"],
expect=parse_expect_set({"items": [{"metric_id": "arp", "key": "old"}]}))
assert out["progress_ok"] == 1
def test_unmapped_same_name_target_is_not_used_as_mapped_target_evidence():
out = evaluate(metric_id="interface_brief", key_fields=["port"], compare_fields=[],
old_baseline_rows=[{"port": "old"}], new_current_rows=[{"port": "old"}],
expect=parse_expect_set({"ports": ["old"]}))
row = next(r for r in out["rows"] if r["in_expect"])
assert row["verdict"] == "lost"
assert row["new"] == {}
assert row["new_key"] == ""
assert row["match_new_key"] == "new"
def test_legacy_raw_interface_expect_uses_same_alias_normalization():
out = evaluate(metric_id="interface_brief", key_fields=["port"],
old_baseline_rows=[{"port": "GE-1"}], new_current_rows=[{"port": "GE-2"}],
compare_fields=[], port_map={"gei-1": "gei-2"},
expect=parse_expect_set({"ports": ["GE-1"]}),
iface_normalize_rules=[{"from": "GE", "to": "gei"}])
assert out["progress_ok"] == 1
def test_baseline_picker_matches_normalized_keys_but_displays_originals(db):
ct = db.get(BizCompareTemplate, "ct")
ct.metrics_json = [{"metric_id": "arp", "sheet_id": "s", "key_fields": ["ip", "port"], "iface_fields": ["port"]}]
ct.iface_normalize_json = [{"from": "GE", "to": "gei"}]
db.get(BizStateMetricRow, "row-ob").data_json = {"ip": 0, "port": "GE-1"}
db.commit()
item = svc.list_baseline_expect_objects(db, "p")["sheets"][0]["items"][0]
assert item["keys"] == ["0", "gei-1"]
assert item["label"] == "0|GE-1"
assert parse_expect_set({"items": [{"metric_id": "arp", "key": item["keys"]}]})["arp"] == {"0\x1fgei-1"}
def test_empty_composite_segments_are_preserved_and_false_is_not_blank():
assert parse_expect_set({"items": [{"metric_id": "x", "key": [0, "", False]}]})["x"] == {"0\x1f\x1ffalse"}
def test_missing_status_field_cannot_match_negative_success_condition():
assert not _eval_field_op({"ip": "a"}, "state", "ne", "down")
assert not _eval_field_op({"ip": "a"}, "state", "not_in", ["down"])
assert _eval_field_op({"ip": "a"}, "state", "empty", None)
def test_stable_outside_inventory_is_counted_without_repeated_evidence_cards():
row = {"ip": "a", "mac": "good", "port": "old"}
out = evaluate(expect={}, old_current_rows=[row], new_current_rows=[])
assert out["rows"] == []
assert out["steady_outside"] == 1
assert out["anomaly"] == 0
def test_removed_previous_wave_sheet_blocks_full_coverage_acceptance(db):
db.add(BizMigrationBatch(id="prior", project_id="p", status="done",
created_at=utcnow_naive() - timedelta(days=1),
expect_set_json={"items": [{"sheet_id": "removed-sheet", "metric_id": "arp", "key": "old-customer"}]}))
db.commit()
finished = svc.finish_batch(db, "wave")
assert not finished["accept_summary"]["passed"]
assert finished["run"]["summary"]["uncovered_scope"] == ["removed-sheet"]
def test_split_sheets_share_loaded_rows_and_command_metadata(db):
from unittest.mock import patch
ct = db.get(BizCompareTemplate, "ct")
sheet = ct.metrics_json[0]
ct.metrics_json = [sheet, {**sheet, "sheet_id": "s2"}]
for i in range(500):
for bid in ("ob", "nc"):
db.add(BizStateMetricRow(id=f"bulk-{bid}-{i}", batch_id=bid,
batch_command_id="cmd-" + bid, metric_id="arp", data_json={"ip": f"bulk-{i}", "mac": "good"}))
db.commit()
with patch.object(svc, "_load_metric_rows", wraps=svc._load_metric_rows) as load, patch.object(svc, "_command_brief", wraps=svc._command_brief) as command:
run = svc.run_evaluate(db, batch_id="wave")
assert load.call_count == 4 # once for each (batch, metric), not once per sheet
assert command.call_count == 2 # baseline + current metadata, independent of row count
assert svc.list_run_diffs(db, run["id"], limit=1)["total"] == 1002
def test_wave_acceptance_rolls_back_whole_transaction_on_ticket_failure(db):
from unittest.mock import patch
with patch.object(svc, "_persist_red_tickets_from_run", side_effect=RuntimeError("write failed")):
with pytest.raises(RuntimeError):
svc.finish_batch(db, "wave")
db.rollback()
assert db.query(BizMigrationRun).count() == 0
assert db.get(BizMigrationBatch, "wave").status == "active"
def test_command_metadata_is_cached_and_raw_cli_is_not_selected(db):
statements = []
def capture(_conn, _cursor, statement, *_args):
statements.append(statement)
event.listen(db.bind, "before_cursor_execute", capture)
try:
run = svc.run_evaluate(db, batch_id="wave")
finally:
event.remove(db.bind, "before_cursor_execute", capture)
command_selects = [q for q in statements if q.startswith("SELECT") and "biz_state_batch_command" in q]
assert all("raw_text" not in q for q in command_selects)
assert run["summary"]["coverage_complete"]
def test_diff_filters_apply_before_pagination_and_escape_wildcards(db):
run = svc.run_evaluate(db, batch_id="wave")
db.add(BizMigrationDiff(id="outside", run_id=run["id"], metric_id="arp", seq=999, color="red",
key_json={"sheet_id": "outside"}, in_expect=False, search_text="a_100%"))
db.commit()
result = svc.list_run_diffs(db, run["id"], sheet_id="outside", color="red", limit=1)
assert result["total"] == 1 and result["items"][0]["id"] == "outside"
assert svc.list_run_diffs(db, run["id"], only_expect=True)["total"] == 1
assert svc.list_run_diffs(db, run["id"], kw="_")["total"] == 1
@pytest.mark.parametrize("condition", [{"type": "value", "field": "state", "op": "typo", "value": "up"}, {"type": "presence", "value": "typo"}])
def test_invalid_correction_rules_fail_before_evaluation(condition):
with pytest.raises(HTTPException):
validate_monitor_rules({}, [{"metric_id": "arp", "success": [{"old_groups": [[condition]], "new": ["added"]}]}])

View file

@ -5,8 +5,7 @@ from __future__ import annotations
import copy
import unittest
from netx_api.biz_state.compare_engine import compare_rows, mapping_stats, stratify_take
from netx_api.biz_state.compare_service import _kind_allows
from netx_api.biz_state.compare_engine import compare_rows, mapping_stats
from netx_api.biz_state.compare_rules import (
apply_row_filters,
arp_dynamic_row_filters,
@ -97,18 +96,7 @@ class CompareEngineTests(unittest.TestCase):
self.assertEqual(s["removed"], 1)
self.assertEqual(s["added"], 1)
kinds = {d["kind"] for d in out["diffs"]}
# Default: unchanged counted but not listed (million-row safe)
self.assertNotIn("unchanged", kinds)
out_full = compare_rows(
before_rows=before,
after_rows=after,
key_fields=["local_if", "remote_sys", "remote_if"],
iface_fields=["local_if"],
compare_fields=[],
port_map={},
include_unchanged=True,
)
self.assertIn("unchanged", {d["kind"] for d in out_full["diffs"]})
self.assertIn("unchanged", kinds)
def test_empty_port_map_ignores_iface_in_key(self) -> None:
"""No port map → ignore local_if when matching (same neighbor, renamed port)."""
@ -161,107 +149,10 @@ class CompareEngineTests(unittest.TestCase):
iface_fields=["local_if"],
compare_fields=["remote_ip"],
port_map={},
include_unchanged=True,
)
self.assertEqual(out["summary"]["unchanged"], 1)
self.assertEqual(len(out["diffs"]), 1)
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:
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):
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:
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)
# 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.assertGreaterEqual(sum(1 for s in sheets if s["metric_id"] == "bgp_route"), 4)
self.assertIn("bgp_peer.evpn", ids)
self.assertIn("bgp_peer.vpls", ids)
self.assertIn("vrrp.ipv4", ids)
self.assertIn("lldp_neighbor", ids)
# Packaged IOH default: filtered bgp_route ipv4 + percent pfx_rcd
route4 = next(s for s in sheets if sheet_key(s) == "bgp_route")
self.assertEqual(route4["metric_id"], "bgp_route")
self.assertTrue(
any(
f.get("field") == "afi" and f.get("value") == "ipv4"
for f in (route4.get("row_filters") or [])
)
)
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 [])
)
)
detail = next(s for s in sheets if sheet_key(s) == "interface_detail")
self.assertEqual(detail["compare_fields"], ["port_status"])
self.assertIn("input_bps", detail["display_fields"])
optical = next(s for s in sheets if sheet_key(s) == "optical_brief")
self.assertEqual(optical["compare_fields"], ["status"])
self.assertIn("rx_power", optical["display_fields"])
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"])
vpnv4 = next(s for s in sheets if sheet_key(s) == "bgp_peer.vpnv4")
self.assertEqual(
vpnv4["row_filters"],
[{"field": "afi", "op": "eq", "value": "vpnv4"}],
)
self.assertEqual(vpnv4["row_filters"], [{"field": "afi", "op": "eq", "value": "vpnv4"}])
isis4 = next(s for s in sheets if sheet_key(s) == "isis_adjacency.ipv4")
self.assertEqual(isis4["row_filters"][0]["op"], "contains")
def test_ordered_same_key_pairing_2v2(self) -> None:
"""Same key, two rows each: zip by order (not first-wins + duplicate)."""
def test_duplicate_match_keys_are_reported(self) -> None:
before = [
{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "1"},
{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "9"},
@ -567,47 +389,15 @@ class CompareSheetDefaultsTests(unittest.TestCase):
compare_fields=["remote_ip"],
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_after"], 1)
self.assertEqual(out["summary"]["duplicate"], 2)
self.assertIn("a|X|1", out["summary"]["duplicate_key_list"])
kinds = [d["kind"] for d in out["diffs"]]
self.assertNotIn("duplicate", kinds)
# Pair 0: 1==1 unchanged; pair 1: 9!=2 changed
self.assertEqual(kinds.count("duplicate"), 2)
# First before wins → matches first after → unchanged (same remote_ip)
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"]["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:
before = [

View file

@ -1,87 +0,0 @@
"""Export must page lazily, keep tied sequence rows, and produce valid UTF-8 CSV."""
from __future__ import annotations
import csv
import io
import unittest
import zipfile
from unittest.mock import patch
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session
from netx_api.biz_state.compare_service import _iter_sheet_diffs, export_run_zip
from netx_api.db import Base
from netx_api.models import BizCompareDiff, BizCompareRun
class CompareExportTests(unittest.TestCase):
def setUp(self):
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.db = Session(self.engine)
self.sheet = {"sheet_id": "sheet", "metric_id": "test", "key_fields": ["id"],
"compare_fields": ["note"], "display_fields": ["id", "note"],
"summary": {"unchanged": 7}, "status": "done"}
self.run = BizCompareRun(id="run", metric_id="test", status="success",
summary_json={"sheets": [self.sheet]})
self.db.add(self.run)
for seq, ids in ((0, "cba"), (1, "ed"), (2, "gf")):
for rid in ids:
note = '中文, "quoted"\nsecond line'
self.db.add(BizCompareDiff(id=rid, run_id="run", metric_id="sheet", seq=seq,
kind="unchanged", key_json={"id": rid},
before_json={"id": rid, "note": note},
after_json={"id": rid, "note": note}))
self.db.commit()
def tearDown(self):
self.db.close()
self.engine.dispose()
def test_cursor_handles_tied_sequence_numbers_across_chunks(self):
statements = []
def record(_conn, _cursor, statement, _parameters, _context, _executemany):
if "ORDER BY biz_compare_diff.seq" in statement:
statements.append(statement)
event.listen(self.engine, "before_cursor_execute", record)
with patch("netx_api.biz_state.compare_service._DIFF_CHUNK", 2):
iterator = _iter_sheet_diffs(self.db, "run", "sheet")
self.assertEqual(statements, [])
self.assertEqual(next(iterator)["key"]["id"], "a")
self.assertEqual(next(iterator)["key"]["id"], "b")
self.assertEqual(len(statements), 1, "do not load the next chunk early")
self.assertEqual([d["key"]["id"] for d in iterator], list("cdefg"))
self.assertEqual(len(statements), 4)
self.assertTrue(all("biz_compare_diff.seq >" in q and "biz_compare_diff.id >" in q
for q in statements[1:]))
def test_export_csv_escapes_unicode_quotes_commas_and_newlines(self):
with patch("netx_api.biz_state.compare_service._DIFF_CHUNK", 2):
archive_bytes = export_run_zip(self.db, "run")
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive:
payload = archive.read("tables/sheet.csv")
self.assertTrue(payload.startswith(b"\xef\xbb\xbf"))
rows = list(csv.DictReader(io.StringIO(payload.decode("utf-8-sig"))))
self.assertEqual([r["id"] for r in rows], list("abcdefg"))
self.assertTrue(all(r["note__pre"] == '中文, "quoted"\nsecond line' for r in rows))
self.assertTrue(all(r["note__pre"] == r["note__post"] for r in rows))
def test_other_sheets_do_not_leak_into_export(self):
self.db.add(BizCompareDiff(id="other", run_id="run", metric_id="another", seq=0,
kind="added", key_json={"id": "other"}, after_json={"note": "other"}))
self.db.commit()
with patch("netx_api.biz_state.compare_service._DIFF_CHUNK", 2):
self.assertEqual([d["key"]["id"] for d in _iter_sheet_diffs(self.db, "run", "sheet")], list("abcdefg"))
def test_legacy_inline_and_empty_exports(self):
self.db.query(BizCompareDiff).delete()
inline = {"kind": "added", "key": {"id": "legacy"}, "after": {"note": "value"}}
self.run.summary_json = {"sheets": [{**self.sheet, "diffs": [inline]}]}
self.db.commit()
self.assertEqual(list(_iter_sheet_diffs(self.db, "run", "sheet")), [inline])
self.run.summary_json = {"sheets": [self.sheet]}
self.db.commit()
with zipfile.ZipFile(io.BytesIO(export_run_zip(self.db, "run"))) as archive:
self.assertEqual(archive.read("tables/sheet.csv").decode("utf-8-sig"), "kind,id,note__pre,note__post\n")

View file

@ -1,103 +0,0 @@
"""Bounded sampling regressions and an opt-in comparison memory benchmark."""
from __future__ import annotations
import gc
import hashlib
import json
import os
import random
import subprocess
import time
import tracemalloc
import types
from collections import defaultdict, deque
from copy import deepcopy
import pytest
from netx_api.biz_state.compare_engine import _StratifiedSample, compare_rows, stratify_take
def reference_sample(items, limit):
if len(items) <= limit:
return list(items)
buckets = defaultdict(deque)
for item in items:
buckets[item[0]].append(item)
out = []
while buckets and len(out) < limit:
for key in list(buckets):
out.append(buckets[key].popleft())
if not buckets[key]:
del buckets[key]
if len(out) >= limit:
break
return out
def test_streaming_sample_preserves_round_robin_and_small_input_order():
rng = random.Random(42)
for _ in range(100):
items = [(str(rng.randrange(20)), i) for i in range(rng.randrange(100))]
for limit in (0, 1, 5, 20, 100):
expected = reference_sample(items, limit)
assert stratify_take(iter(items), limit, key_fn=lambda item: item[0]) == expected
def test_sample_storage_is_bounded_for_both_many_rows_and_many_strata():
sample = _StratifiedSample(50)
for i in range(20_000):
sample.add(i, str(i) if i >= 10_000 else "one-large-peer")
assert len(sample.heap) == 50
assert len(sample.strata) == 50
assert sample.picked() == [0, *range(10_000, 10_049)]
@pytest.mark.parametrize("iface_fields,rules,pmap", [
([], [], {}),
(["interface"], [], {}),
(["interface"], [{"from": "GE", "to": "gei"}], {"gei-1": "gei-2"}),
])
def test_compare_does_not_mutate_inputs_when_reusing_rows(iface_fields, rules, pmap):
before = [{"id": "1", "interface": "GE-1", "state": "up"}]
after = [{"id": "1", "interface": "GE-2", "state": "up"}]
original = deepcopy((before, after))
compare_rows(before_rows=before, after_rows=after, key_fields=["id"],
iface_fields=iface_fields, compare_fields=["state"], port_map=pmap,
iface_normalize_rules=rules, include_unchanged=True, unchanged_limit=1)
assert (before, after) == original
def test_memory_benchmark_against_git_baseline():
"""Run explicitly with NETX_BENCH_ROWS=1000000; not part of routine CI."""
n = int(os.environ.get("NETX_BENCH_ROWS", "0"))
if not n:
pytest.skip("opt-in large synthetic benchmark")
baseline_ref = os.environ.get("NETX_BENCH_REF", "HEAD")
source = subprocess.run(["git", "show", f"{baseline_ref}:netx_api/biz_state/compare_engine.py"],
check=True, capture_output=True, text=True, encoding="utf-8").stdout
baseline = types.ModuleType("netx_api.biz_state._benchmark_baseline")
baseline.__package__ = "netx_api.biz_state"
exec(compile(source, "baseline_compare_engine.py", "exec"), baseline.__dict__)
before = [{"id": str(i), "neighbor": str(i % 50), "state": "up", "afi": "vpnv4"} for i in range(n)]
after = [dict(row) for row in before]
records = []
digests = []
for name, compare in (("baseline", baseline.compare_rows), ("optimized", compare_rows)):
gc.collect()
tracemalloc.start()
started = time.perf_counter()
result = compare(before_rows=before, after_rows=after, key_fields=["id"], iface_fields=[],
compare_fields=["state"], include_unchanged=True, unchanged_limit=5000,
compact_unchanged=True)
elapsed = time.perf_counter() - started
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
digests.append(hashlib.sha256(json.dumps(result, sort_keys=True).encode()).hexdigest())
records.append({"engine": name, "rows_per_side": n, "peak_engine_mib": round(peak / 2**20, 2),
"seconds_with_tracemalloc": round(elapsed, 3)})
del result
assert digests[0] == digests[1], "large-sheet results and sample order must match"
print("\n" + json.dumps({"baseline_ref": baseline_ref, "input_allocations_included": False,
"results": records}, indent=2))

Some files were not shown because too many files have changed in this diff Show more