mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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).",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
// * 260–319° purple/violet — "update available" (ArrowUpCircle)
|
||||
// * 340–19° 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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 d’exploitation.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "ProxMenux-Monitor",
|
||||
"version": "1.2.4.2-beta",
|
||||
"version": "1.2.5",
|
||||
"description": "Proxmox System Monitoring Dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user