Merge 476f80c37e into 2cdc0265b8
commit
16d3129a83
|
|
@ -40,6 +40,7 @@ from modules.DashboardClients import DashboardClients
|
|||
from modules.DashboardPlugins import DashboardPlugins
|
||||
from modules.DashboardWebHooks import DashboardWebHooks
|
||||
from modules.NewConfigurationTemplates import NewConfigurationTemplates
|
||||
from modules.PeerHealthMonitor import PeerHealthMonitor
|
||||
|
||||
class CustomJsonEncoder(DefaultJSONProvider):
|
||||
def __init__(self, app):
|
||||
|
|
@ -174,6 +175,10 @@ def startThreads():
|
|||
scheduleJobThread = threading.Thread(target=peerJobScheduleBackgroundThread, daemon=True)
|
||||
scheduleJobThread.start()
|
||||
|
||||
# Health Monitor - start if enabled
|
||||
if DashboardConfig.GetConfig("Health", "enabled")[1] == "true":
|
||||
PeerHealthMonitorInstance.start()
|
||||
|
||||
dictConfig({
|
||||
'version': 1,
|
||||
'formatters': {'default': {
|
||||
|
|
@ -203,6 +208,7 @@ with app.app_context():
|
|||
NewConfigurationTemplates: NewConfigurationTemplates = NewConfigurationTemplates()
|
||||
InitWireguardConfigurationsList(startup=True)
|
||||
DashboardClients: DashboardClients = DashboardClients(WireguardConfigurations)
|
||||
PeerHealthMonitorInstance: PeerHealthMonitor = PeerHealthMonitor(DashboardConfig, WireguardConfigurations, app.logger)
|
||||
app.register_blueprint(createClientBlueprint(WireguardConfigurations, DashboardConfig, DashboardClients))
|
||||
|
||||
_, APP_PREFIX = DashboardConfig.GetConfig("Server", "app_prefix")
|
||||
|
|
@ -1220,6 +1226,80 @@ def API_download():
|
|||
else:
|
||||
return ResponseObject(False, "File does not exist")
|
||||
|
||||
# =====================================================
|
||||
# Health Monitor API Endpoints
|
||||
# =====================================================
|
||||
|
||||
@app.get(f'{APP_PREFIX}/api/health/status')
|
||||
def API_Health_Status():
|
||||
"""Get health monitoring status"""
|
||||
return ResponseObject(data=PeerHealthMonitorInstance.get_all_health())
|
||||
|
||||
@app.get(f'{APP_PREFIX}/api/health/peer/<public_key>')
|
||||
def API_Health_Peer(public_key: str):
|
||||
"""Get health info for specific peer"""
|
||||
health = PeerHealthMonitorInstance.get_peer_health(public_key)
|
||||
if health:
|
||||
return ResponseObject(data=health)
|
||||
return ResponseObject(False, "Peer not found in health data", status_code=404)
|
||||
|
||||
@app.post(f'{APP_PREFIX}/api/health/peer/<public_key>/ping')
|
||||
def API_Health_PingPeer(public_key: str):
|
||||
"""Ping specific peer immediately"""
|
||||
result = PeerHealthMonitorInstance.ping_peer_now(public_key)
|
||||
if result:
|
||||
return ResponseObject(data=result)
|
||||
return ResponseObject(False, "Peer not found", status_code=404)
|
||||
|
||||
@app.post(f'{APP_PREFIX}/api/health/cycle')
|
||||
def API_Health_ForceCycle():
|
||||
"""Force a health check cycle"""
|
||||
result = PeerHealthMonitorInstance.force_cycle()
|
||||
return ResponseObject(data=result)
|
||||
|
||||
@app.get(f'{APP_PREFIX}/api/health/stats')
|
||||
def API_Health_Stats():
|
||||
"""Get health monitoring statistics"""
|
||||
return ResponseObject(data=PeerHealthMonitorInstance.get_stats())
|
||||
|
||||
@app.post(f'{APP_PREFIX}/api/health/start')
|
||||
def API_Health_Start():
|
||||
"""Start health monitoring"""
|
||||
if PeerHealthMonitorInstance.start():
|
||||
return ResponseObject(True, "Health monitoring started")
|
||||
return ResponseObject(False, "Health monitoring already running")
|
||||
|
||||
@app.post(f'{APP_PREFIX}/api/health/stop')
|
||||
def API_Health_Stop():
|
||||
"""Stop health monitoring"""
|
||||
if PeerHealthMonitorInstance.stop():
|
||||
return ResponseObject(True, "Health monitoring stopped")
|
||||
return ResponseObject(False, "Health monitoring not running")
|
||||
|
||||
@app.get(f'{APP_PREFIX}/api/health/config/<interface>')
|
||||
def API_Health_GetInterfaceConfig(interface: str):
|
||||
"""Get health monitoring config for interface"""
|
||||
if interface not in WireguardConfigurations:
|
||||
return ResponseObject(False, "Interface not found", status_code=404)
|
||||
cfg = PeerHealthMonitorInstance.get_interface_config(interface)
|
||||
return ResponseObject(data={
|
||||
"interface": interface,
|
||||
"enabled": cfg.enabled,
|
||||
"ping_interval": cfg.ping_interval,
|
||||
"set_keepalive": cfg.set_keepalive,
|
||||
"keepalive_value": cfg.keepalive_value
|
||||
})
|
||||
|
||||
@app.post(f'{APP_PREFIX}/api/health/config/<interface>')
|
||||
def API_Health_SetInterfaceConfig(interface: str):
|
||||
"""Set health monitoring config for interface"""
|
||||
if interface not in WireguardConfigurations:
|
||||
return ResponseObject(False, "Interface not found", status_code=404)
|
||||
data = request.get_json()
|
||||
if PeerHealthMonitorInstance.set_interface_config(interface, data):
|
||||
return ResponseObject(True, "Configuration updated")
|
||||
return ResponseObject(False, "Failed to update configuration")
|
||||
|
||||
|
||||
'''
|
||||
Tools
|
||||
|
|
|
|||
|
|
@ -86,6 +86,13 @@ class DashboardConfig:
|
|||
},
|
||||
"WireGuardConfiguration": {
|
||||
"autostart": ""
|
||||
},
|
||||
"Health": {
|
||||
"enabled": "true",
|
||||
"ping_interval": "30",
|
||||
"ping_timeout": "2",
|
||||
"auto_keepalive": "true",
|
||||
"keepalive_value": "25"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,647 @@
|
|||
"""
|
||||
Peer Health Monitor for WGDashboard
|
||||
===================================
|
||||
Periodically pings VPN IP addresses of peers to:
|
||||
1. Trigger endpoint update on roaming (IP change)
|
||||
2. Collect availability statistics for peers
|
||||
|
||||
Even if the peer doesn't respond to ICMP (firewall), the ping
|
||||
causes the server to send a packet, and the client will respond
|
||||
with a WireGuard keepalive from its new IP -> endpoint update.
|
||||
|
||||
Status logic:
|
||||
- ONLINE: handshake < 3 min AND ping success
|
||||
- UNPINGABLE: handshake < 3 min AND ping failed (connected but firewall blocks ICMP)
|
||||
- RECENT: handshake 3-15 min (still ping to trigger endpoint update)
|
||||
- OFFLINE: handshake > 15 min (DO NOT ping - waste of resources)
|
||||
- UNKNOWN: no handshake data ever
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import ipaddress
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Any
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from icmplib import ping as icmp_ping
|
||||
import logging
|
||||
|
||||
|
||||
class PeerStatus(Enum):
|
||||
"""Peer status based on handshake time and ping result"""
|
||||
ONLINE = "online" # handshake < 3 min AND pingable
|
||||
UNPINGABLE = "unpingable" # handshake < 3 min BUT ping failed (firewall)
|
||||
RECENT = "recent" # handshake 3-15 min
|
||||
OFFLINE = "offline" # handshake > 15 min (don't ping these)
|
||||
UNKNOWN = "unknown" # no handshake data
|
||||
|
||||
|
||||
# Timeouts for status determination
|
||||
HANDSHAKE_ONLINE_TIMEOUT = timedelta(minutes=3)
|
||||
HANDSHAKE_RECENT_TIMEOUT = timedelta(minutes=15)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PeerHealthInfo:
|
||||
"""Health information for a single peer"""
|
||||
public_key: str
|
||||
vpn_ip: str
|
||||
interface: str
|
||||
name: str = "" # Peer name from configuration
|
||||
|
||||
# Ping statistics
|
||||
is_pingable: bool = False
|
||||
last_ping_time: Optional[datetime] = None
|
||||
last_ping_success: bool = False
|
||||
ping_rtt_ms: float = 0.0
|
||||
ping_success_count: int = 0
|
||||
ping_fail_count: int = 0
|
||||
|
||||
# Handshake status (from WireGuard)
|
||||
last_handshake: Optional[datetime] = None
|
||||
status: PeerStatus = PeerStatus.UNKNOWN
|
||||
|
||||
# Endpoint tracking
|
||||
last_endpoint: str = ""
|
||||
endpoint_changed: bool = False
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"public_key": self.public_key,
|
||||
"vpn_ip": self.vpn_ip,
|
||||
"interface": self.interface,
|
||||
"name": self.name,
|
||||
"is_pingable": self.is_pingable,
|
||||
"last_ping_time": self.last_ping_time.isoformat() if self.last_ping_time else None,
|
||||
"last_ping_success": self.last_ping_success,
|
||||
"ping_rtt_ms": round(self.ping_rtt_ms, 2),
|
||||
"ping_success_rate": self._ping_success_rate(),
|
||||
"status": self.status.value,
|
||||
"last_handshake": self.last_handshake.isoformat() if self.last_handshake else None,
|
||||
"last_endpoint": self.last_endpoint,
|
||||
"endpoint_changed": self.endpoint_changed
|
||||
}
|
||||
|
||||
def _ping_success_rate(self) -> float:
|
||||
total = self.ping_success_count + self.ping_fail_count
|
||||
if total == 0:
|
||||
return 0.0
|
||||
return round((self.ping_success_count / total) * 100, 1)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InterfaceHealthConfig:
|
||||
"""Configuration for health monitoring of an interface"""
|
||||
enabled: bool = True
|
||||
ping_interval: int = 30 # seconds
|
||||
set_keepalive: bool = True # automatically set PersistentKeepalive
|
||||
keepalive_value: int = 25
|
||||
|
||||
|
||||
class PeerHealthMonitor:
|
||||
"""
|
||||
Background service for monitoring WireGuard peers.
|
||||
|
||||
Functionality:
|
||||
- Periodic ping to VPN IP of all peers (except offline ones)
|
||||
- Automatic PersistentKeepalive setting on server
|
||||
- Collection of availability statistics
|
||||
- Detection of endpoint changes
|
||||
- Per-interface configuration with persistence
|
||||
|
||||
Status determination:
|
||||
- First check handshake age (from WireGuard)
|
||||
- Then ping only if peer might be reachable (handshake < 15 min)
|
||||
- ONLINE = recent handshake + ping success
|
||||
- UNPINGABLE = recent handshake + ping failed (firewall blocking ICMP)
|
||||
- OFFLINE = old handshake (don't waste resources pinging)
|
||||
"""
|
||||
|
||||
def __init__(self, dashboard_config, wireguard_configurations: dict, logger=None):
|
||||
self.dashboard_config = dashboard_config
|
||||
self.wg_configs = wireguard_configurations
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
# Health data for all peers (key = public_key)
|
||||
self._peer_health: Dict[str, PeerHealthInfo] = {}
|
||||
self._health_lock = threading.Lock()
|
||||
|
||||
# Per-interface configuration
|
||||
self._interface_config: Dict[str, InterfaceHealthConfig] = {}
|
||||
|
||||
# Thread control
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
# Statistics
|
||||
self._stats = {
|
||||
"total_pings": 0,
|
||||
"successful_pings": 0,
|
||||
"failed_pings": 0,
|
||||
"skipped_offline": 0,
|
||||
"endpoint_updates": 0,
|
||||
"last_cycle_time": None,
|
||||
"last_cycle_duration_ms": 0
|
||||
}
|
||||
|
||||
# Load saved configuration from INI file
|
||||
self._load_config_from_ini()
|
||||
|
||||
def _load_config_from_ini(self):
|
||||
"""Load per-interface configuration from wg-dashboard.ini"""
|
||||
try:
|
||||
# Get all sections that start with "Health:"
|
||||
config = self.dashboard_config._DashboardConfig__config
|
||||
for section in config.sections():
|
||||
if section.startswith("Health:"):
|
||||
interface_name = section[7:] # Remove "Health:" prefix
|
||||
cfg = InterfaceHealthConfig()
|
||||
|
||||
if config.has_option(section, "enabled"):
|
||||
cfg.enabled = config.get(section, "enabled").lower() == "true"
|
||||
if config.has_option(section, "ping_interval"):
|
||||
cfg.ping_interval = max(10, min(300, int(config.get(section, "ping_interval"))))
|
||||
if config.has_option(section, "set_keepalive"):
|
||||
cfg.set_keepalive = config.get(section, "set_keepalive").lower() == "true"
|
||||
if config.has_option(section, "keepalive_value"):
|
||||
cfg.keepalive_value = max(10, min(120, int(config.get(section, "keepalive_value"))))
|
||||
|
||||
self._interface_config[interface_name] = cfg
|
||||
self.logger.info(f"Loaded health config for {interface_name}: enabled={cfg.enabled}, interval={cfg.ping_interval}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading health config from INI: {e}")
|
||||
|
||||
def _save_interface_config_to_ini(self, interface: str):
|
||||
"""Save interface configuration to wg-dashboard.ini"""
|
||||
try:
|
||||
if interface not in self._interface_config:
|
||||
return False
|
||||
|
||||
cfg = self._interface_config[interface]
|
||||
section = f"Health:{interface}"
|
||||
|
||||
# Ensure section exists
|
||||
config = self.dashboard_config._DashboardConfig__config
|
||||
if not config.has_section(section):
|
||||
config.add_section(section)
|
||||
|
||||
# Set values
|
||||
config.set(section, "enabled", "true" if cfg.enabled else "false")
|
||||
config.set(section, "ping_interval", str(cfg.ping_interval))
|
||||
config.set(section, "set_keepalive", "true" if cfg.set_keepalive else "false")
|
||||
config.set(section, "keepalive_value", str(cfg.keepalive_value))
|
||||
|
||||
# Save to file
|
||||
return self.dashboard_config.SaveConfig()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving health config for {interface}: {e}")
|
||||
return False
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start health monitoring thread"""
|
||||
if self._running:
|
||||
self.logger.warning("PeerHealthMonitor already running")
|
||||
return False
|
||||
|
||||
self._running = True
|
||||
self._thread = threading.Thread(
|
||||
target=self._monitor_loop,
|
||||
daemon=True,
|
||||
name="PeerHealthMonitor"
|
||||
)
|
||||
self._thread.start()
|
||||
self.logger.info(f"PeerHealthMonitor started (PID: {threading.get_native_id()})")
|
||||
return True
|
||||
|
||||
def stop(self) -> bool:
|
||||
"""Stop health monitoring"""
|
||||
if not self._running:
|
||||
return False
|
||||
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5)
|
||||
self.logger.info("PeerHealthMonitor stopped")
|
||||
return True
|
||||
|
||||
def is_running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
def get_interface_config(self, interface: str) -> InterfaceHealthConfig:
|
||||
"""Return configuration for interface"""
|
||||
if interface not in self._interface_config:
|
||||
self._interface_config[interface] = InterfaceHealthConfig()
|
||||
# Save default config to INI
|
||||
self._save_interface_config_to_ini(interface)
|
||||
return self._interface_config[interface]
|
||||
|
||||
def set_interface_config(self, interface: str, config: dict) -> bool:
|
||||
"""Set configuration for interface and persist to INI"""
|
||||
try:
|
||||
if interface not in self._interface_config:
|
||||
self._interface_config[interface] = InterfaceHealthConfig()
|
||||
|
||||
cfg = self._interface_config[interface]
|
||||
if 'enabled' in config:
|
||||
cfg.enabled = bool(config['enabled'])
|
||||
if 'ping_interval' in config:
|
||||
cfg.ping_interval = max(10, min(300, int(config['ping_interval'])))
|
||||
if 'set_keepalive' in config:
|
||||
cfg.set_keepalive = bool(config['set_keepalive'])
|
||||
if 'keepalive_value' in config:
|
||||
cfg.keepalive_value = max(10, min(120, int(config['keepalive_value'])))
|
||||
|
||||
# Persist to INI file
|
||||
self._save_interface_config_to_ini(interface)
|
||||
self.logger.info(f"Updated health config for {interface}: enabled={cfg.enabled}, interval={cfg.ping_interval}")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error setting interface config: {e}")
|
||||
return False
|
||||
|
||||
def get_peer_health(self, public_key: str) -> Optional[dict]:
|
||||
"""Return health information for peer"""
|
||||
with self._health_lock:
|
||||
if public_key in self._peer_health:
|
||||
return self._peer_health[public_key].to_dict()
|
||||
return None
|
||||
|
||||
def get_all_health(self) -> dict:
|
||||
"""Return health information for all peers, filtering disabled/inactive interfaces"""
|
||||
result = {
|
||||
"peers": {},
|
||||
"interfaces": {},
|
||||
"stats": self._stats.copy(),
|
||||
"running": self._running
|
||||
}
|
||||
|
||||
# Only include peers from active interfaces
|
||||
with self._health_lock:
|
||||
for pk, health in self._peer_health.items():
|
||||
# Check if interface is active (running)
|
||||
iface_name = health.interface
|
||||
if iface_name in self.wg_configs:
|
||||
wg_config = self.wg_configs[iface_name]
|
||||
if wg_config.getStatus(): # Only include if interface is UP
|
||||
result["peers"][pk] = health.to_dict()
|
||||
|
||||
# Only include active interfaces in the config list
|
||||
for iface_name, wg_config in self.wg_configs.items():
|
||||
is_active = wg_config.getStatus()
|
||||
if is_active: # Only show active interfaces in Health UI
|
||||
cfg = self.get_interface_config(iface_name)
|
||||
result["interfaces"][iface_name] = {
|
||||
"enabled": cfg.enabled,
|
||||
"ping_interval": cfg.ping_interval,
|
||||
"set_keepalive": cfg.set_keepalive,
|
||||
"keepalive_value": cfg.keepalive_value,
|
||||
"interface_active": True
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""Return general statistics"""
|
||||
return self._stats.copy()
|
||||
|
||||
def ping_peer_now(self, public_key: str) -> Optional[dict]:
|
||||
"""Execute ping to specific peer immediately"""
|
||||
with self._health_lock:
|
||||
if public_key not in self._peer_health:
|
||||
return None
|
||||
health = self._peer_health[public_key]
|
||||
|
||||
result = self._do_ping(health.vpn_ip)
|
||||
self._update_peer_after_ping(health, result)
|
||||
|
||||
return health.to_dict()
|
||||
|
||||
def force_cycle(self) -> dict:
|
||||
"""Force one check cycle immediately"""
|
||||
return self._run_health_cycle()
|
||||
|
||||
def _monitor_loop(self):
|
||||
"""Main monitoring thread loop"""
|
||||
# Wait a bit on startup
|
||||
time.sleep(15)
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
self._run_health_cycle()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in health monitor cycle: {e}")
|
||||
|
||||
# Wait until next cycle (shortest interval from all interfaces)
|
||||
min_interval = 30
|
||||
for cfg in self._interface_config.values():
|
||||
if cfg.enabled and cfg.ping_interval < min_interval:
|
||||
min_interval = cfg.ping_interval
|
||||
|
||||
# Sleep in parts for faster shutdown
|
||||
for _ in range(min_interval):
|
||||
if not self._running:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
def _run_health_cycle(self) -> dict:
|
||||
"""Execute one health check cycle"""
|
||||
start_time = time.time()
|
||||
cycle_results = {
|
||||
"checked": 0,
|
||||
"online": 0,
|
||||
"unpingable": 0,
|
||||
"recent": 0,
|
||||
"offline": 0,
|
||||
"skipped": 0,
|
||||
"pingable": 0,
|
||||
"endpoint_changes": 0
|
||||
}
|
||||
|
||||
# Collect all peers from all interfaces
|
||||
peers_to_check = []
|
||||
|
||||
for iface_name, wg_config in self.wg_configs.items():
|
||||
# Skip interfaces that are not running (DOWN)
|
||||
if not wg_config.getStatus():
|
||||
continue
|
||||
|
||||
cfg = self.get_interface_config(iface_name)
|
||||
# Skip interfaces where health monitoring is disabled
|
||||
if not cfg.enabled:
|
||||
continue
|
||||
|
||||
# Set PersistentKeepalive if enabled
|
||||
if cfg.set_keepalive:
|
||||
self._ensure_keepalive(iface_name, wg_config, cfg.keepalive_value)
|
||||
|
||||
# Collect peers
|
||||
for peer in wg_config.Peers:
|
||||
vpn_ip = self._extract_vpn_ip(peer.allowed_ip)
|
||||
if vpn_ip:
|
||||
peers_to_check.append({
|
||||
"interface": iface_name,
|
||||
"public_key": peer.id,
|
||||
"name": getattr(peer, 'name', '') or '',
|
||||
"vpn_ip": vpn_ip,
|
||||
"endpoint": getattr(peer, 'endpoint', ''),
|
||||
"latest_handshake": getattr(peer, 'latest_handshake', None)
|
||||
})
|
||||
|
||||
# Check all peers
|
||||
for peer_info in peers_to_check:
|
||||
try:
|
||||
self._check_peer(peer_info, cycle_results)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error checking peer {peer_info['public_key']}: {e}")
|
||||
|
||||
# Update statistics
|
||||
duration_ms = (time.time() - start_time) * 1000
|
||||
self._stats["last_cycle_time"] = datetime.now().isoformat()
|
||||
self._stats["last_cycle_duration_ms"] = round(duration_ms, 2)
|
||||
|
||||
return cycle_results
|
||||
|
||||
def _check_peer(self, peer_info: dict, results: dict):
|
||||
"""Check one peer - FIRST handshake, THEN ping only if needed"""
|
||||
public_key = peer_info["public_key"]
|
||||
vpn_ip = peer_info["vpn_ip"]
|
||||
interface = peer_info["interface"]
|
||||
name = peer_info.get("name", "")
|
||||
|
||||
# Get or create health record
|
||||
with self._health_lock:
|
||||
if public_key not in self._peer_health:
|
||||
self._peer_health[public_key] = PeerHealthInfo(
|
||||
public_key=public_key,
|
||||
vpn_ip=vpn_ip,
|
||||
interface=interface,
|
||||
name=name
|
||||
)
|
||||
health = self._peer_health[public_key]
|
||||
|
||||
# Update VPN IP and name if changed
|
||||
health.vpn_ip = vpn_ip
|
||||
health.interface = interface
|
||||
health.name = name
|
||||
|
||||
# Check for endpoint change
|
||||
current_endpoint = peer_info.get("endpoint", "")
|
||||
if health.last_endpoint and health.last_endpoint != current_endpoint and current_endpoint != "(none)":
|
||||
health.endpoint_changed = True
|
||||
results["endpoint_changes"] += 1
|
||||
self._stats["endpoint_updates"] += 1
|
||||
self.logger.info(f"Endpoint changed for {name or public_key[:8]}...: {health.last_endpoint} -> {current_endpoint}")
|
||||
else:
|
||||
health.endpoint_changed = False
|
||||
health.last_endpoint = current_endpoint
|
||||
|
||||
# STEP 1: Parse handshake time and determine base status
|
||||
latest_handshake = peer_info.get("latest_handshake")
|
||||
handshake_age = self._parse_handshake_age(health, latest_handshake)
|
||||
|
||||
results["checked"] += 1
|
||||
|
||||
# STEP 2: Determine if we should ping based on handshake
|
||||
if handshake_age is None:
|
||||
# Never connected - unknown status, don't ping
|
||||
health.status = PeerStatus.UNKNOWN
|
||||
results["skipped"] += 1
|
||||
self._stats["skipped_offline"] += 1
|
||||
return
|
||||
|
||||
if handshake_age > HANDSHAKE_RECENT_TIMEOUT:
|
||||
# Offline - don't waste resources pinging
|
||||
health.status = PeerStatus.OFFLINE
|
||||
health.is_pingable = False
|
||||
results["offline"] += 1
|
||||
results["skipped"] += 1
|
||||
self._stats["skipped_offline"] += 1
|
||||
return
|
||||
|
||||
# STEP 3: Peer has recent handshake - ping to trigger endpoint update
|
||||
ping_result = self._do_ping(vpn_ip)
|
||||
self._update_peer_after_ping(health, ping_result)
|
||||
|
||||
# STEP 4: Determine final status based on handshake + ping
|
||||
if handshake_age < HANDSHAKE_ONLINE_TIMEOUT:
|
||||
# Recent handshake (< 3 min)
|
||||
if ping_result["success"]:
|
||||
health.status = PeerStatus.ONLINE
|
||||
health.is_pingable = True
|
||||
results["online"] += 1
|
||||
results["pingable"] += 1
|
||||
else:
|
||||
# Connected but firewall blocks ICMP
|
||||
health.status = PeerStatus.UNPINGABLE
|
||||
health.is_pingable = False
|
||||
results["unpingable"] += 1
|
||||
else:
|
||||
# Handshake 3-15 min ago - "recent" status
|
||||
health.status = PeerStatus.RECENT
|
||||
results["recent"] += 1
|
||||
if ping_result["success"]:
|
||||
health.is_pingable = True
|
||||
results["pingable"] += 1
|
||||
|
||||
def _parse_handshake_age(self, health: PeerHealthInfo, latest_handshake) -> Optional[timedelta]:
|
||||
"""Parse handshake time and return age. Returns None if no valid handshake.
|
||||
|
||||
Handles formats:
|
||||
- "0:00:54" (H:MM:SS)
|
||||
- "1 day, 20:38:48" (X day(s), H:MM:SS)
|
||||
- "No Handshake" / "N/A" -> None
|
||||
- Unix timestamp (int/float)
|
||||
- ISO format datetime string
|
||||
"""
|
||||
if not latest_handshake:
|
||||
return None
|
||||
|
||||
# Handle string formats
|
||||
if isinstance(latest_handshake, str):
|
||||
# Skip invalid values
|
||||
if latest_handshake in ("No Handshake", "N/A", "", "0"):
|
||||
return None
|
||||
|
||||
try:
|
||||
# Try to parse timedelta string format from WGDashboard
|
||||
# Format: "H:MM:SS" or "X day(s), H:MM:SS"
|
||||
days = 0
|
||||
time_part = latest_handshake
|
||||
|
||||
if "day" in latest_handshake:
|
||||
# "1 day, 20:38:48" or "2 days, 1:23:45"
|
||||
parts = latest_handshake.split(", ")
|
||||
day_part = parts[0]
|
||||
days = int(day_part.split()[0])
|
||||
time_part = parts[1] if len(parts) > 1 else "0:0:0"
|
||||
|
||||
# Parse time part "H:MM:SS" or "HH:MM:SS"
|
||||
time_parts = time_part.split(":")
|
||||
if len(time_parts) == 3:
|
||||
hours = int(time_parts[0])
|
||||
minutes = int(time_parts[1])
|
||||
seconds = int(time_parts[2])
|
||||
|
||||
age = timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
|
||||
health.last_handshake = datetime.now() - age
|
||||
return age
|
||||
|
||||
except (ValueError, IndexError) as e:
|
||||
self.logger.debug(f"Could not parse timedelta string '{latest_handshake}': {e}")
|
||||
|
||||
# Try ISO format
|
||||
try:
|
||||
health.last_handshake = datetime.fromisoformat(latest_handshake)
|
||||
return datetime.now() - health.last_handshake
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Try as Unix timestamp string
|
||||
try:
|
||||
ts = float(latest_handshake)
|
||||
if ts > 0:
|
||||
health.last_handshake = datetime.fromtimestamp(ts)
|
||||
return datetime.now() - health.last_handshake
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Handle numeric timestamps
|
||||
elif isinstance(latest_handshake, (int, float)):
|
||||
if latest_handshake > 0:
|
||||
health.last_handshake = datetime.fromtimestamp(latest_handshake)
|
||||
return datetime.now() - health.last_handshake
|
||||
|
||||
# Handle datetime objects
|
||||
elif isinstance(latest_handshake, datetime):
|
||||
health.last_handshake = latest_handshake
|
||||
return datetime.now() - health.last_handshake
|
||||
|
||||
return None
|
||||
|
||||
def _do_ping(self, ip: str, count: int = 1, timeout: int = 2) -> dict:
|
||||
"""Execute ICMP ping"""
|
||||
try:
|
||||
result = icmp_ping(ip, count=count, timeout=timeout, privileged=True)
|
||||
self._stats["total_pings"] += 1
|
||||
|
||||
if result.is_alive:
|
||||
self._stats["successful_pings"] += 1
|
||||
return {
|
||||
"success": True,
|
||||
"rtt_ms": result.avg_rtt,
|
||||
"packets_sent": result.packets_sent,
|
||||
"packets_received": result.packets_received
|
||||
}
|
||||
else:
|
||||
self._stats["failed_pings"] += 1
|
||||
return {
|
||||
"success": False,
|
||||
"rtt_ms": 0,
|
||||
"packets_sent": result.packets_sent,
|
||||
"packets_received": 0
|
||||
}
|
||||
except Exception as e:
|
||||
self._stats["failed_pings"] += 1
|
||||
self.logger.debug(f"Ping failed for {ip}: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"rtt_ms": 0,
|
||||
"packets_sent": 1,
|
||||
"packets_received": 0,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def _update_peer_after_ping(self, health: PeerHealthInfo, ping_result: dict):
|
||||
"""Update health information after ping"""
|
||||
health.last_ping_time = datetime.now()
|
||||
health.last_ping_success = ping_result["success"]
|
||||
health.ping_rtt_ms = ping_result.get("rtt_ms", 0)
|
||||
|
||||
if ping_result["success"]:
|
||||
health.ping_success_count += 1
|
||||
else:
|
||||
health.ping_fail_count += 1
|
||||
|
||||
def _extract_vpn_ip(self, allowed_ips: str) -> Optional[str]:
|
||||
"""Extract VPN IP from AllowedIPs"""
|
||||
if not allowed_ips:
|
||||
return None
|
||||
|
||||
for ip_str in allowed_ips.replace(" ", "").split(","):
|
||||
try:
|
||||
network = ipaddress.ip_network(ip_str, strict=False)
|
||||
hosts = list(network.hosts())
|
||||
if len(hosts) == 1:
|
||||
return str(hosts[0])
|
||||
elif network.prefixlen == 32:
|
||||
return str(network.network_address)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def _ensure_keepalive(self, interface: str, wg_config, keepalive: int):
|
||||
"""Set PersistentKeepalive on server for all peers"""
|
||||
try:
|
||||
# Determine if AWG or WG
|
||||
wg_cmd = "awg" if hasattr(wg_config, 'Protocol') and wg_config.Protocol == 'awg' else "wg"
|
||||
|
||||
for peer in wg_config.Peers:
|
||||
# Check current keepalive
|
||||
current_keepalive = getattr(peer, 'persistent_keepalive', 0)
|
||||
if current_keepalive != keepalive:
|
||||
cmd = [
|
||||
wg_cmd, "set", interface,
|
||||
"peer", peer.id,
|
||||
"persistent-keepalive", str(keepalive)
|
||||
]
|
||||
subprocess.run(cmd, capture_output=True, timeout=5)
|
||||
self.logger.debug(f"Set keepalive={keepalive} for {peer.id[:8]}... on {interface}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error setting keepalive on {interface}: {e}")
|
||||
|
||||
def to_json(self) -> dict:
|
||||
"""For serialization in JSON response"""
|
||||
return self.get_all_health()
|
||||
|
|
@ -0,0 +1,461 @@
|
|||
<template>
|
||||
<div class="peerHealthMonitor">
|
||||
<!-- Header -->
|
||||
<div class="d-flex align-items-center mb-4">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-heart-pulse me-2"></i>
|
||||
<LocaleText t="Peer Health Monitor"></LocaleText>
|
||||
</h5>
|
||||
<span
|
||||
class="badge ms-2"
|
||||
:class="healthData.running ? 'bg-success' : 'bg-secondary'"
|
||||
>
|
||||
<LocaleText :t="healthData.running ? 'Running' : 'Stopped'"></LocaleText>
|
||||
</span>
|
||||
<div class="ms-auto">
|
||||
<button
|
||||
class="btn btn-sm me-2"
|
||||
:class="healthData.running ? 'btn-outline-danger' : 'btn-outline-success'"
|
||||
@click="toggleMonitor"
|
||||
:disabled="loading"
|
||||
>
|
||||
<i class="bi" :class="healthData.running ? 'bi-stop-fill' : 'bi-play-fill'"></i>
|
||||
<LocaleText :t="healthData.running ? 'Stop' : 'Start'"></LocaleText>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
@click="forceCycle"
|
||||
:disabled="loading || !healthData.running"
|
||||
>
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
<LocaleText t="Force Check"></LocaleText>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Overview -->
|
||||
<div class="row mb-4" v-if="healthData.stats">
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-dark">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="mb-0">{{ healthData.stats.total_pings || 0 }}</h3>
|
||||
<small class="text-muted"><LocaleText t="Total Pings"></LocaleText></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-dark">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="mb-0 text-success">{{ healthData.stats.successful_pings || 0 }}</h3>
|
||||
<small class="text-muted"><LocaleText t="Successful"></LocaleText></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-dark">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="mb-0 text-danger">{{ healthData.stats.failed_pings || 0 }}</h3>
|
||||
<small class="text-muted"><LocaleText t="Failed"></LocaleText></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-dark">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="mb-0 text-info">{{ healthData.stats.endpoint_updates || 0 }}</h3>
|
||||
<small class="text-muted"><LocaleText t="Endpoint Updates"></LocaleText></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Interface Configuration -->
|
||||
<div class="card bg-dark mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-gear me-2"></i>
|
||||
<LocaleText t="Interface Configuration"></LocaleText>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row" v-if="Object.keys(healthData.interfaces || {}).length > 0">
|
||||
<div
|
||||
class="col-md-6 mb-3"
|
||||
v-for="(config, iface) in healthData.interfaces"
|
||||
:key="iface"
|
||||
>
|
||||
<div class="card bg-secondary bg-opacity-25">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h6 class="mb-0">{{ iface }}</h6>
|
||||
<div class="form-check form-switch ms-auto">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
:checked="config.enabled"
|
||||
@change="toggleInterface(iface, $event.target.checked)"
|
||||
>
|
||||
<label class="form-check-label">
|
||||
<LocaleText :t="config.enabled ? 'Enabled' : 'Disabled'"></LocaleText>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small"><LocaleText t="Ping Interval (sec)"></LocaleText></label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control form-control-sm"
|
||||
:value="config.ping_interval"
|
||||
@change="updateInterfaceConfig(iface, 'ping_interval', $event.target.value)"
|
||||
min="10" max="300"
|
||||
>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small"><LocaleText t="Keepalive (sec)"></LocaleText></label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control form-control-sm"
|
||||
:value="config.keepalive_value"
|
||||
@change="updateInterfaceConfig(iface, 'keepalive_value', $event.target.value)"
|
||||
min="10" max="120"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check mt-2">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
:checked="config.set_keepalive"
|
||||
@change="updateInterfaceConfig(iface, 'set_keepalive', $event.target.checked)"
|
||||
>
|
||||
<label class="form-check-label small">
|
||||
<LocaleText t="Auto-set PersistentKeepalive on server"></LocaleText>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-muted text-center py-3">
|
||||
<LocaleText t="No interfaces configured. Start monitoring to detect interfaces."></LocaleText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Peers Health Table -->
|
||||
<div class="card bg-dark">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<i class="bi bi-people me-2"></i>
|
||||
<LocaleText t="Peers Health Status"></LocaleText>
|
||||
<span class="badge bg-success ms-2">
|
||||
{{ activePeersCount }} active
|
||||
</span>
|
||||
<span
|
||||
class="badge bg-secondary ms-2"
|
||||
v-if="offlinePeersCount > 0"
|
||||
style="cursor: pointer;"
|
||||
@click="showOfflinePeers = !showOfflinePeers"
|
||||
:title="showOfflinePeers ? 'Click to hide offline peers' : 'Click to show offline peers'"
|
||||
>
|
||||
<i class="bi" :class="showOfflinePeers ? 'bi-eye-slash' : 'bi-eye'"></i>
|
||||
{{ offlinePeersCount }} offline
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><LocaleText t="Status"></LocaleText></th>
|
||||
<th><LocaleText t="Interface"></LocaleText></th>
|
||||
<th><LocaleText t="Name"></LocaleText></th>
|
||||
<th><LocaleText t="VPN IP"></LocaleText></th>
|
||||
<th><LocaleText t="Pingable"></LocaleText></th>
|
||||
<th>RTT</th>
|
||||
<th><LocaleText t="Success Rate"></LocaleText></th>
|
||||
<th><LocaleText t="Last Ping"></LocaleText></th>
|
||||
<th><LocaleText t="Endpoint"></LocaleText></th>
|
||||
<th><LocaleText t="Actions"></LocaleText></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(peer, pk) in sortedPeers" :key="pk">
|
||||
<td>
|
||||
<span class="badge" :class="getStatusBadgeClass(peer.status)">
|
||||
{{ getStatusLabel(peer.status) }}
|
||||
</span>
|
||||
<i
|
||||
v-if="peer.endpoint_changed"
|
||||
class="bi bi-arrow-repeat text-warning ms-1"
|
||||
title="Endpoint recently changed"
|
||||
></i>
|
||||
</td>
|
||||
<td>
|
||||
<code class="small">{{ peer.interface }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="peer.name" class="text-info">{{ peer.name }}</span>
|
||||
<span v-else class="text-muted small">{{ pk.substring(0, 8) }}...</span>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ peer.vpn_ip }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<i
|
||||
class="bi"
|
||||
:class="getPingableIcon(peer)"
|
||||
></i>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="peer.last_ping_success">
|
||||
{{ peer.ping_rtt_ms }} ms
|
||||
</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="progress" style="height: 20px; width: 80px;" v-if="peer.status !== 'offline' && peer.status !== 'unknown'">
|
||||
<div
|
||||
class="progress-bar"
|
||||
:class="getSuccessRateClass(peer.ping_success_rate)"
|
||||
:style="{ width: peer.ping_success_rate + '%' }"
|
||||
>
|
||||
{{ peer.ping_success_rate }}%
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">
|
||||
{{ formatTime(peer.last_ping_time) }}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<code class="small text-truncate d-inline-block" style="max-width: 150px;">
|
||||
{{ peer.last_endpoint || '-' }}
|
||||
</code>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
@click="pingPeer(pk)"
|
||||
:disabled="loading || peer.status === 'offline'"
|
||||
title="Ping now"
|
||||
>
|
||||
<i class="bi bi-broadcast"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="Object.keys(healthData.peers || {}).length === 0">
|
||||
<td colspan="10" class="text-center text-muted py-4">
|
||||
<LocaleText :t="healthData.running ? 'Waiting for first check cycle...' : 'No peer health data available. Start monitoring to collect data.'"></LocaleText>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Legend -->
|
||||
<div class="mt-3 small text-muted">
|
||||
<span class="badge bg-success me-2">online</span> Connected, ping OK
|
||||
<span class="badge bg-info text-dark me-2 ms-3">unpingable</span> Connected, firewall blocks ping
|
||||
<span class="badge bg-warning text-dark me-2 ms-3">recent</span> Handshake 3-15 min ago
|
||||
<span v-if="showOfflinePeers" class="badge bg-danger me-2 ms-3">offline</span>
|
||||
<span v-if="showOfflinePeers"> Not connected (not pinged)</span>
|
||||
</div>
|
||||
|
||||
<!-- Last Cycle Info -->
|
||||
<div class="text-muted small mt-3 text-end" v-if="healthData.stats?.last_cycle_time">
|
||||
<LocaleText t="Last check"></LocaleText>: {{ formatDateTime(healthData.stats.last_cycle_time) }}
|
||||
({{ healthData.stats.last_cycle_duration_ms }} ms)
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { fetchGet, fetchPost } from '@/utilities/fetch'
|
||||
import LocaleText from '@/components/text/localeText.vue'
|
||||
|
||||
const healthData = ref({
|
||||
running: false,
|
||||
peers: {},
|
||||
interfaces: {},
|
||||
stats: {}
|
||||
})
|
||||
const loading = ref(false)
|
||||
const showOfflinePeers = ref(false)
|
||||
let refreshInterval = null
|
||||
|
||||
// Filter and sort peers: hide offline/unknown by default, online first
|
||||
const sortedPeers = computed(() => {
|
||||
const statusOrder = {
|
||||
'online': 0,
|
||||
'unpingable': 1,
|
||||
'recent': 2,
|
||||
'offline': 3,
|
||||
'unknown': 4
|
||||
}
|
||||
|
||||
let entries = Object.entries(healthData.value.peers || {})
|
||||
|
||||
// Filter out offline and unknown peers unless showOfflinePeers is true
|
||||
if (!showOfflinePeers.value) {
|
||||
entries = entries.filter(([_, peer]) =>
|
||||
peer.status !== 'offline' && peer.status !== 'unknown'
|
||||
)
|
||||
}
|
||||
|
||||
entries.sort((a, b) => {
|
||||
const orderA = statusOrder[a[1].status] ?? 5
|
||||
const orderB = statusOrder[b[1].status] ?? 5
|
||||
return orderA - orderB
|
||||
})
|
||||
|
||||
return Object.fromEntries(entries)
|
||||
})
|
||||
|
||||
// Count of hidden offline peers
|
||||
const offlinePeersCount = computed(() => {
|
||||
const entries = Object.entries(healthData.value.peers || {})
|
||||
return entries.filter(([_, peer]) =>
|
||||
peer.status === 'offline' || peer.status === 'unknown'
|
||||
).length
|
||||
})
|
||||
|
||||
// Count of active (shown) peers
|
||||
const activePeersCount = computed(() => {
|
||||
return Object.keys(sortedPeers.value).length
|
||||
})
|
||||
|
||||
// Fetch health status
|
||||
function fetchHealthStatus() {
|
||||
fetchGet('/api/health/status', {}, (response) => {
|
||||
if (response && response.status) {
|
||||
healthData.value = response.data
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Toggle monitor on/off
|
||||
function toggleMonitor() {
|
||||
loading.value = true
|
||||
const endpoint = healthData.value.running ? '/api/health/stop' : '/api/health/start'
|
||||
fetchPost(endpoint, {}, () => {
|
||||
fetchHealthStatus()
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// Force a check cycle
|
||||
function forceCycle() {
|
||||
loading.value = true
|
||||
fetchPost('/api/health/cycle', {}, () => {
|
||||
fetchHealthStatus()
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// Ping specific peer
|
||||
function pingPeer(publicKey) {
|
||||
loading.value = true
|
||||
fetchPost(`/api/health/peer/${encodeURIComponent(publicKey)}/ping`, {}, () => {
|
||||
fetchHealthStatus()
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// Toggle interface monitoring
|
||||
function toggleInterface(iface, enabled) {
|
||||
updateInterfaceConfig(iface, 'enabled', enabled)
|
||||
}
|
||||
|
||||
// Update interface configuration
|
||||
function updateInterfaceConfig(iface, key, value) {
|
||||
fetchPost(`/api/health/config/${iface}`, { [key]: value }, () => {
|
||||
fetchHealthStatus()
|
||||
})
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function getStatusBadgeClass(status) {
|
||||
const classes = {
|
||||
'online': 'bg-success',
|
||||
'unpingable': 'bg-info text-dark',
|
||||
'recent': 'bg-warning text-dark',
|
||||
'offline': 'bg-danger',
|
||||
'unknown': 'bg-secondary'
|
||||
}
|
||||
return classes[status] || 'bg-secondary'
|
||||
}
|
||||
|
||||
function getStatusLabel(status) {
|
||||
const labels = {
|
||||
'online': 'online',
|
||||
'unpingable': 'unpingable',
|
||||
'recent': 'recent',
|
||||
'offline': 'offline',
|
||||
'unknown': 'unknown'
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
function getPingableIcon(peer) {
|
||||
if (peer.status === 'offline' || peer.status === 'unknown') {
|
||||
return 'bi-dash text-muted'
|
||||
}
|
||||
if (peer.status === 'unpingable') {
|
||||
return 'bi-shield-x text-info'
|
||||
}
|
||||
return peer.is_pingable ? 'bi-check-circle text-success' : 'bi-x-circle text-danger'
|
||||
}
|
||||
|
||||
function getSuccessRateClass(rate) {
|
||||
if (rate >= 80) return 'bg-success'
|
||||
if (rate >= 50) return 'bg-warning'
|
||||
return 'bg-danger'
|
||||
}
|
||||
|
||||
function formatTime(isoTime) {
|
||||
if (!isoTime) return '-'
|
||||
const date = new Date(isoTime)
|
||||
return date.toLocaleTimeString()
|
||||
}
|
||||
|
||||
function formatDateTime(isoTime) {
|
||||
if (!isoTime) return '-'
|
||||
const date = new Date(isoTime)
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchHealthStatus()
|
||||
// Refresh every 10 seconds
|
||||
refreshInterval = setInterval(fetchHealthStatus, 10000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.peerHealthMonitor {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.table code {
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.progress {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -65,6 +65,14 @@ const router = createRouter({
|
|||
meta: {
|
||||
title: "WireGuard Configuration Settings"
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Peer Health Monitor",
|
||||
path: "health_monitor",
|
||||
component: () => import("@/components/settingsComponent/peerHealthMonitor.vue"),
|
||||
meta: {
|
||||
title: "Peer Health Monitor"
|
||||
}
|
||||
}
|
||||
],
|
||||
meta: {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ export default {
|
|||
{
|
||||
id: "wireguard_settings",
|
||||
title: "WireGuard Configuration Settings"
|
||||
},
|
||||
{
|
||||
id: "health_monitor",
|
||||
title: "Peer Health Monitor"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -438,5 +438,30 @@
|
|||
"or": "или",
|
||||
"or click the button below to download the ": "или щракнете бутона по-долу, за да изтеглите ",
|
||||
"then": "тогава",
|
||||
"to add your server": "за да добавите сървъра си"
|
||||
}
|
||||
"to add your server": "за да добавите сървъра си",
|
||||
"Peer Health Monitor": "Мониторинг на пиъри",
|
||||
"Peers Health Status": "Здравен статус на пиъри",
|
||||
"Interface Configuration": "Конфигурация на интерфейси",
|
||||
"Force Check": "Принудителна проверка",
|
||||
"Total Pings": "Общо пингове",
|
||||
"Successful": "Успешни",
|
||||
"Failed": "Неуспешни",
|
||||
"Endpoint Updates": "Обновени крайни точки",
|
||||
"Ping Interval (sec)": "Интервал на пинг (сек)",
|
||||
"Keepalive (sec)": "Keepalive (сек)",
|
||||
"Auto-set PersistentKeepalive on server": "Автоматично задаване на PersistentKeepalive на сървъра",
|
||||
"No interfaces configured. Start monitoring to detect interfaces.": "Няма конфигурирани интерфейси. Стартирайте мониторинга за откриване.",
|
||||
"Waiting for first check cycle...": "Изчакване на първата проверка...",
|
||||
"No peer health data available. Start monitoring to collect data.": "Няма данни за здравето на пиърите. Стартирайте мониторинга.",
|
||||
"Last check": "Последна проверка",
|
||||
"Last Ping": "Последен пинг",
|
||||
"VPN IP": "VPN IP",
|
||||
"Pingable": "Достъпен",
|
||||
"Success Rate": "Успеваемост",
|
||||
"Endpoint": "Крайна точка",
|
||||
"Interface": "Интерфейс",
|
||||
"Start": "Старт",
|
||||
"Stop": "Стоп",
|
||||
"Running": "Работи",
|
||||
"Stopped": "Спрян"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -438,5 +438,30 @@
|
|||
"Assign successfully!": "",
|
||||
"Sessions": "",
|
||||
"Data": "",
|
||||
"Back": ""
|
||||
}
|
||||
"Back": "",
|
||||
"Peer Health Monitor": "",
|
||||
"Peers Health Status": "",
|
||||
"Interface Configuration": "",
|
||||
"Force Check": "",
|
||||
"Total Pings": "",
|
||||
"Successful": "",
|
||||
"Failed": "",
|
||||
"Endpoint Updates": "",
|
||||
"Ping Interval (sec)": "",
|
||||
"Keepalive (sec)": "",
|
||||
"Auto-set PersistentKeepalive on server": "",
|
||||
"No interfaces configured. Start monitoring to detect interfaces.": "",
|
||||
"Waiting for first check cycle...": "",
|
||||
"No peer health data available. Start monitoring to collect data.": "",
|
||||
"Last check": "",
|
||||
"Last Ping": "",
|
||||
"VPN IP": "",
|
||||
"Pingable": "",
|
||||
"Success Rate": "",
|
||||
"Endpoint": "",
|
||||
"Interface": "",
|
||||
"Start": "",
|
||||
"Stop": "",
|
||||
"Running": "",
|
||||
"Stopped": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -365,5 +365,30 @@
|
|||
"or": "или",
|
||||
"or click the button below to download the ": "Так же, вы можете скачать ",
|
||||
"then": "тогда",
|
||||
"to add your server": "чтобы добавить свой сервер"
|
||||
}
|
||||
"to add your server": "чтобы добавить свой сервер",
|
||||
"Peer Health Monitor": "Мониторинг пиров",
|
||||
"Peers Health Status": "Состояние здоровья пиров",
|
||||
"Interface Configuration": "Конфигурация интерфейсов",
|
||||
"Force Check": "Принудительная проверка",
|
||||
"Total Pings": "Всего пингов",
|
||||
"Successful": "Успешных",
|
||||
"Failed": "Неудачных",
|
||||
"Endpoint Updates": "Обновления конечных точек",
|
||||
"Ping Interval (sec)": "Интервал пинга (сек)",
|
||||
"Keepalive (sec)": "Keepalive (сек)",
|
||||
"Auto-set PersistentKeepalive on server": "Автоматическая установка PersistentKeepalive на сервере",
|
||||
"No interfaces configured. Start monitoring to detect interfaces.": "Интерфейсы не настроены. Запустите мониторинг для обнаружения.",
|
||||
"Waiting for first check cycle...": "Ожидание первой проверки...",
|
||||
"No peer health data available. Start monitoring to collect data.": "Нет данных о состоянии пиров. Запустите мониторинг.",
|
||||
"Last check": "Последняя проверка",
|
||||
"Last Ping": "Последний пинг",
|
||||
"VPN IP": "VPN IP",
|
||||
"Pingable": "Доступен",
|
||||
"Success Rate": "Успешность",
|
||||
"Endpoint": "Крайна точка",
|
||||
"Interface": "Интерфейс",
|
||||
"Start": "Старт",
|
||||
"Stop": "Стоп",
|
||||
"Running": "Работает",
|
||||
"Stopped": "Остановлен"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue