improve update notifications and add apt-listchanges channel

- add an apt-listchanges notification event that surfaces package-maintainer NEWS forwarded through the PVE mail queue, with its own template, emoji and per-locale label
- emit a single post_install_update event when a tool exposes a newer version, and persist per-tool notified versions on disk so a Monitor restart no longer re-fires the same update announcement
- force AI enrichment to formatting-only on availability notices (update_available / pve_update / proxmenux_update / post_install_update / apt_listchanges / lxc_updates_available / secure_gateway_update_available / nvidia_driver_update_available / coral_driver_update_available / app_update_available / docker_stack_update_available / update_summary) so translations never turn factual inventories into advice
- add the apt_listchanges channel label to Monitor UI strings across the eight locales
This commit is contained in:
MacRimi
2026-08-26 21:00:00 +02:00
parent 0fe32d2842
commit c2a6db38a8
12 changed files with 169 additions and 48 deletions
+61 -37
View File
@@ -196,6 +196,20 @@ def _hostname() -> str:
return resolved
def _new_post_install_update_versions(
updates: list[dict[str, Any]],
notified_versions: dict[str, set[str]],
) -> dict[str, str]:
"""Return only optimization versions that have never been announced."""
available: dict[str, str] = {}
for update in updates:
key = str(update.get('key', '') or '').strip()
version = str(update.get('available_version', '') or '').strip()
if key and version and version not in notified_versions.get(key, set()):
available[key] = version
return available
def capture_journal_context(keywords: list, lines: int = 30,
since: str = "5 minutes ago") -> str:
"""Capture relevant journal lines for AI context enrichment.
@@ -2567,11 +2581,9 @@ class PollingCollector:
self._last_ai_model_check = 0
# Sprint 12D: post-install function updates check, on the same
# 24h cooldown as the Proxmox/ProxMenux update checks. Notify
# once per *changed set* of update keys — repeating the same
# notification every 24h forever would be noisy, so we de-dupe
# against the previously-notified set.
# once for each genuinely new available version. The persistent
# history is stored in updates_available.json beside the scan.
self._last_post_install_check = 0
self._notified_post_install_keys: set[str] = set()
# Sprint 14.7: fingerprint (item_id → latest_version) of the
# last managed-installs update notification, across all types
# in the registry. A new notification fires when the
@@ -3503,11 +3515,9 @@ class PollingCollector:
Sprint 12A's detector runs at AppImage startup and writes
``updates_available.json``. This check refreshes the snapshot
every 24h (matching the other update channels), and emits a
single ``post_install_update`` event the first time the *set* of
available updates changes. Repeating the same notification every
24h forever would be noisy, so we de-dupe against the previously
notified set of tool keys: only when a new tool joins the list
(or an existing one disappears) does a fresh notification fire.
single ``post_install_update`` event when a tool exposes an available
version that has never been announced. Applying one item merely
shrinks the pending set and must not produce a second notification.
"""
now = time.time()
if now - self._last_post_install_check < self.UPDATE_CHECK_INTERVAL:
@@ -3523,16 +3533,12 @@ class PollingCollector:
return
if not updates:
# All caught up. Reset so a future bump triggers a fresh
# notification instead of being suppressed by stale state.
self._notified_post_install_keys = set()
return
new_keys = {u.get('key', '') for u in updates if u.get('key')}
if new_keys == self._notified_post_install_keys:
return # already notified about this exact set
self._notified_post_install_keys = new_keys
notified_versions = post_install_versions.load_notified_versions()
new_versions = _new_post_install_update_versions(updates, notified_versions)
if not new_versions:
return
# Pre-format the bullet list here so the template can drop it
# straight in with `{tool_list}` (the renderer is plain
@@ -3567,6 +3573,9 @@ class PollingCollector:
'post_install_update', 'INFO', data,
source='polling', entity='node', entity_id='',
))
for key, version in new_versions.items():
notified_versions.setdefault(key, set()).add(version)
post_install_versions.save_notified_versions(notified_versions)
# ── Managed-installs update check (Sprint 14.7) ─────────────────
@@ -4178,26 +4187,33 @@ class ProxmoxHookWatcher:
# smartd and other system mail contains verbose boilerplate.
# Extract just the actionable warning/error lines.
if pve_type == 'system-mail' and message:
clean_lines = []
for line in message.split('\n'):
stripped = line.strip()
# Skip boilerplate lines
if not stripped:
continue
if stripped.startswith('This message was generated'):
continue
if stripped.startswith('For details see'):
continue
if stripped.startswith('You can also use'):
continue
if stripped.startswith('The original message'):
continue
if stripped.startswith('Another message will'):
continue
if stripped.startswith('host name:') or stripped.startswith('DNS domain:'):
continue
clean_lines.append(stripped)
data['reason'] = '\n'.join(clean_lines).strip() if clean_lines else message.strip()[:500]
# apt-listchanges is package-maintainer NEWS, not diagnostic
# boilerplate. Preserve it verbatim (including paragraphs and
# signatures) so ProxMenux only attributes the source and never
# silently edits or truncates the upstream notice.
if event_type == 'apt_listchanges':
data['reason'] = message.strip()
else:
clean_lines = []
for line in message.split('\n'):
stripped = line.strip()
# Skip boilerplate lines
if not stripped:
continue
if stripped.startswith('This message was generated'):
continue
if stripped.startswith('For details see'):
continue
if stripped.startswith('You can also use'):
continue
if stripped.startswith('The original message'):
continue
if stripped.startswith('Another message will'):
continue
if stripped.startswith('host name:') or stripped.startswith('DNS domain:'):
continue
clean_lines.append(stripped)
data['reason'] = '\n'.join(clean_lines).strip() if clean_lines else message.strip()[:500]
# Extract VMID and VM name from message for vzdump events
if pve_type == 'vzdump' and message:
@@ -4319,6 +4335,14 @@ class ProxmoxHookWatcher:
msg_lower = (message or '').lower()
title_lower_sm = (title or '').lower()
# apt-listchanges forwards legitimate upstream package NEWS through
# PVE's generic system-mail bucket. Keep it, but classify it as an
# update notice of its own so the template can identify the source
# clearly instead of making package-maintainer prose look like a
# recommendation written by ProxMenux.
if 'apt-listchanges' in title_lower_sm or 'apt-listchanges' in msg_lower[:500]:
return 'apt_listchanges', 'node', ''
# ── Record disk observation regardless of noise filter ──
# Even "noise" events are recorded as observations so the user
# can see them in the Storage UI. We just don't send notifications.
+14 -1
View File
@@ -1350,6 +1350,20 @@ class NotificationManager:
# If AI is enabled AND rich_format is on, AI will include emojis directly
# Pass channel_type so AI knows whether to append original (email only)
channel_ai_config = {**ai_config, 'channel_type': ch_name}
# Availability notices are factual inventories, not advice.
# Even when the user enables experimental AI suggestions for
# diagnostic alerts, update announcements must only translate
# and format the versions/actions already supplied by the
# deterministic template.
if event_type in {
'update_available', 'update_summary', 'pve_update',
'proxmenux_update', 'post_install_update', 'apt_listchanges',
'lxc_updates_available', 'secure_gateway_update_available',
'nvidia_driver_update_available',
'coral_driver_update_available', 'app_update_available',
'docker_stack_update_available',
}:
channel_ai_config['ai_allow_suggestions'] = False
# Isolate the AI/enrich block in its own try so a failure
# here (raised from enrich_context_for_ai or any other
@@ -1979,7 +1993,6 @@ class NotificationManager:
# cooldown resumes after that first post-restart send.
'update_summary',
'proxmenux_update',
'post_install_update',
'pve_update',
'update_available',
'nvidia_driver_update_available',
+15 -3
View File
@@ -1033,6 +1033,17 @@ TEMPLATES = {
# /etc/aliases or removing MAILTO from the cron job. Audit Tier 6
# — `system_mail` toggle no visible en UI / reportado por usuario.
},
'apt_listchanges': {
'title': '{hostname}: {pve_title}',
'body': (
'Upstream package information forwarded by Proxmox VE through '
'apt-listchanges. The following text comes from the package '
'maintainer and is not a ProxMenux recommendation.\n\n{reason}'
),
'label': 'apt-listchanges package notices',
'group': 'updates',
'default_enabled': True,
},
'webhook_test': {
'title': '{hostname}: Webhook test received',
'body': 'PVE webhook connectivity test successful.\n{reason}',
@@ -1272,9 +1283,9 @@ TEMPLATES = {
'post_install_update': {
'title': '{hostname}: {count} ProxMenux optimization update(s) available',
'body': (
'{count} optimization update(s) detected on this host.\n\n'
'🛠️ Tools:\n{tool_list}\n\n'
'💡 How to apply:\n'
'{count} ProxMenux optimization update(s) available on this host.\n\n'
'🛠️ Available versions:\n{tool_list}\n\n'
'💡 Apply from:\n'
' • ProxMenux Monitor → Settings → ProxMenux Optimizations\n'
' • Or run the post-install menu (option 2) → "Apply available updates"'
),
@@ -1733,6 +1744,7 @@ CATEGORY_EMOJI = {
EVENT_EMOJI = {
# VM / CT
'lxc_updates_available': '\U0001F4E6', # \uD83D\uDCE6 package \u2014 pending CT updates
'apt_listchanges': '\U0001F4E6', # package-maintainer NEWS via PVE mail
'lxc_update_applied': '\u2705', # \u2705 check \u2014 update applied
'app_update_available': '\U0001F195', # \ud83c\udd95 NEW \u2014 upstream app release
'docker_stack_update_available': '\U0001F433',
+71 -7
View File
@@ -92,6 +92,73 @@ def _read_text(path: Path) -> str:
return ""
def _load_notified_versions_from_disk() -> dict[str, set[str]]:
"""Read the versions already announced for each optimization.
Notification history lives beside the existing update snapshot so no
additional runtime file is introduced. Older snapshots simply have no
``notified_versions`` member and therefore start with an empty history.
"""
try:
payload = json.loads(_read_text(_UPDATES_JSON) or "{}")
except json.JSONDecodeError:
return {}
raw = payload.get("notified_versions", {})
if not isinstance(raw, dict):
return {}
normalized: dict[str, set[str]] = {}
for key, versions in raw.items():
if isinstance(versions, str):
versions = [versions]
if not isinstance(versions, list):
continue
clean = {str(version).strip() for version in versions if str(version).strip()}
if clean:
normalized[str(key)] = clean
return normalized
def _write_persisted_snapshot(
scanned_at: float,
updates: list[dict[str, Any]],
notified_versions: dict[str, set[str]],
) -> None:
"""Atomically persist the update snapshot and notification history."""
payload = {
"scanned_at": scanned_at,
"updates": updates,
"notified_versions": {
key: sorted(versions)
for key, versions in sorted(notified_versions.items())
if versions
},
}
_UPDATES_JSON.parent.mkdir(parents=True, exist_ok=True)
temporary = _UPDATES_JSON.with_suffix(_UPDATES_JSON.suffix + ".tmp")
temporary.write_text(json.dumps(payload, indent=2), encoding="utf-8")
temporary.replace(_UPDATES_JSON)
def load_notified_versions() -> dict[str, set[str]]:
"""Return a defensive copy of optimization versions already announced."""
return {
key: set(versions)
for key, versions in _load_notified_versions_from_disk().items()
}
def save_notified_versions(history: dict[str, set[str]]) -> None:
"""Persist notification history without changing the current scan."""
with _cache_lock:
scanned_at = float(_cache.get("scanned_at", 0.0) or 0.0)
updates = list(_cache.get("updates", []))
try:
_write_persisted_snapshot(scanned_at, updates, history)
except OSError:
# Notification de-duplication remains best-effort on read-only hosts.
pass
# ---------------------------------------------------------------------------
# Bash script parser
# ---------------------------------------------------------------------------
@@ -353,13 +420,10 @@ def scan(persist: bool = True) -> dict[str, Any]:
if persist:
try:
_UPDATES_JSON.parent.mkdir(parents=True, exist_ok=True)
_UPDATES_JSON.write_text(
json.dumps(
{"scanned_at": snapshot["scanned_at"], "updates": updates},
indent=2,
),
encoding="utf-8",
_write_persisted_snapshot(
snapshot["scanned_at"],
updates,
_load_notified_versions_from_disk(),
)
except OSError:
# Writing the on-disk cache is best-effort. If /usr/local