mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-05-22 00:24:48 +00:00
Update AppImage
This commit is contained in:
@@ -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 (
|
||||
<div key={key} className="flex items-center justify-between gap-2 py-1.5 px-1">
|
||||
<span className="text-xs sm:text-sm text-foreground/90 min-w-0 flex items-center gap-2">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-500 flex-shrink-0" aria-hidden="true" />
|
||||
<span className="text-xs sm:text-sm text-foreground/90 min-w-0">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span
|
||||
className={`inline-flex items-center justify-center h-6 px-2 rounded-md border text-[11px] font-mono tabular-nums ${badgeClasses}`}
|
||||
title="Recommended default value"
|
||||
>
|
||||
{leaf.recommended}
|
||||
{leaf.unit}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={leaf.min}
|
||||
@@ -430,14 +429,13 @@ export function HealthThresholds() {
|
||||
step={leaf.step}
|
||||
disabled={!editMode}
|
||||
value={editingValue}
|
||||
title={recommendedTooltip}
|
||||
onChange={(e) =>
|
||||
setPending((p) => ({ ...p, [key]: e.target.value }))
|
||||
}
|
||||
className={`w-20 h-7 text-xs text-right tabular-nums ${
|
||||
!editMode ? "opacity-70" : ""
|
||||
} ${
|
||||
leaf.customised && !(key in pending) ? "border-blue-500/40" : ""
|
||||
}`}
|
||||
} ${severityBorder} ${customisedRing}`}
|
||||
/>
|
||||
<span className="text-[11px] text-muted-foreground w-6">{leaf.unit}</span>
|
||||
</div>
|
||||
@@ -507,9 +505,9 @@ export function HealthThresholds() {
|
||||
</div>
|
||||
<CardDescription>
|
||||
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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -520,14 +518,22 @@ export function HealthThresholds() {
|
||||
) : !tree ? (
|
||||
<div className="text-sm text-muted-foreground">Failed to load thresholds.</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-2.5 rounded-md bg-red-500/10 border border-red-500/30 text-red-500 text-xs">
|
||||
<div className="mb-4 flex items-start gap-2 p-2.5 rounded-md bg-red-500/10 border border-red-500/30 text-red-500 text-xs">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*
|
||||
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 (<md) stays single-column as today.
|
||||
*/}
|
||||
<div className="columns-1 md:columns-2 2xl:columns-3 gap-4 space-y-4 [&>*]:break-inside-avoid">
|
||||
{SECTIONS.map((section) => {
|
||||
const Icon = section.icon
|
||||
return (
|
||||
@@ -568,6 +574,7 @@ export function HealthThresholds() {
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -492,11 +492,11 @@ export function NotificationSettings() {
|
||||
<div className="space-y-2 pt-2 border-t border-border/50">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<Label className="text-xs font-medium flex items-center gap-1.5">
|
||||
<Moon className="h-3.5 w-3.5 text-blue-400" />
|
||||
<Label className="text-xs sm:text-sm text-foreground/80 flex items-center gap-2">
|
||||
<Moon className="h-4 w-4 text-blue-400" />
|
||||
Quiet hours
|
||||
</Label>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
During this window only CRITICAL events reach this channel.
|
||||
</p>
|
||||
</div>
|
||||
@@ -517,29 +517,35 @@ export function NotificationSettings() {
|
||||
</div>
|
||||
{enabled && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground">From</Label>
|
||||
{/* 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. */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground">From</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={start}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground">Until</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Until</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={end}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{sameTime
|
||||
? "Set a different start and end time to activate."
|
||||
: live
|
||||
@@ -571,11 +577,11 @@ export function NotificationSettings() {
|
||||
<div className="space-y-2 pt-2 border-t border-border/50">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<Label className="text-xs font-medium flex items-center gap-1.5">
|
||||
<Newspaper className="h-3.5 w-3.5 text-violet-400" />
|
||||
<Label className="text-xs sm:text-sm text-foreground/80 flex items-center gap-2">
|
||||
<Newspaper className="h-4 w-4 text-violet-400" />
|
||||
Daily digest of INFO events
|
||||
</Label>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
All INFO events (backups OK, updates available, etc.) accumulate during the day and arrive once at this time as a single summary. CRITICAL and WARNING are never delayed.
|
||||
</p>
|
||||
</div>
|
||||
@@ -596,17 +602,17 @@ export function NotificationSettings() {
|
||||
</div>
|
||||
{enabled && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground">Send at</Label>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Label className="text-xs text-muted-foreground">Send at</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{nextLabel}</p>
|
||||
<p className="text-xs text-muted-foreground">{nextLabel}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<string>("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 (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`bg-violet-500/10 text-violet-400 border-violet-500/30 flex items-center flex-shrink-0 ${sizing} ${clickable}`}
|
||||
title={tooltip}
|
||||
onClick={onClick}
|
||||
role={onClick ? "button" : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
>
|
||||
<Package className={iconSize} />
|
||||
{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 && <ChevronRight className={`${iconSize} -mr-0.5 opacity-80`} />}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="space-y-6">
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<style jsx>{`
|
||||
.proxmenux-notes {
|
||||
/* Reset any inherited styles */
|
||||
all: revert;
|
||||
|
||||
/* Ensure links display inline */
|
||||
a {
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Ensure images display inline */
|
||||
img {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Ensure paragraphs with links display inline */
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Override inline width and center the table */
|
||||
table {
|
||||
width: auto !important;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Ensure divs respect centering */
|
||||
div[align="center"] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Remove border-left since logo already has the line, keep text left-aligned */
|
||||
table td:nth-child(2) {
|
||||
text-align: left;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
/* Increase h1 font size for VM name */
|
||||
table td:nth-child(2) h1 {
|
||||
text-align: left;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Ensure p in the second cell is left-aligned */
|
||||
table td:nth-child(2) p {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Add separator after tables */
|
||||
table + p {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.proxmenux-notes :global(a) {
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.proxmenux-notes :global(img) {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.proxmenux-notes :global(p) {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.proxmenux-notes :global(table) {
|
||||
width: auto !important;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.proxmenux-notes :global(div[align="center"]) {
|
||||
text-align: center;
|
||||
}
|
||||
.proxmenux-notes :global(table td:nth-child(2)) {
|
||||
text-align: left;
|
||||
padding-left: 16px;
|
||||
}
|
||||
.proxmenux-notes :global(table td:nth-child(2) h1) {
|
||||
text-align: left;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.proxmenux-notes :global(table td:nth-child(2) p) {
|
||||
text-align: left;
|
||||
}
|
||||
.proxmenux-notes :global(table + p) {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.proxmenux-notes-plaintext {
|
||||
white-space: pre-wrap;
|
||||
font-family: monospace;
|
||||
@@ -1364,6 +1445,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground ml-auto">Uptime: {formatUptime(vm.uptime)}</span>
|
||||
{vm.type === "lxc" && renderLxcUpdateBadge(vm.update_check)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
@@ -1474,7 +1556,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
|
||||
{/* Name and ID */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-foreground truncate">{vm.name}</div>
|
||||
<div className="font-semibold text-foreground truncate flex items-center gap-1.5">
|
||||
<span className="truncate">{vm.name}</span>
|
||||
{vm.type === "lxc" && renderLxcUpdateBadge(vm.update_check, true)}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">ID: {vm.vmid}</div>
|
||||
</div>
|
||||
|
||||
@@ -1575,6 +1660,17 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
Uptime: {formatUptime(selectedVM.uptime)}
|
||||
</span>
|
||||
)}
|
||||
{/* Clickable badge — the sole entry point to
|
||||
the Updates panel now that the tab is no
|
||||
longer in the nav. Full-size so it reads
|
||||
at the same weight as the surrounding
|
||||
Uptime / Type / Status chips. */}
|
||||
{selectedVM.type === "lxc" &&
|
||||
renderLxcUpdateBadge(
|
||||
selectedVM.update_check,
|
||||
false,
|
||||
() => setActiveModalTab("updates"),
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -1600,6 +1696,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
Uptime: {formatUptime(selectedVM.uptime)}
|
||||
</span>
|
||||
)}
|
||||
{selectedVM.type === "lxc" &&
|
||||
renderLxcUpdateBadge(
|
||||
selectedVM.update_check,
|
||||
false,
|
||||
() => setActiveModalTab("updates"),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1610,7 +1712,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<div className="flex border-b border-border px-6 shrink-0">
|
||||
<button
|
||||
onClick={() => setActiveModalTab("status")}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
|
||||
activeModalTab === "status"
|
||||
? "border-cyan-500 text-cyan-500"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
@@ -1643,7 +1745,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
)}
|
||||
<button
|
||||
onClick={() => setActiveModalTab("backups")}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
|
||||
activeModalTab === "backups"
|
||||
? "border-amber-500 text-amber-500"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
@@ -1655,6 +1757,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<Badge variant="secondary" className="text-xs h-5 ml-1">{vmBackups.length}</Badge>
|
||||
)}
|
||||
</button>
|
||||
{/* Updates is intentionally NOT a tab in the nav — the
|
||||
extra tab created a scrolling tab strip on mobile
|
||||
(especially once Mounts + Backups + Updates piled
|
||||
up) and the swipe affordance was missed. The
|
||||
clickable violet badge in the modal header is now
|
||||
the sole entry point; the Updates content panel
|
||||
below still mounts when activeModalTab === 'updates'. */}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
|
||||
@@ -1929,14 +2038,20 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
) : vmDetails.config.description ? (
|
||||
<>
|
||||
{(() => {
|
||||
// VM/CT notes are operator-controlled but historically were
|
||||
// rendered via `dangerouslySetInnerHTML` — a stored XSS sink
|
||||
// for any user with write access to the VM config (a
|
||||
// non-admin user with PVE permissions, or another admin in
|
||||
// a multi-admin deployment). We now render the decoded
|
||||
// notes as plain text inside a <pre> with `white-space:
|
||||
// pre-wrap` so newlines and indentation are preserved
|
||||
// without interpreting any HTML. See audit Tier 2 #13.
|
||||
// VM/CT notes come in two flavours and we mirror the way
|
||||
// the PVE web UI handles each:
|
||||
// • HTML (ProxMenux/community-script helper output with
|
||||
// <div align='center'>, tables, logos) → render the
|
||||
// HTML verbatim. The stable `main` branch did exactly
|
||||
// this with dangerouslySetInnerHTML — we keep that
|
||||
// behaviour but pipe through DOMPurify so the audit
|
||||
// Tier 2 #13 XSS sink stays closed.
|
||||
// • Plain text / markdown (e.g. qBittorrent's
|
||||
// `## qBittorrent LXC`) → marked turns it into
|
||||
// headings + autolinks + line breaks, matching PVE.
|
||||
// Mixing the two paths breaks the HTML one because marked
|
||||
// collapses indentation / wraps inline runs and the
|
||||
// browser then ignores `align="center"`.
|
||||
let decoded: string
|
||||
try {
|
||||
decoded = decodeRecursively(vmDetails.config.description)
|
||||
@@ -1947,12 +2062,71 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const looksLikeHtml = /<\/?[a-z][\s\S]*?>/i.test(decoded)
|
||||
let html: string
|
||||
if (looksLikeHtml) {
|
||||
html = decoded
|
||||
} else {
|
||||
try {
|
||||
html = marked.parse(decoded, {
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
async: false,
|
||||
}) as string
|
||||
} catch {
|
||||
html = decoded.replace(/\n/g, "<br>")
|
||||
}
|
||||
}
|
||||
// Promote legacy `align` HTML attribute to a real inline
|
||||
// `style="text-align: …"` rule. Tailwind / parent CSS,
|
||||
// styled-jsx scoping quirks and Safari's UA stylesheet
|
||||
// can all swallow the bare `align` attribute on `<div>`
|
||||
// (it's HTML4 obsolete syntax). An inline style is
|
||||
// bullet-proof: highest specificity, no scope hash needed.
|
||||
DOMPurify.removeHook("afterSanitizeAttributes")
|
||||
DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => {
|
||||
const a = node.getAttribute?.("align")
|
||||
if (a && /^(center|left|right)$/i.test(a)) {
|
||||
const cur = node.getAttribute("style") || ""
|
||||
const sep = cur && !cur.trim().endsWith(";") ? "; " : ""
|
||||
node.setAttribute(
|
||||
"style",
|
||||
`${cur}${sep}text-align: ${a.toLowerCase()}`,
|
||||
)
|
||||
}
|
||||
// Force `target=_blank` links to open in a new tab
|
||||
// safely (noopener prevents reverse-tabnabbing).
|
||||
if (node.tagName === "A" && node.getAttribute("target") === "_blank") {
|
||||
node.setAttribute("rel", "noopener noreferrer")
|
||||
}
|
||||
})
|
||||
const cleanHtml = DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: [
|
||||
"a", "p", "br", "div", "span",
|
||||
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"img",
|
||||
"table", "thead", "tbody", "tr", "th", "td",
|
||||
"ul", "ol", "li",
|
||||
"strong", "em", "b", "i", "u", "code", "pre",
|
||||
"blockquote", "hr",
|
||||
"small", "sub", "sup",
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
"href", "src", "alt", "title", "target",
|
||||
"rel", "style", "class",
|
||||
"align", "width", "height",
|
||||
"colspan", "rowspan",
|
||||
],
|
||||
ALLOWED_URI_REGEXP:
|
||||
/^(?:(?:https?|mailto|data:image\/(?:png|jpeg|jpg|gif|svg\+xml|webp)):|\/|#)/i,
|
||||
ADD_ATTR: ["target"],
|
||||
})
|
||||
return (
|
||||
<pre
|
||||
className="text-sm text-foreground proxmenux-notes-plaintext font-sans whitespace-pre-wrap break-words m-0"
|
||||
>
|
||||
{decoded}
|
||||
</pre>
|
||||
<div
|
||||
className="text-sm text-foreground proxmenux-notes break-words"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: cleanHtml }}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</>
|
||||
@@ -2345,6 +2519,118 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Updates Tab — LXC only, conditionally rendered.
|
||||
Lives in its own tab so the per-package list (up to
|
||||
30 rows) doesn't blow up the Status tab on mobile.
|
||||
Violet matches the shared "managed updates" theme. */}
|
||||
{activeModalTab === "updates" &&
|
||||
selectedVM?.type === "lxc" &&
|
||||
selectedVM?.update_check?.available && (
|
||||
<div className="space-y-4" key={`updates-${selectedVM.vmid}`}>
|
||||
<Card className="border border-border bg-card/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-violet-500/10">
|
||||
<Package className="h-4 w-4 text-violet-400" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Pending package updates
|
||||
</h3>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-violet-500/10 text-violet-400 border-violet-500/30"
|
||||
>
|
||||
{selectedVM.update_check.count} total
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mb-3 leading-relaxed">
|
||||
Last checked:{" "}
|
||||
{selectedVM.update_check.last_check
|
||||
? new Date(selectedVM.update_check.last_check).toLocaleString()
|
||||
: "—"}
|
||||
{" · "}Apply with{" "}
|
||||
<code className="text-foreground/80">pct enter {selectedVM.vmid}</code>
|
||||
{" → "}
|
||||
<code className="text-foreground/80">apt update && apt upgrade</code>
|
||||
</div>
|
||||
{/* Two render modes:
|
||||
• Full list when every pending package fits
|
||||
(registry cap is 30 packages per CT — so
|
||||
CTs with ≤30 updates show every row).
|
||||
• Summary when the CT has more pending than
|
||||
the registry stored. Showing 30 random
|
||||
rows out of 139 misleads the user — a
|
||||
count + security count + "inspect inside"
|
||||
hint is honester. */}
|
||||
{(() => {
|
||||
const stored = selectedVM.update_check.packages?.length || 0
|
||||
const total = selectedVM.update_check.count || 0
|
||||
const sec = selectedVM.update_check.security_count || 0
|
||||
const truncated = total > stored
|
||||
if (!truncated && stored > 0) {
|
||||
return (
|
||||
<div className="border-t border-border divide-y divide-border/50">
|
||||
{selectedVM.update_check.packages.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-0.5 sm:gap-2 py-2 text-sm"
|
||||
>
|
||||
<span className="font-mono text-foreground/90 flex items-center gap-2 min-w-0">
|
||||
{p.security && (
|
||||
<Shield
|
||||
className="h-4 w-4 text-green-500 flex-shrink-0"
|
||||
aria-label="Security update"
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{p.name}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground flex-shrink-0 font-mono text-xs sm:text-sm">
|
||||
<span>{p.current || "—"}</span>
|
||||
<span>→</span>
|
||||
<span className="text-foreground">{p.latest}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Truncated OR no per-package detail — render a summary.
|
||||
return (
|
||||
<div className="border-t border-border pt-3 space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-violet-400 flex-shrink-0" />
|
||||
<span>
|
||||
<span className="font-semibold">{total}</span> package
|
||||
{total === 1 ? "" : "s"} pending
|
||||
</span>
|
||||
</div>
|
||||
{sec > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||
<span>
|
||||
<span className="font-semibold">{sec}</span> security update
|
||||
{sec === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground pt-1 leading-relaxed">
|
||||
Full list available inside the container:{" "}
|
||||
<code className="text-foreground/80">
|
||||
pct enter {selectedVM.vmid}
|
||||
</code>{" "}
|
||||
→{" "}
|
||||
<code className="text-foreground/80">apt list --upgradable</code>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sprint 13.29: Mount Points Tab — LXC only.
|
||||
Renders configured mpX entries first, then any
|
||||
ad-hoc NFS/CIFS/SMB mounts found inside the
|
||||
|
||||
Reference in New Issue
Block a user