mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user