20 Commits

Author SHA1 Message Date
Nexus Agent 6a6f3f7614 docs: audit cover web/app build output
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / e2e (push) Blocked by required conditions
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 09:00:29 +08:00
Nexus Agent fe8c11b706 fix(credentials): load SSH key presets via getList and validate save form
The ssh-key-presets API returns a plain array; fetchPagePerPage left the
list empty so new keys appeared not to save.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 09:00:00 +08:00
Nexus Agent b287707359 docs: record HTTP detail zh production deploy at 64c8bd7
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 08:56:56 +08:00
Nexus Agent 64c8bd74b8 docs: audit and deploy verification for API detail zh rollout
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 08:54:27 +08:00
Nexus Agent 1c0d7e9eb6 fix(api): localize service-layer ValueError messages to Chinese
So str(exc) passthrough on scripts, batch, sync, and schedule routes
returns Chinese without relying only on the HTTP detail translation layer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 08:53:18 +08:00
Nexus Agent a842af2405 fix(auth): use Chinese fallbacks and map reason codes to user messages
Stop exposing raw reason codes like admin_not_found in TOTP setup errors;
auth routes and JWT guard now use Chinese literals with auth_failure_detail.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 08:44:51 +08:00
Nexus Agent 81004cf0a5 docs: note HTTP detail zh layer in handoff TOP 10
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 08:42:48 +08:00
Nexus Agent 3ba78cd10a feat(api): translate HTTPException detail to Chinese for admin routes
Add a global detail translator for common 4xx messages (Server not found,
auth headers, settings not found, etc.) while keeping /api/agent/* in English.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 08:42:43 +08:00
Nexus Agent 213aad97a4 docs: push category fix verification and refresh handoff to c366ed5
Record production evidence (101 机器人 vs old 5) and update AI handoff
with current deploy state and TOP 10 priorities.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 08:36:14 +08:00
Nexus Agent c366ed5adc fix(push): load full server list for accurate category selection
Push page previously fetched only 200 servers by ID order, so large categories
like 机器人 showed a handful of machines. Also decouple unset-path panel from
the main category chip filter and align category labels with the servers page.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 08:12:50 +08:00
Nexus Agent 664efb9ed5 feat(dashboard): add 24h fleet CPU/mem/disk trends with v-sparkline.
Sample fleet averages every heartbeat flush into fleet_metric_samples;
expose GET /api/servers/fleet-metrics and Dashboard sparkline card (B-02).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 07:24:57 +08:00
Nexus Agent adcd8010a5 docs: record production deploy at 863e49e after full rollout.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 07:14:32 +08:00
Nexus Agent 863e49e518 feat(ops): prod timezone probe, web/app container sync, and D-01 agent upgrade.
Unify upgrade-agent with install.sh via agent_deploy; auto-sync Git web/app after
Docker upgrade to prevent vite hash drift; extend prod_probe for Beijing TZ checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 07:12:38 +08:00
Nexus Agent 56139a42fe docs(audit): cover timezone follow-up files for gate 7 review.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 05:51:56 +08:00
Nexus Agent e3efbd4552 feat(timezone): operator schedules and filters use Beijing wall clock.
Cron matching, audit/alert date filters, and schedule UI align on Asia/Shanghai
while JWT/TOTP/heartbeat stay UTC; follow-ups add WS alert timestamps, fixed
install timezone copy, and 422 on invalid date filters.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 05:50:32 +08:00
Nexus Agent 4d82ed4031 build(frontend): rebuild web/app assets and prune stale hashes.
Align index.html with a complete Vite output set and remove 116 orphaned
chunks so git clone loads the SPA correctly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 03:33:23 +08:00
Nexus Agent b62f1039db feat(ops): add production probe with dual-path health checks.
SSH origin checks cover auth contracts and once-schedule create/delete
when external HTTPS is blocked; includes install script for probe credentials.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 03:28:17 +08:00
Nexus Agent 9f78207aa2 docs: add agent diff guidance and close deploy verification.
Record .cursorrules rules to avoid full-repo diffs over web/app/assets,
and document production health checks for the alerts/schedule fix batch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 02:52:32 +08:00
Nexus Agent 3864d120fb fix: alerts page size and schedule once fire_at parsing.
Wire alert history items-per-page to the API and parse fire_at ISO strings to datetime so single-shot push schedules save instead of 500.
2026-06-09 02:47:36 +08:00
Nexus Agent 0f6f91e61a fix(api): align alerts/commands contracts and ship full-site test suite.
Restore alert history filters and stats fields, paginate command/session logs for the SPA, show script labels on schedules, and land integration/E2E coverage with rebuilt web assets.
2026-06-09 02:13:13 +08:00
7226 changed files with 10914 additions and 79726 deletions
+7
View File
@@ -29,6 +29,13 @@ FastAPI + Async SQLAlchemy + Redis · Vue 3 + Vuetify 4 SPA15 页)· 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
+42 -2
View File
@@ -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
+1
View File
@@ -26,6 +26,7 @@ ENV/
.megamemory/
.playwright-mcp/
frontend/test-results/
frontend/e2e/.auth/
tmp/
# IDE
+15 -3
View File
@@ -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
+7
View File
@@ -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}"
+200
View File
@@ -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"}
+6
View File
@@ -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
+87
View File
@@ -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 "$@"
+6
View File
@@ -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
View File
@@ -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-012026-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 失败 warnprobe 显式断言 |
| Shell 注入 | PASS — docker cp 路径来自 NEXUS_ROOT |
| 分层 | PASS — agent_deploy 在 utilsAPI 薄编排 |
## 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` | 页域 E2E18 |
| `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 36 | PASS(本审计前) |
| MCP-INST-001 各 step UI | ⏭ 用户确认不纳入验收 |
## DoD
- [x] Wave 15 矩阵 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:0023: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 15 实施:先补 `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 14 落地
## 摘要
按计划补齐 IT/CH/E2E 自动化:推送 accepted、链条 WS、12 条集成测试、5 条链条测试、14 条 Playwright 页面 specCI 分层跑 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 14,关闭 `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 15 + E2E 35/35
**日期**: 2026-06-09
## 摘要
补齐 `test-case-matrix` 中 IT/CH/E2E 缺口;Playwright 全量 35/35 greenpytest 455 passed(含 slow)。
## 动机
- Wave 15 仅有 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` 映射时仍显示英文。
## 动机
待办 #1API 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 caseserial |
| 验收聚合 | `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 — 目录与标记(12 周)
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 拆分(23 周)
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. repoinsert_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 + DBhttpx 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 specL4
### 安全与性能约束
- 测试账号:`NEXUS_TEST_ADMIN_*`,禁止写生产密钥入 git
- E2E 默认 `127.0.0.1:8600`;生产 URL 仅只读冒烟且需用户批准
- 链条测试子机:标记 `is_test=1` 或使用 ID 段约定(文档化)
- 单 CI job pytest 目标 < 10minchain/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 流
- [ ] CIPR 跑 L1+L2main 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:0023:59。
+1 -2
View File
@@ -1,8 +1,7 @@
# Nexus — AI 接续(2026-06-08 · Linux
> **新会话请复制下方「复制提示词」整段。**
> **已由 [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`
> 完整待办 132 条见本文 §「Master Backlog 索引」。
---
+94
View File
@@ -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
· 运行中前端 ≈ c366ed5tar/sync_webapp);主机 git checkout 可能仍 664efb9,下次 upgrade 会 pull
SSHNEXUS_SSH=azureuser@20.24.218.235 · NEXUS_SSH_KEY="/home/r/下载/Telegram Desktop/nz-admin.pem"
pushbash 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 0prod_probe 时区、sync_webapp_to_container、D-01 agent_deploy863e49e
- Dashboard 24h 趋势 B-02664efb9fleet_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_runWS 告警时间
- 批量 101 台、WebSSH、Telegram、关 CPU 告警无恢复轰炸
【优先待办 TOP 10】
1. 生产 git pull 对齐 c366ed5(或一次 deploy-production 消歧)
2. API detail 中文化 — 422 ☑ · HTTP 4xx 全局层 ☑ · ValueError 源头 ☑(已部署 64c8bd7
3. Agent 401 子机 rolloutA-05
4. 无心跳自动离线 + 告警降噪
5. L4 Playwright 全绿 + L5 清单
6. 资产管理 / Sync 配置 UIC-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_* 后不再发 TelegramWebSocket/日志仍记录)。
2. **服务器行内详情** — 点击名称展开 `ServerInlineDetail`expanded id 与 `item-value` 数字一致)。
## 浏览器终验(用户)
- [ ] 服务器页 → 点击名称 → 展开 CPU/内存/同步日志
- [ ] 设置页 → 关闭「CPU 超阈值告警」→ 确认不再收到 TelegramWebSocket 告警仍可有)
@@ -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 15 测试矩阵对应的 Playwright 全量 35/35 green**,可与 `tests/integration` + `tests/chain`27 passed)一并作为 L4 证据。
@@ -0,0 +1,13 @@
# 部署验证 — 推送后台化 + 422 中文化 + 隐藏 CSV2026-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_at2026-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 一次性创建/断言/删除(未入仓)。
## 浏览器 E2EPlaywright,本地 `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`(先于 commitGit 已对齐)
## 根因(已修复)
| 现象 | 数据 |
|------|------|
| 推送页「机器人」仅 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 迁移
```
+101
View File
@@ -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. 写报告;若 PASS7 日内补 `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;仅用于部署后探索与用例发现。
+257
View File
@@ -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.mjsmock 子机+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 | ~3manual/安装) | ~21 |
| **合计** | **~133** | **~36** | **~169** |
---
## PR 检查清单
- [ ] 改动域在表中至少更新 1 行状态
- [ ] 新 API 必有 `IT-*`;新纯函数必有 `UT-*`
- [ ] UI 行为变更必有 `E2E-*``MCP-*` 计划
- [ ] 破坏性 E2E 仅对测试子机
+44
View File
@@ -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'
+15 -34
View File
@@ -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()
+60
View File
@@ -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/
+18
View File
@@ -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)
}
}
})
+18
View File
@@ -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)
}
}
})
+14
View File
@@ -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 })
})
+24
View File
@@ -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)
}
}
})
+15
View File
@@ -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,
})
}
})
+11
View File
@@ -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()
})
+12
View File
@@ -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([])
})
+10
View File
@@ -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 })
})
+12
View File
@@ -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()
})
+79
View File
@@ -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)
})
+11
View File
@@ -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/)
})
+12
View File
@@ -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 })
})
+9
View File
@@ -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)
})
+37
View File
@@ -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 })
})
+9
View File
@@ -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 })
})
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -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()
+39 -13
View File
@@ -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 }
}
+9
View File
@@ -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(() => {})
+18
View File
@@ -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) }
}
+15 -7
View File
@@ -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