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