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:
@@ -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 条生效。
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ==="
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user