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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user