mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-04-05 20:03:48 +00:00
Update notification service
This commit is contained in:
267
AppImage/components/latency-detail-modal.tsx
Normal file
267
AppImage/components/latency-detail-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user