Update notification service

This commit is contained in:
MacRimi
2026-03-06 18:44:27 +01:00
parent 591099e42b
commit 46fa89233b
6 changed files with 759 additions and 64 deletions

View File

@@ -0,0 +1,267 @@
"use client"
import { useState, useEffect } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Activity, TrendingDown, TrendingUp, Minus } from "lucide-react"
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
import { useIsMobile } from "../hooks/use-mobile"
import { fetchApi } from "@/lib/api-config"
const TIMEFRAME_OPTIONS = [
{ value: "hour", label: "1 Hour" },
{ value: "6hour", label: "6 Hours" },
{ value: "day", label: "24 Hours" },
{ value: "3day", label: "3 Days" },
{ value: "week", label: "7 Days" },
]
const TARGET_OPTIONS = [
{ value: "gateway", label: "Gateway (Router)" },
{ value: "cloudflare", label: "Cloudflare (1.1.1.1)" },
{ value: "google", label: "Google DNS (8.8.8.8)" },
]
interface LatencyHistoryPoint {
timestamp: number
value: number
min?: number
max?: number
packet_loss?: number
}
interface LatencyStats {
min: number
max: number
avg: number
current: number
}
interface LatencyDetailModalProps {
open: boolean
onOpenChange: (open: boolean) => void
currentLatency?: number
}
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
const entry = payload[0]
const packetLoss = entry?.payload?.packet_loss
return (
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
<p className="text-sm font-semibold text-white mb-2">{label}</p>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-blue-500" />
<span className="text-xs text-gray-300 min-w-[60px]">Latency:</span>
<span className="text-sm font-semibold text-white">{entry.value} ms</span>
</div>
{packetLoss !== undefined && packetLoss > 0 && (
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-red-500" />
<span className="text-xs text-gray-300 min-w-[60px]">Pkt Loss:</span>
<span className="text-sm font-semibold text-red-400">{packetLoss}%</span>
</div>
)}
</div>
</div>
)
}
return null
}
const getStatusColor = (latency: number) => {
if (latency >= 200) return "#ef4444"
if (latency >= 100) return "#f59e0b"
return "#22c55e"
}
const getStatusInfo = (latency: number) => {
if (latency === 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
if (latency < 50) return { status: "Excellent", color: "bg-green-500/10 text-green-500 border-green-500/20" }
if (latency < 100) return { status: "Good", color: "bg-green-500/10 text-green-500 border-green-500/20" }
if (latency < 200) return { status: "Fair", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
return { status: "Poor", color: "bg-red-500/10 text-red-500 border-red-500/20" }
}
export function LatencyDetailModal({ open, onOpenChange, currentLatency }: LatencyDetailModalProps) {
const [timeframe, setTimeframe] = useState("hour")
const [target, setTarget] = useState("gateway")
const [data, setData] = useState<LatencyHistoryPoint[]>([])
const [stats, setStats] = useState<LatencyStats>({ min: 0, max: 0, avg: 0, current: 0 })
const [loading, setLoading] = useState(true)
const isMobile = useIsMobile()
useEffect(() => {
if (open) {
fetchHistory()
}
}, [open, timeframe, target])
const fetchHistory = async () => {
setLoading(true)
try {
const result = await fetchApi<{ data: LatencyHistoryPoint[]; stats: LatencyStats; target: string }>(
`/api/network/latency/history?target=${target}&timeframe=${timeframe}`
)
if (result && result.data) {
setData(result.data)
setStats(result.stats)
}
} catch (err) {
// Silently fail - will show empty state
} finally {
setLoading(false)
}
}
const formatTime = (timestamp: number) => {
const date = new Date(timestamp * 1000)
if (timeframe === "hour" || timeframe === "6hour") {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
} else if (timeframe === "day") {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
} else {
return date.toLocaleDateString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })
}
}
const chartData = data.map((d) => ({
...d,
time: formatTime(d.timestamp),
}))
const currentLat = currentLatency && currentLatency > 0 ? Math.round(currentLatency * 10) / 10 : stats.current
const currentStatus = getStatusInfo(currentLat)
const chartColor = getStatusColor(currentLat)
const values = data.map((d) => d.value).filter(v => v !== null && v !== undefined)
const yMin = 0
const yMax = values.length > 0 ? Math.ceil(Math.max(...values) * 1.2) : 200
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl bg-card border-border px-3 sm:px-6">
<DialogHeader>
<div className="flex items-center justify-between pr-6 gap-2 flex-wrap">
<DialogTitle className="text-foreground flex items-center gap-2">
<Activity className="h-5 w-5" />
Network Latency
</DialogTitle>
<div className="flex items-center gap-2">
<Select value={target} onValueChange={setTarget}>
<SelectTrigger className="w-[150px] bg-card border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TARGET_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={timeframe} onValueChange={setTimeframe}>
<SelectTrigger className="w-[110px] bg-card border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIMEFRAME_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</DialogHeader>
{/* Stats bar */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
<div className={`rounded-lg p-3 text-center ${currentStatus.color}`}>
<div className="text-xs opacity-80 mb-1">Current</div>
<div className="text-lg font-bold">{currentLat} ms</div>
</div>
<div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<TrendingDown className="h-3 w-3" /> Min
</div>
<div className="text-lg font-bold text-green-500">{stats.min} ms</div>
</div>
<div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<Minus className="h-3 w-3" /> Avg
</div>
<div className="text-lg font-bold text-foreground">{stats.avg} ms</div>
</div>
<div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<TrendingUp className="h-3 w-3" /> Max
</div>
<div className="text-lg font-bold text-red-500">{stats.max} ms</div>
</div>
</div>
{/* Chart */}
<div className="h-[300px] lg:h-[350px]">
{loading ? (
<div className="h-full flex items-center justify-center">
<div className="space-y-3 w-full animate-pulse">
<div className="h-4 bg-muted rounded w-1/4 mx-auto" />
<div className="h-[250px] bg-muted/50 rounded" />
</div>
</div>
) : chartData.length === 0 ? (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Activity className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No latency data available for this period</p>
<p className="text-sm mt-1">Data is collected every 60 seconds</p>
</div>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="latencyGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={chartColor} stopOpacity={0.3} />
<stop offset="100%" stopColor={chartColor} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
<XAxis
dataKey="time"
stroke="currentColor"
className="text-foreground"
tick={{ fill: "currentColor", fontSize: isMobile ? 10 : 12 }}
interval="preserveStartEnd"
minTickGap={isMobile ? 40 : 60}
/>
<YAxis
domain={[yMin, yMax]}
stroke="currentColor"
className="text-foreground"
tick={{ fill: "currentColor", fontSize: isMobile ? 10 : 12 }}
tickFormatter={(v) => `${v}ms`}
width={isMobile ? 45 : 50}
/>
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="value"
name="Latency"
stroke={chartColor}
strokeWidth={2}
fill="url(#latencyGradient)"
dot={false}
activeDot={{ r: 4, fill: chartColor, stroke: "#fff", strokeWidth: 2 }}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -4,12 +4,14 @@ import { useEffect, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
import { Wifi, Activity, Network, Router, AlertCircle, Zap } from 'lucide-react'
import { Wifi, Activity, Network, Router, AlertCircle, Zap, Timer } from 'lucide-react'
import useSWR from "swr"
import { NetworkTrafficChart } from "./network-traffic-chart"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { fetchApi } from "../lib/api-config"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { LatencyDetailModal } from "./latency-detail-modal"
import { LineChart, Line, ResponsiveContainer, YAxis } from "recharts"
interface NetworkData {
interfaces: NetworkInterface[]
@@ -150,8 +152,19 @@ export function NetworkMetrics() {
const [modalTimeframe, setModalTimeframe] = useState<"hour" | "day" | "week" | "month" | "year">("day")
const [networkTotals, setNetworkTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 })
const [interfaceTotals, setInterfaceTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 })
const [latencyModalOpen, setLatencyModalOpen] = useState(false)
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(() => getNetworkUnit())
// Latency history for sparkline (last hour)
const { data: latencyData } = useSWR<{
data: Array<{ timestamp: number; value: number }>
stats: { min: number; max: number; avg: number; current: number }
target: string
}>("/api/network/latency/history?target=gateway&timeframe=hour",
(url: string) => fetchApi(url),
{ refreshInterval: 60000, revalidateOnFocus: false }
)
useEffect(() => {
setNetworkUnit(getNetworkUnit())
@@ -330,48 +343,87 @@ export function NetworkMetrics() {
</CardContent>
</Card>
{/* Merged Network Config & Health Card */}
<Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Network Configuration</CardTitle>
<Network className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium text-muted-foreground">Network Status</CardTitle>
<Badge variant="outline" className={healthColor}>
{healthStatus}
</Badge>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex flex-col">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Hostname</span>
<span className="text-sm font-medium text-foreground truncate">{hostname}</span>
<span className="text-xs font-medium text-foreground truncate max-w-[120px]">{hostname}</span>
</div>
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">Domain</span>
<span className="text-sm font-medium text-foreground truncate">{domain}</span>
</div>
<div className="flex flex-col">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Primary DNS</span>
<span className="text-sm font-medium text-foreground truncate">{primaryDNS}</span>
<span className="text-xs font-medium text-foreground font-mono">{primaryDNS}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Packet Loss</span>
<span className="text-xs font-medium text-foreground">{avgPacketLoss}%</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Errors</span>
<span className="text-xs font-medium text-foreground">{totalErrors}</span>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-card border-border">
{/* Latency Card with Sparkline */}
<Card
className="bg-card border-border cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => setLatencyModalOpen(true)}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Network Health</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium text-muted-foreground">Network Latency</CardTitle>
<Timer className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<Badge variant="outline" className={healthColor}>
{healthStatus}
</Badge>
<div className="flex flex-col gap-1 mt-2 text-xs">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Packet Loss:</span>
<span className="font-medium text-foreground">{avgPacketLoss}%</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Errors:</span>
<span className="font-medium text-foreground">{totalErrors}</span>
<div className="flex items-center justify-between mb-2">
<div className="text-xl lg:text-2xl font-bold text-foreground">
{latencyData?.stats?.current ?? 0} <span className="text-sm font-normal text-muted-foreground">ms</span>
</div>
<Badge
variant="outline"
className={
(latencyData?.stats?.current ?? 0) < 50
? "bg-green-500/10 text-green-500 border-green-500/20"
: (latencyData?.stats?.current ?? 0) < 100
? "bg-green-500/10 text-green-500 border-green-500/20"
: (latencyData?.stats?.current ?? 0) < 200
? "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
: "bg-red-500/10 text-red-500 border-red-500/20"
}
>
{(latencyData?.stats?.current ?? 0) < 50 ? "Excellent" :
(latencyData?.stats?.current ?? 0) < 100 ? "Good" :
(latencyData?.stats?.current ?? 0) < 200 ? "Fair" : "Poor"}
</Badge>
</div>
{/* Sparkline */}
{latencyData?.data && latencyData.data.length > 0 && (
<div className="h-[40px] w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={latencyData.data.slice(-30)}>
<YAxis hide domain={['dataMin - 5', 'dataMax + 5']} />
<Line
type="monotone"
dataKey="value"
stroke={(latencyData?.stats?.current ?? 0) < 100 ? "#22c55e" : (latencyData?.stats?.current ?? 0) < 200 ? "#f59e0b" : "#ef4444"}
strokeWidth={1.5}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
<p className="text-xs text-muted-foreground mt-1">
Avg: {latencyData?.stats?.avg ?? 0}ms | Max: {latencyData?.stats?.max ?? 0}ms
</p>
</CardContent>
</Card>
</div>
@@ -1091,6 +1143,12 @@ export function NetworkMetrics() {
)}
</DialogContent>
</Dialog>
{/* Latency Detail Modal */}
<LatencyDetailModal
open={latencyModalOpen}
onOpenChange={setLatencyModalOpen}
/>
</div>
)
}

View File

@@ -1307,7 +1307,7 @@ export function StorageOverview() {
Loading observations...
</div>
) : (
<div className="space-y-3 max-h-60 overflow-y-auto">
<div className="space-y-3">
{diskObservations.map((obs) => (
<div
key={obs.id}

View File

@@ -566,13 +566,228 @@ def _temperature_collector_loop():
cleanup_counter = 0
while True:
_record_temperature()
_record_latency() # Also record latency in the same loop
cleanup_counter += 1
if cleanup_counter >= 60: # Every 60 iterations = 60 minutes
_cleanup_old_temperature_data()
_cleanup_old_latency_data()
cleanup_counter = 0
time.sleep(60)
# ── Latency History (SQLite) ──────────────────────────────────────────────────
# Stores network latency readings every 60s in the same database as temperature.
# Supports multiple targets (gateway, cloudflare, google).
# Retention: 7 days max, cleaned up every hour.
LATENCY_TARGETS = {
'gateway': None, # Auto-detect default gateway
'cloudflare': '1.1.1.1',
'google': '8.8.8.8',
}
def _get_default_gateway():
"""Get the default gateway IP address."""
try:
result = subprocess.run(
['ip', 'route', 'show', 'default'],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
# Parse: "default via 192.168.1.1 dev eth0"
parts = result.stdout.strip().split()
if 'via' in parts:
idx = parts.index('via')
if idx + 1 < len(parts):
return parts[idx + 1]
except Exception:
pass
return '192.168.1.1' # Fallback
def init_latency_db():
"""Create the latency_history table if it doesn't exist."""
try:
conn = _get_temp_db()
conn.execute("""
CREATE TABLE IF NOT EXISTS latency_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
target TEXT NOT NULL,
latency_avg REAL,
latency_min REAL,
latency_max REAL,
packet_loss REAL DEFAULT 0
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_latency_timestamp_target
ON latency_history(timestamp, target)
""")
conn.commit()
conn.close()
return True
except Exception as e:
print(f"[ProxMenux] Latency DB init failed: {e}")
return False
def _measure_latency(target_ip: str) -> dict:
"""Ping a target and return latency stats."""
try:
result = subprocess.run(
['ping', '-c', '3', '-W', '2', target_ip],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
latencies = []
for line in result.stdout.split('\n'):
if 'time=' in line:
try:
latency_str = line.split('time=')[1].split()[0]
latencies.append(float(latency_str))
except:
pass
if latencies:
return {
'success': True,
'avg': round(sum(latencies) / len(latencies), 1),
'min': round(min(latencies), 1),
'max': round(max(latencies), 1),
'packet_loss': round((3 - len(latencies)) / 3 * 100, 1)
}
# Ping failed - 100% packet loss
return {'success': False, 'avg': None, 'min': None, 'max': None, 'packet_loss': 100.0}
except Exception:
return {'success': False, 'avg': None, 'min': None, 'max': None, 'packet_loss': 100.0}
def _record_latency():
"""Record latency to the default gateway."""
try:
gateway = _get_default_gateway()
stats = _measure_latency(gateway)
conn = _get_temp_db()
conn.execute(
"""INSERT INTO latency_history
(timestamp, target, latency_avg, latency_min, latency_max, packet_loss)
VALUES (?, ?, ?, ?, ?, ?)""",
(int(time.time()), 'gateway', stats['avg'], stats['min'], stats['max'], stats['packet_loss'])
)
conn.commit()
conn.close()
except Exception:
pass
def _cleanup_old_latency_data():
"""Remove latency records older than 7 days."""
try:
cutoff = int(time.time()) - (7 * 24 * 3600)
conn = _get_temp_db()
conn.execute("DELETE FROM latency_history WHERE timestamp < ?", (cutoff,))
conn.commit()
conn.close()
except Exception:
pass
def get_latency_history(target='gateway', timeframe='hour'):
"""Get latency history with downsampling for longer timeframes."""
try:
now = int(time.time())
if timeframe == "hour":
since = now - 3600
interval = None # All points (~60)
elif timeframe == "6hour":
since = now - 6 * 3600
interval = 300 # 5 min avg
elif timeframe == "day":
since = now - 86400
interval = 600 # 10 min avg
elif timeframe == "3day":
since = now - 3 * 86400
interval = 1800 # 30 min avg
elif timeframe == "week":
since = now - 7 * 86400
interval = 3600 # 1h avg
else:
since = now - 3600
interval = None
conn = _get_temp_db()
if interval is None:
cursor = conn.execute(
"""SELECT timestamp, latency_avg, latency_min, latency_max, packet_loss
FROM latency_history
WHERE timestamp >= ? AND target = ?
ORDER BY timestamp ASC""",
(since, target)
)
rows = cursor.fetchall()
data = [{"timestamp": r[0], "value": r[1], "min": r[2], "max": r[3], "packet_loss": r[4]} for r in rows if r[1] is not None]
else:
cursor = conn.execute(
"""SELECT (timestamp / ?) * ? as bucket,
ROUND(AVG(latency_avg), 1) as avg_val,
ROUND(MIN(latency_min), 1) as min_val,
ROUND(MAX(latency_max), 1) as max_val,
ROUND(AVG(packet_loss), 1) as avg_loss
FROM latency_history
WHERE timestamp >= ? AND target = ?
GROUP BY bucket
ORDER BY bucket ASC""",
(interval, interval, since, target)
)
rows = cursor.fetchall()
data = [{"timestamp": r[0], "value": r[1], "min": r[2], "max": r[3], "packet_loss": r[4]} for r in rows if r[1] is not None]
conn.close()
# Compute stats
if data:
values = [d["value"] for d in data if d["value"] is not None]
if values:
mins = [d["min"] for d in data if d.get("min") is not None]
maxs = [d["max"] for d in data if d.get("max") is not None]
stats = {
"min": round(min(mins) if mins else min(values), 1),
"max": round(max(maxs) if maxs else max(values), 1),
"avg": round(sum(values) / len(values), 1),
"current": values[-1] if values else 0
}
else:
stats = {"min": 0, "max": 0, "avg": 0, "current": 0}
else:
stats = {"min": 0, "max": 0, "avg": 0, "current": 0}
return {"data": data, "stats": stats, "target": target}
except Exception as e:
return {"data": [], "stats": {"min": 0, "max": 0, "avg": 0, "current": 0}, "target": target}
def get_current_latency(target='gateway'):
"""Get the most recent latency measurement for a target."""
try:
# If gateway, resolve to actual IP
if target == 'gateway':
target_ip = _get_default_gateway()
else:
target_ip = LATENCY_TARGETS.get(target, target)
stats = _measure_latency(target_ip)
return {
'target': target,
'target_ip': target_ip,
'latency_avg': stats['avg'],
'latency_min': stats['min'],
'latency_max': stats['max'],
'packet_loss': stats['packet_loss'],
'status': 'ok' if stats['success'] and stats['avg'] and stats['avg'] < 100 else 'warning' if stats['success'] else 'error'
}
except Exception:
return {'target': target, 'latency_avg': None, 'status': 'error'}
def _health_collector_loop():
"""Background thread: run full health checks every 5 minutes.
Keeps the health cache always fresh and records events/errors in the DB.
@@ -621,9 +836,22 @@ def _health_collector_loop():
# Compare each category's current status to previous cycle.
# Notify when a category DEGRADES (OK->WARNING, WARNING->CRITICAL, etc.)
# Include the detailed 'reason' so the user knows exactly what triggered it.
#
# IMPORTANT: Some health categories map to specific notification toggles:
# - network + latency issue -> 'network_latency' toggle
# - network + connectivity issue -> 'network_down' toggle
# If the specific toggle is disabled, skip that notification.
details = result.get('details', {})
degraded = []
# Map health categories to specific event types for toggle checks
_CATEGORY_EVENT_MAP = {
# (category, reason_contains) -> event_type to check
('network', 'latency'): 'network_latency',
('network', 'connectivity'): 'network_down',
('network', 'unreachable'): 'network_down',
}
for cat_key, cat_data in details.items():
cur_status = cat_data.get('status', 'OK')
prev_status = _prev_statuses.get(cat_key, 'OK')
@@ -632,12 +860,23 @@ def _health_collector_loop():
if cur_rank > prev_rank and cur_rank >= 2: # WARNING or CRITICAL
reason = cat_data.get('reason', f'{cat_key} status changed to {cur_status}')
reason_lower = reason.lower()
cat_name = _CAT_NAMES.get(cat_key, cat_key)
degraded.append({
'category': cat_name,
'status': cur_status,
'reason': reason,
})
# Check if this specific notification type is enabled
skip_notification = False
for (map_cat, map_keyword), event_type in _CATEGORY_EVENT_MAP.items():
if cat_key == map_cat and map_keyword in reason_lower:
if not notification_manager.is_event_enabled(event_type):
skip_notification = True
break
if not skip_notification:
degraded.append({
'category': cat_name,
'status': cur_status,
'reason': reason,
})
_prev_statuses[cat_key] = cur_status
@@ -5438,6 +5677,44 @@ def api_temperature_history():
return jsonify({'data': [], 'stats': {'min': 0, 'max': 0, 'avg': 0, 'current': 0}}), 500
@app.route('/api/network/latency/history', methods=['GET'])
@require_auth
def api_latency_history():
"""Get latency history for charts.
Query params:
target: gateway (default), cloudflare, google
timeframe: hour, 6hour, day, 3day, week
"""
try:
target = request.args.get('target', 'gateway')
if target not in ('gateway', 'cloudflare', 'google'):
target = 'gateway'
timeframe = request.args.get('timeframe', 'hour')
if timeframe not in ('hour', '6hour', 'day', '3day', 'week'):
timeframe = 'hour'
result = get_latency_history(target, timeframe)
return jsonify(result)
except Exception as e:
return jsonify({'data': [], 'stats': {'min': 0, 'max': 0, 'avg': 0, 'current': 0}, 'target': 'gateway'}), 500
@app.route('/api/network/latency/current', methods=['GET'])
@require_auth
def api_latency_current():
"""Get current latency measurement for a target.
Query params:
target: gateway (default), cloudflare, google, or custom IP
"""
try:
target = request.args.get('target', 'gateway')
result = get_current_latency(target)
return jsonify(result)
except Exception as e:
return jsonify({'target': target, 'latency_avg': None, 'status': 'error'}), 500
@app.route('/api/storage', methods=['GET'])
@require_auth
def api_storage():
@@ -7382,17 +7659,18 @@ if __name__ == '__main__':
except Exception as e:
print(f"[ProxMenux] journald check skipped: {e}")
# ── Temperature history collector ──
# Initialize SQLite DB and start background thread to record CPU temp every 60s
if init_temperature_db():
# Record initial reading immediately
# ── Temperature & Latency history collector ──
# Initialize SQLite DB and start background thread to record CPU temp + latency every 60s
if init_temperature_db() and init_latency_db():
# Record initial readings immediately
_record_temperature()
# Start background collector thread
_record_latency()
# Start background collector thread (handles both temp and latency)
temp_thread = threading.Thread(target=_temperature_collector_loop, daemon=True)
temp_thread.start()
print("[ProxMenux] Temperature history collector started (60s interval, 30d retention)")
print("[ProxMenux] Temperature & Latency history collector started (60s interval)")
else:
print("[ProxMenux] Temperature history disabled (DB init failed)")
print("[ProxMenux] Temperature/Latency history disabled (DB init failed)")
# ── Background Health Monitor ──
# Run full health checks every 5 min, keeping cache fresh and recording events for notifications

View File

@@ -2006,7 +2006,11 @@ class HealthMonitor:
return {'status': 'UNKNOWN', 'reason': f'Network check unavailable: {str(e)}', 'checks': {}}
def _check_network_latency(self) -> Optional[Dict[str, Any]]:
"""Check network latency to 1.1.1.1 (cached)"""
"""Check network latency to 1.1.1.1 using 3 consecutive pings.
Uses 3 pings to avoid false positives from transient network spikes.
Reports the average latency and only warns if all 3 exceed threshold.
"""
cache_key = 'network_latency'
current_time = time.time()
@@ -2015,42 +2019,60 @@ class HealthMonitor:
return self.cached_results.get(cache_key)
try:
# Use 3 pings to get reliable latency measurement
result = subprocess.run(
['ping', '-c', '1', '-W', '1', '1.1.1.1'],
['ping', '-c', '3', '-W', '2', '1.1.1.1'],
capture_output=True,
text=True,
timeout=self.NETWORK_TIMEOUT
timeout=self.NETWORK_TIMEOUT + 6 # Allow time for 3 pings
)
if result.returncode == 0:
# Parse individual ping times
latencies = []
for line in result.stdout.split('\n'):
if 'time=' in line:
try:
latency_str = line.split('time=')[1].split()[0]
latency = float(latency_str)
if latency > self.NETWORK_LATENCY_CRITICAL:
status = 'CRITICAL'
reason = f'Latency {latency:.1f}ms >{self.NETWORK_LATENCY_CRITICAL}ms'
elif latency > self.NETWORK_LATENCY_WARNING:
status = 'WARNING'
reason = f'Latency {latency:.1f}ms >{self.NETWORK_LATENCY_WARNING}ms'
else:
status = 'OK'
reason = None
latency_result = {
'status': status,
'latency_ms': round(latency, 1)
}
if reason:
latency_result['reason'] = reason
self.cached_results[cache_key] = latency_result
self.last_check_times[cache_key] = current_time
return latency_result
latencies.append(float(latency_str))
except:
pass
if latencies:
# Calculate average latency
avg_latency = sum(latencies) / len(latencies)
max_latency = max(latencies)
min_latency = min(latencies)
# Count how many pings exceeded thresholds
critical_count = sum(1 for l in latencies if l > self.NETWORK_LATENCY_CRITICAL)
warning_count = sum(1 for l in latencies if l > self.NETWORK_LATENCY_WARNING)
# Only report WARNING/CRITICAL if majority of pings exceed threshold
# This prevents false positives from single transient spikes
if critical_count >= 2: # 2 or more of 3 pings are critical
status = 'CRITICAL'
reason = f'Latency {avg_latency:.1f}ms avg >{self.NETWORK_LATENCY_CRITICAL}ms (min:{min_latency:.0f} max:{max_latency:.0f})'
elif warning_count >= 2: # 2 or more of 3 pings exceed warning
status = 'WARNING'
reason = f'Latency {avg_latency:.1f}ms avg >{self.NETWORK_LATENCY_WARNING}ms (min:{min_latency:.0f} max:{max_latency:.0f})'
else:
status = 'OK'
reason = None
latency_result = {
'status': status,
'latency_ms': round(avg_latency, 1),
'latency_min': round(min_latency, 1),
'latency_max': round(max_latency, 1),
'samples': len(latencies),
}
if reason:
latency_result['reason'] = reason
self.cached_results[cache_key] = latency_result
self.last_check_times[cache_key] = current_time
return latency_result
# If ping failed (timeout, unreachable) - distinguish the reason
stderr_lower = (result.stderr or '').lower() if hasattr(result, 'stderr') else ''

View File

@@ -902,10 +902,21 @@ class NotificationManager:
def send_notification(self, event_type: str, severity: str,
title: str, message: str,
data: Optional[Dict] = None,
source: str = 'api') -> Dict[str, Any]:
source: str = 'api',
skip_toggle_check: bool = False) -> Dict[str, Any]:
"""Send a notification directly (bypasses queue and cooldown).
Used by CLI and API for explicit sends.
Args:
event_type: Type of event (must match TEMPLATES key)
severity: INFO, WARNING, CRITICAL
title: Notification title
message: Notification body
data: Extra data for template rendering
source: Origin of notification (api, cli, health_monitor, etc.)
skip_toggle_check: If True, send even if event toggle is disabled.
Use for 'custom' or 'other' events that should always send.
"""
if not self._channels:
self._load_config()
@@ -917,6 +928,17 @@ class NotificationManager:
'channels_sent': [],
}
# Check if this event type is enabled (unless explicitly skipped)
# 'custom' and 'other' events always send (used for manual/script notifications)
if not skip_toggle_check and event_type not in ('custom', 'other'):
if not self.is_event_enabled(event_type):
return {
'success': False,
'error': f'Event type "{event_type}" is disabled in notification settings',
'channels_sent': [],
'skipped': True,
}
# Render template if available
if event_type in TEMPLATES and not message:
rendered = render_template(event_type, data or {})
@@ -1076,6 +1098,54 @@ class NotificationManager:
return {'success': True, 'enabled': enabled}
def is_event_enabled(self, event_type: str) -> bool:
"""Check if a specific event type is enabled for notifications.
Returns True if ANY active channel has this event enabled.
Returns False only if ALL channels have explicitly disabled this event.
Used by callers like health_polling_thread to skip notifications
for disabled events.
The UI stores toggles per-channel as '{channel}.event.{event_type}'.
We check all configured channels - if any has it enabled, return True.
"""
if not self._config:
self._load_config()
# Get template info for default state
tmpl = TEMPLATES.get(event_type, {})
default_enabled = 'true' if tmpl.get('default_enabled', True) else 'false'
event_group = tmpl.get('group', 'other')
# Check each configured channel
# A channel is "active" if it has .enabled = true
channel_types = ['telegram', 'gotify', 'discord', 'email', 'ntfy', 'pushover', 'slack']
active_channels = []
for ch_name in channel_types:
if self._config.get(f'{ch_name}.enabled', 'false') == 'true':
active_channels.append(ch_name)
# If no channels are configured, consider events as "disabled"
# (no point generating notifications with no destination)
if not active_channels:
return False
# Check if ANY active channel has this event enabled
for ch_name in active_channels:
# First check category toggle for this channel
ch_group_key = f'{ch_name}.events.{event_group}'
if self._config.get(ch_group_key, 'true') == 'false':
continue # Category disabled for this channel
# Then check event-specific toggle
ch_event_key = f'{ch_name}.event.{event_type}'
if self._config.get(ch_event_key, default_enabled) == 'true':
return True # At least one channel has it enabled
# All active channels have this event disabled
return False
def list_channels(self) -> Dict[str, Any]:
"""List all channel types with their configuration status."""
if not self._config: