diff --git a/AppImage/ProxMenux-1.2.1.2-beta.AppImage b/AppImage/ProxMenux-1.2.1.2-beta.AppImage index dcde77a4..1abc5eaa 100755 Binary files a/AppImage/ProxMenux-1.2.1.2-beta.AppImage and b/AppImage/ProxMenux-1.2.1.2-beta.AppImage differ diff --git a/AppImage/ProxMenux-Monitor.AppImage.sha256 b/AppImage/ProxMenux-Monitor.AppImage.sha256 index 5f402f15..ac203539 100644 --- a/AppImage/ProxMenux-Monitor.AppImage.sha256 +++ b/AppImage/ProxMenux-Monitor.AppImage.sha256 @@ -1 +1 @@ -0d74347d2feae2be4b8c6d62d6cd9b1b15b94ef431c088b5580560f6b4751594 ProxMenux-1.2.1.2-beta.AppImage +25564b65c641c292c24e45336b49b68a57c1f87de83d2867945a9a1255a6e8a4 ProxMenux-1.2.1.2-beta.AppImage diff --git a/AppImage/components/storage-overview.tsx b/AppImage/components/storage-overview.tsx index 519ca660..638505cb 100644 --- a/AppImage/components/storage-overview.tsx +++ b/AppImage/components/storage-overview.tsx @@ -373,6 +373,42 @@ export function StorageOverview() { const obsTypeLabel = (t: string) => ({ smart_error: 'SMART Error', io_error: 'I/O Error', filesystem_error: 'Filesystem Error', zfs_pool_error: 'ZFS Pool Error', connection_error: 'Connection Error' }[t] || t) + // Translate the short ATA/SCSI error codes that appear inside `{ ... }` + // in a raw kernel observation (e.g. `error: { IDNF }`) into a one-line + // human description. Mirrors `_translate_ata_error` in + // notification_events.py — kept here so the UI does not have to round-trip + // to the backend just to render a friendlier line under the raw message. + // Returns null when no recognised code is present, so the caller can hide + // the extra line for non-ATA observations. + const translateAtaError = (raw: string): string | null => { + if (!raw) return null + const ATA_CODES: Record = { + IDNF: 'Sector address not found — possible bad sector or cable issue', + UNC: 'Uncorrectable read error — bad sector', + ABRT: 'Command aborted by drive', + AMNF: 'Address mark not found — surface damage', + TK0NF: 'Track 0 not found — drive hardware failure', + BBK: 'Bad block detected', + ICRC: 'Interface CRC error — cable or connector issue', + MC: 'Media changed', + MCR: 'Media change requested', + WP: 'Write protected', + } + const m = raw.match(/\{\s*([A-Z0-9 ]+)\s*\}/) + if (!m) return null + const codes = m[1].split(/\s+/).filter(Boolean) + const seen = new Set() + const out: string[] = [] + for (const c of codes) { + const desc = ATA_CODES[c] + if (desc && !seen.has(c)) { + seen.add(c) + out.push(desc) + } + } + return out.length ? out.join('; ') : null + } + const getStorageTypeBadge = (type: string) => { const typeColors: Record = { pbs: "bg-purple-500/10 text-purple-500 border-purple-500/20", @@ -2128,27 +2164,30 @@ export function StorageOverview() { {diskObservations.map((obs) => (
- {/* Header with type badge */} + {/* Header with type badge — always blue. + The earlier red/blue split-by-severity was + confusing here because the Observations + panel is a *history* view, not a live + alert; the severity already reaches the + user through the notification channels. + The card just records what happened. */}
- + {obsTypeLabel(obs.error_type)}
{/* Error message - responsive text wrap */} -

+

{obs.raw_message}

+ {translateAtaError(obs.raw_message) && ( +

+ ↳ {translateAtaError(obs.raw_message)} +

+ )} {/* Dates - stacked on mobile, inline on desktop */}
diff --git a/AppImage/scripts/notification_manager.py b/AppImage/scripts/notification_manager.py index bfcf5df4..7839922d 100644 --- a/AppImage/scripts/notification_manager.py +++ b/AppImage/scripts/notification_manager.py @@ -1606,9 +1606,17 @@ class NotificationManager: State held in-memory: `self._was_in_quiet_hours[ch_name]`. On first run after restart all channels start as "unknown" — we - seed with the current window status WITHOUT firing a summary, - so a Monitor restart in the middle of someone's quiet window - doesn't trigger a fake close-of-window flush. + seed with the current window status WITHOUT firing a summary + when the channel is currently IN its quiet window (a Monitor + restart mid-window must not look like a "close" transition). + + Recovery seed: if the channel is currently OUT of the quiet + window AND there are leftover rows in `quiet_pending`, those + rows belong to a window that closed during a restart — they + would otherwise stay buffered forever because the seed marks + the channel as "out" without ever seeing the in→out edge. + Flush them now so the user gets their overnight summary even + when an update lands right as the window closes. """ if not hasattr(self, '_was_in_quiet_hours'): self._was_in_quiet_hours = {} @@ -1618,8 +1626,16 @@ class NotificationManager: previously_in = self._was_in_quiet_hours.get(ch_name) self._was_in_quiet_hours[ch_name] = currently_in - # Seed run (no prior state) — don't fire anything. + # Seed run (no prior state). if previously_in is None: + # Recovery: leftover buffer from a window that closed + # during a restart must still reach the user. + if not currently_in and self._has_pending_quiet_rows(ch_name): + try: + self._flush_quiet_for_channel(ch_name, channel) + except Exception as e: + print(f"[NotificationManager] quiet recovery flush " + f"failed for {ch_name}: {e}") continue # Still in the window → just buffer. if currently_in: @@ -1632,6 +1648,28 @@ class NotificationManager: print(f"[NotificationManager] quiet flush failed for " f"{ch_name}: {e}") + def _has_pending_quiet_rows(self, ch_name: str) -> bool: + """Cheap existence check used by the recovery branch of + `_maybe_flush_quiet_hours`. We don't reuse `_flush_*` for this + because a no-op flush call would still open a connection and + do the SELECT — a single COUNT keeps the seed pass O(1) per + channel when nothing is pending.""" + try: + conn = sqlite3.connect(str(DB_PATH), timeout=10) + conn.execute('PRAGMA journal_mode=WAL') + cursor = conn.cursor() + cursor.execute( + 'SELECT 1 FROM quiet_pending WHERE channel = ? LIMIT 1', + (ch_name,), + ) + row = cursor.fetchone() + conn.close() + return row is not None + except Exception as e: + print(f"[NotificationManager] quiet pending probe failed for " + f"{ch_name}: {e}") + return False + def _flush_quiet_for_channel(self, ch_name: str, channel: Any) -> None: """Send a single grouped summary of everything buffered for `ch_name` during the just-closed quiet window, then drop the diff --git a/AppImage/scripts/proxmox_known_errors.py b/AppImage/scripts/proxmox_known_errors.py index cc01b3ee..d8eeff3b 100644 --- a/AppImage/scripts/proxmox_known_errors.py +++ b/AppImage/scripts/proxmox_known_errors.py @@ -317,25 +317,34 @@ def get_error_context(text: str, category: Optional[str] = None, detail_level: s if not error: return None + # NOTE: we intentionally do NOT emit a "Severity:" line here. + # The catalogue's severity is the *typical* severity of a class + # of error, not the *actual* severity of the event the user is + # looking at. A SATA cable warning (rate 11–100 errors/24h, SMART + # PASSED) used to render "Severity: CRITICAL" in the body because + # the catalogue says SMART_FAILED is critical generically — that + # contradicted the WARNING badge on the notification header and + # frightened operators unnecessarily. The event-level severity + # (computed by `_check_disk_io` with the tiered model) is already + # carried by the notification's own severity field; repeating a + # different value here is noise at best, misinformation at worst. if detail_level == "minimal": return f"Known issue: {error['cause']}" - + elif detail_level == "standard": lines = [ f"KNOWN PROXMOX ERROR DETECTED:", f" Cause: {error['cause']}", - f" Severity: {error['severity'].upper()}", f" Solution: {error['solution']}" ] if error.get("url"): lines.append(f" Docs: {error['url']}") return "\n".join(lines) - + else: # detailed lines = [ f"KNOWN PROXMOX ERROR DETECTED:", f" Cause: {error.get('cause_detailed', error['cause'])}", - f" Severity: {error['severity'].upper()}", f" Solution: {error.get('solution_detailed', error['solution'])}" ] if error.get("url"):