Update health monitor

This commit is contained in:
MacRimi
2026-02-17 16:49:26 +01:00
parent 31c5eeb6c3
commit beeeabc377
3 changed files with 296 additions and 139 deletions

View File

@@ -55,8 +55,10 @@ export function Settings() {
// Health Monitor suppression settings // Health Monitor suppression settings
const [suppressionCategories, setSuppressionCategories] = useState<SuppressionCategory[]>([]) const [suppressionCategories, setSuppressionCategories] = useState<SuppressionCategory[]>([])
const [loadingHealth, setLoadingHealth] = useState(true) const [loadingHealth, setLoadingHealth] = useState(true)
const [savingHealth, setSavingHealth] = useState<string | null>(null) const [healthEditMode, setHealthEditMode] = useState(false)
const [savedHealth, setSavedHealth] = useState<string | null>(null) const [savingAllHealth, setSavingAllHealth] = useState(false)
const [savedAllHealth, setSavedAllHealth] = useState(false)
const [pendingChanges, setPendingChanges] = useState<Record<string, number>>({})
const [customValues, setCustomValues] = useState<Record<string, string>>({}) const [customValues, setCustomValues] = useState<Record<string, string>>({})
useEffect(() => { useEffect(() => {
@@ -118,58 +120,98 @@ export function Settings() {
return "custom" return "custom"
} }
const handleSuppressionChange = async (settingKey: string, value: string) => { const getEffectiveHours = (cat: SuppressionCategory): number => {
if (cat.key in pendingChanges) return pendingChanges[cat.key]
return cat.hours
}
const handleSuppressionChange = (settingKey: string, value: string) => {
if (value === "custom") { if (value === "custom") {
// Show custom input -- don't save yet
const current = suppressionCategories.find(c => c.key === settingKey) const current = suppressionCategories.find(c => c.key === settingKey)
setCustomValues(prev => ({ ...prev, [settingKey]: String(current?.hours || 48) })) const effectiveHours = current ? getEffectiveHours(current) : 48
// Temporarily mark as custom in state setCustomValues(prev => ({ ...prev, [settingKey]: String(effectiveHours > 0 ? effectiveHours : 48) }))
setSuppressionCategories(prev => // Mark as custom mode in pending
prev.map(c => c.key === settingKey ? { ...c, hours: -2 } : c) setPendingChanges(prev => ({ ...prev, [settingKey]: -2 }))
)
return return
} }
const hours = parseInt(value, 10) const hours = parseInt(value, 10)
if (isNaN(hours)) return if (isNaN(hours)) return
setPendingChanges(prev => ({ ...prev, [settingKey]: hours }))
await saveSuppression(settingKey, hours) // Clear custom input if switching away
setCustomValues(prev => {
const next = { ...prev }
delete next[settingKey]
return next
})
} }
const handleCustomSave = async (settingKey: string) => { const handleCustomConfirm = (settingKey: string) => {
const raw = customValues[settingKey] const raw = customValues[settingKey]
const hours = parseInt(raw, 10) const hours = parseInt(raw, 10)
if (isNaN(hours) || hours < 1) return if (isNaN(hours) || hours < 1) return
await saveSuppression(settingKey, hours) setPendingChanges(prev => ({ ...prev, [settingKey]: hours }))
setCustomValues(prev => {
const next = { ...prev }
delete next[settingKey]
return next
})
} }
const saveSuppression = async (settingKey: string, hours: number) => { const handleCancelEdit = () => {
setSavingHealth(settingKey) setHealthEditMode(false)
setPendingChanges({})
setCustomValues({})
}
const handleSaveAllHealth = async () => {
// Merge pending changes into a payload: only changed categories
const payload: Record<string, string> = {}
for (const cat of suppressionCategories) {
if (cat.key in pendingChanges && pendingChanges[cat.key] !== -2) {
payload[cat.key] = String(pendingChanges[cat.key])
}
}
if (Object.keys(payload).length === 0) {
setHealthEditMode(false)
setPendingChanges({})
return
}
setSavingAllHealth(true)
try { try {
await fetchApi("/api/health/settings", { await fetchApi("/api/health/settings", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [settingKey]: String(hours) }), body: JSON.stringify(payload),
}) })
// Update local state with saved values
setSuppressionCategories(prev => setSuppressionCategories(prev =>
prev.map(c => c.key === settingKey ? { ...c, hours } : c) prev.map(c => {
if (c.key in pendingChanges && pendingChanges[c.key] !== -2) {
return { ...c, hours: pendingChanges[c.key] }
}
return c
})
) )
// Remove from custom values setPendingChanges({})
setCustomValues(prev => { setCustomValues({})
const next = { ...prev } setHealthEditMode(false)
delete next[settingKey] setSavedAllHealth(true)
return next setTimeout(() => setSavedAllHealth(false), 3000)
})
setSavedHealth(settingKey)
setTimeout(() => setSavedHealth(null), 2000)
} catch (err) { } catch (err) {
console.error("Failed to save health setting:", err) console.error("Failed to save health settings:", err)
} finally { } finally {
setSavingHealth(null) setSavingAllHealth(false)
} }
} }
const hasPendingChanges = Object.keys(pendingChanges).some(
k => pendingChanges[k] !== -2
)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
@@ -211,13 +253,56 @@ export function Settings() {
{/* Health Monitor Settings */} {/* Health Monitor Settings */}
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center justify-between">
<HeartPulse className="h-5 w-5 text-red-500" /> <div className="flex items-center gap-2">
<CardTitle>Health Monitor</CardTitle> <HeartPulse className="h-5 w-5 text-red-500" />
<CardTitle>Health Monitor</CardTitle>
</div>
{!loadingHealth && (
<div className="flex items-center gap-2">
{savedAllHealth && (
<span className="flex items-center gap-1 text-xs text-green-500">
<Check className="h-3.5 w-3.5" />
Saved
</span>
)}
{healthEditMode ? (
<>
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
onClick={handleCancelEdit}
disabled={savingAllHealth}
>
Cancel
</button>
<button
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
onClick={handleSaveAllHealth}
disabled={savingAllHealth || !hasPendingChanges}
>
{savingAllHealth ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Check className="h-3 w-3" />
)}
Save
</button>
</>
) : (
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
onClick={() => setHealthEditMode(true)}
>
<Settings2 className="h-3 w-3" />
Edit
</button>
)}
</div>
)}
</div> </div>
<CardDescription> <CardDescription>
Configure how long dismissed alerts stay suppressed for each category. Configure how long dismissed alerts stay suppressed for each category.
When you dismiss a warning, it will not reappear until the suppression period expires. Changes apply immediately to both existing and future dismissed alerts.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -226,116 +311,124 @@ export function Settings() {
<div className="animate-spin h-8 w-8 border-4 border-red-500 border-t-transparent rounded-full" /> <div className="animate-spin h-8 w-8 border-4 border-red-500 border-t-transparent rounded-full" />
</div> </div>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-0">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-3 pb-2 border-b border-border"> <div className="flex items-center justify-between pb-2 mb-1 border-b border-border">
<span className="text-sm font-medium text-muted-foreground">Category</span> <span className="text-xs font-medium text-muted-foreground">Category</span>
<span className="text-sm font-medium text-muted-foreground">Suppression Duration</span> <span className="text-xs font-medium text-muted-foreground">Suppression Duration</span>
</div> </div>
{/* Per-category rows */} {/* Per-category rows */}
{suppressionCategories.map((cat) => { <div className="divide-y divide-border/50">
const IconComp = CATEGORY_ICONS[cat.icon] || HeartPulse {suppressionCategories.map((cat) => {
const isCustomMode = cat.hours === -2 || (cat.key in customValues) const IconComp = CATEGORY_ICONS[cat.icon] || HeartPulse
const isPermanent = cat.hours === -1 const effectiveHours = getEffectiveHours(cat)
const isLong = cat.hours >= 720 && cat.hours !== -1 const isCustomMode = effectiveHours === -2 || (cat.key in customValues)
const selectVal = isCustomMode ? "custom" : getSelectValue(cat.hours, cat.key) const isPermanent = effectiveHours === -1
const isLong = effectiveHours >= 720 && effectiveHours !== -1 && effectiveHours !== -2
return ( const hasChanged = cat.key in pendingChanges && pendingChanges[cat.key] !== cat.hours
<div key={cat.key} className="space-y-0"> const selectVal = isCustomMode ? "custom" : getSelectValue(effectiveHours, cat.key)
<div className="flex items-center justify-between gap-3 py-2.5 px-2 rounded-lg hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-2.5 min-w-0"> return (
<IconComp className="h-4 w-4 text-muted-foreground shrink-0" /> <div key={cat.key}>
<span className="text-sm font-medium truncate">{cat.label}</span> <div className="flex items-center justify-between gap-2 py-2 sm:py-2.5 px-1 sm:px-2">
{savingHealth === cat.key && ( <div className="flex items-center gap-2 min-w-0 flex-1">
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground shrink-0" /> <IconComp className="h-4 w-4 text-muted-foreground shrink-0" />
)} <span className="text-xs sm:text-sm font-medium">{cat.label}</span>
{savedHealth === cat.key && ( {hasChanged && healthEditMode && (
<Check className="h-3.5 w-3.5 text-green-500 shrink-0" /> <span className="h-1.5 w-1.5 rounded-full bg-blue-500 shrink-0" />
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{isCustomMode ? (
<div className="flex items-center gap-1.5">
<Input
type="number"
min={1}
className="w-20 h-8 text-xs"
value={customValues[cat.key] || ""}
onChange={(e) => setCustomValues(prev => ({ ...prev, [cat.key]: e.target.value }))}
placeholder="Hours"
/>
<span className="text-xs text-muted-foreground">h</span>
<button
className="h-8 px-2 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors"
onClick={() => handleCustomSave(cat.key)}
disabled={savingHealth === cat.key}
>
Save
</button>
<button
className="h-8 px-2 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
onClick={() => {
setCustomValues(prev => {
const next = { ...prev }
delete next[cat.key]
return next
})
loadHealthSettings()
}}
>
Cancel
</button>
</div>
) : (
<Select value={selectVal} onValueChange={(v) => handleSuppressionChange(cat.key, v)}>
<SelectTrigger className="w-32 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUPPRESSION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
{/* Warning for Permanent */}
{isPermanent && (
<div className="flex items-start gap-2 ml-8 mr-2 mb-2 p-2.5 rounded-md bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<p className="text-xs text-amber-400/90 leading-relaxed">
Dismissed alerts for <span className="font-semibold">{cat.label}</span> will never reappear.
{cat.category === "temperature" && (
<span className="block mt-1 text-amber-300 font-medium">
Note: Critical CPU temperature alerts will still trigger for hardware safety.
</span>
)} )}
</p> </div>
<div className="shrink-0">
{isCustomMode && healthEditMode ? (
<div className="flex items-center gap-1.5">
<Input
type="number"
min={1}
className="w-16 sm:w-20 h-7 text-xs"
value={customValues[cat.key] || ""}
onChange={(e) => setCustomValues(prev => ({ ...prev, [cat.key]: e.target.value }))}
placeholder="Hours"
/>
<span className="text-xs text-muted-foreground">h</span>
<button
className="h-7 px-2 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors"
onClick={() => handleCustomConfirm(cat.key)}
>
OK
</button>
<button
className="h-7 px-1.5 text-xs rounded-md text-muted-foreground hover:text-foreground transition-colors"
onClick={() => {
setCustomValues(prev => {
const next = { ...prev }
delete next[cat.key]
return next
})
setPendingChanges(prev => {
const next = { ...prev }
delete next[cat.key]
return next
})
}}
>
X
</button>
</div>
) : (
<Select
value={selectVal}
onValueChange={(v) => handleSuppressionChange(cat.key, v)}
disabled={!healthEditMode}
>
<SelectTrigger className={`w-28 sm:w-32 h-7 text-xs ${!healthEditMode ? "opacity-60" : ""}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUPPRESSION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div> </div>
)}
{/* Notice for Permanent */}
{/* Warning for long custom duration (> 1 month) */} {isPermanent && healthEditMode && (
{isLong && !isPermanent && ( <div className="flex items-start gap-2 ml-6 sm:ml-8 mr-1 mb-2 p-2 rounded-md bg-blue-500/10 border border-blue-500/20">
<div className="flex items-start gap-2 ml-8 mr-2 mb-2 p-2.5 rounded-md bg-amber-500/10 border border-amber-500/20"> <Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
<Info className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" /> <p className="text-[11px] text-blue-400/90 leading-relaxed">
<p className="text-xs text-amber-400/90 leading-relaxed"> Alerts for <span className="font-semibold">{cat.label}</span> will be permanently suppressed when dismissed.
Long suppression period. Dismissed alerts for this category will not reappear for an extended time. {cat.category === "temperature" && (
</p> <span className="block mt-0.5 text-blue-300/80">
</div> Critical CPU temperature alerts will still trigger for hardware safety.
)} </span>
</div> )}
) </p>
})} </div>
)}
{/* Notice for long duration (> 1 month) */}
{isLong && healthEditMode && (
<div className="flex items-start gap-2 ml-6 sm:ml-8 mr-1 mb-2 p-2 rounded-md bg-blue-500/10 border border-blue-500/20">
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-blue-400/90 leading-relaxed">
Long suppression period. Dismissed alerts for this category will not reappear for an extended time.
</p>
</div>
)}
</div>
)
})}
</div>
{/* Info footer */} {/* Info footer */}
<div className="flex items-start gap-2 mt-4 pt-3 border-t border-border"> <div className="flex items-start gap-2 mt-3 pt-3 border-t border-border">
<Info className="h-4 w-4 text-blue-400 shrink-0 mt-0.5" /> <Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
<p className="text-xs text-muted-foreground leading-relaxed"> <p className="text-[11px] text-muted-foreground leading-relaxed">
These settings apply when you dismiss a warning from the Health Monitor. These settings apply when you dismiss a warning from the Health Monitor.
Critical CPU temperature alerts always trigger regardless of settings to protect your hardware. Critical CPU temperature alerts always trigger regardless of settings to protect your hardware.
</p> </p>

View File

@@ -240,10 +240,15 @@ def save_health_settings():
except (ValueError, TypeError): except (ValueError, TypeError):
continue continue
# Retroactively sync all existing dismissed errors
# so changes are effective immediately, not just on next dismiss
synced_count = health_persistence.sync_dismissed_suppression()
return jsonify({ return jsonify({
'success': True, 'success': True,
'updated': updated, 'updated': updated,
'count': len(updated) 'count': len(updated),
'synced_dismissed': synced_count
}) })
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500

View File

@@ -752,12 +752,65 @@ class HealthPersistence:
conn.close() conn.close()
return {row[0]: row[1] for row in rows} return {row[0]: row[1] for row in rows}
def get_suppression_categories(self) -> List[Dict[str, Any]]: def sync_dismissed_suppression(self):
""" """
Get all health categories with their current suppression settings. Retroactively update all existing dismissed errors to match current
Used by the settings page to render the per-category configuration. user settings. Called when the user saves settings, so changes are
effective immediately on already-dismissed items.
For each dismissed error, looks up its category's configured hours
and updates the suppression_hours column to match.
""" """
category_labels = { conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
# Build reverse map: category -> setting_key
cat_to_setting = {v['category']: k
for k, v in self._get_category_labels().items()}
# Get all current suppression settings
current_settings = self.get_all_settings('suppress_')
# Get all dismissed (acknowledged) errors
cursor.execute('''
SELECT id, error_key, category, suppression_hours
FROM errors WHERE acknowledged = 1
''')
dismissed = cursor.fetchall()
updated_count = 0
for err_id, error_key, category, old_hours in dismissed:
setting_key = None
for skey, meta in self._get_category_labels().items():
if meta['category'] == category:
setting_key = skey
break
if not setting_key:
continue
stored = current_settings.get(setting_key)
new_hours = int(stored) if stored else self.DEFAULT_SUPPRESSION_HOURS
if new_hours != old_hours:
cursor.execute(
'UPDATE errors SET suppression_hours = ? WHERE id = ?',
(new_hours, err_id)
)
self._record_event(cursor, 'suppression_updated', error_key, {
'old_hours': old_hours,
'new_hours': new_hours,
'reason': 'settings_sync'
})
updated_count += 1
conn.commit()
conn.close()
return updated_count
def _get_category_labels(self) -> dict:
"""Internal helper for category label metadata."""
return {
'suppress_cpu': {'label': 'CPU Usage & Temperature', 'category': 'temperature', 'icon': 'cpu'}, 'suppress_cpu': {'label': 'CPU Usage & Temperature', 'category': 'temperature', 'icon': 'cpu'},
'suppress_memory': {'label': 'Memory & Swap', 'category': 'memory', 'icon': 'memory'}, 'suppress_memory': {'label': 'Memory & Swap', 'category': 'memory', 'icon': 'memory'},
'suppress_storage': {'label': 'Storage Mounts & Space', 'category': 'storage', 'icon': 'storage'}, 'suppress_storage': {'label': 'Storage Mounts & Space', 'category': 'storage', 'icon': 'storage'},
@@ -769,7 +822,13 @@ class HealthPersistence:
'suppress_updates': {'label': 'System Updates', 'category': 'updates', 'icon': 'updates'}, 'suppress_updates': {'label': 'System Updates', 'category': 'updates', 'icon': 'updates'},
'suppress_security': {'label': 'Security & Certificates', 'category': 'security', 'icon': 'security'}, 'suppress_security': {'label': 'Security & Certificates', 'category': 'security', 'icon': 'security'},
} }
def get_suppression_categories(self) -> List[Dict[str, Any]]:
"""
Get all health categories with their current suppression settings.
Used by the settings page to render the per-category configuration.
"""
category_labels = self._get_category_labels()
current_settings = self.get_all_settings('suppress_') current_settings = self.get_all_settings('suppress_')
result = [] result = []