diff --git a/requirements.txt b/requirements.txt
index c0b2171..14241ef 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -10,6 +10,5 @@ psutil==6.0.0
aiohttp==3.10.0
httpx==0.27.2
pydantic==2.9.0
-passlib[bcrypt]==1.7.4
python-jose[cryptography]==3.3.0
apscheduler==3.10.4
diff --git a/server/api/ha.py b/server/api/ha.py
index 3b96ea8..93bb10d 100644
--- a/server/api/ha.py
+++ b/server/api/ha.py
@@ -1,13 +1,12 @@
"""Home Assistant monitoring API endpoints."""
-import json
from datetime import datetime
from typing import Dict
from fastapi import APIRouter, Request, Depends
from server.auth import require_auth
-from server.config import DATA_DIR
+from server.config import DATA_DIR, load_json, save_json
router = APIRouter(prefix="/api/ha", tags=["homeassistant"])
@@ -18,15 +17,11 @@ HA_FILE = DATA_DIR / "homeassistant.json"
def _load_ha():
- if HA_FILE.exists():
- with open(HA_FILE) as f:
- return json.load(f)
- return []
+ return load_json(HA_FILE, [])
def _save_ha(data):
- with open(HA_FILE, "w") as f:
- json.dump(data, f, indent=2, ensure_ascii=False)
+ save_json(HA_FILE, data)
@router.get("/list")
diff --git a/server/api/keenetic.py b/server/api/keenetic.py
index a3df6d4..a710065 100644
--- a/server/api/keenetic.py
+++ b/server/api/keenetic.py
@@ -1,7 +1,6 @@
"""Keenetic router monitoring API endpoints."""
import asyncio
-import json
import logging
from datetime import datetime
from typing import Dict, List
@@ -10,7 +9,7 @@ from urllib.parse import urlparse
from fastapi import APIRouter, Request, Depends
from server.auth import require_auth
-from server.config import DATA_DIR, load_settings
+from server.config import DATA_DIR, load_settings, load_json, save_json
from server.services.keenetic_client import (
KeeneticClient,
normalize_web_url,
@@ -31,15 +30,11 @@ REFRESH_ALL_GAP_SEC = 2
def _load_keenetic():
- if KEENETIC_FILE.exists():
- with open(KEENETIC_FILE) as f:
- return json.load(f)
- return []
+ return load_json(KEENETIC_FILE, [])
def _save_keenetic(data):
- with open(KEENETIC_FILE, "w") as f:
- json.dump(data, f, indent=2, ensure_ascii=False)
+ save_json(KEENETIC_FILE, data)
def _host_from_url(url: str) -> str:
diff --git a/server/api/pc.py b/server/api/pc.py
index 419a318..5a20dc9 100644
--- a/server/api/pc.py
+++ b/server/api/pc.py
@@ -1,11 +1,10 @@
-import json
from datetime import datetime
from typing import Dict
from fastapi import APIRouter, Request, Depends
from server.auth import require_auth
-from server.config import DATA_DIR
+from server.config import DATA_DIR, load_json, save_json
router = APIRouter(prefix="/api/pc", tags=["pc"])
@@ -16,15 +15,11 @@ PC_FILE = DATA_DIR / "pc_agents.json"
def _load_pc_data():
- if PC_FILE.exists():
- with open(PC_FILE) as f:
- return json.load(f)
- return {}
+ return load_json(PC_FILE, {})
def _save_pc_data(data):
- with open(PC_FILE, "w") as f:
- json.dump(data, f, indent=2, ensure_ascii=False)
+ save_json(PC_FILE, data)
@router.post("/heartbeat")
diff --git a/server/api/synology.py b/server/api/synology.py
index bc7fa3c..a19fa4b 100644
--- a/server/api/synology.py
+++ b/server/api/synology.py
@@ -1,16 +1,15 @@
"""Synology NAS monitoring API endpoints."""
-import json
import os
from datetime import datetime
from pathlib import Path
-from typing import Dict, List
+from typing import Dict
from fastapi import APIRouter, Request, Depends
from fastapi.responses import PlainTextResponse
from server.auth import require_auth
-from server.config import DATA_DIR, BASE_DIR
+from server.config import DATA_DIR, BASE_DIR, load_json, save_json
router = APIRouter(prefix="/api/synology", tags=["synology"])
@@ -25,15 +24,11 @@ SYNOLOGY_FILE = DATA_DIR / "synology.json"
def _load_synology():
- if SYNOLOGY_FILE.exists():
- with open(SYNOLOGY_FILE) as f:
- return json.load(f)
- return []
+ return load_json(SYNOLOGY_FILE, [])
def _save_synology(data):
- with open(SYNOLOGY_FILE, "w") as f:
- json.dump(data, f, indent=2, ensure_ascii=False)
+ save_json(SYNOLOGY_FILE, data)
@router.get("/list")
diff --git a/server/auth.py b/server/auth.py
index bccd3ee..ddf035c 100644
--- a/server/auth.py
+++ b/server/auth.py
@@ -1,24 +1,23 @@
import hashlib
import hmac
-import json
+import os
from datetime import datetime, timedelta
from urllib.parse import parse_qs
from jose import jwt
-from passlib.context import CryptContext
from fastapi import Request, HTTPException
-from fastapi.responses import RedirectResponse
from server.config import load_settings
-SECRET_KEY = "vps-monitoring-secret-key-change-me-in-production"
+# Allow override via env so a single deployment can rotate secrets without
+# rebaking the image. Falls back to a known value for backwards compatibility.
+SECRET_KEY = os.getenv("VPS_MONITORING_SECRET", "vps-monitoring-secret-key-change-me-in-production")
ALGORITHM = "HS256"
TOKEN_EXPIRE_HOURS = 24
-pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
-
-def verify_password(plain_password: str, hashed_password: str) -> bool:
- return plain_password == hashed_password
+def verify_password(plain_password: str, stored_password: str) -> bool:
+ """Plain-text comparison — admin password is stored verbatim in settings.json."""
+ return plain_password == stored_password
def create_token(username: str) -> str:
diff --git a/server/config.py b/server/config.py
index 82847f2..f63a324 100644
--- a/server/config.py
+++ b/server/config.py
@@ -1,5 +1,6 @@
import os
import json
+import tempfile
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -22,26 +23,49 @@ DEFAULT_SETTINGS = {
}
+def load_json(path: Path, default):
+ """Load JSON with explicit UTF-8 and a sane default on missing/corrupt file."""
+ try:
+ if path.exists():
+ with open(path, encoding="utf-8") as f:
+ return json.load(f)
+ except (json.JSONDecodeError, OSError):
+ # Don't crash the whole app if a data file is malformed; fall back to default.
+ pass
+ return default
+
+
+def save_json(path: Path, data) -> None:
+ """Atomic JSON write: dump to temp file then rename. Survives crashes mid-write."""
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fd, tmp_path = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent))
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
+ json.dump(data, f, indent=2, ensure_ascii=False)
+ os.replace(tmp_path, path)
+ except Exception:
+ if os.path.exists(tmp_path):
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+ raise
+
+
def load_settings():
if SETTINGS_FILE.exists():
- with open(SETTINGS_FILE) as f:
- return json.load(f)
+ return load_json(SETTINGS_FILE, dict(DEFAULT_SETTINGS))
save_settings(DEFAULT_SETTINGS)
- return DEFAULT_SETTINGS.copy()
+ return dict(DEFAULT_SETTINGS)
def save_settings(settings):
- with open(SETTINGS_FILE, "w") as f:
- json.dump(settings, f, indent=2, ensure_ascii=False)
+ save_json(SETTINGS_FILE, settings)
def load_servers():
- if SERVERS_FILE.exists():
- with open(SERVERS_FILE) as f:
- return json.load(f)
- return []
+ return load_json(SERVERS_FILE, [])
def save_servers(servers):
- with open(SERVERS_FILE, "w") as f:
- json.dump(servers, f, indent=2, ensure_ascii=False)
+ save_json(SERVERS_FILE, servers)
diff --git a/server/services/alerter.py b/server/services/alerter.py
index 145309d..58dd796 100644
--- a/server/services/alerter.py
+++ b/server/services/alerter.py
@@ -9,12 +9,11 @@ Logic per issue:
State tracked per (source, issue_key) pair.
"""
-import json
import logging
from datetime import datetime
from typing import Dict, Tuple
-from server.config import load_settings, load_servers, DATA_DIR
+from server.config import load_settings, load_servers, load_json, DATA_DIR
from server.services.monitor import get_all_metrics
logger = logging.getLogger(__name__)
@@ -27,9 +26,21 @@ _pending_sustain: Dict[Tuple[str, str, str], datetime] = {}
_keenetic_last_online: Dict[str, datetime] = {}
_keenetic_alert_sent: Dict[str, datetime] = {}
+# Sustain windows (seconds before firing offline alert)
+OFFLINE_SUSTAIN_SEC = 300 # 5 min for VPS / PC / Synology / HA
KEENETIC_OFFLINE_SUSTAIN = 300 # 5 min sustained failure before alert
KEENETIC_OFFLINE_COOLDOWN = 1800 # 30 min between repeat offline alerts
+# Startup grace: skip OFFLINE alerts for the first STARTUP_GRACE_SEC seconds
+# after the alerter module is loaded (i.e. process restart). Prevents the
+# mass-phantom alert storm when the monitor hasn't completed its first poll yet.
+STARTUP_GRACE_SEC = 300
+_startup_at = datetime.now()
+
+
+def _in_startup_grace() -> bool:
+ return (datetime.now() - _startup_at).total_seconds() < STARTUP_GRACE_SEC
+
def _issue_key(category: str, source: str, key: str) -> tuple:
return (category, source, key)
@@ -79,8 +90,14 @@ async def _check_issue(category: str, source: str, key: str, is_problem: bool,
async def _check_sustained_issue(category: str, source: str, key: str, is_problem: bool,
alert_msg: str, resolve_msg: str, subject: str,
- sustain_seconds: int = 300):
- """Alert only after problem persists for sustain_seconds (default 5 min)."""
+ sustain_seconds: int = 300,
+ honor_startup_grace: bool = False):
+ """Alert only after problem persists for sustain_seconds (default 5 min).
+
+ When honor_startup_grace=True, suppress alerts entirely until the process
+ has been running for STARTUP_GRACE_SEC. Pending state is still tracked so
+ the sustain window starts ticking from first detection.
+ """
ik = _issue_key(category, source, key)
was_active = ik in active_issues
now = datetime.now()
@@ -90,6 +107,9 @@ async def _check_sustained_issue(category: str, source: str, key: str, is_proble
_pending_sustain[ik] = now
elapsed = (now - _pending_sustain[ik]).total_seconds()
if elapsed >= sustain_seconds and not was_active:
+ if honor_startup_grace and _in_startup_grace():
+ logger.info(f"Sustained alert deferred (startup grace): {category}/{source}/{key}")
+ return
active_issues[ik] = _pending_sustain[ik]
if not _is_device_muted(category, source):
await _fire_alert(alert_msg, subject, category)
@@ -139,13 +159,15 @@ async def _check_servers(settings: dict):
m = metrics.get(host, {})
online = m.get("online", False)
- # Online/Offline
- await _check_issue(
+ # Online/Offline — 5 min sustain + startup grace to avoid phantom alerts
+ await _check_sustained_issue(
"servers", name, "offline",
is_problem=not online,
- alert_msg=f"🔴 *{name}* ({host}) - OFFLINE!",
+ alert_msg=f"🔴 *{name}* ({host}) - OFFLINE 5+ мин!",
resolve_msg=f"🟢 *{name}* ({host}) - back online",
subject=f"{name} offline",
+ sustain_seconds=OFFLINE_SUSTAIN_SEC,
+ honor_startup_grace=True,
)
if not online:
@@ -190,8 +212,7 @@ async def _check_pc(settings: dict):
if not pc_file.exists():
return
- with open(pc_file) as f:
- pc_data = json.load(f)
+ pc_data = load_json(pc_file, {})
now = datetime.now()
@@ -199,16 +220,19 @@ async def _check_pc(settings: dict):
last_seen = data.get("last_seen", "")
try:
last_dt = datetime.fromisoformat(last_seen)
- stale = (now - last_dt).total_seconds() > 180 # 3 min
+ stale = (now - last_dt).total_seconds() > 180 # 3 min no heartbeat
except Exception:
stale = True
- await _check_issue(
+ # 5 min sustain on top of the 3 min stale window + startup grace
+ await _check_sustained_issue(
"pc", name, "offline",
is_problem=stale,
- alert_msg=f"🔴 *PC {name}* - no heartbeat for 3+ min",
+ alert_msg=f"🔴 *PC {name}* - нет heartbeat 5+ мин",
resolve_msg=f"🟢 *PC {name}* - back online",
subject=f"PC {name}",
+ sustain_seconds=OFFLINE_SUSTAIN_SEC,
+ honor_startup_grace=True,
)
@@ -229,12 +253,14 @@ async def _check_synology(settings: dict):
online = m.get("online", False)
- await _check_issue(
+ await _check_sustained_issue(
"synology", name, "offline",
is_problem=not online,
- alert_msg=f"🔴 *NAS {name}* - OFFLINE!",
+ alert_msg=f"🔴 *NAS {name}* - OFFLINE 5+ мин!",
resolve_msg=f"🟢 *NAS {name}* - back online",
subject=f"NAS {name}",
+ sustain_seconds=OFFLINE_SUSTAIN_SEC,
+ honor_startup_grace=True,
)
if not online:
@@ -302,12 +328,14 @@ async def _check_ha(settings: dict):
online = m.get("online", False)
- await _check_issue(
+ await _check_sustained_issue(
"ha", name, "offline",
is_problem=not online,
- alert_msg=f"🔴 *HA {name}* - OFFLINE!",
+ alert_msg=f"🔴 *HA {name}* - OFFLINE 5+ мин!",
resolve_msg=f"🟢 *HA {name}* - back online",
subject=f"HA {name}",
+ sustain_seconds=OFFLINE_SUSTAIN_SEC,
+ honor_startup_grace=True,
)
if not online:
@@ -365,6 +393,10 @@ async def _check_keenetic_offline(name: str, dev: dict, online: bool):
elapsed = (now - _pending_sustain[ik]).total_seconds()
if elapsed >= KEENETIC_OFFLINE_SUSTAIN and not was_active:
+ if _in_startup_grace():
+ logger.info(f"Keenetic offline deferred (startup grace): {name}")
+ return
+
last_alert = _keenetic_alert_sent.get(name)
last_online = _keenetic_last_online.get(name)
if last_alert and (now - last_alert).total_seconds() < KEENETIC_OFFLINE_COOLDOWN:
@@ -513,8 +545,7 @@ async def get_full_status() -> str:
# PC
pc_file = DATA_DIR / "pc_agents.json"
if pc_file.exists():
- with open(pc_file) as f:
- pc_data = json.load(f)
+ pc_data = load_json(pc_file, {})
if pc_data:
lines.append("\n*PC Agents:*")
now = datetime.now()
diff --git a/server/services/monitor.py b/server/services/monitor.py
index 214be2d..d29c76f 100644
--- a/server/services/monitor.py
+++ b/server/services/monitor.py
@@ -1,12 +1,11 @@
import asyncio
-import json
import logging
from datetime import datetime
from typing import Dict
import asyncssh
-from server.config import load_servers, load_settings, DATA_DIR
+from server.config import load_servers, load_settings, save_json, METRICS_FILE
logger = logging.getLogger(__name__)
@@ -143,10 +142,7 @@ async def monitor_loop():
else:
metrics_cache[srv["host"]] = result
- # Save metrics to file
- metrics_file = DATA_DIR / "metrics.json"
- with open(metrics_file, "w") as f:
- json.dump(metrics_cache, f, indent=2)
+ save_json(METRICS_FILE, metrics_cache)
await asyncio.sleep(interval)
diff --git a/server/templates/dashboard.html b/server/templates/dashboard.html
index 093f646..2f1abf4 100644
--- a/server/templates/dashboard.html
+++ b/server/templates/dashboard.html
@@ -601,6 +601,6 @@ loftliliana https://loftliliana.netcraze.pro 1020687391" style="width:100%;font-
-
+