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:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock } from "lucide-react"
|
||||
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
@@ -42,6 +42,8 @@ interface DiskInfo {
|
||||
error_type?: string // 'io' | 'filesystem'
|
||||
}
|
||||
observations_count?: number
|
||||
connection_type?: 'usb' | 'sata' | 'nvme' | 'sas' | 'internal' | 'unknown'
|
||||
removable?: boolean
|
||||
}
|
||||
|
||||
interface DiskObservation {
|
||||
@@ -421,21 +423,26 @@ export function StorageOverview() {
|
||||
|
||||
const getDiskTypesBreakdown = () => {
|
||||
if (!storageData || !storageData.disks) {
|
||||
return { nvme: 0, ssd: 0, hdd: 0 }
|
||||
return { nvme: 0, ssd: 0, hdd: 0, usb: 0 }
|
||||
}
|
||||
|
||||
let nvme = 0
|
||||
let ssd = 0
|
||||
let hdd = 0
|
||||
let usb = 0
|
||||
|
||||
storageData.disks.forEach((disk) => {
|
||||
if (disk.connection_type === 'usb') {
|
||||
usb++
|
||||
return
|
||||
}
|
||||
const diskType = getDiskType(disk.name, disk.rotation_rate)
|
||||
if (diskType === "NVMe") nvme++
|
||||
else if (diskType === "SSD") ssd++
|
||||
else if (diskType === "HDD") hdd++
|
||||
})
|
||||
|
||||
return { nvme, ssd, hdd }
|
||||
return { nvme, ssd, hdd, usb }
|
||||
}
|
||||
|
||||
const getWearProgressColor = (wearPercent: number): string => {
|
||||
@@ -623,6 +630,12 @@ export function StorageOverview() {
|
||||
<span className="text-blue-500">{diskTypesBreakdown.hdd} HDD</span>
|
||||
</>
|
||||
)}
|
||||
{diskTypesBreakdown.usb > 0 && (
|
||||
<>
|
||||
{(diskTypesBreakdown.nvme > 0 || diskTypesBreakdown.ssd > 0 || diskTypesBreakdown.hdd > 0) && ", "}
|
||||
<span className="text-orange-400">{diskTypesBreakdown.usb} USB</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
<span className="text-green-500">{diskHealthBreakdown.normal} normal</span>
|
||||
@@ -780,7 +793,7 @@ export function StorageOverview() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Physical Disks */}
|
||||
{/* Physical Disks (internal only) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -790,7 +803,7 @@ export function StorageOverview() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{storageData.disks.map((disk) => (
|
||||
{storageData.disks.filter(d => d.connection_type !== 'usb').map((disk) => (
|
||||
<div key={disk.name}>
|
||||
<div
|
||||
className="sm:hidden border border-white/10 rounded-lg p-4 cursor-pointer bg-white/5 transition-colors"
|
||||
@@ -981,13 +994,163 @@ export function StorageOverview() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* External Storage (USB) */}
|
||||
{storageData.disks.filter(d => d.connection_type === 'usb').length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Usb className="h-5 w-5" />
|
||||
External Storage (USB)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{storageData.disks.filter(d => d.connection_type === 'usb').map((disk) => (
|
||||
<div key={disk.name}>
|
||||
{/* Mobile card */}
|
||||
<div
|
||||
className="sm:hidden border border-white/10 rounded-lg p-4 cursor-pointer bg-white/5 transition-colors"
|
||||
onClick={() => handleDiskClick(disk)}
|
||||
>
|
||||
<div className="space-y-2 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Usb className="h-5 w-5 text-orange-400 flex-shrink-0" />
|
||||
<h3 className="font-semibold">/dev/{disk.name}</h3>
|
||||
<Badge className="bg-orange-500/10 text-orange-400 border-orange-500/20 text-[10px] px-1.5">USB</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 pl-7">
|
||||
{disk.model && disk.model !== "Unknown" && (
|
||||
<p className="text-sm text-muted-foreground truncate flex-1 min-w-0">{disk.model}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
{disk.temperature > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Thermometer className={`h-4 w-4 ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`} />
|
||||
<span className={`text-sm font-medium ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`}>
|
||||
{disk.temperature}°C
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{getHealthBadge(disk.health)}
|
||||
{disk.observations_count && disk.observations_count > 0 && (
|
||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1 text-[10px] px-1.5 py-0">
|
||||
<Info className="h-3 w-3" />
|
||||
{disk.observations_count}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop card */}
|
||||
<div
|
||||
className="hidden sm:block border border-white/10 rounded-lg p-4 cursor-pointer hover:bg-white/5 transition-colors"
|
||||
onClick={() => handleDiskClick(disk)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Usb className="h-5 w-5 text-orange-400" />
|
||||
<h3 className="font-semibold">/dev/{disk.name}</h3>
|
||||
<Badge className="bg-orange-500/10 text-orange-400 border-orange-500/20 text-[10px] px-1.5">USB</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{disk.temperature > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Thermometer className={`h-4 w-4 ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`} />
|
||||
<span className={`text-sm font-medium ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`}>
|
||||
{disk.temperature}°C
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{getHealthBadge(disk.health)}
|
||||
{disk.observations_count && disk.observations_count > 0 && (
|
||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1 text-[10px] px-1.5 py-0">
|
||||
<Info className="h-3 w-3" />
|
||||
{disk.observations_count}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{disk.model && disk.model !== "Unknown" && (
|
||||
<p className="text-sm text-muted-foreground mb-3 ml-7">{disk.model}</p>
|
||||
)}
|
||||
|
||||
{disk.io_errors && disk.io_errors.count > 0 && (
|
||||
<div className={`flex items-start gap-2 p-2 rounded text-xs mb-3 ${
|
||||
disk.io_errors.severity === 'CRITICAL'
|
||||
? 'bg-red-500/10 text-red-400 border border-red-500/20'
|
||||
: 'bg-yellow-500/10 text-yellow-400 border border-yellow-500/20'
|
||||
}`}>
|
||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
{disk.io_errors.error_type === 'filesystem' ? (
|
||||
<>
|
||||
<span className="font-medium">Filesystem corruption detected</span>
|
||||
{disk.io_errors.reason && (
|
||||
<p className="mt-0.5 opacity-90 whitespace-pre-line">{disk.io_errors.reason}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-medium">{disk.io_errors.count} I/O error{disk.io_errors.count !== 1 ? 's' : ''} in 5 min</span>
|
||||
{disk.io_errors.sample && (
|
||||
<p className="mt-0.5 opacity-80 font-mono truncate max-w-md">{disk.io_errors.sample}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
{disk.size_formatted && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Size</p>
|
||||
<p className="font-medium">{disk.size_formatted}</p>
|
||||
</div>
|
||||
)}
|
||||
{disk.smart_status && disk.smart_status !== "unknown" && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">SMART Status</p>
|
||||
<p className="font-medium capitalize">{disk.smart_status}</p>
|
||||
</div>
|
||||
)}
|
||||
{disk.power_on_hours !== undefined && disk.power_on_hours > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Power On Time</p>
|
||||
<p className="font-medium">{formatHours(disk.power_on_hours)}</p>
|
||||
</div>
|
||||
)}
|
||||
{disk.serial && disk.serial !== "Unknown" && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Serial</p>
|
||||
<p className="font-medium text-xs">{disk.serial}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Disk Details Dialog */}
|
||||
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<HardDrive className="h-5 w-5" />
|
||||
{selectedDisk?.connection_type === 'usb' ? (
|
||||
<Usb className="h-5 w-5 text-orange-400" />
|
||||
) : (
|
||||
<HardDrive className="h-5 w-5" />
|
||||
)}
|
||||
Disk Details: /dev/{selectedDisk?.name}
|
||||
{selectedDisk?.connection_type === 'usb' && (
|
||||
<Badge className="bg-orange-500/10 text-orange-400 border-orange-500/20 text-[10px] px-1.5">USB</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Complete SMART information and health status</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -1160,6 +1160,54 @@ def serve_images(filename):
|
||||
# Moved helper functions for system info up
|
||||
# def get_system_info(): ... (moved up)
|
||||
|
||||
def get_disk_connection_type(disk_name):
|
||||
"""Detect how a disk is connected: usb, sata, nvme, sas, or unknown.
|
||||
|
||||
Uses /sys/block/<disk>/device symlink to resolve the bus path.
|
||||
Examples:
|
||||
/sys/.../usb3/... -> 'usb'
|
||||
/sys/.../ata2/... -> 'sata'
|
||||
nvme0n1 -> 'nvme'
|
||||
/sys/.../host0/... -> 'sas' (SAS/SCSI)
|
||||
"""
|
||||
try:
|
||||
if disk_name.startswith('nvme'):
|
||||
return 'nvme'
|
||||
|
||||
device_path = f'/sys/block/{disk_name}/device'
|
||||
if os.path.exists(device_path):
|
||||
real_path = os.path.realpath(device_path)
|
||||
if '/usb' in real_path:
|
||||
return 'usb'
|
||||
if '/ata' in real_path:
|
||||
return 'sata'
|
||||
if '/sas' in real_path:
|
||||
return 'sas'
|
||||
|
||||
# Fallback: check removable flag
|
||||
removable_path = f'/sys/block/{disk_name}/removable'
|
||||
if os.path.exists(removable_path):
|
||||
with open(removable_path) as f:
|
||||
if f.read().strip() == '1':
|
||||
return 'usb'
|
||||
|
||||
return 'internal'
|
||||
except Exception:
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def is_disk_removable(disk_name):
|
||||
"""Check if a disk is removable (USB sticks, external drives, etc.)."""
|
||||
try:
|
||||
removable_path = f'/sys/block/{disk_name}/removable'
|
||||
if os.path.exists(removable_path):
|
||||
with open(removable_path) as f:
|
||||
return f.read().strip() == '1'
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_storage_info():
|
||||
"""Get storage and disk information"""
|
||||
try:
|
||||
@@ -1213,6 +1261,9 @@ def get_storage_info():
|
||||
else:
|
||||
size_str = f"{disk_size_gb:.1f}G"
|
||||
|
||||
conn_type = get_disk_connection_type(disk_name)
|
||||
removable = is_disk_removable(disk_name)
|
||||
|
||||
physical_disks[disk_name] = {
|
||||
'name': disk_name,
|
||||
'size': disk_size_kb, # In KB for formatMemory() in Storage Summary
|
||||
@@ -1227,13 +1278,15 @@ def get_storage_info():
|
||||
'reallocated_sectors': smart_data.get('reallocated_sectors', 0),
|
||||
'pending_sectors': smart_data.get('pending_sectors', 0),
|
||||
'crc_errors': smart_data.get('crc_errors', 0),
|
||||
'rotation_rate': smart_data.get('rotation_rate', 0), # Added
|
||||
'power_cycles': smart_data.get('power_cycles', 0), # Added
|
||||
'percentage_used': smart_data.get('percentage_used'), # Added
|
||||
'media_wearout_indicator': smart_data.get('media_wearout_indicator'), # Added
|
||||
'wear_leveling_count': smart_data.get('wear_leveling_count'), # Added
|
||||
'total_lbas_written': smart_data.get('total_lbas_written'), # Added
|
||||
'ssd_life_left': smart_data.get('ssd_life_left') # Added
|
||||
'rotation_rate': smart_data.get('rotation_rate', 0),
|
||||
'power_cycles': smart_data.get('power_cycles', 0),
|
||||
'percentage_used': smart_data.get('percentage_used'),
|
||||
'media_wearout_indicator': smart_data.get('media_wearout_indicator'),
|
||||
'wear_leveling_count': smart_data.get('wear_leveling_count'),
|
||||
'total_lbas_written': smart_data.get('total_lbas_written'),
|
||||
'ssd_life_left': smart_data.get('ssd_life_left'),
|
||||
'connection_type': conn_type,
|
||||
'removable': removable,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -1098,15 +1098,21 @@ class HealthMonitor:
|
||||
if smart_warnings_found:
|
||||
# Collect the actual warning details for the sub-check
|
||||
smart_details_parts = []
|
||||
smart_error_keys = []
|
||||
for disk_path, issue in disk_health_issues.items():
|
||||
for sl in (issue.get('smart_lines') or [])[:3]:
|
||||
smart_details_parts.append(sl)
|
||||
if issue.get('error_key'):
|
||||
smart_error_keys.append(issue['error_key'])
|
||||
detail_text = '; '.join(smart_details_parts[:3]) if smart_details_parts else 'SMART warning in journal'
|
||||
# Use the same error_key as the per-disk check so a single dismiss
|
||||
# covers both the /Dev/Sda sub-check AND the SMART Health sub-check
|
||||
shared_key = smart_error_keys[0] if smart_error_keys else 'smart_health_journal'
|
||||
checks['smart_health'] = {
|
||||
'status': 'WARNING',
|
||||
'detail': detail_text,
|
||||
'dismissable': True,
|
||||
'error_key': 'smart_health_journal',
|
||||
'error_key': shared_key,
|
||||
}
|
||||
else:
|
||||
checks['smart_health'] = {'status': 'OK', 'detail': 'No SMART warnings in journal'}
|
||||
@@ -1118,8 +1124,45 @@ class HealthMonitor:
|
||||
if not issues:
|
||||
return {'status': 'OK', 'checks': checks}
|
||||
|
||||
# ── Mark dismissed checks ──
|
||||
# If an error_key in a check has been acknowledged (dismissed) in the
|
||||
# persistence DB, mark the check as dismissed so the frontend renders
|
||||
# it in blue instead of showing WARNING + Dismiss button.
|
||||
# Also recalculate category status: if ALL warning/critical checks are
|
||||
# dismissed, downgrade the category to OK.
|
||||
try:
|
||||
all_dismissed = True
|
||||
for check_key, check_val in checks.items():
|
||||
ek = check_val.get('error_key')
|
||||
if not ek:
|
||||
continue
|
||||
check_status = (check_val.get('status') or 'OK').upper()
|
||||
if check_status in ('WARNING', 'CRITICAL'):
|
||||
if health_persistence.is_error_acknowledged(ek):
|
||||
check_val['dismissed'] = True
|
||||
else:
|
||||
all_dismissed = False
|
||||
|
||||
# If every non-OK check is dismissed, downgrade the category
|
||||
non_ok_checks = [v for v in checks.values()
|
||||
if (v.get('status') or 'OK').upper() in ('WARNING', 'CRITICAL')]
|
||||
if non_ok_checks and all(v.get('dismissed') for v in non_ok_checks):
|
||||
# All issues are dismissed -- category shows as OK to avoid
|
||||
# persistent WARNING after user has acknowledged.
|
||||
return {
|
||||
'status': 'OK',
|
||||
'reason': '; '.join(issues[:3]),
|
||||
'details': storage_details,
|
||||
'checks': checks,
|
||||
'all_dismissed': True,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Determine overall status
|
||||
has_critical = any(d.get('status') == 'CRITICAL' for d in storage_details.values())
|
||||
has_critical = any(
|
||||
d.get('status') == 'CRITICAL' for d in storage_details.values()
|
||||
)
|
||||
|
||||
return {
|
||||
'status': 'CRITICAL' if has_critical else 'WARNING',
|
||||
|
||||
@@ -580,6 +580,35 @@ class HealthPersistence:
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
def is_error_acknowledged(self, error_key: str) -> bool:
|
||||
"""Check if an error_key has been acknowledged and is still within suppression window."""
|
||||
try:
|
||||
conn = self._get_conn()
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'SELECT acknowledged, resolved_at, suppression_hours FROM errors WHERE error_key = ?',
|
||||
(error_key,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return False
|
||||
if not row['acknowledged']:
|
||||
return False
|
||||
# Check if still within suppression window
|
||||
resolved_at = row['resolved_at']
|
||||
sup_hours = row['suppression_hours'] or self.DEFAULT_SUPPRESSION_HOURS
|
||||
if resolved_at:
|
||||
try:
|
||||
resolved_dt = datetime.fromisoformat(resolved_at)
|
||||
if datetime.now() > resolved_dt + timedelta(hours=sup_hours):
|
||||
return False # Suppression expired
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_active_errors(self, category: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Get all active (unresolved) errors, optionally filtered by category"""
|
||||
conn = self._get_conn()
|
||||
@@ -1358,6 +1387,22 @@ class HealthPersistence:
|
||||
print(f"[HealthPersistence] Error getting observations: {e}")
|
||||
return []
|
||||
|
||||
def get_all_observed_devices(self) -> List[Dict[str, Any]]:
|
||||
"""Return a list of unique device_name + serial pairs that have observations."""
|
||||
try:
|
||||
conn = self._get_conn()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT DISTINCT device_name, serial
|
||||
FROM disk_observations
|
||||
WHERE dismissed = 0
|
||||
''')
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
return [{'device_name': r[0], 'serial': r[1] or ''} for r in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def get_disks_observation_counts(self) -> Dict[str, int]:
|
||||
"""Return {device_name: count} of active observations per disk.
|
||||
|
||||
|
||||
@@ -1731,6 +1731,16 @@ class PollingCollector:
|
||||
self._last_notified.pop(key, None)
|
||||
continue
|
||||
|
||||
# Skip recovery if the error was manually acknowledged (dismissed)
|
||||
# by the user. Acknowledged != resolved -- the problem may still
|
||||
# exist, the user just chose to suppress notifications for it.
|
||||
try:
|
||||
if health_persistence.is_error_acknowledged(key):
|
||||
self._last_notified.pop(key, None)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Calculate duration
|
||||
duration = ''
|
||||
if first_seen:
|
||||
|
||||
Reference in New Issue
Block a user