From 0beeb7a68bc16527daa313f37c5876e3cc1015ab Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sat, 15 Aug 2026 17:33:05 +0200 Subject: [PATCH] Beta cycle bundle over 1.2.4.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **VM/LXC modal** — PVE tags (dots on list cards, editable pills in modal with click-to-edit) using NVIDIA-style hash colour and SAPC contrast; Status tab redesign (single card, always-visible subsections, Edit button, autostart toggle, blue subsection icons); Backups and Firewall tabs now fill the full modal height with sticky headers/notes; stopped VMs no longer shift the metrics grid; mount-point card brightness unified across breakpoints. - **Disks modal** — Overview / SMART / History / Schedule tabs adopt the VM/LXC modal size and the mobile icon-only tab pattern; SMART attributes table drops the 15-row cap and gains a sticky "View full SMART report" footer; Print/Save-as-PDF collapses to two icons in the report; loose i18n and layout follow-ups. - **NVIDIA driver installer (#298)** — version picker cross-checks kernel + NVIDIA's Production/New Feature/Legacy branch classification (scraped from `nvidia.com/en-us/drivers/unix/`) + the PCI Device IDs of every host GPU, with a release-count heuristic to keep superseded production branches selectable while dropping Vulkan-beta ones; Recommended follows same-branch head when a driver is installed, Production Branch head on a fresh install; Hardware card now shows installed alongside available driver version. - **Custom notifications (#297)** — `event_type: "custom"` accepts `title`/`message` at the root or nested under `data`; defensive strip of stray `[TITLE]`/`[BODY]` markers echoed by the AI enhancer. - **App tab** — new "Exclude from the LXC updates counter" toggle; the CT's aggregate updates badge now sums OS packages plus registered apps (respecting the flag); Docs page updated; App suggestion no longer treats bare OS helper slugs (alpine/ubuntu/debian…) as installable apps. - **i18n and copy** — Monitor UI available in EN / ES / DE / FR / IT / PT / SV / SK (thanks @vaso73) surfaced as the first entry in the What's New modal with a link to the contributor's profile; ES cleanup pass (`Historial`, `Velocidad de rotación`, `Consumo actual`, `Ejecutar`, `Eliminar`, `Activar`, `Ver contenido`, `Repuesto disp.`, `Registrar`, `Ocultar`, `Descartar`); redundant "Tip: search any Linux/Proxmox command" line removed from the terminal command search across all locales. --- AppImage/components/hardware.tsx | 3 +- AppImage/components/lxc-app-panel.tsx | 25 +- AppImage/components/lxc-terminal-modal.tsx | 10 +- AppImage/components/release-notes-modal.tsx | 41 +- AppImage/components/storage-overview.tsx | 135 ++-- AppImage/components/terminal-panel.tsx | 10 +- AppImage/components/virtual-machines.tsx | 587 +++++++++++++++--- AppImage/lib/pve-tag-color.ts | 79 +++ AppImage/messages/de/common.json | 20 +- AppImage/messages/en/common.json | 20 +- AppImage/messages/es/common.json | 68 +- AppImage/messages/fr/common.json | 20 +- AppImage/messages/it/common.json | 20 +- AppImage/messages/pt/common.json | 20 +- AppImage/messages/sk/common.json | 20 +- AppImage/messages/sv/common.json | 20 +- AppImage/scripts/flask_notification_routes.py | 15 +- AppImage/scripts/flask_server.py | 136 +++- AppImage/scripts/lxc_apps.py | 29 + AppImage/scripts/lxc_mount_points.py | 36 ++ AppImage/scripts/notification_templates.py | 15 +- scripts/gpu_tpu/nvidia_installer.sh | 365 ++++++++++- .../monitor/dashboard/vms-lxcs/app/page.tsx | 11 + .../docs/monitor/dashboard/vms-lxcs-app.json | 9 + .../docs/monitor/dashboard/vms-lxcs-app.json | 9 + .../docs/monitor/dashboard/vms-lxcs-app.json | 109 +++- 26 files changed, 1554 insertions(+), 278 deletions(-) create mode 100644 AppImage/lib/pve-tag-color.ts diff --git a/AppImage/components/hardware.tsx b/AppImage/components/hardware.tsx index ea9c3a2d..6afcd8e1 100644 --- a/AppImage/components/hardware.tsx +++ b/AppImage/components/hardware.tsx @@ -1048,7 +1048,8 @@ return ( {nvidiaInstall.update_check.available ? ( <>
- {t("hardware.labels.lastChecked")}: {formatLastChecked(nvidiaInstall.update_check.last_check)} ·{" "} + {t("hardware.labels.lastChecked")}: {formatLastChecked(nvidiaInstall.update_check.last_check)} + {` · NVIDIA driver v${nvidiaInstall.current_version} · `} {t("hardware.values.nvidiaDriverAvailable", { version: nvidiaInstall.update_check.latest || "" })} diff --git a/AppImage/components/lxc-app-panel.tsx b/AppImage/components/lxc-app-panel.tsx index b462be9b..0bf51a3b 100644 --- a/AppImage/components/lxc-app-panel.tsx +++ b/AppImage/components/lxc-app-panel.tsx @@ -88,6 +88,9 @@ interface AppConfig { // Absent / true = notify; false = silenced. Set from the bell // toggle on each app card and/or the Edit form's checkbox. notifications_enabled?: boolean + // Per-app opt-out for the CT's aggregate updates badge (default + // false = counted). Independent from `notifications_enabled`. + exclude_from_badge?: boolean } interface DetectedApp { @@ -434,6 +437,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop health_path: existing.health_path || "", logo_url: existing.logo_url || "", helper_slug: existing.helper_slug || "", + notifications_enabled: existing.notifications_enabled !== false, + exclude_from_badge: existing.exclude_from_badge === true, } // Editing an existing app: expand Advanced when tracking is on setShowAdvanced(!!seed.installed_via) @@ -1502,9 +1507,13 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop apps with tracking configured — without an upstream source there's no `app_update_available` event to mute. Default is ON (checkbox checked); the bell - toggle on each card is a shortcut to the same field. */} + toggle on each card is a shortcut to the same field. + The second checkbox below controls the CT's aggregate + updates badge independently — a user may want the + outbound notification but hide the counter (or + the reverse). */} {method && ( -
+
+
)} diff --git a/AppImage/components/lxc-terminal-modal.tsx b/AppImage/components/lxc-terminal-modal.tsx index c44a8cb9..460efa67 100644 --- a/AppImage/components/lxc-terminal-modal.tsx +++ b/AppImage/components/lxc-terminal-modal.tsx @@ -900,13 +900,11 @@ export function LxcTerminalModal({ ) : null} -
-
- - {t("terminal.searchTip")} + {useOnline && searchResults.length > 0 && ( +
+ {t("terminal.poweredByCheatSh")}
- {useOnline && searchResults.length > 0 && {t("terminal.poweredByCheatSh")}} -
+ )}
diff --git a/AppImage/components/release-notes-modal.tsx b/AppImage/components/release-notes-modal.tsx index 091b0096..e839bc94 100644 --- a/AppImage/components/release-notes-modal.tsx +++ b/AppImage/components/release-notes-modal.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from "react" import { Button } from "./ui/button" import { Dialog, DialogContent, DialogTitle } from "./ui/dialog" -import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup, Smartphone } from "lucide-react" +import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup, Smartphone, Languages } from "lucide-react" import { Checkbox } from "./ui/checkbox" import { useT } from "../lib/i18n/provider" @@ -237,6 +237,11 @@ export const CHANGELOG: Record = { // build-i18n-messages workflow feeds to Google Translate for locales // that haven't been curated by hand. const CURRENT_VERSION_FEATURES = [ + { + icon: , + key: "releaseNotes.currentFeatures.i18n", + text: "The Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Swedish and Slovak. Huge thanks to @vaso73 for building the i18n scaffolding that made this possible.", + }, { icon: , key: "releaseNotes.currentFeatures.pageSpeed", @@ -269,6 +274,38 @@ const CURRENT_VERSION_FEATURES = [ }, ] +// Turn any "@handle" mention inside a release-notes string into a +// link to that GitHub profile. Applied to every feature bullet so a +// contributor shout-out reads as a real link without needing rich +// i18n formatting. Only matches `@` followed by a valid GitHub +// username (letters/digits/hyphen, no consecutive hyphens, 1-39 +// chars) so unrelated punctuation stays untouched. +function linkifyGithubMentions(text: string): (string | JSX.Element)[] { + const parts: (string | JSX.Element)[] = [] + const re = /@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)/g + let cursor = 0 + let m: RegExpExecArray | null + let idx = 0 + while ((m = re.exec(text)) !== null) { + if (m.index > cursor) parts.push(text.slice(cursor, m.index)) + const handle = m[1] + parts.push( + + @{handle} + , + ) + cursor = m.index + m[0].length + } + if (cursor < text.length) parts.push(text.slice(cursor)) + return parts +} + interface ReleaseNotesModalProps { open: boolean onClose: () => void @@ -329,7 +366,7 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) { >
{feature.icon}

- {t(feature.key)} + {linkifyGithubMentions(t(feature.key))}

))} diff --git a/AppImage/components/storage-overview.tsx b/AppImage/components/storage-overview.tsx index f4796b75..07e60325 100644 --- a/AppImage/components/storage-overview.tsx +++ b/AppImage/components/storage-overview.tsx @@ -250,6 +250,23 @@ export function StorageOverview() { const [diskObservations, setDiskObservations] = useState([]) const [loadingObservations, setLoadingObservations] = useState(false) const [activeModalTab, setActiveModalTab] = useState<"overview" | "smart" | "history" | "schedule">("overview") + // Detect PWA / standalone display so the disk modal gets the same + // adaptive height the VM/LXC modal uses (95/90 vh in standalone, + // 85 vh capped by the visual viewport otherwise). Keeps every + // detail modal at a matching size across the app. + const [isStandalone, setIsStandalone] = useState(false) + + useEffect(() => { + const checkStandalone = () => { + const standalone = window.matchMedia("(display-mode: standalone)").matches || + (window.navigator as Navigator & { standalone?: boolean }).standalone === true + setIsStandalone(standalone) + } + checkStandalone() + const mediaQuery = window.matchMedia("(display-mode: standalone)") + mediaQuery.addEventListener("change", checkStandalone) + return () => mediaQuery.removeEventListener("change", checkStandalone) + }, []) const [smartJsonData, setSmartJsonData] = useState<{ has_data: boolean data?: Record @@ -1792,7 +1809,13 @@ export function StorageOverview() { setSmartJsonData(null) } }}> - + {selectedDisk?.connection_type === 'usb' ? ( @@ -1816,58 +1839,74 @@ export function StorageOverview() { - {/* Tab Navigation */} -
+ {/* Tab Navigation. + Mobile pattern (same as the VM/LXC modal): each tab + shows only its icon; the active tab additionally + reveals its label. That keeps all four tabs on-screen + on narrow viewports without horizontal scroll. */} +
- {/* Tab Content */} -
+ {/* Tab Content — the wrapper is a flex-col so each tab + can either scroll its own content (Overview) or keep + a sticky footer while an inner area grows/scrolls + (SMART, History, Schedule). Removing the wrapper's + own `overflow-y-auto` is what makes that possible. */} +
{selectedDisk && activeModalTab === "overview" && ( -
+

{t("storage.model")}

@@ -3082,10 +3121,12 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri .top-bar-title { font-weight: 600; } .top-bar-subtitle { font-size: 11px; color: #94a3b8; } .top-bar button { - background: #06b6d4; color: #fff; border: none; padding: 10px 20px; border-radius: 6px; - font-size: 14px; font-weight: 600; cursor: pointer; + background: #06b6d4; color: #fff; border: none; padding: 8px 12px; border-radius: 6px; + font-size: 14px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; } .top-bar button:hover { background: #0891b2; } + .top-bar .btn-group { display: flex; gap: 8px; } + .top-bar button svg { width: 18px; height: 18px; display: block; } /* Header */ .rpt-header { @@ -3184,13 +3225,25 @@ function pmxPrint(){ } - +
${t("storage.smartReport.title")} /dev/${disk.name}
- +
+ + +
@@ -4026,7 +4079,12 @@ function SmartTestTab({ disk, observations = [], lastTestDate }: SmartTestTabPro } return ( -
+
+ {/* Scrollable body — Run test controls, progress, last test, + and the SMART Attributes summary. When the attributes list + is long, THIS is what scrolls; the "View full SMART report" + footer stays pinned at the bottom of the tab. */} +
{/* Quick Actions */}

@@ -4152,8 +4210,8 @@ function SmartTestTab({ disk, observations = [], lastTestDate }: SmartTestTabPro {!isNvme && !testStatus.smart_data?.is_sas &&
{t("storage.smartTest.worst")}
}
{t("storage.smartTest.status")}

-
- {testStatus.smart_data.attributes.slice(0, 15).map((attr) => ( +
+ {testStatus.smart_data.attributes.map((attr) => (
{!isNvme && !testStatus.smart_data?.is_sas &&
{attr.id}
}
{smartAttributeLabel(attr.name)}
@@ -4175,8 +4233,14 @@ function SmartTestTab({ disk, observations = [], lastTestDate }: SmartTestTabPro
)} - {/* View Full Report Button */} -
+
+ {/* View Full Report Button — sticky footer of the tab. + Sits outside the scrollable body so it's always reachable + without hunting for it at the end of a long attribute + list. The helper subtitle was dropped — the button label + already explains the action, and the extra sentence was + eating vertical space we now give back to attributes. */} +
-

- {t("storage.smartTest.reportHelp")} -

- -
) } @@ -4308,7 +4367,7 @@ function HistoryTab({ disk }: { disk: DiskInfo }) { if (loading) { return ( -
+

{t("storage.historyTab.loading")}

@@ -4317,7 +4376,7 @@ function HistoryTab({ disk }: { disk: DiskInfo }) { if (history.length === 0) { return ( -
+
{t("storage.historyTab.empty")} {t("storage.historyTab.emptyHint")} @@ -4326,8 +4385,12 @@ function HistoryTab({ disk }: { disk: DiskInfo }) { } return ( -
-
+
+ {/* Header stays pinned; the list scrolls; the retention note + sits pinned at the bottom. Same "sticky footer / growing + middle" layout as the other tabs so the modal never wastes + space no matter how many history entries the disk has. */} +

{t("storage.historyTab.title")} @@ -4337,7 +4400,7 @@ function HistoryTab({ disk }: { disk: DiskInfo }) {

-
+
{history.map((entry, i) => { const isLatest = i === 0 const testDate = new Date(entry.timestamp) @@ -4399,7 +4462,7 @@ function HistoryTab({ disk }: { disk: DiskInfo }) { })}
-

+

{t("storage.historyTab.note")}

@@ -4569,7 +4632,7 @@ function ScheduleTab({ disk }: { disk: DiskInfo }) { if (loading) { return ( -
+
{t("storage.scheduleTab.loading")}
@@ -4577,7 +4640,7 @@ function ScheduleTab({ disk }: { disk: DiskInfo }) { } return ( -
+
{/* Global Toggle */}
diff --git a/AppImage/components/terminal-panel.tsx b/AppImage/components/terminal-panel.tsx index 8585c780..4f239718 100644 --- a/AppImage/components/terminal-panel.tsx +++ b/AppImage/components/terminal-panel.tsx @@ -1221,13 +1221,11 @@ const handleClose = () => { ) : null}
-
-
- - {t("terminal.searchTip")} + {useOnline && searchResults.length > 0 && ( +
+ {t("terminal.poweredByCheatSh")}
- {useOnline && searchResults.length > 0 && {t("terminal.poweredByCheatSh")}} -
+ )}
diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index 33de5071..d0a76ffd 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -4,12 +4,13 @@ import type React from "react" import { useState, useMemo, useEffect, useRef } from "react" import { fetchLxcApps, getLxcAppsCached, invalidateLxcApps, seedLxcAppsCache } from "../lib/lxc-apps-cache" +import { parseTags, stringifyTags, tagToColor } from "../lib/pve-tag-color" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Badge } from "./ui/badge" import { Progress } from "./ui/progress" import { Button } from "./ui/button" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "./ui/dialog" -import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Pencil, Trash2, Check, AlertTriangle, AlertCircle } from 'lucide-react' +import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Pencil, Trash2, Check, X, AlertTriangle, AlertCircle, Tag as TagIcon } from 'lucide-react' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { Checkbox } from "./ui/checkbox" import { Switch } from "./ui/switch" @@ -126,6 +127,11 @@ interface VMData { diskwrite?: number ip?: string update_check?: LxcUpdateCheck + // Proxmox tags as a raw string ("prod;web;monitoring" — PVE + // separator is ';' but ',' is also accepted). Rendered as + // coloured dots next to the ID in the list cards and as full + // pills inside the modal. + tags?: string // List of registered apps (0..N). Managed entries (Secure Gateway) // always come first when present. app_watches?: LxcAppWatch[] @@ -436,7 +442,7 @@ function MountPointCard({ mp }: { mp: LxcMountPoint }) { ? "border-amber-500/40 bg-amber-500/5" : isReadonly ? "border-amber-500/30 bg-amber-500/5" - : "border border-white/10 sm:border-border bg-white/5 sm:bg-card" + : "border border-border bg-card" const typeBadgeClass: Record = { pve_volume: "bg-cyan-500/10 text-cyan-400 border-cyan-500/20", @@ -698,8 +704,11 @@ export function VirtualMachines() { // Only the static half of mount-points goes here. Runtime // (capacity/health/ad-hoc) is intentionally NOT cached — see // `mountPointsRuntime` state + `fetchMountPoints` for the - // always-fresh side. - mountPoints: new Map(), + // always-fresh side. `ad_hoc_hint_count` is the cheap remote-fs + // count from /proc//mounts (server-side, no subprocess) + // that lets us render the Mount Points tab header at open time + // for CTs that only have NFS/CIFS mounted from inside. + mountPoints: new Map(), // Firewall log is on-demand ONLY — do NOT seed it from the bulk // modal-cache endpoint or from any prewarmer. The log is a live // stream (new entries flow with every packet the firewall drops) @@ -721,6 +730,26 @@ export function VirtualMachines() { } | null>(null) const [confirmDestructiveTyped, setConfirmDestructiveTyped] = useState("") const [detailsLoading, setDetailsLoading] = useState(false) + // Status tab: inline editor for the "start on boot" toggle. + // resourcesEditMode → true when the pencil is open, gates the + // toggle so accidental clicks don't fire. + // pendingOnboot → local edit value; null means "no pending + // change, use whatever vmDetails.config + // says". `qm/pct set --onboot` is hot, so + // the change lands without a reboot. + // savingOnboot → spinner state on Save. + // savedOnboot → 2 s ack pill after successful save. + const [resourcesEditMode, setResourcesEditMode] = useState(false) + const [pendingOnboot, setPendingOnboot] = useState(null) + const [pendingTags, setPendingTags] = useState(null) + const [newTagDraft, setNewTagDraft] = useState("") + // When set (in edit mode), the pill at this index renders as an + // inline text input pre-filled with its current text. Enter/blur + // commits the change; Escape reverts; empty commits removes the tag. + const [editingTagIndex, setEditingTagIndex] = useState(null) + const [editingTagDraft, setEditingTagDraft] = useState("") + const [savingOnboot, setSavingOnboot] = useState(false) + const [savedOnboot, setSavedOnboot] = useState(false) // Post-apply state for the Updates tab. When the script terminal // closes, the tab enters a "Comprobando resultado…" state until // a fresh /api/vms poll delivers the new update_check counts; @@ -791,6 +820,13 @@ export function VirtualMachines() { // static cards still render (paths, types, storage origin) and // reveal usage/health when the fetch resolves. const [mountPointsRuntime, setMountPointsRuntime] = useState> | null>(null) + // Cheap count of remote-fs (nfs/cifs/smb) entries in the CT's + // /proc//mounts, delivered by the static endpoint so the + // Mount Points tab can render its header IMMEDIATELY for CTs that + // only have ad-hoc mounts inside the container. The full ad-hoc + // list still arrives via the runtime fetch (with capacity/health); + // this is just enough to know "should the tab show at all?". + const [mountsAdHocHint, setMountsAdHocHint] = useState(0) // Detect standalone mode (webapp vs browser) const [isStandalone, setIsStandalone] = useState(false) @@ -948,6 +984,15 @@ export function VirtualMachines() { setShowNotes(false) setIsEditingNotes(false) setEditedNotes("") + // Resources edit mode never carries across guests — always + // start in view mode with no pending change. + setResourcesEditMode(false) + setPendingOnboot(null) + setPendingTags(null) + setNewTagDraft("") + setEditingTagIndex(null) + setEditingTagDraft("") + setSavedOnboot(false) setActiveModalTab("status") // Reset firewall log state — fetched lazily when the user opens // that tab, since most operators won't visit it on every modal open. @@ -965,6 +1010,11 @@ export function VirtualMachines() { setVMDetails(seedDetails ?? null) setVmBackups(seedBackups?.backups ?? []) setMountPoints(seedMounts?.mount_points ?? []) + // Seed the ad-hoc hint from the static payload so the Mount + // Points tab header renders instantly even when the CT only + // has NFS/CIFS mounts done from inside (nothing in .conf). + // The full list arrives via the runtime fetch a moment later. + setMountsAdHocHint(seedMounts?.ad_hoc_hint_count ?? 0) // Ad-hoc mounts only come from the runtime fetch — never seeded. // Reset to empty on each modal open; if the CT has any, they // appear as soon as the runtime response arrives. @@ -1059,7 +1109,7 @@ export function VirtualMachines() { backups: { backups?: VMBackup[] } | null apps?: { apps?: any[] } | null schedule?: any | null - mount_points?: { ok?: boolean; mount_points?: LxcMountPoint[]; ad_hoc?: LxcMountPoint[] } | null + mount_points?: { ok?: boolean; mount_points?: LxcMountPoint[]; ad_hoc_hint_count?: number } | null }> }>(`/api/vms/modal-cache-all`) .then((payload) => { @@ -1082,9 +1132,13 @@ export function VirtualMachines() { if (g.mount_points?.ok) { // Only the static half now — ad_hoc + runtime come // from the always-fresh /mount-points/runtime endpoint - // and are not seeded from the bulk payload. + // and are not seeded from the bulk payload. The + // ad_hoc_hint_count travels along so the tab header + // can render at open time even when the CT only has + // NFS/CIFS mounted from inside. cache.mountPoints.set(g.vmid, { mount_points: g.mount_points.mount_points || [], + ad_hoc_hint_count: (g.mount_points as any).ad_hoc_hint_count ?? 0, }) } } @@ -1116,6 +1170,7 @@ export function VirtualMachines() { fetchApi<{ ok: boolean mount_points: LxcMountPoint[] + ad_hoc_hint_count?: number }>(`/api/lxc/${vmid}/mount-points`).catch((e) => { console.error("Error fetching static mount points:", e) return null @@ -1132,8 +1187,10 @@ export function VirtualMachines() { ]) if (staticResp?.ok) { const mp = staticResp.mount_points || [] + const hint = staticResp.ad_hoc_hint_count ?? 0 setMountPoints(mp) - vmModalCacheRef.current.mountPoints.set(vmid, { mount_points: mp }) + setMountsAdHocHint(hint) + vmModalCacheRef.current.mountPoints.set(vmid, { mount_points: mp, ad_hoc_hint_count: hint }) } else if (!hasSeed) { setMountPoints([]) } @@ -1306,6 +1363,65 @@ export function VirtualMachines() { } } + const handleCancelResourcesEdit = () => { + setPendingOnboot(null) + setPendingTags(null) + setNewTagDraft("") + setEditingTagIndex(null) + setEditingTagDraft("") + setResourcesEditMode(false) + } + + const handleSaveResources = async () => { + if (!selectedVM) return + const currentOnboot = !!vmDetails?.config?.onboot + const currentTags = parseTags(selectedVM.tags) + // Build the smallest payload that reflects real changes — the + // backend allow-list accepts onboot + tags together in one call. + const payload: Record = {} + if (pendingOnboot !== null && pendingOnboot !== currentOnboot) { + payload.onboot = pendingOnboot ? 1 : 0 + } + if (pendingTags !== null && stringifyTags(pendingTags) !== stringifyTags(currentTags)) { + payload.tags = pendingTags + } + if (Object.keys(payload).length === 0) { + handleCancelResourcesEdit() + return + } + setSavingOnboot(true) + try { + await fetchApi(`/api/vms/${selectedVM.vmid}/config`, { + method: "POST", + body: JSON.stringify(payload), + }) + // Optimistic local reflect so the UI doesn't wait a poll cycle. + if (payload.onboot !== undefined) { + setVMDetails((prev) => (prev ? { ...prev, config: { ...prev.config, onboot: payload.onboot as number } } : prev)) + } + if (payload.tags !== undefined) { + const nextTagsStr = stringifyTags(payload.tags as string[]) + setSelectedVM((prev) => (prev ? { ...prev, tags: nextTagsStr } : prev)) + } + vmModalCacheRef.current.details.delete(selectedVM.vmid) + // Trigger a natural /api/vms revalidation so tags flow into the + // list card too without waiting up to 2.5 s. + void mutate() + setPendingOnboot(null) + setPendingTags(null) + setNewTagDraft("") + setEditingTagIndex(null) + setEditingTagDraft("") + setResourcesEditMode(false) + setSavedOnboot(true) + setTimeout(() => setSavedOnboot(false), 2000) + } catch (err) { + console.error("Failed to update resources:", err) + } finally { + setSavingOnboot(false) + } + } + const handleVMControl = async (vmid: number, action: string) => { setControlLoading(true) try { @@ -2453,6 +2569,22 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { {vm.name} ID: {vm.vmid} + {/* PVE tag dots — hash-derived colour so the same + tag renders identically across the app and + matches PVE's own tag palette. Full pills + live inside the modal; here only the dot + reads at a glance in a dense card. */} + {parseTags(vm.tags).map((tag) => { + const { bg } = tagToColor(tag) + return ( + + ) + })} {vm.type === "lxc" && (
{renderLxcUpdateBadge(vm.update_check)} @@ -2467,8 +2599,15 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { grid. Below `lg` the left slot is hidden and the grid spans the full card width. */}
- {vm.status === "running" && ( -
+ {/* Left slot is ALWAYS mounted on lg+ so the + metrics grid keeps the same column widths + whether the guest is running or stopped — + otherwise stopped rows shifted their CPU / + RAM / Disk columns leftward and broke the + vertical alignment across cards. Content + inside is still gated on `running`. */} +
+ {vm.status === "running" && (
{t("vmLxc.uptime", { uptime: formatUptime(vm.uptime, t) })} @@ -2480,8 +2619,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { )}
-
- )} + )} +
{t("vmLxc.cpuUsage")}
@@ -2601,6 +2740,17 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{vm.name}
ID: {vm.vmid} + {parseTags(vm.tags).map((tag) => { + const { bg } = tagToColor(tag) + return ( + + ) + })} {vm.type === "lxc" && vm.update_check?.available && (vm.update_check?.count ?? 0) > 0 && ( )} @@ -2868,9 +3018,15 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { )} )} - {/* Sprint 13.29: Mount Points tab — LXC only, and only - when at least one mp / ad-hoc remote mount exists. */} - {selectedVM?.type === "lxc" && (mountPoints.length > 0 || adHocMounts.length > 0) && ( + {/* Mount Points tab — LXC only, and only when at least + one mp / ad-hoc remote mount exists. The ad-hoc + hint (from the static endpoint, counts remote-fs + entries in /proc//mounts) lets the tab render + at open time even before the runtime fetch has + delivered the real ad_hoc list. Badge count adds + that hint so it never reads "0" while data is on + the wire. */} + {selectedVM?.type === "lxc" && (mountPoints.length > 0 || adHocMounts.length > 0 || mountsAdHocHint > 0) && ( )} @@ -3061,6 +3217,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {

{t("vmLxc.resources")}

+ {/* Editar / Guardar / Cancelar collapse to + icon-only squares on mobile so 3 of them + plus Notas don't overflow the row. Notas + keeps its label because "Notes" is + non-obvious as an icon. Pattern is + consistent with tab strips elsewhere. */}
- + {/* Editar — reveals the autostart toggle + as editable. Only one field for now + (`onboot`); the endpoint accepts a + whitelist so growing this to boot + order, protection etc. is additive. + Feedback pattern mirrors the Settings + cards: Saved pill for 2s, Cancel/Save + pair while editing. */} + {savedOnboot && ( + + + {t("status.saved")} + + )} + {resourcesEditMode ? ( + <> + + + + ) : ( + + )}
@@ -3131,11 +3339,180 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { )}
+ {/* PVE tags block. Same edit-gate as the + autostart toggle below — the Editar + button on this card unlocks both. In + view mode we render each tag as a + sharp pill (colors from the 1:1 port + of PVE's stringToRGB + SAPC text + picker, so the palette matches the + PVE UI exactly). In edit mode: + clicking a pill turns it into an + inline input for renaming; the × + removes it; an input at the tail + adds new tags (Enter/,/; commits). + Validation mirrors the Flask + allow-list regex. */} + {(() => { + const viewTags = parseTags(selectedVM?.tags) + const editTags = pendingTags ?? viewTags + const shownTags = resourcesEditMode ? editTags : viewTags + if (!resourcesEditMode && shownTags.length === 0) return null + const commitEdit = (index: number) => { + const raw = editingTagDraft.trim() + const next = [...editTags] + if (!raw) { + next.splice(index, 1) + } else if (!/^[a-zA-Z0-9._\-+]+$/.test(raw)) { + setEditingTagIndex(null) + setEditingTagDraft("") + return + } else if (next.includes(raw) && next[index] !== raw) { + setEditingTagIndex(null) + setEditingTagDraft("") + return + } else { + next[index] = raw + } + setPendingTags(next) + setEditingTagIndex(null) + setEditingTagDraft("") + } + return ( +
+
+ +
+ {t("vmLxc.details.tags")} +
+
+
+ {shownTags.map((tag, index) => { + const { bg, fg } = tagToColor(tag) + const isEditingThis = resourcesEditMode && editingTagIndex === index + if (isEditingThis) { + return ( + setEditingTagDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === "," || e.key === ";") { + e.preventDefault() + commitEdit(index) + } else if (e.key === "Escape") { + e.preventDefault() + setEditingTagIndex(null) + setEditingTagDraft("") + } + }} + onBlur={() => commitEdit(index)} + className="h-7 text-sm px-2 rounded-sm bg-background border border-border focus:outline-none focus:ring-1 focus:ring-blue-500 min-w-[80px]" + style={{ width: `${Math.max(8, editingTagDraft.length + 2)}ch` }} + /> + ) + } + return ( + { + if (!resourcesEditMode) return + setEditingTagIndex(index) + setEditingTagDraft(tag) + }} + > + {tag} + {resourcesEditMode && ( + + )} + + ) + })} + {resourcesEditMode && ( + setNewTagDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === "," || e.key === ";") { + e.preventDefault() + const raw = newTagDraft.trim() + if (!raw) return + if (!/^[a-zA-Z0-9._\-+]+$/.test(raw)) return + if (editTags.includes(raw)) { + setNewTagDraft("") + return + } + setPendingTags([...editTags, raw]) + setNewTagDraft("") + } + }} + placeholder={t("vmLxc.details.tagsPlaceholder")} + className="h-7 text-sm px-2 rounded-sm bg-background border border-border focus:outline-none focus:ring-1 focus:ring-blue-500 min-w-[100px] flex-1" + /> + )} + {!resourcesEditMode && shownTags.length === 0 && ( + + {t("vmLxc.details.tagsNone")} + + )} +
+
+ ) + })()} + + {/* Start-on-host toggle. Tight `mt-1` because + the tags block above is a companion of + this row — same edit-gate, both flip + together when Editar is pressed. Big + margin here made the whole guest + detail scroll further down for no + reason. */} +
+
+ +
+ {t("vmLxc.details.startOnBoot")} +
+
+ setPendingOnboot(v)} + className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!resourcesEditMode ? "opacity-60" : ""}`} + /> +
+ {/* IP Addresses with proper keys */} {selectedVM?.type === "lxc" && vmDetails?.lxc_ip_info && ( -
-

- +
+

+ {t("vmLxc.ipAddresses")}

@@ -3172,9 +3549,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { variant="outline" size="sm" onClick={handleEditNotes} - className="text-xs bg-transparent" + className="text-xs h-9 w-9 sm:w-auto sm:px-3 p-0 sm:p-2 bg-transparent" + aria-label={t("vmLxc.editNotes")} > - {t("vmLxc.editNotes")} + + {t("vmLxc.editNotes")} )}
@@ -3193,16 +3572,27 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { size="sm" onClick={handleCancelEditNotes} disabled={savingNotes} + className="h-9 w-9 sm:w-auto sm:px-3 p-0 sm:p-2" + aria-label={t("actions.cancel")} > - {t("actions.cancel")} + + {t("actions.cancel")}

@@ -3310,12 +3700,19 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
)} - {showAdditionalInfo && ( -
+ {/* Extended info — always shown now. Used to be + gated behind a "+ Info" button; the button + was removed and this block is fully expanded + so the user sees hardware/storage/network + without extra clicks. The former border-t + divider that separated the collapsed block + from the resources grid was dropped too; + the plain `mt-6` gives enough visual air. */} +
{selectedVM?.type === "lxc" && vmDetails?.hardware_info && (
-

- +

+ {t("vmLxc.containerConfiguration")}

@@ -3393,8 +3790,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { {/* Hardware Section */}
-

- +

+ {t("vmLxc.details.hardware")}

@@ -3597,8 +3994,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { } return (
-

- +

+ {t("vmLxc.details.storage")}

@@ -3634,8 +4031,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { {/* Network Section */}
-

- +

+ {t("vmLxc.details.network")}

@@ -3751,8 +4148,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { {/* PCI Devices with proper keys */} {Object.keys(vmDetails.config).some((key) => key.match(/^hostpci\d+$/)) && (
-

- +

+ {t("vmLxc.details.pciPassthrough")}

@@ -3775,8 +4172,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { {/* USB Devices with proper keys */} {Object.keys(vmDetails.config).some((key) => key.match(/^usb\d+$/)) && (
-

- +

+ {t("vmLxc.details.usbDevices")}

@@ -3799,8 +4196,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { {/* Serial Ports with proper keys */} {Object.keys(vmDetails.config).some((key) => key.match(/^serial\d+$/)) && (
-

- +

+ {t("vmLxc.details.serialPorts")}

@@ -3819,8 +4216,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {

)} -
- )} +

@@ -4950,16 +5346,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { const merged = rt ? { ...mp, ...rt } : mp return })} - {adHocMounts.length > 0 && ( - <> -
- {t("vmLxc.mountedFromContainer")} -
- {adHocMounts.map((mp) => ( - - ))} - - )} + {adHocMounts.length > 0 && adHocMounts.map((mp) => ( + // No divider heading here — each MountPointCard + // already carries a coloured "ad-hoc inside CT" + // badge next to its target, so a separate row + // saying the same thing was pure vertical noise. + + ))} )}
@@ -4967,17 +5360,23 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { {/* Backups Tab */} {activeModalTab === "backups" && ( -
- - -
+ /* Backups tab layout: the Card fills the whole tab + body so the backup list can scroll inside its + bordered card instead of leaving empty space + under a short list. Same pattern as the disk + modal tabs: header + summary pinned, list grows + and scrolls in the middle. */ +
+ + +

{t("vmLxc.backups.title")}

-
- + {/* Divider */} -
- +
+ {/* Backup List */} -
+
{t("vmLxc.backups.available")} {vmBackups.length}
- + {loadingBackups ? ( -
+
{t("vmLxc.backups.loading")}
) : vmBackups.length === 0 ? ( -
+
{t("vmLxc.backups.empty")} {t("vmLxc.backups.emptyHint")}
) : ( -
+
{vmBackups.map((backup, index) => (
{ grep). Loading is lazy and triggered by the tab button's onClick. */} {activeModalTab === "firewall" && ( -
- - -
+ /* Firewall tab: Card fills the whole tab body so + the log pre-block can grow with the modal + instead of being capped at 480 px. Header stays + pinned, log block scrolls in the middle. */ +
+ + +
@@ -5078,15 +5481,15 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
-
+
{loadingFirewallLog ? ( -
+
{t("vmLxc.firewall.loading")}
) : !firewallEnabled ? ( -
+
@@ -5104,7 +5507,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
) : firewallLogError ? ( -
+
@@ -5114,14 +5517,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
) : displayedFirewallLogs.length === 0 ? ( -
+
{t("vmLxc.firewall.empty")}
{t("vmLxc.firewall.emptyHint")}
) : ( -
+
                               {displayedFirewallLogs.map((entry, idx) => {
                                 const text = entry.t || ""
diff --git a/AppImage/lib/pve-tag-color.ts b/AppImage/lib/pve-tag-color.ts
new file mode 100644
index 00000000..c859f0d0
--- /dev/null
+++ b/AppImage/lib/pve-tag-color.ts
@@ -0,0 +1,79 @@
+// Proxmox VE tag color scheme — 1:1 port of the algorithm in
+// proxmoxlib.js (`Proxmox.Utils.stringToRGB` +
+// `Proxmox.Utils.getTextContrastClass`). Same input → same color
+// as the PVE web UI, so tags render identically in both places.
+
+export type TagColor = {
+  bg: string      // css `background-color`
+  fg: string      // css `color` — auto-picked for contrast (SAPC)
+  border: string  // css `border-color`
+}
+
+// Verbatim port of stringToRGB from proxmoxlib.js. The `+ 'prox'`
+// suffix, the `<< 5` hash, and the `alpha=0.7 / bg=255` blend
+// keep the output in the [76.5, 255] range per channel — that's
+// why every PVE tag is a "washed" bright color instead of a raw
+// hash-hue.
+function stringToRGB(input: string): [number, number, number] {
+  let hash = 0
+  if (!input) return [255, 255, 255]
+  const source = input + "prox"
+  for (let i = 0; i < source.length; i++) {
+    // eslint-disable-next-line no-bitwise
+    hash = source.charCodeAt(i) + ((hash << 5) - hash)
+    // eslint-disable-next-line no-bitwise
+    hash = hash & hash
+  }
+  const alpha = 0.7
+  const bg = 255
+  return [
+    // eslint-disable-next-line no-bitwise
+    (hash & 255) * alpha + bg * (1 - alpha),
+    // eslint-disable-next-line no-bitwise
+    ((hash >> 8) & 255) * alpha + bg * (1 - alpha),
+    // eslint-disable-next-line no-bitwise
+    ((hash >> 16) & 255) * alpha + bg * (1 - alpha),
+  ]
+}
+
+// SAPC-based light/dark text picker — verbatim port of
+// getTextContrastClass. Same tag → same text color as PVE.
+function getTextContrastClass(rgb: [number, number, number]): "light" | "dark" {
+  const blkThrs = 0.022
+  const blkClmp = 1.414
+  const r = (rgb[0] / 255) ** 2.4
+  const g = (rgb[1] / 255) ** 2.4
+  const b = (rgb[2] / 255) ** 2.4
+  let bg = r * 0.2126729 + g * 0.7151522 + b * 0.072175
+  bg = bg > blkThrs ? bg : bg + (blkThrs - bg) ** blkClmp
+  const contrastLight = bg ** 0.65 - 1
+  const contrastDark = bg ** 0.56 - 0.046134502
+  return Math.abs(contrastLight) >= Math.abs(contrastDark) ? "light" : "dark"
+}
+
+function rgbToCss(rgb: [number, number, number]): string {
+  return `rgb(${Math.round(rgb[0])}, ${Math.round(rgb[1])}, ${Math.round(rgb[2])})`
+}
+
+export function tagToColor(tag: string): TagColor {
+  const rgb = stringToRGB(tag)
+  const bg = rgbToCss(rgb)
+  const fg = getTextContrastClass(rgb) === "light" ? "#ffffff" : "#000000"
+  return { bg, fg, border: bg }
+}
+
+// Split a PVE tags string into an array. PVE separators are ';' and
+// ',' (both accepted); whitespace around tokens is stripped and
+// empty tokens dropped.
+export function parseTags(raw: string | null | undefined): string[] {
+  if (!raw) return []
+  return raw
+    .split(/[;,]/)
+    .map((t) => t.trim())
+    .filter(Boolean)
+}
+
+// Join back into the canonical PVE format (';' separator).
+export function stringifyTags(tags: string[]): string {
+  return tags.map((t) => t.trim()).filter(Boolean).join(";")
+}
diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json
index 24a8c5f7..41120c8f 100644
--- a/AppImage/messages/de/common.json
+++ b/AppImage/messages/de/common.json
@@ -21,6 +21,7 @@
     "cancel": "Stornieren",
     "save": "Speichern",
     "edit": "Bearbeiten",
+    "remove": "",
     "close": "Schließen",
     "resetAll": "Alles zurücksetzen",
     "undo": "Rückgängig machen",
@@ -297,10 +298,10 @@
       "installFailedManual": "Die Installation ist fehlgeschlagen. Versuchen Sie es manuell: apt-get install smartmontools nvme-cli",
       "installToolsManual": "Tools konnten nicht installiert werden. Versuchen Sie es manuell: apt-get install smartmontools nvme-cli",
       "runTest": "Führen Sie den SMART-Test durch",
-      "shortTest": "Kurztest (~2 Min.)",
+      "shortTest": "Kurztest",
       "longTest": "Langer Test (1-4 Stunden)",
-      "extendedTest": "Erweiterter Test (Hintergrund)",
-      "testHelp": "Ein kurzer Test dauert etwa 2 Minuten. Der erweiterte Test läuft im Hintergrund und kann auf großen Datenträgern mehrere Stunden dauern. Sie erhalten eine Benachrichtigung, wenn der Vorgang abgeschlossen ist.",
+      "extendedTest": "Erweiterter Test",
+      "testHelp": "",
       "startFailed": "Der Test konnte nicht gestartet werden",
       "short": "Kurz",
       "extended": "Erweitert",
@@ -316,7 +317,6 @@
       "worst": "Am schlimmsten",
       "status": "Status",
       "viewFullReport": "Vollständigen SMART-Bericht anzeigen",
-      "reportHelp": "Erstellen Sie einen detaillierten SMART-Bericht mit Analysen und Empfehlungen.",
       "loadingReport": "Bericht wird geladen...",
       "reportLoadFailed": "Berichtsdaten konnten nicht geladen werden.",
       "statusValues": {
@@ -1021,7 +1021,11 @@
         "readOnly": "schreibgeschützt",
         "stopped": "gestoppt",
         "mounted": "montiert"
-      }
+      },
+      "startOnBoot": "",
+      "tags": "",
+      "tagsPlaceholder": "",
+      "tagsNone": ""
     },
     "logs": {
       "header": "Protokolle für {name} (VMID: {vmid})",
@@ -1421,7 +1425,9 @@
       "notificationsEnabled": "Upstream-Update-Benachrichtigungen EIN – zum Stummschalten klicken",
       "notificationsMuted": "Upstream-Update-Benachrichtigungen stummgeschaltet – zum Aktivieren klicken",
       "notifyUpstreamLabel": "Benachrichtigen Sie mich, wenn eine neue Upstream-Version verfügbar ist",
-      "notifyUpstreamHelp": "Sendet „app_update_available“ an die Kanäle, die in Einstellungen → Benachrichtigungen aktiviert sind.Deaktivieren Sie diese Option, wenn diese App auf Ihrer Box nicht aktualisiert werden kann."
+      "notifyUpstreamHelp": "Sendet „app_update_available“ an die Kanäle, die in Einstellungen → Benachrichtigungen aktiviert sind.Deaktivieren Sie diese Option, wenn diese App auf Ihrer Box nicht aktualisiert werden kann.",
+      "excludeFromBadgeLabel": "",
+      "excludeFromBadgeHelp": ""
     }
   },
   "settings": {
@@ -3001,6 +3007,7 @@
     "currentFeatures": {
       "hostUpdate": "Host-Update mit einem Klick über den Health Monitor. Die neue Schaltfläche „Jetzt aktualisieren“ in System Updates führt den Proxmox-Update-Flow in einem Dashboard-Terminal aus, ohne den Browser zu verlassen.",
       "mobileInstall": "In-App-Installationsaufforderung für Mobilgeräte. Erstbesucher von Android und iOS Safari sehen jetzt einfache Schritte zum Hinzufügen des Monitors als PWA zu ihrem Startbildschirm.",
+      "i18n": "",
       "pageSpeed": "Schnellere Seitenladevorgänge und reibungslosere Navigation im Dashboard.Die Übersicht wird sofort geöffnet und auf der Seite „VMs und LXCs“ blinkt nie wieder „Laden…“ zwischen den Gastmodalitäten.",
       "appTab": "Neuer App-Tab im VM- und LXC-Modal – insbesondere für LXCs.Registrieren Sie die in einem Container installierten Apps, erfassen Sie ihre Weblinks und erhalten Sie Benachrichtigungen, wenn eine neue Upstream-Version ausgeliefert wird.",
       "updatesTab": "Überarbeitete Registerkarte „Updates“ für LXCs: Wenden Sie Betriebssystempakete und Updates für registrierte Apps über eine einzige Schaltfläche an und planen Sie einen wiederkehrenden automatischen Update-Job, der das Betriebssystem des Containers und seine verfolgte App bei jeder Ausführung überprüft.",
@@ -4579,7 +4586,6 @@
     "tryDifferentSearch": "Versuchen Sie es mit einem anderen Befehl oder überprüfen Sie die Rechtschreibung.",
     "searchAnyCommand": "Suchen Sie nach einem beliebigen Befehl",
     "trySearchingFor": "Suchen Sie nach:",
-    "searchTip": "Tipp: Suchen Sie nach einem beliebigen Linux- oder Proxmox-Befehl (qm, pct, zpool).",
     "noExamplesFound": "Keine Beispiele gefunden",
     "reconnecting": "Wieder verbinden…",
     "reconnected": "Erfolgreich wiederhergestellt",
diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json
index 332c97d5..869c2e2d 100644
--- a/AppImage/messages/en/common.json
+++ b/AppImage/messages/en/common.json
@@ -20,6 +20,7 @@
     "cancel": "Cancel",
     "save": "Save",
     "edit": "Edit",
+    "remove": "Remove",
     "close": "Close",
     "resetAll": "Reset all",
     "undo": "Undo",
@@ -296,10 +297,10 @@
       "installFailedManual": "Installation failed. Try manually: apt-get install smartmontools nvme-cli",
       "installToolsManual": "Failed to install tools. Try manually: apt-get install smartmontools nvme-cli",
       "runTest": "Run SMART test",
-      "shortTest": "Short test (~2 min)",
+      "shortTest": "Short test",
       "longTest": "Long test (1-4 hours)",
-      "extendedTest": "Extended test (background)",
-      "testHelp": "A short test takes about 2 minutes. The extended test runs in the background and can take several hours on large disks. You will receive a notification when it finishes.",
+      "extendedTest": "Extended test",
+      "testHelp": "A short test takes about 2 minutes. The extended test runs in the background and can take several hours on large disks. The result will show up in the History tab when it finishes.",
       "startFailed": "Failed to start test",
       "short": "Short",
       "extended": "Extended",
@@ -315,7 +316,6 @@
       "worst": "Worst",
       "status": "Status",
       "viewFullReport": "View full SMART report",
-      "reportHelp": "Generate a detailed SMART report with analysis and recommendations.",
       "loadingReport": "Loading report...",
       "reportLoadFailed": "Failed to load report data.",
       "statusValues": {
@@ -1020,7 +1020,11 @@
         "readOnly": "read-only",
         "stopped": "stopped",
         "mounted": "mounted"
-      }
+      },
+      "startOnBoot": "Start at boot",
+      "tags": "Tags",
+      "tagsPlaceholder": "Add tag…",
+      "tagsNone": "No tags"
     },
     "logs": {
       "header": "Logs for {name} (VMID: {vmid})",
@@ -1420,7 +1424,9 @@
       "notificationsEnabled": "Upstream update notifications ON — click to mute",
       "notificationsMuted": "Upstream update notifications MUTED — click to enable",
       "notifyUpstreamLabel": "Notify me when a new upstream version is available",
-      "notifyUpstreamHelp": "Sends `app_update_available` to the channels enabled in Settings → Notifications. Turn off if this app can't be updated on your box."
+      "notifyUpstreamHelp": "Sends `app_update_available` to the channels enabled in Settings → Notifications. Turn off if this app can't be updated on your box.",
+      "excludeFromBadgeLabel": "Exclude from the LXC updates counter",
+      "excludeFromBadgeHelp": "Don't count this app in the aggregate updates badge on the LXC list card. Useful when you're pinned to a specific version on purpose (tracker requirement, compatibility freeze). Doesn't affect the App tab's own state or the outbound notification."
     }
   },
   "settings": {
@@ -3000,6 +3006,7 @@
     "currentFeatures": {
       "hostUpdate": "One-click host update from the Health Monitor. The new Update Now button in System Updates runs the Proxmox update flow inside a dashboard terminal, without leaving the browser.",
       "mobileInstall": "In-app install prompt for mobile. First-time visitors on Android and iOS Safari now see simple steps for adding the Monitor to their home screen as a PWA.",
+      "i18n": "The Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Swedish and Slovak. Huge thanks to @vaso73 for building the i18n scaffolding that made this possible.",
       "pageSpeed": "Faster page loads and smoother navigation across the dashboard. Overview opens instantly and the VMs & LXCs page never flashes 'Loading…' between guest modals again.",
       "appTab": "New App tab inside the VM & LXC modal — especially for LXCs. Register the apps installed in a container, capture their weblinks, and get notifications when a new upstream version ships.",
       "updatesTab": "Reworked Updates tab for LXCs: apply OS packages and registered-app updates from a single button, and schedule a recurring auto-update job that checks the container's OS and its tracked app on every run.",
@@ -4579,7 +4586,6 @@
     "tryDifferentSearch": "Try another command or check the spelling.",
     "searchAnyCommand": "Search for any command",
     "trySearchingFor": "Try searching for:",
-    "searchTip": "Tip: Search for any Linux or Proxmox command (qm, pct, zpool).",
     "noExamplesFound": "No examples found",
     "reconnecting": "Reconnecting…",
     "reconnected": "Reconnected successfully",
diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json
index ac2267cb..eb423b2a 100644
--- a/AppImage/messages/es/common.json
+++ b/AppImage/messages/es/common.json
@@ -21,6 +21,7 @@
     "cancel": "Cancelar",
     "save": "Guardar",
     "edit": "Editar",
+    "remove": "Eliminar",
     "close": "Cerrar",
     "resetAll": "Restablecer todo",
     "undo": "Deshacer",
@@ -216,7 +217,7 @@
     "physicalDisk": "Disco físico",
     "overview": "General",
     "smart": "SMART",
-    "history": "Historia",
+    "history": "Historial",
     "schedule": "Programación",
     "serialNumber": "Número de serie",
     "healthStatus": "Estado de salud",
@@ -245,10 +246,10 @@
       "estimatedYears": "~{value} años",
       "estimatedMonths": "~{value} meses"
     },
-    "availableSpare": "Aprovechar. Repuesto",
+    "availableSpare": "Repuesto disp.",
     "smartAttributes": "Atributos SMART",
     "powerOnHours": "Horas de encendido",
-    "rotationRate": "Tasa de rotación",
+    "rotationRate": "Velocidad de rotación",
     "smartStatus": "Estado SMART",
     "reallocatedSectors": "Sectores reasignados",
     "pendingSectors": "Sectores Pendientes",
@@ -297,10 +298,10 @@
       "installFailedManual": "La instalación falló. Pruebe manualmente: apt-get install smartmontools nvme-cli",
       "installToolsManual": "No se pudieron instalar las herramientas. Pruebe manualmente: apt-get install smartmontools nvme-cli",
       "runTest": "Ejecutar prueba SMART",
-      "shortTest": "Prueba corta (~2 min)",
+      "shortTest": "Prueba corta",
       "longTest": "Prueba larga (1-4 horas)",
-      "extendedTest": "Prueba extendida (antecedentes)",
-      "testHelp": "Una prueba corta dura unos 2 minutos. La prueba extendida se ejecuta en segundo plano y puede tardar varias horas en discos grandes. Recibirás una notificación cuando finalice.",
+      "extendedTest": "Prueba extendida",
+      "testHelp": "Una prueba corta dura unos 2 minutos. La prueba extendida se ejecuta en segundo plano y puede tardar varias horas en discos grandes. El resultado aparecerá en la pestaña Historial cuando finalice.",
       "startFailed": "No se pudo iniciar la prueba",
       "short": "Corto",
       "extended": "Extendido",
@@ -316,7 +317,6 @@
       "worst": "El peor",
       "status": "Estado",
       "viewFullReport": "Ver informe SMART completo",
-      "reportHelp": "Genere un informe SMART detallado con análisis y recomendaciones.",
       "loadingReport": "Cargando informe...",
       "reportLoadFailed": "No se pudieron cargar los datos del informe.",
       "statusValues": {
@@ -337,7 +337,7 @@
       "yesterday": "Ayer",
       "daysAgo": "Hace {count} días",
       "downloadJson": "Descargar JSON",
-      "delete": "Borrar",
+      "delete": "Eliminar",
       "confirmDelete": "¿Eliminar este registro de prueba?",
       "note": "Los resultados de las pruebas se almacenan localmente y se utilizan para generar informes SMART detallados."
     },
@@ -945,7 +945,7 @@
       "dhm": "{days}d {hours}h {minutes}m"
     },
     "details": {
-      "sourceHost": "Fuente (anfitrión)",
+      "sourceHost": "Origen (host)",
       "mountedAtCt": "Montado en (CT)",
       "total": "Total",
       "used": "Usado",
@@ -968,7 +968,7 @@
       "dnsNameserver": "Servidor de nombres DNS",
       "searchDomain": "Dominio de búsqueda",
       "hostname": "Nombre de host",
-      "storageType": "{type} almacenamiento",
+      "storageType": "Almacenamiento {type}",
       "mountAttributes": "Atributos de montaje (configuración LXC)",
       "runtimeMountOptions": "Opciones de montaje en tiempo de ejecución",
       "privileged": "Privilegiado",
@@ -1009,10 +1009,10 @@
       "preEnrolledKeys": "Claves preinscritas",
       "serial": "De serie",
       "mountTypes": {
-        "pveVolume": "volumen PVE",
-        "pveStorageBind": "unirse desde el almacenamiento PVE",
-        "hostBind": "enlazar desde el host",
-        "adHoc": "ad-hoc dentro de CT"
+        "pveVolume": "Volumen PVE",
+        "pveStorageBind": "Montado desde almacenamiento PVE",
+        "hostBind": "Montado desde el host",
+        "adHoc": "Ad-hoc dentro del CT"
       },
       "mountStatus": {
         "stale": "duro",
@@ -1021,7 +1021,11 @@
         "readOnly": "solo lectura",
         "stopped": "Detenido",
         "mounted": "montado"
-      }
+      },
+      "startOnBoot": "Iniciar al arrancar",
+      "tags": "Etiquetas",
+      "tagsPlaceholder": "Añadir etiqueta…",
+      "tagsNone": "Sin etiquetas"
     },
     "logs": {
       "header": "Registros para {name} (VMID: {vmid})",
@@ -1275,7 +1279,7 @@
       "closePanel": "Cerrar panel",
       "cancelButton": "Cancelar",
       "saveButton": "Guardar",
-      "hideButton": "Esconder",
+      "hideButton": "Ocultar",
       "hidePermanentlyTooltip": "Ocultar esta detección permanentemente",
       "hiddenBadge": "Actualmente oculto: volverá a aparecer en la lista de detección",
       "registerDifferent": "Registrar una aplicación diferente",
@@ -1410,7 +1414,7 @@
       "httpJsonHelp": "Punto final JSON público que devuelve una versión en algún lugar de la carga útil.",
       "jsonPathHelp": "Ruta de puntos con índices de matriz [N] opcionales.",
       "restoreButton": "Restaurar",
-      "registerButton": "Registro",
+      "registerButton": "Registrar",
       "removeButton": "Eliminar",
       "checkButton": "Controlar",
       "editFieldsButton": "Editar campos",
@@ -1421,7 +1425,9 @@
       "notificationsEnabled": "Notificaciones de actualización activas — clic para silenciar",
       "notificationsMuted": "Notificaciones de actualización silenciadas — clic para activar",
       "notifyUpstreamLabel": "Notificarme cuando haya una nueva versión disponible",
-      "notifyUpstreamHelp": "Envía `app_update_available` a los canales activos en Ajustes → Notificaciones. Desactívalo si esta app no se puede actualizar en tu instalación."
+      "notifyUpstreamHelp": "Envía `app_update_available` a los canales activos en Ajustes → Notificaciones. Desactívalo si esta app no se puede actualizar en tu instalación.",
+      "excludeFromBadgeLabel": "Excluir del contador de actualizaciones del LXC",
+      "excludeFromBadgeHelp": "No sumar esta app al contador agregado de actualizaciones del card del LXC. Útil cuando mantienes una versión concreta a propósito (requisito de tracker, compatibilidad). No afecta al estado que se muestra en la pestaña App ni al envío de la notificación."
     }
   },
   "settings": {
@@ -2006,7 +2012,7 @@
       "perm": "permanente",
       "enabled": "Activado",
       "disabled": "Desactivado",
-      "enable": "Permitir",
+      "enable": "Activar",
       "disable": "Desactivar",
       "enabledLower": "activado",
       "disabledLower": "desactivado",
@@ -2014,7 +2020,7 @@
       "hostLower": "anfitrión",
       "remove": "Eliminar",
       "allow": "Permitir",
-      "delete": "Borrar",
+      "delete": "Eliminar",
       "uninstall": "Desinstalar",
       "uninstalling": "Desinstalando...",
       "serviceRunning": "Servicio en ejecución",
@@ -2124,7 +2130,7 @@
       "confirmPassword": "Confirmar Contraseña",
       "confirmPasswordPlaceholder": "Ingrese la contraseña nuevamente",
       "enabling": "Habilitando...",
-      "enableShort": "Permitir",
+      "enableShort": "Activar",
       "changePassword": "Cambiar la contraseña",
       "currentPassword": "Contraseña actual",
       "currentPasswordPlaceholder": "Ingrese la contraseña actual",
@@ -2388,7 +2394,7 @@
         "singleIp": "dirección IP",
         "network": "Red",
         "edit": "Editar",
-        "delete": "Borrar",
+        "delete": "Eliminar",
         "save": "Guardar cambios",
         "trusted": "Confiable",
         "system": "Sistema",
@@ -3001,6 +3007,7 @@
     "currentFeatures": {
       "hostUpdate": "Actualización del host con un solo clic desde Health Monitor. El nuevo botón Actualizar ahora en Actualizaciones del sistema ejecuta el flujo de actualización de Proxmox dentro de una terminal del tablero, sin salir del navegador.",
       "mobileInstall": "Aviso de instalación en la aplicación para dispositivos móviles. Quienes visitan Safari por primera vez en Android e iOS ahora ven pasos sencillos para agregar el monitor a su pantalla de inicio como PWA.",
+      "i18n": "El Monitor ahora está traducido a 8 idiomas: inglés, español, alemán, francés, italiano, portugués, sueco y eslovaco. Muchas gracias a @vaso73 por crear la estructura de i18n que hizo esto posible.",
       "pageSpeed": "Carga de páginas más rápida y navegación más fluida en todo el panel. La página de Inicio abre al instante y la página de VMs y LXC ya no muestra 'Cargando…' al abrir los modales de cada máquina.",
       "appTab": "Nueva pestaña App dentro del modal de VM y LXC — especialmente para los LXC. Registra las aplicaciones instaladas en un contenedor, guarda sus enlaces web y recibe notificaciones cuando aparece una nueva versión.",
       "updatesTab": "Pestaña Updates rediseñada para LXC: aplica las actualizaciones del SO y de las apps registradas desde un mismo botón, y programa una tarea de auto-actualización recurrente que revisa el SO del contenedor y la app registrada en cada ejecución.",
@@ -3327,7 +3334,7 @@
       "edgeTpuRuntime": "Tiempo de ejecución de Edge TPU",
       "thresholds": "Umbrales",
       "hardwareWarnings": "Advertencias de hardware",
-      "currentDraw": "Sorteo actual",
+      "currentDraw": "Consumo actual",
       "currentOutput": "Salida actual",
       "remote": "Remoto",
       "batteryCharge": "Carga de la batería",
@@ -3361,7 +3368,7 @@
       "linkSpeed": "Velocidad de enlace",
       "family": "Familia",
       "interface": "Interfaz",
-      "rotationRate": "Tasa de rotación",
+      "rotationRate": "Velocidad de rotación",
       "classCode": "Código de clase",
       "serial": "De serie"
     },
@@ -3506,11 +3513,11 @@
       "back": "Atrás",
       "change": "Cambiar",
       "clear": "Limpiar",
-      "delete": "Borrar",
+      "delete": "Eliminar",
       "disable": "Desactivar",
       "download": "Descargar",
       "edit": "Editar",
-      "enable": "Permitir",
+      "enable": "Activar",
       "format": "Formato",
       "generateKey": "Generar clave",
       "import": "Importar",
@@ -3521,12 +3528,12 @@
       "regenerate": "Regenerado",
       "restore": "Restaurar",
       "restoreSelected": "Restaurar seleccionado",
-      "runNow": "Corre ahora",
+      "runNow": "Ejecutar",
       "saveChanges": "Guardar cambios",
       "unmount": "Desmontar",
       "upload": "Subir",
       "use": "Usar",
-      "viewContents": "Ver contenidos"
+      "viewContents": "Ver contenido"
     },
     "archives": {
       "archive": "Archivo",
@@ -4548,8 +4555,8 @@
     },
     "actions": {
       "details": "Detalles",
-      "history": "Historia",
-      "dismiss": "Despedir"
+      "history": "Historial",
+      "dismiss": "Descartar"
     },
     "summary": {
       "guests": "Huéspedes",
@@ -4579,7 +4586,6 @@
     "tryDifferentSearch": "Pruebe con otro comando o revise la ortografía.",
     "searchAnyCommand": "Buscar cualquier comando",
     "trySearchingFor": "Intente buscar:",
-    "searchTip": "Consejo: busque cualquier comando de Linux o Proxmox (qm, pct, zpool).",
     "noExamplesFound": "No se encontraron ejemplos",
     "reconnecting": "Reconectando…",
     "reconnected": "Reconectado exitosamente",
diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json
index cd361bd5..e75ea500 100644
--- a/AppImage/messages/fr/common.json
+++ b/AppImage/messages/fr/common.json
@@ -21,6 +21,7 @@
     "cancel": "Annuler",
     "save": "Sauvegarder",
     "edit": "Modifier",
+    "remove": "",
     "close": "Fermer",
     "resetAll": "Tout réinitialiser",
     "undo": "Défaire",
@@ -297,10 +298,10 @@
       "installFailedManual": "L'installation a échoué. Essayez manuellement : apt-get install smartmontools nvme-cli",
       "installToolsManual": "Échec de l'installation des outils. Essayez manuellement : apt-get install smartmontools nvme-cli",
       "runTest": "Exécuter le test SMART",
-      "shortTest": "Test court (~2 min)",
+      "shortTest": "Test court",
       "longTest": "Test long (1 à 4 heures)",
-      "extendedTest": "Test étendu (contexte)",
-      "testHelp": "Un court test prend environ 2 minutes. Le test étendu s'exécute en arrière-plan et peut prendre plusieurs heures sur des disques volumineux. Vous recevrez une notification une fois terminé.",
+      "extendedTest": "Test étendu",
+      "testHelp": "",
       "startFailed": "Échec du démarrage du test",
       "short": "Court",
       "extended": "Étendu",
@@ -316,7 +317,6 @@
       "worst": "Pire",
       "status": "Statut",
       "viewFullReport": "Afficher le rapport SMART complet",
-      "reportHelp": "Générez un rapport SMART détaillé avec analyse et recommandations.",
       "loadingReport": "Chargement du rapport...",
       "reportLoadFailed": "Échec du chargement des données du rapport.",
       "statusValues": {
@@ -1021,7 +1021,11 @@
         "readOnly": "en lecture seule",
         "stopped": "arrêté",
         "mounted": "monté"
-      }
+      },
+      "startOnBoot": "",
+      "tags": "",
+      "tagsPlaceholder": "",
+      "tagsNone": ""
     },
     "logs": {
       "header": "Journaux pour {name} (VMID : {vmid})",
@@ -1421,7 +1425,9 @@
       "notificationsEnabled": "Notifications de mise à jour en amont activées – cliquez pour désactiver le son",
       "notificationsMuted": "Notifications de mise à jour en amont MUTED – cliquez pour activer",
       "notifyUpstreamLabel": "Me prévenir lorsqu'une nouvelle version en amont est disponible",
-      "notifyUpstreamHelp": "envoie `app_update_available` aux canaux activés dans Paramètres → Notifications.Désactivez-la si cette application ne peut pas être mise à jour sur votre box."
+      "notifyUpstreamHelp": "envoie `app_update_available` aux canaux activés dans Paramètres → Notifications.Désactivez-la si cette application ne peut pas être mise à jour sur votre box.",
+      "excludeFromBadgeLabel": "",
+      "excludeFromBadgeHelp": ""
     }
   },
   "settings": {
@@ -3001,6 +3007,7 @@
     "currentFeatures": {
       "hostUpdate": "Mise à jour de l'hôte en un clic depuis Health Monitor. Le nouveau bouton Mettre à jour maintenant dans les mises à jour système exécute le flux de mise à jour Proxmox dans un terminal de tableau de bord, sans quitter le navigateur.",
       "mobileInstall": "Invite d'installation dans l'application pour mobile. Les nouveaux visiteurs sur Android et iOS Safari voient désormais des étapes simples pour ajouter le moniteur à leur écran d'accueil en tant que PWA.",
+      "i18n": "",
       "pageSpeed": "chargements de pages plus rapides et navigation plus fluide dans le tableau de bord.La présentation s'ouvre instantanément et la page VM et LXC ne clignote plus jamais « Chargement… » entre les modaux invités.",
       "appTab": "nouvel onglet Application dans le modal VM et LXC, en particulier pour les LXC.Enregistrez les applications installées dans un conteneur, capturez leurs liens Web et recevez des notifications lorsqu'une nouvelle version en amont est livrée.",
       "updatesTab": "onglet Mises à jour retravaillées pour les LXC : appliquez les packages de système d'exploitation et les mises à jour des applications enregistrées à partir d'un seul bouton, et planifiez une tâche de mise à jour automatique récurrente qui vérifie le système d'exploitation du conteneur et son application suivie à chaque exécution.",
@@ -4579,7 +4586,6 @@
     "tryDifferentSearch": "Essayez une autre commande ou vérifiez l'orthographe.",
     "searchAnyCommand": "Rechercher n'importe quelle commande",
     "trySearchingFor": "Essayez de rechercher :",
-    "searchTip": "Astuce : recherchez n'importe quelle commande Linux ou Proxmox (qm, pct, zpool).",
     "noExamplesFound": "Aucun exemple trouvé",
     "reconnecting": "Reconnexion…",
     "reconnected": "Reconnecté avec succès",
diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json
index aa8a7948..b0fd704f 100644
--- a/AppImage/messages/it/common.json
+++ b/AppImage/messages/it/common.json
@@ -21,6 +21,7 @@
     "cancel": "Cancellare",
     "save": "Salva",
     "edit": "Modificare",
+    "remove": "",
     "close": "Vicino",
     "resetAll": "Reimposta tutto",
     "undo": "Disfare",
@@ -297,10 +298,10 @@
       "installFailedManual": "Installazione non riuscita. Prova manualmente: apt-get install smartmontools nvme-cli",
       "installToolsManual": "Impossibile installare gli strumenti. Prova manualmente: apt-get install smartmontools nvme-cli",
       "runTest": "Esegui il test SMART",
-      "shortTest": "Test breve (~2 minuti)",
+      "shortTest": "Test breve",
       "longTest": "Test lungo (1-4 ore)",
-      "extendedTest": "Test esteso (contesto)",
-      "testHelp": "Un breve test dura circa 2 minuti. Il test esteso viene eseguito in background e può richiedere diverse ore su dischi di grandi dimensioni. Riceverai una notifica al termine.",
+      "extendedTest": "Test esteso",
+      "testHelp": "",
       "startFailed": "Impossibile avviare il test",
       "short": "Corto",
       "extended": "Esteso",
@@ -316,7 +317,6 @@
       "worst": "Peggio",
       "status": "Stato",
       "viewFullReport": "Visualizza il rapporto SMART completo",
-      "reportHelp": "Genera un report SMART dettagliato con analisi e raccomandazioni.",
       "loadingReport": "Caricamento rapporto...",
       "reportLoadFailed": "Impossibile caricare i dati del rapporto.",
       "statusValues": {
@@ -1021,7 +1021,11 @@
         "readOnly": "sola lettura",
         "stopped": "fermato",
         "mounted": "montato"
-      }
+      },
+      "startOnBoot": "",
+      "tags": "",
+      "tagsPlaceholder": "",
+      "tagsNone": ""
     },
     "logs": {
       "header": "Registri per {name} (VMID: {vmid})",
@@ -1421,7 +1425,9 @@
       "notificationsEnabled": "notifiche di aggiornamento upstream attivate: fai clic per disattivare l'audio",
       "notificationsMuted": "notifiche di aggiornamento upstream MUTED: fare clic per abilitare",
       "notifyUpstreamLabel": "avvisami quando è disponibile una nuova versione upstream",
-      "notifyUpstreamHelp": "invia `app_update_available` ai canali abilitati in Impostazioni → Notifiche.Disattiva se questa app non può essere aggiornata sul tuo box."
+      "notifyUpstreamHelp": "invia `app_update_available` ai canali abilitati in Impostazioni → Notifiche.Disattiva se questa app non può essere aggiornata sul tuo box.",
+      "excludeFromBadgeLabel": "",
+      "excludeFromBadgeHelp": ""
     }
   },
   "settings": {
@@ -3001,6 +3007,7 @@
     "currentFeatures": {
       "hostUpdate": "Aggiornamento host con un clic da Health Monitor. Il nuovo pulsante Aggiorna ora in Aggiornamenti di sistema esegue il flusso di aggiornamento di Proxmox all'interno di un terminale dashboard, senza uscire dal browser.",
       "mobileInstall": "Richiesta di installazione in-app per dispositivi mobili. Chi visita per la prima volta Android e iOS Safari ora vede semplici passaggi per aggiungere il monitor alla propria schermata iniziale come PWA.",
+      "i18n": "",
       "pageSpeed": "caricamenti delle pagine più rapidi e navigazione più fluida nella dashboard.La panoramica si apre immediatamente e la pagina VM e LXC non lampeggia mai più con la dicitura \"Caricamento in corso...\" tra le modalità guest.",
       "appTab": "nuova scheda App all'interno della modalità VM e LXC, in particolare per LXC.Registra le app installate in un contenitore, acquisisci i relativi collegamenti web e ricevi notifiche quando viene fornita una nuova versione upstream.",
       "updatesTab": "scheda Aggiornamenti rielaborati per LXC: applica pacchetti del sistema operativo e aggiornamenti delle app registrate da un singolo pulsante e pianifica un processo di aggiornamento automatico ricorrente che controlla il sistema operativo del contenitore e la relativa app monitorata a ogni esecuzione.",
@@ -4579,7 +4586,6 @@
     "tryDifferentSearch": "Prova un altro comando o controlla l'ortografia.",
     "searchAnyCommand": "Cerca qualsiasi comando",
     "trySearchingFor": "Prova a cercare:",
-    "searchTip": "Suggerimento: cerca qualsiasi comando Linux o Proxmox (qm, pct, zpool).",
     "noExamplesFound": "Nessun esempio trovato",
     "reconnecting": "Riconnessione…",
     "reconnected": "Ricollegato con successo",
diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json
index 3cc15f5e..2c1f9624 100644
--- a/AppImage/messages/pt/common.json
+++ b/AppImage/messages/pt/common.json
@@ -21,6 +21,7 @@
     "cancel": "Cancelar",
     "save": "Salvar",
     "edit": "Editar",
+    "remove": "",
     "close": "Fechar",
     "resetAll": "Redefinir tudo",
     "undo": "Desfazer",
@@ -297,10 +298,10 @@
       "installFailedManual": "A instalação falhou. Tente manualmente: apt-get install smartmontools nvme-cli",
       "installToolsManual": "Falha ao instalar ferramentas. Tente manualmente: apt-get install smartmontools nvme-cli",
       "runTest": "Execute o teste SMART",
-      "shortTest": "Teste curto (~2 min)",
+      "shortTest": "Teste curto",
       "longTest": "Teste longo (1-4 horas)",
-      "extendedTest": "Teste estendido (fundo)",
-      "testHelp": "Um pequeno teste leva cerca de 2 minutos. O teste estendido é executado em segundo plano e pode levar várias horas em discos grandes. Você receberá uma notificação quando terminar.",
+      "extendedTest": "Teste estendido",
+      "testHelp": "",
       "startFailed": "Falha ao iniciar o teste",
       "short": "Curto",
       "extended": "Estendido",
@@ -316,7 +317,6 @@
       "worst": "Pior",
       "status": "Status",
       "viewFullReport": "Veja o relatório SMART completo",
-      "reportHelp": "Gere um relatório SMART detalhado com análises e recomendações.",
       "loadingReport": "Carregando relatório...",
       "reportLoadFailed": "Falha ao carregar dados do relatório.",
       "statusValues": {
@@ -1021,7 +1021,11 @@
         "readOnly": "somente leitura",
         "stopped": "parou",
         "mounted": "montado"
-      }
+      },
+      "startOnBoot": "",
+      "tags": "",
+      "tagsPlaceholder": "",
+      "tagsNone": ""
     },
     "logs": {
       "header": "Registros para {name} (VMID: {vmid})",
@@ -1421,7 +1425,9 @@
       "notificationsEnabled": "Notificações de atualização upstream ATIVADAS – clique para silenciar",
       "notificationsMuted": "notificações de atualização upstream silenciadas – clique para ativar",
       "notifyUpstreamLabel": "Notifique-me quando uma nova versão upstream estiver disponível",
-      "notifyUpstreamHelp": "Envia `app_update_available` para os canais habilitados em Configurações → Notificações.Desligue se este aplicativo não puder ser atualizado em sua caixa."
+      "notifyUpstreamHelp": "Envia `app_update_available` para os canais habilitados em Configurações → Notificações.Desligue se este aplicativo não puder ser atualizado em sua caixa.",
+      "excludeFromBadgeLabel": "",
+      "excludeFromBadgeHelp": ""
     }
   },
   "settings": {
@@ -3001,6 +3007,7 @@
     "currentFeatures": {
       "hostUpdate": "Atualização do host com um clique no Health Monitor. O novo botão Atualizar agora em Atualizações do sistema executa o fluxo de atualização do Proxmox dentro de um terminal de painel, sem sair do navegador.",
       "mobileInstall": "Prompt de instalação no aplicativo para celular. Visitantes iniciantes no Android e iOS Safari agora veem etapas simples para adicionar o Monitor à tela inicial como um PWA.",
+      "i18n": "",
       "pageSpeed": "carregamentos de página mais rápidos e navegação mais suave no painel.A visão geral abre instantaneamente e a página VMs e LXCs nunca mais exibe 'Carregando…' entre os modais convidados.",
       "appTab": "Nova guia de aplicativo dentro do modal VM e LXC - especialmente para LXCs.Registre os aplicativos instalados em um contêiner, capture seus links da web e receba notificações quando uma nova versão upstream for enviada.",
       "updatesTab": "guia Atualizações reformuladas para LXCs: aplique pacotes de sistema operacional e atualizações de aplicativos registrados a partir de um único botão e agende um trabalho de atualização automática recorrente que verifica o sistema operacional do contêiner e seu aplicativo rastreado em cada execução.",
@@ -4579,7 +4586,6 @@
     "tryDifferentSearch": "Tente outro comando ou verifique a ortografia.",
     "searchAnyCommand": "Procure qualquer comando",
     "trySearchingFor": "Tente pesquisar por:",
-    "searchTip": "Dica: Procure qualquer comando Linux ou Proxmox (qm, pct, zpool).",
     "noExamplesFound": "Nenhum exemplo encontrado",
     "reconnecting": "Reconectando…",
     "reconnected": "Reconectado com sucesso",
diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json
index 2b5e2648..351f8ee2 100644
--- a/AppImage/messages/sk/common.json
+++ b/AppImage/messages/sk/common.json
@@ -20,6 +20,7 @@
     "cancel": "Zrušiť",
     "save": "Uložiť",
     "edit": "Upraviť",
+    "remove": "",
     "close": "Zavrieť",
     "resetAll": "Obnoviť všetko",
     "undo": "Vrátiť späť",
@@ -296,10 +297,10 @@
       "installFailedManual": "Inštalácia zlyhala. Skúste ručne: apt-get install smartmontools nvme-cli",
       "installToolsManual": "Nástroje sa nepodarilo nainštalovať. Skúste ručne: apt-get install smartmontools nvme-cli",
       "runTest": "Spustiť SMART test",
-      "shortTest": "Krátky test (~2 min)",
+      "shortTest": "Krátky test",
       "longTest": "Dlhý test (1-4 hodiny)",
-      "extendedTest": "Rozšírený test (na pozadí)",
-      "testHelp": "Krátky test trvá približne 2 minúty. Rozšírený test beží na pozadí a pri veľkých diskoch môže trvať aj niekoľko hodín. Po dokončení dostanete upozornenie.",
+      "extendedTest": "Rozšírený test",
+      "testHelp": "",
       "startFailed": "Test sa nepodarilo spustiť",
       "short": "Krátky",
       "extended": "Rozšírený",
@@ -315,7 +316,6 @@
       "worst": "Najhoršie",
       "status": "Stav",
       "viewFullReport": "Zobraziť celý SMART report",
-      "reportHelp": "Vygeneruje podrobný SMART report s analýzou a odporúčaniami.",
       "loadingReport": "Načítavam report...",
       "reportLoadFailed": "Údaje pre report sa nepodarilo načítať.",
       "statusValues": {
@@ -1020,7 +1020,11 @@
         "readOnly": "iba na čítanie",
         "stopped": "vypnuté",
         "mounted": "pripojené"
-      }
+      },
+      "startOnBoot": "",
+      "tags": "",
+      "tagsPlaceholder": "",
+      "tagsNone": ""
     },
     "logs": {
       "header": "Logy pre {name} (VMID: {vmid})",
@@ -1420,7 +1424,9 @@
       "notificationsEnabled": "Upozornenia na upstream aktualizácie sú ZAPNUTÉ – kliknutím ich stlmíte",
       "notificationsMuted": "Upstream upozornenia na aktualizácie MUTED – kliknutím aktivujete",
       "notifyUpstreamLabel": "Upozorniť ma, keď bude k dispozícii nová upstream verzia",
-      "notifyUpstreamHelp": "Odošle `app_update_available` do kanálov povolených v Nastaveniach → Upozornenia.Vypnite, ak túto aplikáciu nie je možné aktualizovať na vašom boxe."
+      "notifyUpstreamHelp": "Odošle `app_update_available` do kanálov povolených v Nastaveniach → Upozornenia.Vypnite, ak túto aplikáciu nie je možné aktualizovať na vašom boxe.",
+      "excludeFromBadgeLabel": "",
+      "excludeFromBadgeHelp": ""
     }
   },
   "settings": {
@@ -3000,6 +3006,7 @@
     "currentFeatures": {
       "hostUpdate": "Aktualizácia hosta jedným kliknutím z kontroly stavu. Nové tlačidlo Aktualizovať teraz v časti systémových aktualizácií spustí aktualizáciu Proxmoxu priamo v termináli dashboardu, bez odchodu z prehliadača.",
       "mobileInstall": "Výzva na inštaláciu aplikácie v mobile. Prví návštevníci v Androide a iOS Safari uvidia jednoduchý spodný panel s krokmi na pridanie Monitoru na domovskú obrazovku.",
+      "i18n": "",
       "pageSpeed": "Rýchlejšie načítanie stránok a plynulejšia navigácia na informačnom paneli.Prehľad sa otvorí okamžite a stránka VMs & LXCs už nikdy medzi hosťovskými modálmi nebliká „Načítava sa...“.",
       "appTab": "Nová karta aplikácie vo vnútri modálu VM a LXC – najmä pre LXC.Zaregistrujte aplikácie nainštalované v kontajneri, zaznamenajte ich webové odkazy a získajte upozornenia, keď sa odošle nová upstream verzia.",
       "updatesTab": "Karta Prepracované aktualizácie pre LXC: použite balíky OS a aktualizácie registrovaných aplikácií jediným tlačidlom a naplánujte si opakujúcu sa úlohu automatickej aktualizácie, ktorá skontroluje OS kontajnera a jeho sledovanú aplikáciu pri každom spustení.",
@@ -4579,7 +4586,6 @@
     "tryDifferentSearch": "Skúste iný príkaz alebo skontrolujte preklepy.",
     "searchAnyCommand": "Vyhľadajte ľubovoľný príkaz",
     "trySearchingFor": "Môžete skúsiť:",
-    "searchTip": "Tip: Vyhľadajte ľubovoľný príkaz pre Linux alebo Proxmox (qm, pct, zpool).",
     "noExamplesFound": "Nenašli sa žiadne príklady",
     "reconnecting": "Znova sa pripájam…",
     "reconnected": "Pripojenie bolo obnovené",
diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json
index d352af2f..aecf63ed 100644
--- a/AppImage/messages/sv/common.json
+++ b/AppImage/messages/sv/common.json
@@ -21,6 +21,7 @@
     "cancel": "Avbryt",
     "save": "Spara",
     "edit": "Redigera",
+    "remove": "",
     "close": "Stäng",
     "resetAll": "Återställ alla",
     "undo": "Ångra",
@@ -297,10 +298,10 @@
       "installFailedManual": "Installationen misslyckades. Försök manuellt: apt-get install smartmontools nvme-cli",
       "installToolsManual": "Det gick inte att installera verktyg. Försök manuellt: apt-get install smartmontools nvme-cli",
       "runTest": "Kör SMART-test",
-      "shortTest": "Kort test (~2 min)",
+      "shortTest": "Kort test",
       "longTest": "Långt test (1-4 timmar)",
-      "extendedTest": "Utökat test (bakgrund)",
-      "testHelp": "Ett kort test tar cirka 2 minuter. Det utökade testet körs i bakgrunden och kan ta flera timmar på stora diskar. Du kommer att få ett meddelande när det är klart.",
+      "extendedTest": "Utökat test",
+      "testHelp": "",
       "startFailed": "Det gick inte att starta testet",
       "short": "Kort",
       "extended": "Förlängd",
@@ -316,7 +317,6 @@
       "worst": "Värst",
       "status": "Status",
       "viewFullReport": "Se hela SMART-rapporten",
-      "reportHelp": "Skapa en detaljerad SMART-rapport med analyser och rekommendationer.",
       "loadingReport": "Laddar rapport...",
       "reportLoadFailed": "Det gick inte att läsa in rapportdata.",
       "statusValues": {
@@ -1021,7 +1021,11 @@
         "readOnly": "skrivskyddad",
         "stopped": "stannade",
         "mounted": "monterad"
-      }
+      },
+      "startOnBoot": "",
+      "tags": "",
+      "tagsPlaceholder": "",
+      "tagsNone": ""
     },
     "logs": {
       "header": "Loggar för {name} (VMID: {vmid})",
@@ -1421,7 +1425,9 @@
       "notificationsEnabled": "Uppströmsuppdateringsmeddelanden PÅ – klicka för att stänga av ljudet",
       "notificationsMuted": "Uppströmsuppdateringsmeddelanden AVSTÄLLD – klicka för att aktivera",
       "notifyUpstreamLabel": "Meddela mig när en ny uppströmsversion är tillgänglig",
-      "notifyUpstreamHelp": "Skickar `app_update_available` till de kanaler som är aktiverade i Inställningar → Aviseringar.Stäng av om den här appen inte kan uppdateras på din box."
+      "notifyUpstreamHelp": "Skickar `app_update_available` till de kanaler som är aktiverade i Inställningar → Aviseringar.Stäng av om den här appen inte kan uppdateras på din box.",
+      "excludeFromBadgeLabel": "",
+      "excludeFromBadgeHelp": ""
     }
   },
   "settings": {
@@ -3001,6 +3007,7 @@
     "currentFeatures": {
       "hostUpdate": "Värduppdatering med ett klick från Health Monitor. Den nya knappen Uppdatera nu i Systemuppdateringar kör Proxmox-uppdateringsflödet i en instrumentpanelsterminal utan att lämna webbläsaren.",
       "mobileInstall": "Uppmaning om installation i appen för mobil. Förstagångsbesökare på Android och iOS Safari ser nu enkla steg för att lägga till monitorn på sin startskärm som en PWA.",
+      "i18n": "",
       "pageSpeed": "Snabbare sidladdningar och smidigare navigering över instrumentpanelen.Översikten öppnas omedelbart och sidan för virtuella datorer och LXC:er blinkar aldrig \"Laddar...\" mellan gästmodalerna igen.",
       "appTab": "Ny appflik i VM- och LXC-modalerna — speciellt för LXC.Registrera apparna installerade i en behållare, fånga deras webblänkar och få meddelanden när en ny uppströmsversion skickas.",
       "updatesTab": "Fliken Omarbetade uppdateringar för LXC:er: applicera OS-paket och uppdateringar av registrerade appar från en enda knapp och schemalägg ett återkommande automatiskt uppdateringsjobb som kontrollerar containerns OS och dess spårade app vid varje körning.",
@@ -4580,7 +4587,6 @@
     "tryDifferentSearch": "Prova ett annat kommando eller kontrollera stavningen.",
     "searchAnyCommand": "Sök efter valfritt kommando",
     "trySearchingFor": "Prova att söka efter:",
-    "searchTip": "Tips: Sök efter valfritt Linux- eller Proxmox-kommando (qm, pct, zpool).",
     "noExamplesFound": "Inga exempel hittades",
     "reconnecting": "Återansluter...",
     "reconnected": "Återansluten framgångsrikt",
diff --git a/AppImage/scripts/flask_notification_routes.py b/AppImage/scripts/flask_notification_routes.py
index ede757a5..13ddc42f 100644
--- a/AppImage/scripts/flask_notification_routes.py
+++ b/AppImage/scripts/flask_notification_routes.py
@@ -808,12 +808,21 @@ def send_notification():
         if not _validate_severity(severity):
             return _bad_request('Invalid severity')
 
+        # Accept `title`/`message` either at the root of the payload
+        # or nested under `data` — the public docs show the nested
+        # form (`data.message`) as the primary example, so falling
+        # back to it prevents "empty title/message" custom events
+        # (issue #297).
+        payload_body = data.get('data') if isinstance(data.get('data'), dict) else {}
+        title = data.get('title') or payload_body.get('title') or ''
+        message = data.get('message') or payload_body.get('message') or ''
+
         result = notification_manager.send_notification(
             event_type=event_type,
             severity=severity,
-            title=data.get('title', ''),
-            message=data.get('message', ''),
-            data=data.get('data', {}),
+            title=title,
+            message=message,
+            data=payload_body,
             source='api'
         )
         return jsonify(result)
diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py
index 42cf3751..39bddc62 100644
--- a/AppImage/scripts/flask_server.py
+++ b/AppImage/scripts/flask_server.py
@@ -6196,7 +6196,12 @@ def get_proxmox_vms():
                         'netout': resource.get('netout', 0),
                         'diskread': resource.get('diskread', 0),
                         'diskwrite': resource.get('diskwrite', 0),
-                        'maxcpu': resource.get('maxcpu', 0)
+                        'maxcpu': resource.get('maxcpu', 0),
+                        # PVE tags carried straight through — the string
+                        # comes back from `pvesh get /cluster/resources`
+                        # already in PVE's own canonical `tag1;tag2`
+                        # format; the client splits + colours them.
+                        'tags': resource.get('tags', ''),
                     }
                     # Decorate LXC rows with the apt update status if the
                     # managed_installs registry has it. Absent key means
@@ -6214,6 +6219,32 @@ def get_proxmox_vms():
                         if app_list:
                             vm_data['app_watches'] = app_list
 
+                        # Fold registered-app updates into the CT's
+                        # aggregate updates badge so the list card
+                        # counter reflects OS + apps in one number.
+                        # Apps flagged `exclude_from_badge` are
+                        # omitted from the count (pinned versions,
+                        # tracker-locked apps, etc.) — see the
+                        # validator in lxc_apps.py for the full
+                        # rationale. Independent from
+                        # `notifications_enabled`.
+                        if app_list:
+                            app_upd_count = sum(
+                                1 for a in app_list
+                                if a.get('update_available') is True
+                                and not a.get('exclude_from_badge')
+                            )
+                            if app_upd_count:
+                                uc = vm_data.get('update_check') or {}
+                                # Synthesize a minimal update_check
+                                # entry when the CT has no apt/apk
+                                # data (OCI, non-Debian, checker off)
+                                # but at least one counted app.
+                                uc = dict(uc) if uc else {}
+                                uc['count'] = int(uc.get('count') or 0) + app_upd_count
+                                uc['available'] = True
+                                vm_data['update_check'] = uc
+
                     # PVE's cluster resources API reports disk=0 for most
                     # QEMU VMs — it can't see inside the guest filesystem
                     # for the common storage backends. For running QEMU
@@ -14160,6 +14191,109 @@ def api_vm_firewall_log(vmid):
         return jsonify({'error': str(e)}), 500
 
 
+@app.route('/api/vms//config', methods=['POST'])
+@require_auth
+def api_vm_config_set(vmid):
+    """Update a small allow-list of `.conf` fields on a VM or LXC.
+
+    Distinct from `api_vm_config_update` (PUT on the same path plus
+    /description) which is the legacy notes editor. Flask keys
+    endpoints by function name, so this one has its own.
+
+    Currently only `onboot` (start-with-host). Kept intentionally
+    narrow — the Status tab exposes one toggle for it and this
+    endpoint is what backs it. Adding a new field is one line in
+    ALLOWED + one line in the payload handler; every field must
+    map to a `qm set` / `pct set` --option that PVE applies
+    without a reboot.
+
+    Body: {"onboot": 0|1}   (bool accepted too, coerced)
+
+    Returns 200 with the applied value on success; the modal cache
+    is invalidated so the next open renders the fresh state.
+    """
+    ALLOWED = {'onboot', 'tags'}
+    try:
+        data = request.get_json(silent=True) or {}
+        updates = {k: v for k, v in data.items() if k in ALLOWED}
+        if not updates:
+            return jsonify({'error': f'No allowed fields in body. Allowed: {sorted(ALLOWED)}'}), 400
+
+        # Coerce onboot to strict 0/1
+        if 'onboot' in updates:
+            v = updates['onboot']
+            if isinstance(v, bool):
+                v = 1 if v else 0
+            try:
+                v = int(v)
+            except (TypeError, ValueError):
+                return jsonify({'error': 'onboot must be 0 or 1'}), 400
+            if v not in (0, 1):
+                return jsonify({'error': 'onboot must be 0 or 1'}), 400
+            updates['onboot'] = v
+
+        # tags: canonicalise to PVE's `tag1;tag2;tag3` form.
+        # Accepts either a list (client-friendly) or an already-joined
+        # string. Reject anything with characters PVE would refuse
+        # (whitespace, backslash) — spaces inside a tag are the
+        # commonest slip and PVE just drops them silently, so we
+        # fail loud instead. Empty string clears all tags.
+        if 'tags' in updates:
+            v = updates['tags']
+            if isinstance(v, list):
+                parts = [str(t).strip() for t in v]
+            elif isinstance(v, str):
+                # Accept both ';' and ',' as separators, same as PVE
+                parts = [t.strip() for t in re.split(r'[;,]', v)]
+            else:
+                return jsonify({'error': 'tags must be a list or a string'}), 400
+            parts = [p for p in parts if p]
+            for p in parts:
+                if not re.match(r'^[a-zA-Z0-9._\-+]+$', p):
+                    return jsonify({
+                        'error': f'Invalid tag "{p}": use letters, digits, and . _ - + only',
+                    }), 400
+            updates['tags'] = ';'.join(parts)
+
+        # Resolve VM type + node from cluster resources cache
+        resources = get_cached_pvesh_cluster_resources_vm()
+        if not resources:
+            return jsonify({'error': 'Failed to enumerate cluster VMs'}), 500
+        vm_info = next((r for r in resources if r.get('vmid') == vmid), None)
+        if not vm_info:
+            return jsonify({'error': f'VM/LXC {vmid} not found'}), 404
+        vm_type = 'lxc' if vm_info.get('type') == 'lxc' else 'qemu'
+        node = vm_info.get('node', 'pve')
+
+        # `qm set` / `pct set` — hot-applied for onboot, no reboot
+        # needed. Build the argv from the ALLOWED map so a future
+        # extension of the payload naturally lands here.
+        binary = '/usr/sbin/pct' if vm_type == 'lxc' else '/usr/sbin/qm'
+        argv = [binary, 'set', str(vmid)]
+        for k, v in updates.items():
+            argv.extend([f'--{k}', str(v)])
+
+        result = subprocess.run(argv, capture_output=True, text=True, timeout=15)
+        if result.returncode != 0:
+            stderr = (result.stderr or result.stdout or '').strip()
+            return jsonify({
+                'error': stderr[:500] or f'{binary} set failed with exit {result.returncode}',
+            }), 500
+
+        # Reflect the change in the modal cache immediately so the
+        # next open of the guest shows the new value without waiting
+        # for a natural refresh.
+        _vm_cache_invalidate(vmid, _vm_details_cache)
+
+        return jsonify({
+            'success': True,
+            'vmid': vmid,
+            'applied': updates,
+        })
+    except Exception as e:
+        return jsonify({'error': str(e)}), 500
+
+
 @app.route('/api/vms//control', methods=['POST'])
 @require_auth
 def api_vm_control(vmid):
diff --git a/AppImage/scripts/lxc_apps.py b/AppImage/scripts/lxc_apps.py
index 41ada3a4..1600737b 100644
--- a/AppImage/scripts/lxc_apps.py
+++ b/AppImage/scripts/lxc_apps.py
@@ -816,6 +816,19 @@ def validate_config(payload: dict) -> tuple[bool, Any]:
     if ne is not None:
         conf["notifications_enabled"] = bool(ne)
 
+    # Optional per-app switch for the CT's aggregate updates badge.
+    # Default is False (include). Set to True when the user knowingly
+    # keeps a specific version (e.g. qBittorrent pinned to the version
+    # their private tracker requires) and doesn't want the LXC list
+    # badge blinking about an "available" update that doesn't apply to
+    # them. Independent from `notifications_enabled` on purpose — a
+    # user may still want the outbound notification and just hide the
+    # counter, or the reverse. The App tab itself always shows the
+    # real state (purple update signal, editor version fields).
+    efb = payload.get("exclude_from_badge")
+    if efb is not None:
+        conf["exclude_from_badge"] = bool(efb)
+
     return True, conf
 
 
@@ -1872,6 +1885,10 @@ def _summarise_app(app: dict) -> dict:
         # this app.
         "update_command": app.get("update_command") or "",
         "hide_no_updater_notice": bool(app.get("hide_no_updater_notice")),
+        # Whether this app should be counted in the CT's aggregate
+        # updates badge (default: yes). See validator for full context.
+        "exclude_from_badge": bool(app.get("exclude_from_badge")),
+        "notifications_enabled": app.get("notifications_enabled", True) is not False,
         # Community-scripts slug that the Register-chip flow attaches
         # to the app. Surfaced so the Updates tab helper section can
         # match this registered app against the CT's helper_slug and
@@ -2366,6 +2383,18 @@ def get_suggestions(vmid) -> dict:
             break
     meta = _helper_slug_meta(vmid) or {}
     slug = meta.get("slug")
+    # Suppress base-OS helper slugs from the suggestion pipeline.
+    # community-scripts publishes bare-OS templates (alpine, debian,
+    # ubuntu, fedora, archlinux, gentoo, opensuse) under the same
+    # helpers_cache the App tab uses to seed detection, so a CT that
+    # only has the OS installed was showing up as "detected app:
+    # Alpine Linux" and inviting the user to register the OS as if
+    # it were an application. These are not trackable apps — treat
+    # the slug as absent for suggestion purposes so the panel goes
+    # straight to the empty state instead.
+    if slug in {"alpine", "archlinux", "archlinux-vm", "debian", "fedora", "gentoo", "opensuse", "ubuntu"}:
+        slug = None
+        meta = {}
     # Tracking hint pipeline: catalog + curated hints merged.
     #   • catalog (community-scripts helpers_cache.json) covers ~430
     #     apps with name+repo+port+upstream_version, zero curation
diff --git a/AppImage/scripts/lxc_mount_points.py b/AppImage/scripts/lxc_mount_points.py
index c6ec0ebe..f583664a 100644
--- a/AppImage/scripts/lxc_mount_points.py
+++ b/AppImage/scripts/lxc_mount_points.py
@@ -583,10 +583,46 @@ def get_lxc_mount_points_static(vmid: str) -> dict[str, Any]:
             "host_source_is_mountpoint": host_src["is_mountpoint"],
         })
 
+    # Cheap hint so the client can render the Mount Points tab
+    # immediately for CTs that ONLY have ad-hoc NFS/CIFS mounts done
+    # from inside the container (nothing in .conf, so `out` is
+    # empty). Without this hint the tab appears only after the
+    # runtime endpoint returns 200-500 ms later, pushing the other
+    # tabs sideways. Reading /proc//mounts is a pure file read
+    # (~1 ms, no subprocess), filter by remote fs family so only
+    # storage counts — plain bind mounts of /dev/* passthrough
+    # devices don't inflate the count.
+    #
+    # IMPORTANT: exclude runtime targets that match a declared mp.
+    # When a host mp source is itself a remote share (e.g. mp0 binds
+    # /mnt/pve/Piblic which is a CIFS mount on the host), the same
+    # mount surfaces in /proc//mounts with an `nfs`/`cifs`
+    # fstype from the CT's perspective. Without the filter the hint
+    # double-counted it, so the badge showed mp+1 when the tab really
+    # only had `mp` cards to render.
+    ad_hoc_hint_count = 0
+    running, host_pid = _ct_status(vmid)
+    if running and host_pid:
+        try:
+            config_targets = {
+                entry.get("target", "")
+                for entry in config_entries
+                if entry.get("target")
+            }
+            for rt in _read_ct_proc_mounts(host_pid):
+                if not _REMOTE_FS_RE.match(rt.get("rt_fstype", "")):
+                    continue
+                if rt.get("rt_target") in config_targets:
+                    continue
+                ad_hoc_hint_count += 1
+        except Exception:
+            pass
+
     return {
         "ok": True,
         "vmid": vmid,
         "mount_points": out,
+        "ad_hoc_hint_count": ad_hoc_hint_count,
     }
 
 
diff --git a/AppImage/scripts/notification_templates.py b/AppImage/scripts/notification_templates.py
index 543c7ee9..30bd520c 100644
--- a/AppImage/scripts/notification_templates.py
+++ b/AppImage/scripts/notification_templates.py
@@ -2409,7 +2409,20 @@ class AIEnhancer:
         if title_match and body_match:
             title_content = title_match.group(1).strip()
             body_content = body_match.group(1).strip()
-            
+
+            # Strip stray `[TITLE]` / `[BODY]` markers the AI may
+            # have echoed back inside the content itself (issue #297
+            # "additional note": PVE events arriving in Telegram
+            # with a literal `[TITLE]` in the title). The parser
+            # regex above splits on the FIRST occurrence, so any
+            # extra marker the model dropped into its title/body
+            # ends up inside the extracted string. Users see the
+            # markers verbatim in Telegram because they are only
+            # supposed to be structural separators, never content.
+            marker_re = re.compile(r'\[\s*(?:TITLE|BODY)\s*\]', re.IGNORECASE)
+            title_content = marker_re.sub('', title_content).strip()
+            body_content = marker_re.sub('', body_content).strip()
+
             # Remove any "Original message/text" sections the AI might have added.
             # Anchored at start-of-line (`(?:^|\n)\s*`) so legitimate prose
             # like "we received the original message earlier" mid-paragraph
diff --git a/scripts/gpu_tpu/nvidia_installer.sh b/scripts/gpu_tpu/nvidia_installer.sh
index e6cc015f..02114978 100644
--- a/scripts/gpu_tpu/nvidia_installer.sh
+++ b/scripts/gpu_tpu/nvidia_installer.sh
@@ -60,21 +60,38 @@ initialize_cache
 # ==========================================================
 # GPU detection and current status
 # ==========================================================
+# Populated by detect_nvidia_gpus. Holds every video-controller PCI
+# Device ID (lowercase, 4-hex) so the version filter can drop branches
+# whose supportedchips.html doesn't list every card on this host.
+NVIDIA_HOST_GPU_IDS=()
+
 detect_nvidia_gpus() {
-  # Only video controllers (not audio)
+  # Video controllers only — the paired HDA audio functions (10de:xxxx
+  # under class 0403) are not what the display driver ships support for.
   local lspci_output
-  lspci_output=$(lspci | grep -i "NVIDIA" \
+  lspci_output=$(lspci -nn | grep -i "NVIDIA" \
     | grep -Ei "VGA compatible controller|3D controller|Display controller" || true)
 
   if [[ -z "$lspci_output" ]]; then
     NVIDIA_GPU_PRESENT=false
     DETECTED_GPUS_TEXT="$(translate 'No NVIDIA GPU detected on this system.')"
+    NVIDIA_HOST_GPU_IDS=()
   else
     NVIDIA_GPU_PRESENT=true
     DETECTED_GPUS_TEXT=""
+    NVIDIA_HOST_GPU_IDS=()
     local i=1
     while IFS= read -r line; do
       DETECTED_GPUS_TEXT+="  ${i}. ${line}\n"
+      # Extract [10de:XXXX] — Vendor:Device pair. We keep only the
+      # Device half (4-hex) lowercased, which is what NVIDIA lists in
+      # each version's README/supportedchips.html.
+      local dev_id
+      dev_id=$(echo "$line" | grep -oiE '\[10de:[0-9a-f]{4}\]' | head -1 \
+        | sed -E 's/^\[10de:([0-9a-f]{4})\]$/\1/i' | tr 'A-F' 'a-f')
+      if [[ -n "$dev_id" ]]; then
+        NVIDIA_HOST_GPU_IDS+=("$dev_id")
+      fi
       ((i++))
     done <<< "$lspci_output"
   fi
@@ -759,6 +776,196 @@ KEYLASE_PATCH_CACHE="/var/cache/proxmenux/keylase_patch_versions.txt"
 KEYLASE_PATCH_TTL_SECONDS=$((7 * 86400))
 KEYLASE_PATCH_URL="https://raw.githubusercontent.com/keylase/nvidia-patch/master/patch.sh"
 
+# NVIDIA branch classification comes from the vendor's own Unix drivers
+# page, not the CDN — the CDN publishes every branch (production, new
+# feature, vulkan-beta, developer) in the same flat directory, whereas
+# the vendor page carries the current heads clearly labelled "Production
+# Branch", "New Feature Branch" and "Legacy GPU version". Extracting the
+# majors from those three lines gives us the set of branches NVIDIA
+# currently endorses for end users, with zero manual maintenance on our
+# side — when NVIDIA promotes a new rama the cache picks it up on the
+# next 24 h refresh. Cache is fail-open: if the fetch is blocked or the
+# page layout changes, we skip the branch filter rather than emptying
+# the picker.
+NVIDIA_BRANCHES_CACHE="/var/cache/proxmenux/nvidia_stable_branches.txt"
+NVIDIA_PRODUCTION_HEAD_CACHE="/var/cache/proxmenux/nvidia_production_head.txt"
+NVIDIA_BRANCH_HEADS_CACHE="/var/cache/proxmenux/nvidia_branch_heads.txt"
+NVIDIA_GPU_SUPPORT_CACHE_PREFIX="/var/cache/proxmenux/nvidia_gpu_support_"
+NVIDIA_BRANCHES_TTL_SECONDS=$((24 * 3600))
+NVIDIA_BRANCHES_URL="https://www.nvidia.com/en-us/drivers/unix/"
+
+refresh_nvidia_branches_cache() {
+  local now ts age
+  now=$(date +%s)
+  if [[ -f "$NVIDIA_BRANCHES_CACHE" ]]; then
+    ts=$(stat -c '%Y' "$NVIDIA_BRANCHES_CACHE" 2>/dev/null || echo 0)
+    age=$(( now - ts ))
+    if (( age < NVIDIA_BRANCHES_TTL_SECONDS )) && [[ -s "$NVIDIA_BRANCHES_CACHE" ]]; then
+      return 0
+    fi
+  fi
+  mkdir -p "$(dirname "$NVIDIA_BRANCHES_CACHE")" 2>/dev/null || return 1
+  local html tmp
+  html=$(curl -fsSL -A "Mozilla/5.0" --max-time 15 "$NVIDIA_BRANCHES_URL" 2>/dev/null) || return 1
+  [[ -z "$html" ]] && return 1
+  local clean tmp_full
+  clean=$(echo "$html" | perl -0777 -pe 's///gs' 2>/dev/null)
+  tmp=$(mktemp)
+  tmp_full=$(mktemp)
+  # Two label shapes on the page:
+  #   • Production / New Feature → "… Branch Version:" then full.
+  #   • Legacy                   → "Legacy GPU version (NNN.xx series):"
+  #                                then full — the word "Version"
+  #                                lives inside the parenthesised label
+  #                                so the Production/Feature regex misses
+  #                                it (separate alternative below).
+  # HTML comments are stripped first so vestigial `` blocks in older ia32 rows don't leak stale majors.
+  echo "$clean" \
+    | grep -oiE '(Production Branch Version|New Feature Branch Version|Legacy GPU version \([0-9]+\.xx series\)):[^<]*()?\s*]*>[0-9]+\.[0-9]+(\.[0-9]+)?' \
+    | grep -oE '>[0-9]+\.[0-9]+(\.[0-9]+)?' \
+    | tr -d '>' \
+    | awk -F. '{ printf "%s|%s\n", $1, $0 }' \
+    | sort -u -t'|' -k1,1 > "$tmp_full"
+  if [[ ! -s "$tmp_full" ]]; then
+    rm -f "$tmp" "$tmp_full"
+    return 1
+  fi
+  # Derive the majors-only file from the same source so both caches
+  # never disagree.
+  cut -d'|' -f1 "$tmp_full" | sort -un > "$tmp"
+  if [[ ! -s "$tmp" ]]; then
+    rm -f "$tmp" "$tmp_full"
+    return 1
+  fi
+  mv "$tmp" "$NVIDIA_BRANCHES_CACHE"
+  mv "$tmp_full" "$NVIDIA_BRANCH_HEADS_CACHE"
+
+  # Extra pass: capture the full Production Branch head so the picker
+  # can default to it instead of the highest numeric available (which
+  # could be a New Feature Branch head — NVIDIA doesn't recommend those
+  # as the general-purpose default). Best-effort.
+  local prod_head
+  prod_head=$(echo "$clean" \
+    | grep -oiE 'Production Branch Version:[^<]*()?\s*]*>[0-9]+\.[0-9]+(\.[0-9]+)?' \
+    | grep -oE '>[0-9]+\.[0-9]+(\.[0-9]+)?' \
+    | tr -d '>' \
+    | head -n1)
+  if [[ -n "$prod_head" ]]; then
+    echo "$prod_head" > "$NVIDIA_PRODUCTION_HEAD_CACHE"
+  else
+    rm -f "$NVIDIA_PRODUCTION_HEAD_CACHE" 2>/dev/null || true
+  fi
+  return 0
+}
+
+# Return the full head version associated with a major from the
+# branch-heads cache (e.g. `get_nvidia_branch_head 595` → 595.91.07).
+# Used to know which release inside a branch to hit for the PCI-ID
+# supported-GPUs list.
+get_nvidia_branch_head() {
+  local major="$1"
+  [[ -f "$NVIDIA_BRANCH_HEADS_CACHE" && -s "$NVIDIA_BRANCH_HEADS_CACHE" ]] || return 1
+  awk -F'|' -v m="$major" '$1 == m { print $2; exit }' "$NVIDIA_BRANCH_HEADS_CACHE"
+}
+
+# Refresh the per-branch supported-GPU cache. Uses the branch head as
+# the "sample release" for the whole branch — NVIDIA rarely drops chip
+# support inside a live branch, so this is a solid proxy that also
+# minimises fetch count (~3 heads total instead of one per release).
+# Written to nvidia_gpu_support_MAJOR.txt with one lowercase hex device
+# id per line. Same 24h TTL as the branches cache.
+refresh_nvidia_gpu_support_for_major() {
+  local major="$1"
+  [[ -z "$major" ]] && return 1
+  local cache="${NVIDIA_GPU_SUPPORT_CACHE_PREFIX}${major}.txt"
+  local now ts age
+  now=$(date +%s)
+  if [[ -f "$cache" ]]; then
+    ts=$(stat -c '%Y' "$cache" 2>/dev/null || echo 0)
+    age=$(( now - ts ))
+    if (( age < NVIDIA_BRANCHES_TTL_SECONDS )) && [[ -s "$cache" ]]; then
+      return 0
+    fi
+  fi
+  local head_ver
+  head_ver=$(get_nvidia_branch_head "$major") || return 1
+  [[ -z "$head_ver" ]] && return 1
+  local url="https://download.nvidia.com/XFree86/Linux-x86_64/${head_ver}/README/supportedchips.html"
+  local html tmp
+  html=$(curl -fsSL -A "Mozilla/5.0" --max-time 20 "$url" 2>/dev/null) || return 1
+  [[ -z "$html" ]] && return 1
+  mkdir -p "$(dirname "$cache")" 2>/dev/null || return 1
+  tmp=$(mktemp)
+  # NVIDIA's supportedchips.html lays out each GPU row as a  with
+  # the PCI Device ID in 4-char hex. Anchor on the surrounding tag so
+  # we don't sweep up unrelated 4-hex strings elsewhere in the page.
+  echo "$html" \
+    | grep -oiE '[0-9A-F]{4}' \
+    | grep -oiE '[0-9A-F]{4}' \
+    | tr 'A-F' 'a-f' \
+    | sort -u > "$tmp"
+  if [[ -s "$tmp" ]]; then
+    mv "$tmp" "$cache"
+    return 0
+  fi
+  rm -f "$tmp"
+  return 1
+}
+
+# True if every detected NVIDIA GPU on this host has its device id in
+# the branch's supported list. Fail-open when the cache is missing so
+# a network hiccup never locks the picker out.
+is_branch_compatible_with_host_gpus() {
+  local major="$1"
+  local cache="${NVIDIA_GPU_SUPPORT_CACHE_PREFIX}${major}.txt"
+  [[ -f "$cache" && -s "$cache" ]] || return 0
+  [[ ${#NVIDIA_HOST_GPU_IDS[@]} -eq 0 ]] && return 0
+  local id
+  for id in "${NVIDIA_HOST_GPU_IDS[@]}"; do
+    grep -qFx "$id" "$cache" || return 1
+  done
+  return 0
+}
+
+# Reject Vulkan-beta / short-lived / developer branches by counting
+# how many releases NVIDIA actually shipped inside that major on the
+# CDN. Production and long-lived New Feature branches accumulate many
+# release rows (470=17, 535=20, 550=14, 570=12, 580=14 …); Vulkan-beta
+# and developer branches only ever get 1-4 releases before being
+# superseded (590=2, 565=2, 530=2, 555=4 …). Threshold 5 separates the
+# two groups cleanly at time of writing.
+# Fail-open: if the release-count map hasn't been built for whatever
+# reason, the branch passes (kernel + GPU-compat + curated whitelist
+# are still enforced upstream). The map is populated once per
+# `filter_option_c_branch` invocation, so no repeated CDN scraping.
+NVIDIA_BRANCH_MIN_RELEASES=5
+declare -A NVIDIA_BRANCH_RELEASE_COUNT=()
+
+is_branch_release_count_sufficient() {
+  local major="$1"
+  [[ -z "$major" ]] && return 1
+  [[ ${#NVIDIA_BRANCH_RELEASE_COUNT[@]} -eq 0 ]] && return 0
+  local n="${NVIDIA_BRANCH_RELEASE_COUNT[$major]:-0}"
+  (( n >= NVIDIA_BRANCH_MIN_RELEASES ))
+}
+
+get_nvidia_production_head() {
+  [[ -f "$NVIDIA_PRODUCTION_HEAD_CACHE" && -s "$NVIDIA_PRODUCTION_HEAD_CACHE" ]] || return 1
+  local v
+  v=$(head -n1 "$NVIDIA_PRODUCTION_HEAD_CACHE" | tr -d '[:space:]')
+  [[ -z "$v" ]] && return 1
+  printf '%s\n' "$v"
+}
+
+is_nvidia_stable_branch() {
+  local major="$1"
+  [[ -z "$major" ]] && return 1
+  # Fail-open: no cache → don't filter (upstream behaviour preserved).
+  [[ -f "$NVIDIA_BRANCHES_CACHE" && -s "$NVIDIA_BRANCHES_CACHE" ]] || return 0
+  grep -qFx "$major" "$NVIDIA_BRANCHES_CACHE"
+}
+
 refresh_keylase_patch_cache() {
   local now ts age
   now=$(date +%s)
@@ -822,20 +1029,86 @@ filter_option_c_branch() {
     return 0
   fi
 
-  # Accept the target branch AND any newer branch (major ≥ target).
-  # Historical behaviour was an exact-major match, which locked kernel
-  # 7.x users to 580.x only. When a 580.x build happens to fail to
-  # compile on a very recent kernel + toolchain combo (reproduced on
-  # kernel 7.0.14-4-pve — see issue #248), the operator had no
-  # in-menu escape. `MIN_DRIVER_VERSION` from get_kernel_compatibility_info
-  # still gates the floor, so this only opens the ceiling: newer stable
-  # branches like 590 / 595 / 600 that satisfy the min version become
-  # selectable, while ancient branches remain filtered out.
+  # Four-way gate for every candidate version:
+  #   1. `major >= target_branch` — kernel floor.
+  #   2. Branch is currently endorsed on NVIDIA's Unix drivers page
+  #      (Production / New Feature / Legacy heads) OR was substantial
+  #      enough to accumulate ≥ NVIDIA_BRANCH_MIN_RELEASES releases on
+  #      the CDN. The endorsement path always passes; the release-count
+  #      path lets superseded production branches (580, 570, 550, 535 …
+  #      still maintained via bugfix releases) stay selectable while
+  #      Vulkan-beta / developer branches with 1-4 releases (590, 565,
+  #      530 …) get dropped.
+  #   3. `is_branch_compatible_with_host_gpus` — every detected NVIDIA
+  #      GPU on this host must appear in that branch's supportedchips
+  #      list. A host with a Kepler card ends up with 470.x only.
+  # All three fail open when their caches / lookups miss, so the picker
+  # never empties on a network glitch.
+  refresh_nvidia_branches_cache 2>/dev/null || true
+  # Build a majors→count map from the incoming version list. This is
+  # what backs `is_branch_release_count_sufficient` — done once per
+  # call so the tight loop below stays local-arithmetic only.
+  NVIDIA_BRANCH_RELEASE_COUNT=()
+  while IFS= read -r _v; do
+    [[ -z "$_v" ]] && continue
+    local _m="${_v%%.*}"
+    NVIDIA_BRANCH_RELEASE_COUNT[$_m]=$(( ${NVIDIA_BRANCH_RELEASE_COUNT[$_m]:-0} + 1 ))
+  done <<< "$versions_in"
+  # Grab the head (highest version) of every major so we know which
+  # release to sample for supportedchips.html. We use the CDN listing
+  # directly for this — the branch-heads cache only carries the
+  # endorsed heads, not the superseded ones.
+  declare -A _major_head=()
+  while IFS= read -r _v; do
+    [[ -z "$_v" ]] && continue
+    local _m="${_v%%.*}"
+    [[ -z "${_major_head[$_m]:-}" ]] && _major_head[$_m]="$_v"
+  done < <(printf '%s\n' "$versions_in")
+  # Warm the supported-GPU cache for every stable major (whitelist
+  # heads: head already known → normal path; superseded heads: seed the
+  # cache-file's head-version by directly writing a lightweight lookup).
+  # For endorsed majors we can use refresh_nvidia_gpu_support_for_major
+  # as-is (it looks up NVIDIA_BRANCH_HEADS_CACHE). For non-endorsed
+  # majors we need to fetch supportedchips.html against the highest
+  # release we saw in the CDN listing.
+  local _m _head _cache _now _ts _age _html _tmp
+  _now=$(date +%s)
+  for _m in "${!_major_head[@]}"; do
+    _head="${_major_head[$_m]}"
+    _cache="${NVIDIA_GPU_SUPPORT_CACHE_PREFIX}${_m}.txt"
+    if [[ -f "$_cache" ]]; then
+      _ts=$(stat -c '%Y' "$_cache" 2>/dev/null || echo 0)
+      _age=$(( _now - _ts ))
+      if (( _age < NVIDIA_BRANCHES_TTL_SECONDS )) && [[ -s "$_cache" ]]; then
+        continue
+      fi
+    fi
+    _html=$(curl -fsSL -A "Mozilla/5.0" --max-time 20 \
+      "https://download.nvidia.com/XFree86/Linux-x86_64/${_head}/README/supportedchips.html" \
+      2>/dev/null) || continue
+    [[ -z "$_html" ]] && continue
+    mkdir -p "$(dirname "$_cache")" 2>/dev/null || continue
+    _tmp=$(mktemp)
+    echo "$_html" \
+      | grep -oiE '[0-9A-F]{4}' \
+      | grep -oiE '[0-9A-F]{4}' \
+      | tr 'A-F' 'a-f' \
+      | sort -u > "$_tmp"
+    if [[ -s "$_tmp" ]]; then
+      mv "$_tmp" "$_cache"
+    else
+      rm -f "$_tmp"
+    fi
+  done
   while IFS= read -r ver; do
     [[ -z "$ver" ]] && continue
     local ver_major="${ver%%.*}"
     if (( 10#$ver_major >= 10#$target_branch )); then
-      printf '%s\n' "$ver"
+      if is_nvidia_stable_branch "$ver_major" || is_branch_release_count_sufficient "$ver_major"; then
+        if is_branch_compatible_with_host_gpus "$ver_major"; then
+          printf '%s\n' "$ver"
+        fi
+      fi
     fi
   done <<< "$versions_in"
 }
@@ -1359,16 +1632,16 @@ show_version_menu() {
     current_list=$(filter_option_c_branch "$current_list" "$CURRENT_DRIVER_VERSION" "$RECOMMENDED_BRANCH")
   fi
 
-  if [[ -n "$latest" ]]; then
-    local filtered_max_list=""
-    while IFS= read -r ver; do
-      [[ -z "$ver" ]] && continue
-      if version_le "$ver" "$latest"; then
-        filtered_max_list+="$ver"$'\n'
-      fi
-    done <<< "$current_list"
-    current_list="$filtered_max_list"
-  fi
+  # Historically the picker capped candidates at `latest` (from the
+  # CDN's `latest.txt`) so users never saw versions newer than the
+  # global "latest". But latest.txt lags the Production Branch head
+  # (595.91.07 today vs 595.84 in latest.txt) and also hides the New
+  # Feature Branch head (610.x) that is a legitimate option once
+  # kernel + GPU compat pass. Kernel floor, endorsement whitelist,
+  # release-count heuristic and GPU-compat filter already narrow the
+  # list to safe candidates; the Production head still stands out in
+  # the "Latest available" recommendation, so an artificial ceiling
+  # only masked valid options.
 
   # If the user has the keylase NVENC patch applied, only offer versions
   # that the patch supports — picking an unsupported version reinstalls
@@ -1391,16 +1664,50 @@ show_version_menu() {
     fi
   fi
 
-  # Recompute "latest" as the highest version still in the filtered list
-  # so the menu's "Latest available" label matches what we actually offer
-  # rather than the global upstream latest (which may have been filtered
-  # out by Option C / kernel-compat / patch awareness).
-  if [[ -n "$current_list" ]]; then
+  # Pick the default "Recommended" version. Three-tier priority so the
+  # picker stays consistent with what the Monitor's driver-update
+  # notification promised the user:
+  #   1. If a driver is already installed AND its branch is still
+  #      offered in the filtered list, recommend the highest release
+  #      of that same branch (bugfix upgrade in place). Matches the
+  #      Monitor's Hardware card, which surfaces "v580.178.04
+  #      available" for a 580.x install — the user hitting Actualizar
+  #      then expects to land on 580.178.04, not a cross-branch jump
+  #      to Production. Cross-branch is still one row away in the
+  #      list.
+  #   2. Fresh install (no current driver) → Production Branch head
+  #      from NVIDIA's Unix drivers page, when present in the list.
+  #   3. Fallback → highest numeric in the list (Production may have
+  #      been filtered out by kernel-compat / GPU-compat / patch
+  #      awareness).
+  latest=""
+  if [[ -n "$CURRENT_DRIVER_VERSION" && -n "$current_list" ]]; then
+    local _cur_branch="${CURRENT_DRIVER_VERSION%%.*}"
+    if [[ -n "$_cur_branch" ]]; then
+      local _same_branch_head
+      _same_branch_head=$(printf '%s\n' "$current_list" \
+        | awk -F. -v b="$_cur_branch" '$1 == b { print; exit }' \
+        | tr -d '[:space:]')
+      if [[ -n "$_same_branch_head" ]]; then
+        latest="$_same_branch_head"
+      fi
+    fi
+  fi
+  if [[ -z "$latest" ]]; then
+    local prod_head=""
+    prod_head=$(get_nvidia_production_head 2>/dev/null) || prod_head=""
+    if [[ -n "$prod_head" && -n "$current_list" ]]; then
+      if printf '%s\n' "$current_list" | grep -qFx "$prod_head"; then
+        latest="$prod_head"
+      fi
+    fi
+  fi
+  if [[ -z "$latest" && -n "$current_list" ]]; then
     latest=$(printf '%s\n' "$current_list" | head -n1 | tr -d '[:space:]')
   fi
 
   local menu_text="$(translate 'Select the NVIDIA driver version to install:')\n\n"
-  menu_text+="$(translate 'Versions shown are compatible with your kernel. Latest available is recommended in most cases.')"
+  menu_text+="$(translate 'Versions shown are compatible with your kernel and your GPU. The recommended version keeps you on your current driver branch, or defaults to the NVIDIA Production Branch head on a fresh install.')"
   if $patch_filtered; then
     menu_text+="\n\n$(translate 'NVENC patch detected — list narrowed to versions supported by keylase/nvidia-patch.')"
   elif [[ -n "$patch_filter_note" ]]; then
@@ -1408,7 +1715,7 @@ show_version_menu() {
   fi
 
   local choices=()
-  choices+=("latest" "$(translate 'Latest available') (${latest:-unknown})")
+  choices+=("latest" "$(translate 'Recommended') (${latest:-unknown})")
   choices+=("" "")
 
   if [[ -n "$current_list" ]]; then
diff --git a/web/app/[locale]/docs/monitor/dashboard/vms-lxcs/app/page.tsx b/web/app/[locale]/docs/monitor/dashboard/vms-lxcs/app/page.tsx
index d4f84f8e..e4591f3b 100644
--- a/web/app/[locale]/docs/monitor/dashboard/vms-lxcs/app/page.tsx
+++ b/web/app/[locale]/docs/monitor/dashboard/vms-lxcs/app/page.tsx
@@ -63,6 +63,7 @@ export default async function AppTabPage({
       }
       state: { items: string[] }
       manage: { items: string[] }
+      options: { items: string[] }
       notDetected: { steps: string[] }
     } } } }
   }
@@ -82,6 +83,7 @@ export default async function AppTabPage({
   const step6CorrectItems = v.tracking.step6CorrectItems
   const stateItems = v.state.items
   const manageItems = v.manage.items
+  const optionsItems = v.options.items
   const notDetectedSteps = v.notDetected.steps
 
   // Rich-text tag handlers
@@ -374,6 +376,15 @@ export default async function AppTabPage({
       
       

{t.rich("manage.trailing", { strong, em, code })}

+

{t("options.heading")}

+

{t("options.lead")}

+
    + {optionsItems.map((_, idx) => ( +
  • {t.rich(`options.items.${idx}`, { strong, em, code })}
  • + ))} +
+

{t.rich("options.trailing", { strong, em, code })}

+

{t("notDetected.heading")}

{t("notDetected.intro")}

    diff --git a/web/messages/en/docs/monitor/dashboard/vms-lxcs-app.json b/web/messages/en/docs/monitor/dashboard/vms-lxcs-app.json index 3465a99d..97ed6d1a 100644 --- a/web/messages/en/docs/monitor/dashboard/vms-lxcs-app.json +++ b/web/messages/en/docs/monitor/dashboard/vms-lxcs-app.json @@ -257,6 +257,15 @@ ], "trailing": "Deleting a record does not uninstall or stop the app. It only removes the information ProxMenux uses to display it and monitor its version." }, + "options": { + "heading": "Optional toggles", + "lead": "Two independent switches sit under the version tracking options:", + "items": [ + "Notify me when a new upstream version is available — sends the app_update_available event to the channels enabled in Settings → Notifications.", + "Exclude from the LXC updates counter — leaves this app out of the aggregate updates badge shown on the LXC list card." + ], + "trailing": "Both toggles can be set independently. The App tab still shows the real state of each registered app regardless of these choices." + }, "notDetected": { "heading": "If the app is not detected", "intro": "Automatic detection is not required to use this feature. If no suggestion appears:", diff --git a/web/messages/es/docs/monitor/dashboard/vms-lxcs-app.json b/web/messages/es/docs/monitor/dashboard/vms-lxcs-app.json index 68fd3129..781a2006 100644 --- a/web/messages/es/docs/monitor/dashboard/vms-lxcs-app.json +++ b/web/messages/es/docs/monitor/dashboard/vms-lxcs-app.json @@ -257,6 +257,15 @@ ], "trailing": "Eliminar el registro no desinstala ni detiene la aplicación. Solo borra la información que ProxMenux utiliza para mostrarla y supervisar su versión." }, + "options": { + "heading": "Opciones adicionales", + "lead": "Debajo de las opciones de seguimiento de versión hay dos casillas independientes:", + "items": [ + "Notificarme cuando haya una nueva versión disponible — envía el evento app_update_available a los canales activos en Ajustes → Notificaciones.", + "Excluir del contador de actualizaciones del LXC — no suma esta aplicación al badge agregado de actualizaciones del card del LXC." + ], + "trailing": "Ambas casillas se marcan por separado. La pestaña App sigue mostrando el estado real de cada aplicación registrada al margen de esta elección." + }, "notDetected": { "heading": "Si la aplicación no se detecta", "intro": "La detección automática no es necesaria para utilizar esta función. Si no aparece ninguna sugerencia:", diff --git a/web/messages/sk/docs/monitor/dashboard/vms-lxcs-app.json b/web/messages/sk/docs/monitor/dashboard/vms-lxcs-app.json index 557dd324..75675282 100644 --- a/web/messages/sk/docs/monitor/dashboard/vms-lxcs-app.json +++ b/web/messages/sk/docs/monitor/dashboard/vms-lxcs-app.json @@ -121,14 +121,38 @@ "colMethod": "Metóda", "colWhen": "Kedy ju použiť", "rows": [ - { "method": "Žiadna (iba odkaz)", "when": "Potrebujete len názov a webové odkazy." }, - { "method": "dpkg balík", "when": "Aplikácia je nainštalovaná ako Debian alebo Ubuntu balík." }, - { "method": "apk balík", "when": "Aplikácia je nainštalovaná ako Alpine balík." }, - { "method": "Binárka", "when": "Spustiteľný súbor vracia verziu cez argument ako --version." }, - { "method": "Súbor + regex", "when": "Reťazec verzie je zapísaný v súbore." }, - { "method": "Python distribúcia", "when": "Aplikácia je nainštalovaná ako Python balík." }, - { "method": "Príkaz", "when": "Na získanie verzie treba spustiť konkrétny príkaz." }, - { "method": "Ručne", "when": "Používateľ zadá nainštalovanú verziu ručne." } + { + "method": "Žiadna (iba odkaz)", + "when": "Potrebujete len názov a webové odkazy." + }, + { + "method": "dpkg balík", + "when": "Aplikácia je nainštalovaná ako Debian alebo Ubuntu balík." + }, + { + "method": "apk balík", + "when": "Aplikácia je nainštalovaná ako Alpine balík." + }, + { + "method": "Binárka", + "when": "Spustiteľný súbor vracia verziu cez argument ako --version." + }, + { + "method": "Súbor + regex", + "when": "Reťazec verzie je zapísaný v súbore." + }, + { + "method": "Python distribúcia", + "when": "Aplikácia je nainštalovaná ako Python balík." + }, + { + "method": "Príkaz", + "when": "Na získanie verzie treba spustiť konkrétny príkaz." + }, + { + "method": "Ručne", + "when": "Používateľ zadá nainštalovanú verziu ručne." + } ] }, "methodsTrailing": "Použite čo najpriamejšiu a najstabilnejšiu metódu. Ak aplikácia pochádza zo systémového balíka, uprednostnite dotaz na balík pred parsovaním výstupu všeobecného príkazu.", @@ -178,12 +202,30 @@ "colPart": "Časť", "colMeaning": "Význam", "rows": [ - { "part": "version", "meaning": "Ukotví hľadanie na tomto slove, aby sa nezhodovalo nesúvisiace číslo." }, - { "part": "[ :=]+", "meaning": "Povolí jednu alebo viac medzier, dvojbodiek alebo znamienok rovnosti." }, - { "part": "v?", "meaning": "Písmeno v sa môže objaviť raz alebo vôbec." }, - { "part": "( and )", "meaning": "Označuje časť, ktorú má ProxMenux ponechať." }, - { "part": "[0-9]+", "meaning": "Zodpovedá jednej alebo viacerým čísliciam." }, - { "part": "\\.", "meaning": "Zodpovedá skutočnej bodke medzi číslami." } + { + "part": "version", + "meaning": "Ukotví hľadanie na tomto slove, aby sa nezhodovalo nesúvisiace číslo." + }, + { + "part": "[ :=]+", + "meaning": "Povolí jednu alebo viac medzier, dvojbodiek alebo znamienok rovnosti." + }, + { + "part": "v?", + "meaning": "Písmeno v sa môže objaviť raz alebo vôbec." + }, + { + "part": "( and )", + "meaning": "Označuje časť, ktorú má ProxMenux ponechať." + }, + { + "part": "[0-9]+", + "meaning": "Zodpovedá jednej alebo viacerým čísliciam." + }, + { + "part": "\\.", + "meaning": "Zodpovedá skutočnej bodke medzi číslami." + } ] }, "step2DotNote": "Bodka sa píše ako \\., pretože samotná bodka v regexe znamená „ľubovoľný znak“.", @@ -194,11 +236,31 @@ "colRegex": "Odporúčaný regex", "colResult": "Výsledok", "rows": [ - { "text": "v2.14.3", "regex": "v?([0-9]+\\.[0-9]+\\.[0-9]+)", "result": "2.14.3" }, - { "text": "Version: 2.14", "regex": "Version[ :=]+v?([0-9]+(?:\\.[0-9]+){1,3})", "result": "2.14" }, - { "text": "release-2.14.3.1", "regex": "release-v?([0-9]+(?:\\.[0-9]+){1,3})", "result": "2.14.3.1" }, - { "text": "build 2026.08.10", "regex": "build[ :=]+([0-9]{4}\\.[0-9]{1,2}\\.[0-9]{1,2})", "result": "2026.08.10" }, - { "text": "{\"version\":\"2.14.3\"}", "regex": "\"version\"\\s*:\\s*\"v?([0-9]+(?:\\.[0-9]+){1,3})\"", "result": "2.14.3" } + { + "text": "v2.14.3", + "regex": "v?([0-9]+\\.[0-9]+\\.[0-9]+)", + "result": "2.14.3" + }, + { + "text": "Version: 2.14", + "regex": "Version[ :=]+v?([0-9]+(?:\\.[0-9]+){1,3})", + "result": "2.14" + }, + { + "text": "release-2.14.3.1", + "regex": "release-v?([0-9]+(?:\\.[0-9]+){1,3})", + "result": "2.14.3.1" + }, + { + "text": "build 2026.08.10", + "regex": "build[ :=]+([0-9]{4}\\.[0-9]{1,2}\\.[0-9]{1,2})", + "result": "2026.08.10" + }, + { + "text": "{\"version\":\"2.14.3\"}", + "regex": "\"version\"\\s*:\\s*\"v?([0-9]+(?:\\.[0-9]+){1,3})\"", + "result": "2.14.3" + } ] }, "step3Note1": "(?: ... ) zoskupí časť vzoru bez vytvorenia ďalšej výstupnej hodnoty. Hodí sa na prijatie verzií s dvoma, tromi alebo štyrmi blokmi bez komplikovania výsledku.", @@ -257,6 +319,15 @@ ], "trailing": "Odstránenie záznamu aplikáciu neodinštaluje ani nezastaví. Odstráni iba informácie, ktoré ProxMenux používa na jej zobrazenie a sledovanie verzie." }, + "options": { + "heading": "", + "lead": "", + "items": [ + "", + "" + ], + "trailing": "" + }, "notDetected": { "heading": "Ak aplikácia nebola nájdená", "intro": "Automatická detekcia nie je nutná na používanie tejto funkcie. Ak sa nezobrazí žiadny návrh:",