feat: Telegram test+ChatID, Agent IP allowlist/upgrade, alert history page

Telegram:
- POST /api/settings/telegram/test: send test message (checks token+chat_id)
- GET /api/settings/telegram/chats: call getUpdates, return up to 5 chats
- settings.html: test button + detect chat IDs with click-to-fill

Agent IP allowlist (web/agent/agent.py):
- allowed_ips config: list of trusted IPs/CIDRs from config.json
- _ip_allowed(): checks exact IP, CIDR (ipaddress module), hostname
- verify_api_key(): now checks IP allowlist before API key
- /health: also checks IP allowlist
- install.sh: auto-extracts central server IP from --url, adds to allowed_ips

Agent auto-upgrade (server/api/servers.py):
- POST /api/servers/{id}/upgrade-agent: SSH → curl new agent.py → systemctl restart
- servers.html: 升级 Agent button in Agent tab (only when online)

Alert history (new page):
- domain/models: AlertLog table (server_id, type, value, is_recovery, created_at)
- migrations.py: CREATE TABLE IF NOT EXISTS alert_logs
- websocket.py: _save_alert_log() called from broadcast_alert/recovery
- settings.py: GET /api/alert-history/ (paginated, filters), GET /stats
- main.py: register alert_history_router
- alerts.html: new page with stats cards, top-servers bar chart, alert list
- layout.js: 🔔 告警中心 added to sidebar nav

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-05-23 18:27:12 +08:00
parent 488838c989
commit 9bba58a529
12 changed files with 652 additions and 4 deletions
+192
View File
@@ -280,6 +280,198 @@ async def delete_preset(
))
# ── Alert History ──
alert_history_router = APIRouter(prefix="/api/alert-history", tags=["alerts"])
@alert_history_router.get("/", response_model=dict)
async def list_alert_history(
server_id: Optional[int] = None,
alert_type: Optional[str] = None,
is_recovery: Optional[bool] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
limit: int = 50,
offset: int = 0,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Paginated alert/recovery history with filters."""
from sqlalchemy import select, func
from server.domain.models import AlertLog, Server
from datetime import datetime, timezone
filters = []
if server_id:
filters.append(AlertLog.server_id == server_id)
if alert_type:
filters.append(AlertLog.alert_type == alert_type)
if is_recovery is not None:
filters.append(AlertLog.is_recovery == is_recovery)
if date_from:
try:
dt = datetime.fromisoformat(date_from)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
filters.append(AlertLog.created_at >= dt.replace(tzinfo=None))
except ValueError:
pass
if date_to:
try:
dt = datetime.fromisoformat(date_to)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
filters.append(AlertLog.created_at <= dt.replace(tzinfo=None))
except ValueError:
pass
count_q = select(func.count(AlertLog.id))
data_q = (
select(AlertLog, Server.name.label("srv_name"))
.join(Server, AlertLog.server_id == Server.id, isouter=True)
.order_by(AlertLog.created_at.desc())
.offset(offset).limit(limit)
)
if filters:
count_q = count_q.where(*filters)
data_q = data_q.where(*filters)
total = int((await db.execute(count_q)).scalar() or 0)
rows = (await db.execute(data_q)).all()
def _to_dict(log, srv_name):
return {
"id": log.id,
"server_id": log.server_id,
"server_name": log.server_name or srv_name or f"#{log.server_id}",
"alert_type": log.alert_type,
"value": log.value,
"is_recovery": log.is_recovery,
"created_at": str(log.created_at) if log.created_at else None,
}
return {
"items": [_to_dict(log, sn) for log, sn in rows],
"total": total, "limit": limit, "offset": offset,
}
@alert_history_router.get("/stats", response_model=dict)
async def alert_stats(
days: int = 7,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Top alerting servers + daily counts for the last N days."""
from sqlalchemy import select, func, text
from server.domain.models import AlertLog
# Top 5 servers by alert count
top_q = (
select(AlertLog.server_id, AlertLog.server_name, func.count(AlertLog.id).label("cnt"))
.where(AlertLog.is_recovery == False)
.group_by(AlertLog.server_id, AlertLog.server_name)
.order_by(func.count(AlertLog.id).desc())
.limit(5)
)
top_rows = (await db.execute(top_q)).all()
# Daily counts
daily_q = text(
f"SELECT DATE(created_at) as day, "
f"SUM(is_recovery=0) as alerts, SUM(is_recovery=1) as recoveries "
f"FROM alert_logs "
f"WHERE created_at >= DATE_SUB(NOW(), INTERVAL {int(days)} DAY) "
f"GROUP BY DATE(created_at) ORDER BY day"
)
daily_rows = (await db.execute(daily_q)).all()
return {
"top_servers": [{"server_id": r.server_id, "server_name": r.server_name or f"#{r.server_id}", "count": r.cnt} for r in top_rows],
"daily": [{"day": str(r.day), "alerts": int(r.alerts), "recoveries": int(r.recoveries)} for r in daily_rows],
}
# ── Telegram Test + Chat ID Detection ──
@router.post("/telegram/test", response_model=dict)
async def telegram_test_send(
admin: Admin = Depends(get_current_admin),
):
"""Send a test Telegram message to verify Bot Token + Chat ID configuration."""
from server.infrastructure.telegram import send_telegram
from server.config import settings as _settings
if not _settings.TELEGRAM_BOT_TOKEN:
raise HTTPException(status_code=400, detail="TELEGRAM_BOT_TOKEN 未配置")
if not _settings.TELEGRAM_CHAT_ID:
raise HTTPException(status_code=400, detail="TELEGRAM_CHAT_ID 未配置,请先检测并填写")
from datetime import datetime, timezone
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
ok = await send_telegram(
f"✅ <b>Nexus 测试消息</b>\n"
f"Bot 配置正确,告警通知已就绪\n"
f"时间: {now}"
)
if ok:
return {"success": True}
raise HTTPException(status_code=502, detail="Telegram API 返回错误,请检查 Bot Token 和 Chat ID")
@router.get("/telegram/chats", response_model=dict)
async def telegram_get_chats(
admin: Admin = Depends(get_current_admin),
):
"""Call getUpdates to detect recent chats and extract unique chat_ids.
User must send at least one message to the Bot before calling this.
Returns up to 5 distinct chats from recent messages.
"""
import httpx
from server.config import settings as _settings
from server.infrastructure.telegram import TELEGRAM_API_BASE
token = _settings.TELEGRAM_BOT_TOKEN
if not token:
raise HTTPException(status_code=400, detail="TELEGRAM_BOT_TOKEN 未配置")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"{TELEGRAM_API_BASE}/bot{token}/getUpdates",
params={"limit": 50, "allowed_updates": ["message", "channel_post"]},
)
if resp.status_code != 200:
raise HTTPException(status_code=502, detail=f"Telegram API 返回 {resp.status_code}")
data = resp.json()
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=f"请求失败: {e}")
# Extract unique chats
seen: set[int] = set()
chats: list[dict] = []
for update in data.get("result", []):
msg = update.get("message") or update.get("channel_post") or {}
chat = msg.get("chat", {})
cid = chat.get("id")
if cid and cid not in seen:
seen.add(cid)
chats.append({
"id": cid,
"type": chat.get("type", ""),
"title": chat.get("title") or chat.get("first_name") or "",
"username": chat.get("username") or "",
})
if len(chats) >= 5:
break
return {"chats": chats, "count": len(chats)}
# ── IP Allowlist ──
class IpAllowlistRequest(BaseModel):