From beeeabc3771b0e6d4627ef86f5279ed3d157bed6 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Tue, 17 Feb 2026 16:49:26 +0100 Subject: [PATCH] Update health monitor --- AppImage/components/settings.tsx | 359 +++++++++++++++--------- AppImage/scripts/flask_health_routes.py | 7 +- AppImage/scripts/health_persistence.py | 69 ++++- 3 files changed, 296 insertions(+), 139 deletions(-) diff --git a/AppImage/components/settings.tsx b/AppImage/components/settings.tsx index 764bfeb1..4f037221 100644 --- a/AppImage/components/settings.tsx +++ b/AppImage/components/settings.tsx @@ -55,8 +55,10 @@ export function Settings() { // Health Monitor suppression settings const [suppressionCategories, setSuppressionCategories] = useState([]) const [loadingHealth, setLoadingHealth] = useState(true) - const [savingHealth, setSavingHealth] = useState(null) - const [savedHealth, setSavedHealth] = useState(null) + const [healthEditMode, setHealthEditMode] = useState(false) + const [savingAllHealth, setSavingAllHealth] = useState(false) + const [savedAllHealth, setSavedAllHealth] = useState(false) + const [pendingChanges, setPendingChanges] = useState>({}) const [customValues, setCustomValues] = useState>({}) useEffect(() => { @@ -118,58 +120,98 @@ export function Settings() { 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") { - // Show custom input -- don't save yet const current = suppressionCategories.find(c => c.key === settingKey) - setCustomValues(prev => ({ ...prev, [settingKey]: String(current?.hours || 48) })) - // Temporarily mark as custom in state - setSuppressionCategories(prev => - prev.map(c => c.key === settingKey ? { ...c, hours: -2 } : c) - ) + const effectiveHours = current ? getEffectiveHours(current) : 48 + setCustomValues(prev => ({ ...prev, [settingKey]: String(effectiveHours > 0 ? effectiveHours : 48) })) + // Mark as custom mode in pending + setPendingChanges(prev => ({ ...prev, [settingKey]: -2 })) return } const hours = parseInt(value, 10) if (isNaN(hours)) return - - await saveSuppression(settingKey, hours) + setPendingChanges(prev => ({ ...prev, [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 hours = parseInt(raw, 10) 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) => { - setSavingHealth(settingKey) + const handleCancelEdit = () => { + setHealthEditMode(false) + setPendingChanges({}) + setCustomValues({}) + } + + const handleSaveAllHealth = async () => { + // Merge pending changes into a payload: only changed categories + const payload: Record = {} + 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({ [settingKey]: String(hours) }), + body: JSON.stringify(payload), }) + // Update local state with saved values 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 - setCustomValues(prev => { - const next = { ...prev } - delete next[settingKey] - return next - }) - setSavedHealth(settingKey) - setTimeout(() => setSavedHealth(null), 2000) + setPendingChanges({}) + setCustomValues({}) + setHealthEditMode(false) + setSavedAllHealth(true) + setTimeout(() => setSavedAllHealth(false), 3000) } catch (err) { - console.error("Failed to save health setting:", err) + console.error("Failed to save health settings:", err) } finally { - setSavingHealth(null) + setSavingAllHealth(false) } } + const hasPendingChanges = Object.keys(pendingChanges).some( + k => pendingChanges[k] !== -2 + ) + return (
@@ -211,13 +253,56 @@ export function Settings() { {/* Health Monitor Settings */} -
- - Health Monitor +
+
+ + Health Monitor +
+ {!loadingHealth && ( +
+ {savedAllHealth && ( + + + Saved + + )} + {healthEditMode ? ( + <> + + + + ) : ( + + )} +
+ )}
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. @@ -226,116 +311,124 @@ export function Settings() {
) : ( -
+
{/* Header */} -
- Category - Suppression Duration +
+ Category + Suppression Duration
{/* Per-category rows */} - {suppressionCategories.map((cat) => { - const IconComp = CATEGORY_ICONS[cat.icon] || HeartPulse - const isCustomMode = cat.hours === -2 || (cat.key in customValues) - const isPermanent = cat.hours === -1 - const isLong = cat.hours >= 720 && cat.hours !== -1 - const selectVal = isCustomMode ? "custom" : getSelectValue(cat.hours, cat.key) - - return ( -
-
-
- - {cat.label} - {savingHealth === cat.key && ( - - )} - {savedHealth === cat.key && ( - - )} -
-
- {isCustomMode ? ( -
- setCustomValues(prev => ({ ...prev, [cat.key]: e.target.value }))} - placeholder="Hours" - /> - h - - -
- ) : ( - - )} -
-
- - {/* Warning for Permanent */} - {isPermanent && ( -
- -

- Dismissed alerts for {cat.label} will never reappear. - {cat.category === "temperature" && ( - - Note: Critical CPU temperature alerts will still trigger for hardware safety. - +

+ {suppressionCategories.map((cat) => { + const IconComp = CATEGORY_ICONS[cat.icon] || HeartPulse + const effectiveHours = getEffectiveHours(cat) + const isCustomMode = effectiveHours === -2 || (cat.key in customValues) + const isPermanent = effectiveHours === -1 + 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 ( +
+
+
+ + {cat.label} + {hasChanged && healthEditMode && ( + )} -

+
+
+ {isCustomMode && healthEditMode ? ( +
+ setCustomValues(prev => ({ ...prev, [cat.key]: e.target.value }))} + placeholder="Hours" + /> + h + + +
+ ) : ( + + )} +
- )} - - {/* Warning for long custom duration (> 1 month) */} - {isLong && !isPermanent && ( -
- -

- Long suppression period. Dismissed alerts for this category will not reappear for an extended time. -

-
- )} -
- ) - })} + + {/* Notice for Permanent */} + {isPermanent && healthEditMode && ( +
+ +

+ Alerts for {cat.label} will be permanently suppressed when dismissed. + {cat.category === "temperature" && ( + + Critical CPU temperature alerts will still trigger for hardware safety. + + )} +

+
+ )} + + {/* Notice for long duration (> 1 month) */} + {isLong && healthEditMode && ( +
+ +

+ Long suppression period. Dismissed alerts for this category will not reappear for an extended time. +

+
+ )} +
+ ) + })} +
{/* Info footer */} -
- -

+

+ +

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.

diff --git a/AppImage/scripts/flask_health_routes.py b/AppImage/scripts/flask_health_routes.py index 81910850..f5b9ebc5 100644 --- a/AppImage/scripts/flask_health_routes.py +++ b/AppImage/scripts/flask_health_routes.py @@ -240,10 +240,15 @@ def save_health_settings(): except (ValueError, TypeError): 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({ 'success': True, 'updated': updated, - 'count': len(updated) + 'count': len(updated), + 'synced_dismissed': synced_count }) except Exception as e: return jsonify({'error': str(e)}), 500 diff --git a/AppImage/scripts/health_persistence.py b/AppImage/scripts/health_persistence.py index ba97f1fa..d2473adc 100644 --- a/AppImage/scripts/health_persistence.py +++ b/AppImage/scripts/health_persistence.py @@ -752,12 +752,65 @@ class HealthPersistence: conn.close() 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. - Used by the settings page to render the per-category configuration. + Retroactively update all existing dismissed errors to match current + 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_memory': {'label': 'Memory & Swap', 'category': 'memory', 'icon': 'memory'}, '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_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_') result = []