mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 18:32:13 +00:00
Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts: # .github/workflows/test-oci-registries.yml
This commit is contained in:
@@ -38,6 +38,18 @@ DEFAULT_CONTEXT = "Context: Technical message for Proxmox and IT. Translate:"
|
||||
# provider, then restore the exact source spelling afterwards. Keep longer
|
||||
# terms first so ``gasket`` cannot consume part of ``gasket-dkms``.
|
||||
PROTECTED_TECHNICAL_TERMS = (
|
||||
# Product names, kept whole. Left to a translator, "Proxmox Backup Server"
|
||||
# became "Proxmox Sicherungsserver" and "Serveur de sauvegarde Proxmox",
|
||||
# and when a provider rightly handed it back unchanged the string was
|
||||
# retried and then dropped as untranslated.
|
||||
"Proxmox Backup Server",
|
||||
"Proxmox Datacenter Manager",
|
||||
"Proxmox Mail Gateway",
|
||||
"Proxmox VE",
|
||||
"ProxMenux Monitor",
|
||||
"ProxMenux Optimizations",
|
||||
"Secure Gateway",
|
||||
"Tailscale",
|
||||
"google/gasket-driver",
|
||||
"feranick/gasket-driver",
|
||||
"libedgetpu1-std",
|
||||
|
||||
@@ -34,7 +34,22 @@ jobs:
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p 'test_carried*.py' -v
|
||||
python3 -m unittest discover -s oci/tests -p test_restore_recovery.py -v
|
||||
- name: Run read-only volume preparation regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
test -x /bin/bash
|
||||
test -x /usr/bin/jq
|
||||
python3 -m unittest discover -s oci/tests -p test_readonly_volume_preparation.py -v
|
||||
- name: Run offline registry account tests
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: python3 -m unittest discover -s oci/tests -p 'test_registry_*.py' -v
|
||||
- name: Run external OCI settings regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p 'test_external_tags.py' -v
|
||||
python3 -m unittest discover -s oci/tests -p 'test_instance_reconcile.py' -v
|
||||
python3 -m unittest discover -s oci/tests -p 'test_guessed_endpoint_healthcheck.py' -v
|
||||
python3 -m unittest discover -s oci/tests -p 'test_runtime_home_*.py' -v
|
||||
|
||||
@@ -17,6 +17,9 @@ on:
|
||||
- 'scripts/global/repository-functions.sh'
|
||||
- 'scripts/menus/pbs_menu.sh'
|
||||
- 'scripts/utilities/pbs_update.sh'
|
||||
- 'scripts/global/update-pbs.sh'
|
||||
- 'scripts/menus/menu_post_install.sh'
|
||||
- 'scripts/post_install/*.sh'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
@@ -49,7 +52,7 @@ jobs:
|
||||
run: |
|
||||
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs.py -v
|
||||
python -B -m unittest discover -s AppImage/scripts/tests -p test_auth_manager_ssl_reload.py -v
|
||||
for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh; do
|
||||
for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh scripts/post_install/uninstall-tools.sh scripts/global/update-pbs.sh scripts/menus/menu_post_install.sh; do
|
||||
bash -n "$script"
|
||||
done
|
||||
- uses: actions/setup-node@v6
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
import { useIsPbs } from "@/lib/use-platform"
|
||||
|
||||
interface CategoryCheck {
|
||||
status: string
|
||||
@@ -119,6 +120,7 @@ const CATEGORIES = [
|
||||
|
||||
export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
|
||||
const t = useT()
|
||||
const isPbs = useIsPbs()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [healthData, setHealthData] = useState<HealthDetails | null>(null)
|
||||
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
|
||||
@@ -460,6 +462,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
return originalStatus?.toUpperCase() || "UNKNOWN"
|
||||
}
|
||||
|
||||
// Checks the host reports as not applicable (guests on PBS) are not listed or counted.
|
||||
const visibleCategories = CATEGORIES.filter(({ key }) =>
|
||||
!(healthData?.details?.[key as keyof typeof healthData.details] as { not_applicable?: boolean } | undefined)?.not_applicable)
|
||||
const categoryLabel = (key: string) => t(`healthStatus.categories.${isPbs && key === "services" ? "pbsServices" : key}`)
|
||||
|
||||
const getHealthStats = () => {
|
||||
if (!healthData?.details) return { total: 0, healthy: 0, info: 0, warnings: 0, critical: 0, unknown: 0 }
|
||||
|
||||
@@ -469,7 +476,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
let critical = 0
|
||||
let unknown = 0
|
||||
|
||||
CATEGORIES.forEach(({ key }) => {
|
||||
visibleCategories.forEach(({ key }) => {
|
||||
const categoryData = healthData.details[key as keyof typeof healthData.details]
|
||||
if (categoryData) {
|
||||
const effectiveStatus = getEffectiveStatus(key, categoryData.status)
|
||||
@@ -481,7 +488,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
}
|
||||
})
|
||||
|
||||
return { total: CATEGORIES.length, healthy, info, warnings, critical, unknown }
|
||||
return { total: visibleCategories.length, healthy, info, warnings, critical, unknown }
|
||||
}
|
||||
|
||||
const stats = getHealthStats()
|
||||
@@ -759,7 +766,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
|
||||
{/* Category List */}
|
||||
<div className="space-y-2">
|
||||
{CATEGORIES.map(({ key, Icon }) => {
|
||||
{visibleCategories.map(({ key, Icon }) => {
|
||||
const categoryData = healthData.details[key as keyof typeof healthData.details]
|
||||
const originalStatus = categoryData?.status || "UNKNOWN"
|
||||
const status = getEffectiveStatus(key, originalStatus)
|
||||
@@ -784,7 +791,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
<p className="font-medium text-xs sm:text-sm truncate">{t(`healthStatus.categories.${key}`)}</p>
|
||||
<p className="font-medium text-xs sm:text-sm truncate">{categoryLabel(key)}</p>
|
||||
{hasChecks && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
({Object.values(checks).filter(c => c.installed !== false).length})
|
||||
@@ -871,7 +878,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
{filteredDismissed.map((item) => {
|
||||
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
|
||||
const CatIcon = catMeta?.Icon || BellOff
|
||||
const catLabel = catMeta ? t(`healthStatus.categories.${catMeta.key}`) : item.category
|
||||
const catLabel = catMeta ? categoryLabel(catMeta.key) : item.category
|
||||
const isPermanent = item.permanent || item.suppression_remaining_hours === -1
|
||||
|
||||
return (
|
||||
@@ -936,7 +943,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
<div key={cs.key} className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<CatIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5 text-blue-400/70 shrink-0" />
|
||||
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{catMeta ? t(`healthStatus.categories.${catMeta.key}`) : cs.label}</span>
|
||||
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{catMeta ? categoryLabel(catMeta.key) : cs.label}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[9px] sm:text-[10px] border-blue-500/30 text-blue-400/80 bg-transparent shrink-0">
|
||||
{durationLabel}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import useSWR, { mutate as refreshCache } from "swr"
|
||||
import { ArrowLeft, ChevronDown, DatabaseBackup, ExternalLink, Plus, Server, Trash2 } from "lucide-react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Switch } from "./ui/switch"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface MonitorLink {
|
||||
key: string
|
||||
name: string
|
||||
product: "pve" | "pbs"
|
||||
group: "cluster" | "standalone" | "pbs"
|
||||
url: string | null
|
||||
status: "online" | "offline" | "unknown"
|
||||
native_url?: string | null
|
||||
manual: boolean
|
||||
hidden: boolean
|
||||
custom_url: boolean
|
||||
}
|
||||
interface MonitorLinks { self: { name: string; product: string }; links: MonitorLink[] }
|
||||
|
||||
const GROUPS = ["cluster", "standalone", "pbs"] as const
|
||||
const fetcher = (url: string) => fetchApi<MonitorLinks>(url)
|
||||
const ORIGIN_KEY = "proxmenux-came-from"
|
||||
|
||||
interface Origin { url: string; name: string; product: "pve" | "pbs" }
|
||||
|
||||
// The Monitor a jump came from, as the browser reached it. Only a bare http(s)
|
||||
// origin is accepted, and the parameters leave the address at once.
|
||||
function takeOrigin(): (Origin & { fresh?: boolean }) | null {
|
||||
if (typeof window === "undefined") return null
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const from = params.get("pmx_from")
|
||||
if (from !== null) {
|
||||
const name = params.get("pmx_name") || ""
|
||||
const product = params.get("pmx_product") === "pbs" ? "pbs" : "pve"
|
||||
for (const key of ["pmx_from", "pmx_name", "pmx_product"]) params.delete(key)
|
||||
const query = params.toString()
|
||||
window.history.replaceState(null, "", window.location.pathname + (query ? `?${query}` : "") + window.location.hash)
|
||||
try {
|
||||
const parsed = new URL(from)
|
||||
if (/^https?:$/.test(parsed.protocol) && parsed.origin === from && parsed.origin !== window.location.origin) {
|
||||
const origin: Origin = { url: parsed.origin, name: (name || parsed.hostname).slice(0, 64), product }
|
||||
try { sessionStorage.setItem(ORIGIN_KEY, JSON.stringify(origin)) } catch { /* private mode */ }
|
||||
return { ...origin, fresh: true }
|
||||
}
|
||||
} catch { /* not an address */ }
|
||||
}
|
||||
try {
|
||||
const stored = JSON.parse(sessionStorage.getItem(ORIGIN_KEY) || "null")
|
||||
return stored && typeof stored.url === "string" && /^https?:\/\//.test(stored.url) ? stored : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
// Where a link leads: its Monitor, or the native PBS interface when no Monitor answers there.
|
||||
function destination(link: MonitorLink): { href: string; native: boolean } | null {
|
||||
if (link.product === "pbs" && link.status !== "online" && link.native_url) return { href: link.native_url, native: true }
|
||||
return link.url ? { href: link.url, native: false } : null
|
||||
}
|
||||
|
||||
function ProductIcon({ product, className }: { product: string; className?: string }) {
|
||||
return product === "pbs" ? <DatabaseBackup className={className} /> : <Server className={className} />
|
||||
}
|
||||
|
||||
export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: string; compact?: boolean }) {
|
||||
const t = useT()
|
||||
const [manageOpen, setManageOpen] = useState(false)
|
||||
// The backend keeps the list ready, so the menu opens already filled.
|
||||
const { data, error, isLoading } = useSWR<MonitorLinks>("/api/monitor-links", fetcher, { revalidateOnFocus: false, refreshInterval: 300000 })
|
||||
|
||||
const [origin, setOrigin] = useState<(Origin & { fresh?: boolean }) | null>(null)
|
||||
const [offerOrigin, setOfferOrigin] = useState(false)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [addError, setAddError] = useState("")
|
||||
useEffect(() => { setOrigin(takeOrigin()) }, [])
|
||||
const originKnown = !!origin && !!data?.links.some(link => link.url === origin.url)
|
||||
useEffect(() => {
|
||||
// Offered once, right after arriving, when this Monitor has no way back yet.
|
||||
if (origin?.fresh && data && !originKnown) setOfferOrigin(true)
|
||||
}, [origin, data, originKnown])
|
||||
|
||||
// The address carries where the jump comes from, so the other Monitor can offer the way back.
|
||||
const withOrigin = (href: string) => {
|
||||
const params = new URLSearchParams({ pmx_from: window.location.origin, pmx_name: nodeName, pmx_product: data?.self.product === "pbs" ? "pbs" : "pve" })
|
||||
return `${href}/?${params.toString()}`
|
||||
}
|
||||
const open = (href: string, newTab: boolean) => {
|
||||
if (newTab) window.open(href, "_blank", "noopener,noreferrer")
|
||||
else window.location.assign(href)
|
||||
}
|
||||
const go = (link: MonitorLink, newTab: boolean) => {
|
||||
const target = destination(link)
|
||||
if (!target) return
|
||||
if (target.native) open(target.href, true)
|
||||
else open(withOrigin(target.href), newTab)
|
||||
}
|
||||
const addOrigin = async () => {
|
||||
if (!origin) return
|
||||
setAdding(true); setAddError("")
|
||||
try {
|
||||
await fetchApi("/api/monitor-links", { method: "POST", body: JSON.stringify({ name: origin.name, url: origin.url, product: origin.product }) })
|
||||
await refreshCache("/api/monitor-links")
|
||||
setOfferOrigin(false)
|
||||
} catch (err) {
|
||||
setAddError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
|
||||
} finally { setAdding(false) }
|
||||
}
|
||||
|
||||
return <div onClick={event => event.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className={`gap-2 border-border bg-transparent hover:bg-secondary ${compact ? "h-8 px-2.5 text-xs" : ""}`}>
|
||||
<Server className={compact ? "h-3.5 w-3.5" : "h-4 w-4"} />
|
||||
<span className="max-w-[12rem] truncate">{t("status.node", { node: nodeName })}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-72">
|
||||
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">{t("monitorLinks.thisNode")}</DropdownMenuLabel>
|
||||
<DropdownMenuItem disabled className="gap-2 opacity-100">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<ProductIcon product={data?.self.product || ""} className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="truncate font-medium">{nodeName}</span>
|
||||
</DropdownMenuItem>
|
||||
{origin && !originKnown && <DropdownMenuItem className="gap-2" onSelect={() => open(withOrigin(origin.url), false)}>
|
||||
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{t("monitorLinks.backTo", { name: origin.name })}</span>
|
||||
</DropdownMenuItem>}
|
||||
{isLoading && <p className="px-2 py-1.5 text-xs text-muted-foreground">{t("monitorLinks.checking")}</p>}
|
||||
{error && <p className="px-2 py-1.5 text-xs text-amber-500">{t("monitorLinks.loadFailed")}</p>}
|
||||
{data && data.links.length === 0 && <p className="px-2 py-1.5 text-xs text-muted-foreground">{t("monitorLinks.empty")}</p>}
|
||||
{data && GROUPS.map(group => {
|
||||
const rows = data.links.filter(link => link.group === group)
|
||||
if (rows.length === 0) return null
|
||||
return <div key={group}>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">{t(`monitorLinks.groups.${group}`)}</DropdownMenuLabel>
|
||||
{rows.map(link => {
|
||||
const target = destination(link)
|
||||
return <DropdownMenuItem key={link.key} disabled={!target} className="gap-2" onSelect={() => go(link, false)}>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${link.status === "online" ? "bg-green-500" : "bg-gray-500"}`} />
|
||||
<ProductIcon product={link.product} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{link.name}</span>
|
||||
{target?.native
|
||||
? <span className="shrink-0 text-[11px] text-muted-foreground">{t("monitorLinks.pbsInterface")}</span>
|
||||
: link.status !== "online" && <span className="shrink-0 text-[11px] text-muted-foreground">{t("monitorLinks.noResponse")}</span>}
|
||||
{target && <button type="button" aria-label={t("monitorLinks.openNewTab")} title={t("monitorLinks.openNewTab")}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={event => { event.preventDefault(); event.stopPropagation(); go(link, true) }}>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</button>}
|
||||
</DropdownMenuItem>
|
||||
})}
|
||||
</div>
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="gap-2" onSelect={() => setManageOpen(true)}>
|
||||
<Plus className="h-4 w-4" />{t("monitorLinks.manage")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{manageOpen && <ManageLinksDialog open={manageOpen} onOpenChange={setManageOpen} />}
|
||||
{origin && <Dialog open={offerOrigin} onOpenChange={setOfferOrigin}>
|
||||
<DialogContent className="max-w-md" onClick={event => event.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("monitorLinks.cameFromTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("monitorLinks.cameFromBody", { name: origin.name })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="break-all rounded-md border border-border bg-muted/40 px-3 py-2 font-mono text-sm">{origin.url}</p>
|
||||
{addError && <p role="alert" className="text-sm text-red-500">{addError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" disabled={adding} onClick={() => setOfferOrigin(false)}>{t("monitorLinks.notNow")}</Button>
|
||||
<Button disabled={adding} onClick={addOrigin}><Plus className="mr-2 h-4 w-4" />{t("monitorLinks.add")}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const t = useT()
|
||||
const { data, mutate } = useSWR<MonitorLinks>("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false })
|
||||
const [form, setForm] = useState({ name: "", url: "", product: "pve" })
|
||||
const [addresses, setAddresses] = useState<Record<string, string>>({})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const run = async (action: () => Promise<unknown>) => {
|
||||
setBusy(true); setError("")
|
||||
try {
|
||||
await action()
|
||||
await mutate()
|
||||
await refreshCache("/api/monitor-links")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
const send = (path: string, method: string, body?: unknown) =>
|
||||
fetchApi(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
|
||||
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto" onClick={event => event.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("monitorLinks.dialogTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("monitorLinks.dialogDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-border p-4">
|
||||
<p className="text-sm font-medium">{t("monitorLinks.addTitle")}</p>
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_1.5fr_auto]">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="monitor-link-name">{t("monitorLinks.name")}</Label>
|
||||
<Input id="monitor-link-name" value={form.name} disabled={busy} onChange={event => setForm({ ...form, name: event.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="monitor-link-url">{t("monitorLinks.address")}</Label>
|
||||
<Input id="monitor-link-url" value={form.url} disabled={busy} placeholder="https://192.168.1.20:8008"
|
||||
onChange={event => setForm({ ...form, url: event.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="monitor-link-product">{t("monitorLinks.type")}</Label>
|
||||
<Select value={form.product} disabled={busy} onValueChange={product => setForm({ ...form, product })}>
|
||||
<SelectTrigger id="monitor-link-product" className="w-full sm:w-56"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pve">Proxmox VE</SelectItem>
|
||||
<SelectItem value="pbs">Proxmox Backup Server</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" disabled={busy || !form.url.trim()} onClick={() => run(async () => {
|
||||
await send("/api/monitor-links", "POST", form)
|
||||
setForm({ name: "", url: "", product: "pve" })
|
||||
})}><Plus className="mr-2 h-4 w-4" />{t("monitorLinks.add")}</Button>
|
||||
</div>
|
||||
|
||||
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<div className="space-y-3">
|
||||
{data?.links.length === 0 && <p className="text-sm text-muted-foreground">{t("monitorLinks.empty")}</p>}
|
||||
{data?.links.map(link => {
|
||||
const address = addresses[link.key] ?? (link.manual || link.custom_url ? link.url || "" : "")
|
||||
return <div key={link.key} className={`space-y-2 rounded-lg border border-border p-3 ${link.hidden ? "opacity-60" : ""}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProductIcon product={link.product} className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{link.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{link.manual ? t("monitorLinks.added") : t("monitorLinks.detected")}</span>
|
||||
{!link.manual && <label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{t("monitorLinks.visible")}
|
||||
<Switch checked={!link.hidden} disabled={busy} className="data-[state=checked]:bg-blue-600"
|
||||
onCheckedChange={checked => run(() => send(`/api/monitor-links/${encodeURIComponent(link.key)}`, "PATCH", { hidden: !checked }))} />
|
||||
</label>}
|
||||
{link.manual && <Button variant="ghost" size="sm" disabled={busy} aria-label={t("monitorLinks.remove")}
|
||||
onClick={() => run(() => send(`/api/monitor-links/${encodeURIComponent(link.key)}`, "DELETE"))}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input value={address} disabled={busy} aria-label={t("monitorLinks.address")}
|
||||
placeholder={link.manual ? "" : `${t("monitorLinks.autoAddress")}${link.url ? `: ${link.url}` : ""}`}
|
||||
onChange={event => setAddresses({ ...addresses, [link.key]: event.target.value })} />
|
||||
<Button variant="outline" size="sm" className="h-10" disabled={busy || addresses[link.key] === undefined}
|
||||
onClick={() => run(async () => {
|
||||
await send(`/api/monitor-links/${encodeURIComponent(link.key)}`, "PATCH", { url: addresses[link.key] })
|
||||
setAddresses(({ [link.key]: _, ...rest }) => rest)
|
||||
})}>{t("monitorLinks.save")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { useIsPbs } from "../lib/use-platform"
|
||||
import {
|
||||
Bell, BellOff, Send, CheckCircle2, XCircle, Loader2,
|
||||
AlertTriangle, Info, Settings2, Zap, Eye, EyeOff,
|
||||
@@ -154,6 +155,8 @@ function validateGotifyUrl(url: string): { error?: string; warning?: string } {
|
||||
}
|
||||
|
||||
const EVENT_CATEGORIES = ["vm_ct", "backup", "resources", "storage", "network", "security", "cluster", "services", "health", "updates", "hardware", "system", "other"].map(key => ({ key }))
|
||||
// Guests, vzdump backups and the cluster do not exist on Proxmox Backup Server.
|
||||
const PVE_ONLY_CATEGORIES = new Set(["vm_ct", "backup", "cluster"])
|
||||
|
||||
const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email", "pushover", "apprise"] as const
|
||||
|
||||
@@ -330,6 +333,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
|
||||
|
||||
export function NotificationSettings() {
|
||||
const t = useT()
|
||||
const isPbs = useIsPbs()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [config, setConfig] = useState<NotificationConfig>(DEFAULT_CONFIG)
|
||||
const [status, setStatus] = useState<ServiceStatus | null>(null)
|
||||
@@ -703,7 +707,7 @@ export function NotificationSettings() {
|
||||
<Label className="text-[11px] text-muted-foreground">{t("settings.notifications.ui.notificationCategories")}</Label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{EVENT_CATEGORIES.filter(cat => cat.key !== "other").map(cat => {
|
||||
{EVENT_CATEGORIES.filter(cat => cat.key !== "other" && !(isPbs && PVE_ONLY_CATEGORIES.has(cat.key))).map(cat => {
|
||||
const isEnabled = overrides.categories[cat.key] ?? true
|
||||
const isExpanded = expandedCategories.has(`${chName}.${cat.key}`)
|
||||
// Hide the LXC update toggle when the user has disabled the
|
||||
@@ -922,7 +926,7 @@ export function NotificationSettings() {
|
||||
const wasEnabled = originalConfig.enabled
|
||||
const isNowDisabled = !nextConfig.enabled
|
||||
|
||||
if (wasEnabled && isNowDisabled) {
|
||||
if (wasEnabled && isNowDisabled && !isPbs) {
|
||||
try {
|
||||
await fetchApi("/api/notifications/proxmox/cleanup-webhook", { method: "POST" })
|
||||
} catch {
|
||||
@@ -1217,8 +1221,10 @@ export function NotificationSettings() {
|
||||
setConfig(newConfig)
|
||||
setOriginalConfig(newConfig)
|
||||
|
||||
// 2) Auto-configure PVE webhook
|
||||
try {
|
||||
// 2) Auto-configure PVE webhook; PBS has no /etc/pve notifications
|
||||
if (isPbs) {
|
||||
setWebhookSetup({ status: "idle", fallback_commands: [], error: "" })
|
||||
} else try {
|
||||
const setup = await fetchApi<{
|
||||
configured: boolean
|
||||
secret?: string
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import useSWR from "swr"
|
||||
import { PBSPanel } from "./pbs-panel"
|
||||
import { Security } from "./security"
|
||||
import { SystemLogs } from "./system-logs"
|
||||
import { TerminalPanel } from "./terminal-panel"
|
||||
import { ThemeToggle } from "./theme-toggle"
|
||||
import { Button } from "./ui/button"
|
||||
import { Card, CardContent } from "./ui/card"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface Vitals { hostname: string; cpu_usage: number; memory_usage: number; memory_used: number; memory_total: number; uptime: string }
|
||||
|
||||
export function PBSDashboard() {
|
||||
const t = useT()
|
||||
const [tab, setTab] = useState("overview")
|
||||
const { data } = useSWR<Vitals>("/api/system", url => fetchApi<Vitals>(url), { refreshInterval: 10000 })
|
||||
return <div className="container mx-auto max-w-7xl space-y-6 px-4 py-6">
|
||||
<header className="flex flex-wrap items-center gap-3 border-b pb-4">
|
||||
<div className="mr-auto"><h1 className="text-2xl font-bold">ProxMenux · PBS</h1><p className="text-sm text-muted-foreground">{data?.hostname || t("pbs.loading")}</p></div>
|
||||
<ThemeToggle />
|
||||
<Button variant="outline" onClick={() => { localStorage.removeItem("proxmenux-auth-token"); window.location.reload() }}>{t("pbs.logout")}</Button>
|
||||
</header>
|
||||
<nav aria-label={t("pbs.navigation")} className="flex flex-wrap gap-2">
|
||||
{(["overview", "logs", "terminal", "security"] as const).map(key =>
|
||||
<Button key={key} variant={tab === key ? "default" : "outline"} onClick={() => setTab(key)}>{t(`pbs.${key}`)}</Button>)}
|
||||
</nav>
|
||||
{tab === "overview" && <>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{[[t("pbs.cpu"), data ? `${data.cpu_usage.toFixed(1)}%` : "—"],
|
||||
[t("pbs.memory"), data ? `${data.memory_used.toFixed(1)} / ${data.memory_total.toFixed(1)} GiB (${data.memory_usage.toFixed(1)}%)` : "—"],
|
||||
[t("pbs.uptime"), data?.uptime || "—"]].map(([label, value]) =>
|
||||
<Card key={label}><CardContent className="p-4"><p className="text-xs text-muted-foreground">{label}</p><p className="mt-1 text-lg font-semibold">{value}</p></CardContent></Card>)}
|
||||
</div>
|
||||
<PBSPanel />
|
||||
</>}
|
||||
{tab === "logs" && <SystemLogs />}
|
||||
{tab === "terminal" && <TerminalPanel />}
|
||||
{tab === "security" && <Security hostType="pbs" />}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
|
||||
import { CalendarClock, Database } from "lucide-react"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { formatStorage } from "../lib/utils"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
export interface PBSDatastore {
|
||||
name: string
|
||||
path?: string
|
||||
total?: number | null
|
||||
used?: number | null
|
||||
avail?: number | null
|
||||
error?: string
|
||||
"backend-type"?: string
|
||||
"estimated-full-date"?: number
|
||||
"gc-status"?: { upid?: string | null; "removed-bytes"?: number; "pending-bytes"?: number }
|
||||
}
|
||||
|
||||
export function datastorePercent(store: PBSDatastore): number | null {
|
||||
if (store.total == null || store.used == null || store.total <= 0) return null
|
||||
return (store.used / store.total) * 100
|
||||
}
|
||||
|
||||
// Same thresholds and colours as the PVE storage cards.
|
||||
export function usageTone(percent: number): { bar: string; text: string; used: string } {
|
||||
if (percent > 90) return { bar: "[&>div]:bg-red-500", text: "text-red-500", used: "text-red-400" }
|
||||
if (percent > 75) return { bar: "[&>div]:bg-yellow-500", text: "text-yellow-500", used: "text-yellow-400" }
|
||||
return { bar: "[&>div]:bg-blue-500", text: "text-muted-foreground", used: "text-blue-400" }
|
||||
}
|
||||
|
||||
const gib = (bytes: number) => formatStorage(bytes / 1024 ** 3)
|
||||
|
||||
// A datastore without a readable capacity keeps its message: it is never shown as empty.
|
||||
export function DatastoreUsageRow({ store, compact = false }: { store: PBSDatastore; compact?: boolean }) {
|
||||
const t = useT()
|
||||
const percent = datastorePercent(store)
|
||||
if (percent == null) {
|
||||
return <div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="font-medium text-foreground truncate">{store.name}</span>
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
</div>
|
||||
<p className="text-xs text-amber-500">{store.error || t("pbs.capacityUnavailable")}</p>
|
||||
</div>
|
||||
}
|
||||
const tone = usageTone(percent)
|
||||
return <div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="font-medium text-foreground truncate">{store.name}</span>
|
||||
<span className={`text-xs font-semibold tabular-nums ${tone.text}`}>{percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
<Progress value={percent} className={`${compact ? "h-1.5" : "h-2"} ${tone.bar}`} />
|
||||
{!compact && <div className="flex justify-between text-xs text-muted-foreground tabular-nums">
|
||||
<span>{gib(store.used!)} / {gib(store.total!)}</span>
|
||||
<span>{t("pbs.free")}: <span className="font-semibold text-green-500">{gib(store.avail ?? store.total! - store.used!)}</span></span>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
// Same layout as the "Proxmox storage" cards of the PVE Monitor.
|
||||
export function PBSDatastoreCard({ store }: { store: PBSDatastore }) {
|
||||
const t = useT()
|
||||
const percent = datastorePercent(store)
|
||||
const tone = percent == null ? null : usageTone(percent)
|
||||
const fullDate = store["estimated-full-date"]
|
||||
const type = store["backend-type"] || "datastore"
|
||||
return <div className={`border rounded-lg p-4 ${store.error ? "border-red-500/50 bg-red-500/5" : ""}`}>
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-2 md:gap-3 min-w-0">
|
||||
<Database className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<h3 className="font-semibold text-base md:text-lg truncate">{store.name}</h3>
|
||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20">{type}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Badge className={store.error ? "bg-red-500/10 text-red-500 border-red-500/20" : "bg-green-500/10 text-green-500 border-green-500/20"}>
|
||||
{store.error ? t("storage.status.error") : t("storage.status.active")}
|
||||
</Badge>
|
||||
{percent != null && <span className="text-sm font-medium">{percent.toFixed(2)}%</span>}
|
||||
</div>
|
||||
</div>
|
||||
{percent != null && tone ? (
|
||||
<div className="space-y-2">
|
||||
<Progress value={percent} className={`h-2 ${tone.bar}`} />
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.total")}</p>
|
||||
<p className="font-medium">{gib(store.total!)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.used")}</p>
|
||||
<p className={`font-medium ${tone.used}`}>{gib(store.used!)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.available")}</p>
|
||||
<p className="font-medium text-green-400">{gib(store.avail ?? store.total! - store.used!)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{store.error || t("storage.capacityNotReported")}</p>
|
||||
)}
|
||||
{(store.path || fullDate !== undefined) && <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
{store.path && <span className="font-mono truncate" title={store.path}>{store.path}</span>}
|
||||
{fullDate !== undefined && <span className="flex items-center">
|
||||
<CalendarClock className="mr-1.5 h-3.5 w-3.5" />{t("pbs.estimatedFull")}:
|
||||
<span className="ml-1 font-medium text-foreground">{fullDate > 0 ? new Date(fullDate * 1000).toLocaleDateString() : t("pbs.estimatedFullUnknown")}</span>
|
||||
</span>}
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
@@ -2,29 +2,74 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import useSWR from "swr"
|
||||
import {
|
||||
Activity, ArchiveRestore, Database, DatabaseBackup, Download, ListChecks,
|
||||
RefreshCw, Scissors, Server, Settings2, ShieldCheck, Terminal, Trash2,
|
||||
} from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { PBSDatastoreCard, type PBSDatastore } from "./pbs-datastore-usage"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { formatBytes } from "../lib/utils"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface Connection { id: string; name: string; url: string; token_id: string; fingerprint: string; has_token: boolean }
|
||||
interface Connections { local_available: boolean; servers: Connection[] }
|
||||
interface Datastore { name: string; path?: string; total?: number | null; used?: number | null; avail?: number | null; error?: string }
|
||||
interface Task { upid: string; worker_type?: string; worker_id?: string; starttime?: number; endtime?: number; status?: string }
|
||||
interface Job { id: string; store?: string; schedule?: string; "last-run-state"?: string }
|
||||
interface PBSStatus {
|
||||
name: string; version: string | null; fetched_at: number; services: Record<string, string>;
|
||||
datastores: Datastore[] | null; tasks: Task[] | null;
|
||||
datastores: PBSDatastore[] | null; tasks: Task[] | null;
|
||||
sync: Job[] | null; verify: Job[] | null; prune: Job[] | null; errors: Record<string, string>;
|
||||
}
|
||||
|
||||
const emptyForm = { id: "", name: "", url: "", token_id: "", fingerprint: "", token_secret: "" }
|
||||
const selectClass = "h-10 rounded-md border bg-background px-3 text-sm"
|
||||
const NEW_CONNECTION = "__new__"
|
||||
const badgeTone = {
|
||||
ok: "bg-green-500/10 text-green-500 border-green-500/20",
|
||||
warn: "bg-amber-500/10 text-amber-500 border-amber-500/20",
|
||||
error: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||
running: "bg-blue-500/10 text-blue-500 border-blue-500/20",
|
||||
muted: "bg-muted text-muted-foreground border-border",
|
||||
}
|
||||
|
||||
export function PBSPanel() {
|
||||
const TASK_ICONS: Record<string, typeof Activity> = {
|
||||
backup: DatabaseBackup, reader: ArchiveRestore, verify: ShieldCheck, verificationjob: ShieldCheck,
|
||||
verify_group: ShieldCheck, verify_snapshot: ShieldCheck, prune: Scissors, prunejob: Scissors,
|
||||
garbage_collection: Trash2, sync: RefreshCw, syncjob: RefreshCw, aptupdate: Download,
|
||||
termproxy: Terminal, dircreate: Database, "create-datastore": Database,
|
||||
}
|
||||
|
||||
// PBS reports "OK", "WARNINGS: n" or the error text; a task without one is still running.
|
||||
function taskState(task: Task): keyof typeof badgeTone {
|
||||
if (!task.status) return task.endtime ? "muted" : "running"
|
||||
if (task.status === "OK") return "ok"
|
||||
if (task.status.startsWith("WARNINGS")) return "warn"
|
||||
return "error"
|
||||
}
|
||||
|
||||
function jobState(state?: string): keyof typeof badgeTone {
|
||||
if (!state) return "muted"
|
||||
if (state === "ok") return "ok"
|
||||
if (state.toLowerCase().startsWith("warn")) return "warn"
|
||||
return "error"
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "—"
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ${Math.round(seconds % 60)}s`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
return hours < 24 ? `${hours}h ${minutes % 60}m` : `${Math.floor(hours / 24)}d ${hours % 24}h`
|
||||
}
|
||||
|
||||
// `localOnly` is the PBS host's own Monitor: no connection picker, the
|
||||
// header Refresh already reloads the page data.
|
||||
export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
|
||||
const t = useT()
|
||||
const { data: connections, error: connectionError, mutate: reloadConnections } = useSWR<Connections>(
|
||||
"/api/pbs/servers", url => fetchApi<Connections>(url), { revalidateOnFocus: false })
|
||||
@@ -69,36 +114,43 @@ export function PBSPanel() {
|
||||
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
const capacity = (value?: number | null) => value == null ? "—" : formatBytes(value)
|
||||
const now = Date.now() / 1000
|
||||
|
||||
return <div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h2 className="text-xl font-semibold">{t("pbs.title")}</h2>
|
||||
<select aria-label={t("pbs.server")} className={`${selectClass} sm:ml-auto`} value={selected}
|
||||
onChange={event => setSelected(event.target.value)}>
|
||||
{!selected && <option value="">{t("pbs.chooseServer")}</option>}
|
||||
{connections?.local_available && <option value="local">{t("pbs.local")}</option>}
|
||||
{connections?.servers.map(row => <option key={row.id} value={row.id}>{row.name}</option>)}
|
||||
</select>
|
||||
<Button variant="outline" disabled={isLoading || !selected} onClick={() => mutate()}>{t("pbs.refresh")}</Button>
|
||||
<Button variant="outline" onClick={() => setManage(!manage)}>{t("pbs.manage")}</Button>
|
||||
</div>
|
||||
return <div className="space-y-4 md:space-y-6">
|
||||
{!localOnly && <div className="flex flex-wrap items-center gap-3">
|
||||
<h2 className="flex items-center text-2xl font-semibold text-foreground"><Server className="mr-2 h-5 w-5" />{t("pbs.title")}</h2>
|
||||
<Select value={selected || undefined} onValueChange={setSelected}>
|
||||
<SelectTrigger aria-label={t("pbs.server")} className="w-full sm:ml-auto sm:w-56"><SelectValue placeholder={t("pbs.chooseServer")} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections?.local_available && <SelectItem value="local">{t("pbs.local")}</SelectItem>}
|
||||
{connections?.servers.map(row => <SelectItem key={row.id} value={row.id}>{row.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" disabled={isLoading || !selected} onClick={() => mutate()}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />{t("pbs.refresh")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setManage(!manage)}>
|
||||
<Settings2 className="mr-2 h-4 w-4" />{t("pbs.manage")}
|
||||
</Button>
|
||||
</div>}
|
||||
{(connectionError || statusError) && <p role="alert" className="text-sm text-red-500">
|
||||
{String((connectionError || statusError)?.message || t("pbs.loadFailed"))}</p>}
|
||||
{isLoading && <p role="status" className="text-sm text-muted-foreground">{t("pbs.loading")}</p>}
|
||||
{!selected && !connectionError && connections && <p className="text-sm text-muted-foreground">{t("pbs.noServer")}</p>}
|
||||
{manage && <Card>
|
||||
{manage && !localOnly && <Card>
|
||||
<CardHeader><CardTitle>{t("pbs.manage")}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t("pbs.permissionsHelp")}</p>
|
||||
<select aria-label={t("pbs.editConnection")} className={`${selectClass} w-full`} value={form.id}
|
||||
onChange={event => {
|
||||
const row = connections?.servers.find(item => item.id === event.target.value)
|
||||
setForm(row ? { ...row, token_secret: "" } : emptyForm)
|
||||
}}>
|
||||
<option value="">{t("pbs.add")}</option>
|
||||
{connections?.servers.map(row => <option key={row.id} value={row.id}>{row.name}</option>)}
|
||||
</select>
|
||||
<Select value={form.id || NEW_CONNECTION} onValueChange={value => {
|
||||
const row = connections?.servers.find(item => item.id === value)
|
||||
setForm(row ? { ...row, token_secret: "" } : emptyForm)
|
||||
}}>
|
||||
<SelectTrigger aria-label={t("pbs.editConnection")} className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NEW_CONNECTION}>{t("pbs.add")}</SelectItem>
|
||||
{connections?.servers.map(row => <SelectItem key={row.id} value={row.id}>{row.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(["name", "url", "token_id", "token_secret", "fingerprint"] as const).map(field =>
|
||||
<div key={field} className="space-y-2">
|
||||
@@ -118,32 +170,78 @@ export function PBSPanel() {
|
||||
</CardContent>
|
||||
</Card>}
|
||||
{data && <>
|
||||
<p className="text-sm text-muted-foreground">{data.name} · PBS {data.version || "—"} · {t("pbs.sampled")} {new Date(data.fetched_at * 1000).toLocaleTimeString()}</p>
|
||||
{Object.keys(data.services).length > 0 && <div className="flex flex-wrap gap-3 text-sm">
|
||||
{Object.entries(data.services).map(([service, state]) => <span key={service} className={state === "active" ? "text-green-500" : "text-red-500"}>{service}: {state}</span>)}
|
||||
</div>}
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.sampled")} {new Date(data.fetched_at * 1000).toLocaleTimeString()}</span>
|
||||
{Object.entries(data.services).map(([service, state]) =>
|
||||
<Badge key={service} variant="outline" className={state === "active" ? badgeTone.ok : badgeTone.error}>{service}: {state}</Badge>)}
|
||||
</div>
|
||||
{Object.entries(data.errors).map(([section, detail]) => <p key={section} role="alert" className="text-sm text-amber-500">{t(`pbs.${section}`)}: {detail}</p>)}
|
||||
<Card><CardHeader><CardTitle>{t("pbs.datastores")}</CardTitle></CardHeader><CardContent>
|
||||
{data.datastores?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>}
|
||||
{data.datastores && data.datastores.length > 0 && <div className="overflow-x-auto"><table className="w-full text-sm">
|
||||
<thead><tr className="border-b text-left"><th className="p-2">{t("pbs.name")}</th><th>{t("pbs.total")}</th><th>{t("pbs.used")}</th><th>{t("pbs.free")}</th></tr></thead>
|
||||
<tbody>{data.datastores.map(row => <tr key={row.name} className="border-b"><td className="p-2 font-medium">{row.name}{row.error && <p className="font-normal text-xs text-amber-500">{row.error}</p>}</td><td>{capacity(row.total)}</td><td>{capacity(row.used)}</td><td>{capacity(row.avail)}</td></tr>)}</tbody>
|
||||
</table></div>}
|
||||
</CardContent></Card>
|
||||
<Card><CardHeader><CardTitle>{t("pbs.tasks")}</CardTitle></CardHeader><CardContent>
|
||||
{data.tasks?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noTasks")}</p>}
|
||||
{data.tasks && data.tasks.length > 0 && <div className="overflow-x-auto"><table className="w-full text-sm">
|
||||
<thead><tr className="border-b text-left"><th className="p-2">{t("pbs.task")}</th><th>{t("pbs.started")}</th><th>{t("pbs.status")}</th></tr></thead>
|
||||
<tbody>{data.tasks.map(row => <tr key={row.upid} className="border-b"><td className="p-2">{row.worker_type} {row.worker_id}</td><td>{row.starttime ? new Date(row.starttime * 1000).toLocaleString() : "—"}</td><td>{row.status || (row.endtime ? "—" : t("pbs.running"))}</td></tr>)}</tbody>
|
||||
</table></div>}
|
||||
</CardContent></Card>
|
||||
<Card><CardHeader><CardTitle>{t("pbs.jobs")}</CardTitle></CardHeader><CardContent>
|
||||
<div className="overflow-x-auto"><table className="w-full text-sm">
|
||||
<thead><tr className="border-b text-left"><th className="p-2">{t("pbs.job")}</th><th>{t("pbs.datastores")}</th><th>{t("pbs.schedule")}</th><th>{t("pbs.status")}</th></tr></thead>
|
||||
<tbody>{(["sync", "verify", "prune"] as const).flatMap(kind => (data[kind] || []).map(row =>
|
||||
<tr key={`${kind}-${row.id}`} className="border-b"><td className="p-2">{t(`pbs.${kind}`)} · {row.id}</td><td>{row.store || "—"}</td><td>{row.schedule || "—"}</td><td>{row["last-run-state"] || "—"}</td></tr>))}</tbody>
|
||||
</table></div>
|
||||
</CardContent></Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader><CardTitle className="flex items-center text-foreground"><Database className="mr-2 h-5 w-5" />{t("pbs.datastores")}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{data.datastores?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>}
|
||||
{data.datastores && data.datastores.length > 0 && <div className="space-y-4">
|
||||
{data.datastores.map(store => <PBSDatastoreCard key={store.name} store={store} />)}
|
||||
</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader><CardTitle className="flex items-center text-foreground"><Activity className="mr-2 h-5 w-5" />{t("pbs.tasks")}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{data.tasks?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noTasks")}</p>}
|
||||
{data.tasks && data.tasks.length > 0 && <div className="overflow-x-auto"><table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-border text-left text-xs text-muted-foreground">
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.task")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.started")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.duration")}</th>
|
||||
<th className="py-2 font-medium">{t("pbs.status")}</th>
|
||||
</tr></thead>
|
||||
<tbody>{data.tasks.map(row => {
|
||||
const Icon = TASK_ICONS[row.worker_type || ""] || Activity
|
||||
const state = taskState(row)
|
||||
const end = row.endtime ?? (state === "running" ? now : undefined)
|
||||
return <tr key={row.upid} className="border-b border-border/60 last:border-0">
|
||||
<td className="py-2 pr-3"><span className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-foreground">{row.worker_type}</span>
|
||||
{row.worker_id && <span className="truncate text-muted-foreground">{row.worker_id}</span>}
|
||||
</span></td>
|
||||
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime ? new Date(row.starttime * 1000).toLocaleString() : "—"}</td>
|
||||
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime && end ? formatDuration(end - row.starttime) : "—"}</td>
|
||||
<td className="py-2"><Badge variant="outline" className={`${badgeTone[state]} max-w-[16rem] truncate`} title={row.status}>
|
||||
{state === "running" ? t("pbs.running") : row.status || "—"}
|
||||
</Badge></td>
|
||||
</tr>
|
||||
})}</tbody>
|
||||
</table></div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader><CardTitle className="flex items-center text-foreground"><ListChecks className="mr-2 h-5 w-5" />{t("pbs.jobs")}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{(() => {
|
||||
const jobs = (["sync", "verify", "prune"] as const).flatMap(kind => (data[kind] || []).map(row => ({ kind, row })))
|
||||
if (jobs.length === 0) return <p className="text-sm text-muted-foreground">{t("pbs.noJobs")}</p>
|
||||
return <div className="overflow-x-auto"><table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-border text-left text-xs text-muted-foreground">
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.job")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.datastores")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.schedule")}</th>
|
||||
<th className="py-2 font-medium">{t("pbs.status")}</th>
|
||||
</tr></thead>
|
||||
<tbody>{jobs.map(({ kind, row }) => <tr key={`${kind}-${row.id}`} className="border-b border-border/60 last:border-0">
|
||||
<td className="py-2 pr-3"><span className="text-foreground">{t(`pbs.${kind}`)}</span> <span className="text-muted-foreground">· {row.id}</span></td>
|
||||
<td className="py-2 pr-3 text-muted-foreground">{row.store || "—"}</td>
|
||||
<td className="py-2 pr-3 font-mono text-xs text-muted-foreground">{row.schedule || "—"}</td>
|
||||
<td className="py-2"><Badge variant="outline" className={badgeTone[jobState(row["last-run-state"])]}>{row["last-run-state"] || "—"}</Badge></td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ import { Security } from "./security"
|
||||
import { Profile } from "./profile"
|
||||
import { About } from "./about"
|
||||
import { BackupTab } from "./backup-tab"
|
||||
import { PBSDashboard } from "./pbs-dashboard"
|
||||
import { PBSPanel } from "./pbs-panel"
|
||||
import { MonitorSwitcher } from "./monitor-switcher"
|
||||
import { OnboardingCarousel } from "./onboarding-carousel"
|
||||
import { HealthStatusModal } from "./health-status-modal"
|
||||
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
|
||||
@@ -46,7 +47,9 @@ import {
|
||||
ClipboardCheck,
|
||||
Info,
|
||||
DatabaseBackup,
|
||||
Database,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { ThemeToggle } from "./theme-toggle"
|
||||
@@ -105,10 +108,27 @@ export function ProxmoxDashboard() {
|
||||
}
|
||||
|
||||
function PVEDashboard() {
|
||||
return <MonitorDashboard product="pve" />
|
||||
}
|
||||
|
||||
function PBSDashboard() {
|
||||
return <MonitorDashboard product="pbs" />
|
||||
}
|
||||
|
||||
type Product = "pve" | "pbs"
|
||||
type TopTab = TabId | "datastores"
|
||||
|
||||
// Proxmox Backup Server has no guests, apps or PVE storage: it keeps the
|
||||
// same header, health and administration with its own top-level sections.
|
||||
const PBS_TABS: TopTab[] = ["overview", "datastores", "terminal", "admin"]
|
||||
|
||||
function MonitorDashboard({ product }: { product: Product }) {
|
||||
const t = useT()
|
||||
useSWR("/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
||||
useSWR("/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
||||
const { order: tabOrder } = useTabOrder()
|
||||
const isPbs = product === "pbs"
|
||||
useSWR(isPbs ? null : "/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
||||
useSWR(isPbs ? null : "/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
||||
const { order: userTabOrder } = useTabOrder()
|
||||
const tabOrder: TopTab[] = isPbs ? PBS_TABS : userTabOrder
|
||||
const [systemStatus, setSystemStatus] = useState<SystemStatus>({
|
||||
status: "healthy",
|
||||
uptime: "Loading...",
|
||||
@@ -126,8 +146,8 @@ function PVEDashboard() {
|
||||
// runs post-hydration; the tiny flash is acceptable and matches the
|
||||
// pattern next-themes uses for the same reason.
|
||||
useEffect(() => {
|
||||
setActiveTab(firstActualTab())
|
||||
}, [])
|
||||
setActiveTab(isPbs ? "overview" : firstActualTab())
|
||||
}, [isPbs])
|
||||
const [infoCount, setInfoCount] = useState(0)
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false)
|
||||
const [showNavigation, setShowNavigation] = useState(true)
|
||||
@@ -400,6 +420,7 @@ function PVEDashboard() {
|
||||
const getActiveTabLabel = () => {
|
||||
switch (activeTab) {
|
||||
case "overview": return t("navigation.overview")
|
||||
case "datastores": return t("pbs.datastores")
|
||||
case "apps": return t("navigation.apps")
|
||||
case "vms": return t("navigation.virtualMachines")
|
||||
case "storage": return t("navigation.storage")
|
||||
@@ -419,8 +440,9 @@ function PVEDashboard() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<OnboardingCarousel />
|
||||
<ReleaseNotesModal open={showReleaseNotes} onClose={() => setShowReleaseNotes(false)} />
|
||||
{/* Both describe the PVE Monitor. */}
|
||||
{!isPbs && <OnboardingCarousel />}
|
||||
{!isPbs && <ReleaseNotesModal open={showReleaseNotes} onClose={() => setShowReleaseNotes(false)} />}
|
||||
|
||||
{!isServerConnected && (
|
||||
<div className="bg-red-500/10 border-b border-red-500/20 px-6 py-3">
|
||||
@@ -473,22 +495,17 @@ function PVEDashboard() {
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">{t("app.title")}</h1>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">{t("app.description")}</p>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">{t(isPbs ? "app.descriptionPbs" : "app.description")}</p>
|
||||
<div className="lg:hidden flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||
<Server className="h-3 w-3" />
|
||||
<span className="truncate">{t("status.node", { node: displayServerName })}</span>
|
||||
<Clock className="h-3 w-3" />
|
||||
<span className="truncate">{t("status.uptime", { uptime: displayUptime })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Actions */}
|
||||
<div className="hidden lg:flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-foreground">{t("status.node", { node: displayServerName })}</div>
|
||||
</div>
|
||||
</div>
|
||||
<MonitorSwitcher nodeName={displayServerName} />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className={statusColor}>
|
||||
@@ -589,9 +606,7 @@ function PVEDashboard() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{t("status.uptime", { uptime: displayUptime })}
|
||||
</span>
|
||||
<MonitorSwitcher nodeName={displayServerName} compact />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -632,7 +647,7 @@ function PVEDashboard() {
|
||||
{ value: "audit", label: t("navigation.audit"), Icon: ClipboardCheck, default: false },
|
||||
{ value: "settings", label: t("navigation.settings"), Icon: SettingsIcon, default: false },
|
||||
{ value: "about", label: t("navigation.about"), Icon: Info, default: false },
|
||||
]
|
||||
].filter(item => !isPbs || item.value !== "audit")
|
||||
const activeNodeItem = NODE_ITEMS.find(i => i.value === activeTab)
|
||||
const activeAdminItem = ADMIN_ITEMS.find(i => i.value === activeTab)
|
||||
const isNodeActive = activeNodeItem !== undefined
|
||||
@@ -698,9 +713,10 @@ function PVEDashboard() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
const renderTop = (id: TabId) => {
|
||||
const renderTop = (id: TopTab) => {
|
||||
switch (id) {
|
||||
case "overview": return renderDirect("overview", LayoutDashboard, t("navigation.overview"))
|
||||
case "datastores": return renderDirect("datastores", Database, t("pbs.datastores"))
|
||||
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"))
|
||||
@@ -710,7 +726,7 @@ function PVEDashboard() {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<TabsList className="hidden lg:grid w-full grid-cols-7 bg-card border border-border">
|
||||
<TabsList className={`hidden lg:grid w-full ${isPbs ? "grid-cols-4" : "grid-cols-7"} bg-card border border-border`}>
|
||||
{tabOrder.map(renderTop)}
|
||||
</TabsList>
|
||||
)
|
||||
@@ -771,6 +787,7 @@ function PVEDashboard() {
|
||||
{tabOrder.map((id): React.ReactNode => {
|
||||
switch (id) {
|
||||
case "overview": return btn("overview", LayoutDashboard, t("navigation.overview"))
|
||||
case "datastores": return btn("datastores", Database, t("pbs.datastores"))
|
||||
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"))
|
||||
@@ -786,7 +803,7 @@ function PVEDashboard() {
|
||||
<React.Fragment key="admin">
|
||||
{btn("logs", ScrollText, t("navigation.systemLogs"))}
|
||||
{btn("security", ShieldCheck, t("navigation.security"))}
|
||||
{btn("audit", ClipboardCheck, t("navigation.audit"))}
|
||||
{!isPbs && btn("audit", ClipboardCheck, t("navigation.audit"))}
|
||||
{btn("settings", SettingsIcon, t("navigation.settings"))}
|
||||
{btn("about", Info, t("navigation.about"))}
|
||||
</React.Fragment>
|
||||
@@ -820,9 +837,16 @@ function PVEDashboard() {
|
||||
5 s / 59 s refresh intervals keep the data fresh in the
|
||||
background — reopening the tab is instant. */}
|
||||
<TabsContent value="overview" forceMount className="space-y-4 md:space-y-6 mt-0 data-[state=inactive]:hidden">
|
||||
<SystemOverview key={`overview-${componentKey}`} />
|
||||
<SystemOverview key={`overview-${componentKey}`} product={product} />
|
||||
</TabsContent>
|
||||
|
||||
{isPbs && (
|
||||
<TabsContent value="datastores" className="space-y-4 md:space-y-6 mt-0">
|
||||
<PBSPanel key={`datastores-${componentKey}`} localOnly />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{!isPbs && <>
|
||||
<TabsContent value="apps" className="space-y-4 md:space-y-6 mt-0">
|
||||
<AppsDashboard key={`apps-${componentKey}`} />
|
||||
</TabsContent>
|
||||
@@ -848,11 +872,13 @@ function PVEDashboard() {
|
||||
<TabsContent value="hardware" className="space-y-4 md:space-y-6 mt-0">
|
||||
<Hardware key={`hardware-${componentKey}`} />
|
||||
</TabsContent>
|
||||
</>}
|
||||
|
||||
<TabsContent value="logs" className="space-y-4 md:space-y-6 mt-0">
|
||||
<SystemLogs key={`logs-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
{!isPbs && <>
|
||||
<TabsContent value="audit" className="space-y-4 md:space-y-6 mt-0">
|
||||
<AuditReport key={`audit-${componentKey}`} />
|
||||
</TabsContent>
|
||||
@@ -860,13 +886,14 @@ function PVEDashboard() {
|
||||
<TabsContent value="backup" className="space-y-4 md:space-y-6 mt-0">
|
||||
<BackupTab key={`backup-${componentKey}`} />
|
||||
</TabsContent>
|
||||
</>}
|
||||
|
||||
<TabsContent value="terminal" className="mt-0">
|
||||
<TerminalPanel key={`terminal-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="space-y-4 md:space-y-6 mt-0">
|
||||
<Security key={`security-${componentKey}`} />
|
||||
<Security key={`security-${componentKey}`} hostType={product} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Profile tab — not surfaced in the top tabs nav. The only
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Input } from "./ui/input"
|
||||
import { getNetworkUnit } from "../lib/format-network"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { SUPPORTED_LANGUAGES, useI18n } from "../lib/i18n/provider"
|
||||
import { useIsPbs } from "../lib/use-platform"
|
||||
import type { LanguageCode } from "../lib/i18n/languages"
|
||||
import { NavTabOrderCard } from "./nav-tab-order-card"
|
||||
|
||||
@@ -312,6 +313,7 @@ interface NetworkInterface {
|
||||
|
||||
export function Settings() {
|
||||
const { language, setLanguage, t } = useI18n()
|
||||
const isPbs = useIsPbs()
|
||||
const tFallback = (key: string, fallback: string) => {
|
||||
const translated = t(key)
|
||||
return translated === key ? fallback : translated
|
||||
@@ -1124,7 +1126,7 @@ export function Settings() {
|
||||
</Card>
|
||||
|
||||
{/* Navigation Tab Order — user-orderable top-level tabs */}
|
||||
<NavTabOrderCard />
|
||||
{!isPbs && <NavTabOrderCard />}
|
||||
|
||||
{/* Network Units Settings */}
|
||||
<Card>
|
||||
@@ -1280,7 +1282,7 @@ export function Settings() {
|
||||
|
||||
{/* Per-category rows */}
|
||||
<div className="divide-y divide-border/50">
|
||||
{suppressionCategories.map((cat) => {
|
||||
{suppressionCategories.filter(cat => !isPbs || healthCategoryKey(cat) !== "vms").map((cat) => {
|
||||
const IconComp = CATEGORY_ICONS[cat.icon] || HeartPulse
|
||||
const effectiveHours = getEffectiveHours(cat)
|
||||
const isCustomMode = effectiveHours === -2 || (cat.key in customValues)
|
||||
@@ -1288,7 +1290,8 @@ export function Settings() {
|
||||
const isLong = effectiveHours >= 720 && effectiveHours !== -1 && effectiveHours !== -2
|
||||
const hasChanged = cat.key in pendingChanges && pendingChanges[cat.key] !== cat.hours
|
||||
const selectVal = isCustomMode ? "custom" : getSelectValue(effectiveHours, cat.key)
|
||||
const catLabel = tFallback(`settings.healthMonitor.categories.${healthCategoryKey(cat)}`, cat.label)
|
||||
const categoryKey = isPbs && healthCategoryKey(cat) === "services" ? "pbsServices" : healthCategoryKey(cat)
|
||||
const catLabel = tFallback(`settings.healthMonitor.categories.${categoryKey}`, cat.label)
|
||||
|
||||
return (
|
||||
<div key={cat.key}>
|
||||
@@ -1496,6 +1499,7 @@ export function Settings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{!isPbs && <>
|
||||
{/* Remote Storage Exclusions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -1678,6 +1682,7 @@ export function Settings() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>}
|
||||
|
||||
{/* Network Interface Exclusions */}
|
||||
<Card>
|
||||
@@ -1875,6 +1880,7 @@ export function Settings() {
|
||||
is re-enabled). */}
|
||||
<LxcUpdateDetection />
|
||||
|
||||
{!isPbs && <>
|
||||
{/* GitHub API — optional authentication for app upstream checks. */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -1981,15 +1987,16 @@ export function Settings() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>}
|
||||
|
||||
{/* Notification Settings */}
|
||||
<NotificationSettings />
|
||||
{/* Notification Settings (PVE only for now) */}
|
||||
{!isPbs && <NotificationSettings />}
|
||||
|
||||
{/* Issue #195: snippets storage selector. Only renders when more
|
||||
than one storage advertises content=snippets — on a typical
|
||||
standalone host with just `local` there's nothing to choose,
|
||||
so showing an empty selector would be noise. */}
|
||||
{snippetsCandidates.length > 1 && (
|
||||
{!isPbs && snippetsCandidates.length > 1 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import useSWR from "swr"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight } from "lucide-react"
|
||||
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight, Database } from "lucide-react"
|
||||
import { DatastoreUsageRow, type PBSDatastore } from "./pbs-datastore-usage"
|
||||
import { NodeMetricsCharts } from "./node-metrics-charts"
|
||||
import { NetworkTrafficChart } from "./network-traffic-chart"
|
||||
import { TemperatureDetailModal } from "./temperature-detail-modal"
|
||||
@@ -185,9 +187,17 @@ const getUnitsSettings = (): "Bytes" | "Bits" => {
|
||||
return raw && raw.toLowerCase() === "bits" ? "Bits" : "Bytes"
|
||||
}
|
||||
|
||||
export function SystemOverview() {
|
||||
export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" } = {}) {
|
||||
const t = useT()
|
||||
const { language } = useI18n()
|
||||
const isPbs = product === "pbs"
|
||||
// On Proxmox Backup Server the datastores take the place of guests and PVE storage.
|
||||
const { data: pbsStatus, error: pbsError } = useSWR<{ datastores: PBSDatastore[] | null }>(
|
||||
isPbs ? "/api/pbs/status?server=local" : null,
|
||||
(url: string) => fetchApi<{ datastores: PBSDatastore[] | null }>(url),
|
||||
{ refreshInterval: 60000, revalidateOnFocus: false },
|
||||
)
|
||||
const datastores = pbsStatus?.datastores ?? null
|
||||
const [systemData, setSystemData] = useState<SystemData | null>(null)
|
||||
const [vmData, setVmData] = useState<VMData[]>([])
|
||||
const [storageData, setStorageData] = useState<StorageData | null>(null)
|
||||
@@ -212,8 +222,8 @@ export function SystemOverview() {
|
||||
const fetchAllData = async () => {
|
||||
const [systemResult, vmResult, storageResults, networkResult] = await Promise.all([
|
||||
fetchSystemData().finally(() => setLoadingStates((prev) => ({ ...prev, system: false }))),
|
||||
fetchVMData().finally(() => setLoadingStates((prev) => ({ ...prev, vms: false }))),
|
||||
Promise.all([fetchStorageData(), fetchProxmoxStorageData()]).finally(() =>
|
||||
(isPbs ? Promise.resolve([]) : fetchVMData()).finally(() => setLoadingStates((prev) => ({ ...prev, vms: false }))),
|
||||
Promise.all([fetchStorageData(), isPbs ? Promise.resolve(null) : fetchProxmoxStorageData()]).finally(() =>
|
||||
setLoadingStates((prev) => ({ ...prev, storage: false })),
|
||||
),
|
||||
fetchNetworkData().finally(() => setLoadingStates((prev) => ({ ...prev, network: false }))),
|
||||
@@ -248,12 +258,13 @@ export function SystemOverview() {
|
||||
}, 5000)
|
||||
|
||||
const vmInterval = setInterval(async () => {
|
||||
if (isPbs) return
|
||||
const data = await fetchVMData()
|
||||
setVmData(data)
|
||||
}, 59000)
|
||||
|
||||
const storageInterval = setInterval(async () => {
|
||||
const [storage, proxmoxStorage] = await Promise.all([fetchStorageData(), fetchProxmoxStorageData()])
|
||||
const [storage, proxmoxStorage] = await Promise.all([fetchStorageData(), isPbs ? Promise.resolve(null) : fetchProxmoxStorageData()])
|
||||
if (storage) setStorageData(storage)
|
||||
if (proxmoxStorage) setProxmoxStorageData(proxmoxStorage)
|
||||
}, 59000)
|
||||
@@ -278,7 +289,7 @@ export function SystemOverview() {
|
||||
clearInterval(networkInterval)
|
||||
window.removeEventListener("networkUnitChanged" as any, handleUnitChange)
|
||||
}
|
||||
}, [t])
|
||||
}, [t, isPbs])
|
||||
|
||||
if (!hasAttemptedLoad || loadingStates.system) {
|
||||
return (
|
||||
@@ -527,7 +538,46 @@ export function SystemOverview() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */}
|
||||
{isPbs ? (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("pbs.datastores")}</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!datastores && !pbsError ? (
|
||||
<div className="space-y-2 animate-pulse">
|
||||
<div className="h-10 bg-muted rounded w-20"></div>
|
||||
<div className="h-5 bg-muted rounded w-32"></div>
|
||||
</div>
|
||||
) : !datastores ? (
|
||||
<p className="text-sm text-amber-500">{t("pbs.loadFailed")}</p>
|
||||
) : datastores.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-end justify-between">
|
||||
<span className="text-4xl font-bold leading-none text-foreground">{datastores.length}</span>
|
||||
{datastores.some(store => store.error) ? (
|
||||
<Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20">
|
||||
{t("pbs.datastoresUnavailable", { count: datastores.filter(store => store.error).length })}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{t("pbs.datastoresOnline")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{datastores.slice(0, 2).map(store => <DatastoreUsageRow key={store.name} store={store} compact />)}
|
||||
{datastores.length > 2 && (
|
||||
<p className="text-xs text-muted-foreground">{t("pbs.moreDatastores", { count: datastores.length - 2 })}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.activeVmLxc")}</CardTitle>
|
||||
@@ -565,6 +615,7 @@ export function SystemOverview() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card
|
||||
className={`bg-card border-border ${systemData.temperature > 0 ? "cursor-pointer hover:bg-white/5 transition-colors" : ""}`}
|
||||
@@ -708,7 +759,16 @@ export function SystemOverview() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vmLxcStorages && vmLxcStorages.length > 0 ? (
|
||||
{isPbs ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">{t("pbs.datastores")}</div>
|
||||
{!datastores ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">{pbsError ? t("pbs.loadFailed") : t("pbs.loading")}</div>
|
||||
) : datastores.length === 0 ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">{t("pbs.noDatastores")}</div>
|
||||
) : datastores.map(store => <DatastoreUsageRow key={store.name} store={store} />)}
|
||||
</div>
|
||||
) : vmLxcStorages && vmLxcStorages.length > 0 ? (
|
||||
<div className="space-y-2 pb-3 border-b border-border">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.vmLxcStorage")}</div>
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -744,7 +804,7 @@ export function SystemOverview() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localStorage && (
|
||||
{!isPbs && localStorage && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.localStorageSystem")}</div>
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -899,7 +959,7 @@ export function SystemOverview() {
|
||||
<span className="text-foreground">{formatSystemUptime(systemData.uptime)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t("overview.proxmoxVersion")}</span>
|
||||
<span className="text-muted-foreground">{t(isPbs ? "overview.pbsVersion" : "overview.proxmoxVersion")}</span>
|
||||
<span className="text-foreground">{systemData.proxmox_version || t("app.notAvailable")}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import useSWR from "swr"
|
||||
import { fetchApi } from "./api-config"
|
||||
|
||||
// Shares the dashboard's /api/platform request through the SWR cache.
|
||||
export function useIsPbs(): boolean {
|
||||
const { data } = useSWR<{ product: string }>("/api/platform", (url: string) => fetchApi<{ product: string }>(url), { revalidateOnFocus: false })
|
||||
return data?.product === "pbs"
|
||||
}
|
||||
@@ -8,7 +8,8 @@
|
||||
"unknown": "Unbekannt",
|
||||
"notAvailable": "N/A",
|
||||
"serverOffline": "Server offline",
|
||||
"supportProject": "Unterstützen Sie das Projekt und tragen Sie dazu bei"
|
||||
"supportProject": "Unterstützen Sie das Projekt und tragen Sie dazu bei",
|
||||
"descriptionPbs": "Proxmox Backup Server Dashboard"
|
||||
},
|
||||
"common": {
|
||||
"notAvailable": "N/A",
|
||||
@@ -165,7 +166,8 @@
|
||||
},
|
||||
"runningCountOne": "{count} läuft",
|
||||
"runningCountFew": "{count} laufen",
|
||||
"runningCountMany": "{count} laufen"
|
||||
"runningCountMany": "{count} laufen",
|
||||
"pbsVersion": "PBS-Version:"
|
||||
},
|
||||
"storage": {
|
||||
"loadingTitle": "Speicherdaten werden geladen...",
|
||||
@@ -1831,7 +1833,8 @@
|
||||
"logs": "Systemprotokolle",
|
||||
"updates": "Systemaktualisierungen",
|
||||
"security": "Sicherheit & Zertifikate",
|
||||
"temperature": "Temperatur"
|
||||
"temperature": "Temperatur",
|
||||
"pbsServices": "PBS Services"
|
||||
}
|
||||
},
|
||||
"remoteStorage": {
|
||||
@@ -4824,7 +4827,8 @@
|
||||
"services": "PVE-Dienste",
|
||||
"logs": "Systemprotokolle",
|
||||
"updates": "Systemaktualisierungen",
|
||||
"security": "Sicherheit & Zertifikate"
|
||||
"security": "Sicherheit & Zertifikate",
|
||||
"pbsServices": "PBS-Dienste"
|
||||
},
|
||||
"checks": {
|
||||
"cpu_usage": "CPU-Auslastung",
|
||||
@@ -7320,6 +7324,47 @@
|
||||
"navigation": "PBS-Navigation",
|
||||
"cpu": "CPU",
|
||||
"memory": "Arbeitsspeicher",
|
||||
"uptime": "Betriebszeit"
|
||||
"uptime": "Betriebszeit",
|
||||
"capacityUnavailable": "Kapazität nicht verfügbar.",
|
||||
"datastoresUnavailable": "{count} nicht verfügbar",
|
||||
"datastoresOnline": "Alle verfügbar",
|
||||
"moreDatastores": "+{count} weitere Datenspeicher",
|
||||
"estimatedFull": "Voraussichtlich voll",
|
||||
"estimatedFullUnknown": "Noch nicht genügend Daten",
|
||||
"duration": "Dauer",
|
||||
"noJobs": "Es sind keine Synchronisierungs-, Überprüfungs- oder Löschzeitpläne konfiguriert."
|
||||
},
|
||||
"monitorLinks": {
|
||||
"groups": {
|
||||
"cluster": "Cluster",
|
||||
"standalone": "Eigenständige Knoten",
|
||||
"pbs": "Proxmox Backup Server"
|
||||
},
|
||||
"thisNode": "Dieser Knoten",
|
||||
"checking": "Knoten werden überprüft…",
|
||||
"loadFailed": "Die Knotenliste konnte nicht geladen werden.",
|
||||
"empty": "Noch keine anderen Knoten. Fügen Sie einen hinzu, um seinen Monitor von hier aus zu öffnen.",
|
||||
"noResponse": "Keine Antwort",
|
||||
"pbsInterface": "PBS-Schnittstelle",
|
||||
"openNewTab": "In einem neuen Tab öffnen",
|
||||
"manage": "Knoten hinzufügen oder verwalten…",
|
||||
"dialogTitle": "Verknüpfte Knoten",
|
||||
"dialogDescription": "Clusterknoten und konfigurierte Proxmox Backup Server werden automatisch erkannt. Jeder Monitor behält seine eigene Anmeldung.",
|
||||
"addTitle": "Einen Knoten hinzufügen",
|
||||
"name": "Name",
|
||||
"address": "Adresse überwachen",
|
||||
"type": "Geben Sie ein",
|
||||
"add": "Hinzufügen",
|
||||
"save": "Speichern",
|
||||
"remove": "Entfernen",
|
||||
"visible": "Sichtbar",
|
||||
"autoAddress": "Automatisch",
|
||||
"detected": "Erkannt",
|
||||
"added": "Hinzugefügt",
|
||||
"saveFailed": "Die Knotenliste konnte nicht gespeichert werden.",
|
||||
"backTo": "Zurück zu {name}",
|
||||
"cameFromTitle": "Monitorknoten hinzufügen?",
|
||||
"cameFromBody": "{name} zu den verknüpften Knoten dieses Monitors hinzufügen.",
|
||||
"notNow": "Nicht jetzt"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -50,12 +50,21 @@
|
||||
"navigation": "Navegación de PBS",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memoria",
|
||||
"uptime": "Tiempo de actividad"
|
||||
"uptime": "Tiempo de actividad",
|
||||
"capacityUnavailable": "Capacidad no disponible.",
|
||||
"datastoresUnavailable": "{count} no disponibles",
|
||||
"datastoresOnline": "Todos disponibles",
|
||||
"moreDatastores": "+{count} datastores más",
|
||||
"estimatedFull": "Lleno previsto",
|
||||
"estimatedFullUnknown": "Aún no hay datos suficientes",
|
||||
"duration": "Duración",
|
||||
"noJobs": "No hay programaciones de sincronización, verificación ni prune."
|
||||
},
|
||||
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values.",
|
||||
"app": {
|
||||
"title": "ProxMenux Monitor",
|
||||
"description": "Proxmox System Dashboard",
|
||||
"descriptionPbs": "Proxmox Backup Server Dashboard",
|
||||
"loading": "Cargando...",
|
||||
"connecting": "Conexión al ProxMenux Monitor",
|
||||
"unknown": "Desconocido",
|
||||
@@ -193,6 +202,7 @@
|
||||
"dayMany": "{count} días"
|
||||
},
|
||||
"proxmoxVersion": "Versión Proxmox:",
|
||||
"pbsVersion": "Versión PBS:",
|
||||
"kernel": "Núcleo:",
|
||||
"availableUpdates": "Actualizaciones disponibles:",
|
||||
"packages": "paquetes",
|
||||
@@ -1881,6 +1891,7 @@
|
||||
"network": "Interfaces de red",
|
||||
"vms": "Máquinas virtuales y contenedores",
|
||||
"services": "Servicios PVE",
|
||||
"pbsServices": "Servicios PBS",
|
||||
"logs": "Registros del sistema",
|
||||
"updates": "Actualizaciones del sistema",
|
||||
"security": "Seguridad y certificados",
|
||||
@@ -4874,7 +4885,8 @@
|
||||
"disks": "E/S de disco y errores",
|
||||
"network": "Interfaces de red",
|
||||
"vms": "Máquinas virtuales y contenedores",
|
||||
"services": "servicios PVE",
|
||||
"services": "Servicios PVE",
|
||||
"pbsServices": "Servicios PBS",
|
||||
"logs": "Registros del sistema",
|
||||
"updates": "Actualizaciones del sistema",
|
||||
"security": "Seguridad y certificados"
|
||||
@@ -7321,5 +7333,38 @@
|
||||
"diagnosticsOmitted": "Líneas o texto de diagnóstico omitidos: {count}. Se conserva el informe original."
|
||||
}
|
||||
}
|
||||
},
|
||||
"monitorLinks": {
|
||||
"thisNode": "Este nodo",
|
||||
"checking": "Comprobando nodos…",
|
||||
"loadFailed": "No se pudo cargar la lista de nodos.",
|
||||
"empty": "Aún no hay otros nodos. Añade uno para abrir su Monitor desde aquí.",
|
||||
"groups": {
|
||||
"cluster": "Clúster",
|
||||
"standalone": "Nodos independientes",
|
||||
"pbs": "Proxmox Backup Server"
|
||||
},
|
||||
"noResponse": "Sin respuesta",
|
||||
"pbsInterface": "Interfaz de PBS",
|
||||
"openNewTab": "Abrir en una pestaña nueva",
|
||||
"manage": "Añadir o gestionar nodos…",
|
||||
"dialogTitle": "Nodos enlazados",
|
||||
"dialogDescription": "Los nodos del clúster y los Proxmox Backup Server configurados se detectan automáticamente. Cada Monitor mantiene su propio inicio de sesión.",
|
||||
"addTitle": "Añadir un nodo",
|
||||
"name": "Nombre",
|
||||
"address": "Dirección del Monitor",
|
||||
"type": "Tipo",
|
||||
"add": "Añadir",
|
||||
"save": "Guardar",
|
||||
"remove": "Eliminar",
|
||||
"visible": "Visible",
|
||||
"autoAddress": "Automática",
|
||||
"detected": "Detectado",
|
||||
"added": "Añadido",
|
||||
"saveFailed": "No se pudo guardar la lista de nodos.",
|
||||
"backTo": "Volver a {name}",
|
||||
"cameFromTitle": "¿Añadir nodo Monitor?",
|
||||
"cameFromBody": "Añade {name} a los nodos enlazados de este Monitor.",
|
||||
"notNow": "Ahora no"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"unknown": "Inconnu",
|
||||
"notAvailable": "N/A",
|
||||
"serverOffline": "Serveur hors ligne",
|
||||
"supportProject": "Soutenir et contribuer au projet"
|
||||
"supportProject": "Soutenir et contribuer au projet",
|
||||
"descriptionPbs": "Tableau de bord Proxmox Backup Server"
|
||||
},
|
||||
"common": {
|
||||
"notAvailable": "N/A",
|
||||
@@ -165,7 +166,8 @@
|
||||
},
|
||||
"runningCountOne": "{count} en cours d'exécution",
|
||||
"runningCountFew": "{count} en cours d'exécution",
|
||||
"runningCountMany": "{count} en cours d'exécution"
|
||||
"runningCountMany": "{count} en cours d'exécution",
|
||||
"pbsVersion": "Version PBS :"
|
||||
},
|
||||
"storage": {
|
||||
"loadingTitle": "Chargement des données de stockage...",
|
||||
@@ -1831,7 +1833,8 @@
|
||||
"logs": "Journaux système",
|
||||
"updates": "Mises à jour du système",
|
||||
"security": "Sécurité et certificats",
|
||||
"temperature": "Température"
|
||||
"temperature": "Température",
|
||||
"pbsServices": "Services PBS"
|
||||
}
|
||||
},
|
||||
"remoteStorage": {
|
||||
@@ -4824,7 +4827,8 @@
|
||||
"services": "Services PVE",
|
||||
"logs": "Journaux système",
|
||||
"updates": "Mises à jour du système",
|
||||
"security": "Sécurité et certificats"
|
||||
"security": "Sécurité et certificats",
|
||||
"pbsServices": "services PBS"
|
||||
},
|
||||
"checks": {
|
||||
"cpu_usage": "Utilisation du processeur",
|
||||
@@ -7320,6 +7324,47 @@
|
||||
"navigation": "Navigation PBS",
|
||||
"cpu": "CPU",
|
||||
"memory": "Mémoire",
|
||||
"uptime": "Durée de fonctionnement"
|
||||
"uptime": "Durée de fonctionnement",
|
||||
"capacityUnavailable": "Capacité non disponible.",
|
||||
"datastoresUnavailable": "{count} indisponible",
|
||||
"datastoresOnline": "Tous disponibles",
|
||||
"moreDatastores": "+{count} banques de données supplémentaires",
|
||||
"estimatedFull": "Estimé complet",
|
||||
"estimatedFullUnknown": "Pas encore assez de données",
|
||||
"duration": "Durée",
|
||||
"noJobs": "Aucune planification de synchronisation, de vérification ou d'élagage n'est configurée."
|
||||
},
|
||||
"monitorLinks": {
|
||||
"groups": {
|
||||
"cluster": "Cluster",
|
||||
"standalone": "nœuds autonomes",
|
||||
"pbs": "Proxmox Backup Server"
|
||||
},
|
||||
"thisNode": "Ce nœud",
|
||||
"checking": "Vérification des nœuds…",
|
||||
"loadFailed": "Impossible de charger la liste des nœuds.",
|
||||
"empty": "Aucun autre nœud pour l'instant. Ajoutez-en un pour ouvrir son Monitor depuis ici.",
|
||||
"noResponse": "Pas de réponse",
|
||||
"pbsInterface": "interface PBS",
|
||||
"openNewTab": "Ouvrir dans un nouvel onglet",
|
||||
"manage": "Ajouter ou gérer des nœuds…",
|
||||
"dialogTitle": "nœuds liés",
|
||||
"dialogDescription": "Les nœuds du cluster et les Proxmox Backup Server configurés sont détectés automatiquement. Chaque Monitor conserve sa propre connexion.",
|
||||
"addTitle": "ajouter un nœud",
|
||||
"name": "Nom",
|
||||
"address": "Adresse du moniteur",
|
||||
"type": "Type",
|
||||
"add": "Ajouter",
|
||||
"save": "Enregistrer",
|
||||
"remove": "Supprimer",
|
||||
"visible": "Visible",
|
||||
"autoAddress": "Automatique",
|
||||
"detected": "Détecté",
|
||||
"added": "ajouté",
|
||||
"saveFailed": "Impossible d'enregistrer la liste des nœuds.",
|
||||
"backTo": "Retour à {name}",
|
||||
"cameFromTitle": "Ajouter un nœud Moniteur ?",
|
||||
"cameFromBody": "Ajoutez {name} aux nœuds liés de ce moniteur.",
|
||||
"notNow": "Pas maintenant"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"unknown": "Sconosciuto",
|
||||
"notAvailable": "N/A",
|
||||
"serverOffline": "Server offline",
|
||||
"supportProject": "Sostieni e contribuisci al progetto"
|
||||
"supportProject": "Sostieni e contribuisci al progetto",
|
||||
"descriptionPbs": "Dashboard di Proxmox Backup Server"
|
||||
},
|
||||
"common": {
|
||||
"notAvailable": "N/A",
|
||||
@@ -165,7 +166,8 @@
|
||||
},
|
||||
"runningCountOne": "{count} in esecuzione",
|
||||
"runningCountFew": "{count} in esecuzione",
|
||||
"runningCountMany": "{count} in esecuzione"
|
||||
"runningCountMany": "{count} in esecuzione",
|
||||
"pbsVersion": "Versione PBS:"
|
||||
},
|
||||
"storage": {
|
||||
"loadingTitle": "Caricamento dati storage...",
|
||||
@@ -1831,7 +1833,8 @@
|
||||
"logs": "Log di sistema",
|
||||
"updates": "Aggiornamenti di sistema",
|
||||
"security": "Sicurezza e certificati",
|
||||
"temperature": "Temperatura"
|
||||
"temperature": "Temperatura",
|
||||
"pbsServices": "Servizi PBS"
|
||||
}
|
||||
},
|
||||
"remoteStorage": {
|
||||
@@ -4824,7 +4827,8 @@
|
||||
"services": "Servizi PVE",
|
||||
"logs": "Log di sistema",
|
||||
"updates": "Aggiornamenti di sistema",
|
||||
"security": "Sicurezza e certificati"
|
||||
"security": "Sicurezza e certificati",
|
||||
"pbsServices": "servizi PBS"
|
||||
},
|
||||
"checks": {
|
||||
"cpu_usage": "Utilizzo CPU",
|
||||
@@ -7320,6 +7324,47 @@
|
||||
"navigation": "Navigazione PBS",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memoria",
|
||||
"uptime": "Tempo di attività"
|
||||
"uptime": "Tempo di attività",
|
||||
"capacityUnavailable": "Capacità non disponibile.",
|
||||
"datastoresUnavailable": "{count} non disponibile",
|
||||
"datastoresOnline": "Tutti disponibili",
|
||||
"moreDatastores": "+{count} altri archivi dati",
|
||||
"estimatedFull": "Pieno stimato",
|
||||
"estimatedFullUnknown": "dati non ancora sufficienti",
|
||||
"duration": "Durata",
|
||||
"noJobs": "non è configurata alcuna pianificazione di sincronizzazione, verifica o eliminazione."
|
||||
},
|
||||
"monitorLinks": {
|
||||
"groups": {
|
||||
"cluster": "cluster",
|
||||
"standalone": "nodi autonomi",
|
||||
"pbs": "Proxmox Backup Server"
|
||||
},
|
||||
"thisNode": "questo nodo",
|
||||
"checking": "Controllo dei nodi…",
|
||||
"loadFailed": "impossibile caricare l'elenco dei nodi.",
|
||||
"empty": "Ancora nessun altro nodo. Aggiungine uno per aprire il suo Monitor da qui.",
|
||||
"noResponse": "Nessuna risposta",
|
||||
"pbsInterface": "interfaccia PBS",
|
||||
"openNewTab": "apri in una nuova scheda",
|
||||
"manage": "aggiungi o gestisci nodi...",
|
||||
"dialogTitle": "nodi collegati",
|
||||
"dialogDescription": "I nodi del cluster e i Proxmox Backup Server configurati vengono rilevati automaticamente. Ogni Monitor mantiene il proprio accesso.",
|
||||
"addTitle": "aggiungi un nodo",
|
||||
"name": "Nome",
|
||||
"address": "monitora l'indirizzo",
|
||||
"type": "digita",
|
||||
"add": "aggiungi",
|
||||
"save": "salva",
|
||||
"remove": "rimuovere",
|
||||
"visible": "visibile",
|
||||
"autoAddress": "automatico",
|
||||
"detected": "rilevato",
|
||||
"added": "aggiunto",
|
||||
"saveFailed": "impossibile salvare l'elenco dei nodi.",
|
||||
"backTo": "torna a {name}",
|
||||
"cameFromTitle": "aggiungi il nodo Monitor?",
|
||||
"cameFromBody": "aggiungi {name} ai nodi collegati di questo monitor.",
|
||||
"notNow": "Non ora"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"unknown": "Desconhecido",
|
||||
"notAvailable": "N/D",
|
||||
"serverOffline": "Servidor off-line",
|
||||
"supportProject": "Apoie e contribua para o projeto"
|
||||
"supportProject": "Apoie e contribua para o projeto",
|
||||
"descriptionPbs": "Painel do Proxmox Backup Server"
|
||||
},
|
||||
"common": {
|
||||
"notAvailable": "N/D",
|
||||
@@ -165,7 +166,8 @@
|
||||
},
|
||||
"runningCountOne": "{count} em execução",
|
||||
"runningCountFew": "{count} em execução",
|
||||
"runningCountMany": "{count} em execução"
|
||||
"runningCountMany": "{count} em execução",
|
||||
"pbsVersion": "Versão PBS:"
|
||||
},
|
||||
"storage": {
|
||||
"loadingTitle": "Carregando dados de armazenamento...",
|
||||
@@ -1831,7 +1833,8 @@
|
||||
"logs": "Registros do sistema",
|
||||
"updates": "Atualizações do sistema",
|
||||
"security": "Segurança e Certificados",
|
||||
"temperature": "Temperatura"
|
||||
"temperature": "Temperatura",
|
||||
"pbsServices": "Serviços PBS"
|
||||
}
|
||||
},
|
||||
"remoteStorage": {
|
||||
@@ -4824,7 +4827,8 @@
|
||||
"services": "Serviços PVE",
|
||||
"logs": "Registros do sistema",
|
||||
"updates": "Atualizações do sistema",
|
||||
"security": "Segurança e certificados"
|
||||
"security": "Segurança e certificados",
|
||||
"pbsServices": "serviços PBS"
|
||||
},
|
||||
"checks": {
|
||||
"cpu_usage": "Uso da CPU",
|
||||
@@ -7320,6 +7324,47 @@
|
||||
"navigation": "Navegação PBS",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memória",
|
||||
"uptime": "Tempo de atividade"
|
||||
"uptime": "Tempo de atividade",
|
||||
"capacityUnavailable": "Capacidade não disponível.",
|
||||
"datastoresUnavailable": "{count} indisponível",
|
||||
"datastoresOnline": "Todos disponíveis",
|
||||
"moreDatastores": "+{count} mais armazenamentos de dados",
|
||||
"estimatedFull": "estimativa completa",
|
||||
"estimatedFullUnknown": "Not enough data yet",
|
||||
"duration": "Duração",
|
||||
"noJobs": "nenhuma programação de sincronização, verificação ou remoção está configurada."
|
||||
},
|
||||
"monitorLinks": {
|
||||
"groups": {
|
||||
"cluster": "Cluster",
|
||||
"standalone": "nós autônomos",
|
||||
"pbs": "Proxmox Backup Server"
|
||||
},
|
||||
"thisNode": "Este nó",
|
||||
"checking": "Verificando nós…",
|
||||
"loadFailed": "não foi possível carregar a lista de nós.",
|
||||
"empty": "Ainda não há outros nós. Adicione um para abrir o seu Monitor a partir daqui.",
|
||||
"noResponse": "Sem resposta",
|
||||
"pbsInterface": "interface PBS",
|
||||
"openNewTab": "abrir em uma nova guia",
|
||||
"manage": "Adicionar ou gerenciar nós…",
|
||||
"dialogTitle": "nós vinculados",
|
||||
"dialogDescription": "Os nós do cluster e os Proxmox Backup Server configurados são detectados automaticamente. Cada Monitor mantém o seu próprio login.",
|
||||
"addTitle": "Adicionar um nó",
|
||||
"name": "Nome",
|
||||
"address": "endereço do monitor",
|
||||
"type": "Tipo",
|
||||
"add": "Adicionar",
|
||||
"save": "Salvar",
|
||||
"remove": "Remover",
|
||||
"visible": "Visível",
|
||||
"autoAddress": "Automático",
|
||||
"detected": "Detectado",
|
||||
"added": "Adicionado",
|
||||
"saveFailed": "não foi possível salvar a lista de nós.",
|
||||
"backTo": "Voltar para {name}",
|
||||
"cameFromTitle": "Adicionar nó Monitor?",
|
||||
"cameFromBody": "Adicione {name} aos nós vinculados deste Monitor.",
|
||||
"notNow": "agora não"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"unknown": "Neznáme",
|
||||
"notAvailable": "Nedostupné",
|
||||
"serverOffline": "Server je offline",
|
||||
"supportProject": "Podporte projekt alebo prispejte vlastnými úpravami"
|
||||
"supportProject": "Podporte projekt alebo prispejte vlastnými úpravami",
|
||||
"descriptionPbs": "Proxmox Backup Server Dashboard"
|
||||
},
|
||||
"common": {
|
||||
"notAvailable": "Nedostupné",
|
||||
@@ -164,7 +165,8 @@
|
||||
"errors": {
|
||||
"serverUnavailableTitle": "Server Monitora nie je dostupný",
|
||||
"serverUnavailableDescription": "Nedá sa pripojiť k serveru Monitora. Skontrolujte, či služba beží, a skúste to znova."
|
||||
}
|
||||
},
|
||||
"pbsVersion": "Verzia PBS:"
|
||||
},
|
||||
"storage": {
|
||||
"loadingTitle": "Načítavam úložiská...",
|
||||
@@ -1830,7 +1832,8 @@
|
||||
"logs": "Systémové záznamy",
|
||||
"updates": "Aktualizácie systému",
|
||||
"security": "Bezpečnosť a certifikáty",
|
||||
"temperature": "Teplota"
|
||||
"temperature": "Teplota",
|
||||
"pbsServices": "Služby PBS"
|
||||
}
|
||||
},
|
||||
"remoteStorage": {
|
||||
@@ -4823,7 +4826,8 @@
|
||||
"services": "Služby PVE",
|
||||
"logs": "Systémové záznamy",
|
||||
"updates": "Aktualizácie systému",
|
||||
"security": "Bezpečnosť a certifikáty"
|
||||
"security": "Bezpečnosť a certifikáty",
|
||||
"pbsServices": "Služby PBS"
|
||||
},
|
||||
"checks": {
|
||||
"cpu_usage": "Využitie CPU",
|
||||
@@ -7319,6 +7323,47 @@
|
||||
"navigation": "Navigácia PBS",
|
||||
"cpu": "CPU",
|
||||
"memory": "Pamäť",
|
||||
"uptime": "Čas prevádzky"
|
||||
"uptime": "Čas prevádzky",
|
||||
"capacityUnavailable": "Kapacita nie je k dispozícii.",
|
||||
"datastoresUnavailable": "{count} nie je k dispozícii",
|
||||
"datastoresOnline": "Všetky dostupné",
|
||||
"moreDatastores": "+{count} ďalších dátových úložísk",
|
||||
"estimatedFull": "Odhadovaný plný",
|
||||
"estimatedFullUnknown": "Zatiaľ nie je dostatok údajov",
|
||||
"duration": "Trvanie",
|
||||
"noJobs": "Nie sú nakonfigurované žiadne plány synchronizácie, overovania ani odstraňovania."
|
||||
},
|
||||
"monitorLinks": {
|
||||
"groups": {
|
||||
"cluster": "Cluster",
|
||||
"standalone": "Samostatné uzly",
|
||||
"pbs": "Proxmox Backup Server"
|
||||
},
|
||||
"thisNode": "Tento uzol",
|
||||
"checking": "Kontrolujú sa uzly…",
|
||||
"loadFailed": "Nepodarilo sa načítať zoznam uzlov.",
|
||||
"empty": "Zatiaľ žiadne ďalšie uzly. Pridajte jeden, aby ste odtiaľto otvorili jeho Monitor.",
|
||||
"noResponse": "Žiadna odpoveď",
|
||||
"pbsInterface": "rozhranie PBS",
|
||||
"openNewTab": "Otvoriť na novej karte",
|
||||
"manage": "Pridať alebo spravovať uzly…",
|
||||
"dialogTitle": "Prepojené uzly",
|
||||
"dialogDescription": "Uzly klastra a nakonfigurované Proxmox Backup Server sa zisťujú automaticky. Každý Monitor má vlastné prihlásenie.",
|
||||
"addTitle": "Pridať uzol",
|
||||
"name": "Meno",
|
||||
"address": "Adresa monitora",
|
||||
"type": "Typ",
|
||||
"add": "Pridať",
|
||||
"save": "Uložiť",
|
||||
"remove": "Odstrániť",
|
||||
"visible": "Viditeľné",
|
||||
"autoAddress": "Automaticky",
|
||||
"detected": "Zistené",
|
||||
"added": "Pridané",
|
||||
"saveFailed": "Nepodarilo sa uložiť zoznam uzlov.",
|
||||
"backTo": "Späť na {name}",
|
||||
"cameFromTitle": "Pridať uzol monitora?",
|
||||
"cameFromBody": "Pridajte {name} do prepojených uzlov tohto monitora.",
|
||||
"notNow": "Teraz nie"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"unknown": "Okänd",
|
||||
"notAvailable": "N/A",
|
||||
"serverOffline": "Servern är frånkopplad",
|
||||
"supportProject": "Stöd projektet och bidra"
|
||||
"supportProject": "Stöd projektet och bidra",
|
||||
"descriptionPbs": "Proxmox Backup Server Dashboard"
|
||||
},
|
||||
"common": {
|
||||
"notAvailable": "N/A",
|
||||
@@ -165,7 +166,8 @@
|
||||
},
|
||||
"runningCountOne": "{count} körs",
|
||||
"runningCountFew": "{count} körs",
|
||||
"runningCountMany": "{count} körs"
|
||||
"runningCountMany": "{count} körs",
|
||||
"pbsVersion": "PBS-version:"
|
||||
},
|
||||
"storage": {
|
||||
"loadingTitle": "Laddar lagringsdata...",
|
||||
@@ -1831,7 +1833,8 @@
|
||||
"logs": "Systemloggar",
|
||||
"updates": "Systemuppdateringar",
|
||||
"security": "Säkerhet och certifikat",
|
||||
"temperature": "Temperatur"
|
||||
"temperature": "Temperatur",
|
||||
"pbsServices": "PBS Services"
|
||||
}
|
||||
},
|
||||
"remoteStorage": {
|
||||
@@ -4824,7 +4827,8 @@
|
||||
"services": "PVE-tjänster",
|
||||
"logs": "Systemloggar",
|
||||
"updates": "Systemuppdateringar",
|
||||
"security": "Säkerhet & certifikat"
|
||||
"security": "Säkerhet & certifikat",
|
||||
"pbsServices": "PBS-tjänster"
|
||||
},
|
||||
"checks": {
|
||||
"cpu_usage": "CPU-användning",
|
||||
@@ -7320,6 +7324,47 @@
|
||||
"navigation": "PBS-navigering",
|
||||
"cpu": "CPU",
|
||||
"memory": "Minne",
|
||||
"uptime": "Drifttid"
|
||||
"uptime": "Drifttid",
|
||||
"capacityUnavailable": "Kapacitet inte tillgänglig.",
|
||||
"datastoresUnavailable": "{count} inte tillgänglig",
|
||||
"datastoresOnline": "Alla tillgängliga",
|
||||
"moreDatastores": "+{count} fler databutiker",
|
||||
"estimatedFull": "Beräknad full",
|
||||
"estimatedFullUnknown": "Inte tillräckligt med data ännu",
|
||||
"duration": "Varaktighet",
|
||||
"noJobs": "Inga synkroniserings-, verifierings- eller beskärningsscheman är konfigurerade."
|
||||
},
|
||||
"monitorLinks": {
|
||||
"groups": {
|
||||
"cluster": "Kluster",
|
||||
"standalone": "Fristående noder",
|
||||
"pbs": "Proxmox Backup Server"
|
||||
},
|
||||
"thisNode": "Denna nod",
|
||||
"checking": "Kontrollerar noder...",
|
||||
"loadFailed": "Kunde inte ladda nodlistan.",
|
||||
"empty": "Inga andra noder än. Lägg till en för att öppna dess Monitor härifrån.",
|
||||
"noResponse": "Inget svar",
|
||||
"pbsInterface": "PBS-gränssnitt",
|
||||
"openNewTab": "Öppna i en ny flik",
|
||||
"manage": "Lägg till eller hantera noder...",
|
||||
"dialogTitle": "Länkade noder",
|
||||
"dialogDescription": "Klusternoder och konfigurerade Proxmox Backup Server identifieras automatiskt. Varje Monitor behåller sin egen inloggning.",
|
||||
"addTitle": "Lägg till en nod",
|
||||
"name": "Namn",
|
||||
"address": "Övervaka adress",
|
||||
"type": "Typ",
|
||||
"add": "Lägg till",
|
||||
"save": "Spara",
|
||||
"remove": "Ta bort",
|
||||
"visible": "Synlig",
|
||||
"autoAddress": "Automatisk",
|
||||
"detected": "Upptäckt",
|
||||
"added": "Tillagd",
|
||||
"saveFailed": "Kunde inte spara nodlistan.",
|
||||
"backTo": "Tillbaka till {name}",
|
||||
"cameFromTitle": "Lägg till monitornod?",
|
||||
"cameFromBody": "Lägg till {name} till de länkade noderna på denna monitor.",
|
||||
"notNow": "Inte nu"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,8 @@ cp "$SCRIPT_DIR/temperature_sensor_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null
|
||||
cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found"
|
||||
cp "$SCRIPT_DIR/pbs_manager.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/flask_pbs_routes.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/monitor_links.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/flask_monitor_links_routes.py" "$APP_DIR/usr/bin/"
|
||||
cp "$APPIMAGE_ROOT/../scripts/global/proxmox_platform.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/flask_script_runner.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_script_runner.py not found"
|
||||
cp "$SCRIPT_DIR/security_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ security_manager.py not found"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Links between ProxMenux Monitors for the node selector in the header."""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
import monitor_links
|
||||
from jwt_middleware import require_auth, require_admin_scope
|
||||
|
||||
monitor_links_bp = Blueprint("monitor_links", __name__)
|
||||
|
||||
|
||||
@monitor_links_bp.after_request
|
||||
def no_cache(response):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
@monitor_links_bp.route("/api/monitor-links")
|
||||
@require_auth
|
||||
def list_links():
|
||||
return jsonify(monitor_links.cached_links(include_hidden=request.args.get("all") == "1"))
|
||||
|
||||
|
||||
@monitor_links_bp.route("/api/monitor-links", methods=["POST"])
|
||||
@require_admin_scope
|
||||
def add_link():
|
||||
try:
|
||||
key = monitor_links.add_link(request.get_json(silent=True))
|
||||
monitor_links.refresh()
|
||||
return jsonify(success=True, key=key)
|
||||
except monitor_links.LinkError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
except OSError:
|
||||
return jsonify(error="Could not save the node list."), 500
|
||||
|
||||
|
||||
@monitor_links_bp.route("/api/monitor-links/<path:key>", methods=["PATCH"])
|
||||
@require_admin_scope
|
||||
def update_link(key):
|
||||
try:
|
||||
monitor_links.update_link(key, request.get_json(silent=True))
|
||||
monitor_links.refresh()
|
||||
return jsonify(success=True)
|
||||
except monitor_links.LinkError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
except OSError:
|
||||
return jsonify(error="Could not save the node list."), 500
|
||||
|
||||
|
||||
@monitor_links_bp.route("/api/monitor-links/<path:key>", methods=["DELETE"])
|
||||
@require_admin_scope
|
||||
def delete_link(key):
|
||||
try:
|
||||
monitor_links.delete_link(key)
|
||||
monitor_links.refresh()
|
||||
return jsonify(success=True)
|
||||
except monitor_links.LinkError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
except OSError:
|
||||
return jsonify(error="Could not save the node list."), 500
|
||||
@@ -1156,6 +1156,14 @@ def setup_pve_webhook_core() -> dict:
|
||||
'fallback_commands': [],
|
||||
'error': None,
|
||||
}
|
||||
try: # Proxmox Backup Server has no /etc/pve notification configuration
|
||||
from pbs_manager import is_pbs_host
|
||||
on_pbs = is_pbs_host()
|
||||
except Exception:
|
||||
on_pbs = False
|
||||
if on_pbs:
|
||||
result['not_applicable'] = True
|
||||
return result
|
||||
|
||||
try:
|
||||
if not _pve_cluster_fs_mounted():
|
||||
@@ -1339,6 +1347,13 @@ def cleanup_pve_webhook_core() -> dict:
|
||||
Only removes blocks named 'proxmenux-webhook' / 'proxmenux-default'.
|
||||
"""
|
||||
result = {'cleaned': False, 'error': None}
|
||||
try: # Proxmox Backup Server has no /etc/pve notification configuration
|
||||
from pbs_manager import is_pbs_host
|
||||
on_pbs = is_pbs_host()
|
||||
except Exception:
|
||||
on_pbs = False
|
||||
if on_pbs:
|
||||
return {'cleaned': True, 'error': None, 'not_applicable': True}
|
||||
|
||||
try:
|
||||
if not _pve_cluster_fs_mounted():
|
||||
|
||||
@@ -91,6 +91,7 @@ from flask_health_routes import health_bp # noqa: E402
|
||||
from flask_auth_routes import auth_bp # noqa: E402
|
||||
from flask_proxmenux_routes import proxmenux_bp # noqa: E402
|
||||
from flask_pbs_routes import pbs_bp # noqa: E402
|
||||
from flask_monitor_links_routes import monitor_links_bp # noqa: E402
|
||||
from pbs_manager import get_platform, is_pbs_host # noqa: E402
|
||||
from flask_security_routes import security_bp # noqa: E402
|
||||
from flask_notification_routes import notification_bp # noqa: E402
|
||||
@@ -198,6 +199,7 @@ app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(proxmenux_bp)
|
||||
app.register_blueprint(pbs_bp)
|
||||
app.register_blueprint(monitor_links_bp)
|
||||
app.register_blueprint(security_bp)
|
||||
app.register_blueprint(notification_bp)
|
||||
app.register_blueprint(oci_bp)
|
||||
@@ -12214,6 +12216,17 @@ def _get_node_metrics_payload(timeframe, max_age=_NODE_METRICS_TTL):
|
||||
lock.release()
|
||||
|
||||
|
||||
def _node_rrd_command(local_node, timeframe, cf=None):
|
||||
"""Node RRD query; Proxmox Backup Server keeps the same fields under its own API."""
|
||||
if is_pbs_host():
|
||||
return ['proxmox-backup-debug', 'api', 'get', '/nodes/localhost/rrd',
|
||||
'--timeframe', timeframe, '--cf', cf or 'AVERAGE', '--output-format', 'json']
|
||||
command = ['pvesh', 'get', f'/nodes/{local_node}/rrddata', '--timeframe', timeframe]
|
||||
if cf:
|
||||
command += ['--cf', cf]
|
||||
return command + ['--output-format', 'json']
|
||||
|
||||
|
||||
def _compute_node_metrics_payload(timeframe: str) -> dict:
|
||||
"""Fetch and shape one node RRD snapshot within a bounded query budget."""
|
||||
valid_timeframes = ('hour', 'day', 'week', 'month', 'year')
|
||||
@@ -12237,8 +12250,7 @@ def _compute_node_metrics_payload(timeframe: str) -> dict:
|
||||
|
||||
try:
|
||||
rrd_result = subprocess.run(
|
||||
['pvesh', 'get', f'/nodes/{local_node}/rrddata',
|
||||
'--timeframe', timeframe, '--output-format', 'json'],
|
||||
_node_rrd_command(local_node, timeframe),
|
||||
capture_output=True, text=True, timeout=_NODE_METRICS_TIMEOUT,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
@@ -12332,9 +12344,7 @@ def _compute_node_metrics_payload(timeframe: str) -> dict:
|
||||
return None
|
||||
try:
|
||||
extra = subprocess.run(
|
||||
['pvesh', 'get', f'/nodes/{local_node}/rrddata',
|
||||
'--timeframe', timeframe, '--cf', cf,
|
||||
'--output-format', 'json'],
|
||||
_node_rrd_command(local_node, timeframe, cf),
|
||||
capture_output=True, text=True, timeout=remaining,
|
||||
)
|
||||
if extra.returncode == 0 and extra.stdout:
|
||||
@@ -23339,8 +23349,8 @@ if __name__ == '__main__':
|
||||
# the 24 h PollingCollector cycle. Runs in a daemon thread with
|
||||
# a delay so it doesn't block startup — notification_manager needs
|
||||
# a moment to bind channels before we emit.
|
||||
# The guest services below read VMs, LXCs and the PVE node metrics,
|
||||
# none of which exist on a Proxmox Backup Server.
|
||||
# The guest services below read VMs and LXCs, which do not exist on a
|
||||
# Proxmox Backup Server.
|
||||
pve_host = not is_pbs_host()
|
||||
try:
|
||||
def _startup_emit_pending_app_updates():
|
||||
@@ -23359,13 +23369,19 @@ if __name__ == '__main__':
|
||||
|
||||
# Prewarm only the Overview's default day range; other ranges are lazy.
|
||||
try:
|
||||
if pve_host:
|
||||
metrics_thread = threading.Thread(target=_node_metrics_prewarmer_loop, daemon=True, name='node-metrics-prewarmer')
|
||||
metrics_thread.start()
|
||||
print("[ProxMenux] Node-metrics prewarmer started (day range, 90s interval)")
|
||||
metrics_thread = threading.Thread(target=_node_metrics_prewarmer_loop, daemon=True, name='node-metrics-prewarmer')
|
||||
metrics_thread.start()
|
||||
print("[ProxMenux] Node-metrics prewarmer started (day range, 90s interval)")
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] Node-metrics prewarmer failed to start: {e}")
|
||||
|
||||
# Linked Monitors for the header node selector, renewed every 5 minutes.
|
||||
try:
|
||||
import monitor_links
|
||||
monitor_links.start_prewarmer()
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] Monitor links prewarmer failed to start: {e}")
|
||||
|
||||
# ── VM/CT modal-cache prewarmer ──
|
||||
# Keeps _vm_details_cache / _vm_backups_cache / app snapshots /
|
||||
# _vm_schedule_cache warm from the backend so the "Loading
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Other ProxMenux Monitors reachable from this one: cluster nodes, PBS servers
|
||||
and entries added by hand.
|
||||
|
||||
Only links are kept here. No credential is stored or sent: the browser opens
|
||||
the other Monitor, which keeps its own sign-in.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
import pbs_manager
|
||||
|
||||
LINKS_FILE = Path(os.environ.get("PROXMENUX_MONITOR_LINKS", "/usr/local/share/proxmenux/monitor_links.json"))
|
||||
SSL_CONFIG_FILE = Path(os.environ.get("PROXMENUX_SSL_CONFIG", "/etc/proxmenux/ssl_config.json"))
|
||||
STORAGE_CFG = Path("/etc/pve/storage.cfg")
|
||||
MONITOR_PORT = 8008
|
||||
PBS_UI_PORT = 8007
|
||||
PROBE_TIMEOUT = 3
|
||||
PROBE_TTL = 300
|
||||
MAX_MANUAL = 64
|
||||
PRODUCTS = ("pve", "pbs")
|
||||
|
||||
_lock = threading.Lock()
|
||||
_probe_cache = {}
|
||||
_snapshot = {"at": 0.0, "data": None}
|
||||
_refresh_lock = threading.Lock()
|
||||
|
||||
|
||||
class LinkError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _load():
|
||||
try:
|
||||
data = json.loads(LINKS_FILE.read_text())
|
||||
except (OSError, ValueError):
|
||||
return {"manual": [], "overrides": {}}
|
||||
manual = data.get("manual") if isinstance(data.get("manual"), list) else []
|
||||
overrides = data.get("overrides") if isinstance(data.get("overrides"), dict) else {}
|
||||
return {"manual": [row for row in manual if isinstance(row, dict)], "overrides": overrides}
|
||||
|
||||
|
||||
def _save(data):
|
||||
LINKS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = LINKS_FILE.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(data, indent=2))
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, LINKS_FILE)
|
||||
|
||||
|
||||
def normalize_url(value):
|
||||
"""Return the origin of an http(s) address, or raise LinkError."""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise LinkError("Enter the address of the Monitor.")
|
||||
text = value.strip()
|
||||
if "://" not in text:
|
||||
text = "https://" + text
|
||||
parsed = urllib.parse.urlsplit(text)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
raise LinkError("The address must start with http:// or https:// and name a host.")
|
||||
if parsed.username or parsed.password:
|
||||
raise LinkError("The address must not contain a user or password.")
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise LinkError("The port of the address is not valid.") from exc
|
||||
host = parsed.hostname
|
||||
if ":" in host:
|
||||
host = f"[{host}]"
|
||||
return f"{parsed.scheme}://{host}" + (f":{port}" if port else "")
|
||||
|
||||
|
||||
def _local_scheme():
|
||||
try:
|
||||
return "https" if json.loads(SSL_CONFIG_FILE.read_text()).get("enabled") else "http"
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return "http"
|
||||
|
||||
|
||||
def _host_url(host, port, scheme=None):
|
||||
if ":" in host and not host.startswith("["):
|
||||
host = f"[{host}]"
|
||||
return f"{scheme or _local_scheme()}://{host}:{port}"
|
||||
|
||||
|
||||
def _is_private(host):
|
||||
"""Only addresses in private ranges are probed without being asked to."""
|
||||
try:
|
||||
addresses = {info[4][0] for info in socket.getaddrinfo(host, None)}
|
||||
except OSError:
|
||||
return False
|
||||
try:
|
||||
return bool(addresses) and all(ipaddress.ip_address(a.split("%")[0]).is_private for a in addresses)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _cluster_nodes():
|
||||
try:
|
||||
result = subprocess.run(["pvesh", "get", "/cluster/status", "--output-format", "json"],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
rows = json.loads(result.stdout) if result.returncode == 0 else []
|
||||
except (OSError, subprocess.SubprocessError, ValueError):
|
||||
return []
|
||||
nodes = []
|
||||
for row in rows if isinstance(rows, list) else []:
|
||||
if isinstance(row, dict) and row.get("type") == "node" and not row.get("local") and row.get("name") and row.get("ip"):
|
||||
nodes.append({"key": f"cluster:{row['name']}", "name": str(row["name"]), "product": "pve",
|
||||
"group": "cluster", "host": str(row["ip"])})
|
||||
return nodes
|
||||
|
||||
|
||||
def _storage_pbs():
|
||||
try:
|
||||
text = STORAGE_CFG.read_text()
|
||||
except OSError:
|
||||
return []
|
||||
servers, current = [], None
|
||||
for line in text.splitlines():
|
||||
header = re.match(r"^(\w+):\s*(\S+)", line)
|
||||
if header:
|
||||
current = {"name": header.group(2), "port": PBS_UI_PORT} if header.group(1) == "pbs" else None
|
||||
if current is not None:
|
||||
servers.append(current)
|
||||
continue
|
||||
option = re.match(r"^\s+(server|port)\s+(\S+)", line)
|
||||
if current is not None and option:
|
||||
current[option.group(1)] = option.group(2)
|
||||
return [row for row in servers if row.get("server")]
|
||||
|
||||
|
||||
def _pbs_servers():
|
||||
found = {}
|
||||
for row in _storage_pbs():
|
||||
host = row["server"]
|
||||
port = row.get("port") if str(row.get("port", "")).isdigit() else PBS_UI_PORT
|
||||
found.setdefault(host, {"name": row["name"], "native_url": _host_url(host, port, "https")})
|
||||
try:
|
||||
for row in pbs_manager.public_servers():
|
||||
host = urllib.parse.urlsplit(row.get("url", "")).hostname
|
||||
if host:
|
||||
found.setdefault(host, {"name": row.get("name") or host, "native_url": normalize_url(row["url"])})
|
||||
except Exception:
|
||||
pass
|
||||
return [{"key": f"pbs:{host}", "name": info["name"], "product": "pbs", "group": "pbs",
|
||||
"host": host, "native_url": info["native_url"]} for host, info in found.items()]
|
||||
|
||||
|
||||
def _probe(url):
|
||||
"""True when a ProxMenux Monitor answers at url; nothing is sent but the request."""
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
cached = _probe_cache.get(url)
|
||||
if cached and cached[0] > now:
|
||||
return cached[1]
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE # liveness only: no credential or token is ever sent
|
||||
online = False
|
||||
try:
|
||||
request = urllib.request.Request(url + "/api/auth/status", headers={"Accept": "application/json"})
|
||||
with urllib.request.urlopen(request, timeout=PROBE_TIMEOUT, context=context) as response:
|
||||
online = response.status == 200 and "auth_enabled" in json.loads(response.read(4096))
|
||||
except Exception:
|
||||
online = False
|
||||
with _lock:
|
||||
if len(_probe_cache) > 256:
|
||||
_probe_cache.clear()
|
||||
_probe_cache[url] = (now + PROBE_TTL, online)
|
||||
return online
|
||||
|
||||
|
||||
def _resolve(entry, override):
|
||||
"""Pick the Monitor address and its state for one discovered or manual entry."""
|
||||
if override.get("url"):
|
||||
return override["url"], "online" if _probe(override["url"]) else "offline"
|
||||
if entry.get("url"):
|
||||
return entry["url"], "online" if _probe(entry["url"]) else "offline"
|
||||
host = entry["host"]
|
||||
if not _is_private(host):
|
||||
return None, "unknown"
|
||||
for scheme in (_local_scheme(), "https" if _local_scheme() == "http" else "http"):
|
||||
url = _host_url(host, MONITOR_PORT, scheme)
|
||||
if _probe(url):
|
||||
return url, "online"
|
||||
return _host_url(host, MONITOR_PORT), "offline"
|
||||
|
||||
|
||||
def list_links(include_hidden=False):
|
||||
platform = pbs_manager.get_platform()
|
||||
data = _load()
|
||||
entries = []
|
||||
if platform.get("product") != "pbs":
|
||||
entries += _cluster_nodes() + _pbs_servers()
|
||||
for row in data["manual"]:
|
||||
entries.append({"key": f"manual:{row.get('id')}", "name": row.get("name") or row.get("url"),
|
||||
"product": row.get("product") if row.get("product") in PRODUCTS else "pve",
|
||||
"group": "pbs" if row.get("product") == "pbs" else "standalone",
|
||||
"url": row.get("url"), "manual": True})
|
||||
visible = [e for e in entries if include_hidden or not data["overrides"].get(e["key"], {}).get("hidden")]
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
resolved = list(executor.map(lambda e: _resolve(e, data["overrides"].get(e["key"], {})), visible))
|
||||
links = []
|
||||
for entry, (url, state) in zip(visible, resolved):
|
||||
override = data["overrides"].get(entry["key"], {})
|
||||
links.append({"key": entry["key"], "name": entry["name"], "product": entry["product"],
|
||||
"group": entry["group"], "url": url, "status": state,
|
||||
"native_url": entry.get("native_url"), "manual": bool(entry.get("manual")),
|
||||
"hidden": bool(override.get("hidden")), "custom_url": bool(override.get("url"))})
|
||||
return {"self": {"name": platform.get("hostname") or socket.gethostname(), "product": platform.get("product")},
|
||||
"links": links}
|
||||
|
||||
|
||||
def refresh():
|
||||
"""Recompute the list with fresh probes and keep it for the header."""
|
||||
with _refresh_lock:
|
||||
with _lock:
|
||||
_probe_cache.clear()
|
||||
data = list_links(include_hidden=True)
|
||||
_snapshot.update(at=time.monotonic(), data=data)
|
||||
return data
|
||||
|
||||
|
||||
def _refresh_quietly():
|
||||
try:
|
||||
refresh()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def cached_links(include_hidden=False):
|
||||
"""The last list at once; a stale one is renewed in the background."""
|
||||
data = _snapshot["data"]
|
||||
if data is None:
|
||||
data = refresh()
|
||||
elif time.monotonic() - _snapshot["at"] > PROBE_TTL and not _refresh_lock.locked():
|
||||
threading.Thread(target=_refresh_quietly, daemon=True, name="monitor-links-refresh").start()
|
||||
if include_hidden:
|
||||
return data
|
||||
return {**data, "links": [row for row in data["links"] if not row["hidden"]]}
|
||||
|
||||
|
||||
def start_prewarmer():
|
||||
def loop():
|
||||
time.sleep(10)
|
||||
while True:
|
||||
_refresh_quietly()
|
||||
time.sleep(PROBE_TTL)
|
||||
threading.Thread(target=loop, daemon=True, name="monitor-links-prewarmer").start()
|
||||
|
||||
|
||||
def add_link(payload):
|
||||
if not isinstance(payload, dict):
|
||||
raise LinkError("Invalid request.")
|
||||
name = str(payload.get("name") or "").strip()[:64]
|
||||
url = normalize_url(payload.get("url"))
|
||||
product = payload.get("product") if payload.get("product") in PRODUCTS else "pve"
|
||||
with _lock:
|
||||
data = _load()
|
||||
if len(data["manual"]) >= MAX_MANUAL:
|
||||
raise LinkError(f"At most {MAX_MANUAL} nodes can be added.")
|
||||
if any(row.get("url") == url for row in data["manual"]):
|
||||
raise LinkError("This address is already in the list.")
|
||||
identifier = secrets.token_hex(6)
|
||||
data["manual"].append({"id": identifier, "name": name or urllib.parse.urlsplit(url).hostname,
|
||||
"url": url, "product": product})
|
||||
_save(data)
|
||||
return f"manual:{identifier}"
|
||||
|
||||
|
||||
def update_link(key, payload):
|
||||
if not isinstance(payload, dict):
|
||||
raise LinkError("Invalid request.")
|
||||
with _lock:
|
||||
data = _load()
|
||||
if key.startswith("manual:"):
|
||||
row = next((r for r in data["manual"] if f"manual:{r.get('id')}" == key), None)
|
||||
if row is None:
|
||||
raise LinkError("Node not found.")
|
||||
if "name" in payload:
|
||||
row["name"] = str(payload["name"] or "").strip()[:64] or row["name"]
|
||||
if payload.get("url"):
|
||||
row["url"] = normalize_url(payload["url"])
|
||||
if payload.get("product") in PRODUCTS:
|
||||
row["product"] = payload["product"]
|
||||
elif re.fullmatch(r"(cluster|pbs):[\w.:\-\[\]]{1,253}", key):
|
||||
override = data["overrides"].setdefault(key, {})
|
||||
if "url" in payload:
|
||||
if payload["url"]:
|
||||
override["url"] = normalize_url(payload["url"])
|
||||
else:
|
||||
override.pop("url", None)
|
||||
if "hidden" in payload:
|
||||
override["hidden"] = bool(payload["hidden"])
|
||||
if not override:
|
||||
data["overrides"].pop(key, None)
|
||||
else:
|
||||
raise LinkError("Node not found.")
|
||||
_save(data)
|
||||
|
||||
|
||||
def delete_link(key):
|
||||
with _lock:
|
||||
data = _load()
|
||||
remaining = [r for r in data["manual"] if f"manual:{r.get('id')}" != key]
|
||||
if len(remaining) == len(data["manual"]):
|
||||
raise LinkError("Only nodes added by hand can be removed; detected ones can be hidden.")
|
||||
data["manual"] = remaining
|
||||
_save(data)
|
||||
@@ -26,18 +26,18 @@ class FwbrUdevRuleOrderTests(unittest.TestCase):
|
||||
def test_auto_post_install_uses_late_rule_and_removes_legacy_rule(self):
|
||||
source = network_optimization_block(AUTO_POST_INSTALL)
|
||||
self.assertIn('local FUNC_VERSION="1.2"', source)
|
||||
self.assertIn(f"rm -f /etc/udev/rules.d/{OLD_RULE}", source)
|
||||
self.assertIn(f"cat > /etc/udev/rules.d/{NEW_RULE}", source)
|
||||
self.assertIn(f"pmx_remove_file /etc/udev/rules.d/{OLD_RULE}", source)
|
||||
self.assertIn(f"pmx_write_file /etc/udev/rules.d/{NEW_RULE}", source)
|
||||
self.assertIn(f"chmod 0644 /etc/udev/rules.d/{NEW_RULE}", source)
|
||||
self.assertNotIn(f"cat > /etc/udev/rules.d/{OLD_RULE}", source)
|
||||
self.assertNotIn(f"pmx_write_file /etc/udev/rules.d/{OLD_RULE}", source)
|
||||
|
||||
def test_customizable_post_install_uses_late_rule_and_removes_legacy_rule(self):
|
||||
source = network_optimization_block(CUSTOM_POST_INSTALL)
|
||||
self.assertIn('local FUNC_VERSION="1.2"', source)
|
||||
self.assertIn(f"rm -f /etc/udev/rules.d/{OLD_RULE}", source)
|
||||
self.assertIn(f"cat > /etc/udev/rules.d/{NEW_RULE}", source)
|
||||
self.assertIn(f"pmx_remove_file /etc/udev/rules.d/{OLD_RULE}", source)
|
||||
self.assertIn(f"pmx_write_file /etc/udev/rules.d/{NEW_RULE}", source)
|
||||
self.assertIn(f"chmod 0644 /etc/udev/rules.d/{NEW_RULE}", source)
|
||||
self.assertNotIn(f"cat > /etc/udev/rules.d/{OLD_RULE}", source)
|
||||
self.assertNotIn(f"pmx_write_file /etc/udev/rules.d/{OLD_RULE}", source)
|
||||
|
||||
def test_uninstall_removes_new_and_legacy_rule_names(self):
|
||||
source = UNINSTALL_TOOLS.read_text()
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT / "AppImage/scripts"))
|
||||
|
||||
import monitor_links as links
|
||||
|
||||
|
||||
class MonitorLinksTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
storage = self.root / "storage.cfg"
|
||||
storage.write_text("dir: local\n\tpath /var/lib/vz\n\npbs: Lab\n\tdatastore store1\n\tserver 10.0.0.49\n\n"
|
||||
"pbs: Cloud\n\tdatastore x\n\tserver pbs.example.com\n\tport 8443\n")
|
||||
patches = [
|
||||
mock.patch.object(links, "LINKS_FILE", self.root / "links.json"),
|
||||
mock.patch.object(links, "SSL_CONFIG_FILE", self.root / "ssl.json"),
|
||||
mock.patch.object(links, "STORAGE_CFG", storage),
|
||||
mock.patch.object(links.pbs_manager, "get_platform", return_value={"product": "pve", "hostname": "amd"}),
|
||||
mock.patch.object(links.pbs_manager, "public_servers", return_value=[]),
|
||||
]
|
||||
for patch in patches:
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
links._probe_cache.clear()
|
||||
cluster = [{"type": "cluster", "name": "Casa"},
|
||||
{"type": "node", "name": "amd", "ip": "10.0.0.55", "local": 1},
|
||||
{"type": "node", "name": "constructor", "ip": "10.0.0.50", "local": 0}]
|
||||
self.run_patch = mock.patch.object(links.subprocess, "run",
|
||||
return_value=SimpleNamespace(returncode=0, stdout=json.dumps(cluster)))
|
||||
self.run_patch.start()
|
||||
self.addCleanup(self.run_patch.stop)
|
||||
|
||||
def test_normalize_url_keeps_only_the_origin_and_rejects_credentials(self):
|
||||
self.assertEqual(links.normalize_url("192.168.0.49:8008/dashboard?x=1"), "https://192.168.0.49:8008")
|
||||
self.assertEqual(links.normalize_url("http://pve.lan"), "http://pve.lan")
|
||||
for bad in ("", "ftp://host", "https://user:pass@host", "https://host:99999", "javascript:alert(1)"):
|
||||
with self.subTest(bad=bad), self.assertRaises(links.LinkError):
|
||||
links.normalize_url(bad)
|
||||
|
||||
def test_discovers_other_cluster_nodes_and_pbs_without_the_local_node(self):
|
||||
probed = []
|
||||
with mock.patch.object(links, "_probe", side_effect=lambda url: probed.append(url) or url.endswith("10.0.0.50:8008")), \
|
||||
mock.patch.object(links, "_is_private", side_effect=lambda host: host.startswith("10.")):
|
||||
result = links.list_links()
|
||||
by_key = {row["key"]: row for row in result["links"]}
|
||||
self.assertEqual(result["self"]["name"], "amd")
|
||||
self.assertNotIn("cluster:amd", by_key)
|
||||
self.assertEqual(by_key["cluster:constructor"]["status"], "online")
|
||||
self.assertEqual(by_key["pbs:10.0.0.49"]["native_url"], "https://10.0.0.49:8007")
|
||||
self.assertEqual(by_key["pbs:10.0.0.49"]["status"], "offline")
|
||||
# A public PBS is listed with its own interface but never probed unasked.
|
||||
self.assertEqual(by_key["pbs:pbs.example.com"]["status"], "unknown")
|
||||
self.assertEqual(by_key["pbs:pbs.example.com"]["native_url"], "https://pbs.example.com:8443")
|
||||
self.assertFalse(any("example.com" in url for url in probed))
|
||||
|
||||
def test_pbs_host_lists_only_manual_entries(self):
|
||||
links.pbs_manager.get_platform.return_value = {"product": "pbs", "hostname": "pbs-lab"}
|
||||
links.add_link({"name": "amd", "url": "https://10.0.0.55:8008", "product": "pve"})
|
||||
with mock.patch.object(links, "_probe", return_value=True):
|
||||
result = links.list_links()
|
||||
self.assertEqual([row["name"] for row in result["links"]], ["amd"])
|
||||
self.links_run_not_called()
|
||||
|
||||
def links_run_not_called(self):
|
||||
self.assertFalse(any(call.args and call.args[0][:1] == ["pvesh"] for call in links.subprocess.run.call_args_list))
|
||||
|
||||
def test_manual_entries_overrides_and_hidden_detected_nodes(self):
|
||||
key = links.add_link({"name": "lab", "url": "10.0.0.46:8008", "product": "pve"})
|
||||
with self.assertRaises(links.LinkError):
|
||||
links.add_link({"url": "https://10.0.0.46:8008"})
|
||||
links.update_link("cluster:constructor", {"url": "https://constructor.lan:8008"})
|
||||
links.update_link("pbs:10.0.0.49", {"hidden": True})
|
||||
with mock.patch.object(links, "_probe", return_value=False), \
|
||||
mock.patch.object(links, "_is_private", return_value=True):
|
||||
visible = {row["key"]: row for row in links.list_links()["links"]}
|
||||
everything = {row["key"]: row for row in links.list_links(include_hidden=True)["links"]}
|
||||
self.assertEqual(visible[key]["url"], "https://10.0.0.46:8008")
|
||||
self.assertEqual(visible["cluster:constructor"]["url"], "https://constructor.lan:8008")
|
||||
self.assertNotIn("pbs:10.0.0.49", visible)
|
||||
self.assertTrue(everything["pbs:10.0.0.49"]["hidden"])
|
||||
with self.assertRaises(links.LinkError):
|
||||
links.delete_link("cluster:constructor")
|
||||
links.delete_link(key)
|
||||
self.assertEqual(json.loads((self.root / "links.json").read_text())["manual"], [])
|
||||
self.assertEqual((self.root / "links.json").stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_cached_list_is_served_without_new_probes_and_renewed_when_stale(self):
|
||||
links._snapshot.update(at=0.0, data=None)
|
||||
self.addCleanup(links._snapshot.update, at=0.0, data=None)
|
||||
links.update_link("pbs:10.0.0.49", {"hidden": True})
|
||||
with mock.patch.object(links, "_probe", return_value=True) as probe, \
|
||||
mock.patch.object(links, "_is_private", return_value=True):
|
||||
first = links.cached_links()
|
||||
calls = probe.call_count
|
||||
again = links.cached_links()
|
||||
self.assertEqual(probe.call_count, calls)
|
||||
self.assertEqual(first, again)
|
||||
self.assertNotIn("pbs:10.0.0.49", [row["key"] for row in first["links"]])
|
||||
self.assertIn("pbs:10.0.0.49", [row["key"] for row in links.cached_links(include_hidden=True)["links"]])
|
||||
links._snapshot["at"] -= links.PROBE_TTL + 1
|
||||
with mock.patch.object(links.threading, "Thread") as thread:
|
||||
stale = links.cached_links()
|
||||
self.assertEqual(stale, first)
|
||||
thread.assert_called_once()
|
||||
|
||||
def test_unknown_keys_are_rejected(self):
|
||||
for key in ("other:x", "cluster:../../etc", "manual:nope"):
|
||||
with self.subTest(key=key), self.assertRaises(links.LinkError):
|
||||
links.update_link(key, {"hidden": True})
|
||||
|
||||
def test_probe_sends_no_credentials_and_needs_a_monitor_answer(self):
|
||||
seen = []
|
||||
|
||||
class Response:
|
||||
status = 200
|
||||
|
||||
def __init__(self, body):
|
||||
self.body = body
|
||||
|
||||
def read(self, _size):
|
||||
return self.body
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
return False
|
||||
|
||||
def urlopen(request, timeout, context):
|
||||
seen.append(request)
|
||||
return Response(b'{"auth_enabled": true}' if "monitor" in request.full_url else b"<html>")
|
||||
|
||||
with mock.patch.object(links.urllib.request, "urlopen", side_effect=urlopen):
|
||||
self.assertTrue(links._probe("https://monitor:8008"))
|
||||
self.assertFalse(links._probe("https://other:8008"))
|
||||
self.assertTrue(all(request.full_url.endswith("/api/auth/status") for request in seen))
|
||||
self.assertTrue(all("Authorization" not in request.headers for request in seen))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -37,6 +37,7 @@
|
||||
"/var/lib/vz/dump (Proxmox default vzdump path)": "/var/lib/vz/dump (Proxmox-Standard-vzdump-Pfad)",
|
||||
"/var/lib/vz/dump (Proxmox default)": "/var/lib/vz/dump (Proxmox-Standard)",
|
||||
"1777 = sticky bit + rwx for all. No shared group needed.": "1777 = Sticky Bit + RWX für alle. Keine gemeinsame Gruppe erforderlich.",
|
||||
"====== PBS UPDATE COMPLETED ======": "====== PBS-UPDATE ABGESCHLOSSEN ======",
|
||||
"====== PVE UPDATE COMPLETED ======": "====== PVE-UPDATE ABGESCHLOSSEN ======",
|
||||
">> Create a new backup job <<": ">> Einen neuen Sicherungsauftrag erstellen <<",
|
||||
">> Save and return <<": ">> Speichern und zurück <<",
|
||||
@@ -87,6 +88,7 @@
|
||||
"A multi-container application is not recreated: its containers are updated together.": "Eine Multi-Container-Anwendung wird nicht neu erstellt: Ihre Container werden zusammen aktualisiert.",
|
||||
"A new ProxMenux version is available:": "Eine neue ProxMenux-Version ist verfügbar:",
|
||||
"A new kernel is staged for the next boot:": "Ein neuer Kernel wird für den nächsten Start bereitgestellt:",
|
||||
"A newer kernel is installed. Reboot before the next backup window to use it.": "Ein neuerer Kernel ist installiert. Starten Sie vor dem nächsten Backup-Fenster einen Neustart, um es zu verwenden.",
|
||||
"A newer version is available:": "Eine neuere Version ist verfügbar:",
|
||||
"A pending operation exists for": "Eine ausstehende operation existiert für",
|
||||
"A pending stack assembly already exists; it is not overwritten": "Eine ausstehende Stapelanordnung existiert bereits; sie wird nicht überschrieben",
|
||||
@@ -169,6 +171,7 @@
|
||||
"APT language configuration updated": "APT-Sprachkonfiguration aktualisiert",
|
||||
"APT language downloads restored": "APT-Sprachdownloads wiederhergestellt",
|
||||
"APT package lists updated": "APT-Paketlisten aktualisiert",
|
||||
"APT refresh failed. Check the PBS subscription and repositories in Administration > Repositories. Log:": "APT-Aktualisierung fehlgeschlagen. Überprüfen Sie das PBS-Abonnement und die Repositorys unter Administration > Repositorys. Protokoll:",
|
||||
"APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "APT-Aktualisierung fehlgeschlagen. Prüfen Sie das PBS-Abonnement und die Repositories in der nativen PBS-Oberfläche; es wurden keine Paketquellen geändert.",
|
||||
"APT update failed. Check repository access; NVIDIA installation stopped.": "APT-Update fehlgeschlagen. Überprüfen Sie den Repository-Zugriff. Die Installation von NVIDIA wurde gestoppt.",
|
||||
"Aborted": "Abgebrochen",
|
||||
@@ -538,6 +541,7 @@
|
||||
"Available Borg targets:": "Verfügbare Borg-Ziele:",
|
||||
"Available Disks on Host": "Verfügbare Festplatten auf dem Host",
|
||||
"Available ISO Images": "Verfügbare ISO-Images",
|
||||
"Available PBS Version": "Verfügbare PBS-Version",
|
||||
"Available PBS repositories:": "Verfügbare PBS-Repositories:",
|
||||
"Available PVE Version": "Verfügbare PVE-Version",
|
||||
"Available Shares:": "Verfügbare Aktien:",
|
||||
@@ -1520,6 +1524,7 @@
|
||||
"Current NVIDIA inventory resolved: a refresh is required": "Aktuelles NVIDIA-Inventar behoben: ein Refresh ist required",
|
||||
"Current NVIDIA inventory resolved: no refresh is required": "Aktuelles NVIDIA-Inventar behoben: kein Refresh ist required",
|
||||
"Current Network Configuration": "Aktuelle Netzwerkkonfiguration",
|
||||
"Current PBS Version": "Aktuelle PBS-Version",
|
||||
"Current PVE Version": "Aktuelle PVE-Version",
|
||||
"Current ProxMenux host scripts register remote shares as Proxmox storages using pvesm.": "Aktuelle ProxMenux-Hostskripte registrieren Remote-Freigaben mithilfe von pvesm als Proxmox-Speicher.",
|
||||
"Current ProxMenux version:": "Aktuelle ProxMenux-Version:",
|
||||
@@ -1685,6 +1690,7 @@
|
||||
"Detected Coral hardware:": "Erkannte Coral-Hardware:",
|
||||
"Detected GPU(s):": "Erkannte GPU(s):",
|
||||
"Detected Intel CPU": "Intel-CPU erkannt",
|
||||
"Detected Proxmox Backup Server": "Proxmox Backup Server erkannt",
|
||||
"Detected Proxmox VE": "Proxmox VE erkannt",
|
||||
"Detected Proxmox VE 8.x - Installing Ceph Squid for Debian": "Proxmox VE 8.x erkannt – Ceph Squid für Debian installieren",
|
||||
"Detected Proxmox VE 9.x - Installing Ceph Squid for Debian Trixie": "Proxmox VE 9.x erkannt – Ceph Squid für Debian Trixie installieren",
|
||||
@@ -1845,6 +1851,7 @@
|
||||
"Do you want to proceed with the system update?": "Möchten Sie mit der Systemaktualisierung fortfahren?",
|
||||
"Do you want to proceed?": "Möchten Sie fortfahren?",
|
||||
"Do you want to reboot now?": "Möchten Sie jetzt neu starten?",
|
||||
"Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "Möchten Sie das Proxmox-Abonnementbanner von der Proxmox Backup Server-Weboberfläche entfernen?",
|
||||
"Do you want to remove the stale entry from vfio.conf and continue?": "Möchten Sie den veralteten Eintrag aus vfio.conf entfernen und fortfahren?",
|
||||
"Do you want to remove the stale entry from vfio.conf?": "Möchten Sie den veralteten Eintrag aus vfio.conf entfernen?",
|
||||
"Do you want to restart the network service now to apply changes?": "Möchten Sie den Netzwerkdienst jetzt neu starten, um die Änderungen zu übernehmen?",
|
||||
@@ -2270,6 +2277,7 @@
|
||||
"Failed to clone log2ram repository. Check /tmp/log2ram_install.log": "Das Klonen des log2ram-Repositorys ist fehlgeschlagen. Überprüfen Sie /tmp/log2ram_install.log",
|
||||
"Failed to configure EFI disk": "Die EFI-Festplatte konnte nicht konfiguriert werden",
|
||||
"Failed to configure IOMMU automatically.": "IOMMU konnte nicht automatisch konfiguriert werden.",
|
||||
"Failed to configure Proxmox Backup Server repositories": "Proxmox Backup Server-Repositorys konnten nicht konfiguriert werden",
|
||||
"Failed to configure Proxmox repositories": "Proxmox-Repositorys konnten nicht konfiguriert werden",
|
||||
"Failed to configure TPM device in VM": "Das TPM-Gerät konnte in der VM nicht konfiguriert werden",
|
||||
"Failed to configure repositories.": "Die Repositorys konnten nicht konfiguriert werden.",
|
||||
@@ -4157,6 +4165,7 @@
|
||||
"No active Proxmox storage accepts OCI template archives.": "Kein aktiver Proxmox-Speicher akzeptiert OCI-Vorlagenarchive.",
|
||||
"No active exports or showmount not available": "Keine aktiven Exporte oder Showmount nicht verfügbar",
|
||||
"No active session": "Keine aktive Sitzung",
|
||||
"No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.": "Kein aktives Abonnement und kein verwendbares PBS-Repository. Im nicht interaktiven Modus können APT-Quellen nicht geändert werden. Konfigurieren Sie sie unter Administration > Repositorys.",
|
||||
"No active subscription and no usable PVE repository. Noninteractive mode cannot change APT sources; configure them in Node > Updates > Repositories.": "Kein aktives Abonnement und kein nutzbares PVE-Repository. Im nicht interaktiven Modus können APT-Quellen nicht geändert werden. Konfigurieren Sie sie unter Knoten > Updates > Repositorys.",
|
||||
"No additional GPU can be added.": "Es kann keine zusätzliche GPU hinzugefügt werden.",
|
||||
"No additional device needs to be added.": "Es muss kein zusätzliches Gerät hinzugefügt werden.",
|
||||
@@ -4874,7 +4883,11 @@
|
||||
"Proxmology logo applied": "Proxmology-Logo angewendet",
|
||||
"Proxmox 9 system update allready": "Proxmox 9 Systemupdate bereits",
|
||||
"Proxmox APT repositories configured": "Proxmox APT-Repositorys konfiguriert",
|
||||
"Proxmox Backup Server": "Proxmox Backup Server",
|
||||
"Proxmox Backup Server (PBS) destinations": "Proxmox Backup Server (PBS)-Ziele",
|
||||
"Proxmox Backup Server APT repositories configured": "Proxmox Backup Server APT-Repositorys konfiguriert",
|
||||
"Proxmox Backup Server Update": "Aktualisierung von Proxmox Backup Server",
|
||||
"Proxmox Backup Server update completed.": "Aktualisierung von Proxmox Backup Server abgeschlossen.",
|
||||
"Proxmox CIFS Storage Status:": "Proxmox CIFS-Speicherstatus:",
|
||||
"Proxmox NFS Storage Status:": "Proxmox NFS-Speicherstatus:",
|
||||
"Proxmox System Update": "Proxmox-Systemupdate",
|
||||
@@ -5234,6 +5247,7 @@
|
||||
"Repository helper unavailable. NVIDIA installation stopped.": "Repository-Helfer nicht verfügbar. Die Installation von NVIDIA wurde gestoppt.",
|
||||
"Repository policy returned an unexpected result.": "Die Repository-Richtlinie hat ein unerwartetes Ergebnis zurückgegeben.",
|
||||
"Repository ready.": "Repository bereit.",
|
||||
"Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Administration > Repositories and retry apt-get update before continuing.": "Repository-Quellen geändert, aber die Aktualisierung der APT-Paketliste ist fehlgeschlagen. Betrieb gestoppt;Quellen wurden nicht zurückgesetzt. Überprüfen Sie „Administration > Repositorys“ und wiederholen Sie apt-get update, bevor Sie fortfahren.",
|
||||
"Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Node > Updates > Repositories and retry apt-get update before continuing.": "Die Repository-Quellen wurden geändert, aber die Aktualisierung der APT-Paketliste ist fehlgeschlagen. Vorgang gestoppt; die Quellen wurden nicht zurückgesetzt. Überprüfen Sie Knoten > Updates > Repositorys und wiederholen Sie apt-get update, bevor Sie fortfahren.",
|
||||
"Repository switch declined; no APT source changed.": "Repository-Wechsel abgelehnt; es wurde keine APT-Quelle geändert.",
|
||||
"Repository:": "Repository:",
|
||||
@@ -6783,6 +6797,7 @@
|
||||
"This CIFS share is mounted with restrictive permissions.": "Diese CIFS-Freigabe wird mit restriktiven Berechtigungen gemountet.",
|
||||
"This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Diese GPU gilt als inkompatibel mit GPU-Passthrough zu einer VM in ProxMenux.",
|
||||
"This NFS share is fully restricted — even the host root cannot write to it.": "Diese NFS-Freigabe ist vollständig eingeschränkt – selbst der Host-Root kann nicht darauf schreiben.",
|
||||
"This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Dieser Proxmox Backup Server hat kein aktives Abonnement. Zum Repository ohne Abonnement wechseln? Die nicht erreichbare Enterprise-PBS-Quelle wird deaktiviert.",
|
||||
"This VM requires extra installation steps, see install guide at:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144": "Für diese VM sind zusätzliche Installationsschritte erforderlich. Weitere Informationen finden Sie im Installationshandbuch unter:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144",
|
||||
"This account is for {registry}. Enter an image from that registry.": "Dieses Konto ist für {registry}. Geben Sie ein Bild aus dieser Registrierung ein.",
|
||||
"This action will:": "Diese Aktion wird:",
|
||||
@@ -6866,6 +6881,7 @@
|
||||
"This script cannot be executed from the Proxmox web terminal.": "Dieses Skript kann nicht über das Proxmox-Webterminal ausgeführt werden.",
|
||||
"This script must be run as root.": "Dieses Skript muss als Root ausgeführt werden.",
|
||||
"This script must be run on a Proxmox host.": "Dieses Skript muss auf einem Proxmox-Host ausgeführt werden.",
|
||||
"This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Dieses Skript wendet die folgenden Optimierungen und erweiterten Anpassungen auf Ihren Proxmox Backup Server an",
|
||||
"This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Dieses Skript wendet die folgenden Optimierungen und erweiterten Anpassungen auf Ihren Proxmox VE-Server an",
|
||||
"This script will update your Proxmox VE system with advanced options:": "Dieses Skript aktualisiert Ihr Proxmox VE-System mit erweiterten Optionen:",
|
||||
"This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Diese Sitzung wird im Monitor-Terminal ausgeführt. Wenn Sie es von hier aus ausführen, wird die Verbindung während der Installation unterbrochen und der Switch bleibt in einem defekten Zustand.",
|
||||
@@ -7192,6 +7208,7 @@
|
||||
"Upgrade assistant: Proxmox VE 8 → 9 (Trixie)": "Upgrade-Assistent: Proxmox VE 8 → 9 (Trixie)",
|
||||
"Upgrade canceled by user": "Upgrade vom Benutzer abgebrochen",
|
||||
"Upgrade one node at a time": "Aktualisieren Sie jeweils einen Knoten",
|
||||
"Upgrade the system, offering the": "Aktualisieren Sie das System und bieten Sie das an",
|
||||
"Upgrade to Proxmox VE 9": "Upgrade auf Proxmox VE 9",
|
||||
"Upgrading NVIDIA utils (Arch)...": "NVIDIA-Dienstprogramme werden aktualisiert (Arch)...",
|
||||
"Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "Eine verschlüsselte Kopie des Schlüssels auf PBS hochladen, damit Sie ihn auf einem neu installierten Host mit nur einer Passphrase wiederherstellen können?",
|
||||
@@ -8111,6 +8128,7 @@
|
||||
"was": "war",
|
||||
"was installed, but the kernel reports:": "wurde installiert, aber der Kernel meldet:",
|
||||
"when finished": "wenn fertig",
|
||||
"when the server has no subscription (you will be asked)": "Wenn der Server kein Abonnement hat (Sie werden gefragt)",
|
||||
"will rebind the GPU to vfio-pci on the next reboot, breaking the driver that is about to be installed.": "Bindet die GPU beim nächsten Neustart erneut an vfio-pci, wodurch der zu installierende Treiber beschädigt wird.",
|
||||
"wipefs failed on": "Wipefs sind fehlgeschlagen",
|
||||
"with": "mit",
|
||||
|
||||
+19
-1
@@ -37,6 +37,7 @@
|
||||
"/var/lib/vz/dump (Proxmox default vzdump path)": "/var/lib/vz/dump (ruta por defecto de vzdump)",
|
||||
"/var/lib/vz/dump (Proxmox default)": "/var/lib/vz/dump (por defecto en Proxmox)",
|
||||
"1777 = sticky bit + rwx for all. No shared group needed.": "1777 = sticky bit + rwx para todos. No se necesita un grupo compartido.",
|
||||
"====== PBS UPDATE COMPLETED ======": "====== ACTUALIZACIÓN PBS COMPLETADA ======",
|
||||
"====== PVE UPDATE COMPLETED ======": "====== ACTUALIZACIÓN PVE COMPLETADA ======",
|
||||
">> Create a new backup job <<": ">> Crear un trabajo de backup nuevo <<",
|
||||
">> Save and return <<": ">> Guardar y volver <<",
|
||||
@@ -86,6 +87,7 @@
|
||||
"A modern wiki and knowledge base for teams": "Una wiki y base de conocimiento moderna para equipos",
|
||||
"A new ProxMenux version is available:": "Una nueva versión de ProxMenux está disponible:",
|
||||
"A new kernel is staged for the next boot:": "Se prepara un nuevo kernel para el siguiente arranque:",
|
||||
"A newer kernel is installed. Reboot before the next backup window to use it.": "Hay un kernel más reciente instalado. Reinicie antes de la próxima ventana de backups para usarlo.",
|
||||
"A newer version is available:": "Hay una versión más nueva disponible:",
|
||||
"A pending operation exists for": "Existe una operación pendiente",
|
||||
"A pending stack assembly already exists; it is not overwritten": "Ya existe un ensamblado de pila pendiente; no se sobrescribe",
|
||||
@@ -168,6 +170,7 @@
|
||||
"APT language configuration updated": "Configuración del idioma APT actualizada",
|
||||
"APT language downloads restored": "Descargas del idioma APT restauradas",
|
||||
"APT package lists updated": "Listas de paquetes APT actualizadas",
|
||||
"APT refresh failed. Check the PBS subscription and repositories in Administration > Repositories. Log:": "La actualización de APT ha fallado. Revise la suscripción y los repositorios de PBS en Administration > Repositories. Log:",
|
||||
"APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "No se pudieron actualizar los índices de APT. Comprueba la suscripción y los repositorios de PBS en su interfaz nativa; no se ha cambiado ninguna fuente.",
|
||||
"APT update failed. Check repository access; NVIDIA installation stopped.": "La actualización de APT ha fallado. Verifica el acceso a los repositorios; la instalación de NVIDIA se ha detenido.",
|
||||
"Aborted": "Abortado",
|
||||
@@ -536,6 +539,7 @@
|
||||
"Available Borg targets:": "Destinos Borg disponibles:",
|
||||
"Available Disks on Host": "Discos disponibles en el host",
|
||||
"Available ISO Images": "Imágenes ISO disponibles",
|
||||
"Available PBS Version": "Versión PBS disponible",
|
||||
"Available PBS repositories:": "Repositorios de PBS disponibles:",
|
||||
"Available PVE Version": "Versión PVE disponible",
|
||||
"Available Shares:": "Recursos compartidos disponibles:",
|
||||
@@ -1518,6 +1522,7 @@
|
||||
"Current NVIDIA inventory resolved: a refresh is required": "Actual inventario NVIDIA resuelto: un refresco es necesario",
|
||||
"Current NVIDIA inventory resolved: no refresh is required": "Inventario NVIDIA actual resuelto: ningún refresco es necesario",
|
||||
"Current Network Configuration": "Configuración de red actual",
|
||||
"Current PBS Version": "Versión PBS actual",
|
||||
"Current PVE Version": "Versión PVE actual",
|
||||
"Current ProxMenux host scripts register remote shares as Proxmox storages using pvesm.": "Los scripts de host ProxMenux actuales registran recursos compartidos remotos como almacenamientos en Proxmox utilizando pvesm.",
|
||||
"Current ProxMenux version:": "Versión actual de ProxMenux:",
|
||||
@@ -1683,6 +1688,7 @@
|
||||
"Detected Coral hardware:": "Hardware Coral detectado:",
|
||||
"Detected GPU(s):": "GPU detectadas:",
|
||||
"Detected Intel CPU": "CPU Intel detectada",
|
||||
"Detected Proxmox Backup Server": "Proxmox Backup Server detectado",
|
||||
"Detected Proxmox VE": "Proxmox VE detectado",
|
||||
"Detected Proxmox VE 8.x - Installing Ceph Squid for Debian": "Proxmox VE 8.x detectado: instalación de Ceph Squid para Debian",
|
||||
"Detected Proxmox VE 9.x - Installing Ceph Squid for Debian Trixie": "Detectado Proxmox VE 9.x - Instalación de Ceph Squid para Debian Trixie",
|
||||
@@ -1843,6 +1849,7 @@
|
||||
"Do you want to proceed with the system update?": "¿Quieres continuar con la actualización del sistema?",
|
||||
"Do you want to proceed?": "¿Quieres continuar?",
|
||||
"Do you want to reboot now?": "¿Quieres reiniciar ahora?",
|
||||
"Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "¿Desea eliminar el banner de suscripción de Proxmox de la interfaz web de Proxmox Backup Server?",
|
||||
"Do you want to remove the stale entry from vfio.conf and continue?": "¿Quiere eliminar la entrada obsoleta de vfio.conf y continuar?",
|
||||
"Do you want to remove the stale entry from vfio.conf?": "¿Quiere eliminar la entrada obsoleta de vfio.conf?",
|
||||
"Do you want to restart the network service now to apply changes?": "¿Quieres reiniciar el servicio de red ahora para aplicar los cambios?",
|
||||
@@ -2267,6 +2274,7 @@
|
||||
"Failed to clone log2ram repository. Check /tmp/log2ram_install.log": "No se pudo clonar el repositorio log2ram. Compruebe /tmp/log2ram_install.log",
|
||||
"Failed to configure EFI disk": "No se pudo configurar el disco EFI",
|
||||
"Failed to configure IOMMU automatically.": "No se pudo configurar IOMMU automáticamente.",
|
||||
"Failed to configure Proxmox Backup Server repositories": "No se han podido configurar los repositorios de Proxmox Backup Server",
|
||||
"Failed to configure Proxmox repositories": "No se pudieron configurar los repositorios de Proxmox",
|
||||
"Failed to configure TPM device in VM": "No se pudo configurar el dispositivo TPM en VM",
|
||||
"Failed to configure repositories.": "No se pudieron configurar los repositorios.",
|
||||
@@ -4152,6 +4160,7 @@
|
||||
"No active Proxmox storage accepts OCI template archives.": "Ningún almacenamiento activo de Proxmox acepta archivos de plantilla OCI.",
|
||||
"No active exports or showmount not available": "No hay exportaciones activas o showmount no disponible",
|
||||
"No active session": "Ninguna sesión activa",
|
||||
"No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.": "No hay suscripción activa ni un repositorio de PBS utilizable. El modo no interactivo no puede cambiar las fuentes de APT; configúrelas en Administration > Repositories.",
|
||||
"No active subscription and no usable PVE repository. Noninteractive mode cannot change APT sources; configure them in Node > Updates > Repositories.": "No hay suscripción activa ni un repositorio de PVE utilizable. El modo no interactivo no puede cambiar las fuentes de APT; configúralas en Nodo > Actualizaciones > Repositorios.",
|
||||
"No additional GPU can be added.": "No se puede agregar ninguna GPU adicional.",
|
||||
"No additional device needs to be added.": "No es necesario agregar ningún dispositivo adicional.",
|
||||
@@ -4525,7 +4534,7 @@
|
||||
"PBS extraction failed": "Error en la extracción de PBS",
|
||||
"PBS host or IP address:": "Host de PBS o dirección IP:",
|
||||
"PBS package update": "Actualización de paquetes de PBS",
|
||||
"PBS package upgrade completed. Check whether a reboot is required before your next backup window.": "Actualización de paquetes de PBS completada. Comprueba si es necesario reiniciar antes del próximo período de copias de seguridad.",
|
||||
"PBS package upgrade completed. Check whether a reboot is required before your next backup window.": "Actualización de paquetes de PBS completada. Comprueba si hace falta reiniciar antes de la próxima ventana de backups.",
|
||||
"PBS package upgrade failed. Review the APT output before retrying.": "La actualización de paquetes de PBS ha fallado. Revisa la salida de APT antes de volver a intentarlo.",
|
||||
"PBS upload": "Subida a PBS",
|
||||
"PBS version and packages": "Versión y paquetes de PBS",
|
||||
@@ -4865,7 +4874,11 @@
|
||||
"Proxmology logo applied": "Logotipo de Proxmology aplicado",
|
||||
"Proxmox 9 system update allready": "Actualización del sistema Proxmox 9 ya",
|
||||
"Proxmox APT repositories configured": "Repositorios Proxmox APT configurados",
|
||||
"Proxmox Backup Server": "Proxmox Backup Server",
|
||||
"Proxmox Backup Server (PBS) destinations": "Destinos de Proxmox Backup Server (PBS)",
|
||||
"Proxmox Backup Server APT repositories configured": "Repositorios APT de Proxmox Backup Server configurados",
|
||||
"Proxmox Backup Server Update": "Actualización de Proxmox Backup Server",
|
||||
"Proxmox Backup Server update completed.": "Actualización de Proxmox Backup Server completada.",
|
||||
"Proxmox CIFS Storage Status:": "Estado de almacenamiento CIFS de Proxmox:",
|
||||
"Proxmox NFS Storage Status:": "Estado de almacenamiento NFS de Proxmox:",
|
||||
"Proxmox System Update": "Actualización del sistema Proxmox",
|
||||
@@ -5218,6 +5231,7 @@
|
||||
"Repository helper unavailable. NVIDIA installation stopped.": "El asistente de repositorios no está disponible. La instalación de NVIDIA se ha detenido.",
|
||||
"Repository policy returned an unexpected result.": "La política de repositorios ha devuelto un resultado inesperado.",
|
||||
"Repository ready.": "Repositorio listo.",
|
||||
"Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Administration > Repositories and retry apt-get update before continuing.": "Las fuentes de repositorio han cambiado, pero la actualización de la lista de paquetes de APT ha fallado. Operación detenida; las fuentes no se han revertido. Revise Administration > Repositories y vuelva a ejecutar apt-get update antes de continuar.",
|
||||
"Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Node > Updates > Repositories and retry apt-get update before continuing.": "Las fuentes de los repositorios han cambiado, pero la actualización de la lista de paquetes de APT ha fallado. La operación se ha detenido; las fuentes no se han revertido. Revisa Nodo > Actualizaciones > Repositorios y vuelve a ejecutar apt-get update antes de continuar.",
|
||||
"Repository switch declined; no APT source changed.": "Cambio de repositorio rechazado; no se ha modificado ninguna fuente de APT.",
|
||||
"Repository:": "Repositorio:",
|
||||
@@ -6758,6 +6772,7 @@
|
||||
"This CIFS share is mounted with restrictive permissions.": "Este recurso compartido CIFS está montado con permisos restrictivos.",
|
||||
"This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Esta GPU se considera incompatible con el passthrough de GPU a una VM en ProxMenux.",
|
||||
"This NFS share is fully restricted — even the host root cannot write to it.": "Este recurso compartido NFS está completamente restringido: ni siquiera la raíz del host puede escribir en él.",
|
||||
"This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Este Proxmox Backup Server no tiene una suscripción activa, ¿cambiar al repositorio sin suscripción? Se deshabilitará la fuente Enterprise de PBS, que no es accesible.",
|
||||
"This VM requires extra installation steps, see install guide at:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144": "Esta máquina virtual requiere pasos de instalación adicionales; consulte la guía de instalación en:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144",
|
||||
"This account is for {registry}. Enter an image from that registry.": "Esta cuenta es de {registry}. Escribe una imagen de ese registro.",
|
||||
"This action will:": "Esta acción:",
|
||||
@@ -6841,6 +6856,7 @@
|
||||
"This script cannot be executed from the Proxmox web terminal.": "Este script no se puede ejecutar desde la terminal web de Proxmox.",
|
||||
"This script must be run as root.": "Este script debe ejecutarse como root.",
|
||||
"This script must be run on a Proxmox host.": "Este script debe ejecutarse en un host Proxmox.",
|
||||
"This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Este script aplicará las siguientes optimizaciones y ajustes avanzados a su Proxmox Backup Server",
|
||||
"This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Este script aplicará las siguientes optimizaciones y ajustes avanzados a su servidor Proxmox VE",
|
||||
"This script will update your Proxmox VE system with advanced options:": "Este script actualizará su sistema Proxmox VE con opciones avanzadas:",
|
||||
"This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Esta sesión se ejecuta en el terminal del Monitor. Ejecutarlo desde aquí cortaría la conexión a mitad de la instalación y dejaría el cambio en un estado roto.",
|
||||
@@ -7165,6 +7181,7 @@
|
||||
"Upgrade assistant: Proxmox VE 8 → 9 (Trixie)": "Asistente de actualización: Proxmox VE 8 → 9 (Trixie)",
|
||||
"Upgrade canceled by user": "Actualización cancelada por el usuario",
|
||||
"Upgrade one node at a time": "Actualice un nodo a la vez",
|
||||
"Upgrade the system, offering the": "Actualizar el sistema, ofreciendo el",
|
||||
"Upgrade to Proxmox VE 9": "Actualice a Proxmox VE 9",
|
||||
"Upgrading NVIDIA utils (Arch)...": "Actualizando las utilidades de NVIDIA (Arch)...",
|
||||
"Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "¿Cargar una copia cifrada de la clave a PBS para poder recuperarla en un host reinstalado con solo una frase de contraseña?",
|
||||
@@ -8084,6 +8101,7 @@
|
||||
"was": "era",
|
||||
"was installed, but the kernel reports:": "se instaló, pero el kernel informa:",
|
||||
"when finished": "al terminar",
|
||||
"when the server has no subscription (you will be asked)": "cuando el servidor no tiene suscripción (se le preguntará)",
|
||||
"will rebind the GPU to vfio-pci on the next reboot, breaking the driver that is about to be installed.": "volverá a vincular la GPU a vfio-pci en el próximo reinicio, rompiendo el controlador que está a punto de instalarse.",
|
||||
"wipefs failed on": "los borrados fallaron",
|
||||
"with": "con",
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"/var/lib/vz/dump (Proxmox default vzdump path)": "/var/lib/vz/dump (chemin vzdump par défaut de Proxmox)",
|
||||
"/var/lib/vz/dump (Proxmox default)": "/var/lib/vz/dump (Proxmox par défaut)",
|
||||
"1777 = sticky bit + rwx for all. No shared group needed.": "1777 = bit collant + rwx pour tous. Aucun groupe partagé n’est nécessaire.",
|
||||
"====== PBS UPDATE COMPLETED ======": "====== MISE À JOUR PBS TERMINÉE ======",
|
||||
"====== PVE UPDATE COMPLETED ======": "====== MISE À JOUR PVE TERMINÉE ======",
|
||||
">> Create a new backup job <<": ">> Créer une nouvelle tâche de sauvegarde <<",
|
||||
">> Save and return <<": ">> Enregistrer et retourner <<",
|
||||
@@ -87,6 +88,7 @@
|
||||
"A multi-container application is not recreated: its containers are updated together.": "Une application multi-conteneurs n'est pas recréée : ses conteneurs sont mis à jour ensemble.",
|
||||
"A new ProxMenux version is available:": "Une nouvelle version de ProxMenux est disponible :",
|
||||
"A new kernel is staged for the next boot:": "Un nouveau noyau est préparé pour le prochain démarrage :",
|
||||
"A newer kernel is installed. Reboot before the next backup window to use it.": "Un noyau plus récent est installé. Redémarrez avant la prochaine fenêtre de sauvegarde pour l'utiliser.",
|
||||
"A newer version is available:": "Une version plus récente est disponible :",
|
||||
"A pending operation exists for": "Un opera en attente existe pour",
|
||||
"A pending stack assembly already exists; it is not overwritten": "Une pile en attente existe déjà; elle n'est pas écrasée",
|
||||
@@ -169,6 +171,7 @@
|
||||
"APT language configuration updated": "Configuration du langage APT mise à jour",
|
||||
"APT language downloads restored": "Téléchargements de langues APT restaurés",
|
||||
"APT package lists updated": "Listes de packages APT mises à jour",
|
||||
"APT refresh failed. Check the PBS subscription and repositories in Administration > Repositories. Log:": "L’actualisation APT a échoué. Vérifiez l'abonnement PBS et les référentiels dans Administration > Référentiels. Enregistrer:",
|
||||
"APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "Échec de l'actualisation APT. Vérifiez l'abonnement et les dépôts de PBS dans son interface native ; aucune source n'a été modifiée.",
|
||||
"APT update failed. Check repository access; NVIDIA installation stopped.": "La mise à jour APT a échoué. Vérifiez l'accès au référentiel ; l'installation de NVIDIA s'est arrêtée.",
|
||||
"Aborted": "Avorté",
|
||||
@@ -538,6 +541,7 @@
|
||||
"Available Borg targets:": "Cibles Borg disponibles :",
|
||||
"Available Disks on Host": "Disques disponibles sur l'hôte",
|
||||
"Available ISO Images": "Images ISO disponibles",
|
||||
"Available PBS Version": "version PBS disponible",
|
||||
"Available PBS repositories:": "Dépôts PBS disponibles :",
|
||||
"Available PVE Version": "Version PVE disponible",
|
||||
"Available Shares:": "Actions disponibles :",
|
||||
@@ -1520,6 +1524,7 @@
|
||||
"Current NVIDIA inventory resolved: a refresh is required": "L'inventaire actuel de NVIDIA résolu: un rafraîchissement est required",
|
||||
"Current NVIDIA inventory resolved: no refresh is required": "L'inventaire actuel de NVIDIA résolu: aucun rafraîchissement n'est required",
|
||||
"Current Network Configuration": "Configuration réseau actuelle",
|
||||
"Current PBS Version": "version actuelle de PBS",
|
||||
"Current PVE Version": "Version PVE actuelle",
|
||||
"Current ProxMenux host scripts register remote shares as Proxmox storages using pvesm.": "Les scripts hôtes ProxMenux actuels enregistrent les partages distants en tant que stockages Proxmox à l'aide de pvesm.",
|
||||
"Current ProxMenux version:": "Version actuelle de ProxMenux :",
|
||||
@@ -1685,6 +1690,7 @@
|
||||
"Detected Coral hardware:": "Matériel Coral détecté :",
|
||||
"Detected GPU(s):": "GPU(s) détecté(s) :",
|
||||
"Detected Intel CPU": "Processeur Intel détecté",
|
||||
"Detected Proxmox Backup Server": "Proxmox Backup Server détecté",
|
||||
"Detected Proxmox VE": "Proxmox VE détecté",
|
||||
"Detected Proxmox VE 8.x - Installing Ceph Squid for Debian": "Proxmox VE 8.x détecté - Installation de Ceph Squid pour Debian",
|
||||
"Detected Proxmox VE 9.x - Installing Ceph Squid for Debian Trixie": "Proxmox VE 9.x détecté - Installation de Ceph Squid pour Debian Trixie",
|
||||
@@ -1845,6 +1851,7 @@
|
||||
"Do you want to proceed with the system update?": "Voulez-vous procéder à la mise à jour du système ?",
|
||||
"Do you want to proceed?": "Voulez-vous continuer ?",
|
||||
"Do you want to reboot now?": "Voulez-vous redémarrer maintenant ?",
|
||||
"Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "Voulez-vous supprimer la bannière d'abonnement Proxmox de l'interface web de Proxmox Backup Server ?",
|
||||
"Do you want to remove the stale entry from vfio.conf and continue?": "Voulez-vous supprimer l'entrée obsolète de vfio.conf et continuer ?",
|
||||
"Do you want to remove the stale entry from vfio.conf?": "Voulez-vous supprimer l'entrée obsolète de vfio.conf ?",
|
||||
"Do you want to restart the network service now to apply changes?": "Voulez-vous redémarrer le service réseau maintenant pour appliquer les modifications ?",
|
||||
@@ -2270,6 +2277,7 @@
|
||||
"Failed to clone log2ram repository. Check /tmp/log2ram_install.log": "Échec du clonage du référentiel log2ram. Vérifiez /tmp/log2ram_install.log",
|
||||
"Failed to configure EFI disk": "Échec de la configuration du disque EFI",
|
||||
"Failed to configure IOMMU automatically.": "Échec de la configuration automatique d'IOMMU.",
|
||||
"Failed to configure Proxmox Backup Server repositories": "Échec de la configuration des dépôts de Proxmox Backup Server",
|
||||
"Failed to configure Proxmox repositories": "Échec de la configuration des référentiels Proxmox",
|
||||
"Failed to configure TPM device in VM": "Échec de la configuration du périphérique TPM dans la VM",
|
||||
"Failed to configure repositories.": "Échec de la configuration des référentiels.",
|
||||
@@ -4157,6 +4165,7 @@
|
||||
"No active Proxmox storage accepts OCI template archives.": "Aucun stockage Proxmox actif n'accepte les archives de modèles OCI.",
|
||||
"No active exports or showmount not available": "Aucune exportation active ou showmount non disponible",
|
||||
"No active session": "Aucune session active",
|
||||
"No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.": "Aucun abonnement actif et aucun référentiel PBS utilisable. Le mode non interactif ne peut pas modifier les sources APT ;configurez-les dans Administration > Dépôts.",
|
||||
"No active subscription and no usable PVE repository. Noninteractive mode cannot change APT sources; configure them in Node > Updates > Repositories.": "Aucun abonnement actif et aucun référentiel PVE utilisable. Le mode non interactif ne peut pas modifier les sources APT ; configurez-les dans Nœud > Mises à jour > Dépôts.",
|
||||
"No additional GPU can be added.": "Aucun GPU supplémentaire ne peut être ajouté.",
|
||||
"No additional device needs to be added.": "Aucun appareil supplémentaire ne doit être ajouté.",
|
||||
@@ -4874,7 +4883,11 @@
|
||||
"Proxmology logo applied": "Logo Proxmologie appliqué",
|
||||
"Proxmox 9 system update allready": "La mise à jour du système Proxmox 9 est déjà terminée",
|
||||
"Proxmox APT repositories configured": "Dépôts Proxmox APT configurés",
|
||||
"Proxmox Backup Server": "Proxmox Backup Server",
|
||||
"Proxmox Backup Server (PBS) destinations": "Destinations Proxmox Backup Server (PBS)",
|
||||
"Proxmox Backup Server APT repositories configured": "Dépôts APT de Proxmox Backup Server configurés",
|
||||
"Proxmox Backup Server Update": "Mise à jour de Proxmox Backup Server",
|
||||
"Proxmox Backup Server update completed.": "Mise à jour de Proxmox Backup Server terminée.",
|
||||
"Proxmox CIFS Storage Status:": "État de stockage Proxmox CIFS :",
|
||||
"Proxmox NFS Storage Status:": "Statut de stockage Proxmox NFS :",
|
||||
"Proxmox System Update": "Mise à jour du système Proxmox",
|
||||
@@ -5234,6 +5247,7 @@
|
||||
"Repository helper unavailable. NVIDIA installation stopped.": "Assistant du référentiel indisponible. L'installation de NVIDIA s'est arrêtée.",
|
||||
"Repository policy returned an unexpected result.": "La stratégie du référentiel a renvoyé un résultat inattendu.",
|
||||
"Repository ready.": "Référentiel prêt.",
|
||||
"Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Administration > Repositories and retry apt-get update before continuing.": "les sources du référentiel ont été modifiées, mais l'actualisation de la liste des packages APT a échoué. Opération arrêtée ;les sources n’ont pas été annulées. Inspectez Administration > Référentiels et réessayez apt-get update avant de continuer.",
|
||||
"Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Node > Updates > Repositories and retry apt-get update before continuing.": "Les sources du référentiel ont été modifiées, mais l'actualisation de la liste des paquets APT a échoué. Opération arrêtée ; les sources n'ont pas été restaurées. Vérifiez Nœud > Mises à jour > Dépôts et relancez apt-get update avant de continuer.",
|
||||
"Repository switch declined; no APT source changed.": "Le changement de référentiel a été refusé ; aucune source APT n'a été modifiée.",
|
||||
"Repository:": "Dépôt:",
|
||||
@@ -6782,6 +6796,7 @@
|
||||
"This CIFS share is mounted with restrictive permissions.": "Ce partage CIFS est monté avec des autorisations restrictives.",
|
||||
"This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Ce GPU est considéré comme incompatible avec le relais GPU vers une VM dans ProxMenux.",
|
||||
"This NFS share is fully restricted — even the host root cannot write to it.": "Ce partage NFS est entièrement restreint : même la racine de l'hôte ne peut pas y écrire.",
|
||||
"This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Ce Proxmox Backup Server n'a pas d'abonnement actif. Passer au dépôt sans abonnement ? La source Enterprise PBS inaccessible sera désactivée.",
|
||||
"This VM requires extra installation steps, see install guide at:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144": "Cette VM nécessite des étapes d'installation supplémentaires. Consultez le guide d'installation à l'adresse :\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144",
|
||||
"This account is for {registry}. Enter an image from that registry.": "Ce compte concerne {registry}. Entrez une image de ce registre.",
|
||||
"This action will:": "Cette action va :",
|
||||
@@ -6865,6 +6880,7 @@
|
||||
"This script cannot be executed from the Proxmox web terminal.": "Ce script ne peut pas être exécuté depuis le terminal web Proxmox.",
|
||||
"This script must be run as root.": "Ce script doit être exécuté en tant que root.",
|
||||
"This script must be run on a Proxmox host.": "Ce script doit être exécuté sur un hôte Proxmox.",
|
||||
"This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Ce script appliquera les optimisations et réglages avancés suivants à votre Proxmox Backup Server",
|
||||
"This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Ce script appliquera les optimisations et ajustements avancés suivants à votre serveur Proxmox VE",
|
||||
"This script will update your Proxmox VE system with advanced options:": "Ce script mettra à jour votre système Proxmox VE avec des options avancées :",
|
||||
"This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Cette session est en cours d'exécution dans le terminal Monitor. L’exécuter à partir d’ici couperait la connexion en cours d’installation et laisserait le commutateur dans un état cassé.",
|
||||
@@ -7191,6 +7207,7 @@
|
||||
"Upgrade assistant: Proxmox VE 8 → 9 (Trixie)": "Assistant de mise à niveau : Proxmox VE 8 → 9 (Trixie)",
|
||||
"Upgrade canceled by user": "Mise à niveau annulée par l'utilisateur",
|
||||
"Upgrade one node at a time": "Mettre à niveau un nœud à la fois",
|
||||
"Upgrade the system, offering the": "mettre à niveau le système, en offrant le",
|
||||
"Upgrade to Proxmox VE 9": "Mise à niveau vers Proxmox VE 9",
|
||||
"Upgrading NVIDIA utils (Arch)...": "Mise à niveau des utilitaires NVIDIA (Arch)...",
|
||||
"Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "Télécharger une copie cryptée de la clé sur PBS afin de pouvoir la récupérer sur un hôte réinstallé avec juste une phrase secrète ?",
|
||||
@@ -8110,6 +8127,7 @@
|
||||
"was": "était",
|
||||
"was installed, but the kernel reports:": "a été installé, mais le noyau rapporte :",
|
||||
"when finished": "à la fin",
|
||||
"when the server has no subscription (you will be asked)": "lorsque le serveur n'a pas d'abonnement (il vous sera demandé)",
|
||||
"will rebind the GPU to vfio-pci on the next reboot, breaking the driver that is about to be installed.": "reliera le GPU à vfio-pci au prochain redémarrage, cassant ainsi le pilote qui est sur le point d'être installé.",
|
||||
"wipefs failed on": "les wipefs ont échoué",
|
||||
"with": "avec",
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"/var/lib/vz/dump (Proxmox default vzdump path)": "/var/lib/vz/dump (percorso vzdump predefinito di Proxmox)",
|
||||
"/var/lib/vz/dump (Proxmox default)": "/var/lib/vz/dump (predefinito di Proxmox)",
|
||||
"1777 = sticky bit + rwx for all. No shared group needed.": "1777 = sticky bit + rwx per tutti. Non è necessario alcun gruppo condiviso.",
|
||||
"====== PBS UPDATE COMPLETED ======": "====== AGGIORNAMENTO PBS COMPLETATO ======",
|
||||
"====== PVE UPDATE COMPLETED ======": "====== AGGIORNAMENTO PVE COMPLETATO ======",
|
||||
">> Create a new backup job <<": ">> Crea un nuovo job di backup <<",
|
||||
">> Save and return <<": ">> Salva e torna indietro <<",
|
||||
@@ -87,6 +88,7 @@
|
||||
"A multi-container application is not recreated: its containers are updated together.": "Un’applicazione con più container non viene ricreata: i suoi container vengono aggiornati insieme.",
|
||||
"A new ProxMenux version is available:": "È disponibile una nuova versione di ProxMenux:",
|
||||
"A new kernel is staged for the next boot:": "Un nuovo kernel è predisposto per il prossimo avvio:",
|
||||
"A newer kernel is installed. Reboot before the next backup window to use it.": "è installato un kernel più recente. Riavviare prima della finestra di backup successiva per utilizzarlo.",
|
||||
"A newer version is available:": "È disponibile una versione più recente:",
|
||||
"A pending operation exists for": "Operazione in sospeso per il",
|
||||
"A pending stack assembly already exists; it is not overwritten": "Esiste già un assemblaggio dello stack in sospeso; non verrà sovrascritto",
|
||||
@@ -169,6 +171,7 @@
|
||||
"APT language configuration updated": "Configurazione lingue APT aggiornata",
|
||||
"APT language downloads restored": "Download dei file lingua APT ripristinati",
|
||||
"APT package lists updated": "Elenchi pacchetti APT aggiornati",
|
||||
"APT refresh failed. Check the PBS subscription and repositories in Administration > Repositories. Log:": "aggiornamento APT non riuscito. Controllare l'abbonamento e i repository PBS in Amministrazione > Repository. Tronco d'albero:",
|
||||
"APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "Aggiornamento degli indici APT non riuscito. Controlla l'abbonamento e i repository PBS nell'interfaccia nativa di PBS; nessuna sorgente è stata modificata.",
|
||||
"APT update failed. Check repository access; NVIDIA installation stopped.": "Aggiornamento APT non riuscito. Controlla l’accesso al repository; installazione NVIDIA interrotta.",
|
||||
"Aborted": "Interrotto",
|
||||
@@ -538,6 +541,7 @@
|
||||
"Available Borg targets:": "Destinazioni Borg disponibili:",
|
||||
"Available Disks on Host": "Dischi disponibili sull'host",
|
||||
"Available ISO Images": "Immagini ISO disponibili",
|
||||
"Available PBS Version": "versione PBS disponibile",
|
||||
"Available PBS repositories:": "Repository PBS disponibili:",
|
||||
"Available PVE Version": "Versione PVE disponibile",
|
||||
"Available Shares:": "Condivisioni disponibili:",
|
||||
@@ -1520,6 +1524,7 @@
|
||||
"Current NVIDIA inventory resolved: a refresh is required": "Inventario NVIDIA attuale acquisito: è necessario un aggiornamento",
|
||||
"Current NVIDIA inventory resolved: no refresh is required": "Inventario NVIDIA corrente acquisito: non serve aggiornarlo",
|
||||
"Current Network Configuration": "Configurazione di rete corrente",
|
||||
"Current PBS Version": "versione attuale di PBS",
|
||||
"Current PVE Version": "Versione PVE attuale",
|
||||
"Current ProxMenux host scripts register remote shares as Proxmox storages using pvesm.": "Gli attuali script host ProxMenux registrano le condivisioni remote come storage Proxmox con pvesm.",
|
||||
"Current ProxMenux version:": "Versione attuale di ProxMenux:",
|
||||
@@ -1685,6 +1690,7 @@
|
||||
"Detected Coral hardware:": "Hardware Coral rilevato:",
|
||||
"Detected GPU(s):": "GPU rilevate:",
|
||||
"Detected Intel CPU": "Rilevata CPU Intel",
|
||||
"Detected Proxmox Backup Server": "Proxmox Backup Server rilevato",
|
||||
"Detected Proxmox VE": "Rilevato Proxmox VE",
|
||||
"Detected Proxmox VE 8.x - Installing Ceph Squid for Debian": "Rilevato Proxmox VE 8.x - Installazione di Ceph Squid per Debian",
|
||||
"Detected Proxmox VE 9.x - Installing Ceph Squid for Debian Trixie": "Rilevato Proxmox VE 9.x - Installazione di Ceph Squid per Debian Trixie",
|
||||
@@ -1845,6 +1851,7 @@
|
||||
"Do you want to proceed with the system update?": "Vuoi procedere con l'aggiornamento del sistema?",
|
||||
"Do you want to proceed?": "Vuoi procedere?",
|
||||
"Do you want to reboot now?": "Vuoi riavviare ora?",
|
||||
"Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "Vuoi rimuovere il banner dell'abbonamento Proxmox dall'interfaccia web di Proxmox Backup Server?",
|
||||
"Do you want to remove the stale entry from vfio.conf and continue?": "Vuoi rimuovere la voce obsoleta da vfio.conf e continuare?",
|
||||
"Do you want to remove the stale entry from vfio.conf?": "Vuoi rimuovere la voce obsoleta da vfio.conf?",
|
||||
"Do you want to restart the network service now to apply changes?": "Riavviare ora il servizio di rete per applicare le modifiche?",
|
||||
@@ -2270,6 +2277,7 @@
|
||||
"Failed to clone log2ram repository. Check /tmp/log2ram_install.log": "Impossibile clonare il repository log2ram. Controlla /tmp/log2ram_install.log",
|
||||
"Failed to configure EFI disk": "Impossibile configurare il disco EFI",
|
||||
"Failed to configure IOMMU automatically.": "Impossibile configurare IOMMU automaticamente.",
|
||||
"Failed to configure Proxmox Backup Server repositories": "Impossibile configurare i repository di Proxmox Backup Server",
|
||||
"Failed to configure Proxmox repositories": "Impossibile configurare i repository Proxmox",
|
||||
"Failed to configure TPM device in VM": "Impossibile configurare il dispositivo TPM nella VM",
|
||||
"Failed to configure repositories.": "Impossibile configurare i repository.",
|
||||
@@ -4157,6 +4165,7 @@
|
||||
"No active Proxmox storage accepts OCI template archives.": "Nessuno spazio di archiviazione Proxmox attivo accetta archivi modello OCI.",
|
||||
"No active exports or showmount not available": "Nessuna esportazione attiva o showmount non disponibile",
|
||||
"No active session": "Nessuna sessione attiva",
|
||||
"No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.": "nessun abbonamento attivo e nessun repository PBS utilizzabile. La modalità non interattiva non può modificare le origini APT;configurarli in Amministrazione > Repository.",
|
||||
"No active subscription and no usable PVE repository. Noninteractive mode cannot change APT sources; configure them in Node > Updates > Repositories.": "Nessun abbonamento attivo e nessun repository PVE utilizzabile. La modalità non interattiva non può modificare le origini APT; configurarle in Nodo > Aggiornamenti > Repository.",
|
||||
"No additional GPU can be added.": "Non è possibile aggiungere alcuna GPU aggiuntiva.",
|
||||
"No additional device needs to be added.": "Non è necessario aggiungere alcun dispositivo aggiuntivo.",
|
||||
@@ -4874,7 +4883,11 @@
|
||||
"Proxmology logo applied": "Logo Proxmology applicato",
|
||||
"Proxmox 9 system update allready": "Il sistema è già aggiornato a Proxmox 9",
|
||||
"Proxmox APT repositories configured": "Repository APT Proxmox configurati",
|
||||
"Proxmox Backup Server": "Proxmox Backup Server",
|
||||
"Proxmox Backup Server (PBS) destinations": "Destinazioni Proxmox Backup Server (PBS)",
|
||||
"Proxmox Backup Server APT repositories configured": "Repository APT di Proxmox Backup Server configurati",
|
||||
"Proxmox Backup Server Update": "Aggiornamento di Proxmox Backup Server",
|
||||
"Proxmox Backup Server update completed.": "Aggiornamento di Proxmox Backup Server completato.",
|
||||
"Proxmox CIFS Storage Status:": "Stato storage CIFS Proxmox:",
|
||||
"Proxmox NFS Storage Status:": "Stato storage NFS Proxmox:",
|
||||
"Proxmox System Update": "Aggiornamento sistema Proxmox",
|
||||
@@ -5234,6 +5247,7 @@
|
||||
"Repository helper unavailable. NVIDIA installation stopped.": "Funzione di gestione dei repository non disponibile. L’installazione di NVIDIA è stata interrotta.",
|
||||
"Repository policy returned an unexpected result.": "La policy del repository ha restituito un risultato imprevisto.",
|
||||
"Repository ready.": "Repository pronto.",
|
||||
"Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Administration > Repositories and retry apt-get update before continuing.": "Le sorgenti dei repository sono cambiate, ma l'aggiornamento dell'elenco dei pacchetti APT non è riuscito. Operazione interrotta; le sorgenti non sono state ripristinate. Controlla Administration > Repositories e riesegui apt-get update prima di continuare.",
|
||||
"Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Node > Updates > Repositories and retry apt-get update before continuing.": "Le origini del repository sono cambiate, ma l'aggiornamento dell'elenco dei pacchetti APT non è riuscito. Operazione interrotta; le origini non sono state ripristinate. Controlla Nodo > Aggiornamenti > Repository e riprova apt-get update prima di continuare.",
|
||||
"Repository switch declined; no APT source changed.": "Cambio di repository rifiutato; nessuna origine APT è stata modificata.",
|
||||
"Repository:": "Repository:",
|
||||
@@ -6783,6 +6797,7 @@
|
||||
"This CIFS share is mounted with restrictive permissions.": "Questa condivisione CIFS è montata con autorizzazioni restrittive.",
|
||||
"This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Questa GPU è considerata incompatibile con il passthrough GPU a una VM in ProxMenux.",
|
||||
"This NFS share is fully restricted — even the host root cannot write to it.": "Questa condivisione NFS è completamente limitata: nemmeno l'utente root dell'host può scrivervi.",
|
||||
"This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Questo Proxmox Backup Server non ha un abbonamento attivo. Passare al repository senza abbonamento? La sorgente Enterprise PBS non accessibile verrà disattivata.",
|
||||
"This VM requires extra installation steps, see install guide at:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144": "Questa VM richiede ulteriori passaggi di installazione; vedere la guida:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144",
|
||||
"This account is for {registry}. Enter an image from that registry.": "Questo account è per {registry}. Inserisci un'immagine da quel registro.",
|
||||
"This action will:": "Questa azione:",
|
||||
@@ -6866,6 +6881,7 @@
|
||||
"This script cannot be executed from the Proxmox web terminal.": "Questo script non può essere eseguito dal terminale web Proxmox.",
|
||||
"This script must be run as root.": "Questo script deve essere eseguito come root.",
|
||||
"This script must be run on a Proxmox host.": "Questo script deve essere eseguito su un host Proxmox.",
|
||||
"This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Questo script applicherà le seguenti ottimizzazioni e regolazioni avanzate al tuo Proxmox Backup Server",
|
||||
"This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Questo script applicherà le seguenti ottimizzazioni e regolazioni avanzate al server Proxmox VE",
|
||||
"This script will update your Proxmox VE system with advanced options:": "Questo script aggiornerà il sistema Proxmox VE con opzioni avanzate:",
|
||||
"This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Questa sessione è in esecuzione nel terminale Monitor. Eseguirlo da qui interromperebbe la connessione a metà installazione e lascerebbe il cambio di canale incompleto.",
|
||||
@@ -7192,6 +7208,7 @@
|
||||
"Upgrade assistant: Proxmox VE 8 → 9 (Trixie)": "Assistente aggiornamento: Proxmox VE 8 → 9 (Trixie)",
|
||||
"Upgrade canceled by user": "Aggiornamento annullato dall'utente",
|
||||
"Upgrade one node at a time": "Aggiorna un nodo alla volta",
|
||||
"Upgrade the system, offering the": "aggiorna il sistema, offrendo il",
|
||||
"Upgrade to Proxmox VE 9": "Aggiorna a Proxmox VE 9",
|
||||
"Upgrading NVIDIA utils (Arch)...": "Aggiornamento utilità NVIDIA (Arch)...",
|
||||
"Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "Caricare una copia crittografata della chiave su PBS per recuperarla su un host reinstallato con la sola passphrase?",
|
||||
@@ -8111,6 +8128,7 @@
|
||||
"was": "era",
|
||||
"was installed, but the kernel reports:": "è stato installato, ma il kernel segnala:",
|
||||
"when finished": "al termine",
|
||||
"when the server has no subscription (you will be asked)": "quando il server non ha abbonamento (ti verrà chiesto)",
|
||||
"will rebind the GPU to vfio-pci on the next reboot, breaking the driver that is about to be installed.": "riassocerà la GPU a vfio-pci al prossimo riavvio, impedendo il funzionamento del driver che sta per essere installato.",
|
||||
"wipefs failed on": "wipefs non è riuscito su",
|
||||
"with": "con",
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"/var/lib/vz/dump (Proxmox default vzdump path)": "/var/lib/vz/dump (caminho vzdump padrão do Proxmox)",
|
||||
"/var/lib/vz/dump (Proxmox default)": "/var/lib/vz/dump (padrão Proxmox)",
|
||||
"1777 = sticky bit + rwx for all. No shared group needed.": "1777 = sticky bit + rwx para todos. Nenhum grupo compartilhado é necessário.",
|
||||
"====== PBS UPDATE COMPLETED ======": "====== ATUALIZAÇÃO PBS CONCLUÍDA ======",
|
||||
"====== PVE UPDATE COMPLETED ======": "====== ATUALIZAÇÃO PVE CONCLUÍDA ======",
|
||||
">> Create a new backup job <<": ">> Crie uma nova tarefa de backup <<",
|
||||
">> Save and return <<": ">> Salvar e retornar <<",
|
||||
@@ -87,6 +88,7 @@
|
||||
"A multi-container application is not recreated: its containers are updated together.": "Um aplicativo com vários contêineres não é recriado: seus contêineres são atualizados juntos.",
|
||||
"A new ProxMenux version is available:": "Uma nova versão do ProxMenux está disponível:",
|
||||
"A new kernel is staged for the next boot:": "Um novo kernel está preparado para a próxima inicialização:",
|
||||
"A newer kernel is installed. Reboot before the next backup window to use it.": "Um kernel mais recente está instalado. Reinicie antes da próxima janela de backup para usá-lo.",
|
||||
"A newer version is available:": "Uma versão mais recente está disponível:",
|
||||
"A pending operation exists for": "Existe uma operation pendente para",
|
||||
"A pending stack assembly already exists; it is not overwritten": "Já existe um conjunto de pilha pendente; não é substituído",
|
||||
@@ -169,6 +171,7 @@
|
||||
"APT language configuration updated": "Configuração de idioma APT atualizada",
|
||||
"APT language downloads restored": "Downloads de idiomas APT restaurados",
|
||||
"APT package lists updated": "Listas de pacotes APT atualizadas",
|
||||
"APT refresh failed. Check the PBS subscription and repositories in Administration > Repositories. Log:": "falha na atualização do APT.Verifique a assinatura e os repositórios do PBS em Administração > Repositórios. Registro:",
|
||||
"APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "Falha ao atualizar os índices APT. Verifique a subscrição e os repositórios PBS na interface nativa do PBS; nenhuma fonte foi alterada.",
|
||||
"APT update failed. Check repository access; NVIDIA installation stopped.": "Falha na atualização do APT. Verifique o acesso ao repositório; a instalação do NVIDIA foi interrompida.",
|
||||
"Aborted": "Abortado",
|
||||
@@ -538,6 +541,7 @@
|
||||
"Available Borg targets:": "Alvos Borg disponíveis:",
|
||||
"Available Disks on Host": "Discos disponíveis no host",
|
||||
"Available ISO Images": "Imagens ISO disponíveis",
|
||||
"Available PBS Version": "versão PBS disponível",
|
||||
"Available PBS repositories:": "Repositórios PBS disponíveis:",
|
||||
"Available PVE Version": "Versão PVE disponível",
|
||||
"Available Shares:": "Ações disponíveis:",
|
||||
@@ -1520,6 +1524,7 @@
|
||||
"Current NVIDIA inventory resolved: a refresh is required": "Inventário NVIDIA atual resolvido: uma atualização é required",
|
||||
"Current NVIDIA inventory resolved: no refresh is required": "Inventário NVIDIA atual resolvido: nenhuma atualização é required",
|
||||
"Current Network Configuration": "Configuração de rede atual",
|
||||
"Current PBS Version": "versão atual do PBS",
|
||||
"Current PVE Version": "Versão atual do PVE",
|
||||
"Current ProxMenux host scripts register remote shares as Proxmox storages using pvesm.": "Os scripts atuais do host ProxMenux registram compartilhamentos remotos como armazenamentos Proxmox usando pvesm.",
|
||||
"Current ProxMenux version:": "Versão atual do ProxMenux:",
|
||||
@@ -1685,6 +1690,7 @@
|
||||
"Detected Coral hardware:": "Hardware Coral detectado:",
|
||||
"Detected GPU(s):": "GPU(s) detectada(s):",
|
||||
"Detected Intel CPU": "CPU Intel detectada",
|
||||
"Detected Proxmox Backup Server": "Proxmox Backup Server detectado",
|
||||
"Detected Proxmox VE": "Proxmox VE detectado",
|
||||
"Detected Proxmox VE 8.x - Installing Ceph Squid for Debian": "Detectado Proxmox VE 8.x - Instalando Ceph Squid para Debian",
|
||||
"Detected Proxmox VE 9.x - Installing Ceph Squid for Debian Trixie": "Detectado Proxmox VE 9.x - Instalando Ceph Squid para Debian Trixie",
|
||||
@@ -1845,6 +1851,7 @@
|
||||
"Do you want to proceed with the system update?": "Deseja prosseguir com a atualização do sistema?",
|
||||
"Do you want to proceed?": "Você quer prosseguir?",
|
||||
"Do you want to reboot now?": "Você quer reiniciar agora?",
|
||||
"Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "Deseja remover o banner de assinatura do Proxmox da interface web do Proxmox Backup Server?",
|
||||
"Do you want to remove the stale entry from vfio.conf and continue?": "Deseja remover a entrada obsoleta do vfio.conf e continuar?",
|
||||
"Do you want to remove the stale entry from vfio.conf?": "Você deseja remover a entrada obsoleta do vfio.conf?",
|
||||
"Do you want to restart the network service now to apply changes?": "Deseja reiniciar o serviço de rede agora para aplicar as alterações?",
|
||||
@@ -2270,6 +2277,7 @@
|
||||
"Failed to clone log2ram repository. Check /tmp/log2ram_install.log": "Falha ao clonar o repositório log2ram. Verifique /tmp/log2ram_install.log",
|
||||
"Failed to configure EFI disk": "Falha ao configurar o disco EFI",
|
||||
"Failed to configure IOMMU automatically.": "Falha ao configurar o IOMMU automaticamente.",
|
||||
"Failed to configure Proxmox Backup Server repositories": "Falha ao configurar os repositórios do Proxmox Backup Server",
|
||||
"Failed to configure Proxmox repositories": "Falha ao configurar repositórios Proxmox",
|
||||
"Failed to configure TPM device in VM": "Falha ao configurar o dispositivo TPM na VM",
|
||||
"Failed to configure repositories.": "Falha ao configurar repositórios.",
|
||||
@@ -4157,6 +4165,7 @@
|
||||
"No active Proxmox storage accepts OCI template archives.": "Nenhum armazenamento Proxmox ativo aceita arquivos de modelo OCI.",
|
||||
"No active exports or showmount not available": "Nenhuma exportação ativa ou showmount não disponível",
|
||||
"No active session": "Nenhuma sessão ativa",
|
||||
"No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.": "Nenhuma assinatura ativa e nenhum repositório PBS utilizável. O modo não interativo não pode alterar fontes APT;configure-os em Administração > Repositórios.",
|
||||
"No active subscription and no usable PVE repository. Noninteractive mode cannot change APT sources; configure them in Node > Updates > Repositories.": "Nenhuma assinatura ativa e nenhum repositório PVE utilizável. O modo não interativo não pode alterar fontes APT; configure-as em Nó > Atualizações > Repositórios.",
|
||||
"No additional GPU can be added.": "Nenhuma GPU adicional pode ser adicionada.",
|
||||
"No additional device needs to be added.": "Nenhum dispositivo adicional precisa ser adicionado.",
|
||||
@@ -4874,7 +4883,11 @@
|
||||
"Proxmology logo applied": "Logotipo da Proxmologia aplicado",
|
||||
"Proxmox 9 system update allready": "Atualização do sistema Proxmox 9 já",
|
||||
"Proxmox APT repositories configured": "repositórios Proxmox APT configurados",
|
||||
"Proxmox Backup Server": "Proxmox Backup Server",
|
||||
"Proxmox Backup Server (PBS) destinations": "Destinos Proxmox Backup Server (PBS)",
|
||||
"Proxmox Backup Server APT repositories configured": "Repositórios APT do Proxmox Backup Server configurados",
|
||||
"Proxmox Backup Server Update": "Atualização do Proxmox Backup Server",
|
||||
"Proxmox Backup Server update completed.": "Atualização do Proxmox Backup Server concluída.",
|
||||
"Proxmox CIFS Storage Status:": "Status de armazenamento CIFS do Proxmox:",
|
||||
"Proxmox NFS Storage Status:": "Status de armazenamento NFS do Proxmox:",
|
||||
"Proxmox System Update": "Atualização do sistema Proxmox",
|
||||
@@ -5234,6 +5247,7 @@
|
||||
"Repository helper unavailable. NVIDIA installation stopped.": "Auxiliar de repositório indisponível. A instalação do NVIDIA foi interrompida.",
|
||||
"Repository policy returned an unexpected result.": "A política do repositório retornou um resultado inesperado.",
|
||||
"Repository ready.": "Repositório pronto.",
|
||||
"Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Administration > Repositories and retry apt-get update before continuing.": "as fontes do repositório foram alteradas, mas a atualização da lista de pacotes APT falhou. Operação interrompida;fontes não foram revertidas. Inspecione Administração > Repositórios e tente apt-get update novamente antes de continuar.",
|
||||
"Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Node > Updates > Repositories and retry apt-get update before continuing.": "As fontes do repositório foram alteradas, mas a atualização da lista de pacotes APT falhou. Operação interrompida; as fontes não foram revertidas. Verifique Nó > Atualizações > Repositórios e tente apt-get update novamente antes de continuar.",
|
||||
"Repository switch declined; no APT source changed.": "Troca de repositório recusada; nenhuma fonte APT foi alterada.",
|
||||
"Repository:": "Repositório:",
|
||||
@@ -6783,6 +6797,7 @@
|
||||
"This CIFS share is mounted with restrictive permissions.": "Este compartilhamento CIFS é montado com permissões restritivas.",
|
||||
"This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Esta GPU é considerada incompatível com a passagem de GPU para uma VM no ProxMenux.",
|
||||
"This NFS share is fully restricted — even the host root cannot write to it.": "Este compartilhamento NFS é totalmente restrito — até mesmo o host root não pode gravar nele.",
|
||||
"This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Este Proxmox Backup Server não tem assinatura ativa. Mudar para o repositório sem assinatura? A fonte Enterprise do PBS, inacessível, será desativada.",
|
||||
"This VM requires extra installation steps, see install guide at:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144": "Esta VM requer etapas extras de instalação. Consulte o guia de instalação em:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144",
|
||||
"This account is for {registry}. Enter an image from that registry.": "Esta conta é para {registry}. Insira uma imagem desse registro.",
|
||||
"This action will:": "Esta ação irá:",
|
||||
@@ -6866,6 +6881,7 @@
|
||||
"This script cannot be executed from the Proxmox web terminal.": "Este script não pode ser executado no terminal web Proxmox.",
|
||||
"This script must be run as root.": "Este script deve ser executado como root.",
|
||||
"This script must be run on a Proxmox host.": "Este script deve ser executado em um host Proxmox.",
|
||||
"This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Este script aplicará as seguintes otimizações e ajustes avançados ao seu Proxmox Backup Server",
|
||||
"This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Este script aplicará as seguintes otimizações e ajustes avançados ao seu servidor Proxmox VE",
|
||||
"This script will update your Proxmox VE system with advanced options:": "Este script atualizará seu sistema Proxmox VE com opções avançadas:",
|
||||
"This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Esta sessão está sendo executada no terminal Monitor. Executá-lo a partir daqui cortaria a conexão no meio da instalação e deixaria o switch quebrado.",
|
||||
@@ -7192,6 +7208,7 @@
|
||||
"Upgrade assistant: Proxmox VE 8 → 9 (Trixie)": "Assistente de atualização: Proxmox VE 8 → 9 (Trixie)",
|
||||
"Upgrade canceled by user": "Atualização cancelada pelo usuário",
|
||||
"Upgrade one node at a time": "Atualize um nó por vez",
|
||||
"Upgrade the system, offering the": "Atualize o sistema, oferecendo o",
|
||||
"Upgrade to Proxmox VE 9": "Atualize para Proxmox VE 9",
|
||||
"Upgrading NVIDIA utils (Arch)...": "Atualizando utilitários NVIDIA (Arch)...",
|
||||
"Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "Carregar uma cópia criptografada da chave para o PBS para que você possa recuperá-la em um host reinstalado com apenas uma senha?",
|
||||
@@ -8111,6 +8128,7 @@
|
||||
"was": "era",
|
||||
"was installed, but the kernel reports:": "foi instalado, mas o kernel informa:",
|
||||
"when finished": "quando terminado",
|
||||
"when the server has no subscription (you will be asked)": "quando o servidor não tem assinatura (será solicitado)",
|
||||
"will rebind the GPU to vfio-pci on the next reboot, breaking the driver that is about to be installed.": "irá religar a GPU ao vfio-pci na próxima reinicialização, quebrando o driver que está prestes a ser instalado.",
|
||||
"wipefs failed on": "Wipefs falhou em",
|
||||
"with": "com",
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"/var/lib/vz/dump (Proxmox default vzdump path)": "/var/lib/vz/dump (predvolená cesta Proxmox vzdump)",
|
||||
"/var/lib/vz/dump (Proxmox default)": "/var/lib/vz/dump (predvolené v Proxmoxe)",
|
||||
"1777 = sticky bit + rwx for all. No shared group needed.": "1777 = sticky bit + rwx pre všetkých. Zdieľaná skupina nie je potrebná.",
|
||||
"====== PBS UPDATE COMPLETED ======": "====== AKTUALIZÁCIA PBS DOKONČENÁ ======",
|
||||
"====== PVE UPDATE COMPLETED ======": "====== AKTUALIZÁCIA PVE DOKONČENÁ ======",
|
||||
">> Create a new backup job <<": ">> Vytvoriť novú úlohu zálohovania <<",
|
||||
">> Save and return <<": ">> Uložiť a vrátiť <<",
|
||||
@@ -87,6 +88,7 @@
|
||||
"A multi-container application is not recreated: its containers are updated together.": "Aplikácia s viacerými kontajnermi sa nevytvorí znova: jej kontajnery sa aktualizujú spoločne.",
|
||||
"A new ProxMenux version is available:": "Je dostupná nová verzia ProxMenux:",
|
||||
"A new kernel is staged for the next boot:": "Na najbližší štart je pripravený nový kernel:",
|
||||
"A newer kernel is installed. Reboot before the next backup window to use it.": "Je nainštalované novšie jadro. Aby ste ho mohli použiť, reštartujte ho pred ďalším oknom zálohy.",
|
||||
"A newer version is available:": "Je dostupná novšia verzia:",
|
||||
"A pending operation exists for": "Čaká na operation existuje",
|
||||
"A pending stack assembly already exists; it is not overwritten": "Nevyriešená zostava stohov už existuje; nie je prepísaná",
|
||||
@@ -169,6 +171,7 @@
|
||||
"APT language configuration updated": "Nastavenie jazykov pre APT bolo aktualizované",
|
||||
"APT language downloads restored": "Sťahovanie jazykov cez APT bolo obnovené",
|
||||
"APT package lists updated": "Zoznamy APT balíkov aktualizované",
|
||||
"APT refresh failed. Check the PBS subscription and repositories in Administration > Repositories. Log:": "Obnovenie APT zlyhalo. Skontrolujte predplatné a repozitáre PBS v časti Správa > Repozitáre. Denník:",
|
||||
"APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "Obnovenie indexov APT zlyhalo. Skontrolujte predplatné a repozitáre PBS v pôvodnom rozhraní PBS; žiadne zdroje sa nezmenili.",
|
||||
"APT update failed. Check repository access; NVIDIA installation stopped.": "Aktualizácia APT zlyhala. Skontrolujte prístup k úložisku; inštalácia NVIDIA sa zastavila.",
|
||||
"Aborted": "Zrušené",
|
||||
@@ -538,6 +541,7 @@
|
||||
"Available Borg targets:": "Dostupné Borg ciele:",
|
||||
"Available Disks on Host": "Dostupné disky na hostovi",
|
||||
"Available ISO Images": "Dostupné ISO obrazy",
|
||||
"Available PBS Version": "Dostupná verzia PBS",
|
||||
"Available PBS repositories:": "Dostupné PBS repozitáre:",
|
||||
"Available PVE Version": "Dostupná verzia PVE",
|
||||
"Available Shares:": "Dostupné zdieľania:",
|
||||
@@ -1520,6 +1524,7 @@
|
||||
"Current NVIDIA inventory resolved: a refresh is required": "Aktuálne NVIDIA inventár vyriešený: obnovovanie je required",
|
||||
"Current NVIDIA inventory resolved: no refresh is required": "Aktuálne NVIDIA inventár vyriešený: žiadne obnovenie je required",
|
||||
"Current Network Configuration": "Aktuálne nastavenie siete",
|
||||
"Current PBS Version": "Aktuálna verzia PBS",
|
||||
"Current PVE Version": "Aktuálna verzia PVE",
|
||||
"Current ProxMenux host scripts register remote shares as Proxmox storages using pvesm.": "Aktuálne host skripty ProxMenux registrujú vzdialené zdieľania ako úložiská Proxmoxu pomocou pvesm.",
|
||||
"Current ProxMenux version:": "Aktuálna verzia ProxMenux:",
|
||||
@@ -1685,6 +1690,7 @@
|
||||
"Detected Coral hardware:": "Nájdený Coral hardvér:",
|
||||
"Detected GPU(s):": "Nájdené GPU:",
|
||||
"Detected Intel CPU": "Zistený Intel procesor",
|
||||
"Detected Proxmox Backup Server": "Zistený Proxmox Backup Server",
|
||||
"Detected Proxmox VE": "Zistený Proxmox VE",
|
||||
"Detected Proxmox VE 8.x - Installing Ceph Squid for Debian": "Zistený Proxmox VE 8.x - inštalujem Ceph Squid pre Debian",
|
||||
"Detected Proxmox VE 9.x - Installing Ceph Squid for Debian Trixie": "Zistený Proxmox VE 9.x - inštalujem Ceph Squid pre Debian Trixie",
|
||||
@@ -1845,6 +1851,7 @@
|
||||
"Do you want to proceed with the system update?": "Chcete pokračovať v aktualizácii systému?",
|
||||
"Do you want to proceed?": "Chcete pokračovať?",
|
||||
"Do you want to reboot now?": "Chcete reštartovať teraz?",
|
||||
"Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "Chcete odstrániť banner predplatného Proxmox z webového rozhrania Proxmox Backup Server?",
|
||||
"Do you want to remove the stale entry from vfio.conf and continue?": "Chcete odstrániť zastaraný záznam z vfio.conf a pokračovať?",
|
||||
"Do you want to remove the stale entry from vfio.conf?": "Chcete odstrániť zastaraný záznam z vfio.conf?",
|
||||
"Do you want to restart the network service now to apply changes?": "Chcete teraz reštartovať sieťovú službu, aby sa zmeny prejavili?",
|
||||
@@ -2270,6 +2277,7 @@
|
||||
"Failed to clone log2ram repository. Check /tmp/log2ram_install.log": "Repozitár log2ram sa nepodarilo stiahnuť. Skontrolujte /tmp/log2ram_install.log",
|
||||
"Failed to configure EFI disk": "EFI disk sa nepodarilo nastaviť",
|
||||
"Failed to configure IOMMU automatically.": "IOMMU sa nepodarilo nastaviť automaticky.",
|
||||
"Failed to configure Proxmox Backup Server repositories": "Repozitáre Proxmox Backup Server sa nepodarilo nastaviť",
|
||||
"Failed to configure Proxmox repositories": "Repozitáre Proxmoxu sa nepodarilo nastaviť",
|
||||
"Failed to configure TPM device in VM": "TPM zariadenie sa vo VM nepodarilo nastaviť",
|
||||
"Failed to configure repositories.": "Repozitáre sa nepodarilo nastaviť.",
|
||||
@@ -4157,6 +4165,7 @@
|
||||
"No active Proxmox storage accepts OCI template archives.": "Žiadne aktívne úložisko Proxmox neakceptuje archívy šablón OCI.",
|
||||
"No active exports or showmount not available": "Nie sú aktívne exporty alebo príkaz showmount nie je dostupný",
|
||||
"No active session": "Žiadna aktívna relácia",
|
||||
"No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.": "Žiadne aktívne predplatné a žiadne použiteľné úložisko PBS.Neinteraktívny režim nemôže zmeniť zdroje APT;nakonfigurujte ich v Administrácia > Repozitáre.",
|
||||
"No active subscription and no usable PVE repository. Noninteractive mode cannot change APT sources; configure them in Node > Updates > Repositories.": "Žiadne aktívne predplatné a žiadne použiteľné úložisko PVE. Neinteraktívny režim nemôže zmeniť zdroje APT; nakonfigurujte ich v časti Uzol > Aktualizácie > Úložiská.",
|
||||
"No additional GPU can be added.": "Nie je možné pridať ďalšiu GPU.",
|
||||
"No additional device needs to be added.": "Netreba pridávať žiadne ďalšie zariadenie.",
|
||||
@@ -4876,7 +4885,11 @@
|
||||
"Proxmology logo applied": "Logo Proxmology bolo použité",
|
||||
"Proxmox 9 system update allready": "Systém Proxmox 9 je už aktualizovaný",
|
||||
"Proxmox APT repositories configured": "APT repozitáre Proxmoxu sú nastavené",
|
||||
"Proxmox Backup Server": "Proxmox Backup Server",
|
||||
"Proxmox Backup Server (PBS) destinations": "Ciele Proxmox Backup Servera (PBS)",
|
||||
"Proxmox Backup Server APT repositories configured": "APT repozitáre Proxmox Backup Server sú nastavené",
|
||||
"Proxmox Backup Server Update": "Aktualizácia Proxmox Backup Server",
|
||||
"Proxmox Backup Server update completed.": "Aktualizácia Proxmox Backup Server bola dokončená.",
|
||||
"Proxmox CIFS Storage Status:": "Stav CIFS úložiska v Proxmoxe:",
|
||||
"Proxmox NFS Storage Status:": "Stav NFS úložiska v Proxmoxe:",
|
||||
"Proxmox System Update": "Aktualizácia systému Proxmox",
|
||||
@@ -5236,6 +5249,7 @@
|
||||
"Repository helper unavailable. NVIDIA installation stopped.": "Pomocník úložiska nie je k dispozícii. Inštalácia NVIDIA sa zastavila.",
|
||||
"Repository policy returned an unexpected result.": "Politika úložiska vrátila neočakávaný výsledok.",
|
||||
"Repository ready.": "Repozitár je pripravený.",
|
||||
"Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Administration > Repositories and retry apt-get update before continuing.": "Zdroje úložiska sa zmenili, ale obnovenie zoznamu balíkov APT zlyhalo. Prevádzka zastavená;zdroje neboli vrátené späť. Pred pokračovaním skontrolujte Administrácia > Repozitáre a zopakujte apt-get update.",
|
||||
"Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Node > Updates > Repositories and retry apt-get update before continuing.": "Zdroje úložiska sa zmenili, ale obnovenie zoznamu balíkov APT zlyhalo. Prevádzka zastavená; zdroje neboli vrátené späť. Pred pokračovaním skontrolujte Node > Updates > Repository a zopakujte apt-get update.",
|
||||
"Repository switch declined; no APT source changed.": "Prepnutie úložiska bolo odmietnuté; žiadny zdroj APT sa nezmenil.",
|
||||
"Repository:": "Repozitár:",
|
||||
@@ -6784,6 +6798,7 @@
|
||||
"This CIFS share is mounted with restrictive permissions.": "Toto CIFS zdieľanie je pripojené s obmedzujúcimi oprávneniami.",
|
||||
"This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Táto GPU sa v ProxMenux považuje za nekompatibilnú s GPU passthrough do VM.",
|
||||
"This NFS share is fully restricted — even the host root cannot write to it.": "Toto NFS zdieľanie je úplne obmedzené - ani root na hostovi doň nevie zapisovať.",
|
||||
"This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Tento Proxmox Backup Server nemá aktívne predplatné. Prepnúť na repozitár bez predplatného? Nedostupný zdroj Enterprise PBS bude vypnutý.",
|
||||
"This VM requires extra installation steps, see install guide at:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144": "Táto VM vyžaduje ďalšie kroky inštalácie. Návod nájdete tu:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144",
|
||||
"This account is for {registry}. Enter an image from that registry.": "Tento účet je pre {registry}. Zadajte obrázok z tohto registra.",
|
||||
"This action will:": "Táto akcia vykoná:",
|
||||
@@ -6867,6 +6882,7 @@
|
||||
"This script cannot be executed from the Proxmox web terminal.": "Tento skript sa nedá spustiť z webového terminálu Proxmoxu.",
|
||||
"This script must be run as root.": "Tento skript musí byť spustený ako root.",
|
||||
"This script must be run on a Proxmox host.": "Tento skript musí byť spustený na Proxmox serveri.",
|
||||
"This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Tento skript použije nasledujúce optimalizácie a pokročilé úpravy na váš Proxmox Backup Server",
|
||||
"This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Tento skript použije tieto optimalizácie a pokročilé úpravy na vašom serveri Proxmox VE",
|
||||
"This script will update your Proxmox VE system with advanced options:": "Tento skript aktualizuje váš Proxmox VE systém s pokročilými možnosťami:",
|
||||
"This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Táto relácia beží v termináli Monitora. Ak by sa to spustilo odtiaľto, pripojenie sa počas inštalácie preruší a prepnutie môže zostať v zlom stave.",
|
||||
@@ -7193,6 +7209,7 @@
|
||||
"Upgrade assistant: Proxmox VE 8 → 9 (Trixie)": "Asistent aktualizácie: Proxmox VE 8 → 9 (Trixie)",
|
||||
"Upgrade canceled by user": "Aktualizácia zrušená používateľom",
|
||||
"Upgrade one node at a time": "Aktualizujte vždy iba jeden uzol naraz",
|
||||
"Upgrade the system, offering the": "Aktualizujte systém a ponúknite",
|
||||
"Upgrade to Proxmox VE 9": "Aktualizovať na Proxmox VE 9",
|
||||
"Upgrading NVIDIA utils (Arch)...": "Aktualizujem NVIDIA nástroje (Arch)...",
|
||||
"Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "Nahrať šifrovanú kópiu kľúča do PBS, aby ste ju vedeli obnoviť na preinštalovanom hostovi iba pomocou frázy?",
|
||||
@@ -8112,6 +8129,7 @@
|
||||
"was": "bolo",
|
||||
"was installed, but the kernel reports:": "bol nainštalovaný, ale jadro hlási:",
|
||||
"when finished": "po dokončení",
|
||||
"when the server has no subscription (you will be asked)": "keď server nemá predplatné (budete požiadaný)",
|
||||
"will rebind the GPU to vfio-pci on the next reboot, breaking the driver that is about to be installed.": "pri ďalšom reštarte znova naviaže GPU na vfio-pci, čím sa preruší ovládač, ktorý sa má nainštalovať.",
|
||||
"wipefs failed on": "wipefs zlyhalo na",
|
||||
"with": "za",
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"/var/lib/vz/dump (Proxmox default vzdump path)": "/var/lib/vz/dump (Proxmox standard vzdump sökväg)",
|
||||
"/var/lib/vz/dump (Proxmox default)": "/var/lib/vz/dump (Proxmox standard)",
|
||||
"1777 = sticky bit + rwx for all. No shared group needed.": "1777 = sticky bit + rwx för alla. Ingen delad grupp behövs.",
|
||||
"====== PBS UPDATE COMPLETED ======": "====== PBS UPPDATERING AVSLUTAD ======",
|
||||
"====== PVE UPDATE COMPLETED ======": "====== PVE UPPDATERING AVSLUTAD ======",
|
||||
">> Create a new backup job <<": ">> Skapa ett nytt säkerhetskopieringsjobb <<",
|
||||
">> Save and return <<": ">> Spara och returnera <<",
|
||||
@@ -87,6 +88,7 @@
|
||||
"A multi-container application is not recreated: its containers are updated together.": "Ett program med flera behållare återskapas inte: dess behållare uppdateras tillsammans.",
|
||||
"A new ProxMenux version is available:": "En ny ProxMenux-version är tillgänglig:",
|
||||
"A new kernel is staged for the next boot:": "En ny kärna är iscensatt för nästa uppstart:",
|
||||
"A newer kernel is installed. Reboot before the next backup window to use it.": "En nyare kärna är installerad. Starta om innan nästa säkerhetskopieringsfönster för att använda det.",
|
||||
"A newer version is available:": "En nyare version finns tillgänglig:",
|
||||
"A pending operation exists for": "En väntande operation finns för",
|
||||
"A pending stack assembly already exists; it is not overwritten": "En väntande stack församling existerar redan; den är inte överskriven",
|
||||
@@ -169,6 +171,7 @@
|
||||
"APT language configuration updated": "APT-språkkonfigurationen uppdaterad",
|
||||
"APT language downloads restored": "APT-språknedladdningar återställda",
|
||||
"APT package lists updated": "APT-paketlistor uppdaterade",
|
||||
"APT refresh failed. Check the PBS subscription and repositories in Administration > Repositories. Log:": "APT-uppdatering misslyckades. Kontrollera PBS-prenumerationen och arkiven i Administration > Repositories. Logga:",
|
||||
"APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "APT-uppdateringen misslyckades. Kontrollera PBS-prenumerationen och paketförråden i PBS eget gränssnitt; inga paketkällor ändrades.",
|
||||
"APT update failed. Check repository access; NVIDIA installation stopped.": "APT-uppdateringen misslyckades. Kontrollera åtkomsten till arkiven; NVIDIA-installationen stoppades.",
|
||||
"Aborted": "Avbruten",
|
||||
@@ -538,6 +541,7 @@
|
||||
"Available Borg targets:": "Tillgängliga Borg-mål:",
|
||||
"Available Disks on Host": "Tillgängliga diskar på värd",
|
||||
"Available ISO Images": "Tillgängliga ISO-bilder",
|
||||
"Available PBS Version": "Tillgänglig PBS-version",
|
||||
"Available PBS repositories:": "Tillgängliga PBS-lager:",
|
||||
"Available PVE Version": "Tillgänglig PVE-version",
|
||||
"Available Shares:": "Tillgängliga andelar:",
|
||||
@@ -1520,6 +1524,7 @@
|
||||
"Current NVIDIA inventory resolved: a refresh is required": "Nuvarande NVIDIA inventering löst: en uppfriskning är required",
|
||||
"Current NVIDIA inventory resolved: no refresh is required": "Nuvarande NVIDIA inventering löst: ingen uppfriskning är required",
|
||||
"Current Network Configuration": "Aktuell nätverkskonfiguration",
|
||||
"Current PBS Version": "Aktuell PBS-version",
|
||||
"Current PVE Version": "Aktuell PVE-version",
|
||||
"Current ProxMenux host scripts register remote shares as Proxmox storages using pvesm.": "Aktuella ProxMenux-värdskript registrerar fjärrresurser som Proxmox-lagringar med hjälp av pvesm.",
|
||||
"Current ProxMenux version:": "Aktuell ProxMenux-version:",
|
||||
@@ -1685,6 +1690,7 @@
|
||||
"Detected Coral hardware:": "Upptäckt Coral-hårdvara:",
|
||||
"Detected GPU(s):": "Upptäckta GPU:er:",
|
||||
"Detected Intel CPU": "Upptäckte Intel CPU",
|
||||
"Detected Proxmox Backup Server": "Upptäckte Proxmox Backup Server",
|
||||
"Detected Proxmox VE": "Detekterade Proxmox VE",
|
||||
"Detected Proxmox VE 8.x - Installing Ceph Squid for Debian": "Upptäckte Proxmox VE 8.x - Installerar Ceph Squid för Debian",
|
||||
"Detected Proxmox VE 9.x - Installing Ceph Squid for Debian Trixie": "Upptäckte Proxmox VE 9.x - Installerar Ceph Squid för Debian Trixie",
|
||||
@@ -1845,6 +1851,7 @@
|
||||
"Do you want to proceed with the system update?": "Vill du fortsätta med systemuppdateringen?",
|
||||
"Do you want to proceed?": "Vill du fortsätta?",
|
||||
"Do you want to reboot now?": "Vill du starta om nu?",
|
||||
"Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "Vill du ta bort Proxmox-prenumerationsbannern från Proxmox Backup Server-webbgränssnittet?",
|
||||
"Do you want to remove the stale entry from vfio.conf and continue?": "Vill du ta bort den inaktuella posten från vfio.conf och fortsätta?",
|
||||
"Do you want to remove the stale entry from vfio.conf?": "Vill du ta bort den inaktuella posten från vfio.conf?",
|
||||
"Do you want to restart the network service now to apply changes?": "Vill du starta om nätverkstjänsten nu för att tillämpa ändringar?",
|
||||
@@ -2270,6 +2277,7 @@
|
||||
"Failed to clone log2ram repository. Check /tmp/log2ram_install.log": "Det gick inte att klona log2ram-förrådet. Kontrollera /tmp/log2ram_install.log",
|
||||
"Failed to configure EFI disk": "Det gick inte att konfigurera EFI-disken",
|
||||
"Failed to configure IOMMU automatically.": "Det gick inte att konfigurera IOMMU automatiskt.",
|
||||
"Failed to configure Proxmox Backup Server repositories": "Det gick inte att konfigurera Proxmox Backup Server-förråd",
|
||||
"Failed to configure Proxmox repositories": "Det gick inte att konfigurera Proxmox-förråd",
|
||||
"Failed to configure TPM device in VM": "Det gick inte att konfigurera TPM-enhet i virtuell dator",
|
||||
"Failed to configure repositories.": "Det gick inte att konfigurera förråd.",
|
||||
@@ -4157,6 +4165,7 @@
|
||||
"No active Proxmox storage accepts OCI template archives.": "Ingen aktiv Proxmox-lagring accepterar OCI-mallarkiv.",
|
||||
"No active exports or showmount not available": "Inga aktiva exporter eller showmount är inte tillgängligt",
|
||||
"No active session": "Ingen aktiv session",
|
||||
"No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.": "Inget aktivt abonnemang och inget användbart PBS-förråd. Icke-interaktivt läge kan inte ändra APT-källor;konfigurera dem i Administration > Repositories.",
|
||||
"No active subscription and no usable PVE repository. Noninteractive mode cannot change APT sources; configure them in Node > Updates > Repositories.": "Ingen aktiv prenumeration och inget användbart PVE-arkiv. Icke-interaktivt läge kan inte ändra APT-källor; konfigurera dem i Node > Updates > Repositories.",
|
||||
"No additional GPU can be added.": "Ingen ytterligare GPU kan läggas till.",
|
||||
"No additional device needs to be added.": "Ingen ytterligare enhet behöver läggas till.",
|
||||
@@ -4874,7 +4883,11 @@
|
||||
"Proxmology logo applied": "Proxmology-logotyp applicerad",
|
||||
"Proxmox 9 system update allready": "Proxmox 9 systemuppdatering redan",
|
||||
"Proxmox APT repositories configured": "Proxmox APT-förråd konfigurerade",
|
||||
"Proxmox Backup Server": "Proxmox Backup Server",
|
||||
"Proxmox Backup Server (PBS) destinations": "Destinationer för Proxmox Backup Server (PBS)",
|
||||
"Proxmox Backup Server APT repositories configured": "Proxmox Backup Server APT-förråd konfigurerade",
|
||||
"Proxmox Backup Server Update": "Proxmox Backup Server Uppdatering",
|
||||
"Proxmox Backup Server update completed.": "Proxmox Backup Server-uppdatering slutförd.",
|
||||
"Proxmox CIFS Storage Status:": "Proxmox CIFS-lagringsstatus:",
|
||||
"Proxmox NFS Storage Status:": "Proxmox NFS-lagringsstatus:",
|
||||
"Proxmox System Update": "Proxmox systemuppdatering",
|
||||
@@ -5234,6 +5247,7 @@
|
||||
"Repository helper unavailable. NVIDIA installation stopped.": "Arkivhjälparen är inte tillgänglig. NVIDIA-installationen stoppades.",
|
||||
"Repository policy returned an unexpected result.": "Arkivpolicyn gav ett oväntat resultat.",
|
||||
"Repository ready.": "Förvaret klart.",
|
||||
"Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Administration > Repositories and retry apt-get update before continuing.": "Lagringskällorna har ändrats, men uppdateringen av APT-paketlistan misslyckades. Drift stoppad;källor rullades inte tillbaka. Inspektera Administration > Repositories och försök apt-get update igen innan du fortsätter.",
|
||||
"Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Node > Updates > Repositories and retry apt-get update before continuing.": "Arkivkällorna har ändrats, men uppdateringen av APT-paketlistan misslyckades. Åtgärden stoppades; källorna återställdes inte. Kontrollera Node > Updates > Repositories och försök apt-get update igen innan du fortsätter.",
|
||||
"Repository switch declined; no APT source changed.": "Arkivbytet avvisades; ingen APT-källa har ändrats.",
|
||||
"Repository:": "Förvar:",
|
||||
@@ -6783,6 +6797,7 @@
|
||||
"This CIFS share is mounted with restrictive permissions.": "Denna CIFS-andel är monterad med restriktiva behörigheter.",
|
||||
"This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Denna GPU anses vara inkompatibel med GPU-genomföring till en virtuell dator i ProxMenux.",
|
||||
"This NFS share is fully restricted — even the host root cannot write to it.": "Denna NFS-resurs är helt begränsad — även värdroten kan inte skriva till den.",
|
||||
"This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Denna Proxmox Backup Server har ingen aktiv prenumeration, byta till arkivet utan prenumeration? Den otillgängliga Enterprise PBS-källan kommer att inaktiveras.",
|
||||
"This VM requires extra installation steps, see install guide at:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144": "Denna virtuella dator kräver extra installationssteg, se installationsguiden på:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144",
|
||||
"This account is for {registry}. Enter an image from that registry.": "Detta konto är för {registry}. Ange en bild från det registret.",
|
||||
"This action will:": "Denna åtgärd kommer att:",
|
||||
@@ -6866,6 +6881,7 @@
|
||||
"This script cannot be executed from the Proxmox web terminal.": "Detta skript kan inte köras från Proxmox webbterminal.",
|
||||
"This script must be run as root.": "Detta skript måste köras som root.",
|
||||
"This script must be run on a Proxmox host.": "Detta skript måste köras på en Proxmox-värd.",
|
||||
"This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Detta skript kommer att tillämpa följande optimeringar och avancerade justeringar på din Proxmox Backup Server",
|
||||
"This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Detta skript kommer att tillämpa följande optimeringar och avancerade justeringar på din Proxmox VE-server",
|
||||
"This script will update your Proxmox VE system with advanced options:": "Detta skript kommer att uppdatera ditt Proxmox VE-system med avancerade alternativ:",
|
||||
"This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Den här sessionen körs i Monitor-terminalen. Om du kör den härifrån bryts anslutningen mitt under installationen och läget förblir felaktigt.",
|
||||
@@ -7192,6 +7208,7 @@
|
||||
"Upgrade assistant: Proxmox VE 8 → 9 (Trixie)": "Uppgraderingsassistent: Proxmox VE 8 → 9 (Ttrixie)",
|
||||
"Upgrade canceled by user": "Uppgraderingen avbröts av användaren",
|
||||
"Upgrade one node at a time": "Uppgradera en nod i taget",
|
||||
"Upgrade the system, offering the": "Uppgradera systemet och erbjuder",
|
||||
"Upgrade to Proxmox VE 9": "Uppgradera till Proxmox VE 9",
|
||||
"Upgrading NVIDIA utils (Arch)...": "Uppgraderar NVIDIA utils (Arch)...",
|
||||
"Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "Ladda upp en krypterad kopia av nyckeln till PBS så att du kan återställa den på en ominstallerad värd med bara en lösenfras?",
|
||||
@@ -8111,6 +8128,7 @@
|
||||
"was": "var",
|
||||
"was installed, but the kernel reports:": "installerades, men kärnan rapporterar:",
|
||||
"when finished": "När den är färdig",
|
||||
"when the server has no subscription (you will be asked)": "när servern inte har något abonnemang (du kommer att bli tillfrågad)",
|
||||
"will rebind the GPU to vfio-pci on the next reboot, breaking the driver that is about to be installed.": "kommer att binda om GPU:n till vfio-pci vid nästa omstart, vilket bryter drivrutinen som är på väg att installeras.",
|
||||
"wipefs failed on": "wipefs misslyckades",
|
||||
"with": "med",
|
||||
|
||||
@@ -359,13 +359,18 @@ cleanup_canonical_inputs() {
|
||||
# A derived check accepts any HTTP answer below 500: the application is up,
|
||||
# even when its first page is a redirect or asks for a login.
|
||||
healthcheck_probe() {
|
||||
local code status=0
|
||||
if [[ ${HC_ANY_STATUS:-false} == true ]]; then
|
||||
local code
|
||||
code=$(curl "${CURL_ARGS[@]}" "$HC_URL" 2>/dev/null) || return 1
|
||||
[[ $code =~ ^[1-4][0-9][0-9]$ ]]
|
||||
code=$(curl "${CURL_ARGS[@]}" "$HC_URL" 2>/dev/null) || status=$?
|
||||
(( status == 0 )) && [[ $code =~ ^[1-4][0-9][0-9]$ ]] && return 0
|
||||
else
|
||||
curl "${CURL_ARGS[@]}" "$HC_URL" 2>/dev/null
|
||||
curl "${CURL_ARGS[@]}" "$HC_URL" >/dev/null 2>&1 || status=$?
|
||||
(( status == 0 )) && return 0
|
||||
fi
|
||||
# A port taken from the compose file may serve a database or SSH: an answer
|
||||
# in another protocol (HTTP/0.9, weird or empty reply, reset) means it is up.
|
||||
[[ ${HC_ACCEPT_NON_HTTP:-false} == true ]] \
|
||||
&& (( status == 1 || status == 8 || status == 52 || status == 56 ))
|
||||
}
|
||||
|
||||
cleanup_failed_install() {
|
||||
@@ -1025,15 +1030,18 @@ apply_extra_hosts() {
|
||||
}
|
||||
|
||||
# Settings an update gives back to the rebuilt container before it starts:
|
||||
# the LAN leg of a suite or stack member and its start order.
|
||||
# the LAN leg of a suite or stack member, its start order and current tags.
|
||||
apply_kept_settings() {
|
||||
local key value
|
||||
while IFS=$'\t' read -r key value; do
|
||||
[[ -n $key ]] || continue
|
||||
[[ $key == net1 || $key == startup ]] \
|
||||
[[ $key == net1 || $key == startup || $key == tags ]] \
|
||||
|| die "$(translate "Unsupported kept setting:") $key"
|
||||
oci_quiet pct set "$VMID" "--$key" "$value" \
|
||||
|| die "$(translate "Could not restore the container setting:") $key"
|
||||
if [[ $key == tags && -z $value ]]; then
|
||||
oci_quiet pct set "$VMID" --delete tags
|
||||
else
|
||||
oci_quiet pct set "$VMID" "--$key" "$value"
|
||||
fi || die "$(translate "Could not restore the container setting:") $key"
|
||||
done < <(jq -r '.kept_proxmox_settings // {} | to_entries[] | [.key, .value] | @tsv' "$DEPLOYMENT_FILE")
|
||||
}
|
||||
|
||||
@@ -1091,7 +1099,7 @@ ensure_runtime_home() {
|
||||
passwd_file="${rootfs}/etc/passwd"
|
||||
mount_ct_rootfs
|
||||
if ! home=$(python3 "$OCI_RUNTIME_RESOLVER" --home "$passwd_file" "$uid" 2>>"${OCI_LOG:-/dev/stderr}"); then
|
||||
oci_quiet pct unmount "$VMID" || true
|
||||
oci_quiet pct unmount "$VMID" || die "$(translate "Could not unmount the container filesystem")"
|
||||
return 0
|
||||
fi
|
||||
oci_quiet pct unmount "$VMID" || die "$(translate "Could not unmount the container filesystem")"
|
||||
@@ -1230,6 +1238,17 @@ apply_installer_profile() {
|
||||
fi
|
||||
[[ -d ${rootfs}${target} ]] \
|
||||
|| { oci_log "The volume to prepare does not exist: $target"; failed=1; break; }
|
||||
# A reused volume may intentionally be mounted read-only (for example,
|
||||
# /run/secrets). Do not remove files or change ownership/mode on it.
|
||||
# Use the most specific containing mount: an RW child can override RO.
|
||||
if [[ $(jq -r --arg target "$target" '
|
||||
[.mounts[]? | select(.container_path as $path |
|
||||
$target == $path or ($target | startswith($path + "/")))]
|
||||
| sort_by(.container_path | length) | last | .read_only // false' \
|
||||
"$DEPLOYMENT_FILE") == true ]]; then
|
||||
oci_log "Skipping preparation of read-only volume: $target"
|
||||
continue
|
||||
fi
|
||||
if [[ $remove_lost_found == true ]]; then
|
||||
rm -rf "${rootfs}${target}/lost+found" || { failed=1; break; }
|
||||
fi
|
||||
@@ -2253,6 +2272,7 @@ if [[ $START_AFTER == 1 && $HAS_STARTUP_HEALTHCHECK == 1 ]]; then
|
||||
[[ -n $IP ]] || die "$(translate "The healthcheck cannot run without an IP address")"
|
||||
HC_URL="${HC_SCHEME}://${IP}:${HC_PORT}${HC_PATH}"
|
||||
HC_ANY_STATUS=$(jq -r '.accept_any_status // false' <<<"$STARTUP_HEALTHCHECK")
|
||||
HC_ACCEPT_NON_HTTP=$(jq -r '.accept_non_http // false' <<<"$STARTUP_HEALTHCHECK")
|
||||
if [[ $HC_ANY_STATUS == true ]]; then
|
||||
CURL_ARGS=(-sS --noproxy '*' -o /dev/null -w '%{http_code}' --max-time "$HC_REQUEST_TIMEOUT")
|
||||
else
|
||||
|
||||
@@ -352,6 +352,22 @@ def profile_keys(deployment):
|
||||
return keys
|
||||
|
||||
|
||||
GUESSED_ENDPOINT = 'compose-first-tcp-port-fallback'
|
||||
# curl exit codes for a port that accepted the connection and answered in a
|
||||
# protocol other than HTTP: HTTP/0.9 refused, weird reply, empty reply, reset.
|
||||
NON_HTTP_ANSWER = (1, 8, 52, 56)
|
||||
|
||||
|
||||
def probe_http(args):
|
||||
"""Run one curl probe and return its output and exit code."""
|
||||
log('$ ' + shlex.join(str(arg) for arg in args))
|
||||
try:
|
||||
result = subprocess.run(args, capture_output=True, timeout=60, close_fds=True)
|
||||
except subprocess.TimeoutExpired:
|
||||
return b'', -1
|
||||
return result.stdout, result.returncode
|
||||
|
||||
|
||||
def effective_healthcheck(template):
|
||||
"""The template's own health check or, when it has none, one derived from
|
||||
its web address: any HTTP answer below 500 means the application is up."""
|
||||
@@ -367,9 +383,14 @@ def effective_healthcheck(template):
|
||||
path = str(endpoint.get('path') or '/')
|
||||
if (endpoint.get('scheme') in ('http', 'https') and str(endpoint.get('port', '')).isdigit()
|
||||
and path.startswith('/') and not any(c.isspace() for c in path)):
|
||||
return {'scheme': endpoint['scheme'], 'port': int(endpoint['port']), 'path': path,
|
||||
'timeout_seconds': 300, 'request_timeout_seconds': 10, 'stability_seconds': 4,
|
||||
'verify_tls': False, 'accept_any_status': True}
|
||||
check = {'scheme': endpoint['scheme'], 'port': int(endpoint['port']), 'path': path,
|
||||
'timeout_seconds': 300, 'request_timeout_seconds': 10, 'stability_seconds': 4,
|
||||
'verify_tls': False, 'accept_any_status': True}
|
||||
# A port taken from the compose file because the recipe names no
|
||||
# web address may serve a database or SSH: any answer counts.
|
||||
if endpoint.get('source') == GUESSED_ENDPOINT:
|
||||
check['accept_non_http'] = True
|
||||
return check
|
||||
# Without a web address, the new image must at least keep the container running.
|
||||
return {'type': 'running', 'timeout_seconds': 90, 'stability_seconds': 20}
|
||||
|
||||
@@ -415,10 +436,9 @@ def candidate_contract(record, operation, proposal=None):
|
||||
return with_default_healthcheck(result)
|
||||
|
||||
|
||||
# Resource settings edited in Proxmox that the new container keeps.
|
||||
# Proxmox settings the rebuilt container gets back exactly as they are: the
|
||||
# LAN leg ProxMenux adds to a suite or stack member, and the start order.
|
||||
KEPT_AS_IS = ('net1', 'startup')
|
||||
# LAN leg ProxMenux adds to a suite or stack member, the start order and tags.
|
||||
KEPT_AS_IS = ('net1', 'startup', 'tags')
|
||||
ADOPTABLE = {'memory': ('resources', 'memory_mb'), 'swap': ('resources', 'swap_mb'),
|
||||
'cores': ('resources', 'cores'), 'cpulimit': ('resources', 'cores'),
|
||||
'cpuunits': ('resources', 'cpu_units'), 'onboot': (None, 'onboot')}
|
||||
@@ -734,11 +754,10 @@ def healthcheck(vmid, template):
|
||||
args += ['-w', '%{http_code}']
|
||||
if not check.get('verify_tls', False):
|
||||
args.append('-k')
|
||||
try:
|
||||
answer = run(*args, f'{scheme}://{ip}:{port}{path}')
|
||||
ok = not any_status or re.fullmatch(rb'[1-4][0-9][0-9]', answer.strip()) is not None
|
||||
except RuntimeError:
|
||||
pass
|
||||
answer, status = probe_http([*args, f'{scheme}://{ip}:{port}{path}'])
|
||||
ok = status == 0 and (not any_status or re.fullmatch(rb'[1-4][0-9][0-9]', answer.strip()) is not None)
|
||||
if not ok and check.get('accept_non_http') and status in NON_HTTP_ANSWER:
|
||||
ok = True
|
||||
if ok:
|
||||
stable_since = stable_since or time.monotonic()
|
||||
if time.monotonic() - stable_since >= stability:
|
||||
@@ -1051,6 +1070,9 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
|
||||
'original_gpu_devices': original_gpu, 'desired_gpu_devices': desired_gpu,
|
||||
'was_running': run('pct', 'status', str(vmid)).strip() == b'status: running'}
|
||||
state['kept_config'] = {key: cfg[key] for key in KEPT_AS_IS if key in cfg}
|
||||
# The installer adds default tags. Preserve an explicit absence too, so
|
||||
# deleting all tags in Proxmox does not bring them back on the next update.
|
||||
state['kept_config']['tags'] = cfg.get('tags', '')
|
||||
if not coordinated and state['kept_config']:
|
||||
# Applied by the installer before the new container starts, so the
|
||||
# application has its LAN leg from the first second.
|
||||
@@ -1058,9 +1080,10 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
|
||||
if coordinated:
|
||||
state['coordinated'] = coordinated
|
||||
state['preserved_stack_config'] = {key: cfg[key] for key in ('net1', 'startup', 'hookscript') if key in cfg}
|
||||
state['preserved_stack_config']['tags'] = cfg.get('tags', '')
|
||||
if operation == 'update':
|
||||
state['preserved_stack_config'].update({key: cfg[key] for key in
|
||||
('net0', 'features', 'cmode', 'console', 'tty', 'cpuunits', 'tags') if key in cfg})
|
||||
('net0', 'features', 'cmode', 'console', 'tty', 'cpuunits') if key in cfg})
|
||||
checkpoint(journal, state, 'prepared')
|
||||
pending = copy.deepcopy(record)
|
||||
pending.update(status='updating', pending_transaction=str(journal), transaction_id=state['id'])
|
||||
@@ -1141,7 +1164,10 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
|
||||
restore_firewall(vmid, state)
|
||||
if coordinated:
|
||||
for key, value in state['preserved_stack_config'].items():
|
||||
run('pct', 'set', str(vmid), '--' + key, value)
|
||||
if key == 'tags' and not value:
|
||||
run('pct', 'set', str(vmid), '--delete', 'tags')
|
||||
else:
|
||||
run('pct', 'set', str(vmid), '--' + key, value)
|
||||
check_runtime_mounts(run('pct', 'config', str(vmid)), candidate['deployment'])
|
||||
gpu_devices.check(run('pct', 'config', str(vmid)), candidate['deployment'])
|
||||
state['staged_config_sha256'] = sha(owned(vmid, record['installation_id']))
|
||||
|
||||
@@ -230,8 +230,9 @@ def identity(config):
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def same_config_except_notes(record, config):
|
||||
"""Accept a presentation-only note edit, never a changed OCI identity or LXC setting."""
|
||||
def same_config_except_notes(record, config, ignore_tags=False):
|
||||
"""Accept a presentation-only note edit, never a changed OCI identity or LXC setting.
|
||||
With `ignore_tags`, Proxmox tags may differ too: the operation keeps them as they are."""
|
||||
previous = record['observed']['config'].encode()
|
||||
expected = record['installation_id']
|
||||
if identity(previous) != expected or identity(config) != expected:
|
||||
@@ -242,7 +243,8 @@ def same_config_except_notes(record, config):
|
||||
notes = [line for line in lines if line.startswith(b'description: ')]
|
||||
if len(notes) != 1:
|
||||
return None
|
||||
return b''.join(line for line in lines if not line.startswith(b'description: '))
|
||||
dropped = (b'description: ', b'tags: ') if ignore_tags else (b'description: ',)
|
||||
return b''.join(line for line in lines if not line.startswith(dropped))
|
||||
|
||||
original = without_notes(previous)
|
||||
return original is not None and original == without_notes(config)
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import stat
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
@@ -90,14 +92,39 @@ def effective_entrypoint(
|
||||
|
||||
|
||||
def home_directory(passwd_path: Path, uid: int) -> str | None:
|
||||
"""Return the safe home directory recorded for a container UID."""
|
||||
for line in passwd_path.read_text(encoding="utf-8").splitlines():
|
||||
"""Read a regular image passwd file; only an absent UID defaults to /."""
|
||||
if uid < 0:
|
||||
return None
|
||||
# The caller supplies <mounted rootfs>/etc/passwd. Pin etc so an image
|
||||
# symlink or a rename during lookup cannot redirect the host-side read.
|
||||
directory = os.open(passwd_path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
try:
|
||||
if not stat.S_ISREG(os.stat(passwd_path.name, dir_fd=directory, follow_symlinks=False).st_mode):
|
||||
return None
|
||||
descriptor = os.open(
|
||||
passwd_path.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory,
|
||||
)
|
||||
try:
|
||||
# Recheck the opened object, not its name; a FIFO replacement
|
||||
# must neither block open() nor reach a read operation.
|
||||
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
|
||||
return None
|
||||
with os.fdopen(descriptor, encoding="utf-8", closefd=False) as stream:
|
||||
content = stream.read()
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
finally:
|
||||
os.close(directory)
|
||||
for line in content.splitlines():
|
||||
fields = line.split(":")
|
||||
if len(fields) < 7 or fields[2] != str(uid):
|
||||
if len(fields) < 3 or fields[2] != str(uid):
|
||||
continue
|
||||
if len(fields) != 7:
|
||||
return None
|
||||
home = fields[5]
|
||||
return home if home.startswith("/") else None
|
||||
return None
|
||||
safe = home.startswith("/") and not any(ord(char) < 32 or ord(char) == 127 for char in home)
|
||||
return home if safe else None
|
||||
return "/"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@@ -192,7 +192,7 @@ class NativeAdapter:
|
||||
config = instances.command('pct', 'config', str(vmid))
|
||||
if instances.identity(config) != record['installation_id']:
|
||||
raise ValueError(translate('The identity of a member was replaced'))
|
||||
if (not self.journal.exists() or not self.state().get('replacement_intent')) and not instances.same_config_except_notes(record, config):
|
||||
if (not self.journal.exists() or not self.state().get('replacement_intent')) and not instances.same_config_except_notes(record, config, ignore_tags=True):
|
||||
raise ValueError(translate('A member configuration changed during the preparation'))
|
||||
else:
|
||||
if Path('/etc/pve/lxc/%s.conf' % vmid).exists():
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Proxmox tags can change without preventing OCI management or being lost."""
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'remote'))
|
||||
|
||||
import oci_instance_reconcile as reconcile
|
||||
import oci_instance_transaction as transaction
|
||||
from oci_installation_state import sha
|
||||
|
||||
MARKER = '11111111-1111-4111-8111-111111111111'
|
||||
BASE = (f'description: proxmenux-instance={MARKER}\n'
|
||||
'rootfs: local-lvm:vm-200-disk-0,size=8G\n'
|
||||
'unprivileged: 1\ncores: 2\nmemory: 512\n'
|
||||
'net0: name=eth0,bridge=vmbr0,hwaddr=02:00:00:00:00:01,ip=dhcp\n'
|
||||
'net1: name=eth1,bridge=vmbr1,ip=dhcp\nstartup: order=20\n').encode()
|
||||
|
||||
|
||||
def config(tags):
|
||||
return BASE + (f'tags: {tags}\n'.encode() if tags is not None else b'')
|
||||
|
||||
|
||||
def record(tags='oci;proxmenux'):
|
||||
before = config(tags)
|
||||
return {
|
||||
'schema_version': 1, 'vmid': 200, 'status': 'installed',
|
||||
'installation_id': MARKER,
|
||||
'template': {'id': 'test-app', 'container_contract': {'volumes': []}},
|
||||
'deployment': {
|
||||
'vmid': 200, 'rootfs': {'storage': 'local-lvm', 'size_gb': 8},
|
||||
'network': {}, 'resources': {'cores': 2, 'memory_mb': 512},
|
||||
'mounts': [], 'devices': [],
|
||||
},
|
||||
'observed': {
|
||||
'config': before.decode(), 'config_sha256': sha(before),
|
||||
'image': {'architecture': 'amd64', 'manifest_digest': 'sha256:old',
|
||||
'defaults': {'Volumes': {}}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ExternalTagsTests(unittest.TestCase):
|
||||
def test_adding_editing_reordering_and_removing_tags_need_no_adoption(self):
|
||||
for old, new in ((None, 'homelab'), ('oci;proxmenux', 'homelab;oci;proxmenux'),
|
||||
('oci;proxmenux', 'proxmenux;oci'), ('oci;proxmenux', None),
|
||||
('oci;proxmenux', '')):
|
||||
with self.subTest(old=old, new=new):
|
||||
saved, current = record(old), config(new)
|
||||
self.assertIsNone(reconcile.propose(saved, current))
|
||||
self.assertEqual(transaction.external_changes(saved, current), {})
|
||||
|
||||
def test_tags_can_change_together_with_cpu_and_memory(self):
|
||||
current = config('homelab').replace(b'cores: 2', b'cores: 4').replace(b'memory: 512', b'memory: 2048')
|
||||
self.assertIsNone(reconcile.propose(record(), current))
|
||||
self.assertEqual(transaction.external_changes(record(), current), {'cores': 4, 'memory': 2048})
|
||||
|
||||
def test_tag_changes_do_not_bypass_identity_or_network_checks(self):
|
||||
for current in (config('homelab').replace(MARKER.encode(), b'22222222-2222-4222-8222-222222222222'),
|
||||
config('homelab').replace(b'bridge=vmbr0', b'bridge=vmbr2')):
|
||||
with self.subTest(config=current):
|
||||
with self.assertRaises(ValueError):
|
||||
reconcile.propose(record(), current)
|
||||
with self.assertRaises(ValueError):
|
||||
transaction.external_changes(record(), current)
|
||||
|
||||
def test_new_volume_still_requires_adoption_when_tags_also_change(self):
|
||||
saved = record()
|
||||
current = config('homelab') + b'mp0: local-lvm:vm-200-disk-1,mp=/data,size=8G,backup=1\n'
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder)
|
||||
path = root / '200/oci-compose.json'
|
||||
path.parent.mkdir()
|
||||
path.write_text(json.dumps(saved))
|
||||
with patch.object(reconcile.instances, 'ROOT', root):
|
||||
proposal = reconcile.propose(saved, current)
|
||||
self.assertEqual(proposal['candidate']['deployment']['mounts'][0]['container_path'], '/data')
|
||||
self.assertEqual(len(proposal['details']), 1)
|
||||
self.assertEqual(json.loads(path.read_text()), saved)
|
||||
|
||||
|
||||
class Prepared(Exception):
|
||||
"""Stop a mocked transaction before it can stop or modify any container."""
|
||||
|
||||
|
||||
class TagReplayTests(unittest.TestCase):
|
||||
def prepare(self, folder, tags, operation, coordinated=None):
|
||||
current, saved = config(tags), record()
|
||||
archive = folder / 'image.tar'
|
||||
archive.write_bytes(b'fixture')
|
||||
states = []
|
||||
|
||||
def checkpoint(_path, state, phase):
|
||||
self.assertEqual(phase, 'prepared')
|
||||
states.append(copy.deepcopy(state))
|
||||
raise Prepared()
|
||||
|
||||
def command(*args):
|
||||
if args[:2] == ('pct', 'config'):
|
||||
return current
|
||||
if args[:3] == ('pvesh', 'get', '/cluster/ha/resources'):
|
||||
return b'[]'
|
||||
if args[:2] == ('pct', 'status'):
|
||||
return b'status: running'
|
||||
self.fail(f'Unexpected host command: {args}')
|
||||
|
||||
proposal = {'operation': 'recreate', 'candidate': copy.deepcopy(saved)} if operation == 'recreate' else None
|
||||
image = {'architecture': 'amd64', 'os': 'linux', 'manifest_digest': 'sha256:new', 'defaults': {}}
|
||||
with (patch.object(transaction.instances, 'read', return_value=saved),
|
||||
patch.object(transaction, 'run', side_effect=command),
|
||||
patch.object(transaction, 'check_archive'),
|
||||
patch.object(transaction, 'image_from_archive', return_value=image),
|
||||
patch.object(transaction.work_backup, 'locate', side_effect=lambda directory, *_: directory),
|
||||
patch.object(transaction, 'open_log'),
|
||||
patch.object(transaction, 'checkpoint', side_effect=checkpoint),
|
||||
redirect_stdout(io.StringIO()), self.assertRaises(Prepared)):
|
||||
transaction.apply(folder, 200, archive, operation, proposal=proposal, coordinated=coordinated)
|
||||
return states[0]
|
||||
|
||||
@unittest.skipUnless(shutil.which('bash') and shutil.which('jq'), 'bash and jq are needed')
|
||||
def test_update_and_modify_replay_current_tags_including_no_tags(self):
|
||||
installer = (ROOT / 'remote/install_oci.sh').read_text()
|
||||
function = re.search(r'^apply_kept_settings\(\) \{\n.*?^\}\n', installer, re.M | re.S)
|
||||
self.assertIsNotNone(function)
|
||||
for operation in ('update', 'recreate'):
|
||||
for tags in ('homelab;oci;proxmenux', 'oci', None, ''):
|
||||
with self.subTest(operation=operation, tags=tags), tempfile.TemporaryDirectory() as name:
|
||||
folder = Path(name)
|
||||
state = self.prepare(folder, tags, operation)
|
||||
kept = state['runtime_deployment']['kept_proxmox_settings']
|
||||
deployment = folder / 'deployment.json'
|
||||
deployment.write_text(json.dumps({'kept_proxmox_settings': kept}))
|
||||
calls = folder / 'calls'
|
||||
script = '''
|
||||
pct() { printf '%s\\0' "$@" >> "$CALLS"; printf '\\n' >> "$CALLS"; }
|
||||
oci_quiet() { "$@"; }
|
||||
translate() { printf '%s' "$1"; }
|
||||
die() { printf '%s\\n' "$*" >&2; exit 1; }
|
||||
'''
|
||||
done = subprocess.run(['bash', '-euc', script + function.group(0) + '\napply_kept_settings'],
|
||||
env=dict(os.environ, VMID='200', DEPLOYMENT_FILE=str(deployment), CALLS=str(calls)),
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(done.returncode, 0, done.stderr)
|
||||
commands = [line.split(b'\0')[:-1] for line in calls.read_bytes().splitlines()]
|
||||
expected = [b'set', b'200', b'--tags', tags.encode()] if tags else [b'set', b'200', b'--delete', b'tags']
|
||||
self.assertIn(expected, commands)
|
||||
self.assertIn([b'set', b'200', b'--net1', b'name=eth1,bridge=vmbr1,ip=dhcp'], commands)
|
||||
self.assertIn([b'set', b'200', b'--startup', b'order=20'], commands)
|
||||
|
||||
def test_stack_operations_also_preserve_tags_and_their_absence(self):
|
||||
for operation in ('update', 'recreate'):
|
||||
for tags in ('homelab;oci', None, ''):
|
||||
with self.subTest(operation=operation, tags=tags), tempfile.TemporaryDirectory() as name:
|
||||
state = self.prepare(Path(name), tags, operation, coordinated={'stack': 'test'})
|
||||
self.assertEqual(state['preserved_stack_config']['tags'], tags or '')
|
||||
|
||||
@unittest.skipUnless(shutil.which('bash') and shutil.which('jq'), 'bash and jq are needed')
|
||||
def test_native_stack_check_accepts_tag_edits_and_nothing_else(self):
|
||||
saved = record()
|
||||
instances = transaction.instances
|
||||
for new in ('homelab;oci;proxmenux', 'proxmenux;oci', '', None):
|
||||
with self.subTest(new=new):
|
||||
self.assertTrue(instances.same_config_except_notes(saved, config(new), ignore_tags=True))
|
||||
self.assertFalse(instances.same_config_except_notes(saved, config(new)))
|
||||
changed = config('homelab').replace(b'cores: 2', b'cores: 4')
|
||||
self.assertFalse(instances.same_config_except_notes(saved, changed, ignore_tags=True))
|
||||
other = config('homelab').replace(MARKER.encode(), b'22222222-2222-4222-8222-222222222222')
|
||||
self.assertFalse(instances.same_config_except_notes(saved, other, ignore_tags=True))
|
||||
source = (ROOT / 'remote/oci_stack_native.py').read_text()
|
||||
self.assertIn('same_config_except_notes(record, config, ignore_tags=True)', source)
|
||||
|
||||
def test_installer_still_refuses_unsupported_kept_settings(self):
|
||||
installer = (ROOT / 'remote/install_oci.sh').read_text()
|
||||
function = re.search(r'^apply_kept_settings\(\) \{\n.*?^\}\n', installer, re.M | re.S).group(0)
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
deployment = Path(name) / 'deployment.json'
|
||||
deployment.write_text(json.dumps({'kept_proxmox_settings': {'unprivileged': '0'}}))
|
||||
script = '''
|
||||
oci_quiet() { echo 'must not run' >&2; exit 99; }
|
||||
translate() { printf '%s' "$1"; }
|
||||
die() { printf '%s\\n' "$*" >&2; exit 1; }
|
||||
'''
|
||||
done = subprocess.run(['bash', '-euc', script + function + '\napply_kept_settings'],
|
||||
env=dict(os.environ, VMID='200', DEPLOYMENT_FILE=str(deployment)),
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(done.returncode, 1)
|
||||
self.assertIn('Unsupported kept setting: unprivileged', done.stderr)
|
||||
self.assertNotIn('must not run', done.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""A web address guessed from the compose file may belong to a service that does
|
||||
not speak HTTP. Updates, Modify and recovery must accept its answer instead of
|
||||
waiting for an HTTP reply that never comes."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'remote'))
|
||||
|
||||
import oci_instance_transaction as transaction
|
||||
|
||||
INSTALLER = ROOT / 'remote/install_oci.sh'
|
||||
GUESSED = {'first_run': {'endpoints': [{'label': 'Web UI', 'scheme': 'http', 'port': 3306, 'path': '/',
|
||||
'source': 'compose-first-tcp-port-fallback'}]}}
|
||||
DECLARED = {'first_run': {'endpoints': [{'label': 'Web UI', 'scheme': 'http', 'port': 8080, 'path': '/'}]}}
|
||||
|
||||
|
||||
def probe_function():
|
||||
source = INSTALLER.read_text()
|
||||
start = source.index('healthcheck_probe() {')
|
||||
end = source.index('\n}\n', start) + 3
|
||||
return source[start:end]
|
||||
|
||||
|
||||
class GuessedEndpointCheckTests(unittest.TestCase):
|
||||
def test_only_a_guessed_endpoint_accepts_other_protocols(self):
|
||||
self.assertTrue(transaction.effective_healthcheck(GUESSED)['accept_non_http'])
|
||||
self.assertNotIn('accept_non_http', transaction.effective_healthcheck(DECLARED))
|
||||
|
||||
def run_probe(self, exit_code, code='000', accept_non_http='true', any_status='true'):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
fake = Path(directory) / 'curl'
|
||||
fake.write_text(f'#!/bin/sh\nprintf %s {code}\nexit {exit_code}\n')
|
||||
fake.chmod(0o755)
|
||||
script = (probe_function() + '\nCURL_ARGS=()\nHC_URL=http://192.0.2.1:3306/\n'
|
||||
f'HC_ANY_STATUS={any_status}\nHC_ACCEPT_NON_HTTP={accept_non_http}\nhealthcheck_probe\n')
|
||||
env = {**os.environ, 'PATH': f'{directory}:{os.environ["PATH"]}'}
|
||||
return subprocess.run(['bash', '-c', script], env=env, capture_output=True).returncode
|
||||
|
||||
def test_installer_probe_accepts_a_non_http_answer_only_when_guessed(self):
|
||||
for status in transaction.NON_HTTP_ANSWER:
|
||||
with self.subTest(status=status):
|
||||
self.assertEqual(self.run_probe(status), 0)
|
||||
self.assertNotEqual(self.run_probe(status, accept_non_http='false'), 0)
|
||||
# A closed port, a timeout or a server error still fail.
|
||||
for status, code in ((7, '000'), (28, '000'), (0, '503')):
|
||||
with self.subTest(status=status, code=code):
|
||||
self.assertNotEqual(self.run_probe(status, code), 0)
|
||||
self.assertEqual(self.run_probe(0, '200'), 0)
|
||||
self.assertEqual(self.run_probe(0, any_status='false', accept_non_http='false'), 0)
|
||||
|
||||
def test_recovery_check_accepts_a_database_answer(self):
|
||||
check = transaction.effective_healthcheck(GUESSED)
|
||||
check.update(timeout_seconds=6, stability_seconds=0)
|
||||
template = {'proxmox': {'installer_profile': {'startup_healthcheck': check}}}
|
||||
|
||||
def run(*args):
|
||||
if args[:2] == ('pct', 'status'):
|
||||
return b'status: running\n'
|
||||
if args[:2] == ('pct', 'config'):
|
||||
return b''
|
||||
if args[0] == 'lxc-info':
|
||||
return b'192.0.2.10\n'
|
||||
raise AssertionError(args)
|
||||
|
||||
with patch.object(transaction, 'run', side_effect=run), \
|
||||
patch.object(transaction, 'probe_http', return_value=(b'000', 1)), \
|
||||
patch.object(transaction, 'log'):
|
||||
self.assertEqual(transaction.healthcheck(113, template), 'http://192.0.2.10:3306/')
|
||||
declared = transaction.effective_healthcheck(DECLARED)
|
||||
declared.update(timeout_seconds=4, stability_seconds=0)
|
||||
template = {'proxmox': {'installer_profile': {'startup_healthcheck': declared}}}
|
||||
with patch.object(transaction, 'run', side_effect=run), \
|
||||
patch.object(transaction, 'probe_http', return_value=(b'000', 1)), \
|
||||
patch.object(transaction, 'log'), patch.object(transaction.time, 'sleep'):
|
||||
with self.assertRaises(ValueError):
|
||||
transaction.healthcheck(113, template)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Run the installer's volume preparation loop against filesystem fixtures.
|
||||
|
||||
Write doubles reject every mutation of the protected directory, including
|
||||
lost+found removal. Writable paths still run real rm/chmod; chown is recorded
|
||||
without requiring root. These tests never invoke Proxmox or touch a container.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
INSTALLER = ROOT / "remote/install_oci.sh"
|
||||
|
||||
|
||||
def preparation_loop():
|
||||
source = INSTALLER.read_text()
|
||||
start = source.index(" if (( failed == 0 )) && [[ $(jq -r '.stack_managed")
|
||||
end = source.index("\n if (( failed == 0 && tls_count == 1 )); then", start)
|
||||
return source[start:end]
|
||||
|
||||
|
||||
def preparation(target):
|
||||
return {
|
||||
"container_path": target,
|
||||
"remove_lost_found": True,
|
||||
"owner_strategy": "mapped-application-user",
|
||||
"mode": "0750",
|
||||
}
|
||||
|
||||
|
||||
class ReadOnlyVolumePreparationTests(unittest.TestCase):
|
||||
def run_case(self, mounts, *, target="/run/secrets", read_only=True,
|
||||
preparations=None):
|
||||
preparations = preparations if preparations is not None else [preparation(target)]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
fixture = Path(directory)
|
||||
rootfs = fixture / "rootfs"
|
||||
for item in preparations:
|
||||
path = rootfs / item["container_path"].lstrip("/")
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
(path / "lost+found").mkdir(exist_ok=True)
|
||||
protected = rootfs / target.lstrip("/")
|
||||
protected.mkdir(parents=True, exist_ok=True)
|
||||
sentinel = protected / "sentinel"
|
||||
sentinel.write_bytes(b"existing secret\n")
|
||||
sentinel.chmod(0o600)
|
||||
protected.chmod(0o700)
|
||||
before = sentinel.stat()
|
||||
template = {"proxmox": {"installer_profile": {
|
||||
"volume_preparations": preparations,
|
||||
}}}
|
||||
(fixture / "template.json").write_text(json.dumps(template))
|
||||
(fixture / "deployment.json").write_text(json.dumps({"mounts": mounts}))
|
||||
script = r'''set -Eeuo pipefail
|
||||
rootfs="$FIXTURE/rootfs"
|
||||
TEMPLATE_FILE="$FIXTURE/template.json"
|
||||
DEPLOYMENT_FILE="$FIXTURE/deployment.json"
|
||||
HOST_BIND_UID=110001; HOST_BIND_GID=110001
|
||||
HOST_ROOT_UID=100000; HOST_ROOT_GID=100000
|
||||
failed=0; volumes_prepared=0
|
||||
oci_log() { printf 'log %s\n' "$*"; }
|
||||
write_op() {
|
||||
local operation=$1
|
||||
shift
|
||||
local path=${!#}
|
||||
printf 'WRITE %s %s\n' "$operation" "$path"
|
||||
if [[ -n $PROTECTED && ( $path == "$PROTECTED" || $path == "$PROTECTED/"* ) ]]; then
|
||||
printf 'Read-only file system\n' >&2
|
||||
return 1
|
||||
fi
|
||||
case "$operation" in
|
||||
rm|chmod) command "$operation" "$@" ;;
|
||||
chown) return 0 ;;
|
||||
esac
|
||||
}
|
||||
chown() { write_op chown "$@"; }
|
||||
chmod() { write_op chmod "$@"; }
|
||||
rm() { write_op rm "$@"; }
|
||||
''' + preparation_loop() + r'''
|
||||
printf 'failed=%s prepared=%s\n' "$failed" "$volumes_prepared"
|
||||
test "$failed" = 0
|
||||
'''
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script], capture_output=True, text=True,
|
||||
env=dict(os.environ, FIXTURE=str(fixture),
|
||||
PROTECTED=str(protected) if read_only else ""),
|
||||
timeout=15,
|
||||
)
|
||||
self.assertEqual(sentinel.read_bytes(), b"existing secret\n")
|
||||
after = sentinel.stat()
|
||||
self.assertEqual(
|
||||
(before.st_ino, before.st_uid, before.st_gid, before.st_mode, before.st_mtime_ns),
|
||||
(after.st_ino, after.st_uid, after.st_gid, after.st_mode, after.st_mtime_ns),
|
||||
)
|
||||
if read_only:
|
||||
self.assertEqual(protected.stat().st_mode & 0o777, 0o700)
|
||||
self.assertTrue((protected / "lost+found").is_dir())
|
||||
elif "prepared=1" in result.stdout:
|
||||
self.assertEqual(protected.stat().st_mode & 0o777, 0o750)
|
||||
self.assertFalse((protected / "lost+found").exists())
|
||||
return result
|
||||
|
||||
def assert_skipped(self, result):
|
||||
self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
|
||||
self.assertNotIn("WRITE ", result.stdout)
|
||||
self.assertIn("Skipping preparation of read-only volume", result.stdout)
|
||||
self.assertIn("prepared=0", result.stdout)
|
||||
|
||||
def assert_prepared(self, result):
|
||||
self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
|
||||
for operation in ("rm", "chown", "chmod"):
|
||||
self.assertIn(f"WRITE {operation} ", result.stdout)
|
||||
self.assertIn("prepared=1", result.stdout)
|
||||
|
||||
def test_read_only_managed_and_bind_volumes_receive_no_writes(self):
|
||||
for mount_type in ("managed-volume", "host-bind"):
|
||||
with self.subTest(mount_type=mount_type):
|
||||
self.assert_skipped(self.run_case([
|
||||
{"container_path": "/run/secrets", "type": mount_type, "read_only": True},
|
||||
]))
|
||||
|
||||
def test_read_only_parent_protects_nested_preparation(self):
|
||||
self.assert_skipped(self.run_case([
|
||||
{"container_path": "/run", "read_only": True},
|
||||
]))
|
||||
|
||||
def test_writable_child_overrides_read_only_parent_regardless_of_order(self):
|
||||
mounts = [
|
||||
{"container_path": "/run", "read_only": True},
|
||||
{"container_path": "/run/secrets", "read_only": False},
|
||||
]
|
||||
for ordered in (mounts, mounts[::-1]):
|
||||
with self.subTest(mounts=ordered):
|
||||
self.assert_prepared(self.run_case(ordered, read_only=False))
|
||||
|
||||
def test_child_without_read_only_flag_overrides_read_only_parent(self):
|
||||
self.assert_prepared(self.run_case([
|
||||
{"container_path": "/run", "read_only": True},
|
||||
{"container_path": "/run/secrets"},
|
||||
], read_only=False))
|
||||
|
||||
def test_read_only_child_overrides_writable_parent_regardless_of_order(self):
|
||||
mounts = [
|
||||
{"container_path": "/run/secrets", "read_only": True},
|
||||
{"container_path": "/run", "read_only": False},
|
||||
]
|
||||
for ordered in (mounts, mounts[::-1]):
|
||||
with self.subTest(mounts=ordered):
|
||||
self.assert_skipped(self.run_case(ordered))
|
||||
|
||||
def test_writable_volume_is_still_prepared(self):
|
||||
self.assert_prepared(self.run_case([
|
||||
{"container_path": "/data", "read_only": False},
|
||||
], target="/data", read_only=False))
|
||||
|
||||
def test_missing_read_only_flag_defaults_to_writable(self):
|
||||
self.assert_prepared(self.run_case([
|
||||
{"container_path": "/data"},
|
||||
], target="/data", read_only=False))
|
||||
|
||||
def test_unrelated_mount_with_similar_prefix_does_not_skip_preparation(self):
|
||||
self.assert_prepared(self.run_case([
|
||||
{"container_path": "/run/secret", "read_only": True},
|
||||
], read_only=False))
|
||||
|
||||
def test_directory_outside_mounts_is_still_prepared(self):
|
||||
self.assert_prepared(self.run_case([], read_only=False))
|
||||
|
||||
def test_skipping_read_only_volume_does_not_skip_following_writable_volume(self):
|
||||
result = self.run_case([
|
||||
{"container_path": "/run/secrets", "read_only": True},
|
||||
{"container_path": "/data", "read_only": False},
|
||||
], preparations=[preparation("/run/secrets"), preparation("/data")])
|
||||
self.assert_prepared(result)
|
||||
self.assertIn("Skipping preparation of read-only volume: /run/secrets", result.stdout)
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("WRITE "):
|
||||
self.assertTrue(line.endswith("/data") or line.endswith("/data/lost+found"), line)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -31,14 +31,14 @@ class RuntimeHomeResolutionTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(oci_runtime.home_directory(passwd, 1001), "/srv/app")
|
||||
|
||||
def test_unknown_or_unsafe_home_is_not_invented(self):
|
||||
def test_unsafe_record_is_rejected_but_unknown_uid_defaults_to_root(self):
|
||||
passwd = self.passwd(
|
||||
"root:x:0:0:root:relative-home:/bin/sh\n"
|
||||
"app:x:1001:1001:App user::/sbin/nologin\n"
|
||||
)
|
||||
self.assertIsNone(oci_runtime.home_directory(passwd, 0))
|
||||
self.assertIsNone(oci_runtime.home_directory(passwd, 1001))
|
||||
self.assertIsNone(oci_runtime.home_directory(passwd, 1234))
|
||||
self.assertEqual(oci_runtime.home_directory(passwd, 1234), "/")
|
||||
|
||||
|
||||
class RuntimeHomeInstallerContractTests(unittest.TestCase):
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Exercise HOME resolution without reading image symlinks or changing guests."""
|
||||
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "remote"))
|
||||
import oci_runtime
|
||||
|
||||
|
||||
class HomeHardeningTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
(self.root / "etc").mkdir()
|
||||
self.passwd = self.root / "etc/passwd"
|
||||
self.passwd.write_text("root:x:0:0:root:/root:/bin/sh\n", encoding="utf-8")
|
||||
|
||||
def cli(self, uid="1234"):
|
||||
output, error = io.StringIO(), io.StringIO()
|
||||
with patch.object(sys, "argv", ["oci_runtime.py", "--home", str(self.passwd), uid]), \
|
||||
redirect_stdout(output), redirect_stderr(error):
|
||||
status = oci_runtime.main()
|
||||
return status, output.getvalue(), error.getvalue()
|
||||
|
||||
def assert_rejected_without_read(self):
|
||||
with patch.object(os, "fdopen", side_effect=AssertionError("unsafe file read")):
|
||||
status, output, _ = self.cli()
|
||||
self.assertEqual(status, 1)
|
||||
self.assertEqual(output, "")
|
||||
|
||||
def test_unknown_uid_uses_root_only_after_reading_a_regular_database(self):
|
||||
self.assertEqual(self.cli(), (0, "/\n", ""))
|
||||
|
||||
def test_empty_regular_database_uses_root(self):
|
||||
self.passwd.write_text("")
|
||||
self.assertEqual(self.cli(), (0, "/\n", ""))
|
||||
|
||||
def test_final_symlink_is_rejected_without_reading_its_target(self):
|
||||
target = self.root / "outside"
|
||||
target.write_text("app:x:1234:1234::/host-secret:/bin/sh\n")
|
||||
self.passwd.unlink()
|
||||
self.passwd.symlink_to(target)
|
||||
self.assert_rejected_without_read()
|
||||
self.assertEqual(target.read_text(), "app:x:1234:1234::/host-secret:/bin/sh\n")
|
||||
|
||||
def test_symlinked_etc_is_rejected_without_reading_its_target(self):
|
||||
directory = self.root / "etc"
|
||||
directory.rename(self.root / "outside-etc")
|
||||
directory.symlink_to(self.root / "outside-etc", target_is_directory=True)
|
||||
self.assert_rejected_without_read()
|
||||
self.assertEqual(self.cli("0")[:2], (1, ""))
|
||||
|
||||
def test_pinned_etc_does_not_follow_a_later_directory_replacement(self):
|
||||
original_open = os.open
|
||||
outside = self.root / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "passwd").write_text("root:x:0:0::/outside:/bin/sh\n")
|
||||
replaced = []
|
||||
def replace(path, flags, *args, **kwargs):
|
||||
if str(path) == "passwd":
|
||||
(self.root / "etc").rename(self.root / "original-etc")
|
||||
(self.root / "etc").symlink_to(outside, target_is_directory=True)
|
||||
replaced.append(True)
|
||||
return original_open(path, flags, *args, **kwargs)
|
||||
with patch.object(os, "open", side_effect=replace):
|
||||
self.assertEqual(self.cli("0"), (0, "/root\n", ""))
|
||||
self.assertEqual(replaced, [True])
|
||||
|
||||
def test_directory_is_rejected_without_reading(self):
|
||||
self.passwd.unlink()
|
||||
self.passwd.mkdir()
|
||||
self.assert_rejected_without_read()
|
||||
|
||||
def test_fifo_is_rejected_without_reading_or_waiting_for_a_writer(self):
|
||||
self.passwd.unlink()
|
||||
os.mkfifo(self.passwd)
|
||||
done = subprocess.run(
|
||||
[sys.executable, "-B", str(ROOT / "remote/oci_runtime.py"), "--home", str(self.passwd), "0"],
|
||||
capture_output=True, text=True, timeout=3,
|
||||
)
|
||||
self.assertEqual(done.returncode, 1)
|
||||
self.assertEqual(done.stdout, "")
|
||||
self.assert_rejected_without_read()
|
||||
|
||||
def test_missing_database_does_not_masquerade_as_unknown_uid(self):
|
||||
self.passwd.unlink()
|
||||
self.assert_rejected_without_read()
|
||||
|
||||
def test_permission_failure_does_not_masquerade_as_unknown_uid(self):
|
||||
with patch.object(os, "open", side_effect=PermissionError("denied")):
|
||||
self.assert_rejected_without_read()
|
||||
|
||||
def test_invalid_encoding_does_not_produce_a_fallback(self):
|
||||
self.passwd.write_bytes(b"\xff\xfe")
|
||||
status, output, _ = self.cli()
|
||||
self.assertEqual((status, output), (1, ""))
|
||||
|
||||
def test_partial_matching_record_does_not_produce_a_fallback(self):
|
||||
self.passwd.write_text("app:x:1234:1234\n")
|
||||
self.assertEqual(self.cli()[:2], (1, ""))
|
||||
|
||||
def test_read_error_closes_both_descriptors_without_a_fallback(self):
|
||||
descriptors = []
|
||||
original_open = os.open
|
||||
def opened(*args, **kwargs):
|
||||
fd = original_open(*args, **kwargs)
|
||||
descriptors.append(fd)
|
||||
return fd
|
||||
with patch.object(os, "open", side_effect=opened), \
|
||||
patch.object(os, "fdopen", side_effect=OSError("read failed")):
|
||||
self.assertEqual(self.cli()[:2], (1, ""))
|
||||
self.assertEqual(len(descriptors), 2)
|
||||
for fd in descriptors:
|
||||
with self.assertRaises(OSError):
|
||||
os.fstat(fd)
|
||||
|
||||
def test_matching_unsafe_home_is_not_replaced_with_root(self):
|
||||
for value in ("", "relative", "/bad\tvalue", "/bad\x00value", "/bad\x7fvalue"):
|
||||
with self.subTest(value=repr(value)):
|
||||
self.passwd.write_text(f"app:x:1234:1234::{value}:/bin/sh\n")
|
||||
self.assertEqual(self.cli()[:2], (1, ""))
|
||||
|
||||
def test_invalid_uid_does_not_open_the_database(self):
|
||||
for uid in ("-1", "nobody"):
|
||||
with self.subTest(uid=uid), patch.object(os, "open") as opened:
|
||||
self.assertEqual(self.cli(uid)[:2], (1, ""))
|
||||
opened.assert_not_called()
|
||||
|
||||
def test_symlink_replacement_between_check_and_open_is_not_read(self):
|
||||
original_open = os.open
|
||||
outside = self.root / "outside"
|
||||
outside.write_text("app:x:1234:1234::/outside:/bin/sh\n")
|
||||
def replace(path, flags, *args, **kwargs):
|
||||
if str(path) == "passwd":
|
||||
self.passwd.unlink()
|
||||
self.passwd.symlink_to(outside)
|
||||
return original_open(path, flags, *args, **kwargs)
|
||||
with patch.object(os, "open", side_effect=replace):
|
||||
self.assert_rejected_without_read()
|
||||
|
||||
def test_fifo_replacement_between_check_and_open_is_not_read(self):
|
||||
original_open = os.open
|
||||
def replace(path, flags, *args, **kwargs):
|
||||
if str(path) == "passwd":
|
||||
self.passwd.unlink()
|
||||
os.mkfifo(self.passwd)
|
||||
return original_open(path, flags, *args, **kwargs)
|
||||
with patch.object(os, "open", side_effect=replace):
|
||||
self.assert_rejected_without_read()
|
||||
|
||||
|
||||
class HomeInstallerExecutionTests(unittest.TestCase):
|
||||
"""Run the real shell HOME function and Python CLI with fake mount boundaries."""
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
(self.root / "etc").mkdir()
|
||||
self.passwd = self.root / "etc/passwd"
|
||||
self.passwd.write_text("root:x:0:0:root:/root:/bin/sh\napp:x:1001:1001::/srv/app:/bin/sh\n")
|
||||
self.conf = self.root / "guest.conf"
|
||||
self.trace = self.root / "trace"
|
||||
self.source = (ROOT / "remote/install_oci.sh").read_text()
|
||||
|
||||
def run_home(self, config="unrelated: keep\n", unmount_status=0, mount_status=0):
|
||||
self.conf.write_text(config)
|
||||
self.trace.write_text("")
|
||||
functions = []
|
||||
for name in ("ensure_runtime_home", "set_runtime_environment"):
|
||||
found = re.search(rf"^{name}\(\) \{{\n.*?^\}}\n", self.source, re.M | re.S)
|
||||
self.assertIsNotNone(found, name)
|
||||
functions.append(found.group(0))
|
||||
script = r'''
|
||||
set -euo pipefail
|
||||
CONF=$1
|
||||
FIXTURE_ROOT=$2
|
||||
TRACE=$3
|
||||
OCI_RUNTIME_RESOLVER=$4
|
||||
PYTHON=$5
|
||||
UNMOUNT_STATUS=$6
|
||||
MOUNT_STATUS=$7
|
||||
OCI_LOG="$FIXTURE_ROOT/resolver.log"
|
||||
VMID=123456789
|
||||
translate() { printf '%s' "$1"; }
|
||||
die() { printf '%s\n' "$*" >&2; exit 1; }
|
||||
mount_ct_rootfs() {
|
||||
printf 'mount\n' >> "$TRACE"
|
||||
[ "$MOUNT_STATUS" = 0 ] || die 'mount failed'
|
||||
rootfs=$FIXTURE_ROOT
|
||||
passwd_file="$rootfs/etc/passwd"
|
||||
}
|
||||
oci_quiet() { printf '%s\n' "$*" >> "$TRACE"; return "$UNMOUNT_STATUS"; }
|
||||
python3() { printf 'resolve\n' >> "$TRACE"; command "$PYTHON" -B "$@"; }
|
||||
'''
|
||||
done = subprocess.run(
|
||||
["bash", "-c", script + "\n".join(functions) + "\nensure_runtime_home\nprintf 'continued\\n'\n",
|
||||
"bash", str(self.conf), str(self.root), str(self.trace), str(ROOT / "remote/oci_runtime.py"),
|
||||
sys.executable, str(unmount_status), str(mount_status)],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
return done, self.conf.read_text(), self.trace.read_text().splitlines()
|
||||
|
||||
def test_explicit_home_including_empty_never_mounts_or_resolves(self):
|
||||
for home in ("/custom", ""):
|
||||
config = f"unrelated: keep\nlxc.environment.runtime: HOME={home}\n"
|
||||
done, after, trace = self.run_home(config)
|
||||
self.assertEqual((done.returncode, after, trace), (0, config, []))
|
||||
|
||||
def test_default_and_effective_uid_write_the_resolved_home_after_unmount(self):
|
||||
for uid, expected in ((None, "/root"), (1001, "/srv/app"), (9999, "/")):
|
||||
config = "unrelated: keep\n" + (f"lxc.init.uid: {uid}\n" if uid is not None else "")
|
||||
done, after, trace = self.run_home(config)
|
||||
self.assertEqual(done.returncode, 0, done.stderr)
|
||||
self.assertEqual(after, config + f"lxc.environment.runtime: HOME={expected}\n")
|
||||
self.assertEqual(trace, ["mount", "resolve", "pct unmount 123456789"])
|
||||
|
||||
def test_invalid_uid_leaves_config_unchanged_without_mount(self):
|
||||
config = "lxc.init.uid: not-a-uid\n"
|
||||
done, after, trace = self.run_home(config)
|
||||
self.assertEqual((done.returncode, after, trace), (0, config, []))
|
||||
|
||||
def test_rejected_input_unmounts_and_preserves_config(self):
|
||||
self.passwd.unlink()
|
||||
self.passwd.symlink_to(self.root / "missing")
|
||||
config = "unrelated: keep\n"
|
||||
done, after, trace = self.run_home(config)
|
||||
self.assertEqual((done.returncode, after), (0, config))
|
||||
self.assertEqual(trace, ["mount", "resolve", "pct unmount 123456789"])
|
||||
|
||||
def test_failed_unmount_stops_before_config_write_for_success_and_failure(self):
|
||||
for valid in (True, False):
|
||||
if not valid:
|
||||
self.passwd.unlink()
|
||||
config = "unrelated: keep\n"
|
||||
done, after, trace = self.run_home(config, unmount_status=1)
|
||||
self.assertNotEqual(done.returncode, 0)
|
||||
self.assertEqual(after, config)
|
||||
self.assertNotIn("continued", done.stdout)
|
||||
self.assertEqual(trace, ["mount", "resolve", "pct unmount 123456789"])
|
||||
|
||||
def test_failed_mount_never_reads_or_writes(self):
|
||||
config = "unrelated: keep\n"
|
||||
done, after, trace = self.run_home(config, mount_status=1)
|
||||
self.assertNotEqual(done.returncode, 0)
|
||||
self.assertEqual(after, config)
|
||||
self.assertEqual(trace, ["mount"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -283,6 +283,10 @@ reload_services() {
|
||||
systemctl is-active --quiet pvedaemon 2>/dev/null && {
|
||||
systemctl reload pvedaemon 2>/dev/null || true
|
||||
}
|
||||
# Proxmox Backup Server serves the same toolkit from its own proxy.
|
||||
systemctl is-active --quiet proxmox-backup-proxy 2>/dev/null && {
|
||||
systemctl reload proxmox-backup-proxy 2>/dev/null || systemctl restart proxmox-backup-proxy 2>/dev/null || true
|
||||
}
|
||||
}
|
||||
|
||||
main() {
|
||||
@@ -316,9 +320,12 @@ EOFAPT
|
||||
# Main function to remove subscription banner
|
||||
remove_subscription_banner_v3() {
|
||||
local pve_version
|
||||
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' | head -1 || echo "unknown")
|
||||
|
||||
msg_info "$(translate "Detected Proxmox VE") ${pve_version} - $(translate "applying minimal banner patch")"
|
||||
if command -v pveversion >/dev/null 2>&1; then
|
||||
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' | head -1 || echo "unknown")
|
||||
msg_info "$(translate "Detected Proxmox VE") ${pve_version} - $(translate "applying minimal banner patch")"
|
||||
else
|
||||
msg_info "$(translate "Detected Proxmox Backup Server") - $(translate "applying minimal banner patch")"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -62,3 +62,48 @@ ensure_repositories() {
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# The no-subscription switch of a Proxmox Backup Server: the same policy and
|
||||
# API as on PVE, offered only when the host has no active subscription.
|
||||
ensure_pbs_repositories() {
|
||||
local suite decision refresh_status
|
||||
if [[ "$(pmx_platform_field supported)" != true ]]; then
|
||||
msg_error "$(translate 'This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.')"
|
||||
return 1
|
||||
fi
|
||||
suite=$(pmx_platform_field codename)
|
||||
decision=$(repository_policy plan "$suite" pbs) || return 1
|
||||
case "$decision" in
|
||||
preserve) return 0 ;;
|
||||
offer) ;;
|
||||
*) msg_error "$(translate 'Repository policy returned an unexpected result.')"; return 1 ;;
|
||||
esac
|
||||
if ! declare -F hybrid_yesno >/dev/null || { [[ ! -t 0 ]] && ! { declare -F is_web_mode >/dev/null && is_web_mode; }; }; then
|
||||
msg_error "$(translate 'No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.')"
|
||||
return 1
|
||||
fi
|
||||
if ! hybrid_yesno "$(translate 'Proxmox repository')" \
|
||||
"$(translate 'This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.')" 14 90; then
|
||||
msg_error "$(translate 'Repository switch declined; no APT source changed.')"
|
||||
return 1
|
||||
fi
|
||||
decision=$(repository_policy apply "$suite" pbs) || return 1
|
||||
[[ "$decision" == changed || "$decision" == preserve ]] || return 1
|
||||
if [[ "$decision" == changed ]]; then
|
||||
if declare -F pmx_record_execution >/dev/null; then
|
||||
local journal_function="${PMX_JOURNAL_FUNCTION:-}" journal_version="${PMX_JOURNAL_VERSION:-}"
|
||||
pmx_journal_context "ensure_pbs_repositories" "" "${PMX_JOURNAL_SOURCE:-}"
|
||||
pmx_record_execution "Switch to the PBS no-subscription repository" \
|
||||
"proxmox-backup-debug api set /nodes/localhost/apt/repositories --handle no-subscription"
|
||||
PMX_JOURNAL_FUNCTION="$journal_function" PMX_JOURNAL_VERSION="$journal_version"
|
||||
fi
|
||||
if apt-get update; then
|
||||
return 0
|
||||
else
|
||||
refresh_status=$?
|
||||
msg_error "$(translate 'Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Administration > Repositories and retry apt-get update before continuing.')"
|
||||
return "$refresh_status"
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Proxmox repository policy via its own parsed APT repository API.
|
||||
"""Proxmox VE and Proxmox Backup Server repository policy via their own parsed APT
|
||||
repository API.
|
||||
|
||||
Only `notfound` or `expired` permits an offered switch. Never print raw subscription or
|
||||
repository API responses: they can contain subscription keys or credentials.
|
||||
@@ -12,6 +13,19 @@ import sys
|
||||
|
||||
ENDPOINT = '/nodes/localhost/apt/repositories'
|
||||
PVE_CHANNELS = {'pve-enterprise', 'pve-no-subscription', 'pve-test', 'pvetest'}
|
||||
# Proxmox VE and Proxmox Backup Server share the repository API; each has its
|
||||
# own command line, channels and place in the web interface.
|
||||
PRODUCTS = {
|
||||
'pve': {'api': ['pvesh'], 'name': 'Proxmox VE', 'short': 'PVE',
|
||||
'channels': PVE_CHANNELS, 'enterprise': 'pve-enterprise', 'public': 'pve-no-subscription',
|
||||
'test': {'pve-no-subscription', 'pve-test', 'pvetest'},
|
||||
'ui': 'Node > Updates > Repositories', 'ceph': True},
|
||||
'pbs': {'api': ['proxmox-backup-debug', 'api'], 'name': 'Proxmox Backup Server', 'short': 'PBS',
|
||||
'channels': {'pbs-enterprise', 'pbs-no-subscription', 'pbs-test', 'pbstest'},
|
||||
'enterprise': 'pbs-enterprise', 'public': 'pbs-no-subscription',
|
||||
'test': {'pbs-no-subscription', 'pbs-test', 'pbstest'},
|
||||
'ui': 'Administration > Repositories', 'ceph': False},
|
||||
}
|
||||
|
||||
|
||||
def run(args):
|
||||
@@ -21,7 +35,16 @@ def run(args):
|
||||
raise ValueError(f'Unable to query or change Proxmox repository state ({args[0]}). Check the service and retry.') from exc
|
||||
|
||||
|
||||
def subscription_status():
|
||||
def subscription_status(product='pve'):
|
||||
if product == 'pbs':
|
||||
# Only the status field is read; the answer also carries the server ID.
|
||||
try:
|
||||
status = json.loads(run(['proxmox-backup-manager', 'subscription', 'get', '--output-format', 'json'])).get('status')
|
||||
except (ValueError, AttributeError):
|
||||
status = None
|
||||
if status not in ('active', 'notfound', 'expired'):
|
||||
raise ValueError('Subscription status is not unambiguously active, notfound or expired. Check proxmox-backup-manager subscription get locally; no repository changed.')
|
||||
return status
|
||||
# pvesubscription's CLI get printer emits sorted "key: value" lines,
|
||||
# including a secret key and server ID. Only parse the exact status line.
|
||||
output = run(['pvesubscription', 'get'])
|
||||
@@ -31,9 +54,10 @@ def subscription_status():
|
||||
return statuses[0]
|
||||
|
||||
|
||||
def repository_state(suite):
|
||||
def repository_state(suite, product='pve'):
|
||||
p = PRODUCTS[product]
|
||||
try:
|
||||
data = json.loads(run(['pvesh', 'get', ENDPOINT, '--output-format', 'json']))
|
||||
data = json.loads(run([*p['api'], 'get', ENDPOINT, '--output-format', 'json']))
|
||||
if not isinstance(data, dict) or not isinstance(data['files'], list) or not isinstance(data['errors'], list) or not isinstance(data['digest'], str) or not isinstance(data['standard-repos'], list):
|
||||
raise ValueError()
|
||||
if data['errors']:
|
||||
@@ -86,13 +110,14 @@ def repository_state(suite):
|
||||
entries.append((file['path'], index, row))
|
||||
return data, entries
|
||||
except (KeyError, TypeError, ValueError, IndexError) as exc:
|
||||
raise ValueError('Proxmox APT repository inventory is invalid or reports parse errors. Fix sources in Node > Updates > Repositories; no repository changed.') from exc
|
||||
raise ValueError(f"Proxmox APT repository inventory is invalid or reports parse errors. Fix sources in {p['ui']}; no repository changed.") from exc
|
||||
|
||||
|
||||
def evaluate(suite, apply=False):
|
||||
def evaluate(suite, apply=False, product='pve'):
|
||||
if suite not in ('bookworm', 'trixie'):
|
||||
raise ValueError('Unsupported Proxmox suite; no repository changed.')
|
||||
data, entries = repository_state(suite)
|
||||
p = PRODUCTS[product]
|
||||
data, entries = repository_state(suite, product)
|
||||
pve = []
|
||||
ceph = []
|
||||
debian = False
|
||||
@@ -102,40 +127,40 @@ def evaluate(suite, apply=False):
|
||||
if not row['Enabled'] or 'deb' not in row['Types']:
|
||||
continue
|
||||
components = set(row['Components'])
|
||||
if components & PVE_CHANNELS:
|
||||
if components & p['channels']:
|
||||
if suite not in row['Suites']:
|
||||
wrong_pve.append(f"{' '.join(sorted(components & PVE_CHANNELS))} in {path} (suite {' '.join(row['Suites'])})")
|
||||
wrong_pve.append(f"{' '.join(sorted(components & p['channels']))} in {path} (suite {' '.join(row['Suites'])})")
|
||||
pve.append((path, index, row))
|
||||
if 'main' in components and suite in row['Suites'] and not components & PVE_CHANNELS:
|
||||
if 'main' in components and suite in row['Suites'] and not components & p['channels']:
|
||||
debian = True
|
||||
if 'enterprise' in components and any('/ceph-' in uri for uri in row['URIs']):
|
||||
if p['ceph'] and 'enterprise' in components and any('/ceph-' in uri for uri in row['URIs']):
|
||||
if suite not in row['Suites']:
|
||||
wrong_ceph.append(f"{path} (suite {' '.join(row['Suites'])})")
|
||||
ceph.append((path, index, row))
|
||||
# Name the stale sources, never their URIs: those can carry credentials.
|
||||
if wrong_pve:
|
||||
raise ValueError(f"This host runs Proxmox VE on {suite}, but these Proxmox VE repositories are for another suite: "
|
||||
f"{'; '.join(wrong_pve)}. Disable or remove them in Node > Updates > Repositories and run the update again; no repository changed.")
|
||||
raise ValueError(f"This host runs {p['name']} on {suite}, but these {p['name']} repositories are for another suite: "
|
||||
f"{'; '.join(wrong_pve)}. Disable or remove them in {p['ui']} and run the update again; no repository changed.")
|
||||
if wrong_ceph:
|
||||
raise ValueError(f"This host runs Proxmox VE on {suite}, but these Enterprise Ceph repositories are for another suite: "
|
||||
f"{'; '.join(wrong_ceph)}. Correct them in Node > Updates > Repositories before switching; no repository changed.")
|
||||
|
||||
if any(set(row['Components']) & {'pve-no-subscription', 'pve-test', 'pvetest'} for _, _, row in pve):
|
||||
if any(set(row['Components']) & p['test'] for _, _, row in pve):
|
||||
return 'preserve'
|
||||
# Entitlement lookup is only needed when considering a switch. Existing
|
||||
# public/test channels are the operator's choice, even if lookup fails.
|
||||
status = subscription_status() # fail closed before any write
|
||||
status = subscription_status(product) # fail closed before any write
|
||||
if status == 'active':
|
||||
if not pve:
|
||||
raise ValueError('Host has an active subscription but no active PVE repository. Configure its Enterprise source in Node > Updates > Repositories; no repository changed.')
|
||||
raise ValueError(f"Host has an active subscription but no active {p['short']} repository. Configure its Enterprise source in {p['ui']}; no repository changed.")
|
||||
return 'preserve'
|
||||
if not debian:
|
||||
raise ValueError('No active Debian base repository for this suite; configure it in the Proxmox repository UI before continuing.')
|
||||
# No active PVE source or only inaccessible Enterprise. A mixed stanza
|
||||
# No active product source or only inaccessible Enterprise. A mixed stanza
|
||||
# cannot be disabled without also disabling an unrelated component.
|
||||
disable = [item for item in pve if 'pve-enterprise' in item[2]['Components']] + ceph
|
||||
disable = [item for item in pve if p['enterprise'] in item[2]['Components']] + ceph
|
||||
for _, _, row in disable:
|
||||
expected = {'pve-enterprise'} if 'pve-enterprise' in row['Components'] else {'enterprise'}
|
||||
expected = {p['enterprise']} if p['enterprise'] in row['Components'] else {'enterprise'}
|
||||
if set(row['Components']) != expected:
|
||||
raise ValueError('Enterprise shares an APT stanza with other components. Split it in the Proxmox repository UI; no repository changed.')
|
||||
handles = [r.get('handle') for r in data['standard-repos'] if r.get('handle') == 'no-subscription']
|
||||
@@ -149,9 +174,9 @@ def evaluate(suite, apply=False):
|
||||
try:
|
||||
for path, index, original in disable:
|
||||
next(file for file in expected if file['path'] == path)['repositories'][index]['Enabled'] = False
|
||||
run(['pvesh', 'create', ENDPOINT, '--path', path, '--index', str(index),
|
||||
run([*p['api'], 'create', ENDPOINT, '--path', path, '--index', str(index),
|
||||
'--enabled', '0', '--digest', data['digest']])
|
||||
refreshed, current = repository_state(suite)
|
||||
refreshed, current = repository_state(suite, product)
|
||||
# Per-file digests change when Proxmox serializes the disabled entry.
|
||||
inventory = lambda files: sorted(
|
||||
({key: value for key, value in file.items() if key != 'digest'} for file in files),
|
||||
@@ -159,23 +184,25 @@ def evaluate(suite, apply=False):
|
||||
if inventory(refreshed['files']) != inventory(expected):
|
||||
raise ValueError('Could not verify the expected repository inventory after Enterprise disable; possible concurrent edit. Inspect the Proxmox repository UI before retrying.')
|
||||
data = refreshed
|
||||
run(['pvesh', 'set', ENDPOINT, '--handle', 'no-subscription', '--digest', data['digest']])
|
||||
_, current = repository_state(suite)
|
||||
run([*p['api'], 'set', ENDPOINT, '--handle', 'no-subscription', '--digest', data['digest']])
|
||||
_, current = repository_state(suite, product)
|
||||
if not any(row['Enabled'] and 'deb' in row['Types'] and suite in row['Suites']
|
||||
and 'pve-no-subscription' in row['Components'] for _, _, row in current) \
|
||||
and p['public'] in row['Components'] for _, _, row in current) \
|
||||
or any(p == path and i == index and row['Enabled']
|
||||
for path, index, _ in disable for p, i, row in current):
|
||||
raise ValueError('Could not verify the switched repositories; inspect the Proxmox repository UI before retrying.')
|
||||
except ValueError as exc:
|
||||
raise ValueError('Could not complete or verify the repository switch; changes may be partial and repository state is unknown. Inspect Node > Updates > Repositories before retrying; no automatic rollback was attempted.') from exc
|
||||
raise ValueError(f"Could not complete or verify the repository switch; changes may be partial and repository state is unknown. Inspect {p['ui']} before retrying; no automatic rollback was attempted.") from exc
|
||||
return 'changed'
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
if len(sys.argv) != 3 or sys.argv[1] not in ('plan', 'apply'):
|
||||
raise ValueError('Usage: repository_policy.py plan|apply bookworm|trixie')
|
||||
print(evaluate(sys.argv[2], apply=sys.argv[1] == 'apply'))
|
||||
if len(sys.argv) not in (3, 4) or sys.argv[1] not in ('plan', 'apply') \
|
||||
or (len(sys.argv) == 4 and sys.argv[3] not in PRODUCTS):
|
||||
raise ValueError('Usage: repository_policy.py plan|apply bookworm|trixie [pve|pbs]')
|
||||
product = sys.argv[3] if len(sys.argv) == 4 else 'pve'
|
||||
print(evaluate(sys.argv[2], apply=sys.argv[1] == 'apply', product=product))
|
||||
except ValueError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# Proxmox Backup Server Update Script (post-install flows)
|
||||
# ==========================================================
|
||||
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
APT_ENV="env DEBIAN_FRONTEND=noninteractive LC_ALL=C LANG=C"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
|
||||
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
|
||||
fi
|
||||
source "$LOCAL_SCRIPTS/global/common-functions.sh"
|
||||
source "$LOCAL_SCRIPTS/global/repository-functions.sh"
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
update_pbs() {
|
||||
local FUNC_VERSION="1.0"
|
||||
pmx_journal_context "update_pbs" "$FUNC_VERSION"
|
||||
|
||||
if ! pmx_is_pbs || [[ "$(pmx_platform_field supported)" != true ]]; then
|
||||
msg_error "$(translate 'This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.')"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local start_time
|
||||
start_time=$(date +%s)
|
||||
local log_file="/var/log/proxmox-update-$(date +%Y%m%d-%H%M%S).log"
|
||||
local screen_capture="/tmp/proxmenux_screen_capture_$$.txt"
|
||||
local OS_CODENAME
|
||||
OS_CODENAME=$(pmx_platform_field codename)
|
||||
|
||||
{
|
||||
echo "=== ProxMenux — Proxmox Backup Server update ==="
|
||||
echo "Started: $(date -Iseconds)"
|
||||
echo "Host: $(hostname)"
|
||||
echo "Running: proxmox-backup-server $(dpkg-query -W -f='${Version}' proxmox-backup-server 2>/dev/null)"
|
||||
} > "$log_file"
|
||||
|
||||
if ! ensure_pbs_repositories; then
|
||||
msg_error "$(translate "Failed to configure Proxmox Backup Server repositories")"
|
||||
rm -f "$screen_capture"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local available_space
|
||||
available_space=$(df /var/cache/apt/archives | awk 'NR==2 {print int($4/1024)}')
|
||||
if [ "$available_space" -lt 1024 ]; then
|
||||
msg_error "$(translate "Insufficient disk space. Available: ${available_space}MB")"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local update_output update_exit_code
|
||||
update_output=$(apt-get -o APT::Update::Error-Mode=any update 2>&1)
|
||||
update_exit_code=$?
|
||||
{
|
||||
echo
|
||||
echo "--- apt-get update (exit $update_exit_code) ---"
|
||||
printf '%s\n' "$update_output"
|
||||
} >> "$log_file"
|
||||
if [ $update_exit_code -ne 0 ]; then
|
||||
msg_error "$(translate "APT refresh failed. Check the PBS subscription and repositories in Administration > Repositories. Log:") $log_file"
|
||||
return 1
|
||||
fi
|
||||
msg_ok "$(translate "Package lists updated successfully")" | tee -a "$screen_capture"
|
||||
|
||||
local current_version available_version
|
||||
current_version=$(dpkg-query -W -f='${Version}' proxmox-backup-server 2>/dev/null)
|
||||
available_version=$(apt-cache policy proxmox-backup-server 2>/dev/null | awk '/Candidate:/ {print $2; exit}')
|
||||
|
||||
local upgradable upgradable_raw upgradable_list security_updates
|
||||
upgradable_raw=$($APT_ENV apt list --upgradable 2>/dev/null \
|
||||
| sed '1d' \
|
||||
| sed '/^\s*$/d')
|
||||
upgradable=$(printf '%s' "$upgradable_raw" | grep -c . )
|
||||
security_updates=$(printf '%s\n' "$upgradable_raw" | grep -ci '\-security')
|
||||
upgradable_list=$(printf '%s\n' "$upgradable_raw" | pmx_format_upgradable)
|
||||
|
||||
{
|
||||
echo
|
||||
echo "--- Packages to upgrade ($upgradable) ---"
|
||||
[ "$upgradable" -gt 0 ] && printf '%s\n' "$upgradable_list"
|
||||
} >> "$log_file"
|
||||
|
||||
local menu_text
|
||||
menu_text="$(translate "System Update Information")\n\n"
|
||||
menu_text+="$(translate "Current PBS Version"): $current_version\n"
|
||||
if [ -n "$available_version" ] && [ "$available_version" != "$current_version" ]; then
|
||||
menu_text+="$(translate "Available PBS Version"): $available_version\n"
|
||||
fi
|
||||
menu_text+="\n$(translate "Package Updates Available"): $upgradable\n"
|
||||
menu_text+="$(translate "Security Updates"): $security_updates\n\n"
|
||||
|
||||
if [ "$upgradable" -eq 0 ]; then
|
||||
menu_text+="$(translate "System is already up to date")"
|
||||
whiptail --title "$(translate "Update Status")" --msgbox "$menu_text" 15 70
|
||||
clear
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
cat "$screen_capture"
|
||||
msg_ok "$(translate "System is already up to date. No update needed.")"
|
||||
echo -e "\nSystem is already up to date." >> "$log_file"
|
||||
rm -f "$screen_capture"
|
||||
return 0
|
||||
fi
|
||||
|
||||
menu_text+="$(translate "Packages to be upgraded"):\n$upgradable_list\n\n"
|
||||
menu_text+="$(translate "Do you want to proceed with the system update?")"
|
||||
local proceed=0
|
||||
whiptail --title "$(translate "Proxmox Backup Server Update")" --scrolltext --yesno "$menu_text" 24 78 || proceed=1
|
||||
|
||||
clear
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
cat "$screen_capture"
|
||||
|
||||
if [ $proceed -ne 0 ]; then
|
||||
msg_info2 "$(translate "Update cancelled by user")"
|
||||
rm -f "$screen_capture"
|
||||
return 0
|
||||
fi
|
||||
|
||||
pmx_record_execution "Upgrade Proxmox Backup Server packages" "apt -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold full-upgrade"
|
||||
local dpkg_mark upgrade_exit_code
|
||||
dpkg_mark=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
echo -e "\n--- apt full-upgrade ---" >> "$log_file"
|
||||
DEBIAN_FRONTEND=noninteractive apt -y \
|
||||
-o Dpkg::Options::='--force-confdef' \
|
||||
-o Dpkg::Options::='--force-confold' \
|
||||
full-upgrade 2> >(tee -a "$log_file" >&2)
|
||||
upgrade_exit_code=$?
|
||||
echo -e
|
||||
{
|
||||
echo
|
||||
echo "--- Packages changed (exit $upgrade_exit_code) ---"
|
||||
pmx_dpkg_changes_since "$dpkg_mark"
|
||||
} >> "$log_file"
|
||||
|
||||
clear
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
cat "$screen_capture"
|
||||
rm -f "$screen_capture"
|
||||
|
||||
if [ $upgrade_exit_code -ne 0 ]; then
|
||||
msg_error "$(translate "System upgrade failed. Check log: $log_file")"
|
||||
echo "Finished: $(date -Iseconds) — upgrade failed (exit $upgrade_exit_code)" >> "$log_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
pmx_record_execution "Remove unused packages" "apt-get -y autoremove"
|
||||
apt-get -y autoremove > /dev/null 2>&1 || true
|
||||
apt-get -y autoclean > /dev/null 2>&1 || true
|
||||
msg_ok "$(translate "Cleanup finished")"
|
||||
|
||||
local end_time duration minutes seconds
|
||||
end_time=$(date +%s)
|
||||
duration=$((end_time - start_time))
|
||||
minutes=$((duration / 60))
|
||||
seconds=$((duration % 60))
|
||||
|
||||
echo -e "${TAB}${BGN}$(translate "====== PBS UPDATE COMPLETED ======")${CL}"
|
||||
echo -e "${TAB}${GN}⏱️ $(translate "Duration")${CL}: ${BL}${minutes}m ${seconds}s${CL}"
|
||||
echo -e "${TAB}${GN}📄 $(translate "Log file")${CL}: ${BL}$log_file${CL}"
|
||||
echo -e "${TAB}${GN}📦 $(translate "Packages upgraded")${CL}: ${BL}$upgradable${CL}"
|
||||
echo -e "${TAB}${GN}🗄️ $(translate "Proxmox Backup Server")${CL}: ${BL}$(dpkg-query -W -f='${Version}' proxmox-backup-server 2>/dev/null) (Debian $OS_CODENAME)${CL}"
|
||||
|
||||
{
|
||||
echo
|
||||
echo "Finished: $(date -Iseconds) — ${minutes}m ${seconds}s"
|
||||
echo "Running: proxmox-backup-server $(dpkg-query -W -f='${Version}' proxmox-backup-server 2>/dev/null)"
|
||||
} >> "$log_file"
|
||||
|
||||
if [ -f /var/run/reboot-required ] || [ "$(uname -r)" != "$(ls -1 /boot/vmlinuz-* 2>/dev/null | sed 's|/boot/vmlinuz-||' | sort -V | tail -1)" ]; then
|
||||
msg_warn "$(translate "A newer kernel is installed. Reboot before the next backup window to use it.")"
|
||||
fi
|
||||
msg_ok "$(translate "Proxmox Backup Server update completed.")"
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
update_pbs
|
||||
fi
|
||||
@@ -28,6 +28,12 @@ fi
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
PMX_ON_PBS=false
|
||||
if [[ -f "$LOCAL_SCRIPTS/global/platform-functions.sh" ]]; then
|
||||
source "$LOCAL_SCRIPTS/global/platform-functions.sh"
|
||||
pmx_is_pbs && PMX_ON_PBS=true
|
||||
fi
|
||||
|
||||
# ==========================================================
|
||||
confirm_and_run() {
|
||||
local name="$1"
|
||||
@@ -55,8 +61,13 @@ confirm_automated_script() {
|
||||
local script_info=""
|
||||
|
||||
|
||||
script_info+="$(translate "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server"):\n\n"
|
||||
script_info+="• $(translate "Configure") \Z4free repositories\Z0 $(translate "and upgrade the system (disables the enterprise repo)")\n"
|
||||
if [[ "$PMX_ON_PBS" == true ]]; then
|
||||
script_info+="$(translate "This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server"):\n\n"
|
||||
script_info+="• $(translate "Upgrade the system, offering the") \Z4no-subscription repository\Z0 $(translate "when the server has no subscription (you will be asked)")\n"
|
||||
else
|
||||
script_info+="$(translate "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server"):\n\n"
|
||||
script_info+="• $(translate "Configure") \Z4free repositories\Z0 $(translate "and upgrade the system (disables the enterprise repo)")\n"
|
||||
fi
|
||||
script_info+="• $(translate "Optionally remove") \Z4subscription banner\Z0 $(translate "from Proxmox web interface (you will be asked)")\n"
|
||||
script_info+="• $(translate "Optimize") \Z4memory\Z0, \Z4kernel\Z0, $(translate "and") \Z4network\Z0 $(translate "for better performance and stability")\n"
|
||||
script_info+="• $(translate "Install and configure") \Z4Log2RAM\Z0 $(translate "(only on SSD/NVMe) to protect your disk")\n"
|
||||
@@ -229,6 +240,12 @@ declare -a COMMUNITY_SCRIPTS=(
|
||||
"Proxmox VE Post Install|Helper-Scripts|bash -c \"\$(wget -qLO - https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/tools/pve/post-pve-install.sh); msg_success \\\"\$(translate 'Press ENTER to continue...')\\\"; read -r _\""
|
||||
"Proxmox VE Microcode|Helper-Scripts|bash -c \"\$(wget -qLO - https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/tools/pve/microcode.sh); msg_success \\\"\$(translate 'Press ENTER to continue...')\\\"; read -r _\""
|
||||
)
|
||||
if [[ "$PMX_ON_PBS" == true ]]; then
|
||||
COMMUNITY_SCRIPTS=(
|
||||
"Proxmox Backup Server Post Install|Helper-Scripts|bash -c \"\$(wget -qLO - https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/tools/pve/post-pbs-install.sh); msg_success \\\"\$(translate 'Press ENTER to continue...')\\\"; read -r _\""
|
||||
"Proxmox Backup Server Microcode|Helper-Scripts|bash -c \"\$(wget -qLO - https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/tools/pve/pbs-microcode.sh); msg_success \\\"\$(translate 'Press ENTER to continue...')\\\"; read -r _\""
|
||||
)
|
||||
fi
|
||||
|
||||
# ==========================================================
|
||||
|
||||
@@ -271,6 +288,12 @@ post_install_label() {
|
||||
"Proxmox VE Microcode")
|
||||
echo "Proxmox VE Microcode"
|
||||
;;
|
||||
"Proxmox Backup Server Post Install")
|
||||
echo "Proxmox Backup Server Post Install"
|
||||
;;
|
||||
"Proxmox Backup Server Microcode")
|
||||
echo "Proxmox Backup Server Microcode"
|
||||
;;
|
||||
"Community Scripts")
|
||||
translate "Community Scripts"
|
||||
;;
|
||||
|
||||
+15
-13
@@ -2,8 +2,8 @@
|
||||
# ==========================================================
|
||||
# ProxMenux - Proxmox Backup Server menu
|
||||
# ==========================================================
|
||||
# PBS exposes compatible host tools; PVE guest/repository optimizations
|
||||
# stay in the PVE menu.
|
||||
# PBS exposes compatible host tools and its own post-install profile;
|
||||
# PVE guest optimizations stay in the PVE menu.
|
||||
# ==========================================================
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
LOCAL_SCRIPTS="$BASE_DIR/scripts"
|
||||
@@ -31,20 +31,22 @@ pbs_show() {
|
||||
|
||||
while true; do
|
||||
choice=$(dialog --clear --backtitle "ProxMenux" --title "Proxmox Backup Server" \
|
||||
--menu "$(translate 'Select an option:')" 18 75 8 \
|
||||
1 "$(translate 'PBS version and packages')" \
|
||||
2 "$(translate 'PBS datastores')" \
|
||||
3 "$(translate 'Recent PBS tasks')" \
|
||||
4 "$(translate 'System Utilities Installer')" \
|
||||
5 "$(translate 'Update PBS using configured repositories')" \
|
||||
--menu "$(translate 'Select an option:')" 19 75 9 \
|
||||
1 "$(translate "Settings post-install Proxmox")" \
|
||||
2 "$(translate 'PBS version and packages')" \
|
||||
3 "$(translate 'PBS datastores')" \
|
||||
4 "$(translate 'Recent PBS tasks')" \
|
||||
5 "$(translate 'System Utilities Installer')" \
|
||||
6 "$(translate 'Update PBS using configured repositories')" \
|
||||
s "$(translate 'Settings')" \
|
||||
0 "$(translate 'Exit')" 2>&1 >/dev/tty) || break
|
||||
case "$choice" in
|
||||
1) pbs_show "$(translate 'PBS version and packages')" proxmox-backup-manager versions --verbose ;;
|
||||
2) pbs_show "$(translate 'PBS datastores')" proxmox-backup-manager datastore list ;;
|
||||
3) pbs_show "$(translate 'Recent PBS tasks')" proxmox-backup-manager task list --all true --limit 20 ;;
|
||||
4) bash "$LOCAL_SCRIPTS/utilities/system_utils.sh" ;;
|
||||
5) bash "$LOCAL_SCRIPTS/utilities/pbs_update.sh" ;;
|
||||
1) exec bash "$LOCAL_SCRIPTS/menus/menu_post_install.sh" ;;
|
||||
2) pbs_show "$(translate 'PBS version and packages')" proxmox-backup-manager versions --verbose ;;
|
||||
3) pbs_show "$(translate 'PBS datastores')" proxmox-backup-manager datastore list ;;
|
||||
4) pbs_show "$(translate 'Recent PBS tasks')" proxmox-backup-manager task list --all true --limit 20 ;;
|
||||
5) bash "$LOCAL_SCRIPTS/utilities/system_utils.sh" ;;
|
||||
6) bash "$LOCAL_SCRIPTS/utilities/pbs_update.sh" ;;
|
||||
s) exec bash "$LOCAL_SCRIPTS/menus/config_menu.sh" ;;
|
||||
0) break ;;
|
||||
esac
|
||||
|
||||
@@ -59,7 +59,12 @@ initialize_cache
|
||||
|
||||
if [[ -f "$LOCAL_SCRIPTS/global/platform-functions.sh" ]]; then
|
||||
source "$LOCAL_SCRIPTS/global/platform-functions.sh"
|
||||
pmx_require_pve || exit 1
|
||||
fi
|
||||
# Proxmox Backup Server runs the same host optimizations; repositories,
|
||||
# package upgrade and the subscription notice follow its own tooling.
|
||||
PMX_ON_PBS=false
|
||||
if declare -F pmx_is_pbs >/dev/null && pmx_is_pbs; then
|
||||
PMX_ON_PBS=true
|
||||
fi
|
||||
|
||||
# Global variables
|
||||
@@ -142,6 +147,10 @@ $(translate "Do you want to continue anyway?")" 13 70
|
||||
|
||||
|
||||
apt_upgrade() {
|
||||
if [[ "$PMX_ON_PBS" == true ]]; then
|
||||
bash "$LOCAL_SCRIPTS/global/update-pbs.sh"
|
||||
return
|
||||
fi
|
||||
local pve_version
|
||||
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
|
||||
|
||||
@@ -171,6 +180,20 @@ apt_upgrade() {
|
||||
remove_subscription_banner() {
|
||||
local FUNC_VERSION="1.0"
|
||||
# description: Patch the Proxmox web UI to suppress the subscription dialog and register a successful patch.
|
||||
if [[ "$PMX_ON_PBS" == true ]]; then
|
||||
stop_spinner
|
||||
if ! whiptail --title "Proxmox Backup Server Subscription Banner Removal" \
|
||||
--yesno "$(translate "Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?")\n\n$(translate "Attention: Removing the subscription banner may cause issues in the web interface after a future update.")\n\n$(translate "If this happens, you can restore the backup from the 'Subscription Banner Removal' option in 'Uninstall optimizations'.")\n\n$(translate "Are you sure you want to continue?")" 14 75; then
|
||||
msg_warn "Banner removal cancelled by user."
|
||||
return 1
|
||||
fi
|
||||
if ! bash "$LOCAL_SCRIPTS/global/remove-banner-pve-v3.sh"; then
|
||||
msg_error "$(translate "Subscription banner removal failed")"
|
||||
return 1
|
||||
fi
|
||||
register_tool "subscription_banner" true "$FUNC_VERSION"
|
||||
return 0
|
||||
fi
|
||||
local pve_version
|
||||
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
|
||||
|
||||
@@ -225,6 +248,17 @@ setup_proxmox_repositories() {
|
||||
|
||||
msg_info2 "$(translate "Configuring Proxmox APT repositories...")"
|
||||
|
||||
if [[ "$PMX_ON_PBS" == true ]]; then
|
||||
if ! ensure_pbs_repositories; then
|
||||
msg_error "$(translate "Failed to configure Proxmox Backup Server repositories")"
|
||||
register_tool "proxmox_repos" false "$FUNC_VERSION"
|
||||
return 1
|
||||
fi
|
||||
register_tool "proxmox_repos" true "$FUNC_VERSION"
|
||||
msg_success "$(translate "Proxmox Backup Server APT repositories configured")"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! ensure_repositories; then
|
||||
msg_error "$(translate "Failed to configure Proxmox repositories")"
|
||||
register_tool "proxmox_repos" false "$FUNC_VERSION"
|
||||
@@ -524,7 +558,7 @@ force_apt_ipv4() {
|
||||
# ==========================================================
|
||||
|
||||
apply_network_optimizations() {
|
||||
local FUNC_VERSION="1.1"
|
||||
local FUNC_VERSION="1.2"
|
||||
pmx_journal_context "apply_network_optimizations" "$FUNC_VERSION"
|
||||
# description: Tune TCP buffers, somaxconn, IPv4 hardening and disable rp_filter on fw bridges (PVE 9 compatible).
|
||||
msg_info "$(translate "Optimizing network settings...")"
|
||||
@@ -622,14 +656,16 @@ RemainAfterExit=yes
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
pmx_write_file /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules <<'EOF'
|
||||
pmx_remove_file /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules
|
||||
|
||||
pmx_write_file /etc/udev/rules.d/99-zz-proxmenux-fwbr-tune.rules <<'EOF'
|
||||
ACTION=="add", SUBSYSTEM=="net", KERNEL=="fwbr*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k"
|
||||
ACTION=="add", SUBSYSTEM=="net", KERNEL=="fwln*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k"
|
||||
ACTION=="add", SUBSYSTEM=="net", KERNEL=="fwpr*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k"
|
||||
ACTION=="add", SUBSYSTEM=="net", KERNEL=="tap*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k"
|
||||
EOF
|
||||
chmod 0644 /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules
|
||||
chown root:root /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules
|
||||
chmod 0644 /etc/udev/rules.d/99-zz-proxmenux-fwbr-tune.rules
|
||||
chown root:root /etc/udev/rules.d/99-zz-proxmenux-fwbr-tune.rules
|
||||
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
udevadm control --reload-rules >/dev/null 2>&1 || true
|
||||
|
||||
@@ -53,7 +53,12 @@ initialize_cache
|
||||
|
||||
if [[ -f "$LOCAL_SCRIPTS/global/platform-functions.sh" ]]; then
|
||||
source "$LOCAL_SCRIPTS/global/platform-functions.sh"
|
||||
pmx_require_pve || exit 1
|
||||
fi
|
||||
# On Proxmox Backup Server the menu lists only the options that apply to
|
||||
# it; repositories, upgrade and the subscription notice use its tooling.
|
||||
PMX_ON_PBS=false
|
||||
if declare -F pmx_is_pbs >/dev/null && pmx_is_pbs; then
|
||||
PMX_ON_PBS=true
|
||||
fi
|
||||
|
||||
# Load shared global functions
|
||||
@@ -122,6 +127,17 @@ setup_proxmox_repositories() {
|
||||
|
||||
msg_info2 "$(translate "Configuring Proxmox APT repositories...")"
|
||||
|
||||
if [[ "$PMX_ON_PBS" == true ]]; then
|
||||
if ! ensure_pbs_repositories; then
|
||||
msg_error "$(translate "Failed to configure Proxmox Backup Server repositories")"
|
||||
register_tool "proxmox_repos" false "$FUNC_VERSION"
|
||||
return 1
|
||||
fi
|
||||
register_tool "proxmox_repos" true "$FUNC_VERSION"
|
||||
msg_success "$(translate "Proxmox Backup Server APT repositories configured")"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! ensure_repositories; then
|
||||
msg_error "$(translate "Failed to configure Proxmox repositories")"
|
||||
register_tool "proxmox_repos" false "$FUNC_VERSION"
|
||||
@@ -248,6 +264,10 @@ EOF
|
||||
|
||||
|
||||
apt_upgrade() {
|
||||
if [[ "$PMX_ON_PBS" == true ]]; then
|
||||
bash "$LOCAL_SCRIPTS/global/update-pbs.sh"
|
||||
return
|
||||
fi
|
||||
local pve_version
|
||||
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
|
||||
|
||||
@@ -2376,6 +2396,14 @@ EOF
|
||||
remove_subscription_banner() {
|
||||
local FUNC_VERSION="1.0"
|
||||
# description: Patch the Proxmox web UI to suppress the "no valid subscription" dialog (PVE 8 + 9 variants supported).
|
||||
if [[ "$PMX_ON_PBS" == true ]]; then
|
||||
if ! bash "$LOCAL_SCRIPTS/global/remove-banner-pve-v3.sh"; then
|
||||
msg_error "$(translate "Subscription banner removal failed")"
|
||||
return 1
|
||||
fi
|
||||
register_tool "subscription_banner" true "$FUNC_VERSION"
|
||||
return 0
|
||||
fi
|
||||
local pve_version
|
||||
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
|
||||
|
||||
@@ -3143,8 +3171,11 @@ EOF
|
||||
}
|
||||
|
||||
|
||||
ensure_aliases
|
||||
msg_ok "$(translate "Aliases added to .bashrc")"
|
||||
# The aliases call Proxmox VE tools.
|
||||
if [[ "$PMX_ON_PBS" != true ]]; then
|
||||
ensure_aliases
|
||||
msg_ok "$(translate "Aliases added to .bashrc")"
|
||||
fi
|
||||
|
||||
msg_success "$(translate "Figurine installation and configuration completed successfully.")"
|
||||
register_tool "figurine" true "$FUNC_VERSION"
|
||||
@@ -3993,6 +4024,19 @@ main_menu() {
|
||||
"Optional|Install and configure Log2RAM|LOG2RAM"
|
||||
)
|
||||
|
||||
if [[ "$PMX_ON_PBS" == true ]]; then
|
||||
local -A pve_only=(
|
||||
[GUESTAGENT]=1 [VFIO_IOMMU]=1 [OPENVSWITCH]=1 [VZDUMP]=1 [PVEAM]=1
|
||||
[CEPH]=1 [ENABLE_HA]=1 [OVHRTM]=1 [PIGZ]=1
|
||||
)
|
||||
local pbs_options=() option_key
|
||||
for option in "${options[@]}"; do
|
||||
option_key="${option##*|}"
|
||||
[[ -n "${pve_only[$option_key]:-}" ]] || pbs_options+=("$option")
|
||||
done
|
||||
options=("${pbs_options[@]}")
|
||||
fi
|
||||
|
||||
IFS=$'\n' sorted_options=($(for option in "${options[@]}"; do
|
||||
IFS='|' read -r category description function_name <<< "$option"
|
||||
printf "%d|%s|%s|%s\n" "${category_order[$category]:-999}" "$category" "$description" "$function_name"
|
||||
|
||||
@@ -266,6 +266,12 @@ uninstall_subscription_banner() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# Proxmox Backup Server ships the same widget toolkit without the PVE packages.
|
||||
local -a ui_packages=(pve-manager proxmox-widget-toolkit libjs-extjs libpve-http-server-perl)
|
||||
if [[ "$(dpkg-query -W -f='${Status}' proxmox-backup-server 2>/dev/null)" == "install ok installed" ]]; then
|
||||
ui_packages=(proxmox-widget-toolkit libjs-extjs)
|
||||
fi
|
||||
|
||||
if [[ "$restored" == false ]]; then
|
||||
msg_info "$(translate "Performing complete package reinstallation...")"
|
||||
|
||||
@@ -274,7 +280,7 @@ uninstall_subscription_banner() {
|
||||
|
||||
# Reinstall packages with force-confnew to restore original configs
|
||||
if apt-get --reinstall -o Dpkg::Options::="--force-confnew" install \
|
||||
pve-manager proxmox-widget-toolkit libjs-extjs libpve-http-server-perl -y >/dev/null 2>&1; then
|
||||
"${ui_packages[@]}" -y >/dev/null 2>&1; then
|
||||
msg_ok "$(translate "Reinstalled Proxmox packages successfully")"
|
||||
restored=true
|
||||
else
|
||||
@@ -286,7 +292,7 @@ uninstall_subscription_banner() {
|
||||
|
||||
# Second pass reinstallation to ensure everything is clean
|
||||
apt-get update >/dev/null 2>&1
|
||||
apt-get --reinstall install proxmox-widget-toolkit pve-manager libjs-extjs libpve-http-server-perl -y >/dev/null 2>&1 || true
|
||||
apt-get --reinstall install "${ui_packages[@]}" -y >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
msg_info "$(translate "Cleaning cached files...")"
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Fixture-only PBS policy checks: never call host proxmox-backup-* tools or edit host APT sources."""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
POLICY = ROOT / 'scripts/global/repository_policy.py'
|
||||
|
||||
|
||||
def repo(components, *, uri='https://enterprise.proxmox.com/debian/pbs', suite='trixie', enabled=True):
|
||||
return {'Types': ['deb'], 'URIs': [uri], 'Suites': [suite],
|
||||
'Components': components.split(), 'Enabled': enabled}
|
||||
|
||||
|
||||
class PBSRepositoryPolicyTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
spec = importlib.util.spec_from_file_location('repository_policy', POLICY)
|
||||
cls.module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(cls.module)
|
||||
|
||||
def setUp(self):
|
||||
self.calls = []
|
||||
self.status = 'notfound'
|
||||
self.repositories = {
|
||||
'files': [
|
||||
{'path': '/etc/apt/sources.list.d/pbs-enterprise.sources', 'repositories': [repo('pbs-enterprise')]},
|
||||
{'path': '/etc/apt/sources.list.d/debian.sources',
|
||||
'repositories': [repo('main contrib', uri='http://deb.debian.org/debian')]},
|
||||
],
|
||||
'errors': [], 'digest': 'fixture-digest',
|
||||
'standard-repos': [{'handle': 'enterprise'}, {'handle': 'no-subscription'}, {'handle': 'test'}],
|
||||
}
|
||||
|
||||
def run_policy(self, apply=False):
|
||||
def run(args):
|
||||
self.calls.append(args)
|
||||
if args[:3] == ['proxmox-backup-manager', 'subscription', 'get']:
|
||||
return json.dumps({'status': self.status, 'serverid': 'FIXTURE-SECRET'})
|
||||
self.assertEqual(args[:2], ['proxmox-backup-debug', 'api'])
|
||||
action = args[2]
|
||||
if action == 'get':
|
||||
return json.dumps(self.repositories)
|
||||
self.assertEqual(args[args.index('--digest') + 1], self.repositories['digest'])
|
||||
if action == 'create':
|
||||
path = args[args.index('--path') + 1]
|
||||
index = int(args[args.index('--index') + 1])
|
||||
next(f for f in self.repositories['files'] if f['path'] == path)['repositories'][index]['Enabled'] = False
|
||||
elif action == 'set':
|
||||
self.repositories['files'].append({'path': '/etc/apt/sources.list.d/proxmox.sources',
|
||||
'repositories': [repo('pbs-no-subscription', uri='http://download.proxmox.com/debian/pbs')]})
|
||||
self.repositories['digest'] += '+'
|
||||
return ''
|
||||
with patch.object(self.module, 'run', side_effect=run):
|
||||
return self.module.evaluate('trixie', apply=apply, product='pbs')
|
||||
|
||||
def writes(self):
|
||||
return [c for c in self.calls if c[:2] == ['proxmox-backup-debug', 'api'] and c[2] in ('create', 'set')]
|
||||
|
||||
def test_fresh_pbs_offers_switch_without_writing(self):
|
||||
self.assertEqual(self.run_policy(), 'offer')
|
||||
self.assertEqual(self.writes(), [])
|
||||
self.assertFalse(any(c[0] in ('pvesh', 'pvesubscription') for c in self.calls))
|
||||
|
||||
def test_consented_switch_disables_pbs_enterprise_then_adds_no_subscription(self):
|
||||
self.assertEqual(self.run_policy(apply=True), 'changed')
|
||||
writes = self.writes()
|
||||
self.assertEqual([c[2] for c in writes], ['create', 'set'])
|
||||
self.assertEqual(writes[0][writes[0].index('--path') + 1], '/etc/apt/sources.list.d/pbs-enterprise.sources')
|
||||
self.assertEqual(writes[0][writes[0].index('--enabled') + 1], '0')
|
||||
self.assertEqual(writes[1][writes[1].index('--handle') + 1], 'no-subscription')
|
||||
|
||||
def test_active_subscription_preserves_enterprise(self):
|
||||
self.status = 'active'
|
||||
self.assertEqual(self.run_policy(apply=True), 'preserve')
|
||||
self.assertEqual(self.writes(), [])
|
||||
|
||||
def test_existing_no_subscription_is_preserved_without_lookup(self):
|
||||
self.repositories['files'][0]['repositories'] = [repo('pbs-no-subscription')]
|
||||
self.assertEqual(self.run_policy(apply=True), 'preserve')
|
||||
self.assertFalse(any(c[0] == 'proxmox-backup-manager' for c in self.calls))
|
||||
|
||||
def test_unrecognized_subscription_status_cannot_mutate(self):
|
||||
self.status = 'invalid'
|
||||
with self.assertRaisesRegex(ValueError, 'proxmox-backup-manager'):
|
||||
self.run_policy(apply=True)
|
||||
self.assertEqual(self.writes(), [])
|
||||
|
||||
def test_pve_channels_do_not_count_as_pbs_sources(self):
|
||||
self.repositories['files'][0]['repositories'] = [repo('pve-no-subscription')]
|
||||
self.assertEqual(self.run_policy(), 'offer')
|
||||
|
||||
def test_wrong_suite_points_to_pbs_interface(self):
|
||||
self.repositories['files'][0]['repositories'] = [repo('pbs-enterprise', suite='bookworm')]
|
||||
with self.assertRaisesRegex(ValueError, 'Administration > Repositories'):
|
||||
self.run_policy()
|
||||
self.assertEqual(self.writes(), [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -89,10 +89,12 @@ export default async function PostInstallPage({
|
||||
docs: { postInstall: {
|
||||
routes: Route[]
|
||||
related: { items: RelatedItem[] }
|
||||
pbs?: { items: string[] }
|
||||
} }
|
||||
}
|
||||
const routes = messages.docs.postInstall.routes
|
||||
const relatedItems = messages.docs.postInstall.related.items
|
||||
const pbsItems = messages.docs.postInstall.pbs?.items ?? []
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
@@ -210,6 +212,14 @@ export default async function PostInstallPage({
|
||||
{t.rich("community.body", { em, code, a: communityAnchor, link: externalRepoLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("pbs.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("pbs.body", { strong })}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
|
||||
{pbsItems.map((_, i) => (
|
||||
<li key={i}>{t.rich(`pbs.items.${i}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
|
||||
@@ -78,6 +78,17 @@
|
||||
"heading": "Community scripts",
|
||||
"body": "The menu also exposes two third-party scripts from the <a>community-scripts</a> project: <em>Proxmox VE Post Install</em> and <em>Proxmox VE Microcode</em>. These are wrappers that <code>wget | bash</code> the original authors' scripts. They are not maintained by ProxMenux. See <link>External Repositories</link> for details and trust considerations before running them."
|
||||
},
|
||||
"pbs": {
|
||||
"heading": "On Proxmox Backup Server",
|
||||
"body": "On a Proxmox Backup Server host, the same menu opens from <strong>Settings post-install Proxmox</strong> in the PBS menu. Automated, Customizable, Apply Available Updates and Uninstall Optimizations work as on Proxmox VE, with these differences:",
|
||||
"items": [
|
||||
"<strong>Repositories and upgrade:</strong> when the server has no subscription and only the Enterprise PBS repository is active, ProxMenux asks before switching to the PBS no-subscription repository. The switch goes through the server's own repository API, the same one behind <em>Administration > Repositories</em>. With an active subscription, or with a no-subscription or test repository already configured, the sources stay as they are.",
|
||||
"<strong>Customizable list:</strong> the options that only apply to Proxmox VE are not listed: guest agent, VFIO IOMMU, Open vSwitch, vzdump backup speed, pigz, Appliance Manager, Ceph, High Availability and OVH Real Time Monitoring.",
|
||||
"<strong>Subscription banner:</strong> the patch applies to the Proxmox Backup Server web interface, and Uninstall Optimizations restores it.",
|
||||
"<strong>Figurine:</strong> installs without the Proxmox VE aliases.",
|
||||
"<strong>Community scripts:</strong> the section offers <em>Proxmox Backup Server Post Install</em> and <em>Proxmox Backup Server Microcode</em> in place of the Proxmox VE scripts."
|
||||
]
|
||||
},
|
||||
"related": {
|
||||
"heading": "Related",
|
||||
"items": [
|
||||
|
||||
@@ -78,6 +78,17 @@
|
||||
"heading": "Community scripts",
|
||||
"body": "El menú también expone dos scripts de terceros del proyecto <a>community-scripts</a>: <em>Proxmox VE Post Install</em> y <em>Proxmox VE Microcode</em>. Son wrappers que hacen <code>wget | bash</code> de los scripts de los autores originales. No los mantiene ProxMenux. Mira <link>External Repositories</link> para detalles y consideraciones de confianza antes de ejecutarlos."
|
||||
},
|
||||
"pbs": {
|
||||
"heading": "En Proxmox Backup Server",
|
||||
"body": "En un host Proxmox Backup Server, el mismo menú se abre desde <strong>Settings post-install Proxmox</strong> en el menú de PBS. Automatizado, Personalizable, Apply Available Updates y Uninstall Optimizations funcionan igual que en Proxmox VE, con estas diferencias:",
|
||||
"items": [
|
||||
"<strong>Repositorios y actualización:</strong> cuando el servidor no tiene suscripción y solo está activo el repositorio Enterprise de PBS, ProxMenux pregunta antes de cambiar al repositorio sin suscripción de PBS. El cambio se hace con la API de repositorios del propio servidor, la misma que usa <em>Administration > Repositories</em>. Con una suscripción activa, o con un repositorio sin suscripción o de pruebas ya configurado, las fuentes se mantienen como están.",
|
||||
"<strong>Lista personalizable:</strong> no aparecen las opciones que solo se aplican a Proxmox VE: agente de invitado, VFIO IOMMU, Open vSwitch, velocidad de backups vzdump, pigz, Appliance Manager, Ceph, alta disponibilidad y OVH Real Time Monitoring.",
|
||||
"<strong>Banner de suscripción:</strong> el parche se aplica a la interfaz web de Proxmox Backup Server, y Uninstall Optimizations lo restaura.",
|
||||
"<strong>Figurine:</strong> se instala sin los alias de Proxmox VE.",
|
||||
"<strong>Scripts de la comunidad:</strong> la sección ofrece <em>Proxmox Backup Server Post Install</em> y <em>Proxmox Backup Server Microcode</em> en lugar de los scripts de Proxmox VE."
|
||||
]
|
||||
},
|
||||
"related": {
|
||||
"heading": "Relacionado",
|
||||
"items": [
|
||||
|
||||
Reference in New Issue
Block a user