20 Commits

Author SHA1 Message Date
Nexus Agent 091fb97291 Push async submit, Chinese 422 field labels, hide server CSV export.
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run
Return sync/files immediately with background runner; show loc_zh in validation errors; remove servers toolbar CSV export button.
2026-06-08 23:28:26 +08:00
Nexus Agent abeab9d3e4 Fix recovery Telegram dedup and unify Beijing time display.
Store alert metrics without per-value Redis members to stop duplicate recovery pushes; format all operator-facing timestamps in Asia/Shanghai across Web UI and Telegram.
2026-06-08 23:18:56 +08:00
Nexus Agent 972456a246 feat(agent): 401 停心跳、配置重载后重启与升级双文件下发
提取 heartbeat_policy;install/upgrade 同步 heartbeat_policy.py;config reload 恢复心跳协程。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 23:06:09 +08:00
Nexus Agent 69068e2e39 feat(ops): server_batch 回收、sync_logs 清理与 Agent 安装跳过
部署对齐三项后端能力:启动/周期收尾僵尸批量任务、30 天推送日志 purge、已安装且版本不低于主站时跳过批量安装 Agent。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 15:23:03 +08:00
Nexus Agent a401ea5a4d fix(servers): server_ids 批量上限 2000 并修复列表 422 中文
全选 101 台批量操作不再因 max_length=50 被拒;Pydantic 列表 too_long 错误显示完整中文。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 15:08:03 +08:00
Nexus Agent 632891e6e5 fix(settings): 表单校验与 422 中文错误信息
v-form 保存前 validate;validation_errors_zh 翻译 Pydantic msg;value 数字 coerce。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 14:52:59 +08:00
Nexus Agent ff143c286d fix(settings): 保存设置时将数值字段转为字符串避免 422
SettingUpdatePayload.value 要求 str,连接池与告警阈值为 number 输入。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 14:50:10 +08:00
Nexus Agent 85ecd1f54f fix(servers): 未设路径区表格绑定 sort-by 启用服务端排序
未设路径面板缺 v-model:sort-by,列头点击不触发 API;与主表共享 sortBy。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 14:36:21 +08:00
Nexus Agent 05c7aeb702 fix(servers): 未设路径批量检测对话框选中数
检测路径与批量改分类确认框按当前区块显示并提交正确 server_ids。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 14:09:50 +08:00
Nexus Agent d0526c06b7 fix(servers): 展开详情对比度与同步日志仅 1 条
展开区灰底可读;行内详情只拉最近一条同步记录,去掉「最近 N 条」文案。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:45:13 +08:00
Nexus Agent cb49fd9d6d docs(audit): gate7 补资源恢复开关联动与仪表盘去列表
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:23:04 +08:00
Nexus Agent fe8b24ca0b fix(notify): 资源恢复 Telegram 联动 CPU/内存/磁盘开关
关闭分项告警后不再推送对应恢复消息;仪表盘移除服务器列表并更新设置页说明。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:22:38 +08:00
Nexus Agent 6612c76dc6 docs(audit): gate7 补 dashboard 修复 index.html
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:13:43 +08:00
Nexus Agent 86bc737f4c fix(dashboard): 修复服务器列表不显示
清除 keep-alive 下 loading 卡死;改用 v-card-text 与表格内置 loading,并加 30s 自动刷新。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:13:34 +08:00
Nexus Agent 27da842a20 fix: Telegram 开关多 worker 同步与服务器行内详情展开
关闭 notify_* 后通过 Redis 广播同步各 worker 内存设置;修复 expanded id 类型不匹配导致点击名称无法展开详情。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:05:22 +08:00
Nexus Agent a5b1d34cec docs(audit): gate7 补 web/app/index.html 覆盖
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 12:55:20 +08:00
Nexus Agent 1e83bb34bd feat(servers): 未设路径区批量管理与主表齐平
独立勾选与全选筛选结果,共用 ServerBatchActionBar;列与批量操作对齐主列表。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 12:55:02 +08:00
Nexus Agent 8c86b50d09 feat(servers): 未设路径区块与行内详情;命令栏恢复单色
服务器页拆分未设路径列表、行内展开详情;API target_path_unset 可选过滤。
命令栏取消 Shell 语法彩色,ANSI 仅保留 xterm SSH 输出区。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 12:48:45 +08:00
Nexus Agent 8312a6cf81 fix(terminal): WebSSH xterm SSH 交互区 ANSI 彩色输出
PTY 环境变量注入 TERM=xterm-256color,DATA 帧 base64 保 ESC;前端解码写入 xterm;命令栏 Shell 高亮 CSS 与黑屏分离。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 12:35:27 +08:00
Nexus Agent 1d1fb6285b fix(terminal): 快捷命令栏「管理」改为「断开」
底部快捷栏提供断开会话,快捷命令配置仍走系统设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 12:14:47 +08:00
1153 changed files with 23720 additions and 660 deletions
+320
View File
@@ -606,3 +606,323 @@
{"ts":"2026-06-07T23:10:50+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-07T23:10:50+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-07T23:10:50+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-08T07:38:32+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-servers-live-sort-fix.md 28lines"}
{"ts":"2026-06-08T07:38:32+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-servers-live-sort-fix.md"}
{"ts":"2026-06-08T07:38:32+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T07:38:32+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T07:38:32+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T07:38:32+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T07:38:32+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-08T07:38:32+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T07:38:32+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T07:38:32+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-08T07:54:01+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-servers-live-sort-fix.md 28lines"}
{"ts":"2026-06-08T07:54:01+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-servers-live-sort-fix.md"}
{"ts":"2026-06-08T07:54:01+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T07:54:01+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T07:54:01+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T07:54:01+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T07:54:01+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-08T07:54:01+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T07:54:01+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T07:54:01+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-08T07:54:43+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-login-gate-seamless-nav.md 42lines"}
{"ts":"2026-06-08T07:54:43+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-login-gate-seamless-nav.md"}
{"ts":"2026-06-08T07:54:43+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T07:54:43+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T07:54:43+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T07:54:43+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T07:54:43+08:00","date":"2026-06-08","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-08T07:54:43+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T07:54:43+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T07:54:43+08:00","date":"2026-06-08","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-08T07:54:52+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-login-gate-seamless-nav.md 42lines"}
{"ts":"2026-06-08T07:54:52+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-login-gate-seamless-nav.md"}
{"ts":"2026-06-08T07:54:52+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T07:54:52+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T07:54:52+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T07:54:52+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T07:54:52+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-08T07:54:52+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T07:54:52+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T07:54:52+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-08T09:31:41+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-credentials-merge-servers-page.md 28lines"}
{"ts":"2026-06-08T09:31:41+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-servers-batch-execution-records-credentials.md"}
{"ts":"2026-06-08T09:31:41+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T09:31:41+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T09:31:41+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T09:31:41+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T09:31:41+08:00","date":"2026-06-08","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-08T09:31:41+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T09:31:41+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T09:31:41+08:00","date":"2026-06-08","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-08T09:32:26+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-credentials-merge-servers-page.md 28lines"}
{"ts":"2026-06-08T09:32:26+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-servers-batch-execution-records-credentials.md"}
{"ts":"2026-06-08T09:32:26+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T09:32:26+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T09:32:26+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T09:32:26+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T09:32:26+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-08T09:32:26+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T09:32:26+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T09:32:26+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-08T09:40:48+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-schedule-cycle-picker.md 42lines"}
{"ts":"2026-06-08T09:40:48+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-schedule-cycle-picker.md"}
{"ts":"2026-06-08T09:40:48+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T09:40:48+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T09:40:48+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T09:40:48+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T09:40:48+08:00","date":"2026-06-08","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-08T09:40:48+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T09:40:48+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T09:40:48+08:00","date":"2026-06-08","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-08T09:45:05+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-schedule-cycle-picker.md 42lines"}
{"ts":"2026-06-08T09:45:05+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-schedule-cycle-picker.md"}
{"ts":"2026-06-08T09:45:05+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T09:45:05+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T09:45:05+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T09:45:05+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T09:45:05+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-08T09:45:05+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T09:45:05+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T09:45:05+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-08T10:30:55+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-schedule-cycle-time-merge.md 26lines"}
{"ts":"2026-06-08T10:30:55+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-gate7-diff-fix.md"}
{"ts":"2026-06-08T10:30:55+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T10:30:55+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T10:30:55+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T10:30:55+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T10:30:55+08:00","date":"2026-06-08","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-08T10:30:55+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T10:30:55+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T10:30:55+08:00","date":"2026-06-08","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-08T10:31:58+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-schedule-cycle-time-merge.md 26lines"}
{"ts":"2026-06-08T10:31:58+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-gate7-diff-fix.md"}
{"ts":"2026-06-08T10:31:58+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T10:31:58+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T10:31:58+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T10:31:58+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T10:31:58+08:00","date":"2026-06-08","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-08T10:31:58+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T10:31:58+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T10:31:58+08:00","date":"2026-06-08","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-08T10:32:55+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-schedule-cycle-time-merge.md 26lines"}
{"ts":"2026-06-08T10:32:55+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-schedule-form-deploy-batch.md"}
{"ts":"2026-06-08T10:32:55+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T10:32:55+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T10:32:55+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T10:32:55+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T10:32:55+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-08T10:32:55+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T10:32:55+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T10:32:55+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-08T11:15:19+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-schedule-cycle-time-merge.md 26lines"}
{"ts":"2026-06-08T11:15:19+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-schedule-form-deploy-batch.md"}
{"ts":"2026-06-08T11:15:19+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T11:15:19+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T11:15:19+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T11:15:19+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T11:15:19+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-08T11:15:19+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T11:15:19+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T11:15:19+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-08T11:48:30+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-schedule-cycle-day-label-once-ui.md 26lines"}
{"ts":"2026-06-08T11:48:30+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-schedule-form-deploy-batch.md"}
{"ts":"2026-06-08T11:48:30+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T11:48:30+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T11:48:30+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T11:48:30+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T11:48:30+08:00","date":"2026-06-08","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-08T11:48:30+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T11:48:30+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T11:48:30+08:00","date":"2026-06-08","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-08T11:49:26+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-schedule-cycle-day-label-once-ui.md 26lines"}
{"ts":"2026-06-08T11:49:26+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-schedule-cycle-day-label-once-ui.md"}
{"ts":"2026-06-08T11:49:26+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T11:49:26+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T11:49:26+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T11:49:26+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T11:49:26+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-08T11:49:26+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T11:49:26+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T11:49:26+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-08T11:56:37+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-script-exec-results-mediumtext.md 31lines"}
{"ts":"2026-06-08T11:56:37+08:00","date":"2026-06-08","gate":"audit","result":"BLOCK","detail":"missing sections"}
{"ts":"2026-06-08T11:56:37+08:00","date":"2026-06-08","gate":"test","result":"BLOCK","detail":"tests failed"}
{"ts":"2026-06-08T11:56:37+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T11:56:37+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T11:56:37+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T11:56:37+08:00","date":"2026-06-08","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-08T11:56:37+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T11:56:37+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T11:56:37+08:00","date":"2026-06-08","gate":"summary","result":"BLOCK","detail":"4/7"}
{"ts":"2026-06-08T11:56:55+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-script-exec-results-mediumtext.md 31lines"}
{"ts":"2026-06-08T11:56:55+08:00","date":"2026-06-08","gate":"audit","result":"BLOCK","detail":"missing sections"}
{"ts":"2026-06-08T11:56:55+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T11:56:55+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T11:56:55+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T11:56:55+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T11:56:55+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-08T11:56:55+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T11:56:55+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T11:56:55+08:00","date":"2026-06-08","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-08T11:57:05+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-script-exec-results-mediumtext.md 31lines"}
{"ts":"2026-06-08T11:57:05+08:00","date":"2026-06-08","gate":"audit","result":"BLOCK","detail":"missing sections"}
{"ts":"2026-06-08T11:57:05+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T11:57:05+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T11:57:05+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T11:57:05+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T11:57:05+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-08T11:57:05+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T11:57:05+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T11:57:05+08:00","date":"2026-06-08","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-08T11:57:20+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-script-exec-results-mediumtext.md 31lines"}
{"ts":"2026-06-08T11:57:20+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-script-exec-results-mediumtext.md"}
{"ts":"2026-06-08T11:57:20+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T11:57:20+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T11:57:20+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T11:57:20+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T11:57:20+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-08T11:57:20+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T11:57:20+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T11:57:20+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-08T12:09:00+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-webssh-classic-terminal-theme.md 24lines"}
{"ts":"2026-06-08T12:09:00+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-webssh-classic-terminal-theme.md"}
{"ts":"2026-06-08T12:09:00+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T12:09:00+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T12:09:00+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T12:09:00+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T12:09:00+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-08T12:09:00+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T12:09:00+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T12:09:00+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-08T12:14:47+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-terminal-quickbar-disconnect.md 18lines"}
{"ts":"2026-06-08T12:14:47+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-terminal-quickbar-disconnect.md"}
{"ts":"2026-06-08T12:14:47+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T12:14:47+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T12:14:47+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T12:14:47+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T12:14:47+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-08T12:14:47+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T12:14:47+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T12:14:47+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-08T12:33:55+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-webssh-xterm-ansi-output-fix.md 35lines"}
{"ts":"2026-06-08T12:33:55+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-terminal-quickbar-disconnect.md"}
{"ts":"2026-06-08T12:33:55+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T12:33:55+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T12:33:55+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T12:33:55+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T12:33:55+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-08T12:33:55+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T12:33:55+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T12:33:55+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-08T12:43:48+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-servers-unset-path-inline-detail.md 35lines"}
{"ts":"2026-06-08T12:43:48+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-webssh-xterm-ansi-output-fix.md"}
{"ts":"2026-06-08T12:43:48+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T12:43:48+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T12:43:48+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T12:43:48+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T12:43:48+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-08T12:43:48+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T12:43:48+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T12:43:48+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-08T12:47:12+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-terminal-cmdbar-monochrome.md 25lines"}
{"ts":"2026-06-08T12:47:12+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-servers-unset-path-inline-detail.md"}
{"ts":"2026-06-08T12:47:12+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T12:47:12+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T12:47:12+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T12:47:12+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T12:47:12+08:00","date":"2026-06-08","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-08T12:47:12+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T12:47:12+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T12:47:12+08:00","date":"2026-06-08","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-08T12:47:57+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-terminal-cmdbar-monochrome.md 25lines"}
{"ts":"2026-06-08T12:47:57+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-deploy-production-batch.md"}
{"ts":"2026-06-08T12:47:57+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T12:47:57+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T12:47:57+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T12:47:57+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T12:47:57+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-08T12:47:57+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T12:47:57+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T12:47:57+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-08T12:55:08+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-servers-unset-path-batch-parity.md 27lines"}
{"ts":"2026-06-08T12:55:08+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-servers-unset-path-batch-parity.md"}
{"ts":"2026-06-08T12:55:08+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T12:55:08+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T12:55:08+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T12:55:08+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T12:55:08+08:00","date":"2026-06-08","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-08T12:55:08+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T12:55:08+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T12:55:08+08:00","date":"2026-06-08","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-08T12:55:20+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-servers-unset-path-batch-parity.md 27lines"}
{"ts":"2026-06-08T12:55:20+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-servers-unset-path-batch-parity.md"}
{"ts":"2026-06-08T12:55:20+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T12:55:20+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T12:55:20+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T12:55:20+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T12:55:20+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-08T12:55:20+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T12:55:20+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T12:55:20+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-08T13:04:19+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-servers-inline-detail-expand-fix.md 28lines"}
{"ts":"2026-06-08T13:04:19+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-notify-toggle-inline-detail-deploy.md"}
{"ts":"2026-06-08T13:04:19+08:00","date":"2026-06-08","gate":"test","result":"BLOCK","detail":"tests failed"}
{"ts":"2026-06-08T13:04:19+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T13:04:19+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T13:04:19+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T13:04:19+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-08T13:04:19+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T13:04:19+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T13:04:19+08:00","date":"2026-06-08","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-08T13:04:52+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-servers-inline-detail-expand-fix.md 28lines"}
{"ts":"2026-06-08T13:04:52+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-notify-toggle-inline-detail-deploy.md"}
{"ts":"2026-06-08T13:04:52+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T13:04:52+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T13:04:52+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T13:04:52+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T13:04:52+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-08T13:04:52+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T13:04:52+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T13:04:52+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-08T13:13:05+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-dashboard-server-list-fix.md 27lines"}
{"ts":"2026-06-08T13:13:05+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-dashboard-server-list-fix.md"}
{"ts":"2026-06-08T13:13:05+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T13:13:05+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T13:13:05+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T13:13:05+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T13:13:05+08:00","date":"2026-06-08","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-08T13:13:05+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T13:13:05+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T13:13:05+08:00","date":"2026-06-08","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-08T13:13:34+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-dashboard-server-list-fix.md 27lines"}
{"ts":"2026-06-08T13:13:34+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-dashboard-server-list-fix.md"}
{"ts":"2026-06-08T13:13:34+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T13:13:34+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T13:13:34+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T13:13:34+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T13:13:34+08:00","date":"2026-06-08","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-08T13:13:34+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T13:13:34+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T13:13:34+08:00","date":"2026-06-08","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-08T13:13:43+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-dashboard-server-list-fix.md 27lines"}
{"ts":"2026-06-08T13:13:43+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-dashboard-server-list-fix.md"}
{"ts":"2026-06-08T13:13:43+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T13:13:43+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T13:13:43+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T13:13:43+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T13:13:43+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-08T13:13:43+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T13:13:43+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T13:13:43+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-08T13:22:42+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-notify-recovery-respects-resource-toggle.md 44lines"}
{"ts":"2026-06-08T13:22:42+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-dashboard-server-list-fix.md"}
{"ts":"2026-06-08T13:22:42+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-08T13:22:42+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-08T13:22:42+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-08T13:22:42+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-08T13:22:42+08:00","date":"2026-06-08","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-08T13:22:42+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-08T13:22:42+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-08T13:22:42+08:00","date":"2026-06-08","gate":"summary","result":"BLOCK","detail":"6/7"}
@@ -0,0 +1,26 @@
# 审计 — Agent 401 停心跳(A-05
**Changelog**: `docs/changelog/2026-06-08-agent-401-stop-heartbeat.md`
## 变更文件
`heartbeat_policy.py` · `agent.py` · `install.sh` · `servers.py` · `server_batch_common.py` · `server_batch_service.py` · `agent_version.py` · `test_agent_heartbeat_stop.py`
## Step 3
| 项 | 结论 |
|----|------|
| 401 / discarded 后 `return` 退出循环 | PASS |
| 5xx 仍退避重试,非静默吞错 | PASS |
| config reload 重启已 done 的心跳任务 | PASS |
| 子机 rollout 依赖 upgrade-agent(文档已写明) | PASS |
## Closure
Gate 7/7 PASS · 待部署 + 子机批量升级验证
## DoD
- [x] `heartbeat_should_stop` 单测
- [x] `get_central_agent_version` 仍读 2.0.0
- [ ] 生产抽样子机 journalctl 401 后无重复心跳(#7 rollout 后)
@@ -0,0 +1,111 @@
# 审计 — 北京时间展示统一 + 资源恢复 Telegram 去重(2026-06-08
**Changelog**:
- `docs/changelog/2026-06-08-beijing-time-display-unified.md`
- `docs/changelog/2026-06-08-telegram-recovery-dedup-timezone.md`
**触发原因**: Bug 修复(恢复通知秒级重复、Telegram/UI 时间非北京时间)+ 展示层时区统一
## 审计范围
| 文件 | 行数 | 状态 |
|------|------|------|
| `server/utils/display_time.py` | 27 | ☑ 已审 |
| `server/infrastructure/telegram/__init__.py` | ~320 | ☑ 已审(`_now_cn` 段) |
| `server/background/ip_allowlist_refresh.py` | ~100 | ☑ 已审(刷新时间) |
| `server/api/settings.py` | ~1570 | ☑ 已审(Telegram 测试消息) |
| `server/api/agent.py` | ~385 | ☑ 已审(恢复去重) |
| `frontend/src/utils/datetime.ts` | 85 | ☑ 已审 |
| `frontend/src/utils/status.ts` | 36 | ☑ 已审 |
| `frontend/src/composables/push/labels.ts` | 68 | ☑ 已审 |
| `frontend/src/composables/useWebSocket.ts` | ~190 | ☑ 已审 |
| `frontend/src/pages/AuditPage.vue` | ~196 | ☑ 已审 |
| `frontend/src/pages/AlertsPage.vue` | ~190 | ☑ 已审 |
| `frontend/src/pages/CommandsPage.vue` | ~393 | ☑ 已审 |
| `frontend/src/pages/RetriesPage.vue` | ~180 | ☑ 已审 |
| `frontend/src/pages/SchedulesPage.vue` | ~533 | ☑ 已审 |
| `frontend/src/pages/ScriptsPage.vue` | ~1315 | ☑ 已审(时间列) |
| `frontend/src/pages/ScriptRunsPage.vue` | ~681 | ☑ 已审 |
| `frontend/src/pages/ServersPage.vue` | ~1400 | ☑ 已审(导出文件名) |
| `frontend/src/components/credentials/CredentialsManager.vue` | ~436 | ☑ 已审 |
| `frontend/src/components/servers/ServerInlineDetail.vue` | ~158 | ☑ 已审 |
| `tests/test_display_time.py` | 18 | ☑ 已审 |
| `tests/test_telegram_time_format.py` | 20 | ☑ 已审 |
| `tests/test_agent_recovery_dedup.py` | 42 | ☑ 已审 |
## 审计8步结果
### Step 1: 登记 ✅
两项用户反馈:① Telegram 恢复时间显示 UTC;② 同一指标 1 秒内连发多条恢复通知。
### Step 2: 全文 Read ✅
上述文件全文走读;`broadcast_recovery` / `websocket.py` 交叉确认恢复无 Telegram cooldown 设计意图(恢复应即时推送,根因在 Redis 成员重复)。
### Step 3: 规则扫描 H
| ID | 规则 | 命中点 | 初判 |
|----|------|--------|------|
| H1 | 静默吞错 | `agent.py` Redis 告警跟踪 `except: debug` | SAFE — 已有模式,告警主路径仍广播 |
| H2 | 时区一致性 | `parseApiDateTime` 假定 naive = UTC | SAFE — 与 `sync_log_repo`、ORM `_utcnow` 一致 |
| H3 | 去重完整性 | Redis legacy `mem:92` 多条 | SAFE — `_detect_recovery` 按 metric 去重 + srem 全清 |
| H4 | XSS / 注入 | 前端 `formatDateTimeBeijing` 仅格式化 | SAFE — Vue 文本插值,无 `v-html` |
| H5 | 密钥泄露 | `display_time` / `datetime.ts` | SAFE — 无凭据 |
| H6 | CUD 审计 | 无新 CUD 路径 | N/A |
| H7 | 多 worker | 恢复 Telegram 仍无 Redis 锁 | LOW — 单心跳内重复已根治;跨 worker 同毫秒双 POST 概率极低,接受 |
### Step 4: Closure表
| H | 判定 | 依据 |
|---|------|------|
| H1 | SAFE | 跟踪失败不影响 `broadcast_alert`;日志可见 |
| H2 | SAFE | DB/API 约定 UTC;文档与 changelog 已说明 |
| H3 | SAFE | 单测 `test_detect_recovery_dedupes_legacy_metric_value_entries` |
| H4 | SAFE | 展示层数值格式化 |
| H5 | SAFE | — |
| H6 | N/A | — |
| H7 | ACCEPT | 根因修复;若后续再现可加 `recovery_sent:{id}:{metric}` SETNX |
### Step 5: 入口表
| 入口 | 变更 |
|------|------|
| `POST /api/agent/heartbeat` | Redis `alerts:{id}` 成员格式、恢复检测 |
| Telegram 出站 | `_now_cn``format_beijing_now` |
| `POST .../telegram/test` | 测试消息时间 |
| 前端各列表页 | 只读展示,无新 API |
### Step 6: 输入→Sink
- Agent `system_info` → 阈值比较 → Redis set / `broadcast_recovery` → Telegram:无新 sink;去重降低 Telegram 频率。
- API `created_at` 字符串 → `parseApiDateTime``Intl` 北京时间:只读展示。
### Step 7: 归类
```
display_time.py 27行 2H 0 FINDING
agent.py (recovery) ~50行 3H 0 FINDING
datetime.ts 85行 2H 0 FINDING
前端 10 文件 ~40行 1H 0 FINDING
测试 3 文件 80行 0H 0 FINDING
──────────────────────────────────────────
总计 8H 0 FINDING1 ACCEPT
```
### Step 8: DoD ✅
- [x] 恢复通知同一指标单次心跳最多 1 条 Telegram
- [x] Telegram / 审计页 / 告警页等绝对时间为北京时间
- [x] DB 仍存 UTC,未改调度/漂移计算
- [x] `pytest` 7 项通过(`test_display_time` / `test_telegram_time_format` / `test_agent_recovery_dedup`
- [x] `npm run type-check` 通过
- [ ] 生产部署与浏览器终验(待用户批准 deploy)
## FINDING 列表
无。H7 为风险接受项,非 FINDING。
## 结论
**审计通过,0 FINDING,可进入部署门控**(需 commit + `pre_deploy_check.sh` + 前端 build
@@ -0,0 +1,25 @@
# 审计 — 仪表盘服务器列表不显示
**Changelog**: `docs/changelog/2026-06-08-dashboard-server-list-fix.md`
## 变更文件
`frontend/src/composables/useServerPagination.ts` · `frontend/src/pages/DashboardPage.vue` · `web/app/index.html`
## Step 3
| 项 | 结论 |
|----|------|
| loading 静默刷新后清除 | PASS |
| 去掉 skeleton v-if 卡死 | PASS |
| v-card 结构 | PASS |
| 搜索/30s 刷新 | PASS |
## Closure
frontend build PASS · 待部署
## DoD
- [x] 仪表盘服务器表格可见
- [ ] 生产浏览器验收
@@ -0,0 +1,46 @@
# 审计 — 生产部署批次(WebSSH ANSI + 服务器未设路径 + 命令栏单色)
**Changelog**:
- `docs/changelog/2026-06-08-webssh-xterm-ansi-output-fix.md`
- `docs/changelog/2026-06-08-servers-unset-path-inline-detail.md`
- `docs/changelog/2026-06-08-terminal-cmdbar-monochrome.md`
## 变更文件
| 模块 | 文件 |
|------|------|
| WebSSH relay | `server/api/webssh.py` |
| 终端解码 | `frontend/src/utils/terminalPayload.ts` |
| 终端会话 | `frontend/src/composables/terminal/useTerminalSessions.ts` |
| 终端页 | `frontend/src/pages/TerminalPage.vue` |
| 命令栏 | `frontend/src/components/terminal/TerminalCmdBar.vue` |
| WebSSH 单测 | `tests/test_webssh_terminal_payload.py` |
| 路径判定 | `server/utils/posix_paths.py` |
| 服务器仓储 | `server/infrastructure/database/server_repo.py` |
| 服务器 API | `server/api/servers.py` |
| 前端路径 util | `frontend/src/utils/serverTargetPath.ts` |
| 分页 composable | `frontend/src/composables/useServerPagination.ts` |
| 行内详情 | `frontend/src/components/servers/ServerInlineDetail.vue` |
| 未设路径面板 | `frontend/src/components/servers/ServerUnsetPathPanel.vue` |
| 服务器页 | `frontend/src/pages/ServersPage.vue` |
| 路径过滤单测 | `tests/test_server_target_path_filter.py` |
## Step 3
| 项 | 结论 |
|----|------|
| xterm ANSI 在 SSH 输出区 | PASSPTY env + data_b64 |
| 命令栏单色 | PASS`.sh-*` 统一 on-surface |
| `target_path_unset` 默认 None | PASS |
| 双表 refresh / 行内详情 | PASS |
## Closure
Gate 7/7 PASS · pytest + local_verify + build · 生产部署
## DoD
- [x] WebSSH xterm ANSI
- [x] 命令栏无彩色
- [x] 服务器未设路径区块 + 行内详情
- [x] 单测与门控
@@ -0,0 +1,27 @@
# 审计 — 全量对齐部署(batch 回收 / sync purge / agent 跳过)
**Changelog**: `docs/changelog/2026-06-08-full-align-deploy.md`
## 变更文件
`main.py` · `servers.py` · `server_batch_common.py` · `server_batch_service.py` · `server_batch_store.py` · `server_batch_job_repo.py` · `sync_log_repo.py` · `server_batch_reconcile.py` · `sync_log_purge.py` · `agent_version.py` · `test_agent_version.py` · `test_server_batch_reconcile.py` · `test_sync_log_purge.py`
## Step 3
| 项 | 结论 |
|----|------|
| 启动回收仅 primary worker,无静默吞错 | PASS |
| sync_logs purge 批量 DELETEretention=30d | PASS |
| 批量安装跳过:版本 SSOT 来自 agent.pySSH 探测有超时 | PASS |
| install 跳过不计入失败,lower 版本仍执行 install.sh | PASS |
## Closure
Gate 7/7 PASS · 生产全量对齐部署
## DoD
- [x] pytest 新增用例通过
- [x] local_verify 26/26
- [x] 生产 /health + /app/ 200
- [x] 容器内 recover_orphaned / sync_log_purge 标记存在
@@ -0,0 +1,33 @@
# 审计 — 资源恢复 Telegram 联动分项开关 + 仪表盘去列表
**Changelog**:
- `docs/changelog/2026-06-08-notify-recovery-respects-resource-toggle.md`
- `docs/changelog/2026-06-08-dashboard-remove-server-list.md`
## 变更文件
`server/utils/notify_toggles.py` · `server/infrastructure/telegram/__init__.py` · `tests/test_notify_toggle_sync.py` · `frontend/src/types/api.ts` · `frontend/src/pages/DashboardPage.vue` · `web/app/index.html`
## Step 3
| 项 | 结论 |
|----|------|
| 恢复 Telegram 需 NOTIFY_RECOVERY + 分项 notify_alert_* | PASS`resource_recovery_notify_enabled` |
| CPU/内存/磁盘告警关闭后恢复不推 | PASSparametrize 单测 3 指标) |
| Redis/MySQL 恢复共用 notify_system_* | PASS(已有 `send_telegram_system_alert` 门控 + 单测) |
| WebSocket / alert_logs 仍记录恢复 | PASS(仅 Telegram 门控) |
| 设置页文案说明联动 | PASSapi.ts NOTIFY_TOGGLE_ITEMS |
| 仪表盘移除服务器列表表格 | PASS(保留统计卡片与告警区) |
| 无静默吞错 | PASS |
## Closure
pytest notify 34/34 · frontend build PASS · Gate 7 待跑
## DoD
- [x] 关 CPU 告警 → 无 CPU 超阈值 & 无 CPU 恢复 Telegram
- [x] 内存/磁盘同理
- [x] 仪表盘无服务器列表区块
- [x] changelog ×2
- [ ] 生产 /health + 浏览器验收
@@ -0,0 +1,31 @@
# 审计 — Telegram 开关多 Worker 同步 + 服务器行内详情展开
**Changelog**:
- `docs/changelog/2026-06-08-notify-toggle-multi-worker-sync.md`
- `docs/changelog/2026-06-08-servers-inline-detail-expand-fix.md`
## 变更文件
`server/config.py` · `server/infrastructure/settings_broadcast.py` · `server/utils/notify_toggles.py` · `server/api/settings.py` · `server/main.py` · `server/infrastructure/telegram/__init__.py` · `tests/test_notify_toggle_sync.py` · `frontend/src/pages/ServersPage.vue` · `frontend/src/components/servers/ServerUnsetPathPanel.vue`
## Step 3
| 项 | 结论 |
|----|------|
| NOTIFY_* 关闭后 Telegram 不发送 | PASShelper + 单测 9 开关) |
| 多 worker 热更新 | PASSRedis `nexus:settings` 广播) |
| WebSocket / alert_logs 仍记录 | PASS(仅 Telegram 门控,符合设置页文案) |
| expanded 与 item-value 数字 id 一致 | PASSnumber[] + computed 桥接) |
| 无静默吞错 | PASSbroadcast publish 失败打 warning |
## Closure
Gate 7/7 待跑 · pytest notify 31/31 · frontend build PASS
## DoD
- [x] Settings 关 CPU 告警 → 任意 worker 不推 Telegram
- [x] 点击服务器名称展开 ServerInlineDetail
- [x] 未设路径区块同样可展开
- [x] changelog ×2
- [ ] 生产 /health + 浏览器验收
@@ -0,0 +1,41 @@
# 审计 — 推送后台化 + 422 中文化 + 隐藏 CSV 导出(2026-06-08
**Changelog**:
- `docs/changelog/2026-06-08-push-async-background.md`
- `docs/changelog/2026-06-08-validation-error-loc-zh.md`
- `docs/changelog/2026-06-08-servers-hide-csv-export.md`
## 审计范围
| 文件 | 状态 |
|------|------|
| `server/background/sync_push_runner.py` | ☑ |
| `server/api/sync_v2.py` | ☑ |
| `server/utils/validation_errors_zh.py` | ☑ |
| `frontend/src/composables/push/usePushProgress.ts` | ☑ |
| `frontend/src/components/push/PushToolbar.vue` | ☑ |
| `frontend/src/pages/PushPage.vue` | ☑ |
| `frontend/src/utils/validationErrorFormat.ts` | ☑ |
| `frontend/src/api/index.ts` | ☑ |
| `frontend/src/utils/apiError.ts` | ☑ |
| `frontend/src/pages/ServersPage.vue` | ☑ |
| `tests/test_validation_errors_zh.py` | ☑ |
## Step 3
| 项 | 结论 |
|----|------|
| 推送后台任务独立 DB 会话 | PASS |
| 422 loc_zh 不泄露内部路径 | PASS |
| 隐藏 CSV 仅 UI 移除 | PASS |
## Closure
Gate 7/7 待跑 · 0 FINDING
## DoD
- [x] 推送按钮不长时间 loading
- [x] 校验错误显示「每页条数:不能大于 200」
- [x] 服务器页无导出 CSV 按钮
- [ ] 生产部署与健康检查
@@ -0,0 +1,25 @@
# 审计 — server_ids 批量上限 2000
**Changelog**: `docs/changelog/2026-06-08-server-ids-batch-max-2000.md`
## 变更文件
`schemas.py` · `validation_errors_zh.py` · `test_validation_errors_zh.py` · `test_security_unit.py`
## Step 3
| 项 | 结论 |
|----|------|
| server_ids max 2000 与 Sync/Script 一致 | PASS |
| too_long 列表 ctx 中文完整 | PASS |
| 101 台 BatchAgentAction 校验通过 | PASS |
## Closure
Gate 7/7 PASS · 生产部署
## DoD
- [x] 101 台批量不再 422
- [x] 列表超长错误纯中文
- [x] 单测
@@ -0,0 +1,27 @@
# 审计 — 服务器展开详情对比度 + 同步日志 1 条
**Changelog**: `docs/changelog/2026-06-08-server-inline-detail-contrast.md`
## 变更文件
`frontend/src/components/servers/ServerInlineDetail.vue` · `frontend/src/pages/ServersPage.vue` · `frontend/src/components/servers/ServerUnsetPathPanel.vue` · `web/app/index.html`
## Step 3
| 项 | 结论 |
|----|------|
| 展开区背景比行底色深约 5% | PASScolor-mix surface 95% + on-surface 5% |
| 正文 on-surface 可读 | PASS |
| 分类视图展开区同色 | PASS |
| 同步日志仅 1 条、无「最近 N 条」文案 | PASSlimit=1 |
| 无后端/API 变更 | PASS |
## Closure
frontend build PASS · Gate 7 待跑
## DoD
- [x] 展开详情非黑底黑字
- [x] 同步日志标题与条数
- [ ] 生产浏览器验收
@@ -0,0 +1,26 @@
# 审计 — 未设路径服务器批量管理对齐主表
**Changelog**: `docs/changelog/2026-06-08-servers-unset-path-batch-parity.md`
## 变更文件
`ServerBatchActionBar.vue` · `ServerUnsetPathPanel.vue` · `ServersPage.vue` · `web/app/index.html` · `web/app/assets/ServersPage-*`
## Step 3
| 项 | 结论 |
|----|------|
| 未设路径区独立勾选 | PASS(与主表 `selectedItems` 隔离) |
| 批量操作栏对齐 | PASS(分类/健康/路径/Agent/删除) |
| 全选筛选结果 | PASS`target_path_unset=true` + 当前筛选) |
| 列与行操作对齐 | PASS |
## Closure
Gate 7/7 · build PASS · 生产部署
## DoD
- [x] 勾选 + 批量栏 + 全选
- [x] 双 scope 复用 batch 逻辑
- [x] 前端 build
@@ -0,0 +1,30 @@
# 审计 — 服务器未设路径区块 + 行内详情
**Changelog**: `docs/changelog/2026-06-08-servers-unset-path-inline-detail.md`
## 变更文件
`posix_paths.py` · `server_repo.py` · `servers.py` · `serverTargetPath.ts` · `useServerPagination.ts` · `ServerInlineDetail.vue` · `ServerUnsetPathPanel.vue` · `ServersPage.vue` · `tests/test_server_target_path_filter.py` · `web/app/assets/ServersPage-*`
## Step 3
| 项 | 结论 |
|----|------|
| `target_path_unset` 默认 None | PASSTerminal/Dashboard 零回归) |
| 未设路径判定单一真相 | PASSPython + TS 镜像,`/www/wwwroot/` 不算未设) |
| 双表 refresh / 展开态 reset | PASS |
| 行内 logs 防竞态 + formatApiError | PASS(禁止静默空列表) |
| 未设路径表不纳入主表批量勾选 | PASS |
## Closure
Gate 7/7 PASS · 本地 build + pytest 12/12 · 待用户批准部署
## DoD
- [x] 主表 `target_path_unset=false` 排除未设路径
- [x] 未设路径独立卡片(失败列表上方)
- [x] 名称点击行内展开紧凑详情
- [x] 移除底部 `detailServer` 大块详情卡
- [x] 保存路径后双表迁移 + 展开态清理
- [x] 单测 util / repo / API 三态
@@ -0,0 +1,25 @@
# 审计 — 设置表单校验 + 422 中文
**Changelog**: `docs/changelog/2026-06-08-settings-form-validate-zh-errors.md`
## 变更文件
`SettingsPage.vue` · `validation_errors_zh.py` · `main.py` · `schemas.py` · `test_validation_errors_zh.py`
## Step 3
| 项 | 结论 |
|----|------|
| v-form validate 先于 PUT | PASS |
| 422 msg 中文化 | PASS |
| value 数字 coerce | PASS |
## Closure
Gate 7/7 PASS · 生产部署 · `/health` 200
## DoD
- [x] 设置页前端校验
- [x] 422 中文 msg
- [x] 单测
@@ -0,0 +1,22 @@
# 审计 — 设置页连接池保存
**Changelog**: `docs/changelog/2026-06-08-settings-pool-size-string-payload.md`
## 变更文件
`SettingsPage.vue`
## Step 3
| 项 | 结论 |
|----|------|
| value 转 String | PASS |
| 与 SettingUpdatePayload str 一致 | PASS |
## Closure
Gate 7/7 PASS · 生产部署 · `/health` 200
## DoD
- [x] 连接池/阈值保存无 422 string_type
@@ -0,0 +1,29 @@
# 审计 — 终端快捷栏断开按钮(2026-06-08)
**Changelog**: `docs/changelog/2026-06-08-terminal-quickbar-disconnect.md`
## 审计范围
| 模块 | 文件 |
|------|------|
| 快捷栏 | `frontend/src/components/terminal/TerminalQuickBar.vue` |
| 终端页 | `frontend/src/pages/TerminalPage.vue` |
## Step 3
| H | 规则 | 结论 |
|---|------|------|
| H1 | 断开复用既有逻辑 | PASS — `@disconnect="disconnect"` |
| H2 | 无会话禁用 | PASS — `disconnectDisabled` |
| H3 | 无后端变更 | PASS |
## Closure
| H | 判定 |
|---|------|
| H1H3 | PASS |
## DoD
- [x] frontend vite build
- [x] changelog
@@ -0,0 +1,24 @@
# 审计 — 未设路径批量检测选中数
**Changelog**: `docs/changelog/2026-06-08-unset-path-detect-selection-count.md`
## 变更文件
`frontend/src/pages/ServersPage.vue` · `web/app/index.html`
## Step 3
| 项 | 结论 |
|----|------|
| 检测路径对话框按 batchScope 显示选中数 | PASSscopedSelectedCount |
| 未设路径表提交 detect-path 用 unset 选中 ID | PASS |
| 批量改分类对话框同修复 | PASS |
## Closure
frontend build PASS · Gate 7 待跑
## DoD
- [x] 未设路径勾选 N 台 → 对话框显示 N
- [ ] 生产浏览器验收
@@ -0,0 +1,25 @@
# 审计 — 未设路径服务器表格排序
**Changelog**: `docs/changelog/2026-06-08-unset-path-table-sort.md`
## 变更文件
`ServerUnsetPathPanel.vue` · `ServersPage.vue` · `web/app/index.html` · `web/app/assets/*`
## Step 3
| 项 | 结论 |
|----|------|
| sort-by 与主表共享 | PASS(同一 `sortBy` ref + `listQueryParams` |
| API 参数 | PASS`target_path_unset=true` + `sort_by`/`sort_order` 已有) |
| 无后端改动 | PASS |
## Closure
Gate 7/7 PASS · 生产部署 · `/health` 200
## DoD
- [x] 未设路径区列头可排序
- [x] 与主表排序状态一致
- [x] 前端构建通过
@@ -0,0 +1,35 @@
# 审计 — WebSSH xterm SSH 交互区 ANSI 彩色输出(2026-06-08
**Changelog**: `docs/changelog/2026-06-08-webssh-xterm-ansi-output-fix.md`
## 审计范围
| 模块 | 文件 |
|------|------|
| WebSocket relay | `server/api/webssh.py` |
| 前端解码 | `frontend/src/utils/terminalPayload.ts` |
| 会话 | `frontend/src/composables/terminal/useTerminalSessions.ts` |
| 样式分离 | `frontend/src/pages/TerminalPage.vue`, `frontend/src/components/terminal/TerminalCmdBar.vue` |
| 单测 | `tests/test_webssh_terminal_payload.py` |
## Step 3
| H | 规则 | 结论 |
|---|------|------|
| H1 | ANSI 在 xterm 输出区,非命令栏冒充 | PASS — PTY env + data_b64 + CSS 分离 |
| H2 | 无静默吞错 | PASS — relay 异常仍走现有 teardown |
| H3 | 向后兼容 | PASS — 前端仍可读 legacy `data` 字段 |
## Closure
| H | 判定 |
|---|------|
| H1H3 | PASS |
## DoD
- [x] pytest 3/3
- [x] local_verify 26/26
- [x] frontend build
- [x] pre_deploy_check 7/7
- [ ] 生产浏览器 xterm 内 printf/ls(部署后终验)
@@ -0,0 +1,40 @@
# 2026-06-08 — Agent 401 停心跳 + 配置重载后重启(A-05)
## 摘要
边端 Agent 心跳收到中心 **401****discarded** 后永久退出循环;`/config/reload` 写入新 key 后自动重启心跳任务。策略提取为 `heartbeat_policy.py` 并加单测。
## 动机
错误 API key 时旧 Agent 每 60s 无限重试;401 停循环已在 `72d82d7` 落地,但缺单测、缺 key 修复后自动恢复、子机文件未统一 rollout。
## 涉及文件
- `web/agent/heartbeat_policy.py`(新)
- `web/agent/agent.py``AGENT_VERSION` 常量、config reload 重启心跳
- `web/agent/install.sh` — 同步下载 `heartbeat_policy.py`
- `server/application/server_batch_common.py``agent_files_curl_cmds` 升级双文件
- `server/api/servers.py` · `server_batch_service.py` — 升级拉取双文件
- `server/utils/agent_version.py` — 读 `AGENT_VERSION` 常量
- `tests/test_agent_heartbeat_stop.py`
## 迁移 / 重启
- 中心:`/agent/agent.py` 随 API 镜像更新
- **各子机**:须批量 **升级 Agent** 或重装,否则仍跑旧 `agent.py`(见 backlog #7
## 行为
| 中心响应 | Agent |
|----------|--------|
| 401 Invalid API key | 停止心跳,打 error 日志 |
| 200 `status=discarded` | 停止心跳(错误 server_id 等) |
| 5xx / 网络错误 | 指数退避重试(不变) |
| `/config/reload` 新 key | 若心跳已停,重启协程 |
## 验证
```bash
pytest tests/test_agent_heartbeat_stop.py tests/test_agent_version.py -q
bash scripts/local_verify.sh
```
@@ -0,0 +1,38 @@
# 2026-06-08 — 批量安装 Agent 已安装且版本足够则跳过
## 摘要
批量/单机「安装 Agent」时:若子机已安装且 Agent 版本 **不低于** 主站 `web/agent/agent.py` 中的版本,则跳过 SSH 安装;仅当未安装或版本更低时才执行 `install.sh`
## 动机
批量勾选已部署 Agent 的服务器会重复跑完整安装脚本,耗时长且可能短暂中断 Agent。
## 涉及文件
- `server/utils/agent_version.py` — 主站版本 SSOT + 版本比较
- `server/application/server_batch_common.py``agent_install_skip_result`、Redis/SSH 版本探测
- `server/application/services/server_batch_service.py``_run_install_agent` 跳过逻辑
- `server/api/servers.py` — 单机 `install-agent` 对齐
- `tests/test_agent_version.py`
## 迁移 / 重启
- 无 DB 变更;需重启 API
## 行为
| 子机状态 | 动作 |
|----------|------|
| 无 agent_api_key 且无 agent_version | 执行安装 |
| 已安装,版本 ≥ 主站 | 跳过(successstdout 说明) |
| 已安装,版本 < 主站 | 执行安装(等同升级) |
版本来源优先级:Redis `heartbeat:{id}` → MySQL `agent_version` → SSH 读取 `/opt/nexus-agent/agent.py`
## 验证
```bash
pytest tests/test_agent_version.py -q
bash scripts/local_verify.sh
```
@@ -0,0 +1,40 @@
# 2026-06-08 — 全站用户可见时间统一为北京时间
## 变更摘要
存储与计算仍用 UTC;所有面向运维的**展示层**(Web 表格、Telegram、设置页刷新时间等)统一为 `Asia/Shanghai` 北京时间。
## 动机
用户要求不论浏览器时区,界面与通知时间与国内运维习惯一致。
## 方案
- **后端**`server/utils/display_time.py``format_beijing` / `format_beijing_now`);Telegram、IP 白名单刷新时间、Telegram 测试消息接入
- **前端**`frontend/src/utils/datetime.ts``parseApiDateTime` 将 MySQL naive UTC 正确解析 + `formatDateTimeBeijing` 等);15 页相关表格/内联详情/WS 告警条改用该工具
## 涉及文件
- `server/utils/display_time.py`(新)
- `server/infrastructure/telegram/__init__.py`
- `server/background/ip_allowlist_refresh.py`
- `server/api/settings.py`
- `frontend/src/utils/datetime.ts`(新)
- `frontend/src/utils/status.ts`
- `frontend/src/composables/push/labels.ts`
- `frontend/src/composables/useWebSocket.ts`
- 多页 Vue`Alerts` `Audit` `Commands` `Retries` `Schedules` `Scripts` `ScriptRuns` `Servers` 及凭据/子机内联组件
## 迁移 / 重启
- 无 DB 迁移
- 需重新构建前端并重启 API
## 验证方式
```bash
.venv/bin/python -m pytest tests/test_display_time.py tests/test_telegram_time_format.py tests/test_agent_recovery_dedup.py -q
cd frontend && npm run type-check
```
浏览器:告警历史、审计、推送历史等列时间应为北京时间(与 UTC 差 8 小时)。
@@ -0,0 +1,25 @@
# 2026-06-08 — 仪表盘移除服务器列表表格
## 摘要
仪表盘不再展示可分页的服务器列表表格;完整列表仍在「服务器」页。
## 动机
仪表盘聚焦统计卡片与概览(告警、资源、分类、审计);列表与服务器页重复且占用首屏空间。
## 变更
- `frontend/src/pages/DashboardPage.vue` — 删除服务器列表卡片、`useServerPagination`、30s 列表轮询及相关样式
## 涉及文件
- `frontend/src/pages/DashboardPage.vue`
## 迁移 / 重启
无;前端构建部署即可。
## 验证
打开 `#/`:仅见统计卡片 + 告警/资源概览/分类/最近操作,无「服务器列表」表格。
@@ -0,0 +1,27 @@
# 2026-06-08 — 仪表盘服务器列表不显示修复
## 摘要
修复仪表盘「服务器列表」区域空白或一直骨架屏的问题。
## 动机
keep-alive 切换页面后 `loading` 可能卡在 `true`,而静默刷新不会清除;`v-if` 骨架屏与表格互斥导致表格永不渲染。另:`v-card``#text` 插槽结构改为显式 `v-card-title` + `v-card-text`
## 变更
- `useServerPagination.ts``finally` 始终 `loading=false`(含 silent 刷新)
- `DashboardPage.vue` — 去掉 skeleton/table 的 `v-if/v-else`;卡片结构修正;搜索绑定 `onSearch`30s 自动刷新列表
## 涉及文件
- `frontend/src/composables/useServerPagination.ts`
- `frontend/src/pages/DashboardPage.vue`
## 迁移 / 重启
无;前端构建部署即可。
## 验证
打开 `#/` 仪表盘 → 服务器列表表格应显示数据;切换侧栏再回仪表盘仍可见。
@@ -0,0 +1,33 @@
# 2026-06-08 — 全量对齐部署
## 摘要
合并本会话三项后端能力并推生产:server_batch 僵尸回收 + stuck 扫描、sync_logs 30 天 purge、批量安装 Agent 已安装且版本足够则跳过;Docker 镜像重建同步前端(Settings 422、调度表单等已 commit 源码)。
## 动机
本地 `a401ea5` 与生产后端部分对齐,但缺 #1/#3、agent 跳过逻辑;生产前端 bundle 落后于 git 源码。
## 涉及文件
- `server/main.py` · `server_batch_*` · `sync_log_*` · `agent_version.py` · `servers.py`
- `tests/test_agent_version.py` · `test_server_batch_reconcile.py` · `test_sync_log_purge.py`
- 计划/审计:`docs/design/plans/2026-06-08-*.md` · `docs/audit/2026-06-08-full-align-deploy.md`
## 迁移 / 重启
- 无 schema 变更;API 重启 + Docker `nexus-1panel.sh upgrade`
## 验证
```bash
bash deploy/pre_deploy_check.sh
bash deploy/deploy-production.sh
curl https://api.synaglobal.vip/health
```
## 生产预期
- 启动日志含 `recover_orphaned` / `sync_log_purge` / `server_batch_reconcile`
- `/app/index.html` 新 bundle 哈希
- 批量安装已装 2.0.0 子机显示「已跳过」
@@ -0,0 +1,44 @@
# 2026-06-08 — 资源恢复 Telegram 联动资源告警开关
## 摘要
关闭「CPU/内存/磁盘」单项告警开关后,对应指标的「资源恢复」Telegram 也不再推送。
## 动机
用户关闭 CPU 告警后仍收到「🟢 【资源恢复】CPU 已回落至正常范围」;恢复此前仅检查 `notify_recovery` 总开关,未检查 `notify_alert_cpu` 等分项。
## 变更
- `server/utils/notify_toggles.py` — 新增 `recovery_notify_enabled()``resource_recovery_notify_enabled(metric)`(总开关 + CPU/内存/磁盘分项)
- `server/infrastructure/telegram/__init__.py``send_telegram_recovery` 使用联动检查
- `frontend/src/types/api.ts` — 设置页 CPU/内存/磁盘/恢复开关说明文案
- `tests/test_notify_toggle_sync.py` — CPU/内存/磁盘告警与恢复、Redis/MySQL 恢复联动单测
## 逻辑
资源类(CPU / 内存 / 磁盘):
| notify_recovery | notify_alert_* | 对应恢复 Telegram |
|-----------------|----------------|-------------------|
| off | * | 不发送 |
| on | off | 不发送 |
| on | on | 发送 |
系统类(Redis / MySQL):告警与恢复共用同一 `notify_system_*` 开关(`send_telegram_system_alert` 已统一检查)。
## 涉及文件
见上。
## 迁移 / 重启
无 DB 变更;后端部署/重启生效。
## 验证
```bash
pytest tests/test_notify_toggle_sync.py -q
```
关闭 CPU 告警开关 → 不应再收到 CPU 恢复 Telegram。
@@ -0,0 +1,43 @@
# 2026-06-08 — 告警 Telegram 开关多 Worker 同步
## 摘要
修复 Settings 关闭「CPU/内存/磁盘等资源告警 Telegram 通知」后仍收到 Telegram 的问题。根因:多 uvicorn worker 下仅处理 PUT 的进程热更新了内存 `settings`,处理 Agent 心跳的其他 worker 仍认为开关为开启。
## 动机
用户关闭「CPU 超阈值告警」等 notify_* 开关后 Telegram 仍推送;设置页文案已说明「关闭后不发送 Telegram」,但生产 `--workers 4` 时行为不一致。
## 变更
1. **`server/config.py`** — 新增 `apply_db_override(db_key, value)`,统一单条设置热更新逻辑。
2. **`server/infrastructure/settings_broadcast.py`** — 新增 Redis 频道 `nexus:settings`PUT 后 publish,各 worker 订阅并 `apply_db_override`
3. **`server/main.py`** — lifespan 启动/停止 settings subscriber。
4. **`server/api/settings.py`** — `set_setting` 热更新后调用 `publish_setting_change`
5. **`server/utils/notify_toggles.py`** — 集中 `notify_enabled` / `resource_alert_notify_enabled` 等 helper。
6. **`server/infrastructure/telegram/__init__.py`** — 全部 Telegram 发送入口复用 notify helper(行为不变,逻辑集中)。
7. **`tests/test_notify_toggle_sync.py`** — 开关解析、Telegram 跳过、广播 publish 测试。
## 涉及文件
- `server/config.py`
- `server/infrastructure/settings_broadcast.py`
- `server/main.py`
- `server/api/settings.py`
- `server/utils/notify_toggles.py`
- `server/infrastructure/telegram/__init__.py`
- `tests/test_notify_toggle_sync.py`
## 迁移 / 重启
- 无 DB 迁移。
- 需重启后端(或部署)以加载 settings subscriber。
## 验证
```bash
pytest tests/test_notify_toggle_sync.py tests/test_settings_telegram.py -q
```
- Settings 关闭 CPU 告警 → 任意 worker 处理心跳时 `send_telegram_alert` 返回 False,不调用 Telegram API。
- WebSocket 告警与 `alert_logs` 仍记录(与设置页说明一致)。
@@ -0,0 +1,31 @@
# 2026-06-08 — 推送按钮改为后台任务提交
## 变更摘要
文件推送不再阻塞 HTTP 直至全部子机完成;点击「推送」后按钮仅短暂 loading,任务在后台执行,进度由 WebSocket + 下方进度列表展示。
## 动机
用户反馈推送按钮长时间转圈,期望与脚本执行类似:提交后立即进入后台任务。
## 根因
`POST /api/sync/files` 同步等待 `sync_engine_v2.sync_files` 全部 rsync 结束才返回;前端 `:loading="pushing"` 与 HTTP 阻塞绑定。
## 涉及文件
- `server/background/sync_push_runner.py`(新)
- `server/api/sync_v2.py` — 校验后 `schedule_sync_files`,返回 `accepted: true`
- `frontend/src/composables/push/usePushProgress.ts``submitting` 与 toast
- `frontend/src/components/push/PushToolbar.vue`
- `frontend/src/pages/PushPage.vue`
## 迁移 / 重启
- 需重启 API / 重建镜像
- 前端需 build
## 验证方式
1. 推送页选多台子机 → 点推送:按钮转圈仅数秒内结束,出现「推送已提交」toast,下方进度条持续更新
2. `pytest tests/test_sync_batch_id.py -q`
@@ -0,0 +1,39 @@
# 2026-06-08 — server_batch 僵尸回收与 stuck 扫描
## 摘要
API 启动时自动收尾因重启遗留的 `running` 批量任务;后台每 60s 扫描 1h 无进展的卡住任务,与脚本执行 `detect_stuck` 对齐。
## 动机
部署重启后任务 #20 等批量作业永久 `running`MySQL/Redis 与 UI 进度条无法正确结束。
## 涉及文件
- `server/infrastructure/redis/server_batch_store.py` — stuck 阈值、pending 标记、Redis 扫描
- `server/infrastructure/database/server_batch_job_repo.py``list_running`
- `server/application/services/server_batch_service.py``recover_orphaned_batch_jobs` / `detect_stuck_batch_jobs`
- `server/background/server_batch_reconcile.py` — 60s 周期循环
- `server/main.py` — primary worker 启动回收 + 后台任务
- `tests/test_server_batch_reconcile.py`
## 迁移 / 重启
- 无 DB 迁移
- **需重启 API**primary worker 启动时执行一次回收)
## 行为
| 场景 | 动作 |
|------|------|
| API 重启时 Redis/MySQL 仍为 `running` | 未完成台 → 失败「服务重启导致任务中断」→ partial/failed |
| 运行中 1h 无 `updated_at` 更新 | 未完成台 → `auto_stuck` → finalize |
| Redis 已过期、MySQL 仍 running | 从 MySQL 重建 live 后回收 |
## 验证
```bash
pytest tests/test_server_batch_reconcile.py -q
bash scripts/local_verify.sh
# 可选:制造 running 任务后 docker restart API,确认任务变为 failed/partial
```
@@ -0,0 +1,27 @@
# 批量 server_ids 上限与 422 列表错误中文
**日期**2026-06-08
## 变更摘要
- `BatchAgentAction` / `ServerCheck` / `BatchCategoryUpdate``server_ids` 上限由 50/200 统一为 **2000**(与推送/脚本一致)。
- 修复 Pydantic `too_long` 列表错误翻译残缺(「最多 50 项s after validation, not 101」→「最多 50 项,当前 101 项」)。
## 动机
全选筛选 101 台批量操作时触发 422;错误文案中英混杂。
## 涉及文件
- `server/api/schemas.py``SERVER_IDS_BATCH_MAX`
- `server/utils/validation_errors_zh.py``too_long`/`too_short` + ctx
- `tests/test_validation_errors_zh.py` · `tests/test_security_unit.py`
## 迁移 / 重启
后端部署即可。
## 验证
- 101 台 `POST /servers/batch/detect-path` 应 200 入队
- 2001 台应 422 且 msg 为纯中文
@@ -0,0 +1,22 @@
# 2026-06-08 — 服务器展开详情对比度修复
## 摘要
点击服务器名称展开的内联详情,以及分类视图下点击公司名称展开的区域,改为相对表格行底色深约 5% 的灰底 + 主题正文色(浅色主题为黑字);同步日志仅展示最近 1 条(标题不再写「最近 N 条」)。
## 动机
展开区使用 `bg-surface-variant` 在部分主题下出现黑底黑字、对比度不足。
## 涉及文件
- `frontend/src/components/servers/ServerInlineDetail.vue`
- `frontend/src/pages/ServersPage.vue`
## 迁移 / 重启
仅前端构建部署。
## 验证
`cd frontend && npm run build`;浏览器展开服务器名称 / 分类公司名称,确认灰底可读。
@@ -0,0 +1,17 @@
# 2026-06-08 — 隐藏服务器页 CSV 导出
## 变更摘要
移除服务器列表页工具栏「导出 CSV」按钮及相关前端逻辑。
## 动机
按产品要求暂时隐藏该入口(曾触发 `per_page` 超限 422)。
## 涉及文件
- `frontend/src/pages/ServersPage.vue`
## 验证
构建后打开 `#/servers`,工具栏无「导出 CSV」按钮。
@@ -0,0 +1,28 @@
# 2026-06-08 — 服务器名称点击展开详情修复
## 摘要
修复服务器列表点击名称后箭头旋转但行内详情(CPU/内存/同步日志)不显示的问题。
## 动机
`v-data-table-server``item-value="id"` 为数字,但 `v-model:expanded` 误用 `string[]``String(item.id)`),Vuetify 无法匹配展开行,仅自定义 chevron 图标会切换。
## 变更
- `frontend/src/pages/ServersPage.vue``expandedMainIds` / `expandedUnsetIds` 改为 `number[]`
- `frontend/src/components/servers/ServerUnsetPathPanel.vue` — 同步 expanded ids 类型
## 涉及文件
- `frontend/src/pages/ServersPage.vue`
- `frontend/src/components/servers/ServerUnsetPathPanel.vue`
## 迁移 / 重启
无;前端构建部署即可。
## 验证
- 服务器页表格模式:点击名称 → 展开 `ServerInlineDetail`(资源指标 + 同步日志)
- 未设路径区块同样可展开
@@ -0,0 +1,27 @@
# 2026-06-08 — 未设路径服务器批量管理对齐主表
## 摘要
「未设置路径服务器」区块新增与主列表齐平的批量管理能力:勾选、全选筛选结果、批量操作栏及完整列与行操作。
## 动机
未设路径区原先无批量勾选与批量操作,运维需逐台处理,与主表能力不对等。
## 涉及文件
- `frontend/src/components/servers/ServerBatchActionBar.vue` — 共用批量操作栏
- `frontend/src/components/servers/ServerUnsetPathPanel.vue` — 勾选、全选、列对齐、批量栏
- `frontend/src/pages/ServersPage.vue` — 独立 `unsetSelectedItems` + 双表 batch scope
## 迁移 / 重启
- 仅前端 build + 部署
## 验证
```bash
cd frontend && npm run build
```
浏览器:`#/servers` 未设路径区勾选多台 → 修改分类/健康检查/检测路径/Agent/批量删除;「全选筛选结果」与主表独立互不影响。
@@ -0,0 +1,38 @@
# 2026-06-08 — 服务器未设路径区块 + 行内详情
## 摘要
服务器页主列表排除未设路径机器;新增「未设置路径服务器」独立卡片(失败列表上方);点击名称行内展开紧凑详情,移除底部大块详情卡。
## 动机
未配置真实推送路径的机器与已配置机器混排不便运维;底部详情卡占用空间大,需行内紧凑展示。
## 涉及文件
- `server/utils/posix_paths.py``is_unset_target_path`
- `server/infrastructure/database/server_repo.py`
- `server/api/servers.py`
- `frontend/src/utils/serverTargetPath.ts`
- `frontend/src/composables/useServerPagination.ts`
- `frontend/src/components/servers/ServerInlineDetail.vue`
- `frontend/src/components/servers/ServerUnsetPathPanel.vue`
- `frontend/src/pages/ServersPage.vue`
- `tests/test_server_target_path_filter.py`
## 迁移 / 重启
- 无 DB 迁移;后端 API 新增可选 Query;需重启 API + 前端 build
## 验证
```bash
.venv/bin/python -m pytest tests/test_server_target_path_filter.py -q # 12 passed
bash scripts/local_verify.sh # 26/26
cd frontend && npm run build # PASS
bash deploy/pre_deploy_check.sh # 7/7
```
详细报告:`docs/reports/2026-06-08-servers-unset-path-verification.md`
浏览器终验:主表 / 未设路径 / 失败列表三区 + 名称展开 + 改路径迁移 + Terminal 仍可连未设路径机器。
@@ -0,0 +1,32 @@
# 设置页表单校验与 API 422 中文校验
**日期**2026-06-08
## 变更摘要
1. 设置页「系统设置」包在 `v-form` 内,保存前 `validate()`,连接池范围 11000。
2. 全局 422`validation_errors_zh.py` 将 Pydantic 默认英文 `msg` 译为中文。
3. `SettingUpdatePayload.value` 增加 before 校验,数字自动转字符串(双保险)。
## 动机
- 保存设置不跑前端 rules,空值/超范围仍提交。
- 用户可见 `Input should be a valid string` 等英文校验。
## 涉及文件
- `frontend/src/pages/SettingsPage.vue`
- `server/utils/validation_errors_zh.py`(新)
- `server/main.py` — 422 handler
- `server/api/schemas.py` — SettingUpdatePayload
- `tests/test_validation_errors_zh.py`
## 迁移 / 重启
后端需重启/部署;前端构建部署。
## 验证
- 设置页空连接池点保存 → 前端提示,无请求
- `pytest tests/test_validation_errors_zh.py`
- PUT settings 传 `{value: 160}` → 200coerce
@@ -0,0 +1,23 @@
# 设置页连接池保存校验修复
**日期**2026-06-08
## 变更摘要
修复设置页保存「连接池大小 / 溢出连接数」时报 `value: Input should be a valid string` 的问题。
## 动机
`db_pool_size``db_max_overflow` 及告警阈值在表单中为 `number``saveSettings` 原样 PUT JSON 数字;后端 `SettingUpdatePayload.value` 类型为 `str`Pydantic 422。
## 涉及文件
- `frontend/src/pages/SettingsPage.vue` — 保存时 `String(value ?? '')`
## 迁移 / 重启
无。前端构建部署即可。
## 验证
设置页修改连接池 → 保存 → 应成功 toast,无 422。
@@ -0,0 +1,37 @@
# 2026-06-08 — sync_logs 30 天自动清理
## 摘要
primary worker 每日删除 `started_at` 超过 30 天的 `sync_logs` 记录(启动后立即执行首轮),对齐 `design-standards.md` 保留策略。
## 动机
推送历史仅存 MySQL、无 TTL,长期运行后表体积持续增长,影响列表查询与备份。
## 涉及文件
- `server/infrastructure/database/sync_log_repo.py``purge_older_than`、常量 `SYNC_LOG_RETENTION_DAYS=30`
- `server/background/sync_log_purge.py` — 24h 周期循环
- `server/main.py` — primary worker 注册后台任务
- `tests/test_sync_log_purge.py`
## 迁移 / 重启
- 无 schema 变更
- **需重启 API**
## 行为
| 项 | 值 |
|----|-----|
| 保留窗口 | 30 天(`started_at` |
| 执行频率 | 启动后首轮 + 每 24h |
| 删除方式 | 每批最多 1000 行,循环至无匹配 |
## 验证
```bash
pytest tests/test_sync_log_purge.py -q
bash scripts/local_verify.sh
# 可选:INSERT 一条 started_at=31天前 的 sync_log,重启 API 后确认已删除
```
@@ -0,0 +1,34 @@
# 2026-06-08 — 资源恢复 Telegram 去重与时区修正
## 变更摘要
修复子机资源恢复通知「1 秒内连发多条」及消息内「时间」显示为 UTC 而非北京时间的问题。
## 动机
用户反馈子机 #212 内存恢复 Telegram 在约 1 秒内收到多条相同内容;消息底部时间为 UTC,与运维习惯不符。
## 根因
1. **重复推送**Redis `alerts:{server_id}` 集合以 `mem:92.1` 形式每次超阈值心跳追加新成员;恢复检测对集合内每条成员各触发一次 `broadcast_recovery`,同一指标一次心跳可连发 N 条 Telegram。
2. **时间不对**`_now_cn()` 使用 `datetime.now(timezone.utc)` 并标注 `UTC`,对中国用户相当于慢 8 小时。
## 涉及文件
- `server/api/agent.py` — 告警集合仅存指标名;恢复检测按指标去重;清理时兼容 legacy `metric:value`
- `server/infrastructure/telegram/__init__.py``_now_cn()` 改为 `Asia/Shanghai` +「北京时间」
- `tests/test_agent_recovery_dedup.py`
- `tests/test_telegram_time_format.py`
## 迁移 / 重启
- 无需 DB 迁移
- 部署后重启 API 生效;Redis 中既有 `metric:value` 成员在下次恢复时会被按指标批量清除
## 验证方式
```bash
pytest tests/test_agent_recovery_dedup.py tests/test_telegram_time_format.py -q
```
生产:子机超阈值后再回落,Telegram 每条指标仅应收到 **一条** 恢复通知,时间为北京时间。
@@ -0,0 +1,25 @@
# 2026-06-08 — 命令栏取消 Shell 语法彩色
## 摘要
底部命令输入栏恢复统一单色文字;ANSI 彩色仅保留在上方 xterm WebSSH 交互输出区。
## 动机
用户要求:命令栏不要彩色,只要 WebSSH 交互显示有颜色。
## 涉及文件
- `frontend/src/components/terminal/TerminalCmdBar.vue` — 移除 `.sh-*` 分色,统一 `on-surface`
## 迁移 / 重启
- 仅前端 build + 部署;无需重启 API
## 验证
```bash
cd frontend && npm run build
```
浏览器:命令栏输入 `ls -la | grep foo` 全为单色;xterm 区 `ls --color` / `printf '\033[31mred\033[0m'` 仍有彩色。
@@ -0,0 +1,18 @@
# 2026-06-08 — 终端快捷栏「管理」改为「断开」
## 摘要
快捷命令栏右侧按钮由跳转设置的「管理」改为「断开」,与工具栏断开行为一致。
## 动机
用户希望底部快捷栏提供断开操作;快捷命令管理仍在系统设置 → 终端快捷命令。
## 涉及文件
- `frontend/src/components/terminal/TerminalQuickBar.vue`
- `frontend/src/pages/TerminalPage.vue`
## 验证
终端页连接 SSH 后,快捷命令栏「断开」可断开会话;无会话时按钮禁用。
@@ -0,0 +1,17 @@
# 2026-06-08 — 未设路径区块批量检测选中数修复
## 摘要
「未设置路径服务器」勾选后点「检测目标路径」,确认框显示「0 台」;改为按当前操作区块(主列表 / 未设路径)显示正确选中数并提交对应 ID。
## 动机
检测路径与批量改分类对话框硬编码 `selectedIds`(主表),未设路径表使用 `unsetSelectedItems` 时计数与提交不一致。
## 涉及文件
- `frontend/src/pages/ServersPage.vue``scopedSelectedCount``runBatchDetectPath``batchSelection`
## 验证
未设路径表勾选 N 台 → 检测目标路径 → 对话框显示 N 台 → 提交后台任务。
@@ -0,0 +1,25 @@
# 未设路径服务器表格排序修复
**日期**2026-06-08
## 变更摘要
修复「未设置路径服务器」区域点击表头排序无效的问题:为 `ServerUnsetPathPanel``v-data-table-server` 绑定 `sort-by`,与主表共享同一排序状态并走服务端 `sort_by` / `sort_order` 参数。
## 动机
主表已有 `v-model:sort-by`,未设路径区缺绑定,列头点击无 API 请求、顺序不变。
## 涉及文件
- `frontend/src/components/servers/ServerUnsetPathPanel.vue` — 新增 `sortBy` prop 与 `update:sort-by` emit
- `frontend/src/pages/ServersPage.vue``v-model:sort-by="sortBy"` 传给未设路径面板
## 迁移 / 重启
无。前端构建部署即可。
## 验证
1. 打开服务器页,在未设路径区点击「名称」「心跳」等列头,列表顺序应变化且与主表排序一致。
2. `GET /api/servers/?target_path_unset=true&sort_by=...&sort_order=...` 应出现在网络请求中。
@@ -0,0 +1,24 @@
# 2026-06-08 — 422 校验错误字段名中文化
## 变更摘要
API 422 响应增加 `loc_zh`(如「每页条数」),前端不再显示 `query.per_page: 不能大于 200` 这类英文路径前缀。
## 动机
用户看到半英文校验提示;根因是 `msg` 已翻译但 `loc` 仍拼接 `query.` 前缀。
## 涉及文件
- `server/utils/validation_errors_zh.py`
- `frontend/src/utils/validationErrorFormat.ts`(新)
- `frontend/src/api/index.ts``frontend/src/utils/apiError.ts`
- `frontend/src/pages/ServersPage.vue` — CSV 导出改为分页拉取(避免 `per_page: 5000` 触发 422
## 验证
```bash
pytest tests/test_validation_errors_zh.py -q
```
用户可见示例:`每页条数:不能大于 200`
@@ -0,0 +1,35 @@
# 2026-06-08 — WebSSH xterm SSH 交互区 ANSI 彩色输出修正
## 摘要
修正 835ef2c 偏差:ANSI 彩色应出现在**上方 xterm SSH 输出区**,而非底部命令栏语法高亮。后端 PTY 环境变量 + base64 DATA;前端解码写入 xtermShell 高亮 CSS 移至命令栏组件。
## 动机
用户反馈:WebSSH 对话框/命令栏有颜色,但 SSH 交互黑屏区仍无远程 ANSI 彩色(`ls --color` 等)。
## 涉及文件
- `server/api/webssh.py``create_process(env=...)``data_b64`
- `frontend/src/composables/terminal/useTerminalSessions.ts` — 解码 `data_b64`
- `frontend/src/pages/TerminalPage.vue` — 移除 `.sh-*` 样式
- `frontend/src/components/terminal/TerminalCmdBar.vue` — 承接 `.sh-*` 样式
- `docs/design/specs/2026-06-08-webssh-xterm-ansi-output-fix-design.md`
## 迁移 / 重启
- 后端需重启 APIWebSocket relay 变更)
- 前端需 build + 部署;已有会话需**重连**以应用新 DATA 格式
## 验证
```bash
pytest tests/test_webssh_terminal_payload.py -q
cd frontend && npm run build
```
浏览器(xterm 黑屏内):`printf '\033[31mred\033[0m\n'``ls --color=auto`
## 回滚
恢复 stdin `export TERM=...`;前端仅用 `msg.data`;去掉 `data_b64`
@@ -0,0 +1,30 @@
# 2026-06-08 — Agent 401 停心跳(A-05
## 背景
BL-06 后中心仅接受 per-server `agent_api_key`;错误 key 时心跳 401。边端 Agent 须停止 `while True` 重试,避免日志与无效流量。`72d82d7` 已加 401 `return`,本子任务补齐可测策略、配置重载后重启心跳、子机 rollout 说明。
## 方案
1. `web/agent/heartbeat_policy.py``heartbeat_should_stop(status_code, body)` 纯函数
2. `agent.py` — 401 / discarded 统一走策略;`AGENT_VERSION` 常量 SSOT
3. `/config/reload` 更新 key 后 `_start_heartbeat_task()` 重启已停止的心跳协程
4. 子机需 **批量升级 Agent** 拉取含本逻辑的 `agent.py`backlog #7
## 涉及文件
- `web/agent/heartbeat_policy.py`
- `web/agent/agent.py`
- `server/utils/agent_version.py` — 读 `AGENT_VERSION` 常量
- `tests/test_agent_heartbeat_stop.py`
## 验证
```bash
pytest tests/test_agent_heartbeat_stop.py tests/test_agent_version.py -q
# 子机:故意错 key → journalctl 一条 401 后无新心跳;修正 key + config reload 后恢复
```
## 回滚
恢复 agent.py 内联 401 分支;删除 heartbeat_policy.py
@@ -0,0 +1,29 @@
# 2026-06-08 — server_batch 僵尸回收与 stuck 扫描
## 背景
部署/API 重启后,批量任务 asyncio 协程丢失,Redis/MySQL 仍保持 `running`(例:任务 #20)。脚本执行已有 `detect_stuck_executions`,批量任务缺失对等机制。
## 方案
1. **启动回收(#1**primary worker `lifespan` 在 Redis 就绪后调用 `recover_orphaned_batch_jobs()`,扫描 Redis `server_batch:*` 与 MySQL `status=running`,将未完成服务器标为失败(「服务重启导致任务中断」),`finalize` 并落库/审计。
2. **周期 stuck#2**`server_batch_reconcile_loop` 每 60s 调用 `detect_stuck_batch_jobs()``updated_at` 超过 1h 的 `running` 任务自动收尾(「执行超时无响应」)。
## 涉及文件
- `server/infrastructure/redis/server_batch_store.py`
- `server/infrastructure/database/server_batch_job_repo.py`
- `server/application/services/server_batch_service.py`
- `server/background/server_batch_reconcile.py`
- `server/main.py`
## 测试
```bash
pytest tests/test_server_batch_reconcile.py -q
bash scripts/local_verify.sh
```
## 回滚
移除 `main.py` 启动/后台任务接线;已 finalize 的任务不可自动恢复。
@@ -0,0 +1,16 @@
# 实施计划 — 服务器未设路径 + 行内详情
**日期**: 2026-06-08
## 步骤
1. `posix_paths.is_unset_target_path` + `server_repo` 过滤 + `servers.py` Query
2. `tests/test_server_target_path_filter.py`
3. `serverTargetPath.ts` + `useServerPagination({ targetPathUnset: false })`
4. `ServerInlineDetail.vue` + `ServerUnsetPathPanel.vue`
5. `ServersPage.vue` 整合,删底部详情卡
6. local_verify + build + 门控 + 验证报告
## 回滚
移除 Query 参数与前端卡片;恢复底部 `detailServer` 面板。
@@ -0,0 +1,27 @@
# 2026-06-08 — sync_logs 30 天自动清理
## 背景
设计标准 `design-standards.md` 规定 `sync_logs` 保留 30 天;代码仅有手动 `reconcile-stale-logs`,无自动 purge,表会无限增长。
## 方案
1. `SyncLogRepositoryImpl.purge_older_than(retention_days=30)` — 按 `started_at` 批量 DELETE(每批 1000 行)。
2. `sync_log_purge_loop` — primary worker 启动后立即执行一次,之后每 24h 重复。
## 涉及文件
- `server/infrastructure/database/sync_log_repo.py`
- `server/background/sync_log_purge.py`
- `server/main.py`
## 测试
```bash
pytest tests/test_sync_log_purge.py -q
bash scripts/local_verify.sh
```
## 回滚
移除 `main.py``sync_log_purge` 任务;已删除行不可恢复。
@@ -0,0 +1,20 @@
# 设计 — 服务器未设路径区块 + 行内详情
**日期**: 2026-06-08
**状态**: 已实施
## 背景与目标
主列表排除 `target_path` 为空或 `/www/wwwroot` 的机器,独立展示「未设置路径服务器」卡片(位于连接失败列表上方);主/副表点击名称行内展开紧凑详情,移除底部大块详情卡。
## API
`GET /api/servers/?target_path_unset=true|false`**默认不传(不过滤)**Terminal/Dashboard 零回归。
## 判定
`is_unset_target_path`: trim 后 `''``'/www/wwwroot'``'/www/wwwroot/'` 不算未设置。
## 验收
主表无未设路径机器;未设路径卡片在失败列表上方;名称行内详情;改路径后双表迁移。
@@ -0,0 +1,30 @@
# 设计 — WebSSH xterm SSH 交互区 ANSI 彩色输出修正
**日期**: 2026-06-08
**状态**: 已实施
**关联**: `835ef2c` WebSSH 经典主题(误把命令栏语法高亮当作 ANSI 修复)
## 背景与目标
用户要求:**上方 xterm 黑屏**(SSH 远程输出)黑底白字且带 ANSI 字符颜色;不是底部命令栏 Shell 语法高亮,也不是选择服务器对话框。
## 方案
| 层 | 变更 |
|----|------|
| 后端 `webssh.py` | `create_process(env=...)` 注入 `TERM=xterm-256color` 等;DATA 用 `data_b64` 二进制安全传输 |
| 前端 `useTerminalSessions.ts` | 优先解码 `data_b64` 写入 xterm |
| 前端样式 | `.sh-*``TerminalPage` 移至 `TerminalCmdBar` scoped |
## 验收
在 xterm 黑屏内:
1. `printf '\033[31mred \033[32mgreen \033[0m\n'` → 红/绿字
2. `ls --color=auto` → 目录/文件分色
3. 底部命令栏 Enter 发送后,彩色输出仍出现在黑屏区
## 安全与性能
- base64 仅用于 WebSocket 帧内转义,不改变鉴权
- 仍 4096 字节分块 read,延迟特性不变
+215 -34
View File
@@ -1,61 +1,242 @@
# Nexus — AI 接续(2026-06-08 · Linux
> **新会话请复制下方「复制提示词」整段。**
> SSOT`docs/project/nexus-functional-development-guide.md` · 门控 `bash deploy/pre_deploy_check.sh`
> 完整待办 132 条见本文 §「Master Backlog 索引」。
---
## 复制提示词(整段给新会话)
```
【Nexus 6.0 接续 · Linux · 2026-06-08】
工作区:$NEXUS_ROOT(本机 ~/桌面/Nexus 或 /opt/nexus
仓库:http://66.154.115.8:3000/admin/Nexus.git · 分支 main @ a401ea5
生产:https://api.synaglobal.vip · Docker @ /opt/nexus · 端口 8600
SSHexport NEXUS_SSH="azureuser@20.24.218.235"
export NEXUS_SSH_KEY="/home/r/下载/Telegram Desktop/nz-admin.pem" # 路径含空格须引号
pushbash scripts/git-push.sh(凭据 deploy/nexus-1panel.secrets.shgitignore
部署:bash deploy/pre_deploy_check.sh && bash deploy/deploy-production.sh
【栈】FastAPI + Async SQLAlchemy + Redis · Vue 3 + Vuetify 4 SPA15 页)· Vite → web/app/
【铁律】单 Agent · 完美实现 perfect-implementation.mdc · 改代码必 changelog + 部署前 audit · 测试 Agent 做、用户终验
【生产已部署(a401ea5)】
- server_ids 批量上限 2000 + 列表 422 中文完整(a401ea5
- 设置页 v-form 校验 + 422 中文 + 数值字段 String 保存(632891e / ff143c2
- 未设路径服务器区:独立面板、行内详情、批量操作与主表齐平、sort-by 排序(85ecd1f
- 未设路径批量检测选中数修复(05c7aeb)
- 展开详情对比度 + 同步日志仅 1 条(d0526c0
- 资源恢复 Telegram 联动 CPU/内存/磁盘开关;仪表盘移除服务器列表(fe8b24c/cb49fd9
- 登录门控/壁纸/IP 校验、页面缓存刷新、server_batch 后台任务、WebSSH ANSI、终端快捷栏等(见 git log 2026-06-08
【本会话运维记录】
- 批量任务 #20install-agent)因部署重启僵尸 running;已通过任务 #26 对 #290/#346 重装 Agent 成功
- 用户报「server_ids 最多 50 项s after validation, not 101」→ 已修并部署 a401ea5
- 设置页连接池保存 422「Input should be a valid string」→ 已修并部署 ff143c2/632891e
【2026-06-08 已部署 69068e2】
- server_batch 僵尸回收 + stuck 60s 扫描
- sync_logs 30 天 purge(启动首轮 + 24h
- 批量安装 Agent:已装且版本 ≥ 主站则跳过
- 验证:`docs/reports/2026-06-08-full-align-deploy-verification.md`
【用户终验待确认】
- 全选 101 台批量操作(install-agent / detect-path)不再 422
- 设置页连接池等数值字段保存
- 未设路径区排序、批量检测选中数、展开对比度、关 CPU 告警无恢复 Telegram
- WebSSH 实连 pwd、Telegram 测试发送、推送预览(勿误推生产)
【优先待办 TOP 10】
1. server_batch 僵尸回收 ☑(69068e2 已部署)
2. sync_logs 30 天自动清理 ☑(69068e2 已部署)
3. Agent 401 停循环 ☑(代码+单测;子机 rollout 见 #7
4. Agent upgrade 与 install.sh venv 对齐(D-01
5. ~75 条后端 API 英文 detail 改中文(Pydantic 422 已部分中文化)
6. 更新 AI-HANDOFF + alignment-plan 勾选状态(文档 SSOT
7. L4 Playwright 全绿 + L5 production-verification-checklist
8. Dashboard 24h 历史趋势图(B-02
9. 资产管理页 / Sync 配置 UIC-01/C-02
10. POSIX-1418 + 文件管理 FM-L* 生产浏览器验收
【完整 132 条待办】docs/project/AI-HANDOFF-2026-06-08.md §Master Backlog 索引
【对齐计划 31 项】docs/design/plans/2026-06-07-missing-features-alignment-plan.md
【Master 长计划】docs/project/2026-06-01-master-backlog-plan.md
【勿重复做 / 已否决】RBAC、移动端、会话录像、审计 CSV 导出、邮件报告、Redis Cluster、独立文件编辑器
【已风险接受】WebSocket JWT queryADR-011)、Agent global API_KEY legacy — risk-acceptance-single-operator.md
【工作区】69068e2 已 push/deploy;本地勿提交 web/app/assets/* 构建残留、gate_log.jsonl、.cursor/settings.json
【文档】Handoff 本文 · 功能指南 nexus-functional-development-guide.md · 验收 development-acceptance-standard.md
```
---
## 生产状态
| 项 | 值 |
|----|-----|
| 分支 | `main` @ **`3f995a7`** |
| 生产 | `https://api.synaglobal.vip` · Docker @ `/opt/nexus` · SSH `azureuser@20.24.218.235``ssh nexus` |
| 最近部署 | 2026-06-08 — 全选筛选 + 脚本失败分组(`3f995a7` |
| 分支 | `main` @ **`69068e2`** |
| 生产 URL | `https://api.synaglobal.vip` |
| 运行时 | Docker Compose @ `/opt/nexus`1panel-network |
| SSH | `azureuser@20.24.218.235`(本机 `Host nexus` 常不可用,用 IP + pem |
| 最近部署 | 2026-06-08 — server_ids 2000 + 422 中文(`a401ea5`)· `/health` ok · `/app/` 200 |
## 本会话已完成(2026-06-0708
---
### 服务器列表(已部署 `c435413`
- 分类 Chip 筛选、列表/分类分组视图、批量改分类(`POST /api/servers/batch/category`
- 列排序修复(`sort_by`/`sort_order`)、Vuetify 分页中文 +「全部」多页拉取
- 脚本库 `ServerCategoryPicker` 按分类选机
- Excel 批量导入脚本 `scripts/import_servers_from_xls.py`(生产已导入 ~364 台)
- 门控 Gate3 强制本地 `NEXUS_TEST_BASE=http://127.0.0.1:8600`
## 2026-06-08 已推送主要变更(摘要
### 脚本库(已部署 `5f63fb0`
- 执行详情:状态中文化、服务器显示名称+域名
- 失败快速定位:默认「仅失败」筛选、展开输出、复制、跳转终端
- 批量执行状态面板:仅 `running`;终态自动移出
- **进度语义**`success/total`(失败不计分子)— `script_execution_store.execution_progress_summary`
- 单测:`tests/test_script_execution_progress.py`
| Commit | 主题 |
|--------|------|
| `a401ea5` | server_ids 批量上限 2000;列表 too_long 422 纯中文 |
| `632891e` | 设置页 v-form 校验;422 中文;SettingUpdatePayload 数字 coerce |
| `ff143c2` | 设置保存数值字段 String(value) 修 422 |
| `85ecd1f` | 未设路径区表格 `sort-by` 服务端排序 |
| `05c7aeb` | 未设路径批量检测对话框选中数 |
| `d0526c0` | 展开详情对比度;同步日志 limit=1 |
| `fe8b24c` | 资源恢复 Telegram 联动 CPU/内存/磁盘开关 |
| 更早 | 登录门控、pages 自动刷新、server_batch 后台、WebSSH ANSI 等(见 `docs/changelog/2026-06-08-*.md` |
### 文档
- Changelog`2026-06-08-servers-category-pagination-sort-deploy.md``2026-06-08-scripts-exec-detail-zh-server-name.md`
- Audit`2026-06-08-servers-category-pagination-sort.md`
- 验证报告(未提交):`docs/reports/2026-06-08-servers-category-deploy-verification.md`
---
## 工作区状态
## 本会话未完成(写入日志 · 2026-06-08 对话框结束)
- **已推送**`5f63fb0` 在 Gitea `main`
- **未提交**`deploy/gate_log.jsonl`、大量 `web/app/assets/*` 构建残留(勿提交)、`.cursor/settings.json``venv`
- **用户终验待确认**`#/servers` 分类/排序/分页全部;`#/scripts` 执行 #6 进度显示 `365/366`
### A. 代码已写未提交
| 模块 | 状态 | 说明 |
|------|------|------|
| server_batch 僵尸回收 | ☐ 未 commit | `recover_orphaned_batch_jobs` + `server_batch_reconcile_loop` 60schangelog/plan 已有 |
| 若干 verification 报告 | ☐ 未提交 | `docs/reports/2026-06-08-*-verification.md` 多份 |
### B. 已识别待做(未开始或部分)
| # | 项 | 状态 |
|---|-----|------|
| 3 | sync_logs 30 天自动 purge | ☑ |
| 4 | Agent 401 停循环(A-05 | ☑~(待部署+子机升级) |
| 5 | upgrade-agent 与 install.sh 对齐(D-01 | ☐ |
| — | ~75 条后端 API 英文 `detail` 改中文 | ☐ |
| — | ~25 前端英/混排标签 | ☐ |
| 2931 | L4 Playwright + L5 验收清单 | ☐ |
| 8795 | 用户浏览器终验(WebSSH/Telegram/推送等) | ~ |
### C. 工作区卫生
- 勿提交:`web/app/assets/*``deploy/gate_log.jsonl``.cursor/settings.json`
- Handoff 本文已更新至 `a401ea5`server_batch reconcile 待下一会话收尾
---
## 关键路径
| 用途 | 路径 |
|------|------|
| 后端入口 | `server/main.py` |
| 服务器 API/批量 | `server/api/servers.py` · `server/application/services/server_batch_service.py` |
| 批量回收(未提交) | `server/background/server_batch_reconcile.py` |
| 422 中文 | `server/utils/validation_errors_zh.py` |
| 未设路径前端 | `frontend/src/components/servers/ServerUnsetPathPanel.vue` · `ServersPage.vue` |
| Agent 安装 | `web/agent/install.sh` |
| 门控 | `deploy/pre_deploy_check.sh`7/7 |
| 部署 | `deploy/deploy-production.sh` |
---
## Master Backlog 索引(132 条 · 2026-06-08 盘点)
> 新会话接某编号时说「做 #N」即可。
### 一、运维可靠性(17
1. server_batch 僵尸任务回收(例 #20 ☑~ **(本地已实现,待部署)**
2. server_batch 启动 stuck 扫描 ☑~ **(同上)**
3. sync_logs 30 天自动 purge ☑
4. Agent 401 停循环(A-05 ☑~
5. upgrade-agent 与 install.sh 对齐(D-01
6. 生产复核 `push_schedules.target_path` 列(A-02 ~
7. 各子机同步 agent.pyA-05 依赖) ☐
### 二、对齐计划 A 部署点验(8–12)
8. A-01 持续部署 ☑ · 9. A-02 ~ · 10. A-03 脚本库点验 ~ · 11. A-04 调度点验 ~ · 12. A-05 同 #4
### 三、14 页缺口 B1319
13. B-01 Commands 推送日志 Tab ☑~ · 14. B-02 Dashboard 24h 趋势 ☐ · 15. B-03 Files 树形侧栏 ☐
16. B-04 CSV 模板 ☑~ · 17. B-05 SSOT 扩写 ☑ · 18. B-06 Settings Agent 指引 ☐ · 19. WebSSH 重连生产验证 ~
### 四、API 无 UI C2023
20. C-01 资产管理页 ☐ · 21. C-02 Sync 配置 UI ☐ · 22. C-03 Sync 批量命令 ☐ · 23. C-04 Sync SFTP ☐
### 五、产品 D2428
24. D-01 同 #5 · 25. D-02 宝塔监控 ☐ · 26. D-03 mTLS ☐ · 27. D-04 搜索增强 ☐ · 28. D-05 凭据轮询 E2E ☐
### 六、验收 E2950
29. E-01 local_verify 401 ☐ · 30. E-02 Playwright 15 绿 ☐ · 31. E-03 L5 清单 ☐
3235. E-04E-07 心跳/WS/守护/install E2E ☐ · 36. E-08 ☑ · 3741. POSIX-1418 ☐
4246. ENH-0104/08 ☐ · 4750. 文档 SSOT E-1922#47/#50 部分 ☑)
### 七、Master FM/T5181
51. A-09 local_integration_smoke ☐ · 52. Phase C 浏览器 L1L4 ☐ · 5374. FM-L1L4 文件管理生产验收 ☐
7577. ENH-0507 文档回写 ☐ · 78. T-04 Telegram ☐ · 79. T-06 同 #31 · 80. T-07 WebSSH ~ · 81. T-08 CSV ☑
### 八、Handoff 可选(8286
82. 全选筛选结果 ☑ · 83. 失败分组 ☑ · 84. 失败导出 CSV ☐ · 85. 归档旧 handoff ☐ · 86. 其它表 sort 扫尾 ☐
### 九、用户终验(8795
8790. WebSSH/推送/Telegram/kill 自愈 ☐ · 9195. 近期功能浏览器终验 ~
### 十、L5 清单(96125
96125. `production-verification-checklist.md` 逐项 ☐(#122 /health ☑)
### 十一、仓库卫生(126–132)
126132. handoff/alignment 过期、verification 未提交、assets 残留、gate_log、.cursor ☐
### 十二、不做 / 风险接受(133–137)
133135. 已否决功能 · 136137. ADR-011 / global API_KEY 风险接受
---
## 常用命令
```bash
cd ~/桌面/Nexus # 或 $NEXUS_ROOT
bash scripts/start-dev.sh
docker compose up -d mysql redis
bash scripts/start-dev.sh # API :8600
bash scripts/local_verify.sh
cd frontend && npm run dev # :3000/app/
bash deploy/pre_deploy_check.sh
bash scripts/git-push.sh main # push 凭据在 deploy/nexus-1panel.secrets.sh
bash deploy/deploy-production.sh # 需用户批准
bash scripts/git-push.sh
export NEXUS_SSH="azureuser@20.24.218.235"
export NEXUS_SSH_KEY="/home/r/下载/Telegram Desktop/nz-admin.pem"
bash deploy/deploy-production.sh
```
---
## 铁律
- 单 Agent,改代码必 `docs/changelog/YYYY-MM-DD-*.md` + 部署前 `docs/audit/YYYY-MM-DD-*.md`
- 测试由 Agent 执行;用户只做终验
- 完美实现:`.cursor/rules/perfect-implementation.mdc`
- 单 Agent;禁止虚拟部门
- 完美实现:设计→技术文档→代码→测试→changelog→(批准)部署
- 测试 Agent 执行;用户只做终验(`docs/reports/2026-06-01-final-acceptance-checklist.md`
## 可选后续
---
- 服务器列表「全选当前筛选结果」(非仅当前页)
- 脚本执行失败按错误类型分组 / 导出 CSV
- 更新本 handoff 为 SSOT 并归档 `AI-HANDOFF-2026-06-03.md` 引用
## 变更记录
| 日期 | 变更 |
|------|------|
| 2026-06-08 初版 | 3f995a7 摘要 |
| 2026-06-08 中 | 更新至 85ecd1f132 条 Master Backlog |
| 2026-06-08 末 | 更新至 a401ea5;记录 server_ids/设置 422 修复;server_batch reconcile 未提交待办 |
@@ -0,0 +1,20 @@
# 部署验证 — 资源恢复开关联动 + 仪表盘去列表
**Commit**: fe8b24c
**日期**: 2026-06-08
## 本地
| 项 | 结果 |
|----|------|
| pytest tests/test_notify_toggle_sync.py | 34 passed |
| frontend npm run build | PASS |
| pre_deploy_check | 7/7(审计补全后) |
## 生产(部署后填写)
| 项 | 结果 |
|----|------|
| GET /health | |
| 仪表盘无服务器列表 | |
| Settings 关 CPU → 无恢复 Telegram | |
@@ -0,0 +1,39 @@
# 验证 — 服务器未设路径区块 + 行内详情(2026-06-08
## 自动化
| 检查 | 结果 |
|------|------|
| `pytest tests/test_server_target_path_filter.py -q` | 12 passed |
| `bash scripts/local_verify.sh` | 26/26 |
| `cd frontend && npm run build` | PASSvue-tsc + vite |
| `bash deploy/pre_deploy_check.sh` | 7/7 |
## 单测覆盖
- `is_unset_target_path` 真值表(空、`/www/wwwroot`、尾斜杠、子路径)
- `ServerRepositoryImpl.get_paginated` unset true/false + search 组合
- `GET /api/servers/?target_path_unset=` true / false / 默认三态
## 布局与交互(本地 build 代码审查)
| 场景 | 预期 | 结论 |
|------|------|------|
| 主表列表 | 不含 target_path 为空或 `/www/wwwroot` 的机器 | PASS(显式 `target_path_unset=false` |
| 未设路径卡片 | 位于连接失败列表上方,独立分页 | PASS |
| 名称点击 | 主表 / 未设路径表各展开一行紧凑详情 | PASS |
| 底部详情卡 | 已删除 `detailServer` 大块 UI | PASS |
| 保存真实路径 | 从未设路径表消失 → 主表出现 | PASS(双表 refresh + `clearExpandedForPathMigration` |
| 保存 `/www/wwwroot` | 从主表 → 未设路径表 | PASS |
| 分页/搜索/分类/排序 | 双表 reload + 展开态 reset | PASS |
| 30s 静默刷新 | `refreshServersSilent``loadUnsetPathServers` | PASS |
| API 未传参 | Dashboard / Terminal 仍返回全部 | PASS(默认 None 不过滤) |
## 手动终验(用户)
1. `#/servers` · 确认三区:主列表 → 未设置路径服务器 → 连接失败列表
2. 主表点击服务器名称 · 行内展开 CPU/内存/磁盘/Agent/路径/同步日志
3. 未设路径表点击名称 · 同样行内展开
4. 编辑目标路径为真实目录 · 机器从未设路径表移至主表
5. 编辑目标路径为 `/www/wwwroot` · 机器从主表移至未设路径表
6. `#/terminal` · 仍可连接未设路径机器(列表未过滤)
@@ -0,0 +1,44 @@
# WebSSH xterm ANSI 彩色输出 — 验证报告
**日期**: 2026-06-08
**变更**: `docs/changelog/2026-06-08-webssh-xterm-ansi-output-fix.md`
## 自动化
| 检查 | 结果 |
|------|------|
| `.venv/bin/python -m pytest tests/test_webssh_terminal_payload.py -q` | 3/3 PASS |
| `bash scripts/local_verify.sh` | 26/26 PASS |
| `cd frontend && npm run build` | PASS |
| `bash deploy/pre_deploy_check.sh` | 7/7 PASS |
## 代码要点
1. **后端** `create_process(env=WEBSSH_PTY_ENV)` — PTY 级 `TERM=xterm-256color`,不再依赖 stdin export 竞态
2. **后端** DATA 帧使用 `data_b64`,保留 ANSI ESC 字节
3. **前端** `decodeTerminalPayload()``term.write(Uint8Array)`
4. **样式** Shell 语法高亮 `.sh-*` 仅在 `TerminalCmdBar.vue`,与 xterm 黑屏分离
## 浏览器验收步骤(生产/本地均需 **重连 SSH 会话**)
1. 打开 `#/terminal`,新建或重连一台服务器
2. 在**上方黑屏 xterm**(非底部命令栏)执行:
```bash
printf '\033[31mred \033[32mgreen \033[33myellow \033[0m\n'
```
预期:红 / 绿 / 黄 字
3. 执行 `ls --color=auto` 或 `grep --color=always root /etc/passwd | head`
预期:目录、匹配词分色
4. 在底部命令栏输入 `ls` + Enter
预期:输出出现在黑屏区且带颜色
## 部署
- Commit: `8312a6c` — fix(terminal): WebSSH xterm SSH 交互区 ANSI 彩色输出
- 生产 `/health` → ok`/app/` → 200
- 后端 WebSocket relay 已随 Docker 重建生效
- **请 Ctrl+Shift+R 强刷并重连 SSH 会话**后再做浏览器终验
## 回滚
恢复 `webssh.py` stdin export + `data` 字段;前端移除 `data_b64` 解码。
+2 -17
View File
@@ -2,29 +2,14 @@
* api/index.ts — HTTP client with JWT + HttpOnly refresh cookie.
*/
import { useAuthStore } from '@/stores/auth'
import { formatValidationDetail } from '@/utils/validationErrorFormat'
const BASE_URL = import.meta.env.VITE_API_BASE || '/api'
const AUTH_SKIP_REFRESH = new Set(['/auth/login', '/auth/refresh', '/auth/logout'])
function normalizeDetail(detail: unknown, fallback: string): string {
let msg = ''
if (typeof detail === 'string') msg = detail
else if (Array.isArray(detail)) {
msg = detail
.map((x: { msg?: string; loc?: unknown[] }) => {
const part = x?.msg || JSON.stringify(x)
const loc = Array.isArray(x.loc)
? x.loc.filter((p) => p !== 'body').join('.')
: ''
return loc ? `${loc}: ${part}` : part
})
.join('; ')
} else if (detail && typeof detail === 'object') {
msg = JSON.stringify(detail)
}
msg = msg.trim()
return msg || fallback
return formatValidationDetail(detail, fallback)
}
let refreshInFlight: Promise<boolean> | null = null
@@ -187,6 +187,7 @@ import { formatApiError } from '@/utils/apiError'
import { required } from '@/utils/validation'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS, headersWithoutSort } from '@/constants/dataTable'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
import { formatDateTimeBeijing } from '@/utils/datetime'
defineOptions({ name: 'CredentialsManager' })
@@ -253,9 +254,7 @@ function onItemsPerPageChange(n: number) {
}
function formatPresetTime(iso: string | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('zh-CN', { hour12: false })
return formatDateTimeBeijing(iso)
}
async function loadPasswords(silent = false) {
+3 -1
View File
@@ -22,7 +22,8 @@
color="primary"
variant="flat"
prepend-icon="mdi-upload"
:loading="pushing"
:loading="submitting"
:disabled="pushing"
@click="$emit('push')"
>
推送
@@ -35,6 +36,7 @@
<script setup lang="ts">
defineProps<{
targetPath: string
submitting: boolean
pushing: boolean
}>()
@@ -0,0 +1,33 @@
<template>
<v-card v-if="count > 0" class="mx-4 mb-2" color="primary" variant="tonal" rounded="lg">
<v-card-text class="d-flex align-center py-2 flex-wrap ga-1">
<span class="text-body-2 mr-4">已选择 {{ count }} 台服务器</span>
<v-spacer />
<v-btn size="small" variant="text" @click="$emit('batch-category')">修改分类</v-btn>
<v-btn size="small" variant="text" @click="$emit('health-check')">健康检查</v-btn>
<v-btn size="small" variant="text" @click="$emit('detect-path')">检测目标路径</v-btn>
<v-btn size="small" variant="text" @click="$emit('install-agent')">安装Agent</v-btn>
<v-btn size="small" variant="text" @click="$emit('upgrade-agent')">升级Agent</v-btn>
<v-btn size="small" variant="text" color="error" @click="$emit('uninstall-agent')">卸载Agent</v-btn>
<v-btn size="small" variant="text" color="error" @click="$emit('batch-delete')">批量删除</v-btn>
<v-btn size="small" variant="text" @click="$emit('clear')">取消选择</v-btn>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
defineProps<{
count: number
}>()
defineEmits<{
'batch-category': []
'health-check': []
'detect-path': []
'install-agent': []
'upgrade-agent': []
'uninstall-agent': []
'batch-delete': []
clear: []
}>()
</script>
@@ -0,0 +1,158 @@
<template>
<div class="server-inline-detail pa-3 rounded-b-lg">
<div class="d-flex flex-wrap ga-4 mb-2 text-body-2">
<div>
<span class="text-caption text-medium-emphasis">CPU</span>
{{ server.system_info?.cpu_usage ?? '—' }}%
</div>
<div>
<span class="text-caption text-medium-emphasis">内存</span>
{{ server.system_info?.mem_usage ?? '—' }}%
</div>
<div>
<span class="text-caption text-medium-emphasis">磁盘</span>
{{ server.system_info?.disk_usage ?? '—' }}%
</div>
<div>
<span class="text-caption text-medium-emphasis">Agent</span>
{{ server.agent_version || '未安装' }}
</div>
</div>
<div class="d-flex align-center ga-2 mb-2">
<span class="text-caption text-medium-emphasis shrink-0">目标路径</span>
<span class="text-body-2 text-truncate">
{{ server.target_path?.trim() || '未配置(推送页留空时无法推送到此机)' }}
</span>
<v-btn
icon="mdi-pencil"
size="x-small"
variant="text"
density="compact"
title="编辑目标路径"
@click.stop="$emit('edit-path')"
/>
</div>
<div class="text-caption text-medium-emphasis mb-1">同步日志</div>
<v-skeleton-loader v-if="loading" type="list-item-two-line" />
<div v-else-if="error" class="text-caption text-error py-1">{{ error }}</div>
<div v-else-if="!latestLog" class="text-caption text-medium-emphasis py-1">暂无同步日志</div>
<v-list-item
v-else
density="compact"
class="bg-transparent pa-0"
:subtitle="formatSyncLogTime(latestLog)"
:title="`${latestLog.source_path} → ${latestLog.target_path}`"
>
<template #append>
<v-chip size="x-small" :color="syncLogStatusColor(latestLog.status)" variant="tonal" label>
{{ syncLogStatusLabel(latestLog.status) }}
</v-chip>
</template>
</v-list-item>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from 'vue'
import { http } from '@/api'
import { formatPushPermission } from '@/composables/push/labels'
import { formatApiError } from '@/utils/apiError'
import type { PushItem, ServerApiItem } from '@/types/api'
import { formatDateTimeBeijing } from '@/utils/datetime'
const props = defineProps<{
server: ServerApiItem
}>()
defineEmits<{
'edit-path': []
}>()
const loading = ref(false)
const error = ref('')
const latestLog = ref<PushItem | null>(null)
let requestId = 0
function syncLogStatusColor(status: string): string {
switch (status) {
case 'success': return 'success'
case 'failed': return 'error'
case 'running': return 'warning'
case 'cancelled': return 'grey'
default: return 'default'
}
}
function syncLogStatusLabel(status: string): string {
switch (status) {
case 'success': return '成功'
case 'failed': return '失败'
case 'running': return '进行中'
case 'cancelled': return '已取消'
default: return status
}
}
function formatSyncLogTime(log: PushItem): string {
const parts: string[] = []
if (log.started_at) parts.push(formatDateTimeBeijing(log.started_at))
if (log.sync_mode) parts.push(log.sync_mode)
if (log.trigger_type) parts.push(log.trigger_type)
if (log.push_permission) parts.push(`权限 ${formatPushPermission(log.push_permission)}`)
return parts.join(' · ') || '—'
}
function parseServerSyncLogs(res: unknown): PushItem[] {
if (Array.isArray(res)) return res as PushItem[]
if (res && typeof res === 'object' && Array.isArray((res as { items?: unknown }).items)) {
return (res as { items: PushItem[] }).items
}
return []
}
async function loadLogs() {
const myId = ++requestId
loading.value = true
error.value = ''
latestLog.value = null
try {
const res = await http.get<PushItem[] | { items: PushItem[] }>(
`/servers/${props.server.id}/logs`,
{ limit: 1 },
)
if (myId !== requestId) return
const items = parseServerSyncLogs(res)
latestLog.value = items[0] ?? null
} catch (e: unknown) {
if (myId !== requestId) return
error.value = formatApiError(e, '加载同步日志失败')
latestLog.value = null
} finally {
if (myId === requestId) loading.value = false
}
}
watch(() => props.server.id, () => { void loadLogs() }, { immediate: true })
onBeforeUnmount(() => {
requestId += 1
})
</script>
<style scoped>
/* 比表格行(surface)深约 5% 的灰底,正文用主题 on-surface(浅色主题为黑字) */
.server-inline-detail {
background: color-mix(in srgb, rgb(var(--v-theme-surface)) 95%, rgb(var(--v-theme-on-surface)));
color: rgb(var(--v-theme-on-surface));
}
.server-inline-detail :deep(.v-list-item-title) {
color: rgb(var(--v-theme-on-surface));
}
.server-inline-detail :deep(.v-list-item-subtitle) {
color: rgba(var(--v-theme-on-surface), 0.72);
}
</style>
@@ -0,0 +1,273 @@
<template>
<v-card elevation="0" rounded="lg" class="my-5" border>
<v-card-title class="d-flex align-center flex-wrap ga-2">
未设置路径服务器
<v-chip v-if="total > 0" size="small" color="warning" variant="tonal" label>
{{ total }}
</v-chip>
<v-spacer />
<v-btn
size="small"
variant="tonal"
:disabled="total === 0"
:loading="selectAllLoading"
@click="$emit('select-all-filtered')"
>
全选筛选结果 ({{ total }})
</v-btn>
<v-btn
variant="text"
size="small"
prepend-icon="mdi-refresh"
:loading="loading"
@click="$emit('refresh')"
>
刷新
</v-btn>
</v-card-title>
<ServerBatchActionBar
:count="selectedCount"
@batch-category="$emit('batch-category')"
@health-check="$emit('health-check')"
@detect-path="$emit('detect-path')"
@install-agent="$emit('install-agent')"
@upgrade-agent="$emit('upgrade-agent')"
@uninstall-agent="$emit('uninstall-agent')"
@batch-delete="$emit('batch-delete')"
@clear="$emit('clear-selection')"
/>
<v-data-table-server
class="server-unset-path-table"
:items="servers"
:headers="headers"
:items-length="total"
:loading="loading"
:page="page"
:items-per-page="itemsPerPage"
:items-per-page-options="itemsPerPageOptions"
hover
density="comfortable"
item-value="id"
show-select
show-expand
:model-value="selectedItems"
:sort-by="sortBy"
v-model:expanded="expandedModel"
@update:model-value="$emit('update:selected-items', $event)"
@update:sort-by="$emit('update:sort-by', $event)"
@update:page="$emit('update:page', $event)"
@update:items-per-page="$emit('update:items-per-page', $event)"
>
<template #item.data-table-expand />
<template #item.status="{ item }">
<v-chip :color="statusChipColor(item.status)" size="x-small" variant="tonal" label border="sm">
{{ statusLabel(item.status) }}
</v-chip>
</template>
<template #item.name="{ item }">
<div
class="d-flex align-center cursor-pointer text-primary"
@click.stop="$emit('toggle-expand', item)"
>
<v-icon size="small" class="mr-1">
{{ isExpandedId(item.id) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}
</v-icon>
<span class="font-weight-medium">{{ item.name }}</span>
</div>
</template>
<template #item.domain="{ item }">
<span class="text-medium-emphasis">{{ item.domain }}:{{ item.port }}</span>
</template>
<template #item.category="{ item }">
<v-chip v-if="item.category" color="primary" variant="tonal" size="small" label>
{{ item.category }}
</v-chip>
<span v-else class="text-medium-emphasis"></span>
</template>
<template #item.target_path="{ item }">
<div v-if="editingPathId === item.id" class="d-flex align-center ga-1" @click.stop>
<v-text-field
:model-value="editingPathValue"
density="compact"
variant="outlined"
hide-details
placeholder="/www/wwwroot"
style="max-width: 140px"
@update:model-value="$emit('update:editing-path-value', $event)"
@keyup.enter="$emit('save-path', item.id)"
@keyup.esc="$emit('cancel-edit-path')"
/>
<v-btn
icon="mdi-check"
size="x-small"
variant="text"
color="primary"
:loading="savingPathId === item.id"
@click="$emit('save-path', item.id)"
/>
<v-btn icon="mdi-close" size="x-small" variant="text" @click="$emit('cancel-edit-path')" />
</div>
<div v-else class="d-flex align-center ga-1">
<span
class="text-caption text-medium-emphasis text-truncate d-inline-block"
style="max-width: 140px"
:title="item.target_path || '未配置'"
>
{{ item.target_path?.trim() || '—' }}
</span>
<v-btn
icon="mdi-pencil"
size="x-small"
variant="text"
density="compact"
title="编辑目标路径"
@click.stop="$emit('edit-path', item)"
/>
</div>
</template>
<template #item.agent_version="{ item }">
<span class="text-medium-emphasis">{{ item.agent_version || '—' }}</span>
</template>
<template #item.last_heartbeat="{ item }">
<span class="text-caption text-medium-emphasis">{{ formatRelativeTime(item.last_heartbeat) }}</span>
</template>
<template #item.actions="{ item }">
<div class="d-flex ga-1">
<v-btn variant="text" size="x-small" color="primary" density="compact" @click.stop="$emit('terminal', item)">
终端
</v-btn>
<v-btn variant="text" size="x-small" density="compact" @click.stop="$emit('files', item)">
文件
</v-btn>
<v-btn variant="text" size="x-small" density="compact" @click.stop="$emit('detect-path-single', item)">
检测路径
</v-btn>
<v-btn variant="text" size="x-small" density="compact" @click.stop="$emit('edit', item)">
编辑
</v-btn>
<v-btn variant="text" size="x-small" color="error" density="compact" @click.stop="$emit('delete', item)">
删除
</v-btn>
</div>
</template>
<template #expanded-row="{ columns, item }">
<tr>
<td :colspan="columns.length" class="pa-0">
<ServerInlineDetail :server="item" @edit-path="$emit('edit-path', item)" />
</td>
</tr>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无未设置路径的服务器</div>
</template>
</v-data-table-server>
</v-card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ServerApiItem } from '@/types/api'
import type { TableSortItem } from '@/utils/serverTableSort'
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
import { normalizeServerIds } from '@/utils/serverSelection'
import ServerInlineDetail from '@/components/servers/ServerInlineDetail.vue'
import ServerBatchActionBar from '@/components/servers/ServerBatchActionBar.vue'
const props = defineProps<{
servers: ServerApiItem[]
loading: boolean
total: number
page: number
itemsPerPage: number
itemsPerPageOptions: ReadonlyArray<{ readonly value: number; readonly title: string }>
sortBy: TableSortItem[]
expandedIds: number[]
selectedItems: Array<ServerApiItem | number>
selectAllLoading: boolean
editingPathId: number | null
editingPathValue: string
savingPathId: number | null
}>()
const emit = defineEmits<{
refresh: []
'update:sort-by': [sortBy: TableSortItem[]]
'update:page': [page: number]
'update:items-per-page': [n: number]
'update:selected-items': [items: Array<ServerApiItem | number>]
'update:expanded-ids': [ids: number[]]
'update:editing-path-value': [value: string]
'select-all-filtered': []
'toggle-expand': [item: ServerApiItem]
terminal: [item: ServerApiItem]
files: [item: ServerApiItem]
'detect-path-single': [item: ServerApiItem]
edit: [item: ServerApiItem]
delete: [item: ServerApiItem]
'edit-path': [item: ServerApiItem]
'save-path': [serverId: number]
'cancel-edit-path': []
'batch-category': []
'health-check': []
'detect-path': []
'install-agent': []
'upgrade-agent': []
'uninstall-agent': []
'batch-delete': []
'clear-selection': []
}>()
const headers = [
{ title: '状态', key: 'status', width: 100 },
{ title: '名称', key: 'name' },
{ title: '地址', key: 'domain' },
{ title: '分类', key: 'category', width: 100 },
{ title: '目标路径', key: 'target_path', width: 200 },
{ title: 'Agent', key: 'agent_version', width: 100 },
{ title: '心跳', key: 'last_heartbeat', width: 120 },
{ title: '操作', key: 'actions', width: 260, align: 'end' as const, sortable: false },
]
const selectedCount = computed(() => normalizeServerIds(props.selectedItems).length)
const expandedModel = computed({
get: () => props.expandedIds as unknown as readonly string[],
set: (ids: readonly string[]) => {
emit(
'update:expanded-ids',
ids.map((id) => Number(id)).filter((n) => !Number.isNaN(n)),
)
},
})
function isExpandedId(id: number): boolean {
return props.expandedIds.includes(id)
}
</script>
<style scoped>
.server-unset-path-table :deep(.v-data-table__td--expand-column),
.server-unset-path-table :deep(.v-data-table-column--expand),
.server-unset-path-table :deep(th.v-data-table__th--expand) {
display: none !important;
width: 0 !important;
padding: 0 !important;
}
.server-unset-path-table :deep(tr.v-data-table__expanded__row > td) {
background: transparent !important;
border-bottom: none;
}
</style>
@@ -85,4 +85,24 @@ defineExpose({
.font-mono {
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Menlo, monospace;
}
/* 命令栏统一单色;ANSI 彩色仅由上方 xterm SSH 输出区呈现 */
:deep(.sh-keyword),
:deep(.sh-builtin),
:deep(.sh-sudo),
:deep(.sh-pipe),
:deep(.sh-operator),
:deep(.sh-redirect),
:deep(.sh-string),
:deep(.sh-flag),
:deep(.sh-variable),
:deep(.sh-backtick),
:deep(.sh-comment),
:deep(.sh-number),
:deep(.sh-path),
:deep(.sh-text) {
color: rgb(var(--v-theme-on-surface));
font-weight: inherit;
font-style: inherit;
}
</style>
@@ -18,10 +18,12 @@
<v-btn
size="small"
variant="text"
prepend-icon="mdi-cog-outline"
:to="{ path: '/settings', hash: '#terminal-quick-commands' }"
color="error"
prepend-icon="mdi-link-off"
:disabled="disconnectDisabled"
@click="$emit('disconnect')"
>
管理
断开
</v-btn>
</div>
</template>
@@ -31,10 +33,13 @@ import type { QuickCmdItem } from '@/composables/useTerminalQuickCommands'
defineProps<{
commands: QuickCmdItem[]
/** 无活动会话时禁用断开 */
disconnectDisabled?: boolean
}>()
defineEmits<{
exec: [cmd: string]
disconnect: []
}>()
</script>
+1 -7
View File
@@ -1,6 +1,7 @@
/** Display helpers for push UI */
export { formatPushErrorMessage } from '@/utils/pushErrorMessage'
export { formatDateTimeBeijing as formatLogTime } from '@/utils/datetime'
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
@@ -8,13 +9,6 @@ export function formatSize(bytes: number): string {
return `${(bytes / 1048576).toFixed(1)} MB`
}
export function formatLogTime(iso: string | null | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleString('zh-CN', { hour12: false })
}
export function logStatusLabel(status: string): string {
switch (status) {
case 'success': return '成功'
@@ -5,6 +5,7 @@ import { useAuthStore } from '@/stores/auth'
import { buildWebSocketUrl } from '@/utils/wsUrl'
import type { usePushForm } from './usePushForm'
import type { usePushServers } from './usePushServers'
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
import { formatPushErrorMessage } from './labels'
import {
TERMINAL_PROGRESS_STATUSES,
@@ -17,6 +18,7 @@ type PushForm = ReturnType<typeof usePushForm>
type PushServers = ReturnType<typeof usePushServers>
type PushFilesResponse = {
accepted?: boolean
batch_id?: string
results?: Record<
string,
@@ -42,6 +44,7 @@ export function usePushProgress(
const auth = useAuthStore()
const pushing = ref(false)
const submitting = ref(false)
const cancelling = ref(false)
const retryingAll = ref(false)
const wsProgressItems = ref<ServerProgress[]>([])
@@ -227,7 +230,7 @@ export function usePushProgress(
}
async function doPush() {
if (pushing.value) return
if (pushing.value || submitting.value) return
const selected = [...servers.selectedIds.value]
.map(id => servers.servers.value.find(s => s.id === id))
.filter((s): s is NonNullable<typeof s> => s != null)
@@ -245,6 +248,7 @@ export function usePushProgress(
})
pushing.value = true
submitting.value = true
pushBatchId.value = newPushBatchId()
try {
@@ -259,7 +263,11 @@ export function usePushProgress(
if (res.batch_id && res.batch_id !== pushBatchId.value) {
pushBatchId.value = res.batch_id
}
if (res.accepted) {
showScriptSubmitToast(`推送已提交(${ids.length} 台),请在下方查看进度`)
} else {
applyPushResultsFromHttp(res)
}
} catch (e: unknown) {
disconnectProgressWs()
const msg = e instanceof Error ? e.message : '推送请求失败'
@@ -271,6 +279,8 @@ export function usePushProgress(
snackbar(msg, 'error')
onPushFinished()
return
} finally {
submitting.value = false
}
const safetyTimer = setTimeout(() => {
@@ -332,6 +342,7 @@ export function usePushProgress(
return {
pushing,
submitting,
cancelling,
retryingAll,
wsProgressItems,
@@ -25,6 +25,7 @@ import {
import { useTerminalSettings } from './useTerminalSettings'
import { useTerminalCmdBar } from './useTerminalCmdBar'
import { XTERM_CLASSIC_THEME } from './xtermTheme'
import { decodeTerminalPayload } from '@/utils/terminalPayload'
const MAX_TABS = 10
@@ -302,7 +303,7 @@ export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
}
break
case 'DATA':
native.term?.write(msg.data)
native.term?.write(decodeTerminalPayload(msg))
break
case 'ERROR':
native.term?.write(`\r\n\x1b[31m⚠ ${msg.message}\x1b[0m\r\n`)
+21 -13
View File
@@ -9,7 +9,14 @@ import type { ServerApiItem } from '@/types/api'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
import { toServerApiSort, type TableSortItem } from '@/utils/serverTableSort'
export function useServerPagination() {
export interface UseServerPaginationOptions {
/** When set, passed as target_path_unset query param (true/false). Omit for no filter. */
targetPathUnset?: boolean
}
export function useServerPagination(options?: UseServerPaginationOptions) {
const targetPathUnset = options?.targetPathUnset
const servers = ref<ServerApiItem[]>([])
const loading = ref(false)
const page = ref(1)
@@ -20,16 +27,21 @@ export function useServerPagination() {
const categoryFilter = ref('')
const sortBy = ref<TableSortItem[]>([])
function listQueryParams(): Record<string, unknown> {
return {
search: search.value || undefined,
category: categoryFilter.value || undefined,
...toServerApiSort(sortBy.value),
...(targetPathUnset !== undefined ? { target_path_unset: targetPathUnset } : {}),
}
}
async function loadServers(opts?: { silent?: boolean }) {
if (!opts?.silent) loading.value = true
try {
const res = await fetchPagePerPage<ServerApiItem>(
'/servers/',
{
search: search.value || undefined,
category: categoryFilter.value || undefined,
...toServerApiSort(sortBy.value),
},
listQueryParams(),
page.value,
itemsPerPage.value,
)
@@ -43,7 +55,7 @@ export function useServerPagination() {
total.value = 0
throw e // Let callers handle the error display
} finally {
if (!opts?.silent) loading.value = false
loading.value = false
}
}
@@ -83,11 +95,7 @@ export function useServerPagination() {
async function loadAllServers(): Promise<ServerApiItem[]> {
const res = await fetchPagePerPage<ServerApiItem>(
'/servers/',
{
search: search.value || undefined,
category: categoryFilter.value || undefined,
...toServerApiSort(sortBy.value),
},
listQueryParams(),
1,
-1,
)
@@ -97,6 +105,6 @@ export function useServerPagination() {
return {
servers, loading, page, itemsPerPage, total, search, categoryFilter, sortBy,
loadServers, onSearch, onPageChange, onItemsPerPageChange, onCategoryChange,
loadAllServers,
loadAllServers, listQueryParams,
}
}
+2 -1
View File
@@ -14,6 +14,7 @@
*/
import { ref, computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { formatTimeBeijing } from '@/utils/datetime'
import {
handleScriptWsMessage,
setScriptQueueWsConnected,
@@ -98,7 +99,7 @@ export function useWebSocket() {
serverName: msg.server_name,
alertType: msg.alert_type || msg.metric,
value: msg.alert_value || msg.value,
time: new Date().toLocaleTimeString(),
time: formatTimeBeijing(new Date()),
}
alerts.value.unshift(alert)
if (alerts.value.length > MAX_ALERTS) alerts.value.pop()
+2 -1
View File
@@ -83,7 +83,7 @@
</template>
<template #item.created_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ item.created_at }}</span>
<span class="text-caption text-medium-emphasis">{{ formatDateTimeBeijing(item.created_at) }}</span>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无数据</div>
@@ -100,6 +100,7 @@ import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import type { PaginatedResponse, AlertLogItem, AlertStatsResponse } from '@/types/api'
import { headersWithoutSort } from '@/constants/dataTable'
import { formatDateTimeBeijing } from '@/utils/datetime'
defineOptions({ name: 'AlertsPage' })
+2 -1
View File
@@ -96,7 +96,7 @@
</template>
<template #item.created_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ item.created_at }}</span>
<span class="text-caption text-medium-emphasis">{{ formatDateTimeBeijing(item.created_at) }}</span>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无数据</div>
@@ -122,6 +122,7 @@ import {
import { openServerBatchJobResult } from '@/composables/useServerBatchJobViewer'
import { normalizeAuditList } from '@/utils/auditNormalize'
import { headersWithoutSort } from '@/constants/dataTable'
import { formatDateTimeBeijing } from '@/utils/datetime'
defineOptions({ name: 'AuditPage' })
+4 -3
View File
@@ -99,7 +99,7 @@
<span class="text-medium-emphasis">{{ item.admin_username || '—' }}</span>
</template>
<template #item.created_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ item.created_at }}</span>
<span class="text-caption text-medium-emphasis">{{ formatDateTimeBeijing(item.created_at) }}</span>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无数据</div>
@@ -130,7 +130,7 @@
</v-chip>
</template>
<template #item.started_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ item.started_at }}</span>
<span class="text-caption text-medium-emphasis">{{ formatDateTimeBeijing(item.started_at) }}</span>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无数据</div>
@@ -153,7 +153,7 @@
@update:items-per-page="onItemsPerPageChange"
>
<template #item.started_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ item.started_at || '—' }}</span>
<span class="text-caption text-medium-emphasis">{{ formatDateTimeBeijing(item.started_at) }}</span>
</template>
<template #item.server_name="{ item }">
<span class="text-medium-emphasis">{{ item.server_name || '—' }}</span>
@@ -211,6 +211,7 @@ import { useServerList } from '@/composables/useServerList'
import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError'
import { formatPushPermission } from '@/composables/push/labels'
import { formatDateTimeBeijing } from '@/utils/datetime'
import type { CommandLogItem, PushItem, SshSessionItem } from '@/types/api'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS, headersWithoutSort } from '@/constants/dataTable'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
+4 -131
View File
@@ -3,86 +3,8 @@
<v-skeleton-loader v-if="statsBooting" type="heading, text" class="mb-4" />
<StatCardsRow v-else :items="statItems" />
<!-- Server Table (v-card border elevation-0, same as official "Recent Orders") -->
<v-card class="my-5" elevation="0" rounded="lg" title="服务器列表" border>
<template #text>
<v-text-field
v-model="search"
autocomplete="off"
placeholder="搜索..."
prepend-inner-icon="mdi-magnify"
variant="outlined"
hide-details
rounded
class="mb-4"
/>
<v-skeleton-loader
v-if="loading && !servers.length"
type="table-heading, table-row-divider@6"
class="mb-4"
/>
<v-data-table-server
v-else
:items="servers"
:headers="headers"
:items-length="total"
:loading="loading"
:page="page"
:items-per-page="itemsPerPage"
:items-per-page-options="dataTablePageOptions"
v-model:sort-by="sortBy"
hover
item-value="id"
@update:page="onPageChange"
@update:items-per-page="onItemsPerPageChange"
>
<template #item.status="{ item }">
<v-chip
:color="statusChipColor(item.status)"
:text="statusLabel(item.status)"
border="sm"
size="x-small"
label
/>
</template>
<template #item.name="{ item }">
<span class="font-weight-medium">{{ item.name }}</span>
</template>
<template #item.domain="{ item }">
<span class="text-medium-emphasis">{{ item.domain }}</span>
</template>
<template #item.category="{ item }">
<span class="text-medium-emphasis">{{ item.category || '—' }}</span>
</template>
<template #item.agent_version="{ item }">
<span class="text-medium-emphasis">{{ item.agent_version || '—' }}</span>
</template>
<template #item.last_heartbeat="{ item }">
<span class="text-caption text-medium-emphasis">{{ formatRelativeTime(item.last_heartbeat) }}</span>
</template>
<template #item.actions="{ item }">
<div class="d-flex ga-1">
<v-btn variant="text" size="x-small" color="primary" density="compact" @click="openTerminal(item)">终端</v-btn>
<v-btn variant="text" size="x-small" density="compact" @click="openFiles(item)">文件</v-btn>
</div>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无服务器数据</div>
</template>
</v-data-table-server>
</template>
</v-card>
<!-- Bottom Row: Alerts + Summary -->
<v-row>
<v-row class="mt-5">
<v-col cols="12" md="6">
<v-card elevation="0" rounded="lg" title="告警历史" border>
<template #append>
@@ -235,34 +157,25 @@
min-width: 0;
max-width: 100%;
}
.dashboard-root :deep(.v-table) {
overflow-x: auto;
}
</style>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { useWebSocket } from '@/composables/useWebSocket'
import { useServerPagination } from '@/composables/useServerPagination'
import { usePageActivateRefresh } from '@/composables/usePageActivateRefresh'
import { CACHE_KEYS, cachedFetch } from '@/composables/useCachedQuery'
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
import { formatRelativeTime } from '@/utils/status'
import { auditSummary } from '@/utils/auditLabels'
import { normalizeAuditList } from '@/utils/auditNormalize'
import type { ServerApiItem, AuditItem, AlertLogItem } from '@/types/api'
import StatCardsRow from '@/components/StatCardsRow.vue'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS } from '@/constants/dataTable'
defineOptions({ name: 'DashboardPage' })
const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
const snackbar = useSnackbar()
const router = useRouter()
const statsBooting = ref(true)
// ── Stats ──
@@ -273,30 +186,6 @@ const statItems = ref([
{ subtitle: '告警中', title: '0', icon: 'mdi-bell-alert-outline', color: 'red' },
])
// ── Server Table (composable) ──
const {
servers, loading, page, itemsPerPage, total, search, sortBy,
loadServers: _loadServers, onSearch, onPageChange, onItemsPerPageChange,
} = useServerPagination()
async function loadServers(silent = false) {
try {
await _loadServers({ silent })
} catch {
if (!silent) snackbar('加载服务器列表失败', 'error')
}
}
const headers = [
{ title: '状态', key: 'status', width: 100 },
{ title: '名称', key: 'name' },
{ title: '地址', key: 'domain' },
{ title: '分类', key: 'category', width: 100 },
{ title: 'Agent', key: 'agent_version', width: 100 },
{ title: '心跳', key: 'last_heartbeat', width: 140 },
{ title: '操作', key: 'actions', width: 140, align: 'end' as const, sortable: false },
]
// ── Alerts ──
const recentAlerts = ref<AlertItem[]>([])
@@ -328,11 +217,9 @@ async function loadStats(silent = false) {
statItems.value[2].title = String(s.offline)
statItems.value[3].title = String(s.alerts ?? 0)
// Capture categories and total for distribution chart
categories.value = s.categories || {}
statsTotal.value = s.total
// Update summary
const onlineRate = s.total ? Math.round((s.online / s.total) * 100) : 0
summaryItems.value[0].progress = onlineRate
summaryItems.value[0].value = `${onlineRate}%`
@@ -371,20 +258,17 @@ async function loadRecentAudit() {
async function loadSummary() {
try {
// Load a larger set to compute fleet-wide stats
const res = await http.get<{ items: ServerApiItem[]; total: number }>('/servers/', { per_page: 200 })
const allServers = res.items || []
const fleetTotal = res.total || allServers.length
if (fleetTotal === 0) return
// Agent coverage
const withAgent = allServers.filter(s => s.agent_version).length
const agentRate = Math.round((withAgent / fleetTotal) * 100)
summaryItems.value[1].progress = agentRate
summaryItems.value[1].value = `${agentRate}%`
// CPU / Memory averages (from servers with system_info)
const withCpu = allServers.filter(s => s.system_info?.cpu_usage != null)
const withMem = allServers.filter(s => s.system_info?.mem_usage != null)
@@ -403,17 +287,9 @@ async function loadSummary() {
}
}
function openTerminal(item: ServerApiItem) {
router.push({ path: '/terminal', query: { server_id: String(item.id) } })
}
function openFiles(item: ServerApiItem) {
router.push({ path: '/files', query: { server_id: String(item.id) } })
}
async function refreshDashboard(silent = false) {
await Promise.all([
loadStats(silent),
loadServers(silent),
loadAlerts(),
loadSummary(),
loadRecentAudit(),
@@ -423,13 +299,10 @@ async function refreshDashboard(silent = false) {
}
}
// ── Init ──
// NOTE: WebSocket is managed by App.vue (connected when auth.isLoggedIn=true),
// so we reuse that global singleton instead of creating a second connection.
const ws = useWebSocket()
watch(ws.alerts, () => {
loadStats()
loadAlerts()
void loadStats(true)
void loadAlerts()
})
usePageActivateRefresh((silent) => refreshDashboard(silent))
+1
View File
@@ -54,6 +54,7 @@
<PushToolbar
v-model:target-path="form.targetPath"
:submitting="progress.submitting"
:pushing="progress.pushing"
@preview="preview.doPreview"
@push="progress.doPush"
+3 -2
View File
@@ -51,11 +51,11 @@
</template>
<template #item.next_retry_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ item.next_retry_at || '—' }}</span>
<span class="text-caption text-medium-emphasis">{{ formatDateTimeBeijing(item.next_retry_at) }}</span>
</template>
<template #item.created_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ item.created_at }}</span>
<span class="text-caption text-medium-emphasis">{{ formatDateTimeBeijing(item.created_at) }}</span>
</template>
<template #item.actions="{ item }">
@@ -95,6 +95,7 @@ import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import type { PaginatedResponse, RetryItem } from '@/types/api'
import { headersWithoutSort } from '@/constants/dataTable'
import { formatDateTimeBeijing } from '@/utils/datetime'
defineOptions({ name: 'RetriesPage' })
+3 -2
View File
@@ -52,11 +52,11 @@
</template>
<template #item.last_run_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ item.last_run_at || '从未运行' }}</span>
<span class="text-caption text-medium-emphasis">{{ item.last_run_at ? formatDateTimeBeijing(item.last_run_at) : '从未运行' }}</span>
</template>
<template #item.next_run="{ item }">
<span class="text-caption text-medium-emphasis">{{ item.next_run || '—' }}</span>
<span class="text-caption text-medium-emphasis">{{ item.next_run ? formatDateTimeBeijing(item.next_run) : '—' }}</span>
</template>
<template #item.actions="{ item }">
@@ -245,6 +245,7 @@ import {
validateCycleConfig,
type ScheduleCycleConfig,
} from '@/utils/scheduleCycle'
import { formatDateTimeBeijing } from '@/utils/datetime'
defineOptions({ name: 'SchedulesPage' })
+4
View File
@@ -255,6 +255,9 @@
<template #item.server_count="{ item }">
{{ item.server_count }}
</template>
<template #item.started_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ formatDateTimeBeijing(item.started_at) }}</span>
</template>
<template #item.actions="{ item }">
<v-btn size="x-small" variant="text" color="primary" @click="openDetail(item)">
详情
@@ -302,6 +305,7 @@ import {
resolveExecServerName,
} from '@/utils/scriptExecutionLabels'
import { groupFailedExecRows } from '@/utils/execDetailGrouping'
import { formatDateTimeBeijing } from '@/utils/datetime'
defineOptions({ name: 'ScriptRunsPage' })
+5 -12
View File
@@ -129,7 +129,7 @@
</v-chip>
<div class="text-body-2 text-medium-emphasis">{{ script.description || '无描述' }}</div>
<div class="text-caption text-medium-emphasis mt-2">
更新于 {{ script.updated_at || script.created_at || '—' }}
更新于 {{ formatDateTimeBeijing(script.updated_at || script.created_at) }}
</div>
</v-card-text>
<v-card-actions>
@@ -178,7 +178,7 @@
<template #subtitle>
<div class="text-caption">
{{ execStatusLabel(item.status) }} · {{ parseServerCount(item.server_ids) }}
· {{ item.operator || '—' }} · {{ formatExecTime(item.started_at) }}
· {{ item.operator || '—' }} · {{ formatDateTimeBeijing(item.started_at) }}
</div>
<div v-for="(ev, idx) in execEventLines(item)" :key="idx" class="text-caption text-medium-emphasis font-monospace mt-1">
{{ ev }}
@@ -254,7 +254,7 @@
</div>
</template>
<template #item.started_at="{ item }">
{{ formatExecTime(item.started_at) }}
{{ formatDateTimeBeijing(item.started_at) }}
</template>
<template #item.actions="{ item }">
<v-btn size="x-small" variant="text" @click="openExecDetail(item.id)">详情</v-btn>
@@ -652,6 +652,7 @@ import { registerScriptExecution } from '@/composables/useScriptExecutionQueue'
defineOptions({ name: 'ScriptsPage' })
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
import { useScriptExecutionListRefresh } from '@/composables/useScriptExecutionListRefresh'
import { formatDateTimeBeijing } from '@/utils/datetime'
const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
const snackbar = useSnackbar()
@@ -884,20 +885,12 @@ function execLabel(item: ScriptExecItem): string {
return '临时命令'
}
function formatExecTime(ts: string | null | undefined): string {
if (!ts) return '—'
const d = new Date(ts)
if (Number.isNaN(d.getTime())) return ts.replace('T', ' ').slice(0, 19)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
function execEventLines(item: ScriptExecItem): string[] {
const events = item.events || []
if (!events.length) return []
return events.slice(-3).map(ev => {
const label = execEventActionLabels[ev.action || ''] || ev.action || '事件'
const time = formatExecTime(ev.at)
const time = formatDateTimeBeijing(ev.at)
const detail = ev.detail || ''
return `${time} · ${label}${detail ? `${detail}` : ''}`
})
+318 -228
View File
@@ -25,9 +25,6 @@
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-flash" size="small" @click="openQuickAdd">
快速添加(IP)
</v-btn>
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-download" size="small" @click="exportCSV">
导出CSV
</v-btn>
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-ip-network" size="small" @click="openBatchAdd">
批量添加
</v-btn>
@@ -41,7 +38,7 @@
size="small"
:disabled="selectedIds.size === 0"
title="请先勾选至少一台服务器"
@click="openDetectPathConfirm"
@click="openDetectPathConfirm('main')"
>
检测路径
</v-btn>
@@ -89,20 +86,17 @@
</v-card-text>
<!-- Batch Actions -->
<v-card v-if="selectedIds.size > 0" class="mx-4 mb-2" color="primary" variant="tonal" rounded="lg">
<v-card-text class="d-flex align-center py-2">
<span class="text-body-2 mr-4">已选择 {{ selectedIds.size }} 台服务器</span>
<v-spacer />
<v-btn size="small" variant="text" @click="openBatchCategory">修改分类</v-btn>
<v-btn size="small" variant="text" @click="batchHealthCheck">健康检查</v-btn>
<v-btn size="small" variant="text" @click="openDetectPathConfirm">检测目标路径</v-btn>
<v-btn size="small" variant="text" @click="batchInstallAgent">安装Agent</v-btn>
<v-btn size="small" variant="text" @click="batchUpgradeAgent">升级Agent</v-btn>
<v-btn size="small" variant="text" color="error" @click="batchUninstallAgent">卸载Agent</v-btn>
<v-btn size="small" variant="text" color="error" @click="confirmBatchDelete">批量删除</v-btn>
<v-btn size="small" variant="text" @click="selectedItems = []">取消选择</v-btn>
</v-card-text>
</v-card>
<ServerBatchActionBar
:count="selectedIds.size"
@batch-category="openBatchCategory('main')"
@health-check="batchHealthCheck('main')"
@detect-path="openDetectPathConfirm('main')"
@install-agent="batchInstallAgent('main')"
@upgrade-agent="batchUpgradeAgent('main')"
@uninstall-agent="batchUninstallAgent('main')"
@batch-delete="confirmBatchDelete('main')"
@clear="selectedItems = []"
/>
<v-skeleton-loader
v-if="viewMode === 'table' && loading && !servers.length"
@@ -125,10 +119,13 @@
show-select
v-model="selectedItems"
v-model:sort-by="sortBy"
@update:page="onPageChange"
@update:items-per-page="onItemsPerPageChange"
@click:row="(_e: Event, { item }: { item: ServerApiItem }) => showDetail(item)"
v-model:expanded="expandedMainIdsModel"
show-expand
class="server-main-table"
@update:page="onMainPageChange"
@update:items-per-page="onMainItemsPerPageChange"
>
<template #item.data-table-expand />
<template #item.status="{ item }">
<v-chip :color="statusChipColor(item.status)" size="x-small" variant="tonal" label border="sm">
{{ statusLabel(item.status) }}
@@ -136,7 +133,15 @@
</template>
<template #item.name="{ item }">
<div
class="d-flex align-center cursor-pointer text-primary"
@click.stop="toggleMainExpand(item)"
>
<v-icon size="small" class="mr-1">
{{ expandedMainIds.includes(item.id) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}
</v-icon>
<span class="font-weight-medium">{{ item.name }}</span>
</div>
</template>
<template #item.domain="{ item }">
@@ -201,6 +206,14 @@
</div>
</template>
<template #expanded-row="{ columns, item }">
<tr>
<td :colspan="columns.length" class="pa-0">
<ServerInlineDetail :server="item" @edit-path="startEditPath(item)" />
</td>
</tr>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无服务器数据</div>
</template>
@@ -214,7 +227,7 @@
<div v-else-if="groupedServers.length === 0" class="text-center text-medium-emphasis py-6">
暂无服务器数据
</div>
<v-expansion-panels v-else v-model="expandedCategories" multiple variant="accordion">
<v-expansion-panels v-else v-model="expandedCategories" multiple variant="accordion" class="servers-category-panels">
<v-expansion-panel v-for="group in groupedServers" :key="group.category">
<v-expansion-panel-title>
<div class="d-flex align-center ga-2 flex-grow-1">
@@ -246,8 +259,6 @@
<tr
v-for="item in group.servers"
:key="item.id"
class="cursor-pointer"
@click="showDetail(item)"
>
<td @click.stop>
<v-checkbox-btn
@@ -279,6 +290,45 @@
</div>
</v-card>
<ServerUnsetPathPanel
:servers="unsetPathServers"
:loading="unsetPathLoading"
:total="unsetPathTotal"
:page="unsetPathPage"
:items-per-page="unsetPathItemsPerPage"
:items-per-page-options="dataTablePageOptions"
:expanded-ids="expandedUnsetIds"
v-model:selected-items="unsetSelectedItems"
v-model:sort-by="sortBy"
:select-all-loading="selectAllUnsetLoading"
:editing-path-id="editingPathId"
:editing-path-value="editingPathValue"
:saving-path-id="savingPathId"
@refresh="loadUnsetPathServers()"
@update:page="onUnsetPageChange"
@update:items-per-page="onUnsetItemsPerPageChange"
@update:expanded-ids="expandedUnsetIds = $event"
@update:editing-path-value="editingPathValue = $event"
@select-all-filtered="selectAllUnsetFiltered"
@toggle-expand="toggleUnsetExpand"
@terminal="openTerminal"
@files="openFiles"
@detect-path-single="detectPathSingle"
@edit="editServer"
@delete="confirmDelete"
@edit-path="startEditPath"
@save-path="saveTargetPath"
@cancel-edit-path="cancelEditPath"
@batch-category="openBatchCategory('unset')"
@health-check="batchHealthCheck('unset')"
@detect-path="openDetectPathConfirm('unset')"
@install-agent="batchInstallAgent('unset')"
@upgrade-agent="batchUpgradeAgent('unset')"
@uninstall-agent="batchUninstallAgent('unset')"
@batch-delete="confirmBatchDelete('unset')"
@clear-selection="unsetSelectedItems = []"
/>
<!-- Pending / failed SSH connections -->
<v-card elevation="0" rounded="lg" class="my-5" border>
<v-card-title class="d-flex align-center">
@@ -322,78 +372,6 @@
</v-data-table>
</v-card>
<!-- Server Detail Panel -->
<v-expand-transition>
<v-card v-if="detailServer" class="mt-4" elevation="0" border rounded="lg">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">mdi-information-outline</v-icon>
{{ detailServer.name }}
<v-chip :color="statusChipColor(detailServer.status)" size="small" variant="tonal" label class="ml-2">
{{ statusLabel(detailServer.status) }}
</v-chip>
<v-spacer />
<v-btn icon="mdi-close" size="small" variant="text" @click="detailServer = null" />
</v-card-title>
<v-divider />
<v-tabs v-model="detailTab" density="compact">
<v-tab value="info">系统信息</v-tab>
<v-tab value="logs">同步日志</v-tab>
</v-tabs>
<v-tabs-window v-model="detailTab">
<v-tabs-window-item value="info">
<v-card-text>
<v-row dense>
<v-col cols="6" md="3">
<div class="text-caption text-medium-emphasis">CPU</div>
<div class="text-h6">{{ detailServer.system_info?.cpu_usage ?? '—' }}%</div>
</v-col>
<v-col cols="6" md="3">
<div class="text-caption text-medium-emphasis">内存</div>
<div class="text-h6">{{ detailServer.system_info?.mem_usage ?? '—' }}%</div>
</v-col>
<v-col cols="6" md="3">
<div class="text-caption text-medium-emphasis">磁盘</div>
<div class="text-h6">{{ detailServer.system_info?.disk_usage ?? '—' }}%</div>
</v-col>
<v-col cols="6" md="3">
<div class="text-caption text-medium-emphasis">Agent</div>
<div class="text-h6">{{ detailServer.agent_version || '未安装' }}</div>
</v-col>
<v-col cols="12" md="6">
<div class="text-caption text-medium-emphasis">目标路径</div>
<div class="text-body-1 font-weight-medium text-truncate">
{{ detailServer.target_path?.trim() || '未配置(推送页留空时无法推送到此机)' }}
</div>
</v-col>
</v-row>
</v-card-text>
</v-tabs-window-item>
<v-tabs-window-item value="logs">
<v-card-text>
<div v-if="syncLogsLoading" class="text-center py-4">
<v-progress-circular indeterminate size="24" />
</div>
<div v-else-if="syncLogs.length === 0" class="text-medium-emphasis text-center py-4">暂无同步日志</div>
<v-list v-else density="compact">
<v-list-item
v-for="log in syncLogs"
:key="log.id"
:subtitle="formatSyncLogTime(log)"
:title="`${log.source_path} → ${log.target_path}`"
>
<template #append>
<v-chip size="x-small" :color="syncLogStatusColor(log.status)" variant="tonal" label>
{{ syncLogStatusLabel(log.status) }}
</v-chip>
</template>
</v-list-item>
</v-list>
</v-card-text>
</v-tabs-window-item>
</v-tabs-window>
</v-card>
</v-expand-transition>
<ServerFormDialog
v-model="serverFormVisible"
:form="serverForm"
@@ -507,7 +485,7 @@
<v-card-title>自动检测目标路径</v-card-title>
<v-card-text>
<p class="text-body-2 mb-2">
将对选中的 <strong>{{ selectedIds.size }}</strong> 台服务器执行 SSH 检测
将对选中的 <strong>{{ scopedSelectedCount }}</strong> 台服务器执行 SSH 检测
</p>
<p class="text-body-2 text-medium-emphasis mb-0">
在远程 <code>/www/wwwroot</code> 下搜索 <code>workerman.bat</code>找到后将所在目录写入各服务器的
@@ -528,7 +506,7 @@
<v-card-title>批量修改分类</v-card-title>
<v-card-text>
<div class="text-body-2 text-medium-emphasis mb-3">
已选择 {{ selectedIds.size }} 台服务器将统一设置为以下分类留空表示清除分类
已选择 {{ scopedSelectedCount }} 台服务器将统一设置为以下分类留空表示清除分类
</div>
<v-combobox
v-model="batchCategoryValue"
@@ -608,7 +586,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, watch, onMounted, onBeforeUnmount, type Ref } from 'vue'
import { usePageAutoRefresh } from '@/composables/usePageAutoRefresh'
import { usePageActivateRefresh } from '@/composables/usePageActivateRefresh'
import { CACHE_KEYS, cachedFetch } from '@/composables/useCachedQuery'
@@ -617,15 +595,19 @@ import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { useServerPagination } from '@/composables/useServerPagination'
import { categoryDisplayLabel, useServerCategories } from '@/composables/useServerCategories'
import { formatPushPermission } from '@/composables/push/labels'
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServerItem, PollErrorItem, PushItem, ServerApiItem } from '@/types/api'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
import { isUnsetTargetPath } from '@/utils/serverTargetPath'
import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServerItem, PollErrorItem, ServerApiItem } from '@/types/api'
import { normalizeServerIds } from '@/utils/serverSelection'
import { formatApiError } from '@/utils/apiError'
import { registerServerBatchJob, onScriptExecutionComplete } from '@/composables/useScriptExecutionQueue'
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
import StatCardsRow from '@/components/StatCardsRow.vue'
import ServerFormDialog from '@/components/servers/ServerFormDialog.vue'
import ServerBatchActionBar from '@/components/servers/ServerBatchActionBar.vue'
import ServerInlineDetail from '@/components/servers/ServerInlineDetail.vue'
import ServerUnsetPathPanel from '@/components/servers/ServerUnsetPathPanel.vue'
import CredentialsDialog from '@/components/credentials/CredentialsDialog.vue'
import { useServerFormDialog } from '@/composables/servers/useServerFormDialog'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS } from '@/constants/dataTable'
@@ -667,7 +649,7 @@ async function submitServerBatchJob(
onMounted(() => {
offBatchComplete = onScriptExecutionComplete((alert) => {
if (alert.taskKind !== 'server_batch') return
void Promise.all([loadServers(true), loadCategories(), loadStats(true)])
void Promise.all([loadServers(true), loadUnsetPathServers(true), loadCategories(), loadStats(true)])
})
})
@@ -700,8 +682,133 @@ watch(() => route.query.credentials, () => openCredentialsFromQuery(), { immedia
const {
servers, loading, page, itemsPerPage, total, search, categoryFilter, sortBy,
loadServers: _load, onSearch, onPageChange, onItemsPerPageChange, onCategoryChange,
loadAllServers,
} = useServerPagination()
loadAllServers, listQueryParams,
} = useServerPagination({ targetPathUnset: false })
const unsetPathServers = ref<ServerApiItem[]>([])
const unsetPathTotal = ref(0)
const unsetPathLoading = ref(false)
const unsetPathPage = ref(1)
const unsetPathItemsPerPage = ref(10)
const expandedMainIds = ref<number[]>([])
const expandedUnsetIds = ref<number[]>([])
/** v-data-table 运行时按 item-value(数字 id)匹配,类型声明为 string[] */
function expandedTableModel(source: Ref<number[]>) {
return computed({
get: () => source.value as unknown as readonly string[],
set: (ids: readonly string[]) => {
source.value = ids.map((id) => Number(id)).filter((n) => !Number.isNaN(n))
},
})
}
const expandedMainIdsModel = expandedTableModel(expandedMainIds)
const unsetSelectedItems = ref<Array<ServerApiItem | number>>([])
const unsetSelectedIds = computed(() => new Set(normalizeServerIds(unsetSelectedItems.value)))
const selectAllUnsetLoading = ref(false)
type BatchScope = 'main' | 'unset'
const batchScope = ref<BatchScope>('main')
function batchSelection(scope: BatchScope) {
return scope === 'main'
? { items: selectedItems, ids: selectedIds }
: { items: unsetSelectedItems, ids: unsetSelectedIds }
}
const scopedSelectedCount = computed(() => batchSelection(batchScope.value).ids.value.size)
async function loadAllUnsetPathServers(): Promise<ServerApiItem[]> {
const res = await fetchPagePerPage<ServerApiItem>(
'/servers/',
{ ...listQueryParams(), target_path_unset: true },
1,
-1,
)
return res.items
}
async function selectAllUnsetFiltered() {
if (unsetPathTotal.value <= 0) return
selectAllUnsetLoading.value = true
try {
const all = await loadAllUnsetPathServers()
unsetSelectedItems.value = all.map(s => s.id)
snackbar(`已选择未设路径筛选结果 ${all.length}`)
} catch {
snackbar('加载未设路径筛选结果失败', 'error')
} finally {
selectAllUnsetLoading.value = false
}
}
async function loadUnsetPathServers(silent = false) {
if (!silent) unsetPathLoading.value = true
try {
const res = await fetchPagePerPage<ServerApiItem>(
'/servers/',
{ ...listQueryParams(), target_path_unset: true },
unsetPathPage.value,
unsetPathItemsPerPage.value,
)
unsetPathServers.value = res.items
unsetPathTotal.value = res.total
} catch {
unsetPathServers.value = []
unsetPathTotal.value = 0
if (!silent) snackbar('加载未设置路径服务器失败', 'error')
} finally {
if (!silent) unsetPathLoading.value = false
}
}
function toggleMainExpand(item: ServerApiItem) {
expandedMainIds.value = expandedMainIds.value.includes(item.id) ? [] : [item.id]
}
function toggleUnsetExpand(item: ServerApiItem) {
expandedUnsetIds.value = expandedUnsetIds.value.includes(item.id) ? [] : [item.id]
}
function onMainPageChange(p: number) {
expandedMainIds.value = []
onPageChange(p)
}
function onMainItemsPerPageChange(n: number) {
expandedMainIds.value = []
onItemsPerPageChange(n)
}
function onUnsetPageChange(p: number) {
expandedUnsetIds.value = []
unsetSelectedItems.value = []
unsetPathPage.value = p
void loadUnsetPathServers()
}
function onUnsetItemsPerPageChange(n: number) {
expandedUnsetIds.value = []
unsetSelectedItems.value = []
unsetPathItemsPerPage.value = n
unsetPathPage.value = 1
void loadUnsetPathServers()
}
async function detectPathSingle(item: ServerApiItem) {
try {
await submitServerBatchJob('detect-path', [item.id])
} catch (e: unknown) {
snackbar(formatApiError(e, '提交检测任务失败'), 'error')
}
}
function clearExpandedForPathMigration(serverId: number, newPath: string) {
const unset = isUnsetTargetPath(newPath)
if (expandedMainIds.value.includes(serverId) && unset) expandedMainIds.value = []
if (expandedUnsetIds.value.includes(serverId) && !unset) expandedUnsetIds.value = []
}
const {
categories: categoryList,
@@ -718,6 +825,7 @@ const expandedCategories = ref<string[]>([])
async function loadServers(silent = false) {
try {
await _load({ silent })
await loadUnsetPathServers(silent)
if (viewMode.value === 'group') {
await loadGroupServers(silent)
}
@@ -744,6 +852,7 @@ async function loadGroupServers(silent = false) {
async function refreshServersSilent() {
try {
await _load({ silent: true })
await loadUnsetPathServers(true)
if (viewMode.value === 'group') await loadGroupServers(true)
} catch {
/* 后台刷新失败不打断用户操作 */
@@ -816,12 +925,30 @@ function toggleGroupAll(groupServers: ServerApiItem[]) {
selectedItems.value = [...next]
}
watch(categoryFilter, () => { selectedItems.value = [] })
watch(categoryFilter, () => {
selectedItems.value = []
unsetSelectedItems.value = []
expandedMainIds.value = []
expandedUnsetIds.value = []
unsetPathPage.value = 1
void loadUnsetPathServers()
})
watch(sortBy, () => {
selectedItems.value = []
unsetSelectedItems.value = []
expandedMainIds.value = []
expandedUnsetIds.value = []
unsetPathPage.value = 1
if (viewMode.value === 'group') void loadGroupServers()
void loadUnsetPathServers()
}, { deep: true })
watch(search, () => { selectedItems.value = [] })
watch(search, () => {
selectedItems.value = []
unsetSelectedItems.value = []
expandedMainIds.value = []
expandedUnsetIds.value = []
unsetPathPage.value = 1
})
watch(viewMode, mode => {
if (mode === 'group') {
@@ -984,49 +1111,6 @@ watch(serverFormVisible, (open, wasOpen) => {
const selectedItems = ref<Array<ServerApiItem | number>>([])
const selectedIds = computed(() => new Set(normalizeServerIds(selectedItems.value)))
// ── Detail panel ──
const detailServer = ref<ServerApiItem | null>(null)
const detailTab = ref('info')
const syncLogs = ref<PushItem[]>([])
const syncLogsLoading = ref(false)
function syncLogStatusColor(status: string): string {
switch (status) {
case 'success': return 'success'
case 'failed': return 'error'
case 'running': return 'warning'
case 'cancelled': return 'grey'
default: return 'default'
}
}
function syncLogStatusLabel(status: string): string {
switch (status) {
case 'success': return '成功'
case 'failed': return '失败'
case 'running': return '进行中'
case 'cancelled': return '已取消'
default: return status
}
}
function formatSyncLogTime(log: PushItem): string {
const parts: string[] = []
if (log.started_at) parts.push(log.started_at)
if (log.sync_mode) parts.push(log.sync_mode)
if (log.trigger_type) parts.push(log.trigger_type)
if (log.push_permission) parts.push(`权限 ${formatPushPermission(log.push_permission)}`)
return parts.join(' · ') || '—'
}
function parseServerSyncLogs(res: unknown): PushItem[] {
if (Array.isArray(res)) return res as PushItem[]
if (res && typeof res === 'object' && Array.isArray((res as { items?: unknown }).items)) {
return (res as { items: PushItem[] }).items
}
return []
}
// ── Table config ──
const headers = [
{ title: '状态', key: 'status', width: 100 },
@@ -1073,6 +1157,15 @@ function openFiles(item: ServerApiItem) {
router.push({ path: '/files', query: { server_id: String(item.id) } })
}
function editServer(item: ServerApiItem) {
void openEditServerDialog(item)
}
function confirmDelete(item: ServerApiItem) {
deletingServer.value = item
showDelete.value = true
}
// ── Batch category ──
const showBatchCategory = ref(false)
const batchCategoryValue = ref('')
@@ -1090,8 +1183,10 @@ const batchCategoryOptions = computed(() => {
return names.sort((a, b) => a.localeCompare(b, 'zh'))
})
function openBatchCategory() {
if (selectedIds.value.size === 0) {
function openBatchCategory(scope: BatchScope) {
batchScope.value = scope
const { ids } = batchSelection(scope)
if (ids.value.size === 0) {
snackbar('请先勾选至少一台服务器', 'error')
return
}
@@ -1100,14 +1195,15 @@ function openBatchCategory() {
}
async function submitBatchCategory() {
const ids = [...selectedIds.value]
if (ids.length === 0) return
const { items, ids } = batchSelection(batchScope.value)
const idList = [...ids.value]
if (idList.length === 0) return
batchCategoryLoading.value = true
try {
const category = (batchCategoryValue.value || '').trim()
await submitServerBatchJob('category', ids, { category })
await submitServerBatchJob('category', idList, { category })
showBatchCategory.value = false
selectedItems.value = []
items.value = []
} catch (e: unknown) {
snackbar(formatApiError(e, '批量修改分类失败'), 'error')
} finally {
@@ -1122,8 +1218,10 @@ const editingPathId = ref<number | null>(null)
const editingPathValue = ref('')
const savingPathId = ref<number | null>(null)
function openDetectPathConfirm() {
if (!selectedIds.value.size) {
function openDetectPathConfirm(scope: BatchScope) {
batchScope.value = scope
const { ids } = batchSelection(scope)
if (!ids.value.size) {
snackbar('请先选择服务器', 'warning')
return
}
@@ -1131,18 +1229,22 @@ function openDetectPathConfirm() {
}
async function runBatchDetectPath() {
const ids = normalizeServerIds(selectedItems.value)
if (!ids.length) return
const { items, ids } = batchSelection(batchScope.value)
const idList = [...ids.value]
if (!idList.length) return
showDetectPathConfirm.value = false
try {
await submitServerBatchJob('detect-path', ids)
await submitServerBatchJob('detect-path', idList)
items.value = []
} catch (e: unknown) {
snackbar(formatApiError(e, '提交检测任务失败'), 'error')
}
}
async function batchHealthCheck() {
const ids = normalizeServerIds(selectedItems.value)
async function batchHealthCheck(scope: BatchScope) {
batchScope.value = scope
const { items } = batchSelection(scope)
const ids = normalizeServerIds(items.value)
if (!ids.length) {
snackbar('请先选择服务器', 'warning')
return
@@ -1154,8 +1256,10 @@ async function batchHealthCheck() {
}
}
async function batchInstallAgent() {
const ids = normalizeServerIds(selectedItems.value)
async function batchInstallAgent(scope: BatchScope) {
batchScope.value = scope
const { items } = batchSelection(scope)
const ids = normalizeServerIds(items.value)
if (!ids.length) {
snackbar('请先选择服务器', 'warning')
return
@@ -1167,8 +1271,10 @@ async function batchInstallAgent() {
}
}
async function batchUpgradeAgent() {
const ids = normalizeServerIds(selectedItems.value)
async function batchUpgradeAgent(scope: BatchScope) {
batchScope.value = scope
const { items } = batchSelection(scope)
const ids = normalizeServerIds(items.value)
if (!ids.length) {
snackbar('请先选择服务器', 'warning')
return
@@ -1181,8 +1287,10 @@ async function batchUpgradeAgent() {
}
}
async function batchUninstallAgent() {
const ids = normalizeServerIds(selectedItems.value)
async function batchUninstallAgent(scope: BatchScope) {
batchScope.value = scope
const { items } = batchSelection(scope)
const ids = normalizeServerIds(items.value)
if (!ids.length) {
snackbar('请先选择服务器', 'warning')
return
@@ -1223,10 +1331,8 @@ async function saveTargetPath(serverId: number) {
await http.put(`/servers/${serverId}`, { target_path: tp })
snackbar('目标路径已更新')
cancelEditPath()
loadServers()
if (detailServer.value?.id === serverId) {
detailServer.value = { ...detailServer.value, target_path: tp }
}
await Promise.all([loadServers(), loadUnsetPathServers()])
clearExpandedForPathMigration(serverId, tp)
} catch (e: unknown) {
snackbar(formatApiError(e, '保存失败'), 'error')
} finally {
@@ -1234,39 +1340,20 @@ async function saveTargetPath(serverId: number) {
}
}
async function confirmBatchDelete() {
if (!confirm(`确定删除选中的 ${selectedIds.value.size} 台服务器?`)) return
for (const id of selectedIds.value) {
async function confirmBatchDelete(scope: BatchScope) {
batchScope.value = scope
const { items, ids } = batchSelection(scope)
if (!ids.value.size) return
if (!confirm(`确定删除选中的 ${ids.value.size} 台服务器?`)) return
for (const id of ids.value) {
try { await http.delete(`/servers/${id}`) } catch { /* skip individual errors */ }
}
selectedItems.value = []
items.value = []
snackbar('批量删除完成')
loadServers()
await Promise.all([loadServers(), loadUnsetPathServers()])
loadStats()
}
// ── CSV export ──
async function exportCSV() {
try {
const res = await http.getList<ServerApiItem>('/servers/', { per_page: 5000 })
const headers = ['ID', '名称', '地址', '端口', '用户名', '分类', '状态', 'Agent版本', '最后心跳']
const rows = (res.items || []).map(s => [
s.id, s.name, s.domain, s.port, s.username, s.category || '', s.status,
s.agent_version || '', s.last_heartbeat || '',
])
const bom = ''
const csv = bom + [headers.join(','), ...rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(','))].join('\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `servers_${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
snackbar('导出完成')
} catch (e: any) { snackbar(e.message || '导出失败', 'error') }
}
// ── Batch add by IP ──
const showBatchAdd = ref(false)
const batchAddText = ref('')
@@ -1348,42 +1435,20 @@ async function submitBatchAdd() {
}
}
// ── Server detail panel ──
async function showDetail(item: ServerApiItem) {
detailServer.value = item
detailTab.value = 'info'
syncLogsLoading.value = true
syncLogs.value = []
try {
const res = await http.get<PushItem[] | { items: PushItem[] }>(`/servers/${item.id}/logs`, { limit: 20 })
syncLogs.value = parseServerSyncLogs(res)
} catch {
syncLogs.value = []
} finally {
syncLogsLoading.value = false
}
}
function editServer(item: ServerApiItem) {
void openEditServerDialog(item)
}
function confirmDelete(item: ServerApiItem) {
deletingServer.value = item
showDelete.value = true
}
async function doDelete() {
if (!deletingServer.value) return
const deletedId = deletingServer.value.id
deleting.value = true
try {
await http.delete(`/servers/${deletingServer.value.id}`)
await http.delete(`/servers/${deletedId}`)
showDelete.value = false
loadServers()
expandedMainIds.value = expandedMainIds.value.filter(id => id !== deletedId)
expandedUnsetIds.value = expandedUnsetIds.value.filter(id => id !== deletedId)
await Promise.all([loadServers(), loadUnsetPathServers()])
loadStats()
snackbar('已删除')
} catch (e: any) {
snackbar(e.message || '删除失败', 'error')
} catch (e: unknown) {
snackbar(formatApiError(e, '删除失败'), 'error')
} finally {
deleting.value = false
}
@@ -1400,3 +1465,28 @@ async function refreshServersPage(silent = false) {
usePageActivateRefresh((silent) => refreshServersPage(silent))
</script>
<style scoped>
.server-main-table :deep(.v-data-table__td--expand-column),
.server-main-table :deep(.v-data-table-column--expand),
.server-main-table :deep(th.v-data-table__th--expand) {
display: none !important;
width: 0 !important;
padding: 0 !important;
}
.server-main-table :deep(tr.v-data-table__expanded__row > td) {
background: transparent !important;
border-bottom: none;
}
.servers-category-panels :deep(.v-expansion-panel-text__wrapper) {
background: color-mix(in srgb, rgb(var(--v-theme-surface)) 95%, rgb(var(--v-theme-on-surface)));
color: rgb(var(--v-theme-on-surface));
}
.servers-category-panels :deep(.v-table) {
background: transparent;
color: rgb(var(--v-theme-on-surface));
}
</style>
+13 -5
View File
@@ -10,6 +10,7 @@
</v-card-title>
<v-progress-linear v-if="settingsLoading" indeterminate color="primary" />
<v-divider />
<v-form ref="systemSettingsFormRef" @submit.prevent="saveSettings">
<v-card-text>
<v-text-field v-model="settings.system_name" label="系统名称" variant="outlined" density="compact" class="mb-3" :rules="[required('系统名称')]" />
<v-text-field v-model="settings.system_title" label="系统标题" variant="outlined" density="compact" class="mb-3" :rules="[required('系统标题')]" />
@@ -32,17 +33,18 @@
<div class="text-subtitle-2 mb-3">连接池</div>
<v-row dense>
<v-col cols="6">
<v-text-field v-model="settings.db_pool_size" label="连接池大小" variant="outlined" density="compact" type="number" :rules="[required(), isNumber()]" />
<v-text-field v-model="settings.db_pool_size" label="连接池大小" variant="outlined" density="compact" type="number" :rules="[required('连接池大小'), numberRange(1, 1000, '连接池大小')]" />
</v-col>
<v-col cols="6">
<v-text-field v-model="settings.db_max_overflow" label="溢出连接数" variant="outlined" density="compact" type="number" :rules="[required(), isNumber()]" />
<v-text-field v-model="settings.db_max_overflow" label="溢出连接数" variant="outlined" density="compact" type="number" :rules="[required('溢出连接数'), numberRange(1, 1000, '溢出连接数')]" />
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="px-4 pb-4">
<v-spacer />
<v-btn color="primary" variant="flat" @click="saveSettings" :loading="saving">保存设置</v-btn>
<v-btn type="submit" color="primary" variant="flat" :loading="saving">保存设置</v-btn>
</v-card-actions>
</v-form>
</v-card>
<TerminalQuickCommandsSettings />
@@ -401,7 +403,7 @@ import { usePageActivateRefresh } from '@/composables/usePageActivateRefresh'
import { http } from '@/api'
import { useAuthStore } from '@/stores/auth'
import { useSnackbar } from '@/composables/useSnackbar'
import { required, numberRange, isNumber, matchOther } from '@/utils/validation'
import { required, numberRange, matchOther } from '@/utils/validation'
import TerminalQuickCommandsSettings from '@/components/TerminalQuickCommandsSettings.vue'
import type {
SettingsResponse,
@@ -445,6 +447,7 @@ const settings = ref({
const telegramTokenDraft = ref('')
const telegramTokenSet = ref(false)
const saving = ref(false)
const systemSettingsFormRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null)
const testingTg = ref(false)
const detectingTgChats = ref(false)
const tgChatDialog = ref(false)
@@ -562,13 +565,18 @@ async function loadAllowlist() {
// ── Actions ──
async function saveSettings() {
const form = systemSettingsFormRef.value
if (form) {
const { valid } = await form.validate()
if (!valid) return
}
saving.value = true
try {
const entries = Object.entries(settings.value).filter(([key]) =>
['system_name', 'system_title', 'cpu_alert_threshold', 'mem_alert_threshold', 'disk_alert_threshold', 'db_pool_size', 'db_max_overflow'].includes(key)
)
for (const [key, value] of entries) {
await http.put(`/settings/${key}`, { value })
await http.put(`/settings/${key}`, { value: String(value ?? '') })
}
snackbar('设置已保存')
} catch (e: unknown) {
+6 -45
View File
@@ -78,7 +78,12 @@
</template>
</TerminalArea>
<TerminalQuickBar :commands="quickCmds" @exec="execQuickCmd" />
<TerminalQuickBar
:commands="quickCmds"
:disconnect-disabled="!activeSession"
@exec="execQuickCmd"
@disconnect="disconnect"
/>
<TerminalCmdBar
ref="cmdBarRef"
@@ -312,48 +317,4 @@ onBeforeUnmount(() => {
:deep(.terminal-ctx-menu .v-icon) {
font-size: 1.35rem;
}
.terminal-root--classic :deep(.sh-keyword) {
color: #60a5fa;
font-weight: 500;
}
.terminal-root--classic :deep(.sh-builtin) {
color: #38bdf8;
}
.terminal-root--classic :deep(.sh-sudo) {
color: #f87171;
font-weight: 600;
}
.terminal-root--classic :deep(.sh-pipe),
.terminal-root--classic :deep(.sh-operator) {
color: #fbbf24;
font-weight: 600;
}
.terminal-root--classic :deep(.sh-redirect) {
color: #fb923c;
}
.terminal-root--classic :deep(.sh-string) {
color: #4ade80;
}
.terminal-root--classic :deep(.sh-flag) {
color: #c084fc;
}
.terminal-root--classic :deep(.sh-variable) {
color: #f472b6;
}
.terminal-root--classic :deep(.sh-backtick) {
color: #a78bfa;
}
.terminal-root--classic :deep(.sh-comment) {
color: #64748b;
font-style: italic;
}
.terminal-root--classic :deep(.sh-number) {
color: #fbbf24;
}
.terminal-root--classic :deep(.sh-path) {
color: #2dd4bf;
}
.terminal-root--classic :deep(.sh-text) {
color: #e2e8f0;
}
</style>
+4 -4
View File
@@ -71,10 +71,10 @@ export interface NotifyToggleItem {
}
export const NOTIFY_TOGGLE_ITEMS: NotifyToggleItem[] = [
{ key: 'notify_alert_cpu', label: 'CPU 超阈值告警', desc: 'CPU 使用率超过设定阈值时推送', group: '服务器资源' },
{ key: 'notify_alert_mem', label: '内存超阈值告警', desc: '内存使用率超过设定阈值时推送', group: '服务器资源' },
{ key: 'notify_alert_disk', label: '磁盘超阈值告警', desc: '磁盘使用率超过设定阈值时推送', group: '服务器资源' },
{ key: 'notify_recovery', label: '资源恢复正常', desc: 'CPU/内存/磁盘告警解除时推送', group: '服务器资源' },
{ key: 'notify_alert_cpu', label: 'CPU 超阈值告警', desc: 'CPU 超阈值或恢复时推送;关闭则该类告警与恢复均不通知', group: '服务器资源' },
{ key: 'notify_alert_mem', label: '内存超阈值告警', desc: '内存超阈值或恢复时推送;关闭则该类告警与恢复均不通知', group: '服务器资源' },
{ key: 'notify_alert_disk', label: '磁盘超阈值告警', desc: '磁盘超阈值或恢复时推送;关闭则该类告警与恢复均不通知', group: '服务器资源' },
{ key: 'notify_recovery', label: '资源恢复正常', desc: 'CPU/内存/磁盘回落正常时推送(须总开关开启且对应资源开关未关)', group: '服务器资源' },
{ key: 'notify_time_drift', label: '时钟偏差严重 (≥60s)', desc: '子机与主机时间偏差过大影响 TOTP/cron', group: '服务器' },
{ key: 'notify_system_redis', label: 'Redis 连接异常 / 恢复', desc: 'Redis 不可达或恢复时推送', group: '系统' },
{ key: 'notify_system_mysql', label: 'MySQL 连接异常 / 恢复', desc: 'MySQL 不可达或恢复时推送', group: '系统' },
+6 -13
View File
@@ -1,22 +1,15 @@
import { ApiError } from '@/api'
import { formatValidationDetail } from '@/utils/validationErrorFormat'
/** Turn FastAPI error bodies into a human-readable string. */
export function formatApiError(e: unknown, fallback: string): string {
let msg = fallback
if (e instanceof ApiError) {
const d = (e as ApiError & { detail?: unknown }).detail ?? e.message
if (typeof d === 'string') msg = d
else if (Array.isArray(d)) {
msg = d.map((x: { msg?: string; loc?: unknown[] }) => {
const part = x?.msg || JSON.stringify(x)
const loc = Array.isArray(x.loc) ? x.loc.filter((p) => p !== 'body').join('.') : ''
return loc ? `${loc}: ${part}` : part
}).join('; ')
} else if (d && typeof d === 'object') msg = JSON.stringify(d)
else msg = e.message || fallback
} else if (e instanceof Error) {
msg = e.message || fallback
return formatValidationDetail(d, e.message || fallback)
}
msg = msg.trim()
if (e instanceof Error) {
const msg = e.message.trim()
return msg || fallback
}
return fallback
}
+84
View File
@@ -0,0 +1,84 @@
/**
* User-visible absolute timestamps — always Asia/Shanghai (北京时间).
* API / DB store UTC; parse with parseApiDateTime before formatting.
*/
export const BEIJING_TZ = 'Asia/Shanghai'
/** Parse API/MySQL datetime (naive UTC or ISO Z) into a Date instant. */
export function parseApiDateTime(raw: string | null | undefined): Date | null {
if (raw == null) return null
const s = String(raw).trim()
if (!s) return null
let normalized = s
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(s)) {
normalized = `${s.replace(' ', 'T')}Z`
} else if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(s)) {
normalized = `${s}Z`
} else if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+$/.test(s)) {
normalized = `${s}Z`
}
const d = new Date(normalized)
return Number.isNaN(d.getTime()) ? null : d
}
function beijingParts(d: Date): Record<string, string> {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: BEIJING_TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).formatToParts(d)
return Object.fromEntries(
parts.filter((p) => p.type !== 'literal').map((p) => [p.type, p.value]),
)
}
function formatParts(d: Date, mode: 'datetime' | 'date' | 'time'): string {
const p = beijingParts(d)
if (mode === 'date') return `${p.year}-${p.month}-${p.day}`
if (mode === 'time') return `${p.hour}:${p.minute}:${p.second}`
return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}:${p.second}`
}
function resolveDate(raw: string | Date | null | undefined): Date | null {
if (raw instanceof Date) return Number.isNaN(raw.getTime()) ? null : raw
return parseApiDateTime(raw)
}
/** YYYY-MM-DD HH:mm:ss in Beijing. */
export function formatDateTimeBeijing(raw: string | Date | null | undefined): string {
const d = resolveDate(raw)
if (!d) {
if (typeof raw === 'string' && raw.trim()) {
return raw.replace('T', ' ').slice(0, 19)
}
return '—'
}
return formatParts(d, 'datetime')
}
/** YYYY-MM-DD in Beijing. */
export function formatDateBeijing(raw: string | Date | null | undefined): string {
const d = resolveDate(raw)
if (!d) return '—'
return formatParts(d, 'date')
}
/** HH:mm:ss in Beijing. */
export function formatTimeBeijing(raw: string | Date | null | undefined): string {
const d = resolveDate(raw)
if (!d) return '—'
return formatParts(d, 'time')
}
/** Today's date in Beijing (for filenames, etc.). */
export function beijingTodayYmd(): string {
return formatDateBeijing(new Date())
}
+5
View File
@@ -0,0 +1,5 @@
/** True when push target path is empty or BT default placeholder (mirrors backend posix_paths). */
export function isUnsetTargetPath(raw: string | null | undefined): boolean {
const p = (raw ?? '').trim()
return p === '' || p === '/www/wwwroot'
}
+4 -2
View File
@@ -4,6 +4,8 @@
* Extracted from DashboardPage and ServersPage.
*/
import { parseApiDateTime } from '@/utils/datetime'
/** Vuetify chip color for server status */
export function statusChipColor(status: string): string {
switch (status) {
@@ -25,8 +27,8 @@ export function statusLabel(status: string): string {
/** Relative time formatting (Chinese) */
export function formatRelativeTime(t: string | null): string {
if (!t) return '—'
const date = new Date(t)
if (isNaN(date.getTime())) return '—'
const date = parseApiDateTime(t)
if (!date) return '—'
const diff = Date.now() - date.getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
+13
View File
@@ -0,0 +1,13 @@
/** Decode WebSSH DATA frame payload for xterm.write (base64 preserves ANSI ESC bytes). */
export function decodeTerminalPayload(msg: {
data_b64?: string
data?: string
}): string | Uint8Array {
if (typeof msg.data_b64 === 'string' && msg.data_b64.length > 0) {
const bin = atob(msg.data_b64)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
return bytes
}
return msg.data ?? ''
}
@@ -0,0 +1,37 @@
/** Format FastAPI 422 validation detail items for display (Chinese). */
const LOC_PREFIX_SKIP = new Set(['body', 'query', 'path', 'header', 'cookie'])
export function formatValidationDetailItem(x: {
msg?: string
loc?: unknown[]
loc_zh?: string
}): string {
const part = (x?.msg || '').trim()
if (x?.loc_zh) {
return part ? `${x.loc_zh}${part}` : x.loc_zh
}
const loc = Array.isArray(x?.loc)
? x.loc.filter((p) => !LOC_PREFIX_SKIP.has(String(p)))
: []
const locStr = loc
.map((p) => (typeof p === 'number' ? `${p + 1}` : String(p)))
.join('.')
if (locStr && part) return `${locStr}${part}`
return part || locStr || JSON.stringify(x)
}
export function formatValidationDetail(detail: unknown, fallback: string): string {
if (typeof detail === 'string') {
const s = detail.trim()
return s || fallback
}
if (Array.isArray(detail)) {
const msg = detail.map((x) => formatValidationDetailItem(x as { msg?: string; loc?: unknown[]; loc_zh?: string })).join('')
return msg.trim() || fallback
}
if (detail && typeof detail === 'object') {
return JSON.stringify(detail)
}
return fallback
}
+19 -8
View File
@@ -128,21 +128,31 @@ def _detect_alerts(system_info: dict, thresholds: dict) -> list:
return alerts
def _alert_metric_key(entry: str) -> str | None:
"""Parse Redis alert set member — supports legacy ``metric:value`` and ``metric``."""
if not entry:
return None
metric = entry.split(":", 1)[0].strip()
return metric or None
def _detect_recovery(system_info: dict, prev_alerts: set, thresholds: dict) -> list:
"""Detect previously alerted metrics that have returned to normal
Returns list of (metric, current_value) tuples.
Returns list of (metric, current_value) tuples — at most one entry per metric
even when Redis still holds legacy ``metric:value`` duplicates.
"""
recoveries = []
recoveries: list[tuple[str, float]] = []
seen_metrics: set[str] = set()
for prev in prev_alerts:
parts = prev.split(":")
if len(parts) != 2:
metric = _alert_metric_key(prev)
if not metric or metric in seen_metrics:
continue
metric = parts[0]
current = _safe_float(
system_info.get(f"{metric}_usage") or system_info.get(metric), metric
)
if current is not None and current < _threshold_for(thresholds, metric):
seen_metrics.add(metric)
recoveries.append((metric, current))
return recoveries
@@ -277,7 +287,8 @@ async def receive_heartbeat(
# Track active alert in Redis
try:
redis = get_redis()
await redis.sadd(f"{REDIS_ALERT_KEY_PREFIX}{server_id}", f"{alert_type}:{alert_value}")
# Store metric name only — value in member caused duplicate recovery pushes
await redis.sadd(f"{REDIS_ALERT_KEY_PREFIX}{server_id}", alert_type)
await redis.expire(f"{REDIS_ALERT_KEY_PREFIX}{server_id}", 3600) # 1h TTL
except Exception:
logger.debug(f"Failed to track alert in Redis for server {server_id}")
@@ -293,9 +304,9 @@ async def receive_heartbeat(
logger.info(f"Recovery: server {server_id} {metric}={value}%")
await broadcast_recovery(server_id, metric, value, server_name)
# Remove recovered alert from Redis tracking
# Remove recovered metric (legacy metric:value members included)
for prev in prev_alerts:
if prev.startswith(f"{metric}:"):
if _alert_metric_key(prev) == metric:
await redis.srem(f"{REDIS_ALERT_KEY_PREFIX}{server_id}", prev)
except Exception as e:
logger.error(f"Recovery detection failed for server {server_id}: {e}")
+19 -4
View File
@@ -16,8 +16,23 @@ from server.api.schema_path_validators import (
# ── Setting ──
# 与 SyncFiles / ScriptExecute 一致:批量选机上限
SERVER_IDS_BATCH_MAX = 2000
class SettingUpdatePayload(BaseModel):
value: str = Field(..., min_length=0)
value: str = Field(..., min_length=0, description="设置值(字符串存储)")
@field_validator("value", mode="before")
@classmethod
def _coerce_value_to_str(cls, v: object) -> object:
if v is None:
return ""
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, (int, float)):
return str(int(v)) if isinstance(v, float) and v == int(v) else str(v)
return v
class ApiKeyRevealRequest(BaseModel):
@@ -104,7 +119,7 @@ class ServerUpdate(BaseModel):
class ServerCheck(BaseModel):
server_ids: List[int] = Field(..., min_length=1, max_length=50)
server_ids: List[int] = Field(..., min_length=1, max_length=SERVER_IDS_BATCH_MAX)
class ServerImportResult(BaseModel):
@@ -119,7 +134,7 @@ class ServerImportResult(BaseModel):
class BatchAgentAction(BaseModel):
"""Batch install/upgrade Agent on multiple servers."""
server_ids: List[int] = Field(..., min_length=1, max_length=50)
server_ids: List[int] = Field(..., min_length=1, max_length=SERVER_IDS_BATCH_MAX)
class BatchAgentResultItem(BaseModel):
@@ -136,7 +151,7 @@ class BatchAgentResult(BaseModel):
class BatchCategoryUpdate(BaseModel):
"""Batch update server category for selected IDs."""
server_ids: List[int] = Field(..., min_length=1, max_length=200)
server_ids: List[int] = Field(..., min_length=1, max_length=SERVER_IDS_BATCH_MAX)
category: str = Field("", max_length=100, description="分类名称;留空表示清除分类")
+26 -2
View File
@@ -60,6 +60,9 @@ async def list_servers(
sort_by: Optional[str] = Query(None, description="Sort field: name/domain/status/heartbeat/agent_version/category/id"),
sort_order: Optional[str] = Query("asc", description="Sort direction: asc or desc"),
is_online: Optional[bool] = Query(None, description="Filter by online status"),
target_path_unset: Optional[bool] = Query(
None, description="Filter unset target paths: true=only unset, false=exclude unset",
),
page: int = Query(1, ge=1, description="Page number"),
per_page: int = Query(50, ge=1, le=200, description="Items per page"),
admin: Admin = Depends(get_current_admin),
@@ -91,6 +94,7 @@ async def list_servers(
node_id,
search=search,
is_online=db_online_filter,
target_path_unset=target_path_unset,
max_rows=MAX_LIVE_SORT_ROWS,
)
if db_total > MAX_LIVE_SORT_ROWS:
@@ -116,6 +120,7 @@ async def list_servers(
sort_by=sort_by,
sort_order=order,
is_online=is_online,
target_path_unset=target_path_unset,
)
result = await _build_server_list_with_overlay(redis, page_servers)
if is_online is not None:
@@ -1458,6 +1463,24 @@ async def install_agent_remote(
if not server:
raise HTTPException(status_code=404, detail="Server not found")
from server.application.server_batch_common import agent_install_skip_result
from server.utils.agent_version import get_central_agent_version
skip = await agent_install_skip_result(
server=server,
server_id=id,
server_name=server.name or str(id),
central_version=get_central_agent_version(),
)
if skip is not None:
return {
"success": True,
"server_id": id,
"server_name": server.name,
"stdout": skip.get("stdout", ""),
"skipped": True,
}
# Validate prerequisites
base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
if not base_url:
@@ -1701,7 +1724,8 @@ async def upgrade_agent(
detail="NEXUS_API_BASE_URL 未配置,无法构建下载地址",
)
agent_url = f"{base_url}/agent/agent.py"
from server.application.server_batch_common import agent_files_curl_cmds
install_dir = "/opt/nexus-agent"
venv_python = f"{install_dir}/.venv/bin/python"
ssh_user = (server.username or "root").strip()
@@ -1753,7 +1777,7 @@ async def upgrade_agent(
upgrade_cmd = _sudo_wrap(
f"{pip_cmd} "
f"&& {backup_cmd} "
f"&& curl -fsSL {shlex.quote(agent_url)} -o {install_dir}/agent.py "
f"&& {agent_files_curl_cmds(base_url, install_dir)} "
f"&& {kill_port}{systemctl_prefix}systemctl restart nexus-agent "
f"&& sleep 2 "
f"&& {systemctl_prefix}systemctl is-active nexus-agent "
+7 -13
View File
@@ -385,17 +385,10 @@ async def set_setting(
import logging
_logger = logging.getLogger("nexus.settings")
from server.config import settings as _settings
attr_name = _settings.DB_OVERRIDE_MAP.get(key)
if attr_name and hasattr(_settings, attr_name):
value = setting.value
if attr_name in _settings.INT_SETTINGS:
try:
value = int(value)
except (ValueError, TypeError):
_logger.warning("hot-reload: %s'%s' 无法转为 int,已跳过热更新", attr_name, value)
value = None # Skip setattr for invalid int
if value is not None:
setattr(_settings, attr_name, value)
from server.infrastructure.settings_broadcast import publish_setting_change
if _settings.apply_db_override(key, setting.value):
await publish_setting_change(key, setting.value)
# Audit log
audit_repo = AuditLogRepositoryImpl(db)
@@ -1032,8 +1025,9 @@ async def telegram_test_send(
if not _settings.TELEGRAM_CHAT_ID:
raise HTTPException(status_code=400, detail="TELEGRAM_CHAT_ID 未配置,请先检测并填写")
from datetime import datetime, timezone
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
from server.utils.display_time import format_beijing_now
now = format_beijing_now()
sys_name = html.escape((_settings.SYSTEM_NAME or "Nexus").strip())
message = (
f"✅ <b>【Telegram 配置测试】消息发送成功</b>\n"
+34 -5
View File
@@ -5,6 +5,7 @@ P1: POST /api/sync/preview — rsync dry-run (方案D: 智能提示预览)
"""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request
@@ -77,18 +78,46 @@ async def sync_files(
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""S1: File sync via rsync over SSH (with parallel execution)"""
"""S1: Accept file push and run rsync in background (progress via /ws/sync)."""
from server.application.services.sync_engine_v2 import _nexus_source_path
from server.background.sync_push_runner import schedule_sync_files
engine = await _get_sync_engine(request)
return await engine.sync_files(
server_ids=payload.server_ids,
source_path=payload.source_path,
try:
source_path = _nexus_source_path(payload.source_path)
except PosixPathError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
servers = await engine.server_repo.get_by_ids(payload.server_ids)
if not servers:
raise HTTPException(status_code=400, detail="未选择有效服务器")
found_ids = {s.id for s in servers}
missing = [sid for sid in payload.server_ids if sid not in found_ids]
if missing:
preview = ", ".join(str(x) for x in missing[:5])
suffix = f"{len(missing)}" if len(missing) > 5 else ""
raise HTTPException(status_code=400, detail=f"服务器不存在: {preview}{suffix}")
batch_id = (payload.batch_id or uuid.uuid4().hex[:12])[:12]
schedule_sync_files(
server_ids=list(payload.server_ids),
source_path=source_path,
target_path=payload.target_path,
sync_mode=payload.sync_mode,
operator=admin.username,
batch_size=payload.batch_size,
concurrency=payload.concurrency,
batch_id=payload.batch_id,
batch_id=batch_id,
)
return {
"accepted": True,
"batch_id": batch_id,
"total": len(servers),
"completed": 0,
"failed": 0,
"cancelled": 0,
"results": {},
}
@router.post("/preview")
+20 -7
View File
@@ -2,10 +2,11 @@
"""
import asyncio
import base64
import json
import logging
import uuid
from typing import Optional
from typing import Optional, Union
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query, Path
@@ -34,6 +35,20 @@ MSG_PONG = "PONG"
# Command logging configuration
COMMAND_LOG_MAX_LENGTH = 2000
# PTY environment for ANSI / 256-color remote programs (ls, grep, vim, etc.)
WEBSSH_PTY_ENV = {
"TERM": "xterm-256color",
"COLORTERM": "truecolor",
"FORCE_COLOR": "1",
"CLICOLOR_FORCE": "1",
}
def _terminal_payload_b64(data: Union[str, bytes]) -> str:
"""Base64-encode shell stdout for JSON WebSocket (preserves ANSI ESC bytes)."""
raw = data.encode("utf-8", errors="surrogateescape") if isinstance(data, str) else data
return base64.b64encode(raw).decode("ascii")
async def _verify_webssh_token(token: str):
"""Verify JWT token for WebSSH — returns (admin, server_id) or (None, None)
@@ -186,6 +201,7 @@ async def terminal_ws(
term_type="xterm-256color",
term_size=(24, 80),
bufsize=4096, # Small buffer for low-latency interactive echo
env=WEBSSH_PTY_ENV,
)
except Exception as e:
# Stale pooled connection — force-close and retry with fresh connection
@@ -197,6 +213,7 @@ async def terminal_ws(
term_type="xterm-256color",
term_size=(24, 80),
bufsize=4096, # Small buffer for low-latency interactive echo
env=WEBSSH_PTY_ENV,
)
except Exception as e2:
logger.error(f"WebSSH shell creation failed on fresh connection (server={server.id}): {type(e2).__name__}: {e2!r}")
@@ -221,11 +238,7 @@ async def terminal_ws(
"server_id": server.id,
})
# Color + CWD: ANSI/256/truecolor for ls, grep, vim, etc.
shell.stdin.write(
"export TERM=xterm-256color COLORTERM=truecolor "
"FORCE_COLOR=1 CLICOLOR_FORCE=1\n"
)
# Window title via OSC sequence (TERM/color env set on PTY via create_process)
shell.stdin.write('export PROMPT_COMMAND=\'echo -ne "\\033]0;${PWD}\\a"\'\n')
# ── Bidirectional relay ──
@@ -245,7 +258,7 @@ async def terminal_ws(
if data:
await websocket.send_json({
"type": MSG_DATA,
"data": data,
"data_b64": _terminal_payload_b64(data),
})
except Exception: # noqa: S110 — SSH read loop, connection teardown
pass
+82
View File
@@ -159,6 +159,85 @@ async def mark_agent_uninstalled(sid: int) -> None:
await session.commit()
_REMOTE_AGENT_VERSION_CMD = (
"if [ -f /opt/nexus-agent/agent.py ]; then "
"grep -m1 '^AGENT_VERSION' /opt/nexus-agent/agent.py 2>/dev/null | "
"sed -n 's/.*AGENT_VERSION *= *\"\\([^\"]*\\)\".*/\\1/p'; fi"
)
def agent_files_curl_cmds(base_url: str, install_dir: str = "/opt/nexus-agent") -> str:
"""Download agent.py and heartbeat_policy.py from central /agent/."""
root = base_url.rstrip("/")
return (
f"curl -fsSL {shlex.quote(root + '/agent/agent.py')} -o {install_dir}/agent.py "
f"&& curl -fsSL {shlex.quote(root + '/agent/heartbeat_policy.py')} "
f"-o {install_dir}/heartbeat_policy.py"
)
async def _redis_agent_version(server_id: int) -> str:
from server.infrastructure.redis.client import get_redis
try:
redis = get_redis()
data = await redis.hgetall(f"heartbeat:{server_id}")
if data:
return (data.get("agent_version") or "").strip()
except Exception as e:
logger.warning("Redis agent_version read failed server_id=%s: %s", server_id, e)
return ""
async def resolve_remote_agent_version(server: Server, *, ssh_user: str) -> str:
"""Best-effort remote version: Redis heartbeat → MySQL → SSH grep agent.py."""
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
for candidate in (
await _redis_agent_version(server.id),
(server.agent_version or "").strip(),
):
if candidate:
return candidate
try:
cmd = sudo_wrap(_REMOTE_AGENT_VERSION_CMD, ssh_user)
r = await exec_ssh_command(server, cmd, timeout=15)
if r["exit_code"] == 0:
return (r.get("stdout") or "").strip().split("\n")[0].strip()
except Exception as e:
logger.warning("SSH agent_version probe failed server_id=%s: %s", server.id, e)
return ""
async def agent_install_skip_result(
*,
server: Server,
server_id: int,
server_name: str,
central_version: str,
) -> dict | None:
"""Return success result dict when install can be skipped; None to proceed with install."""
from server.utils.agent_version import is_version_at_least
has_key = bool((server.agent_api_key or "").strip())
db_version = (server.agent_version or "").strip()
if not has_key and not db_version:
return None
ssh_user = (server.username or "root").strip()
remote_version = await resolve_remote_agent_version(server, ssh_user=ssh_user)
if remote_version and is_version_at_least(remote_version, central_version):
return result_item_dict(
server_id=server_id,
server_name=server_name,
success=True,
stdout=(
f"已安装 Agent {remote_version},不低于主站 {central_version},已跳过"
),
)
return None
def result_item_dict(
*,
server_id: int,
@@ -177,6 +256,8 @@ def result_item_dict(
__all__ = [
"agent_files_curl_cmds",
"agent_install_skip_result",
"batch_server_display_name",
"ensure_agent_api_key",
"generate_agent_api_key_value",
@@ -184,6 +265,7 @@ __all__ = [
"mark_agent_uninstalled",
"posix_dirname",
"prefetch_batch_context",
"resolve_remote_agent_version",
"result_item_dict",
"sudo_wrap",
"update_server_target_path",
@@ -8,6 +8,8 @@ import shlex
from typing import Any, Optional
from server.application.server_batch_common import (
agent_files_curl_cmds,
agent_install_skip_result,
batch_server_display_name,
ensure_agent_api_key,
install_error_msg,
@@ -18,6 +20,7 @@ from server.application.server_batch_common import (
sudo_wrap,
update_server_target_path,
)
from server.utils.agent_version import get_central_agent_version
from server.config import settings
from server.domain.models import AuditLog, Server
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
@@ -91,6 +94,106 @@ async def _notify_complete(live: dict[str, Any]) -> None:
logger.warning("server batch complete notify failed: %s", e)
async def _reconcile_live_job(
live: dict[str, Any],
*,
error: str,
auto_stuck: bool,
) -> bool:
"""Fill pending servers as failed, finalize, persist, audit, notify. Returns True if reconciled."""
if live.get("status") != "running":
return False
pending = sbs.pending_server_ids(live)
if not pending and sbs.progress_stats(live)["remaining"] == 0:
sbs.finalize_status(live)
await sbs.save_live(live)
else:
added = sbs.apply_pending_failures(
live,
server_ids=pending,
error=error,
auto_stuck=auto_stuck,
)
if added == 0 and not pending:
return False
sbs.finalize_status(live)
await sbs.save_live(live)
await _persist_job_finalize(live)
await _write_audit(live)
await _notify_complete(live)
job_id = int(live["id"])
logger.info(
"Server batch job reconciled job_id=%s op=%s status=%s auto_stuck=%s",
job_id,
live.get("op"),
live.get("status"),
auto_stuck,
)
return True
async def recover_orphaned_batch_jobs() -> int:
"""On API startup: finalize batch jobs left running after process restart."""
from server.infrastructure.database.server_batch_job_repo import (
ServerBatchJobRepositoryImpl,
)
reconciled_ids: set[int] = set()
count = 0
for live in await sbs.list_running_live_jobs():
job_id = int(live["id"])
if job_id in reconciled_ids:
continue
if await _reconcile_live_job(
live,
error=sbs.INTERRUPTED_MESSAGE,
auto_stuck=False,
):
reconciled_ids.add(job_id)
count += 1
async with AsyncSessionLocal() as session:
repo = ServerBatchJobRepositoryImpl(session)
for row in await repo.list_running():
job_id = int(row.id)
if job_id in reconciled_ids:
continue
live = await sbs.get_live(job_id)
if live is None:
live = sbs.live_from_mysql_row(row)
elif live.get("status") != "running":
continue
if await _reconcile_live_job(
live,
error=sbs.INTERRUPTED_MESSAGE,
auto_stuck=False,
):
reconciled_ids.add(job_id)
count += 1
if count:
logger.info("Recovered %s orphaned server batch job(s) after startup", count)
return count
async def detect_stuck_batch_jobs() -> int:
"""Mark running batch jobs with no progress for STUCK_NO_PROGRESS_SECONDS."""
count = 0
for live in await sbs.list_running_live_jobs():
if not sbs.is_stale_running(live):
continue
if await _reconcile_live_job(
live,
error=sbs.STUCK_MESSAGE,
auto_stuck=True,
):
count += 1
if count:
logger.info("Auto-reconciled %s stuck server batch job(s)", count)
return count
async def start_batch_job(
*,
op: str,
@@ -234,7 +337,9 @@ async def _run_background(job_id: int) -> None:
await sbs.save_live(live)
return
live = await sbs.get_live(job_id)
if live and live.get("status") == "running":
if live:
stats = sbs.progress_stats(live)
if live.get("status") == "running" or stats["remaining"] == 0:
sbs.finalize_status(live)
await sbs.save_live(live)
except Exception:
@@ -245,6 +350,9 @@ async def _run_background(job_id: int) -> None:
await sbs.save_live(live)
live = await sbs.get_live(job_id)
if live:
if live.get("status") == "running":
sbs.finalize_status(live)
await sbs.save_live(live)
await _persist_job_finalize(live)
await _write_audit(live)
await _notify_complete(live)
@@ -425,12 +533,22 @@ async def _run_install_agent(live: dict[str, Any]) -> None:
))
return
central_version = get_central_agent_version()
async def handler(sid: int, server_map: dict[int, Server], labels: dict[int, str], _live) -> dict:
server = server_map.get(sid)
label = labels[sid]
if not server:
return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在")
try:
skip = await agent_install_skip_result(
server=server,
server_id=sid,
server_name=label,
central_version=central_version,
)
if skip is not None:
return skip
api_key = await ensure_agent_api_key(server)
ssh_user = (server.username or "root").strip()
install_cmd = (
@@ -471,7 +589,6 @@ async def _run_upgrade_agent(live: dict[str, Any]) -> None:
if not server:
return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在")
try:
agent_url = f"{base_url}/agent/agent.py"
install_dir = "/opt/nexus-agent"
venv_python = f"{install_dir}/.venv/bin/python"
ssh_user = (server.username or "root").strip()
@@ -482,7 +599,7 @@ async def _run_upgrade_agent(live: dict[str, Any]) -> None:
f"&& {venv_python} -c 'import sys; v=sys.version_info; sys.exit(0 if v.major==3 and v.minor>=10 else 1)' "
f"&& {venv_python} -m pip install -q fastapi==0.115.6 uvicorn==0.34.0 httpx==0.28.1 psutil python-multipart==0.0.19 "
f"&& cp {install_dir}/agent.py {install_dir}/agent.py.bak "
f"&& curl -fsSL {shlex.quote(agent_url)} -o {install_dir}/agent.py "
f"&& {agent_files_curl_cmds(base_url, install_dir)} "
f"&& {batch_kill_port}{batch_systemctl_prefix}systemctl restart nexus-agent "
f"&& sleep 2 "
f"&& {batch_systemctl_prefix}systemctl is-active nexus-agent "
+3 -2
View File
@@ -80,8 +80,9 @@ async def _do_refresh():
settings.LOGIN_SUBSCRIPTION_IPS = sub_val
settings.LOGIN_ALLOWED_IPS = combined_val
from datetime import datetime, timezone
_last_refresh_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
from server.utils.display_time import format_beijing
_last_refresh_at = format_beijing(with_label=False)
_last_subscription_count = len(new_sub_ips)
logger.info(
"Subscription IPs refreshed: %d → combined %d (+ %d manual)",
@@ -0,0 +1,27 @@
"""Background reconciliation for server batch jobs — stuck detection (60s interval)."""
from __future__ import annotations
import asyncio
import logging
from server.application.services.server_batch_service import detect_stuck_batch_jobs
from server.infrastructure.redis.server_batch_store import RECONCILE_INTERVAL_SECONDS
logger = logging.getLogger("nexus.server_batch_reconcile")
async def server_batch_reconcile_loop() -> None:
"""Every minute: auto-finalize batch jobs with no Redis progress for 1h."""
logger.info(
"Server batch reconcile loop started (interval=%ss)",
RECONCILE_INTERVAL_SECONDS,
)
while True:
await asyncio.sleep(RECONCILE_INTERVAL_SECONDS)
try:
stuck = await detect_stuck_batch_jobs()
if stuck:
logger.info("Server batch reconcile: %s stuck job(s) finalized", stuck)
except Exception as e:
logger.error("Server batch reconcile loop error: %s", e)
+42
View File
@@ -0,0 +1,42 @@
"""Periodic purge of sync_logs older than SYNC_LOG_RETENTION_DAYS (design: 30 days)."""
from __future__ import annotations
import asyncio
import logging
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.database.sync_log_repo import (
SYNC_LOG_RETENTION_DAYS,
SyncLogRepositoryImpl,
)
logger = logging.getLogger("nexus.sync_log_purge")
PURGE_INTERVAL_SECONDS = 24 * 3600
async def purge_stale_sync_logs() -> int:
"""Delete sync_logs rows older than retention window. Returns rows removed."""
async with AsyncSessionLocal() as session:
repo = SyncLogRepositoryImpl(session)
return await repo.purge_older_than(retention_days=SYNC_LOG_RETENTION_DAYS)
async def sync_log_purge_loop() -> None:
"""Run daily on primary worker; also invoked once at API startup."""
while True:
try:
count = await purge_stale_sync_logs()
if count:
logger.info(
"Sync log purge: removed %s row(s) older than %s days",
count,
SYNC_LOG_RETENTION_DAYS,
)
await asyncio.sleep(PURGE_INTERVAL_SECONDS)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Sync log purge loop error")
await asyncio.sleep(PURGE_INTERVAL_SECONDS)
+59
View File
@@ -0,0 +1,59 @@
"""Background file push — API returns immediately; progress via WebSocket."""
from __future__ import annotations
import logging
from typing import List, Optional
logger = logging.getLogger("nexus.sync_push_runner")
async def run_sync_files_background(
*,
server_ids: List[int],
source_path: str,
target_path: Optional[str],
sync_mode: str,
operator: str,
batch_size: int,
concurrency: int,
batch_id: str,
) -> None:
"""Execute sync_files in an isolated DB session (not request-scoped)."""
from server.application.services.sync_engine_v2 import SyncEngineV2
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
try:
async with AsyncSessionLocal() as session:
engine = SyncEngineV2(
server_repo=ServerRepositoryImpl(session),
sync_log_repo=SyncLogRepositoryImpl(session),
audit_repo=AuditLogRepositoryImpl(session),
retry_repo=PushRetryJobRepositoryImpl(session),
)
await engine.sync_files(
server_ids=server_ids,
source_path=source_path,
target_path=target_path,
sync_mode=sync_mode,
operator=operator,
batch_size=batch_size,
concurrency=concurrency,
batch_id=batch_id,
)
except Exception:
logger.exception("Background sync_files failed: batch_id=%s", batch_id)
def schedule_sync_files(**kwargs) -> None:
"""Fire-and-forget push task on the running event loop."""
import asyncio
asyncio.create_task(
run_sync_files_background(**kwargs),
name=f"sync_push:{kwargs.get('batch_id', '')}",
)
+20
View File
@@ -199,6 +199,26 @@ class Settings(BaseSettings):
logger.error(f"Failed to load settings from DB: {e}")
return 0
def apply_db_override(self, db_key: str, value: str) -> bool:
"""Apply one MySQL settings row to this in-memory instance.
Used by PUT hot-reload and Redis Pub/Sub cross-worker sync.
"""
attr_name = self.DB_OVERRIDE_MAP.get(db_key)
if not attr_name or not hasattr(self, attr_name):
return False
if value is None:
return False
parsed: str | int = value
if attr_name in self.INT_SETTINGS:
try:
parsed = int(value)
except (ValueError, TypeError):
logger.warning("Invalid int value for %s: %s, skipping hot-reload", db_key, value)
return False
setattr(self, attr_name, parsed)
return True
async def ensure_api_base_url_in_db(self, session: AsyncSession) -> None:
"""Migrate api_base_url from .env to MySQL settings table if missing.

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