mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-07-26 18:38:30 +00:00
Update 1.2.2.3 beta
This commit is contained in:
Binary file not shown.
@@ -1 +1 @@
|
||||
8db4067d1bcd3d7cd440d4de72d449e8b47480787f3650b35213c83a4bfade40 ProxMenux-1.2.2.3-beta.AppImage
|
||||
891b6218f11ad99c1159d1cd187648bb104ec9c96e38927ef926307110cf0d4d
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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']}")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user