diff --git a/AppImage/ProxMenux-1.2.1.1-beta.AppImage b/AppImage/ProxMenux-1.2.1.1-beta.AppImage index 3e36cfab..98e49c2c 100755 Binary files a/AppImage/ProxMenux-1.2.1.1-beta.AppImage and b/AppImage/ProxMenux-1.2.1.1-beta.AppImage differ diff --git a/AppImage/ProxMenux-Monitor.AppImage.sha256 b/AppImage/ProxMenux-Monitor.AppImage.sha256 index 5b8501e5..ff2cb3fa 100644 --- a/AppImage/ProxMenux-Monitor.AppImage.sha256 +++ b/AppImage/ProxMenux-Monitor.AppImage.sha256 @@ -1 +1 @@ -9315f939f10353d0105a6a2cb8f3c7e21b02620a513b52ce9349a088b95751b8 ProxMenux-1.2.1.1-beta.AppImage +150694a49a5b0a4546a2bf5fedcc0914d37666d0cdeac1d9fdc58793c131b4bd ProxMenux-1.2.1.1-beta.AppImage diff --git a/AppImage/components/health-thresholds.tsx b/AppImage/components/health-thresholds.tsx index 5ebd6e77..9d33f810 100644 --- a/AppImage/components/health-thresholds.tsx +++ b/AppImage/components/health-thresholds.tsx @@ -398,31 +398,30 @@ export function HealthThresholds() { if (!leaf) return null const key = pathKey(path) const editingValue = pending[key] ?? String(leaf.value) - // Pick the badge palette from the leaf name so warning rows render - // amber and critical rows render red. `swap_critical` and any other - // *_critical key fall into the red bucket via the substring check. + // The input border carries the severity colour so the editable field + // itself shows what kind of threshold this is β€” no separate badge + // duplicating the number, which users mistook for the "real" value. + // `swap_critical` and any other `*_critical` leaf falls into the red + // bucket via the substring check. A blue ring on top of the colour + // border signals "customised vs recommended" β€” two independent + // signals on the same widget. const last = path[path.length - 1] || "" const isCritical = last.toLowerCase().includes("critical") const isWarning = last.toLowerCase().includes("warning") - const badgeClasses = isCritical - ? "bg-red-500/10 text-red-500 border-red-500/30" + const severityBorder = isCritical + ? "border-red-500/40 bg-red-500/5 focus-visible:border-red-500" : isWarning - ? "bg-amber-500/10 text-amber-500 border-amber-500/30" - : "bg-muted text-muted-foreground border-border" + ? "border-amber-500/40 bg-amber-500/5 focus-visible:border-amber-500" + : "" + const isCustomised = leaf.customised && !(key in pending) + const customisedRing = isCustomised ? "ring-2 ring-blue-500/40" : "" + const recommendedTooltip = `Recommended: ${leaf.recommended}${leaf.unit}` return (
- -
The Health Monitor and notifications fire when these thresholds are crossed. - Recommended values are shown with their reference color (amber for warning, - red for critical); your edits override them. Leave a value unchanged to keep - the recommended. + Amber inputs are warning levels, red inputs are critical levels. A blue ring + marks a value you've customised away from the recommended default β€” hover the + field to see the recommendation, or use Reset to restore it. @@ -520,14 +518,22 @@ export function HealthThresholds() { ) : !tree ? (
Failed to load thresholds.
) : ( -
+
{error && ( -
+
{error}
)} + {/* + Masonry-style flow via CSS columns: cards keep their natural + height (CPU = 2 rows, Disk temperature = 8 rows) and the + browser packs them top-to-bottom into 1/2/3 columns based on + viewport. `break-inside-avoid` keeps each card whole. + Mobile ( {SECTIONS.map((section) => { const Icon = section.icon return ( @@ -568,6 +574,7 @@ export function HealthThresholds() {
) })} +
)}
diff --git a/AppImage/components/notification-settings.tsx b/AppImage/components/notification-settings.tsx index 1bfc3899..5faa8f2f 100644 --- a/AppImage/components/notification-settings.tsx +++ b/AppImage/components/notification-settings.tsx @@ -492,11 +492,11 @@ export function NotificationSettings() {
-
@@ -517,29 +517,35 @@ export function NotificationSettings() {
{enabled && ( <> -
-
- + {/* Inline label + intrinsic-width inputs. The previous + `grid-cols-2 + full-width inputs` rendered weirdly on + iOS Safari (the native time picker centered "22:00" + inside a 200-px box with huge empty margins). flex + + w-24/w-28 keeps the input tight to the HH:MM text on + every viewport and the touch target stays comfortable. */} +
+
+ updateChannel(chName, "quiet_start", e.target.value)} disabled={!editMode} - className="h-7 text-xs font-mono" + className="h-9 w-28 text-sm font-mono" />
-
- +
+ updateChannel(chName, "quiet_end", e.target.value)} disabled={!editMode} - className="h-7 text-xs font-mono" + className="h-9 w-28 text-sm font-mono" />
-

+

{sameTime ? "Set a different start and end time to activate." : live @@ -571,11 +577,11 @@ export function NotificationSettings() {

-
@@ -596,17 +602,17 @@ export function NotificationSettings() {
{enabled && ( <> -
- +
+ updateChannel(chName, "digest_time", e.target.value)} disabled={!editMode} - className="h-7 text-xs font-mono" + className="h-9 w-28 text-sm font-mono" />
-

{nextLabel}

+

{nextLabel}

)}
diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index 2df045c9..376b996d 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -8,7 +8,7 @@ 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, Terminal, Archive, Plus, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity } 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 } from 'lucide-react' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { Checkbox } from "./ui/checkbox" import { Textarea } from "./ui/textarea" @@ -19,6 +19,28 @@ import { LxcTerminalModal } from "./lxc-terminal-modal" import { formatStorage } from "../lib/utils" import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network" import { fetchApi } from "../lib/api-config" +import DOMPurify from "dompurify" +import { marked } from "marked" + +// Sent by /api/vms only for LXC rows, only when the user has enabled +// `lxc_updates_available` notifications. The Monitor populates this +// from managed_installs registry β†’ frontend uses it to render the +// inline update badge + the modal's "Pending updates" section. +interface LxcPackageUpdate { + name: string + current: string + latest: string + security: boolean +} +interface LxcUpdateCheck { + available: boolean + count: number + security_count: number + last_check: string | null + latest: string | null + error: string | null + packages: LxcPackageUpdate[] +} interface VMData { vmid: number @@ -36,6 +58,7 @@ interface VMData { diskread?: number diskwrite?: number ip?: string + update_check?: LxcUpdateCheck } interface VMConfig { @@ -622,7 +645,7 @@ export function VirtualMachines() { const [backupPbsChangeMode, setBackupPbsChangeMode] = useState("default") // Tab state for modal - const [activeModalTab, setActiveModalTab] = useState<"status" | "mounts" | "backups">("status") + const [activeModalTab, setActiveModalTab] = useState<"status" | "mounts" | "backups" | "updates">("status") // Sprint 13.29: per-LXC mount points lazy-loaded when the user opens // the LXC modal. We fetch alongside backups (one-shot) so switching // tabs is instantaneous; the cost is small (parses one config file @@ -984,6 +1007,74 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { // Ensure vmData is always an array (backend may return object on error) const safeVMData = Array.isArray(vmData) ? vmData : [] + // Render the "πŸ“¦ N updates / πŸ›‘ N security" badge next to an LXC in + // the dashboard list. Used ONLY in the card row alongside Uptime β€” + // the modal surfaces the same info via a dedicated tab instead of + // duplicating a badge in its header. + // + // Sizing matches the sibling "Uptime: …" text (text-sm + h-4 icon) + // so the row reads as a single visual unit. Colour is violet, the + // shared accent for "managed updates" across notifications and UI + // (mirrors the Secure Gateway visual treatment). Security count + // stays red because it's still an urgency cue independent of the + // update theme. + const renderLxcUpdateBadge = ( + uc?: LxcUpdateCheck, + compact = false, + onClick?: () => void, + ) => { + if (!uc?.available || !uc.count || uc.count <= 0) return null + const last = uc.last_check + ? new Date(uc.last_check).toLocaleString() + : "β€”" + const topNames = (uc.packages || []) + .slice(0, 5) + .map((p) => p.name) + .join(", ") + const secHint = + uc.security_count > 0 ? ` Β· ${uc.security_count} security` : "" + // Tooltip leads with the action when the badge is clickable so the + // affordance is explicit on hover β€” the chevron at the end of the + // badge reinforces the same signal visually for users who don't + // hover (mobile). + const tooltipPrefix = onClick ? "Click to view pending packages Β· " : "" + const tooltip = `${tooltipPrefix}Last checked: ${last}${secHint}${topNames ? ` Β· ${topNames}` : ""}` + // Compact = mobile card; matches the surrounding 10-12px chrome + // (ID line, type badge) so the count doesn't visually dominate. + // Non-compact = desktop card row, sized to match "Uptime: ..." text. + const sizing = compact + ? "text-[11px] gap-1 px-1.5 py-0" + : "text-sm gap-1.5 px-2 py-0.5" + const iconSize = compact ? "h-3 w-3" : "h-4 w-4" + // Only soften the bg on hover β€” no border change, no focus ring. + // The chevron at the end of the badge carries the "open this" + // affordance on its own. The Badge component's CVA base adds a + // `focus:ring-2 focus:ring-ring focus:ring-offset-2` (the white + // double border we kept seeing on tap/click) β€” explicitly cancel + // every piece of it here. + const clickable = onClick + ? "cursor-pointer hover:bg-violet-500/20 transition-colors focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0" + : "" + return ( + + + {uc.count} {compact ? "" : (uc.count === 1 ? "update" : "updates")} + {/* Chevron only when the badge is wired up as a clickable + shortcut β€” its absence on the dashboard card avoids + implying interactivity where there isn't any (the whole + row is the click target there). */} + {onClick && } + + ) + } + // Total allocated RAM for ALL VMs/LXCs (running + stopped) const totalAllocatedMemoryGB = useMemo(() => { return (safeVMData.reduce((sum, vm) => sum + (vm.maxmem || 0), 0) / 1024 ** 3).toFixed(1) @@ -1111,67 +1202,57 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { return (
+ {/* + styled-jsx is scoped by default β€” it adds a hash class to + selectors so they only match elements rendered by this + component. Content injected via `dangerouslySetInnerHTML` + does NOT get the hash, so descendant selectors like + `div[align="center"]` never matched the helper-script HTML + and notes rendered left-aligned. Wrapping the descendant + selectors in `:global(...)` keeps the parent class scoped + but lets the inner rules apply to the injected HTML. + */}