diff --git a/AppImage/ProxMenux-1.2.2.3-beta.AppImage b/AppImage/ProxMenux-1.2.2.3-beta.AppImage index 08fb9913..a7c79504 100755 Binary files a/AppImage/ProxMenux-1.2.2.3-beta.AppImage and b/AppImage/ProxMenux-1.2.2.3-beta.AppImage differ diff --git a/AppImage/ProxMenux-Monitor.AppImage.sha256 b/AppImage/ProxMenux-Monitor.AppImage.sha256 index 50036eb4..6ec27afd 100644 --- a/AppImage/ProxMenux-Monitor.AppImage.sha256 +++ b/AppImage/ProxMenux-Monitor.AppImage.sha256 @@ -1 +1 @@ -8db4067d1bcd3d7cd440d4de72d449e8b47480787f3650b35213c83a4bfade40 ProxMenux-1.2.2.3-beta.AppImage +891b6218f11ad99c1159d1cd187648bb104ec9c96e38927ef926307110cf0d4d diff --git a/AppImage/components/host-backup.tsx b/AppImage/components/host-backup.tsx index ddb6b76b..43371bb5 100644 --- a/AppImage/components/host-backup.tsx +++ b/AppImage/components/host-backup.tsx @@ -208,6 +208,63 @@ interface PreflightReport { const fetcher = async (url: string) => fetchApi(url) +// Detect encrypted-backup key errors coming from proxmox-backup-client +// and pull the manifest + provided fingerprints out of the message so +// the UI can render them prominently instead of burying the info +// inside a raw stack-trace-shaped string. Returns null when the error +// is not encryption-related — the caller should fall back to plain +// text rendering in that case. +type KeyfileError = { + manifestFp: string | null + providedFp: string | null + raw: string +} +const parseKeyfileError = (raw: string): KeyfileError | null => { + if (!raw) return null + const lower = raw.toLowerCase() + if (!/missing key|wrong key|was created with key|manifest.?s key|does not match|no key found|unable to (load|read) key|failed to decrypt/.test(lower)) { + return null + } + const manifest = raw.match(/(?:was created with key|manifest'?s key)\s+([0-9a-f:]+)/i) + const provided = raw.match(/provided key\s+([0-9a-f:]+)/i) + return { + manifestFp: manifest?.[1] ?? null, + providedFp: provided?.[1] ?? null, + raw, + } +} + +const KeyfileErrorBlock: React.FC<{ err: KeyfileError; className?: string }> = ({ err, className }) => ( +
+
+ +
+
Encrypted backup — wrong keyfile on this host
+
+ The keyfile installed here does not match the one used to create this backup, so PBS refuses to open it. +
+ {err.manifestFp && ( +
+
Required (manifest)
+
{err.manifestFp}
+
+ )} + {err.providedFp && ( +
+
Currently installed
+
{err.providedFp}
+
+ )} +
+ Import the correct keyfile from{" "} + Backup configuration → Destinations → PBS row → Upload + {" "}(you may need to Delete the current one first) and retry. +
+
+
+
+) + const formatMtime = (mtime: number) => new Date(mtime * 1000).toLocaleString(undefined, { year: "numeric", @@ -1956,11 +2013,16 @@ function InspectModal({ )} - {error && ( -
- {error} -
- )} + {error && (() => { + const kf = parseKeyfileError(error) + return kf ? ( + + ) : ( +
+ {error} +
+ ) + })()} {/* Encrypted-backup gate: no local keyfile → block the three actions until the operator imports the key. The @@ -2110,24 +2172,30 @@ function InspectModal({ /> {/* ── Restore error toast (prepare failed) ─────────────────── */} - {restoreError && ( - setRestoreError(null)}> - - - - - Restore preparation failed - - - {restoreError} - - -
- -
-
-
- )} + {restoreError && (() => { + const kf = parseKeyfileError(restoreError) + return ( + setRestoreError(null)}> + + + + + {kf ? "Restore blocked — encrypted backup" : "Restore preparation failed"} + + {!kf && ( + + {restoreError} + + )} + + {kf && } +
+ +
+
+
+ ) + })()} {/* ── Restore options modal: opens only AFTER the prepare step, so the Custom checklist already knows what's inside. ──── */} @@ -8022,11 +8090,16 @@ function ArchiveContentsModal({ )} - {error && !loading && ( -
- {error} -
- )} + {error && !loading && (() => { + const kf = parseKeyfileError(error) + return kf ? ( +
+ ) : ( +
+ {error} +
+ ) + })()} {data && !loading && ( diff --git a/AppImage/components/ui/switch.tsx b/AppImage/components/ui/switch.tsx index ba1943e3..8adcaa7a 100644 --- a/AppImage/components/ui/switch.tsx +++ b/AppImage/components/ui/switch.tsx @@ -11,7 +11,7 @@ const Switch = React.forwardRef< >(({ className, ...props }, ref) => ( 200: + title = title[:197] + '…' lines = [] for d in degraded: lines.append(f" [{d['status']}] {d['category']}: {d['reason']}") diff --git a/AppImage/scripts/health_monitor.py b/AppImage/scripts/health_monitor.py index f9d4cb7d..a9a7ea24 100644 --- a/AppImage/scripts/health_monitor.py +++ b/AppImage/scripts/health_monitor.py @@ -59,6 +59,47 @@ def _perf_log(section: str, elapsed_ms: float): print(f"[PERF] {section} = {elapsed_ms:.1f}ms") +# Cap notification `reason` strings so a spike (e.g. 40 CTs above 85% +# rootfs) can't blow past Telegram's 4096-byte cap or truncate the +# subject line in email clients. First 8 names + a tail count keeps +# the message actionable without listing every offender. +_NAME_LIST_LIMIT = 8 + +def _fmt_name_list(items, limit: int = _NAME_LIST_LIMIT, sep: str = ", ") -> str: + """Join up to `limit` items into a comma-separated list; when the + input is longer, append " …and N more" so the reader sees both a + concrete sample AND that the list is truncated. + + items: iterable of strings. + """ + seq = list(items) + if len(seq) <= limit: + return sep.join(seq) + head = sep.join(seq[:limit]) + return f"{head} …and {len(seq) - limit} more" + + +def _fmt_entity_and_summary(items, singular: str, plural: str, limit: int = _NAME_LIST_LIMIT): + """Return a (title_entity, reason_summary) tuple for a list of + affected entities. Used by health checks that want to surface both + a headline entity in the notification title AND a full summary in + the body. + + - 1 item → ("Foo", "Foo ") + - 2 items → ("Foo +1", "2 : Foo, Bar") + - N items → ("Foo +N-1", "N : Foo, Bar, … …and M more") + """ + seq = list(items) + n = len(seq) + if n == 0: + return "", "" + if n == 1: + return seq[0], f"{seq[0]} {singular}" + title_entity = f"{seq[0]} +{n - 1}" + reason = f"{n} {plural}: {_fmt_name_list(seq, limit)}" + return title_entity, reason + + # USB-NVMe bridges (ASMedia, JMicron, Realtek) answer plain smartctl with # the *bridge* identity — model shows as "ASMT 2462 NVME" and there is no # temperature. Only `-d snt*` passes through to the actual NVMe controller @@ -1148,16 +1189,16 @@ class HealthMonitor: # Severity: CRITICAL > WARNING > UNKNOWN (capped at WARNING) > INFO > OK if critical_issues: overall = 'CRITICAL' - summary = '; '.join(critical_issues[:3]) + summary = _fmt_name_list(critical_issues, sep='; ') elif warning_issues: overall = 'WARNING' - summary = '; '.join(warning_issues[:3]) + summary = _fmt_name_list(warning_issues, sep='; ') elif unknown_issues: overall = 'WARNING' # UNKNOWN caps at WARNING, never escalates to CRITICAL - summary = '; '.join(unknown_issues[:3]) + summary = _fmt_name_list(unknown_issues, sep='; ') elif info_issues: overall = 'OK' # INFO statuses don't degrade overall health - summary = '; '.join(info_issues[:3]) + summary = _fmt_name_list(info_issues, sep='; ') else: overall = 'OK' summary = 'All systems operational' @@ -1184,15 +1225,24 @@ class HealthMonitor: cat_status = cat_data.get('status', 'OK') prev_status = previous_details.get(cat_key, 'OK') if prev_status != cat_status and cat_status in ('WARNING', 'CRITICAL'): + # `entity` gives the notification title a concrete + # subject (e.g. "Tuxis (dir)") instead of the bare + # category name. Checks that surface a single + # affected object populate it in _check_XXX; the + # rest just fall through and the template renders + # without the placeholder thanks to _SafeDict. + event_data = { + 'previous': prev_status, + 'current': cat_status, + 'reason': cat_data.get('reason', ''), + } + if cat_data.get('entity'): + event_data['entity'] = cat_data['entity'] health_persistence.emit_event( event_type='state_change', category=cat_key, severity=cat_status, - data={ - 'previous': prev_status, - 'current': cat_status, - 'reason': cat_data.get('reason', '') - } + data=event_data, ) self._last_overall_status = overall @@ -2084,22 +2134,22 @@ class HealthMonitor: # persistent WARNING after user has acknowledged. return { 'status': 'OK', - 'reason': '; '.join(issues[:3]), + 'reason': _fmt_name_list(issues, sep='; '), '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() ) - + return { 'status': 'CRITICAL' if has_critical else 'WARNING', - 'reason': '; '.join(issues[:3]), + 'reason': _fmt_name_list(issues, sep='; '), 'details': storage_details, 'checks': checks } @@ -3031,10 +3081,17 @@ class HealthMonitor: } has_critical = any(d.get('status') == 'CRITICAL' for d in active_results.values()) - + + # Surface disk names in the reason so notifications identify + # which device(s) took the hit rather than just a count. + disk_names = sorted(active_results.keys()) + entity, summary = _fmt_entity_and_summary( + disk_names, singular='has errors', plural='disks with errors', + ) return { 'status': 'CRITICAL' if has_critical else 'WARNING', - 'reason': f"{len(active_results)} disk(s) with errors", + 'reason': summary, + 'entity': entity, 'details': disk_results } @@ -3178,11 +3235,11 @@ class HealthMonitor: return { 'status': 'CRITICAL' if has_critical else 'WARNING', - 'reason': '; '.join(issues[:2]), + 'reason': _fmt_name_list(issues, sep='; '), 'details': interface_details, 'checks': checks } - + except Exception as e: print(f"[HealthMonitor] Network check failed: {e}") return {'status': 'UNKNOWN', 'reason': f'Network check unavailable: {str(e)}', 'checks': {}, 'dismissable': True} @@ -3522,10 +3579,10 @@ class HealthMonitor: return { 'status': 'CRITICAL' if has_critical else 'WARNING', - 'reason': '; '.join(issues[:3]), + 'reason': _fmt_name_list(issues, sep='; '), 'details': vm_details } - + except Exception as e: print(f"[HealthMonitor] VMs/CTs check failed: {e}") return {'status': 'UNKNOWN', 'reason': f'VM/CT check unavailable: {str(e)}', 'checks': {}, 'dismissable': True} @@ -3752,11 +3809,11 @@ class HealthMonitor: return { 'status': 'CRITICAL' if has_critical else 'WARNING', - 'reason': '; '.join(issues[:3]), + 'reason': _fmt_name_list(issues, sep='; '), 'details': vm_details, 'checks': checks } - + except Exception as e: print(f"[HealthMonitor] VMs/CTs persistence check failed: {e}") return {'status': 'UNKNOWN', 'reason': f'VM/CT check unavailable: {str(e)}', 'checks': {}, 'dismissable': True} @@ -4367,7 +4424,12 @@ class HealthMonitor: 'log_persistent_errors': {'active': persistent_count > 0, 'severity': 'WARNING', 'reason': f'{persistent_count} recurring pattern(s) over 15+ min:\n' + '\n'.join(f' - {s}' for s in persist_samples) if persistent_count else ''}, 'log_critical_errors': {'active': unique_critical_count > 0, 'severity': 'CRITICAL', - 'reason': f'{unique_critical_count} critical error(s) found', 'dismissable': True}, + 'reason': ( + self._enrich_critical_log_reason(next(iter(critical_errors_found.values()))) + if unique_critical_count == 1 + else f'{unique_critical_count} critical errors:\n' + self._enrich_critical_log_reason(next(iter(critical_errors_found.values()))) + ) if unique_critical_count > 0 else '', + 'dismissable': True}, } # Track which sub-checks were dismissed @@ -4457,7 +4519,7 @@ class HealthMonitor: detail = v.get('detail', '') if detail: active_reasons.append(detail) - reason = '; '.join(active_reasons[:3]) if active_reasons else None + reason = _fmt_name_list(active_reasons, sep='; ') if active_reasons else None log_result = {'status': status, 'checks': log_checks} if reason: @@ -5210,7 +5272,7 @@ class HealthMonitor: overall_status = 'CRITICAL' if has_critical else 'WARNING' return { 'status': overall_status, - 'reason': '; '.join(active_issues[:2]), + 'reason': _fmt_name_list(active_issues, sep='; '), 'checks': checks } @@ -5748,11 +5810,22 @@ class HealthMonitor: # Determine overall status based on non-excluded issues only if real_unavailable: + # Build a name list like "Tuxis (dir), backups-nfs (nfs)" + # so both the notification body and the (headline) title + # can tell the user which storages actually went down — + # the previous "N storage(s) unavailable" wording forced + # them to open the UI to find out. + labels = [f"{s['name']} ({s.get('type', 'unknown')})" for s in real_unavailable] + entity, summary = _fmt_entity_and_summary( + labels, singular='unavailable', plural='Proxmox storages unavailable', + ) # During grace period, return INFO instead of CRITICAL if in_grace_period: + grace_summary = f"{summary} (startup)" if len(labels) > 1 else f"{labels[0]} not yet available (startup)" return { 'status': 'INFO', - 'reason': f'{len(real_unavailable)} storage(s) not yet available (startup)', + 'reason': grace_summary, + 'entity': entity, 'details': storage_details, 'checks': checks, 'grace_period': True @@ -5760,7 +5833,8 @@ class HealthMonitor: else: return { 'status': 'CRITICAL', - 'reason': f'{len(real_unavailable)} Proxmox storage(s) unavailable', + 'reason': summary, + 'entity': entity, 'details': storage_details, 'checks': checks } @@ -5941,15 +6015,23 @@ class HealthMonitor: health_persistence.clear_error(ek) if critical_targets: + entity, summary = _fmt_entity_and_summary( + critical_targets, singular='is stale', plural='remote mounts stale', + ) return { 'status': 'CRITICAL', - 'reason': f'{len(critical_targets)} remote mount(s) stale: {", ".join(critical_targets[:3])}', + 'reason': summary, + 'entity': entity, 'checks': checks, } if warning_targets: + entity, summary = _fmt_entity_and_summary( + warning_targets, singular='is read-only', plural='remote mounts read-only', + ) return { 'status': 'WARNING', - 'reason': f'{len(warning_targets)} remote mount(s) read-only: {", ".join(warning_targets[:3])}', + 'reason': summary, + 'entity': entity, 'checks': checks, } return { @@ -6082,15 +6164,19 @@ class HealthMonitor: if not checks: return None if critical_cts: + entity, _ = _fmt_entity_and_summary(critical_cts, 'x', 'x') return { 'status': 'CRITICAL', - 'reason': f'{len(critical_cts)} CT(s) at >{CRIT_PCT}% rootfs: {", ".join(critical_cts[:3])}', + 'reason': f'{len(critical_cts)} CT(s) at >{CRIT_PCT}% rootfs: {_fmt_name_list(critical_cts)}', + 'entity': entity, 'checks': checks, } if warning_cts: + entity, _ = _fmt_entity_and_summary(warning_cts, 'x', 'x') return { 'status': 'WARNING', - 'reason': f'{len(warning_cts)} CT(s) at >{WARN_PCT}% rootfs: {", ".join(warning_cts[:3])}', + 'reason': f'{len(warning_cts)} CT(s) at >{WARN_PCT}% rootfs: {_fmt_name_list(warning_cts)}', + 'entity': entity, 'checks': checks, } return { @@ -6204,15 +6290,19 @@ class HealthMonitor: health_persistence.clear_error(ek) if critical_labels: + entity, _ = _fmt_entity_and_summary(critical_labels, 'x', 'x') return { 'status': 'CRITICAL', - 'reason': f'{len(critical_labels)} CT mount(s) ≥{crit_pct:.0f}%: {", ".join(critical_labels[:3])}', + 'reason': f'{len(critical_labels)} CT mount(s) ≥{crit_pct:.0f}%: {_fmt_name_list(critical_labels)}', + 'entity': entity, 'checks': checks, } if warning_labels: + entity, _ = _fmt_entity_and_summary(warning_labels, 'x', 'x') return { 'status': 'WARNING', - 'reason': f'{len(warning_labels)} CT mount(s) ≥{warn_pct:.0f}%: {", ".join(warning_labels[:3])}', + 'entity': entity, + 'reason': f'{len(warning_labels)} CT mount(s) ≥{warn_pct:.0f}%: {_fmt_name_list(warning_labels)}', 'checks': checks, } return { @@ -6323,15 +6413,19 @@ class HealthMonitor: if not checks: return None # No block storages on this host if critical_labels: + entity, _ = _fmt_entity_and_summary(critical_labels, 'x', 'x') return { 'status': 'CRITICAL', - 'reason': f'{len(critical_labels)} PVE storage(s) ≥{crit_pct:.0f}%: {", ".join(critical_labels[:3])}', + 'reason': f'{len(critical_labels)} PVE storage(s) ≥{crit_pct:.0f}%: {_fmt_name_list(critical_labels)}', + 'entity': entity, 'checks': checks, } if warning_labels: + entity, _ = _fmt_entity_and_summary(warning_labels, 'x', 'x') return { 'status': 'WARNING', - 'reason': f'{len(warning_labels)} PVE storage(s) ≥{warn_pct:.0f}%: {", ".join(warning_labels[:3])}', + 'reason': f'{len(warning_labels)} PVE storage(s) ≥{warn_pct:.0f}%: {_fmt_name_list(warning_labels)}', + 'entity': entity, 'checks': checks, } return { @@ -6431,15 +6525,19 @@ class HealthMonitor: health_persistence.clear_error(ek) if critical_labels: + entity, _ = _fmt_entity_and_summary(critical_labels, 'x', 'x') return { 'status': 'CRITICAL', - 'reason': f'{len(critical_labels)} ZFS pool(s) ≥{crit_pct:.0f}%: {", ".join(critical_labels[:3])}', + 'reason': f'{len(critical_labels)} ZFS pool(s) ≥{crit_pct:.0f}%: {_fmt_name_list(critical_labels)}', + 'entity': entity, 'checks': checks, } if warning_labels: + entity, _ = _fmt_entity_and_summary(warning_labels, 'x', 'x') return { 'status': 'WARNING', - 'reason': f'{len(warning_labels)} ZFS pool(s) ≥{warn_pct:.0f}%: {", ".join(warning_labels[:3])}', + 'reason': f'{len(warning_labels)} ZFS pool(s) ≥{warn_pct:.0f}%: {_fmt_name_list(warning_labels)}', + 'entity': entity, 'checks': checks, } return { diff --git a/AppImage/scripts/health_persistence.py b/AppImage/scripts/health_persistence.py index 4c595808..41f1a1a8 100644 --- a/AppImage/scripts/health_persistence.py +++ b/AppImage/scripts/health_persistence.py @@ -487,7 +487,55 @@ class HealthPersistence: conn.close() - def record_error(self, error_key: str, category: str, severity: str, + @staticmethod + def _entity_from_details(details: Optional[Dict]) -> str: + """Derive a short display identifier for the affected object from + the details blob so notification titles can name it. Returns '' + when no per-object identity is available (aggregate-only checks). + + Order of preference matches what users actually recognize: + friendly name first, then id-with-name, then bare id, then path. + """ + if not details: + return '' + d = details + # Storage / mounts / pools + if d.get('storage_name'): + st = d.get('storage_type') or d.get('type') + return f"{d['storage_name']} ({st})" if st else d['storage_name'] + if d.get('pool_name'): + return d['pool_name'] + if d.get('mount_point'): + return d['mount_point'] + if d.get('mount_target'): + return d['mount_target'] + # VMs / CTs + if d.get('vm_name') and d.get('vmid'): + return f"{d['vm_name']} ({d['vmid']})" + if d.get('ct_name') and d.get('vmid'): + return f"{d['ct_name']} ({d['vmid']})" + if d.get('vmid'): + return str(d['vmid']) + # Disks / devices + if d.get('device'): + return d['device'] + if d.get('disk'): + return d['disk'] + # Network / services + if d.get('interface'): + return d['interface'] + if d.get('iface'): + return d['iface'] + if d.get('service_name'): + return d['service_name'] + if d.get('service'): + return d['service'] + # Generic fallback + if d.get('name'): + return str(d['name']) + return '' + + def record_error(self, error_key: str, category: str, severity: str, reason: str, details: Optional[Dict] = None) -> Dict[str, Any]: """ Record or update an error. @@ -601,6 +649,8 @@ class HealthPersistence: event_info = {'type': 'new', 'needs_notification': True} self._record_event(cursor, 'new', error_key, {'severity': severity, 'reason': reason, + 'entity': self._entity_from_details(details), + 'details': details, 'note': 'Re-triggered after suppression expired'}) conn.commit() return event_info @@ -653,9 +703,15 @@ class HealthPersistence: conn.commit() return event_info - # Record event + # Record event with the caller-supplied `details` so downstream + # notification templates can render the affected object's name + # (storage_name, vm_name, mount_point, …) in the title/body + # via `_SafeDict`. Without this, only the aggregate `reason` + # travels and titles fall back to bare category labels. self._record_event(cursor, event_info['type'], error_key, - {'severity': severity, 'reason': reason}) + {'severity': severity, 'reason': reason, + 'entity': self._entity_from_details(details), + 'details': details}) conn.commit() finally: @@ -690,7 +746,26 @@ class HealthPersistence: ''', (now, reason, error_key)) if cursor.rowcount > 0: - self._record_event(cursor, 'resolved', error_key, {'reason': reason}) + # Reload the resolved error's details so the resolution + # event can name the same entity that was named when it + # was created — otherwise "Storage 'Tuxis' unavailable" + # comes back as "Resolved - Storage" with no identity. + cursor.execute( + 'SELECT details FROM errors WHERE error_key = ? ORDER BY id DESC LIMIT 1', + (error_key,), + ) + row = cursor.fetchone() + stored_details = None + if row and row[0]: + try: + stored_details = json.loads(row[0]) + except Exception: + stored_details = None + self._record_event(cursor, 'resolved', error_key, { + 'reason': reason, + 'entity': self._entity_from_details(stored_details), + 'details': stored_details or {}, + }) conn.commit() diff --git a/AppImage/scripts/notification_events.py b/AppImage/scripts/notification_events.py index bee6c865..e34ebeaa 100644 --- a/AppImage/scripts/notification_events.py +++ b/AppImage/scripts/notification_events.py @@ -2544,12 +2544,17 @@ class PollingCollector: if not error_key: continue + # Keep the raw `details` blob in the tracker so recovery + # notifications later can name the same entity ("Storage + # 'Tuxis'") that the original alert did — without this, the + # resolved notification defaults to the bare category. current_keys[error_key] = { 'category': error.get('category', ''), 'severity': error.get('severity', 'WARNING'), 'reason': error.get('reason', ''), 'first_seen': error.get('first_seen', ''), 'error_key': error_key, + 'details': error.get('details'), } category = error.get('category', '') severity = error.get('severity', 'WARNING') @@ -2880,6 +2885,18 @@ class PollingCollector: 'duration': duration_label, 'is_recovery': True, } + # Spread the original details blob so the resolved notification + # can use the same {storage_name}/{vm_name}/{device} placeholders + # the alert did. Mirrors what the alert path does on line ~2718. + resolved_details = old_meta.get('details') + if isinstance(resolved_details, str): + try: + resolved_details = json.loads(resolved_details) + except (json.JSONDecodeError, TypeError): + resolved_details = None + if isinstance(resolved_details, dict): + for _k, _v in resolved_details.items(): + data.setdefault(_k, _v) self._queue.put(NotificationEvent( 'error_resolved', 'OK', data, source='health', @@ -3503,13 +3520,20 @@ class PollingCollector: about the final shape.""" item_type = item.get('type', '') update = item.get('update_check', {}) or {} + # Version fallbacks: if `latest` is missing (checker couldn't + # determine an upstream — network hiccup, or the app itself + # isn't in the update list because only sidecar packages need + # updating), anchor to the current version so template + # placeholders never render as "v" with nothing after. + _cur = item.get('current_version') or '' + _lat = update.get('latest') or _cur or 'unknown' common = { 'hostname': self._hostname, 'name': item.get('name') or item.get('id'), 'menu_label': item.get('menu_label') or '', 'menu_script': item.get('menu_script') or '', - 'current_version': item.get('current_version') or '', - 'latest_version': update.get('latest') or '', + 'current_version': _cur or 'unknown', + 'latest_version': _lat, } if item_type == 'oci_app': @@ -3519,12 +3543,24 @@ class PollingCollector: f" → {p.get('latest', '?')}" for p in packages ] + # Decide the wording based on whether Tailscale itself moved. + # When current == latest, showing "v1.90 → v1.90" reads as a + # bug ("update to same version?"); switch to a neutral line + # that makes it clear only sidecar packages are updating. + if _cur and _lat and _cur == _lat: + update_title_suffix = f' — v{_cur} (packages only)' + version_line = f'🔹 Tailscale: v{_cur} (unchanged — only sidecar packages need updating)' + else: + update_title_suffix = f' — v{_lat}' + version_line = f'🔹 Current Tailscale: v{_cur or "unknown"} → 🟢 Latest: v{_lat}' data = { **common, 'app_id': item.get('id', '').removeprefix('oci:'), 'app_name': common['name'], 'package_count': len(packages), 'package_list': '\n'.join(pkg_lines) or ' (no detail)', + 'update_title_suffix': update_title_suffix, + 'version_line': version_line, } return 'secure_gateway_update_available', data diff --git a/AppImage/scripts/notification_templates.py b/AppImage/scripts/notification_templates.py index 1c7d69aa..6fd75cb8 100644 --- a/AppImage/scripts/notification_templates.py +++ b/AppImage/scripts/notification_templates.py @@ -448,35 +448,44 @@ TEMPLATES = { # status oscillation (OK->WARNING->OK) which creates noise. # The health_persistent and new_error templates cover this better. 'state_change': { - 'title': '{hostname}: {category} changed to {current}', + 'title': '{hostname}: {category} changed to {current}{entity_suffix}', 'body': '{category} status changed from {previous} to {current}.\n{reason}', 'label': 'Health state changed', 'group': 'health', 'default_enabled': False, }, 'new_error': { - 'title': '{hostname}: New {severity} - {category}', + 'title': '{hostname}: New {severity} - {category}{entity_suffix}', 'body': '{reason}', 'label': 'New health issue', 'group': 'health', 'default_enabled': True, }, 'error_resolved': { - 'title': '{hostname}: Resolved - {category}', + # `{entity}` is populated by health_persistence.resolve_error() + # (via _entity_from_details) and by PollingCollector's spread of + # the original details blob. When absent, _SafeDict elides the + # placeholder and the title collapses back to "Resolved - " + # without a trailing dash. + 'title': '{hostname}: Resolved - {category}{entity_suffix}', 'body': 'The {category} issue has been resolved.\n{reason}\n\U0001F6A6 Previous severity: {original_severity}\n\u23F1\uFE0F Duration: {duration}', 'label': 'Recovery notification', 'group': 'health', 'default_enabled': True, }, 'error_escalated': { - 'title': '{hostname}: Escalated to {severity} - {category}', + 'title': '{hostname}: Escalated to {severity} - {category}{entity_suffix}', 'body': '{reason}', 'label': 'Health issue escalated', 'group': 'health', 'default_enabled': True, }, 'health_degraded': { - 'title': '{hostname}: Health check degraded', + # flask_server._health_collector already builds the rich title + # (e.g. "prox: Storage CRITICAL — Tuxis (dir)") and passes it in + # data['title']. Fall back to the constant when absent so hand- + # rolled callers keep working. + 'title': '{title_or_default}', 'body': '{reason}', 'label': 'Health check degraded', 'group': 'health', @@ -787,7 +796,7 @@ TEMPLATES = { 'default_enabled': True, }, 'pci_passthrough_conflict': { - 'title': '{hostname}: PCIe device conflict detected', + 'title': '{hostname}: PCIe device conflict detected — {device_pci}', 'body': ( 'A PCIe device is assigned to multiple guests.\n' 'Device: {device_pci}\n' @@ -809,7 +818,7 @@ TEMPLATES = { # ── Network events ── 'network_down': { - 'title': '{hostname}: Network connectivity lost', + 'title': '{hostname}: Network connectivity lost{entity_suffix}', 'body': 'The node has lost network connectivity.\nReason: {reason}', 'label': 'Network connectivity lost', 'group': 'network', @@ -839,7 +848,7 @@ TEMPLATES = { 'default_enabled': True, }, 'firewall_issue': { - 'title': '{hostname}: Firewall issue detected', + 'title': '{hostname}: Firewall issue detected{entity_suffix}', 'body': 'A firewall configuration issue has been detected.\nReason: {reason}', 'label': 'Firewall issue detected', 'group': 'security', @@ -916,7 +925,7 @@ TEMPLATES = { 'default_enabled': True, }, 'system_problem': { - 'title': '{hostname}: System problem detected', + 'title': '{hostname}: System problem detected{entity_suffix}', 'body': 'A system-level problem has been detected.\nReason: {reason}', 'label': 'System problem detected', 'group': 'services', @@ -939,7 +948,7 @@ TEMPLATES = { # ── Hidden internal templates (not shown in UI) ── 'service_fail_batch': { - 'title': '{hostname}: {service_count} services failed', + 'title': '{hostname}: {service_count} services failed{entity_suffix}', 'body': '{reason}', 'label': 'Service fail batch', 'group': 'services', @@ -1004,31 +1013,31 @@ TEMPLATES = { 'hidden': True, }, 'unknown_persistent': { - 'title': '{hostname}: Check unavailable - {category}', + 'title': '{hostname}: Check unavailable - {category}{entity_suffix}', 'body': 'Health check for {category} has been unavailable for 3+ cycles.\n{reason}', 'label': 'Check unavailable', 'group': 'health', 'default_enabled': False, 'hidden': True, }, - + # ── Health Monitor events ── 'health_persistent': { - 'title': '{hostname}: {count} active health issue(s)', + 'title': '{hostname}: {count} active health issue(s){entity_suffix}', 'body': 'The following health issues remain unresolved:\n{issue_list}\n\nThis digest is sent once every 24 hours while issues persist.', 'label': 'Active health issues (daily)', 'group': 'health', 'default_enabled': True, }, 'health_issue_new': { - 'title': '{hostname}: New health issue — {category}', + 'title': '{hostname}: New health issue — {category}{entity_suffix}', 'body': 'New {severity} issue detected in: {category}\nDetails: {reason}', 'label': 'New health issue', 'group': 'health', 'default_enabled': True, }, 'health_issue_resolved': { - 'title': '{hostname}: Resolved - {category}', + 'title': '{hostname}: Resolved - {category}{entity_suffix}', 'body': '{category} issue has been resolved.\n{reason}\nDuration: {duration}', 'label': 'Health issue resolved', 'group': 'health', @@ -1224,11 +1233,19 @@ TEMPLATES = { # bullet list make sure the operator sees exactly what's moving # without opening the dashboard first. 'secure_gateway_update_available': { - 'title': '{hostname}: {app_name} update available — v{latest_version}', + # `{update_title_suffix}` and `{version_line}` are computed in + # `_build_managed_install_event` so the same template can render + # both scenarios cleanly: + # - Tailscale itself moved: "— v" + "current → latest" line + # - Only sidecar packages moved: "— v (packages only)" + + # single-line "Tailscale unchanged" body + # Prevents the confusing "v1.90.9-r6 → v1.90.9-r6" render when + # Tailscale is stable and only alpine libs are updating. + 'title': '{hostname}: {app_name} update available{update_title_suffix}', 'body': ( '{app_name} (managed by ProxMenux) has 📦 {package_count} package update(s) ' 'pending in its container.\n' - '🔹 Current Tailscale: v{current_version} → 🟢 Latest: v{latest_version}\n\n' + '{version_line}\n\n' '💡 Open ProxMenux Monitor > Settings > Secure Gateway and click ' '"Update" to apply.\n\n' '🗂️ Packages:\n{package_list}' @@ -1468,6 +1485,36 @@ def render_template(event_type: str, data: Dict[str, Any]) -> Dict[str, Any]: # Ensure important_list is never blank (fallback to 'none') if not variables.get('important_list', '').strip(): variables['important_list'] = 'none' + + # Derive the affected object's display name for titles that use it. + # Priority: caller-supplied `entity` (health_monitor.emit_event) → + # per-detail fields spread in from `record_error`'s details blob. + # `entity_suffix` gives templates a way to render "…- Storage 'Tuxis'" + # without the trailing " - " when the field is empty. + _ent = str(variables.get('entity', '')).strip() + if not _ent: + _ent = ( + variables.get('storage_name') + or variables.get('mount_point') + or variables.get('device') + or variables.get('interface') + or variables.get('vm_name') + or '' + ) + _ent = str(_ent).strip() + variables['entity'] = _ent + variables['entity_suffix'] = f' — {_ent}' if _ent else '' + + # `title_or_default` lets a template surface a caller-computed title + # when the caller already knows the entity context (e.g. + # flask_server's health collector), and fall back to a stock string + # otherwise. Prevents empty subject lines when the caller forgot to + # populate `title`. + _caller_title = str(variables.get('title', '')).strip() + if not _caller_title: + hn = variables.get('hostname', '') + _caller_title = f'{hn}: Health check degraded' if hn else 'Health check degraded' + variables['title_or_default'] = _caller_title # `format_map` with a SafeDict avoids the KeyError → "show raw template # with `{placeholder}` literal" failure mode. If a template gets a new diff --git a/AppImage/scripts/oci_manager.py b/AppImage/scripts/oci_manager.py index 2d8b61f4..3c2b0329 100644 --- a/AppImage/scripts/oci_manager.py +++ b/AppImage/scripts/oci_manager.py @@ -1508,6 +1508,15 @@ def check_app_update_available(app_id: str, force: bool = False) -> Dict[str, An except Exception: pass + # When Tailscale itself isn't in the update list but other packages + # inside the container are, `latest_version` stays None and downstream + # notification renders "— v" / "🟢 Latest: v" with a dangling prefix. + # Anchor it to the installed version so the notification reads + # cleanly ("current == latest" tells the operator Tailscale itself + # is unchanged, only sidecar packages are updating). + if result["current_version"] and not result["latest_version"]: + result["latest_version"] = result["current_version"] + _app_update_cache[app_id] = result return result