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 &&
+199 -36
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>
<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"
title={url}
>
<ExternalLink className="h-3.5 w-3.5 flex-shrink-0" />
<span className="font-mono text-sm truncate">{url}</span>
</a>
<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 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"
>
{searchingApplications
? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
: <Search className="h-4 w-4 mr-1.5" />}
{searchingApplications
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 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" />
{t("vmLxc.appEditor.addAnotherApplication")}
{hiddenDetections.length > 0 && (
<span className="ml-2 text-[10px] opacity-70">
· {t("vmLxc.appEditor.hiddenSuffix", { count: hiddenDetections.length })}
</span>
)}
<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
+41 -18
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,30 +1611,36 @@ 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()
swap = psutil.swap_memory()
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
})
self.state_history[state_key] = [
entry for entry in self.state_history[state_key]
if current_time - entry['time'] < 600
]
mem_critical_samples = [
entry for entry in self.state_history[state_key]
if entry['mem_percent'] >= 90 and
@@ -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)
@@ -1672,12 +1690,12 @@ class HealthMonitor:
else:
status = 'OK'
reason = None
ram_avail_gb = round(memory.available / (1024**3), 2)
ram_total_gb = round(memory.total / (1024**3), 2)
swap_used_gb = round(swap.used / (1024**3), 2)
swap_total_gb = round(swap.total / (1024**3), 2)
# Determine per-sub-check status
ram_status = 'CRITICAL' if mem_percent >= 90 and mem_critical_count >= MEM_CRITICAL_MIN_SAMPLES else ('WARNING' if mem_percent >= self.MEMORY_WARNING and mem_warning_count >= MEM_WARNING_MIN_SAMPLES else 'OK')
swap_status = 'CRITICAL' if swap_critical >= 2 else 'OK'
@@ -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.