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
}
const handleCustomSave = async (settingKey: string) => {
const raw = customValues[settingKey]
const hours = parseInt(raw, 10)
if (isNaN(hours) || hours < 1) return
await saveSuppression(settingKey, hours)
}
const saveSuppression = async (settingKey: string, hours: number) => {
setSavingHealth(settingKey)
try {
await fetchApi("/api/health/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [settingKey]: String(hours) }),
})
setSuppressionCategories(prev =>
prev.map(c => c.key === settingKey ? { ...c, hours } : c)
)
// Remove from custom values
setCustomValues(prev => { setCustomValues(prev => {
const next = { ...prev } const next = { ...prev }
delete next[settingKey] delete next[settingKey]
return next return next
}) })
setSavedHealth(settingKey) }
setTimeout(() => setSavedHealth(null), 2000)
const handleCustomConfirm = (settingKey: string) => {
const raw = customValues[settingKey]
const hours = parseInt(raw, 10)
if (isNaN(hours) || hours < 1) return
setPendingChanges(prev => ({ ...prev, [settingKey]: hours }))
setCustomValues(prev => {
const next = { ...prev }
delete next[settingKey]
return next
})
}
const handleCancelEdit = () => {
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 {
await fetchApi("/api/health/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
// Update local state with saved values
setSuppressionCategories(prev =>
prev.map(c => {
if (c.key in pendingChanges && pendingChanges[c.key] !== -2) {
return { ...c, hours: pendingChanges[c.key] }
}
return c
})
)
setPendingChanges({})
setCustomValues({})
setHealthEditMode(false)
setSavedAllHealth(true)
setTimeout(() => setSavedAllHealth(false), 3000)
} 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 justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<HeartPulse className="h-5 w-5 text-red-500" /> <HeartPulse className="h-5 w-5 text-red-500" />
<CardTitle>Health Monitor</CardTitle> <CardTitle>Health Monitor</CardTitle>
</div> </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>
<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,70 +311,77 @@ 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 */}
<div className="divide-y divide-border/50">
{suppressionCategories.map((cat) => { {suppressionCategories.map((cat) => {
const IconComp = CATEGORY_ICONS[cat.icon] || HeartPulse const IconComp = CATEGORY_ICONS[cat.icon] || HeartPulse
const isCustomMode = cat.hours === -2 || (cat.key in customValues) const effectiveHours = getEffectiveHours(cat)
const isPermanent = cat.hours === -1 const isCustomMode = effectiveHours === -2 || (cat.key in customValues)
const isLong = cat.hours >= 720 && cat.hours !== -1 const isPermanent = effectiveHours === -1
const selectVal = isCustomMode ? "custom" : getSelectValue(cat.hours, cat.key) const isLong = effectiveHours >= 720 && effectiveHours !== -1 && effectiveHours !== -2
const hasChanged = cat.key in pendingChanges && pendingChanges[cat.key] !== cat.hours
const selectVal = isCustomMode ? "custom" : getSelectValue(effectiveHours, cat.key)
return ( return (
<div key={cat.key} className="space-y-0"> <div key={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 justify-between gap-2 py-2 sm:py-2.5 px-1 sm:px-2">
<div className="flex items-center gap-2.5 min-w-0"> <div className="flex items-center gap-2 min-w-0 flex-1">
<IconComp className="h-4 w-4 text-muted-foreground shrink-0" /> <IconComp className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate">{cat.label}</span> <span className="text-xs sm:text-sm font-medium">{cat.label}</span>
{savingHealth === cat.key && ( {hasChanged && healthEditMode && (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground shrink-0" /> <span className="h-1.5 w-1.5 rounded-full bg-blue-500 shrink-0" />
)}
{savedHealth === cat.key && (
<Check className="h-3.5 w-3.5 text-green-500 shrink-0" />
)} )}
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="shrink-0">
{isCustomMode ? ( {isCustomMode && healthEditMode ? (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Input <Input
type="number" type="number"
min={1} min={1}
className="w-20 h-8 text-xs" className="w-16 sm:w-20 h-7 text-xs"
value={customValues[cat.key] || ""} value={customValues[cat.key] || ""}
onChange={(e) => setCustomValues(prev => ({ ...prev, [cat.key]: e.target.value }))} onChange={(e) => setCustomValues(prev => ({ ...prev, [cat.key]: e.target.value }))}
placeholder="Hours" placeholder="Hours"
/> />
<span className="text-xs text-muted-foreground">h</span> <span className="text-xs text-muted-foreground">h</span>
<button <button
className="h-8 px-2 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors" className="h-7 px-2 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors"
onClick={() => handleCustomSave(cat.key)} onClick={() => handleCustomConfirm(cat.key)}
disabled={savingHealth === cat.key}
> >
Save OK
</button> </button>
<button <button
className="h-8 px-2 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground" className="h-7 px-1.5 text-xs rounded-md text-muted-foreground hover:text-foreground transition-colors"
onClick={() => { onClick={() => {
setCustomValues(prev => { setCustomValues(prev => {
const next = { ...prev } const next = { ...prev }
delete next[cat.key] delete next[cat.key]
return next return next
}) })
loadHealthSettings() setPendingChanges(prev => {
const next = { ...prev }
delete next[cat.key]
return next
})
}} }}
> >
Cancel X
</button> </button>
</div> </div>
) : ( ) : (
<Select value={selectVal} onValueChange={(v) => handleSuppressionChange(cat.key, v)}> <Select
<SelectTrigger className="w-32 h-8 text-xs"> 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 /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -304,26 +396,26 @@ export function Settings() {
</div> </div>
</div> </div>
{/* Warning for Permanent */} {/* Notice for Permanent */}
{isPermanent && ( {isPermanent && healthEditMode && (
<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"> <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">
<AlertTriangle className="h-4 w-4 text-amber-500 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-amber-400/90 leading-relaxed"> <p className="text-[11px] text-blue-400/90 leading-relaxed">
Dismissed alerts for <span className="font-semibold">{cat.label}</span> will never reappear. Alerts for <span className="font-semibold">{cat.label}</span> will be permanently suppressed when dismissed.
{cat.category === "temperature" && ( {cat.category === "temperature" && (
<span className="block mt-1 text-amber-300 font-medium"> <span className="block mt-0.5 text-blue-300/80">
Note: Critical CPU temperature alerts will still trigger for hardware safety. Critical CPU temperature alerts will still trigger for hardware safety.
</span> </span>
)} )}
</p> </p>
</div> </div>
)} )}
{/* Warning for long custom duration (> 1 month) */} {/* Notice for long duration (> 1 month) */}
{isLong && !isPermanent && ( {isLong && healthEditMode && (
<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"> <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-4 w-4 text-amber-500 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-amber-400/90 leading-relaxed"> <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. Long suppression period. Dismissed alerts for this category will not reappear for an extended time.
</p> </p>
</div> </div>
@@ -331,11 +423,12 @@ export function Settings() {
</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'},
@@ -770,6 +823,12 @@ class HealthPersistence:
'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 = []