New version 1.2.5

Stable release consolidating the v1.2.4 beta cycle (1.2.4.1-beta and 1.2.4.2-beta) into 1.2.5.

Highlights:

- Apps dashboard: single launcher for every LXC-registered app and user-defined Custom Web Link, with category badges, search, sort and one-click deep-links back to the guest modal.
- LXC Apps & Updates end-to-end: App tab inside every guest modal, upstream version tracking, and Easy Updates that cover OS packages, registered apps, Docker Engine and per-image updates on the same 24-hour cycle.
- Application detection catalog with 380+ tracked workloads generated live from community-scripts across seven detector methods.
- Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Slovak and Swedish (i18n scaffolding by @vaso73).
- NVIDIA multi-GPU passthrough by exact BDF so one card can be assigned to a VM while another stays operational on the host or LXC.
- Navigation reorder, Memory & Swap real memory-pressure signal, native Pushover channel, Actions API, plus wide-reaching improvements across health, hardware, network, backup and post-install.

Full release notes: see CHANGELOG.md and https://github.com/MacRimi/ProxMenux/releases
This commit is contained in:
MacRimi
2026-09-01 19:15:42 +02:00
parent f8e65cc4c4
commit 315259f5ec
54 changed files with 4343 additions and 333 deletions
+727
View File
@@ -0,0 +1,727 @@
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import useSWR from "swr"
import { ArrowUpCircle, Check, ExternalLink, Pencil, Plus, Search } from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
import { ThemeAwareLogo } from "./lxc-app-panel"
import { CustomLinkEditor, type CustomLink, type GuestOption } from "./custom-link-editor"
import { Button } from "./ui/button"
import { categoryChipStyle, useIsLightTheme } from "../lib/category-color"
// ─── Local subset of /api/vms shape ─────────────────────────────
// Kept narrow on purpose — this component only needs what feeds a
// launcher card. Full VMData / LxcAppWatch types live in
// virtual-machines.tsx.
interface AppPort {
port: number
description?: string
scheme?: "http" | "https"
web_path?: string
logo_url?: string | null
category?: string
custom_url?: string
}
interface AppWatch {
id: string
name: string | null
logo_url?: string | null
ports?: AppPort[]
installed_version: string | null
latest_version: string | null
update_available: boolean | null
managed_oci_app_id?: string | null
helper_slug?: string
}
interface DockerImageUpdate {
reference: string
display_name?: string | null
used_by?: string[]
update_available: boolean | null
}
// Locate the docker_inventory image whose lifecycle matches a given
// Web Link. The port's `description` is a user-typed label (e.g.
// "Paperless") so exact match on `used_by` (real container names
// like "paperless-webserver-1") almost never hits. Fall back through:
// 1. exact match in `used_by`
// 2. case-insensitive substring either way in `used_by`
// 3. substring in `display_name`
// 4. substring in `reference` (the full image path)
// Returns undefined when nothing matches — the caller treats that as
// "no upstream update signal for this port".
function findDockerImageForPort(
port: AppPort,
images: DockerImageUpdate[],
): DockerImageUpdate | undefined {
const desc = (port.description || "").trim().toLowerCase()
if (!desc || !images.length) return undefined
const exact = images.find((i) =>
(i.used_by || []).some((c) => c.toLowerCase() === desc),
)
if (exact) return exact
const inclUsedBy = images.find((i) =>
(i.used_by || []).some((c) => {
const cl = c.toLowerCase()
return cl.includes(desc) || desc.includes(cl)
}),
)
if (inclUsedBy) return inclUsedBy
const byDisplay = images.find((i) => {
const d = (i.display_name || "").toLowerCase()
return !!d && (d.includes(desc) || desc.includes(d))
})
if (byDisplay) return byDisplay
return images.find((i) => (i.reference || "").toLowerCase().includes(desc))
}
interface VM {
vmid: number
name: string
ip?: string
type: string
app_watches?: AppWatch[]
docker_inventory?: { images?: DockerImageUpdate[] }
}
interface LaunchLink {
key: string
// Present for LXC-registered apps and for custom links with a
// guest binding. Absent when the link is an unbound custom entry
// (e.g. an external service).
vmid: number | null
guestType: "lxc" | "qemu" | null
ctName: string
appName: string
logoUrl: string | null
weblink: string
category: string
updateAvailable: boolean
// Set for user-defined custom links so the card can offer edit
// and delete actions in edit mode.
isCustom: boolean
customId?: string
}
// ─── Helpers ─────────────────────────────────────────────────────
// Same URL construction as the Web Link row in the App tab.
// Duplicated (small) on purpose — buildWebUrl in lxc-app-panel.tsx
// is scoped to that module, and copying keeps this component free of
// hidden cross-file dependencies. A per-port `custom_url` overrides
// the ip:port composition entirely — used for apps served behind a
// reverse-proxy domain.
function buildWebUrl(ip: string | undefined, port: AppPort): string | null {
const custom = (port.custom_url || "").trim()
if (custom) return custom
const raw = (ip || "").trim().split("/")[0]
if (!raw || raw === "DHCP" || !port?.port) return null
const host = raw.includes(":") && !raw.startsWith("[") ? `[${raw}]` : raw
const scheme = port.scheme || ([443, 8443, 9443].includes(port.port) ? "https" : "http")
const path = port.web_path ? `/${port.web_path.replace(/^\/+/, "")}` : ""
return `${scheme}://${host}:${port.port}${path}`
}
type SortMode = "name" | "ct" | "category"
const SORT_STORAGE_KEY = "proxmenux-apps-sort"
const ALL_CATEGORIES = "__all__"
const fetcher = async (url: string) => fetchApi(url)
// ─── Component ───────────────────────────────────────────────────
export function AppsDashboard() {
const t = useT()
const { data: vms } = useSWR<VM[]>("/api/vms", fetcher, { refreshInterval: 5000, revalidateOnFocus: false })
// Custom links persisted in /etc/proxmenux/custom_links.json. Small
// and rarely changes, so we don't poll on an interval — mutate() is
// called explicitly after create / update / delete.
const { data: customLinks, mutate: mutateCustomLinks } = useSWR<CustomLink[]>(
"/api/apps/custom-links", fetcher, { revalidateOnFocus: false },
)
// Category presets for the "+ Add link" modal.
const { data: categoryPresets } = useSWR<string[]>(
"/api/apps/categories", fetcher, { revalidateOnFocus: false },
)
const isLightTheme = useIsLightTheme()
// Flatten VMs → LaunchLinks. One card per (app × port with weblink)
// for LXC-registered apps, plus one card per user-defined custom
// link. A custom link with a binding resolves its ctName from the
// matching guest in `vms` so renames stay in sync automatically.
const links = useMemo<LaunchLink[]>(() => {
const out: LaunchLink[] = []
const vmsList = Array.isArray(vms) ? vms : []
for (const vm of vmsList) {
const apps = vm.app_watches || []
if (!apps.length) continue
for (const app of apps) {
// Skip the synthetic entry ProxMenux inserts for managed
// OCI apps (Secure Gateway) — it has no user-assigned
// Web Link and doesn't belong in a launcher.
if (app.managed_oci_app_id) continue
// `app.update_available` refers to the app itself. For a
// Docker registration that app is the Docker engine, and its
// ports are containers running INSIDE Docker (Portainer,
// Frigate…) — each with an independent image update
// lifecycle in `vm.docker_inventory.images[]`. Propagating
// the engine-level flag to every container card would falsely
// mark Portainer/Frigate as updatable when only the engine
// needs bumping; missing the per-image flag would hide real
// Portainer/Frigate updates that ARE tracked in the App tab
// and fire notifications. Resolution: for each Docker port,
// find the image entry whose `used_by` includes the port's
// container name (== `port.description`) and use THAT image's
// update_available. Engine update stays out of the port cards
// — it belongs in the Updates tab.
const isDockerApp = app.helper_slug === "docker"
const dockerImages = vm.docker_inventory?.images || []
for (const port of app.ports || []) {
const url = buildWebUrl(vm.ip, port)
if (!url) continue
let updateAvailable = false
if (isDockerApp) {
const img = findDockerImageForPort(port, dockerImages)
updateAvailable = img?.update_available === true
} else {
updateAvailable = app.update_available === true
}
out.push({
key: `lxc-${vm.vmid}-${app.id}-${port.port}`,
vmid: vm.vmid,
guestType: "lxc",
ctName: vm.name,
appName: (port.description || app.name || vm.name || "").trim(),
logoUrl: port.logo_url || app.logo_url || null,
weblink: url,
category: (port.category || "").trim(),
updateAvailable,
isCustom: false,
})
}
}
}
// Merge user-defined custom links. Their `binding` decides how the
// CT/VM reference renders and where clicking it navigates.
const guestByVmid = new Map<number, { name: string; type: string }>()
for (const vm of vmsList) guestByVmid.set(vm.vmid, { name: vm.name, type: vm.type })
for (const link of customLinks || []) {
let ctName = ""
let vmid: number | null = null
let guestType: "lxc" | "qemu" | null = null
if (link.binding) {
const guest = guestByVmid.get(link.binding.vmid)
vmid = link.binding.vmid
guestType = link.binding.guest_type
ctName = guest?.name || ""
}
out.push({
key: `custom-${link.id}`,
vmid,
guestType,
ctName,
appName: link.name,
logoUrl: link.logo_url || null,
weblink: link.url,
category: (link.category || "").trim(),
updateAvailable: false,
isCustom: true,
customId: link.id,
})
}
return out
}, [vms, customLinks])
// Category list for the filter dropdown — built from the data so
// it always reflects reality (presets and custom-entered names).
const categoryCounts = useMemo(() => {
const map = new Map<string, number>()
for (const l of links) {
const key = l.category || t("apps.uncategorized")
map.set(key, (map.get(key) || 0) + 1)
}
return map
}, [links, t])
const sortedCategoryEntries = useMemo(
() => Array.from(categoryCounts.entries()).sort((a, b) => a[0].localeCompare(b[0])),
[categoryCounts],
)
// ─── Controls state ────────────────────────────────────────────
const [query, setQuery] = useState("")
const [currentCat, setCurrentCat] = useState<string>(ALL_CATEGORIES)
const [sortMode, setSortMode] = useState<SortMode>("name")
const [searchExpanded, setSearchExpanded] = useState(false)
const searchInputRef = useRef<HTMLInputElement | null>(null)
// Restore sort from localStorage on mount.
useEffect(() => {
try {
const saved = localStorage.getItem(SORT_STORAGE_KEY)
if (saved === "name" || saved === "ct" || saved === "category") {
setSortMode(saved)
}
} catch (_) { /* private mode / storage disabled — silent */ }
}, [])
// Persist sort choice — only this one preference survives reload;
// category filter and search reset each visit so the dashboard
// always opens showing every app.
useEffect(() => {
try { localStorage.setItem(SORT_STORAGE_KEY, sortMode) } catch (_) {}
}, [sortMode])
// Filter category resets if the user removes/renames the currently
// selected one and it disappears from the list.
useEffect(() => {
if (currentCat === ALL_CATEGORIES) return
if (!categoryCounts.has(currentCat)) setCurrentCat(ALL_CATEGORIES)
}, [currentCat, categoryCounts])
// ─── Custom link editor state ──────────────────────────────────
const [editorOpen, setEditorOpen] = useState(false)
const [editingLink, setEditingLink] = useState<CustomLink | null>(null)
const [editMode, setEditMode] = useState(false)
// Guest list feeds the binding dropdown in the editor modal.
const guestOptions = useMemo<GuestOption[]>(() => {
if (!Array.isArray(vms)) return []
return vms
.filter((v) => v.type === "lxc" || v.type === "qemu")
.map((v) => ({
vmid: v.vmid,
name: v.name,
type: v.type as "lxc" | "qemu",
}))
}, [vms])
const openNewLink = () => {
setEditingLink(null)
setEditorOpen(true)
}
const openEditForLink = (customId: string) => {
const found = (customLinks || []).find((l) => l.id === customId)
if (!found) return
setEditingLink(found)
setEditorOpen(true)
}
// ─── Filter + sort ─────────────────────────────────────────────
const shown = useMemo(() => {
const q = query.trim().toLowerCase()
const uncatKey = t("apps.uncategorized")
let filtered = links
if (currentCat !== ALL_CATEGORIES) {
filtered = filtered.filter((l) => (l.category || uncatKey) === currentCat)
}
if (q) {
filtered = filtered.filter((l) =>
l.appName.toLowerCase().includes(q) ||
(l.ctName || "").toLowerCase().includes(q) ||
(l.vmid != null && String(l.vmid).includes(q)) ||
(l.category || "").toLowerCase().includes(q)
)
}
const sorted = [...filtered]
sorted.sort((a, b) => {
if (sortMode === "name") return a.appName.localeCompare(b.appName)
if (sortMode === "ct") {
// Unbound custom links have no vmid; sort them after every
// bound entry, ordered alphabetically by app name.
if (a.vmid == null && b.vmid == null) return a.appName.localeCompare(b.appName)
if (a.vmid == null) return 1
if (b.vmid == null) return -1
return (a.vmid - b.vmid) || a.appName.localeCompare(b.appName)
}
// category — grouped alphabetically, then by app name inside
const catA = a.category || uncatKey
const catB = b.category || uncatKey
const c = catA.localeCompare(catB)
return c !== 0 ? c : a.appName.localeCompare(b.appName)
})
return sorted
}, [links, query, currentCat, sortMode, t])
// ─── Empty state ───────────────────────────────────────────────
const hasAnyData = links.length > 0 || (customLinks && customLinks.length > 0)
if (vms && !hasAnyData) {
return (
<>
<div className="text-center text-muted-foreground py-16">
<div className="text-sm">{t("apps.emptyTitle")}</div>
<div className="text-xs mt-1 opacity-80">{t("apps.emptyHint")}</div>
<Button
onClick={openNewLink}
variant="outline"
className="mt-4"
>
<Plus className="h-4 w-4 mr-1.5" />
{t("apps.customLinkAdd")}
</Button>
</div>
<CustomLinkEditor
open={editorOpen}
onOpenChange={setEditorOpen}
editing={editingLink}
guests={guestOptions}
categoryPresets={categoryPresets || []}
onSaved={() => mutateCustomLinks()}
/>
</>
)
}
// ─── Render ────────────────────────────────────────────────────
const countLabel = shown.length === 1
? t("apps.countOne")
: t("apps.countMany", { n: shown.length })
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2">
{/* Search — icon-only until tapped on narrow screens */}
<div className={`relative ${searchExpanded ? "flex-1 min-w-full sm:min-w-0 sm:flex-none sm:w-72" : "sm:flex-1 sm:min-w-40 sm:max-w-xs"}`}>
{!searchExpanded && (
<button
type="button"
onClick={() => {
setSearchExpanded(true)
requestAnimationFrame(() => searchInputRef.current?.focus())
}}
className="sm:hidden inline-flex items-center justify-center w-9 h-9 rounded-md border border-border bg-card text-muted-foreground hover:text-foreground hover:border-border/80 transition-colors"
aria-label={t("apps.searchAriaLabel")}
>
<Search className="h-4 w-4" />
</button>
)}
<div className={`relative ${searchExpanded ? "block" : "hidden sm:block"}`}>
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<input
ref={searchInputRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onBlur={() => { if (!query.trim()) setSearchExpanded(false) }}
placeholder={t("apps.searchPlaceholder")}
aria-label={t("apps.searchAriaLabel")}
className="w-full h-9 pl-8 pr-3 text-sm bg-card border border-border rounded-md text-foreground placeholder:text-muted-foreground focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring"
/>
</div>
</div>
{/* Category filter */}
<select
value={currentCat}
onChange={(e) => setCurrentCat(e.target.value)}
aria-label={t("apps.filterAriaLabel")}
className="h-9 pl-3 pr-8 text-sm bg-card border border-border rounded-md text-foreground appearance-none cursor-pointer max-w-[9.5rem] sm:max-w-none truncate focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring bg-no-repeat bg-[right_0.6rem_center]"
style={{
backgroundImage: "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>\")",
}}
>
<option value={ALL_CATEGORIES}>{t("apps.filterAll")}</option>
{sortedCategoryEntries.map(([cat, n]) => (
<option key={cat} value={cat}>{`${cat} · ${n}`}</option>
))}
</select>
{/* Sort */}
<select
value={sortMode}
onChange={(e) => setSortMode(e.target.value as SortMode)}
aria-label={t("apps.sortAriaLabel")}
className="h-9 pl-3 pr-8 text-sm bg-card border border-border rounded-md text-foreground appearance-none cursor-pointer max-w-[8rem] sm:max-w-none truncate focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring bg-no-repeat bg-[right_0.6rem_center]"
style={{
backgroundImage: "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>\")",
}}
>
<option value="name">{t("apps.sortName")}</option>
<option value="ct">{t("apps.sortId")}</option>
<option value="category">{t("apps.sortCategory")}</option>
</select>
{/* Count — desktop only. Mobile gives the horizontal room to
the + button instead so everything stays on one line. */}
<span className="ml-auto hidden sm:inline-flex items-center h-9 px-3 text-xs text-muted-foreground rounded-md bg-card border border-border font-mono tabular-nums">
{countLabel}
</span>
{/* + Add custom link. Icon-only on mobile (mirrors the search
icon-toggle pattern) so the toolbar fits in one line even
in the narrowest viewport. On desktop shows label + icon. */}
<Button
type="button"
variant="outline"
onClick={openNewLink}
className="sm:ml-2 ml-auto h-9 px-2.5 sm:px-3 flex-shrink-0"
aria-label={t("apps.customLinkAdd")}
>
<Plus className="h-4 w-4 sm:mr-1.5" />
<span className="hidden sm:inline">{t("apps.customLinkAdd")}</span>
</Button>
{/* Edit mode toggle — only shown when at least one custom link
exists, since it's the only card type that carries per-card
edit/delete actions. LXC-registered apps are edited in the
LXC App tab of their guest modal. */}
{(customLinks && customLinks.length > 0) && (
<Button
type="button"
variant="outline"
onClick={() => setEditMode((v) => !v)}
className={`h-9 px-2.5 sm:px-3 flex-shrink-0 ${editMode ? "border-blue-500/60 text-blue-400" : ""}`}
aria-pressed={editMode}
aria-label={t("apps.editModeToggle")}
>
{editMode ? <Check className="h-4 w-4 sm:mr-1.5" /> : <Pencil className="h-4 w-4 sm:mr-1.5" />}
<span className="hidden sm:inline">{editMode ? t("apps.editModeDone") : t("apps.editModeToggle")}</span>
</Button>
)}
</div>
{/* Grid — grouped headers when sorted by category */}
<CardsGrid
links={shown}
grouped={sortMode === "category"}
uncategorizedLabel={t("apps.uncategorized")}
openLabel={t("apps.openAriaLabel")}
isLightTheme={isLightTheme}
editMode={editMode}
onEditCustom={openEditForLink}
/>
<CustomLinkEditor
open={editorOpen}
onOpenChange={setEditorOpen}
editing={editingLink}
guests={guestOptions}
categoryPresets={categoryPresets || []}
onSaved={() => mutateCustomLinks()}
/>
</div>
)
}
// ─── Cards grid + card ───────────────────────────────────────────
function CardsGrid({
links,
grouped,
uncategorizedLabel,
openLabel,
isLightTheme,
editMode,
onEditCustom,
}: {
links: LaunchLink[]
grouped: boolean
uncategorizedLabel: string
openLabel: string
isLightTheme: boolean
editMode: boolean
onEditCustom: (customId: string) => void
}) {
if (!links.length) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
<div className="col-span-full text-center text-muted-foreground text-sm py-8">
{/* No results after filter/search */}
</div>
</div>
)
}
if (!grouped) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
{links.map((link) => (
<AppCard key={link.key} link={link} openLabel={openLabel} isLightTheme={isLightTheme} editMode={editMode} onEditCustom={onEditCustom} />
))}
</div>
)
}
// Group by category, insert header rows spanning the full grid width.
const groups: Array<[string, LaunchLink[]]> = []
let currentCat: string | null = null
let bucket: LaunchLink[] = []
for (const link of links) {
const cat = link.category || uncategorizedLabel
if (cat !== currentCat) {
if (bucket.length) groups.push([currentCat!, bucket])
currentCat = cat
bucket = []
}
bucket.push(link)
}
if (bucket.length) groups.push([currentCat!, bucket])
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
{groups.map(([cat, items]) => (
<div key={`grp-${cat}`} className="contents">
<h3 className="col-span-full uppercase text-xs tracking-wider text-muted-foreground font-semibold pt-3 pb-1.5 border-b border-border/60 flex items-center gap-2">
<span>{cat}</span>
<span className="font-mono tabular-nums text-[10px] px-1.5 py-0.5 rounded bg-card border border-border/60 font-normal">{items.length}</span>
</h3>
{items.map((link) => (
<AppCard key={link.key} link={link} openLabel={openLabel} isLightTheme={isLightTheme} editMode={editMode} onEditCustom={onEditCustom} />
))}
</div>
))}
</div>
)
}
// hueForCategory / categoryChipStyle / readIsLightTheme moved to
// lib/category-color.ts so the LXC App tab can render the same chip.
// Dispatch the pair of events that jumps from the Apps dashboard to
// the VMs modal on the App tab for a given CT. Two events by design:
// `changeTab` switches the outer tab (dashboard-level) and
// `openLxcAppModal` tells VirtualMachines which guest to open and on
// which inner tab to land. Both fire in the same tick.
function openLxcModalOnAppTab(vmid: number) {
window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } }))
window.dispatchEvent(new CustomEvent("openLxcAppModal", { detail: { vmid } }))
}
// Same pattern for a QEMU guest: land on the modal's Status tab
// (QEMU guests don't have the App tab). Used by custom links whose
// binding is a VM instead of an LXC.
function openVmModalOnStatusTab(vmid: number) {
window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } }))
window.dispatchEvent(new CustomEvent("openVmStatusModal", { detail: { vmid } }))
}
function AppCard({
link,
openLabel,
isLightTheme,
editMode,
onEditCustom,
}: {
link: LaunchLink
openLabel: string
isLightTheme: boolean
editMode: boolean
onEditCustom: (customId: string) => void
}) {
const t = useT()
// Navigate to the bound guest's modal on the appropriate inner tab:
// LXC → App tab (where the weblink was registered), VM → Status tab
// (VMs don't have an App tab). Unbound custom links have no CT ref
// to click, so this handler is only wired when `link.vmid` exists.
const goToBoundGuest = (e: React.MouseEvent | React.KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
if (link.vmid == null) return
if (link.guestType === "qemu") {
openVmModalOnStatusTab(link.vmid)
} else {
openLxcModalOnAppTab(link.vmid)
}
}
const goToEditor = (e: React.MouseEvent | React.KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
if (link.customId) onEditCustom(link.customId)
}
const guestPrefix = link.guestType === "qemu" ? "VM" : "CT"
const hasBinding = link.vmid != null
return (
<a
href={link.weblink}
target="_blank"
rel="noopener noreferrer"
aria-label={openLabel.replace("{name}", link.appName)}
className="group relative flex flex-col gap-2 p-3.5 bg-card border border-border rounded-xl no-underline text-foreground hover:bg-white/5 hover:border-border/80 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:translate-y-px"
>
{/* Head: logo + name (+ update icon) */}
<div className="flex items-center gap-3">
<div className="w-14 h-14 rounded-md bg-muted/40 grid place-items-center flex-shrink-0 overflow-hidden">
{link.logoUrl ? (
<ThemeAwareLogo src={link.logoUrl} className="w-9 h-9 object-contain" />
) : (
<span className="text-[10px] font-mono text-muted-foreground uppercase">{link.appName.slice(0, 2)}</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-base font-semibold text-foreground truncate leading-tight">{link.appName}</div>
</div>
{/* Edit mode on a custom card takes over the update-icon slot
with a proper edit button — custom links never carry the
update signal, so nothing is displaced. Falls back to the
update icon in every other case. */}
{editMode && link.isCustom ? (
<button
type="button"
onClick={goToEditor}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") goToEditor(e) }}
className="h-8 w-8 rounded-md border border-border bg-background hover:bg-muted flex items-center justify-center flex-shrink-0 self-start text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("apps.customLinkEditAria", { name: link.appName })}
title={t("actions.edit")}
>
<Pencil className="h-4 w-4" />
</button>
) : link.updateAvailable && (
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0 self-start mt-0.5" aria-hidden="true" />
)}
</div>
{/* Foot: weblink + CT ref + category chip */}
<div className="flex flex-col gap-1.5 mt-auto pt-2 border-t border-dashed border-border/60">
<div className="flex items-center gap-1.5 text-blue-400 text-sm font-mono truncate">
<ExternalLink className="h-3.5 w-3.5 flex-shrink-0 opacity-80" />
<span className="truncate">{link.weblink}</span>
</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
{/* Guest ref → click opens that guest's modal (LXC → App
tab, VM → Status tab). stopPropagation keeps the outer
anchor from firing at the same time. Unbound custom
links: in edit mode show the edit button here, otherwise
show nothing. */}
{hasBinding && (
<button
type="button"
onClick={goToBoundGuest}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") goToBoundGuest(e) }}
className="inline-flex items-center gap-1.5 min-w-0 rounded hover:text-blue-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring cursor-pointer"
aria-label={t("apps.openGuestAriaLabel", {
name: link.ctName || String(link.vmid),
type: guestPrefix,
id: link.vmid!,
})}
>
<span className="font-mono px-1.5 py-0.5 rounded bg-muted/40 text-muted-foreground/90 flex-shrink-0">{link.vmid}</span>
<span className="truncate min-w-0">{link.ctName || `${guestPrefix} ${link.vmid}`}</span>
</button>
)}
{link.category && (
<span
style={categoryChipStyle(link.category, isLightTheme)}
className="ml-auto px-1.5 py-0.5 border rounded text-[10px] font-medium flex-shrink-0 truncate max-w-[45%]"
title={link.category}
>
{link.category}
</span>
)}
</div>
</div>
</a>
)
}
+306
View File
@@ -0,0 +1,306 @@
"use client"
import { useEffect, useState } from "react"
import { Trash2 } from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"
import { Button } from "./ui/button"
import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
// Minimal shape we need from the /api/vms poll. Kept narrow so this
// component stays independent of the fuller VMData type used in
// virtual-machines.tsx.
export interface GuestOption {
vmid: number
name: string
type: "lxc" | "qemu"
}
export interface CustomLink {
id: string
name: string
url: string
logo_url: string
category: string
binding: { vmid: number; guest_type: "lxc" | "qemu" } | null
created_at?: number
updated_at?: number
}
export interface DraftCustomLink {
name: string
url: string
logo_url: string
category: string
bindingKey: string
}
const UNBOUND_KEY = "__none__"
function buildKey(binding: CustomLink["binding"]): string {
if (!binding) return UNBOUND_KEY
return `${binding.guest_type}:${binding.vmid}`
}
function parseKey(key: string): CustomLink["binding"] {
if (!key || key === UNBOUND_KEY) return null
const [type, vmid] = key.split(":")
if (type !== "lxc" && type !== "qemu") return null
const n = Number(vmid)
if (!Number.isFinite(n)) return null
return { guest_type: type, vmid: n }
}
export function CustomLinkEditor({
open,
onOpenChange,
editing,
guests,
categoryPresets,
onSaved,
}: {
open: boolean
onOpenChange: (v: boolean) => void
/** null = create; existing link = edit */
editing: CustomLink | null
/** VMs + LXCs from /api/vms so the user can bind a link to a guest */
guests: GuestOption[]
/** Populated from /api/apps/categories */
categoryPresets: string[]
/** Called on successful save/delete so the parent can refresh */
onSaved: () => void
}) {
const t = useT()
const [draft, setDraft] = useState<DraftCustomLink>({
name: "", url: "", logo_url: "", category: "", bindingKey: UNBOUND_KEY,
})
const [customCategoryMode, setCustomCategoryMode] = useState(false)
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Reset the draft whenever the modal opens with a new target.
useEffect(() => {
if (!open) return
setError(null)
if (editing) {
setDraft({
name: editing.name,
url: editing.url,
logo_url: editing.logo_url || "",
category: editing.category || "",
bindingKey: buildKey(editing.binding),
})
setCustomCategoryMode(
!!editing.category && !categoryPresets.includes(editing.category),
)
} else {
setDraft({ name: "", url: "", logo_url: "", category: "", bindingKey: UNBOUND_KEY })
setCustomCategoryMode(false)
}
}, [open, editing, categoryPresets])
const canSave = draft.name.trim() && draft.url.trim() && !saving
const handleSave = async () => {
setError(null)
setSaving(true)
try {
const payload = {
name: draft.name.trim(),
url: draft.url.trim(),
logo_url: draft.logo_url.trim(),
category: draft.category.trim(),
binding: parseKey(draft.bindingKey),
}
if (editing) {
await fetchApi(`/api/apps/custom-links/${editing.id}`, {
method: "PUT",
body: JSON.stringify(payload),
headers: { "Content-Type": "application/json" },
})
} else {
await fetchApi("/api/apps/custom-links", {
method: "POST",
body: JSON.stringify(payload),
headers: { "Content-Type": "application/json" },
})
}
onSaved()
onOpenChange(false)
} catch (e: any) {
setError((e && e.message) || t("apps.customLinkSaveError"))
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!editing) return
setError(null)
setDeleting(true)
try {
await fetchApi(`/api/apps/custom-links/${editing.id}`, { method: "DELETE" })
onSaved()
onOpenChange(false)
} catch (e: any) {
setError((e && e.message) || t("apps.customLinkDeleteError"))
} finally {
setDeleting(false)
}
}
// Sort guests by vmid so the dropdown is easy to scan
const sortedGuests = [...guests].sort((a, b) => a.vmid - b.vmid)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px] bg-accent [&_input]:bg-background [&_[role=combobox]]:bg-background">
<DialogHeader>
<DialogTitle>
{editing ? t("apps.customLinkEditTitle") : t("apps.customLinkNewTitle")}
</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-2">
<div className="grid gap-1.5">
<Label htmlFor="cl-name" className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkName")}</Label>
<Input
id="cl-name"
autoFocus
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
placeholder={t("apps.customLinkNamePlaceholder")}
maxLength={80}
className="text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cl-url" className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkUrl")}</Label>
<Input
id="cl-url"
type="url"
value={draft.url}
onChange={(e) => setDraft((d) => ({ ...d, url: e.target.value }))}
placeholder="https://example.com"
maxLength={512}
className="text-sm font-mono"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cl-logo" className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkLogo")}</Label>
<Input
id="cl-logo"
type="url"
value={draft.logo_url}
onChange={(e) => setDraft((d) => ({ ...d, logo_url: e.target.value }))}
placeholder={t("apps.customLinkLogoPlaceholder")}
maxLength={512}
className="text-sm font-mono"
/>
</div>
<div className="grid gap-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkCategory")}</Label>
{customCategoryMode ? (
<Input
autoFocus
value={draft.category}
onChange={(e) => setDraft((d) => ({ ...d, category: e.target.value }))}
placeholder={t("vmLxc.appEditor.portCategoryCustomPlaceholder")}
maxLength={60}
className="text-sm"
onBlur={() => { if (!draft.category.trim()) setCustomCategoryMode(false) }}
/>
) : (
<Select
value={draft.category || "__none__"}
onValueChange={(v) => {
if (v === "__add__") {
setCustomCategoryMode(true)
setDraft((d) => ({ ...d, category: "" }))
} else if (v === "__none__") {
setDraft((d) => ({ ...d, category: "" }))
} else {
setDraft((d) => ({ ...d, category: v }))
}
}}
>
<SelectTrigger className="text-sm h-9">
<SelectValue placeholder={t("vmLxc.appEditor.portCategoryPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{t("vmLxc.appEditor.portCategoryNone")}</SelectItem>
{categoryPresets.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
<SelectItem value="__add__">{t("vmLxc.appEditor.portCategoryAddNew")}</SelectItem>
</SelectContent>
</Select>
)}
</div>
<div className="grid gap-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkBinding")}</Label>
<Select
value={draft.bindingKey}
onValueChange={(v) => setDraft((d) => ({ ...d, bindingKey: v }))}
>
<SelectTrigger className="text-sm h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNBOUND_KEY}>{t("apps.customLinkBindingNone")}</SelectItem>
{sortedGuests.map((g) => (
<SelectItem key={`${g.type}:${g.vmid}`} value={`${g.type}:${g.vmid}`}>
{g.type === "qemu" ? "VM" : "CT"} {g.vmid} · {g.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t("apps.customLinkBindingHelp")}
</p>
</div>
{error && (
<div className="text-xs text-red-400 bg-red-500/10 border border-red-500/30 rounded px-3 py-2">
{error}
</div>
)}
</div>
<DialogFooter className="gap-2 sm:justify-between">
{editing ? (
<Button
variant="ghost"
onClick={handleDelete}
disabled={deleting || saving}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<Trash2 className="h-4 w-4 mr-1.5" />
{t("apps.customLinkDelete")}
</Button>
) : <div />}
<div className="flex gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving}>
{t("apps.customLinkCancel")}
</Button>
<Button
onClick={handleSave}
disabled={!canSave}
className="bg-blue-500 hover:bg-blue-600 !text-white"
>
{editing ? t("apps.customLinkSave") : t("apps.customLinkCreate")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+27 -10
View File
@@ -81,7 +81,7 @@ interface ThresholdLeaf {
interface ThresholdsTree {
cpu: { warning: ThresholdLeaf; critical: ThresholdLeaf }
memory: { warning: ThresholdLeaf; critical: ThresholdLeaf; swap_critical: ThresholdLeaf }
memory: { warning: ThresholdLeaf; critical: ThresholdLeaf; swap_high: ThresholdLeaf; available_min: ThresholdLeaf }
host_storage: { warning: ThresholdLeaf; critical: ThresholdLeaf }
lxc_rootfs: { warning: ThresholdLeaf; critical: ThresholdLeaf }
cpu_temperature: { warning: ThresholdLeaf; critical: ThresholdLeaf }
@@ -150,7 +150,8 @@ const SECTIONS: SectionDef[] = [
fields: [
{ path: ["memory", "warning"], label: "Memory warning" },
{ path: ["memory", "critical"], label: "Memory critical" },
{ path: ["memory", "swap_critical"], label: "Swap critical" },
{ path: ["memory", "swap_high"], label: "Swap high" },
{ path: ["memory", "available_min"], label: "Memory available minimum" },
],
},
// ── Heat ────────────────────────────────────────────────────────
@@ -812,20 +813,36 @@ export function HealthThresholds() {
))
) : section.id === "memory" ? (
// Memory & Swap is special: warn/crit pair for
// RAM, plus a single Swap threshold that has no
// companion (it's a "critical only" metric).
// Both use sliders so the section reads as one
// visual language end to end.
// RAM, plus the swap-pressure pair. Swap
// CRITICAL fires only when both conditions hold
// — swap file above `swap_high` AND available
// RAM below `available_min`. Rendering the two
// sliders one under the other under a shared
// header reads as "the two knobs of one signal".
<>
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1">
{t("settings.healthThresholds.ram")}
</div>
{renderThresholdRange(["memory"])}
<div className="border-t border-border/40">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1 pt-1.5">
{t("settings.healthThresholds.swapCriticalOnly")}
<div className="border-t border-border/40 pt-1.5 mt-1.5">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1">
{t("settings.healthThresholds.swapPressure")}
</div>
<p className="text-[11px] text-muted-foreground px-1 pt-1 pb-1 leading-snug">
{t("settings.healthThresholds.swapPressureHint")}
</p>
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground/80 px-1 pt-1">
{t("settings.healthThresholds.swapHighLabel")}
</div>
{renderSingleThresholdSlider(["memory", "swap_high"], "critical")}
</div>
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground/80 px-1">
{t("settings.healthThresholds.availableMinLabel")}
</div>
{renderSingleThresholdSlider(["memory", "available_min"], "warning")}
</div>
{renderSingleThresholdSlider(["memory", "swap_critical"], "critical")}
</div>
</>
) : section.fields.length === 2 &&
+181 -18
View File
@@ -34,6 +34,7 @@ import { Badge } from "./ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { fetchApi } from "../lib/api-config"
import { fetchLxcApps, getLxcAppsCached, setLxcAppsCached } from "../lib/lxc-apps-cache"
import { categoryChipStyle, useIsLightTheme } from "../lib/category-color"
import { useT } from "@/lib/i18n/provider"
// installed_via is optional now — an empty value means "register only,
@@ -50,6 +51,14 @@ interface PortEntry {
scheme?: "http" | "https"
web_path?: string
logo_url?: string
// Free-text label picked from the presets sourced by
// /api/apps/categories (built from helpers_cache.category_names) or
// typed manually. Powers the Apps dashboard filter/group.
category?: string
// Overrides ip:port composition when present — used for apps that
// sit behind a reverse-proxy domain. The Apps dashboard opens this
// URL as-is instead of `${scheme}://${ip}:${port}${path}`.
custom_url?: string
}
interface AppConfig {
@@ -102,6 +111,9 @@ interface DetectedApp {
name: string
logo_url?: string | null
default_ports?: number[]
// Categoría preset from helpers_cache.category_names[0] — used to
// auto-fill the Web Link editor when the user clicks "Register".
category?: string | null
tracking_suggestion?: TrackingSuggestion | null
}
@@ -206,6 +218,7 @@ interface Suggestions {
tracking_suggestion?: TrackingSuggestion | null
default_ports?: number[]
logo_url?: string | null
category?: string | null
extras?: DetectedApp[]
docker_web_links?: DockerWebLinkSuggestion[]
}
@@ -230,6 +243,9 @@ interface CatalogDetail {
logo_url: string | null
website: string
default_ports: number[]
// First helpers_cache.category_names value for this slug — auto-fills
// the Categoría field on each port when seeded.
category?: string | null
tracking_suggestion?: TrackingSuggestion | null
}
@@ -319,7 +335,9 @@ const HTTPS_HINT_PORTS = new Set([443, 4443, 8443, 9443])
const defaultSchemeFor = (port: number | ""): "http" | "https" =>
HTTPS_HINT_PORTS.has(Number(port)) ? "https" : "http"
function buildWebUrl(ip: string | undefined | null, port: number | "", scheme?: "http" | "https") {
function buildWebUrl(ip: string | undefined | null, port: number | "", scheme?: "http" | "https", customUrl?: string) {
const custom = (customUrl || "").trim()
if (custom) return custom
if (!ip || ip === "DHCP" || !port) return null
return `${scheme || defaultSchemeFor(port)}://${ip}:${port}`
}
@@ -339,6 +357,7 @@ function suggestPackageName(name: string) {
export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Props) {
const t = useT()
const isLightTheme = useIsLightTheme()
// Seed from `initialData` first, then fall back to the shared cache
// module. Together those two sources cover every reopen scenario
// without flashing a spinner — see lxc-apps-cache.ts for the dedup
@@ -370,6 +389,16 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
// endpoter to seed name / logo / ports / tracking_suggestion at once.
const [catalog, setCatalog] = useState<CatalogEntry[]>([])
const [pickerOpen, setPickerOpen] = useState(false)
// Preset categories exposed by /api/apps/categories (built from
// helpers_cache.category_names). Feeds the Categoría <Select> in
// the Web Link editor. Cached client-side for the session — the
// endpoint is static per app version so no revalidation needed.
const [categoryPresets, setCategoryPresets] = useState<string[]>([])
// Ports where the user picked "+ Add new" and is typing a custom
// category. Tracked by port index; cleared once they blur the
// input or come back to a preset. Also auto-inferred when the
// stored category isn't in the presets (freshly-loaded sidecar).
const [customCategoryPorts, setCustomCategoryPorts] = useState<Set<number>>(new Set())
// "Register a different app" browse panel: when the user has hidden
// some detections we surface them here with a Restore button before
@@ -489,6 +518,17 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
return () => { cancelled = true }
}, [editorOpen, catalog.length])
useEffect(() => {
if (!editorOpen || categoryPresets.length > 0) return
let cancelled = false
fetchApi<string[]>("/api/apps/categories")
.then((data: string[]) => {
if (!cancelled && Array.isArray(data)) setCategoryPresets(data)
})
.catch(() => { /* non-fatal — datalist stays empty, user still types freely */ })
return () => { cancelled = true }
}, [editorOpen, categoryPresets.length])
// Derived state — computed here BEFORE any conditional early
// return so React sees the same hook order on every render.
// Rules of Hooks: `useMemo` after an `if (loading) return …`
@@ -508,6 +548,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
name: suggestions.name_suggestion,
logo_url: suggestions.logo_url,
default_ports: suggestions.default_ports,
category: suggestions.category,
tracking_suggestion: suggestions.tracking_suggestion,
})
}
@@ -659,6 +700,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
port,
scheme: defaultSchemeFor(port),
web_path: s?.web_path_hint || "",
// Auto-fill Categoría from helpers_cache.category_names[0]
// when the catalog entry carries one. User can still change
// it in the editor before saving.
...(p.category ? { category: p.category } : {}),
}))
}
if (opts.withTracking && p.tracking_suggestion) {
@@ -1081,12 +1126,28 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
logo_url: link.logo_url || "",
}
const last = draft.ports[draft.ports.length - 1]
if (last && last.port === "" && !last.description) {
const ports = [...draft.ports]
ports[ports.length - 1] = entry
setField({ ports })
} else {
const indexAfterAdd = (last && last.port === "" && !last.description)
? draft.ports.length - 1
: draft.ports.length
if (indexAfterAdd === draft.ports.length) {
setField({ ports: [...draft.ports, entry] })
} else {
const ports = [...draft.ports]
ports[indexAfterAdd] = entry
setField({ ports })
}
// Ask the backend whether this service_name has a known catalog
// category and, if so, patch the just-inserted port so the user
// finds it pre-selected instead of having to open the dropdown.
// Non-blocking — the port is already visible either way.
const q = (link.service_name || "").trim()
if (q) {
fetchApi<{ category: string | null }>(`/api/apps/suggest_category?name=${encodeURIComponent(q)}`)
.then((r) => {
if (!r?.category) return
setPort(indexAfterAdd, { category: r.category })
})
.catch(() => { /* non-fatal — user can pick manually */ })
}
}
const removePort = (i: number) =>
@@ -1323,6 +1384,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
</div>
)}
<div className="space-y-3">
{draft.ports.map((entry, i) => (
<div
@@ -1373,9 +1435,79 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
onChange={(e) => setPort(i, { logo_url: e.target.value })}
placeholder={t("vmLxc.appEditor.portLogoLabel")}
maxLength={512}
className="col-start-1 col-end-4 text-xs font-mono h-8 opacity-70 focus:opacity-100"
className="col-start-1 col-end-4 text-xs font-mono h-8"
type="url"
/>
{/* Category + custom URL — stacked on mobile so
each field gets full width; side-by-side on
tablet+ (≥sm) to save vertical space. Both
span cols 1-3 via the outer wrapper. */}
<div className="col-start-1 col-end-4 flex flex-col sm:flex-row gap-2">
{/* Per-link category — <Select> for presets +
"+ Add new" option that flips to a text
input. Matches the "Installed via" Select
look elsewhere in this editor. */}
<div className="flex-1 min-w-0">
{(customCategoryPorts.has(i) ||
(entry.category && !categoryPresets.includes(entry.category))) ? (
<Input
autoFocus={customCategoryPorts.has(i)}
value={entry.category || ""}
onChange={(e) => setPort(i, { category: e.target.value })}
onBlur={() => {
if (!(entry.category || "").trim()) {
setCustomCategoryPorts((s) => { const n = new Set(s); n.delete(i); return n })
}
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
setPort(i, { category: "" })
setCustomCategoryPorts((s) => { const n = new Set(s); n.delete(i); return n })
}
}}
placeholder={t("vmLxc.appEditor.portCategoryCustomPlaceholder")}
maxLength={60}
className="text-xs h-8"
/>
) : (
<Select
value={entry.category || "__none__"}
onValueChange={(v) => {
if (v === "__add__") {
setCustomCategoryPorts((s) => new Set(s).add(i))
setPort(i, { category: "" })
} else if (v === "__none__") {
setPort(i, { category: "" })
} else {
setPort(i, { category: v })
}
}}
>
<SelectTrigger className="text-xs h-8">
<SelectValue placeholder={t("vmLxc.appEditor.portCategoryPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{t("vmLxc.appEditor.portCategoryNone")}</SelectItem>
{categoryPresets.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
<SelectItem value="__add__">{t("vmLxc.appEditor.portCategoryAddNew")}</SelectItem>
</SelectContent>
</Select>
)}
</div>
{/* Per-link custom URL — takes precedence over
ip:port when the app lives behind a reverse
proxy on a public domain. */}
<Input
value={entry.custom_url || ""}
onChange={(e) => setPort(i, { custom_url: e.target.value })}
placeholder={t("vmLxc.appEditor.portCustomUrlPlaceholder")}
maxLength={512}
className="flex-1 min-w-0 text-xs font-mono h-8"
type="url"
/>
</div>
</div>
))}
</div>
@@ -1434,7 +1566,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
<SelectItem value="binary">{t("vmLxc.appEditor.binaryVersionHint")}</SelectItem>
<SelectItem value="file">{t("vmLxc.appEditor.methodFile")}</SelectItem>
<SelectItem value="python_dist">{t("vmLxc.appEditor.methodPython")}</SelectItem>
<SelectItem value="docker_label">{t("vmLxc.appEditor.methodDockerLabel")}</SelectItem>
<SelectItem value="docker_label" disabled>{t("vmLxc.appEditor.methodDockerLabel")}</SelectItem>
<SelectItem value="docker_exec">{t("vmLxc.appEditor.methodDockerExec")}</SelectItem>
<SelectItem value="command">{t("vmLxc.appEditor.methodCommand")}</SelectItem>
<SelectItem value="manual">{t("vmLxc.appEditor.methodManual")}</SelectItem>
@@ -2281,7 +2413,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
{app.ports && app.ports.length > 0 && (
<div className="mb-3 space-y-4">
{app.ports.map((p) => {
const url = buildWebUrl(ctIp, p.port, p.scheme)
const url = buildWebUrl(ctIp, p.port, p.scheme, p.custom_url)
if (!url) return null
const label = p.description || app.name
return (
@@ -2292,18 +2424,34 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
className="h-14 w-14 flex-shrink-0 rounded-md object-contain"
/>
)}
<div className="min-w-0 flex flex-col">
<div className="min-w-0 flex flex-col gap-1 flex-1">
<span className="text-sm font-medium text-foreground truncate">{label}</span>
<div className="flex items-center gap-2 min-w-0">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300 inline-flex items-center gap-1.5 min-w-0"
className="text-blue-400 hover:text-blue-300 inline-flex items-center gap-1.5 min-w-0 flex-1"
title={url}
>
<ExternalLink className="h-3.5 w-3.5 flex-shrink-0" />
<span className="font-mono text-sm truncate">{url}</span>
</a>
{/* Category chip — same OKLCH deterministic
colour as the Apps dashboard. Anchored
right end of the weblink row so the URL
gets `flex-1` (truncates when long)
while the chip keeps its full width. */}
{p.category && (
<span
style={categoryChipStyle(p.category, isLightTheme)}
className="flex-shrink-0 px-1.5 py-0.5 border rounded text-[10px] font-medium truncate max-w-[40%]"
title={p.category}
>
{p.category}
</span>
)}
</div>
</div>
</div>
)
@@ -2402,40 +2550,55 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
detections, so the user gets one-click Restore before hand-
typing a custom app. */}
{apps.length > 0 && (
<div className="flex flex-col items-stretch gap-2 max-w-xs mx-auto sm:flex-row sm:flex-wrap sm:justify-end sm:items-center sm:max-w-none sm:mx-0">
// Mobile: three buttons in one row, aligned right. Order is
// Search → Register → Edit (Edit rightmost, matches desktop).
// All three share the same width — a min-w that fits the
// widest translated label of the Edit button ("Bearbeiten" in
// DE, 10 chars) so the icon-only Search and Register buttons
// line up as neat equal squares next to the labelled Edit.
// Desktop: no min-width — each button auto-sizes to its text.
<div className="flex flex-row flex-wrap justify-end items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={searchInstalledApplications}
disabled={searchingApplications || editMode}
className="w-full sm:w-auto order-2 sm:order-1"
className="min-w-[7rem] sm:min-w-0 px-2.5 sm:px-3"
aria-label={searchingApplications
? t("vmLxc.appEditor.searchingApplications")
: t("vmLxc.appEditor.searchApplications")}
>
{searchingApplications
? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
: <Search className="h-4 w-4 mr-1.5" />}
? <Loader2 className="h-4 w-4 sm:mr-1.5 animate-spin" />
: <Search className="h-4 w-4 sm:mr-1.5" />}
<span className="hidden sm:inline">
{searchingApplications
? t("vmLxc.appEditor.searchingApplications")
: t("vmLxc.appEditor.searchApplications")}
</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={openBrowseOrEditor}
disabled={editMode}
className="w-full sm:w-auto order-3 sm:order-2"
className="min-w-[7rem] sm:min-w-0 px-2.5 sm:px-3"
aria-label={t("vmLxc.appEditor.addAnotherApplication")}
>
<PlusCircle className="h-4 w-4 mr-1.5" />
<PlusCircle className="h-4 w-4 sm:mr-1.5" />
<span className="hidden sm:inline">
{t("vmLxc.appEditor.addAnotherApplication")}
{hiddenDetections.length > 0 && (
<span className="ml-2 text-[10px] opacity-70">
· {t("vmLxc.appEditor.hiddenSuffix", { count: hiddenDetections.length })}
</span>
)}
</span>
</Button>
<button
type="button"
onClick={() => setEditMode((v) => !v)}
className="h-9 px-3 text-sm rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center justify-center gap-1.5 w-full sm:w-auto order-1 sm:order-3"
className="h-9 px-3 text-sm rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center justify-center gap-1.5 min-w-[7rem] sm:min-w-0"
>
{editMode ? (
<>
+373
View File
@@ -0,0 +1,373 @@
"use client"
import React, { useState, useRef, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import {
Boxes,
Check,
ChevronDown,
DatabaseBackup,
GripVertical,
Grid3x3,
LayoutDashboard,
Layers,
RotateCcw,
Server,
Settings2,
Terminal,
} from "lucide-react"
import { useT } from "../lib/i18n/provider"
import {
DEFAULT_TAB_ORDER,
useTabOrder,
type TabId,
} from "../lib/tab-order"
// Long-press activation on touch — matches the delay platform UIs
// use so the user can still scroll a page that happens to start on a
// tab handle.
const TOUCH_ACTIVATION_MS = 250
const TOUCH_TOLERANCE_PX = 5
type TabMeta = {
id: TabId
Icon: React.ComponentType<{ className?: string }>
labelKey: string
hasDropdown: boolean
}
const META: Record<TabId, TabMeta> = {
overview: { id: "overview", Icon: LayoutDashboard, labelKey: "navigation.overview", hasDropdown: false },
apps: { id: "apps", Icon: Grid3x3, labelKey: "navigation.apps", hasDropdown: false },
vms: { id: "vms", Icon: Boxes, labelKey: "navigation.virtualMachines", hasDropdown: false },
node: { id: "node", Icon: Server, labelKey: "navigation.node", hasDropdown: true },
backup: { id: "backup", Icon: DatabaseBackup, labelKey: "navigation.backup", hasDropdown: false },
terminal: { id: "terminal", Icon: Terminal, labelKey: "navigation.terminal", hasDropdown: false },
admin: { id: "admin", Icon: Settings2, labelKey: "navigation.admin", hasDropdown: true },
}
// Sortable list built on Pointer Events. Mouse activates on move
// (2px threshold to survive accidental clicks); touch activates on
// long-press after 250ms unless the finger moves past 5px, which is
// treated as a scroll intent and the drag is cancelled.
export function NavTabOrderCard() {
const t = useT()
const { order: savedOrder, setOrder, reset, isCustom } = useTabOrder()
const [editMode, setEditMode] = useState(false)
const [draft, setDraft] = useState<TabId[]>(savedOrder)
const [saved, setSaved] = useState(false)
useEffect(() => {
if (!editMode) setDraft(savedOrder)
}, [savedOrder, editMode])
const handleCancel = () => {
setDraft(savedOrder)
setEditMode(false)
}
const handleSave = () => {
setOrder(draft)
setEditMode(false)
setSaved(true)
window.setTimeout(() => setSaved(false), 2000)
}
const handleReset = () => {
setDraft([...DEFAULT_TAB_ORDER])
}
const draftIsChanged =
draft.length !== savedOrder.length ||
draft.some((id, i) => id !== savedOrder[i])
const draftIsCustom =
draft.length !== DEFAULT_TAB_ORDER.length ||
draft.some((id, i) => id !== DEFAULT_TAB_ORDER[i])
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Layers className="h-5 w-5 text-blue-500" />
<CardTitle>{t("settings.navOrder.title")}</CardTitle>
</div>
<div className="flex items-center gap-2">
{saved && (
<span className="flex items-center gap-1 text-xs text-green-500">
<Check className="h-3.5 w-3.5" />
{t("status.saved")}
</span>
)}
{editMode ? (
<>
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
onClick={handleCancel}
>
{t("actions.cancel")}
</button>
<button
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
onClick={handleSave}
disabled={!draftIsChanged}
>
<Check className="h-3 w-3" />
{t("actions.save")}
</button>
</>
) : (
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
onClick={() => setEditMode(true)}
>
<Settings2 className="h-3 w-3" />
{t("actions.edit")}
</button>
)}
</div>
</div>
<CardDescription>{t("settings.navOrder.description")}</CardDescription>
</CardHeader>
<CardContent
className={
editMode
? "bg-accent"
: undefined
}
>
<SortableList
items={editMode ? draft : savedOrder}
onReorder={setDraft}
editable={editMode}
t={t}
/>
{editMode && (
<div className="mt-4 flex items-center justify-between">
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5 text-muted-foreground disabled:opacity-50 disabled:pointer-events-none"
onClick={handleReset}
disabled={!draftIsCustom}
>
<RotateCcw className="h-3 w-3" />
{t("settings.navOrder.reset")}
</button>
<span className="text-[11px] text-muted-foreground">
{t("settings.navOrder.hint")}
</span>
</div>
)}
{!editMode && isCustom && (
<div className="mt-3 text-[11px] text-muted-foreground">
{t("settings.navOrder.customActive")}
</div>
)}
</CardContent>
</Card>
)
}
// -----------------------------------------------------------------
// SortableList
// -----------------------------------------------------------------
type DragState = {
fromIdx: number
pointerY: number
offsetY: number
itemHeight: number
} | null
function SortableList({
items,
onReorder,
editable,
t,
}: {
items: TabId[]
onReorder: (next: TabId[]) => void
editable: boolean
t: (k: string) => string
}) {
const [drag, setDrag] = useState<DragState>(null)
const [hoverIdx, setHoverIdx] = useState<number | null>(null)
const listRef = useRef<HTMLUListElement | null>(null)
const commit = (from: number, to: number) => {
if (from === to || to < 0 || to >= items.length) return
const next = items.slice()
const [moved] = next.splice(from, 1)
next.splice(to, 0, moved)
onReorder(next)
}
const handleDragMove = (clientY: number) => {
if (!drag || !listRef.current) return
const rows = Array.from(listRef.current.querySelectorAll<HTMLLIElement>("li[data-row]"))
let target = drag.fromIdx
for (let i = 0; i < rows.length; i++) {
const rect = rows[i].getBoundingClientRect()
const mid = rect.top + rect.height / 2
if (clientY < mid) { target = i; break }
target = i
}
setHoverIdx(target)
setDrag((d) => (d ? { ...d, pointerY: clientY } : d))
}
const handleDragEnd = () => {
if (drag && hoverIdx !== null) commit(drag.fromIdx, hoverIdx)
setDrag(null)
setHoverIdx(null)
}
return (
<ul
ref={listRef}
className="flex flex-col gap-1.5 select-none"
onPointerMove={(e) => { if (drag) handleDragMove(e.clientY) }}
onPointerUp={handleDragEnd}
onPointerCancel={handleDragEnd}
>
{items.map((id, idx) => {
const isDragging = drag?.fromIdx === idx
const meta = META[id]
return (
<SortableRow
key={id}
id={id}
idx={idx}
meta={meta}
label={t(meta.labelKey)}
editable={editable}
isDragging={!!isDragging}
hoverIdx={hoverIdx}
drag={drag}
onDragStart={(fromIdx, pointerY, offsetY, itemHeight) => {
setDrag({ fromIdx, pointerY, offsetY, itemHeight })
setHoverIdx(fromIdx)
}}
/>
)
})}
</ul>
)
}
function SortableRow({
id,
idx,
meta,
label,
editable,
isDragging,
hoverIdx,
drag,
onDragStart,
}: {
id: TabId
idx: number
meta: TabMeta
label: string
editable: boolean
isDragging: boolean
hoverIdx: number | null
drag: DragState
onDragStart: (fromIdx: number, pointerY: number, offsetY: number, itemHeight: number) => void
}) {
const rowRef = useRef<HTMLLIElement | null>(null)
const longPressTimer = useRef<number | null>(null)
const pointerStart = useRef<{ x: number; y: number } | null>(null)
const activatedRef = useRef(false)
const cancelLongPress = () => {
if (longPressTimer.current !== null) {
window.clearTimeout(longPressTimer.current)
longPressTimer.current = null
}
}
const beginDrag = (clientY: number) => {
if (!rowRef.current) return
const rect = rowRef.current.getBoundingClientRect()
activatedRef.current = true
onDragStart(idx, clientY, clientY - rect.top, rect.height)
}
const handlePointerDown = (e: React.PointerEvent<HTMLLIElement>) => {
if (!editable) return
if (e.button !== undefined && e.button !== 0) return
pointerStart.current = { x: e.clientX, y: e.clientY }
activatedRef.current = false
if (e.pointerType === "touch") {
longPressTimer.current = window.setTimeout(() => {
longPressTimer.current = null
beginDrag(e.clientY)
}, TOUCH_ACTIVATION_MS)
} else {
// Mouse/pen: activate immediately on press.
beginDrag(e.clientY)
}
;(e.currentTarget as HTMLLIElement).setPointerCapture(e.pointerId)
}
const handlePointerMove = (e: React.PointerEvent<HTMLLIElement>) => {
if (!editable) return
if (!activatedRef.current && pointerStart.current) {
const dx = e.clientX - pointerStart.current.x
const dy = e.clientY - pointerStart.current.y
if (Math.hypot(dx, dy) > TOUCH_TOLERANCE_PX) {
// Movement before activation → scroll intent on touch. Cancel
// the pending long-press so the page can scroll normally.
cancelLongPress()
}
}
}
const handlePointerUp = () => {
cancelLongPress()
pointerStart.current = null
}
// Simple drag visual: ghost the row being dragged, show a blue
// drop-line above or below the row the pointer is currently over.
// No item swap animation — commit on release.
const isTarget = drag && hoverIdx === idx && drag.fromIdx !== idx
const dropAbove = isTarget && drag!.fromIdx > idx
const dropBelow = isTarget && drag!.fromIdx < idx
const RowIcon = meta.Icon
return (
<li
ref={rowRef}
data-row
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
className={[
"relative rounded-md border transition-colors",
editable
? "bg-background border-border cursor-grab active:cursor-grabbing touch-none"
: "bg-card border-border/60",
isDragging ? "opacity-40" : "",
].join(" ")}
>
{dropAbove && <div className="absolute -top-[3px] left-2 right-2 h-[2px] bg-blue-500 rounded-full pointer-events-none" />}
{dropBelow && <div className="absolute -bottom-[3px] left-2 right-2 h-[2px] bg-blue-500 rounded-full pointer-events-none" />}
<div className="flex items-center gap-3 p-2.5">
<GripVertical
className={
"h-4 w-4 flex-shrink-0 " +
(editable ? "text-muted-foreground" : "text-muted-foreground/40")
}
/>
<RowIcon className="h-4 w-4 flex-shrink-0 text-blue-500" />
<div className="flex-1 min-w-0 flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{label}</span>
{meta.hasDropdown && (
<ChevronDown className="h-3 w-3 text-muted-foreground/70" />
)}
</div>
</div>
</li>
)
}
+135 -118
View File
@@ -1,6 +1,7 @@
"use client"
import { useState, useEffect, useMemo, useCallback } from "react"
import React, { useState, useEffect, useMemo, useCallback } from "react"
import useSWR from "swr"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"
@@ -8,6 +9,7 @@ import { SystemOverview } from "./system-overview"
import { StorageOverview } from "./storage-overview"
import { NetworkMetrics } from "./network-metrics"
import { VirtualMachines } from "./virtual-machines"
import { AppsDashboard } from "./apps-dashboard"
import Hardware from "./hardware"
import { SystemLogs } from "./system-logs"
import { Settings } from "./settings"
@@ -32,6 +34,7 @@ import {
HardDrive,
NetworkIcon,
Boxes,
Grid3x3,
Cpu,
ScrollText,
SettingsIcon,
@@ -53,6 +56,7 @@ import {
} from "./ui/dropdown-menu"
import { useT } from "../lib/i18n/provider"
import { APP_VERSION } from "../lib/version"
import { useTabOrder, firstActualTab, type TabId } from "../lib/tab-order"
interface SystemStatus {
status: "healthy" | "warning" | "critical"
@@ -81,8 +85,19 @@ interface FlaskSystemInfo {
}
}
// Prefetch on dashboard mount: SWR caches by key across all
// `useSWR` calls, so firing these here means the Apps tab finds the
// data already resolved when it opens. Without this, the tab pays a
// visible roundtrip on first render because VirtualMachines has been
// warming /api/vms since page load but nobody was warming the custom
// links endpoint.
const _dashboardPrefetchFetcher = (url: string) => fetchApi(url)
export function ProxmoxDashboard() {
const t = useT()
useSWR("/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
useSWR("/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
const { order: tabOrder } = useTabOrder()
const [systemStatus, setSystemStatus] = useState<SystemStatus>({
status: "healthy",
uptime: "Loading...",
@@ -95,6 +110,13 @@ export function ProxmoxDashboard() {
const [componentKey, setComponentKey] = useState(0)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [activeTab, setActiveTab] = useState("overview")
// On first mount, land on whatever the user's custom order says is
// the leading tab. localStorage isn't available during SSR so this
// runs post-hydration; the tiny flash is acceptable and matches the
// pattern next-themes uses for the same reason.
useEffect(() => {
setActiveTab(firstActualTab())
}, [])
const [infoCount, setInfoCount] = useState(0)
const [updateAvailable, setUpdateAvailable] = useState(false)
const [showNavigation, setShowNavigation] = useState(true)
@@ -368,6 +390,7 @@ export function ProxmoxDashboard() {
const getActiveTabLabel = () => {
switch (activeTab) {
case "overview": return t("navigation.overview")
case "apps": return t("navigation.apps")
case "vms": return t("navigation.virtualMachines")
case "storage": return t("navigation.storage")
case "network": return t("navigation.network")
@@ -621,75 +644,62 @@ export function ProxmoxDashboard() {
: "text-muted-foreground hover:text-foreground rounded-sm"
}`
// Data-driven TabsList: iterate over the user's saved
// top-level order. Each slot is either a direct tab or a
// dropdown group (Node/Admin). The internal items of a
// dropdown are never reordered by the user — a grouped
// slot moves as a unit.
const renderDirect = (
value: string,
Icon: React.ComponentType<{ className?: string }>,
label: string,
) => (
<TabsTrigger key={value} value={value} className={triggerActiveClass}>
<Icon className="mr-2 h-4 w-4" />
{label}
</TabsTrigger>
)
const renderDropdown = (
key: string,
items: { value: string; label: string; Icon: React.ComponentType<{ className?: string }> }[],
active: boolean,
TriggerIcon: React.ComponentType<{ className?: string }>,
triggerLabel: string,
) => (
<DropdownMenu key={key}>
<DropdownMenuTrigger className={dropdownBtnClass(active)}>
<TriggerIcon className="mr-2 h-4 w-4" />
{triggerLabel}
<ChevronDown className="ml-1.5 h-3 w-3 opacity-70" />
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[180px]">
{items.map(({ value, label, Icon }) => (
<DropdownMenuItem
key={value}
onClick={() => setActiveTab(value)}
className={activeTab === value ? "bg-blue-500/10 text-blue-500" : ""}
>
<Icon className="mr-2 h-4 w-4" />
{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
const renderTop = (id: TabId) => {
switch (id) {
case "overview": return renderDirect("overview", LayoutDashboard, t("navigation.overview"))
case "apps": return renderDirect("apps", Grid3x3, t("navigation.apps"))
case "vms": return renderDirect("vms", Boxes, t("navigation.virtualMachines"))
case "backup": return renderDirect("backup", DatabaseBackup, t("navigation.backup"))
case "terminal": return renderDirect("terminal", Terminal, t("navigation.terminal"))
case "node": return renderDropdown("node", NODE_ITEMS, isNodeActive, NodeTriggerIcon, NodeTriggerLabel)
case "admin": return renderDropdown("admin", ADMIN_ITEMS, isAdminActive, AdminTriggerIcon, AdminTriggerLabel)
}
}
return (
<TabsList className="hidden lg:grid w-full grid-cols-6 bg-card border border-border">
{/* Direct: Overview */}
<TabsTrigger value="overview" className={triggerActiveClass}>
<LayoutDashboard className="mr-2 h-4 w-4" />
{t("navigation.overview")}
</TabsTrigger>
{/* Direct: VMs & LXCs — first-class because Proxmox IS
a hypervisor; workloads belong at top level. */}
<TabsTrigger value="vms" className={triggerActiveClass}>
<Boxes className="mr-2 h-4 w-4" />
{t("navigation.virtualMachines")}
</TabsTrigger>
{/* Dropdown: Node (Storage / Network / Hardware) */}
<DropdownMenu>
<DropdownMenuTrigger className={dropdownBtnClass(isNodeActive)}>
<NodeTriggerIcon className="mr-2 h-4 w-4" />
{NodeTriggerLabel}
<ChevronDown className="ml-1.5 h-3 w-3 opacity-70" />
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[180px]">
{NODE_ITEMS.map(({ value, label, Icon }) => (
<DropdownMenuItem
key={value}
onClick={() => setActiveTab(value)}
className={activeTab === value ? "bg-blue-500/10 text-blue-500" : ""}
>
<Icon className="mr-2 h-4 w-4" />
{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Direct: Backup (today: Host Backup only). When VM/LXC
backup ships this becomes a dropdown. */}
<TabsTrigger value="backup" className={triggerActiveClass}>
<DatabaseBackup className="mr-2 h-4 w-4" />
{t("navigation.backup")}
</TabsTrigger>
{/* Direct: Terminal */}
<TabsTrigger value="terminal" className={triggerActiveClass}>
<Terminal className="mr-2 h-4 w-4" />
{t("navigation.terminal")}
</TabsTrigger>
{/* Dropdown: Admin (System Logs / Security / Settings / About) */}
<DropdownMenu>
<DropdownMenuTrigger className={dropdownBtnClass(isAdminActive)}>
<AdminTriggerIcon className="mr-2 h-4 w-4" />
{AdminTriggerLabel}
<ChevronDown className="ml-1.5 h-3 w-3 opacity-70" />
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[180px]">
{ADMIN_ITEMS.map(({ value, label, Icon }) => (
<DropdownMenuItem
key={value}
onClick={() => setActiveTab(value)}
className={activeTab === value ? "bg-blue-500/10 text-blue-500" : ""}
>
<Icon className="mr-2 h-4 w-4" />
{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<TabsList className="hidden lg:grid w-full grid-cols-7 bg-card border border-border">
{tabOrder.map(renderTop)}
</TabsList>
)
})()}
@@ -724,56 +734,52 @@ export function ProxmoxDashboard() {
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
: ""
}`
// Mobile sheet is a flat list (no section headers).
// The desktop layout uses dropdowns to express the
// Node/Admin grouping; here we just enumerate items
// in the same visual order.
// Mobile sheet honours the same user-defined
// top-level order as the desktop TabsList. Grouped
// slots (Node/Admin) expand their child items
// inline right after their position — the group
// still moves as a unit, but children stay grouped.
const btn = (
value: string,
Icon: React.ComponentType<{ className?: string }>,
label: string,
) => (
<Button
key={value}
variant="ghost"
onClick={() => select(value)}
className={itemClass(activeTab === value)}
>
<Icon className="h-5 w-5" />
<span>{label}</span>
</Button>
)
return (
<div className="flex flex-col gap-1 mt-4">
<Button variant="ghost" onClick={() => select("overview")} className={itemClass(activeTab === "overview")}>
<LayoutDashboard className="h-5 w-5" />
<span>{t("navigation.overview")}</span>
</Button>
<Button variant="ghost" onClick={() => select("vms")} className={itemClass(activeTab === "vms")}>
<Boxes className="h-5 w-5" />
<span>{t("navigation.virtualMachines")}</span>
</Button>
<Button variant="ghost" onClick={() => select("storage")} className={itemClass(activeTab === "storage")}>
<HardDrive className="h-5 w-5" />
<span>{t("navigation.storage")}</span>
</Button>
<Button variant="ghost" onClick={() => select("network")} className={itemClass(activeTab === "network")}>
<NetworkIcon className="h-5 w-5" />
<span>{t("navigation.network")}</span>
</Button>
<Button variant="ghost" onClick={() => select("hardware")} className={itemClass(activeTab === "hardware")}>
<Cpu className="h-5 w-5" />
<span>{t("navigation.hardware")}</span>
</Button>
<Button variant="ghost" onClick={() => select("backup")} className={itemClass(activeTab === "backup")}>
<DatabaseBackup className="h-5 w-5" />
<span>{t("navigation.backup")}</span>
</Button>
<Button variant="ghost" onClick={() => select("terminal")} className={itemClass(activeTab === "terminal")}>
<Terminal className="h-5 w-5" />
<span>{t("navigation.terminal")}</span>
</Button>
<Button variant="ghost" onClick={() => select("logs")} className={itemClass(activeTab === "logs")}>
<ScrollText className="h-5 w-5" />
<span>{t("navigation.systemLogs")}</span>
</Button>
<Button variant="ghost" onClick={() => select("security")} className={itemClass(activeTab === "security")}>
<ShieldCheck className="h-5 w-5" />
<span>{t("navigation.security")}</span>
</Button>
<Button variant="ghost" onClick={() => select("settings")} className={itemClass(activeTab === "settings")}>
<SettingsIcon className="h-5 w-5" />
<span>{t("navigation.settings")}</span>
</Button>
<Button variant="ghost" onClick={() => select("about")} className={itemClass(activeTab === "about")}>
<Info className="h-5 w-5" />
<span>{t("navigation.about")}</span>
</Button>
{tabOrder.map((id): React.ReactNode => {
switch (id) {
case "overview": return btn("overview", LayoutDashboard, t("navigation.overview"))
case "apps": return btn("apps", Grid3x3, t("navigation.apps"))
case "vms": return btn("vms", Boxes, t("navigation.virtualMachines"))
case "backup": return btn("backup", DatabaseBackup, t("navigation.backup"))
case "terminal": return btn("terminal", Terminal, t("navigation.terminal"))
case "node": return (
<React.Fragment key="node">
{btn("storage", HardDrive, t("navigation.storage"))}
{btn("network", NetworkIcon, t("navigation.network"))}
{btn("hardware", Cpu, t("navigation.hardware"))}
</React.Fragment>
)
case "admin": return (
<React.Fragment key="admin">
{btn("logs", ScrollText, t("navigation.systemLogs"))}
{btn("security", ShieldCheck, t("navigation.security"))}
{btn("settings", SettingsIcon, t("navigation.settings"))}
{btn("about", Info, t("navigation.about"))}
</React.Fragment>
)
}
})}
</div>
)
})()}
@@ -784,7 +790,14 @@ export function ProxmoxDashboard() {
</div>
<div className="container mx-auto px-4 md:px-6 py-4 md:py-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4 md:space-y-6">
{/* No `space-y-*` here: only one TabsContent is visible at a
time, but Overview stays force-mounted (hidden) as the
first child, so every OTHER active tab used to inherit an
extra top margin from the space-y utility — pushing the
page content further from the nav than on Overview.
Vertical spacing INSIDE each tab lives on its own
TabsContent's `space-y-*`. */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-0">
{/* forceMount so SystemOverview mounts at dashboard load and
never gets torn down when the user visits another tab.
Without this, every return to Overview re-fires ~7 fetches
@@ -797,6 +810,10 @@ export function ProxmoxDashboard() {
<SystemOverview key={`overview-${componentKey}`} />
</TabsContent>
<TabsContent value="apps" className="space-y-4 md:space-y-6 mt-0">
<AppsDashboard key={`apps-${componentKey}`} />
</TabsContent>
<TabsContent value="storage" className="space-y-4 md:space-y-6 mt-0">
<StorageOverview key={`storage-${componentKey}`} />
</TabsContent>
+49 -9
View File
@@ -18,6 +18,41 @@ interface ReleaseNote {
}
export const CHANGELOG: Record<string, ReleaseNote> = {
"1.2.5": {
date: "September 1, 2026",
changes: {
added: [
"New top-level Apps dashboard — a single launcher for every Web Link across the node. LXC-registered apps and user-defined Custom Web Links share the same grid with category badges, search, sort by name/id/category, and one-click deep-links back to the guest modal (LXC cards land on App, VM cards on Status).",
"Application detection catalog with over 380 tracked workloads, generated live from the community-scripts source across seven detector methods (file, binary, dpkg, apk, Python, Docker exec, Docker label). Primary and fallback detectors cover both new and historical LXC layouts.",
"App tab inside every VM & LXC modal — register the apps installed in a container, capture their weblinks, and get notifications when a new upstream version ships. Cold-start Docker detection now correctly promotes Docker as the parent workload before the daemon finishes booting.",
"Reworked LXC Updates tab — apply OS packages and registered-app updates from a single button, schedule a recurring auto-update job, and cover Docker end to end (Engine + per-image on the same 24-hour rolling cycle as OS packages, with a 'Check now' action for on-demand digest comparison).",
"The Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Swedish and Slovak. Thanks to @vaso73 for building the i18n scaffolding.",
"Navigation order — a new Settings card lets each user reorder the seven top-level tabs by drag and drop. Grouped slots (Node, Admin) move as a single unit; the first slot in the saved order becomes the tab the Monitor opens on. Mouse and touch supported.",
"Custom Web Links — persistent sidecar at /etc/proxmenux/custom_links.json for URLs that don't live inside a registered LXC app. Editor takes name, URL, optional logo, category and optional binding to a specific VM or LXC.",
"NVIDIA — multi-GPU passthrough by exact BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs. Kernel + branch + GPU-aware version picker (#298).",
"Pushover joins Telegram, Gotify, Discord, Email and Apprise as a native notification channel — user/API key, device and sound selectors, priority 0 for regular messages, optional priority 1 for CRITICAL events. Suggested by @benginx (#308).",
"Host backup/restore continuity — ProxMenux configuration and independent backups survive a full Proxmox restore (#317, reported by @tropicaljoe).",
"Hardware temperature sensor identity — storage temperatures now identify the physical drive (NVMe namespace, model, serial) instead of the generic hwmon label. HDD/SSD classification follows the block device rotational flag (#315, suggested by @Dark-Witcher).",
"Post-install — precise rollback for every registered flow and Debian 13 readiness.",
"First-time visitors on Android and iOS Safari now see an in-app install prompt for adding the Monitor to their home screen as a PWA.",
],
changed: [
"Memory & Swap health check now signals real memory pressure — CRITICAL fires only when swap file is nearly full AND available RAM is genuinely tight (both editable in Settings → Health thresholds). The old swap-only signal was noisy on hosts where Linux proactively swaps out inactive pages.",
"Faster page loads and smoother navigation across the dashboard. Overview opens instantly and the VMs & LXCs page never flashes 'Loading…' between guest modals again.",
"Long backup jobs no longer time out. VM and LXC backups launched from the Monitor now run in the background until they naturally finish, so a 30-minute PBS backup completes the same as a 10-second local one.",
"VMs running the QEMU Guest Agent now report real used / total disk figures on the dashboard, instead of the '0 GB' that PVE returns for guest-managed filesystems.",
"App tab cache stays warm across every action. Successful add / edit / check / dismiss / delete operations write the returned sidecar directly into both backend and shared frontend caches; post-update scans revalidate in the background while the last valid content stays visible.",
"Docker cards on the Apps dashboard resolve their update state per image, not per Docker Engine. The purple update arrow only lights up when that specific image has an upstream update.",
"ZFS ARC sizing under memory pressure and OOM diagnostics reworked (credit: LeidenSpain).",
],
fixed: [
"Fix #309 — Proxmox storage availability: false critical alerts for iSCSI storages with maxdisk=0.",
"HID USB device class no longer reads as 'escondido' (past tense of 'to hide') in Spanish, German, French, Italian and Portuguese — the acronym is now preserved.",
"The Memory & Swap health card is dismissable like every other category (RAM usage and Swap usage sub-checks carry the dismissable flag).",
"Regenerated Proxmox VE Helper-Scripts updaters remain available after an app update (both historical wrappers and the current generated entrypoint are recognised).",
],
},
},
"1.2.4.1-beta": {
date: "August 17, 2026",
changes: {
@@ -254,23 +289,28 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
const CURRENT_VERSION_FEATURES = [
{
icon: <Sparkles className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.appDetection",
text: "Smarter app detection in the App tab: Docker is correctly promoted as the parent workload during cold start (Portainer/SearXNG no longer briefly show up as native apps), unregistered suggestions live in the startup cache, and 'Find applications' runs a fresh catalog-backed scan on demand.",
key: "releaseNotes.currentFeatures.appsDashboard",
text: "New top-level Apps dashboard — a single launcher for every Web Link across the node. LXC-registered apps and user-defined Custom Web Links share the same grid with category badges, search, and one-click deep-links back to the guest modal.",
},
{
icon: <RefreshCw className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.dockerUpdates",
text: "The Updates tab now covers Docker end-to-end: Docker Engine and per-image update tracking follow the same 24-hour rolling cycle as OS packages, with a 'Check now' action for on-demand digest comparison — no more waiting for the daily collector.",
icon: <Cpu className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.lxcAppsUpdates",
text: "App tab inside every LXC modal registers installed apps, captures weblinks and tracks upstream versions. Reworked Updates tab applies OS packages and app updates from a single button; Docker Engine and per-image tracking follow the same 24-hour cycle, with a 'Check now' action on demand.",
},
{
icon: <Zap className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.appCatalog",
text: "New application detection catalog with over 380 tracked workloads, built from the live community-scripts source with independent script evidence. Primary and fallback detectors (file, binary, dpkg, apk, Python, Docker exec, Docker label) cover both new and historical LXC layouts.",
text: "New application detection catalog with over 380 tracked workloads, generated live from community-scripts across seven detector methods (file, binary, dpkg, apk, Python, Docker exec, Docker label). Primary and fallback detectors cover both new and historical LXC layouts.",
},
{
icon: <Bell className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.pushover",
text: "Pushover joins Telegram, Gotify, Discord, Email and Apprise as a native notification channel — user/API key, device and sound selectors, priority 0 for regular messages, optional priority 1 for CRITICAL events. Suggested by @benginx (#308).",
icon: <Languages className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.multilingual",
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: <Server className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.nvidiaMultiGpu",
text: "NVIDIA driver lifecycle moves to per-BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs, plus a kernel + branch + GPU-aware version picker (#298).",
},
]
+4
View File
@@ -16,6 +16,7 @@ import { getNetworkUnit } from "../lib/format-network"
import { fetchApi } from "../lib/api-config"
import { SUPPORTED_LANGUAGES, useI18n } from "../lib/i18n/provider"
import type { LanguageCode } from "../lib/i18n/languages"
import { NavTabOrderCard } from "./nav-tab-order-card"
// GitHub Dark color palette for bash syntax highlighting
const BASH_KEYWORDS = new Set([
@@ -1052,6 +1053,9 @@ export function Settings() {
</CardContent>
</Card>
{/* Navigation Tab Order — user-orderable top-level tabs */}
<NavTabOrderCard />
{/* Network Units Settings */}
<Card>
<CardHeader>
+92 -17
View File
@@ -79,6 +79,12 @@ interface LxcAppPort {
scheme?: "http" | "https"
web_path?: string
logo_url?: string | null
// Free-text category shown in the Apps dashboard. Present on every
// Web Link the user assigned one to; absent otherwise.
category?: string
// Overrides ip:port composition — used for apps served behind a
// reverse-proxy domain (e.g. https://vault.example.com).
custom_url?: string
}
interface LxcAppWatch {
id: string
@@ -208,6 +214,8 @@ interface VMData {
}
function buildRegisteredAppUrl(vm: VMData, port?: LxcAppPort): string | null {
const custom = (port?.custom_url || "").trim()
if (custom) return custom
const rawIp = (vm.ip || "").trim().split("/")[0]
if (!rawIp || rawIp === "DHCP" || !port?.port) return null
const host = rawIp.includes(":") && !rawIp.startsWith("[") ? `[${rawIp}]` : rawIp
@@ -847,6 +855,8 @@ export function VirtualMachines() {
const [updatesRefreshing, setUpdatesRefreshing] = useState(false)
const [updatesResult, setUpdatesResult] = useState<{ pendingAfter: number; appliedCount: number } | null>(null)
const [updatesBaselineCount, setUpdatesBaselineCount] = useState<number | null>(null)
const selectedVMRef = useRef<VMData | null>(null)
const updatesBaselineCountRef = useRef<number | null>(null)
const [terminalOpen, setTerminalOpen] = useState(false)
const [terminalVmid, setTerminalVmid] = useState<number | null>(null)
const [terminalVmName, setTerminalVmName] = useState<string>("")
@@ -1007,6 +1017,44 @@ export function VirtualMachines() {
}
}, [])
// Deep-link from the Apps dashboard: when the user clicks the CT
// ref inside a launcher card, that component dispatches
// `openLxcAppModal` with the target vmid. We resolve it against the
// current /api/vms cache and open the modal on the "App" tab so the
// user lands exactly where the weblink was registered.
useEffect(() => {
const handler = (ev: Event) => {
const detail = (ev as CustomEvent).detail || {}
const targetVmid = Number(detail.vmid)
if (!Number.isFinite(targetVmid)) return
const vm = (vmData || []).find((v) => v.vmid === targetVmid)
if (!vm) return
handleVMClick(vm)
// handleVMClick resets the inner tab to "status"; override to
// "app" in the same render tick — React batches these and the
// last setActiveModalTab wins.
setActiveModalTab("app")
}
window.addEventListener("openLxcAppModal", handler as EventListener)
return () => window.removeEventListener("openLxcAppModal", handler as EventListener)
}, [vmData])
// Same deep-link but for QEMU guests. VMs don't have the App tab,
// so we land on Status (which is what handleVMClick already
// defaults to — no override needed).
useEffect(() => {
const handler = (ev: Event) => {
const detail = (ev as CustomEvent).detail || {}
const targetVmid = Number(detail.vmid)
if (!Number.isFinite(targetVmid)) return
const vm = (vmData || []).find((v) => v.vmid === targetVmid)
if (!vm) return
handleVMClick(vm)
}
window.addEventListener("openVmStatusModal", handler as EventListener)
return () => window.removeEventListener("openVmStatusModal", handler as EventListener)
}, [vmData])
// Keep the open modal's VM in sync with the /api/vms poll so CPU/RAM/I-O values
// don't stay frozen at click-time. Single data source (/cluster/resources) shared
// with the list — no source mismatch, no flicker.
@@ -1048,39 +1096,50 @@ export function VirtualMachines() {
}
}, [vmData])
useEffect(() => {
selectedVMRef.current = selectedVM
}, [selectedVM])
useEffect(() => {
updatesBaselineCountRef.current = updatesBaselineCount
}, [updatesBaselineCount])
// Settle the Updates-tab "Comprobando resultado…" state as soon as
// the /api/vms poll delivers a post-apply count that differs from
// the baseline captured when the terminal closed. Also drops the
// spinner after 15 s of no observed change (backend hook already
// force-refreshed managed_installs, so a still-equal count at that
// point means either everything was a no-op or the scan hasn't
// finished — either way the user shouldn't keep staring at a
// loader). Sets `updatesResult` for the transient banner: green if
// count is now 0, amber if some packages remain.
// the baseline captured when the terminal closed. Sets
// `updatesResult` for the transient banner: green if count is now
// 0, amber if some packages remain.
useEffect(() => {
if (!updatesRefreshing) return
if (!selectedVM) return
const currentCount = selectedVM.update_check?.count ?? 0
// A change from baseline (or landing at 0) means the fresh
// post-apply snapshot is in.
if (updatesBaselineCount !== null && currentCount !== updatesBaselineCount) {
const applied = Math.max(0, updatesBaselineCount - currentCount)
setUpdatesResult({ pendingAfter: currentCount, appliedCount: applied })
setUpdatesRefreshing(false)
setUpdatesBaselineCount(null)
return
}
// Safety timeout — never leave the spinner spinning forever.
}, [selectedVM, updatesRefreshing, updatesBaselineCount])
// Safety timeout — never leave the spinner spinning forever. Armed
// once when `updatesRefreshing` flips to true; a still-equal count
// 15 s later means either everything was a no-op or the scan hasn't
// caught up. Dependency stays scoped to `updatesRefreshing` so the
// 2.5 s SWR poll on `selectedVM` cannot keep resetting the timer.
useEffect(() => {
if (!updatesRefreshing) return
const safety = setTimeout(() => {
const currentCount = selectedVMRef.current?.update_check?.count ?? 0
const baseline = updatesBaselineCountRef.current
setUpdatesResult({
pendingAfter: currentCount,
appliedCount: Math.max(0, (updatesBaselineCount ?? 0) - currentCount),
appliedCount: Math.max(0, (baseline ?? 0) - currentCount),
})
setUpdatesRefreshing(false)
setUpdatesBaselineCount(null)
}, 15000)
return () => clearTimeout(safety)
}, [selectedVM, updatesRefreshing, updatesBaselineCount])
}, [updatesRefreshing])
// Auto-dismiss the post-apply banner after 6 s so it doesn't
// clutter the tab forever.
@@ -4824,7 +4883,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{!selectedVM.update_check?.managed_oci_app &&
!selectedVM.update_check?.is_oci_lxc && (() => {
const uc = selectedVM.update_check
const hasOsUpdates = !!uc?.available
const osUpdateStatusKnown = !!uc && !uc.error
const hasOsUpdates = osUpdateStatusKnown && !!uc.available
const dockerAppWatch = (selectedVM.app_watches || []).find((a) => a.helper_slug === "docker")
const dockerRegistered = !!dockerAppWatch
const dockerEngineInstalledVersion = selectedVM.docker_inventory?.engine_version
@@ -5081,7 +5141,15 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<> · {t("vmLxc.updates.familyLabel")} <code className="text-foreground/80">{uc.os_family}</code></>
)}
</div>
{hasOsUpdates ? (() => {
{!osUpdateStatusKnown ? (
<div
className="text-sm text-muted-foreground flex items-center gap-2"
title={uc?.error || undefined}
>
<AlertTriangle className="h-4 w-4 text-amber-400 flex-shrink-0" />
{t("vmLxc.updates.osStatusUnavailable")}
</div>
) : hasOsUpdates ? (() => {
const stored = uc!.packages?.length || 0
const total = uc!.count || 0
const sec = uc!.security_count || 0
@@ -5131,10 +5199,17 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<Button
size="sm"
onClick={() => openApplyTerminal(selectedVM.vmid, "os")}
className={hasOsUpdates ? pendingBtnCls : upToDateBtnCls}
className={hasOsUpdates
? pendingBtnCls
: osUpdateStatusKnown
? upToDateBtnCls
: neutralBtnCls}
>
{hasOsUpdates && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
{hasOsUpdates ? t("vmLxc.updates.applyOsUpdate") : t("vmLxc.updates.osUpToDate")}
{!hasOsUpdates && !osUpdateStatusKnown && <RefreshCw className="h-4 w-4 mr-1.5" />}
{hasOsUpdates || !osUpdateStatusKnown
? t("vmLxc.updates.applyOsUpdate")
: t("vmLxc.updates.osUpToDate")}
</Button>
</div>
</div>
+87
View File
@@ -0,0 +1,87 @@
// Deterministic OKLCH colouring for category badges. Shared between
// the Apps dashboard and the LXC App tab so both surfaces show the
// exact same colour for a given category name.
//
// Hue exclusions
// --------------
// Two bands are skipped because their meaning is already reserved by
// the rest of the Monitor and a chip in those hues on the same view
// would be visually confusing:
// * 260319° purple/violet — "update available" (ArrowUpCircle)
// * 34019° red — error / danger signal
// Green and yellow ARE used elsewhere for health status, but only as
// tiny dots in other views — a chip in those hues on an app card
// carries no false meaning, so they stay in the allowed range.
//
// Allowed ranges after the exclusions:
// [20, 260) [320, 340) = 240° + 20° = 260° of usable hues.
import { useEffect, useState } from "react"
export function hueForCategory(text: string): number {
let hash = 5381
for (let i = 0; i < text.length; i++) {
hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0
}
const raw = Math.abs(hash) % 260
if (raw < 240) return 20 + raw // 0-239 → 20-259 (orange..blue)
return 320 + (raw - 240) // 240-259 → 320-339 (pink/magenta)
}
// OKLCH is perceptually uniform — L=0.80 looks equally bright for a
// blue and a yellow. HSL fails this because eyes weight green/yellow
// more, so the same L% renders visually darker for blues.
export function categoryChipStyle(text: string, isLight: boolean): {
backgroundColor: string
color: string
borderColor: string
} {
const h = hueForCategory(text)
if (isLight) {
return {
backgroundColor: `oklch(0.55 0.20 ${h} / 0.14)`,
color: `oklch(0.42 0.19 ${h})`,
borderColor: `oklch(0.55 0.20 ${h} / 0.5)`,
}
}
return {
backgroundColor: `oklch(0.60 0.16 ${h} / 0.18)`,
color: `oklch(0.80 0.16 ${h})`,
borderColor: `oklch(0.60 0.16 ${h} / 0.55)`,
}
}
// Read the effective theme from next-themes' hooks on <html>:
// `class="dark|light"` (Tailwind class strategy) or `data-theme`.
// Falls back to the OS setting when the user hasn't chosen one.
export function readIsLightTheme(): boolean {
if (typeof window === "undefined" || typeof document === "undefined") return false
const el = document.documentElement
if (el.classList.contains("dark")) return false
if (el.classList.contains("light")) return true
const attr = el.getAttribute("data-theme")
if (attr === "light") return true
if (attr === "dark") return false
return window.matchMedia("(prefers-color-scheme: light)").matches
}
// React hook — recomputes when the user toggles theme or the OS pref
// flips. Watches <html>'s attributes (data-theme + class) and the
// system media query. Used by any component that renders category
// chips so they stay legible after a theme change.
export function useIsLightTheme(): boolean {
const [isLight, setIsLight] = useState<boolean>(false)
useEffect(() => {
const update = () => setIsLight(readIsLightTheme())
update()
const mq = window.matchMedia("(prefers-color-scheme: light)")
const observer = new MutationObserver(update)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme", "class"],
})
mq.addEventListener("change", update)
return () => { observer.disconnect(); mq.removeEventListener("change", update) }
}, [])
return isLight
}
+132
View File
@@ -0,0 +1,132 @@
import { useEffect, useState, useCallback } from "react"
// Persistent top-level tab order for the Monitor dashboard.
//
// Only the seven top-level slots are user-orderable; the internal
// items of the Node and Admin dropdowns keep their canonical order —
// grouped items move as a single unit.
export type TabId = "overview" | "apps" | "vms" | "node" | "backup" | "terminal" | "admin"
export const DEFAULT_TAB_ORDER: TabId[] = [
"overview",
"apps",
"vms",
"node",
"backup",
"terminal",
"admin",
]
const STORAGE_KEY = "proxmenux-nav-order"
const CHANGE_EVENT = "proxmenux-nav-order-changed"
function isTabId(v: unknown): v is TabId {
return typeof v === "string" && (DEFAULT_TAB_ORDER as string[]).includes(v)
}
// Read + normalise: unknown ids are dropped, missing ones are
// appended in their default position so a future release adding a
// new tab still surfaces it for users with a stored order.
export function readTabOrder(): TabId[] {
if (typeof window === "undefined") return DEFAULT_TAB_ORDER
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return DEFAULT_TAB_ORDER
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return DEFAULT_TAB_ORDER
const seen = new Set<TabId>()
const clean: TabId[] = []
for (const item of parsed) {
if (isTabId(item) && !seen.has(item)) {
clean.push(item)
seen.add(item)
}
}
for (const id of DEFAULT_TAB_ORDER) {
if (!seen.has(id)) clean.push(id)
}
return clean
} catch {
return DEFAULT_TAB_ORDER
}
}
export function writeTabOrder(order: TabId[]): void {
if (typeof window === "undefined") return
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(order))
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
} catch {
// Storage full / disabled — the in-memory state still updates.
}
}
// Map a top-level slot id to the concrete `activeTab` value the
// Tabs component uses. Direct tabs pass through; grouped slots
// (Node/Admin) resolve to the first child in the dropdown so the
// dashboard lands on a real tab, not a group header.
const GROUP_FIRST_CHILD: Record<TabId, string> = {
overview: "overview",
apps: "apps",
vms: "vms",
node: "storage",
backup: "backup",
terminal: "terminal",
admin: "logs",
}
export function firstActualTab(order: TabId[] = readTabOrder()): string {
const head = order[0]
return (head && GROUP_FIRST_CHILD[head]) || "overview"
}
export function resetTabOrder(): void {
if (typeof window === "undefined") return
try {
window.localStorage.removeItem(STORAGE_KEY)
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
} catch {
// ignore
}
}
// Hook that keeps every consumer in sync. Firing a custom event on
// write means the Settings card and the top navigation update in the
// same tick without prop-drilling.
export function useTabOrder(): {
order: TabId[]
setOrder: (next: TabId[]) => void
reset: () => void
isCustom: boolean
} {
const [order, setOrderState] = useState<TabId[]>(DEFAULT_TAB_ORDER)
useEffect(() => {
setOrderState(readTabOrder())
const onChange = () => setOrderState(readTabOrder())
window.addEventListener(CHANGE_EVENT, onChange)
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEY) onChange()
})
return () => {
window.removeEventListener(CHANGE_EVENT, onChange)
}
}, [])
const setOrder = useCallback((next: TabId[]) => {
writeTabOrder(next)
setOrderState(next)
}, [])
const reset = useCallback(() => {
resetTabOrder()
setOrderState(DEFAULT_TAB_ORDER)
}, [])
const isCustom =
order.length !== DEFAULT_TAB_ORDER.length ||
order.some((id, idx) => id !== DEFAULT_TAB_ORDER[idx])
return { order, setOrder, reset, isCustom }
}
+1 -1
View File
@@ -8,4 +8,4 @@
// 3. beta_version.txt ← bash pipeline (build_appimage.sh)
//
// Keep the three in sync on every bump.
export const APP_VERSION = "1.2.4.2-beta"
export const APP_VERSION = "1.2.5"
+66 -6
View File
@@ -31,6 +31,7 @@
},
"navigation": {
"overview": "Überblick",
"apps": "Apps",
"storage": "Lagerung",
"network": "Netzwerk",
"virtualMachines": "VMs und LXCs",
@@ -1211,6 +1212,7 @@
"familyLabel": "Familie:",
"applyOsUpdate": "Betriebssystem-Update anwenden",
"osUpToDate": "Betriebssystem auf dem neuesten Stand",
"osStatusUnavailable": "Der Status der Betriebssystemaktualisierungen konnte nicht ermittelt werden.",
"installedByHelperPrefix": "Installiert von",
"helperScriptsName": "Proxmox VE Helper-Scripts",
"helperUpdatesRun": "Installiert von Proxmox Helper-Scripts.",
@@ -1435,6 +1437,11 @@
"portHttps": "https",
"portLogoLabel": "Logo-URL für diesen Link (optional)",
"portLogoPlaceholder": "z. B. https://example.com/logo.webp",
"portCategoryPlaceholder": "Category for the Apps dashboard (optional)",
"portCategoryNone": "No category",
"portCategoryAddNew": "+ Add new category…",
"portCategoryCustomPlaceholder": "Type a category and press Enter (Esc to cancel)",
"portCustomUrlPlaceholder": "Custom URL (e.g. https://vault.example.com) — overrides IP:port",
"removePortTooltip": "Port entfernen",
"detectMethodDpkg": "dpkg ·",
"detectMethodApk": "apk ·",
@@ -1484,7 +1491,7 @@
"managedApp": "Verwaltete App",
"managedStatus": "verwaltet",
"checkedAt": "Geprüft {date}",
"latestUpstream": "Neueste verfügbare Version",
"latestUpstream": "Neueste",
"loadingApplications": "Bewerbungen werden geladen…",
"nameSearchPlaceholder": "Geben Sie ein, um über 700 Apps zu durchsuchen, oder geben Sie Ihre eigene ein",
"matchCount": "{count} Übereinstimmungen",
@@ -2045,7 +2052,18 @@
"title": "ZFS-Poolkapazität",
"description": "ZFS-Pools auf Host-Ebene, unabhängig von der PVE-Registrierung, sodass auch Rpool- und dedizierte Backup-Pools überwacht werden."
}
}
},
"swapPressure": "Speicherdruck",
"swapPressureHint": "Markiert ein kritisches Ereignis nur, wenn BEIDE Bedingungen gleichzeitig zutreffen — Swap-Nutzung über dem ersten Schwellenwert UND verfügbarer RAM unter dem zweiten. Alarmierung nur wegen Swap war auf Proxmox-Hosts laut, da Linux inaktive Seiten auslagert, während der RAM reichlich verfügbar bleibt.",
"swapHighLabel": "Swap-Datei-Nutzung",
"availableMinLabel": "Mindestens verfügbarer RAM"
},
"navOrder": {
"title": "Navigationsreihenfolge",
"description": "Passe die Reihenfolge der Hauptregisterkarten an. Gruppierte Registerkarten (Node, Admin) werden als Einheit verschoben; ihre internen Elemente behalten die Standardreihenfolge.",
"reset": "Standard wiederherstellen",
"hint": "Ziehen zum Neuanordnen · Auf Touch zuerst lange drücken",
"customActive": "Benutzerdefinierte Navigationsreihenfolge aktiv."
}
},
"login": {
@@ -2069,7 +2087,7 @@
"backupCodeHint": "Sie können auch einen Backup-Code verwenden (Format: XXXX-XXXX)",
"backToLogin": "Zurück zum Login",
"verifyCode": "Code überprüfen",
"version": "ProxMenux Monitor v1.2.4.1-beta"
"version": "ProxMenux Monitor v1.2.5"
},
"account": {
"signedIn": "Angemeldet",
@@ -3120,8 +3138,12 @@
"currentFeatures": {
"appDetection": "Smarter app detection in the App tab: Docker is correctly promoted as the parent workload during cold start (Portainer/SearXNG no longer briefly show up as native apps), unregistered suggestions live in the startup cache, and 'Find applications' runs a fresh catalog-backed scan on demand.",
"dockerUpdates": "The Updates tab now covers Docker end-to-end: Docker Engine and per-image update tracking follow the same 24-hour rolling cycle as OS packages, with a 'Check now' action for on-demand digest comparison — no more waiting for the daily collector.",
"appCatalog": "New application detection catalog with over 380 tracked workloads, built from the live community-scripts source with independent script evidence. Primary and fallback detectors (file, binary, dpkg, apk, Python, Docker exec, Docker label) cover both new and historical LXC layouts.",
"pushover": "Pushover joins Telegram, Gotify, Discord, Email and Apprise as a native notification channel — user/API key, device and sound selectors, priority 0 for regular messages, optional priority 1 for CRITICAL events. Suggested by @benginx (#308)."
"appCatalog": "New application detection catalog with over 380 tracked workloads, generated live from community-scripts across seven detector methods (file, binary, dpkg, apk, Python, Docker exec, Docker label). Primary and fallback detectors cover both new and historical LXC layouts.",
"pushover": "Pushover joins Telegram, Gotify, Discord, Email and Apprise as a native notification channel — user/API key, device and sound selectors, priority 0 for regular messages, optional priority 1 for CRITICAL events. Suggested by @benginx (#308).",
"appsDashboard": "New top-level Apps dashboard — a single launcher for every Web Link across the node. LXC-registered apps and user-defined Custom Web Links share the same grid with category badges, search, and one-click deep-links back to the guest modal.",
"lxcAppsUpdates": "App tab inside every LXC modal registers installed apps, captures weblinks and tracks upstream versions. Reworked Updates tab applies OS packages and app updates from a single button; Docker Engine and per-image tracking follow the same 24-hour cycle, with a 'Check now' action on demand.",
"multilingual": "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.",
"nvidiaMultiGpu": "NVIDIA driver lifecycle moves to per-BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs, plus a kernel + branch + GPU-aware version picker (#298)."
}
},
"network": {
@@ -3525,7 +3547,7 @@
"wifi": "W-lan",
"storage": "Lagerung",
"storageController": "Speichercontroller",
"hid": "VERSTECKT",
"hid": "HID",
"vendorSpecific": "Anbieterspezifisch",
"communications": "Kommunikation",
"integrated": "Integriert",
@@ -4733,5 +4755,43 @@
"yourInput": "Ihr Beitrag:",
"submit": "Einreichen",
"ok": "OK"
},
"apps": {
"searchPlaceholder": "Search app or CT…",
"searchAriaLabel": "Search",
"filterAll": "All categories",
"filterAriaLabel": "Filter by category",
"sortAriaLabel": "Sort by",
"sortName": "Name",
"sortId": "ID",
"sortCategory": "Category",
"countOne": "1 app",
"countMany": "{n} apps",
"uncategorized": "Uncategorized",
"openAriaLabel": "Open {name} in a new tab",
"openGuestAriaLabel": "{name} öffnen ({type} {id})",
"emptyTitle": "No apps with a web link yet.",
"emptyHint": "Register a Web Link for any app (App tab of a CT) to see it here.",
"customLinkAdd": "Add link",
"editModeToggle": "Edit",
"editModeDone": "Done",
"customLinkNewTitle": "New web link",
"customLinkEditTitle": "Edit web link",
"customLinkEditAria": "Benutzerdefinierten Link {name} bearbeiten",
"customLinkName": "Name",
"customLinkNamePlaceholder": "e.g. My app",
"customLinkUrl": "URL",
"customLinkLogo": "Logo URL (optional)",
"customLinkLogoPlaceholder": "e.g., https://example.com/logo.webp",
"customLinkCategory": "Category (optional)",
"customLinkBinding": "Bound to",
"customLinkBindingNone": "Not bound to any guest",
"customLinkBindingHelp": "Bind this link to a VM or CT to add its ID + name to the card and jump to the guest with one click.",
"customLinkSave": "Save",
"customLinkCreate": "Create",
"customLinkCancel": "Cancel",
"customLinkDelete": "Delete",
"customLinkSaveError": "Save failed",
"customLinkDeleteError": "Delete failed"
}
}
+65 -5
View File
@@ -30,6 +30,7 @@
},
"navigation": {
"overview": "Overview",
"apps": "Apps",
"storage": "Storage",
"network": "Network",
"virtualMachines": "VMs & LXCs",
@@ -1210,6 +1211,7 @@
"familyLabel": "Family:",
"applyOsUpdate": "Apply OS update",
"osUpToDate": "OS up to date",
"osStatusUnavailable": "The OS update status could not be determined.",
"installedByHelperPrefix": "Installed by",
"helperScriptsName": "Proxmox VE Helper-Scripts",
"helperUpdatesRun": "Installed by Proxmox Helper-Scripts.",
@@ -1437,6 +1439,11 @@
"portHttps": "https",
"portLogoLabel": "Logo URL for this link (optional)",
"portLogoPlaceholder": "e.g., https://example.com/logo.webp",
"portCategoryPlaceholder": "Category for the Apps dashboard (optional)",
"portCategoryNone": "No category",
"portCategoryAddNew": "+ Add new category…",
"portCategoryCustomPlaceholder": "Type a category and press Enter (Esc to cancel)",
"portCustomUrlPlaceholder": "Custom URL (e.g. https://vault.example.com) — overrides IP:port",
"removePortTooltip": "Remove port",
"detectMethodDpkg": "dpkg ·",
"detectMethodApk": "apk ·",
@@ -1486,7 +1493,7 @@
"managedApp": "Managed app",
"managedStatus": "managed",
"checkedAt": "Checked {date}",
"latestUpstream": "Latest upstream",
"latestUpstream": "Latest",
"loadingApplications": "Loading applications…",
"nameSearchPlaceholder": "Type to search 700+ apps, or type your own",
"matchCount": "{count} matches",
@@ -2044,7 +2051,18 @@
"title": "ZFS pool capacity",
"description": "ZFS pools at host level, independent of PVE registration, so rpool and dedicated backup pools are also monitored."
}
}
},
"swapPressure": "Swap pressure",
"swapPressureHint": "Marks a critical event only when BOTH conditions hold at the same time — swap file usage above the first threshold AND available RAM below the second. Alerting on swap alone is noisy on Proxmox hosts where Linux swaps out inactive pages while RAM is still plentifully available.",
"swapHighLabel": "Swap file usage",
"availableMinLabel": "Minimum available RAM"
},
"navOrder": {
"title": "Navigation order",
"description": "Personalize the order of the top-level tabs. Grouped tabs (Node, Admin) move as a single unit; their internal items keep their default order.",
"reset": "Restore default",
"hint": "Drag to reorder · On touch, long-press first",
"customActive": "Using custom navigation order."
}
},
"login": {
@@ -2068,7 +2086,7 @@
"backupCodeHint": "You can also use a backup code (format: XXXX-XXXX)",
"backToLogin": "Back to login",
"verifyCode": "Verify Code",
"version": "ProxMenux Monitor v1.2.4.1-beta"
"version": "ProxMenux Monitor v1.2.5"
},
"account": {
"signedIn": "Signed in",
@@ -3119,8 +3137,12 @@
"currentFeatures": {
"appDetection": "Smarter app detection in the App tab: Docker is correctly promoted as the parent workload during cold start (Portainer/SearXNG no longer briefly show up as native apps), unregistered suggestions live in the startup cache, and 'Find applications' runs a fresh catalog-backed scan on demand.",
"dockerUpdates": "The Updates tab now covers Docker end-to-end: Docker Engine and per-image update tracking follow the same 24-hour rolling cycle as OS packages, with a 'Check now' action for on-demand digest comparison — no more waiting for the daily collector.",
"appCatalog": "New application detection catalog with over 380 tracked workloads, built from the live community-scripts source with independent script evidence. Primary and fallback detectors (file, binary, dpkg, apk, Python, Docker exec, Docker label) cover both new and historical LXC layouts.",
"pushover": "Pushover joins Telegram, Gotify, Discord, Email and Apprise as a native notification channel — user/API key, device and sound selectors, priority 0 for regular messages, optional priority 1 for CRITICAL events. Suggested by @benginx (#308)."
"appCatalog": "New application detection catalog with over 380 tracked workloads, generated live from community-scripts across seven detector methods (file, binary, dpkg, apk, Python, Docker exec, Docker label). Primary and fallback detectors cover both new and historical LXC layouts.",
"pushover": "Pushover joins Telegram, Gotify, Discord, Email and Apprise as a native notification channel — user/API key, device and sound selectors, priority 0 for regular messages, optional priority 1 for CRITICAL events. Suggested by @benginx (#308).",
"appsDashboard": "New top-level Apps dashboard — a single launcher for every Web Link across the node. LXC-registered apps and user-defined Custom Web Links share the same grid with category badges, search, and one-click deep-links back to the guest modal.",
"lxcAppsUpdates": "App tab inside every LXC modal registers installed apps, captures weblinks and tracks upstream versions. Reworked Updates tab applies OS packages and app updates from a single button; Docker Engine and per-image tracking follow the same 24-hour cycle, with a 'Check now' action on demand.",
"multilingual": "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.",
"nvidiaMultiGpu": "NVIDIA driver lifecycle moves to per-BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs, plus a kernel + branch + GPU-aware version picker (#298)."
}
},
"network": {
@@ -4799,5 +4821,43 @@
"yourInput": "Your input:",
"submit": "Submit",
"ok": "OK"
},
"apps": {
"searchPlaceholder": "Search app or CT…",
"searchAriaLabel": "Search",
"filterAll": "All categories",
"filterAriaLabel": "Filter by category",
"sortAriaLabel": "Sort by",
"sortName": "Name",
"sortId": "ID",
"sortCategory": "Category",
"countOne": "1 app",
"countMany": "{n} apps",
"uncategorized": "Uncategorized",
"openAriaLabel": "Open {name} in a new tab",
"openGuestAriaLabel": "Open {name} ({type} {id})",
"emptyTitle": "No apps with a web link yet.",
"emptyHint": "Register a Web Link for any app (App tab of a CT) to see it here.",
"customLinkAdd": "Add link",
"editModeToggle": "Edit",
"editModeDone": "Done",
"customLinkNewTitle": "New web link",
"customLinkEditTitle": "Edit web link",
"customLinkEditAria": "Edit custom link {name}",
"customLinkName": "Name",
"customLinkNamePlaceholder": "e.g. My app",
"customLinkUrl": "URL",
"customLinkLogo": "Logo URL (optional)",
"customLinkLogoPlaceholder": "e.g., https://example.com/logo.webp",
"customLinkCategory": "Category (optional)",
"customLinkBinding": "Bound to",
"customLinkBindingNone": "Not bound to any guest",
"customLinkBindingHelp": "Bind this link to a VM or CT to add its ID + name to the card and jump to the guest with one click.",
"customLinkSave": "Save",
"customLinkCreate": "Create",
"customLinkCancel": "Cancel",
"customLinkDelete": "Delete",
"customLinkSaveError": "Save failed",
"customLinkDeleteError": "Delete failed"
}
}
+68 -8
View File
@@ -31,6 +31,7 @@
},
"navigation": {
"overview": "General",
"apps": "Apps",
"storage": "Almacenamiento",
"network": "Red",
"virtualMachines": "VM y LXC",
@@ -1211,6 +1212,7 @@
"familyLabel": "Familia:",
"applyOsUpdate": "Aplicar actualizaciones de SO",
"osUpToDate": "SO actualizado",
"osStatusUnavailable": "No se ha podido determinar el estado de las actualizaciones del SO.",
"installedByHelperPrefix": "Instalado por",
"helperScriptsName": "Proxmox VE Helper-Scripts",
"helperUpdatesRun": "Instalado por Proxmox Helper-Scripts.",
@@ -1369,7 +1371,7 @@
"methodApk": "paquete apk (alpino)",
"methodBinary": "Binario (ruta absoluta o nombre simple)",
"methodFile": "archivo + expresión regular",
"methodDockerLabel": "inspección acoplable (etiqueta OCI)",
"methodDockerLabel": "docker inspect (etiqueta OCI)",
"methodDockerExec": "docker exec (binario en contenedor)",
"methodPython": "distribución de Python (importlib.metadata)",
"methodCommand": "comando (avanzado - argv)",
@@ -1438,7 +1440,12 @@
"portHttps": "https",
"portLogoLabel": "URL del logotipo para este enlace (opcional)",
"portLogoPlaceholder": "por ejemplo, https://example.com/logo.webp",
"removePortTooltip": "Quitar puerto",
"portCategoryPlaceholder": "Categoría para el dashboard de Apps (opcional)",
"portCategoryNone": "Sin categoría",
"portCategoryAddNew": "+ Añadir nueva categoría…",
"portCategoryCustomPlaceholder": "Escribe una categoría y pulsa Enter (Esc para cancelar)",
"portCustomUrlPlaceholder": "URL personalizada (p.ej. https://vault.example.com) — sustituye IP:puerto",
"removePortTooltip": "Eliminar puerto",
"detectMethodDpkg": "dp kg ·",
"detectMethodApk": "aplicación ·",
"detectMethodBinary": "binario ·",
@@ -1487,7 +1494,7 @@
"managedApp": "aplicación administrada",
"managedStatus": "administrado",
"checkedAt": "Marcado {date}",
"latestUpstream": "Última versión disponible",
"latestUpstream": "Última",
"loadingApplications": "Cargando aplicaciones…",
"nameSearchPlaceholder": "Escribe para buscar más de 700 aplicaciones o escribe la tuya propia",
"matchCount": "{count} coincidencias",
@@ -2045,7 +2052,18 @@
"title": "Capacidad del grupo ZFS",
"description": "Grupos ZFS a nivel de host, independientes del registro PVE, por lo que también se monitorean el rpool y los grupos de respaldo dedicados."
}
}
},
"swapPressure": "Presión de memoria",
"swapPressureHint": "Marca un evento crítico solo cuando se cumplen AMBAS condiciones a la vez: uso del archivo de swap por encima del primer umbral Y RAM disponible por debajo del segundo. Alertar solo por swap era ruidoso en Proxmox porque Linux swapea páginas inactivas mientras la RAM sigue disponible.",
"swapHighLabel": "Uso del archivo de swap",
"availableMinLabel": "RAM disponible mínima"
},
"navOrder": {
"title": "Orden de navegación",
"description": "Personaliza el orden de las pestañas principales. Las pestañas agrupadas (Node, Admin) se mueven como una unidad; sus elementos internos mantienen el orden por defecto.",
"reset": "Restaurar por defecto",
"hint": "Arrastra para reordenar · En táctil, mantén pulsado primero",
"customActive": "Usando orden de navegación personalizado."
}
},
"login": {
@@ -2069,7 +2087,7 @@
"backupCodeHint": "También puedes utilizar un código de respaldo (formato: XXXX-XXXX)",
"backToLogin": "Volver a iniciar sesión",
"verifyCode": "Verificar código",
"version": "ProxMenux Monitor v1.2.4.1-beta"
"version": "ProxMenux Monitor v1.2.5"
},
"account": {
"signedIn": "Iniciado sesión",
@@ -3120,8 +3138,12 @@
"currentFeatures": {
"appDetection": "Detección de apps más inteligente en la pestaña App: Docker se identifica correctamente como la aplicación principal durante el arranque en frío (Portainer/SearXNG ya no aparecen brevemente como apps nativas), las sugerencias sin registrar viven en el cache de inicio y 'Find applications' lanza un escaneo desde el catálogo bajo demanda.",
"dockerUpdates": "La pestaña Updates ahora cubre Docker de principio a fin: Docker Engine y el seguimiento de actualizaciones por imagen siguen el mismo ciclo de 24 horas que los paquetes del SO, con una acción 'Check now' para comparar digests bajo demanda — sin esperar al collector diario.",
"appCatalog": "Nuevo catálogo de detección de aplicaciones con más de 380 workloads reconocidos, construido en directo desde community-scripts con evidencia independiente por script. Detectores primarios y de fallback (fichero, binario, dpkg, apk, Python, Docker exec, Docker label) cubren layouts LXC nuevos e históricos.",
"pushover": "Pushover se une a Telegram, Gotify, Discord, Email y Apprise como canal de notificación nativo — clave user/API, selectores de dispositivo y sonido, prioridad 0 para mensajes normales, prioridad 1 opcional para eventos CRITICAL. Sugerido por @benginx (#308)."
"appCatalog": "Nuevo catálogo de detección de aplicaciones con más de 380 workloads, generado en vivo desde community-scripts con siete métodos de detección (archivo, binario, dpkg, apk, Python, Docker exec, Docker label). Detectores principales y de reserva cubren tanto los nuevos como los históricos layouts de LXC.",
"pushover": "Pushover se une a Telegram, Gotify, Discord, Email y Apprise como canal de notificación nativo — clave user/API, selectores de dispositivo y sonido, prioridad 0 para mensajes normales, prioridad 1 opcional para eventos CRITICAL. Sugerido por @benginx (#308).",
"appsDashboard": "Nueva pestaña Apps de nivel superior — un lanzador único para cada enlace web del nodo. Las aplicaciones registradas en LXC y los enlaces web personalizados comparten la misma cuadrícula con etiquetas de categoría, búsqueda y acceso directo al modal del invitado.",
"lxcAppsUpdates": "La pestaña App dentro del modal de cada LXC registra las aplicaciones instaladas, captura sus enlaces web y realiza seguimiento de versiones. La pestaña Updates rediseñada aplica actualizaciones de paquetes del sistema y de aplicaciones desde un solo botón; Docker Engine y cada imagen siguen el mismo ciclo de 24 horas, con acción 'Comprobar ahora' bajo demanda.",
"multilingual": "El Monitor ahora habla 8 idiomas: inglés, español, alemán, francés, italiano, portugués, sueco y eslovaco. Un enorme agradecimiento a @vaso73 por construir la base de i18n que lo hizo posible.",
"nvidiaMultiGpu": "El ciclo de vida del driver NVIDIA pasa a propiedad por BDF exacto, de modo que un host multi-GPU puede pasar una tarjeta a una VM y mantener la otra operativa en el host o en LXCs, junto con un selector de versión sensible al kernel, la rama y la GPU (#298)."
}
},
"network": {
@@ -3525,7 +3547,7 @@
"wifi": "wifi",
"storage": "Almacenamiento",
"storageController": "Controlador de almacenamiento",
"hid": "escondido",
"hid": "HID",
"vendorSpecific": "Específico del proveedor",
"communications": "Comunicaciones",
"integrated": "Integrado",
@@ -4733,5 +4755,43 @@
"yourInput": "Su entrada:",
"submit": "Entregar",
"ok": "OK"
},
"apps": {
"searchPlaceholder": "Buscar app o CT…",
"searchAriaLabel": "Buscar",
"filterAll": "Todas las categorías",
"filterAriaLabel": "Filtrar por categoría",
"sortAriaLabel": "Ordenar por",
"sortName": "Nombre",
"sortId": "ID",
"sortCategory": "Categoría",
"countOne": "1 app",
"countMany": "{n} apps",
"uncategorized": "Sin categoría",
"openAriaLabel": "Abrir {name} en una pestaña nueva",
"openGuestAriaLabel": "Abrir {name} ({type} {id})",
"emptyTitle": "Todavía no hay apps con enlace web.",
"emptyHint": "Registra un enlace web en la pestaña App de cualquier CT para verlo aquí.",
"customLinkAdd": "Añadir enlace",
"editModeToggle": "Editar",
"editModeDone": "Hecho",
"customLinkNewTitle": "Nuevo enlace web",
"customLinkEditTitle": "Editar enlace web",
"customLinkEditAria": "Editar el enlace personalizado {name}",
"customLinkName": "Nombre",
"customLinkNamePlaceholder": "p. ej. Mi app",
"customLinkUrl": "URL",
"customLinkLogo": "URL del logo (opcional)",
"customLinkLogoPlaceholder": "por ejemplo, https://example.com/logo.webp",
"customLinkCategory": "Categoría (opcional)",
"customLinkBinding": "Asociado a",
"customLinkBindingNone": "Sin asociar a ningún guest",
"customLinkBindingHelp": "Asocia el enlace a una VM o LXC para que la card muestre su ID + nombre y puedas saltar al guest con un click.",
"customLinkSave": "Guardar",
"customLinkCreate": "Crear",
"customLinkCancel": "Cancelar",
"customLinkDelete": "Eliminar",
"customLinkSaveError": "Error al guardar",
"customLinkDeleteError": "Error al eliminar"
}
}
+66 -6
View File
@@ -31,6 +31,7 @@
},
"navigation": {
"overview": "Aperçu",
"apps": "Apps",
"storage": "Stockage",
"network": "Réseau",
"virtualMachines": "VM et LXC",
@@ -1211,6 +1212,7 @@
"familyLabel": "Famille:",
"applyOsUpdate": "Appliquer la mise à jour du système d'exploitation",
"osUpToDate": "OS à jour",
"osStatusUnavailable": "Impossible de déterminer l’état des mises à jour du système dexploitation.",
"installedByHelperPrefix": "Installé par",
"helperScriptsName": "Proxmox VE Helper-Scripts",
"helperUpdatesRun": "Installé par Proxmox Helper-Scripts.",
@@ -1435,6 +1437,11 @@
"portHttps": "https",
"portLogoLabel": "URL du logo pour ce lien (facultatif)",
"portLogoPlaceholder": "par exemple, https://example.com/logo.webp",
"portCategoryPlaceholder": "Category for the Apps dashboard (optional)",
"portCategoryNone": "No category",
"portCategoryAddNew": "+ Add new category…",
"portCategoryCustomPlaceholder": "Type a category and press Enter (Esc to cancel)",
"portCustomUrlPlaceholder": "Custom URL (e.g. https://vault.example.com) — overrides IP:port",
"removePortTooltip": "Supprimer le port",
"detectMethodDpkg": "dpkg ·",
"detectMethodApk": "apk ·",
@@ -1484,7 +1491,7 @@
"managedApp": "Application gérée",
"managedStatus": "géré",
"checkedAt": "Vérifié {date}",
"latestUpstream": "Dernière version disponible",
"latestUpstream": "Dernière",
"loadingApplications": "Chargement des applications…",
"nameSearchPlaceholder": "Tapez pour rechercher plus de 700 applications ou tapez la vôtre",
"matchCount": "{count} correspondances",
@@ -2045,7 +2052,18 @@
"title": "Capacité du pool ZFS",
"description": "Pools ZFS au niveau de l'hôte, indépendamment de l'enregistrement PVE, de sorte que les pools rpool et de sauvegarde dédiés sont également surveillés."
}
}
},
"swapPressure": "Pression mémoire",
"swapPressureHint": "Marque un événement critique uniquement quand LES DEUX conditions sont vraies en même temps — utilisation du fichier swap au-dessus du premier seuil ET RAM disponible en-dessous du second. Alerter uniquement sur le swap était bruyant sur les hôtes Proxmox où Linux échange des pages inactives alors que la RAM reste abondamment disponible.",
"swapHighLabel": "Utilisation du fichier swap",
"availableMinLabel": "RAM disponible minimale"
},
"navOrder": {
"title": "Ordre de navigation",
"description": "Personnalisez l'ordre des onglets principaux. Les onglets groupés (Node, Admin) se déplacent comme une unité ; leurs éléments internes conservent l'ordre par défaut.",
"reset": "Restaurer par défaut",
"hint": "Glissez pour réorganiser · Sur tactile, appui long d'abord",
"customActive": "Ordre de navigation personnalisé actif."
}
},
"login": {
@@ -2069,7 +2087,7 @@
"backupCodeHint": "Vous pouvez également utiliser un code de secours (format : XXXX-XXXX)",
"backToLogin": "Retour à la connexion",
"verifyCode": "Vérifier le code",
"version": "ProxMenux Monitor v1.2.4.1-bêta"
"version": "ProxMenux Monitor v1.2.5"
},
"account": {
"signedIn": "Connecté",
@@ -3120,8 +3138,12 @@
"currentFeatures": {
"appDetection": "Smarter app detection in the App tab: Docker is correctly promoted as the parent workload during cold start (Portainer/SearXNG no longer briefly show up as native apps), unregistered suggestions live in the startup cache, and 'Find applications' runs a fresh catalog-backed scan on demand.",
"dockerUpdates": "The Updates tab now covers Docker end-to-end: Docker Engine and per-image update tracking follow the same 24-hour rolling cycle as OS packages, with a 'Check now' action for on-demand digest comparison — no more waiting for the daily collector.",
"appCatalog": "New application detection catalog with over 380 tracked workloads, built from the live community-scripts source with independent script evidence. Primary and fallback detectors (file, binary, dpkg, apk, Python, Docker exec, Docker label) cover both new and historical LXC layouts.",
"pushover": "Pushover joins Telegram, Gotify, Discord, Email and Apprise as a native notification channel — user/API key, device and sound selectors, priority 0 for regular messages, optional priority 1 for CRITICAL events. Suggested by @benginx (#308)."
"appCatalog": "New application detection catalog with over 380 tracked workloads, generated live from community-scripts across seven detector methods (file, binary, dpkg, apk, Python, Docker exec, Docker label). Primary and fallback detectors cover both new and historical LXC layouts.",
"pushover": "Pushover joins Telegram, Gotify, Discord, Email and Apprise as a native notification channel — user/API key, device and sound selectors, priority 0 for regular messages, optional priority 1 for CRITICAL events. Suggested by @benginx (#308).",
"appsDashboard": "New top-level Apps dashboard — a single launcher for every Web Link across the node. LXC-registered apps and user-defined Custom Web Links share the same grid with category badges, search, and one-click deep-links back to the guest modal.",
"lxcAppsUpdates": "App tab inside every LXC modal registers installed apps, captures weblinks and tracks upstream versions. Reworked Updates tab applies OS packages and app updates from a single button; Docker Engine and per-image tracking follow the same 24-hour cycle, with a 'Check now' action on demand.",
"multilingual": "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.",
"nvidiaMultiGpu": "NVIDIA driver lifecycle moves to per-BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs, plus a kernel + branch + GPU-aware version picker (#298)."
}
},
"network": {
@@ -3525,7 +3547,7 @@
"wifi": "Wi-Fi",
"storage": "Stockage",
"storageController": "Contrôleur de stockage",
"hid": "CACHÉ",
"hid": "HID",
"vendorSpecific": "Spécifique au fournisseur",
"communications": "Communications",
"integrated": "Intégré",
@@ -4733,5 +4755,43 @@
"yourInput": "Votre contribution :",
"submit": "Soumettre",
"ok": "OK"
},
"apps": {
"searchPlaceholder": "Search app or CT…",
"searchAriaLabel": "Search",
"filterAll": "All categories",
"filterAriaLabel": "Filter by category",
"sortAriaLabel": "Sort by",
"sortName": "Name",
"sortId": "ID",
"sortCategory": "Category",
"countOne": "1 app",
"countMany": "{n} apps",
"uncategorized": "Uncategorized",
"openAriaLabel": "Open {name} in a new tab",
"openGuestAriaLabel": "Ouvrir {name} ({type} {id})",
"emptyTitle": "No apps with a web link yet.",
"emptyHint": "Register a Web Link for any app (App tab of a CT) to see it here.",
"customLinkAdd": "Add link",
"editModeToggle": "Edit",
"editModeDone": "Done",
"customLinkNewTitle": "New web link",
"customLinkEditTitle": "Edit web link",
"customLinkEditAria": "Modifier le lien personnalisé {name}",
"customLinkName": "Name",
"customLinkNamePlaceholder": "e.g. My app",
"customLinkUrl": "URL",
"customLinkLogo": "Logo URL (optional)",
"customLinkLogoPlaceholder": "e.g., https://example.com/logo.webp",
"customLinkCategory": "Category (optional)",
"customLinkBinding": "Bound to",
"customLinkBindingNone": "Not bound to any guest",
"customLinkBindingHelp": "Bind this link to a VM or CT to add its ID + name to the card and jump to the guest with one click.",
"customLinkSave": "Save",
"customLinkCreate": "Create",
"customLinkCancel": "Cancel",
"customLinkDelete": "Delete",
"customLinkSaveError": "Save failed",
"customLinkDeleteError": "Delete failed"
}
}
+67 -7
View File
@@ -31,6 +31,7 @@
},
"navigation": {
"overview": "Panoramica",
"apps": "Apps",
"storage": "Magazzinaggio",
"network": "Rete",
"virtualMachines": "VM e LXC",
@@ -1211,6 +1212,7 @@
"familyLabel": "Famiglia:",
"applyOsUpdate": "Applica l'aggiornamento del sistema operativo",
"osUpToDate": "Sistema operativo aggiornato",
"osStatusUnavailable": "Impossibile determinare lo stato degli aggiornamenti del sistema operativo.",
"installedByHelperPrefix": "Installato da",
"helperScriptsName": "Proxmox VE Helper-Scripts",
"helperUpdatesRun": "Installato da Proxmox Helper-Scripts.",
@@ -1369,7 +1371,7 @@
"methodApk": "pacchetto apk (Alpine)",
"methodBinary": "Binario (percorso assoluto o nome semplice)",
"methodFile": "file + espressione regolare",
"methodDockerLabel": "ispezione finestra mobile (etichetta OCI)",
"methodDockerLabel": "docker inspect (etichetta OCI)",
"methodDockerExec": "docker exec (binario nel contenitore)",
"methodPython": "distribuzione Python (importlib.metadata)",
"methodCommand": "comando (avanzato — argv)",
@@ -1435,6 +1437,11 @@
"portHttps": "https",
"portLogoLabel": "URL del logo per questo collegamento (facoltativo)",
"portLogoPlaceholder": "ad esempio, https://example.com/logo.webp",
"portCategoryPlaceholder": "Category for the Apps dashboard (optional)",
"portCategoryNone": "No category",
"portCategoryAddNew": "+ Add new category…",
"portCategoryCustomPlaceholder": "Type a category and press Enter (Esc to cancel)",
"portCustomUrlPlaceholder": "Custom URL (e.g. https://vault.example.com) — overrides IP:port",
"removePortTooltip": "Rimuovere la porta",
"detectMethodDpkg": "dpkg ·",
"detectMethodApk": "apk ·",
@@ -1484,7 +1491,7 @@
"managedApp": "Applicazione gestita",
"managedStatus": "gestito",
"checkedAt": "Controllato {date}",
"latestUpstream": "Ultima versione disponibile",
"latestUpstream": "Ultima",
"loadingApplications": "Caricamento applicazioni…",
"nameSearchPlaceholder": "Digita per cercare oltre 700 app oppure digita la tua",
"matchCount": "{count} corrisponde",
@@ -2045,7 +2052,18 @@
"title": "Capacità del pool ZFS",
"description": "Pool ZFS a livello di host, indipendenti dalla registrazione PVE, quindi vengono monitorati anche rpool e pool di backup dedicati."
}
}
},
"swapPressure": "Pressione memoria",
"swapPressureHint": "Segnala un evento critico solo quando ENTRAMBE le condizioni sono vere contemporaneamente — utilizzo del file di swap sopra la prima soglia E RAM disponibile sotto la seconda. Allertare solo sullo swap era rumoroso sugli host Proxmox dove Linux scambia pagine inattive mentre la RAM resta abbondantemente disponibile.",
"swapHighLabel": "Utilizzo del file di swap",
"availableMinLabel": "RAM disponibile minima"
},
"navOrder": {
"title": "Ordine di navigazione",
"description": "Personalizza l'ordine delle schede principali. Le schede raggruppate (Node, Admin) si spostano come un'unica unità; i loro elementi interni mantengono l'ordine predefinito.",
"reset": "Ripristina predefinito",
"hint": "Trascina per riordinare · Su touch, pressione prolungata prima",
"customActive": "Ordine di navigazione personalizzato attivo."
}
},
"login": {
@@ -2069,7 +2087,7 @@
"backupCodeHint": "Puoi anche utilizzare un codice di backup (formato: XXXX-XXXX)",
"backToLogin": "Torna al login",
"verifyCode": "Verifica codice",
"version": "ProxMenux Monitor v1.2.4.1-beta"
"version": "ProxMenux Monitor v1.2.5"
},
"account": {
"signedIn": "Effettuato l'accesso",
@@ -3120,8 +3138,12 @@
"currentFeatures": {
"appDetection": "Smarter app detection in the App tab: Docker is correctly promoted as the parent workload during cold start (Portainer/SearXNG no longer briefly show up as native apps), unregistered suggestions live in the startup cache, and 'Find applications' runs a fresh catalog-backed scan on demand.",
"dockerUpdates": "The Updates tab now covers Docker end-to-end: Docker Engine and per-image update tracking follow the same 24-hour rolling cycle as OS packages, with a 'Check now' action for on-demand digest comparison — no more waiting for the daily collector.",
"appCatalog": "New application detection catalog with over 380 tracked workloads, built from the live community-scripts source with independent script evidence. Primary and fallback detectors (file, binary, dpkg, apk, Python, Docker exec, Docker label) cover both new and historical LXC layouts.",
"pushover": "Pushover joins Telegram, Gotify, Discord, Email and Apprise as a native notification channel — user/API key, device and sound selectors, priority 0 for regular messages, optional priority 1 for CRITICAL events. Suggested by @benginx (#308)."
"appCatalog": "New application detection catalog with over 380 tracked workloads, generated live from community-scripts across seven detector methods (file, binary, dpkg, apk, Python, Docker exec, Docker label). Primary and fallback detectors cover both new and historical LXC layouts.",
"pushover": "Pushover joins Telegram, Gotify, Discord, Email and Apprise as a native notification channel — user/API key, device and sound selectors, priority 0 for regular messages, optional priority 1 for CRITICAL events. Suggested by @benginx (#308).",
"appsDashboard": "New top-level Apps dashboard — a single launcher for every Web Link across the node. LXC-registered apps and user-defined Custom Web Links share the same grid with category badges, search, and one-click deep-links back to the guest modal.",
"lxcAppsUpdates": "App tab inside every LXC modal registers installed apps, captures weblinks and tracks upstream versions. Reworked Updates tab applies OS packages and app updates from a single button; Docker Engine and per-image tracking follow the same 24-hour cycle, with a 'Check now' action on demand.",
"multilingual": "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.",
"nvidiaMultiGpu": "NVIDIA driver lifecycle moves to per-BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs, plus a kernel + branch + GPU-aware version picker (#298)."
}
},
"network": {
@@ -3525,7 +3547,7 @@
"wifi": "Wifi",
"storage": "Magazzinaggio",
"storageController": "Controllore di archiviazione",
"hid": "NASCOSTO",
"hid": "HID",
"vendorSpecific": "Specifico del fornitore",
"communications": "Comunicazioni",
"integrated": "Integrato",
@@ -4733,5 +4755,43 @@
"yourInput": "Il tuo contributo:",
"submit": "Invia",
"ok": "OK"
},
"apps": {
"searchPlaceholder": "Search app or CT…",
"searchAriaLabel": "Search",
"filterAll": "All categories",
"filterAriaLabel": "Filter by category",
"sortAriaLabel": "Sort by",
"sortName": "Name",
"sortId": "ID",
"sortCategory": "Category",
"countOne": "1 app",
"countMany": "{n} apps",
"uncategorized": "Uncategorized",
"openAriaLabel": "Open {name} in a new tab",
"openGuestAriaLabel": "Apri {name} ({type} {id})",
"emptyTitle": "No apps with a web link yet.",
"emptyHint": "Register a Web Link for any app (App tab of a CT) to see it here.",
"customLinkAdd": "Add link",
"editModeToggle": "Edit",
"editModeDone": "Done",
"customLinkNewTitle": "New web link",
"customLinkEditTitle": "Edit web link",
"customLinkEditAria": "Modifica il collegamento personalizzato {name}",
"customLinkName": "Name",
"customLinkNamePlaceholder": "e.g. My app",
"customLinkUrl": "URL",
"customLinkLogo": "Logo URL (optional)",
"customLinkLogoPlaceholder": "e.g., https://example.com/logo.webp",
"customLinkCategory": "Category (optional)",
"customLinkBinding": "Bound to",
"customLinkBindingNone": "Not bound to any guest",
"customLinkBindingHelp": "Bind this link to a VM or CT to add its ID + name to the card and jump to the guest with one click.",
"customLinkSave": "Save",
"customLinkCreate": "Create",
"customLinkCancel": "Cancel",
"customLinkDelete": "Delete",
"customLinkSaveError": "Save failed",
"customLinkDeleteError": "Delete failed"
}
}
+67 -7
View File
@@ -31,6 +31,7 @@
},
"navigation": {
"overview": "Visão geral",
"apps": "Apps",
"storage": "Armazenar",
"network": "Rede",
"virtualMachines": "VMs e LXCs",
@@ -1211,6 +1212,7 @@
"familyLabel": "Família:",
"applyOsUpdate": "Aplicar atualização do sistema operacional",
"osUpToDate": "SO atualizado",
"osStatusUnavailable": "Não foi possível determinar o estado das atualizações do sistema operativo.",
"installedByHelperPrefix": "Instalado por",
"helperScriptsName": "Proxmox VE Helper-Scripts",
"helperUpdatesRun": "Instalado pelo Proxmox Helper-Scripts.",
@@ -1369,7 +1371,7 @@
"methodApk": "pacote apk (Alpino)",
"methodBinary": "Binário (caminho absoluto ou nome simples)",
"methodFile": "arquivo + regex",
"methodDockerLabel": "docker inspecionar (etiqueta OCI)",
"methodDockerLabel": "docker inspect (etiqueta OCI)",
"methodDockerExec": "docker exec (binário no contêiner)",
"methodPython": "distribuição python (importlib.metadata)",
"methodCommand": "comando (avançado - argv)",
@@ -1435,6 +1437,11 @@
"portHttps": "https",
"portLogoLabel": "URL do logotipo deste link (opcional)",
"portLogoPlaceholder": "por exemplo, https://example.com/logo.webp",
"portCategoryPlaceholder": "Category for the Apps dashboard (optional)",
"portCategoryNone": "No category",
"portCategoryAddNew": "+ Add new category…",
"portCategoryCustomPlaceholder": "Type a category and press Enter (Esc to cancel)",
"portCustomUrlPlaceholder": "Custom URL (e.g. https://vault.example.com) — overrides IP:port",
"removePortTooltip": "Remover porta",
"detectMethodDpkg": "dpkg ·",
"detectMethodApk": "APK ·",
@@ -1484,7 +1491,7 @@
"managedApp": "Aplicativo gerenciado",
"managedStatus": "gerenciou",
"checkedAt": "Verificado {date}",
"latestUpstream": "Versão mais recente disponível",
"latestUpstream": "Última",
"loadingApplications": "Carregando aplicativos…",
"nameSearchPlaceholder": "Digite para pesquisar mais de 700 aplicativos ou digite o seu próprio",
"matchCount": "{count} corresponde",
@@ -2045,7 +2052,18 @@
"title": "Capacidade do pool ZFS",
"description": "Os pools ZFS no nível do host, independentemente do registro do PVE, portanto, o rpool e os pools de backup dedicados também são monitorados."
}
}
},
"swapPressure": "Pressão de memória",
"swapPressureHint": "Marca um evento crítico apenas quando AMBAS as condições forem verdadeiras ao mesmo tempo — uso do arquivo de swap acima do primeiro limite E RAM disponível abaixo do segundo. Alertar apenas por swap era ruidoso em hosts Proxmox onde o Linux troca páginas inativas enquanto a RAM permanece amplamente disponível.",
"swapHighLabel": "Uso do arquivo de swap",
"availableMinLabel": "RAM disponível mínima"
},
"navOrder": {
"title": "Ordem de navegação",
"description": "Personalize a ordem das abas principais. As abas agrupadas (Node, Admin) movem-se como uma unidade; seus itens internos mantêm a ordem padrão.",
"reset": "Restaurar padrão",
"hint": "Arraste para reordenar · No toque, pressione longamente primeiro",
"customActive": "Ordem de navegação personalizada ativa."
}
},
"login": {
@@ -2069,7 +2087,7 @@
"backupCodeHint": "Você também pode usar um código de backup (formato: XXXX-XXXX)",
"backToLogin": "Voltar ao login",
"verifyCode": "Verifique o código",
"version": "ProxMenux Monitor v1.2.4.1-beta"
"version": "ProxMenux Monitor v1.2.5"
},
"account": {
"signedIn": "Conectado",
@@ -3120,8 +3138,12 @@
"currentFeatures": {
"appDetection": "Smarter app detection in the App tab: Docker is correctly promoted as the parent workload during cold start (Portainer/SearXNG no longer briefly show up as native apps), unregistered suggestions live in the startup cache, and 'Find applications' runs a fresh catalog-backed scan on demand.",
"dockerUpdates": "The Updates tab now covers Docker end-to-end: Docker Engine and per-image update tracking follow the same 24-hour rolling cycle as OS packages, with a 'Check now' action for on-demand digest comparison — no more waiting for the daily collector.",
"appCatalog": "New application detection catalog with over 380 tracked workloads, built from the live community-scripts source with independent script evidence. Primary and fallback detectors (file, binary, dpkg, apk, Python, Docker exec, Docker label) cover both new and historical LXC layouts.",
"pushover": "Pushover joins Telegram, Gotify, Discord, Email and Apprise as a native notification channel — user/API key, device and sound selectors, priority 0 for regular messages, optional priority 1 for CRITICAL events. Suggested by @benginx (#308)."
"appCatalog": "New application detection catalog with over 380 tracked workloads, generated live from community-scripts across seven detector methods (file, binary, dpkg, apk, Python, Docker exec, Docker label). Primary and fallback detectors cover both new and historical LXC layouts.",
"pushover": "Pushover joins Telegram, Gotify, Discord, Email and Apprise as a native notification channel — user/API key, device and sound selectors, priority 0 for regular messages, optional priority 1 for CRITICAL events. Suggested by @benginx (#308).",
"appsDashboard": "New top-level Apps dashboard — a single launcher for every Web Link across the node. LXC-registered apps and user-defined Custom Web Links share the same grid with category badges, search, and one-click deep-links back to the guest modal.",
"lxcAppsUpdates": "App tab inside every LXC modal registers installed apps, captures weblinks and tracks upstream versions. Reworked Updates tab applies OS packages and app updates from a single button; Docker Engine and per-image tracking follow the same 24-hour cycle, with a 'Check now' action on demand.",
"multilingual": "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.",
"nvidiaMultiGpu": "NVIDIA driver lifecycle moves to per-BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs, plus a kernel + branch + GPU-aware version picker (#298)."
}
},
"network": {
@@ -3525,7 +3547,7 @@
"wifi": "Wi-fi",
"storage": "Armazenar",
"storageController": "Controlador de armazenamento",
"hid": "ESCONDIDO",
"hid": "HID",
"vendorSpecific": "Específico do fornecedor",
"communications": "Comunicações",
"integrated": "Integrado",
@@ -4733,5 +4755,43 @@
"yourInput": "Sua entrada:",
"submit": "Enviar",
"ok": "OK"
},
"apps": {
"searchPlaceholder": "Search app or CT…",
"searchAriaLabel": "Search",
"filterAll": "All categories",
"filterAriaLabel": "Filter by category",
"sortAriaLabel": "Sort by",
"sortName": "Name",
"sortId": "ID",
"sortCategory": "Category",
"countOne": "1 app",
"countMany": "{n} apps",
"uncategorized": "Uncategorized",
"openAriaLabel": "Open {name} in a new tab",
"openGuestAriaLabel": "Abrir {name} ({type} {id})",
"emptyTitle": "No apps with a web link yet.",
"emptyHint": "Register a Web Link for any app (App tab of a CT) to see it here.",
"customLinkAdd": "Add link",
"editModeToggle": "Edit",
"editModeDone": "Done",
"customLinkNewTitle": "New web link",
"customLinkEditTitle": "Edit web link",
"customLinkEditAria": "Editar o link personalizado {name}",
"customLinkName": "Name",
"customLinkNamePlaceholder": "e.g. My app",
"customLinkUrl": "URL",
"customLinkLogo": "Logo URL (optional)",
"customLinkLogoPlaceholder": "e.g., https://example.com/logo.webp",
"customLinkCategory": "Category (optional)",
"customLinkBinding": "Bound to",
"customLinkBindingNone": "Not bound to any guest",
"customLinkBindingHelp": "Bind this link to a VM or CT to add its ID + name to the card and jump to the guest with one click.",
"customLinkSave": "Save",
"customLinkCreate": "Create",
"customLinkCancel": "Cancel",
"customLinkDelete": "Delete",
"customLinkSaveError": "Save failed",
"customLinkDeleteError": "Delete failed"
}
}
+65 -5
View File
@@ -30,6 +30,7 @@
},
"navigation": {
"overview": "Prehľad",
"apps": "Apps",
"storage": "Úložiská",
"network": "Sieť",
"virtualMachines": "VM a LXC",
@@ -1210,6 +1211,7 @@
"familyLabel": "Systém:",
"applyOsUpdate": "Aktualizovať systém",
"osUpToDate": "Systém je aktuálny",
"osStatusUnavailable": "Stav aktualizácií operačného systému sa nepodarilo zistiť.",
"installedByHelperPrefix": "Nainštalované cez",
"helperScriptsName": "Proxmox VE Helper-Scripts",
"helperUpdatesRun": "Inštalované pomocou Proxmox Helper-Scripts.",
@@ -1434,6 +1436,11 @@
"portHttps": "https",
"portLogoLabel": "URL loga pre tento odkaz (voliteľné)",
"portLogoPlaceholder": "napr. https://example.com/logo.webp",
"portCategoryPlaceholder": "Category for the Apps dashboard (optional)",
"portCategoryNone": "No category",
"portCategoryAddNew": "+ Add new category…",
"portCategoryCustomPlaceholder": "Type a category and press Enter (Esc to cancel)",
"portCustomUrlPlaceholder": "Custom URL (e.g. https://vault.example.com) — overrides IP:port",
"removePortTooltip": "Odstrániť port",
"detectMethodDpkg": "dpkg ·",
"detectMethodApk": "apk ·",
@@ -1483,7 +1490,7 @@
"managedApp": "Spravovaná aplikácia",
"managedStatus": "spravované",
"checkedAt": "Skontrolované {date}",
"latestUpstream": "Najnovšia dostupná",
"latestUpstream": "Najnovšia",
"loadingApplications": "Načítavam aplikácie…",
"nameSearchPlaceholder": "Vyhľadajte medzi viac ako 700 aplikáciami alebo zadajte vlastný názov",
"matchCount": "Počet výsledkov: {count}",
@@ -2044,7 +2051,18 @@
"title": "Kapacita ZFS poolov",
"description": "ZFS pooly na úrovni hosta, nezávisle od registrácie v PVE. Sleduje sa teda aj rpool a samostatné zálohovacie pooly."
}
}
},
"swapPressure": "Tlak na pamäť",
"swapPressureHint": "Označí kritickú udalosť len keď platia OBIDVE podmienky súčasne — využitie súboru swap nad prvým limitom A dostupná RAM pod druhým. Upozorňovanie iba na swap bolo hlučné na hostiteľoch Proxmox, kde Linux odsúva neaktívne stránky, kým RAM zostáva dostatočne dostupná.",
"swapHighLabel": "Využitie súboru swap",
"availableMinLabel": "Minimálna dostupná RAM"
},
"navOrder": {
"title": "Poradie navigácie",
"description": "Prispôsobte poradie hlavných záložiek. Zoskupené záložky (Node, Admin) sa presúvajú ako jedna jednotka; ich vnútorné položky si zachovávajú predvolené poradie.",
"reset": "Obnoviť predvolené",
"hint": "Presúvajte pre zmenu poradia · Na dotyku najprv podržte",
"customActive": "Používa sa vlastné poradie navigácie."
}
},
"login": {
@@ -2068,7 +2086,7 @@
"backupCodeHint": "Môžete použiť aj záložný kód vo formáte XXXX-XXXX",
"backToLogin": "Späť na prihlásenie",
"verifyCode": "Overiť kód",
"version": "ProxMenux Monitor v1.2.4.1-beta"
"version": "ProxMenux Monitor v1.2.5"
},
"account": {
"signedIn": "Prihlásený",
@@ -3119,8 +3137,12 @@
"currentFeatures": {
"appDetection": "Inteligentnejšie rozpoznávanie aplikácií na karte Aplikácie: pri štarte sa Docker správne rozpozná ako nadradené prostredie (Portainer/SearXNG sa už na chvíľu nezobrazia ako samostatné aplikácie), neregistrované návrhy ostávajú vo vyrovnávacej pamäti po štarte a tlačidlo „Nájsť aplikácie“ spustí nový sken podľa katalógu.",
"dockerUpdates": "Karta Aktualizácie teraz naplno podporuje Docker: Docker Engine aj jednotlivé obrazy sa kontrolujú v rovnakom 24-hodinovom cykle ako balíky systému. Tlačidlo „Skontrolovať teraz“ overí aktuálne digesty hneď, bez čakania na dennú kontrolu.",
"appCatalog": "Nový katalóg na rozpoznávanie aplikácií sleduje viac ako 380 služieb a vychádza priamo z community-scripts, overených nezávislými údajmi zo skriptov. Hlavné aj záložné metódy rozpoznania (súbor, binárka, dpkg, apk, Python, Docker exec, Docker label) pokrývajú nové aj staršie rozloženia LXC.",
"pushover": "Pushover je teraz natívny kanál upozornení spolu s Telegramom, Gotify, Discordom, e-mailom a Apprise — nastavíte kľúč používateľa/API, zariadenie a zvuk; bežné správy majú prioritu 0, pri CRITICAL udalostiach môžete zapnúť prioritu 1. Navrhol @benginx (#308)."
"appCatalog": "Nový katalóg detekcie aplikácií s viac ako 380 sledovanými pracovnými záťažami, generovaný naživo z community-scripts pomocou siedmich detekčných metód (súbor, binárka, dpkg, apk, Python, Docker exec, Docker label). Primárne a záložné detektory pokrývajú nové aj historické rozloženia LXC.",
"pushover": "Pushover je teraz natívny kanál upozornení spolu s Telegramom, Gotify, Discordom, e-mailom a Apprise — nastavíte kľúč používateľa/API, zariadenie a zvuk; bežné správy majú prioritu 0, pri CRITICAL udalostiach môžete zapnúť prioritu 1. Navrhol @benginx (#308).",
"appsDashboard": "Nová hlavná karta Apps — jednotný spúšťač pre každý webový odkaz v uzle. Aplikácie zaregistrované v LXC a používateľské vlastné webové odkazy zdieľajú rovnakú mriežku s kategóriami, vyhľadávaním a priamym prístupom do modálu hostiteľa.",
"lxcAppsUpdates": "Karta App v modáli každého LXC registruje nainštalované aplikácie, zachytáva webové odkazy a sleduje verzie. Prepracovaná karta Updates aplikuje aktualizácie OS a aplikácií jediným tlačidlom; Docker Engine a jednotlivé image sledujú rovnaký 24-hodinový cyklus s akciou 'Skontrolovať teraz' na požiadanie.",
"multilingual": "Monitor teraz hovorí 8 jazykmi: angličtina, španielčina, nemčina, francúzština, taliančina, portugalčina, švédčina a slovenčina. Veľká vďaka patrí @vaso73 za vybudovanie i18n základov.",
"nvidiaMultiGpu": "Životný cyklus NVIDIA driverov prechádza na vlastníctvo podľa presného BDF, takže multi-GPU hostiteľ môže odovzdať jednu kartu do VM a druhú nechať funkčnú v hostiteľovi alebo v LXC, plus výber verzie citlivý na kernel, vetvu a GPU (#298)."
}
},
"network": {
@@ -4733,5 +4755,43 @@
"yourInput": "Tvoj vstup:",
"submit": "Odoslať",
"ok": "OK"
},
"apps": {
"searchPlaceholder": "Search app or CT…",
"searchAriaLabel": "Search",
"filterAll": "All categories",
"filterAriaLabel": "Filter by category",
"sortAriaLabel": "Sort by",
"sortName": "Name",
"sortId": "ID",
"sortCategory": "Category",
"countOne": "1 app",
"countMany": "{n} apps",
"uncategorized": "Uncategorized",
"openAriaLabel": "Open {name} in a new tab",
"openGuestAriaLabel": "Otvoriť {name} ({type} {id})",
"emptyTitle": "No apps with a web link yet.",
"emptyHint": "Register a Web Link for any app (App tab of a CT) to see it here.",
"customLinkAdd": "Add link",
"editModeToggle": "Edit",
"editModeDone": "Done",
"customLinkNewTitle": "New web link",
"customLinkEditTitle": "Edit web link",
"customLinkEditAria": "Upraviť vlastný odkaz {name}",
"customLinkName": "Name",
"customLinkNamePlaceholder": "e.g. My app",
"customLinkUrl": "URL",
"customLinkLogo": "Logo URL (optional)",
"customLinkLogoPlaceholder": "e.g., https://example.com/logo.webp",
"customLinkCategory": "Category (optional)",
"customLinkBinding": "Bound to",
"customLinkBindingNone": "Not bound to any guest",
"customLinkBindingHelp": "Bind this link to a VM or CT to add its ID + name to the card and jump to the guest with one click.",
"customLinkSave": "Save",
"customLinkCreate": "Create",
"customLinkCancel": "Cancel",
"customLinkDelete": "Delete",
"customLinkSaveError": "Save failed",
"customLinkDeleteError": "Delete failed"
}
}
+66 -6
View File
@@ -31,6 +31,7 @@
},
"navigation": {
"overview": "Översikt",
"apps": "Apps",
"storage": "Lagring",
"network": "Nätverk",
"virtualMachines": "VM och LXC",
@@ -1211,6 +1212,7 @@
"familyLabel": "Familj:",
"applyOsUpdate": "Använd OS-uppdatering",
"osUpToDate": "OS uppdaterat",
"osStatusUnavailable": "Statusen för operativsystemets uppdateringar kunde inte fastställas.",
"installedByHelperPrefix": "Installerad av",
"helperScriptsName": "Proxmox VE Helper-Scripts",
"helperUpdatesRun": "Installerad av Proxmox Helper-Scripts.",
@@ -1369,7 +1371,7 @@
"methodApk": "apk-paket (alpint)",
"methodBinary": "Binär (absolut sökväg eller bara namn)",
"methodFile": "fil + regex",
"methodDockerLabel": "hamnarbesiktning (OCI-etikett)",
"methodDockerLabel": "docker inspect (OCI-etikett)",
"methodDockerExec": "docker exec (binär i container)",
"methodPython": "pythondistribution (importlib.metadata)",
"methodCommand": "kommando (avancerat — argv)",
@@ -1435,6 +1437,11 @@
"portHttps": "https",
"portLogoLabel": "Logotyp URL för denna länk (valfritt)",
"portLogoPlaceholder": "t.ex. https://example.com/logo.webp",
"portCategoryPlaceholder": "Category for the Apps dashboard (optional)",
"portCategoryNone": "No category",
"portCategoryAddNew": "+ Add new category…",
"portCategoryCustomPlaceholder": "Type a category and press Enter (Esc to cancel)",
"portCustomUrlPlaceholder": "Custom URL (e.g. https://vault.example.com) — overrides IP:port",
"removePortTooltip": "Ta bort porten",
"detectMethodDpkg": "dpkg ·",
"detectMethodApk": "apk ·",
@@ -1484,7 +1491,7 @@
"managedApp": "Hanterad app",
"managedStatus": "lyckades",
"checkedAt": "Markerade {date}",
"latestUpstream": "Senaste tillgängliga version",
"latestUpstream": "Senaste",
"loadingApplications": "Laddar appar...",
"nameSearchPlaceholder": "Skriv för att söka efter 700+ appar, eller skriv din egen",
"matchCount": "{count} matchar",
@@ -2045,7 +2052,18 @@
"title": "ZFS poolkapacitet",
"description": "ZFS-pooler på värdnivå, oberoende av PVE-registrering, så rpool och dedikerade backuppooler övervakas också."
}
}
},
"swapPressure": "Minnestryck",
"swapPressureHint": "Markerar en kritisk händelse endast när BÅDA villkoren är sanna samtidigt — swap-filanvändning över första tröskeln OCH tillgängligt RAM under andra. Att larma enbart på swap var störande på Proxmox-värdar där Linux växlar ut inaktiva sidor medan RAM förblir rikligt tillgängligt.",
"swapHighLabel": "Swap-filanvändning",
"availableMinLabel": "Minsta tillgängliga RAM"
},
"navOrder": {
"title": "Navigeringsordning",
"description": "Anpassa ordningen på huvudflikarna. Grupperade flikar (Node, Admin) flyttas som en enhet; deras interna objekt behåller sin standardordning.",
"reset": "Återställ standard",
"hint": "Dra för att ändra ordning · På touch, håll länge först",
"customActive": "Anpassad navigeringsordning aktiv."
}
},
"login": {
@@ -2069,7 +2087,7 @@
"backupCodeHint": "Du kan också använda en reservkod (format: XXXX-XXXX)",
"backToLogin": "Tillbaka till inloggning",
"verifyCode": "Verifiera koden",
"version": "ProxMenux Monitor v1.2.4.1-beta"
"version": "ProxMenux Monitor v1.2.5"
},
"account": {
"signedIn": "Inloggad",
@@ -3120,8 +3138,12 @@
"currentFeatures": {
"appDetection": "Smarter app detection in the App tab: Docker is correctly promoted as the parent workload during cold start (Portainer/SearXNG no longer briefly show up as native apps), unregistered suggestions live in the startup cache, and 'Find applications' runs a fresh catalog-backed scan on demand.",
"dockerUpdates": "The Updates tab now covers Docker end-to-end: Docker Engine and per-image update tracking follow the same 24-hour rolling cycle as OS packages, with a 'Check now' action for on-demand digest comparison — no more waiting for the daily collector.",
"appCatalog": "New application detection catalog with over 380 tracked workloads, built from the live community-scripts source with independent script evidence. Primary and fallback detectors (file, binary, dpkg, apk, Python, Docker exec, Docker label) cover both new and historical LXC layouts.",
"pushover": "Pushover joins Telegram, Gotify, Discord, Email and Apprise as a native notification channel — user/API key, device and sound selectors, priority 0 for regular messages, optional priority 1 for CRITICAL events. Suggested by @benginx (#308)."
"appCatalog": "Ny katalog för appdetektering med över 380 spårade arbetsuppgifter, genererad live från community-scripts via sju detektionsmetoder (fil, binär, dpkg, apk, Python, Docker exec, Docker label). Primära och reservdetektorer täcker både nya och historiska LXC-layouter.",
"pushover": "Pushover joins Telegram, Gotify, Discord, Email and Apprise as a native notification channel — user/API key, device and sound selectors, priority 0 for regular messages, optional priority 1 for CRITICAL events. Suggested by @benginx (#308).",
"appsDashboard": "Ny huvudflik Apps — en enda startpunkt för varje webblänk på noden. LXC-registrerade appar och användardefinierade Custom Web Links delar samma rutnät med kategori-taggar, sökning och direktlänk till gästens modal.",
"lxcAppsUpdates": "App-fliken inuti varje LXC-modal registrerar installerade appar, fångar webblänkar och spårar uppströmsversioner. Omarbetad Updates-flik applicerar OS-paket och appuppdateringar med en enda knapp; Docker Engine och per-image följer samma 24-timmarscykel med en 'Kontrollera nu'-åtgärd på begäran.",
"multilingual": "Monitorn talar nu 8 språk: engelska, spanska, tyska, franska, italienska, portugisiska, svenska och slovakiska. Ett stort tack till @vaso73 för att ha byggt i18n-grunden.",
"nvidiaMultiGpu": "NVIDIA-driverns livscykel går över till ägarskap per exakt BDF, så att en multi-GPU-värd kan skicka ett kort till en VM och behålla det andra operativt på värden eller i LXC, plus en versionsväljare som är medveten om kärna, gren och GPU (#298)."
}
},
"network": {
@@ -4734,5 +4756,43 @@
"yourInput": "Din inmatning:",
"submit": "Skicka",
"ok": "OK"
},
"apps": {
"searchPlaceholder": "Search app or CT…",
"searchAriaLabel": "Search",
"filterAll": "All categories",
"filterAriaLabel": "Filter by category",
"sortAriaLabel": "Sort by",
"sortName": "Name",
"sortId": "ID",
"sortCategory": "Category",
"countOne": "1 app",
"countMany": "{n} apps",
"uncategorized": "Uncategorized",
"openAriaLabel": "Open {name} in a new tab",
"openGuestAriaLabel": "Öppna {name} ({type} {id})",
"emptyTitle": "No apps with a web link yet.",
"emptyHint": "Register a Web Link for any app (App tab of a CT) to see it here.",
"customLinkAdd": "Add link",
"editModeToggle": "Edit",
"editModeDone": "Done",
"customLinkNewTitle": "New web link",
"customLinkEditTitle": "Edit web link",
"customLinkEditAria": "Redigera den anpassade länken {name}",
"customLinkName": "Name",
"customLinkNamePlaceholder": "e.g. My app",
"customLinkUrl": "URL",
"customLinkLogo": "Logo URL (optional)",
"customLinkLogoPlaceholder": "e.g., https://example.com/logo.webp",
"customLinkCategory": "Category (optional)",
"customLinkBinding": "Bound to",
"customLinkBindingNone": "Not bound to any guest",
"customLinkBindingHelp": "Bind this link to a VM or CT to add its ID + name to the card and jump to the guest with one click.",
"customLinkSave": "Save",
"customLinkCreate": "Create",
"customLinkCancel": "Cancel",
"customLinkDelete": "Delete",
"customLinkSaveError": "Save failed",
"customLinkDeleteError": "Delete failed"
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ProxMenux-Monitor",
"version": "1.2.4.2-beta",
"version": "1.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ProxMenux-Monitor",
"version": "1.2.4.2-beta",
"version": "1.2.5",
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "1.2.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ProxMenux-Monitor",
"version": "1.2.4.2-beta",
"version": "1.2.5",
"description": "Proxmox System Monitoring Dashboard",
"private": true,
"scripts": {
+1
View File
@@ -128,6 +128,7 @@ cp "$SCRIPT_DIR/smartctl_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "
cp "$SCRIPT_DIR/health_thresholds.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_thresholds.py not found"
cp "$SCRIPT_DIR/managed_installs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ managed_installs.py not found"
cp "$SCRIPT_DIR/lxc_apps.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_apps.py not found"
cp "$SCRIPT_DIR/custom_links.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ custom_links.py not found"
cp "$SCRIPT_DIR/recreate_docker_container.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ recreate_docker_container.py not found"
chmod +x "$APP_DIR/usr/bin/recreate_docker_container.py" 2>/dev/null || true
cp "$SCRIPT_DIR/update_docker_engine.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ update_docker_engine.py not found"
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""User-defined web links surfaced in the Apps dashboard alongside
LXC-registered apps. Kept in a single sidecar
(/etc/proxmenux/custom_links.json) because the collection is small,
global, and never bound to a specific guest by ProxMenux itself.
Schema of each entry
--------------------
{
"id": "<uuid4>",
"name": "<display name>", # required
"url": "<http(s) URL>", # required
"logo_url": "<http(s) URL or ''>", # optional
"category": "<free text or ''>", # optional
"binding": { # optional; null when unbound
"vmid": <int>,
"guest_type": "lxc" | "qemu"
},
"created_at": <unix ts>,
"updated_at": <unix ts>
}
Design notes
------------
* One file (not per-VM). Volume is small; unbound links have no natural
home; global lookups are O(N) with N tiny.
* All writes go through `save_all` which does the classic
write-temp+rename dance so a crash mid-save can't corrupt the file.
* Validation is strict at the boundary the frontend can send whatever;
the backend refuses anything malformed. Fields that survive are
exactly the schema above; unknown keys are dropped silently.
"""
from __future__ import annotations
import json
import os
import re
import threading
import time
import uuid
from typing import Any, Optional
_CUSTOM_LINKS_PATH = "/etc/proxmenux/custom_links.json"
_lock = threading.RLock()
# In-memory copy of the full list. Populated on first read (or by
# `warmup()` at Monitor startup) and refreshed only when a write goes
# through this module. The sidecar file is our source of truth; the
# cache exists so `/api/apps/custom-links` doesn't hit disk on every
# request. Reads always return a fresh copy so callers can't mutate
# the cached state by accident.
_cached_entries: Optional[list[dict]] = None
# Same character set / max length as the LXC-app editor uses so users
# don't have to learn two different rulesets.
_NAME_RE = re.compile(r"^[\w\s._+\-()/:,&]{1,80}$", re.UNICODE)
_URL_RE = re.compile(r"^https?://[\w\-._~:/?#\[\]@!$&'()*+,;=%]{1,510}$")
_CATEGORY_RE = re.compile(r"^[\w\s&/,.\-*+()]{1,60}$", re.UNICODE)
_UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
_GUEST_TYPES = frozenset({"lxc", "qemu"})
def _err(msg: str) -> tuple[bool, str]:
return False, msg
# ── Persistence ────────────────────────────────────────────────────
def _read_from_disk() -> list[dict]:
"""Actually parse the sidecar file. A missing/empty/corrupt file
returns [] we never let bad JSON take down the whole Apps
dashboard, the user's other data is fine."""
try:
with open(_CUSTOM_LINKS_PATH, encoding="utf-8") as f:
raw = json.load(f)
except (FileNotFoundError, PermissionError):
return []
except (OSError, ValueError):
return []
if not isinstance(raw, list):
return []
return [entry for entry in raw if isinstance(entry, dict)]
def load_all() -> list[dict]:
"""Return the current list of custom links from the in-memory
cache. First call after a Monitor restart pays one disk read
(~1 ms); every subsequent call is a memory op. Writes go through
`save_all` which also refreshes the cache, so callers never see
stale data.
"""
global _cached_entries
with _lock:
if _cached_entries is None:
_cached_entries = _read_from_disk()
return [dict(entry) for entry in _cached_entries]
def warmup() -> int:
"""Force the cache to populate now. Invoked from Monitor startup
so the very first `/api/apps/custom-links` request is served
straight from memory. Returns the entry count for the log line."""
global _cached_entries
with _lock:
_cached_entries = _read_from_disk()
return len(_cached_entries)
def save_all(entries: list[dict]) -> None:
"""Persist the full list. Write-temp+rename so a crash cannot
leave a half-written JSON on disk. Also refreshes the in-memory
cache so the next `load_all` returns the new state without a
disk read. Caller must have validated every entry this
function trusts its input and writes verbatim.
"""
global _cached_entries
directory = os.path.dirname(_CUSTOM_LINKS_PATH)
os.makedirs(directory, exist_ok=True)
payload = json.dumps(entries, ensure_ascii=False, indent=2)
with _lock:
tmp = f"{_CUSTOM_LINKS_PATH}.tmp.{os.getpid()}"
try:
with open(tmp, "w", encoding="utf-8") as f:
f.write(payload)
f.write("\n")
os.replace(tmp, _CUSTOM_LINKS_PATH)
_cached_entries = [dict(e) for e in entries]
finally:
try:
if os.path.exists(tmp):
os.remove(tmp)
except OSError:
pass
# ── Validation ─────────────────────────────────────────────────────
def _validate_binding(raw: Any) -> tuple[bool, Any]:
"""Accepts either null (unbound) or {vmid, guest_type}. Coerces
vmid to int and guest_type to one of the allowed literals."""
if raw in (None, "", {}):
return True, None
if not isinstance(raw, dict):
return _err("binding must be an object with {vmid, guest_type}")
vmid_raw = raw.get("vmid")
try:
vmid = int(vmid_raw)
except (TypeError, ValueError):
return _err("binding.vmid must be an integer")
if not (0 < vmid <= 999_999_999):
return _err("binding.vmid out of range")
guest_type = (raw.get("guest_type") or "").strip().lower()
if guest_type not in _GUEST_TYPES:
return _err("binding.guest_type must be 'lxc' or 'qemu'")
return True, {"vmid": vmid, "guest_type": guest_type}
def validate_entry(raw: Any, existing_id: Optional[str] = None) -> tuple[bool, Any]:
"""Validate a single link payload from the API layer. Returns
(True, sanitised_dict) or (False, error_string). Fields absent in
the input default to safe values; unknown keys are ignored."""
if not isinstance(raw, dict):
return _err("payload must be a JSON object")
name = (raw.get("name") or "").strip()
if not name:
return _err("name is required")
if not _NAME_RE.match(name):
return _err("name contains invalid characters or exceeds 80 chars")
url = (raw.get("url") or "").strip()
if not url:
return _err("url is required")
if not _URL_RE.match(url):
return _err("url must be an http(s) URL (max 512 chars)")
logo_url = (raw.get("logo_url") or "").strip()
if logo_url and not _URL_RE.match(logo_url):
return _err("logo_url must be an http(s) URL (max 512 chars)")
category = (raw.get("category") or "").strip()
if category and not _CATEGORY_RE.match(category):
return _err("category contains invalid characters or exceeds 60 chars")
ok, binding = _validate_binding(raw.get("binding"))
if not ok:
return _err(binding)
entry_id = existing_id or raw.get("id") or str(uuid.uuid4())
if not _UUID_RE.match(entry_id):
entry_id = str(uuid.uuid4())
now = int(time.time())
return True, {
"id": entry_id,
"name": name,
"url": url,
"logo_url": logo_url,
"category": category,
"binding": binding,
"created_at": int(raw.get("created_at") or now),
"updated_at": now,
}
# ── CRUD helpers used by the Flask endpoints ───────────────────────
def create(payload: dict) -> tuple[bool, Any]:
"""Add a new link. Assigns a fresh UUID and appends to the file."""
ok, entry = validate_entry(payload)
if not ok:
return False, entry
with _lock:
current = load_all()
current.append(entry)
save_all(current)
return True, entry
def update(link_id: str, payload: dict) -> tuple[bool, Any]:
"""Replace one link by id. 404 if the id is unknown."""
if not _UUID_RE.match(link_id or ""):
return _err("invalid link id")
with _lock:
current = load_all()
for i, existing in enumerate(current):
if existing.get("id") == link_id:
merged = dict(existing)
merged.update(payload)
merged["id"] = link_id # id is immutable
merged["created_at"] = existing.get("created_at")
ok, entry = validate_entry(merged, existing_id=link_id)
if not ok:
return False, entry
current[i] = entry
save_all(current)
return True, entry
return _err("link not found")
def delete(link_id: str) -> tuple[bool, Any]:
"""Remove one link by id. Idempotent — deleting an unknown id
returns success so the UI doesn't have to distinguish."""
if not _UUID_RE.match(link_id or ""):
return _err("invalid link id")
with _lock:
current = load_all()
remaining = [e for e in current if e.get("id") != link_id]
if len(remaining) != len(current):
save_all(remaining)
return True, {"deleted": link_id}
def purge_binding_for_vmid(vmid: int) -> int:
"""Clear the `binding` on every link that pointed to a guest that
no longer exists. Called from the guest lifecycle hook when a VM
or CT is destroyed so the dashboard never surfaces a dead ID.
Returns the number of links updated (0 or more)."""
try:
target = int(vmid)
except (TypeError, ValueError):
return 0
changed = 0
with _lock:
current = load_all()
for entry in current:
binding = entry.get("binding") or {}
if isinstance(binding, dict) and binding.get("vmid") == target:
entry["binding"] = None
entry["updated_at"] = int(time.time())
changed += 1
if changed:
save_all(current)
return changed
+192
View File
@@ -609,6 +609,73 @@ def parse_lxc_hardware_config(vmid, node):
return hardware_info
def _get_lxc_primary_ip_cached(vmid):
"""Return the LXC's primary non-Docker IP with an indefinite
cache. First read per CT spawns one `lxc-info` subprocess;
subsequent reads are free until the CT's lifecycle event drops
the entry via `_invalidate_lxc_ip`. A running CT's IP doesn't
change on its own the invalidation on start/stop/reboot is the
only path that requires re-probing.
"""
try:
vmid_int = int(vmid)
except (TypeError, ValueError):
return None
if vmid_int in _lxc_ip_cache:
return _lxc_ip_cache[vmid_int]
info = get_lxc_ip_from_lxc_info(vmid_int)
ip = None
if info:
ip = info.get('primary_ip') or (info.get('real_ips') or [None])[0]
_lxc_ip_cache[vmid_int] = ip
return ip
def _invalidate_lxc_ip(vmid):
"""Drop the cached IP so the next request re-probes `lxc-info`.
Fired from the guest lifecycle handler on start/stop/reboot."""
try:
_lxc_ip_cache.pop(int(vmid), None)
except (TypeError, ValueError):
pass
def _warmup_lxc_ip_cache() -> int:
"""Populate the LXC IP cache for every running CT at Monitor
startup. After this runs, /api/vms serves the IPs from memory
without spawning `lxc-info` on the request path the cache only
changes when a CT's lifecycle event (start/stop/reboot) fires the
invalidator. Returns the count for the startup log line.
"""
try:
result = subprocess.run(
['/usr/sbin/pct', 'list'],
capture_output=True, text=True, timeout=10,
)
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return 0
if result.returncode != 0:
return 0
count = 0
for line in result.stdout.splitlines()[1:]:
parts = line.split()
if len(parts) < 2:
continue
try:
vmid_int = int(parts[0])
except ValueError:
continue
if parts[1].lower() != 'running':
continue
info = get_lxc_ip_from_lxc_info(vmid_int)
ip = None
if info:
ip = info.get('primary_ip') or (info.get('real_ips') or [None])[0]
_lxc_ip_cache[vmid_int] = ip
count += 1
return count
def get_lxc_ip_from_lxc_info(vmid):
"""Get LXC IP addresses using lxc-info command (for DHCP containers)
Returns a dict with all IPs and classification"""
@@ -1634,6 +1701,11 @@ _vm_apps_cache: dict = {} # vmid -> (ts, payload)
_vm_app_suggestions_cache: dict = {} # vmid -> (ts, payload)
_vm_schedule_cache: dict = {} # vmid -> (ts, payload)
_vm_mounts_cache: dict = {} # vmid -> (ts, payload) — LXC only
# LXC primary IP cache — populated on first read, held indefinitely.
# A running CT's IP doesn't change; the cache is invalidated only when
# the CT's lifecycle event fires (start/stop/reboot), so no periodic
# polling is needed. See `_handle_guest_lifecycle`.
_lxc_ip_cache: dict = {}
# Effective TTL is "indefinite": these caches are refreshed only
# by explicit event-based invalidation (`_vm_cache_invalidate` calls
# on start/stop/reboot, add/edit/delete app, apply update, edit
@@ -1942,6 +2014,11 @@ def _handle_guest_lifecycle(vmid: str, vm_type: str, action: str) -> None:
_pvesh_cache['cluster_resources_vm_time'] = 0
_vm_cache_invalidate(guest_id)
_vm_disk_cache.pop(guest_id, None)
# LXC IP can only change when the CT restarts (fresh DHCP lease)
# or stops; drop the cached IP so the next /api/vms poll re-reads
# it via `lxc-info`. QEMU guests do not touch this cache.
if guest_type == 'lxc':
_invalidate_lxc_ip(guest_id)
if action in ('start', 'reboot'):
_schedule_started_guest_refresh(guest_id, guest_type)
return
@@ -6506,6 +6583,15 @@ def get_proxmox_vms():
app_list = lxc_app_map.get(str(resource.get('vmid')))
if app_list:
vm_data['app_watches'] = app_list
# Apps dashboard reads this to build
# weblinks. Only paid on CTs that have
# registered apps; the IP is cached
# indefinitely and invalidated by the
# guest lifecycle hook on start/stop/reboot.
if vm_type == 'lxc' and resource.get('status') == 'running':
_ip = _get_lxc_primary_ip_cached(resource.get('vmid'))
if _ip:
vm_data['ip'] = _ip
docker_inventory = lxc_docker_map.get(str(resource.get('vmid')))
# Docker image drift is an Updates-tab feature,
# not an automatic app detection. Do not attach
@@ -13678,6 +13764,92 @@ def api_lxc_apps_dockerhub_tag_preview():
return jsonify({'error': str(e)}), 500
# ── Custom Web Links ─────────────────────────────────────────────
# User-defined launcher entries (in the Apps dashboard) that don't
# come from a registered LXC app. Backed by /etc/proxmenux/custom_links.json
# — one small global sidecar. Full schema + validation lives in
# custom_links.py; the endpoints here are thin CRUD wrappers.
@app.route('/api/apps/custom-links', methods=['GET'])
@require_auth
def api_custom_links_list():
try:
import custom_links
return jsonify(custom_links.load_all())
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/apps/custom-links', methods=['POST'])
@require_auth
def api_custom_links_create():
payload = request.get_json(silent=True) or {}
try:
import custom_links
ok, result = custom_links.create(payload)
if not ok:
return jsonify({'error': result}), 400
return jsonify(result), 201
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/apps/custom-links/<link_id>', methods=['PUT'])
@require_auth
def api_custom_links_update(link_id):
payload = request.get_json(silent=True) or {}
try:
import custom_links
ok, result = custom_links.update(link_id, payload)
if not ok:
code = 404 if result == 'link not found' else 400
return jsonify({'error': result}), code
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/apps/custom-links/<link_id>', methods=['DELETE'])
@require_auth
def api_custom_links_delete(link_id):
try:
import custom_links
ok, result = custom_links.delete(link_id)
if not ok:
return jsonify({'error': result}), 400
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/apps/categories', methods=['GET'])
@require_auth
def api_apps_categories():
"""List of category preset labels the Web Link editor offers in
its Categoría dropdown. Sourced from helpers_cache.category_names
so the taxonomy stays aligned with community-scripts, with a
small built-in fallback so the dropdown never renders empty."""
try:
import lxc_apps
return jsonify(lxc_apps.get_category_presets())
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/apps/suggest_category', methods=['GET'])
@require_auth
def api_apps_suggest_category():
"""Auto-fill the Categoría field when the user types a Web Link
name that matches a helpers_cache entry (by slug or name). Returns
{"category": "<name>"} or {"category": null}."""
try:
import lxc_apps
name = (request.args.get('name') or '').strip()
return jsonify({'category': lxc_apps.suggest_category_for(name)})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/apps/catalog/<slug>', methods=['GET'])
@require_auth
def api_apps_catalog_slug(slug):
@@ -21988,6 +22160,26 @@ if __name__ == '__main__':
except Exception as e:
print(f"[ProxMenux] Docker inventory startup init failed: {e}",
file=sys.stderr, flush=True)
# Warm the LXC IP cache once so the Apps dashboard renders
# instantly on first paint and /api/vms polls stay free of
# `lxc-info` subprocesses until a CT actually restarts.
try:
ip_count = _warmup_lxc_ip_cache()
print(f"[ProxMenux] LXC IP cache warmed ({ip_count} running CTs)",
flush=True)
except Exception as e:
print(f"[ProxMenux] LXC IP warmup failed: {e}",
file=sys.stderr, flush=True)
# Preload custom weblinks so the first Apps dashboard fetch
# is served straight from memory (0 disk I/O).
try:
import custom_links
cl_count = custom_links.warmup()
print(f"[ProxMenux] Custom links cache warmed ({cl_count} entries)",
flush=True)
except Exception as e:
print(f"[ProxMenux] Custom links warmup failed: {e}",
file=sys.stderr, flush=True)
threading.Thread(target=_deferred_startup_inits, daemon=True).start()
# Self-healing maintenance run on every startup. Two passes, both
+35 -12
View File
@@ -219,7 +219,12 @@ class HealthMonitor:
MEMORY_CRITICAL = 95
MEMORY_DURATION = 300 # 5 minutes sustained (aligned with CPU)
SWAP_WARNING_DURATION = 300
SWAP_CRITICAL_PERCENT = 5
# Swap CRITICAL now requires BOTH: swap file nearly full AND RAM
# genuinely tight. Alerting on just one of them fired constantly on
# healthy Proxmox hosts where the kernel proactively swaps out
# inactive pages while RAM remains plentifully available.
SWAP_HIGH_PERCENT = 80 # % of swap file in use
AVAILABLE_MIN_PERCENT = 15 # % of RAM that must stay available
SWAP_CRITICAL_DURATION = 120
# Storage Thresholds
@@ -435,7 +440,8 @@ class HealthMonitor:
(("cpu", "critical"), "CPU_CRITICAL"),
(("memory", "warning"), "MEMORY_WARNING"),
(("memory", "critical"), "MEMORY_CRITICAL"),
(("memory", "swap_critical"), "SWAP_CRITICAL_PERCENT"),
(("memory", "swap_high"), "SWAP_HIGH_PERCENT"),
(("memory", "available_min"), "AVAILABLE_MIN_PERCENT"),
(("host_storage", "warning"), "STORAGE_WARNING"),
(("host_storage", "critical"), "STORAGE_CRITICAL"),
(("cpu_temperature", "warning"), "TEMP_WARNING"),
@@ -625,12 +631,12 @@ class HealthMonitor:
current_time = time.time()
mem_percent = memory.percent
swap_percent = swap.percent if swap.total > 0 else 0
swap_vs_ram = (swap.used / memory.total * 100) if memory.total > 0 else 0
available_percent = (memory.available / memory.total * 100) if memory.total > 0 else 100
state_key = 'memory_usage'
self.state_history[state_key].append({
'mem_percent': mem_percent,
'swap_percent': swap_percent,
'swap_vs_ram': swap_vs_ram,
'available_percent': available_percent,
'time': current_time
})
# Prune entries older than 10 minutes
@@ -1605,7 +1611,13 @@ class HealthMonitor:
def _check_memory_comprehensive(self) -> Dict[str, Any]:
"""
Check memory including RAM and swap with realistic thresholds.
Only alerts on truly problematic memory situations.
Swap CRITICAL requires the memory-pressure AND-clause: swap is
called out only when the swap file is nearly full AND RAM is
genuinely tight (available memory below the configured floor).
Alerting on swap size alone fires constantly on healthy hosts
where Linux proactively swaps out inactive pages the user
can't act on that signal and it drowns real pressure events.
"""
try:
memory = psutil.virtual_memory()
@@ -1614,13 +1626,13 @@ class HealthMonitor:
mem_percent = memory.percent
swap_percent = swap.percent if swap.total > 0 else 0
swap_vs_ram = (swap.used / memory.total * 100) if memory.total > 0 else 0
available_percent = (memory.available / memory.total * 100) if memory.total > 0 else 100
state_key = 'memory_usage'
self.state_history[state_key].append({
'mem_percent': mem_percent,
'swap_percent': swap_percent,
'swap_vs_ram': swap_vs_ram,
'available_percent': available_percent,
'time': current_time
})
@@ -1641,10 +1653,15 @@ class HealthMonitor:
current_time - entry['time'] <= self.MEMORY_DURATION
]
# Swap CRITICAL requires BOTH conditions sustained. Older
# samples predating the new `available_percent` field are
# skipped rather than defaulted to a passing value so the
# transition period never manufactures a false positive.
swap_critical = sum(
1 for entry in self.state_history[state_key]
if entry['swap_vs_ram'] > 20 and
current_time - entry['time'] <= self.SWAP_CRITICAL_DURATION
if entry['swap_percent'] > self.SWAP_HIGH_PERCENT
and entry.get('available_percent', 100) < self.AVAILABLE_MIN_PERCENT
and current_time - entry['time'] <= self.SWAP_CRITICAL_DURATION
)
# Require sustained high usage across most of the 300s window.
@@ -1663,7 +1680,8 @@ class HealthMonitor:
reason = f'RAM >90% sustained for {actual_duration}s'
elif swap_critical >= 2:
status = 'CRITICAL'
reason = f'Swap >20% of RAM ({swap_vs_ram:.1f}%)'
reason = (f'Memory pressure: swap {swap_percent:.0f}% used '
f'and only {available_percent:.0f}% RAM available')
elif mem_warning_count >= MEM_WARNING_MIN_SAMPLES:
oldest = min(s['time'] for s in mem_warning_samples)
actual_duration = int(current_time - oldest)
@@ -1691,11 +1709,16 @@ class HealthMonitor:
'checks': {
'ram_usage': {
'status': ram_status,
'detail': 'High RAM usage sustained' if ram_status != 'OK' else 'Normal'
'detail': 'High RAM usage sustained' if ram_status != 'OK' else 'Normal',
'dismissable': True,
},
'swap_usage': {
'status': swap_status,
'detail': 'Excessive swap usage' if swap_status != 'OK' else ('Normal' if swap.total > 0 else 'No swap configured')
'detail': (
'Swap nearly full with RAM tight' if swap_status != 'OK'
else ('Normal' if swap.total > 0 else 'No swap configured')
),
'dismissable': True,
}
}
}
+6 -1
View File
@@ -65,7 +65,12 @@ DEFAULTS: dict[str, Any] = {
"memory": {
"warning": {"value": 85, "unit": "%", "min": 1, "max": 100, "step": 1},
"critical": {"value": 95, "unit": "%", "min": 1, "max": 100, "step": 1},
"swap_critical": {"value": 5, "unit": "%", "min": 1, "max": 100, "step": 1},
# Swap CRITICAL requires BOTH to hold: swap_high AND
# available_min. Alerting on swap alone was too noisy on
# Proxmox hosts where Linux proactively swaps inactive pages
# while RAM stays plentifully available.
"swap_high": {"value": 80, "unit": "%", "min": 1, "max": 100, "step": 1},
"available_min": {"value": 15, "unit": "%", "min": 1, "max": 100, "step": 1},
},
"host_storage": {
"warning": {"value": 85, "unit": "%", "min": 1, "max": 100, "step": 1},
+104
View File
@@ -262,6 +262,11 @@ _LOGO_URL_RE = re.compile(r"^https?://[\w\-._~:/?#\[\]@!$&'()*+,;=%]{1,510}$")
# Community-scripts slug — lowercase letters/digits/dashes/underscores/dots.
# Same shape helpers_cache uses for its own slug field.
_HELPER_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
# Web Link category — free-text label the user picks from the presets
# built from helpers_cache.category_names, or types freely. Keep the
# charset permissive enough for community-scripts labels ("Media &
# Streaming", "*Arr Suite", "AI / Coding & Dev-Tools").
_CATEGORY_RE = re.compile(r"^[\w\s&/,.\-*+()]{1,60}$", re.UNICODE)
# OCI label key (e.g. org.opencontainers.image.version) — reverse-DNS
# style dot-separated identifiers.
_OCI_LABEL_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9._\-]{0,127}$")
@@ -463,6 +468,24 @@ def _validate_ports(ports_in: Any) -> tuple[bool, Any]:
if not _LOGO_URL_RE.match(link_logo):
return _err(f"ports[{i}].logo_url must be an http(s) URL (max 512 chars)")
entry["logo_url"] = link_logo
# Per-link category — optional free-text label the user picks
# from the presets sourced from helpers_cache.category_names.
# Powers the Apps dashboard (filter/group by category).
category = (item.get("category") or "").strip()
if category:
if not _CATEGORY_RE.match(category):
return _err(f"ports[{i}].category has invalid characters or is too long")
entry["category"] = category
# Optional custom URL — takes precedence over the ip:port
# composition when present. Used for apps reached through a
# reverse-proxy domain (e.g. https://vault.example.com) so the
# Apps dashboard opens the public URL instead of the internal
# ip:port. Same http(s) allow-list as the app-level logo.
custom_url = (item.get("custom_url") or "").strip()
if custom_url:
if not _LOGO_URL_RE.match(custom_url):
return _err(f"ports[{i}].custom_url must be an http(s) URL (max 512 chars)")
entry["custom_url"] = custom_url
out.append(entry)
return True, out
@@ -2135,6 +2158,13 @@ def _docker_inventory_from_ct(vmid) -> dict:
== image_id.removeprefix("sha256:")
)
})
if not used_by:
# Skip orphan images (no container — running or stopped —
# references them). They are residual `docker pull` artifacts
# that would report bogus "update available" entries for tags
# no live workload uses. The user manages orphan cleanup with
# `docker image prune` / `docker rmi` outside of ProxMenux.
continue
used_containers = [item for item in containers if item.get("name") in used_by]
compose_targets: dict[str, dict] = {}
standalone_containers: list[str] = []
@@ -3427,6 +3457,62 @@ def _summarise_app(app: dict) -> dict:
}
def get_category_presets() -> list:
"""Return the sorted list of unique category names sourced from
helpers_cache. Powers the "Categoría" dropdown in the Web Link
editor and the Apps dashboard filter. If the cache is missing or
empty, returns a short built-in fallback so the UI never shows an
empty preset list.
"""
fallback = [
"Adblock & DNS", "Authentication & Security", "Automation & Scheduling",
"Backup & Recovery", "Containers & Docker", "Databases",
"Documents & Notes", "Files & Downloads", "Media & Streaming",
"Miscellaneous", "Monitoring & Analytics", "Network & Firewall",
]
try:
import managed_installs
cache = managed_installs._fetch_helpers_cache() or {}
except Exception:
return fallback
seen: set = set()
for entry in cache.values():
if not isinstance(entry, dict):
continue
for name in entry.get("category_names") or []:
if isinstance(name, str) and name.strip():
seen.add(name.strip())
return sorted(seen) if seen else fallback
def suggest_category_for(name_or_slug: str) -> Optional[str]:
"""Look up a category preset by app name/slug against helpers_cache.
Powers the auto-fill in the Web Link editor when the user types
a name that matches a catalog entry, the category dropdown
pre-selects the first category_names value. Returns None when the
name has no match or the cache is unavailable.
"""
if not name_or_slug:
return None
needle = name_or_slug.strip().lower()
if not needle:
return None
try:
import managed_installs
cache = managed_installs._fetch_helpers_cache() or {}
except Exception:
return None
for slug, entry in cache.items():
if not isinstance(entry, dict):
continue
if slug == needle or (entry.get("name") or "").lower() == needle:
cats = entry.get("category_names") or []
if cats and isinstance(cats[0], str) and cats[0].strip():
return cats[0].strip()
return None
return None
def get_catalog() -> list:
"""Return a compact catalog of registerable apps for the frontend
picker. Sourced from helpers_cache.json (community-scripts, ~700
@@ -3519,6 +3605,13 @@ def get_catalog_entry(slug: str, vmid=None) -> Optional[dict]:
except (TypeError, ValueError):
pass
# First category name from helpers_cache — auto-fills the port's
# Categoría field when the user picks this app from the catalog.
category = None
cat_names = catalog.get("category_names") or []
if cat_names and isinstance(cat_names[0], str) and cat_names[0].strip():
category = cat_names[0].strip()
return {
"slug": slug,
"name": catalog.get("name") or (hint.get("name") if isinstance(hint, dict) else None) or slug,
@@ -3528,6 +3621,7 @@ def get_catalog_entry(slug: str, vmid=None) -> Optional[dict]:
) or None,
"website": catalog.get("website") or "",
"default_ports": default_ports,
"category": category,
"tracking_suggestion": tracking,
}
@@ -4300,11 +4394,18 @@ def get_suggestions(vmid, force: bool = False) -> dict:
det_ports.append(n)
except (TypeError, ValueError):
pass
# Auto-fill Categoría preset from helpers_cache for extras too
# so the Register button pre-selects the category on the port.
det_category = None
det_cat_names = det_catalog.get("category_names") or []
if det_cat_names and isinstance(det_cat_names[0], str) and det_cat_names[0].strip():
det_category = det_cat_names[0].strip()
extras.append({
"slug": det_slug,
"name": det_name,
"logo_url": det_logo or None,
"default_ports": det_ports,
"category": det_category,
"tracking_suggestion": det_tracking,
})
@@ -4316,6 +4417,9 @@ def get_suggestions(vmid, force: bool = False) -> dict:
"tracking_suggestion": tracking,
"default_ports": default_ports,
"logo_url": logo_url or None,
# Categoría preset for the primary detection — same lookup as
# get_catalog_entry so the Register button pre-selects it.
"category": suggest_category_for(slug),
"extras": extras,
"docker_web_links": docker_web_links,
}
+5
View File
@@ -673,6 +673,11 @@ def _fetch_helpers_cache() -> dict:
"updateable": bool(entry.get("updateable")),
"default_port": entry.get("port") or 0,
"logo": entry.get("logo") or "",
# community-scripts taxonomy — powers the Categoría
# dropdown in the Web Link editor and the auto-fill
# on Registrar. Keep only the human-readable labels
# (ignore the parallel `categories` id list).
"category_names": entry.get("category_names") or [],
}
_helpers_cache = index
_helpers_cache_ts = now
+3 -1
View File
@@ -435,7 +435,9 @@ def is_apt_active_on_host() -> bool:
Sources checked, in order:
1. `/var/run/proxmenux-update-in-progress` created by
`scripts/utilities/proxmox_update.sh` around its full-upgrade
call so ProxMenux-driven updates are always covered.
call, and by `scripts/post_install/update_post_install_function.sh`
around the per-tool re-run wrapper (log2ram, chrony), so any
ProxMenux-driven maintenance is covered.
2. `fuser` on `/var/lib/dpkg/lock-frontend` covers a manual
`apt`/`dpkg`/`apt-get` invocation by the operator, or any
other tool holding the lock.
+1 -2
View File
@@ -156,13 +156,12 @@ UI translations ship as pre-built JSON files per language (English, Spanish, Fre
ProxMenux is an open, collaborative project — contributions of every shape are very welcome, no matter your background. Every PR, bug report, idea, translation or kind word helps move the project forward.
> 📖 **Before sending code**, please read the [**Contributing Guide**](CONTRIBUTING.md) — it explains **where to coordinate**, the project structure, the UI design policy (the two-phase `dialog` / `whiptail` flow), message helpers and submission conventions.
> 📖 **Before sending code**, please read the [**Contributing Guide**](CONTRIBUTING.md). It covers the project structure, the UI design policy (the two-phase `dialog` / `whiptail` flow), message helpers, translation policy and submission conventions — what reviewers will look for in your PR.
**Ways to help:**
- 💻 **Code** — fix a bug, polish a script, add a feature. Read the [Contributing Guide](CONTRIBUTING.md) first, then [open a pull request](https://github.com/MacRimi/ProxMenux/pulls).
- 🐛 **Bug reports** — found something broken? [Open an issue](https://github.com/MacRimi/ProxMenux/issues/new) with steps to reproduce, and the Monitor logs if relevant (`journalctl -u proxmenux-monitor -n 50`).
- 🗺️ **Follow the direction** — see what's being worked on and where the project is heading on the [Roadmap project board](https://github.com/users/MacRimi/projects/1). Coordination happens in [Contributor Coordination](https://github.com/MacRimi/ProxMenux/discussions/categories/contributor-coordination).
- 💡 **Ideas & feedback** — share suggestions in [GitHub Discussions](https://github.com/MacRimi/ProxMenux/discussions). Every idea is welcome.
- 🌍 **Translations** — the documentation site already supports English and Spanish; help expand it to more languages following the [translation guide](web/CONTRIBUTING-TRANSLATIONS.md) (one page per PR).
- 🧪 **Beta testing** — run the [beta build](#-beta-program) and let us know what you find.
@@ -3,6 +3,7 @@
# ProxMenux - Apply Pending Restore On Boot
# ==========================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PENDING_BASE="${PMX_RESTORE_PENDING_BASE:-/var/lib/proxmenux/restore-pending}"
CURRENT_LINK="${PENDING_BASE}/current"
LOG_DIR="${PMX_RESTORE_LOG_DIR:-/var/log/proxmenux}"
@@ -82,6 +83,7 @@ cluster_recovery_root=""
applied=0
skipped=0
failed=0
jobs_restored=0
while IFS= read -r rel; do
[[ -z "$rel" ]] && continue
@@ -168,12 +170,14 @@ while IFS= read -r rel; do
if [[ ${#RSYNC_EXCLUDES[@]} -gt 0 ]]; then
if rsync -aAXH "${RSYNC_EXCLUDES[@]}" "$src/" "$dst/" >/dev/null 2>&1; then
((applied++))
[[ "$rel" == "var/lib/proxmenux/backup-jobs" || "$rel" == "var/lib/proxmenux/backup-jobs/"* ]] && jobs_restored=1
else
((failed++))
fi
else
if rsync -aAXH --delete "$src/" "$dst/" >/dev/null 2>&1; then
((applied++))
[[ "$rel" == "var/lib/proxmenux/backup-jobs" || "$rel" == "var/lib/proxmenux/backup-jobs/"* ]] && jobs_restored=1
else
((failed++))
fi
@@ -182,13 +186,36 @@ while IFS= read -r rel; do
mkdir -p "$(dirname "$dst")" >/dev/null 2>&1 || true
if cp -a "$src" "$dst" >/dev/null 2>&1; then
((applied++))
[[ "$rel" == "var/lib/proxmenux/backup-jobs/"* ]] && jobs_restored=1
else
((failed++))
fi
fi
done <"$APPLY_LIST"
if (( jobs_restored )); then
scheduler_script="$SCRIPT_DIR/backup_scheduler.sh"
[[ -f "$scheduler_script" ]] || scheduler_script="/usr/local/share/proxmenux/scripts/backup_restore/backup_scheduler.sh"
jobs_dir="${DEST_PREFIX%/}/var/lib/proxmenux/backup-jobs"
systemd_dir="${DEST_PREFIX%/}/etc/systemd/system"
if [[ -f "$scheduler_script" ]]; then
if [[ "$DEST_PREFIX" == "/" ]]; then
bash "$scheduler_script" --reconcile-restored || ((failed++))
else
PMX_BACKUP_JOBS_DIR="$jobs_dir" \
PMX_BACKUP_SYSTEMD_DIR="$systemd_dir" \
PMX_BACKUP_NO_SYSTEMCTL=1 \
bash "$scheduler_script" --reconcile-restored || ((failed++))
fi
else
echo "Backup scheduler script not found; restored jobs were not reconciled."
((failed++))
fi
fi
if [[ "$DEST_PREFIX" == "/" ]]; then
systemctl daemon-reload >/dev/null 2>&1 || true
fi
# `update-initramfs -u -k all` and `update-grub` used to live here
# but: (a) they take 5-10 minutes for 3 kernels, hanging early-boot
@@ -248,7 +275,7 @@ EOF
# without restarting anything. That second unit is gated by
# ConditionPathExists on the marker file we drop here, so on
# a normal boot (no marker) it's a no-op.
if [[ "${cluster_live_apply:-0}" == "1" ]]; then
if [[ "${cluster_live_apply:-0}" == "1" && "$DEST_PREFIX" == "/" ]]; then
echo "Installing post-boot cluster apply unit..."
# Decide whether the post-boot script needs to run
@@ -353,7 +380,9 @@ restore_id="$(basename "$PENDING_DIR")"
mv "$PENDING_DIR" "${PENDING_BASE}/completed/${restore_id}" >/dev/null 2>&1 || true
rm -f "$CURRENT_LINK" >/dev/null 2>&1 || true
if [[ "$DEST_PREFIX" == "/" ]]; then
systemctl disable proxmenux-restore-onboot.service >/dev/null 2>&1 || true
fi
echo "=== ProxMenux pending restore finished at $(date -Iseconds) ==="
echo "Log file: $LOG_FILE"
+43 -6
View File
@@ -98,7 +98,10 @@ _bk_pbs() {
echo -e ""
msg_info "$(translate "Preparing files for backup...")"
hb_prepare_staging "$staging_root" "${paths[@]}"
if ! hb_prepare_staging "$staging_root" "${paths[@]}" >"$log_file" 2>&1; then
msg_error "$(translate "Backup failed. See log:") $log_file"
return 1
fi
staged_size=$(hb_file_size "$staging_root/rootfs")
msg_ok "$(translate "Staging ready.") $(translate "Data size:") $staged_size"
@@ -242,7 +245,10 @@ _bk_borg() {
echo -e ""
msg_info "$(translate "Preparing files for backup...")"
hb_prepare_staging "$staging_root" "${paths[@]}"
if ! hb_prepare_staging "$staging_root" "${paths[@]}" >"$log_file" 2>&1; then
msg_error "$(translate "Backup failed. See log:") $log_file"
return 1
fi
staged_size=$(hb_file_size "$staging_root/rootfs")
msg_ok "$(translate "Staging ready.") $(translate "Data size:") $staged_size"
@@ -376,7 +382,10 @@ _bk_local() {
echo -e ""
msg_info "$(translate "Preparing files for backup...")"
hb_prepare_staging "$staging_root" "${paths[@]}"
if ! hb_prepare_staging "$staging_root" "${paths[@]}" >"$log_file" 2>&1; then
msg_error "$(translate "Backup failed. See log:") $log_file"
return 1
fi
staged_size=$(hb_file_size "$staging_root/rootfs")
msg_ok "$(translate "Staging ready.") $(translate "Data size:") $staged_size"
@@ -1681,7 +1690,7 @@ _rs_apply() {
backup_root="/var/lib/proxmenux/pre-restore/$(date +%Y%m%d_%H%M%S)"
mkdir -p "$backup_root"
local applied=0 skipped=0 t_start elapsed
local applied=0 skipped=0 t_start elapsed jobs_restored=0 jobs_reconcile_failed=0
local cluster_recovery_root="" CLUSTER_DATA_EXTRACTED=""
t_start=$SECONDS
@@ -1774,13 +1783,31 @@ _rs_apply() {
--exclude "restore-pending/"
)
fi
rsync -aAXH --delete "${rsync_extra[@]}" "$src/" "$dst/" 2>/dev/null && ((applied++)) || ((skipped++))
if rsync -aAXH --delete "${rsync_extra[@]}" "$src/" "$dst/" 2>/dev/null; then
((applied++))
[[ "$rel" == "var/lib/proxmenux/backup-jobs" || "$rel" == "var/lib/proxmenux/backup-jobs/"* ]] && jobs_restored=1
else
((skipped++))
fi
else
mkdir -p "$(dirname "$dst")"
cp -a "$src" "$dst" 2>/dev/null && ((applied++)) || ((skipped++))
if cp -a "$src" "$dst" 2>/dev/null; then
((applied++))
[[ "$rel" == "var/lib/proxmenux/backup-jobs/"* ]] && jobs_restored=1
else
((skipped++))
fi
fi
done
if (( jobs_restored )); then
local scheduler_script="$SCRIPT_DIR/backup_scheduler.sh"
[[ -f "$scheduler_script" ]] || scheduler_script="$LOCAL_SCRIPTS_DEFAULT/backup_restore/backup_scheduler.sh"
if [[ ! -f "$scheduler_script" ]] || ! bash "$scheduler_script" --reconcile-restored; then
jobs_reconcile_failed=1
fi
fi
elapsed=$((SECONDS - t_start))
# Skip `systemctl daemon-reload` when invoked from the Monitor
# (HB_MONITOR_FLOW=1). The reload itself doesn't restart the
@@ -1808,6 +1835,14 @@ _rs_apply() {
msg_warn "$(translate "Changes applied. A system reboot is recommended for them to take full effect.")"
fi
if (( jobs_restored )); then
if (( jobs_reconcile_failed )); then
msg_warn "$(translate "Scheduled backup jobs")"
else
msg_ok "$(translate "Scheduled backup jobs")"
fi
fi
if [[ -n "$CLUSTER_DATA_EXTRACTED" ]]; then
export HB_CLUSTER_DATA_EXTRACTED="$CLUSTER_DATA_EXTRACTED"
_rs_write_cluster_recovery_helper "$CLUSTER_DATA_EXTRACTED"
@@ -1816,6 +1851,8 @@ _rs_apply() {
else
unset HB_CLUSTER_DATA_EXTRACTED
fi
(( jobs_reconcile_failed == 0 ))
}
_rs_collect_plan_stats() {
+200 -12
View File
@@ -35,17 +35,20 @@ else
exit 1
fi
load_language
initialize_cache
JOBS_DIR="/var/lib/proxmenux/backup-jobs"
LOG_DIR="/var/log/proxmenux/backup-jobs"
JOBS_DIR="${PMX_BACKUP_JOBS_DIR:-/var/lib/proxmenux/backup-jobs}"
LOG_DIR="${PMX_BACKUP_LOG_DIR:-/var/log/proxmenux/backup-jobs}"
SYSTEMD_DIR="${PMX_BACKUP_SYSTEMD_DIR:-/etc/systemd/system}"
mkdir -p "$JOBS_DIR" "$LOG_DIR" >/dev/null 2>&1 || true
_job_file() { echo "${JOBS_DIR}/$1.env"; }
_job_paths_file() { echo "${JOBS_DIR}/$1.paths"; }
_service_file() { echo "/etc/systemd/system/proxmenux-backup-$1.service"; }
_timer_file() { echo "/etc/systemd/system/proxmenux-backup-$1.timer"; }
_service_file() { echo "${SYSTEMD_DIR}/proxmenux-backup-$1.service"; }
_timer_file() { echo "${SYSTEMD_DIR}/proxmenux-backup-$1.timer"; }
_scheduler_systemctl() {
[[ "${PMX_BACKUP_NO_SYSTEMCTL:-0}" == "1" ]] && return 0
systemctl "$@"
}
_normalize_uint() {
local v="${1:-0}"
@@ -95,10 +98,11 @@ _list_scheduled_jobs() {
# timer — the trigger comes from the vzdump hook, matched by PVE_STORAGE
# against $STOREID set by PVE for every backup phase).
_job_is_attached() {
local id="$1" f
local id="$1" f storage
f=$(_job_file "$id")
[[ -f "$f" ]] || return 1
grep -q "^PVE_STORAGE=" "$f"
storage=$(_job_env_get "$id" "PVE_STORAGE" || echo "")
[[ -n "$storage" ]]
}
# Reads a key=val pair from the job .env file (handles `printf %q`
@@ -111,6 +115,21 @@ _job_env_get() {
eval "echo $raw" 2>/dev/null || echo "$raw"
}
_set_job_enabled() {
local id="$1" value="$2" file tmp
file=$(_job_file "$id")
[[ -f "$file" ]] || return 1
tmp="${file}.tmp.$$"
awk -v value="$value" '
BEGIN { found=0 }
/^ENABLED=/ { print "ENABLED=" value; found=1; next }
{ print }
END { if (!found) print "ENABLED=" value }
' "$file" > "$tmp" || { rm -f "$tmp"; return 1; }
chmod --reference="$file" "$tmp" 2>/dev/null || chmod 600 "$tmp"
mv -f "$tmp" "$file"
}
_show_job_status() {
local id="$1"
if _job_is_attached "$id"; then
@@ -139,6 +158,7 @@ _write_job_units() {
local on_calendar="$2"
local runner="$LOCAL_SCRIPTS/backup_restore/run_scheduled_backup.sh"
[[ ! -f "$runner" ]] && runner="$SCRIPT_DIR/run_scheduled_backup.sh"
mkdir -p "$SYSTEMD_DIR" || return 1
cat > "$(_service_file "$id")" <<EOF
[Unit]
@@ -168,7 +188,166 @@ Unit=proxmenux-backup-${id}.service
WantedBy=timers.target
EOF
systemctl daemon-reload >/dev/null 2>&1 || true
_scheduler_systemctl daemon-reload >/dev/null 2>&1 || true
}
_sanitize_restored_job() {
local env_file="$1" expected_id="$2" paths_file="$3"
command -v python3 >/dev/null 2>&1 || {
echo "Cannot validate restored backup job ${expected_id}: python3 is unavailable." >&2
return 1
}
python3 - "$env_file" "$expected_id" "$paths_file" <<'PY'
import os
import re
import shlex
import stat
import sys
import tempfile
env_path, expected_id, paths_path = sys.argv[1:]
allowed = {
'JOB_ID', 'BACKEND', 'PVE_PARENT_JOB', 'PVE_STORAGE', 'ON_CALENDAR',
'PROFILE_MODE', 'ENABLED', 'KEEP_LAST', 'KEEP_HOURLY', 'KEEP_DAILY',
'KEEP_WEEKLY', 'KEEP_MONTHLY', 'KEEP_YEARLY', 'LOCAL_DEST_DIR',
'LOCAL_ARCHIVE_EXT', 'BORG_REPO', 'BORG_PASSPHRASE',
'BORG_ENCRYPT_MODE', 'PBS_REPOSITORY', 'PBS_PASSWORD', 'PBS_BACKUP_ID',
'PBS_KEYFILE', 'PBS_ENCRYPTION_PASSWORD', 'PBS_FINGERPRINT', 'MANUAL_RUN',
}
key_re = re.compile(r'^[A-Z][A-Z0-9_]*$')
id_re = re.compile(r'^[A-Za-z0-9_-]+$')
values = {}
order = []
try:
env_stat = os.lstat(env_path)
paths_stat = os.lstat(paths_path)
if not stat.S_ISREG(env_stat.st_mode) or not stat.S_ISREG(paths_stat.st_mode):
raise ValueError('job definition and paths list must be regular files')
with open(env_path, encoding='utf-8') as handle:
for line_number, raw in enumerate(handle, 1):
line = raw.strip()
if not line or line.startswith('#'):
continue
if '=' not in line:
raise ValueError(f'line {line_number} is not KEY=value')
key, rhs = line.split('=', 1)
if not key_re.fullmatch(key) or key not in allowed:
raise ValueError(f'line {line_number} contains unsupported key {key!r}')
parsed = shlex.split(rhs, posix=True)
if len(parsed) != 1:
raise ValueError(f'line {line_number} does not contain one safely quoted value')
if key not in values:
order.append(key)
values[key] = parsed[0]
for required in ('JOB_ID', 'BACKEND', 'PROFILE_MODE', 'ENABLED'):
if required not in values:
raise ValueError(f'missing required key {required}')
if not id_re.fullmatch(expected_id) or values['JOB_ID'] != expected_id:
raise ValueError('JOB_ID does not match the file name')
if values['BACKEND'] not in {'local', 'borg', 'pbs'}:
raise ValueError('unsupported BACKEND')
if values['PROFILE_MODE'] not in {'default', 'custom'}:
raise ValueError('unsupported PROFILE_MODE')
if values['ENABLED'] not in {'0', '1'}:
raise ValueError('ENABLED must be 0 or 1')
if values.get('MANUAL_RUN', '0') not in {'0', '1'}:
raise ValueError('MANUAL_RUN must be 0 or 1')
if not values.get('PVE_STORAGE') and values.get('MANUAL_RUN') != '1' and not values.get('ON_CALENDAR'):
raise ValueError('standalone job has no ON_CALENDAR value')
if any(char in values.get('ON_CALENDAR', '') for char in ('\r', '\n', '\x00')):
raise ValueError('ON_CALENDAR contains invalid control characters')
with open(paths_path, encoding='utf-8') as handle:
paths = [line.strip() for line in handle if line.strip()]
if not paths or any(not path.startswith('/') or '\x00' in path for path in paths):
raise ValueError('paths file must contain at least one absolute path')
fd, temp_path = tempfile.mkstemp(prefix='.restore-', dir=os.path.dirname(env_path), text=True)
try:
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
handle.write('# ProxMenux scheduled backup job\n')
for key in order:
handle.write(f'{key}={shlex.quote(values[key])}\n')
os.chmod(temp_path, 0o600)
os.replace(temp_path, env_path)
finally:
if os.path.exists(temp_path):
os.unlink(temp_path)
except (OSError, ValueError) as exc:
print(f'{expected_id}: {exc}', file=sys.stderr)
raise SystemExit(1)
PY
}
_reconcile_restored_jobs() {
local total=0 restored=0 invalid=0 attached=0 disabled=0 hook_needed=0
local env_file id paths_file manual enabled on_calendar
mkdir -p "$JOBS_DIR" "$SYSTEMD_DIR" || return 1
for env_file in "$JOBS_DIR"/*.env; do
[[ -f "$env_file" ]] || continue
total=$((total + 1))
id="$(basename "$env_file" .env)"
paths_file="$(_job_paths_file "$id")"
if [[ ! -f "$paths_file" ]] || ! _sanitize_restored_job "$env_file" "$id" "$paths_file"; then
invalid=$((invalid + 1))
continue
fi
manual=$(_job_env_get "$id" MANUAL_RUN || echo 0)
if [[ "$manual" == "1" ]]; then
rm -f "$(_service_file "$id")" "$(_timer_file "$id")"
continue
fi
enabled=$(_job_env_get "$id" ENABLED || echo 0)
if _job_is_attached "$id"; then
rm -f "$(_service_file "$id")" "$(_timer_file "$id")"
attached=$((attached + 1))
hook_needed=1
[[ "$enabled" == "1" ]] || disabled=$((disabled + 1))
restored=$((restored + 1))
continue
fi
on_calendar=$(_job_env_get "$id" ON_CALENDAR || echo "")
if [[ "${PMX_BACKUP_NO_SYSTEMCTL:-0}" != "1" ]] && command -v systemd-analyze >/dev/null 2>&1; then
if ! systemd-analyze calendar "$on_calendar" >/dev/null 2>&1; then
echo "${id}: invalid systemd OnCalendar expression." >&2
invalid=$((invalid + 1))
continue
fi
fi
if ! _write_job_units "$id" "$on_calendar"; then
echo "${id}: could not reconstruct systemd units." >&2
invalid=$((invalid + 1))
continue
fi
if [[ "$enabled" == "1" ]]; then
_scheduler_systemctl enable --now "proxmenux-backup-${id}.timer" >/dev/null 2>&1 || {
invalid=$((invalid + 1))
continue
}
else
_scheduler_systemctl disable --now "proxmenux-backup-${id}.timer" >/dev/null 2>&1 || true
disabled=$((disabled + 1))
fi
restored=$((restored + 1))
done
_scheduler_systemctl daemon-reload >/dev/null 2>&1 || true
if (( hook_needed )) && [[ "${PMX_BACKUP_NO_SYSTEMCTL:-0}" != "1" ]]; then
hb_install_vzdump_hook >/dev/null 2>&1 || invalid=$((invalid + 1))
fi
printf 'Backup jobs reconciled: total=%d restored=%d attached=%d disabled=%d invalid=%d\n' \
"$total" "$restored" "$attached" "$disabled" "$invalid"
(( invalid == 0 ))
}
_prompt_retention() {
@@ -620,18 +799,20 @@ _job_toggle() {
f=$(_job_file "$id")
current=$(_job_env_get "$id" "ENABLED")
if [[ "$current" == "0" ]]; then
sed -i 's/^ENABLED=.*/ENABLED=1/' "$f"
_set_job_enabled "$id" 1
action_label="enabled"
else
sed -i 's/^ENABLED=.*/ENABLED=0/' "$f"
_set_job_enabled "$id" 0
action_label="disabled"
fi
else
if systemctl is-enabled --quiet "proxmenux-backup-${id}.timer" >/dev/null 2>&1; then
systemctl disable --now "proxmenux-backup-${id}.timer" >/dev/null 2>&1 || true
_set_job_enabled "$id" 0
action_label="disabled"
else
systemctl enable --now "proxmenux-backup-${id}.timer" >/dev/null 2>&1 || true
_set_job_enabled "$id" 1
action_label="enabled"
fi
fi
@@ -786,4 +967,11 @@ main_menu() {
done
}
if [[ "${1:-}" == "--reconcile-restored" ]]; then
_reconcile_restored_jobs
exit $?
fi
load_language
initialize_cache
main_menu
+137 -15
View File
@@ -16,6 +16,7 @@
}
HB_STATE_DIR="/usr/local/share/proxmenux"
HB_BACKUP_JOBS_DIR="${PMX_BACKUP_JOBS_DIR:-/var/lib/proxmenux/backup-jobs}"
HB_BORG_VERSION="1.2.8"
HB_BORG_LINUX64_SHA256="cfa50fb704a93d3a4fa258120966345fddb394f960dca7c47fcb774d0172f40b"
HB_BORG_LINUX64_URL="https://github.com/borgbackup/borg/releases/download/${HB_BORG_VERSION}/borg-linux64"
@@ -130,6 +131,7 @@ hb_default_profile_paths() {
"/usr/local/bin"
"/usr/local/sbin"
"/usr/local/share/proxmenux"
"$HB_BACKUP_JOBS_DIR"
# ── Root home (rsync excludes volatile dirs) ─────────
"/root"
@@ -411,6 +413,31 @@ hb_del_extra_path() {
chmod 600 "$f"
}
# Returns 0 when a path is operator-added and therefore must be copied
# verbatim. Persisted extra paths always win, even when their value is also
# present in the built-in profile (for example /root). A path supplied by an
# API or a restored custom job is also operator-added when it is not one of
# the exact built-in profile entries.
hb_path_is_operator_added() {
local candidate="${1%/}"
[[ -z "$candidate" ]] && candidate="/"
local configured normalized
while IFS= read -r configured; do
normalized="${configured%/}"
[[ -z "$normalized" ]] && normalized="/"
[[ "$candidate" == "$normalized" ]] && return 0
done < <(hb_load_extra_paths)
while IFS= read -r configured; do
normalized="${configured%/}"
[[ -z "$normalized" ]] && normalized="/"
[[ "$candidate" == "$normalized" ]] && return 1
done < <(hb_default_profile_paths)
return 0
}
hb_select_profile_paths() {
local mode="$1"
local __out_var="$2"
@@ -513,17 +540,67 @@ hb_select_profile_paths() {
# ==========================================================
# STAGING OPERATIONS
# ==========================================================
hb_snapshot_backup_job_states() {
local staging_root="$1"
local jobs_root="$staging_root/rootfs/${HB_BACKUP_JOBS_DIR#/}"
[[ -d "$jobs_root" ]] || return 0
[[ "${PMX_BACKUP_NO_SYSTEMCTL:-0}" == "1" ]] && return 0
local env_file job_id enabled tmp_file
for env_file in "$jobs_root"/*.env; do
[[ -f "$env_file" ]] || continue
grep -q '^MANUAL_RUN=1$' "$env_file" 2>/dev/null && continue
grep -q '^PVE_STORAGE=' "$env_file" 2>/dev/null && continue
job_id="$(basename "$env_file" .env)"
[[ "$job_id" =~ ^[A-Za-z0-9_-]+$ ]] || continue
enabled=0
systemctl is-enabled --quiet "proxmenux-backup-${job_id}.timer" >/dev/null 2>&1 && enabled=1
tmp_file="${env_file}.tmp.$$"
awk -v value="$enabled" '
BEGIN { found=0 }
/^ENABLED=/ { print "ENABLED=" value; found=1; next }
{ print }
END { if (!found) print "ENABLED=" value }
' "$env_file" > "$tmp_file" || { rm -f "$tmp_file"; return 1; }
chmod --reference="$env_file" "$tmp_file" 2>/dev/null || chmod 600 "$tmp_file"
mv -f "$tmp_file" "$env_file" || return 1
done
}
hb_prepare_staging() {
local staging_root="$1"; shift
local paths=("$@")
local -a built_in_paths=() operator_paths=()
mapfile -t built_in_paths < <(hb_default_profile_paths)
mapfile -t operator_paths < <(hb_load_extra_paths)
# Job definitions are control-plane state. Include them even in a
# custom payload so the restored host can reconstruct its schedules.
if [[ -e "$HB_BACKUP_JOBS_DIR" ]]; then
local jobs_covered=0 candidate normalized
for candidate in "${paths[@]}"; do
normalized="${candidate%/}"
[[ -z "$normalized" ]] && normalized="/"
if [[ "$HB_BACKUP_JOBS_DIR" == "$normalized" || "$HB_BACKUP_JOBS_DIR" == "$normalized"/* ]]; then
jobs_covered=1
break
fi
done
(( jobs_covered )) || paths+=("$HB_BACKUP_JOBS_DIR")
fi
rm -rf "$staging_root"
mkdir -p "$staging_root/rootfs" "$staging_root/metadata"
local selected_file="$staging_root/metadata/selected_paths.txt"
local missing_file="$staging_root/metadata/missing_paths.txt"
local failed_file="$staging_root/metadata/failed_paths.txt"
local copy_failed=0
: > "$selected_file"
: > "$missing_file"
: > "$failed_file"
# pmxcfs (/etc/pve) is served from this SQLite DB with pve-cluster
# running. A plain rsync of the raw file can catch it mid-WAL
@@ -538,31 +615,57 @@ hb_prepare_staging() {
".backup '$staging_root/rootfs/var/lib/pve-cluster/config.db'" 2>/dev/null; then
echo "pmxcfs_config_db=sqlite_backup" >> "$staging_root/metadata/run_info.env.tmp"
else
cp -a /var/lib/pve-cluster/config.db \
"$staging_root/rootfs/var/lib/pve-cluster/config.db.raw-fallback" 2>/dev/null || true
if cp -a /var/lib/pve-cluster/config.db \
"$staging_root/rootfs/var/lib/pve-cluster/config.db.raw-fallback"; then
echo "pmxcfs_config_db=raw_fallback" >> "$staging_root/metadata/run_info.env.tmp"
else
echo "/var/lib/pve-cluster/config.db" >> "$failed_file"
copy_failed=1
fi
fi
else
cp -a /var/lib/pve-cluster/config.db \
"$staging_root/rootfs/var/lib/pve-cluster/config.db.raw-fallback" 2>/dev/null || true
if cp -a /var/lib/pve-cluster/config.db \
"$staging_root/rootfs/var/lib/pve-cluster/config.db.raw-fallback"; then
echo "pmxcfs_config_db=raw_fallback" >> "$staging_root/metadata/run_info.env.tmp"
else
echo "/var/lib/pve-cluster/config.db" >> "$failed_file"
copy_failed=1
fi
fi
fi
local p rel target
local p rel target operator_added configured normalized built_in_match
for p in "${paths[@]}"; do
rel="${p#/}"
echo "$rel" >> "$selected_file"
operator_added=0
normalized="${p%/}"
[[ -z "$normalized" ]] && normalized="/"
for configured in "${operator_paths[@]}"; do
configured="${configured%/}"
[[ -z "$configured" ]] && configured="/"
if [[ "$normalized" == "$configured" ]]; then
operator_added=1
break
fi
done
if (( ! operator_added )); then
built_in_match=0
for configured in "${built_in_paths[@]}"; do
configured="${configured%/}"
[[ -z "$configured" ]] && configured="/"
if [[ "$normalized" == "$configured" ]]; then
built_in_match=1
break
fi
done
(( built_in_match )) || operator_added=1
fi
[[ -e "$p" ]] || { echo "$p" >> "$missing_file"; continue; }
target="$staging_root/rootfs/$rel"
if [[ -d "$p" ]]; then
mkdir -p "$target"
local -a rsync_opts=(
-aAXH --numeric-ids
--exclude "images/"
--exclude "dump/"
--exclude "tmp/"
--exclude "*.log"
)
# /var/lib/pve-cluster: skip the raw config.db and its WAL/SHM
@@ -570,7 +673,7 @@ hb_prepare_staging() {
# sqlite3 .backup (or as raw-fallback when sqlite3 isn't
# available). Everything else in the directory (backup subdir,
# auxiliary state) is safe to rsync live.
if [[ "$rel" == "var/lib/pve-cluster" || "$rel" == "var/lib/pve-cluster/"* ]]; then
if (( ! operator_added )) && [[ "$rel" == "var/lib/pve-cluster" ]]; then
rsync_opts+=(
--exclude "config.db"
--exclude "config.db-wal"
@@ -579,7 +682,7 @@ hb_prepare_staging() {
fi
# /root is included by default for easier recovery, but avoid volatile/sensitive noise.
if [[ "$rel" == "root" || "$rel" == "root/"* ]]; then
if (( ! operator_added )) && [[ "$rel" == "root" ]]; then
rsync_opts+=(
--exclude ".bash_history"
--exclude ".cache/"
@@ -595,7 +698,7 @@ hb_prepare_staging() {
# the destination's fresh install silently regresses the apply_cluster_postboot
# dispatcher and the *_installer.sh --auto-reinstall hooks, breaking the
# "user reinstalls nothing" promise.
if [[ "$rel" == "usr/local/share/proxmenux" || "$rel" == "usr/local/share/proxmenux/"* ]]; then
if (( ! operator_added )) && [[ "$rel" == "usr/local/share/proxmenux" ]]; then
rsync_opts+=(
--exclude "restore-pending/"
--exclude "scripts/"
@@ -612,13 +715,30 @@ hb_prepare_staging() {
)
fi
rsync "${rsync_opts[@]}" "$p/" "$target/" 2>/dev/null || true
if rsync "${rsync_opts[@]}" "$p/" "$target/"; then
echo "$rel" >> "$selected_file"
else
echo "$p" >> "$failed_file"
copy_failed=1
rm -rf "$target"
fi
else
mkdir -p "$(dirname "$target")"
cp -a "$p" "$target" 2>/dev/null || true
if cp -a "$p" "$target"; then
echo "$rel" >> "$selected_file"
else
echo "$p" >> "$failed_file"
copy_failed=1
rm -f "$target"
fi
fi
done
if ! hb_snapshot_backup_job_states "$staging_root"; then
echo "$HB_BACKUP_JOBS_DIR (timer state)" >> "$failed_file"
copy_failed=1
fi
# Metadata snapshot
local meta="$staging_root/metadata"
{
@@ -696,6 +816,8 @@ hb_prepare_staging() {
# parse_manifest doesn't read a 0-byte JSON downstream.
[[ -s "$staging_root/manifest.json" ]] || rm -f "$staging_root/manifest.json"
fi
return "$copy_failed"
}
hb_load_restore_paths() {
+11 -1
View File
@@ -713,7 +713,17 @@ main() {
echo "Paths to back up: ${#paths[@]}"
echo "Preparing staging area at $stage_root ..."
} >>"$log_file"
hb_prepare_staging "$stage_root" "${paths[@]}" >>"$log_file" 2>&1
if ! hb_prepare_staging "$stage_root" "${paths[@]}" >>"$log_file" 2>&1; then
echo "RESULT=failed" >>"$summary_file"
echo "LOG_FILE=${log_file}" >>"$summary_file"
echo "=== Job aborted: one or more selected paths could not be copied ===" >>"$log_file"
export HB_NOTIFY_REASON="One or more selected backup paths could not be copied"
export HB_NOTIFY_DURATION="0s"
hb_notify_lifecycle "fail"
rm -rf "$stage_root"
(( TTY )) && msg_error "$(translate "Backup failed. See log:") $log_file"
exit 1
fi
local staged_files staged_size
staged_files=$(find "$stage_root/rootfs" -type f 2>/dev/null | wc -l)
staged_size=$(hb_file_size "$stage_root/rootfs" 2>/dev/null || echo "?")
+1 -1
View File
@@ -159,7 +159,7 @@ apt_upgrade() {
remove_subscription_banner() {
local FUNC_VERSION="1.1"
local FUNC_VERSION="1.0"
# description: Patch the Proxmox web UI to suppress the subscription dialog and register a successful patch.
local pve_version
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
@@ -2045,7 +2045,7 @@ EOF
setup_motd() {
local FUNC_VERSION="1.1"
local FUNC_VERSION="1.0"
# description: Add the ProxMenux MOTD banner while preserving the original file contents or absence for rollback.
msg_info2 "$(translate "Configuring MOTD (Message of the Day) banner...")"
@@ -105,6 +105,22 @@ ensure_flow_loaded() {
esac
}
# Suppress the Monitor's `service_fail` notifications while this
# wrapper runs. Tools like log2ram or chrony stop and reinstall their
# systemd units, and systemd flags the transient "failed" state
# before the fresh unit starts — same class of noise as PVE services
# restarting during an apt full-upgrade. Shared markers with
# scripts/utilities/proxmox_update.sh; consumed by
# AppImage/scripts/notification_events.py::is_apt_active_on_host.
_PROXMENUX_UPDATE_MARKER="/var/run/proxmenux-update-in-progress"
_PROXMENUX_UPDATE_FINISHED_MARKER="/var/run/proxmenux-update-just-finished"
_proxmenux_postinstall_cleanup() {
rm -f "$_PROXMENUX_UPDATE_MARKER"
touch "$_PROXMENUX_UPDATE_FINISHED_MARKER"
}
trap _proxmenux_postinstall_cleanup EXIT
touch "$_PROXMENUX_UPDATE_MARKER"
# ----------------------------------------------------------------------
# Run each tool. We don't bail on the first failure — the user marked a
# multi-select, they expect every chosen tool to be attempted. RCs are
@@ -0,0 +1,181 @@
import type { Metadata } from "next"
import type React from "react"
import { getMessages, getTranslations, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { Callout } from "@/components/ui/callout"
import { DocHeader } from "@/components/ui/doc-header"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.apps.meta" })
return { title: t("title"), description: t("description") }
}
type SourceRow = { source: string; appears: string; managedFrom: string }
type FieldRow = { field: string; required: string; purpose: string }
type ProblemRow = { problem: string; resolution: string }
export default async function AppsDashboardPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.apps" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { dashboard: { apps: {
sources: { rows: SourceRow[] }
cards: { items: string[] }
toolbar: { items: string[] }
customLinks: { steps: string[]; fields: FieldRow[] }
categories: { items: string[] }
persistence: { items: string[] }
troubleshooting: { rows: ProblemRow[] }
whereNext: { items: { label: string; href: string; tail: string }[] }
} } } }
}
const a = messages.docs.monitor.dashboard.apps
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
const code = (chunks: React.ReactNode) => <code className="rounded bg-gray-100 px-1 text-sm">{chunks}</code>
const appTabLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard/vms-lxcs/app" className="text-blue-600 hover:underline">{chunks}</Link>
)
const updatesLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard/vms-lxcs/updates" className="text-blue-600 hover:underline">{chunks}</Link>
)
const settingsLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard/settings#navigation-order" className="text-blue-600 hover:underline">{chunks}</Link>
)
const richList = (base: string, items: string[]) => (
<ul className="mt-2 list-disc space-y-2 pl-6 text-gray-800">
{items.map((_, idx) => (
<li key={idx}>{t.rich(`${base}.${idx}`, { strong, em, code, appTabLink, updatesLink, settingsLink })}</li>
))}
</ul>
)
return (
<div className="mx-auto max-w-4xl px-4 py-8">
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={6}
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { strong, appTabLink })}
</Callout>
<h2 className="mt-10 mb-4 text-2xl font-semibold text-gray-900">{t("sources.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("sources.intro")}</p>
<div className="my-4 overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-3 py-2 text-left">{t("sources.colSource")}</th>
<th className="border border-gray-300 px-3 py-2 text-left">{t("sources.colAppears")}</th>
<th className="border border-gray-300 px-3 py-2 text-left">{t("sources.colManagedFrom")}</th>
</tr>
</thead>
<tbody>
{a.sources.rows.map((row) => (
<tr key={row.source}>
<td className="border border-gray-300 px-3 py-2 font-medium">{row.source}</td>
<td className="border border-gray-300 px-3 py-2">{row.appears}</td>
<td className="border border-gray-300 px-3 py-2">{row.managedFrom}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="tip" title={t("sources.relationshipTitle")}>
{t.rich("sources.relationshipBody", { strong, appTabLink })}
</Callout>
<h2 className="mt-10 mb-4 text-2xl font-semibold text-gray-900">{t("cards.heading")}</h2>
<p className="text-gray-800">{t("cards.intro")}</p>
{richList("cards.items", a.cards.items)}
<h2 className="mt-10 mb-4 text-2xl font-semibold text-gray-900">{t("toolbar.heading")}</h2>
<p className="text-gray-800">{t("toolbar.intro")}</p>
{richList("toolbar.items", a.toolbar.items)}
<h2 className="mt-10 mb-4 text-2xl font-semibold text-gray-900">{t("customLinks.heading")}</h2>
<p className="text-gray-800">{t("customLinks.intro")}</p>
<ol className="mt-2 list-decimal space-y-2 pl-6 text-gray-800">
{a.customLinks.steps.map((_, idx) => (
<li key={idx}>{t.rich(`customLinks.steps.${idx}`, { strong, em, code })}</li>
))}
</ol>
<div className="my-6 overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-3 py-2 text-left">{t("customLinks.colField")}</th>
<th className="border border-gray-300 px-3 py-2 text-left">{t("customLinks.colRequired")}</th>
<th className="border border-gray-300 px-3 py-2 text-left">{t("customLinks.colPurpose")}</th>
</tr>
</thead>
<tbody>
{a.customLinks.fields.map((row) => (
<tr key={row.field}>
<td className="border border-gray-300 px-3 py-2 font-medium">{row.field}</td>
<td className="border border-gray-300 px-3 py-2">{row.required}</td>
<td className="border border-gray-300 px-3 py-2">{row.purpose}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="warning" title={t("customLinks.editTitle")}>
{t.rich("customLinks.editBody", { strong, appTabLink })}
</Callout>
<h2 className="mt-10 mb-4 text-2xl font-semibold text-gray-900">{t("categories.heading")}</h2>
<p className="text-gray-800">{t("categories.intro")}</p>
{richList("categories.items", a.categories.items)}
<h2 className="mt-10 mb-4 text-2xl font-semibold text-gray-900">{t("persistence.heading")}</h2>
<p className="text-gray-800">{t("persistence.intro")}</p>
{richList("persistence.items", a.persistence.items)}
<h2 className="mt-10 mb-4 text-2xl font-semibold text-gray-900">{t("troubleshooting.heading")}</h2>
<div className="my-4 overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-3 py-2 text-left">{t("troubleshooting.colProblem")}</th>
<th className="border border-gray-300 px-3 py-2 text-left">{t("troubleshooting.colResolution")}</th>
</tr>
</thead>
<tbody>
{a.troubleshooting.rows.map((row) => (
<tr key={row.problem}>
<td className="border border-gray-300 px-3 py-2 font-medium">{row.problem}</td>
<td className="border border-gray-300 px-3 py-2">{row.resolution}</td>
</tr>
))}
</tbody>
</table>
</div>
<h2 className="mt-10 mb-4 text-2xl font-semibold text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc space-y-1 pl-6 text-gray-800">
{a.whereNext.items.map((item) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">{item.label}</Link>
{item.tail}
</li>
))}
</ul>
</div>
)
}
@@ -55,7 +55,7 @@ export default async function DashboardIndexPage({
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={3}
estimatedMinutes={4}
/>
<Callout variant="info" title={t("oneHeader.title")}>
@@ -31,6 +31,8 @@ export default async function SettingsTabPage({
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { dashboard: { settings: {
interfaceLanguage: { items: string[] }
navigationOrder: { items: string[] }
health: { items: string[]; activeItems: string[] }
thresholds: {
whatForItems: string[]
@@ -47,6 +49,8 @@ export default async function SettingsTabPage({
} } } }
}
const s = messages.docs.monitor.dashboard.settings
const interfaceLanguageItems = s.interfaceLanguage.items
const navigationOrderItems = s.navigationOrder.items
const healthItems = s.health.items
const activeSuppressionItems = s.health.activeItems
const whatForItems = s.thresholds.whatForItems
@@ -99,13 +103,39 @@ export default async function SettingsTabPage({
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={9}
estimatedMinutes={10}
/>
<Callout variant="info" title={t("intro.title")}>
{t("intro.body")}
</Callout>
<h2 id="interface-language" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("interfaceLanguage.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("interfaceLanguage.intro", { strong, code })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{interfaceLanguageItems.map((_, idx) => (
<li key={idx}>{t.rich(`interfaceLanguage.items.${idx}`, { strong, code })}</li>
))}
</ul>
<Callout variant="tip" title={t("interfaceLanguage.scopeTitle")}>
{t.rich("interfaceLanguage.scopeBody", { strong, code })}
</Callout>
<h2 id="navigation-order" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("navigationOrder.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("navigationOrder.intro", { strong, code })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{navigationOrderItems.map((_, idx) => (
<li key={idx}>{t.rich(`navigationOrder.items.${idx}`, { strong, code })}</li>
))}
</ul>
<Callout variant="info" title={t("navigationOrder.landingTitle")}>
{t.rich("navigationOrder.landingBody", { strong, code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("networkUnits.heading")}</h2>
<figure className="my-4">
@@ -62,6 +62,11 @@ export default async function AppTabPage({
{chunks}
</Link>
)
const linkApps = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard/apps" className="text-blue-600 hover:underline">
{chunks}
</Link>
)
const richList = (base: string, items: string[]) => (
<ul className="mt-2 list-disc space-y-2 pl-6 text-gray-800">
{items.map((_, idx) => (
@@ -77,6 +82,9 @@ export default async function AppTabPage({
<p className="mt-6 text-gray-800">{t.rich("intro.p1", { strong, em, code })}</p>
<p className="mt-4 text-gray-800">{t.rich("intro.p2", { strong, em, code, link: linkUpdates })}</p>
<Callout variant="info">{t.rich("intro.callout", { strong, em, code })}</Callout>
<Callout variant="tip" title={t("intro.dashboardTitle")}>
{t.rich("intro.dashboard", { strong, appsLink: linkApps })}
</Callout>
<h2 className="mt-10 mb-4 text-2xl font-semibold text-gray-900">{t("overview.heading")}</h2>
<p className="text-gray-800">{t("overview.lead")}</p>
+1
View File
@@ -60,6 +60,7 @@ export const sidebarItems: MenuItem[] = [
href: "/docs/monitor/dashboard",
submenu: [
{ title: "System Overview tab", i18nKey: "dashboardSystemOverview", href: "/docs/monitor/dashboard/system-overview" },
{ title: "Apps tab", i18nKey: "dashboardApps", href: "/docs/monitor/dashboard/apps" },
{ title: "Storage tab", i18nKey: "dashboardStorage", href: "/docs/monitor/dashboard/storage" },
{ title: "Network tab", i18nKey: "dashboardNetwork", href: "/docs/monitor/dashboard/network" },
{
+1
View File
@@ -59,6 +59,7 @@
"accessAuth": "Access & Authentication",
"dashboard": "Dashboard",
"dashboardSystemOverview": "System Overview tab",
"dashboardApps": "Apps tab",
"dashboardStorage": "Storage tab",
"dashboardNetwork": "Network tab",
"dashboardVmsLxcs": "VMs & LXCs tab",
@@ -0,0 +1,127 @@
{
"meta": {
"title": "Apps dashboard: one launcher for every Web Link | ProxMenux",
"description": "Use the Apps dashboard to open registered LXC Web Links and custom links, organize them by category, and jump back to the guest that owns each service."
},
"header": {
"title": "Dashboard: Apps tab",
"description": "A single launcher for the web interfaces you use across the node, whether they come from a registered LXC application or a custom Web Link.",
"section": "ProxMenux Monitor · Dashboard"
},
"intro": {
"title": "A launcher, not an application installer",
"body": "The <strong>Apps</strong> tab does not install software or discover services by itself. It turns saved Web Links into one searchable grid. LXC-owned links are configured in the guest's <appTabLink>App tab</appTabLink>; custom links let you include VM services, reverse proxies and external dashboards."
},
"sources": {
"heading": "What appears in Apps",
"intro": "Each launchable URL becomes its own card. The source determines where that card is edited, but not how it looks during normal use.",
"colSource": "Source",
"colAppears": "What appears",
"colManagedFrom": "Where it is managed",
"rows": [
{
"source": "Registered LXC application",
"appears": "One card for every Web Link saved on the application record.",
"managedFrom": "VMs & LXCs → LXC modal → App"
},
{
"source": "Custom link bound to a guest",
"appears": "A service URL associated with a specific VM or LXC.",
"managedFrom": "Apps → Edit"
},
{
"source": "Unbound custom link",
"appears": "Any HTTP(S) destination, including an external or reverse-proxied service.",
"managedFrom": "Apps → Edit"
}
],
"relationshipTitle": "Apps and the LXC App tab have different jobs",
"relationshipBody": "The LXC <appTabLink>App tab</appTabLink> owns application identity, Web Links and optional version tracking. The top-level <strong>Apps</strong> tab consumes those links and makes them easy to launch. Editing an LXC-owned card therefore takes you back to its guest record rather than creating a second copy of the configuration."
},
"cards": {
"heading": "How to use a card",
"intro": "The whole card is the launch target, while its smaller controls provide context without opening the service.",
"items": [
"Click the card to open its URL in a new browser tab.",
"The logo uses the Web Link logo first and falls back to the application logo when appropriate.",
"The category chip uses the same colour and label as the corresponding Web Link in the LXC App tab.",
"A guest pill identifies the bound VM or LXC. Click it to open that guest's modal; LXC links land directly on <strong>App</strong>, while VM links open <strong>Status</strong>.",
"A purple upward arrow means the specific application or Docker image has an available update. It is not a general update count for the whole guest."
]
},
"toolbar": {
"heading": "Search, filter and sort",
"intro": "The toolbar narrows a large application collection without changing the saved records.",
"items": [
"Search matches the application name, guest name, VMID and category.",
"The category filter shows one category at a time or all applications.",
"Sort by <strong>Name</strong>, <strong>ID</strong> or <strong>Category</strong>. Category sorting inserts group headings between card groups.",
"The selected sort order is remembered in this browser. Search text and category filtering reset when the page is revisited.",
"The Apps tab remains available when the grid is empty, so the first custom link can be added without registering an LXC application first."
]
},
"customLinks": {
"heading": "Adding and editing custom Web Links",
"intro": "Use a custom link when the service is not represented by a registered LXC application.",
"steps": [
"Press <strong>Add link</strong> from the toolbar or the empty state.",
"Enter a name and a complete <code>http://</code> or <code>https://</code> URL.",
"Optionally add a logo, choose or create a category, and bind the link to a VM or LXC.",
"Save it. The new card is inserted into the grid immediately.",
"To change or remove a custom link, press <strong>Edit</strong> and use the pencil on its card."
],
"colField": "Field",
"colRequired": "Required",
"colPurpose": "Purpose",
"fields": [
{ "field": "Name", "required": "Yes", "purpose": "The card title and search term." },
{ "field": "URL", "required": "Yes", "purpose": "The complete HTTP(S) destination opened by the card." },
{ "field": "Logo URL", "required": "No", "purpose": "A remote image shown on the card." },
{ "field": "Category", "required": "No", "purpose": "Adds a shared badge and enables category filtering." },
{ "field": "Guest binding", "required": "No", "purpose": "Associates the link with a VM or LXC and enables the guest shortcut." }
],
"editTitle": "Edit the source of truth",
"editBody": "Edit mode only exposes pencils for custom links. To change a card generated from a registered LXC application, open that guest's <appTabLink>App tab</appTabLink> and edit the saved Web Link there."
},
"categories": {
"heading": "Categories and update signals",
"intro": "Categories organize links; update signals report software state. They are intentionally separate.",
"items": [
"Catalog-assisted LXC registration can prefill a category, but it remains editable.",
"Custom category names can be created from the same selector and then reused by the filter.",
"Category colours are deterministic and adapt to the light or dark theme. Purple and red ranges are reserved for update and danger states.",
"For Docker Web Links, ProxMenux matches the card to its container or image and reports that image's update state. A Docker Engine update remains in the LXC <updatesLink>Updates tab</updatesLink>."
]
},
"persistence": {
"heading": "Persistence and cache behaviour",
"intro": "The dashboard is designed to open from already available Monitor data rather than rescan every guest.",
"items": [
"Custom links are stored atomically in <code>/etc/proxmenux/custom_links.json</code> and warmed into memory when the Monitor starts.",
"Registered LXC links remain part of their application's saved Monitor record; the Apps dashboard does not duplicate them into the custom-link file.",
"Creating, editing or deleting a custom link refreshes the in-memory list immediately.",
"The sort preference is browser-local. It is not synchronized between devices or users.",
"The top-level position of Apps is controlled separately by <settingsLink>Settings → Navigation order</settingsLink>."
]
},
"troubleshooting": {
"heading": "Common situations",
"colProblem": "Situation",
"colResolution": "Resolution",
"rows": [
{ "problem": "An LXC application is registered but no card appears", "resolution": "Open its App tab and add at least one Web Link. Version tracking alone does not create a launcher card." },
{ "problem": "The URL or logo is wrong", "resolution": "Edit the custom link in Apps, or edit the Web Link in the owning LXC App tab." },
{ "problem": "A Docker service is missing", "resolution": "Edit the registered Docker application and save the service's published web port as a Web Link." },
{ "problem": "The purple arrow refers to the wrong Docker update", "resolution": "Review the Web Link name and description so they can be matched to the intended container or image." },
{ "problem": "Apps opens first instead of System Overview", "resolution": "The first saved item in Settings → Navigation order becomes the landing tab. Restore the default order or move Overview first." }
]
},
"whereNext": {
"heading": "Where to next",
"items": [
{ "label": "LXC App tab", "href": "/docs/monitor/dashboard/vms-lxcs/app", "tail": " — register applications, Web Links and optional version tracking." },
{ "label": "LXC Updates tab", "href": "/docs/monitor/dashboard/vms-lxcs/updates", "tail": " — configure and run application, Docker Engine and image updates." },
{ "label": "Settings tab", "href": "/docs/monitor/dashboard/settings#navigation-order", "tail": " — change the navigation order and landing tab." }
]
}
}
@@ -1,19 +1,19 @@
{
"meta": {
"title": "ProxMenux Monitor — Dashboard | ProxMenux Documentation",
"description": "The dashboard is the main UI of ProxMenux Monitor: nine tabs (System Overview, Storage, Network, VMs & LXCs, Hardware, System Logs, Terminal, Security, Settings) plus the global header with the Health Monitor status pill."
"description": "The dashboard is the main UI of ProxMenux Monitor: ten content tabs including the Apps launcher, plus the global header and a configurable top-level navigation order."
},
"header": {
"title": "Dashboard",
"description": "The dashboard is the everyday view of ProxMenux Monitor — nine tabs each focused on one slice of the host plus a global header with the Health Monitor status pill, the node identity and the quick-refresh control.",
"description": "The dashboard is the everyday view of ProxMenux Monitor — ten content tabs, including the Apps launcher, plus a global header and a configurable top-level navigation order.",
"section": "ProxMenux Monitor"
},
"oneHeader": {
"title": "One header, nine tabs",
"title": "One header, ten tabs",
"body": "The header (logo, node name, status pill, uptime, refresh, theme toggle) stays visible everywhere. The active tab below it changes the entire content area. The status pill colour mirrors the worst category of the <link>Health Monitor</link> — it's the same data point seen from the dashboard."
},
"tabs": {
"heading": "The nine tabs",
"heading": "The ten tabs",
"intro": "Each tab has its own dedicated page. Pages are added incrementally as the documentation is filled in; below is the full list and what each one is responsible for.",
"headerTab": "Tab",
"headerOwns": "What it owns",
@@ -21,7 +21,12 @@
{
"name": "System Overview",
"linksTo": "/docs/monitor/dashboard/system-overview",
"owns": "CPU / memory / temperature widgets, active VM & LXC count, historical metrics charts, storage and network summaries. Default landing tab."
"owns": "CPU / memory / temperature widgets, active VM & LXC count, historical metrics charts, storage and network summaries. Default landing tab until Navigation order is customized."
},
{
"name": "Apps",
"linksTo": "/docs/monitor/dashboard/apps",
"owns": "Unified launcher for registered LXC Web Links and custom links, with categories, search, guest shortcuts and per-application update signals."
},
{
"name": "Storage",
@@ -53,7 +58,7 @@
},
{
"name": "Settings",
"owns": "Notification channels, AI provider, suppression durations, branding, advanced flags."
"owns": "Monitor language, navigation order, notification channels, AI provider, health thresholds, exclusions and post-install optimization inventory."
}
]
},
@@ -71,6 +76,11 @@
"whereNext": {
"heading": "Where to next",
"items": [
{
"label": "Apps tab",
"href": "/docs/monitor/dashboard/apps",
"tail": " — the unified Web Link launcher and its relationship with registered LXC applications."
},
{
"label": "System Overview tab",
"href": "/docs/monitor/dashboard/system-overview",
@@ -1,17 +1,42 @@
{
"meta": {
"title": "ProxMenux Monitor — Dashboard: Settings tab | ProxMenux Documentation",
"description": "The Settings tab groups dashboard preferences (network units, suppression durations, storage / interface exclusions), the embedded notification + AI panel, and a transparent inventory of every ProxMenux post-install optimization currently active on the host with click-through to its source code."
"description": "The Settings tab controls the Monitor language, navigation order, dashboard preferences, health thresholds and exclusions, notifications, AI and the post-install optimization inventory."
},
"header": {
"title": "Dashboard: Settings tab",
"description": "Dashboard preferences, monitoring exclusions, the embedded notification + AI configuration panel, and a live inventory of the ProxMenux post-install optimizations currently active on the host.",
"description": "Monitor language, navigation order, dashboard preferences, monitoring exclusions, notifications, AI configuration and the live ProxMenux optimization inventory.",
"section": "ProxMenux Monitor · Dashboard"
},
"intro": {
"title": "Where each setting actually lives",
"body": "The Settings tab is a single surface for several distinct concerns: how the dashboard renders, what gets watched by the Health Monitor, how alerts go out, and what ProxMenux has already changed on the host. Cards that have their own deep documentation page link out rather than duplicating content here — Settings is the entry point, not the manual."
},
"interfaceLanguage": {
"heading": "Interface Language",
"intro": "Choose the language used by the <strong>web Monitor</strong>. The change is applied immediately to the current interface; it does not restart the service and does not alter the language used by ProxMenux shell menus.",
"items": [
"ProxMenux Monitor currently offers English, German, Spanish, French, Italian, Portuguese, Slovak and Swedish.",
"On the first visit, the Monitor uses the browser language when it is supported; otherwise it falls back to English.",
"Changing the selection updates the page language and synchronizes other open tabs from the same browser profile.",
"If a translated key is missing, only that text falls back to English instead of rendering a broken placeholder."
],
"scopeTitle": "Browser-local preference",
"scopeBody": "The selection is stored in <code>localStorage</code> under <code>proxmenux-ui-language</code>. It is therefore specific to this browser profile and device, not a node-wide setting. Each operator can choose a different Monitor language."
},
"navigationOrder": {
"heading": "Navigation order",
"intro": "Use this card to arrange the seven top-level navigation slots: <strong>Overview, Apps, VMs & LXCs, Node, Backup, Terminal and Admin</strong>. Press <strong>Edit</strong>, drag the rows, then save the result.",
"items": [
"Mouse dragging starts on press. Touch uses a short 250 ms long-press so normal scrolling is not mistaken for a reorder.",
"The grouped <strong>Node</strong> and <strong>Admin</strong> entries move as complete units; their internal pages keep their canonical order.",
"The mobile menu follows the same saved order.",
"<strong>Restore default</strong> resets the draft order. Save to make the reset permanent.",
"A new top-level tab introduced by a future release is appended to an existing custom order rather than deleting the user's arrangement."
],
"landingTitle": "The first item becomes the landing tab",
"landingBody": "The first saved slot is what the Monitor opens after loading. If <strong>Node</strong> is first, the landing page is Storage; if <strong>Admin</strong> is first, it is System Logs. The order is stored only in this browser under <code>proxmenux-nav-order</code>, so different devices can use different workflows."
},
"networkUnits": {
"heading": "Network Units",
"imageAlt": "Network Units card with Network Unit Display dropdown set to Bytes",
@@ -257,6 +282,16 @@
"headerEndpoint": "Endpoint",
"headerSource": "Source",
"rows": [
{
"card": "Interface Language",
"endpoint": "localStorage",
"source": "Browser preference <code>proxmenux-ui-language</code>, initially derived from the browser locale when no saved choice exists."
},
{
"card": "Navigation order",
"endpoint": "localStorage",
"source": "Browser preference <code>proxmenux-nav-order</code>; it also determines the landing tab."
},
{
"card": "Network Units",
"endpoint": "/api/settings",
@@ -292,6 +327,11 @@
"whereNext": {
"heading": "Where to next",
"items": [
{
"label": "Apps tab",
"href": "/docs/monitor/dashboard/apps",
"tail": " — the launcher whose top-level position can be changed with Navigation order."
},
{
"label": "Notifications",
"href": "/docs/monitor/notifications",
@@ -10,7 +10,9 @@
"intro": {
"p1": "The <strong>App</strong> tab records which applications belong to an LXC. A registration can contain only a name and web link, or also include an installed-version detector and an upstream source.",
"p2": "The procedure that changes software is configured separately on the <link>Updates tab</link>. Saving an app never runs an installer or updater.",
"callout": "Registration, version tracking and updating are three independent capabilities. Any one can be used without requiring the other two."
"callout": "Registration, version tracking and updating are three independent capabilities. Any one can be used without requiring the other two.",
"dashboardTitle": "From registration to the Apps launcher",
"dashboard": "Every Web Link saved here is also presented as a card in the top-level <appsLink>Apps tab</appsLink>. This LXC page remains the source of truth for the application's name, logo, links and version tracking."
},
"overview": {
"heading": "What an application record can provide",
+2 -1
View File
@@ -59,6 +59,7 @@
"accessAuth": "Acceso y autenticación",
"dashboard": "Panel",
"dashboardSystemOverview": "Pestaña Resumen del sistema",
"dashboardApps": "Pestaña Apps",
"dashboardStorage": "Pestaña Almacenamiento",
"dashboardNetwork": "Pestaña Red",
"dashboardVmsLxcs": "Pestaña VMs y LXCs",
@@ -68,7 +69,7 @@
"dashboardSystemLogs": "Pestaña Logs del sistema",
"dashboardTerminal": "Pestaña Terminal",
"dashboardSecurity": "Pestaña Seguridad",
"dashboardSettings": "Pestaña Settings",
"dashboardSettings": "Pestaña Ajustes",
"healthMonitor": "Monitor de salud",
"notifications": "Notificaciones",
"aiAssistant": "Asistente de IA",
@@ -0,0 +1,127 @@
{
"meta": {
"title": "Panel Apps: un lanzador para todos los enlaces web | ProxMenux",
"description": "Utiliza el panel Apps para abrir enlaces web registrados en LXCs y enlaces personalizados, organizarlos por categoría y acceder al sistema invitado que aloja cada servicio."
},
"header": {
"title": "Panel: pestaña Apps",
"description": "Un único lanzador para las interfaces web que utilizas en el nodo, tanto si proceden de una aplicación registrada en un LXC como de un enlace web personalizado.",
"section": "ProxMenux Monitor · Panel"
},
"intro": {
"title": "Un lanzador, no un instalador de aplicaciones",
"body": "La pestaña <strong>Apps</strong> no instala software ni descubre servicios por sí sola. Convierte los enlaces web guardados en una cuadrícula con búsqueda. Los enlaces que pertenecen a un LXC se configuran en la <appTabLink>pestaña App</appTabLink> del contenedor; los enlaces personalizados permiten incluir servicios de VMs, proxies inversos y paneles externos."
},
"sources": {
"heading": "Qué aparece en Apps",
"intro": "Cada URL que se puede abrir genera su propia tarjeta. El origen determina dónde se edita, pero no cambia su aspecto durante el uso normal.",
"colSource": "Origen",
"colAppears": "Qué aparece",
"colManagedFrom": "Dónde se gestiona",
"rows": [
{
"source": "Aplicación registrada en un LXC",
"appears": "Una tarjeta por cada enlace web guardado en el registro de la aplicación.",
"managedFrom": "VMs y LXCs → modal del LXC → App"
},
{
"source": "Enlace personalizado asociado a un invitado",
"appears": "La URL de un servicio asociado a una VM o un LXC concretos.",
"managedFrom": "Apps → Editar"
},
{
"source": "Enlace personalizado sin asociación",
"appears": "Cualquier destino HTTP(S), incluido un servicio externo o publicado mediante proxy inverso.",
"managedFrom": "Apps → Editar"
}
],
"relationshipTitle": "Apps y la pestaña App del LXC tienen funciones diferentes",
"relationshipBody": "La <appTabLink>pestaña App</appTabLink> del LXC gestiona la identidad de la aplicación, sus enlaces web y el seguimiento opcional de versión. La pestaña principal <strong>Apps</strong> utiliza esos enlaces para ofrecer un acceso rápido. Por eso, al editar una tarjeta que pertenece a un LXC se vuelve a su registro original en lugar de crear una segunda configuración."
},
"cards": {
"heading": "Cómo utilizar una tarjeta",
"intro": "La tarjeta completa abre el servicio; sus controles pequeños aportan contexto sin abrir la URL.",
"items": [
"Pulsa la tarjeta para abrir su URL en una pestaña nueva del navegador.",
"El logotipo utiliza primero el definido para el enlace web y, cuando corresponde, recurre al logotipo general de la aplicación.",
"La etiqueta de categoría utiliza el mismo color y nombre que el enlace correspondiente en la pestaña App del LXC.",
"La etiqueta del invitado identifica la VM o el LXC asociado. Púlsala para abrir su modal; los enlaces de LXC abren directamente <strong>App</strong> y los de VM abren <strong>Estado</strong>.",
"La flecha morada hacia arriba indica que existe una actualización para esa aplicación o imagen Docker concreta. No representa el total de actualizaciones del invitado."
]
},
"toolbar": {
"heading": "Buscar, filtrar y ordenar",
"intro": "La barra de herramientas permite reducir una colección grande sin modificar los registros guardados.",
"items": [
"La búsqueda tiene en cuenta el nombre de la aplicación, el nombre del invitado, el VMID y la categoría.",
"El filtro de categoría permite mostrar una categoría concreta o todas las aplicaciones.",
"Se puede ordenar por <strong>Nombre</strong>, <strong>ID</strong> o <strong>Categoría</strong>. Al ordenar por categoría se insertan encabezados entre los grupos de tarjetas.",
"El orden seleccionado se recuerda en este navegador. El texto de búsqueda y el filtro de categoría se restablecen al volver a la página.",
"La pestaña Apps permanece disponible aunque la cuadrícula esté vacía, de modo que se pueda añadir el primer enlace personalizado sin registrar antes una aplicación LXC."
]
},
"customLinks": {
"heading": "Añadir y editar enlaces web personalizados",
"intro": "Utiliza un enlace personalizado cuando el servicio no esté representado por una aplicación registrada en un LXC.",
"steps": [
"Pulsa <strong>Añadir enlace</strong> en la barra de herramientas o en el estado vacío.",
"Introduce un nombre y una URL completa que empiece por <code>http://</code> o <code>https://</code>.",
"Opcionalmente, añade un logotipo, elige o crea una categoría y asocia el enlace a una VM o un LXC.",
"Guarda el enlace. La nueva tarjeta se añade a la cuadrícula de inmediato.",
"Para modificar o eliminar un enlace personalizado, pulsa <strong>Editar</strong> y utiliza el lápiz de su tarjeta."
],
"colField": "Campo",
"colRequired": "Obligatorio",
"colPurpose": "Finalidad",
"fields": [
{ "field": "Nombre", "required": "Sí", "purpose": "Título de la tarjeta y término de búsqueda." },
{ "field": "URL", "required": "Sí", "purpose": "Destino HTTP(S) completo que abre la tarjeta." },
{ "field": "URL del logotipo", "required": "No", "purpose": "Imagen remota que se muestra en la tarjeta." },
{ "field": "Categoría", "required": "No", "purpose": "Añade una etiqueta común y permite filtrar por categoría." },
{ "field": "Asociación a invitado", "required": "No", "purpose": "Vincula el enlace a una VM o un LXC y activa su acceso directo." }
],
"editTitle": "Edita la fuente original",
"editBody": "El modo de edición solo muestra lápices en los enlaces personalizados. Para cambiar una tarjeta generada desde una aplicación LXC registrada, abre la <appTabLink>pestaña App</appTabLink> de ese contenedor y edita allí el enlace web guardado."
},
"categories": {
"heading": "Categorías y señales de actualización",
"intro": "Las categorías organizan los enlaces; las señales de actualización informan del estado del software. Son conceptos independientes.",
"items": [
"El registro asistido por catálogo puede proponer una categoría para una aplicación LXC, pero el usuario siempre puede modificarla.",
"Desde el mismo selector se pueden crear categorías propias y reutilizarlas después en el filtro.",
"Los colores de las categorías son estables y se adaptan al tema claro u oscuro. El morado y el rojo se reservan para actualizaciones y estados de peligro.",
"En los enlaces web de Docker, ProxMenux relaciona la tarjeta con su contenedor o imagen y muestra el estado de actualización de esa imagen. Las actualizaciones de Docker Engine permanecen en la <updatesLink>pestaña Updates</updatesLink> del LXC."
]
},
"persistence": {
"heading": "Persistencia y comportamiento de la caché",
"intro": "El panel está diseñado para abrirse con los datos ya disponibles en el Monitor, sin volver a analizar todos los invitados.",
"items": [
"Los enlaces personalizados se guardan de forma atómica en <code>/etc/proxmenux/custom_links.json</code> y se cargan en memoria al iniciar el Monitor.",
"Los enlaces registrados en LXCs siguen formando parte del registro guardado de cada aplicación; el panel Apps no los duplica en el archivo de enlaces personalizados.",
"Al crear, editar o eliminar un enlace personalizado, la lista en memoria se actualiza de inmediato.",
"La preferencia de ordenación se guarda solo en el navegador. No se sincroniza entre dispositivos ni usuarios.",
"La posición de Apps en la navegación principal se configura por separado en <settingsLink>Ajustes → Orden de navegación</settingsLink>."
]
},
"troubleshooting": {
"heading": "Situaciones habituales",
"colProblem": "Situación",
"colResolution": "Solución",
"rows": [
{ "problem": "Una aplicación LXC está registrada, pero no aparece ninguna tarjeta", "resolution": "Abre su pestaña App y añade al menos un enlace web. El seguimiento de versión por sí solo no crea una tarjeta en el lanzador." },
{ "problem": "La URL o el logotipo son incorrectos", "resolution": "Edita el enlace personalizado desde Apps o modifica el enlace web en la pestaña App del LXC correspondiente." },
{ "problem": "Falta un servicio Docker", "resolution": "Edita la aplicación Docker registrada y guarda el puerto web publicado por el servicio como enlace web." },
{ "problem": "La flecha morada corresponde a otra actualización Docker", "resolution": "Revisa el nombre y la descripción del enlace web para que se puedan relacionar con el contenedor o la imagen correctos." },
{ "problem": "Apps se abre antes que Resumen del sistema", "resolution": "El primer elemento guardado en Ajustes → Orden de navegación se convierte en la pestaña inicial. Restaura el orden predeterminado o mueve Resumen a la primera posición." }
]
},
"whereNext": {
"heading": "Por dónde seguir",
"items": [
{ "label": "Pestaña App del LXC", "href": "/docs/monitor/dashboard/vms-lxcs/app", "tail": " — registrar aplicaciones, enlaces web y seguimiento opcional de versión." },
{ "label": "Pestaña Updates del LXC", "href": "/docs/monitor/dashboard/vms-lxcs/updates", "tail": " — configurar y ejecutar actualizaciones de aplicaciones, Docker Engine e imágenes." },
{ "label": "Pestaña Ajustes", "href": "/docs/monitor/dashboard/settings#navigation-order", "tail": " — cambiar el orden de navegación y la pestaña inicial." }
]
}
}
@@ -1,19 +1,19 @@
{
"meta": {
"title": "ProxMenux Monitor — Panel | ProxMenux Documentation",
"description": "El panel es la UI principal de ProxMenux Monitor: nueve pestañas (Resumen del sistema, Almacenamiento, Red, VMs y LXCs, Hardware, Logs del sistema, Terminal, Seguridad, Settings) más la cabecera global con la información de estado del Monitor de salud."
"description": "El panel es la interfaz principal de ProxMenux Monitor: diez pestañas de contenido, incluido el lanzador Apps, además de la cabecera global y un orden de navegación configurable."
},
"header": {
"title": "Panel",
"description": "El panel es la vista del día a día de ProxMenux Monitor — nueve pestañas, cada una centrada en una parte del host, más una cabecera global con la información de estado del Monitor de salud, la identidad del nodo y el control de refresco rápido.",
"description": "El panel es la vista diaria de ProxMenux Monitor: diez pestañas de contenido, incluido el lanzador Apps, además de una cabecera global y un orden de navegación configurable.",
"section": "ProxMenux Monitor"
},
"oneHeader": {
"title": "Una cabecera, nueve pestañas",
"title": "Una cabecera, diez pestañas",
"body": "La cabecera (logo, nombre del nodo, información de estado, uptime, refresco, conmutador de tema) permanece visible en todo momento. La pestaña activa que hay debajo cambia el área de contenido entera. El color de la información de estado refleja la peor categoría del <link>Monitor de salud</link> — es el mismo dato visto desde el panel."
},
"tabs": {
"heading": "Las nueve pestañas",
"heading": "Las diez pestañas",
"intro": "Cada pestaña tiene su propia página dedicada. Las páginas se añaden de forma incremental a medida que se completa la documentación; abajo está la lista completa con lo que cubre cada una.",
"headerTab": "Pestaña",
"headerOwns": "De qué se encarga",
@@ -21,7 +21,12 @@
{
"name": "Resumen del sistema",
"linksTo": "/docs/monitor/dashboard/system-overview",
"owns": "Widgets de CPU / memoria / temperatura, contador de VMs y LXCs activos, gráficas de métricas históricas, resúmenes de almacenamiento y red. Pestaña por defecto al entrar."
"owns": "Widgets de CPU, memoria y temperatura; contador de VMs y LXCs activos; gráficas históricas y resúmenes de almacenamiento y red. Es la pestaña inicial mientras no se personalice el orden de navegación."
},
{
"name": "Apps",
"linksTo": "/docs/monitor/dashboard/apps",
"owns": "Lanzador unificado para enlaces web registrados en LXCs y enlaces personalizados, con categorías, búsqueda, acceso al invitado y señales de actualización por aplicación."
},
{
"name": "Almacenamiento",
@@ -52,8 +57,8 @@
"owns": "Configuración de autenticación, contraseña / 2FA / tokens API, log de auditoría, panel opcional de Fail2Ban, despliegue de Secure Gateway."
},
{
"name": "Settings",
"owns": "Canales de notificación, proveedor de IA, duraciones de supresión, branding, flags avanzados."
"name": "Ajustes",
"owns": "Idioma del Monitor, orden de navegación, canales de notificación, proveedor de IA, umbrales de salud, exclusiones e inventario de optimizaciones."
}
]
},
@@ -71,10 +76,15 @@
"whereNext": {
"heading": "Por dónde seguir",
"items": [
{
"label": "Pestaña Apps",
"href": "/docs/monitor/dashboard/apps",
"tail": " — el lanzador unificado de enlaces web y su relación con las aplicaciones registradas en LXCs."
},
{
"label": "Pestaña Resumen del sistema",
"href": "/docs/monitor/dashboard/system-overview",
"tail": " — la pestaña por defecto, documentada al completo."
"tail": " — la pestaña inicial predeterminada, documentada al completo."
},
{
"label": "Monitor de salud",
@@ -1,16 +1,41 @@
{
"meta": {
"title": "ProxMenux Monitor — Panel: pestaña Settings | ProxMenux Documentation",
"description": "La pestaña Settings agrupa las preferencias del panel (unidades de red, duraciones de supresión, exclusiones de almacenamiento / interfaz), el panel embebido de notificaciones + IA y un inventario transparente de cada optimización post-instalación de ProxMenux actualmente activa en el host con acceso al código fuente."
"title": "ProxMenux Monitor — Panel: pestaña Ajustes | Documentación de ProxMenux",
"description": "La pestaña Ajustes controla el idioma del Monitor, el orden de navegación, las preferencias del panel, los umbrales y exclusiones de salud, las notificaciones, la IA y el inventario de optimizaciones."
},
"header": {
"title": "Panel: pestaña Settings",
"description": "Preferencias del panel, exclusiones de monitorización, el panel embebido de configuración de notificaciones + IA y un inventario en vivo de las optimizaciones post-instalación de ProxMenux actualmente activas en el host.",
"title": "Panel: pestaña Ajustes",
"description": "Idioma del Monitor, orden de navegación, preferencias del panel, exclusiones de monitorización, notificaciones, configuración de IA e inventario de optimizaciones de ProxMenux.",
"section": "ProxMenux Monitor · Panel"
},
"intro": {
"title": "Dónde vive realmente cada setting",
"body": "La pestaña Settings es una superficie única para varias preocupaciones distintas: cómo renderiza el panel, qué vigila el Monitor de salud, cómo salen las alertas y qué ha cambiado ya ProxMenux en el host. Las tarjetas que tienen su propia página de documentación profunda enlazan en lugar de duplicar el contenido aquí — Settings es el punto de entrada, no el manual."
"body": "La pestaña Ajustes reúne varias funciones distintas: cómo se muestra el panel, qué vigila el Monitor de salud, cómo se envían las alertas y qué cambios ha aplicado ProxMenux en el host. Las tarjetas que tienen su propia página de documentación enlazan a ella en lugar de duplicar el contenido: Ajustes es el punto de entrada."
},
"interfaceLanguage": {
"heading": "Idioma de la interfaz",
"intro": "Selecciona el idioma de la <strong>interfaz web del Monitor</strong>. El cambio se aplica de inmediato; no reinicia el servicio ni modifica el idioma de los menús de ProxMenux ejecutados en la terminal.",
"items": [
"ProxMenux Monitor ofrece actualmente inglés, alemán, español, francés, italiano, portugués, eslovaco y sueco.",
"En la primera visita, el Monitor utiliza el idioma del navegador si está disponible; en caso contrario, utiliza inglés.",
"Al cambiar la selección, se actualiza el idioma de la página y también el de otras pestañas abiertas con el mismo perfil del navegador.",
"Si falta una cadena traducida, solo ese texto se muestra en inglés en lugar de presentar una clave rota."
],
"scopeTitle": "Preferencia local del navegador",
"scopeBody": "La selección se guarda en <code>localStorage</code> con la clave <code>proxmenux-ui-language</code>. Por tanto, pertenece a este perfil de navegador y dispositivo; no es un ajuste global del nodo. Cada administrador puede utilizar un idioma distinto."
},
"navigationOrder": {
"heading": "Orden de navegación",
"intro": "Esta tarjeta permite ordenar los siete elementos principales: <strong>Resumen, Apps, VMs y LXCs, Nodo, Copias, Terminal y Administración</strong>. Pulsa <strong>Editar</strong>, arrastra las filas y guarda el resultado.",
"items": [
"Con el ratón, el arrastre comienza al pulsar. En una pantalla táctil se utiliza una pulsación mantenida de 250 ms para no confundir el desplazamiento normal con una reordenación.",
"Los grupos <strong>Nodo</strong> y <strong>Administración</strong> se mueven como unidades completas; sus páginas internas conservan el orden establecido por ProxMenux.",
"El menú móvil respeta el mismo orden guardado.",
"<strong>Restaurar valores predeterminados</strong> restablece el borrador. Es necesario guardar para aplicar el cambio.",
"Si una versión futura añade una nueva pestaña principal, esta se incorpora al final del orden personalizado sin borrar la organización del usuario."
],
"landingTitle": "El primer elemento se convierte en la pestaña inicial",
"landingBody": "El primer elemento guardado es la página que abre el Monitor al cargar. Si <strong>Nodo</strong> ocupa la primera posición, se abre Almacenamiento; si la ocupa <strong>Administración</strong>, se abren los Logs del sistema. El orden se guarda solo en este navegador con la clave <code>proxmenux-nav-order</code>, por lo que cada dispositivo puede utilizar una organización distinta."
},
"networkUnits": {
"heading": "Network Units",
@@ -257,6 +282,16 @@
"headerEndpoint": "Endpoint",
"headerSource": "Fuente",
"rows": [
{
"card": "Idioma de la interfaz",
"endpoint": "localStorage",
"source": "Preferencia del navegador <code>proxmenux-ui-language</code>, obtenida inicialmente del idioma del navegador cuando todavía no existe una selección guardada."
},
{
"card": "Orden de navegación",
"endpoint": "localStorage",
"source": "Preferencia del navegador <code>proxmenux-nav-order</code>; también determina la pestaña inicial."
},
{
"card": "Network Units",
"endpoint": "/api/settings",
@@ -292,6 +327,11 @@
"whereNext": {
"heading": "Por dónde seguir",
"items": [
{
"label": "Pestaña Apps",
"href": "/docs/monitor/dashboard/apps",
"tail": " — el lanzador cuya posición se puede cambiar desde Orden de navegación."
},
{
"label": "Notificaciones",
"href": "/docs/monitor/notifications",
@@ -10,7 +10,9 @@
"intro": {
"p1": "La pestaña <strong>App</strong> registra qué aplicaciones pertenecen a un LXC. Un registro puede contener solo un nombre y un enlace web o incluir también un detector de la versión instalada y una fuente para la versión disponible.",
"p2": "El procedimiento que modifica el software se configura por separado en la <link>pestaña Actualizaciones</link>. Guardar una app nunca ejecuta un instalador ni un actualizador.",
"callout": "El registro, el seguimiento de versiones y la actualización son tres capacidades independientes. Cada una puede utilizarse sin exigir las otras dos."
"callout": "El registro, el seguimiento de versiones y la actualización son tres capacidades independientes. Cada una puede utilizarse sin exigir las otras dos.",
"dashboardTitle": "Del registro al lanzador Apps",
"dashboard": "Cada enlace web guardado aquí también se muestra como una tarjeta en la <appsLink>pestaña Apps</appsLink> principal. Esta página del LXC sigue siendo la fuente original para el nombre, el logotipo, los enlaces y el seguimiento de versión de la aplicación."
},
"overview": {
"heading": "Qué puede contener un registro",