new modal temperature

This commit is contained in:
MacRimi
2026-02-13 19:07:24 +01:00
parent 4228177920
commit 9b48c498f5
3 changed files with 476 additions and 8 deletions

View File

@@ -7,10 +7,17 @@ import { Badge } from "./ui/badge"
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network } from "lucide-react" import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network } from "lucide-react"
import { NodeMetricsCharts } from "./node-metrics-charts" import { NodeMetricsCharts } from "./node-metrics-charts"
import { NetworkTrafficChart } from "./network-traffic-chart" import { NetworkTrafficChart } from "./network-traffic-chart"
import { TemperatureDetailModal } from "./temperature-detail-modal"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { fetchApi } from "../lib/api-config" import { fetchApi } from "../lib/api-config"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network" import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { formatStorage } from "../lib/utils" import { formatStorage } from "../lib/utils"
import { Area, AreaChart, ResponsiveContainer } from "recharts"
interface TempDataPoint {
timestamp: number
value: number
}
interface SystemData { interface SystemData {
cpu_usage: number cpu_usage: number
@@ -18,6 +25,7 @@ interface SystemData {
memory_total: number memory_total: number
memory_used: number memory_used: number
temperature: number temperature: number
temperature_sparkline?: TempDataPoint[]
uptime: string uptime: string
load_average: number[] load_average: number[]
hostname: string hostname: string
@@ -178,6 +186,7 @@ export function SystemOverview() {
const [networkTimeframe, setNetworkTimeframe] = useState("day") const [networkTimeframe, setNetworkTimeframe] = useState("day")
const [networkTotals, setNetworkTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 }) const [networkTotals, setNetworkTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 })
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">("Bytes") // Added networkUnit state const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">("Bytes") // Added networkUnit state
const [tempModalOpen, setTempModalOpen] = useState(false)
useEffect(() => { useEffect(() => {
const fetchAllData = async () => { const fetchAllData = async () => {
@@ -458,27 +467,59 @@ export function SystemOverview() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-card border-border"> <Card
className={`bg-card border-border ${systemData.temperature > 0 ? "cursor-pointer hover:border-primary/50 transition-colors" : ""}`}
onClick={() => systemData.temperature > 0 && setTempModalOpen(true)}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Temperature</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">Temperature</CardTitle>
<Thermometer className="h-4 w-4 text-muted-foreground" /> <Thermometer className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-xl lg:text-2xl font-bold text-foreground"> <div className="flex items-center gap-2">
{systemData.temperature === 0 ? "N/A" : `${systemData.temperature}°C`} <span className="text-xl lg:text-2xl font-bold text-foreground">
</div> {systemData.temperature === 0 ? "N/A" : `${Math.round(systemData.temperature * 10) / 10}°C`}
<div className="flex items-center mt-2"> </span>
<Badge variant="outline" className={tempStatus.color}> <Badge variant="outline" className={tempStatus.color}>
{tempStatus.status} {tempStatus.status}
</Badge> </Badge>
</div> </div>
{systemData.temperature > 0 && systemData.temperature_sparkline && systemData.temperature_sparkline.length > 1 ? (
<div className="mt-2 h-10">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={systemData.temperature_sparkline} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="tempSparkGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={systemData.temperature >= 75 ? "#ef4444" : systemData.temperature >= 60 ? "#f59e0b" : "#22c55e"} stopOpacity={0.3} />
<stop offset="100%" stopColor={systemData.temperature >= 75 ? "#ef4444" : systemData.temperature >= 60 ? "#f59e0b" : "#22c55e"} stopOpacity={0} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="value"
stroke={systemData.temperature >= 75 ? "#ef4444" : systemData.temperature >= 60 ? "#f59e0b" : "#22c55e"}
strokeWidth={1.5}
fill="url(#tempSparkGradient)"
dot={false}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
) : (
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2">
{systemData.temperature === 0 ? "No sensor available" : "Live temperature reading"} {systemData.temperature === 0 ? "No sensor available" : "Collecting data..."}
</p> </p>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<TemperatureDetailModal
open={tempModalOpen}
onOpenChange={setTempModalOpen}
/>
<NodeMetricsCharts /> <NodeMetricsCharts />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">

View File

@@ -0,0 +1,241 @@
"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 { Badge } from "./ui/badge"
import { Thermometer, 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: "day", label: "24 Hours" },
{ value: "week", label: "7 Days" },
{ value: "month", label: "30 Days" },
]
interface TempHistoryPoint {
timestamp: number
value: number
min?: number
max?: number
}
interface TempStats {
min: number
max: number
avg: number
current: number
}
interface TemperatureDetailModalProps {
open: boolean
onOpenChange: (open: boolean) => void
}
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
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">
{payload.map((entry: any, index: number) => (
<div key={index} className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
<span className="text-sm font-semibold text-white">{entry.value}°C</span>
</div>
))}
</div>
</div>
)
}
return null
}
const getStatusColor = (temp: number) => {
if (temp >= 75) return "#ef4444"
if (temp >= 60) return "#f59e0b"
return "#22c55e"
}
const getStatusInfo = (temp: number) => {
if (temp === 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
if (temp < 60) return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" }
if (temp < 75) return { status: "Warm", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
return { status: "Hot", color: "bg-red-500/10 text-red-500 border-red-500/20" }
}
export function TemperatureDetailModal({ open, onOpenChange }: TemperatureDetailModalProps) {
const [timeframe, setTimeframe] = useState("hour")
const [data, setData] = useState<TempHistoryPoint[]>([])
const [stats, setStats] = useState<TempStats>({ min: 0, max: 0, avg: 0, current: 0 })
const [loading, setLoading] = useState(true)
const isMobile = useIsMobile()
useEffect(() => {
if (open) {
fetchHistory()
}
}, [open, timeframe])
const fetchHistory = async () => {
setLoading(true)
try {
const result = await fetchApi<{ data: TempHistoryPoint[]; stats: TempStats }>(
`/api/temperature/history?timeframe=${timeframe}`
)
if (result && result.data) {
setData(result.data)
setStats(result.stats)
}
} catch (err) {
console.error("[v0] Failed to fetch temperature history:", err)
} finally {
setLoading(false)
}
}
const formatTime = (timestamp: number) => {
const date = new Date(timestamp * 1000)
if (timeframe === "hour") {
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 currentStatus = getStatusInfo(stats.current)
const chartColor = getStatusColor(stats.current)
// Calculate Y axis domain with some padding
const values = data.map((d) => d.value)
const yMin = values.length > 0 ? Math.max(0, Math.floor(Math.min(...values) - 5)) : 0
const yMax = values.length > 0 ? Math.ceil(Math.max(...values) + 5) : 100
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl bg-card border-border">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle className="text-foreground flex items-center gap-2">
<Thermometer className="h-5 w-5" />
CPU Temperature
</DialogTitle>
<Select value={timeframe} onValueChange={setTimeframe}>
<SelectTrigger className="w-[130px] bg-card border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIMEFRAME_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</DialogHeader>
{/* Stats bar */}
<div className="grid grid-cols-4 gap-3">
<div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1">Current</div>
<div className="text-lg font-bold text-foreground flex items-center justify-center gap-1.5">
{stats.current}°C
<Badge variant="outline" className={`${currentStatus.color} text-[10px] px-1.5 py-0`}>
{currentStatus.status}
</Badge>
</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}°C</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}°C</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}°C</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">
<Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No temperature 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: isMobile ? -15 : 0, bottom: 0 }}>
<defs>
<linearGradient id="tempGradient" 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="hsl(var(--border))" opacity={0.3} />
<XAxis
dataKey="time"
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: isMobile ? 10 : 12 }}
tickLine={false}
axisLine={{ stroke: "hsl(var(--border))" }}
interval="preserveStartEnd"
minTickGap={isMobile ? 40 : 60}
/>
<YAxis
domain={[yMin, yMax]}
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: isMobile ? 10 : 12 }}
tickLine={false}
axisLine={{ stroke: "hsl(var(--border))" }}
tickFormatter={(v) => `${v}°`}
width={isMobile ? 35 : 45}
/>
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="value"
name="Temperature"
stroke={chartColor}
strokeWidth={2}
fill="url(#tempGradient)"
dot={false}
activeDot={{ r: 4, fill: chartColor, stroke: "#fff", strokeWidth: 2 }}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -16,9 +16,11 @@ import re
import select import select
import shutil import shutil
import socket import socket
import sqlite3
import subprocess import subprocess
import sys import sys
import time import time
import threading
import urllib.parse import urllib.parse
import hardware_monitor import hardware_monitor
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
@@ -348,6 +350,160 @@ def get_cpu_temperature():
pass pass
return temp return temp
# ── Temperature History (SQLite) ──────────────────────────────────────────────
# Stores CPU temperature readings every 60s in a lightweight SQLite database.
# Data is persisted in /usr/local/share/proxmenux/ alongside config.json.
# Retention: 30 days max, cleaned up every hour.
TEMP_DB_DIR = "/usr/local/share/proxmenux"
TEMP_DB_PATH = os.path.join(TEMP_DB_DIR, "monitor.db")
def _get_temp_db():
"""Get a SQLite connection with WAL mode for concurrent reads."""
conn = sqlite3.connect(TEMP_DB_PATH, timeout=5)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
return conn
def init_temperature_db():
"""Create the temperature_history table if it doesn't exist."""
try:
os.makedirs(TEMP_DB_DIR, exist_ok=True)
conn = _get_temp_db()
conn.execute("""
CREATE TABLE IF NOT EXISTS temperature_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
value REAL NOT NULL
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_temp_timestamp
ON temperature_history(timestamp)
""")
conn.commit()
conn.close()
return True
except Exception as e:
print(f"[ProxMenux] Temperature DB init failed: {e}")
return False
def _record_temperature():
"""Insert a single temperature reading into the DB."""
try:
temp = get_cpu_temperature()
if temp and temp > 0:
conn = _get_temp_db()
conn.execute(
"INSERT INTO temperature_history (timestamp, value) VALUES (?, ?)",
(int(time.time()), round(temp, 1))
)
conn.commit()
conn.close()
except Exception:
pass
def _cleanup_old_temperature_data():
"""Remove temperature records older than 30 days."""
try:
cutoff = int(time.time()) - (30 * 24 * 3600)
conn = _get_temp_db()
conn.execute("DELETE FROM temperature_history WHERE timestamp < ?", (cutoff,))
conn.commit()
conn.close()
except Exception:
pass
def get_temperature_sparkline(minutes=60):
"""Get recent temperature data for the overview sparkline."""
try:
since = int(time.time()) - (minutes * 60)
conn = _get_temp_db()
cursor = conn.execute(
"SELECT timestamp, value FROM temperature_history WHERE timestamp >= ? ORDER BY timestamp ASC",
(since,)
)
rows = cursor.fetchall()
conn.close()
return [{"timestamp": r[0], "value": r[1]} for r in rows]
except Exception:
return []
def get_temperature_history(timeframe="hour"):
"""Get temperature history with downsampling for longer timeframes."""
try:
now = int(time.time())
if timeframe == "hour":
since = now - 3600
interval = None # All points (~60)
elif timeframe == "day":
since = now - 86400
interval = 300 # 5 min avg (288 points)
elif timeframe == "week":
since = now - 7 * 86400
interval = 1800 # 30 min avg (336 points)
elif timeframe == "month":
since = now - 30 * 86400
interval = 7200 # 2h avg (360 points)
else:
since = now - 3600
interval = None
conn = _get_temp_db()
if interval is None:
cursor = conn.execute(
"SELECT timestamp, value FROM temperature_history WHERE timestamp >= ? ORDER BY timestamp ASC",
(since,)
)
rows = cursor.fetchall()
data = [{"timestamp": r[0], "value": r[1]} for r in rows]
else:
# Downsample: average value per interval bucket
cursor = conn.execute(
"""SELECT (timestamp / ?) * ? as bucket,
ROUND(AVG(value), 1) as avg_val,
ROUND(MIN(value), 1) as min_val,
ROUND(MAX(value), 1) as max_val
FROM temperature_history
WHERE timestamp >= ?
GROUP BY bucket
ORDER BY bucket ASC""",
(interval, interval, since)
)
rows = cursor.fetchall()
data = [{"timestamp": r[0], "value": r[1], "min": r[2], "max": r[3]} for r in rows]
conn.close()
# Compute stats
if data:
values = [d["value"] for d in data]
stats = {
"min": round(min(values), 1),
"max": round(max(values), 1),
"avg": round(sum(values) / len(values), 1),
"current": values[-1]
}
else:
stats = {"min": 0, "max": 0, "avg": 0, "current": 0}
return {"data": data, "stats": stats}
except Exception as e:
return {"data": [], "stats": {"min": 0, "max": 0, "avg": 0, "current": 0}}
def _temperature_collector_loop():
"""Background thread: collect temperature every 60s, cleanup every hour."""
cleanup_counter = 0
while True:
_record_temperature()
cleanup_counter += 1
if cleanup_counter >= 60: # Every 60 iterations = 60 minutes
_cleanup_old_temperature_data()
cleanup_counter = 0
time.sleep(60)
def get_uptime(): def get_uptime():
"""Get system uptime in a human-readable format.""" """Get system uptime in a human-readable format."""
try: try:
@@ -4803,12 +4959,16 @@ def api_system():
# Get available updates # Get available updates
available_updates = get_available_updates() available_updates = get_available_updates()
# Get temperature sparkline (last 1h) for overview mini chart
temp_sparkline = get_temperature_sparkline(60)
return jsonify({ return jsonify({
'cpu_usage': round(cpu_usage, 1), 'cpu_usage': round(cpu_usage, 1),
'memory_usage': round(memory_usage_percent, 1), 'memory_usage': round(memory_usage_percent, 1),
'memory_total': round(memory_total_gb, 1), 'memory_total': round(memory_total_gb, 1),
'memory_used': round(memory_used_gb, 1), 'memory_used': round(memory_used_gb, 1),
'temperature': temp, 'temperature': temp,
'temperature_sparkline': temp_sparkline,
'uptime': uptime, 'uptime': uptime,
'load_average': list(load_avg), 'load_average': list(load_avg),
'hostname': socket.gethostname(), 'hostname': socket.gethostname(),
@@ -4826,6 +4986,20 @@ def api_system():
pass pass
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@app.route('/api/temperature/history', methods=['GET'])
@require_auth
def api_temperature_history():
"""Get temperature history for charts. Timeframe: hour, day, week, month"""
try:
timeframe = request.args.get('timeframe', 'hour')
if timeframe not in ('hour', 'day', 'week', 'month'):
timeframe = 'hour'
result = get_temperature_history(timeframe)
return jsonify(result)
except Exception as e:
return jsonify({'data': [], 'stats': {'min': 0, 'max': 0, 'avg': 0, 'current': 0}}), 500
@app.route('/api/storage', methods=['GET']) @app.route('/api/storage', methods=['GET'])
@require_auth @require_auth
def api_storage(): def api_storage():
@@ -6758,6 +6932,18 @@ if __name__ == '__main__':
except Exception as e: except Exception as e:
print(f"[ProxMenux] journald check skipped: {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
_record_temperature()
# Start background collector thread
temp_thread = threading.Thread(target=_temperature_collector_loop, daemon=True)
temp_thread.start()
print("[ProxMenux] Temperature history collector started (60s interval, 30d retention)")
else:
print("[ProxMenux] Temperature history disabled (DB init failed)")
# Check for SSL configuration # Check for SSL configuration
ssl_ctx = None ssl_ctx = None
try: try: