Compare commits
20 Commits
835ef2c8ea
...
091fb97291
| Author | SHA1 | Date | |
|---|---|---|---|
| 091fb97291 | |||
| abeab9d3e4 | |||
| 972456a246 | |||
| 69068e2e39 | |||
| a401ea5a4d | |||
| 632891e6e5 | |||
| ff143c286d | |||
| 85ecd1f54f | |||
| 05c7aeb702 | |||
| d0526c06b7 | |||
| cb49fd9d6d | |||
| fe8b24ca0b | |||
| 6612c76dc6 | |||
| 86bc737f4c | |||
| 27da842a20 | |||
| a5b1d34cec | |||
| 1e83bb34bd | |||
| 8c86b50d09 | |||
| 8312a6cf81 | |||
| 1d1fb6285b |
@@ -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 FINDING(1 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 输出区 | PASS(PTY 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 批量 DELETE,retention=30d | PASS |
|
||||
| 批量安装跳过:版本 SSOT 来自 agent.py,SSH 探测有超时 | 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/内存/磁盘告警关闭后恢复不推 | PASS(parametrize 单测 3 指标) |
|
||||
| Redis/MySQL 恢复共用 notify_system_* | PASS(已有 `send_telegram_system_alert` 门控 + 单测) |
|
||||
| WebSocket / alert_logs 仍记录恢复 | PASS(仅 Telegram 门控) |
|
||||
| 设置页文案说明联动 | PASS(api.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 不发送 | PASS(helper + 单测 9 开关) |
|
||||
| 多 worker 热更新 | PASS(Redis `nexus:settings` 广播) |
|
||||
| WebSocket / alert_logs 仍记录 | PASS(仅 Telegram 门控,符合设置页文案) |
|
||||
| expanded 与 item-value 数字 id 一致 | PASS(number[] + computed 桥接) |
|
||||
| 无静默吞错 | PASS(broadcast 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% | PASS(color-mix surface 95% + on-surface 5%) |
|
||||
| 正文 on-surface 可读 | PASS |
|
||||
| 分类视图展开区同色 | PASS |
|
||||
| 同步日志仅 1 条、无「最近 N 条」文案 | PASS(limit=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 | PASS(Terminal/Dashboard 零回归) |
|
||||
| 未设路径判定单一真相 | PASS(Python + 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 | 判定 |
|
||||
|---|------|
|
||||
| H1–H3 | 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 显示选中数 | PASS(scopedSelectedCount) |
|
||||
| 未设路径表提交 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 | 判定 |
|
||||
|---|------|
|
||||
| H1–H3 | 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 | 执行安装 |
|
||||
| 已安装,版本 ≥ 主站 | 跳过(success,stdout 说明) |
|
||||
| 已安装,版本 < 主站 | 执行安装(等同升级) |
|
||||
|
||||
版本来源优先级: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()`,连接池范围 1–1000。
|
||||
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}` → 200(coerce)
|
||||
@@ -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;前端解码写入 xterm;Shell 高亮 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`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 后端需重启 API(WebSocket 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,延迟特性不变
|
||||
@@ -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
|
||||
SSH:export NEXUS_SSH="azureuser@20.24.218.235"
|
||||
export NEXUS_SSH_KEY="/home/r/下载/Telegram Desktop/nz-admin.pem" # 路径含空格须引号
|
||||
push:bash scripts/git-push.sh(凭据 deploy/nexus-1panel.secrets.sh,gitignore)
|
||||
部署:bash deploy/pre_deploy_check.sh && bash deploy/deploy-production.sh
|
||||
|
||||
【栈】FastAPI + Async SQLAlchemy + Redis · Vue 3 + Vuetify 4 SPA(15 页)· 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)
|
||||
|
||||
【本会话运维记录】
|
||||
- 批量任务 #20(install-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 配置 UI(C-01/C-02)
|
||||
10. POSIX-14~18 + 文件管理 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 query(ADR-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-07~08)
|
||||
---
|
||||
|
||||
### 服务器列表(已部署 `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` 60s;changelog/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 前端英/混排标签 | ☐ |
|
||||
| 29–31 | L4 Playwright + L5 验收清单 | ☐ |
|
||||
| 87–95 | 用户浏览器终验(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」即可。
|
||||
|
||||
### 一、运维可靠性(1–7)
|
||||
|
||||
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.py(A-05 依赖) ☐
|
||||
|
||||
### 二、对齐计划 A 部署点验(8–12)
|
||||
|
||||
8. A-01 持续部署 ☑ · 9. A-02 ~ · 10. A-03 脚本库点验 ~ · 11. A-04 调度点验 ~ · 12. A-05 同 #4
|
||||
|
||||
### 三、14 页缺口 B(13–19)
|
||||
|
||||
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 C(20–23)
|
||||
|
||||
20. C-01 资产管理页 ☐ · 21. C-02 Sync 配置 UI ☐ · 22. C-03 Sync 批量命令 ☐ · 23. C-04 Sync SFTP ☐
|
||||
|
||||
### 五、产品 D(24–28)
|
||||
|
||||
24. D-01 同 #5 · 25. D-02 宝塔监控 ☐ · 26. D-03 mTLS ☐ · 27. D-04 搜索增强 ☐ · 28. D-05 凭据轮询 E2E ☐
|
||||
|
||||
### 六、验收 E(29–50)
|
||||
|
||||
29. E-01 local_verify 401 ☐ · 30. E-02 Playwright 15 绿 ☐ · 31. E-03 L5 清单 ☐
|
||||
32–35. E-04~E-07 心跳/WS/守护/install E2E ☐ · 36. E-08 ☑ · 37–41. POSIX-14~18 ☐
|
||||
42–46. ENH-01~04/08 ☐ · 47–50. 文档 SSOT E-19~22(#47/#50 部分 ☑)
|
||||
|
||||
### 七、Master FM/T(51–81)
|
||||
|
||||
51. A-09 local_integration_smoke ☐ · 52. Phase C 浏览器 L1–L4 ☐ · 53–74. FM-L1~L4 文件管理生产验收 ☐
|
||||
75–77. ENH-05~07 文档回写 ☐ · 78. T-04 Telegram ☐ · 79. T-06 同 #31 · 80. T-07 WebSSH ~ · 81. T-08 CSV ☑
|
||||
|
||||
### 八、Handoff 可选(82–86)
|
||||
|
||||
82. 全选筛选结果 ☑ · 83. 失败分组 ☑ · 84. 失败导出 CSV ☐ · 85. 归档旧 handoff ☐ · 86. 其它表 sort 扫尾 ☐
|
||||
|
||||
### 九、用户终验(87–95)
|
||||
|
||||
87–90. WebSSH/推送/Telegram/kill 自愈 ☐ · 91–95. 近期功能浏览器终验 ~
|
||||
|
||||
### 十、L5 清单(96–125)
|
||||
|
||||
96–125. `production-verification-checklist.md` 逐项 ☐(#122 /health ☑)
|
||||
|
||||
### 十一、仓库卫生(126–132)
|
||||
|
||||
126–132. handoff/alignment 过期、verification 未提交、assets 残留、gate_log、.cursor ☐
|
||||
|
||||
### 十二、不做 / 风险接受(133–137)
|
||||
|
||||
133–135. 已否决功能 · 136–137. 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 中 | 更新至 85ecd1f;132 条 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` | PASS(vue-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,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) {
|
||||
|
||||
@@ -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,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`)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
|
||||
<PushToolbar
|
||||
v-model:target-path="form.targetPath"
|
||||
:submitting="progress.submitting"
|
||||
:pushing="progress.pushing"
|
||||
@preview="preview.doPreview"
|
||||
@push="progress.doPush"
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '系统' },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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,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)} 分钟前`
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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', '')}",
|
||||
)
|
||||
@@ -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
Reference in New Issue
Block a user