Update 1.2.2.3 beta

This commit is contained in:
MacRimi
2026-07-08 19:17:13 +02:00
parent 20f85001d5
commit 87454b5b2d
10 changed files with 455 additions and 92 deletions

View File

@@ -1 +1 @@
8db4067d1bcd3d7cd440d4de72d449e8b47480787f3650b35213c83a4bfade40 ProxMenux-1.2.2.3-beta.AppImage
891b6218f11ad99c1159d1cd187648bb104ec9c96e38927ef926307110cf0d4d

View File

@@ -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 }) => (
<div className={`text-xs rounded-md border border-amber-500/40 bg-amber-500/10 text-amber-100 px-3 py-3 space-y-2 ${className ?? ""}`}>
<div className="flex items-start gap-2">
<Lock className="h-4 w-4 mt-0.5 shrink-0 text-amber-400" />
<div className="flex-1 min-w-0 space-y-1">
<div className="font-semibold text-amber-300">Encrypted backup wrong keyfile on this host</div>
<div className="text-amber-100/90">
The keyfile installed here does not match the one used to create this backup, so PBS refuses to open it.
</div>
{err.manifestFp && (
<div className="pt-1">
<div className="text-[10.5px] uppercase tracking-wider text-amber-300/80">Required (manifest)</div>
<div className="font-mono text-[11px] break-all">{err.manifestFp}</div>
</div>
)}
{err.providedFp && (
<div>
<div className="text-[10.5px] uppercase tracking-wider text-amber-300/80">Currently installed</div>
<div className="font-mono text-[11px] break-all">{err.providedFp}</div>
</div>
)}
<div className="pt-1 text-amber-100/90">
Import the correct keyfile from{" "}
<span className="font-medium">Backup configuration Destinations PBS row Upload</span>
{" "}(you may need to Delete the current one first) and retry.
</div>
</div>
</div>
</div>
)
const formatMtime = (mtime: number) =>
new Date(mtime * 1000).toLocaleString(undefined, {
year: "numeric",
@@ -1956,11 +2013,16 @@ function InspectModal({
</div>
)}
{error && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
{error}
</div>
)}
{error && (() => {
const kf = parseKeyfileError(error)
return kf ? (
<KeyfileErrorBlock err={kf} />
) : (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
{error}
</div>
)
})()}
{/* 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 && (
<Dialog open={true} onOpenChange={() => setRestoreError(null)}>
<DialogContent className="max-w-md bg-card border-border">
<DialogHeader>
<DialogTitle className="text-base flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-500" />
Restore preparation failed
</DialogTitle>
<DialogDescription className="text-xs text-red-400 break-all">
{restoreError}
</DialogDescription>
</DialogHeader>
<div className="flex justify-end">
<Button variant="ghost" onClick={() => setRestoreError(null)}>Close</Button>
</div>
</DialogContent>
</Dialog>
)}
{restoreError && (() => {
const kf = parseKeyfileError(restoreError)
return (
<Dialog open={true} onOpenChange={() => setRestoreError(null)}>
<DialogContent className="max-w-md bg-card border-border">
<DialogHeader>
<DialogTitle className="text-base flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-500" />
{kf ? "Restore blocked — encrypted backup" : "Restore preparation failed"}
</DialogTitle>
{!kf && (
<DialogDescription className="text-xs text-red-400 break-all">
{restoreError}
</DialogDescription>
)}
</DialogHeader>
{kf && <KeyfileErrorBlock err={kf} />}
<div className="flex justify-end">
<Button variant="ghost" onClick={() => setRestoreError(null)}>Close</Button>
</div>
</DialogContent>
</Dialog>
)
})()}
{/* ── Restore options modal: opens only AFTER the prepare step,
so the Custom checklist already knows what's inside. ──── */}
@@ -8022,11 +8090,16 @@ function ArchiveContentsModal({
</div>
)}
{error && !loading && (
<div className="text-sm text-red-400 px-3 py-3 mx-6 rounded-md border border-red-500/30 bg-red-500/10">
{error}
</div>
)}
{error && !loading && (() => {
const kf = parseKeyfileError(error)
return kf ? (
<div className="px-6"><KeyfileErrorBlock err={kf} /></div>
) : (
<div className="text-sm text-red-400 px-3 py-3 mx-6 rounded-md border border-red-500/30 bg-red-500/10">
{error}
</div>
)
})()}
{data && !loading && (
<ScrollArea className="flex-1 min-h-0 px-6">

View File

@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-slate-300 dark:data-[state=unchecked]:bg-slate-600",
className
)}
{...props}

View File

@@ -1255,6 +1255,12 @@ def _health_collector_loop():
'category': cat_name,
'status': cur_status,
'reason': reason,
# `entity` lets the notification title name the
# actual affected object ("Storage Tuxis") instead
# of the bare category ("Storage"). Populated by
# `_check_XXX` in health_monitor when a single
# entity dominates; empty otherwise.
'entity': cat_data.get('entity', ''),
})
_prev_statuses[cat_key] = cur_status
@@ -1274,13 +1280,32 @@ def _health_collector_loop():
if len(degraded) == 1:
d = degraded[0]
title = f"{hostname}: Health {d['status']} - {d['category']}"
# Prefer the specific entity in the title so the reader
# sees WHICH object failed without opening the app.
# Falls back to the category label when the check didn't
# provide one (aggregate-only reasons).
if d.get('entity'):
title = f"{hostname}: {d['category']} {d['status']}{d['entity']}"
else:
title = f"{hostname}: Health {d['status']} - {d['category']}"
body = d['reason']
severity = d['status']
else:
# Multiple categories degraded at once -- group them
# Multiple categories degraded at once -- group them.
# Title lists the categories + first entity per category
# so the reader can distinguish "storage + network"
# from "cpu + memory" at a glance.
max_sev = max(degraded, key=lambda x: _SEV_RANK.get(x['status'], 0))['status']
title = f"{hostname}: {len(degraded)} health checks degraded"
cat_labels = []
for d in degraded:
label = d['category']
if d.get('entity'):
label = f"{label} ({d['entity']})"
cat_labels.append(label)
title = f"{hostname}: {len(degraded)} health checks degraded — {', '.join(cat_labels)}"
# Cap title if the join went long
if len(title) > 200:
title = title[:197] + ''
lines = []
for d in degraded:
lines.append(f" [{d['status']}] {d['category']}: {d['reason']}")

View File

@@ -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 <singular>")
- 2 items → ("Foo +1", "2 <plural>: Foo, Bar")
- N items → ("Foo +N-1", "N <plural>: 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 {

View File

@@ -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()

View File

@@ -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

View File

@@ -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 - <cat>"
# 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<new>" + "current → latest" line
# - Only sidecar packages moved: "— v<current> (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

View File

@@ -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