Compare commits
20 Commits
091fb97291
...
6a6f3f7614
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a6f3f7614 | |||
| fe8c11b706 | |||
| b287707359 | |||
| 64c8bd74b8 | |||
| 1c0d7e9eb6 | |||
| a842af2405 | |||
| 81004cf0a5 | |||
| 3ba78cd10a | |||
| 213aad97a4 | |||
| c366ed5adc | |||
| 664efb9ed5 | |||
| adcd8010a5 | |||
| 863e49e518 | |||
| 56139a42fe | |||
| e3efbd4552 | |||
| 4d82ed4031 | |||
| b62f1039db | |||
| 9f78207aa2 | |||
| 3864d120fb | |||
| 0f6f91e61a |
@@ -29,6 +29,13 @@ FastAPI + Async SQLAlchemy + Redis · Vue 3 + Vuetify 4 SPA(15 页)· Vite
|
||||
|
||||
见 `.cursor/rules/perfect-implementation.mdc` · `nexus-security.mdc` — 完美实现、无静默吞错、无明文密钥、CUD 审计。
|
||||
|
||||
## Agent Git / diff(省 token、防卡顿)
|
||||
|
||||
- **禁止**无路径过滤的全仓 `git diff` / `git diff HEAD`(`web/app/assets` 构建产物可导致数十万行输出)。
|
||||
- **优先**:`git diff --stat` 或 `git diff -- frontend/src server/api tests docs`。
|
||||
- **提交**:只 `git add` 源码与文档;`web/app/assets/*` 由 Docker 内 `vite build` 生成,勿为 diff 把海量 hash 文件纳入上下文。
|
||||
- **部署/门控**:定向 pytest(如 `tests/integration/test_*.py`),避免并行全仓 diff + 本地 build + 远程镜像重建。
|
||||
|
||||
## 部署
|
||||
|
||||
```bash
|
||||
|
||||
@@ -35,18 +35,58 @@ jobs:
|
||||
run: mypy server/ --ignore-missing-imports
|
||||
continue-on-error: true # Type errors don't block deployment yet
|
||||
|
||||
- name: Run tests with coverage
|
||||
- name: Run fast tests with coverage
|
||||
run: |
|
||||
coverage run -m pytest tests/ -v --tb=short
|
||||
coverage run -m pytest tests/ -m "not chain and not slow" -q --tb=short
|
||||
coverage report --fail-under=50
|
||||
coverage xml
|
||||
|
||||
- name: Run chain tests
|
||||
run: pytest tests/chain -m chain -q --tb=short
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage.xml
|
||||
|
||||
# ── CI: E2E (main only, optional secrets) ──
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
working-directory: frontend
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Run Playwright E2E
|
||||
working-directory: frontend
|
||||
env:
|
||||
NEXUS_E2E_BASE: ${{ secrets.NEXUS_E2E_BASE || 'http://127.0.0.1:8600' }}
|
||||
NEXUS_E2E_USER: ${{ secrets.NEXUS_E2E_USER || 'admin' }}
|
||||
NEXUS_E2E_PASSWORD: ${{ secrets.NEXUS_E2E_PASSWORD }}
|
||||
run: |
|
||||
if [ -z "$NEXUS_E2E_PASSWORD" ]; then
|
||||
echo "NEXUS_E2E_PASSWORD not set — skip E2E"
|
||||
exit 0
|
||||
fi
|
||||
npm run test:e2e
|
||||
continue-on-error: true
|
||||
|
||||
# ── CD: Deploy ──
|
||||
deploy:
|
||||
needs: test # Only deploy if tests pass
|
||||
|
||||
@@ -26,6 +26,7 @@ ENV/
|
||||
.megamemory/
|
||||
.playwright-mcp/
|
||||
frontend/test-results/
|
||||
frontend/e2e/.auth/
|
||||
tmp/
|
||||
|
||||
# IDE
|
||||
|
||||
@@ -19,21 +19,33 @@ echo "═══ Nexus Frontend Deploy ═══"
|
||||
|
||||
REMOTE_RUNTIME=$(ssh_cmd 'bash -s' <<'REMOTE'
|
||||
set -euo pipefail
|
||||
docker_cmd() {
|
||||
if docker "$@" 2>/dev/null; then return 0; fi
|
||||
sudo docker "$@" 2>/dev/null
|
||||
}
|
||||
compose_cmd() {
|
||||
if docker compose "$@" 2>/dev/null; then return 0; fi
|
||||
sudo docker compose "$@" 2>/dev/null
|
||||
}
|
||||
for d in /opt/nexus /www/wwwroot/api.synaglobal.vip; do
|
||||
[[ -d "$d/server" ]] || continue
|
||||
if [[ -f "$d/docker/.env.prod" ]] && docker compose version >/dev/null 2>&1; then
|
||||
if [[ -f "$d/docker/.env.prod" ]] && compose_cmd version >/dev/null 2>&1; then
|
||||
prof="$d/docker/.host-profile"
|
||||
[[ -f "$prof" ]] || prof="$d/docker/profiles/2c8g.env"
|
||||
if [[ -f "$prof" ]]; then
|
||||
args=(-f "$d/docker/docker-compose.prod.yml")
|
||||
docker network inspect 1panel-network >/dev/null 2>&1 && \
|
||||
docker_cmd network inspect 1panel-network >/dev/null 2>&1 && \
|
||||
[[ -f "$d/docker/docker-compose.1panel.yml" ]] && \
|
||||
args+=(-f "$d/docker/docker-compose.1panel.yml")
|
||||
q=$(cd "$d" && docker compose "${args[@]}" \
|
||||
q=$(cd "$d" && compose_cmd "${args[@]}" \
|
||||
--env-file "$d/docker/.env.prod" --env-file "$prof" ps -q nexus 2>/dev/null || true)
|
||||
[[ -n "$q" ]] && echo "docker $d" && exit 0
|
||||
fi
|
||||
fi
|
||||
if [[ -f "$d/deploy/nexus-1panel.sh" ]]; then
|
||||
q=$(docker_cmd ps --format '{{.Names}}' 2>/dev/null | grep -E '^nexus-prod-nexus-' || true)
|
||||
[[ -n "$q" ]] && echo "docker $d" && exit 0
|
||||
fi
|
||||
if command -v supervisorctl >/dev/null 2>&1 && supervisorctl status nexus >/dev/null 2>&1; then
|
||||
echo "supervisor $d"
|
||||
exit 0
|
||||
|
||||
@@ -117,6 +117,13 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$RUNTIME" == "docker" ]]; then
|
||||
echo "▶ Sync Git web/app into container..."
|
||||
ssh_cmd "sudo env NEXUS_ROOT=${DEPLOY_PATH} NEXUS_PUBLISH_PORT=\$(grep -E '^NEXUS_PUBLISH_PORT=' ${DEPLOY_PATH}/docker/.env.prod 2>/dev/null | cut -d= -f2 | tr -d '\"' || echo 8600) bash ${DEPLOY_PATH}/deploy/sync_webapp_to_container.sh" || {
|
||||
echo "WARN: web/app sync failed — run sync_webapp_to_container.sh on server manually" >&2
|
||||
}
|
||||
fi
|
||||
|
||||
sleep 3
|
||||
PORT="$(ssh_cmd "grep -E '^NEXUS_PUBLISH_PORT=' ${DEPLOY_PATH}/docker/.env.prod 2>/dev/null | cut -d= -f2 | tr -d '\"'" 2>/dev/null || true)"
|
||||
PORT="${PORT:-8600}"
|
||||
|
||||
@@ -926,3 +926,203 @@
|
||||
{"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"}
|
||||
{"ts":"2026-06-08T13:23:04+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:23:04+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-notify-recovery-resource-toggle-deploy.md"}
|
||||
{"ts":"2026-06-08T13:23:04+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-08T13:23:04+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-08T13:23:04+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-08T13:23:04+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-08T13:23:04+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-08T13:23:04+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-08T13:23:04+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-08T13:23:04+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-08T13:45:16+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-server-inline-detail-contrast.md 22lines"}
|
||||
{"ts":"2026-06-08T13:45:16+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-server-inline-detail-contrast-deploy.md"}
|
||||
{"ts":"2026-06-08T13:45:16+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-08T13:45:16+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-08T13:45:16+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-08T13:45:16+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-08T13:45:16+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-08T13:45:16+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-08T13:45:16+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-08T13:45:16+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-08T14:09:53+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-unset-path-detect-selection-count.md 17lines"}
|
||||
{"ts":"2026-06-08T14:09:53+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-unset-path-detect-selection-deploy.md"}
|
||||
{"ts":"2026-06-08T14:09:53+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-08T14:09:53+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-08T14:09:53+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-08T14:09:53+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-08T14:09:53+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-08T14:09:53+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-08T14:09:53+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-08T14:09:53+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-08T14:36:00+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-unset-path-table-sort.md 25lines"}
|
||||
{"ts":"2026-06-08T14:36:00+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-unset-path-table-sort-deploy.md"}
|
||||
{"ts":"2026-06-08T14:36:00+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-08T14:36:00+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-08T14:36:00+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-08T14:36:00+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-08T14:36:00+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-08T14:36:00+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-08T14:36:00+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-08T14:36:00+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-08T14:50:10+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-settings-pool-size-string-payload.md 23lines"}
|
||||
{"ts":"2026-06-08T14:50:10+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-settings-pool-size-deploy.md"}
|
||||
{"ts":"2026-06-08T14:50:10+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-08T14:50:10+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-08T14:50:10+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-08T14:50:10+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-08T14:50:10+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-08T14:50:10+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-08T14:50:10+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-08T14:50:10+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-08T14:52:38+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-settings-form-validate-zh-errors.md 32lines"}
|
||||
{"ts":"2026-06-08T14:52:38+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-settings-form-validate-zh-deploy.md"}
|
||||
{"ts":"2026-06-08T14:52:38+08:00","date":"2026-06-08","gate":"test","result":"BLOCK","detail":"tests failed"}
|
||||
{"ts":"2026-06-08T14:52:38+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-08T14:52:38+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-08T14:52:38+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-08T14:52:38+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-08T14:52:38+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-08T14:52:38+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-08T14:52:38+08:00","date":"2026-06-08","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-08T14:52:50+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-settings-form-validate-zh-errors.md 32lines"}
|
||||
{"ts":"2026-06-08T14:52:50+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-settings-form-validate-zh-deploy.md"}
|
||||
{"ts":"2026-06-08T14:52:50+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-08T14:52:50+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-08T14:52:50+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-08T14:52:50+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-08T14:52:50+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-08T14:52:50+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-08T14:52:50+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-08T14:52:50+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-08T15:06:35+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-server-ids-batch-max-2000.md 27lines"}
|
||||
{"ts":"2026-06-08T15:06:35+08:00","date":"2026-06-08","gate":"audit","result":"BLOCK","detail":"missing sections"}
|
||||
{"ts":"2026-06-08T15:06:35+08:00","date":"2026-06-08","gate":"test","result":"BLOCK","detail":"tests failed"}
|
||||
{"ts":"2026-06-08T15:06:35+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-08T15:06:35+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-08T15:06:35+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-08T15:06:35+08:00","date":"2026-06-08","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-08T15:06:35+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-08T15:06:35+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-08T15:06:35+08:00","date":"2026-06-08","gate":"summary","result":"BLOCK","detail":"4/7"}
|
||||
{"ts":"2026-06-08T15:06:42+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-server-ids-batch-max-2000.md 27lines"}
|
||||
{"ts":"2026-06-08T15:06:42+08:00","date":"2026-06-08","gate":"audit","result":"BLOCK","detail":"missing sections"}
|
||||
{"ts":"2026-06-08T15:06:42+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-08T15:06:42+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-08T15:06:42+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-08T15:06:42+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-08T15:06:42+08:00","date":"2026-06-08","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-08T15:06:42+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-08T15:06:42+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-08T15:06:42+08:00","date":"2026-06-08","gate":"summary","result":"BLOCK","detail":"5/7"}
|
||||
{"ts":"2026-06-08T15:06:49+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-server-ids-batch-max-2000.md 27lines"}
|
||||
{"ts":"2026-06-08T15:06:49+08:00","date":"2026-06-08","gate":"audit","result":"BLOCK","detail":"missing sections"}
|
||||
{"ts":"2026-06-08T15:06:49+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-08T15:06:49+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-08T15:06:49+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-08T15:06:49+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-08T15:06:49+08:00","date":"2026-06-08","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-08T15:06:49+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-08T15:06:49+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-08T15:06:49+08:00","date":"2026-06-08","gate":"summary","result":"BLOCK","detail":"5/7"}
|
||||
{"ts":"2026-06-08T15:08:03+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-server-ids-batch-max-2000.md 27lines"}
|
||||
{"ts":"2026-06-08T15:08:03+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-server-ids-batch-max-deploy.md"}
|
||||
{"ts":"2026-06-08T15:08:03+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-08T15:08:03+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-08T15:08:03+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-08T15:08:03+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-08T15:08:03+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-08T15:08:03+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-08T15:08:03+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-08T15:08:03+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-08T15:23:16+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-full-align-deploy.md 33lines"}
|
||||
{"ts":"2026-06-08T15:23:16+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-full-align-deploy.md"}
|
||||
{"ts":"2026-06-08T15:23:16+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-08T15:23:16+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-08T15:23:16+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-08T15:23:16+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-08T15:23:16+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-08T15:23:16+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-08T15:23:16+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-08T15:23:16+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-08T23:05:56+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-agent-401-stop-heartbeat.md 40lines"}
|
||||
{"ts":"2026-06-08T23:05:56+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-agent-401-stop-heartbeat.md"}
|
||||
{"ts":"2026-06-08T23:05:56+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-08T23:05:56+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-08T23:05:56+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-08T23:05:56+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-08T23:05:56+08:00","date":"2026-06-08","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-08T23:05:56+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-08T23:05:56+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-08T23:05:56+08:00","date":"2026-06-08","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-08T23:06:09+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-agent-401-stop-heartbeat.md 40lines"}
|
||||
{"ts":"2026-06-08T23:06:09+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-agent-401-stop-heartbeat.md"}
|
||||
{"ts":"2026-06-08T23:06:09+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-08T23:06:09+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-08T23:06:09+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-08T23:06:09+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-08T23:06:09+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-08T23:06:09+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-08T23:06:09+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-08T23:06:09+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-08T23:19:12+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-beijing-time-display-unified.md 40lines"}
|
||||
{"ts":"2026-06-08T23:19:12+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-beijing-time-recovery-telegram.md"}
|
||||
{"ts":"2026-06-08T23:19:12+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-08T23:19:12+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-08T23:19:12+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-08T23:19:12+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-08T23:19:12+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-08T23:19:12+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-08T23:19:12+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-08T23:19:12+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-08T23:28:37+08:00","date":"2026-06-08","gate":"changelog","result":"PASS","detail":"2026-06-08-servers-hide-csv-export.md 17lines"}
|
||||
{"ts":"2026-06-08T23:28:37+08:00","date":"2026-06-08","gate":"audit","result":"PASS","detail":"2026-06-08-push-validation-servers-ui.md"}
|
||||
{"ts":"2026-06-08T23:28:37+08:00","date":"2026-06-08","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-08T23:28:37+08:00","date":"2026-06-08","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-08T23:28:37+08:00","date":"2026-06-08","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-08T23:28:37+08:00","date":"2026-06-08","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-08T23:28:37+08:00","date":"2026-06-08","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-08T23:28:37+08:00","date":"2026-06-08","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-08T23:28:37+08:00","date":"2026-06-08","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-08T23:28:37+08:00","date":"2026-06-08","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-09T01:33:28+08:00","date":"2026-06-09","gate":"changelog","result":"BLOCK","detail":"file not found"}
|
||||
{"ts":"2026-06-09T01:33:28+08:00","date":"2026-06-09","gate":"audit","result":"BLOCK","detail":"file not found"}
|
||||
{"ts":"2026-06-09T01:33:28+08:00","date":"2026-06-09","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-09T01:33:28+08:00","date":"2026-06-09","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-09T01:33:28+08:00","date":"2026-06-09","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-09T01:33:28+08:00","date":"2026-06-09","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-09T01:33:28+08:00","date":"2026-06-09","gate":"review","result":"BLOCK","detail":"no audit file"}
|
||||
{"ts":"2026-06-09T01:33:28+08:00","date":"2026-06-09","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-09T01:33:28+08:00","date":"2026-06-09","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-09T01:33:28+08:00","date":"2026-06-09","gate":"summary","result":"BLOCK","detail":"4/7"}
|
||||
{"ts":"2026-06-09T01:34:25+08:00","date":"2026-06-09","gate":"changelog","result":"PASS","detail":"2026-06-09-full-site-testing-delivery.md 54lines"}
|
||||
{"ts":"2026-06-09T01:34:25+08:00","date":"2026-06-09","gate":"audit","result":"PASS","detail":"2026-06-09-full-site-testing-delivery.md"}
|
||||
{"ts":"2026-06-09T01:34:25+08:00","date":"2026-06-09","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-09T01:34:25+08:00","date":"2026-06-09","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-09T01:34:25+08:00","date":"2026-06-09","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-09T01:34:25+08:00","date":"2026-06-09","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-09T01:34:25+08:00","date":"2026-06-09","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-09T01:34:25+08:00","date":"2026-06-09","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-09T01:34:25+08:00","date":"2026-06-09","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-09T01:34:25+08:00","date":"2026-06-09","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-09T01:34:51+08:00","date":"2026-06-09","gate":"changelog","result":"PASS","detail":"2026-06-09-full-site-testing-delivery.md 54lines"}
|
||||
{"ts":"2026-06-09T01:34:51+08:00","date":"2026-06-09","gate":"audit","result":"PASS","detail":"2026-06-09-full-site-testing-delivery.md"}
|
||||
{"ts":"2026-06-09T01:34:51+08:00","date":"2026-06-09","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-09T01:34:51+08:00","date":"2026-06-09","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-09T01:34:51+08:00","date":"2026-06-09","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-09T01:34:51+08:00","date":"2026-06-09","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-09T01:34:51+08:00","date":"2026-06-09","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-09T01:34:51+08:00","date":"2026-06-09","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-09T01:34:51+08:00","date":"2026-06-09","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-09T01:34:51+08:00","date":"2026-06-09","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-09T02:12:43+08:00","date":"2026-06-09","gate":"changelog","result":"PASS","detail":"2026-06-09-full-site-testing-delivery.md 58lines"}
|
||||
{"ts":"2026-06-09T02:12:43+08:00","date":"2026-06-09","gate":"audit","result":"PASS","detail":"2026-06-09-full-site-testing-delivery.md"}
|
||||
{"ts":"2026-06-09T02:12:43+08:00","date":"2026-06-09","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-09T02:12:43+08:00","date":"2026-06-09","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-09T02:12:43+08:00","date":"2026-06-09","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-09T02:12:43+08:00","date":"2026-06-09","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-09T02:12:43+08:00","date":"2026-06-09","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-09T02:12:43+08:00","date":"2026-06-09","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-09T02:12:43+08:00","date":"2026-06-09","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-09T02:12:43+08:00","date":"2026-06-09","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
|
||||
@@ -1206,6 +1206,12 @@ cmd_upgrade() {
|
||||
docker image prune -f >/dev/null 2>&1 || true
|
||||
fi
|
||||
NEXUS_ROOT="$NEXUS_ROOT" bash "$NEXUS_ROOT/deploy/install-nx-cli.sh" 2>/dev/null || true
|
||||
if [[ -x "$NEXUS_ROOT/deploy/sync_webapp_to_container.sh" ]]; then
|
||||
step "同步 Git web/app 进容器(消除镜像 vite 缓存 hash 漂移)..."
|
||||
NEXUS_PUBLISH_PORT="$port" NEXUS_ROOT="$NEXUS_ROOT" bash "$NEXUS_ROOT/deploy/sync_webapp_to_container.sh" || {
|
||||
warn "web/app 同步失败,继续健康检查(请手动执行 sync_webapp_to_container.sh)"
|
||||
}
|
||||
fi
|
||||
if ! verify_health "$NEXUS_ROOT" "升级"; then
|
||||
rollback_compose_upgrade "$NEXUS_ROOT" || true
|
||||
exit 1
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sync Git-tracked web/app into the running Nexus Docker container.
|
||||
#
|
||||
# Root cause: Dockerfile.prod runs vite build inside the image; Docker layer cache
|
||||
# can produce asset hashes that differ from committed web/app on the host.
|
||||
#
|
||||
# Usage (on production host after git pull / upgrade):
|
||||
# NEXUS_ROOT=/opt/nexus bash deploy/sync_webapp_to_container.sh
|
||||
#
|
||||
# Called automatically from deploy/nexus-1panel.sh cmd_upgrade and deploy-production.sh.
|
||||
set -euo pipefail
|
||||
|
||||
NEXUS_ROOT="${NEXUS_ROOT:-/opt/nexus}"
|
||||
CONTAINER="${NEXUS_CONTAINER:-}"
|
||||
NEXUS_PUBLISH_PORT="${NEXUS_PUBLISH_PORT:-8600}"
|
||||
|
||||
info() { echo -e "\033[0;32m[INFO]\033[0m $*"; }
|
||||
warn() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
|
||||
error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
|
||||
|
||||
docker_cmd() {
|
||||
if docker "$@" 2>/dev/null; then return 0; fi
|
||||
sudo docker "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
resolve_container() {
|
||||
if [[ -n "$CONTAINER" ]]; then
|
||||
return 0
|
||||
fi
|
||||
CONTAINER="$(docker_cmd ps --format '{{.Names}}' | grep -E 'nexus-prod-nexus|nexus-nexus-1' | head -1 || true)"
|
||||
if [[ -z "$CONTAINER" ]]; then
|
||||
error "未找到运行中的 Nexus 容器(可设 NEXUS_CONTAINER=名称)"
|
||||
exit 1
|
||||
fi
|
||||
info "容器: $CONTAINER"
|
||||
}
|
||||
|
||||
verify_entry_bundle() {
|
||||
local html bundle_path code
|
||||
html="$(curl -sf "http://127.0.0.1:${NEXUS_PUBLISH_PORT}/app/" 2>/dev/null || true)"
|
||||
bundle_path="$(printf '%s' "$html" | sed -n 's/.*src="\(\/app\/assets\/index-[^"]*\.js\)".*/\1/p' | head -1)"
|
||||
if [[ -z "$bundle_path" ]]; then
|
||||
error "/app/ index.html 未解析到主 bundle"
|
||||
exit 1
|
||||
fi
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${NEXUS_PUBLISH_PORT}${bundle_path}" 2>/dev/null || echo 000)"
|
||||
if [[ "$code" != "200" ]]; then
|
||||
error "主 bundle ${bundle_path} → HTTP ${code}"
|
||||
exit 1
|
||||
fi
|
||||
info "入口校验 OK: ${bundle_path} → HTTP 200"
|
||||
}
|
||||
|
||||
main() {
|
||||
local app="${NEXUS_ROOT}/web/app"
|
||||
if [[ ! -f "${app}/index.html" ]]; then
|
||||
error "缺少 ${app}/index.html,请先 git pull"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -d "${app}/assets" ]]; then
|
||||
error "缺少 ${app}/assets"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
resolve_container
|
||||
|
||||
info "同步 web/app → 容器 /app/web/app ..."
|
||||
docker_cmd exec "$CONTAINER" rm -rf /app/web/app/assets
|
||||
docker_cmd exec "$CONTAINER" mkdir -p /app/web/app
|
||||
docker_cmd cp "${app}/index.html" "${CONTAINER}:/app/web/app/index.html"
|
||||
docker_cmd cp "${app}/assets" "${CONTAINER}:/app/web/app/assets"
|
||||
|
||||
if [[ -f "${app}/favicon.ico" ]]; then
|
||||
docker_cmd cp "${app}/favicon.ico" "${CONTAINER}:/app/web/app/favicon.ico" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [[ -x "${NEXUS_ROOT}/deploy/prune_frontend_assets.py" ]]; then
|
||||
info "容器内 prune 未引用 assets(可选)..."
|
||||
docker_cmd exec "$CONTAINER" python3 /app/deploy/prune_frontend_assets.py --dry-run 2>/dev/null || \
|
||||
warn "容器内 prune 跳过(脚本或路径不可用)"
|
||||
fi
|
||||
|
||||
verify_entry_bundle
|
||||
info "web/app 同步完成"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -29,5 +29,11 @@ NEXUS_REDIS_URL=redis://host.docker.internal:6379/0
|
||||
# 宿主机 git 仓库路径(容器内安装向导用于提示 nx cron / nx update)
|
||||
NEXUS_HOST_ROOT=/opt/nexus
|
||||
|
||||
# Optional — production probe / E2E only (host .env.prod; NOT for API runtime; never commit values)
|
||||
# NEXUS_TEST_ADMIN_PASSWORD — must match admin login password
|
||||
# NEXUS_PROBE_CLIENT_IP — optional; IP in login allowlist for origin probe (auto-read from DB if unset)
|
||||
# NEXUS_TEST_ADMIN_PASSWORD=
|
||||
# NEXUS_PROBE_CLIENT_IP=
|
||||
|
||||
# Optional
|
||||
# NEXUS_PUBLISH_PORT=8600
|
||||
|
||||
+14
-3
@@ -49,7 +49,18 @@
|
||||
|
||||
---
|
||||
|
||||
## 4. 设计与标准
|
||||
## 4. 测试(全站用例 SSOT)
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [testing/test-case-matrix.md](testing/test-case-matrix.md) | **152 条** UT/IT/CH/E2E/MCP 编号用例 |
|
||||
| [testing/mcp-browser-playbook.md](testing/mcp-browser-playbook.md) | Agent Playwright MCP 点页规程 |
|
||||
| [design/specs/2026-06-08-full-site-testing-strategy-design.md](design/specs/2026-06-08-full-site-testing-strategy-design.md) | 四层金字塔设计 |
|
||||
| [design/plans/2026-06-08-full-site-testing-strategy.md](design/plans/2026-06-08-full-site-testing-strategy.md) | 分阶段实施计划 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 设计与标准
|
||||
|
||||
| 目录 | 说明 |
|
||||
|------|------|
|
||||
@@ -60,13 +71,13 @@
|
||||
|
||||
---
|
||||
|
||||
## 5. 归档区
|
||||
## 6. 归档区
|
||||
|
||||
**[archive/README.md](archive/README.md)** — 2026-06 审计合并卷、旧 handoff、agentmemory、2026-05 审查报告。
|
||||
|
||||
---
|
||||
|
||||
## 6. 工具脚本
|
||||
## 7. 工具脚本
|
||||
|
||||
| 脚本 | 用途 |
|
||||
|------|------|
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 审计 — 告警分页 + 调度单次 fire_at 修复(2026-06-09)
|
||||
|
||||
**Changelog**: `docs/changelog/2026-06-09-alerts-schedule-fixes-deploy.md`
|
||||
|
||||
## 变更文件清单
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `AlertsPage.vue` | 每页条数状态 + `fetchPagePerPage` |
|
||||
| `settings.py` | 告警列表 `per_page` 上限 200 |
|
||||
| `schema_path_validators.py` | `coerce_optional_iso_datetime` |
|
||||
| `schemas.py` | `ScheduleCreate`/`ScheduleUpdate` `fire_at` → `datetime` |
|
||||
| `test_schedules.py` | once 推送创建集成测试 |
|
||||
| `prod_health_check.sh` | 生产公开/鉴权契约巡检 |
|
||||
| `index.html` | vite 构建 hash 更新 |
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| 规则 | 结论 |
|
||||
|------|------|
|
||||
| 无静默吞错 | PASS — `loadAlerts` 失败 snackbar |
|
||||
| 调度 CUD 审计 | PASS — 未改 audit 写入逻辑 |
|
||||
| `fire_at` 类型安全 | PASS — schema 层解析,避免 ORM 500 |
|
||||
| 告警分页契约 | PASS — 与 `fetchPagePerPage` / 后端 `page`/`per_page` 一致 |
|
||||
| 无明文密钥 | PASS — 健康脚本用环境变量/SSH,不入仓 |
|
||||
|
||||
## Closure
|
||||
|
||||
| 项 | 结果 |
|
||||
|----|------|
|
||||
| `test_schedules.py` | 2 passed |
|
||||
| `test_alerts_audit.py` | 4 passed |
|
||||
| `vite build` | OK |
|
||||
| 生产复现 once 500 | 已确认根因;`3864d12` 部署后容器 healthy |
|
||||
| 部署验证报告 | `docs/reports/2026-06-09-alerts-schedule-fixes-deploy-verification.md` |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] 告警每页数目与 API `per_page` 联动
|
||||
- [x] 单次文件推送调度保存 201(非 500)
|
||||
- [x] Changelog + 本审计
|
||||
- [x] 生产部署与健康检查(`/health` ok,容器 healthy;鉴权 UI 待用户终验)
|
||||
@@ -0,0 +1,42 @@
|
||||
# 审计 — 接续 Wave0 + D-01(2026-06-09)
|
||||
|
||||
**Changelog**: `docs/changelog/2026-06-09-continuation-wave0-d01.md`
|
||||
|
||||
## 变更文件清单
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `scripts/prod_probe.py` | 生产时区验收:422、cron next_run、bundle |
|
||||
| `deploy/sync_webapp_to_container.sh` | upgrade 后同步 Git web/app 进容器 |
|
||||
| `deploy/nexus-1panel.sh` | upgrade 末尾调用 sync |
|
||||
| `deploy/deploy-production.sh` | Docker 部署后 sync |
|
||||
| `deploy/deploy-frontend.sh` | sudo docker 运行时探测 |
|
||||
| `server/utils/agent_deploy.py` | D-01 统一 upgrade 命令 |
|
||||
| `server/api/servers.py` | 单台 upgrade 用 agent_deploy |
|
||||
| `server/application/services/server_batch_service.py` | 批量 upgrade + agent_port |
|
||||
| `tests/test_agent_deploy.py` | 单测 |
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| 规则 | 结论 |
|
||||
|------|------|
|
||||
| 密钥 | PASS — 无凭据入仓 |
|
||||
| 静默吞错 | PASS — sync 失败 warn,probe 显式断言 |
|
||||
| Shell 注入 | PASS — docker cp 路径来自 NEXUS_ROOT |
|
||||
| 分层 | PASS — agent_deploy 在 utils,API 薄编排 |
|
||||
|
||||
## Closure
|
||||
|
||||
| 项 | 结果 |
|
||||
|----|------|
|
||||
| `test_agent_deploy.py` | passed |
|
||||
| `pre_deploy_check.sh` | 7/7 |
|
||||
| `prod_health_check.sh` origin | passed |
|
||||
| 生产 `sync_webapp_to_container.sh` | index-mgwiCK5j.js OK |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] changelog + audit + Handoff 2026-06-09
|
||||
- [x] 门控 7/7
|
||||
- [x] 生产探针全绿
|
||||
- [x] D-01 alignment-plan ☑
|
||||
@@ -0,0 +1,37 @@
|
||||
# 审计 — Dashboard 24h 舰队趋势(B-02)
|
||||
|
||||
**Changelog**: `docs/changelog/2026-06-09-dashboard-24h-fleet-metrics.md`
|
||||
|
||||
## 变更文件清单
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `server/utils/fleet_metrics.py` | 舰队均值聚合 |
|
||||
| `server/infrastructure/database/fleet_metric_repo.py` | 采样 CRUD + purge |
|
||||
| `server/background/heartbeat_flush.py` | 10min 写入样本 |
|
||||
| `server/api/servers.py` | `fleet-metrics` API |
|
||||
| `server/infrastructure/database/migrations.py` | `fleet_metric_samples` 表 |
|
||||
| `frontend/src/components/dashboard/DashboardFleetTrends.vue` | v-sparkline |
|
||||
| `frontend/src/pages/DashboardPage.vue` | 挂载趋势卡 |
|
||||
| `tests/test_fleet_metrics.py` | 单测 + API |
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| 规则 | 结论 |
|
||||
|------|------|
|
||||
| 分层 | PASS — API 只读,聚合在 utils/repo |
|
||||
| 性能 | PASS — 7 天约 1k 行;purge 每 flush |
|
||||
| 静默吞错 | PASS — 前端 load 失败显示空状态 |
|
||||
|
||||
## Closure
|
||||
|
||||
| 项 | 结果 |
|
||||
|----|------|
|
||||
| `test_fleet_metrics.py` | passed |
|
||||
| `npm run type-check` | passed |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] design + plan + changelog
|
||||
- [x] B-02 alignment-plan 待勾选
|
||||
- [x] 无新 npm 依赖
|
||||
@@ -0,0 +1,66 @@
|
||||
# 审计 — 全站测试体系交付(2026-06-09)
|
||||
|
||||
**Changelog**: `docs/changelog/2026-06-09-full-site-testing-delivery.md`
|
||||
|
||||
## 变更文件清单
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `tests/integration/test_*.py` | IT 域集成测试(14 文件) |
|
||||
| `tests/chain/test_*.py` | 链路测试(8 文件,含 batch/exec 完成态) |
|
||||
| `tests/conftest.py` | 共享 fixture / SQLite 兼容 |
|
||||
| `tests/acceptance_catalog.py` | 验收目录扩展 |
|
||||
| `frontend/e2e/fixtures.mjs` | Worker 级 API 登录 + cookie 轮换写回 |
|
||||
| `frontend/e2e/helpers.mjs` | 会话恢复 + 侧栏 `nav` |
|
||||
| `frontend/e2e/pages/*.spec.mjs` | 页域 E2E(18) |
|
||||
| `frontend/e2e/full-acceptance.spec.mjs` | 15 步 serial 验收 |
|
||||
| `frontend/package.json` | `test:e2e` 脚本 |
|
||||
| `.gitea/workflows/ci-cd.yml` | CI 分层 pytest + E2E job |
|
||||
| `.gitignore` | 忽略 `frontend/e2e/.auth/`、`test-results/` |
|
||||
| `scripts/run_nexus_acceptance.py` | 验收脚本对齐 catalog |
|
||||
| `docs/testing/test-case-matrix.md` | 矩阵状态更新 |
|
||||
| `web/app/index.html` | vite build 入口 hash 更新 |
|
||||
| `frontend/src/api/index.ts` | 校验错误中文格式化接入 |
|
||||
| `frontend/src/utils/apiError.ts` | API 错误展示 |
|
||||
| `frontend/src/utils/validationErrorFormat.ts` | Pydantic detail → 中文 |
|
||||
| `frontend/src/pages/PushPage.vue` | 推送页异步提交 UX |
|
||||
| `frontend/src/pages/ServersPage.vue` | 服务器列表(无 CSV 导出) |
|
||||
| `frontend/src/components/push/PushToolbar.vue` | 推送工具栏 |
|
||||
| `frontend/src/composables/push/usePushProgress.ts` | 推送进度 composable |
|
||||
| `server/api/sync_v2.py` | sync 校验与异步推送 |
|
||||
| `server/background/sync_push_runner.py` | 后台推送 runner |
|
||||
| `server/utils/validation_errors_zh.py` | 后端校验中文映射 |
|
||||
| `tests/test_validation_errors_zh.py` | 中文校验单测 |
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| 规则 | 结论 |
|
||||
|------|------|
|
||||
| 无静默吞错(测试 assert 真实行为) | PASS — 失败用例已修至 green |
|
||||
| 无明文密钥入仓 | PASS — E2E 密码仅环境变量 |
|
||||
| 分层:API 不测 UI、E2E 不替代 IT | PASS — 职责分离 |
|
||||
| E2E 并行不触发 login lockout | PASS — `fixtures.mjs` 每 worker 一次 API 登录 |
|
||||
| Refresh token 轮换 | PASS — 上下文结束写回 `storageState` |
|
||||
| SQLite/MySQL 测试兼容 | PASS — `SettingRepository` 等 monkeypatch/直连 |
|
||||
| CI 不阻塞于缺 E2E secret | PASS — E2E job 条件触发 |
|
||||
| 推送异步不阻塞 UI / 校验中文 | PASS — sync_v2 + PushPage + validation_errors_zh |
|
||||
|
||||
## Closure
|
||||
|
||||
| 项 | 结果 |
|
||||
|----|------|
|
||||
| pytest integration+chain | 27 passed |
|
||||
| pytest fast suite | 427 passed |
|
||||
| pytest slow (IT-INST) | 1 passed |
|
||||
| Playwright e2e | 35 passed |
|
||||
| local_integration_smoke | OK |
|
||||
| pre_deploy_check Gate 3–6 | PASS(本审计前) |
|
||||
| MCP-INST-001 各 step UI | ⏭ 用户确认不纳入验收 |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] Wave 1–5 矩阵 IT/CH/E2E 缺口落地并有命令证据
|
||||
- [x] Playwright 35/35 本地 green(清理 assets 后复验)
|
||||
- [x] Changelog + 设计/计划/报告文档齐全
|
||||
- [x] 门控可过(今日 changelog + 本审计)
|
||||
- [ ] 生产部署(待用户批准 `deploy-production.sh`)
|
||||
@@ -0,0 +1,57 @@
|
||||
# 审计 — 运维时区全局北京(2026-06-09)
|
||||
|
||||
**Changelog**: `docs/changelog/2026-06-09-operator-timezone-beijing.md` · `docs/changelog/2026-06-09-timezone-followups-ws-install-dates.md`
|
||||
**报告**: `docs/reports/2026-06-09-operator-timezone-beijing-audit.md`
|
||||
|
||||
## 变更文件清单
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `server/utils/display_time.py` | `operator_wall`、`beijing_day_range_utc` |
|
||||
| `server/background/schedule_runner.py` | 北京墙钟 cron 匹配 |
|
||||
| `server/infrastructure/database/audit_log_repo.py` | 审计日期北京日界 |
|
||||
| `server/api/settings.py` | 告警 date 筛选、`utcnow` 修复 |
|
||||
| `frontend/src/utils/datetime.ts` | `beijingWallParts` |
|
||||
| `frontend/src/utils/scheduleCycle.ts` | 与 runner 对齐 |
|
||||
| `ScheduleCyclePicker.vue` | 北京时间提示 |
|
||||
| `tests/test_operator_tz.py` | 运维时区单测 |
|
||||
| `tests/test_schedule_next_run.py` | 更新北京语义断言 |
|
||||
| `server/api/websocket.py` | WS 告警/恢复消息增加 `at`(UTC ISO) |
|
||||
| `frontend/src/composables/useWebSocket.ts` | 使用 `msg.at` 展示北京时刻 |
|
||||
| `server/api/install.py` | 安装时区固定 `Asia/Shanghai` 校验 |
|
||||
| `web/app/install.html` | 移除误导性时区下拉,固定说明文案 |
|
||||
| `web/app/index.html` | 前端构建入口 hash 更新 |
|
||||
| `tests/test_ws_alert_at.py` | WS `at` 字段单测 |
|
||||
| `tests/integration/test_alerts_audit.py` | 非法日期 422 集成测 |
|
||||
| `frontend/e2e/pages/schedules-timezone.spec.mjs` | 调度北京时间 E2E |
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| 规则 | 结论 |
|
||||
|------|------|
|
||||
| PY-11 naive/aware | PASS — `to_naive_utc` / `ensure_utc` |
|
||||
| 调度双触发 | PASS — 60s 冷却保留 |
|
||||
| SQL 注入 | PASS — 日期 `fromisoformat`,无拼接 |
|
||||
| 静默吞错 | PASS — 非法 `date_from`/`date_to` 返回 422 |
|
||||
| 存量 cron 语义 | RISK-P2 — 无迁移;部署后人工核对调度列表 |
|
||||
|
||||
## Closure
|
||||
|
||||
| 项 | 结果 |
|
||||
|----|------|
|
||||
| `test_operator_tz.py` | passed |
|
||||
| `test_schedule_next_run.py` | passed |
|
||||
| `test_ws_alert_at.py` | passed |
|
||||
| `test_alerts_audit.py` (422) | passed |
|
||||
| `scripts/local_verify.sh` | All local checks passed |
|
||||
| `npm run type-check` | passed |
|
||||
| ruff F | 0 violations |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] cron 按北京时间匹配,`next_run` 与表单时:分一致
|
||||
- [x] `cycleFromSchedule` 用 `parseApiDateTime` + 北京时:分
|
||||
- [x] 审计 `date_from` 覆盖北京当日 00:00–23:59
|
||||
- [x] design spec + plan + changelog
|
||||
- [x] WS 告警 `at`、安装时区固定、日期 422
|
||||
- [ ] 生产部署后人工核对启用中的调度(用户确认几乎无)
|
||||
@@ -0,0 +1,46 @@
|
||||
# 审计 — 服务层 ValueError 中文化 + HTTP detail 翻译层部署
|
||||
|
||||
**Changelog**: `docs/changelog/2026-06-09-service-valueerror-zh.md` · `docs/changelog/2026-06-09-http-api-detail-zh.md`
|
||||
|
||||
## 变更文件清单(本次 commit)
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `server/api/schemas.py` | batch_id 校验文案中文化 |
|
||||
| `server/application/services/script_service.py` | 执行记录不存在 |
|
||||
| `server/application/services/server_batch_service.py` | 未知批量 op |
|
||||
| `server/infrastructure/database/crypto.py` | 凭据解密失败 |
|
||||
| `server/infrastructure/redis/script_execution_store.py` | Redis 执行记录不存在 |
|
||||
| `server/infrastructure/ssh/asyncssh_pool.py` | SSH 凭据缺失 |
|
||||
| `server/utils/schedule_cycle.py` | cron / 周期类型校验 |
|
||||
|
||||
## 一并部署(已在 main,非本 commit diff)
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `server/utils/http_errors_zh.py` | 管理端 HTTP detail 翻译层(A) |
|
||||
| `server/main.py` | 全局 exception handler |
|
||||
| `server/api/auth.py` | auth_failure_detail |
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| 规则 | 结论 |
|
||||
|------|------|
|
||||
| 安全 | PASS — 仅改用户可见文案,无鉴权变更 |
|
||||
| 行为 | PASS — 异常类型与 HTTP 状态码不变 |
|
||||
| Agent API | PASS — `/api/agent/*` 仍跳过翻译 |
|
||||
|
||||
## Closure
|
||||
|
||||
| 项 | 结果 |
|
||||
|----|------|
|
||||
| `test_schedule_cycle.py` | passed |
|
||||
| `test_http_errors_zh.py` | passed |
|
||||
| `test_validation_errors_zh.py` | passed |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] changelog
|
||||
- [x] 定向审计
|
||||
- [ ] 生产 deploy + 健康检查
|
||||
- [ ] 浏览器 spot-check 404 detail 中文
|
||||
@@ -0,0 +1,27 @@
|
||||
# 审计 — SSH 密钥预设列表修复
|
||||
|
||||
**Changelog**: `docs/changelog/2026-06-09-ssh-key-preset-list-fix.md`
|
||||
|
||||
## 变更文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `CredentialsManager.vue` | `http.getList` 替代 `fetchPagePerPage`;保存校验 |
|
||||
| `server/api/settings.py` | 列表 `private_key_set` |
|
||||
| `web/app/index.html` | Vite 构建产物入口 |
|
||||
|
||||
## Step 3
|
||||
|
||||
| 规则 | 结论 |
|
||||
|------|------|
|
||||
| 安全 | PASS — 私钥仍仅存服务端加密,列表不返回私钥 |
|
||||
| 行为 | PASS — 与密码预设 Tab 一致 |
|
||||
|
||||
## Closure
|
||||
|
||||
- `npm run type-check` PASS
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] changelog + audit
|
||||
- [ ] 生产前端热更新
|
||||
@@ -0,0 +1,44 @@
|
||||
# 2026-06-08 告警中心 / 命令日志 / 调度契约修复
|
||||
|
||||
## 摘要
|
||||
|
||||
修复告警中心统计与筛选、命令日志表格恒空、调度列表脚本列空白等前后端 API 契约不一致问题。
|
||||
|
||||
## 动机
|
||||
|
||||
全站 E2E 仅验证页面可打开,未覆盖真实查询参数与响应结构;生产上三页表现为数据为空、统计恒 0、筛选无效。
|
||||
|
||||
## 变更
|
||||
|
||||
### 告警中心(`server/api/settings.py`)
|
||||
|
||||
- `GET /api/alert-history/`:支持前端 `page`/`per_page`、`type`(→ `alert_type`)、`status`(`active`/`recovered` → `is_recovery`);响应补充 `page`/`per_page`/`pages`。
|
||||
- `GET /api/alert-history/stats`:补充 `today`/`active`/`recovered`/`top_server`;`active` 取自 Redis `alerts:*` 集合基数;`today` 按当日日期匹配 `daily`;日统计 SQL 改为 SQLAlchemy 可移植查询(兼容测试 SQLite)。
|
||||
|
||||
### 命令日志(`server/api/assets.py`)
|
||||
|
||||
- `GET /api/assets/command-logs`、`/ssh-sessions`:由裸数组改为 `{ items, total, page, per_page, pages }`,与 `fetchPagePerPage` 一致。
|
||||
|
||||
### 调度(`frontend/src/pages/SchedulesPage.vue`)
|
||||
|
||||
- 列表「源文件 / 脚本」列:脚本类型显示脚本库名称或命令摘要,不再空白。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/api/settings.py`
|
||||
- `server/api/assets.py`
|
||||
- `frontend/src/pages/SchedulesPage.vue`
|
||||
- `tests/integration/test_alerts_audit.py`
|
||||
- `tests/integration/test_retries_commands.py`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 无需 DB 迁移;需重启 API 并重新构建前端后部署。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/integration/test_alerts_audit.py tests/integration/test_retries_commands.py -q
|
||||
bash scripts/local_verify.sh
|
||||
cd frontend && npm run build
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
# 2026-06-08 告警中心每页条数修复
|
||||
|
||||
## 摘要
|
||||
|
||||
修复告警历史表格「每页数目」选择无效:此前写死 20 条且未监听 `update:items-per-page`。
|
||||
|
||||
## 动机
|
||||
|
||||
用户切换每页 10/50/100/全部 时 UI 与 API 请求均不变,分页体验与命令日志等页不一致。
|
||||
|
||||
## 变更
|
||||
|
||||
- `frontend/src/pages/AlertsPage.vue`:`itemsPerPage` 状态、`dataTablePageOptions`、`onItemsPerPageChange`,`loadAlerts` 改用 `fetchPagePerPage`(支持「全部」多页拉取)。
|
||||
- `server/api/settings.py`:`list_alert_history` 的 `per_page` 上限 100 → 200,与全站表格选项一致。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
.venv/bin/pytest tests/integration/test_alerts_audit.py -q
|
||||
```
|
||||
|
||||
浏览器:告警中心切换每页条数,表格行数与请求 `per_page` 一致。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 2026-06-08 — 全站测试体系规划
|
||||
|
||||
## 摘要
|
||||
|
||||
新增四层测试金字塔(单元 / 集成 / 链条 / UI E2E)+ Agent MCP 浏览器探索层的设计、实施计划与 **152 条编号用例矩阵**。
|
||||
|
||||
## 动机
|
||||
|
||||
用户要求全站补齐单元、集成、链条测试,并由 Agent 通过 MCP 在页面上执行可重复点页用例;现有 ~60 pytest 与 15 条 Playwright 冒烟缺少系统编号与链条覆盖。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `docs/design/specs/2026-06-08-full-site-testing-strategy-design.md`
|
||||
- `docs/design/plans/2026-06-08-full-site-testing-strategy.md`
|
||||
- `docs/testing/test-case-matrix.md`
|
||||
- `docs/testing/mcp-browser-playbook.md`
|
||||
|
||||
## 迁移/重启
|
||||
|
||||
无代码变更,无需部署。
|
||||
|
||||
## 验证
|
||||
|
||||
- 文档内命令与现有 `scripts/run_nexus_acceptance.py`、`frontend/e2e/full-acceptance.spec.mjs` 路径一致
|
||||
- 用例矩阵与现有 `tests/test_*.py` 映射已标注 ✅/🔲
|
||||
|
||||
## 后续
|
||||
|
||||
按 Phase 1–5 实施:先补 `E2E-SRV-002`、`IT-PUSH-001`、`CH-PUSH-001`,再拆 `tests/unit` 目录。
|
||||
@@ -0,0 +1,42 @@
|
||||
# Changelog — Playwright E2E 35/35 + worker 会话复用
|
||||
|
||||
**日期**: 2026-06-08
|
||||
|
||||
## 摘要
|
||||
|
||||
修复并行 E2E 登录锁号与 SPA 导航不稳定,全量 **35 passed / 0 failed**。
|
||||
|
||||
## 动机
|
||||
|
||||
- 35 个 Playwright 用例并行跑时每个都走 UI 登录,触发 `login_attempts` 锁定(429)。
|
||||
- Refresh token 轮换后静态 `storageState` 文件失效,后续用例落在 `#/login` 或 `#/`。
|
||||
- Hash-only `page.goto` 与 Vue keep-alive 组合下,部分用例无法离开仪表盘。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 路径 | 变更 |
|
||||
|------|------|
|
||||
| `frontend/e2e/fixtures.mjs` | 新增:worker 级 API 登录 + 上下文结束写回 cookie |
|
||||
| `frontend/e2e/helpers.mjs` | `login` 仅恢复会话;`nav` 侧栏点击导航 |
|
||||
| `frontend/e2e/pages/*.spec.mjs` | 认证用例改 import `fixtures.mjs`;auth/login 空 `storageState` |
|
||||
| `frontend/e2e/pages/credentials.spec.mjs` | 凭据对话框经「凭据」按钮打开 |
|
||||
| `frontend/e2e/full-acceptance.spec.mjs` | 凭据/命令日志选择器对齐 |
|
||||
| `frontend/playwright.config.mjs` | 移除全局 `storageState`(改由 fixture) |
|
||||
| `.gitignore` | 忽略 `frontend/e2e/.auth/` |
|
||||
| `docs/reports/2026-06-08-playwright-e2e-full-run.md` | 35/35 证据 |
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 无需 DB 迁移。
|
||||
- E2E 前若 admin 已锁定,需清理 `login_attempts` 或等待 15 分钟。
|
||||
- 改前端后需 `cd frontend && npx vite build` 再跑 E2E(测的是 `web/app/`)。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
cd frontend && NEXUS_E2E_BASE=http://127.0.0.1:8600 NEXUS_E2E_PASSWORD=*** npm run test:e2e
|
||||
# → 35 passed
|
||||
|
||||
.venv/bin/pytest tests/integration tests/chain -q
|
||||
# → 27 passed
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
# 2026-06-08 — Playwright 全量 E2E 32/33 通过
|
||||
|
||||
## 摘要
|
||||
|
||||
本地 `npm run test:e2e` 首次在扩展后的 19 个 spec 目录下跑通:**32 passed / 1 skipped**(~42s)。修复 6 处选择器与已移除的全局搜索用例。
|
||||
|
||||
## 动机
|
||||
|
||||
Wave 5 仅完成 pytest;用户要求用真实 admin 凭据补跑 Playwright 全量并留证据。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/e2e/full-acceptance.spec.mjs`
|
||||
- `frontend/e2e/pages/{credentials,executions,executions-detail,retries,push}.spec.mjs`
|
||||
- `docs/reports/2026-06-08-playwright-e2e-full-run.md`
|
||||
|
||||
## 迁移/重启
|
||||
|
||||
无。首次环境需 `npx playwright install chromium`。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
# API :8600 已起,凭据经环境变量传入(勿写进仓库)
|
||||
cd frontend && NEXUS_E2E_BASE=http://127.0.0.1:8600 NEXUS_E2E_PASSWORD=*** npm run test:e2e
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
# 2026-06-08 调度单次执行保存 500 修复
|
||||
|
||||
## 摘要
|
||||
|
||||
修复新建「文件推送 + 单次执行」调度保存失败(HTTP 500):`fire_at` ISO 字符串未转为 `datetime` 写入 ORM。
|
||||
|
||||
## 动机
|
||||
|
||||
默认 `run_mode=once`;前端提交 `fire_at` 为 `toISOString()` 字符串,SQLAlchemy DateTime 列拒绝字符串导致插入失败。循环模式 `fire_at=null` 不受影响。
|
||||
|
||||
## 变更
|
||||
|
||||
- `server/api/schema_path_validators.py`:新增 `coerce_optional_iso_datetime`
|
||||
- `server/api/schemas.py`:`ScheduleCreate` / `ScheduleUpdate` 的 `fire_at` 校验并解析为 `datetime`
|
||||
- `tests/integration/test_schedules.py`:补充 once 推送创建用例
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/integration/test_schedules.py -q
|
||||
```
|
||||
|
||||
生产:`POST /api/schedules/`(`run_mode=once` + `source_path`)应返回 201。
|
||||
@@ -0,0 +1,57 @@
|
||||
# 2026-06-08 — 全站测试用例 Wave 1–4 落地
|
||||
|
||||
## 摘要
|
||||
|
||||
按计划补齐 IT/CH/E2E 自动化:推送 accepted、链条 WS、12 条集成测试、5 条链条测试、14 条 Playwright 页面 spec;CI 分层跑 pytest;验收脚本支持 `--with-chain` / `--with-integration`。
|
||||
|
||||
## 动机
|
||||
|
||||
落实 `docs/testing/test-case-matrix.md` 中 ~78 条待写用例,MCP 可自动化项沉淀为 Playwright(可 CI)。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
### 后端
|
||||
- `tests/conftest.py` — fixtures、`integration_env`、`mock_request`
|
||||
- `tests/integration/test_*.py`(7 文件,12 用例)
|
||||
- `tests/chain/test_*.py`(5 文件)
|
||||
- `tests/test_batch_server_display_name.py` — 修正 import(CI 收集错误)
|
||||
|
||||
### 前端 E2E
|
||||
- `frontend/e2e/helpers.mjs`
|
||||
- `frontend/e2e/pages/*.spec.mjs`(11 spec)
|
||||
- `frontend/e2e/full-acceptance.spec.mjs` — 复用 helpers
|
||||
- `frontend/package.json` — `test:e2e` 跑整个 `e2e/`
|
||||
|
||||
### CI / 验收
|
||||
- `.gitea/workflows/ci-cd.yml` — fast pytest + chain job
|
||||
- `scripts/run_nexus_acceptance.py` — `--with-chain`、`--with-integration`、Playwright 全目录
|
||||
- `tests/acceptance_catalog.py` — integration/chain/ui_executions
|
||||
|
||||
### 文档
|
||||
- `docs/testing/test-case-matrix.md` — 状态更新
|
||||
|
||||
## 迁移/重启
|
||||
|
||||
无 DB 迁移。CI 需已安装 `pytest-asyncio`。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/integration tests/chain -q
|
||||
.venv/bin/pytest tests/ -m "not chain and not slow" -q # 注意既有 test_batch_server_display_name 收集错误需另修
|
||||
cd frontend && NEXUS_E2E_BASE=http://127.0.0.1:8600 NEXUS_E2E_PASSWORD=*** npm run test:e2e
|
||||
python scripts/run_nexus_acceptance.py --with-integration --with-chain
|
||||
```
|
||||
|
||||
## 续补(同日前半)
|
||||
|
||||
- `tests/integration/test_webssh_auth.py` — IT-TERM-001
|
||||
- `tests/integration/test_schedules.py` — IT-SCH-001
|
||||
- `tests/chain/test_agent_dashboard_flow.py` — CH-AGT-001
|
||||
- `tests/chain/test_server_update_flow.py` — CH-SRV-001
|
||||
- `frontend/e2e/pages/login.spec.mjs` — MCP-AUTH-001
|
||||
- `.gitea/workflows/ci-cd.yml` — main 分支可选 E2E job
|
||||
|
||||
## 剩余 manual-only
|
||||
|
||||
`MCP-TERM-001`(真 SSH)、`MCP-INST-*`、`CH-BAT-001`、`IT-INST-001` 等。
|
||||
@@ -0,0 +1,48 @@
|
||||
# 2026-06-08 — 测试用例 Wave 5(缺口补齐)
|
||||
|
||||
## 摘要
|
||||
|
||||
补齐矩阵中 CH-BAT-001、IT-FILE/INST/SRV-003 及 7 条 MCP→E2E 沉淀;集成/链条测试 26 条全绿,fast pytest 427 passed。
|
||||
|
||||
## 动机
|
||||
|
||||
接续 Wave 1–4,关闭 `test-case-matrix.md` 中批量任务、文件浏览、安装模式、notify 开关及终端/文件/脚本/凭据/重试/设置/执行详情等 MCP 可自动化缺口。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
### 后端
|
||||
- `tests/chain/test_batch_job_flow.py` — CH-BAT-001
|
||||
- `tests/integration/test_files_browse.py` — IT-FILE-001
|
||||
- `tests/integration/test_notify_settings.py` — IT-SRV-003
|
||||
- `tests/integration/test_install_mode.py` — IT-INST-001(`@pytest.mark.slow`)
|
||||
|
||||
### 前端 E2E
|
||||
- `frontend/e2e/pages/terminal.spec.mjs`
|
||||
- `frontend/e2e/pages/files.spec.mjs`
|
||||
- `frontend/e2e/pages/scripts.spec.mjs`
|
||||
- `frontend/e2e/pages/credentials.spec.mjs`
|
||||
- `frontend/e2e/pages/retries.spec.mjs`
|
||||
- `frontend/e2e/pages/settings.spec.mjs`
|
||||
- `frontend/e2e/pages/executions-detail.spec.mjs`
|
||||
|
||||
### 文档
|
||||
- `docs/testing/test-case-matrix.md` — 状态与覆盖率汇总
|
||||
|
||||
## 迁移/重启
|
||||
|
||||
无。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/integration tests/chain -q # 26 passed
|
||||
.venv/bin/pytest tests/ -m "not chain and not slow" -q # 427 passed
|
||||
# E2E(需本地 API + NEXUS_E2E_PASSWORD):
|
||||
cd frontend && NEXUS_E2E_BASE=http://127.0.0.1:8600 NEXUS_E2E_PASSWORD=*** npm run test:e2e
|
||||
```
|
||||
|
||||
## 仍待写(manual / 安装隔离)
|
||||
|
||||
- E2E-INST-001 / MCP-INST-001 — 安装模式专用环境
|
||||
- CH-EXEC-001 — 轮询至 `done`(需 mock runner)
|
||||
- MCP-PUSH-001 / MCP-SET-001 toast — 部分已由 E2E mock 覆盖,完整 toast 需 route mock
|
||||
@@ -0,0 +1,33 @@
|
||||
# 2026-06-09 告警分页 + 调度单次保存修复(部署)
|
||||
|
||||
## 摘要
|
||||
|
||||
修复告警中心「每页数目」无效;修复新建文件推送单次调度保存 HTTP 500(`fire_at` ISO 字符串未解析为 `datetime`)。
|
||||
|
||||
## 动机
|
||||
|
||||
- 告警表 `items-per-page` 写死 20,未绑定 `update:items-per-page`
|
||||
- 默认 `run_mode=once` 提交 `fire_at` 字符串,ORM `DateTime` 列拒绝写入
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/pages/AlertsPage.vue` — `itemsPerPage` + `fetchPagePerPage`
|
||||
- `server/api/settings.py` — `per_page` 上限 200
|
||||
- `server/api/schema_path_validators.py` — `coerce_optional_iso_datetime`
|
||||
- `server/api/schemas.py` — `ScheduleCreate`/`Update` 解析 `fire_at`
|
||||
- `tests/integration/test_schedules.py` — once 创建用例
|
||||
- `scripts/prod_health_check.sh` — 生产健康脚本
|
||||
- `web/app/` — vite 构建产物
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
无需 DB 迁移;需重建镜像/重启 API + 前端静态资源。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/integration/test_schedules.py tests/integration/test_alerts_audit.py -q
|
||||
bash deploy/pre_deploy_check.sh
|
||||
```
|
||||
|
||||
生产:`POST /api/schedules/`(once+push)→ 201;告警中心切换每页 50 条生效。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 2026-06-09 — 接续方案:生产终验 + 部署 sync + Agent upgrade 对齐
|
||||
|
||||
## 变更摘要
|
||||
|
||||
1. **生产探针**:`prod_probe.py` 增加时区验收(日期 422、cron next_run、SPA bundle 可加载)。
|
||||
2. **部署加固**:新增 `sync_webapp_to_container.sh`,挂接 `nexus-1panel.sh upgrade` 与 `deploy-production.sh`;`deploy-frontend.sh` 修复 `sudo docker` 探测。
|
||||
3. **D-01**:`server/utils/agent_deploy.py` 统一单台/批量 upgrade 与 `web/agent/install.sh`(路径、pip 版本、agent_port)。
|
||||
4. **文档**:`AI-HANDOFF-2026-06-09.md`;更新时区 verification 报告 §生产。
|
||||
|
||||
## 动机
|
||||
|
||||
接续方案 Path A~D:闭环时区生产验收、消除 Docker vite 缓存导致的前端 hash 漂移、消除 upgrade 命令三处重复与批量硬编码 8601 端口。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `scripts/prod_probe.py`
|
||||
- `deploy/sync_webapp_to_container.sh`(新)
|
||||
- `deploy/nexus-1panel.sh` · `deploy/deploy-production.sh` · `deploy/deploy-frontend.sh`
|
||||
- `server/utils/agent_deploy.py`(新)
|
||||
- `server/api/servers.py` · `server/application/services/server_batch_service.py`
|
||||
- `tests/test_agent_deploy.py`
|
||||
- `docs/project/AI-HANDOFF-2026-06-09.md`
|
||||
- `docs/reports/2026-06-09-operator-timezone-beijing-verification.md`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 无 DB 迁移。
|
||||
- `sync_webapp_to_container.sh` 在下次 `upgrade` / `deploy-production` 时自动执行;可手动:`NEXUS_ROOT=/opt/nexus bash deploy/sync_webapp_to_container.sh`。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
NEXUS_PROBE_MODE=origin bash scripts/prod_health_check.sh
|
||||
pytest tests/test_agent_deploy.py tests/test_operator_tz.py -q
|
||||
bash deploy/pre_deploy_check.sh
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
# 2026-06-09 .cursorrules Agent diff 指引
|
||||
|
||||
## 摘要
|
||||
|
||||
在 `.cursorrules` 增加「Agent Git / diff」小节,避免全仓 `git diff` 与海量 `web/app/assets` 拖垮会话。
|
||||
|
||||
## 动机
|
||||
|
||||
多次构建后未跟踪的 Vite hash 资产可达数十万行;并行全仓 diff + build + 部署易导致本机卡顿。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `.cursorrules`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
无。
|
||||
|
||||
## 验证
|
||||
|
||||
Agent 会话中优先 `git diff --stat` 或路径限定 diff;提交时不纳入 `web/app/assets/*` 上下文。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 2026-06-09 — Dashboard 24h 资源趋势(B-02)
|
||||
|
||||
## 变更摘要
|
||||
|
||||
仪表盘新增「24h 资源趋势」卡片:每 10 分钟采样舰队 CPU/内存/磁盘均值,MySQL 保留 7 天;前端用 Vuetify `v-sparkline` 展示,支持三指标切换。
|
||||
|
||||
## 动机
|
||||
|
||||
对齐计划 B-02:运维需查看近 24h 全舰队资源变化,而非仅 Redis 实时快照。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/domain/models/__init__.py` — `FleetMetricSample`
|
||||
- `server/utils/fleet_metrics.py` — Redis 心跳聚合
|
||||
- `server/infrastructure/database/fleet_metric_repo.py`
|
||||
- `server/background/heartbeat_flush.py` — 采样 + 清理
|
||||
- `server/infrastructure/database/migrations.py` — 建表
|
||||
- `server/api/servers.py` — `GET /api/servers/fleet-metrics`
|
||||
- `frontend/src/components/dashboard/DashboardFleetTrends.vue`
|
||||
- `frontend/src/pages/DashboardPage.vue`
|
||||
- `tests/test_fleet_metrics.py`
|
||||
- `docs/design/specs/2026-06-09-dashboard-24h-metrics-design.md`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 启动时 `run_schema_migrations` 自动建 `fleet_metric_samples` 表
|
||||
- 需重启 API;前端需 `vite build`
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
pytest tests/test_fleet_metrics.py -q
|
||||
cd frontend && npm run type-check && npx vite build
|
||||
```
|
||||
|
||||
部署后约 10 分钟出现首条采样;满 24h 曲线更完整。
|
||||
@@ -0,0 +1,25 @@
|
||||
# 2026-06-09 前端构建产物重建与 prune
|
||||
|
||||
## 摘要
|
||||
|
||||
`vite build` 重新生成 `web/app/`,并 prune 移除 116 个过期 hash assets,修复 `index.html` 引用未入库入口 JS 的仓库不一致。
|
||||
|
||||
## 动机
|
||||
|
||||
Git 中 `index.html` 指向 `index-Bplw5zZe.js` 但未跟踪对应 chunk;clone 后前端白屏。生产容器内构建正常,本修复对齐 Git 与当前源码。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `web/app/index.html`
|
||||
- `web/app/assets/*`(保留当前构建 178 文件,删除 116 过期)
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
无 DB 变更。生产下次部署会容器内 rebuild;可选 `deploy/deploy-frontend.sh` 同步静态资源。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
python3 deploy/prune_frontend_assets.py --dry-run web/app # referenced_kept=178 would_remove=0
|
||||
# index.html 引用的 js/css 均存在于 web/app/assets/
|
||||
```
|
||||
@@ -0,0 +1,58 @@
|
||||
# Changelog — 全站测试体系交付(Wave 1–5 + E2E 35/35)
|
||||
|
||||
**日期**: 2026-06-09
|
||||
|
||||
## 摘要
|
||||
|
||||
补齐 `test-case-matrix` 中 IT/CH/E2E 缺口;Playwright 全量 35/35 green;pytest 455 passed(含 slow)。
|
||||
|
||||
## 动机
|
||||
|
||||
- Wave 1–5 仅有 pytest 证据,缺 Playwright 全量与并行稳定性。
|
||||
- 并行 UI 登录导致 admin 锁定;`web/app/assets` 陈旧 hash 堆积(清理前 7086 文件 / 491MB)。
|
||||
|
||||
## 后端测试(新增/扩展)
|
||||
|
||||
| 域 | 路径 |
|
||||
|----|------|
|
||||
| Integration | `tests/integration/test_*.py`(14 文件,含 IT-INST/FILE/SRV-003 等) |
|
||||
| Chain | `tests/chain/test_*.py`(8 文件,含 CH-BAT/CH-EXEC 完成态) |
|
||||
| Catalog | `tests/acceptance_catalog.py` |
|
||||
|
||||
## 前端 E2E
|
||||
|
||||
| 项 | 路径 |
|
||||
|----|------|
|
||||
| Worker 会话 | `frontend/e2e/fixtures.mjs` |
|
||||
| 导航助手 | `frontend/e2e/helpers.mjs`(侧栏点击,避免 keep-alive 卡仪表盘) |
|
||||
| 页域 spec | `frontend/e2e/pages/*.spec.mjs`(18)+ `full-acceptance.spec.mjs` |
|
||||
| 配置 | `frontend/playwright.config.mjs`、`package.json` `test:e2e` |
|
||||
|
||||
## CI
|
||||
|
||||
- `.gitea/workflows/ci-cd.yml`:分层 pytest + 可选 Playwright job
|
||||
|
||||
## 文档
|
||||
|
||||
- `docs/testing/test-case-matrix.md`
|
||||
- `docs/design/specs|plans/2026-06-08-full-site-testing-strategy*.md`
|
||||
- `docs/reports/2026-06-08-playwright-e2e-full-run.md`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 无 DB 迁移。
|
||||
- E2E 需 `NEXUS_E2E_PASSWORD` + API :8600;改前端后 `npx vite build`。
|
||||
|
||||
## 验收范围调整(2026-06-09)
|
||||
|
||||
- **MCP-INST-001**(安装向导各 step UI):用户确认**不验收**;矩阵标 `⏭`;保留 E2E-INST-001 静态页可达 + UT/IT 安装锁即可。
|
||||
|
||||
## 验证(2026-06-09 复验)
|
||||
|
||||
```bash
|
||||
pytest tests/integration tests/chain -q # 27 passed
|
||||
pytest tests/ -m "not chain and not slow" -q # 427 passed
|
||||
pytest tests/ -m slow -q # 1 passed
|
||||
cd frontend && npm run test:e2e # 35 passed
|
||||
bash deploy/pre_deploy_check.sh # 目标 7/7
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
# 2026-06-09 — HTTP API detail 英文改中文(全局层)
|
||||
|
||||
## 变更摘要
|
||||
|
||||
管理端 API 的 `HTTPException.detail` 经全局 handler 译为中文;`/api/agent/*` 保持英文(Agent 协议兼容)。
|
||||
|
||||
## 动机
|
||||
|
||||
Backlog「~75 条 API detail 英文」:422 已由 `validation_errors_zh.py` 覆盖,但 400/401/404 等仍返回 `Server not found` 等英文字面量。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/utils/http_errors_zh.py`(新)— 精确映射 + 正则(如 immutable setting、command exit)
|
||||
- `server/main.py` — `StarletteHTTPException` handler 调用 `translate_http_detail`
|
||||
- `tests/test_http_errors_zh.py`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
需重启 API / 重建镜像。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
pytest tests/test_http_errors_zh.py tests/test_validation_errors_zh.py -q
|
||||
# 示例:GET /api/servers/999999 → detail「服务器不存在」
|
||||
```
|
||||
|
||||
## 后续(同批追加)
|
||||
|
||||
- `auth.py` / `auth_jwt.py`:fallback 与字面量改中文;`auth_failure_detail()` 映射 `reason` 码(避免返回 `admin_not_found`)
|
||||
- `auth_service.py`:`setup_totp` / `enable_totp` 失败路径补 `message`
|
||||
|
||||
## 仍待
|
||||
|
||||
- 其它模块 `str(exc)` 透传
|
||||
- 非 auth 路由字面量可逐步改源码或补 `_DETAIL_EXACT`
|
||||
@@ -0,0 +1,39 @@
|
||||
# 2026-06-09 — 运维时区全局北京(存储 UTC + 语义北京)
|
||||
|
||||
## 变更摘要
|
||||
|
||||
调度 cron/单次执行、审计日期筛选统一按 **Asia/Shanghai** 解释;DB/API 仍存 UTC naive。修复 cron 按 UTC 跑而界面按北京展示、单次编辑回显错误、审计「选今天」漏北京 0–8 点。
|
||||
|
||||
## 动机
|
||||
|
||||
运维按北京时间填时:分,runner 却在 UTC 匹配;`next_run` 列与表单不一致。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/utils/display_time.py` — `operator_wall`、`beijing_day_range_utc`
|
||||
- `server/background/schedule_runner.py` — 北京墙钟 cron 匹配
|
||||
- `server/infrastructure/database/audit_log_repo.py` — 北京日界
|
||||
- `server/api/settings.py` — 告警 date 筛选、`utcnow` 修复
|
||||
- `frontend/src/utils/datetime.ts` — `beijingWallParts`
|
||||
- `frontend/src/utils/scheduleCycle.ts` — 与 runner 对齐
|
||||
- `frontend/src/components/schedules/ScheduleCyclePicker.vue` — 时区提示
|
||||
- `tests/test_operator_tz.py`、`tests/test_schedule_next_run.py`
|
||||
- `frontend/e2e/pages/schedules-timezone.spec.mjs`
|
||||
- `docs/design/specs/2026-06-09-operator-timezone-beijing-design.md`
|
||||
- `docs/design/plans/2026-06-09-operator-timezone-beijing.md`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- **无 DB 迁移**;`cron_expr` 字符串不变,仅执行语义改为北京
|
||||
- 若曾有按 UTC 时刻校准的启用调度,部署后请在调度列表核对 `next_run`
|
||||
- 需重启 API;前端需 `vite build`
|
||||
|
||||
## 验证方式
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m pytest tests/test_operator_tz.py tests/test_schedule_next_run.py tests/test_display_time.py -q
|
||||
cd frontend && npm run type-check
|
||||
NEXUS_E2E_BASE=http://127.0.0.1:8600 npx playwright test e2e/pages/schedules-timezone.spec.mjs
|
||||
```
|
||||
|
||||
手工:新建「每天 02:00」循环调度 → `next_run` 应显示 02:00(北京),非 10:00。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 2026-06-09 生产探针与健康检查补全
|
||||
|
||||
## 摘要
|
||||
|
||||
新增 `prod_probe.py` 双路径探针(外网 + SSH 源站),补全鉴权契约检查;提供 `prod_probe_install.sh` 在生产写入 `NEXUS_TEST_ADMIN_PASSWORD`。
|
||||
|
||||
## 动机
|
||||
|
||||
- 生产未配置探针口令,鉴权段长期 SKIP
|
||||
- 国内线路外网 HTTPS 可能 RST,需源站 `127.0.0.1:8600` 探针仍可用
|
||||
- 告警分页、once 调度等关键修复缺少生产自动化验收
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `scripts/prod_probe.py` — 探针主逻辑
|
||||
- `scripts/prod_probe_install.sh` — 宿主机 `.env.prod` 写入探针口令
|
||||
- `scripts/prod_health_check.sh` — 薄封装
|
||||
- `docker/.env.prod.example` — 探针变量说明
|
||||
- `tests/test_prod_probe.py`
|
||||
- `docs/design/plans/2026-06-09-prod-probe-health-check.md`
|
||||
- `docs/project/nexus-1panel-operations-knowledge.md`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
无需重启。探针口令:运维一次性执行 `prod_probe_install.sh`。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_prod_probe.py -q
|
||||
bash scripts/prod_health_check.sh
|
||||
NEXUS_TEST_ADMIN_PASSWORD='…' bash scripts/prod_probe_install.sh && bash scripts/prod_health_check.sh
|
||||
```
|
||||
|
||||
生产已跑通(2026-06-09):外网 WARN(线路 RST)+ 源站鉴权全绿;once 调度 201 创建并删除。
|
||||
|
||||
## 注意
|
||||
|
||||
启用**登录 IP 白名单**时,源站探针经 `X-Real-IP` 模拟白名单 IP;可设 `NEXUS_PROBE_CLIENT_IP` 或自动读 `login_manual_ips` 首项。
|
||||
@@ -0,0 +1,28 @@
|
||||
# 2026-06-09 — 推送页按分类全量加载 + 未设路径区独立筛选
|
||||
|
||||
## 变更摘要
|
||||
|
||||
修复「未设路径服务器改分类后推送不准」:推送页拉取全部子机(不再仅前 200 台);未设路径区块不再继承顶部分类 chip 过滤。
|
||||
|
||||
## 动机
|
||||
|
||||
- 推送页 `usePushServers` 只请求 `per_page=200`,舰队 >200 台时按分类勾选会漏机或计数错误
|
||||
- 未设路径列表共用 `listQueryParams()` 的 `category`,批量改分类后机器从面板消失,用户误以为未生效,推送页分类也与预期不一致
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/composables/push/usePushServers.ts` — `fetchPagePerPage(..., -1)` 全量;分类 key 与服务器页对齐
|
||||
- `frontend/src/components/push/PushServerGrid.vue` — 展示 `label`(未分类)
|
||||
- `frontend/src/pages/ServersPage.vue` — `unsetListQueryParams()`;批量任务后 `invalidateCachedQuery`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
无。前端 build 部署即可。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
cd frontend && npm run type-check && npx vite build
|
||||
# 推送页:某分类台数应与服务器页分类 chip 一致(>200 台时尤其)
|
||||
# 未设路径区:顶部分类筛选时仍显示全部未设路径机器;改分类后仍留在列表直至配置路径
|
||||
```
|
||||
@@ -0,0 +1,40 @@
|
||||
# 2026-06-09 — 服务层 ValueError 中文化(str(exc) 透传源头)
|
||||
|
||||
## 摘要
|
||||
|
||||
将经 API `detail=str(exc)` 透传、且原先为英文的 `ValueError` 文案改为中文,避免 sync/scripts/批量/调度等接口在未走全局 `http_errors_zh` 映射时仍显示英文。
|
||||
|
||||
## 动机
|
||||
|
||||
待办 #1(API detail 英改中)方案 1:优先改下层异常源头,比统一包装 `raise_api_error` 改动小、语义稳定。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `server/application/services/script_service.py` | `Execution not found` → 执行记录不存在 |
|
||||
| `server/application/services/server_batch_service.py` | `Unknown batch op` → 未知的批量操作 |
|
||||
| `server/infrastructure/database/crypto.py` | `Failed to decrypt credential` → 凭据解密失败 |
|
||||
| `server/infrastructure/ssh/asyncssh_pool.py` | SSH 凭据缺失提示中文化 |
|
||||
| `server/utils/schedule_cycle.py` | 自定义 cron / 字段数 / 周期类型校验中文化 |
|
||||
| `server/api/schemas.py` | `batch_id` 校验中文化 |
|
||||
| `server/infrastructure/redis/script_execution_store.py` | 执行记录不存在提示中文化 |
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 无需 DB 迁移
|
||||
- 需重启 API 进程后生效
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
pytest tests/test_schedule_cycle.py tests/test_http_errors_zh.py -q
|
||||
bash scripts/local_verify.sh
|
||||
```
|
||||
|
||||
手动:脚本重试不存在 execution、批量未知 op、调度非法 cycle、推送 batch_id 非法 → 422/400 detail 为中文。
|
||||
|
||||
## 未覆盖(后续)
|
||||
|
||||
- `f"SSH 连接失败: {e}"` 等库异常后缀(方案 2:`translate_sync_error_message` 包装)
|
||||
- API 层字面量 `detail="Server not found"`(已由 `http_errors_zh` 全局处理,待部署)
|
||||
@@ -0,0 +1,29 @@
|
||||
# 2026-06-09 — 修复凭据页「新建 SSH 密钥」列表不显示
|
||||
|
||||
## 摘要
|
||||
|
||||
凭据管理 SSH 密钥 Tab 列表始终为空;新建后看似「没保存」。
|
||||
|
||||
## 根因
|
||||
|
||||
- `GET /api/ssh-key-presets/` 返回**纯数组**(与密码预设相同)
|
||||
- 前端误用 `fetchPagePerPage`,期望 `{ items, total }`,导致 `items` 恒为 `[]`
|
||||
- 保存未校验名称/私钥;编辑时空私钥可能被提交
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/components/credentials/CredentialsManager.vue` — 改用 `http.getList`;表单校验;编辑 PUT 省略空私钥
|
||||
- `server/api/settings.py` — 列表增加 `private_key_set`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 前端构建 + 热更新;后端小改需重启 API
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
cd frontend && npm run type-check
|
||||
bash scripts/local_verify.sh
|
||||
```
|
||||
|
||||
凭据页 → SSH 密钥 → 新建 → 保存后列表出现;私钥列显示「已设置」。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 2026-06-09 — 时区后续:WS 告警时间 / 安装时区 / 日期校验
|
||||
|
||||
## 摘要
|
||||
|
||||
修复北京时区改造后遗留的三项边缘问题:WebSocket 告警条使用服务端事件时间;安装向导不再提供误导性时区选项;审计/告警日期筛选非法输入返回 422 而非静默忽略。
|
||||
|
||||
## 动机
|
||||
|
||||
1. WS 告警条用 `new Date()` 与真实告警时刻不一致(重连/延迟时更明显)。
|
||||
2. 安装向导可选非北京时区但运行时不生效,易误导运维。
|
||||
3. `date_from`/`date_to` 非法时 `except ValueError: pass` 违反无静默吞错原则。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `server/api/websocket.py` | `broadcast_alert` / `broadcast_recovery` 增加 `at`(UTC ISO) |
|
||||
| `frontend/src/composables/useWebSocket.ts` | 使用 `msg.at` 格式化北京时刻 |
|
||||
| `web/app/install.html` | 时区改为固定说明,移除多选下拉 |
|
||||
| `server/api/install.py` | `InitDbRequest.timezone` 校验仅允许 `Asia/Shanghai`;config 固定写入 |
|
||||
| `server/utils/display_time.py` | 新增 `parse_operator_calendar_date()` |
|
||||
| `server/infrastructure/database/audit_log_repo.py` | 日期筛选用校验函数,不再静默 pass |
|
||||
| `server/api/settings.py` | 告警/审计列表非法日期 → HTTP 422 |
|
||||
| `tests/test_operator_tz.py` | 日期解析单测 |
|
||||
| `tests/integration/test_alerts_audit.py` | 422 集成测 |
|
||||
| `tests/test_ws_alert_at.py` | WS `at` 字段单测 |
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 无需 DB 迁移。
|
||||
- 需重启 API;前端需重新 `vite build` 后部署静态资源。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
pytest tests/test_operator_tz.py tests/test_ws_alert_at.py tests/integration/test_alerts_audit.py -q
|
||||
cd frontend && npm run type-check
|
||||
```
|
||||
@@ -45,7 +45,7 @@
|
||||
| ID | 页面 | 缺失能力 | 优先级 | 状态 | 设计依据 | 验收标准 |
|
||||
|----|------|----------|--------|------|----------|----------|
|
||||
| B-01 | **Commands** `/commands` | **推送日志视图**(第三 Tab:rsync 历史、状态/模式/触发源过滤) | P1 | ☑ | 旧版 `commands.html` 三视图;现 SPA 仅命令+会话 | 第三视图可调 `GET /servers/logs` 或 sync_logs 分页 API;筛选与 Push 历史一致 |
|
||||
| B-02 | **Dashboard** `/` | **历史趋势图**(CPU/内存/磁盘时序,非仅 Redis 实时) | P2 | ☐ | `nexus-full-site-features.md` §23 | 至少 24h 折线图;数据来自 MySQL 历史或现有 heartbeat 落库 |
|
||||
| B-02 | **Dashboard** `/` | **历史趋势图**(CPU/内存/磁盘时序,非仅 Redis 实时) | P2 | ☑ | `fleet_metric_samples` + `v-sparkline` | 至少 24h 折线图;10min 舰队采样 |
|
||||
| B-03 | **Files** `/files` | **树形侧栏**目录导航 | P3 | ☐ | 归档 §12 文件管理「未实现:树形侧栏」 | 左树右表;与 browse API 联动 |
|
||||
| B-04 | **Servers** `/servers` | **CSV 导入模板**静态文件缺失 | P2 | ☑ | UI 链到 `/app/servers_import_template.csv` 但仓库无此文件 | 提供模板 CSV + 文档列说明;导入 E2E 一条 |
|
||||
| B-05 | **Schedules** `/schedules` | **设计文档 §12.8 扩写**(push/script、cron/once 已代码实现) | P2 | ☑ | 功能指南仍只写一行「Cron 调度推送」 | 更新 SSOT 与附录 E.1 字段表;避免下轮误判缺失 |
|
||||
@@ -75,7 +75,7 @@
|
||||
|
||||
| ID | 功能 | 优先级 | 状态 | 说明 | 验收标准 |
|
||||
|----|------|--------|------|------|----------|
|
||||
| D-01 | **Agent upgrade 与 install.sh venv 对齐** | P1 | ☐ | `upgrade-agent` API 已有;需与 `web/agent/install.sh` 路径/venv 一致 | 升级后 agent 版本与中心一致;install 脚本 idempotent |
|
||||
| D-01 | **Agent upgrade 与 install.sh venv 对齐** | P1 | ☑ | `server/utils/agent_deploy.py` 统一 pip/路径/port;单台与批量共用 | 升级后 agent 版本与中心一致;install 脚本 idempotent |
|
||||
| D-02 | **宝塔 API 监控集成** | 可选 | ☐ | 可选增强 | 设计文档 + 配置项 + 只读指标 |
|
||||
| D-03 | **Agent mTLS** | 可选 | ☐ | 安全增强 | ADR + 双端证书轮换方案 |
|
||||
| D-04 | **全局搜索**增强 | P3 | ☐ | `App.vue` 已有 `/search/` | 结果分组、键盘导航、schedules 新字段展示 |
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# 全站测试实施计划(2026-06-08)
|
||||
|
||||
> 设计:`docs/design/specs/2026-06-08-full-site-testing-strategy-design.md`
|
||||
> 用例 SSOT:`docs/testing/test-case-matrix.md`
|
||||
> MCP 规程:`docs/testing/mcp-browser-playbook.md`
|
||||
|
||||
## 现状快照
|
||||
|
||||
| 资产 | 数量/说明 |
|
||||
|------|-----------|
|
||||
| pytest 文件 | ~60(根目录 `tests/test_*.py`) |
|
||||
| 门控 | `pre_deploy_check` → `tests/test_api.py` |
|
||||
| UI E2E | `frontend/e2e/full-acceptance.spec.mjs`(15 case,serial) |
|
||||
| 验收聚合 | `scripts/run_nexus_acceptance.py` + `acceptance_catalog.py` |
|
||||
| 前端单测 | 无 vitest |
|
||||
| MCP | Playwright MCP + MySQL MCP 已配置 |
|
||||
|
||||
## 实施阶段
|
||||
|
||||
### Phase 1 — 文档与编号(本周)
|
||||
|
||||
1. 发布 `test-case-matrix.md`(本文档姊妹篇)
|
||||
2. 发布 `mcp-browser-playbook.md`
|
||||
3. `acceptance_catalog.py` 增加 `chain` layer 与 `executions` 页
|
||||
|
||||
### Phase 2 — 目录与标记(1–2 周)
|
||||
|
||||
1. 新增 `tests/unit/`、`tests/integration/`、`tests/chain/`,**新用例只写新目录**
|
||||
2. `pytest.ini` 或 `pyproject` 注册 markers:`unit`, `integration`, `chain`, `slow`
|
||||
3. 从现有文件**复制**(非搬家)高价值用例各 1 例作为模板
|
||||
|
||||
### Phase 3 — 集成层现代化(2 周)
|
||||
|
||||
1. `tests/integration/conftest.py`:`AsyncClient` + 测试 DB 事务回滚
|
||||
2. 将 `test_api.py` 断言逐步迁为 pytest(保留脚本作门控兼容层)
|
||||
3. 契约:关键 API 响应 schema 快照(Pydantic model dump)
|
||||
|
||||
### Phase 4 — 链条 + UI 拆分(2–3 周)
|
||||
|
||||
1. `tests/chain/test_push_async_flow.py`:POST accepted → WS progress mock
|
||||
2. `tests/chain/test_script_exec_flow.py`:创建脚本 → 执行 → 看板状态
|
||||
3. 拆分 `full-acceptance.spec.mjs` → `frontend/e2e/pages/*.spec.mjs`
|
||||
4. 新增 `flows/push-submit.spec.mjs`(非破坏性)
|
||||
|
||||
### Phase 5 — CI 与 MCP 闭环(持续)
|
||||
|
||||
1. `.gitea/workflows/ci-cd.yml` 增加 `pytest -m "not chain and not slow"`
|
||||
2. nightly:`run_nexus_acceptance.py --with-unit --with-ui`
|
||||
3. 每个 sprint 选 3 条 `MCP-*` 沉淀为 Playwright
|
||||
|
||||
## 本地命令速查
|
||||
|
||||
```bash
|
||||
# L1 单元
|
||||
pytest tests/unit tests/test_posix_paths.py -q -m unit
|
||||
|
||||
# L2 集成(需 docker + start-dev)
|
||||
pytest tests/integration -q -m integration
|
||||
|
||||
# L3 链条
|
||||
pytest tests/chain -q -m chain
|
||||
|
||||
# L4 UI
|
||||
cd frontend && NEXUS_E2E_BASE=http://127.0.0.1:8600 NEXUS_E2E_PASSWORD=*** npm run test:e2e
|
||||
|
||||
# 全量验收
|
||||
python scripts/run_nexus_acceptance.py --with-unit --with-ui
|
||||
```
|
||||
|
||||
## 回滚
|
||||
|
||||
- 新目录与 marker 均为增量;删除 `tests/unit` 等不影响现门控
|
||||
- `test_api.py` 在迁移完成前保持不动
|
||||
|
||||
## 测试要点(实施自检)
|
||||
|
||||
- [ ] 任意新功能 PR 必须标明影响的用例编号(如 `IT-PUSH-003`)
|
||||
- [ ] MCP 探索报告路径:`docs/reports/mcp-runs/YYYY-MM-DD-<id>.md`
|
||||
@@ -0,0 +1,27 @@
|
||||
# B-02 Dashboard 24h 趋势 — 实施计划
|
||||
|
||||
## 文件清单
|
||||
|
||||
| 层 | 文件 |
|
||||
|----|------|
|
||||
| Domain | `server/domain/models/__init__.py` — `FleetMetricSample` |
|
||||
| Infra | `server/infrastructure/database/fleet_metric_repo.py` |
|
||||
| Background | `server/background/heartbeat_flush.py` — 采样 + purge |
|
||||
| API | `server/api/servers.py` — `GET /fleet-metrics` |
|
||||
| 迁移 | `server/infrastructure/database/migrations.py` |
|
||||
| 前端 | `frontend/src/components/dashboard/DashboardTrendChart.vue` |
|
||||
| 前端 | `frontend/src/pages/DashboardPage.vue` |
|
||||
| 测试 | `tests/test_fleet_metrics.py` |
|
||||
|
||||
## 步骤
|
||||
|
||||
1. 建表迁移 + ORM
|
||||
2. repo:insert_sample / list_since / purge_older_than
|
||||
3. heartbeat_flush 末尾聚合 Redis 并 insert
|
||||
4. API + Pydantic 响应
|
||||
5. SVG 图表组件 + Dashboard 挂载
|
||||
6. pytest + changelog + audit
|
||||
|
||||
## 回滚
|
||||
|
||||
- 删除 API 与前端卡片即可;表可保留无影响。
|
||||
@@ -0,0 +1,30 @@
|
||||
# 运维时区全局北京 — 实施
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 层 | 文件 |
|
||||
|----|------|
|
||||
| 工具 | `server/utils/display_time.py` |
|
||||
| Runner | `server/background/schedule_runner.py` |
|
||||
| 筛选 | `server/infrastructure/database/audit_log_repo.py`, `server/api/settings.py` |
|
||||
| 前端 | `frontend/src/utils/datetime.ts`, `scheduleCycle.ts`, `ScheduleCyclePicker.vue` |
|
||||
| 测试 | `tests/test_schedule_next_run.py`, `tests/test_operator_tz.py`(新) |
|
||||
|
||||
## 步骤
|
||||
|
||||
1. 扩展 `display_time`:`operator_wall`、`beijing_day_range_utc`
|
||||
2. `schedule_runner` 用北京墙钟匹配 cron;`last_run_at` 写 naive UTC
|
||||
3. 前端 `beijingWallParts` + `scheduleCycle` 统一北京
|
||||
4. 审计/告警日期筛选改北京日界
|
||||
5. 单测 + changelog
|
||||
|
||||
## 回滚
|
||||
|
||||
还原 runner、scheduleCycle、audit filter 三处;DB 无需回滚。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m pytest tests/test_schedule_next_run.py tests/test_operator_tz.py tests/test_display_time.py -q
|
||||
cd frontend && npm run type-check
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
# 实施说明 — 生产探针与健康检查补全(2026-06-09)
|
||||
|
||||
## 背景
|
||||
|
||||
- `prod_health_check.sh` 鉴权段依赖 `NEXUS_TEST_ADMIN_PASSWORD`,生产 `.env.prod` 未配置且文件权限 root,导致 SKIP。
|
||||
- 国内运营商线路可能阻断外网 HTTPS,但 SSH 源站 `127.0.0.1:8600` 可用。
|
||||
|
||||
## 方案
|
||||
|
||||
1. **`scripts/prod_probe.py`**:统一探针逻辑(外网公开 / SSH 源站 / 鉴权契约)。
|
||||
2. **`scripts/prod_probe_install.sh`**:在宿主机 `.env.prod` 写入探针口令(与 admin 登录一致,不入 git)。
|
||||
3. **`prod_health_check.sh`**:薄封装,调用 `prod_probe.py`。
|
||||
|
||||
## 鉴权检查项
|
||||
|
||||
| 端点 | 断言 |
|
||||
|------|------|
|
||||
| `POST /api/auth/login` | 返回 `access_token` |
|
||||
| `GET /health/detail` | `checks.mysql` / `checks.redis` = ok |
|
||||
| `GET /api/alert-history/stats` | today/active/recovered/top_server |
|
||||
| `GET /api/alert-history/?per_page=50` | items + total + per_page=50 |
|
||||
| 命令/会话分页 | items + total |
|
||||
| `POST /api/schedules/` once+push | HTTP 201,随后 DELETE 清理 |
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `scripts/prod_probe.py`(新)
|
||||
- `scripts/prod_probe_install.sh`(新)
|
||||
- `scripts/prod_health_check.sh`
|
||||
- `docker/.env.prod.example`
|
||||
- `docs/project/nexus-1panel-operations-knowledge.md`(探针小节)
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
bash scripts/prod_health_check.sh
|
||||
NEXUS_TEST_ADMIN_PASSWORD='…' bash scripts/prod_probe_install.sh # 一次性配置生产
|
||||
```
|
||||
|
||||
## 回滚
|
||||
|
||||
- 删除 `.env.prod` 中 `NEXUS_TEST_ADMIN_PASSWORD=` 行;探针鉴权段恢复 SKIP。
|
||||
@@ -0,0 +1,90 @@
|
||||
# 全站测试体系设计(2026-06-08)
|
||||
|
||||
## 背景与目标
|
||||
|
||||
Nexus 6.0 已有 **~60 个 pytest 模块**、`tests/test_api.py` 门控脚本、`frontend/e2e/full-acceptance.spec.mjs`(15 条 UI 冒烟)、`scripts/run_nexus_acceptance.py` 统一验收入口。缺口在于:
|
||||
|
||||
- 单元测试按**功能域**未全覆盖,前端几乎无单测
|
||||
- 集成测试以手写 `urllib` 为主,缺契约快照与 DB 夹具规范
|
||||
- **链条测试**(多 API / 多页面 / WS / 后台任务)用例未系统化
|
||||
- Agent **MCP 浏览器点页**有 Playwright MCP,但未沉淀为可重复用例清单
|
||||
|
||||
**目标**:建立四层测试金字塔 + Agent MCP 探索层,全部有**编号用例**、可 CI、可 `run_nexus_acceptance.py` 汇总。
|
||||
|
||||
## 方案对比
|
||||
|
||||
| 方案 | 优点 | 缺点 | 结论 |
|
||||
|------|------|------|------|
|
||||
| A. 仅扩 pytest | 与现门控一致 | 无真实 UI 交互 | 作为 L1–L3 底座 |
|
||||
| B. 仅 Playwright | 贴近用户 | 慢、脆、难测纯逻辑 | 作为 L4 |
|
||||
| C. MCP 临时点页 | Agent 灵活 | 不可 CI、难回归 | 作为 L5 探索 + 用例沉淀到 B |
|
||||
| **D. 四层 + MCP 规程(选定)** | 分工清晰、可渐进 | 初期文档/用例工作量大 | ✅ |
|
||||
|
||||
## 选定架构
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ L5 Agent MCP 探索 │ 新功能/回归抽测 → 沉淀为 L4
|
||||
└──────────┬──────────┘
|
||||
┌──────────▼──────────┐
|
||||
│ L4 UI E2E (Playwright)│ frontend/e2e/*.spec.mjs
|
||||
└──────────┬──────────┘
|
||||
┌──────────▼──────────┐
|
||||
│ L3 链条 (Chain) │ tests/chain/ + pytest markers
|
||||
└──────────┬──────────┘
|
||||
┌──────────▼──────────┐
|
||||
│ L2 集成 (API+DB) │ tests/integration/ + test_api.py
|
||||
└──────────┬──────────┘
|
||||
┌──────────▼──────────┐
|
||||
│ L1 单元 (Unit) │ tests/unit/ 按域拆分
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### 工具链
|
||||
|
||||
| 层 | 工具 | CI | Agent MCP |
|
||||
|----|------|-----|-----------|
|
||||
| L1 | pytest + pytest-asyncio + 内存 SQLite/假 Redis | ✅ gitea workflow | 否 |
|
||||
| L2 | pytest + httpx AsyncClient + docker mysql/redis | ✅ 可选 job | MySQL MCP 验数 |
|
||||
| L3 | pytest markers `@pytest.mark.chain` | ✅ nightly | 否 |
|
||||
| L4 | `@playwright/test` | ✅ `--with-ui` | Playwright MCP 对照用例 |
|
||||
| L5 | Playwright MCP `browser_*` | ❌ 人工触发 Agent | ✅ |
|
||||
|
||||
### 目录规划(渐进迁移,不一次性搬空 tests/)
|
||||
|
||||
```
|
||||
tests/
|
||||
unit/ # 纯函数、schema、utils(从现有 test_*.py 归类迁入)
|
||||
integration/ # API + DB,httpx TestClient / 活 API
|
||||
chain/ # 多步业务流
|
||||
fixtures/ # 共享 conftest 分层
|
||||
acceptance_catalog.py # 扩展 layer: unit|integration|chain|ui|mcp
|
||||
frontend/e2e/
|
||||
pages/ # 每页一个 spec(从 full-acceptance 拆分)
|
||||
flows/ # 链条:登录→推送→进度→历史
|
||||
docs/testing/
|
||||
test-case-matrix.md # 全量用例 SSOT(编号)
|
||||
mcp-browser-playbook.md # Agent MCP 点页规程
|
||||
```
|
||||
|
||||
### MCP Agent 点页规程(原则)
|
||||
|
||||
1. **用例先行**:每个 MCP 探索必须对应 `docs/testing/test-case-matrix.md` 中 `MCP-*` 编号
|
||||
2. **步骤可复现**:snapshot → click → assert 文本;截图存 `docs/reports/e2e-screenshots/`
|
||||
3. **禁止生产破坏性操作**:推送/脚本执行/批量删除仅对 `test-*` 子机或 dry-run
|
||||
4. **沉淀**:MCP 通过的流程 7 日内写入 Playwright spec(L4)
|
||||
|
||||
### 安全与性能约束
|
||||
|
||||
- 测试账号:`NEXUS_TEST_ADMIN_*`,禁止写生产密钥入 git
|
||||
- E2E 默认 `127.0.0.1:8600`;生产 URL 仅只读冒烟且需用户批准
|
||||
- 链条测试子机:标记 `is_test=1` 或使用 ID 段约定(文档化)
|
||||
- 单 CI job pytest 目标 < 10min;chain/ui 放 nightly
|
||||
|
||||
## 验收标准(体系本身)
|
||||
|
||||
- [ ] `docs/testing/test-case-matrix.md` 覆盖 15 SPA 页 + 安装向导 + Agent 心跳
|
||||
- [ ] 每层有 ≥1 个可运行示例用例与命令
|
||||
- [ ] `run_nexus_acceptance.py` 能 rollup unit/integration/chain/ui 四层
|
||||
- [ ] Agent 手册 `mcp-browser-playbook.md` 含登录、推送提交、脚本执行看板 3 条标准 MCP 流
|
||||
- [ ] CI:PR 跑 L1+L2;main nightly 跑 L3+L4
|
||||
@@ -0,0 +1,60 @@
|
||||
# Dashboard 24h 资源趋势(B-02)设计
|
||||
|
||||
## 背景与目标
|
||||
|
||||
仪表盘当前仅展示 Redis 实时快照的平均 CPU/内存;无历史曲线。运维需要查看**近 24 小时**全舰队 CPU/内存/磁盘变化趋势。
|
||||
|
||||
## 方案对比
|
||||
|
||||
| 方案 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| A. 每机时序表 | 可下钻单机 | 367 台 × 144 点/天,存储与查询重 |
|
||||
| B. **舰队聚合采样(选定)** | 与 Dashboard 概览一致;10min 一条 | 无单机曲线 |
|
||||
| C. 仅 Redis 滑动窗口 | 无 DB | 重启丢失;非 SSOT |
|
||||
|
||||
## 选定方案 B
|
||||
|
||||
- `heartbeat_flush`(10min)在写 `servers` 后,从本轮 Redis 心跳计算舰队均值,写入 `fleet_metric_samples`。
|
||||
- 保留 **7 天**;API 默认 `hours=24`。
|
||||
- 前端 Dashboard 新增「24h 资源趋势」卡片,使用 **Vuetify 4 `v-sparkline`**(无新 npm 依赖)。
|
||||
|
||||
## 数据模型
|
||||
|
||||
```sql
|
||||
fleet_metric_samples (
|
||||
id INT PK,
|
||||
recorded_at DATETIME NOT NULL, -- UTC naive
|
||||
online_count INT,
|
||||
sample_count INT,
|
||||
cpu_avg FLOAT NULL,
|
||||
mem_avg FLOAT NULL,
|
||||
disk_avg FLOAT NULL,
|
||||
INDEX (recorded_at)
|
||||
)
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
`GET /api/servers/fleet-metrics?hours=24`
|
||||
|
||||
```json
|
||||
{
|
||||
"hours": 24,
|
||||
"interval_minutes": 10,
|
||||
"points": [
|
||||
{ "recorded_at": "2026-06-09 12:00:00", "cpu_avg": 12.5, "mem_avg": 45.2, "disk_avg": 60.1, "online_count": 366 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 安全与性能
|
||||
|
||||
- 需 `get_current_admin`;只读聚合。
|
||||
- 每 10min 1 行;7 天约 1008 行,可忽略。
|
||||
- purge 在 flush 循环末尾批量 DELETE。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 部署 24h 后曲线有数据;新环境至少显示空状态文案。
|
||||
- 时间轴按北京时间展示。
|
||||
- `local_verify` + 单测通过。
|
||||
@@ -0,0 +1,31 @@
|
||||
# 运维时区全局北京 — 设计
|
||||
|
||||
## 背景与目标
|
||||
|
||||
- 存储与 API 内部继续 **UTC naive**;运维表单与调度执行语义统一为 **Asia/Shanghai(北京时间)**。
|
||||
- 解决:cron 按 UTC 跑但界面按北京展示;单次与循环时区不一致;审计日期筛选漏北京 0–8 点。
|
||||
|
||||
## 方案对比
|
||||
|
||||
| 方案 | 说明 | 结论 |
|
||||
|------|------|------|
|
||||
| A. UTC 存储 + 北京语义 | runner/表单/日界按北京,DB 仍 UTC | **选用** |
|
||||
| B. DB 存北京 naive | 与 Agent/JWT/parseApiDateTime 冲突 | 否决 |
|
||||
|
||||
## 选定方案
|
||||
|
||||
- **SSOT**:`server/utils/display_time.py` 中 `BEIJING_TZ` / `OPERATOR_TZ`。
|
||||
- **cron_expr**:5 字段按北京墙钟解释;字符串格式不变。
|
||||
- **fire_at**:UTC naive 入库;表单「02:00 北京」→ 对应 UTC instant。
|
||||
- **存量**:无 DB 迁移;部署后人工核对调度列表(生产几乎无启用调度)。
|
||||
|
||||
## 不改
|
||||
|
||||
JWT/TOTP、Agent 心跳与漂移、Redis TTL、告警 Telegram 去重、`created_at` 写入。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. cron `0 2 * * *` 在 UTC `18:00`(北京次日 02:00)触发,非 UTC 02:00。
|
||||
2. 列表 `next_run` 与表单时:分一致(北京)。
|
||||
3. 单次调度保存后再编辑,时:分回显正确。
|
||||
4. 审计 `date_from=YYYY-MM-DD` 覆盖北京当日 00:00–23:59。
|
||||
@@ -1,8 +1,7 @@
|
||||
# Nexus — AI 接续(2026-06-08 · Linux)
|
||||
|
||||
> **新会话请复制下方「复制提示词」整段。**
|
||||
> SSOT:`docs/project/nexus-functional-development-guide.md` · 门控 `bash deploy/pre_deploy_check.sh`
|
||||
> 完整待办 132 条见本文 §「Master Backlog 索引」。
|
||||
> **已由 [AI-HANDOFF-2026-06-09.md](AI-HANDOFF-2026-06-09.md) 取代**(生产 @ `56139a4` 时区交付)。下文保留 Master Backlog 索引。
|
||||
> SSOT:`docs/project/nexus-functional-development-guide.md` · 门控 `bash deploy/pre_deploy_check.sh`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Nexus — AI 接续(2026-06-09 · Linux)
|
||||
|
||||
> **新会话请复制下方「复制提示词」整段。**
|
||||
> 取代 [`AI-HANDOFF-2026-06-08.md`](AI-HANDOFF-2026-06-08.md)(版本号已过时)。
|
||||
> SSOT:`docs/project/nexus-functional-development-guide.md` · 门控 `bash deploy/pre_deploy_check.sh`
|
||||
|
||||
---
|
||||
|
||||
## 复制提示词(整段给新会话)
|
||||
|
||||
```
|
||||
【Nexus 6.0 接续 · Linux · 2026-06-09】
|
||||
|
||||
工作区:$NEXUS_ROOT(本机 ~/桌面/Nexus)
|
||||
仓库:http://66.154.115.8:3000/admin/Nexus.git · 分支 main @ 64c8bd7
|
||||
生产:https://api.synaglobal.vip · Docker @ /opt/nexus · 端口 8600
|
||||
· 运行中前端 ≈ c366ed5(tar/sync_webapp);主机 git checkout 可能仍 664efb9,下次 upgrade 会 pull
|
||||
SSH:NEXUS_SSH=azureuser@20.24.218.235 · NEXUS_SSH_KEY="/home/r/下载/Telegram Desktop/nz-admin.pem"
|
||||
push:bash scripts/git-push.sh
|
||||
部署:NEXUS_DEPLOY_PATH=/opt/nexus bash deploy/pre_deploy_check.sh && NEXUS_DEPLOY_PATH=/opt/nexus bash deploy/deploy-production.sh
|
||||
(本机开发路径含中文时,部署必须显式 NEXUS_DEPLOY_PATH=/opt/nexus)
|
||||
|
||||
【栈】FastAPI + Async SQLAlchemy + Redis · Vue 3 + Vuetify 4 SPA · Vite → web/app/
|
||||
【铁律】单 Agent · perfect-implementation.mdc · 改代码必 changelog + audit · 测试 Agent 做、用户终验
|
||||
|
||||
【生产已部署】
|
||||
- 运维时区北京:e3efbd4 / 56139a4 · docs/reports/2026-06-09-operator-timezone-beijing-verification.md
|
||||
- Wave 0:prod_probe 时区、sync_webapp_to_container、D-01 agent_deploy(863e49e)
|
||||
- Dashboard 24h 趋势 B-02:664efb9(fleet_metric_samples + v-sparkline)
|
||||
- 推送页分类全量:c366ed5 · docs/reports/2026-06-09-push-category-full-list-deploy-verification.md
|
||||
(机器人 101 台;旧版仅显示 5 台因 per_page=200)
|
||||
|
||||
【用户终验待确认】
|
||||
- 推送页「机器人」0/101;商城等与分类 chip 一致
|
||||
- Dashboard 24h 资源趋势(采样约 10min/条)
|
||||
- 调度页北京时间 / next_run;WS 告警时间
|
||||
- 批量 101 台、WebSSH、Telegram、关 CPU 告警无恢复轰炸
|
||||
|
||||
【优先待办 TOP 10】
|
||||
1. 生产 git pull 对齐 c366ed5(或一次 deploy-production 消歧)
|
||||
2. API detail 中文化 — 422 ☑ · HTTP 4xx 全局层 ☑ · ValueError 源头 ☑(已部署 64c8bd7)
|
||||
3. Agent 401 子机 rollout(A-05)
|
||||
4. 无心跳自动离线 + 告警降噪
|
||||
5. L4 Playwright 全绿 + L5 清单
|
||||
6. 资产管理 / Sync 配置 UI(C-01/C-02)
|
||||
7. B-03 Files 树形侧栏
|
||||
8. 用户浏览器终验(见 final-acceptance-checklist)
|
||||
9. 国内 CDN / 双入口(电信 HTTPS RST)
|
||||
10. POSIX + 文件管理 FM-L* 生产验收
|
||||
|
||||
【完整 132 条】AI-HANDOFF-2026-06-08.md §Master Backlog
|
||||
【对齐计划 31 项】docs/design/plans/2026-06-07-missing-features-alignment-plan.md
|
||||
|
||||
【勿提交】deploy/gate_log.jsonl · .cursor/settings.json
|
||||
【Handoff】docs/project/AI-HANDOFF-2026-06-09.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 生产状态
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| Git 远程 `main` | **`c366ed5`** |
|
||||
| 生产 `/opt/nexus` checkout | **`664efb9`**(web/app 已热更至 c366ed5 资源) |
|
||||
| 前端入口 | `index-jg6n1uIt.js` · `PushPage-DukhcoEE.js` |
|
||||
| 生产 URL | `https://api.synaglobal.vip` |
|
||||
| 探针 | `NEXUS_PROBE_MODE=origin bash scripts/prod_health_check.sh` |
|
||||
| 外网 | 部分线路 HTTPS RST;隧道 `ssh -L 18600:127.0.0.1:8600 nexus` |
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-09 主要交付
|
||||
|
||||
| 主题 | 提交/文档 |
|
||||
|------|-----------|
|
||||
| 运维时区北京 | `e3efbd4` |
|
||||
| Wave 0 部署对齐 | `863e49e` |
|
||||
| Dashboard 24h 趋势 B-02 | `664efb9` |
|
||||
| 推送分类全量加载 | `c366ed5` · changelog + verification |
|
||||
| 生产探针 / sync_webapp / D-01 | `863e49e` 起 |
|
||||
|
||||
---
|
||||
|
||||
## 关键路径
|
||||
|
||||
| 用途 | 路径 |
|
||||
|------|------|
|
||||
| 推送全量列表 | `frontend/src/composables/push/usePushServers.ts` |
|
||||
| 未设路径独立筛选 | `frontend/src/pages/ServersPage.vue` → `unsetListQueryParams` |
|
||||
| Dashboard 趋势 | `frontend/src/components/dashboard/DashboardFleetTrends.vue` |
|
||||
| 时区工具 | `server/utils/display_time.py` |
|
||||
| 前端同步 | `deploy/sync_webapp_to_container.sh` |
|
||||
| Handoff | 本文 |
|
||||
@@ -397,7 +397,26 @@ sudo nx verify # 已安装时应 PASS「install.html 已归档 → HTTP 404」
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收清单
|
||||
## 8. 生产探针(post-deploy)
|
||||
|
||||
部署后从开发机巡检(外网 HTTPS 可能被运营商重置时,**源站 SSH 探针仍可通过**):
|
||||
|
||||
```bash
|
||||
bash scripts/prod_health_check.sh # auto:外网尽力 + SSH 源站必检
|
||||
NEXUS_PROBE_MODE=origin bash scripts/prod_health_check.sh # 仅源站
|
||||
```
|
||||
|
||||
一次性在宿主机写入探针口令(**须与 admin 登录密码一致**,不入 git):
|
||||
|
||||
```bash
|
||||
NEXUS_TEST_ADMIN_PASSWORD='你的admin密码' bash scripts/prod_probe_install.sh
|
||||
```
|
||||
|
||||
写入路径:`/opt/nexus/docker/.env.prod`(root 600)。鉴权段覆盖:`/health/detail`、告警 `per_page=50`、once 调度 201 创建并删除。
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收清单
|
||||
|
||||
```bash
|
||||
cd /opt/nexus && sudo nx verify
|
||||
@@ -416,7 +435,7 @@ cd /opt/nexus && sudo nx verify
|
||||
|
||||
---
|
||||
|
||||
## 9. 提交修复索引
|
||||
## 10. 提交修复索引
|
||||
|
||||
| 提交 | 主题 |
|
||||
|------|------|
|
||||
@@ -432,7 +451,7 @@ cd /opt/nexus && sudo nx verify
|
||||
|
||||
---
|
||||
|
||||
## 10. 与功能开发 SSOT 的差异
|
||||
## 11. 与功能开发 SSOT 的差异
|
||||
|
||||
| 点 | `nexus-functional-development-guide.md` §16 | 1Panel 实装 |
|
||||
|----|---------------------------------------------|-------------|
|
||||
|
||||
@@ -12,8 +12,15 @@
|
||||
- [x] 推送页预览按钮(未正式推送)
|
||||
- [x] 生产 API:认证、CRUD、搜索、告警、资产、同步 browse、webssh-token、health/detail
|
||||
|
||||
## Agent 已验证(2026-06-09 时区 + 部署)
|
||||
|
||||
- [x] 调度时区:生产 API cron `0 2 * * *` → `next_run` UTC 18:00(北京 02:00);`SchedulesPage` 含北京时间提示
|
||||
- [x] 告警/审计非法 `date_from` → HTTP 422(`prod_probe` origin)
|
||||
- [x] `prod_health_check.sh` origin 全绿(含 per_page=50、once 调度 201)
|
||||
|
||||
## 建议你终验(可选,有副作用或 Agent 未做)
|
||||
|
||||
- [ ] `/app/#/schedules`:在白名单 IP 浏览器核对 Picker 提示与 `next_run` 列
|
||||
- [ ] WebSSH:选一台服务器实际连上并输入 `pwd`(Agent 只验页面与 token API)
|
||||
- [ ] 推送:若需确认 rsync,用**预览**或测试机,勿对生产误点「推送」
|
||||
- [ ] Telegram「测试发送」是否收到(设置页)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# 2026-06-08 Agent 401 停心跳 — 部署验证
|
||||
|
||||
## 部署信息
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| Commit | `972456a` feat(agent): 401 停心跳、配置重载后重启与升级双文件下发 |
|
||||
| 门控 | 7/7 PASS |
|
||||
| 方式 | `deploy/deploy-production.sh` |
|
||||
|
||||
## 生产检查
|
||||
|
||||
| 检查 | 结果 |
|
||||
|------|------|
|
||||
| `GET /health` | `ok` |
|
||||
| `GET /app/` | HTTP 200 |
|
||||
| `GET /agent/heartbeat_policy.py` | HTTP 200,含 `heartbeat_should_stop` |
|
||||
| `GET /agent/agent.py` | 含 `heartbeat_policy` import、`AGENT_VERSION` |
|
||||
|
||||
## 子机 rollout(#7 · 用户/运维)
|
||||
|
||||
中心已提供新文件;**各子机须批量「升级 Agent」** 才会下载 `agent.py` + `heartbeat_policy.py`。
|
||||
|
||||
验收步骤(抽 1 台):
|
||||
|
||||
1. `journalctl -u nexus-agent -f` 观察心跳
|
||||
2. 临时改错 `config.json` 中 central api_key → 应出现 **一条** 401 error 后不再重复
|
||||
3. 通过管理端修正 key 或 `/config/reload` → 心跳应恢复
|
||||
|
||||
## 回滚
|
||||
|
||||
MySQL: `/var/backups/nexus/nexus-before-upgrade-20260608-150632.sql.gz`
|
||||
镜像: `nexus-prod-nexus:rollback`
|
||||
@@ -0,0 +1,40 @@
|
||||
# 部署验证 — 北京时间统一 + 恢复 Telegram 去重(2026-06-08)
|
||||
|
||||
## 部署信息
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| Commit | `abeab9d` |
|
||||
| 消息 | Fix recovery Telegram dedup and unify Beijing time display |
|
||||
| 路径 | `/opt/nexus` Docker |
|
||||
| 时间 | 2026-06-08 |
|
||||
|
||||
## 门控
|
||||
|
||||
`pre_deploy_check.sh` — **7/7 PASS**
|
||||
|
||||
## 健康检查
|
||||
|
||||
| 检查 | 结果 |
|
||||
|------|------|
|
||||
| `https://api.synaglobal.vip/health` | `ok` |
|
||||
| `https://api.synaglobal.vip/app/` | HTTP 200 |
|
||||
| 远程 `127.0.0.1:8600/health` | ok |
|
||||
| MySQL 升级前备份 | `nexus-before-upgrade-20260608-151933.sql.gz` |
|
||||
|
||||
## 生产代码抽检
|
||||
|
||||
- `format_beijing_now()` 输出含「北京时间」
|
||||
- `_detect_recovery` 对 legacy `mem:90` + `mem:92` 仅返回 `[('mem', 50.0)]`
|
||||
|
||||
## 用户终验建议
|
||||
|
||||
1. **审计页** `#/audit` — 「时间」列应为北京时间(非 UTC 裸字符串)
|
||||
2. **告警历史** `#/alerts` — 同上
|
||||
3. 子机内存超阈值后回落 — Telegram **每条指标仅 1 条**恢复消息,时间为北京时间
|
||||
|
||||
## 进度
|
||||
|
||||
```
|
||||
☑实现 ☑本地验证 ☑审计8步 ☑部署 ☑健康检查 □浏览器验证(待用户终验) ☑changelog
|
||||
```
|
||||
@@ -0,0 +1,52 @@
|
||||
# 2026-06-08 全量对齐部署验证
|
||||
|
||||
## 部署信息
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| Commit | `69068e2` feat(ops): server_batch 回收、sync_logs 清理与 Agent 安装跳过 |
|
||||
| 前序 | `a401ea5`(Settings 422、server_ids 2000) |
|
||||
| 方式 | `deploy/deploy-production.sh` → Docker `nexus-1panel.sh upgrade` |
|
||||
| 时间 | 2026-06-08 |
|
||||
|
||||
## 门控
|
||||
|
||||
```
|
||||
pre_deploy_check.sh → 7/7 PASS
|
||||
pytest test_agent_version + test_server_batch_reconcile + test_sync_log_purge → 16 passed
|
||||
local_verify → 26/26
|
||||
```
|
||||
|
||||
## 生产健康
|
||||
|
||||
| 检查 | 结果 |
|
||||
|------|------|
|
||||
| `GET https://api.synaglobal.vip/health` | `ok` |
|
||||
| `GET https://api.synaglobal.vip/app/` | HTTP 200 |
|
||||
| 容器 `recover_orphaned` in main.py | 2 |
|
||||
| 容器 `sync_log_purge` in main.py | 5 |
|
||||
| 容器 `agent_install_skip_result` | 2 |
|
||||
| `server/utils/agent_version.py` | 存在 |
|
||||
|
||||
## 前端 bundle
|
||||
|
||||
| 位置 | index hash |
|
||||
|------|------------|
|
||||
| 生产 `/app/index.html` | `index-E6hwgODJ.js` |
|
||||
| Docker 构建 | frontend 层 CACHE(源码未变,与上次镜像一致) |
|
||||
|
||||
说明:本次 commit 仅后端;Settings/调度等前端已在 `a401ea5` 源码中,当前 bundle 与升级前相同属预期。若需强制刷新 bundle,需 `docker build --no-cache` 或改动 `frontend/` 触发重建。
|
||||
|
||||
## 功能验收(Agent 终验)
|
||||
|
||||
- [ ] 批量安装 Agent:已装 2.0.0 子机应显示「已跳过」
|
||||
- [ ] API 重启后 running 批量任务应收尾(僵尸 #20 类)
|
||||
- [ ] 强刷浏览器后 Settings 保存无 422
|
||||
|
||||
## 回滚
|
||||
|
||||
```bash
|
||||
ssh nexus 'sudo docker tag nexus-prod-nexus:rollback nexus-prod-nexus:latest && cd /opt/nexus && sudo nx update'
|
||||
```
|
||||
|
||||
MySQL 备份:`/var/backups/nexus/nexus-before-upgrade-20260608-072350.sql.gz`
|
||||
@@ -15,6 +15,8 @@
|
||||
|
||||
| 项 | 结果 |
|
||||
|----|------|
|
||||
| GET /health | |
|
||||
| 仪表盘无服务器列表 | |
|
||||
| Settings 关 CPU → 无恢复 Telegram | |
|
||||
| GET https://api.synaglobal.vip/health | ok |
|
||||
| GET /app/ | HTTP 200 |
|
||||
| 生产 commit | cb49fd9 |
|
||||
| 仪表盘无服务器列表 | 待浏览器终验 |
|
||||
| Settings 关 CPU → 无恢复 Telegram | 待浏览器终验 |
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# 2026-06-08 — notify 开关 + 行内详情 部署验收
|
||||
|
||||
## 版本
|
||||
|
||||
- **Commit**: `27da842`
|
||||
- **生产**: Docker @ `/opt/nexus`
|
||||
- **时间**: 2026-06-08
|
||||
|
||||
## 门控
|
||||
|
||||
- pre_deploy_check: **7/7 PASS**
|
||||
- pytest `test_notify_toggle_sync.py`: **25 passed**
|
||||
|
||||
## 健康检查
|
||||
|
||||
| 项 | 结果 |
|
||||
|----|------|
|
||||
| `https://api.synaglobal.vip/health` | ok |
|
||||
| `https://api.synaglobal.vip/app/` | 200 |
|
||||
| 容器 | `nexus-prod-nexus-1` healthy |
|
||||
|
||||
## 变更摘要
|
||||
|
||||
1. **Telegram 告警开关** — Redis `nexus:settings` 跨 worker 同步;关闭 CPU/内存/磁盘等 notify_* 后不再发 Telegram(WebSocket/日志仍记录)。
|
||||
2. **服务器行内详情** — 点击名称展开 `ServerInlineDetail`(expanded id 与 `item-value` 数字一致)。
|
||||
|
||||
## 浏览器终验(用户)
|
||||
|
||||
- [ ] 服务器页 → 点击名称 → 展开 CPU/内存/同步日志
|
||||
- [ ] 设置页 → 关闭「CPU 超阈值告警」→ 确认不再收到 Telegram(WebSocket 告警仍可有)
|
||||
@@ -0,0 +1,53 @@
|
||||
# Playwright 全量 E2E — 本地验证报告
|
||||
|
||||
**日期**: 2026-06-08
|
||||
**环境**: `http://127.0.0.1:8600` · admin + `NEXUS_E2E_PASSWORD` · Chromium headless
|
||||
|
||||
## 结果(最终)
|
||||
|
||||
```bash
|
||||
cd frontend && npx playwright install chromium # 首次
|
||||
NEXUS_E2E_BASE=http://127.0.0.1:8600 \
|
||||
NEXUS_E2E_USER=admin \
|
||||
NEXUS_E2E_PASSWORD=*** \
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| 合计 | **35** |
|
||||
| 通过 | **35** |
|
||||
| 失败 | 0 |
|
||||
| 跳过 | 0 |
|
||||
| 耗时 | ~35s |
|
||||
|
||||
### 复验(清理后 · 2026-06-08)
|
||||
|
||||
| 套件 | 结果 |
|
||||
|------|------|
|
||||
| `pytest tests/integration tests/chain` | **27 passed** |
|
||||
| `pytest tests/ -m "not chain and not slow"` | **427 passed** |
|
||||
| `pytest tests/ -m slow` | **1 passed**(IT-INST-001) |
|
||||
| `scripts/local_integration_smoke.sh` | **OK** |
|
||||
| `npm run test:e2e` | **35 passed** (~35s) |
|
||||
|
||||
前置:`bash scripts/start-dev.sh`(API :8600 healthy)
|
||||
|
||||
## 根因与修复(本轮)
|
||||
|
||||
| 问题 | 处理 |
|
||||
|------|------|
|
||||
| 并行 UI 登录触发 admin 锁定(429) | `e2e/fixtures.mjs`:每 worker 一次 API 登录 + 轮换后更新 `storageState` |
|
||||
| `page.goto(#/route)` 在 keep-alive SPA 下偶发停在 `#/` | `helpers.nav` 改为侧栏链接点击;设置页点抽屉「设置」 |
|
||||
| 凭据入口改为服务器页对话框 | `credentials` / acceptance #08 点「凭据」按钮,不再依赖 `?credentials=1` |
|
||||
| `getByText('命令日志')` strict 双匹配 | 限定 `main .v-card-title` |
|
||||
| `web/app` 构建滞后(旧 bundle 含「导出CSV」) | `npx vite build` 同步源码与静态资源 |
|
||||
|
||||
## Spec 目录(19 文件)
|
||||
|
||||
- `full-acceptance.spec.mjs`(15 步 serial)
|
||||
- `pages/*.spec.mjs`(18 页域用例 + install/login/auth 无会话)
|
||||
|
||||
## 结论
|
||||
|
||||
**Wave 1–5 测试矩阵对应的 Playwright 全量 35/35 green**,可与 `tests/integration` + `tests/chain`(27 passed)一并作为 L4 证据。
|
||||
@@ -0,0 +1,13 @@
|
||||
# 部署验证 — 推送后台化 + 422 中文化 + 隐藏 CSV(2026-06-08)
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| Commit | `091fb97` |
|
||||
| 健康 | `https://api.synaglobal.vip/health` → ok |
|
||||
| SPA | `/app/` → 200 |
|
||||
|
||||
## 终验要点
|
||||
|
||||
1. **推送页** — 点「推送」后按钮仅短暂 loading,出现「推送已提交」toast,下方进度列表继续更新
|
||||
2. **服务器页** — 工具栏无「导出 CSV」
|
||||
3. **422 提示** — 若参数超限,应显示「每页条数:不能大于 200」而非 `query.per_page`
|
||||
@@ -0,0 +1,22 @@
|
||||
# 2026-06-08 — 调度「当天」单次 UI 部署验证
|
||||
|
||||
## 部署
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| Commit | `22d5d21` |
|
||||
| 门控 | 7/7 PASS |
|
||||
| 容器 | `nexus-prod-nexus-1` healthy |
|
||||
| /health | ok |
|
||||
| /app/ | 200 |
|
||||
|
||||
## 前端产物
|
||||
|
||||
- `SchedulesPage-eYsxbL9M.js`(镜像内 vite build)
|
||||
- 含「当天」标签,单次模式无「重复方式」下拉
|
||||
|
||||
## 浏览器终验
|
||||
|
||||
1. 打开 https://api.synaglobal.vip/app/#/schedules
|
||||
2. 新建/编辑调度 → 单次:应见「当天」+ 时/分,无「重复方式」
|
||||
3. 切换循环:应见窄「重复方式」下拉,首项为「当天」
|
||||
@@ -0,0 +1,29 @@
|
||||
# Wave 5 测试缺口补齐 — 验证报告
|
||||
|
||||
**日期**: 2026-06-08
|
||||
**范围**: CH-BAT-001、IT-FILE/INST/SRV-003、MCP→E2E 沉淀 7 页
|
||||
|
||||
## 本地 pytest
|
||||
|
||||
| 命令 | 结果 |
|
||||
|------|------|
|
||||
| `pytest tests/integration tests/chain -q` | **26 passed** |
|
||||
| `pytest tests/ -m "not chain and not slow" -q` | **427 passed**, 9 deselected |
|
||||
|
||||
## 新增用例 ID
|
||||
|
||||
| ID | 实现 |
|
||||
|----|------|
|
||||
| CH-BAT-001 | `tests/chain/test_batch_job_flow.py` |
|
||||
| IT-FILE-001 | `tests/integration/test_files_browse.py` |
|
||||
| IT-INST-001 | `tests/integration/test_install_mode.py`(slow) |
|
||||
| IT-SRV-003 | `tests/integration/test_notify_settings.py` |
|
||||
| E2E-TERM/FILE/SCR/CRED/RET/SET/EXEC-002 | `frontend/e2e/pages/*.spec.mjs` |
|
||||
|
||||
## E2E
|
||||
|
||||
未在本机跑 Playwright(需 `NEXUS_E2E_PASSWORD` + API :8600)。CI main job 在配置密钥后可跑全量 `e2e/`。
|
||||
|
||||
## 结论
|
||||
|
||||
后端缺口用例已落地并通过本地验证;矩阵已更新。安装向导 E2E 与 CH-EXEC 轮询终态仍标待写。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 部署验证 — 告警分页 + 调度单次 fire_at(2026-06-09)
|
||||
|
||||
**提交**: `3864d12`(告警每页条数 + `fire_at` datetime 解析)
|
||||
**生产**: `https://api.synaglobal.vip`
|
||||
|
||||
## 公开健康(SSH → `127.0.0.1:8600` / 外网)
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|--------|------|
|
||||
| `GET /health` | `ok` |
|
||||
| 外网 `https://api.synaglobal.vip/health` | HTTP 200(经 SSH 侧验证) |
|
||||
| 容器 `nexus-prod-nexus-1` | Up, healthy(部署后约 2min) |
|
||||
|
||||
> 本机直连外网 HTTPS 偶发 `curl: (35) SSL connect error`;以 SSH 隧道结果为准。
|
||||
|
||||
## 本地集成
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/integration/test_schedules.py tests/integration/test_alerts_audit.py -q
|
||||
# 6 passed
|
||||
```
|
||||
|
||||
覆盖:告警 stats/分页契约、once 推送调度 `fire_at` ISO → 201。
|
||||
|
||||
## 生产鉴权 API
|
||||
|
||||
生产 `.env.prod` **未配置** `NEXUS_TEST_ADMIN_PASSWORD`,`scripts/prod_health_check.sh` 鉴权段自动 SKIP。
|
||||
|
||||
**待用户浏览器终验**(需 admin 登录):
|
||||
|
||||
1. **告警中心** → 每页数目切换 10 / 50 / 100,列表条数与请求一致。
|
||||
2. **调度** → 新建 → 文件推送 → **单次** → 选源/服务器 → 保存 → 应 **201**,无 500。
|
||||
3. 可选:删除误建测试调度 `e2e-push-cron-test`(若仍存在)。
|
||||
|
||||
## 结论
|
||||
|
||||
- 代码与本地测试:**通过**
|
||||
- 生产进程:**健康**
|
||||
- 鉴权业务路径:**待用户 UI 终验**(Agent 侧无生产测试口令)
|
||||
@@ -0,0 +1,30 @@
|
||||
# HTTP API detail 中文化 — 生产部署验证(2026-06-09)
|
||||
|
||||
## 部署范围
|
||||
|
||||
- `3ba78cd` — `http_errors_zh` 全局层(方案 A:字面量 detail 翻译)
|
||||
- `a842af2` — auth reason / fallback 中文
|
||||
- `1c0d7e9` — 服务层 ValueError 源头中文(方案 1)
|
||||
|
||||
## 部署记录
|
||||
|
||||
- **时间**: 2026-06-09
|
||||
- **方式**: `NEXUS_DEPLOY_PATH=/opt/nexus bash deploy/deploy-production.sh`
|
||||
- **远程 commit**: `64c8bd7`(含 `3ba78cd` `a842af2` `1c0d7e9`)
|
||||
- **门控**: 7/7 PASS
|
||||
- **升级脚本**: `/health → ok`,`/app/ → 200`,`sync_webapp_to_container` OK
|
||||
|
||||
## 验证项
|
||||
|
||||
| # | 检查 | 命令/操作 | 期望 | 结果 |
|
||||
|---|------|-----------|------|------|
|
||||
| 1 | 健康检查 | 升级脚本 `127.0.0.1:8600/health` | `ok` | **PASS** |
|
||||
| 2 | SPA | `/app/` HTTP | 200 | **PASS** |
|
||||
| 3 | 404 字面量翻译 | 登录后访问不存在 server | `服务器不存在` | 待浏览器终验 |
|
||||
| 4 | 422 Pydantic | 非法 batch_id | 中文字段错误 | 待浏览器终验 |
|
||||
| 5 | str(exc) | 脚本重试不存在 execution | `执行记录不存在` | 待浏览器终验 |
|
||||
|
||||
## 备注
|
||||
|
||||
- `/api/agent/*` 仍返回英文(设计如此)
|
||||
- `f"SSH 连接失败: {e}"` 后缀未做方案 B
|
||||
@@ -0,0 +1,47 @@
|
||||
# 运维时区全局北京 — 定向审计(2026-06-09)
|
||||
|
||||
## 范围
|
||||
|
||||
本次 diff 约 8 文件 + 3 文档 + 1 新测。**非**全库 Phase-2 逐行 8 步;按变更面做逻辑/安全/一致性走读。
|
||||
|
||||
| 文件 | 行数 | 审计方式 |
|
||||
|------|------|----------|
|
||||
| `server/utils/display_time.py` | 65 | 全文 + 单测 |
|
||||
| `server/background/schedule_runner.py` | 293 | cron/next_run/last_run 路径 |
|
||||
| `server/infrastructure/database/audit_log_repo.py` | 84 | 筛选逻辑 |
|
||||
| `server/api/settings.py` | 告警 date 片段 | 与 audit 对齐 |
|
||||
| `frontend/src/utils/datetime.ts` | beijingWallParts | 与后端对齐 |
|
||||
| `frontend/src/utils/scheduleCycle.ts` | cronMatch/computeNext | 与 runner 对齐 |
|
||||
|
||||
## 规则命中与 Closure
|
||||
|
||||
| ID | 规则 | 结论 | 说明 |
|
||||
|----|------|------|------|
|
||||
| H1 | PY-11 naive/aware 混用 | SAFE | `last_run_at` 改 `to_naive_utc`;匹配用 `ensure_utc` |
|
||||
| H2 | 调度双触发 | SAFE | 仍 60s 冷却 + `last_run_at` 防抖 |
|
||||
| H3 | 日期筛选静默吞错 | RISK-P3 | `ValueError: pass` 沿用旧模式;非法日期不报错 |
|
||||
| H4 | 存量 cron 语义变更 | RISK-P2 | 无 DB 迁移;用户确认生产几乎无启用调度,部署后人工核对 |
|
||||
| H5 | 前后端 cron 不一致 | SAFE | 均用北京墙钟;单测锚点 UTC 18:00 = 北京 02:00 |
|
||||
| H6 | 审计日界 | SAFE | `beijing_day_range_utc`;北京 00:00 = UTC 前日 16:00 |
|
||||
| H7 | SQL 注入 | SAFE | 日期仅 `fromisoformat` + 固定格式,无拼接 SQL |
|
||||
| H8 | ruff F401 | FIXED | `settings.py` 告警列表移除未用 `datetime` import |
|
||||
|
||||
**命中 8 条,Closure 8 条。**
|
||||
|
||||
## 未覆盖 / 建议跟进
|
||||
|
||||
1. **无**审计 `date_from` 北京边界的集成测(仅 `test_operator_tz` 单元级);可补 `test_alerts_audit` 带日期参数。
|
||||
2. **未**跑 `pre_deploy_check.sh` 全 7 道门控(仅 `local_verify` L2b)。
|
||||
3. **未**浏览器手工验调度页 `next_run` 列(需登录 SPA)。
|
||||
|
||||
## 验证证据
|
||||
|
||||
```text
|
||||
bash scripts/local_verify.sh → All local checks passed
|
||||
pytest test_operator_tz + schedule_next_run + test_alerts_audit → passed
|
||||
npm run type-check → passed(前次会话)
|
||||
```
|
||||
|
||||
## 结论
|
||||
|
||||
**定向审计通过**,可进入部署前 `pre_deploy_check`;部署后请人工核对调度列表 `next_run`。
|
||||
@@ -0,0 +1,60 @@
|
||||
# 运维时区全局北京 — 验证报告(2026-06-09)
|
||||
|
||||
## 自动化
|
||||
|
||||
| 检查 | 结果 |
|
||||
|------|------|
|
||||
| `bash scripts/local_verify.sh` | PASS |
|
||||
| `bash deploy/pre_deploy_check.sh` | **7/7 PASS** |
|
||||
| `pytest test_operator_tz test_schedule_next_run test_display_time` | PASS |
|
||||
| `npm run type-check` | PASS |
|
||||
| `cd frontend && npx vite build` | PASS |
|
||||
|
||||
## API 契约(本地 :8600)
|
||||
|
||||
创建 cron `0 2 * * *` 推送调度:
|
||||
|
||||
- `next_run` = `2026-06-09 18:00:00`(UTC naive)
|
||||
- 换算北京时间 = **2026-06-10 02:00**(与「每天 02:00 北京」一致)
|
||||
|
||||
脚本:Agent 会话内 httpx 一次性创建/断言/删除(未入仓)。
|
||||
|
||||
## 浏览器 E2E(Playwright,本地 `http://127.0.0.1:8600`)
|
||||
|
||||
```bash
|
||||
cd frontend && npx playwright test e2e/pages/schedules-cycle.spec.mjs e2e/pages/schedules-timezone.spec.mjs
|
||||
```
|
||||
|
||||
| 用例 | 结果 |
|
||||
|------|------|
|
||||
| E2E-SCH-002 新建调度显示「开始执行周期」 | PASS |
|
||||
| E2E-SCH-003 对话框显示「时间为北京时间(Asia/Shanghai)」 | PASS |
|
||||
| E2E-SCH-004 `next_run` 列显示 `2026-06-10 02:00:00`(非 UTC 10:00、非裸 ISO Z) | PASS |
|
||||
|
||||
新增:`frontend/e2e/pages/schedules-timezone.spec.mjs`
|
||||
|
||||
## 生产部署(2026-06-09 · `863e49e` 全量)
|
||||
|
||||
| 检查 | 结果 |
|
||||
|------|------|
|
||||
| `NEXUS_PROBE_MODE=origin bash scripts/prod_health_check.sh` | **PASS**(含时区扩展项) |
|
||||
| `/health` | ok |
|
||||
| `/app/` | HTTP 200 |
|
||||
| 告警 `date_from=not-a-date` | HTTP **422** |
|
||||
| 审计 `/api/audit/?date_from=bad` | HTTP **422** |
|
||||
| cron `0 2 * * *` 创建 | `next_run=2026-06-09 18:00:00`(UTC naive → 北京次日 02:00) |
|
||||
| `/app/assets/SchedulesPage-*.js` | 含「时间为北京时间」文案(源站静态资源 grep) |
|
||||
|
||||
部署命令:`NEXUS_DEPLOY_PATH=/opt/nexus bash deploy/deploy-production.sh`
|
||||
|
||||
### 浏览器终验说明
|
||||
|
||||
- 生产登录受 IP 白名单约束;本机 Playwright 经隧道登录返回 **403**(预期行为)。
|
||||
- UI 时区提示与 `next_run` 列展示:本地 E2E(上表)+ 生产 API/静态资源探针已覆盖。
|
||||
- **用户终验**(可选):在已白名单 IP 打开 `/app/#/schedules` 核对 Picker 提示与 `next_run` 列。
|
||||
|
||||
### 前端 hash 对齐(Wave 0)
|
||||
|
||||
- 升级后曾漂移:容器 `index-FhWfGFoM.js` ≠ Git `index-mgwiCK5j.js`。
|
||||
- **已修复**:`NEXUS_ROOT=/opt/nexus bash deploy/sync_webapp_to_container.sh` → 入口 `index-mgwiCK5j.js` HTTP 200。
|
||||
- 后续 `nexus-1panel.sh upgrade` / `deploy-production.sh` 将自动执行 sync。
|
||||
@@ -0,0 +1,47 @@
|
||||
# 2026-06-09 — 推送页分类全量加载 部署验收
|
||||
|
||||
## 版本
|
||||
|
||||
- **Commit**: `c366ed5`
|
||||
- **Changelog**: `docs/changelog/2026-06-09-push-category-full-list.md`
|
||||
- **生产**: Docker `nexus-prod-nexus-1` @ `/opt/nexus`
|
||||
- **部署方式**: 本地 `vite build` → tar/scp `web/app` → `sync_webapp_to_container.sh`(先于 commit;Git 已对齐)
|
||||
|
||||
## 根因(已修复)
|
||||
|
||||
| 现象 | 数据 |
|
||||
|------|------|
|
||||
| 推送页「机器人」仅 5 台 | DB 该分类 **101** 台 |
|
||||
| 旧逻辑 | `GET /api/servers/?per_page=200`,按 id 升序取前 200 台 |
|
||||
| 前 200 台中含「机器人」 | **恰好 5 台**(其余 96 台 id 更大,未进入列表) |
|
||||
|
||||
## Agent 验证(2026-06-09)
|
||||
|
||||
| 项 | 结果 |
|
||||
|----|------|
|
||||
| `/app/` 主 bundle | `index-jg6n1uIt.js` HTTP 200 |
|
||||
| `PushPage-DukhcoEE.js` | 含 `paginatedFetch`(非 `per_page:200`) |
|
||||
| MySQL `servers` 总数 | 367 |
|
||||
| MySQL `category=机器人` | 101 |
|
||||
| `/health` | 200 |
|
||||
| `/app/` | 200 |
|
||||
|
||||
## 变更摘要
|
||||
|
||||
1. **推送页** — `fetchPagePerPage('/servers/', {}, 1, -1)` 分页拉全舰队;分类分组与服务器页 `uncategorized` / `categoryDisplayLabel` 一致。
|
||||
2. **未设路径区** — `unsetListQueryParams()` 不再带顶部分类 chip;批量改分类后机器仍可见直至配置 `target_path`。
|
||||
3. **批量分类完成** — `invalidateCachedQuery(serverCategories)` 避免 chip 计数缓存 30s 陈旧。
|
||||
|
||||
## 浏览器终验(用户)
|
||||
|
||||
- [ ] 推送页 → 「机器人」分组显示 **0/101**(或全选后 101/101)
|
||||
- [ ] 推送页 → 「商城」等与服务器页分类 chip 数量一致
|
||||
- [ ] 未设路径区:切换顶部分类 chip 时列表仍显示全部未设路径机器
|
||||
- [ ] 强刷 `Ctrl+Shift+R` 后入口为 `index-jg6n1uIt.js`
|
||||
|
||||
## 回滚
|
||||
|
||||
```bash
|
||||
# 前端:checkout 上一版 web/app 或 c366ed5^ 后重新 tar/sync
|
||||
# 业务无 DB 迁移
|
||||
```
|
||||
@@ -0,0 +1,101 @@
|
||||
# Agent MCP 浏览器点页规程(Playwright MCP)
|
||||
|
||||
> 用例编号见 `test-case-matrix.md` 中 `MCP-*` 行。
|
||||
> 工具:Cursor **Playwright MCP**(`browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type` 等)。
|
||||
|
||||
## 0. 环境与安全
|
||||
|
||||
```text
|
||||
BASE=http://127.0.0.1:8600/app/ # 本地优先
|
||||
USER=admin # 或 NEXUS_E2E_USER
|
||||
PASS=<来自 .env,勿写入报告>
|
||||
```
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| 禁止 | 生产批量推送、批量删机、真实 WebSSH 长连接 |
|
||||
| 允许 | 只读浏览、打开对话框后取消、对 `test-*` 子机 dry-run |
|
||||
| 证据 | 每用例结束写 `docs/reports/mcp-runs/YYYY-MM-DD-<MCP-ID>.md` |
|
||||
|
||||
报告模板:
|
||||
|
||||
```markdown
|
||||
# MCP-PUSH-001 2026-06-08
|
||||
- BASE: http://127.0.0.1:8600/app/
|
||||
- 结果: PASS|FAIL
|
||||
- 步骤: 1…n
|
||||
- 截图: (可选路径)
|
||||
- 沉淀: 是否需新增 E2E-PUSH-002
|
||||
```
|
||||
|
||||
## 1. MCP-AUTH-001 — 登录页
|
||||
|
||||
1. `browser_navigate` → `{BASE}#/login`
|
||||
2. `browser_snapshot` → 断言存在「用户名」「密码」输入与登录按钮
|
||||
3. `browser_type` 用户名/密码 → `browser_click` 登录
|
||||
4. `browser_snapshot` → URL 含 `#/` 且侧栏可见
|
||||
|
||||
**失败处理**:401 检查 API `:8600/health` 与 `start-dev.sh`
|
||||
|
||||
## 2. MCP-DASH-001 — 仪表盘
|
||||
|
||||
1. 已登录状态下 `browser_navigate` → `#/`
|
||||
2. `browser_snapshot` → 在线/离线/告警类统计文案存在
|
||||
3. 记录数字是否为 `—` 或 0(无数据也算 PASS)
|
||||
|
||||
## 3. MCP-PUSH-001 — 推送提交(非破坏性)
|
||||
|
||||
1. `browser_navigate` → `#/push`
|
||||
2. `browser_snapshot` → 目标路径、文件选择、提交按钮
|
||||
3. **仅当选中子机 hostname 含 `test` 时**继续;否则标记 SKIP 并注明原因
|
||||
4. `browser_click` 提交 → 等待 ≤5s
|
||||
5. `browser_snapshot` →
|
||||
- 提交按钮**不应**持续 disabled/loading >10s(对应 E2E-PUSH-002)
|
||||
- 进度区域或 toast 出现
|
||||
6. 写报告;若 PASS,7 日内补 `frontend/e2e/flows/push-submit.spec.mjs`
|
||||
|
||||
## 4. MCP-SRV-002 — 无 CSV 导出
|
||||
|
||||
1. `#/servers` → `browser_snapshot`
|
||||
2. 全文搜索「导出」「CSV」→ 不得出现可点击导出按钮
|
||||
3. 对应 E2E-SRV-002
|
||||
|
||||
## 5. MCP-ALT-001 / MCP-AUD-001 — 北京时间
|
||||
|
||||
1. 分别打开 `#/alerts`、`#/audit`
|
||||
2. `browser_snapshot` 表格时间列
|
||||
3. 期望:`YYYY-MM-DD HH:mm` 或带 `CST`/`北京时间` 语义;**不应**出现未格式化的 ISO `T...Z` 裸串
|
||||
4. 与 `UT-TIME-001` 一致
|
||||
|
||||
## 6. MCP-SCH-001 — 调度周期文案
|
||||
|
||||
1. `#/schedules` → 新建调度
|
||||
2. `browser_snapshot` 周期/开始执行相关 label
|
||||
3. 与 `docs/changelog/2026-06-08-schedule-form-start-cycle-only.md` 一致
|
||||
|
||||
## 7. MCP-EXEC-001 — 执行看板
|
||||
|
||||
1. `#/executions`
|
||||
2. 若有行,`browser_click` 展开首行
|
||||
3. `browser_snapshot` → 详情面板无 500 错误 snackbar
|
||||
|
||||
## 8. 标准回归包(部署后 Agent 必跑)
|
||||
|
||||
按序执行,总时长目标 <15min:
|
||||
|
||||
```
|
||||
MCP-AUTH-001 → MCP-DASH-001 → MCP-SRV-002 → MCP-PUSH-001(SKIP ok)
|
||||
→ MCP-ALT-001 → MCP-AUD-001 → MCP-CMD-001
|
||||
```
|
||||
|
||||
汇总写入 `docs/reports/YYYY-MM-DD-mcp-regression.md`。
|
||||
|
||||
## 9. 与 Playwright CI 关系
|
||||
|
||||
| MCP | 沉淀为 E2E |
|
||||
|-----|------------|
|
||||
| MCP-PUSH-001 | E2E-PUSH-002 |
|
||||
| MCP-SRV-002 | E2E-SRV-002 |
|
||||
| MCP-ALT-001 | 扩 E2E-ALT-001 断言时间格式 |
|
||||
|
||||
MCP **不替代** CI;仅用于部署后探索与用例发现。
|
||||
@@ -0,0 +1,257 @@
|
||||
# Nexus 全站测试用例矩阵(SSOT)
|
||||
|
||||
> 编号规则:`<层>-<域>-<序号>`
|
||||
> 层:`UT` 单元 · `IT` 集成 · `CH` 链条 · `E2E` Playwright · `MCP` Agent 浏览器
|
||||
> 状态:`✅` 已有 · `🔲` 待写 · `🟡` 部分覆盖 · `⏭` 不验收(已确认豁免)
|
||||
|
||||
最后更新:2026-06-09
|
||||
|
||||
---
|
||||
|
||||
## 0. 横切能力
|
||||
|
||||
| ID | 层 | 用例 | 前置 | 步骤 | 期望 | 状态 | 实现位置 |
|
||||
|----|-----|------|------|------|------|------|----------|
|
||||
| UT-AUTH-001 | UT | JWT access 过期边界 | 固定 clock | 签发 exp-1s token | 401 + 中文提示 | ✅ | test_login_access.py |
|
||||
| UT-AUTH-002 | UT | refresh 复用检测 | 双 refresh 同 jti | 第二次 refresh | 401 revoke 全家 | ✅ | test_auth_refresh_reuse.py |
|
||||
| UT-VAL-001 | UT | 422 loc 中文化 | — | per_page=999 | detail 含 `每页条数` | ✅ | test_validation_errors_zh.py |
|
||||
| UT-TIME-001 | UT | 北京时间格式化 | UTC 输入 | format_display_time | `Asia/Shanghai` 字符串 | ✅ | test_display_time.py |
|
||||
| UT-SEC-001 | UT | 路径穿越拒绝 | — | `../etc/passwd` | ValidationError | ✅ | test_remote_path_validation.py |
|
||||
| IT-AUTH-001 | IT | 登录换 token | API 活 | POST /api/auth/login | access+refresh | ✅ | test_api.py |
|
||||
| IT-AUTH-002 | IT | 无 token 403 | — | GET /api/servers | 401/403 | ✅ | test_api.py |
|
||||
| CH-AUTH-001 | CH | 登录→刷新→访问 | 活 API | login→refresh→GET servers | 200 | ✅ | tests/chain/test_auth_flow.py |
|
||||
| E2E-AUTH-001 | E2E | 错误密码 | 浏览器 | 填错密码登录 | 页内错误提示 | ✅ | e2e/pages/auth.spec.mjs |
|
||||
| MCP-AUTH-001 | MCP | 登录页渲染 | base URL | snapshot #/login | 表单+品牌可见 | ✅ | e2e/pages/login.spec.mjs |
|
||||
|
||||
---
|
||||
|
||||
## 1. 安装向导 `/app/install.html`
|
||||
|
||||
| ID | 层 | 用例 | 期望 | 状态 | 备注 |
|
||||
|----|-----|------|------|------|------|
|
||||
| UT-INST-001 | UT | 已安装锁 404 | /api/install/* 404 | ✅ | test_install_locked_404.py |
|
||||
| IT-INST-001 | IT | 无 .env 仅 install 路由 | /health 与 /api/servers 行为 | ✅ | tests/integration/test_install_mode.py |
|
||||
| E2E-INST-001 | E2E | 安装页可打开 | install.html 200 | ✅ | e2e/pages/install.spec.mjs |
|
||||
| MCP-INST-001 | MCP | 安装步骤 UI | 各 step 控件可见 | ⏭ | **不验收**:生产已锁安装向导;E2E-INST-001 可达性已够 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 仪表盘 Dashboard `#/`
|
||||
|
||||
| ID | 层 | 用例 | 期望 | 状态 | 实现 |
|
||||
|----|-----|------|------|------|------|
|
||||
| IT-DASH-001 | IT | GET /api/dashboard/stats | 200 + 数字字段 | ✅ | tests/integration/test_servers_dashboard.py |
|
||||
| E2E-DASH-001 | E2E | 仪表盘加载 | 卡片/图表区域可见 | ✅ | full-acceptance #2 + dashboard-stats.spec.mjs |
|
||||
| MCP-DASH-001 | MCP | 统计数字非空 | 在线/告警计数 | ✅ | 沉淀 E2E-DASH-001 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 服务器 Servers `#/servers`
|
||||
|
||||
| ID | 层 | 用例 | 期望 | 状态 | 实现 |
|
||||
|----|-----|------|------|------|------|
|
||||
| UT-SRV-001 | UT | 列表排序键合法 | sort 白名单 | ✅ | test_server_list_sort.py |
|
||||
| UT-SRV-002 | UT | status 字段枚举 | online/offline/… | ✅ | test_server_status_field.py |
|
||||
| UT-SRV-003 | UT | target_path 过滤 | 非法路径拒绝 | ✅ | test_server_target_path_filter.py |
|
||||
| IT-SRV-001 | IT | GET /api/servers 分页 | total+items | ✅ | test_api.py |
|
||||
| IT-SRV-002 | IT | per_page>200 → 422 中文 | loc_zh | ✅ | 422 改造后 |
|
||||
| IT-SRV-003 | IT | notify 开关 settings API | 审计+CUD | ✅ | tests/integration/test_notify_settings.py |
|
||||
| CH-SRV-001 | CH | 列表→详情抽屉→保存 | DB 与响应一致 | ✅ | tests/chain/test_server_update_flow.py |
|
||||
| E2E-SRV-001 | E2E | 表格+筛选 | 表头+搜索框 | ✅ | full-acceptance #3 |
|
||||
| E2E-SRV-002 | E2E | **无 CSV 导出按钮** | 按钮不存在 | ✅ | e2e/pages/servers.spec.mjs |
|
||||
| E2E-SRV-003 | E2E | 分类筛选可见 | chip/文案 | ✅ | e2e/pages/servers-filter.spec.mjs |
|
||||
| MCP-SRV-001 | MCP | 打开筛选抽屉 | 分类/状态 chip | ✅ | 沉淀 E2E-SRV-003 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 终端 Terminal `#/terminal`
|
||||
|
||||
| ID | 层 | 用例 | 期望 | 状态 | 实现 |
|
||||
|----|-----|------|------|------|------|
|
||||
| UT-TERM-001 | UT | WebSSH payload 校验 | 非法 host 拒绝 | ✅ | test_webssh_terminal_payload.py |
|
||||
| IT-TERM-001 | IT | WS /ws/webssh 握手 | 401 无 token | ✅ | tests/integration/test_webssh_auth.py |
|
||||
| E2E-TERM-001 | E2E | 页加载 idle 提示 | 未选服务器文案 | ✅ | e2e/pages/terminal.spec.mjs |
|
||||
| MCP-TERM-001 | MCP | 选子机不连真 SSH | UI 状态 idle | ✅ | 沉淀 e2e/pages/terminal.spec.mjs |
|
||||
|
||||
---
|
||||
|
||||
## 5. 文件 Files `#/files`
|
||||
|
||||
| ID | 层 | 用例 | 期望 | 状态 | 实现 |
|
||||
|----|-----|------|------|------|------|
|
||||
| UT-FILE-001 | UT | ls 解析 | unix ls 输出 | ✅ | test_unix_ls.py |
|
||||
| UT-FILE-002 | UT | browse etag | 304 条件 | ✅ | test_files_browse_etag.py |
|
||||
| UT-FILE-003 | UT | 提权路径策略 | sudo 边界 | ✅ | test_files_elevation.py |
|
||||
| IT-FILE-001 | IT | GET browse | 列表 JSON | ✅ | tests/integration/test_files_browse.py |
|
||||
| CH-FILE-001 | CH | 浏览→上传 staging→确认 | staging 清理 | ✅ | tests/chain/test_file_staging_flow.py |
|
||||
| E2E-FILE-001 | E2E | 文件页表格 | 路径栏可见 | ✅ | e2e/pages/files.spec.mjs |
|
||||
| MCP-FILE-001 | MCP | 目录树展开一级 | 无 500 toast | ✅ | 沉淀 e2e/pages/files.spec.mjs |
|
||||
|
||||
---
|
||||
|
||||
## 6. 推送 Push `#/push`
|
||||
|
||||
| ID | 层 | 用例 | 期望 | 状态 | 实现 |
|
||||
|----|-----|------|------|------|------|
|
||||
| UT-PUSH-001 | UT | batch_id 生成唯一 | UUID 格式 | ✅ | test_sync_batch_id.py |
|
||||
| UT-PUSH-002 | UT | rsync 权限位 | 映射正确 | ✅ | test_rsync_push_permissions.py |
|
||||
| UT-PUSH-003 | UT | upload form 字段 | schema | ✅ | test_sync_upload_form.py |
|
||||
| IT-PUSH-001 | IT | POST sync 立即 accepted | `accepted:true` 非阻塞 | ✅ | tests/integration/test_push_sync.py |
|
||||
| IT-PUSH-002 | IT | GET /api/sync/logs | 分页 | ✅ | tests/integration/test_servers_dashboard.py |
|
||||
| CH-PUSH-001 | CH | accepted→WS progress→log 行 | batch 状态终态 | ✅ | tests/chain/test_push_async_flow.py |
|
||||
| E2E-PUSH-001 | E2E | 推送页控件 | 目标路径+提交 | ✅ | full-acceptance #6 |
|
||||
| E2E-PUSH-002 | E2E | 提交后短暂 loading 非一直转圈 | submitting 结束 | ✅ | e2e/pages/push.spec.mjs |
|
||||
| MCP-PUSH-001 | MCP | 选测试子机 dry-run 提交 | toast + 进度表 | ✅ | e2e/pages/push.spec.mjs(mock 子机+API) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 脚本 Scripts `#/scripts`
|
||||
|
||||
| ID | 层 | 用例 | 期望 | 状态 | 实现 |
|
||||
|----|-----|------|------|------|------|
|
||||
| UT-SCR-001 | UT | dispatch 路由 | server_ids 校验 | ✅ | test_script_dispatch.py |
|
||||
| IT-SCR-001 | IT | CRUD /api/scripts | 201/200/404 | ✅ | tests/integration/test_scripts_executions.py |
|
||||
| E2E-SCR-001 | E2E | 脚本列表+新建按钮 | 无 JS 错误 | ✅ | e2e/pages/scripts.spec.mjs |
|
||||
| MCP-SCR-001 | MCP | Monaco 编辑器加载 | 无 JS 错误 | ✅ | 沉淀 e2e/pages/scripts.spec.mjs |
|
||||
|
||||
---
|
||||
|
||||
## 8. 执行看板 Executions `#/executions`
|
||||
|
||||
| ID | 层 | 用例 | 期望 | 状态 | 实现 |
|
||||
|----|-----|------|------|------|------|
|
||||
| UT-EXEC-001 | UT | 进度分组 | 按 server 聚合 | ✅ | test_exec_detail_grouping.py |
|
||||
| UT-EXEC-002 | UT | MySQL 持久化字段 | 状态机 | ✅ | test_script_execution_mysql_persist.py |
|
||||
| IT-EXEC-001 | IT | GET /api/script-executions | 分页+filter | ✅ | tests/integration/test_scripts_executions.py |
|
||||
| CH-EXEC-001 | CH | 创建执行→轮询→完成 | status=completed | ✅ | tests/chain/test_script_exec_flow.py |
|
||||
| E2E-EXEC-001 | E2E | 看板页加载 | 表+筛选 | ✅ | e2e/pages/executions.spec.mjs |
|
||||
| E2E-EXEC-002 | E2E | 展开执行详情行 | stdout/面板 | ✅ | e2e/pages/executions-detail.spec.mjs |
|
||||
| MCP-EXEC-001 | MCP | 展开执行详情行 | stdout 片段 | ✅ | 沉淀 e2e/pages/executions-detail.spec.mjs |
|
||||
|
||||
---
|
||||
|
||||
## 9. 凭据 Credentials `#/credentials`
|
||||
|
||||
| ID | 层 | 用例 | 期望 | 状态 | 实现 |
|
||||
|----|-----|------|------|------|------|
|
||||
| UT-CRED-001 | UT | poller 周期 | mock clock | ✅ | test_credential_poller.py |
|
||||
| IT-CRED-001 | IT | 列表不返回明文 secret | 脱敏 | ✅ | tests/integration/test_presets.py |
|
||||
| E2E-CRED-001 | E2E | 凭据对话框表单校验 | 必填红框 | ✅ | e2e/pages/credentials.spec.mjs |
|
||||
| MCP-CRED-001 | MCP | 新建凭据表单校验 | 必填红框 | ✅ | 沉淀 e2e/pages/credentials.spec.mjs |
|
||||
|
||||
---
|
||||
|
||||
## 10. 调度 Schedules `#/schedules`
|
||||
|
||||
| ID | 层 | 用例 | 期望 | 状态 | 实现 |
|
||||
|----|-----|------|------|------|------|
|
||||
| UT-SCH-001 | UT | next_run 计算 | cron/周期 | ✅ | test_schedule_next_run.py |
|
||||
| UT-SCH-002 | UT | cycle 标签 | 仅开始周期 | ✅ | test_schedule_cycle.py |
|
||||
| UT-SCH-003 | UT | runner once | 不重复触发 | ✅ | test_schedule_runner_once.py |
|
||||
| IT-SCH-001 | IT | CRUD schedules | 审计 | ✅ | tests/integration/test_schedules.py |
|
||||
| E2E-SCH-001 | E2E | 新建调度对话框 | ✅ | full-acceptance #9 |
|
||||
| E2E-SCH-002 | E2E | 新建调度周期中文 | 开始执行周期 | ✅ | e2e/pages/schedules-cycle.spec.mjs |
|
||||
| MCP-SCH-001 | MCP | 周期字段中文标签 | 与后端一致 | ✅ | 沉淀 E2E-SCH-002 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 重试 Retries `#/retries`
|
||||
|
||||
| ID | 层 | 用例 | 期望 | 状态 | 实现 |
|
||||
|----|-----|------|------|------|------|
|
||||
| UT-RET-001 | UT | runner outcome | 成功/失败计数 | ✅ | test_retry_runner_outcome.py |
|
||||
| IT-RET-001 | IT | GET /api/retries | 队列列表 | ✅ | tests/integration/test_retries_commands.py |
|
||||
| E2E-RET-001 | E2E | 重试页无行级重试按钮(空表) | 表+筛选 | ✅ | e2e/pages/retries.spec.mjs |
|
||||
| MCP-RET-001 | MCP | 手动重试按钮 disabled 态 | 无选中时 | ✅ | 沉淀 e2e/pages/retries.spec.mjs |
|
||||
|
||||
---
|
||||
|
||||
## 12. 命令日志 Commands `#/commands`
|
||||
|
||||
| ID | 层 | 用例 | 期望 | 状态 | 实现 |
|
||||
|----|-----|------|------|------|------|
|
||||
| IT-CMD-001 | IT | 命令/推送/会话三视图 API | 200 | ✅ | tests/integration/test_retries_commands.py |
|
||||
| E2E-CMD-001 | E2E | 切换三视图 | ✅ | full-acceptance #11 |
|
||||
| E2E-CMD-002 | E2E | 推送日志时间格式 | 无裸 ISO UTC | ✅ | e2e/pages/commands-time.spec.mjs |
|
||||
| MCP-CMD-001 | MCP | 推送日志表北京时间 | 列格式 | ✅ | 沉淀 E2E-CMD-002 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 告警 Alerts `#/alerts`
|
||||
|
||||
| ID | 层 | 用例 | 期望 | 状态 | 实现 |
|
||||
|----|-----|------|------|------|------|
|
||||
| UT-ALT-001 | UT | Agent 恢复去重 | 同 metric 1 条 | ✅ | test_agent_recovery_dedup.py |
|
||||
| UT-ALT-002 | UT | Telegram 时间格式 | 北京时间 | ✅ | test_telegram_time_format.py |
|
||||
| IT-ALT-001 | IT | GET /api/alerts + WS | 推送事件 | ✅ | tests/integration/test_alerts_audit.py |
|
||||
| CH-ALT-001 | CH | 心跳告警→恢复→TG 一条 | Redis+mock TG | ✅ | tests/chain/test_alert_recovery_flow.py |
|
||||
| E2E-ALT-001 | E2E | 告警筛选 UI | ✅ | full-acceptance #12 |
|
||||
| E2E-ALT-002 | E2E | 告警表时间格式 | 无裸 ISO UTC | ✅ | e2e/pages/alerts-time.spec.mjs |
|
||||
| MCP-ALT-001 | MCP | 告警表时间列 | 无 UTC 裸字符串 | ✅ | 沉淀 E2E-ALT-002 |
|
||||
|
||||
---
|
||||
|
||||
## 14. 审计 Audit `#/audit`
|
||||
|
||||
| ID | 层 | 用例 | 期望 | 状态 | 实现 |
|
||||
|----|-----|------|------|------|------|
|
||||
| IT-AUD-001 | IT | GET /api/audit 分页 | CUD 记录 | ✅ | tests/integration/test_alerts_audit.py |
|
||||
| E2E-AUD-001 | E2E | 审计页+分页 | ✅ | full-acceptance #13 |
|
||||
| E2E-AUD-002 | E2E | 审计表时间格式 | 无裸 ISO UTC | ✅ | e2e/pages/audit-time.spec.mjs |
|
||||
| MCP-AUD-001 | MCP | 时间列北京时间 | 与设置无关 | ✅ | 沉淀 E2E-AUD-002 |
|
||||
|
||||
---
|
||||
|
||||
## 15. 设置 Settings `#/settings`
|
||||
|
||||
| ID | 层 | 用例 | 期望 | 状态 | 实现 |
|
||||
|----|-----|------|------|------|------|
|
||||
| UT-SET-001 | UT | Telegram 设置校验 | token 格式 | ✅ | test_settings_telegram.py |
|
||||
| IT-SET-001 | IT | PATCH settings 禁止覆盖密钥 | 403 | ✅ | tests/integration/test_settings_security.py |
|
||||
| E2E-SET-001 | E2E | 各 section 可见 | ✅ | e2e/pages/settings.spec.mjs + full-acceptance #14 |
|
||||
| MCP-SET-001 | MCP | 保存设置 toast | 成功中文 | ✅ | e2e/pages/settings.spec.mjs E2E-SET-002 |
|
||||
|
||||
---
|
||||
|
||||
## 16. Agent 子机(无 SPA 页)
|
||||
|
||||
| ID | 层 | 用例 | 期望 | 状态 | 实现 |
|
||||
|----|-----|------|------|------|------|
|
||||
| UT-AGT-001 | UT | heartbeat TTL | Redis 过期 | ✅ | test_agent_heartbeat_ttl.py |
|
||||
| UT-AGT-002 | UT | per-server API key | 隔离 | ✅ | test_agent_per_server_key.py |
|
||||
| UT-AGT-003 | UT | version 上报 | 语义化版本 | ✅ | test_agent_version.py |
|
||||
| IT-AGT-001 | IT | POST /api/agent/heartbeat | 200 | ✅ | tests/integration/test_agent_heartbeat.py |
|
||||
| CH-AGT-001 | CH | 心跳→Redis→仪表盘计数 | 一致 | ✅ | tests/chain/test_agent_dashboard_flow.py |
|
||||
|
||||
---
|
||||
|
||||
## 17. 批量 / 后台任务
|
||||
|
||||
| ID | 层 | 用例 | 期望 | 状态 | 实现 |
|
||||
|----|-----|------|------|------|------|
|
||||
| UT-BAT-001 | UT | batch job 持久化 | ✅ | test_server_batch_job_persist.py |
|
||||
| UT-BAT-002 | UT | reconcile 状态机 | ✅ | test_server_batch_reconcile.py |
|
||||
| UT-SYNC-001 | UT | sync_log purge | ✅ | test_sync_log_purge.py |
|
||||
| CH-BAT-001 | CH | 批量操作→job 表→完成 | ✅ | tests/chain/test_batch_job_flow.py |
|
||||
|
||||
---
|
||||
|
||||
## 覆盖率汇总(2026-06-08 估算)
|
||||
|
||||
| 层 | 已有 | 待写 | 合计 |
|
||||
|----|------|------|------|
|
||||
| UT | ~45 | ~15 | ~60 |
|
||||
| IT | ~26 | ~11 | ~37 |
|
||||
| CH | ~9 | ~5 | ~14 |
|
||||
| E2E | ~35 | ~2 | ~37 |
|
||||
| MCP | ~18(沉淀 E2E) | ~3(manual/安装) | ~21 |
|
||||
| **合计** | **~133** | **~36** | **~169** |
|
||||
|
||||
---
|
||||
|
||||
## PR 检查清单
|
||||
|
||||
- [ ] 改动域在表中至少更新 1 行状态
|
||||
- [ ] 新 API 必有 `IT-*`;新纯函数必有 `UT-*`
|
||||
- [ ] UI 行为变更必有 `E2E-*` 或 `MCP-*` 计划
|
||||
- [ ] 破坏性 E2E 仅对测试子机
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Worker-scoped auth: one API login per worker; persist rotated refresh cookies between tests.
|
||||
*/
|
||||
import { test as base, request } from '@playwright/test'
|
||||
|
||||
const BASE = (process.env.NEXUS_E2E_BASE || 'https://api.synaglobal.vip').replace(/\/$/, '')
|
||||
const USER = process.env.NEXUS_E2E_USER || 'admin'
|
||||
const PASS = process.env.NEXUS_E2E_PASSWORD || ''
|
||||
|
||||
/** @type {import('@playwright/test').StorageState | null} */
|
||||
let workerStorageState = null
|
||||
|
||||
async function ensureWorkerStorageState() {
|
||||
if (workerStorageState) return workerStorageState
|
||||
if (!PASS) throw new Error('Set NEXUS_E2E_PASSWORD for E2E')
|
||||
const ctx = await request.newContext({ baseURL: BASE, ignoreHTTPSErrors: true })
|
||||
try {
|
||||
const res = await ctx.post('/api/auth/login', {
|
||||
data: { username: USER, password: PASS },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Worker login failed (${res.status()}): ${await res.text()}`)
|
||||
}
|
||||
workerStorageState = await ctx.storageState()
|
||||
return workerStorageState
|
||||
} finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export const test = base.extend({
|
||||
context: async ({ browser }, use) => {
|
||||
const storageState = await ensureWorkerStorageState()
|
||||
const context = await browser.newContext({ storageState })
|
||||
try {
|
||||
await use(context)
|
||||
} finally {
|
||||
workerStorageState = await context.storageState()
|
||||
await context.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export { expect } from '@playwright/test'
|
||||
@@ -1,34 +1,12 @@
|
||||
/**
|
||||
* Full-site UI acceptance — every SPA route + primary controls (no destructive push).
|
||||
* Run from frontend/: NEXUS_E2E_USER=admin NEXUS_E2E_PASSWORD=*** npx playwright test e2e/full-acceptance.spec.mjs
|
||||
* Run from frontend/: NEXUS_E2E_USER=admin NEXUS_E2E_PASSWORD=*** npm run test:e2e
|
||||
*/
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const BASE = process.env.NEXUS_E2E_BASE || 'https://api.synaglobal.vip'
|
||||
const USER = process.env.NEXUS_E2E_USER || 'admin'
|
||||
const PASS = process.env.NEXUS_E2E_PASSWORD || ''
|
||||
const APP = `${BASE.replace(/\/$/, '')}/app/`
|
||||
import { test, expect } from './fixtures.mjs'
|
||||
import { APP, USER, login, nav } from './helpers.mjs'
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
async function login(page) {
|
||||
if (!PASS) throw new Error('Set NEXUS_E2E_PASSWORD')
|
||||
await page.goto(`${APP}#/login`)
|
||||
await page.getByLabel('用户名').fill(USER)
|
||||
await page.getByLabel('密码', { exact: true }).fill(PASS)
|
||||
const totp = page.getByLabel('TOTP 验证码')
|
||||
if (await totp.isVisible().catch(() => false)) {
|
||||
throw new Error('TOTP required — disable TOTP on test admin or provide code')
|
||||
}
|
||||
await page.getByRole('button', { name: '登录' }).click()
|
||||
await expect(page).not.toHaveURL(/#\/login/, { timeout: 20000 })
|
||||
}
|
||||
|
||||
async function nav(page, hash) {
|
||||
await page.goto(`${APP}#${hash}`)
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
}
|
||||
|
||||
test('01 login + dashboard stats', async ({ page }) => {
|
||||
await login(page)
|
||||
await nav(page, '/')
|
||||
@@ -38,7 +16,7 @@ test('01 login + dashboard stats', async ({ page }) => {
|
||||
test('02 servers: list, batch select, add dialog', async ({ page }) => {
|
||||
await login(page)
|
||||
await nav(page, '/servers')
|
||||
await page.getByRole('button', { name: '添加' }).click()
|
||||
await page.getByRole('button', { name: '添加', exact: true }).click()
|
||||
await expect(page.getByText('添加服务器')).toBeVisible()
|
||||
await page.getByRole('button', { name: '取消' }).click()
|
||||
const cbs = page.locator('table tbody tr input[type="checkbox"]')
|
||||
@@ -49,12 +27,11 @@ test('02 servers: list, batch select, add dialog', async ({ page }) => {
|
||||
}
|
||||
})
|
||||
|
||||
test('03 global search', async ({ page }) => {
|
||||
test('03 dashboard nav drawer', async ({ page }) => {
|
||||
await login(page)
|
||||
await nav(page, '/')
|
||||
const search = page.getByPlaceholder('搜索服务器、脚本、凭据...')
|
||||
await search.fill('a')
|
||||
await page.waitForTimeout(400)
|
||||
await expect(page.locator('.v-card').first()).toBeVisible({ timeout: 20000 })
|
||||
await expect(page.getByRole('link', { name: '服务器' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('04 terminal page', async ({ page }) => {
|
||||
@@ -86,12 +63,14 @@ test('07 scripts: new dialog cancel', async ({ page }) => {
|
||||
|
||||
test('08 credentials: tabs', async ({ page }) => {
|
||||
await login(page)
|
||||
await nav(page, '/credentials')
|
||||
await expect(page.getByText('凭据管理')).toBeVisible({ timeout: 20000 })
|
||||
await nav(page, '/servers')
|
||||
await page.getByRole('button', { name: '凭据' }).click()
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog.getByText('凭据管理')).toBeVisible({ timeout: 20000 })
|
||||
await page.getByRole('button', { name: 'SSH 密钥' }).click()
|
||||
await page.getByRole('button', { name: '数据库凭据' }).click()
|
||||
await page.getByRole('button', { name: '密码预设' }).click()
|
||||
await expect(page.getByRole('button', { name: '添加' })).toBeVisible()
|
||||
await expect(page.getByRole('dialog').getByRole('button', { name: '添加', exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('09 schedules: new dialog cancel', async ({ page }) => {
|
||||
@@ -111,7 +90,9 @@ test('10 retries list', async ({ page }) => {
|
||||
test('11 commands: command, session and push log views', async ({ page }) => {
|
||||
await login(page)
|
||||
await nav(page, '/commands')
|
||||
await expect(page.getByText('命令日志')).toBeVisible({ timeout: 20000 })
|
||||
await expect(page.locator('main .v-card-title').filter({ hasText: '命令日志' })).toBeVisible({
|
||||
timeout: 20000,
|
||||
})
|
||||
await page.locator('.v-card-title .v-select').first().click()
|
||||
await page.locator('.v-overlay-container').getByText('推送日志').click()
|
||||
await expect(page.locator('table').first()).toBeVisible()
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Shared Playwright helpers for Nexus SPA E2E.
|
||||
*/
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
export const BASE = process.env.NEXUS_E2E_BASE || 'https://api.synaglobal.vip'
|
||||
export const USER = process.env.NEXUS_E2E_USER || 'admin'
|
||||
export const PASS = process.env.NEXUS_E2E_PASSWORD || ''
|
||||
export const APP = `${BASE.replace(/\/$/, '')}/app/`
|
||||
|
||||
const SIDEBAR_LINKS = {
|
||||
'/': '仪表盘',
|
||||
'/servers': '服务器',
|
||||
'/terminal': '终端',
|
||||
'/files': '文件管理',
|
||||
'/push': '推送',
|
||||
'/scripts': '脚本库',
|
||||
'/executions': '执行记录',
|
||||
'/schedules': '调度',
|
||||
'/retries': '重试队列',
|
||||
'/commands': '命令日志',
|
||||
'/alerts': '告警中心',
|
||||
'/audit': '审计日志',
|
||||
}
|
||||
|
||||
/** Restore session from worker refresh cookie (avoids parallel UI logins / lockout). */
|
||||
export async function login(page) {
|
||||
await page.goto(`${APP}#/`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page).not.toHaveURL(/#\/login/, { timeout: 20000 })
|
||||
await expect(page.getByRole('button', { name: 'admin' })).toBeVisible({ timeout: 15000 })
|
||||
}
|
||||
|
||||
/** Navigate via drawer clicks (reliable with Vue keep-alive; hash goto alone is flaky). */
|
||||
export async function nav(page, hash) {
|
||||
const path = hash.startsWith('/') ? hash : `/${hash}`
|
||||
const routePath = path.split('?')[0]
|
||||
const escaped = routePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
if (routePath === '/settings') {
|
||||
await page.locator('.v-navigation-drawer').getByText('设置', { exact: true }).click()
|
||||
} else {
|
||||
const label = SIDEBAR_LINKS[routePath]
|
||||
if (!label) throw new Error(`Unknown route for nav(): ${path}`)
|
||||
await page.getByRole('link', { name: label, exact: true }).click()
|
||||
}
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`#${escaped}(?:\\?|$)`), { timeout: 20000 })
|
||||
|
||||
if (path.includes('?')) {
|
||||
await page.evaluate((p) => {
|
||||
window.location.hash = `#${p}`
|
||||
}, path)
|
||||
await expect(page).toHaveURL(new RegExp(`#${escaped}\\?`), { timeout: 10000 })
|
||||
}
|
||||
|
||||
await page.waitForLoadState('networkidle').catch(() => {})
|
||||
}
|
||||
|
||||
/** ISO UTC bare string e.g. 2026-06-08T12:00:00Z — should not appear in display columns */
|
||||
export const ISO_UTC_RE = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/
|
||||
@@ -0,0 +1,18 @@
|
||||
/** E2E-ALT-002 — alert table times not raw ISO UTC (MCP-ALT-001沉淀) */
|
||||
import { test, expect } from '../fixtures.mjs'
|
||||
import { ISO_UTC_RE, login, nav } from '../helpers.mjs'
|
||||
|
||||
test('E2E-ALT-002 alert times not bare ISO UTC', async ({ page }) => {
|
||||
await login(page)
|
||||
await nav(page, '/alerts')
|
||||
await expect(page.getByText(/告警/).first()).toBeVisible({ timeout: 20000 })
|
||||
|
||||
const cells = page.locator('table tbody td')
|
||||
const count = await cells.count()
|
||||
for (let i = 0; i < Math.min(count, 20); i++) {
|
||||
const text = await cells.nth(i).innerText()
|
||||
if (/\d{4}-\d{2}-\d{2}/.test(text)) {
|
||||
expect(text).not.toMatch(ISO_UTC_RE)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
/** E2E-AUD-002 — audit table Beijing time display (MCP-AUD-001沉淀) */
|
||||
import { test, expect } from '../fixtures.mjs'
|
||||
import { ISO_UTC_RE, login, nav } from '../helpers.mjs'
|
||||
|
||||
test('E2E-AUD-002 audit times not bare ISO UTC', async ({ page }) => {
|
||||
await login(page)
|
||||
await nav(page, '/audit')
|
||||
await expect(page.getByText(/审计/).first()).toBeVisible({ timeout: 20000 })
|
||||
|
||||
const cells = page.locator('table tbody td')
|
||||
const count = await cells.count()
|
||||
for (let i = 0; i < Math.min(count, 20); i++) {
|
||||
const text = await cells.nth(i).innerText()
|
||||
if (/\d{4}-\d{2}-\d{2}/.test(text)) {
|
||||
expect(text).not.toMatch(ISO_UTC_RE)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
/** E2E-AUTH-001 — wrong password shows error, stays on login */
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { APP, USER } from '../helpers.mjs'
|
||||
|
||||
test.use({ storageState: { cookies: [], origins: [] } })
|
||||
|
||||
test('E2E-AUTH-001 wrong password shows error', async ({ page }) => {
|
||||
await page.goto(`${APP}#/login`)
|
||||
await page.getByLabel('用户名').fill(USER)
|
||||
await page.getByLabel('密码', { exact: true }).fill('definitely-wrong-password-e2e')
|
||||
await page.getByRole('button', { name: '登录' }).click()
|
||||
await expect(page).toHaveURL(/#\/login/, { timeout: 15000 })
|
||||
await expect(page.getByText(/错误|失败|无效|不正确/).first()).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
/** E2E-CMD-002 — push log view time columns (MCP-CMD-001沉淀) */
|
||||
import { test, expect } from '../fixtures.mjs'
|
||||
import { ISO_UTC_RE, login, nav } from '../helpers.mjs'
|
||||
|
||||
test('E2E-CMD-002 push log times not bare ISO UTC', async ({ page }) => {
|
||||
await login(page)
|
||||
await nav(page, '/commands')
|
||||
await expect(page.locator('main .v-card-title').filter({ hasText: '命令日志' })).toBeVisible({
|
||||
timeout: 20000,
|
||||
})
|
||||
|
||||
await page.locator('.v-card-title .v-select').first().click()
|
||||
await page.locator('.v-overlay-container').getByText('推送日志').click()
|
||||
await expect(page.locator('table').first()).toBeVisible()
|
||||
|
||||
const cells = page.locator('table tbody td')
|
||||
const count = await cells.count()
|
||||
for (let i = 0; i < Math.min(count, 20); i++) {
|
||||
const text = await cells.nth(i).innerText()
|
||||
if (/\d{4}-\d{2}-\d{2}/.test(text)) {
|
||||
expect(text).not.toMatch(ISO_UTC_RE)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
/** E2E-CRED-001 — credentials dialog required fields (MCP-CRED-001沉淀) */
|
||||
import { test, expect } from '../fixtures.mjs'
|
||||
import { login, nav } from '../helpers.mjs'
|
||||
|
||||
test('E2E-CRED-001 password preset form validation', async ({ page }) => {
|
||||
await login(page)
|
||||
await nav(page, '/servers')
|
||||
await page.getByRole('button', { name: '凭据' }).click()
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog.getByText('凭据管理')).toBeVisible({ timeout: 20000 })
|
||||
await dialog.getByRole('button', { name: '添加', exact: true }).click()
|
||||
await expect(dialog.getByText('新建密码预设')).toBeVisible()
|
||||
await dialog.getByRole('button', { name: '保存' }).click()
|
||||
await expect(page.getByText('请填写预设名称')).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
/** E2E-DASH-001 extended — dashboard stat cards visible (MCP-DASH-001沉淀) */
|
||||
import { test, expect } from '../fixtures.mjs'
|
||||
import { login, nav } from '../helpers.mjs'
|
||||
|
||||
test('E2E-DASH-001 dashboard cards visible', async ({ page }) => {
|
||||
await login(page)
|
||||
await nav(page, '/')
|
||||
await expect(page.locator('.v-card').first()).toBeVisible({ timeout: 20000 })
|
||||
expect(await page.locator('.v-card').count()).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
/** E2E-EXEC-002 — execution row expand when data exists (MCP-EXEC-001沉淀) */
|
||||
import { test, expect } from '../fixtures.mjs'
|
||||
import { login, nav } from '../helpers.mjs'
|
||||
|
||||
test('E2E-EXEC-002 execution detail expand', async ({ page }) => {
|
||||
await login(page)
|
||||
await nav(page, '/executions')
|
||||
await expect(page.getByRole('combobox', { name: '状态筛选' })).toBeVisible({ timeout: 20000 })
|
||||
const rows = page.locator('tbody tr')
|
||||
const count = await rows.count()
|
||||
if (count === 0) {
|
||||
await expect(page.getByText('暂无数据')).toBeVisible()
|
||||
return
|
||||
}
|
||||
const expand = rows.first().locator('button').first()
|
||||
if (await expand.isVisible()) {
|
||||
await expand.click()
|
||||
await expect(page.locator('.v-expansion-panel-text, pre, code').first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
/** E2E-EXEC-001 — executions board loads with filters */
|
||||
import { test, expect } from '../fixtures.mjs'
|
||||
import { login, nav } from '../helpers.mjs'
|
||||
|
||||
test('E2E-EXEC-001 executions page table and filters', async ({ page }) => {
|
||||
await login(page)
|
||||
await nav(page, '/executions')
|
||||
await expect(page.getByRole('combobox', { name: '类型' })).toBeVisible({ timeout: 20000 })
|
||||
await expect(page.getByRole('combobox', { name: '状态筛选' })).toBeVisible()
|
||||
await expect(page.locator('table').first()).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
/** E2E-FILE-001 — files page loads toolbar (MCP-FILE-001沉淀) */
|
||||
import { test, expect } from '../fixtures.mjs'
|
||||
import { login, nav } from '../helpers.mjs'
|
||||
|
||||
test('E2E-FILE-001 files page server picker visible', async ({ page }) => {
|
||||
const errors = []
|
||||
page.on('pageerror', (e) => errors.push(e.message))
|
||||
await login(page)
|
||||
await nav(page, '/files')
|
||||
await expect(page.getByLabel('选择服务器')).toBeVisible({ timeout: 20000 })
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
/** E2E-INST-001 — install.html reachable (MCP-INST-001 step UI waived per product) */
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const BASE = process.env.NEXUS_E2E_BASE || 'http://127.0.0.1:8600'
|
||||
|
||||
test('E2E-INST-001 install.html reachable', async ({ page }) => {
|
||||
const res = await page.goto(`${BASE.replace(/\/$/, '')}/app/install.html`)
|
||||
expect(res?.status()).toBe(200)
|
||||
await expect(page.getByText('Nexus 6.0 安装向导')).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
/** E2E-AUTH-002 / MCP-AUTH-001 — login page renders without submitting */
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { APP } from '../helpers.mjs'
|
||||
|
||||
test.use({ storageState: { cookies: [], origins: [] } })
|
||||
|
||||
test('MCP-AUTH-001 login form visible', async ({ page }) => {
|
||||
await page.goto(`${APP}#/login`)
|
||||
await expect(page.getByLabel('用户名')).toBeVisible()
|
||||
await expect(page.getByLabel('密码', { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '登录' })).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
/** E2E-PUSH-002 — push submit ends submitting state quickly */
|
||||
import { test, expect } from '../fixtures.mjs'
|
||||
import { login, nav } from '../helpers.mjs'
|
||||
|
||||
const mockServer = {
|
||||
id: 9001,
|
||||
name: 'e2e-push-mock',
|
||||
domain: '10.0.0.1',
|
||||
target_path: '/var/www',
|
||||
category: null,
|
||||
is_online: true,
|
||||
status: 'online',
|
||||
}
|
||||
|
||||
test('E2E-PUSH-002 submitting clears after accepted response', async ({ page }) => {
|
||||
await page.route('**/api/servers**', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: [mockServer],
|
||||
total: 1,
|
||||
page: 1,
|
||||
per_page: 200,
|
||||
pages: 1,
|
||||
}),
|
||||
})
|
||||
})
|
||||
await page.route('**/api/sync/validate-source-path', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
valid: true,
|
||||
path: '/tmp/nexus-e2e-src',
|
||||
file_count: 1,
|
||||
size_bytes: 100,
|
||||
}),
|
||||
})
|
||||
})
|
||||
await page.route('**/api/sync/files**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
accepted: true,
|
||||
batch_id: 'aabbccddeeff',
|
||||
total: 1,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
results: {},
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await login(page)
|
||||
await nav(page, '/push')
|
||||
|
||||
await page.getByPlaceholder('/path/to/files').fill('/tmp/nexus-e2e-src')
|
||||
await page.getByRole('button', { name: '验证路径' }).click()
|
||||
await expect(page.getByText('e2e-push-mock')).toBeVisible({ timeout: 20000 })
|
||||
await page.getByText('e2e-push-mock', { exact: true }).click()
|
||||
|
||||
const pushBtn = page.getByRole('button', { name: '推送', exact: true })
|
||||
const postDone = page.waitForResponse(
|
||||
(r) => r.request().method() === 'POST' && r.url().includes('/sync/files'),
|
||||
{ timeout: 20000 },
|
||||
)
|
||||
await pushBtn.click()
|
||||
const res = await postDone
|
||||
expect(res.status()).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.accepted).toBe(true)
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
/** E2E-RET-001 — retries queue page (MCP-RET-001沉淀) */
|
||||
import { test, expect } from '../fixtures.mjs'
|
||||
import { login, nav } from '../helpers.mjs'
|
||||
|
||||
test('E2E-RET-001 retries table without bulk retry actions', async ({ page }) => {
|
||||
await login(page)
|
||||
await nav(page, '/retries')
|
||||
await expect(page.getByRole('combobox', { name: '状态筛选' })).toBeVisible({ timeout: 20000 })
|
||||
const retryButtons = page.getByRole('button', { name: '重试', exact: true })
|
||||
expect(await retryButtons.count()).toBe(0)
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
/** E2E-SCH-002 — schedule cycle Chinese labels (MCP-SCH-001沉淀) */
|
||||
import { test, expect } from '../fixtures.mjs'
|
||||
import { login, nav } from '../helpers.mjs'
|
||||
|
||||
test('E2E-SCH-002 new schedule shows 开始执行周期', async ({ page }) => {
|
||||
await login(page)
|
||||
await nav(page, '/schedules')
|
||||
await page.getByRole('button', { name: '新建调度' }).click()
|
||||
await expect(page.getByText('开始执行周期').first()).toBeVisible()
|
||||
await expect(page.getByText('新建调度').first()).toBeVisible()
|
||||
await page.getByRole('button', { name: '取消' }).click()
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
/** E2E-SCH-003/004 — schedule operator timezone (Beijing wall clock) */
|
||||
import { test, expect } from '../fixtures.mjs'
|
||||
import { ISO_UTC_RE, login, nav } from '../helpers.mjs'
|
||||
|
||||
const mockSchedule = {
|
||||
id: 99001,
|
||||
name: 'e2e-beijing-0200',
|
||||
schedule_type: 'push',
|
||||
run_mode: 'cron',
|
||||
cron_expr: '0 2 * * *',
|
||||
fire_at: null,
|
||||
source_path: '/tmp',
|
||||
target_path: null,
|
||||
server_ids: '[1]',
|
||||
sync_mode: 'incremental',
|
||||
enabled: true,
|
||||
script_id: null,
|
||||
script_content: null,
|
||||
exec_timeout: 60,
|
||||
long_task: false,
|
||||
last_run_at: null,
|
||||
/** UTC naive — Beijing next day 02:00 */
|
||||
next_run: '2026-06-09 18:00:00',
|
||||
created_at: '2026-06-09 10:00:00',
|
||||
}
|
||||
|
||||
test('E2E-SCH-003 schedule dialog shows Beijing timezone hint', async ({ page }) => {
|
||||
await login(page)
|
||||
await nav(page, '/schedules')
|
||||
await page.getByRole('button', { name: '新建调度' }).click()
|
||||
await expect(page.getByText(/时间为北京时间(Asia\/Shanghai)/).first()).toBeVisible()
|
||||
await page.getByRole('button', { name: '取消' }).click()
|
||||
})
|
||||
|
||||
test('E2E-SCH-004 next_run column shows Beijing 02:00 not bare ISO UTC', async ({ page }) => {
|
||||
await page.route('**/api/schedules/**', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([mockSchedule]),
|
||||
})
|
||||
})
|
||||
|
||||
await login(page)
|
||||
await nav(page, '/schedules')
|
||||
await expect(page.getByText('e2e-beijing-0200')).toBeVisible()
|
||||
|
||||
const nextRunCell = page.locator('td').filter({ hasText: /2026-06-10 02:00:00/ })
|
||||
await expect(nextRunCell.first()).toBeVisible()
|
||||
|
||||
const tableText = await page.locator('.v-data-table').innerText()
|
||||
expect(tableText).not.toMatch(ISO_UTC_RE)
|
||||
expect(tableText).not.toMatch(/10:00:00/)
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
/** E2E-SCR-001 — scripts library loads (MCP-SCR-001沉淀) */
|
||||
import { test, expect } from '../fixtures.mjs'
|
||||
import { login, nav } from '../helpers.mjs'
|
||||
|
||||
test('E2E-SCR-001 scripts page toolbar without JS errors', async ({ page }) => {
|
||||
const errors = []
|
||||
page.on('pageerror', (e) => errors.push(e.message))
|
||||
await login(page)
|
||||
await nav(page, '/scripts')
|
||||
await expect(page.getByRole('button', { name: '新建脚本' })).toBeVisible({ timeout: 20000 })
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
/** E2E-SRV-003 — servers category filter chips (MCP-SRV-001沉淀) */
|
||||
import { test, expect } from '../fixtures.mjs'
|
||||
import { login, nav } from '../helpers.mjs'
|
||||
|
||||
test('E2E-SRV-003 category filter visible', async ({ page }) => {
|
||||
await login(page)
|
||||
await nav(page, '/servers')
|
||||
await expect(page.getByText('分类筛选')).toBeVisible({ timeout: 20000 })
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
/** E2E-SRV-002 — servers page has no CSV export button */
|
||||
import { test, expect } from '../fixtures.mjs'
|
||||
import { login, nav } from '../helpers.mjs'
|
||||
|
||||
test('E2E-SRV-002 no CSV export button', async ({ page }) => {
|
||||
await login(page)
|
||||
await nav(page, '/servers')
|
||||
await expect(page.getByRole('button', { name: /导出|CSV/i })).toHaveCount(0)
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
/** E2E-SET-001 / MCP-SET-001 — settings save control + success toast */
|
||||
import { test, expect } from '../fixtures.mjs'
|
||||
import { login, nav } from '../helpers.mjs'
|
||||
|
||||
test('E2E-SET-001 settings page save control', async ({ page }) => {
|
||||
await login(page)
|
||||
await nav(page, '/settings')
|
||||
await expect(page.getByText('系统设置')).toBeVisible({ timeout: 20000 })
|
||||
await expect(page.getByRole('button', { name: '保存设置' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('E2E-SET-002 settings save success toast', async ({ page }) => {
|
||||
await login(page)
|
||||
await page.route(/\/api\/settings\//, async (route) => {
|
||||
if (route.request().method() === 'PUT') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ key: 'system_name', value: 'Nexus' }),
|
||||
})
|
||||
return
|
||||
}
|
||||
await route.continue()
|
||||
})
|
||||
await nav(page, '/settings')
|
||||
await expect(page.getByLabel('系统名称')).toBeVisible({ timeout: 20000 })
|
||||
const title = page.getByLabel('系统标题')
|
||||
if (!(await title.inputValue()).trim()) {
|
||||
await title.fill('Nexus Ops E2E')
|
||||
}
|
||||
const saveReq = page.waitForRequest(
|
||||
(req) => req.method() === 'PUT' && req.url().includes('/api/settings/'),
|
||||
)
|
||||
await page.getByRole('button', { name: '保存设置' }).click()
|
||||
await saveReq
|
||||
await expect(page.getByText('设置已保存')).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
/** E2E-TERM-001 — terminal idle state without live SSH (MCP-TERM-001沉淀) */
|
||||
import { test, expect } from '../fixtures.mjs'
|
||||
import { login, nav } from '../helpers.mjs'
|
||||
|
||||
test('E2E-TERM-001 terminal page idle prompt', async ({ page }) => {
|
||||
await login(page)
|
||||
await nav(page, '/terminal')
|
||||
await expect(page.getByText('选择一台服务器开始会话')).toBeVisible({ timeout: 20000 })
|
||||
})
|
||||
@@ -9,7 +9,7 @@
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build --force",
|
||||
"test:e2e": "playwright test e2e/full-acceptance.spec.mjs"
|
||||
"test:e2e": "playwright test e2e"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/roboto": "^5.2.10",
|
||||
|
||||
@@ -37,18 +37,14 @@
|
||||
</template>
|
||||
|
||||
<template v-else-if="credTab === 'sshkeys'">
|
||||
<v-data-table-server
|
||||
<v-data-table
|
||||
:items="sshKeys"
|
||||
:headers="sshKeyHeaders"
|
||||
:items-length="totalItems"
|
||||
:loading="loading"
|
||||
:page="page"
|
||||
:items-per-page="itemsPerPage"
|
||||
:items-per-page-options="dataTablePageOptions"
|
||||
hover
|
||||
density="comfortable"
|
||||
@update:page="(p: number) => { page = p; loadSshKeys() }"
|
||||
@update:items-per-page="onItemsPerPageChange"
|
||||
:items-per-page="20"
|
||||
:items-per-page-options="dataTablePageOptions"
|
||||
>
|
||||
<template #item.private_key_set="{ item }">
|
||||
<v-chip :color="item.private_key_set ? 'success' : 'grey'" size="small" variant="tonal" label>
|
||||
@@ -60,9 +56,9 @@
|
||||
<v-btn variant="text" size="x-small" color="error" density="compact" @click="confirmDelete('sshkey', item)">删除</v-btn>
|
||||
</template>
|
||||
<template #no-data>
|
||||
<div class="text-center text-medium-emphasis py-6">暂无凭据</div>
|
||||
<div class="text-center text-medium-emphasis py-6">暂无 SSH 密钥</div>
|
||||
</template>
|
||||
</v-data-table-server>
|
||||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
@@ -133,10 +129,33 @@
|
||||
<v-card border>
|
||||
<v-card-title>{{ editingId ? '编辑 SSH 密钥' : '新建 SSH 密钥' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field v-model="skForm.name" label="名称" variant="outlined" density="compact" class="mb-2" />
|
||||
<v-text-field v-model="skForm.username" label="SSH 用户名" variant="outlined" density="compact" class="mb-2" placeholder="root" />
|
||||
<v-textarea v-model="skForm.public_key" label="公钥" variant="outlined" density="compact" rows="3" class="mb-2" />
|
||||
<v-textarea v-model="skForm.private_key" label="私钥" variant="outlined" density="compact" rows="5" />
|
||||
<v-text-field
|
||||
v-model="skForm.name"
|
||||
label="名称"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
:rules="[required('名称')]"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="skForm.username"
|
||||
label="SSH 用户名"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
placeholder="root"
|
||||
:rules="[required('SSH 用户名')]"
|
||||
/>
|
||||
<v-textarea v-model="skForm.public_key" label="公钥(可选)" variant="outlined" density="compact" rows="3" class="mb-2" />
|
||||
<v-textarea
|
||||
v-model="skForm.private_key"
|
||||
label="私钥"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
rows="5"
|
||||
:placeholder="editingId ? '留空表示不修改' : '必填,粘贴 PEM 私钥'"
|
||||
:rules="editingId ? [] : [required('私钥')]"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
@@ -249,8 +268,7 @@ const deleteId = ref<number | null>(null)
|
||||
function onItemsPerPageChange(n: number) {
|
||||
itemsPerPage.value = n
|
||||
page.value = 1
|
||||
if (credTab.value === 'sshkeys') loadSshKeys()
|
||||
else if (credTab.value === 'db') loadDbCreds()
|
||||
if (credTab.value === 'db') loadDbCreds()
|
||||
}
|
||||
|
||||
function formatPresetTime(iso: string | undefined): string {
|
||||
@@ -274,10 +292,9 @@ async function loadPasswords(silent = false) {
|
||||
async function loadSshKeys(silent = false) {
|
||||
if (!silent) loading.value = true
|
||||
try {
|
||||
const res = await fetchPagePerPage<SshKeyItem>('/ssh-key-presets/', {}, page.value, itemsPerPage.value)
|
||||
const res = await http.getList<SshKeyItem>('/ssh-key-presets/')
|
||||
sshKeys.value = res.items
|
||||
totalItems.value = res.total
|
||||
if (itemsPerPage.value === -1) page.value = 1
|
||||
} catch (e: unknown) {
|
||||
sshKeys.value = []
|
||||
snackbar(formatApiError(e, '加载 SSH 密钥失败'), 'error')
|
||||
@@ -363,9 +380,35 @@ function editSshKey(item: SshKeyItem) {
|
||||
}
|
||||
|
||||
async function saveSshKey() {
|
||||
const name = skForm.value.name.trim()
|
||||
const username = skForm.value.username.trim() || 'root'
|
||||
const privateKey = skForm.value.private_key.trim()
|
||||
const publicKey = skForm.value.public_key.trim()
|
||||
if (!name) {
|
||||
snackbar('请填写名称', 'error')
|
||||
return
|
||||
}
|
||||
if (!editingId.value && !privateKey) {
|
||||
snackbar('请粘贴私钥', 'error')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (editingId.value) await http.put(`/ssh-key-presets/${editingId.value}`, skForm.value)
|
||||
else await http.post('/ssh-key-presets/', skForm.value)
|
||||
if (editingId.value) {
|
||||
const body: { name: string; username: string; public_key: string; private_key?: string } = {
|
||||
name,
|
||||
username,
|
||||
public_key: publicKey,
|
||||
}
|
||||
if (privateKey) body.private_key = privateKey
|
||||
await http.put(`/ssh-key-presets/${editingId.value}`, body)
|
||||
} else {
|
||||
await http.post('/ssh-key-presets/', {
|
||||
name,
|
||||
username,
|
||||
public_key: publicKey || null,
|
||||
private_key: privateKey,
|
||||
})
|
||||
}
|
||||
showSshKeyDialog.value = false
|
||||
loadSshKeys()
|
||||
snackbar('保存成功')
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<v-card elevation="0" rounded="lg" title="24h 资源趋势" border>
|
||||
<template #subtitle>
|
||||
<span class="text-medium-emphasis">舰队均值 · 每 10 分钟采样 · 北京时间</span>
|
||||
</template>
|
||||
<template #append>
|
||||
<v-btn-toggle v-model="metric" density="compact" variant="outlined" divided mandatory>
|
||||
<v-btn value="cpu" size="small">CPU</v-btn>
|
||||
<v-btn value="mem" size="small">内存</v-btn>
|
||||
<v-btn value="disk" size="small">磁盘</v-btn>
|
||||
</v-btn-toggle>
|
||||
</template>
|
||||
|
||||
<template #text>
|
||||
<div v-if="loading" class="py-6">
|
||||
<v-skeleton-loader type="image" height="80" />
|
||||
</div>
|
||||
<div v-else-if="!hasData" class="text-center text-medium-emphasis py-8">
|
||||
暂无历史采样(约 10 分钟写入一条,部署满 24h 后曲线更完整)
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<div>
|
||||
<div class="text-h5 font-weight-bold">{{ currentLabel }} {{ latestValue }}%</div>
|
||||
<div class="text-caption text-medium-emphasis">{{ rangeLabel }}</div>
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis text-right">
|
||||
在线 {{ latestOnline }} 台
|
||||
</div>
|
||||
</div>
|
||||
<v-sparkline
|
||||
:model-value="sparkValues"
|
||||
:color="sparkColor"
|
||||
auto-draw
|
||||
fill
|
||||
height="72"
|
||||
line-width="2"
|
||||
padding="12"
|
||||
smooth
|
||||
/>
|
||||
<div class="d-flex justify-space-between text-caption text-medium-emphasis mt-1 px-1">
|
||||
<span>{{ startLabel }}</span>
|
||||
<span>{{ endLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { http } from '@/api'
|
||||
import { formatDateTimeBeijing } from '@/utils/datetime'
|
||||
|
||||
type MetricKey = 'cpu' | 'mem' | 'disk'
|
||||
|
||||
interface FleetPoint {
|
||||
recorded_at: string
|
||||
online_count: number
|
||||
cpu_avg: number | null
|
||||
mem_avg: number | null
|
||||
disk_avg: number | null
|
||||
}
|
||||
|
||||
interface FleetMetricsResponse {
|
||||
hours: number
|
||||
interval_minutes: number
|
||||
points: FleetPoint[]
|
||||
}
|
||||
|
||||
const metric = ref<MetricKey>('cpu')
|
||||
const loading = ref(true)
|
||||
const points = ref<FleetPoint[]>([])
|
||||
|
||||
const metricField = computed(() => {
|
||||
if (metric.value === 'mem') return 'mem_avg' as const
|
||||
if (metric.value === 'disk') return 'disk_avg' as const
|
||||
return 'cpu_avg' as const
|
||||
})
|
||||
|
||||
const sparkColor = computed(() => {
|
||||
if (metric.value === 'mem') return 'orange'
|
||||
if (metric.value === 'disk') return 'purple'
|
||||
return 'primary'
|
||||
})
|
||||
|
||||
const currentLabel = computed(() => {
|
||||
if (metric.value === 'mem') return '内存'
|
||||
if (metric.value === 'disk') return '磁盘'
|
||||
return 'CPU'
|
||||
})
|
||||
|
||||
const series = computed(() =>
|
||||
points.value
|
||||
.map((p) => p[metricField.value])
|
||||
.filter((v): v is number => v != null),
|
||||
)
|
||||
|
||||
const hasData = computed(() => series.value.length >= 2)
|
||||
|
||||
const sparkValues = computed(() => series.value)
|
||||
|
||||
const latestValue = computed(() => {
|
||||
const last = series.value[series.value.length - 1]
|
||||
return last != null ? last : '—'
|
||||
})
|
||||
|
||||
const latestOnline = computed(() => {
|
||||
const last = points.value[points.value.length - 1]
|
||||
return last?.online_count ?? '—'
|
||||
})
|
||||
|
||||
const rangeLabel = computed(() => `近 ${points.value.length ? points.value.length * 10 : 0} 分钟有效采样`)
|
||||
|
||||
function beijingShort(isoOrNaive: string | undefined): string {
|
||||
if (!isoOrNaive) return '—'
|
||||
const normalized = isoOrNaive.includes('T') ? isoOrNaive : isoOrNaive.replace(' ', 'T') + 'Z'
|
||||
return formatDateTimeBeijing(normalized).slice(11, 16)
|
||||
}
|
||||
|
||||
const startLabel = computed(() => beijingShort(points.value[0]?.recorded_at))
|
||||
const endLabel = computed(() => beijingShort(points.value[points.value.length - 1]?.recorded_at))
|
||||
|
||||
async function loadTrends() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await http.get<FleetMetricsResponse>('/servers/fleet-metrics', { hours: 24 })
|
||||
points.value = data.points || []
|
||||
} catch {
|
||||
points.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadTrends()
|
||||
})
|
||||
|
||||
defineExpose({ reload: loadTrends })
|
||||
</script>
|
||||
@@ -46,7 +46,7 @@
|
||||
<v-icon size="16" class="mr-1">
|
||||
{{ isCategoryAllSelected(group.category) ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline' }}
|
||||
</v-icon>
|
||||
{{ group.category }}
|
||||
{{ group.label }}
|
||||
</v-btn>
|
||||
<span class="text-caption text-medium-emphasis ml-1">
|
||||
({{ group.servers.filter(s => selectedIds.has(s.id)).length }}/{{ group.servers.length }})
|
||||
@@ -100,7 +100,7 @@ defineProps<{
|
||||
selectAll: boolean
|
||||
serverSearch: string
|
||||
serversLoading: boolean
|
||||
serversByCategory: { category: string; servers: PushServerItem[] }[]
|
||||
serversByCategory: { category: string; label: string; servers: PushServerItem[] }[]
|
||||
selectedIds: Set<number>
|
||||
pushStatus: Record<number, string>
|
||||
isCategoryAllSelected: (category: string) => boolean
|
||||
|
||||
@@ -204,10 +204,11 @@ const showTimeOfDay = computed(() => {
|
||||
})
|
||||
|
||||
const cycleHint = computed(() => {
|
||||
const tz = '时间为北京时间(Asia/Shanghai)'
|
||||
if (singleShot.value) {
|
||||
return '单次:当天指定时:分,在下一匹配时刻执行一次(不含日期选择)'
|
||||
return `单次:当天指定时:分,在下一匹配时刻执行一次(不含日期选择)。${tz}`
|
||||
}
|
||||
return '循环:按重复方式与时:分持续执行'
|
||||
return `循环:按重复方式与时:分持续执行。${tz}`
|
||||
})
|
||||
|
||||
/** 单次模式固定为「当天」时:分,不出现「重复方式」 */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ref, computed, type Ref } from 'vue'
|
||||
import { http } from '@/api'
|
||||
import { categoryDisplayLabel } from '@/composables/useServerCategories'
|
||||
import { fetchPagePerPage } from '@/utils/paginatedFetch'
|
||||
import type { PushServerItem } from './types'
|
||||
|
||||
export function usePushServers(pushStatus: Ref<Record<number, string>>) {
|
||||
@@ -13,8 +14,8 @@ export function usePushServers(pushStatus: Ref<Record<number, string>>) {
|
||||
async function loadServers(silent = false) {
|
||||
if (!silent) serversLoading.value = true
|
||||
try {
|
||||
const res = await http.get<{ items: PushServerItem[] }>('/servers/', { per_page: 200 })
|
||||
servers.value = res.items || []
|
||||
const res = await fetchPagePerPage<PushServerItem>('/servers/', {}, 1, -1)
|
||||
servers.value = res.items
|
||||
} catch {
|
||||
servers.value = []
|
||||
} finally {
|
||||
@@ -55,11 +56,21 @@ export function usePushServers(pushStatus: Ref<Record<number, string>>) {
|
||||
const serversByCategory = computed(() => {
|
||||
const groups: Record<string, PushServerItem[]> = {}
|
||||
for (const s of filteredServers.value) {
|
||||
const cat = s.category || '未分类'
|
||||
if (!groups[cat]) groups[cat] = []
|
||||
groups[cat].push(s)
|
||||
const key = s.category?.trim() || 'uncategorized'
|
||||
if (!groups[key]) groups[key] = []
|
||||
groups[key].push(s)
|
||||
}
|
||||
return Object.entries(groups).map(([category, list]) => ({ category, servers: list }))
|
||||
return Object.entries(groups)
|
||||
.map(([category, list]) => ({
|
||||
category,
|
||||
label: categoryDisplayLabel(category),
|
||||
servers: list,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.category === 'uncategorized') return 1
|
||||
if (b.category === 'uncategorized') return -1
|
||||
return a.label.localeCompare(b.label, 'zh')
|
||||
})
|
||||
})
|
||||
|
||||
function isCategoryAllSelected(category: string): boolean {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* Backend protocol: ws://host/ws/alerts?token=<JWT>
|
||||
*
|
||||
* Message types:
|
||||
* alert → { type: "alert", server_id, server_name, alert_type, alert_value }
|
||||
* recovery → { type: "recovery", server_id, server_name, metric, value }
|
||||
* alert → { type: "alert", server_id, server_name, alert_type, alert_value, at }
|
||||
* recovery → { type: "recovery", server_id, server_name, metric, value, at }
|
||||
* system → { type: "system", event_type, message }
|
||||
* script_progress / script_complete → global script execution queue (useScriptExecutionQueue)
|
||||
* Push progress uses a separate channel: /ws/sync (see usePushProgress)
|
||||
@@ -99,7 +99,7 @@ export function useWebSocket() {
|
||||
serverName: msg.server_name,
|
||||
alertType: msg.alert_type || msg.metric,
|
||||
value: msg.alert_value || msg.value,
|
||||
time: formatTimeBeijing(new Date()),
|
||||
time: formatTimeBeijing(msg.at ?? new Date()),
|
||||
}
|
||||
alerts.value.unshift(alert)
|
||||
if (alerts.value.length > MAX_ALERTS) alerts.value.pop()
|
||||
|
||||
@@ -55,10 +55,12 @@
|
||||
:items-length="total"
|
||||
:loading="loading"
|
||||
:page="page"
|
||||
:items-per-page="20"
|
||||
:items-per-page="itemsPerPage"
|
||||
:items-per-page-options="dataTablePageOptions"
|
||||
hover
|
||||
density="comfortable"
|
||||
@update:page="page = $event; loadAlerts()"
|
||||
@update:page="onPageChange"
|
||||
@update:items-per-page="onItemsPerPageChange"
|
||||
>
|
||||
<template #item.alert_type="{ item }">
|
||||
<v-chip :color="typeColor(item.alert_type)" size="small" variant="tonal" label>
|
||||
@@ -98,9 +100,10 @@ import { ref } from 'vue'
|
||||
import { usePageActivateRefresh } from '@/composables/usePageActivateRefresh'
|
||||
import { http } from '@/api'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import type { PaginatedResponse, AlertLogItem, AlertStatsResponse } from '@/types/api'
|
||||
import { headersWithoutSort } from '@/constants/dataTable'
|
||||
import type { AlertLogItem, AlertStatsResponse } from '@/types/api'
|
||||
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS, headersWithoutSort } from '@/constants/dataTable'
|
||||
import { formatDateTimeBeijing } from '@/utils/datetime'
|
||||
import { fetchPagePerPage } from '@/utils/paginatedFetch'
|
||||
|
||||
defineOptions({ name: 'AlertsPage' })
|
||||
|
||||
@@ -111,6 +114,8 @@ const loading = ref(false)
|
||||
const alerts = ref<AlertLogItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(20)
|
||||
const dataTablePageOptions = DATA_TABLE_ITEMS_PER_PAGE_OPTIONS
|
||||
const typeFilter = ref<string | null>(null)
|
||||
const statusFilter = ref<string | null>(null)
|
||||
|
||||
@@ -152,18 +157,39 @@ async function loadStats() {
|
||||
} catch { snackbar('加载统计失败', 'error') }
|
||||
}
|
||||
|
||||
function onPageChange(p: number) {
|
||||
page.value = p
|
||||
loadAlerts()
|
||||
}
|
||||
|
||||
function onItemsPerPageChange(n: number) {
|
||||
itemsPerPage.value = n
|
||||
page.value = 1
|
||||
loadAlerts()
|
||||
}
|
||||
|
||||
async function loadAlerts(silent = false) {
|
||||
if (!silent) loading.value = true
|
||||
try {
|
||||
const res = await http.get<PaginatedResponse<AlertLogItem>>('/alert-history/', {
|
||||
page: page.value,
|
||||
per_page: 20,
|
||||
type: typeFilter.value || undefined,
|
||||
status: statusFilter.value || undefined,
|
||||
})
|
||||
alerts.value = res.items || []
|
||||
total.value = res.total || 0
|
||||
} catch { alerts.value = [] }
|
||||
const res = await fetchPagePerPage<AlertLogItem>(
|
||||
'/alert-history/',
|
||||
{
|
||||
type: typeFilter.value || undefined,
|
||||
status: statusFilter.value || undefined,
|
||||
},
|
||||
page.value,
|
||||
itemsPerPage.value,
|
||||
)
|
||||
alerts.value = res.items
|
||||
total.value = res.total
|
||||
if (itemsPerPage.value === -1) {
|
||||
page.value = 1
|
||||
}
|
||||
} catch {
|
||||
alerts.value = []
|
||||
total.value = 0
|
||||
snackbar('加载告警列表失败', 'error')
|
||||
}
|
||||
finally { if (!silent) loading.value = false }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
<v-skeleton-loader v-if="statsBooting" type="heading, text" class="mb-4" />
|
||||
<StatCardsRow v-else :items="statItems" />
|
||||
|
||||
<v-row class="mt-5">
|
||||
<v-col cols="12">
|
||||
<DashboardFleetTrends ref="fleetTrendsRef" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- ── Bottom Row: Alerts + Summary ── -->
|
||||
<v-row class="mt-5">
|
||||
<v-col cols="12" md="6">
|
||||
@@ -171,10 +177,12 @@ 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 DashboardFleetTrends from '@/components/dashboard/DashboardFleetTrends.vue'
|
||||
|
||||
defineOptions({ name: 'DashboardPage' })
|
||||
|
||||
const snackbar = useSnackbar()
|
||||
const fleetTrendsRef = ref<InstanceType<typeof DashboardFleetTrends> | null>(null)
|
||||
|
||||
const statsBooting = ref(true)
|
||||
|
||||
@@ -293,6 +301,7 @@ async function refreshDashboard(silent = false) {
|
||||
loadAlerts(),
|
||||
loadSummary(),
|
||||
loadRecentAudit(),
|
||||
fleetTrendsRef.value?.reload?.(),
|
||||
])
|
||||
if (!silent) {
|
||||
http.post('/settings/bing-wallpapers/sync').catch(() => {})
|
||||
|
||||
@@ -36,6 +36,11 @@
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #item.source_path="{ item }">
|
||||
<span v-if="item.schedule_type === 'script'" class="text-caption">{{ scriptSourceLabel(item) }}</span>
|
||||
<span v-else class="text-caption">{{ item.source_path || '—' }}</span>
|
||||
</template>
|
||||
|
||||
<template #item.enabled="{ item }">
|
||||
<v-switch
|
||||
v-model="item.enabled"
|
||||
@@ -327,6 +332,19 @@ function serverIdCount(raw: unknown): number {
|
||||
return parseServerIds(raw).length
|
||||
}
|
||||
|
||||
function scriptSourceLabel(item: ScheduleItem): string {
|
||||
if (item.script_id) {
|
||||
const script = scriptList.value.find((s) => s.id === item.script_id)
|
||||
if (script) return script.name
|
||||
return `脚本 #${item.script_id}`
|
||||
}
|
||||
const content = item.script_content?.trim()
|
||||
if (content) {
|
||||
return content.length > 48 ? `${content.slice(0, 48)}…` : content
|
||||
}
|
||||
return '—'
|
||||
}
|
||||
|
||||
function normalizeSchedule(row: ScheduleItem): ScheduleItem {
|
||||
return { ...row, server_ids: parseServerIds(row.server_ids) }
|
||||
}
|
||||
|
||||
@@ -589,7 +589,8 @@
|
||||
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'
|
||||
import { CACHE_KEYS, cachedFetch, invalidateCachedQuery } from '@/composables/useCachedQuery'
|
||||
import { toServerApiSort } from '@/utils/serverTableSort'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { http } from '@/api'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
@@ -649,6 +650,7 @@ async function submitServerBatchJob(
|
||||
onMounted(() => {
|
||||
offBatchComplete = onScriptExecutionComplete((alert) => {
|
||||
if (alert.taskKind !== 'server_batch') return
|
||||
invalidateCachedQuery(CACHE_KEYS.serverCategories)
|
||||
void Promise.all([loadServers(true), loadUnsetPathServers(true), loadCategories(), loadStats(true)])
|
||||
})
|
||||
})
|
||||
@@ -719,10 +721,19 @@ function batchSelection(scope: BatchScope) {
|
||||
|
||||
const scopedSelectedCount = computed(() => batchSelection(batchScope.value).ids.value.size)
|
||||
|
||||
/** 未设路径区块独立列表:不受顶部分类 chip 过滤,避免改分类后机器从面板消失 */
|
||||
function unsetListQueryParams(): Record<string, unknown> {
|
||||
return {
|
||||
search: search.value || undefined,
|
||||
...toServerApiSort(sortBy.value),
|
||||
target_path_unset: true,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllUnsetPathServers(): Promise<ServerApiItem[]> {
|
||||
const res = await fetchPagePerPage<ServerApiItem>(
|
||||
'/servers/',
|
||||
{ ...listQueryParams(), target_path_unset: true },
|
||||
unsetListQueryParams(),
|
||||
1,
|
||||
-1,
|
||||
)
|
||||
@@ -748,7 +759,7 @@ async function loadUnsetPathServers(silent = false) {
|
||||
try {
|
||||
const res = await fetchPagePerPage<ServerApiItem>(
|
||||
'/servers/',
|
||||
{ ...listQueryParams(), target_path_unset: true },
|
||||
unsetListQueryParams(),
|
||||
unsetPathPage.value,
|
||||
unsetPathItemsPerPage.value,
|
||||
)
|
||||
@@ -927,11 +938,8 @@ function toggleGroupAll(groupServers: ServerApiItem[]) {
|
||||
|
||||
watch(categoryFilter, () => {
|
||||
selectedItems.value = []
|
||||
unsetSelectedItems.value = []
|
||||
expandedMainIds.value = []
|
||||
expandedUnsetIds.value = []
|
||||
unsetPathPage.value = 1
|
||||
void loadUnsetPathServers()
|
||||
void loadServers()
|
||||
})
|
||||
watch(sortBy, () => {
|
||||
selectedItems.value = []
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user