Beta cycle bundle over 1.2.4.1

- **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.
This commit is contained in:
MacRimi
2026-08-15 17:33:05 +02:00
parent 34b8c47415
commit 0beeb7a68b
26 changed files with 1554 additions and 278 deletions
+2 -1
View File
@@ -1048,7 +1048,8 @@ return (
{nvidiaInstall.update_check.available ? (
<>
<div className="text-xs text-muted-foreground">
{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} · `}
<span className="text-purple-400 font-medium">
{t("hardware.values.nvidiaDriverAvailable", { version: nvidiaInstall.update_check.latest || "" })}
</span>
+23 -2
View File
@@ -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 && (
<div className="pt-2 border-t border-border/50">
<div className="pt-2 border-t border-border/50 space-y-3">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
@@ -1517,6 +1526,18 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
<div className="text-xs text-muted-foreground mt-1">{t("vmLxc.appEditor.notifyUpstreamHelp")}</div>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={draft.exclude_from_badge === true}
onChange={(e) => setField({ exclude_from_badge: e.target.checked })}
className="mt-0.5 h-4 w-4 rounded border-border accent-blue-500"
/>
<div className="text-sm">
<div className="text-foreground">{t("vmLxc.appEditor.excludeFromBadgeLabel")}</div>
<div className="text-xs text-muted-foreground mt-1">{t("vmLxc.appEditor.excludeFromBadgeHelp")}</div>
</div>
</label>
</div>
)}
+4 -6
View File
@@ -900,13 +900,11 @@ export function LxcTerminalModal({
) : null}
</div>
<div className="pt-2 border-t border-zinc-800 flex items-center justify-between text-xs text-zinc-500">
<div className="flex items-center gap-2">
<Lightbulb className="w-3 h-3" />
<span>{t("terminal.searchTip")}</span>
{useOnline && searchResults.length > 0 && (
<div className="pt-2 border-t border-zinc-800 text-xs text-zinc-500 text-right">
<span className="text-zinc-600">{t("terminal.poweredByCheatSh")}</span>
</div>
{useOnline && searchResults.length > 0 && <span className="text-zinc-600">{t("terminal.poweredByCheatSh")}</span>}
</div>
)}
</div>
</SearchDialogContent>
</SearchDialog>
+39 -2
View File
@@ -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<string, ReleaseNote> = {
// build-i18n-messages workflow feeds to Google Translate for locales
// that haven't been curated by hand.
const CURRENT_VERSION_FEATURES = [
{
icon: <Languages className="h-5 w-5" />,
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: <Zap className="h-5 w-5" />,
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(
<a
key={`gh-${idx++}`}
href={`https://github.com/${handle}`}
target="_blank"
rel="noopener noreferrer"
className="text-orange-500 hover:text-orange-400 underline underline-offset-2"
>
@{handle}
</a>,
)
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) {
>
<div className="text-orange-500 mt-0.5 flex-shrink-0">{feature.icon}</div>
<p className="text-xs md:text-sm text-foreground leading-relaxed">
{t(feature.key)}
{linkifyGithubMentions(t(feature.key))}
</p>
</div>
))}
+99 -36
View File
@@ -250,6 +250,23 @@ export function StorageOverview() {
const [diskObservations, setDiskObservations] = useState<DiskObservation[]>([])
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<string, unknown>
@@ -1792,7 +1809,13 @@ export function StorageOverview() {
setSmartJsonData(null)
}
}}>
<DialogContent className="max-w-4xl max-h-[80vh] sm:max-h-[85vh] overflow-hidden flex flex-col p-0">
<DialogContent
className={`max-w-4xl flex flex-col p-0 overflow-hidden ${
isStandalone
? "h-[95vh] sm:h-[90vh]"
: "h-[85vh] sm:h-[85vh] max-h-[calc(100dvh-env(safe-area-inset-top)-env(safe-area-inset-bottom)-40px)]"
}`}
>
<DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle className="flex items-center gap-2">
{selectedDisk?.connection_type === 'usb' ? (
@@ -1816,58 +1839,74 @@ export function StorageOverview() {
</DialogDescription>
</DialogHeader>
{/* Tab Navigation */}
<div className="flex border-b border-border px-6 overflow-x-auto">
{/* 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. */}
<div className="flex border-b border-border px-3 sm:px-6 overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<button
onClick={() => setActiveModalTab("overview")}
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap shrink-0 ${
activeModalTab === "overview"
? "border-blue-500 text-blue-500"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Info className="h-4 w-4" />
{t("storage.overview")}
<span className={activeModalTab === "overview" ? "" : "hidden sm:inline"}>
{t("storage.overview")}
</span>
</button>
<button
onClick={() => setActiveModalTab("smart")}
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap shrink-0 ${
activeModalTab === "smart"
? "border-green-500 text-green-500"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Activity className="h-4 w-4" />
{t("storage.smart")}
<span className={activeModalTab === "smart" ? "" : "hidden sm:inline"}>
{t("storage.smart")}
</span>
</button>
<button
onClick={() => setActiveModalTab("history")}
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap shrink-0 ${
activeModalTab === "history"
? "border-orange-500 text-orange-500"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Archive className="h-4 w-4" />
{t("storage.history")}
<span className={activeModalTab === "history" ? "" : "hidden sm:inline"}>
{t("storage.history")}
</span>
</button>
<button
onClick={() => setActiveModalTab("schedule")}
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap shrink-0 ${
activeModalTab === "schedule"
? "border-purple-500 text-purple-500"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Clock className="h-4 w-4" />
{t("storage.schedule")}
<span className={activeModalTab === "schedule" ? "" : "hidden sm:inline"}>
{t("storage.schedule")}
</span>
</button>
</div>
{/* Tab Content */}
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
{/* 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. */}
<div className="flex-1 flex flex-col min-h-0 px-6 py-4">
{selectedDisk && activeModalTab === "overview" && (
<div className="space-y-4">
<div className="space-y-4 flex-1 overflow-y-auto min-h-0 pr-1 -mr-1">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">{t("storage.model")}</p>
@@ -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(){
}
</script>
<!-- Top bar (screen only) -->
<!-- Top bar (screen only).
Print / Save as PDF actions replaced by icon-only buttons —
both call the same window.print() dialog (the browser's print
dialog exposes 'Save as PDF' as a destination), so labels
don't need to be translated. aria-label carries the intent
for screen readers. -->
<div class="top-bar no-print">
<div style="display:flex;align-items:center;gap:12px;">
<strong>${t("storage.smartReport.title")}</strong>
<span id="pmx-print-hint" style="font-size:11px;opacity:0.7;">/dev/${disk.name}</span>
</div>
<button onclick="pmxPrint()">${t("storage.smartReport.printPdf")}</button>
<div class="btn-group">
<button onclick="pmxPrint()" title="Print" aria-label="Print">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
</button>
<button onclick="pmxPrint()" title="Save as PDF" aria-label="Save as PDF">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>
</button>
</div>
</div>
<!-- Header -->
@@ -4026,7 +4079,12 @@ function SmartTestTab({ disk, observations = [], lastTestDate }: SmartTestTabPro
}
return (
<div className="space-y-6">
<div className="flex flex-col h-full min-h-0">
{/* 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. */}
<div className="flex-1 overflow-y-auto min-h-0 pr-1 -mr-1 space-y-6">
{/* Quick Actions */}
<div className="space-y-3">
<h4 className="font-semibold flex items-center gap-2">
@@ -4152,8 +4210,8 @@ function SmartTestTab({ disk, observations = [], lastTestDate }: SmartTestTabPro
{!isNvme && !testStatus.smart_data?.is_sas && <div className="col-span-2 text-center">{t("storage.smartTest.worst")}</div>}
<div className="col-span-2 text-center">{t("storage.smartTest.status")}</div>
</div>
<div className="divide-y divide-border max-h-[200px] overflow-y-auto">
{testStatus.smart_data.attributes.slice(0, 15).map((attr) => (
<div className="divide-y divide-border">
{testStatus.smart_data.attributes.map((attr) => (
<div key={attr.id} className={`grid ${(isNvme || testStatus.smart_data?.is_sas) ? 'grid-cols-10' : 'grid-cols-12'} gap-2 p-3 text-sm items-center`}>
{!isNvme && !testStatus.smart_data?.is_sas && <div className="col-span-1 text-muted-foreground">{attr.id}</div>}
<div className={`${(isNvme || testStatus.smart_data?.is_sas) ? 'col-span-5' : 'col-span-5'} truncate`} title={smartAttributeLabel(attr.name)}>{smartAttributeLabel(attr.name)}</div>
@@ -4175,8 +4233,14 @@ function SmartTestTab({ disk, observations = [], lastTestDate }: SmartTestTabPro
</div>
)}
{/* View Full Report Button */}
<div className="pt-4 border-t">
</div>
{/* 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. */}
<div className="pt-4 mt-4 border-t shrink-0">
<Button
variant="outline"
className="w-full gap-2 bg-blue-500/10 border-blue-500/30 text-blue-500 hover:bg-blue-500/20 hover:text-blue-400"
@@ -4201,12 +4265,7 @@ function SmartTestTab({ disk, observations = [], lastTestDate }: SmartTestTabPro
<FileText className="h-4 w-4" />
{t("storage.smartTest.viewFullReport")}
</Button>
<p className="text-xs text-muted-foreground text-center mt-2">
{t("storage.smartTest.reportHelp")}
</p>
</div>
</div>
)
}
@@ -4308,7 +4367,7 @@ function HistoryTab({ disk }: { disk: DiskInfo }) {
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-12 gap-3">
<div className="flex flex-col items-center justify-center h-full min-h-0 gap-3">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">{t("storage.historyTab.loading")}</p>
</div>
@@ -4317,7 +4376,7 @@ function HistoryTab({ disk }: { disk: DiskInfo }) {
if (history.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<div className="flex flex-col items-center justify-center h-full min-h-0 text-muted-foreground">
<Archive className="h-12 w-12 mb-3 opacity-30" />
<span className="text-sm">{t("storage.historyTab.empty")}</span>
<span className="text-xs mt-1">{t("storage.historyTab.emptyHint")}</span>
@@ -4326,8 +4385,12 @@ function HistoryTab({ disk }: { disk: DiskInfo }) {
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex flex-col h-full min-h-0">
{/* 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. */}
<div className="flex items-center justify-between shrink-0 pb-3">
<h4 className="font-semibold flex items-center gap-2">
<Archive className="h-4 w-4" />
{t("storage.historyTab.title")}
@@ -4337,7 +4400,7 @@ function HistoryTab({ disk }: { disk: DiskInfo }) {
</h4>
</div>
<div className="space-y-2">
<div className="space-y-2 flex-1 overflow-y-auto min-h-0 pr-1 -mr-1">
{history.map((entry, i) => {
const isLatest = i === 0
const testDate = new Date(entry.timestamp)
@@ -4399,7 +4462,7 @@ function HistoryTab({ disk }: { disk: DiskInfo }) {
})}
</div>
<p className="text-xs text-muted-foreground text-center pt-2">
<p className="text-xs text-muted-foreground text-center pt-3 mt-2 border-t shrink-0">
{t("storage.historyTab.note")}
</p>
</div>
@@ -4569,7 +4632,7 @@ function ScheduleTab({ disk }: { disk: DiskInfo }) {
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<div className="flex items-center justify-center h-full min-h-0">
<div className="h-6 w-6 rounded-full border-2 border-transparent border-t-purple-400 animate-spin" />
<span className="ml-2 text-muted-foreground">{t("storage.scheduleTab.loading")}</span>
</div>
@@ -4577,7 +4640,7 @@ function ScheduleTab({ disk }: { disk: DiskInfo }) {
}
return (
<div className="space-y-4">
<div className="space-y-4 h-full min-h-0 overflow-y-auto pr-1 -mr-1">
{/* Global Toggle */}
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<div>
+4 -6
View File
@@ -1221,13 +1221,11 @@ const handleClose = () => {
) : null}
</div>
<div className="pt-2 border-t border-zinc-800 flex items-center justify-between text-xs text-zinc-500">
<div className="flex items-center gap-2">
<Lightbulb className="w-3 h-3" />
<span>{t("terminal.searchTip")}</span>
{useOnline && searchResults.length > 0 && (
<div className="pt-2 border-t border-zinc-800 text-xs text-zinc-500 text-right">
<span className="text-zinc-600">{t("terminal.poweredByCheatSh")}</span>
</div>
{useOnline && searchResults.length > 0 && <span className="text-zinc-600">{t("terminal.poweredByCheatSh")}</span>}
</div>
)}
</div>
</DialogContent>
</Dialog>
+495 -92
View File
@@ -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<LxcMountPoint["type"], string> = {
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<number, { mount_points: LxcMountPoint[] }>(),
// always-fresh side. `ad_hoc_hint_count` is the cheap remote-fs
// count from /proc/<pid>/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<number, { mount_points: LxcMountPoint[]; ad_hoc_hint_count?: number }>(),
// 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<boolean | null>(null)
const [pendingTags, setPendingTags] = useState<string[] | null>(null)
const [newTagDraft, setNewTagDraft] = useState<string>("")
// 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<number | null>(null)
const [editingTagDraft, setEditingTagDraft] = useState<string>("")
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<Record<string, Partial<LxcMountPoint>> | null>(null)
// Cheap count of remote-fs (nfs/cifs/smb) entries in the CT's
// /proc/<pid>/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<number>(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<string, unknown> = {}
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) => {
</Badge>
<span className="font-semibold text-foreground truncate min-w-0">{vm.name}</span>
<span className="text-sm text-muted-foreground whitespace-nowrap flex-shrink-0">ID: {vm.vmid}</span>
{/* 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 (
<span
key={`tag-dot-${vm.vmid}-${tag}`}
title={tag}
className="inline-block h-3 w-3 rounded-full flex-shrink-0"
style={{ backgroundColor: bg }}
/>
)
})}
{vm.type === "lxc" && (
<div className="ml-auto flex-shrink-0">
{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. */}
<div className="flex flex-row gap-3 lg:gap-4">
{vm.status === "running" && (
<div className="hidden lg:block lg:min-w-[180px] lg:flex-shrink-0">
{/* 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`. */}
<div className="hidden lg:block lg:min-w-[180px] lg:flex-shrink-0">
{vm.status === "running" && (
<div className="flex flex-col gap-1">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{t("vmLxc.uptime", { uptime: formatUptime(vm.uptime, t) })}
@@ -2480,8 +2619,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</span>
)}
</div>
</div>
)}
)}
</div>
<div className="flex-1 grid grid-cols-5 gap-2 lg:gap-3 min-w-0">
<div>
<div className="text-xs text-muted-foreground mb-1">{t("vmLxc.cpuUsage")}</div>
@@ -2601,6 +2740,17 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<div className="font-semibold text-foreground truncate">{vm.name}</div>
<div className="text-[10px] text-muted-foreground flex items-center gap-1">
<span>ID: {vm.vmid}</span>
{parseTags(vm.tags).map((tag) => {
const { bg } = tagToColor(tag)
return (
<span
key={`tag-dot-m-${vm.vmid}-${tag}`}
title={tag}
className="inline-block h-2.5 w-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: bg }}
/>
)
})}
{vm.type === "lxc" && vm.update_check?.available && (vm.update_check?.count ?? 0) > 0 && (
<ArrowUpCircle className="h-3 w-3 text-violet-400 flex-shrink-0" />
)}
@@ -2868,9 +3018,15 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
)}
</button>
)}
{/* 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/<pid>/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) && (
<button
onClick={() => setActiveModalTab("mounts")}
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap shrink-0 ${
@@ -2884,7 +3040,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{t("vmLxc.tabs.mounts")}
</span>
<Badge variant="secondary" className="text-xs h-5 ml-0.5 sm:ml-1">
{mountPoints.length + adHocMounts.length}
{mountPoints.length + Math.max(adHocMounts.length, mountsAdHocHint)}
</Badge>
</button>
)}
@@ -3061,6 +3217,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div>
<h3 className="text-sm font-semibold text-foreground">{t("vmLxc.resources")}</h3>
</div>
{/* 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. */}
<div className="flex gap-2">
<Button
variant="outline"
@@ -3080,24 +3242,70 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowAdditionalInfo(!showAdditionalInfo)}
className="text-xs max-sm:bg-black/5 max-sm:dark:bg-white/5 sm:bg-transparent sm:hover:bg-black/5 sm:dark:hover:bg-white/5"
>
{showAdditionalInfo ? (
<>
<ChevronUp className="h-3 w-3 mr-1" />
{t("vmLxc.lessInfo")}
</>
) : (
<>
<ChevronDown className="h-3 w-3 mr-1" />
+ {t("vmLxc.info")}
</>
)}
</Button>
{/* 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 && (
<span className="flex items-center gap-1 text-xs text-green-500 mr-1">
<Check className="h-3.5 w-3.5" />
{t("status.saved")}
</span>
)}
{resourcesEditMode ? (
<>
<Button
variant="outline"
size="sm"
onClick={handleCancelResourcesEdit}
disabled={savingOnboot}
aria-label={t("actions.cancel")}
className="text-xs h-9 w-9 sm:w-auto sm:px-3 p-0 sm:p-2"
>
<X className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">{t("actions.cancel")}</span>
</Button>
<Button
size="sm"
onClick={handleSaveResources}
disabled={
savingOnboot ||
(
(pendingOnboot === null || pendingOnboot === !!vmDetails.config.onboot) &&
(pendingTags === null || stringifyTags(pendingTags) === stringifyTags(parseTags(selectedVM?.tags)))
)
}
aria-label={t("actions.save")}
className="text-xs h-9 w-9 sm:w-auto sm:px-3 p-0 sm:p-2 bg-blue-600 hover:bg-blue-700 text-white"
>
{savingOnboot ? (
<Loader2 className="h-3 w-3 animate-spin sm:mr-1" />
) : (
<Check className="h-3.5 w-3.5 sm:mr-1" />
)}
<span className="hidden sm:inline">{t("actions.save")}</span>
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
onClick={() => {
setPendingOnboot(!!vmDetails.config.onboot)
setPendingTags(parseTags(selectedVM?.tags))
setResourcesEditMode(true)
}}
aria-label={t("actions.edit")}
className="text-xs h-9 w-9 sm:w-auto sm:px-3 p-0 sm:p-2 max-sm:bg-black/5 max-sm:dark:bg-white/5 sm:bg-transparent sm:hover:bg-black/5 sm:dark:hover:bg-white/5"
>
<Settings2 className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">{t("actions.edit")}</span>
</Button>
)}
</div>
</div>
@@ -3131,11 +3339,180 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
)}
</div>
{/* 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 (
<div
className={`mt-4 rounded-md p-3 ${
resourcesEditMode ? "bg-accent" : ""
}`}
>
<div className="flex items-center gap-2 mb-2">
<TagIcon className="h-4 w-4 text-blue-500 shrink-0" />
<div className="text-sm font-medium text-foreground">
{t("vmLxc.details.tags")}
</div>
</div>
<div className="flex flex-wrap items-center gap-1.5">
{shownTags.map((tag, index) => {
const { bg, fg } = tagToColor(tag)
const isEditingThis = resourcesEditMode && editingTagIndex === index
if (isEditingThis) {
return (
<input
key={`vm-tag-edit-${selectedVM?.vmid}-${index}`}
type="text"
autoFocus
value={editingTagDraft}
onChange={(e) => 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 (
<span
key={`vm-tag-${selectedVM?.vmid}-${tag}-${index}`}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm text-sm font-medium leading-none ${
resourcesEditMode ? "cursor-text" : ""
}`}
style={{ backgroundColor: bg, color: fg }}
onClick={() => {
if (!resourcesEditMode) return
setEditingTagIndex(index)
setEditingTagDraft(tag)
}}
>
<span>{tag}</span>
{resourcesEditMode && (
<button
type="button"
aria-label={`${t("actions.remove")} ${tag}`}
onClick={(e) => {
e.stopPropagation()
const next = editTags.filter((_, i) => i !== index)
setPendingTags(next)
}}
className="inline-flex items-center justify-center h-4 w-4 rounded-sm hover:bg-black/20"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</span>
)
})}
{resourcesEditMode && (
<input
type="text"
value={newTagDraft}
onChange={(e) => 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 && (
<span className="text-xs text-muted-foreground italic">
{t("vmLxc.details.tagsNone")}
</span>
)}
</div>
</div>
)
})()}
{/* 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. */}
<div
className={`mt-1 rounded-md p-3 flex items-center justify-between gap-3 ${
resourcesEditMode ? "bg-accent" : ""
}`}
>
<div className="flex items-center gap-2 min-w-0">
<Power className="h-4 w-4 text-blue-500 shrink-0" />
<div className="text-sm font-medium text-foreground">
{t("vmLxc.details.startOnBoot")}
</div>
</div>
<Switch
checked={pendingOnboot ?? !!vmDetails.config.onboot}
disabled={!resourcesEditMode || savingOnboot}
onCheckedChange={(v) => setPendingOnboot(v)}
className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!resourcesEditMode ? "opacity-60" : ""}`}
/>
</div>
{/* IP Addresses with proper keys */}
{selectedVM?.type === "lxc" && vmDetails?.lxc_ip_info && (
<div className="mt-4 lg:mt-6 pt-4 lg:pt-6 border-t border-border">
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<Network className="h-4 w-4" />
<div className="mt-4 lg:mt-6">
<h4 className="flex items-center gap-2 text-sm font-semibold text-foreground mb-3 uppercase tracking-wide">
<Network className="h-4 w-4 text-blue-500" />
{t("vmLxc.ipAddresses")}
</h4>
<div className="flex flex-wrap gap-2">
@@ -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")}
<Settings2 className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">{t("vmLxc.editNotes")}</span>
</Button>
)}
</div>
@@ -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")}
<X className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">{t("actions.cancel")}</span>
</Button>
<Button
size="sm"
onClick={handleSaveNotes}
disabled={savingNotes}
className="bg-blue-600 hover:bg-blue-700 text-white"
className="h-9 w-9 sm:w-auto sm:px-3 p-0 sm:p-2 bg-blue-600 hover:bg-blue-700 text-white"
aria-label={savingNotes ? t("vmLxc.savingNotes") : t("actions.save")}
>
{savingNotes ? t("vmLxc.savingNotes") : t("actions.save")}
{savingNotes ? (
<Loader2 className="h-3.5 w-3.5 sm:mr-1 animate-spin" />
) : (
<Check className="h-3.5 w-3.5 sm:mr-1" />
)}
<span className="hidden sm:inline">
{savingNotes ? t("vmLxc.savingNotes") : t("actions.save")}
</span>
</Button>
</div>
</div>
@@ -3310,12 +3700,19 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div>
)}
{showAdditionalInfo && (
<div className="mt-6 pt-6 border-t border-border space-y-6">
{/* 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. */}
<div className="mt-6 space-y-6">
{selectedVM?.type === "lxc" && vmDetails?.hardware_info && (
<div>
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<Container className="h-4 w-4" />
<h4 className="flex items-center gap-2 text-sm font-semibold text-foreground mb-3 uppercase tracking-wide">
<Container className="h-4 w-4 text-blue-500" />
{t("vmLxc.containerConfiguration")}
</h4>
<div className="space-y-4">
@@ -3393,8 +3790,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{/* Hardware Section */}
<div>
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<Settings2 className="h-4 w-4" />
<h4 className="flex items-center gap-2 text-sm font-semibold text-foreground mb-3 uppercase tracking-wide">
<Settings2 className="h-4 w-4 text-blue-500" />
{t("vmLxc.details.hardware")}
</h4>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
@@ -3597,8 +3994,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
}
return (
<div>
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<HardDrive className="h-4 w-4" />
<h4 className="flex items-center gap-2 text-sm font-semibold text-foreground mb-3 uppercase tracking-wide">
<HardDrive className="h-4 w-4 text-blue-500" />
{t("vmLxc.details.storage")}
</h4>
<div className="space-y-3">
@@ -3634,8 +4031,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{/* Network Section */}
<div>
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<Network className="h-4 w-4" />
<h4 className="flex items-center gap-2 text-sm font-semibold text-foreground mb-3 uppercase tracking-wide">
<Network className="h-4 w-4 text-blue-500" />
{t("vmLxc.details.network")}
</h4>
<div className="space-y-3">
@@ -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+$/)) && (
<div>
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<Cpu className="h-4 w-4" />
<h4 className="flex items-center gap-2 text-sm font-semibold text-foreground mb-3 uppercase tracking-wide">
<Cpu className="h-4 w-4 text-blue-500" />
{t("vmLxc.details.pciPassthrough")}
</h4>
<div className="space-y-3">
@@ -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+$/)) && (
<div>
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<Server className="h-4 w-4" />
<h4 className="flex items-center gap-2 text-sm font-semibold text-foreground mb-3 uppercase tracking-wide">
<Server className="h-4 w-4 text-blue-500" />
{t("vmLxc.details.usbDevices")}
</h4>
<div className="space-y-3">
@@ -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+$/)) && (
<div>
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<Terminal className="h-4 w-4" />
<h4 className="flex items-center gap-2 text-sm font-semibold text-foreground mb-3 uppercase tracking-wide">
<Terminal className="h-4 w-4 text-blue-500" />
{t("vmLxc.details.serialPorts")}
</h4>
<div className="space-y-3">
@@ -3819,8 +4216,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div>
</div>
)}
</div>
)}
</div>
</CardContent>
</Card>
</>
@@ -4950,16 +5346,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const merged = rt ? { ...mp, ...rt } : mp
return <MountPointCard key={mp.mp_index || mp.target} mp={merged} />
})}
{adHocMounts.length > 0 && (
<>
<div className="text-sm font-semibold text-muted-foreground pt-2 border-t border-border">
{t("vmLxc.mountedFromContainer")}
</div>
{adHocMounts.map((mp) => (
<MountPointCard key={`adhoc-${mp.target}`} mp={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.
<MountPointCard key={`adhoc-${mp.target}`} mp={mp} />
))}
</>
)}
</div>
@@ -4967,17 +5360,23 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{/* Backups Tab */}
{activeModalTab === "backups" && (
<div className="space-y-4">
<Card className="border border-border bg-card/50">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-4">
/* 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. */
<div className="h-full flex flex-col min-h-0">
<Card className="border border-border bg-card/50 flex flex-col flex-1 min-h-0">
<CardContent className="p-4 flex flex-col flex-1 min-h-0">
<div className="flex items-center justify-between mb-4 shrink-0">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-amber-500/10">
<Archive className="h-4 w-4 text-amber-500" />
</div>
<h3 className="text-sm font-semibold text-foreground">{t("vmLxc.backups.title")}</h3>
</div>
<Button
<Button
size="sm"
className="h-7 text-xs bg-amber-600/20 border border-amber-600/50 text-amber-400 hover:bg-amber-600/30 gap-1"
onClick={openBackupModal}
@@ -4991,29 +5390,29 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<span>{t("vmLxc.backups.create")}</span>
</Button>
</div>
{/* Divider */}
<div className="border-t border-border/50 mb-4" />
<div className="border-t border-border/50 mb-4 shrink-0" />
{/* Backup List */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center justify-between mb-3 shrink-0">
<span className="text-xs text-muted-foreground">{t("vmLxc.backups.available")}</span>
<Badge variant="secondary" className="text-xs h-5">{vmBackups.length}</Badge>
</div>
{loadingBackups ? (
<div className="flex items-center justify-center py-6 text-muted-foreground">
<div className="flex-1 flex items-center justify-center text-muted-foreground min-h-0">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm">{t("vmLxc.backups.loading")}</span>
</div>
) : vmBackups.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground min-h-0">
<Archive className="h-12 w-12 mb-3 opacity-30" />
<span className="text-sm">{t("vmLxc.backups.empty")}</span>
<span className="text-xs mt-1">{t("vmLxc.backups.emptyHint")}</span>
</div>
) : (
<div className="space-y-2">
<div className="space-y-2 flex-1 overflow-y-auto min-h-0 pr-1 -mr-1">
{vmBackups.map((backup, index) => (
<div
key={`backup-${backup.volid}-${index}`}
@@ -5047,10 +5446,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
grep). Loading is lazy and triggered by the tab
button's onClick. */}
{activeModalTab === "firewall" && (
<div className="space-y-4">
<Card className="border border-border bg-card/50">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-4 gap-2 flex-wrap">
/* 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. */
<div className="h-full flex flex-col min-h-0">
<Card className="border border-border bg-card/50 flex flex-col flex-1 min-h-0">
<CardContent className="p-4 flex flex-col flex-1 min-h-0">
<div className="flex items-center justify-between mb-4 gap-2 flex-wrap shrink-0">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-orange-500/10">
<Shield className="h-4 w-4 text-orange-500" />
@@ -5078,15 +5481,15 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</Button>
</div>
<div className="border-t border-border/50 mb-4" />
<div className="border-t border-border/50 mb-4 shrink-0" />
{loadingFirewallLog ? (
<div className="flex items-center justify-center py-6 text-muted-foreground">
<div className="flex-1 flex items-center justify-center text-muted-foreground min-h-0">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm">{t("vmLxc.firewall.loading")}</span>
</div>
) : !firewallEnabled ? (
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-4 text-sm">
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-4 text-sm shrink-0">
<div className="flex items-start gap-2">
<Shield className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<div className="space-y-2">
@@ -5104,7 +5507,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div>
</div>
) : firewallLogError ? (
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm">
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm shrink-0">
<div className="flex items-start gap-2">
<Shield className="h-4 w-4 text-red-500 shrink-0 mt-0.5" />
<div>
@@ -5114,14 +5517,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div>
</div>
) : displayedFirewallLogs.length === 0 ? (
<div className="text-center py-6 text-sm text-muted-foreground">
<div className="flex-1 flex flex-col items-center justify-center text-sm text-muted-foreground min-h-0">
{t("vmLxc.firewall.empty")}
<div className="text-xs mt-1">
{t("vmLxc.firewall.emptyHint")}
</div>
</div>
) : (
<div className="rounded-md border border-border bg-background/50 max-h-[480px] overflow-y-auto">
<div className="rounded-md border border-border bg-background/50 flex-1 overflow-y-auto min-h-0">
<pre className="text-[11px] font-mono leading-snug whitespace-pre-wrap break-all p-3">
{displayedFirewallLogs.map((entry, idx) => {
const text = entry.t || ""