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.
This commit is contained in:
Nexus Agent
2026-06-09 02:47:36 +08:00
parent 0f6f91e61a
commit 3864d120fb
11 changed files with 295 additions and 17 deletions
@@ -0,0 +1,41 @@
# 审计 — 告警分页 + 调度单次 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 | 已确认根因;修复后待部署复验 |
## DoD
- [x] 告警每页数目与 API `per_page` 联动
- [x] 单次文件推送调度保存 201(非 500)
- [x] Changelog + 本审计
- [ ] 生产部署与健康检查(本批次)
@@ -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,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,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 条生效。
+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 }
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Quick production health + fixed API contract checks (no secrets in output).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SECRETS="${ROOT}/deploy/nexus-1panel.secrets.sh"
if [[ -f "$SECRETS" ]]; then
# shellcheck disable=SC1090
source "$SECRETS"
fi
BASE="${NEXUS_PROBE_BASE:-https://api.synaglobal.vip}"
SSH_HOST="${NEXUS_SSH:-nexus}"
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=15)
[[ -n "${NEXUS_SSH_KEY:-}" ]] && SSH_OPTS+=(-i "${NEXUS_SSH_KEY}")
echo "=== Public ==="
HEALTH=$(curl -sk --max-time 15 "${BASE}/health")
[[ "$HEALTH" == "ok" ]] || { echo "FAIL /health: ${HEALTH}"; exit 1; }
echo "✓ /health → ok"
SPA_CODE=$(curl -sk --max-time 15 -o /dev/null -w '%{http_code}' "${BASE}/app/")
[[ "$SPA_CODE" == "200" ]] || { echo "FAIL /app/: HTTP ${SPA_CODE}"; exit 1; }
echo "✓ /app/ → HTTP 200"
INSTALL_CODE=$(curl -sk --max-time 15 -o /dev/null -w '%{http_code}' "${BASE}/app/install.html")
echo " /app/install.html → HTTP ${INSTALL_CODE} (404 expected when locked)"
echo "=== Authenticated API ==="
PW="$(ssh "${SSH_OPTS[@]}" "${SSH_HOST}" \
'for f in /opt/nexus/docker/.env.prod /opt/nexus/.env /www/wwwroot/api.synaglobal.vip/.env; do
if [[ -f "$f" ]]; then
grep -E "^NEXUS_TEST_ADMIN_PASSWORD=" "$f" 2>/dev/null | cut -d= -f2- | tr -d "\""
exit 0
fi
done' \
2>/dev/null || true)"
if [[ -z "$PW" ]]; then
echo "SKIP authenticated checks (NEXUS_TEST_ADMIN_PASSWORD not found)"
exit 0
fi
TOKEN="$(curl -sk --max-time 15 -X POST "${BASE}/api/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"username\":\"admin\",\"password\":\"${PW}\"}" \
| python3 -c 'import sys,json; print(json.load(sys.stdin).get("access_token",""))')"
[[ -n "$TOKEN" ]] || { echo "FAIL login"; exit 1; }
echo "✓ login OK"
TMP="$(mktemp)"
trap 'rm -f "$TMP"' EXIT
AUTH=(-H "Authorization: Bearer ${TOKEN}")
# python inline per endpoint
stats_code=$(curl -sk --max-time 15 -o "$TMP" -w '%{http_code}' "${AUTH[@]}" "${BASE}/api/alert-history/stats")
[[ "$stats_code" == "200" ]] || { echo "FAIL /api/alert-history/stats HTTP ${stats_code}"; exit 1; }
python3 -c "import json;d=json.load(open('$TMP'));assert all(k in d for k in ('today','active','recovered','top_server'))"
echo "✓ /api/alert-history/stats contract OK"
for path in \
"/api/alert-history/?page=1&per_page=5" \
"/api/assets/command-logs?page=1&per_page=5" \
"/api/assets/ssh-sessions?page=1&per_page=5"; do
code=$(curl -sk --max-time 15 -o "$TMP" -w '%{http_code}' "${AUTH[@]}" "${BASE}${path}")
[[ "$code" == "200" ]] || { echo "FAIL ${path} HTTP ${code}"; exit 1; }
python3 -c "import json;d=json.load(open('$TMP'));assert 'items' in d and 'total' in d;print(f' {path}: items={len(d[\"items\"])} total={d[\"total\"]}')"
done
echo "✓ paginated list APIs contract OK"
code=$(curl -sk --max-time 15 -o "$TMP" -w '%{http_code}' "${AUTH[@]}" "${BASE}/api/schedules/")
[[ "$code" == "200" ]] || { echo "FAIL /api/schedules/ HTTP ${code}"; exit 1; }
python3 -c "import json;d=json.load(open('$TMP'));assert isinstance(d,list);print(f' schedules count={len(d)}')"
echo "✓ /api/schedules/ OK"
echo "=== All production health checks passed ==="
+22
View File
@@ -2,9 +2,31 @@
from __future__ import annotations
from datetime import datetime, timezone
from server.utils.posix_paths import PosixPathError, normalize_remote_abs_path, to_posix
def coerce_optional_iso_datetime(value: object) -> datetime | None:
"""Parse ISO-8601 / MySQL-style datetime for ORM DateTime columns."""
if value is None:
return None
if isinstance(value, datetime):
return value.replace(tzinfo=None) if value.tzinfo else value
text = str(value).strip()
if not text:
return None
try:
if text.endswith("Z"):
text = f"{text[:-1]}+00:00"
dt = datetime.fromisoformat(text)
except ValueError as exc:
raise ValueError("执行时间格式无效") from exc
if dt.tzinfo is not None:
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt
def coerce_optional_remote_abs_path(value: str | None) -> str | None:
"""Optional remote absolute path (e.g. server.target_path)."""
if value is None:
+14 -2
View File
@@ -2,11 +2,13 @@
Shared schemas for API validation, replacing raw `dict` parameters.
"""
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, Field, field_validator, model_validator
from server.api.schema_path_validators import (
coerce_nexus_host_abs_path,
coerce_optional_iso_datetime,
coerce_optional_remote_abs_path,
coerce_remote_abs_path,
coerce_remote_abs_path_list,
@@ -587,7 +589,7 @@ class ScheduleCreate(BaseModel):
server_ids: str = Field(..., min_length=3) # JSON array string
run_mode: str = Field("once", pattern="^(once|cron)$")
cron_expr: Optional[str] = None # required when run_mode=cron
fire_at: Optional[str] = None # ISO datetime, required when run_mode=once
fire_at: Optional[datetime] = None # required when run_mode=once
enabled: bool = True
# Schedule type
schedule_type: str = Field("push", pattern="^(push|script)$")
@@ -613,6 +615,11 @@ class ScheduleCreate(BaseModel):
raise ValueError("push 类型调度必须提供 source_path")
return self
@field_validator("fire_at", mode="before")
@classmethod
def _parse_fire_at(cls, v: object) -> object:
return coerce_optional_iso_datetime(v)
@field_validator("source_path", mode="before")
@classmethod
def _normalize_source_path(cls, v: object) -> object:
@@ -636,7 +643,7 @@ class ScheduleUpdate(BaseModel):
server_ids: Optional[str] = None
run_mode: Optional[str] = Field(None, pattern="^(once|cron)$")
cron_expr: Optional[str] = None
fire_at: Optional[str] = None
fire_at: Optional[datetime] = None
sync_mode: Optional[str] = Field(None, pattern="^(incremental|full|overwrite|checksum)$")
enabled: Optional[bool] = None
schedule_type: Optional[str] = Field(None, pattern="^(push|script)$")
@@ -645,6 +652,11 @@ class ScheduleUpdate(BaseModel):
exec_timeout: Optional[int] = Field(None, ge=10, le=600)
long_task: Optional[bool] = None
@field_validator("fire_at", mode="before")
@classmethod
def _parse_fire_at(cls, v: object) -> object:
return coerce_optional_iso_datetime(v)
@field_validator("source_path", mode="before")
@classmethod
def _normalize_source_path(cls, v: object) -> object:
+1 -1
View File
@@ -818,7 +818,7 @@ async def list_alert_history(
date_from: Optional[str] = None,
date_to: Optional[str] = None,
page: Optional[int] = Query(None, ge=1),
per_page: Optional[int] = Query(None, ge=1, le=100),
per_page: Optional[int] = Query(None, ge=1, le=200),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
admin: Admin = Depends(get_current_admin),
+24
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from server.api.schemas import ScheduleCreate, ScheduleUpdate
@@ -10,6 +12,28 @@ from server.api.settings import create_schedule, delete_schedule, list_schedules
pytestmark = pytest.mark.integration
@pytest.mark.asyncio
async def test_schedule_create_once_push_parses_fire_at(db_session, test_admin, test_server, mock_request):
"""once 模式 fire_at 须写入 ORM datetime(非 ISO 字符串),避免 500。"""
fire = datetime.now(timezone.utc).replace(microsecond=0)
payload = ScheduleCreate(
name="it-schedule-once",
server_ids=f"[{test_server.id}]",
run_mode="once",
cron_expr="30 14 * * *",
fire_at=fire.isoformat(),
schedule_type="push",
source_path="/tmp/nexus-schedule-src",
)
assert isinstance(payload.fire_at, datetime)
created = await create_schedule(payload, mock_request, test_admin["admin"], db_session)
assert created["id"] > 0
assert created["fire_at"] is not None
await delete_schedule(created["id"], mock_request, test_admin["admin"], db_session)
@pytest.mark.asyncio
async def test_schedule_crud_minimal(db_session, test_admin, test_server, mock_request):
payload = ScheduleCreate(
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" href="/app/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus</title>
<script type="module" crossorigin src="/app/assets/index-DawRgH7u.js"></script>
<script type="module" crossorigin src="/app/assets/index-Bplw5zZe.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-DUUkodzN.css">
</head>
<body>