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:
@@ -442,6 +442,75 @@ async def get_agent_install_cmd(
|
||||
}
|
||||
|
||||
|
||||
# ── Agent Upgrade ──
|
||||
|
||||
@router.post("/{id}/upgrade-agent", response_model=dict)
|
||||
async def upgrade_agent(
|
||||
request: Request,
|
||||
id: int,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ServerService = Depends(get_server_service),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Upgrade Nexus Agent on the managed server via SSH.
|
||||
|
||||
Downloads the latest agent.py from the central server and restarts
|
||||
the nexus-agent systemd service. Does NOT touch config.json.
|
||||
"""
|
||||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||||
import shlex
|
||||
|
||||
server = await service.get_server(id)
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
|
||||
base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
|
||||
if not base_url:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="NEXUS_API_BASE_URL 未配置,无法构建下载地址",
|
||||
)
|
||||
|
||||
agent_url = f"{base_url}/agent/agent.py"
|
||||
install_dir = "/opt/nexus-agent"
|
||||
|
||||
# Download new agent.py → restart service
|
||||
upgrade_cmd = (
|
||||
f"curl -fsSL {shlex.quote(agent_url)} -o {install_dir}/agent.py "
|
||||
f"&& systemctl restart nexus-agent "
|
||||
f"&& sleep 2 "
|
||||
f"&& systemctl is-active nexus-agent "
|
||||
f"&& echo 'upgrade_ok'"
|
||||
)
|
||||
|
||||
try:
|
||||
result = await exec_ssh_command(server, upgrade_cmd, timeout=60)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"SSH 连接失败: {e}")
|
||||
|
||||
success = result["exit_code"] == 0 and "upgrade_ok" in result["stdout"]
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"升级失败 (exit {result['exit_code']}): {result['stderr'][:300]}",
|
||||
)
|
||||
|
||||
# Audit
|
||||
ip_address = request.client.host if request.client else ""
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="upgrade_agent",
|
||||
target_type="server",
|
||||
target_id=id,
|
||||
detail=f"Agent 升级: {server.name} ({server.domain})",
|
||||
ip_address=ip_address,
|
||||
))
|
||||
|
||||
return {"success": True, "server_id": id, "server_name": server.name, "stdout": result["stdout"][:500]}
|
||||
|
||||
|
||||
# ── Health Check ──
|
||||
|
||||
@router.post("/check", response_model=dict)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -324,6 +324,24 @@ def _should_push_telegram(alert_key: str) -> bool:
|
||||
|
||||
# ── Broadcast Functions (called from other modules) ──
|
||||
|
||||
async def _save_alert_log(server_id: int, server_name: str, alert_type: str, value: str, is_recovery: bool):
|
||||
"""Persist alert / recovery event to MySQL alert_logs table."""
|
||||
try:
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.domain.models import AlertLog
|
||||
async with AsyncSessionLocal() as session:
|
||||
session.add(AlertLog(
|
||||
server_id=server_id,
|
||||
server_name=server_name or None,
|
||||
alert_type=alert_type,
|
||||
value=value,
|
||||
is_recovery=is_recovery,
|
||||
))
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
logger.debug("Failed to save alert log: %s", e)
|
||||
|
||||
|
||||
async def broadcast_alert(server_id: int, alert_type: str, alert_value: float, server_name: str = ""):
|
||||
"""Broadcast alert to all WebSocket clients + Telegram
|
||||
|
||||
@@ -344,6 +362,9 @@ async def broadcast_alert(server_id: int, alert_type: str, alert_value: float, s
|
||||
# Redis Pub/Sub (multi-worker) — local clients via subscriber only (C9)
|
||||
await _dispatch_ws_message(msg)
|
||||
|
||||
# Persist to alert_logs
|
||||
await _save_alert_log(server_id, server_name, alert_type, f"{alert_value:.1f}%", is_recovery=False)
|
||||
|
||||
# Telegram push — deduplicated per (server_id, alert_type) with cooldown
|
||||
alert_key = f"alert:{server_id}:{alert_type}"
|
||||
if _should_push_telegram(alert_key):
|
||||
@@ -369,6 +390,9 @@ async def broadcast_recovery(server_id: int, metric: str, value: float, server_n
|
||||
|
||||
await _dispatch_ws_message(msg)
|
||||
|
||||
# Persist recovery to alert_logs
|
||||
await _save_alert_log(server_id, server_name, metric, f"{value:.1f}%", is_recovery=True)
|
||||
|
||||
# Recovery always sends Telegram + clears cooldown for next alert cycle
|
||||
alert_key = f"alert:{server_id}:{metric}"
|
||||
_last_alert_time.pop(alert_key, None) # Clear cooldown
|
||||
|
||||
@@ -338,6 +338,27 @@ class DbCredential(Base):
|
||||
# Web SSH 会话 & 命令日志
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class AlertLog(Base):
|
||||
"""Persistent alert & recovery history."""
|
||||
__tablename__ = "alert_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), nullable=False)
|
||||
server_name = Column(String(100), nullable=True, comment="冗余服务器名,便于列表显示")
|
||||
alert_type = Column(String(20), nullable=False, comment="cpu|mem|disk|time_drift|system")
|
||||
value = Column(String(50), nullable=True, comment="指标值(百分比或描述)")
|
||||
is_recovery = Column(Boolean, default=False, comment="True=恢复通知")
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
server = relationship("Server")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_alert_logs_server_id", "server_id"),
|
||||
Index("idx_alert_logs_created_at", "created_at"),
|
||||
Index("idx_alert_logs_type", "alert_type"),
|
||||
)
|
||||
|
||||
|
||||
class SshSession(Base):
|
||||
"""Web SSH session lifecycle tracking"""
|
||||
__tablename__ = "ssh_sessions"
|
||||
|
||||
@@ -112,6 +112,19 @@ async def run_schema_migrations():
|
||||
"ALTER TABLE push_schedules ADD COLUMN sync_mode VARCHAR(20) DEFAULT 'incremental' COMMENT '同步模式'",
|
||||
"ALTER TABLE admins ADD COLUMN token_version INT DEFAULT 0 COMMENT '令牌版本(重用时递增使旧token失效)'",
|
||||
"ALTER TABLE script_executions ADD COLUMN credential_id INT NULL COMMENT 'DB凭据替换$DB_*变量'",
|
||||
# Alert log table
|
||||
"""CREATE TABLE IF NOT EXISTS `alert_logs` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`server_id` INT NOT NULL,
|
||||
`server_name` VARCHAR(100) NULL,
|
||||
`alert_type` VARCHAR(20) NOT NULL,
|
||||
`value` VARCHAR(50) NULL,
|
||||
`is_recovery` TINYINT(1) DEFAULT 0,
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX `idx_alert_logs_server_id` (`server_id`),
|
||||
INDEX `idx_alert_logs_created_at` (`created_at`),
|
||||
INDEX `idx_alert_logs_type` (`alert_type`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
||||
# Script-schedule columns (push_schedules extended to support script execution)
|
||||
"ALTER TABLE push_schedules ADD COLUMN schedule_type VARCHAR(20) DEFAULT 'push' COMMENT '调度类型: push|script'",
|
||||
"ALTER TABLE push_schedules ADD COLUMN script_id INT NULL COMMENT '脚本库引用'",
|
||||
|
||||
@@ -50,6 +50,7 @@ from server.api.settings import (
|
||||
preset_router,
|
||||
audit_router,
|
||||
retry_router,
|
||||
alert_history_router,
|
||||
)
|
||||
from server.api.websocket import router as websocket_router
|
||||
from server.api.health import router as health_router
|
||||
@@ -365,6 +366,7 @@ app.include_router(settings_router)
|
||||
app.include_router(schedule_router)
|
||||
app.include_router(preset_router)
|
||||
app.include_router(audit_router)
|
||||
app.include_router(alert_history_router)
|
||||
app.include_router(retry_router)
|
||||
|
||||
# WebSocket + Health check routers (new in v6.0)
|
||||
|
||||
+38
-2
@@ -45,6 +45,11 @@ CENTRAL_URL = config.get("central", {}).get("url", "")
|
||||
CENTRAL_API_KEY = config.get("central", {}).get("api_key", "")
|
||||
HEARTBEAT_INTERVAL = config.get("heartbeat_interval", 60)
|
||||
|
||||
# --- IP allowlist (only accept requests from trusted IPs) ---
|
||||
# In config.json: "allowed_ips": ["1.2.3.4", "10.0.0.0/8"]
|
||||
# Empty list = allow all (default for backward compat)
|
||||
_ALLOWED_IPS: list[str] = config.get("allowed_ips") or []
|
||||
|
||||
# --- Logging ---
|
||||
|
||||
log_file = config.get("log_file", "/var/log/nexus-agent.log")
|
||||
@@ -65,7 +70,35 @@ logger = logging.getLogger("nexus-agent")
|
||||
app = FastAPI(title="Nexus Agent", version="2.0.0")
|
||||
|
||||
|
||||
def verify_api_key(x_api_key: str = Header(default="")):
|
||||
def _ip_allowed(client_ip: str) -> bool:
|
||||
"""Check client IP against allowed_ips list (supports CIDR and exact match)."""
|
||||
if not _ALLOWED_IPS:
|
||||
return True # Empty = allow all
|
||||
import ipaddress
|
||||
try:
|
||||
addr = ipaddress.ip_address(client_ip)
|
||||
for entry in _ALLOWED_IPS:
|
||||
entry = entry.strip()
|
||||
try:
|
||||
if "/" in entry:
|
||||
if addr in ipaddress.ip_network(entry, strict=False):
|
||||
return True
|
||||
elif addr == ipaddress.ip_address(entry):
|
||||
return True
|
||||
except ValueError:
|
||||
if client_ip == entry:
|
||||
return True
|
||||
except ValueError:
|
||||
return client_ip in _ALLOWED_IPS
|
||||
return False
|
||||
|
||||
|
||||
def verify_api_key(request: Request, x_api_key: str = Header(default="")):
|
||||
# IP allowlist check first
|
||||
client_ip = request.client.host if request.client else ""
|
||||
if not _ip_allowed(client_ip):
|
||||
logger.warning("Blocked request from %s (not in allowed_ips)", client_ip)
|
||||
raise HTTPException(status_code=403, detail="IP not allowed")
|
||||
if not secrets.compare_digest(x_api_key or "", API_KEY):
|
||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||
|
||||
@@ -73,8 +106,11 @@ def verify_api_key(x_api_key: str = Header(default="")):
|
||||
# --- Health Endpoint ---
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
async def health_check(request: Request):
|
||||
"""Health check endpoint — called by Nexus central server"""
|
||||
client_ip = request.client.host if request.client else ""
|
||||
if not _ip_allowed(client_ip):
|
||||
raise HTTPException(status_code=403, detail="IP not allowed")
|
||||
try:
|
||||
cpu_percent = psutil.cpu_percent(interval=0.5)
|
||||
memory = psutil.virtual_memory()
|
||||
|
||||
@@ -66,17 +66,22 @@ curl -fsSL "${WEB_URL}/agent/agent.py" -o "$INSTALL_DIR/agent.py" || {
|
||||
|
||||
# 4. Config
|
||||
echo "[4/5] Write config..."
|
||||
# Extract central IP from URL for IP allowlist
|
||||
CENTRAL_HOST=$(echo "${CENTRAL_URL}" | sed 's~https\?://~~' | cut -d'/' -f1 | cut -d':' -f1)
|
||||
|
||||
cat > "$INSTALL_DIR/config.json" << EOF
|
||||
{
|
||||
"server_id": ${SERVER_ID},
|
||||
"central": { "url": "${CENTRAL_URL}", "api_key": "${API_KEY}" },
|
||||
"api_key": "${API_KEY}",
|
||||
"heartbeat_interval": 60,
|
||||
"allowed_ips": ["${CENTRAL_HOST}"],
|
||||
"log_file": "/var/log/nexus-agent.log",
|
||||
"log_level": "INFO",
|
||||
"server": { "host": "127.0.0.1", "port": ${AGENT_PORT} }
|
||||
}
|
||||
EOF
|
||||
echo " IP allowlist: ${CENTRAL_HOST}"
|
||||
|
||||
# 5. systemd + firewall + start
|
||||
echo "[5/5] Setup service + firewall + start..."
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 告警中心</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button>
|
||||
<h1 class="text-white font-semibold">告警中心</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<select id="serverFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<option value="">全部服务器</option>
|
||||
</select>
|
||||
<select id="typeFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<option value="">全部类型</option>
|
||||
<option value="cpu">CPU</option>
|
||||
<option value="mem">内存</option>
|
||||
<option value="disk">磁盘</option>
|
||||
<option value="time_drift">时钟偏差</option>
|
||||
<option value="system">系统</option>
|
||||
</select>
|
||||
<select id="kindFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<option value="">告警+恢复</option>
|
||||
<option value="alert">仅告警</option>
|
||||
<option value="recovery">仅恢复</option>
|
||||
</select>
|
||||
<input id="dateFrom" type="date" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<span class="text-slate-600 text-xs">至</span>
|
||||
<input id="dateTo" type="date" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<button onclick="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
|
||||
<!-- Stats cards -->
|
||||
<div id="statsSection" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"></div>
|
||||
|
||||
<!-- Top alerting servers -->
|
||||
<div id="topServersSection" class="hidden bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<h2 class="text-white font-medium text-sm mb-3">告警最多的服务器(近 7 天)</h2>
|
||||
<div id="topServersList" class="space-y-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Alert list -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[700px]">
|
||||
<thead class="bg-slate-800/50 text-slate-400 text-xs uppercase">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3">状态</th>
|
||||
<th class="text-left px-4 py-3">类型</th>
|
||||
<th class="text-left px-4 py-3">服务器</th>
|
||||
<th class="text-left px-4 py-3">数值</th>
|
||||
<th class="text-right px-4 py-3">时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="alertsTbody" class="divide-y divide-slate-800">
|
||||
<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div id="pagination" class="hidden flex items-center justify-between text-sm">
|
||||
<span id="pageInfo" class="text-slate-500 text-xs"></span>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="prevPage()" id="prevBtn" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">← 上一页</button>
|
||||
<button onclick="nextPage()" id="nextBtn" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">下一页 →</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
initLayout('alerts');
|
||||
const PAGE_SIZE = 50;
|
||||
let _offset = 0, _total = 0;
|
||||
|
||||
const TYPE_LABEL = {cpu:'CPU',mem:'内存',disk:'磁盘',time_drift:'时钟偏差',system:'系统'};
|
||||
const TYPE_COLOR = {cpu:'text-orange-400',mem:'text-blue-400',disk:'text-yellow-400',time_drift:'text-purple-400',system:'text-slate-400'};
|
||||
|
||||
async function loadServers(){
|
||||
const r=await apiFetch(API+'/servers/?per_page=200');if(!r)return;
|
||||
const data=await r.json();
|
||||
const sel=document.getElementById('serverFilter');
|
||||
(data.items||[]).forEach(s=>{const o=document.createElement('option');o.value=s.id;o.textContent=s.name;sel.appendChild(o)});
|
||||
}
|
||||
|
||||
async function loadStats(){
|
||||
const r=await apiFetch(API+'/alert-history/stats?days=7');if(!r)return;
|
||||
const d=await r.json();
|
||||
// Top servers
|
||||
if(d.top_servers&&d.top_servers.length){
|
||||
document.getElementById('topServersSection').classList.remove('hidden');
|
||||
document.getElementById('topServersList').innerHTML=d.top_servers.map((s,i)=>`
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-5 text-slate-600 text-xs text-right">${i+1}</span>
|
||||
<div class="flex-1 bg-slate-800 rounded-full h-2 overflow-hidden">
|
||||
<div class="h-full bg-red-500/70 rounded-full transition-all" style="width:${Math.round(s.count/d.top_servers[0].count*100)}%"></div>
|
||||
</div>
|
||||
<span class="text-slate-300 text-xs w-32 truncate">${esc(s.server_name)}</span>
|
||||
<span class="text-red-400 text-xs font-medium w-12 text-right">${s.count} 次</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
// Stats cards
|
||||
const totalAlerts=d.daily.reduce((s,r)=>s+r.alerts,0);
|
||||
const totalRecoveries=d.daily.reduce((s,r)=>s+r.recoveries,0);
|
||||
document.getElementById('statsSection').innerHTML=`
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-slate-400 text-xs mb-1">近 7 天告警次数</div>
|
||||
<div class="text-red-400 text-3xl font-bold">${totalAlerts}</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-slate-400 text-xs mb-1">近 7 天恢复次数</div>
|
||||
<div class="text-green-400 text-3xl font-bold">${totalRecoveries}</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-slate-400 text-xs mb-1">告警最多的类型</div>
|
||||
<div class="text-white text-xl font-bold">${getTopType(d)}</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-slate-400 text-xs mb-1">告警最多的服务器</div>
|
||||
<div class="text-white text-base font-bold truncate">${d.top_servers[0]?.server_name||'--'}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function getTopType(d){
|
||||
// Just show the top server's alert type label — we'd need per-type stats for this
|
||||
return '见详情';
|
||||
}
|
||||
|
||||
async function loadAlerts(){
|
||||
const serverId=document.getElementById('serverFilter').value;
|
||||
const alertType=document.getElementById('typeFilter').value;
|
||||
const kind=document.getElementById('kindFilter').value;
|
||||
const dateFrom=document.getElementById('dateFrom').value;
|
||||
const dateTo=document.getElementById('dateTo').value;
|
||||
|
||||
let url=`${API}/alert-history/?limit=${PAGE_SIZE}&offset=${_offset}`;
|
||||
if(serverId)url+='&server_id='+serverId;
|
||||
if(alertType)url+='&alert_type='+alertType;
|
||||
if(kind==='alert')url+='&is_recovery=false';
|
||||
if(kind==='recovery')url+='&is_recovery=true';
|
||||
if(dateFrom)url+='&date_from='+encodeURIComponent(dateFrom);
|
||||
if(dateTo)url+='&date_to='+encodeURIComponent(dateTo+'T23:59:59');
|
||||
|
||||
const r=await apiFetch(url);if(!r)return;
|
||||
const data=await r.json();
|
||||
_total=data.total||0;
|
||||
const logs=data.items||[];
|
||||
const tbody=document.getElementById('alertsTbody');
|
||||
|
||||
if(!logs.length){tbody.innerHTML='<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">暂无告警记录</td></tr>';document.getElementById('pagination').classList.add('hidden');return}
|
||||
|
||||
tbody.innerHTML=logs.map(l=>{
|
||||
const isRec=l.is_recovery;
|
||||
const statusBadge=isRec
|
||||
?'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-green-900/30 text-green-400 border border-green-500/30">🟢 恢复</span>'
|
||||
:'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-red-900/30 text-red-400 border border-red-500/30">🔴 告警</span>';
|
||||
const typeColor=TYPE_COLOR[l.alert_type]||'text-slate-400';
|
||||
return `<tr class="hover:bg-slate-800/30 transition">
|
||||
<td class="px-4 py-2.5">${statusBadge}</td>
|
||||
<td class="px-4 py-2.5 ${typeColor} text-xs font-medium">${esc(TYPE_LABEL[l.alert_type]||l.alert_type)}</td>
|
||||
<td class="px-4 py-2.5 text-slate-300 text-xs">${esc(l.server_name||'#'+l.server_id)}</td>
|
||||
<td class="px-4 py-2.5 text-slate-400 text-xs font-mono">${esc(l.value||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-slate-500 text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
const pg=document.getElementById('pagination');
|
||||
pg.classList.remove('hidden');
|
||||
const curPage=Math.floor(_offset/PAGE_SIZE)+1;
|
||||
const totalPages=Math.max(1,Math.ceil(_total/PAGE_SIZE));
|
||||
document.getElementById('pageInfo').textContent=`共 ${_total} 条 · 第 ${curPage}/${totalPages} 页`;
|
||||
document.getElementById('prevBtn').disabled=_offset===0;
|
||||
document.getElementById('nextBtn').disabled=_offset+PAGE_SIZE>=_total;
|
||||
}
|
||||
|
||||
function prevPage(){_offset=Math.max(0,_offset-PAGE_SIZE);loadAlerts()}
|
||||
function nextPage(){_offset+=PAGE_SIZE;loadAlerts()}
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
function fmt(t){if(!t)return'--';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit',second:'2-digit'})}
|
||||
|
||||
Promise.all([loadServers(),loadStats()]).then(()=>loadAlerts());
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -19,6 +19,7 @@ function initLayout(activeNav) {
|
||||
|
||||
const bottomNav = [
|
||||
{ id:'commands', icon:'💻', label:'命令日志', href:'/app/commands.html' },
|
||||
{ id:'alerts', icon:'🔔', label:'告警中心', href:'/app/alerts.html' },
|
||||
{ id:'audit', icon:'📋', label:'审计日志', href:'/app/audit.html' },
|
||||
{ id:'settings', icon:'⚙️', label:'设置', href:'/app/settings.html' },
|
||||
];
|
||||
|
||||
@@ -314,6 +314,10 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
${remoteBtn}
|
||||
${d.is_online?`<button onclick="upgradeAgent()" id="upgradeAgentBtn"
|
||||
class="px-3 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title="下载最新 agent.py 并重启服务">
|
||||
⬆ 升级 Agent
|
||||
</button>`:''}
|
||||
<span class="text-slate-500 text-xs">安装约需 30-60 秒;安装完成后 Agent 发送首次心跳约需再等 60 秒,状态徽章将自动更新为 <span class="text-green-400">● Agent 在线</span></span>
|
||||
</div>
|
||||
<div id="remoteInstallLog" class="hidden rounded-lg border border-slate-700 bg-slate-950 p-3 max-h-56 overflow-y-auto">
|
||||
@@ -329,6 +333,31 @@
|
||||
}catch(e){el.innerHTML=`<div class="text-red-400 text-sm">加载失败: ${esc(e.message)}</div>`}
|
||||
}
|
||||
|
||||
async function upgradeAgent(){
|
||||
if(!selectedServerId)return;
|
||||
const btn=document.getElementById('upgradeAgentBtn');
|
||||
if(btn){btn.disabled=true;btn.textContent='升级中...'}
|
||||
const logEl=document.getElementById('remoteInstallLog');
|
||||
const logText=document.getElementById('remoteInstallLogText');
|
||||
logEl?.classList.remove('hidden');
|
||||
logText.textContent='正在下载最新 agent.py 并重启服务...\n';
|
||||
try{
|
||||
const r=await apiFetch(API+'/servers/'+selectedServerId+'/upgrade-agent',{method:'POST',headers:apiHeadersJSON()});
|
||||
if(!r){logText.textContent+='请求失败\n';return}
|
||||
const d=await r.json();
|
||||
if(r.ok&&d.success){
|
||||
logText.textContent+=d.stdout||'';
|
||||
logText.textContent+='\n✓ Agent 升级成功!';
|
||||
toast('success','Agent 升级成功');
|
||||
setTimeout(loadServers,3000);
|
||||
}else{
|
||||
logText.textContent+=(d.detail||'升级失败')+'\n';
|
||||
toast('error','升级失败');
|
||||
}
|
||||
}catch(e){logText.textContent+='错误: '+e.message;toast('error','升级请求失败')}
|
||||
finally{if(btn){btn.disabled=false;btn.textContent='⬆ 升级 Agent'}}
|
||||
}
|
||||
|
||||
function copyAgentCmd(){
|
||||
const cmd=document.getElementById('agentCmdText')?.textContent;
|
||||
if(cmd){
|
||||
|
||||
+72
-2
@@ -137,9 +137,23 @@
|
||||
</div>
|
||||
|
||||
<!-- Telegram -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h2 class="text-white font-medium mb-4">Telegram 通知</h2>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
|
||||
<h2 class="text-white font-medium">Telegram 通知</h2>
|
||||
<div id="telegramSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
|
||||
<!-- Test + Chat ID detection -->
|
||||
<div class="flex flex-wrap items-center gap-2 pt-1 border-t border-slate-800">
|
||||
<button onclick="telegramTest()" id="telegramTestBtn"
|
||||
class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-xs rounded-lg transition">
|
||||
📨 发送测试消息
|
||||
</button>
|
||||
<button onclick="telegramGetChats()" id="telegramChatBtn"
|
||||
class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-xs rounded-lg transition">
|
||||
🔍 检测 Chat ID
|
||||
</button>
|
||||
<span class="text-slate-600 text-xs">检测前请先向 Bot 发一条消息</span>
|
||||
</div>
|
||||
<!-- Chat list -->
|
||||
<div id="telegramChatList" class="hidden space-y-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Login IP Allowlist -->
|
||||
@@ -574,6 +588,62 @@
|
||||
if(key)saveSetting(key);
|
||||
});
|
||||
|
||||
// ── Telegram Test + Chat ID Detection ──
|
||||
async function telegramTest(){
|
||||
const btn=document.getElementById('telegramTestBtn');
|
||||
btn.disabled=true;btn.textContent='发送中...';
|
||||
try{
|
||||
const r=await apiFetch(API+'/settings/telegram/test',{method:'POST',headers:apiHeadersJSON()});
|
||||
if(!r)return;
|
||||
const d=await r.json();
|
||||
if(d.success)toast('success','Telegram 测试消息发送成功!');
|
||||
else toast('error','发送失败: '+(d.detail||d.error||'未知错误'));
|
||||
}catch(e){toast('error','请求失败: '+e.message)}
|
||||
finally{btn.disabled=false;btn.textContent='📨 发送测试消息'}
|
||||
}
|
||||
|
||||
async function telegramGetChats(){
|
||||
const btn=document.getElementById('telegramChatBtn');
|
||||
btn.disabled=true;btn.textContent='检测中...';
|
||||
try{
|
||||
const r=await apiFetch(API+'/settings/telegram/chats');
|
||||
if(!r)return;
|
||||
const d=await r.json();
|
||||
const listEl=document.getElementById('telegramChatList');
|
||||
if(!d.chats||d.chats.length===0){
|
||||
toast('warning','未检测到消息,请先向 Bot 发一条消息后再试');
|
||||
listEl.classList.add('hidden');return;
|
||||
}
|
||||
listEl.classList.remove('hidden');
|
||||
listEl.innerHTML='<p class="text-slate-400 text-xs">检测到以下对话,点击填入 Chat ID:</p>'+
|
||||
d.chats.map(c=>`<div class="flex items-center gap-3 px-3 py-2 bg-slate-800 rounded-lg">
|
||||
<div class="flex-1">
|
||||
<span class="text-white text-sm font-medium">${esc(c.title||c.username||'私聊')}</span>
|
||||
<span class="ml-2 text-slate-500 text-xs">${esc(c.type)}</span>
|
||||
<code class="ml-2 text-brand-light text-xs font-mono">${esc(String(c.id))}</code>
|
||||
</div>
|
||||
<button onclick="fillChatId('${escAttr(String(c.id))}')"
|
||||
class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-xs rounded-lg transition">
|
||||
使用此 ID
|
||||
</button>
|
||||
</div>`).join('');
|
||||
toast('success',`检测到 ${d.chats.length} 个对话`);
|
||||
}catch(e){toast('error','检测失败: '+e.message)}
|
||||
finally{btn.disabled=false;btn.textContent='🔍 检测 Chat ID'}
|
||||
}
|
||||
|
||||
async function fillChatId(chatId){
|
||||
// Save chat_id to settings
|
||||
const r=await apiFetch(API+'/settings/telegram_chat_id',{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify({value:chatId})});
|
||||
if(r&&r.ok){
|
||||
toast('success','Chat ID 已保存: '+chatId);
|
||||
// Update the input field in telegramSection
|
||||
const input=document.getElementById('set_telegram_chat_id');
|
||||
if(input)input.value=chatId;
|
||||
loadSettings();
|
||||
}else toast('error','保存失败');
|
||||
}
|
||||
|
||||
// ── Notification Toggles ──
|
||||
const NOTIFY_ITEMS = [
|
||||
{ key:'notify_alert_cpu', label:'CPU 超阈值告警', desc:'CPU 使用率超过设定阈值时推送', group:'服务器资源' },
|
||||
|
||||
Reference in New Issue
Block a user