diff --git a/.github/scripts/build_translation_cache.py b/.github/scripts/build_translation_cache.py index 95825a29..b5797010 100644 --- a/.github/scripts/build_translation_cache.py +++ b/.github/scripts/build_translation_cache.py @@ -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", diff --git a/.github/scripts/tests/test_oci_recovery_inventory_wording.py b/.github/scripts/tests/test_oci_recovery_inventory_wording.py index 77e7afaa..338698ae 100644 --- a/.github/scripts/tests/test_oci_recovery_inventory_wording.py +++ b/.github/scripts/tests/test_oci_recovery_inventory_wording.py @@ -5,6 +5,7 @@ import json from pathlib import Path import re import runpy +import subprocess import sys import tempfile from types import ModuleType, SimpleNamespace @@ -52,7 +53,8 @@ class InventoryMessages(unittest.TestCase): state = ModuleType('oci_installation_state') state.parse_config = lambda raw: dict(line.split(': ', 1) for line in raw.decode().splitlines()) scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda text: text or '', - 'translate': translate, 're': re} + 'translate': translate, 're': re, 'subprocess': subprocess, + '_selected_configuration': remover.guest_config} summary = extracted(MENU, '_removal_summary', scope) with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover, 'oci_installation_state': state}): diff --git a/.github/scripts/tests/test_oci_removal_firewall_wording.py b/.github/scripts/tests/test_oci_removal_firewall_wording.py index bde90937..8041f923 100644 --- a/.github/scripts/tests/test_oci_removal_firewall_wording.py +++ b/.github/scripts/tests/test_oci_removal_firewall_wording.py @@ -56,7 +56,8 @@ class RemovalWordings(unittest.TestCase): state = ModuleType('oci_installation_state') state.parse_config = lambda raw: {} scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '', - 'translate': translated or (lambda value: value), 're': re} + 'translate': translated or (lambda value: value), 're': re, + '_selected_configuration': remover.guest_config, 'subprocess': subprocess} with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover, 'oci_installation_state': state}): return extract(MENU, '_removal_summary', scope)(Path('/inert'), 101) @@ -87,7 +88,8 @@ class RemovalWordings(unittest.TestCase): with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover, 'oci_installation_state': state}): scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '', - 'translate': lambda value: value, 're': re} + 'translate': lambda value: value, 're': re, + '_selected_configuration': remover.guest_config, 'subprocess': subprocess} preview = extract(MENU, '_removal_summary', scope)(Path('/inert'), 102) self.assertIn(WHOLE, preview) diff --git a/.github/scripts/tests/test_oci_selection_setup_wording.py b/.github/scripts/tests/test_oci_selection_setup_wording.py index 9d35d7d5..7baa2ad5 100644 --- a/.github/scripts/tests/test_oci_selection_setup_wording.py +++ b/.github/scripts/tests/test_oci_selection_setup_wording.py @@ -42,13 +42,13 @@ class SelectionSetupWording(TestCase): shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: [], _clean_orphans=lambda project: None, recover_automatically=lambda project: None, offer_recovery=lambda project, ui: None, - carry_records=lambda project, **options: {}, + carry_records=lambda project, **options: {}, _records_unread=lambda project: False, translate=lambda s: s) fn(Path('/fixture'), ui) ui.message.assert_called_once_with(EMPTY, 'OCI management') ui.choose.assert_not_called() - def _menu(self, inventories, selections): + def _menu(self, inventories, selections, unread=True): rows = iter(inventories) ui = SimpleNamespace(message=Mock(), choose=Mock(side_effect=selections)) refresh = Mock(return_value={}) @@ -57,7 +57,8 @@ class SelectionSetupWording(TestCase): shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: next(rows), _clean_orphans=lambda project: None, recover_automatically=lambda project: None, offer_recovery=lambda project, ui: None, - carry_records=refresh, manage_instance=manage, menu_size=lambda: 10, + carry_records=refresh, _records_unread=lambda project: unread, + manage_instance=manage, menu_size=lambda: 10, STATUS_LABELS={}, translate=lambda s: s) fn(Path('/fixture'), ui) return ui, refresh, manage @@ -67,7 +68,14 @@ class SelectionSetupWording(TestCase): ui, refresh, manage = self._menu([[row], [row]], ['101', None]) self.assertEqual(ui.choose.call_count, 2) manage.assert_called_once() - refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=True) + refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=True, + heading='Manage installed OCI applications') + + def test_the_list_opens_without_the_sync_notice_once_every_configuration_is_cached(self): + row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'} + _, refresh, _ = self._menu([[row]], [None], unread=False) + refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=False, + heading='Manage installed OCI applications') def test_removing_the_last_application_returns_without_a_message(self): row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'} diff --git a/.github/scripts/tests/test_oci_update_preservation_wording.py b/.github/scripts/tests/test_oci_update_preservation_wording.py index 44146970..477196e9 100644 --- a/.github/scripts/tests/test_oci_update_preservation_wording.py +++ b/.github/scripts/tests/test_oci_update_preservation_wording.py @@ -130,6 +130,7 @@ class UpdateWording(unittest.TestCase): reconcile.propose = lambda *args: None scope = {'sys': SimpleNamespace(path=[], executable='python3'), 'translate': lambda text: text, 'check_selected': lambda project, row: row, + '_selected_configuration': lambda vmid: instances.command('pct', 'config', str(vmid)), 'images': SimpleNamespace(offer_removal=lambda *args: None), 'carry_records': lambda project, **options: {}, '_work_backup_ready': lambda project, ui, vmid: True, diff --git a/.github/workflows/test-oci-registries.yml b/.github/workflows/test-oci-registries.yml index fbff4ff7..a4f0b150 100644 --- a/.github/workflows/test-oci-registries.yml +++ b/.github/workflows/test-oci-registries.yml @@ -37,7 +37,46 @@ jobs: python3 -m unittest discover -s oci/tests -p test_modify_review.py -v python3 -m unittest discover -s oci/tests -p test_modify_review_transaction.py -v python3 -m unittest discover -s oci/tests -p test_stage_release.py -v + - name: Run carried record, recovery and configuration cache regressions + env: + PYTHONDONTWRITEBYTECODE: '1' + 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 OCI Modify latency regressions + env: + PYTHONDONTWRITEBYTECODE: '1' + run: | + python3 -m unittest discover -s oci/tests -p test_modify_latency.py -v + python3 -m unittest discover -s oci/tests -p test_menu_performance.py -v + python3 -m unittest discover -s oci/tests -p test_dialog_escape.py -v + python3 -m unittest discover -s oci/tests -p test_work_backup.py -v + python3 -m unittest discover -s oci/tests -p test_recreation.py -v + - name: Run OCI modification menu regressions + env: + PYTHONDONTWRITEBYTECODE: '1' + run: | + python3 -m unittest discover -s oci/tests -p test_modify_menu.py -v + python3 -m unittest discover -s oci/tests -p test_manage_sync_notice.py -v + python3 -m unittest discover -s oci/tests -p test_recreation.py -v + python3 -m unittest discover -s oci/tests -p test_shared_directories.py -v + python3 -m unittest discover -s oci/tests -p 'test_wizard*.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 diff --git a/.github/workflows/test-pbs.yml b/.github/workflows/test-pbs.yml index 2d60623c..2dbc71b0 100644 --- a/.github/workflows/test-pbs.yml +++ b/.github/workflows/test-pbs.yml @@ -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 diff --git a/AppImage/components/health-status-modal.tsx b/AppImage/components/health-status-modal.tsx index c8be541e..090a51fb 100644 --- a/AppImage/components/health-status-modal.tsx +++ b/AppImage/components/health-status-modal.tsx @@ -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(null) const [dismissedItems, setDismissedItems] = useState([]) @@ -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 */}
- {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
-

{t(`healthStatus.categories.${key}`)}

+

{categoryLabel(key)}

{hasChecks && ( ({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
- {catMeta ? t(`healthStatus.categories.${catMeta.key}`) : cs.label} + {catMeta ? categoryLabel(catMeta.key) : cs.label}
{durationLabel} diff --git a/AppImage/components/monitor-switcher.tsx b/AppImage/components/monitor-switcher.tsx new file mode 100644 index 00000000..0cd2d34b --- /dev/null +++ b/AppImage/components/monitor-switcher.tsx @@ -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(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" ? : +} + +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("/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
event.stopPropagation()}> + + + + + + {t("monitorLinks.thisNode")} + + + + {nodeName} + + {origin && !originKnown && open(withOrigin(origin.url), false)}> + + {t("monitorLinks.backTo", { name: origin.name })} + } + {isLoading &&

{t("monitorLinks.checking")}

} + {error &&

{t("monitorLinks.loadFailed")}

} + {data && data.links.length === 0 &&

{t("monitorLinks.empty")}

} + {data && GROUPS.map(group => { + const rows = data.links.filter(link => link.group === group) + if (rows.length === 0) return null + return
+ + {t(`monitorLinks.groups.${group}`)} + {rows.map(link => { + const target = destination(link) + return go(link, false)}> + + + {link.name} + {target?.native + ? {t("monitorLinks.pbsInterface")} + : link.status !== "online" && {t("monitorLinks.noResponse")}} + {target && } + + })} +
+ })} + + setManageOpen(true)}> + {t("monitorLinks.manage")} + +
+
+ {manageOpen && } + {origin && + event.stopPropagation()}> + + {t("monitorLinks.cameFromTitle")} + {t("monitorLinks.cameFromBody", { name: origin.name })} + +

{origin.url}

+ {addError &&

{addError}

} +
+ + +
+
+
} +
+} + +function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { + const t = useT() + const { data, mutate } = useSWR("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false }) + const [form, setForm] = useState({ name: "", url: "", product: "pve" }) + const [addresses, setAddresses] = useState>({}) + const [busy, setBusy] = useState(false) + const [error, setError] = useState("") + + const run = async (action: () => Promise) => { + 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 + event.stopPropagation()}> + + {t("monitorLinks.dialogTitle")} + {t("monitorLinks.dialogDescription")} + + +
+

{t("monitorLinks.addTitle")}

+
+
+ + setForm({ ...form, name: event.target.value })} /> +
+
+ + setForm({ ...form, url: event.target.value })} /> +
+
+ + +
+
+ +
+ + {error &&

{error}

} + +
+ {data?.links.length === 0 &&

{t("monitorLinks.empty")}

} + {data?.links.map(link => { + const address = addresses[link.key] ?? (link.manual || link.custom_url ? link.url || "" : "") + return
+
+ + {link.name} + {link.manual ? t("monitorLinks.added") : t("monitorLinks.detected")} + {!link.manual && } + {link.manual && } +
+
+ setAddresses({ ...addresses, [link.key]: event.target.value })} /> + +
+
+ })} +
+
+
+} diff --git a/AppImage/components/notification-settings.tsx b/AppImage/components/notification-settings.tsx index e84590ee..194d5574 100644 --- a/AppImage/components/notification-settings.tsx +++ b/AppImage/components/notification-settings.tsx @@ -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(DEFAULT_CONFIG) const [status, setStatus] = useState(null) @@ -703,7 +707,7 @@ export function NotificationSettings() {
- {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 diff --git a/AppImage/components/pbs-dashboard.tsx b/AppImage/components/pbs-dashboard.tsx deleted file mode 100644 index fd90b1b6..00000000 --- a/AppImage/components/pbs-dashboard.tsx +++ /dev/null @@ -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("/api/system", url => fetchApi(url), { refreshInterval: 10000 }) - return
-
-

ProxMenux · PBS

{data?.hostname || t("pbs.loading")}

- - -
- - {tab === "overview" && <> -
- {[[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]) => -

{label}

{value}

)} -
- - } - {tab === "logs" && } - {tab === "terminal" && } - {tab === "security" && } -
-} diff --git a/AppImage/components/pbs-datastore-usage.tsx b/AppImage/components/pbs-datastore-usage.tsx new file mode 100644 index 00000000..944ba340 --- /dev/null +++ b/AppImage/components/pbs-datastore-usage.tsx @@ -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
+
+ {store.name} + — +
+

{store.error || t("pbs.capacityUnavailable")}

+
+ } + const tone = usageTone(percent) + return
+
+ {store.name} + {percent.toFixed(1)}% +
+ + {!compact &&
+ {gib(store.used!)} / {gib(store.total!)} + {t("pbs.free")}: {gib(store.avail ?? store.total! - store.used!)} +
} +
+} + +// 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
+
+
+ +

{store.name}

+ {type} +
+
+ + {store.error ? t("storage.status.error") : t("storage.status.active")} + + {percent != null && {percent.toFixed(2)}%} +
+
+ {percent != null && tone ? ( +
+ +
+
+

{t("storage.total")}

+

{gib(store.total!)}

+
+
+

{t("storage.used")}

+

{gib(store.used!)}

+
+
+

{t("storage.available")}

+

{gib(store.avail ?? store.total! - store.used!)}

+
+
+
+ ) : ( +

{store.error || t("storage.capacityNotReported")}

+ )} + {(store.path || fullDate !== undefined) &&
+ {store.path && {store.path}} + {fullDate !== undefined && + {t("pbs.estimatedFull")}: + {fullDate > 0 ? new Date(fullDate * 1000).toLocaleDateString() : t("pbs.estimatedFullUnknown")} + } +
} +
+} diff --git a/AppImage/components/pbs-panel.tsx b/AppImage/components/pbs-panel.tsx index fb1354eb..d71b30a9 100644 --- a/AppImage/components/pbs-panel.tsx +++ b/AppImage/components/pbs-panel.tsx @@ -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; - datastores: Datastore[] | null; tasks: Task[] | null; + datastores: PBSDatastore[] | null; tasks: Task[] | null; sync: Job[] | null; verify: Job[] | null; prune: Job[] | null; errors: Record; } 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 = { + 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( "/api/pbs/servers", url => fetchApi(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
-
-

{t("pbs.title")}

- - - -
+ return
+ {!localOnly &&
+

{t("pbs.title")}

+ + + +
} {(connectionError || statusError) &&

{String((connectionError || statusError)?.message || t("pbs.loadFailed"))}

} {isLoading &&

{t("pbs.loading")}

} {!selected && !connectionError && connections &&

{t("pbs.noServer")}

} - {manage && + {manage && !localOnly && {t("pbs.manage")}

{t("pbs.permissionsHelp")}

- +
{(["name", "url", "token_id", "token_secret", "fingerprint"] as const).map(field =>
@@ -118,32 +170,78 @@ export function PBSPanel() { } {data && <> -

{data.name} · PBS {data.version || "—"} · {t("pbs.sampled")} {new Date(data.fetched_at * 1000).toLocaleTimeString()}

- {Object.keys(data.services).length > 0 &&
- {Object.entries(data.services).map(([service, state]) => {service}: {state})} -
} +
+ {data.name} · PBS {data.version || "—"} · {t("pbs.sampled")} {new Date(data.fetched_at * 1000).toLocaleTimeString()} + {Object.entries(data.services).map(([service, state]) => + {service}: {state})} +
{Object.entries(data.errors).map(([section, detail]) =>

{t(`pbs.${section}`)}: {detail}

)} - {t("pbs.datastores")} - {data.datastores?.length === 0 &&

{t("pbs.noDatastores")}

} - {data.datastores && data.datastores.length > 0 &&
- - {data.datastores.map(row => )} -
{t("pbs.name")}{t("pbs.total")}{t("pbs.used")}{t("pbs.free")}
{row.name}{row.error &&

{row.error}

}
{capacity(row.total)}{capacity(row.used)}{capacity(row.avail)}
} -
- {t("pbs.tasks")} - {data.tasks?.length === 0 &&

{t("pbs.noTasks")}

} - {data.tasks && data.tasks.length > 0 &&
- - {data.tasks.map(row => )} -
{t("pbs.task")}{t("pbs.started")}{t("pbs.status")}
{row.worker_type} {row.worker_id}{row.starttime ? new Date(row.starttime * 1000).toLocaleString() : "—"}{row.status || (row.endtime ? "—" : t("pbs.running"))}
} -
- {t("pbs.jobs")} -
- - {(["sync", "verify", "prune"] as const).flatMap(kind => (data[kind] || []).map(row => - ))} -
{t("pbs.job")}{t("pbs.datastores")}{t("pbs.schedule")}{t("pbs.status")}
{t(`pbs.${kind}`)} · {row.id}{row.store || "—"}{row.schedule || "—"}{row["last-run-state"] || "—"}
-
+ + + {t("pbs.datastores")} + + {data.datastores?.length === 0 &&

{t("pbs.noDatastores")}

} + {data.datastores && data.datastores.length > 0 &&
+ {data.datastores.map(store => )} +
} +
+
+ + + {t("pbs.tasks")} + + {data.tasks?.length === 0 &&

{t("pbs.noTasks")}

} + {data.tasks && data.tasks.length > 0 &&
+ + + + + + + {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 + + + + + + })} +
{t("pbs.task")}{t("pbs.started")}{t("pbs.duration")}{t("pbs.status")}
+ + {row.worker_type} + {row.worker_id && {row.worker_id}} + {row.starttime ? new Date(row.starttime * 1000).toLocaleString() : "—"}{row.starttime && end ? formatDuration(end - row.starttime) : "—"} + {state === "running" ? t("pbs.running") : row.status || "—"} +
} +
+
+ + + {t("pbs.jobs")} + + {(() => { + const jobs = (["sync", "verify", "prune"] as const).flatMap(kind => (data[kind] || []).map(row => ({ kind, row }))) + if (jobs.length === 0) return

{t("pbs.noJobs")}

+ return
+ + + + + + + {jobs.map(({ kind, row }) => + + + + + )} +
{t("pbs.job")}{t("pbs.datastores")}{t("pbs.schedule")}{t("pbs.status")}
{t(`pbs.${kind}`)} · {row.id}{row.store || "—"}{row.schedule || "—"}{row["last-run-state"] || "—"}
+ })()} +
+
}
} diff --git a/AppImage/components/proxmox-dashboard.tsx b/AppImage/components/proxmox-dashboard.tsx index 679c9aaf..4b426082 100644 --- a/AppImage/components/proxmox-dashboard.tsx +++ b/AppImage/components/proxmox-dashboard.tsx @@ -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 +} + +function PBSDashboard() { + return +} + +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({ 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 (
- - setShowReleaseNotes(false)} /> + {/* Both describe the PVE Monitor. */} + {!isPbs && } + {!isPbs && setShowReleaseNotes(false)} />} {!isServerConnected && (
@@ -473,22 +495,17 @@ function PVEDashboard() {

{t("app.title")}

-

{t("app.description")}

+

{t(isPbs ? "app.descriptionPbs" : "app.description")}

- - {t("status.node", { node: displayServerName })} + + {t("status.uptime", { uptime: displayUptime })}
{/* Desktop Actions */}
-
- -
-
{t("status.node", { node: displayServerName })}
-
-
+
@@ -589,9 +606,7 @@ function PVEDashboard() { )}
- - {t("status.uptime", { uptime: displayUptime })} - +
@@ -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() { ) - 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 ( - + {tabOrder.map(renderTop)} ) @@ -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() { {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"))} @@ -820,9 +837,16 @@ function PVEDashboard() { 5 s / 59 s refresh intervals keep the data fresh in the background — reopening the tab is instant. */} - + + {isPbs && ( + + + + )} + + {!isPbs && <> @@ -848,11 +872,13 @@ function PVEDashboard() { + } + {!isPbs && <> @@ -860,13 +886,14 @@ function PVEDashboard() { + } - + {/* Profile tab — not surfaced in the top tabs nav. The only diff --git a/AppImage/components/settings.tsx b/AppImage/components/settings.tsx index a49968f6..37e3edc8 100644 --- a/AppImage/components/settings.tsx +++ b/AppImage/components/settings.tsx @@ -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() {
{/* Navigation Tab Order — user-orderable top-level tabs */} - + {!isPbs && } {/* Network Units Settings */} @@ -1280,7 +1282,7 @@ export function Settings() { {/* Per-category rows */}
- {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 (
@@ -1496,6 +1499,7 @@ export function Settings() { + {!isPbs && <> {/* Remote Storage Exclusions */} @@ -1678,6 +1682,7 @@ export function Settings() { )} + } {/* Network Interface Exclusions */} @@ -1875,6 +1880,7 @@ export function Settings() { is re-enabled). */} + {!isPbs && <> {/* GitHub API — optional authentication for app upstream checks. */} @@ -1981,15 +1987,16 @@ export function Settings() { )} + } - {/* Notification Settings */} - + {/* Notification Settings (PVE only for now) */} + {!isPbs && } {/* 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 && (
diff --git a/AppImage/components/system-overview.tsx b/AppImage/components/system-overview.tsx index ab443d42..ecbeeef7 100644 --- a/AppImage/components/system-overview.tsx +++ b/AppImage/components/system-overview.tsx @@ -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(null) const [vmData, setVmData] = useState([]) const [storageData, setStorageData] = useState(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() { - {/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */} + {isPbs ? ( + + + {t("pbs.datastores")} + + + + {!datastores && !pbsError ? ( +
+
+
+
+ ) : !datastores ? ( +

{t("pbs.loadFailed")}

+ ) : datastores.length === 0 ? ( +

{t("pbs.noDatastores")}

+ ) : ( +
+
+ {datastores.length} + {datastores.some(store => store.error) ? ( + + {t("pbs.datastoresUnavailable", { count: datastores.filter(store => store.error).length })} + + ) : ( + + {t("pbs.datastoresOnline")} + + )} +
+ {datastores.slice(0, 2).map(store => )} + {datastores.length > 2 && ( +

{t("pbs.moreDatastores", { count: datastores.length - 2 })}

+ )} +
+ )} +
+
+ ) : ( + /* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */ {t("overview.activeVmLxc")} @@ -565,6 +615,7 @@ export function SystemOverview() { )} + )} 0 ? "cursor-pointer hover:bg-white/5 transition-colors" : ""}`} @@ -708,7 +759,16 @@ export function SystemOverview() {
- {vmLxcStorages && vmLxcStorages.length > 0 ? ( + {isPbs ? ( +
+
{t("pbs.datastores")}
+ {!datastores ? ( +
{pbsError ? t("pbs.loadFailed") : t("pbs.loading")}
+ ) : datastores.length === 0 ? ( +
{t("pbs.noDatastores")}
+ ) : datastores.map(store => )} +
+ ) : vmLxcStorages && vmLxcStorages.length > 0 ? (
{t("overview.vmLxcStorage")}
@@ -744,7 +804,7 @@ export function SystemOverview() {
)} - {localStorage && ( + {!isPbs && localStorage && (
{t("overview.localStorageSystem")}
@@ -899,7 +959,7 @@ export function SystemOverview() { {formatSystemUptime(systemData.uptime)}
- {t("overview.proxmoxVersion")} + {t(isPbs ? "overview.pbsVersion" : "overview.proxmoxVersion")} {systemData.proxmox_version || t("app.notAvailable")}
diff --git a/AppImage/lib/use-platform.ts b/AppImage/lib/use-platform.ts new file mode 100644 index 00000000..cbb99cc6 --- /dev/null +++ b/AppImage/lib/use-platform.ts @@ -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" +} diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json index 9fa0c7cf..eb4fc2ec 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -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" } } diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index fa596302..cc8d31f7 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -50,11 +50,20 @@ "navigation": "PBS navigation", "cpu": "CPU", "memory": "Memory", - "uptime": "Uptime" + "uptime": "Uptime", + "capacityUnavailable": "Capacity not available.", + "datastoresUnavailable": "{count} unavailable", + "datastoresOnline": "All available", + "moreDatastores": "+{count} more datastores", + "estimatedFull": "Estimated full", + "estimatedFullUnknown": "Not enough data yet", + "duration": "Duration", + "noJobs": "No sync, verification or prune schedules are configured." }, "app": { "title": "ProxMenux Monitor", "description": "Proxmox System Dashboard", + "descriptionPbs": "Proxmox Backup Server Dashboard", "loading": "Loading...", "connecting": "Connecting to ProxMenux Monitor", "unknown": "Unknown", @@ -195,6 +204,7 @@ "dayMany": "{count} days" }, "proxmoxVersion": "Proxmox Version:", + "pbsVersion": "PBS Version:", "kernel": "Kernel:", "availableUpdates": "Available Updates:", "packages": "packages", @@ -1880,6 +1890,7 @@ "network": "Network Interfaces", "vms": "VMs & Containers", "services": "PVE Services", + "pbsServices": "PBS Services", "logs": "System Logs", "updates": "System Updates", "security": "Security & Certificates", @@ -4874,6 +4885,7 @@ "network": "Network interfaces", "vms": "VMs & containers", "services": "PVE services", + "pbsServices": "PBS services", "logs": "System logs", "updates": "System updates", "security": "Security & certificates" @@ -6529,5 +6541,38 @@ "cancel": "Cancel" } }, - "runtime": {"notifications":{"templates":{"state_change":{"title":"{hostname}: {category} changed to {current}{entity_suffix}","body":"{category} status changed from {previous} to {current}.\n{reason}","label":"Health state changed"},"new_error":{"title":"{hostname}: New {severity} - {category}{entity_suffix}","body":"{reason}","label":"New health issue"},"error_resolved":{"title":"{hostname}: Resolved - {category}{entity_suffix}","body":"The {category} issue has been resolved.\n{reason}\n🚦 Previous severity: {original_severity}\n⏱️ Duration: {duration}","label":"Recovery notification"},"error_escalated":{"title":"{hostname}: Escalated to {severity} - {category}{entity_suffix}","body":"{reason}","label":"Health issue escalated"},"health_degraded":{"title":"{title_or_default}","body":"{reason}","label":"Health check degraded"},"lxc_updates_available":{"title":"{hostname}: {count} LXC(s) with package updates available","body":"📊 {count} LXC(s) with pending package updates (📦 {total_packages} total, 🔒 {security_count} security):\n\n{ct_list}","label":"LXC updates available (experimental)"},"lxc_update_applied":{"title":"{hostname}: LXC {ct_name} ({vmid}) update {result}","body":"{details}","label":"LXC update applied"},"oci_update_completed":{"title":"{hostname}: {app_name} updated","body":"{app_name} was updated to its new image and its data was kept.\nContainers: {containers}","label":"OCI application updated"},"oci_update_failed":{"title":"{hostname}: {app_name} update did not complete","body":"The update of {app_name} did not complete.\nReason: {reason}\nContainers: {containers}\nOpen Manage installed OCI applications to check its state.","label":"OCI application update failed"},"oci_recreate_completed":{"title":"{hostname}: {app_name} recreated","body":"{app_name} was recreated with its saved configuration and its data was kept.\nContainers: {containers}","label":"OCI application recreated"},"oci_recreate_failed":{"title":"{hostname}: {app_name} recreation did not complete","body":"The recreation of {app_name} did not complete.\nReason: {reason}\nContainers: {containers}\nOpen Manage installed OCI applications to check its state.","label":"OCI application recreation failed"},"oci_modify_completed":{"title":"{hostname}: {app_name} modified","body":"{app_name} was modified with its new options and its data was kept.\nContainers: {containers}","label":"OCI application modified"},"oci_modify_failed":{"title":"{hostname}: {app_name} modification did not complete","body":"The modification of {app_name} did not complete.\nReason: {reason}\nContainers: {containers}\nOpen Manage installed OCI applications to check its state.","label":"OCI application modification failed"},"oci_watchdog_restarted":{"title":"{hostname}: {app_name} restarted after it stopped","body":"{app_name} stopped on its own and the watchdog started it again.\nContainers: {containers}","label":"OCI application restarted by the watchdog"},"oci_watchdog_failed":{"title":"{hostname}: {app_name} could not be kept running","body":"The watchdog could not keep {app_name} running: it stopped again or did not start after several attempts, and it is left stopped.\nContainers: {containers}\nStart it by hand once the cause is solved; the watchdog then watches it again.","label":"OCI application left stopped by the watchdog"},"app_update_available":{"title":"{hostname}: {app_name} update available on CT {vmid}","body":"{app_name} on CT {vmid} ({ct_name}) has a new version:\n {installed} → {latest}","label":"App update available"},"docker_stack_update_available":{"title":"{hostname}: Docker updates available on CT {vmid}","body":"Container {ct_name} (CT {vmid}) has {count} Docker update(s):\n{details}","label":"Docker updates available"},"vm_start":{"title":"{hostname}: VM {vmname} ({vmid}) started","body":"Virtual machine {vmname} (ID: {vmid}) is now running.","label":"VM started"},"vm_start_warning":{"title":"{hostname}: VM {vmname} ({vmid}) started with warnings","body":"Virtual machine {vmname} (ID: {vmid}) started successfully but has warnings.\nWarnings: {reason}","label":"VM started (warnings)"},"vm_stop":{"title":"{hostname}: VM {vmname} ({vmid}) stopped","body":"Virtual machine {vmname} (ID: {vmid}) has been stopped.","label":"VM stopped"},"vm_shutdown":{"title":"{hostname}: VM {vmname} ({vmid}) shut down","body":"Virtual machine {vmname} (ID: {vmid}) has been cleanly shut down.","label":"VM shutdown"},"vm_fail":{"title":"{hostname}: VM {vmname} ({vmid}) FAILED","body":"Virtual machine {vmname} (ID: {vmid}) has crashed or failed to start.\nReason: {reason}","label":"VM FAILED"},"vm_restart":{"title":"{hostname}: VM {vmname} ({vmid}) restarted","body":"Virtual machine {vmname} (ID: {vmid}) has been restarted.","label":"VM restarted"},"ct_start":{"title":"{hostname}: CT {vmname} ({vmid}) started","body":"Container {vmname} (ID: {vmid}) is now running.","label":"CT started"},"ct_start_warning":{"title":"{hostname}: CT {vmname} ({vmid}) started with warnings","body":"Container {vmname} (ID: {vmid}) started successfully but has warnings.\nWarnings: {reason}","label":"CT started (warnings)"},"ct_stop":{"title":"{hostname}: CT {vmname} ({vmid}) stopped","body":"Container {vmname} (ID: {vmid}) has been stopped.","label":"CT stopped"},"ct_shutdown":{"title":"{hostname}: CT {vmname} ({vmid}) shut down","body":"Container {vmname} (ID: {vmid}) has been cleanly shut down.","label":"CT shutdown"},"ct_restart":{"title":"{hostname}: CT {vmname} ({vmid}) restarted","body":"Container {vmname} (ID: {vmid}) has been restarted.","label":"CT restarted"},"ct_fail":{"title":"{hostname}: CT {vmname} ({vmid}) FAILED","body":"Container {vmname} (ID: {vmid}) has crashed or failed to start.\nReason: {reason}","label":"CT FAILED"},"migration_start":{"title":"{hostname}: Migration started — {vmname} ({vmid})","body":"Live migration of {vmname} (ID: {vmid}) to node {target_node} has started.","label":"Migration started"},"migration_complete":{"title":"{hostname}: Migration complete — {vmname} ({vmid})","body":"{vmname} (ID: {vmid}) successfully migrated to node {target_node}.","label":"Migration complete"},"migration_warning":{"title":"{hostname}: Migration complete with warnings — {vmname} ({vmid})","body":"{vmname} (ID: {vmid}) migrated to node {target_node} but encountered warnings.\nWarnings: {reason}","label":"Migration (warnings)"},"migration_fail":{"title":"{hostname}: Migration FAILED — {vmname} ({vmid})","body":"Migration of {vmname} (ID: {vmid}) to node {target_node} failed.\nReason: {reason}","label":"Migration FAILED"},"replication_fail":{"title":"{hostname}: Replication FAILED — {vmname} ({vmid})","body":"Replication of {vmname} (ID: {vmid}) failed.\nReason: {reason}","label":"Replication FAILED"},"replication_complete":{"title":"{hostname}: Replication complete — {vmname} ({vmid})","body":"Replication of {vmname} (ID: {vmid}) completed successfully.","label":"Replication complete"},"backup_start":{"title":"{hostname}: Backup started on {storage}","body":"Backup job started on storage {storage}.\n{reason}","label":"Backup started"},"backup_complete":{"title":"{hostname} → {storage}: Backup complete — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) completed successfully on {storage}.\nSize: {size}","label":"Backup complete"},"backup_warning":{"title":"{hostname} → {storage}: Backup complete with warnings — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) on {storage} completed but encountered warnings.\nWarnings: {reason}","label":"Backup (warnings)"},"backup_fail":{"title":"{hostname} → {storage}: Backup FAILED — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) failed on {storage}.\nReason: {reason}","label":"Backup FAILED"},"host_backup_start":{"title":"{hostname}: Host backup started → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nProfile: {profile_mode}","label":"Host backup started"},"host_backup_complete":{"title":"{hostname}: Host backup complete → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nData size: {data_size}\nArchive size: {archive_size}\nDuration: {duration}","label":"Host backup complete"},"host_backup_fail":{"title":"{hostname}: Host backup FAILED → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nDuration before failure: {duration}\nReason: {reason}\nLog: {log_file}","label":"Host backup FAILED"},"snapshot_complete":{"title":"{hostname}: Snapshot created — {vmname} ({vmid})","body":"Snapshot \"{snapshot_name}\" created for {vmname} (ID: {vmid}).","label":"Snapshot created"},"snapshot_fail":{"title":"{hostname}: Snapshot FAILED — {vmname} ({vmid})","body":"Snapshot creation for {vmname} (ID: {vmid}) failed.\nReason: {reason}","label":"Snapshot FAILED"},"cpu_high":{"title":"{hostname}: High CPU usage — {value}%","body":"CPU usage has reached {value}% on {cores} cores.\n{details}","label":"High CPU usage"},"ram_high":{"title":"{hostname}: High memory usage — {value}%","body":"Memory usage: {used} / {total} ({value}%).\n{details}","label":"High memory usage"},"temp_high":{"title":"{hostname}: High sensor temperature — {value}°C","body":"Sensor temperature has reached {value}°C (threshold: {threshold}°C).\n{details}","label":"High temperature"},"disk_space_low":{"title":"{hostname}: Low disk space on {mount}","body":"Filesystem {mount}: {used}% used ({available} available).\nFree up disk space to avoid service disruption.","label":"Low disk space"},"disk_io_error":{"title":"{hostname}: Disk failure detected — {device}","body":"I/O error or disk failure detected on device {device}.\n{reason}","label":"Disk failure / I/O error"},"storage_unavailable":{"title":"{hostname}: Storage unavailable — {storage_name}","body":"PVE storage \"{storage_name}\" (type: {storage_type}) is not accessible.\nReason: {reason}","label":"Storage unavailable"},"smart_test_complete":{"title":"{hostname}: SMART test completed — {device}","body":"SMART {test_type} test on /dev/{device} has completed.\nResult: {result}\nDuration: {duration}","label":"SMART test completed"},"smart_test_failed":{"title":"{hostname}: SMART test FAILED — {device}","body":"SMART {test_type} test on /dev/{device} has failed.\nResult: {result}\nReason: {reason}","label":"SMART test FAILED"},"gpu_mode_switch":{"title":"{hostname}: GPU mode changed to {new_mode}","body":"GPU passthrough mode has been switched.\nGPU: {gpu_name} ({gpu_pci})\nPrevious mode: {old_mode}\nNew mode: {new_mode}\n{details}","label":"GPU mode switched"},"gpu_passthrough_blocked":{"title":"{hostname}: {guest_type} {guest_id} blocked at startup","body":"PCIe passthrough guard prevented {guest_type} {guest_id} ({guest_name}) from starting.\nReason: {reason}\n{details}","label":"GPU passthrough blocked"},"pci_passthrough_conflict":{"title":"{hostname}: PCIe device conflict detected — {device_pci}","body":"A PCIe device is assigned to multiple guests.\nDevice: {device_pci}\nConflicting guests: {guest_list}\nAction required: Stop one of the guests or reassign the device.","label":"PCIe device conflict"},"load_high":{"title":"{hostname}: High system load — {value}","body":"System load average is {value} on {cores} cores.\n{details}","label":"High system load"},"network_down":{"title":"{hostname}: Network connectivity lost{entity_suffix}","body":"The node has lost network connectivity.\nReason: {reason}","label":"Network connectivity lost"},"network_latency":{"title":"{hostname}: High network latency — {value}ms","body":"Latency to gateway: {value}ms (threshold: {threshold}ms).\nThis may indicate network congestion or hardware issues.","label":"High network latency"},"auth_fail":{"title":"{hostname}: Authentication failure","body":"Failed login attempt detected.\nSource IP: {source_ip}\nUser: {username}\nService: {service}","label":"Authentication failure"},"ip_block":{"title":"{hostname}: IP blocked by Fail2Ban","body":"IP address {source_ip} has been banned.\nJail: {jail}\nFailed attempts: {failures}","label":"IP blocked by Fail2Ban"},"firewall_issue":{"title":"{hostname}: Firewall issue detected{entity_suffix}","body":"A firewall configuration issue has been detected.\nReason: {reason}","label":"Firewall issue detected"},"user_permission_change":{"title":"{hostname}: User permission changed","body":"User: {username}\nChange: {change_details}","label":"User permission changed"},"split_brain":{"title":"{hostname}: Cluster event reported","body":"A cluster event was reported:\n{reason}","label":"Cluster event"},"node_disconnect":{"title":"{hostname}: Node {node_name} disconnected","body":"Node {node_name} has disconnected from the cluster.","label":"Node disconnected"},"node_reconnect":{"title":"{hostname}: Node {node_name} reconnected","body":"Node {node_name} has rejoined the cluster successfully.","label":"Node reconnected"},"system_startup":{"title":"{hostname}: {reason}","body":"{summary}","label":"System startup report"},"system_shutdown":{"title":"{hostname}: System shutting down","body":"The node is shutting down.\n{reason}","label":"System shutting down"},"system_reboot":{"title":"{hostname}: System rebooting","body":"The node is rebooting.\n{reason}","label":"System rebooting"},"system_restore_completed":{"title":"{hostname}: Host restore finished","body":"Post-restore tasks completed in background.\n\nGuest configurations copied: {guests}\nBind-mount stub directories created: {stubs}\nStale node dirs removed: {stale_nodes}\nComponents reinstalled: {components}\nDuration: {duration}\n{warnings_block}","label":"Host restore completed"},"system_problem":{"title":"{hostname}: System problem detected{entity_suffix}","body":"A system-level problem has been detected.\nReason: {reason}","label":"System problem detected"},"service_fail":{"title":"{hostname}: Service failed — {service_name}","body":"System service \"{service_name}\" has failed.\nReason: {reason}","label":"Service failed"},"oom_kill":{"title":"{hostname}: OOM Kill — {process}","body":"Process \"{process}\" was killed by the Out-of-Memory manager.\n{reason}","label":"Out of memory kill"},"service_fail_batch":{"title":"{hostname}: {service_count} services failed{entity_suffix}","body":"{reason}","label":"Service fail batch"},"cron_output":{"title":"{hostname}: {pve_title}","body":"{reason}","label":"Cron job output (per-cron stdout via mail)"},"system_mail":{"title":"{hostname}: {pve_title}","body":"{reason}","label":"Smartd / mail bounces (PVE system mail)"},"apt_listchanges":{"title":"{hostname}: {pve_title}","body":"Upstream package information forwarded by Proxmox VE through apt-listchanges. The following text comes from the package maintainer and is not a ProxMenux recommendation.\n\n{reason}","label":"apt-listchanges package notices"},"webhook_test":{"title":"{hostname}: Webhook test received","body":"PVE webhook connectivity test successful.\n{reason}","label":"Webhook test"},"update_available":{"title":"{hostname}: Updates available","body":"Total updates: {total_count}\nSecurity: {security_count}\nProxmox: {pve_count}\nKernel: {kernel_count}\nImportant packages:\n{important_list}","label":"Updates available (legacy)"},"unknown_persistent":{"title":"{hostname}: Check unavailable - {category}{entity_suffix}","body":"Health check for {category} has been unavailable for 3+ cycles.\n{reason}","label":"Check unavailable"},"health_persistent":{"title":"{hostname}: {count} active health issue(s){entity_suffix}","body":"The following health issues remain unresolved:\n{issue_list}\n\nThis digest is sent once every 24 hours while issues persist.","label":"Active health issues (daily)"},"health_issue_new":{"title":"{hostname}: New health issue — {category}{entity_suffix}","body":"New {severity} issue detected in: {category}\nDetails: {reason}","label":"New health issue"},"health_issue_resolved":{"title":"{hostname}: Resolved - {category}{entity_suffix}","body":"{category} issue has been resolved.\n{reason}\nDuration: {duration}","label":"Health issue resolved"},"update_summary":{"title":"{hostname}: Updates available","body":"Total updates: {total_count}\nSecurity updates: {security_count}\nProxmox-related updates: {pve_count}\nKernel updates: {kernel_count}\nImportant packages:\n{important_list}","label":"Host package updates"},"pve_update":{"title":"{hostname}: Proxmox VE {new_version} available","body":"A new Proxmox VE release is available.\nCurrent: {current_version}\nNew: {new_version}\n{details}","label":"Proxmox VE update available"},"update_complete":{"title":"{hostname}: System update completed","body":"System packages have been successfully updated.\n{details}","label":"Host update completed"},"ai_model_migrated":{"title":"{hostname}: AI model updated","body":"The AI model for notifications has been automatically updated.\nProvider: {provider}\nPrevious model: {old_model}\nNew model: {new_model}\n\n{message}","label":"AI model auto-updated"},"proxmenux_update":{"title":"{hostname}: ProxMenux {new_version} available","body":"A new version of ProxMenux is available.\nCurrent: {current_version}\nNew: {new_version}","label":"ProxMenux update available"},"mount_stale":{"title":"{hostname}: stale remote mount {mount_target}","body":"Remote mount {mount_target} ({fstype}) from {mount_source} is stale{lxc_scope}.\nStat timed out or returned an error: {error}\n\nApps writing to this path will silently land on the underlying filesystem and may fill the disk. Remount or fix connectivity ASAP.","label":"Remote mount stale"},"mount_readonly":{"title":"{hostname}: remote mount {mount_target} is read-only","body":"Remote mount {mount_target} ({fstype}) from {mount_source} is mounted read-only{lxc_scope}. Writes will fail. If this was unintentional, remount with rw.","label":"Remote mount read-only"},"lxc_disk_low":{"title":"{hostname}: CT {vmid} rootfs at {usage_percent}%","body":"CT {vmid} ({name}) rootfs is at {usage_percent}% ({disk_bytes_human} / {maxdisk_bytes_human}).\n\nA full LXC rootfs prevents the container from booting cleanly. Either expand the rootfs (pct resize {vmid} rootfs +1G) or free space inside the container.","label":"LXC rootfs near full"},"vm_disk_low":{"title":"{hostname}: VM {vmid} filesystems at {usage_percent}%","body":"VM {vmid} ({name}) guest filesystems are at {usage_percent}% ({disk_bytes_human} / {maxdisk_bytes_human}).\n\nReported by the QEMU guest agent. Includes every persistent filesystem the guest mounts on a block device — virtual disks and PCI-passthrough drives alike. Free up space inside the guest or expand the affected storage before writes start to fail.","label":"VM filesystems near full"},"lxc_mount_low":{"title":"{hostname}: CT {vmid} mount {mount} at {usage_percent}%","body":"Mount {mount} inside CT {vmid} ({name}) is at {usage_percent}% used.\nFilesystem type: {fstype}\n\nA full mount inside a container often blocks the application silently — writes either fail or, worse, land on the rootfs and trigger the rootfs alert next. Free up space on the mount or expand it.","label":"LXC mount near full"},"pve_storage_full":{"title":"{hostname}: PVE storage {storage_name} at {usage_percent}%","body":"Proxmox storage \"{storage_name}\" (type: {storage_type}) is at {usage_percent}% used.\n\nOnce full, no new VM/CT can be provisioned and existing guests may fail to write. Move/delete unused volumes or expand the underlying pool/LV/RBD image.","label":"PVE storage near full"},"zfs_pool_full":{"title":"{hostname}: ZFS pool {pool_name} at {usage_percent}%","body":"ZFS pool \"{pool_name}\" is at {usage_percent}% capacity.\n\nZFS performance and write reliability degrade sharply above ~80% capacity (CoW needs free space for new blocks). Free up snapshots, prune old datasets, or add more vdevs to the pool.","label":"ZFS pool near full"},"post_install_update":{"title":"{hostname}: {count} ProxMenux optimization update(s) available","body":"{count} ProxMenux optimization update(s) available on this host.\n\n🛠️ Available versions:\n{tool_list}\n\n💡 Apply from:\n • ProxMenux Monitor → Settings → ProxMenux Optimizations\n • Or open the ProxMenux menu → Settings post-install Proxmox → \"Apply available updates\"","label":"ProxMenux optimization updates available"},"secure_gateway_update_available":{"title":"{hostname}: {app_name} update available{update_title_suffix}","body":"{app_name} (managed by ProxMenux) has 📦 {package_count} package update(s) pending in its container.\n{version_line}\n\n💡 Open ProxMenux Monitor > Security > Secure Gateway and click \"Update\" to apply.\n\n🗂️ Packages:\n{package_list}","label":"Secure Gateway update available"},"nvidia_driver_update_available":{"title":"{hostname}: NVIDIA driver update available — v{latest_version}","body":"A newer maintenance release is available for the installed NVIDIA driver branch.\n🔹 Currently installed: v{current_version}\n🟢 Latest available: v{latest_version}\n\n{upgrade_reason}\n\n💡 To reinstall:\n • From the ProxMenux post-install menu: {menu_label}\n\nReinstalling rebuilds the DKMS module against the running kernel and requires a reboot to load the new driver.","label":"NVIDIA driver update available"},"coral_driver_update_available":{"title":"{hostname}: Coral TPU driver update available — {latest_version}","body":"A newer {variant_label} is available.\n🔹 Currently installed: {current_version}\n🟢 Latest available: {latest_version}\n\n{upgrade_reason}\n\n💡 To reinstall:\n • From the ProxMenux post-install menu: {menu_label}\n\n{reboot_note}","label":"Coral TPU driver update available"},"burst_auth_fail":{"title":"{hostname}: +{count} more auth failures in {window}","body":"+{count} additional authentication failures detected in {window} ({total_count} total).\nSources: {entity_list}","label":"Auth failures burst"},"burst_ip_block":{"title":"{hostname}: Fail2Ban banned +{count} more IPs in {window}","body":"+{count} additional IPs banned by Fail2Ban in {window} ({total_count} total).\nIPs: {entity_list}","label":"IP block burst"},"burst_disk_io":{"title":"{hostname}: +{count} more disk I/O errors on {entity_list}","body":"+{count} additional I/O errors detected in {window} ({total_count} total).\nDevices: {entity_list}","label":"Disk I/O burst"},"burst_cluster":{"title":"{hostname}: Cluster flapping detected (+{count} more changes)","body":"Cluster state changed +{count} more times in {window} ({total_count} total).\nNodes: {entity_list}","label":"Cluster flapping burst"},"burst_service_fail":{"title":"{hostname}: +{count} more services failed in {window}","body":"+{count} additional service failures detected in {window} ({total_count} total).\nThis typically indicates a node reboot or PVE service restart.\n\nAdditional failures:\n{details}","label":"Service fail burst"},"burst_system":{"title":"{hostname}: +{count} more system problems in {window}","body":"+{count} additional system problems detected in {window} ({total_count} total).\n\nAdditional issues:\n{details}","label":"System problems burst"},"burst_generic":{"title":"{hostname}: +{count} more {event_type} events in {window}","body":"+{count} additional events of type {event_type} in {window} ({total_count} total).\n\nAdditional events:\n{details}","label":"Generic burst"},"kernel_warning":{"title":"{hostname}: Kernel diagnostic event detected","body":"The kernel recorded a diagnostic event: {kernel_details}","label":"Kernel warnings and diagnostic traces"}},"lxcUpdate":{"status":{"success":"succeeded","failure":"failed","partial":"completed partially","deferred":"deferred","skipped":"skipped"},"source":{"scheduled":"Scheduled","manual":"Manual"},"sourceLabel":"Source","targets":"Targets","osPending":"OS packages pending: {before} → {after}","osUnverified":"OS packages: update command executed; result not verified","applications":"Applications: {items}","applicationsUnverified":"Applications: updater executed; no tracked version change was observed","dockerEngineChange":"Docker Engine: {before} → {after}","dockerEngineVerified":"Docker Engine: verified at {version}","dockerEngineUnverified":"Docker Engine: update command executed; version not verified","dockerImagesPending":"Docker images pending: {before} → {after}","dockerImagesChanged":"Docker images changed: {images}","deferredTargets":"Deferred targets: {targets}","reason":"Reason: {reason}","restartRequired":"Restart required: {value}","yes":"yes","no":"no","verificationPending":"Verification pending until the container is running","verificationWarning":"Verification warning: {error}","duration":"Duration: {duration}"},"fallback":{"unknownTitle":"{hostname}: {event_type}","healthCheckDegraded":"Health check degraded","none":"none","temperatureAlertTitle":"{hostname}: Sensor temperature alert","temperatureAlertBody":"Temperature alert recorded without a complete measurement payload.","recordedReason":"Recorded reason: {reason}","recordedDetails":"Recorded details: {details}"},"fields":{"vmid":"VM/CT","name":"Name","device":"Device","sourceIp":"Source IP","node":"Node","category":"Category","service":"Service","jail":"Jail","user":"User","count":"Count","window":"Window","affected":"Affected"},"vzdump":{"size":"Size: {value}","duration":"Duration: {value}","file":"File","backups":"{count} backups","failed":"{count} failed","total":"Total: {value}","time":"Time: {value}"},"startup":{"issuesTitle":"{hostname}: System startup - {count} issue(s) detected","completeTitle":"{hostname}: System startup completed","operational":"All systems operational.","vmCountOne":"{count} VM","vmCountMany":"{count} VMs","ctCountOne":"{count} CT","ctCountMany":"{count} CTs","started":"✅ {counts} started","vmFailed":"❌ VM failed: {name} - {reason}","ctFailed":"❌ CT failed: {name} - {reason}","unknownError":"unknown error","storageUnavailable":"⚠️ Storage: {count} unavailable ({names})","servicesFailed":"⚠️ Services: {count} failed ({names})","duration":"⏱️ Startup completed in {minutes} min"},"appUpdates":{"singleTitle":"{hostname}: {app_name} update available on CT {vmid}","singleBody":"{app_name} on CT {vmid} ({ct_name}) has a new version:\n {installed} → {latest}","emptyTitle":"{hostname}: Application updates available","emptyBody":"Application updates are available.","batchTitle":"{hostname}: {count} application updates available","batchLeadOneContainer":"{count} applications in {container_count} LXC container have a newer version:","batchLeadManyContainers":"{count} applications in {container_count} LXC containers have a newer version:","additional":"… {count} additional application(s)","app":"app","unknown":"unknown"},"healthDegraded":{"categories":{"cpu":"CPU usage and temperature","memory":"Memory and swap","storage":"Storage mounts and space","disks":"Disk I/O and errors","network":"Network interfaces","vms":"VMs and containers","services":"PVE services","logs":"System logs","updates":"System updates","security":"Security"},"severity":{"critical":"Critical","warning":"Warning"},"singleTitle":"{hostname}: {severity} health status – {category}","multipleTitle":"{hostname}: {count} health checks degraded","multipleLine":"• {severity}: {category}\n {reason}","reasons":{"cpuSustained":"CPU is above {threshold}% for {duration}s."}},"digest":{"title":"{hostname}: 24h summary ({timestamp})","readFailed":"digest read failed: {error}","empty":"No INFO events buffered for this digest window.","lead":"{count} INFO events grouped by category:\n","more":" • … and {count} more","footer":"(Critical/Warning events arrived at the time they happened, not in this digest.)","quietTitle":"{hostname}: {count} events buffered during Quiet Hours","groups":{"vm_ct":"VM a CT","backup":"Backup","resources":"Resources","storage":"Storage","network":"Network","security":"Security","cluster":"Cluster","services":"Services","health":"Health","updates":"Updates","hardware":"Hardware","system":"System","other":"Other"}},"test":{"title":"ProxMenux Test","welcome":"Welcome to ProxMenux Monitor!","verify":"This is a test message to verify your notification channel is working correctly.","configuration":"Channel configuration:","iconsEnabled":"Icons: enabled","iconsDisabled":"Icons: disabled","aiEnabled":"AI: enabled ({info})","aiDisabled":"AI: disabled","alerts":"You will receive alerts about system events, health status changes, and security incidents.","photoCaption":"You can use this image as the profile photo for your notification bot."},"channels":{"discord":{"category":"Category","host":"Host","severity":"Severity"},"email":{"report":"{group} Report","details":"Details","host":"Host","footer":"ProxMenux Notification Service","severity":{"critical":"Critical","warning":"Warning","info":"Information","ok":"Resolved","default":"Notice"},"groups":{"vm_ct":"Virtual Machine / Container","backup":"Backup & Snapshot","resources":"System Resources","storage":"Storage","network":"Network","security":"Security","cluster":"Cluster","services":"System Services","health":"Health Monitor","updates":"System Updates","system":"System","hardware":"Hardware","other":"System Notification"},"fields":{"vmCtId":"VM/CT ID","name":"Name","action":"Action","targetNode":"Target Node","reason":"Reason","storage":"Storage","status":"Status","size":"Size","duration":"Duration","snapshot":"Snapshot","metric":"Metric","currentValue":"Current Value","threshold":"Threshold","cpuCores":"CPU Cores","memory":"Memory","temperature":"Temperature","mountPoint":"Mount Point","usage":"Usage","available":"Available","device":"Device","severity":"Severity","storageName":"Storage Name","type":"Type","interface":"Interface","latency":"Latency","event":"Event","sourceIp":"Source IP","username":"Username","service":"Service","jail":"Jail","failures":"Failures","change":"Change","node":"Node","quorum":"Quorum","nodesAffected":"Nodes Affected","process":"Process","category":"Category","previousSeverity":"Previous Severity","activeIssues":"Active Issues","totalUpdates":"Total Updates","securityUpdates":"Security Updates","proxmoxUpdates":"Proxmox Updates","kernelUpdates":"Kernel Updates","importantPackages":"Important Packages","currentVersion":"Current Version","newVersion":"New Version"},"status":{"failed":"Failed","completed":"Completed","started":"Started","unconfirmed":"Unconfirmed","completed_with_warnings":"Completed with warnings"}}},"temperature":{"sampleSpan":"High samples span {duration}."},"backup":{"unconfirmedTitle":"{hostname}: Backup outcome unconfirmed","confirmedTitle":"{hostname}: Backup complete","confirmedBody":"Backup completed successfully.","errorTitle":"{hostname}: Backup error reported","errorBody":"The backup report contains an error.","unconfirmedBody":"The backup outcome is not confirmed.","warningTitle":"{hostname}: Backup completed with warnings","warningBody":"Backup completed with warnings.","diagnosticsOmitted":"Additional diagnostic lines or text omitted: {count}. Original report retained."}}} + "runtime": {"notifications":{"templates":{"state_change":{"title":"{hostname}: {category} changed to {current}{entity_suffix}","body":"{category} status changed from {previous} to {current}.\n{reason}","label":"Health state changed"},"new_error":{"title":"{hostname}: New {severity} - {category}{entity_suffix}","body":"{reason}","label":"New health issue"},"error_resolved":{"title":"{hostname}: Resolved - {category}{entity_suffix}","body":"The {category} issue has been resolved.\n{reason}\n🚦 Previous severity: {original_severity}\n⏱️ Duration: {duration}","label":"Recovery notification"},"error_escalated":{"title":"{hostname}: Escalated to {severity} - {category}{entity_suffix}","body":"{reason}","label":"Health issue escalated"},"health_degraded":{"title":"{title_or_default}","body":"{reason}","label":"Health check degraded"},"lxc_updates_available":{"title":"{hostname}: {count} LXC(s) with package updates available","body":"📊 {count} LXC(s) with pending package updates (📦 {total_packages} total, 🔒 {security_count} security):\n\n{ct_list}","label":"LXC updates available (experimental)"},"lxc_update_applied":{"title":"{hostname}: LXC {ct_name} ({vmid}) update {result}","body":"{details}","label":"LXC update applied"},"oci_update_completed":{"title":"{hostname}: {app_name} updated","body":"{app_name} was updated to its new image and its data was kept.\nContainers: {containers}","label":"OCI application updated"},"oci_update_failed":{"title":"{hostname}: {app_name} update did not complete","body":"The update of {app_name} did not complete.\nReason: {reason}\nContainers: {containers}\nOpen Manage installed OCI applications to check its state.","label":"OCI application update failed"},"oci_recreate_completed":{"title":"{hostname}: {app_name} recreated","body":"{app_name} was recreated with its saved configuration and its data was kept.\nContainers: {containers}","label":"OCI application recreated"},"oci_recreate_failed":{"title":"{hostname}: {app_name} recreation did not complete","body":"The recreation of {app_name} did not complete.\nReason: {reason}\nContainers: {containers}\nOpen Manage installed OCI applications to check its state.","label":"OCI application recreation failed"},"oci_modify_completed":{"title":"{hostname}: {app_name} modified","body":"{app_name} was modified with its new options and its data was kept.\nContainers: {containers}","label":"OCI application modified"},"oci_modify_failed":{"title":"{hostname}: {app_name} modification did not complete","body":"The modification of {app_name} did not complete.\nReason: {reason}\nContainers: {containers}\nOpen Manage installed OCI applications to check its state.","label":"OCI application modification failed"},"oci_watchdog_restarted":{"title":"{hostname}: {app_name} restarted after it stopped","body":"{app_name} stopped on its own and the watchdog started it again.\nContainers: {containers}","label":"OCI application restarted by the watchdog"},"oci_watchdog_failed":{"title":"{hostname}: {app_name} could not be kept running","body":"The watchdog could not keep {app_name} running: it stopped again or did not start after several attempts, and it is left stopped.\nContainers: {containers}\nStart it by hand once the cause is solved; the watchdog then watches it again.","label":"OCI application left stopped by the watchdog"},"app_update_available":{"title":"{hostname}: {app_name} update available on CT {vmid}","body":"{app_name} on CT {vmid} ({ct_name}) has a new version:\n {installed} → {latest}","label":"App update available"},"docker_stack_update_available":{"title":"{hostname}: Docker updates available on CT {vmid}","body":"Container {ct_name} (CT {vmid}) has {count} Docker update(s):\n{details}","label":"Docker updates available"},"vm_start":{"title":"{hostname}: VM {vmname} ({vmid}) started","body":"Virtual machine {vmname} (ID: {vmid}) is now running.","label":"VM started"},"vm_start_warning":{"title":"{hostname}: VM {vmname} ({vmid}) started with warnings","body":"Virtual machine {vmname} (ID: {vmid}) started successfully but has warnings.\nWarnings: {reason}","label":"VM started (warnings)"},"vm_stop":{"title":"{hostname}: VM {vmname} ({vmid}) stopped","body":"Virtual machine {vmname} (ID: {vmid}) has been stopped.","label":"VM stopped"},"vm_shutdown":{"title":"{hostname}: VM {vmname} ({vmid}) shut down","body":"Virtual machine {vmname} (ID: {vmid}) has been cleanly shut down.","label":"VM shutdown"},"vm_fail":{"title":"{hostname}: VM {vmname} ({vmid}) FAILED","body":"Virtual machine {vmname} (ID: {vmid}) has crashed or failed to start.\nReason: {reason}","label":"VM FAILED"},"vm_restart":{"title":"{hostname}: VM {vmname} ({vmid}) restarted","body":"Virtual machine {vmname} (ID: {vmid}) has been restarted.","label":"VM restarted"},"ct_start":{"title":"{hostname}: CT {vmname} ({vmid}) started","body":"Container {vmname} (ID: {vmid}) is now running.","label":"CT started"},"ct_start_warning":{"title":"{hostname}: CT {vmname} ({vmid}) started with warnings","body":"Container {vmname} (ID: {vmid}) started successfully but has warnings.\nWarnings: {reason}","label":"CT started (warnings)"},"ct_stop":{"title":"{hostname}: CT {vmname} ({vmid}) stopped","body":"Container {vmname} (ID: {vmid}) has been stopped.","label":"CT stopped"},"ct_shutdown":{"title":"{hostname}: CT {vmname} ({vmid}) shut down","body":"Container {vmname} (ID: {vmid}) has been cleanly shut down.","label":"CT shutdown"},"ct_restart":{"title":"{hostname}: CT {vmname} ({vmid}) restarted","body":"Container {vmname} (ID: {vmid}) has been restarted.","label":"CT restarted"},"ct_fail":{"title":"{hostname}: CT {vmname} ({vmid}) FAILED","body":"Container {vmname} (ID: {vmid}) has crashed or failed to start.\nReason: {reason}","label":"CT FAILED"},"migration_start":{"title":"{hostname}: Migration started — {vmname} ({vmid})","body":"Live migration of {vmname} (ID: {vmid}) to node {target_node} has started.","label":"Migration started"},"migration_complete":{"title":"{hostname}: Migration complete — {vmname} ({vmid})","body":"{vmname} (ID: {vmid}) successfully migrated to node {target_node}.","label":"Migration complete"},"migration_warning":{"title":"{hostname}: Migration complete with warnings — {vmname} ({vmid})","body":"{vmname} (ID: {vmid}) migrated to node {target_node} but encountered warnings.\nWarnings: {reason}","label":"Migration (warnings)"},"migration_fail":{"title":"{hostname}: Migration FAILED — {vmname} ({vmid})","body":"Migration of {vmname} (ID: {vmid}) to node {target_node} failed.\nReason: {reason}","label":"Migration FAILED"},"replication_fail":{"title":"{hostname}: Replication FAILED — {vmname} ({vmid})","body":"Replication of {vmname} (ID: {vmid}) failed.\nReason: {reason}","label":"Replication FAILED"},"replication_complete":{"title":"{hostname}: Replication complete — {vmname} ({vmid})","body":"Replication of {vmname} (ID: {vmid}) completed successfully.","label":"Replication complete"},"backup_start":{"title":"{hostname}: Backup started on {storage}","body":"Backup job started on storage {storage}.\n{reason}","label":"Backup started"},"backup_complete":{"title":"{hostname} → {storage}: Backup complete — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) completed successfully on {storage}.\nSize: {size}","label":"Backup complete"},"backup_warning":{"title":"{hostname} → {storage}: Backup complete with warnings — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) on {storage} completed but encountered warnings.\nWarnings: {reason}","label":"Backup (warnings)"},"backup_fail":{"title":"{hostname} → {storage}: Backup FAILED — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) failed on {storage}.\nReason: {reason}","label":"Backup FAILED"},"host_backup_start":{"title":"{hostname}: Host backup started → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nProfile: {profile_mode}","label":"Host backup started"},"host_backup_complete":{"title":"{hostname}: Host backup complete → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nData size: {data_size}\nArchive size: {archive_size}\nDuration: {duration}","label":"Host backup complete"},"host_backup_fail":{"title":"{hostname}: Host backup FAILED → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nDuration before failure: {duration}\nReason: {reason}\nLog: {log_file}","label":"Host backup FAILED"},"snapshot_complete":{"title":"{hostname}: Snapshot created — {vmname} ({vmid})","body":"Snapshot \"{snapshot_name}\" created for {vmname} (ID: {vmid}).","label":"Snapshot created"},"snapshot_fail":{"title":"{hostname}: Snapshot FAILED — {vmname} ({vmid})","body":"Snapshot creation for {vmname} (ID: {vmid}) failed.\nReason: {reason}","label":"Snapshot FAILED"},"cpu_high":{"title":"{hostname}: High CPU usage — {value}%","body":"CPU usage has reached {value}% on {cores} cores.\n{details}","label":"High CPU usage"},"ram_high":{"title":"{hostname}: High memory usage — {value}%","body":"Memory usage: {used} / {total} ({value}%).\n{details}","label":"High memory usage"},"temp_high":{"title":"{hostname}: High sensor temperature — {value}°C","body":"Sensor temperature has reached {value}°C (threshold: {threshold}°C).\n{details}","label":"High temperature"},"disk_space_low":{"title":"{hostname}: Low disk space on {mount}","body":"Filesystem {mount}: {used}% used ({available} available).\nFree up disk space to avoid service disruption.","label":"Low disk space"},"disk_io_error":{"title":"{hostname}: Disk failure detected — {device}","body":"I/O error or disk failure detected on device {device}.\n{reason}","label":"Disk failure / I/O error"},"storage_unavailable":{"title":"{hostname}: Storage unavailable — {storage_name}","body":"PVE storage \"{storage_name}\" (type: {storage_type}) is not accessible.\nReason: {reason}","label":"Storage unavailable"},"smart_test_complete":{"title":"{hostname}: SMART test completed — {device}","body":"SMART {test_type} test on /dev/{device} has completed.\nResult: {result}\nDuration: {duration}","label":"SMART test completed"},"smart_test_failed":{"title":"{hostname}: SMART test FAILED — {device}","body":"SMART {test_type} test on /dev/{device} has failed.\nResult: {result}\nReason: {reason}","label":"SMART test FAILED"},"gpu_mode_switch":{"title":"{hostname}: GPU mode changed to {new_mode}","body":"GPU passthrough mode has been switched.\nGPU: {gpu_name} ({gpu_pci})\nPrevious mode: {old_mode}\nNew mode: {new_mode}\n{details}","label":"GPU mode switched"},"gpu_passthrough_blocked":{"title":"{hostname}: {guest_type} {guest_id} blocked at startup","body":"PCIe passthrough guard prevented {guest_type} {guest_id} ({guest_name}) from starting.\nReason: {reason}\n{details}","label":"GPU passthrough blocked"},"pci_passthrough_conflict":{"title":"{hostname}: PCIe device conflict detected — {device_pci}","body":"A PCIe device is assigned to multiple guests.\nDevice: {device_pci}\nConflicting guests: {guest_list}\nAction required: Stop one of the guests or reassign the device.","label":"PCIe device conflict"},"load_high":{"title":"{hostname}: High system load — {value}","body":"System load average is {value} on {cores} cores.\n{details}","label":"High system load"},"network_down":{"title":"{hostname}: Network connectivity lost{entity_suffix}","body":"The node has lost network connectivity.\nReason: {reason}","label":"Network connectivity lost"},"network_latency":{"title":"{hostname}: High network latency — {value}ms","body":"Latency to gateway: {value}ms (threshold: {threshold}ms).\nThis may indicate network congestion or hardware issues.","label":"High network latency"},"auth_fail":{"title":"{hostname}: Authentication failure","body":"Failed login attempt detected.\nSource IP: {source_ip}\nUser: {username}\nService: {service}","label":"Authentication failure"},"ip_block":{"title":"{hostname}: IP blocked by Fail2Ban","body":"IP address {source_ip} has been banned.\nJail: {jail}\nFailed attempts: {failures}","label":"IP blocked by Fail2Ban"},"firewall_issue":{"title":"{hostname}: Firewall issue detected{entity_suffix}","body":"A firewall configuration issue has been detected.\nReason: {reason}","label":"Firewall issue detected"},"user_permission_change":{"title":"{hostname}: User permission changed","body":"User: {username}\nChange: {change_details}","label":"User permission changed"},"split_brain":{"title":"{hostname}: Cluster event reported","body":"A cluster event was reported:\n{reason}","label":"Cluster event"},"node_disconnect":{"title":"{hostname}: Node {node_name} disconnected","body":"Node {node_name} has disconnected from the cluster.","label":"Node disconnected"},"node_reconnect":{"title":"{hostname}: Node {node_name} reconnected","body":"Node {node_name} has rejoined the cluster successfully.","label":"Node reconnected"},"system_startup":{"title":"{hostname}: {reason}","body":"{summary}","label":"System startup report"},"system_shutdown":{"title":"{hostname}: System shutting down","body":"The node is shutting down.\n{reason}","label":"System shutting down"},"system_reboot":{"title":"{hostname}: System rebooting","body":"The node is rebooting.\n{reason}","label":"System rebooting"},"system_restore_completed":{"title":"{hostname}: Host restore finished","body":"Post-restore tasks completed in background.\n\nGuest configurations copied: {guests}\nBind-mount stub directories created: {stubs}\nStale node dirs removed: {stale_nodes}\nComponents reinstalled: {components}\nDuration: {duration}\n{warnings_block}","label":"Host restore completed"},"system_problem":{"title":"{hostname}: System problem detected{entity_suffix}","body":"A system-level problem has been detected.\nReason: {reason}","label":"System problem detected"},"service_fail":{"title":"{hostname}: Service failed — {service_name}","body":"System service \"{service_name}\" has failed.\nReason: {reason}","label":"Service failed"},"oom_kill":{"title":"{hostname}: OOM Kill — {process}","body":"Process \"{process}\" was killed by the Out-of-Memory manager.\n{reason}","label":"Out of memory kill"},"service_fail_batch":{"title":"{hostname}: {service_count} services failed{entity_suffix}","body":"{reason}","label":"Service fail batch"},"cron_output":{"title":"{hostname}: {pve_title}","body":"{reason}","label":"Cron job output (per-cron stdout via mail)"},"system_mail":{"title":"{hostname}: {pve_title}","body":"{reason}","label":"Smartd / mail bounces (PVE system mail)"},"apt_listchanges":{"title":"{hostname}: {pve_title}","body":"Upstream package information forwarded by Proxmox VE through apt-listchanges. The following text comes from the package maintainer and is not a ProxMenux recommendation.\n\n{reason}","label":"apt-listchanges package notices"},"webhook_test":{"title":"{hostname}: Webhook test received","body":"PVE webhook connectivity test successful.\n{reason}","label":"Webhook test"},"update_available":{"title":"{hostname}: Updates available","body":"Total updates: {total_count}\nSecurity: {security_count}\nProxmox: {pve_count}\nKernel: {kernel_count}\nImportant packages:\n{important_list}","label":"Updates available (legacy)"},"unknown_persistent":{"title":"{hostname}: Check unavailable - {category}{entity_suffix}","body":"Health check for {category} has been unavailable for 3+ cycles.\n{reason}","label":"Check unavailable"},"health_persistent":{"title":"{hostname}: {count} active health issue(s){entity_suffix}","body":"The following health issues remain unresolved:\n{issue_list}\n\nThis digest is sent once every 24 hours while issues persist.","label":"Active health issues (daily)"},"health_issue_new":{"title":"{hostname}: New health issue — {category}{entity_suffix}","body":"New {severity} issue detected in: {category}\nDetails: {reason}","label":"New health issue"},"health_issue_resolved":{"title":"{hostname}: Resolved - {category}{entity_suffix}","body":"{category} issue has been resolved.\n{reason}\nDuration: {duration}","label":"Health issue resolved"},"update_summary":{"title":"{hostname}: Updates available","body":"Total updates: {total_count}\nSecurity updates: {security_count}\nProxmox-related updates: {pve_count}\nKernel updates: {kernel_count}\nImportant packages:\n{important_list}","label":"Host package updates"},"pve_update":{"title":"{hostname}: Proxmox VE {new_version} available","body":"A new Proxmox VE release is available.\nCurrent: {current_version}\nNew: {new_version}\n{details}","label":"Proxmox VE update available"},"update_complete":{"title":"{hostname}: System update completed","body":"System packages have been successfully updated.\n{details}","label":"Host update completed"},"ai_model_migrated":{"title":"{hostname}: AI model updated","body":"The AI model for notifications has been automatically updated.\nProvider: {provider}\nPrevious model: {old_model}\nNew model: {new_model}\n\n{message}","label":"AI model auto-updated"},"proxmenux_update":{"title":"{hostname}: ProxMenux {new_version} available","body":"A new version of ProxMenux is available.\nCurrent: {current_version}\nNew: {new_version}","label":"ProxMenux update available"},"mount_stale":{"title":"{hostname}: stale remote mount {mount_target}","body":"Remote mount {mount_target} ({fstype}) from {mount_source} is stale{lxc_scope}.\nStat timed out or returned an error: {error}\n\nApps writing to this path will silently land on the underlying filesystem and may fill the disk. Remount or fix connectivity ASAP.","label":"Remote mount stale"},"mount_readonly":{"title":"{hostname}: remote mount {mount_target} is read-only","body":"Remote mount {mount_target} ({fstype}) from {mount_source} is mounted read-only{lxc_scope}. Writes will fail. If this was unintentional, remount with rw.","label":"Remote mount read-only"},"lxc_disk_low":{"title":"{hostname}: CT {vmid} rootfs at {usage_percent}%","body":"CT {vmid} ({name}) rootfs is at {usage_percent}% ({disk_bytes_human} / {maxdisk_bytes_human}).\n\nA full LXC rootfs prevents the container from booting cleanly. Either expand the rootfs (pct resize {vmid} rootfs +1G) or free space inside the container.","label":"LXC rootfs near full"},"vm_disk_low":{"title":"{hostname}: VM {vmid} filesystems at {usage_percent}%","body":"VM {vmid} ({name}) guest filesystems are at {usage_percent}% ({disk_bytes_human} / {maxdisk_bytes_human}).\n\nReported by the QEMU guest agent. Includes every persistent filesystem the guest mounts on a block device — virtual disks and PCI-passthrough drives alike. Free up space inside the guest or expand the affected storage before writes start to fail.","label":"VM filesystems near full"},"lxc_mount_low":{"title":"{hostname}: CT {vmid} mount {mount} at {usage_percent}%","body":"Mount {mount} inside CT {vmid} ({name}) is at {usage_percent}% used.\nFilesystem type: {fstype}\n\nA full mount inside a container often blocks the application silently — writes either fail or, worse, land on the rootfs and trigger the rootfs alert next. Free up space on the mount or expand it.","label":"LXC mount near full"},"pve_storage_full":{"title":"{hostname}: PVE storage {storage_name} at {usage_percent}%","body":"Proxmox storage \"{storage_name}\" (type: {storage_type}) is at {usage_percent}% used.\n\nOnce full, no new VM/CT can be provisioned and existing guests may fail to write. Move/delete unused volumes or expand the underlying pool/LV/RBD image.","label":"PVE storage near full"},"zfs_pool_full":{"title":"{hostname}: ZFS pool {pool_name} at {usage_percent}%","body":"ZFS pool \"{pool_name}\" is at {usage_percent}% capacity.\n\nZFS performance and write reliability degrade sharply above ~80% capacity (CoW needs free space for new blocks). Free up snapshots, prune old datasets, or add more vdevs to the pool.","label":"ZFS pool near full"},"post_install_update":{"title":"{hostname}: {count} ProxMenux optimization update(s) available","body":"{count} ProxMenux optimization update(s) available on this host.\n\n🛠️ Available versions:\n{tool_list}\n\n💡 Apply from:\n • ProxMenux Monitor → Settings → ProxMenux Optimizations\n • Or open the ProxMenux menu → Settings post-install Proxmox → \"Apply available updates\"","label":"ProxMenux optimization updates available"},"secure_gateway_update_available":{"title":"{hostname}: {app_name} update available{update_title_suffix}","body":"{app_name} (managed by ProxMenux) has 📦 {package_count} package update(s) pending in its container.\n{version_line}\n\n💡 Open ProxMenux Monitor > Security > Secure Gateway and click \"Update\" to apply.\n\n🗂️ Packages:\n{package_list}","label":"Secure Gateway update available"},"nvidia_driver_update_available":{"title":"{hostname}: NVIDIA driver update available — v{latest_version}","body":"A newer maintenance release is available for the installed NVIDIA driver branch.\n🔹 Currently installed: v{current_version}\n🟢 Latest available: v{latest_version}\n\n{upgrade_reason}\n\n💡 To reinstall:\n • From the ProxMenux post-install menu: {menu_label}\n\nReinstalling rebuilds the DKMS module against the running kernel and requires a reboot to load the new driver.","label":"NVIDIA driver update available"},"coral_driver_update_available":{"title":"{hostname}: Coral TPU driver update available — {latest_version}","body":"A newer {variant_label} is available.\n🔹 Currently installed: {current_version}\n🟢 Latest available: {latest_version}\n\n{upgrade_reason}\n\n💡 To reinstall:\n • From the ProxMenux post-install menu: {menu_label}\n\n{reboot_note}","label":"Coral TPU driver update available"},"burst_auth_fail":{"title":"{hostname}: +{count} more auth failures in {window}","body":"+{count} additional authentication failures detected in {window} ({total_count} total).\nSources: {entity_list}","label":"Auth failures burst"},"burst_ip_block":{"title":"{hostname}: Fail2Ban banned +{count} more IPs in {window}","body":"+{count} additional IPs banned by Fail2Ban in {window} ({total_count} total).\nIPs: {entity_list}","label":"IP block burst"},"burst_disk_io":{"title":"{hostname}: +{count} more disk I/O errors on {entity_list}","body":"+{count} additional I/O errors detected in {window} ({total_count} total).\nDevices: {entity_list}","label":"Disk I/O burst"},"burst_cluster":{"title":"{hostname}: Cluster flapping detected (+{count} more changes)","body":"Cluster state changed +{count} more times in {window} ({total_count} total).\nNodes: {entity_list}","label":"Cluster flapping burst"},"burst_service_fail":{"title":"{hostname}: +{count} more services failed in {window}","body":"+{count} additional service failures detected in {window} ({total_count} total).\nThis typically indicates a node reboot or PVE service restart.\n\nAdditional failures:\n{details}","label":"Service fail burst"},"burst_system":{"title":"{hostname}: +{count} more system problems in {window}","body":"+{count} additional system problems detected in {window} ({total_count} total).\n\nAdditional issues:\n{details}","label":"System problems burst"},"burst_generic":{"title":"{hostname}: +{count} more {event_type} events in {window}","body":"+{count} additional events of type {event_type} in {window} ({total_count} total).\n\nAdditional events:\n{details}","label":"Generic burst"},"kernel_warning":{"title":"{hostname}: Kernel diagnostic event detected","body":"The kernel recorded a diagnostic event: {kernel_details}","label":"Kernel warnings and diagnostic traces"}},"lxcUpdate":{"status":{"success":"succeeded","failure":"failed","partial":"completed partially","deferred":"deferred","skipped":"skipped"},"source":{"scheduled":"Scheduled","manual":"Manual"},"sourceLabel":"Source","targets":"Targets","osPending":"OS packages pending: {before} → {after}","osUnverified":"OS packages: update command executed; result not verified","applications":"Applications: {items}","applicationsUnverified":"Applications: updater executed; no tracked version change was observed","dockerEngineChange":"Docker Engine: {before} → {after}","dockerEngineVerified":"Docker Engine: verified at {version}","dockerEngineUnverified":"Docker Engine: update command executed; version not verified","dockerImagesPending":"Docker images pending: {before} → {after}","dockerImagesChanged":"Docker images changed: {images}","deferredTargets":"Deferred targets: {targets}","reason":"Reason: {reason}","restartRequired":"Restart required: {value}","yes":"yes","no":"no","verificationPending":"Verification pending until the container is running","verificationWarning":"Verification warning: {error}","duration":"Duration: {duration}"},"fallback":{"unknownTitle":"{hostname}: {event_type}","healthCheckDegraded":"Health check degraded","none":"none","temperatureAlertTitle":"{hostname}: Sensor temperature alert","temperatureAlertBody":"Temperature alert recorded without a complete measurement payload.","recordedReason":"Recorded reason: {reason}","recordedDetails":"Recorded details: {details}"},"fields":{"vmid":"VM/CT","name":"Name","device":"Device","sourceIp":"Source IP","node":"Node","category":"Category","service":"Service","jail":"Jail","user":"User","count":"Count","window":"Window","affected":"Affected"},"vzdump":{"size":"Size: {value}","duration":"Duration: {value}","file":"File","backups":"{count} backups","failed":"{count} failed","total":"Total: {value}","time":"Time: {value}"},"startup":{"issuesTitle":"{hostname}: System startup - {count} issue(s) detected","completeTitle":"{hostname}: System startup completed","operational":"All systems operational.","vmCountOne":"{count} VM","vmCountMany":"{count} VMs","ctCountOne":"{count} CT","ctCountMany":"{count} CTs","started":"✅ {counts} started","vmFailed":"❌ VM failed: {name} - {reason}","ctFailed":"❌ CT failed: {name} - {reason}","unknownError":"unknown error","storageUnavailable":"⚠️ Storage: {count} unavailable ({names})","servicesFailed":"⚠️ Services: {count} failed ({names})","duration":"⏱️ Startup completed in {minutes} min"},"appUpdates":{"singleTitle":"{hostname}: {app_name} update available on CT {vmid}","singleBody":"{app_name} on CT {vmid} ({ct_name}) has a new version:\n {installed} → {latest}","emptyTitle":"{hostname}: Application updates available","emptyBody":"Application updates are available.","batchTitle":"{hostname}: {count} application updates available","batchLeadOneContainer":"{count} applications in {container_count} LXC container have a newer version:","batchLeadManyContainers":"{count} applications in {container_count} LXC containers have a newer version:","additional":"… {count} additional application(s)","app":"app","unknown":"unknown"},"healthDegraded":{"categories":{"cpu":"CPU usage and temperature","memory":"Memory and swap","storage":"Storage mounts and space","disks":"Disk I/O and errors","network":"Network interfaces","vms":"VMs and containers","services":"PVE services","logs":"System logs","updates":"System updates","security":"Security"},"severity":{"critical":"Critical","warning":"Warning"},"singleTitle":"{hostname}: {severity} health status – {category}","multipleTitle":"{hostname}: {count} health checks degraded","multipleLine":"• {severity}: {category}\n {reason}","reasons":{"cpuSustained":"CPU is above {threshold}% for {duration}s."}},"digest":{"title":"{hostname}: 24h summary ({timestamp})","readFailed":"digest read failed: {error}","empty":"No INFO events buffered for this digest window.","lead":"{count} INFO events grouped by category:\n","more":" • … and {count} more","footer":"(Critical/Warning events arrived at the time they happened, not in this digest.)","quietTitle":"{hostname}: {count} events buffered during Quiet Hours","groups":{"vm_ct":"VM a CT","backup":"Backup","resources":"Resources","storage":"Storage","network":"Network","security":"Security","cluster":"Cluster","services":"Services","health":"Health","updates":"Updates","hardware":"Hardware","system":"System","other":"Other"}},"test":{"title":"ProxMenux Test","welcome":"Welcome to ProxMenux Monitor!","verify":"This is a test message to verify your notification channel is working correctly.","configuration":"Channel configuration:","iconsEnabled":"Icons: enabled","iconsDisabled":"Icons: disabled","aiEnabled":"AI: enabled ({info})","aiDisabled":"AI: disabled","alerts":"You will receive alerts about system events, health status changes, and security incidents.","photoCaption":"You can use this image as the profile photo for your notification bot."},"channels":{"discord":{"category":"Category","host":"Host","severity":"Severity"},"email":{"report":"{group} Report","details":"Details","host":"Host","footer":"ProxMenux Notification Service","severity":{"critical":"Critical","warning":"Warning","info":"Information","ok":"Resolved","default":"Notice"},"groups":{"vm_ct":"Virtual Machine / Container","backup":"Backup & Snapshot","resources":"System Resources","storage":"Storage","network":"Network","security":"Security","cluster":"Cluster","services":"System Services","health":"Health Monitor","updates":"System Updates","system":"System","hardware":"Hardware","other":"System Notification"},"fields":{"vmCtId":"VM/CT ID","name":"Name","action":"Action","targetNode":"Target Node","reason":"Reason","storage":"Storage","status":"Status","size":"Size","duration":"Duration","snapshot":"Snapshot","metric":"Metric","currentValue":"Current Value","threshold":"Threshold","cpuCores":"CPU Cores","memory":"Memory","temperature":"Temperature","mountPoint":"Mount Point","usage":"Usage","available":"Available","device":"Device","severity":"Severity","storageName":"Storage Name","type":"Type","interface":"Interface","latency":"Latency","event":"Event","sourceIp":"Source IP","username":"Username","service":"Service","jail":"Jail","failures":"Failures","change":"Change","node":"Node","quorum":"Quorum","nodesAffected":"Nodes Affected","process":"Process","category":"Category","previousSeverity":"Previous Severity","activeIssues":"Active Issues","totalUpdates":"Total Updates","securityUpdates":"Security Updates","proxmoxUpdates":"Proxmox Updates","kernelUpdates":"Kernel Updates","importantPackages":"Important Packages","currentVersion":"Current Version","newVersion":"New Version"},"status":{"failed":"Failed","completed":"Completed","started":"Started","unconfirmed":"Unconfirmed","completed_with_warnings":"Completed with warnings"}}},"temperature":{"sampleSpan":"High samples span {duration}."},"backup":{"unconfirmedTitle":"{hostname}: Backup outcome unconfirmed","confirmedTitle":"{hostname}: Backup complete","confirmedBody":"Backup completed successfully.","errorTitle":"{hostname}: Backup error reported","errorBody":"The backup report contains an error.","unconfirmedBody":"The backup outcome is not confirmed.","warningTitle":"{hostname}: Backup completed with warnings","warningBody":"Backup completed with warnings.","diagnosticsOmitted":"Additional diagnostic lines or text omitted: {count}. Original report retained."}}}, + "monitorLinks": { + "thisNode": "This node", + "checking": "Checking nodes…", + "loadFailed": "Could not load the node list.", + "empty": "No other nodes yet. Add one to open its Monitor from here.", + "groups": { + "cluster": "Cluster", + "standalone": "Standalone nodes", + "pbs": "Proxmox Backup Server" + }, + "noResponse": "No response", + "pbsInterface": "PBS interface", + "openNewTab": "Open in a new tab", + "manage": "Add or manage nodes…", + "dialogTitle": "Linked nodes", + "dialogDescription": "Cluster nodes and configured Proxmox Backup Servers are detected automatically. Each Monitor keeps its own sign-in.", + "addTitle": "Add a node", + "name": "Name", + "address": "Monitor address", + "type": "Type", + "add": "Add", + "save": "Save", + "remove": "Remove", + "visible": "Visible", + "autoAddress": "Automatic", + "detected": "Detected", + "added": "Added", + "saveFailed": "Could not save the node list.", + "backTo": "Back to {name}", + "cameFromTitle": "Add Monitor node?", + "cameFromBody": "Add {name} to the linked nodes of this Monitor.", + "notNow": "Not now" + } } diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index 322ea52a..a9754ca6 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -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" } } diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json index 7dea3a29..5b30c969 100644 --- a/AppImage/messages/fr/common.json +++ b/AppImage/messages/fr/common.json @@ -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" } } diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index 9fb2d83e..b595a4cc 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -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" } } diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index d60c1806..b64fa2d5 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -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" } } diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 692fcce6..4b91a2cd 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -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" } } diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json index 5a74449a..7b8cab64 100644 --- a/AppImage/messages/sv/common.json +++ b/AppImage/messages/sv/common.json @@ -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" } } diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh index 40560c21..783978f6 100755 --- a/AppImage/scripts/build_appimage.sh +++ b/AppImage/scripts/build_appimage.sh @@ -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" diff --git a/AppImage/scripts/flask_monitor_links_routes.py b/AppImage/scripts/flask_monitor_links_routes.py new file mode 100644 index 00000000..24c7d3ee --- /dev/null +++ b/AppImage/scripts/flask_monitor_links_routes.py @@ -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/", 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/", 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 diff --git a/AppImage/scripts/flask_notification_routes.py b/AppImage/scripts/flask_notification_routes.py index 2354141d..907ad3b6 100644 --- a/AppImage/scripts/flask_notification_routes.py +++ b/AppImage/scripts/flask_notification_routes.py @@ -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(): diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 3ffa0c4c..ca789d38 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -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 diff --git a/AppImage/scripts/monitor_links.py b/AppImage/scripts/monitor_links.py new file mode 100644 index 00000000..35ff58a2 --- /dev/null +++ b/AppImage/scripts/monitor_links.py @@ -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) diff --git a/AppImage/scripts/tests/test_fwbr_udev_rule_order.py b/AppImage/scripts/tests/test_fwbr_udev_rule_order.py index 70f61b5f..231fc61d 100644 --- a/AppImage/scripts/tests/test_fwbr_udev_rule_order.py +++ b/AppImage/scripts/tests/test_fwbr_udev_rule_order.py @@ -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() diff --git a/AppImage/scripts/tests/test_monitor_links.py b/AppImage/scripts/tests/test_monitor_links.py new file mode 100644 index 00000000..48036393 --- /dev/null +++ b/AppImage/scripts/tests/test_monitor_links.py @@ -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"") + + 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() diff --git a/lang/de.json b/lang/de.json index 562eca99..9bc18d14 100644 --- a/lang/de.json +++ b/lang/de.json @@ -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", @@ -540,6 +543,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:", @@ -751,6 +755,7 @@ "Cannot verify the reused disk:": "Kann die wiederverwendete Festplatte nicht überprüfen:", "Cannot write the image archive to {storage}. Check storage access and mount status.": "Das Bildarchiv kann nicht in {storage} geschrieben werden. Überprüfen Sie den Speicherzugriff und den Bereitstellungsstatus.", "Capabilities cannot be kept and all dropped at the same time": "Fähigkeiten können nicht gehalten werden und alle gleichzeitig fallen gelassen werden", + "Catalog options": "Katalogoptionen", "Category": "Kategorie", "Caution: Maximum mode generates more heat.": "Achtung: Im Maximalmodus entsteht mehr Wärme.", "Ceph check skipped by user flag (--ignore-ceph-check)": "Ceph-Prüfung durch Benutzerflag übersprungen (--ignore-ceph-check)", @@ -885,6 +890,7 @@ "Choose a schedule (systemd calendar-event syntax):": "Wählen Sie einen Zeitplan (systemd-Kalender-Ereignis-Syntax):", "Choose a script to execute:": "Wählen Sie ein auszuführendes Skript aus:", "Choose a server:": "Wählen Sie einen Server:", + "Choose a setting. Changes are applied only after the final review.": "Wähle eine Einstellung. Änderungen werden erst nach der abschließenden Prüfung angewendet.", "Choose a share to mount:": "Wählen Sie eine Freigabe zum Mounten aus:", "Choose a specific supported GPU or USB node": "Wählen Sie einen bestimmten unterstützten GPU- oder USB-Knoten aus", "Choose action for this controller/NVMe:": "Wählen Sie eine Aktion für diesen Controller/NVMe:", @@ -1522,6 +1528,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:", @@ -1687,6 +1694,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", @@ -1768,6 +1776,8 @@ "Disabling and stopping rpcbind service and socket...": "Rpcbind-Dienst und Socket deaktivieren und stoppen ...", "Disabling non-free firmware warnings...": "Warnungen zu nicht kostenloser Firmware werden deaktiviert...", "Disabling portmapper/rpcbind for security...": "Portmapper/rpcbind wird aus Sicherheitsgründen deaktiviert...", + "Discard changes": "Änderungen verwerfen", + "Discard unapplied changes?": "Noch nicht angewendete Änderungen verwerfen?", "Discover targets on portal:": "Entdecken Sie Ziele auf dem Portal:", "Discovering iSCSI targets...": "iSCSI-Ziele werden ermittelt...", "Discovery successful": "Entdeckung erfolgreich", @@ -1847,6 +1857,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?", @@ -2272,6 +2283,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.", @@ -4164,6 +4176,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.", @@ -4268,6 +4281,7 @@ "No password provided.": "Kein Passwort angegeben.", "No paths available": "Keine Pfade verfügbar", "No paths selected. Select at least one path.": "Keine Pfade ausgewählt. Wählen Sie mindestens einen Pfad aus.", + "No pending changes": "Keine ausstehenden Änderungen", "No pending paths to schedule for reboot.": "Keine ausstehenden Pfade zum Planen eines Neustarts.", "No permanent NFS mounts configured.": "Keine permanenten NFS-Mounts konfiguriert.", "No permanent Samba mounts configured.": "Keine permanenten Samba-Mounts konfiguriert.", @@ -4409,6 +4423,7 @@ "OVH RTM removed (Puppet artefacts may need manual cleanup)": "OVH RTM entfernt (Marionettenartefakte müssen möglicherweise manuell bereinigt werden)", "OVH server detected": "OVH-Server erkannt", "OVH server detection and RTM installation process completed": "OVH-Servererkennung und RTM-Installationsprozess abgeschlossen", + "Object detector": "Objekterkennung", "Object detector for Frigate": "Objektdetektor für Frigate", "Observer is not responding on port 4357": "Beobachter reagiert nicht auf Port 4357", "Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian ist eine Notiz-App, mit der Sie Ihre Notizen auf Ihrem Gerät erstellen, verknüpfen und organisieren können, mit Hunderten von Plugins und Themes, um Ihren Workflow anzupassen. Sie können Ihre Notizen auch online veröffentlichen, offline darauf zugreifen und sie sicher mit einer End-to-End-Verschlüsselung synchronisieren.", @@ -4881,7 +4896,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", @@ -5242,6 +5261,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:", @@ -5336,6 +5356,7 @@ "Reverting ZFS ARC tuning...": "ZFS ARC-Tuning wird zurückgesetzt...", "Reverting pigz wrapper...": "Pigz-Wrapper wird zurückgesetzt...", "Reverting vzdump speed tuning...": "vzdump-Geschwindigkeitsoptimierung wird zurückgesetzt...", + "Review and apply": "Prüfen und anwenden", "Review external OCI changes": "Überprüfen Sie externe OCI-Änderungen", "Review package upgrade": "Paketaktualisierung prüfen", "Review passthrough config files": "Überprüfen Sie die Passthrough-Konfigurationsdateien", @@ -5795,6 +5816,7 @@ "ShareCoveX panel": "ShareCoveX-Panel", "Shared Directory Ready:": "Bereit für das freigegebene Verzeichnis:", "Shared Group": "Geteilte Gruppe", + "Shared directories": "Freigegebene Verzeichnisse", "Shared directory created:": "Gemeinsames Verzeichnis erstellt:", "Shared directory for consume and export": "Gemeinsames Verzeichnis für Konsum und Export", "Shared directory for copy/sync operations": "Gemeinsames Verzeichnis für Copy/Sync operations", @@ -6797,6 +6819,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:", @@ -6880,6 +6903,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.", @@ -7011,6 +7035,7 @@ "Unable to identify the root filesystem. Aborting.": "Das Root-Dateisystem konnte nicht identifiziert werden. Abbruch.", "Unable to resolve system ZFS pool disks. Aborting.": "System-Pool-Festplatten ZFS können nicht aufgelöst werden. Abbruch.", "Unable to resolve system disk topology. Aborting.": "Die Topologie der Systemfestplatte kann nicht aufgelöst werden. Abbruch.", + "Unapplied changes": "Noch nicht angewendete Änderungen", "Understand the security implications of privileged containers": "Verstehen Sie die Sicherheitsauswirkungen privilegierter Container", "Unexpected Proxmox inventory; recovery blocked": "Unerwartetes Proxmox-Inventar; Erholung blockiert", "Unexpected formatting directory in the new Valkey volume": "Unerwartetes Formatierungsverzeichnis im neuen Valkey-Volume", @@ -7207,6 +7232,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?", @@ -8126,6 +8152,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", diff --git a/lang/es.json b/lang/es.json index 57a6014b..077ac4c6 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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", @@ -538,6 +541,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:", @@ -750,6 +754,7 @@ "Cannot verify the reused disk:": "No se puede verificar el disco reutilizado:", "Cannot write the image archive to {storage}. Check storage access and mount status.": "No se puede escribir el archivo de la imagen en {storage}. Comprueba el acceso al almacenamiento y que esté montado.", "Capabilities cannot be kept and all dropped at the same time": "No se pueden mantener las capacidades y todas se disminuyen al mismo tiempo", + "Catalog options": "Opciones del catálogo", "Category": "Categoría", "Caution: Maximum mode generates more heat.": "Precaución: el modo máximo genera más calor.", "Ceph check skipped by user flag (--ignore-ceph-check)": "Comprobación de Ceph omitida por el indicador del usuario (--ignore-ceph-check)", @@ -884,6 +889,7 @@ "Choose a schedule (systemd calendar-event syntax):": "Elija una programación (sintaxis de eventos de calendario de systemd):", "Choose a script to execute:": "Elige un script para ejecutar:", "Choose a server:": "Elige un servidor:", + "Choose a setting. Changes are applied only after the final review.": "Elige qué configurar. Los cambios solo se aplican tras la revisión final.", "Choose a share to mount:": "Elige un recurso compartido para montar:", "Choose a specific supported GPU or USB node": "Elija un nodo concreto de GPU o USB compatible", "Choose action for this controller/NVMe:": "Elige la acción para este controlador/NVMe:", @@ -1520,6 +1526,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:", @@ -1685,6 +1692,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", @@ -1766,6 +1774,8 @@ "Disabling and stopping rpcbind service and socket...": "Deshabilitar y detener el servicio y el socket rpcbind...", "Disabling non-free firmware warnings...": "Deshabilitando las advertencias de firmware no libre...", "Disabling portmapper/rpcbind for security...": "Deshabilitando portmapper/rpcbind por seguridad...", + "Discard changes": "Descartar cambios", + "Discard unapplied changes?": "¿Descartar los cambios sin aplicar?", "Discover targets on portal:": "Detectar destinos en el portal:", "Discovering iSCSI targets...": "Descubriendo destinos iSCSI...", "Discovery successful": "Detección correcta", @@ -1845,6 +1855,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?", @@ -2269,6 +2280,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.", @@ -4159,6 +4171,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.", @@ -4263,6 +4276,7 @@ "No password provided.": "No se proporcionó contraseña.", "No paths available": "No hay rutas disponibles", "No paths selected. Select at least one path.": "No hay rutas seleccionadas. Seleccione al menos una ruta.", + "No pending changes": "No hay cambios pendientes", "No pending paths to schedule for reboot.": "No hay rutas pendientes para programar el reinicio.", "No permanent NFS mounts configured.": "No se han configurado montajes NFS permanentes.", "No permanent Samba mounts configured.": "No hay montajes Samba permanentes configurados.", @@ -4401,6 +4415,7 @@ "OVH RTM removed (Puppet artefacts may need manual cleanup)": "OVH RTM eliminado (los artefactos de las marionetas pueden necesitar una limpieza manual)", "OVH server detected": "Servidor OVH detectado", "OVH server detection and RTM installation process completed": "Finalizado el proceso de detección del servidor OVH e instalación de RTM", + "Object detector": "Detector de objetos", "Object detector for Frigate": "Detector de objetos para Frigate", "Observer is not responding on port 4357": "El observador no responde en el puerto 4357", "Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian es una aplicación de notas que te permite crear, enlazar y organizar tus notas en tu dispositivo, con cientos de plugins y temas para personalizar tu flujo de trabajo. También puedes publicar tus notas en línea, acceder a ellas sin conexión y sincronizarlas de forma segura con cifrado de extremo a extremo.", @@ -4532,7 +4547,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", @@ -4872,7 +4887,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", @@ -5226,6 +5245,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:", @@ -5320,6 +5340,7 @@ "Reverting ZFS ARC tuning...": "Revirtiendo el ajuste ZFS ARC...", "Reverting pigz wrapper...": "Revertir la envoltura de pigz...", "Reverting vzdump speed tuning...": "Revertir el ajuste de velocidad de vzdump...", + "Review and apply": "Revisar y aplicar", "Review external OCI changes": "Revisar cambios externos de OCI", "Review package upgrade": "Revisar la actualización de paquetes", "Review passthrough config files": "Revisar los archivos de configuración de passthrough", @@ -5779,6 +5800,7 @@ "ShareCoveX panel": "Panel de ShareCoveX", "Shared Directory Ready:": "Directorio compartido listo:", "Shared Group": "Grupo compartido", + "Shared directories": "Directorios compartidos", "Shared directory created:": "Directorio compartido creado:", "Shared directory for consume and export": "Directorio compartido para consumo y exportación", "Shared directory for copy/sync operations": "Directorio compartido para operaciones de copia/sincronización", @@ -6772,6 +6794,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:", @@ -6855,6 +6878,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.", @@ -6986,6 +7010,7 @@ "Unable to identify the root filesystem. Aborting.": "No se pudo identificar el sistema de archivos raíz. Se cancela.", "Unable to resolve system ZFS pool disks. Aborting.": "No se pudieron resolver los discos del pool ZFS del sistema. Se cancela.", "Unable to resolve system disk topology. Aborting.": "No se pudo resolver la topología de discos del sistema. Se cancela.", + "Unapplied changes": "Cambios sin aplicar", "Understand the security implications of privileged containers": "Comprender las implicaciones de seguridad de los contenedores privilegiados", "Unexpected Proxmox inventory; recovery blocked": "Inventario Proxmox inesperado; recuperación bloqueada", "Unexpected formatting directory in the new Valkey volume": "Directorio de formato inesperado en el nuevo volumen Valkey", @@ -7180,6 +7205,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?", @@ -8099,6 +8125,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", diff --git a/lang/fr.json b/lang/fr.json index 1f37b051..6b828c4a 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -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é", @@ -540,6 +543,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 :", @@ -751,6 +755,7 @@ "Cannot verify the reused disk:": "Impossible de vérifier le disque réutilisé :", "Cannot write the image archive to {storage}. Check storage access and mount status.": "Impossible d'écrire l'archive d'images sur {storage}. Vérifiez l'accès au stockage et l'état du montage.", "Capabilities cannot be kept and all dropped at the same time": "Les capacités ne peuvent être conservées et toutes abandonnées en même temps", + "Catalog options": "Options du catalogue", "Category": "Catégorie", "Caution: Maximum mode generates more heat.": "Attention : le mode maximum génère plus de chaleur.", "Ceph check skipped by user flag (--ignore-ceph-check)": "Vérification Ceph ignorée par l'indicateur utilisateur (--ignore-ceph-check)", @@ -885,6 +890,7 @@ "Choose a schedule (systemd calendar-event syntax):": "Choisissez un calendrier (syntaxe d'événement de calendrier systemd) :", "Choose a script to execute:": "Choisissez un script à exécuter :", "Choose a server:": "Choisissez un serveur :", + "Choose a setting. Changes are applied only after the final review.": "Choisissez un réglage. Les modifications ne sont appliquées qu’après la vérification finale.", "Choose a share to mount:": "Choisissez un partage à monter :", "Choose a specific supported GPU or USB node": "Choisissez un GPU ou un nœud USB pris en charge spécifique", "Choose action for this controller/NVMe:": "Choisissez une action pour ce contrôleur/NVMe :", @@ -1522,6 +1528,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 :", @@ -1687,6 +1694,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", @@ -1768,6 +1776,8 @@ "Disabling and stopping rpcbind service and socket...": "Désactivation et arrêt du service et du socket rpcbind...", "Disabling non-free firmware warnings...": "Désactivation des avertissements du firmware non libre...", "Disabling portmapper/rpcbind for security...": "Désactivation de portmapper/rpcbind pour la sécurité...", + "Discard changes": "Abandonner les modifications", + "Discard unapplied changes?": "Abandonner les modifications non appliquées ?", "Discover targets on portal:": "Découvrez les cibles sur le portail :", "Discovering iSCSI targets...": "Découverte des cibles iSCSI...", "Discovery successful": "Découverte réussie", @@ -1847,6 +1857,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 ?", @@ -2272,6 +2283,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.", @@ -4164,6 +4176,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é.", @@ -4268,6 +4281,7 @@ "No password provided.": "Aucun mot de passe fourni.", "No paths available": "Aucun chemin disponible", "No paths selected. Select at least one path.": "Aucun chemin sélectionné. Sélectionnez au moins un chemin.", + "No pending changes": "Aucune modification en attente", "No pending paths to schedule for reboot.": "Aucun chemin en attente pour planifier le redémarrage.", "No permanent NFS mounts configured.": "Aucun montage NFS permanent configuré.", "No permanent Samba mounts configured.": "Aucun montage Samba permanent configuré.", @@ -4409,6 +4423,7 @@ "OVH RTM removed (Puppet artefacts may need manual cleanup)": "OVH RTM supprimé (les artefacts de marionnettes peuvent nécessiter un nettoyage manuel)", "OVH server detected": "Serveur OVH détecté", "OVH server detection and RTM installation process completed": "Processus de détection du serveur OVH et d'installation de RTM terminé", + "Object detector": "Détecteur d’objets", "Object detector for Frigate": "Détecteur d'objets pour Frigate", "Observer is not responding on port 4357": "L'observateur ne répond pas au port 4357", "Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian est une application de prise de notes qui vous permet de créer, de lier et d'organiser vos notes sur votre appareil, avec des centaines de plugins et de thèmes pour personnaliser votre workflow. Vous pouvez également publier vos notes en ligne, les accéder hors ligne et les synchroniser en toute sécurité avec le chiffrement de bout en bout.", @@ -4881,7 +4896,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", @@ -5242,6 +5261,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:", @@ -5336,6 +5356,7 @@ "Reverting ZFS ARC tuning...": "Annulation du réglage ZFS ARC...", "Reverting pigz wrapper...": "Rétablissement du wrapper pigz...", "Reverting vzdump speed tuning...": "Annulation du réglage de la vitesse de vzdump...", + "Review and apply": "Vérifier et appliquer", "Review external OCI changes": "Examiner les modifications externes de OCI", "Review package upgrade": "Examiner la mise à niveau des paquets", "Review passthrough config files": "Examiner les fichiers de configuration relais", @@ -5795,6 +5816,7 @@ "ShareCoveX panel": "Panneau ShareCoveX", "Shared Directory Ready:": "Répertoire partagé prêt :", "Shared Group": "Groupe partagé", + "Shared directories": "Dossiers partagés", "Shared directory created:": "Répertoire partagé créé & #160;:", "Shared directory for consume and export": "Répertoire partagé pour la consommation et l'exportation", "Shared directory for copy/sync operations": "Répertoire partagé pour copier/sync operations", @@ -6796,6 +6818,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 :", @@ -6879,6 +6902,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é.", @@ -7010,6 +7034,7 @@ "Unable to identify the root filesystem. Aborting.": "Impossible d'identifier le système de fichiers racine. Avorter.", "Unable to resolve system ZFS pool disks. Aborting.": "Impossible de résoudre les disques du pool système ZFS.Avorter.", "Unable to resolve system disk topology. Aborting.": "Impossible de résoudre la topologie du disque système. Avorter.", + "Unapplied changes": "Modifications non appliquées", "Understand the security implications of privileged containers": "Comprendre les implications de sécurité des conteneurs privilégiés", "Unexpected Proxmox inventory; recovery blocked": "stocks imprévus Proxmox; récupération bloquée", "Unexpected formatting directory in the new Valkey volume": "Répertoire de formatage inattendu dans le nouveau volume Valkey", @@ -7206,6 +7231,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 ?", @@ -8125,6 +8151,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", diff --git a/lang/it.json b/lang/it.json index cddb6f85..e589c0d1 100644 --- a/lang/it.json +++ b/lang/it.json @@ -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", @@ -540,6 +543,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:", @@ -751,6 +755,7 @@ "Cannot verify the reused disk:": "Impossibile verificare il disco riutilizzato:", "Cannot write the image archive to {storage}. Check storage access and mount status.": "Impossibile scrivere l'archivio immagini su {storage}. Controlla l'accesso allo spazio di archiviazione e lo stato di montaggio.", "Capabilities cannot be kept and all dropped at the same time": "Non è possibile conservare alcune capability e rimuoverle tutte contemporaneamente", + "Catalog options": "Opzioni del catalogo", "Category": "Categoria", "Caution: Maximum mode generates more heat.": "Attenzione: la modalità massima genera più calore.", "Ceph check skipped by user flag (--ignore-ceph-check)": "Controllo Ceph saltato dal flag utente (--ignore-ceph-check)", @@ -885,6 +890,7 @@ "Choose a schedule (systemd calendar-event syntax):": "Scegli una pianificazione (sintassi calendar-event di systemd):", "Choose a script to execute:": "Scegli uno script da eseguire:", "Choose a server:": "Scegli un server:", + "Choose a setting. Changes are applied only after the final review.": "Scegli cosa configurare. Le modifiche vengono applicate solo dopo la verifica finale.", "Choose a share to mount:": "Scegli una condivisione da montare:", "Choose a specific supported GPU or USB node": "Scegli uno specifico nodo GPU o USB supportato", "Choose action for this controller/NVMe:": "Scegli l'azione per questo controller/NVMe:", @@ -1522,6 +1528,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:", @@ -1687,6 +1694,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", @@ -1768,6 +1776,8 @@ "Disabling and stopping rpcbind service and socket...": "Disabilitazione e arresto del servizio e del socket rpcbind...", "Disabling non-free firmware warnings...": "Disabilitazione degli avvisi sul firmware non libero...", "Disabling portmapper/rpcbind for security...": "Disabilitazione di portmapper/rpcbind per sicurezza...", + "Discard changes": "Scarta le modifiche", + "Discard unapplied changes?": "Scartare le modifiche non applicate?", "Discover targets on portal:": "Scopri target sul portale:", "Discovering iSCSI targets...": "Individuazione dei target iSCSI...", "Discovery successful": "Scoperta riuscita", @@ -1847,6 +1857,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?", @@ -2272,6 +2283,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.", @@ -4164,6 +4176,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.", @@ -4268,6 +4281,7 @@ "No password provided.": "Nessuna password fornita.", "No paths available": "Nessun percorso disponibile", "No paths selected. Select at least one path.": "Nessun percorso selezionato. Seleziona almeno un percorso.", + "No pending changes": "Nessuna modifica in sospeso", "No pending paths to schedule for reboot.": "Nessun percorso in sospeso da pianificare per il riavvio.", "No permanent NFS mounts configured.": "Nessun montaggio NFS permanente configurato.", "No permanent Samba mounts configured.": "Nessun montaggio Samba permanente configurato.", @@ -4409,6 +4423,7 @@ "OVH RTM removed (Puppet artefacts may need manual cleanup)": "OVH RTM rimosso (gli artefatti di Puppet potrebbero richiedere una pulizia manuale)", "OVH server detected": "Rilevato server OVH", "OVH server detection and RTM installation process completed": "Processo di rilevamento dei server OVH e installazione RTM completato", + "Object detector": "Rilevatore di oggetti", "Object detector for Frigate": "Rilevatore di oggetti per Frigate", "Observer is not responding on port 4357": "Observer non risponde sulla porta 4357", "Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian è un’app per creare, collegare e organizzare le note sul tuo dispositivo, con centinaia di plugin e temi per personalizzare il flusso di lavoro. Puoi pubblicare le note online, consultarle offline e sincronizzarle in modo sicuro con crittografia end-to-end.", @@ -4881,7 +4896,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", @@ -5242,6 +5261,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:", @@ -5336,6 +5356,7 @@ "Reverting ZFS ARC tuning...": "Rimozione delle ottimizzazioni di ZFS ARC…", "Reverting pigz wrapper...": "Rimozione del wrapper pigz e ripristino della configurazione precedente...", "Reverting vzdump speed tuning...": "Rimozione ottimizzazioni velocità vzdump…", + "Review and apply": "Verifica e applica", "Review external OCI changes": "Esamina le modifiche OCI esterne", "Review package upgrade": "Esamina l'aggiornamento dei pacchetti", "Review passthrough config files": "Esamina i file di configurazione passthrough", @@ -5795,6 +5816,7 @@ "ShareCoveX panel": "Pannello ShareCoveX", "Shared Directory Ready:": "Directory condivisa pronta:", "Shared Group": "Gruppo condiviso", + "Shared directories": "Cartelle condivise", "Shared directory created:": "Directory condivisa creata:", "Shared directory for consume and export": "Directory condivisa per consume ed export", "Shared directory for copy/sync operations": "Directory condivisa per copia e sincronizzazione", @@ -6797,6 +6819,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:", @@ -6880,6 +6903,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.", @@ -7011,6 +7035,7 @@ "Unable to identify the root filesystem. Aborting.": "Impossibile identificare il filesystem root. Interruzione.", "Unable to resolve system ZFS pool disks. Aborting.": "Impossibile individuare i dischi del pool ZFS di sistema. Interruzione.", "Unable to resolve system disk topology. Aborting.": "Impossibile determinare la topologia del disco di sistema. Interruzione.", + "Unapplied changes": "Modifiche non applicate", "Understand the security implications of privileged containers": "Comprendere le implicazioni sulla sicurezza dei container privilegiati", "Unexpected Proxmox inventory; recovery blocked": "Inventario Proxmox inatteso; ripristino bloccato", "Unexpected formatting directory in the new Valkey volume": "Directory di formattazione imprevista nel nuovo volume Valkey", @@ -7207,6 +7232,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?", @@ -8126,6 +8152,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", diff --git a/lang/pt.json b/lang/pt.json index 8e0abe42..0710e044 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -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", @@ -540,6 +543,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:", @@ -751,6 +755,7 @@ "Cannot verify the reused disk:": "Não foi possível verificar o disco reutilizado:", "Cannot write the image archive to {storage}. Check storage access and mount status.": "Não é possível gravar o arquivo de imagem em {storage}. Verifique o acesso ao armazenamento e o status de montagem.", "Capabilities cannot be kept and all dropped at the same time": "Capacidades não podem ser mantidas e todas retiradas ao mesmo tempo", + "Catalog options": "Opções do catálogo", "Category": "Categoria", "Caution: Maximum mode generates more heat.": "Cuidado: O modo máximo gera mais calor.", "Ceph check skipped by user flag (--ignore-ceph-check)": "Verificação do Ceph ignorada pelo sinalizador do usuário (--ignore-ceph-check)", @@ -885,6 +890,7 @@ "Choose a schedule (systemd calendar-event syntax):": "Escolha uma programação (sintaxe de evento de calendário do systemd):", "Choose a script to execute:": "Escolha um script para executar:", "Choose a server:": "Escolha um servidor:", + "Choose a setting. Changes are applied only after the final review.": "Escolha o que configurar. As alterações só são aplicadas após a revisão final.", "Choose a share to mount:": "Escolha um compartilhamento para montar:", "Choose a specific supported GPU or USB node": "Escolha um GPU ou nó USB específico compatível", "Choose action for this controller/NVMe:": "Escolha a ação para este controlador/NVMe:", @@ -1522,6 +1528,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:", @@ -1687,6 +1694,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", @@ -1768,6 +1776,8 @@ "Disabling and stopping rpcbind service and socket...": "Desativando e parando o serviço e soquete rpcbind...", "Disabling non-free firmware warnings...": "Desativando avisos de firmware não-livre...", "Disabling portmapper/rpcbind for security...": "Desativando portmapper/rpcbind por segurança...", + "Discard changes": "Descartar alterações", + "Discard unapplied changes?": "Descartar as alterações por aplicar?", "Discover targets on portal:": "Descubra alvos no portal:", "Discovering iSCSI targets...": "Descobrindo alvos iSCSI...", "Discovery successful": "Descoberta bem-sucedida", @@ -1847,6 +1857,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?", @@ -2272,6 +2283,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.", @@ -4164,6 +4176,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.", @@ -4268,6 +4281,7 @@ "No password provided.": "Nenhuma senha fornecida.", "No paths available": "Nenhum caminho disponível", "No paths selected. Select at least one path.": "Nenhum caminho selecionado. Selecione pelo menos um caminho.", + "No pending changes": "Sem alterações pendentes", "No pending paths to schedule for reboot.": "Não há caminhos pendentes para agendar a reinicialização.", "No permanent NFS mounts configured.": "Nenhuma montagem NFS permanente configurada.", "No permanent Samba mounts configured.": "Nenhuma montagem permanente do Samba configurada.", @@ -4409,6 +4423,7 @@ "OVH RTM removed (Puppet artefacts may need manual cleanup)": "OVH RTM removido (artefatos de marionetes podem precisar de limpeza manual)", "OVH server detected": "Servidor OVH detectado", "OVH server detection and RTM installation process completed": "Processo de detecção do servidor OVH e instalação RTM concluído", + "Object detector": "Detetor de objetos", "Object detector for Frigate": "Detector de objetos para Frigate", "Observer is not responding on port 4357": "Observador não está respondendo na porta 4357", "Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian é um aplicativo de anotações que permite criar, vincular e organizar suas notas em seu dispositivo, com centenas de plugins e temas para personalizar seu fluxo de trabalho. Você também pode publicar suas notas online, acessá-las offline e sincronizá-las com segurança com criptografia de ponta a ponta.", @@ -4881,7 +4896,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", @@ -5242,6 +5261,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:", @@ -5336,6 +5356,7 @@ "Reverting ZFS ARC tuning...": "Revertendo ajuste do ZFS ARC...", "Reverting pigz wrapper...": "Revertendo o wrapper pigz...", "Reverting vzdump speed tuning...": "Revertendo o ajuste de velocidade do vzdump...", + "Review and apply": "Rever e aplicar", "Review external OCI changes": "revisar alterações externas do OCI", "Review package upgrade": "Rever a atualização de pacotes", "Review passthrough config files": "Revise os arquivos de configuração de passagem", @@ -5795,6 +5816,7 @@ "ShareCoveX panel": "Painel ShareCoveX", "Shared Directory Ready:": "Diretório compartilhado pronto:", "Shared Group": "Grupo Compartilhado", + "Shared directories": "Diretórios partilhados", "Shared directory created:": "Directório partilhado criado:", "Shared directory for consume and export": "Diretório compartilhado para consumir e exportar", "Shared directory for copy/sync operations": "Diretório compartilhado para copiar/sincronizar operations", @@ -6797,6 +6819,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á:", @@ -6880,6 +6903,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.", @@ -7011,6 +7035,7 @@ "Unable to identify the root filesystem. Aborting.": "Não foi possível identificar o sistema de arquivos raiz. Abortando.", "Unable to resolve system ZFS pool disks. Aborting.": "Não foi possível resolver os discos do pool ZFS do sistema. Abortando.", "Unable to resolve system disk topology. Aborting.": "Não é possível resolver a topologia do disco do sistema. Abortando.", + "Unapplied changes": "Alterações por aplicar", "Understand the security implications of privileged containers": "Entenda as implicações de segurança de contêineres privilegiados", "Unexpected Proxmox inventory; recovery blocked": "Inesperado inventário Proxmox; recuperação bloqueada", "Unexpected formatting directory in the new Valkey volume": "Directório de formatação inesperado no novo volume Valkey", @@ -7207,6 +7232,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?", @@ -8126,6 +8152,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", diff --git a/lang/sk.json b/lang/sk.json index 3d6cd693..7c3d7a7c 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -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é", @@ -540,6 +543,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:", @@ -751,6 +755,7 @@ "Cannot verify the reused disk:": "Nie je možné overiť znovu použitý disk:", "Cannot write the image archive to {storage}. Check storage access and mount status.": "Nie je možné zapísať archív obrázkov do {storage}. Skontrolujte prístup k úložisku a stav pripojenia.", "Capabilities cannot be kept and all dropped at the same time": "Nie je možné udržať si schopnosti a zároveň ich znížiť.", + "Catalog options": "Možnosti katalógu", "Category": "Kategória", "Caution: Maximum mode generates more heat.": "Pozor: maximálny režim vytvára viac tepla.", "Ceph check skipped by user flag (--ignore-ceph-check)": "Kontrola Ceph preskočená používateľskou voľbou (--ignore-ceph-check)", @@ -885,6 +890,7 @@ "Choose a schedule (systemd calendar-event syntax):": "Vyberte plán (syntax systémového kalendára a udalosti):", "Choose a script to execute:": "Vyberte skript, ktorý sa má spustiť:", "Choose a server:": "Vyberte server:", + "Choose a setting. Changes are applied only after the final review.": "Vyberte nastavenie. Zmeny sa použijú až po záverečnej kontrole.", "Choose a share to mount:": "Vyberte zdieľanie na pripojenie:", "Choose a specific supported GPU or USB node": "Vyberte konkrétny podporovaný uzol GPU alebo USB", "Choose action for this controller/NVMe:": "Vyberte akciu pre tento Controller/NVMe:", @@ -1522,6 +1528,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:", @@ -1687,6 +1694,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", @@ -1768,6 +1776,8 @@ "Disabling and stopping rpcbind service and socket...": "Zakázanie a zastavenie služby rpcbind a soketu...", "Disabling non-free firmware warnings...": "Vypínam upozornenia na non-free firmware...", "Disabling portmapper/rpcbind for security...": "Z bezpečnostných dôvodov vypínam portmapper/rpcbind...", + "Discard changes": "Zahodiť zmeny", + "Discard unapplied changes?": "Zahodiť nepoužité zmeny?", "Discover targets on portal:": "Vyhľadať targety na portáli:", "Discovering iSCSI targets...": "Vyhľadávam iSCSI targety...", "Discovery successful": "Vyhľadanie bolo úspešné", @@ -1847,6 +1857,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?", @@ -2272,6 +2283,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ť.", @@ -4164,6 +4176,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.", @@ -4268,6 +4281,7 @@ "No password provided.": "Nebolo zadané heslo.", "No paths available": "Nie sú dostupné žiadne cesty", "No paths selected. Select at least one path.": "Nie sú vybrané žiadne cesty. Vyberte aspoň jednu cestu.", + "No pending changes": "Žiadne čakajúce zmeny", "No pending paths to schedule for reboot.": "Nie sú žiadne čakajúce cesty, ktoré by sa mali naplánovať na reštart.", "No permanent NFS mounts configured.": "Nie sú nastavené žiadne trvalé NFS pripojenia.", "No permanent Samba mounts configured.": "Nie sú nastavené žiadne trvalé Samba pripojenia.", @@ -4411,6 +4425,7 @@ "OVH RTM removed (Puppet artefacts may need manual cleanup)": "OVH RTM bol odstránený (Puppet súbory môže byť potrebné vyčistiť ručne)", "OVH server detected": "Zistený OVH server", "OVH server detection and RTM installation process completed": "Kontrola OVH servera a inštalácia RTM sú dokončené", + "Object detector": "Detektor objektov", "Object detector for Frigate": "Detektor objektov pre Frigate", "Observer is not responding on port 4357": "Pozorovateľ nereaguje na prístav 4357", "Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian je note-prijímajúce aplikácie, ktoré vám umožní vytvoriť, odkaz, a usporiadať poznámky na vašom zariadení, so stovkami pluginov a tém prispôsobiť svoje pracovné postupy. Môžete tiež zverejniť svoje poznámky on-line, prístup je offline, a synchronizovať ich bezpečne s end-to-end šifrovanie.", @@ -4883,7 +4898,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", @@ -5244,6 +5263,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:", @@ -5338,6 +5358,7 @@ "Reverting ZFS ARC tuning...": "Vraciam späť ladenie ZFS ARC...", "Reverting pigz wrapper...": "Vraciam späť wrapper pigz...", "Reverting vzdump speed tuning...": "Vraciam späť ladenie rýchlosti vzdump...", + "Review and apply": "Skontrolovať a použiť", "Review external OCI changes": "Skontrolujte externé zmeny OCI", "Review package upgrade": "Skontrolovať aktualizáciu balíkov", "Review passthrough config files": "Skontrolovať konfiguračné súbory passthrough", @@ -5797,6 +5818,7 @@ "ShareCoveX panel": "Panel ShareCoveX", "Shared Directory Ready:": "Zdieľaný priečinok je pripravený:", "Shared Group": "Zdieľaná skupina", + "Shared directories": "Zdieľané priečinky", "Shared directory created:": "Vytvorený zdieľaný adresár:", "Shared directory for consume and export": "Zdieľaný adresár pre spotrebu a export", "Shared directory for copy/sync operations": "Zdieľaný adresár pre kopírovanie/sync operations", @@ -6798,6 +6820,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á:", @@ -6881,6 +6904,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.", @@ -7012,6 +7036,7 @@ "Unable to identify the root filesystem. Aborting.": "Nepodarilo sa identifikovať koreňový súborový systém. Prerušuje sa.", "Unable to resolve system ZFS pool disks. Aborting.": "Nepodarilo sa vyriešiť systémové disky ZFS fondu. Prerušuje sa.", "Unable to resolve system disk topology. Aborting.": "Nepodarilo sa vyriešiť topológiu systémového disku. Prerušuje sa.", + "Unapplied changes": "Nepoužité zmeny", "Understand the security implications of privileged containers": "Uistite sa, že rozumiete bezpečnostným dôsledkom privilegovaných kontajnerov", "Unexpected Proxmox inventory; recovery blocked": "Neočakávaný inventár Proxmox; zablokovaná obnova", "Unexpected formatting directory in the new Valkey volume": "Neočakávaný formátovanie adresára v novom hlasitosti Valkey", @@ -7208,6 +7233,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?", @@ -8127,6 +8153,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", diff --git a/lang/sv.json b/lang/sv.json index 51c60d2c..8a5e64aa 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -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", @@ -540,6 +543,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:", @@ -751,6 +755,7 @@ "Cannot verify the reused disk:": "Kan inte verifiera den återanvända disken:", "Cannot write the image archive to {storage}. Check storage access and mount status.": "Kan inte skriva bildarkivet till {storage}. Kontrollera lagringsåtkomst och monteringsstatus.", "Capabilities cannot be kept and all dropped at the same time": "Förmågor kan inte hållas och alla tappas samtidigt", + "Catalog options": "Katalogalternativ", "Category": "Kategori", "Caution: Maximum mode generates more heat.": "Varning: Maximalt läge genererar mer värme.", "Ceph check skipped by user flag (--ignore-ceph-check)": "Ceph-kontroll hoppas över av användarflaggan (--ignore-ceph-check)", @@ -885,6 +890,7 @@ "Choose a schedule (systemd calendar-event syntax):": "Välj ett schema (syntax för systemd kalenderhändelser):", "Choose a script to execute:": "Välj ett skript att köra:", "Choose a server:": "Välj en server:", + "Choose a setting. Changes are applied only after the final review.": "Välj en inställning. Ändringarna tillämpas först efter den slutliga granskningen.", "Choose a share to mount:": "Välj en resurs att montera:", "Choose a specific supported GPU or USB node": "Välj en specifik GPU- eller USB-nod som stöds", "Choose action for this controller/NVMe:": "Välj åtgärd för denna kontroller/NVMe:", @@ -1522,6 +1528,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:", @@ -1687,6 +1694,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", @@ -1768,6 +1776,8 @@ "Disabling and stopping rpcbind service and socket...": "Inaktivera och stoppa rpcbind-tjänsten och -socket...", "Disabling non-free firmware warnings...": "Inaktiverar icke-fri firmware-varningar...", "Disabling portmapper/rpcbind for security...": "Inaktiverar portmapper/rpcbind för säkerhet...", + "Discard changes": "Kasta ändringar", + "Discard unapplied changes?": "Kasta ändringar som inte har tillämpats?", "Discover targets on portal:": "Upptäck mål på portalen:", "Discovering iSCSI targets...": "Upptäcker iSCSI-mål...", "Discovery successful": "Upptäckten lyckades", @@ -1847,6 +1857,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?", @@ -2272,6 +2283,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.", @@ -4164,6 +4176,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.", @@ -4268,6 +4281,7 @@ "No password provided.": "Inget lösenord angett.", "No paths available": "Inga vägar tillgängliga", "No paths selected. Select at least one path.": "Inga sökvägar har valts. Välj minst en väg.", + "No pending changes": "Inga väntande ändringar", "No pending paths to schedule for reboot.": "Inga väntande sökvägar att schemalägga för omstart.", "No permanent NFS mounts configured.": "Inga permanenta NFS-monteringar har konfigurerats.", "No permanent Samba mounts configured.": "Inga permanenta Samba-fästen har konfigurerats.", @@ -4409,6 +4423,7 @@ "OVH RTM removed (Puppet artefacts may need manual cleanup)": "OVH RTM har tagits bort (Puppet artefakter kan behöva manuell rengöring)", "OVH server detected": "OVH-server upptäckt", "OVH server detection and RTM installation process completed": "OVH-serverdetektering och RTM-installation har slutförts", + "Object detector": "Objektdetektor", "Object detector for Frigate": "Objektdetektor för Frigate", "Observer is not responding on port 4357": "Observer svarar inte på port 4357", "Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian är en noterande app som låter dig skapa, länka och organisera dina anteckningar på din enhet, med hundratals plugins och teman för att anpassa ditt arbetsflöde. Du kan också publicera dina anteckningar online, komma åt dem offline och synkronisera dem säkert med end-to-end-kryptering.", @@ -4881,7 +4896,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", @@ -5242,6 +5261,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:", @@ -5336,6 +5356,7 @@ "Reverting ZFS ARC tuning...": "Återställer ZFS ARC-inställning...", "Reverting pigz wrapper...": "Återställer pigz-omslag...", "Reverting vzdump speed tuning...": "Återställer vzdump-hastighetsinställning...", + "Review and apply": "Granska och tillämpa", "Review external OCI changes": "Granska externa OCI ändringar", "Review package upgrade": "Granska paketuppgraderingen", "Review passthrough config files": "Granska passthrough-konfigurationsfiler", @@ -5795,6 +5816,7 @@ "ShareCoveX panel": "ShareCoveX panel", "Shared Directory Ready:": "Redo för delad katalog:", "Shared Group": "Delad grupp", + "Shared directories": "Delade kataloger", "Shared directory created:": "Delad katalog skapad:", "Shared directory for consume and export": "Delad katalog för konsumering och export", "Shared directory for copy/sync operations": "Delad katalog för kopia/synkronisera operations", @@ -6797,6 +6819,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:", @@ -6880,6 +6903,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.", @@ -7011,6 +7035,7 @@ "Unable to identify the root filesystem. Aborting.": "Det går inte att identifiera rotfilsystemet. Avbryter.", "Unable to resolve system ZFS pool disks. Aborting.": "Det går inte att lösa system ZFS pooldiskar. Avbryter.", "Unable to resolve system disk topology. Aborting.": "Det gick inte att lösa systemdisktopologin. Avbryter.", + "Unapplied changes": "Ändringar som inte har tillämpats", "Understand the security implications of privileged containers": "Förstå säkerhetskonsekvenserna av privilegierade containrar", "Unexpected Proxmox inventory; recovery blocked": "Oväntad Proxmox inventering; återhämtning blockerad", "Unexpected formatting directory in the new Valkey volume": "Oväntad formateringskatalog i den nya Valkey-volymen", @@ -7207,6 +7232,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?", @@ -8126,6 +8152,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", diff --git a/oci/remote/install_oci.sh b/oci/remote/install_oci.sh index 7d1d8e85..b3b100e6 100755 --- a/oci/remote/install_oci.sh +++ b/oci/remote/install_oci.sh @@ -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 diff --git a/oci/remote/oci_carried_record.py b/oci/remote/oci_carried_record.py index 725640dd..14375ef5 100644 --- a/oci/remote/oci_carried_record.py +++ b/oci/remote/oci_carried_record.py @@ -38,6 +38,8 @@ DIRECTORY = '.proxmenux' NAME = 'oci-record.json' KIND = 'proxmenux.oci-carried-record' STAMP = 'carried.sha256' +CONFIG_CACHE = 'carried-config.json' +LOCAL_CONFIGS = Path('/etc/pve/lxc') LIMIT = 16 * 1024 * 1024 STACK_CONTRACT = '/etc/pve/priv/proxmenux-stack-{}.json' NVIDIA_HOOK = re.compile(r'/usr/local/lib/proxmenux/oci/nvidia-mount-([a-f0-9]{64})\.sh') @@ -279,6 +281,47 @@ def read_stamp(path): return value if isinstance(value, dict) else {} +def configuration(root, vmid, verify=False): + """Reuse pct's formatted output only while its local source is unchanged. + + Opening Manage checks every carried copy. Starting a fresh pct process + for each unchanged guest dominates that check. The private host cache is + keyed by the complete native configuration, including notes, pending + changes and snapshot sections. Lifecycle verification always calls pct. + An unavailable source, invalid cache or concurrent edit falls back to pct. + """ + source = LOCAL_CONFIGS / f'{int(vmid)}.conf' + cache = instances.location(root, vmid).parent / CONFIG_CACHE + + def fingerprint(): + try: + return sha(source.read_bytes()) + except OSError: + return None + + before = fingerprint() + if before is not None and not verify: + try: + if not cache.is_symlink() and cache.stat().st_size <= LIMIT: + value = json.loads(cache.read_text()) + if (isinstance(value, dict) and value.get('schema_version') == 1 + and value.get('source_sha256') == before + and isinstance(value.get('config'), str) and value['config'] + and fingerprint() == before): + return value['config'] + except (OSError, ValueError): + pass + result = _run('pct', 'config', str(vmid)) + if result.returncode != 0: + return None + # Do not associate output from one revision with another revision's hash. + if before is not None and fingerprint() == before: + with contextlib.suppress(OSError, ValueError): + instances.write(cache, {'schema_version': 1, 'source_sha256': before, + 'config': result.stdout}) + return result.stdout + + def superseded(record, existing, stamp): """Whether the container carries a copy this host did not write, with a record that differs from the one of this host. That is a container that @@ -308,10 +351,10 @@ def carry(root, vmid, mount_stopped=True, verify=False, adopted=False): if record.get('status') != 'installed' or record.get('pending_transaction') \ or record.get('pending_stack_transaction'): return 'skipped' - result = _run('pct', 'config', str(vmid)) - if result.returncode != 0 or instances.identity(result.stdout.encode()) != record['installation_id']: + config = configuration(root, vmid, verify) + if config is None or instances.identity(config.encode()) != record['installation_id']: return 'skipped' - value = bundle(record, result.stdout) + value = bundle(record, config) expected = digest(value) record_path = instances.location(root, vmid) path = record_path.parent / STAMP @@ -340,7 +383,7 @@ def carry(root, vmid, mount_stopped=True, verify=False, adopted=False): else: value['saved_at'] = datetime.datetime.now(datetime.timezone.utc).isoformat() value['generation'] = uuid.uuid4().hex - write_copy(rootfs, value, mapped_root(result.stdout)) + write_copy(rootfs, value, mapped_root(config)) stamp = {'generation': value['generation']} outcome = 'carried' if outcome == 'stale': diff --git a/oci/remote/oci_instance_transaction.py b/oci/remote/oci_instance_transaction.py index bd8f09d6..a2d635ce 100644 --- a/oci/remote/oci_instance_transaction.py +++ b/oci/remote/oci_instance_transaction.py @@ -382,6 +382,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.""" @@ -397,9 +413,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} @@ -445,10 +466,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')} @@ -777,11 +797,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: @@ -1095,6 +1114,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. @@ -1102,9 +1124,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']) @@ -1187,7 +1210,10 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i move_retained_unused(state, to_stage=False) 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'])) diff --git a/oci/remote/oci_instances.py b/oci/remote/oci_instances.py index f039b956..0f4d19ce 100644 --- a/oci/remote/oci_instances.py +++ b/oci/remote/oci_instances.py @@ -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) diff --git a/oci/remote/oci_runtime.py b/oci/remote/oci_runtime.py index ee3b9d56..3d842fd3 100644 --- a/oci/remote/oci_runtime.py +++ b/oci/remote/oci_runtime.py @@ -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 /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: diff --git a/oci/remote/oci_stack_native.py b/oci/remote/oci_stack_native.py index b679ee74..2b564205 100644 --- a/oci/remote/oci_stack_native.py +++ b/oci/remote/oci_stack_native.py @@ -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(): diff --git a/oci/remote/oci_work_backup.py b/oci/remote/oci_work_backup.py index b37a2c26..251ffe71 100644 --- a/oci/remote/oci_work_backup.py +++ b/oci/remote/oci_work_backup.py @@ -192,21 +192,25 @@ def application(root, vmid): return primary_id, members or [vmid] -def status(root, vmid): +def status(root, vmid, only_needed=False): """What the menu needs to know before an operation: whether the backup fits on the system disk, the storage chosen earlier and the ones that - have room.""" + have room. The menu can skip unused alternatives once a destination fits; + the default retains the complete status used by the maintenance CLI.""" primary_id, vmids = application(root, vmid) record = instances.read(root, primary_id) default = instances.location(root, primary_id).parent needed, free = required(vmids), available(default) saved = (record.get('deployment') or {}).get(KEY) - path = storage_path(saved) if saved else None + fits = free >= needed + path = storage_path(saved) if saved and not (only_needed and fits) else None saved_free = available(path) if path is not None and path.is_dir() else None - return {'needed': needed, 'free': free, 'fits': free >= needed, 'saved': saved, - 'saved_fits': saved_free is not None and saved_free >= needed, + saved_fits = saved_free is not None and saved_free >= needed + candidates = [] if only_needed and (fits or saved_fits) else file_storages() + return {'needed': needed, 'free': free, 'fits': fits, 'saved': saved, + 'saved_fits': saved_fits, 'candidates': [{'storage': item['storage'], 'free': item['free']} - for item in file_storages() if item['free'] >= needed]} + for item in candidates if item['free'] >= needed]} def choose(root, vmid, storage): diff --git a/oci/src/proxmenux_oci/cli.py b/oci/src/proxmenux_oci/cli.py index 9266f283..14dda6e2 100644 --- a/oci/src/proxmenux_oci/cli.py +++ b/oci/src/proxmenux_oci/cli.py @@ -578,8 +578,6 @@ def interactive(catalog: Catalog) -> int: f"{translate('All applications')} ({len(applications)})") elif selection == "manage": from .management import interactive_management - console.show_logo() - console.msg_title(translate("Manage installed OCI applications")) interactive_management(PROJECT_ROOT, ui) elif selection == "registries": from .registry_accounts import open_registry_accounts diff --git a/oci/src/proxmenux_oci/management.py b/oci/src/proxmenux_oci/management.py index cbf8e041..27ee09ad 100644 --- a/oci/src/proxmenux_oci/management.py +++ b/oci/src/proxmenux_oci/management.py @@ -106,13 +106,32 @@ def _config_hostname(vmid): return '' +def _selected_configuration(vmid): + """Reuse pct's output only while its complete native source is unchanged. + + The forced carried-record verification has just fetched a fresh config. + Older installations without that source-keyed cache still query pct. + The lifecycle backend independently validates again before any mutation. + """ + import oci_instances as instances + try: + import oci_carried_record + reader = getattr(oci_carried_record, 'configuration', None) + config = reader(instances.ROOT, vmid) if reader else None + if isinstance(config, str) and config: + return config.encode() + except (ImportError, OSError, ValueError, RuntimeError, subprocess.SubprocessError): + pass + return instances.command('pct', 'config', str(vmid)) + + def check_selected(project, row): """Validate only the chosen container; lifecycle backends validate its stack.""" sys.path.insert(0, str(project / 'remote')) import oci_instances as instances with instances.locked(instances.ROOT): record = instances.read(instances.ROOT, row['vmid']) - config = instances.command('pct', 'config', str(row['vmid'])) + config = _selected_configuration(row['vmid']) marker = instances.identity(config) reason = 'matched' if marker == record['installation_id'] else 'identity-unconfirmed' return public_row(record, {'reason': reason}) @@ -132,7 +151,7 @@ def _run_lifecycle(command, title): return completed.returncode == 0 -def carry_records(project, mount_stopped=True, vmids=None, verify=False, shown=False): +def carry_records(project, mount_stopped=True, vmids=None, verify=False, shown=False, heading=None): """Leave inside each container the copy of its record that a restore on another host needs. It is refreshed after every operation. Returns what was found for each container. Every installed application is looked at, @@ -140,6 +159,9 @@ def carry_records(project, mount_stopped=True, vmids=None, verify=False, shown=F from . import console sys.path.insert(0, str(project / 'remote')) if shown: + if heading: + console.show_logo() + console.msg_title(heading) console.msg_info(translate('Updating the copy of the record...')) try: import oci_carried_record @@ -234,6 +256,17 @@ def interactive_management(project, ui): ui.message(translate('OCI management could not be completed. Check the backend status; no additional cleanup has been authorized.'), translate('OCI management')) +def _records_unread(project): + """Whether an installed application has no cached configuration yet; only + then does the first sync read every container and take a while.""" + sys.path.insert(0, str(project / 'remote')) + import oci_carried_record as carried + return any(not (carried.instances.location(carried.instances.ROOT, row['vmid']).parent + / carried.CONFIG_CACHE).exists() + for row in saved_inventory(project) + if row.get('status') == 'installed' and not row.get('pending')) + + def _clean_orphans(project): """Remove, silently, what is left of containers that exist on no node of the cluster.""" sys.path.insert(0, str(project / 'remote')) @@ -253,7 +286,8 @@ def _interactive_management(project, ui): _clean_orphans(project) recover_automatically(project) offer_recovery(project, ui) - carry_records(project, mount_stopped=False, shown=True) + carry_records(project, mount_stopped=False, shown=_records_unread(project), + heading=translate('Manage installed OCI applications')) # After each operation the list is shown again. Every operation leaves # the copies of the records up to date, so they are not all refreshed again. first = True @@ -313,7 +347,7 @@ def _work_backup_ready(project, ui, vmid): import oci_instances as instances import oci_work_backup try: - state = oci_work_backup.status(instances.ROOT, vmid) + state = oci_work_backup.status(instances.ROOT, vmid, only_needed=True) except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError): # What cannot be measured here is told by the engine when it stops. return True @@ -397,12 +431,10 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=()): return _toggle_watchdog(project, ui, row['vmid'], watched) if action == 'remove': return _remove(project, ui, row['vmid']) - if not _work_backup_ready(project, ui, row['vmid']): - return False record = instances.read(instances.ROOT, row['vmid']) import oci_instance_reconcile as reconcile try: - current_config = instances.command('pct', 'config', str(row['vmid'])) + current_config = _selected_configuration(row['vmid']) adoption = reconcile.propose(record, current_config) except (OSError, ValueError, RuntimeError) as error: ui.message(str(error), translate('Review external OCI changes')) @@ -423,29 +455,16 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=()): proposal = None if action == 'modify': from .recreation import edit_recreation - from .cli import _deployment_summary_text - from .ui import BacktrackUI, RestartWizard - wizard = BacktrackUI(ui) - try: - while True: - try: - proposal = edit_recreation(record, wizard) - approved = wizard.review(_deployment_summary_text(proposal['candidate']['template'], - proposal['candidate']['deployment']), translate('Modify OCI'), - question=translate('Apply these changes?'), default=True) - break - except RestartWizard: - wizard.restart() - except ValueError as error: - if not wizard.retry_last(error): - raise - finally: - wizard.close() - if not approved: + proposal = edit_recreation(record, ui) + if proposal is None: return False elif not ui.review(translate('If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with the new image to update the container. If the update fails after the container was changed, the backup is restored. An update that stops halfway is recovered from the OCI management menu.'), translate('Update OCI'), question=translate('Update now?'), default=True): return False + # Opening/cancelling a confirmation needs no disk or storage probes. + # Both accepted operations retain this check before the backend starts. + if not _work_backup_ready(project, ui, row['vmid']): + return False command = [sys.executable, str(project / 'remote/oci_update_current.py'), str(row['vmid']), *lifecycle_args] # Host directories are configured by the user, who knows they are @@ -495,7 +514,10 @@ def _removal_summary(project, vmid): record = instances.read(instances.ROOT, member) except (OSError, ValueError, KeyError): record = {} - config = oci_remove.guest_config(member) + try: + config = _selected_configuration(member) + except (OSError, RuntimeError, subprocess.SubprocessError): + config = None cfg = parse_config(config) if config else {} name = (cfg.get('hostname') or (record.get('stack_member') or {}).get('name') or record.get('deployment', {}).get('hostname') or '') diff --git a/oci/src/proxmenux_oci/recreation.py b/oci/src/proxmenux_oci/recreation.py index d8ddf4b9..e1f3d897 100644 --- a/oci/src/proxmenux_oci/recreation.py +++ b/oci/src/proxmenux_oci/recreation.py @@ -4,7 +4,7 @@ import re from pathlib import Path, PurePosixPath from .i18n import translate -from .ui import UserCancelled +from .ui import BacktrackUI, RestartWizard, UserCancelled def positive_integer(ui, prompt, value, minimum=1): @@ -30,13 +30,13 @@ def edit_network(deployment, ui): network.update(bridge=bridge, ipv4=ipv4, gateway=gateway) -def edit_acceleration(candidate, ui): +def edit_acceleration(candidate, ui, confirm=True): from .installer import configure_acceleration deployment = candidate['deployment'] template = candidate.get('template', {}) installer = template.get('proxmox', {}).get('installer_profile', {}) hardware = installer.get('hardware_acceleration') - if not hardware or not ui.confirm(translate('Change GPU acceleration?'), False): + if not hardware or (confirm and not ui.confirm(translate('Change GPU acceleration?'), False)): return profiles = hardware.get('profiles', []) reference = template.get('container_contract', {}).get('image', {}).get('reference', '') @@ -104,44 +104,143 @@ def edit_environment(deployment, ui): environment.append(item) -def edit_recreation(record, ui): - candidate = copy.deepcopy(record) - refresh_template(candidate, ui) +def modification_options(candidate): + """Only offer supported settings; never put environment values in the menu.""" deployment = candidate['deployment'] resources = deployment['resources'] - resources['cores'] = positive_integer(ui, translate('Cores'), resources['cores']) - resources['memory_mb'] = positive_integer(ui, translate('RAM in MiB'), resources['memory_mb'], 128) + profile = candidate.get('template', {}).get('proxmox', {}).get('installer_profile', {}) + network = deployment.get('network', {}) + address = network.get('ipv4', 'dhcp') + + def row(label, value): + return f"{label:<26} {value}" + + options = [ + ('cores', row(translate('CPU cores'), resources['cores'])), + ('memory', row(translate('RAM in MiB'), resources['memory_mb'])), + ('network', row(translate('Network'), + f"{network.get('bridge', '-')} · {'DHCP' if address == 'dhcp' else address}")), + # Only the paths added by hand: the application's own volumes are not extra. + ('paths', row(translate('Extra paths'), sum(1 for m in deployment.get('mounts', []) if m.get('custom')))), + ] + if profile.get('shared_directories'): + options.append(('shares', translate('Shared directories'))) + if profile.get('hardware_acceleration'): + hardware = profile['hardware_acceleration'] + selected = next((p for p in hardware.get('profiles', []) + if p['id'] == deployment.get('hardware_profile')), {}) + options.append(('acceleration', row(translate('Acceleration'), + translate(selected.get('label') or deployment.get('hardware_profile') or 'CPU')))) + if profile.get('object_detector'): + options.append(('detector', translate('Object detector'))) + options += [ + ('devices', row(translate('Devices'), len(deployment.get('devices', [])))), + ('environment', row(translate('Variables'), len(deployment.get('environment', [])))), + ('catalog', translate('Catalog options')), + ('apply', translate('Review and apply')), + ('discard', translate('Discard changes')), + ] + return options + + +def edit_setting(candidate, ui, setting): + deployment = candidate['deployment'] template = candidate.get('template', {}) - if template.get('proxmox', {}).get('installer_profile', {}).get('shared_directories'): + profile = template.get('proxmox', {}).get('installer_profile', {}) + if setting == 'cores': + resources = deployment['resources'] + resources['cores'] = positive_integer(ui, translate('Cores'), resources['cores']) + elif setting == 'memory': + resources = deployment['resources'] + resources['memory_mb'] = positive_integer(ui, translate('RAM in MiB'), resources['memory_mb'], 128) + elif setting == 'network': + edit_network(deployment, ui) + elif setting == 'shares': from .installer import ask_shared_directories deployment['mounts'] += ask_shared_directories( ui, template, deployment['mounts'], deployment['rootfs']['storage'], True, optional=True, unprivileged=deployment.get('security', {}).get('unprivileged', True)) - from .custom_mounts import ask_custom_mounts - deployment['mounts'] = ask_custom_mounts( - ui, deployment['mounts'], deployment['rootfs']['storage']) - if ui.confirm(translate('Change the access network?'), False): - edit_network(deployment, ui) - edit_acceleration(candidate, ui) - from .installer import configure_detector - installer_profile = candidate.get('template', {}).get('proxmox', {}).get('installer_profile', {}) - deployment['devices'], notes = configure_detector(installer_profile, deployment.get('devices', []), ui) - if notes: - deployment['completion_notes'] = notes - from .extra_devices import ask_extra_devices - reference = candidate.get('template', {}).get('container_contract', {}).get('image', {}).get('reference', '') - repository = reference.split('@')[0].rsplit(':', 1)[0] - deployment['devices'] = ask_extra_devices( - ui, deployment.get('devices', []), deployment.get('security', {}).get('unprivileged', True), - allow_coral=repository in ('ghcr.io/blakeblackshear/frigate', 'codeproject/ai-server', 'docker.io/codeproject/ai-server')) - from .extra_devices import device_permissions - deployment['device_permissions'] = device_permissions( - reference, deployment['devices'], deployment.get('device_permissions')) - edit_environment(deployment, ui) - proposal = {'operation': 'recreate', 'candidate': candidate} - if record.get('observed', {}).get('config_sha256'): - proposal['base_config_sha256'] = record['observed']['config_sha256'] - return proposal + elif setting == 'paths': + from .custom_mounts import ask_custom_mounts + deployment['mounts'] = ask_custom_mounts(ui, deployment['mounts'], deployment['rootfs']['storage']) + elif setting == 'acceleration': + edit_acceleration(candidate, ui, confirm=False) + elif setting == 'detector': + from .installer import configure_detector + deployment['devices'], notes = configure_detector(profile, deployment.get('devices', []), ui) + if notes: + deployment['completion_notes'] = notes + elif setting == 'devices': + from .extra_devices import ask_extra_devices + reference = template.get('container_contract', {}).get('image', {}).get('reference', '') + repository = reference.split('@')[0].rsplit(':', 1)[0] + deployment['devices'] = ask_extra_devices( + ui, deployment.get('devices', []), deployment.get('security', {}).get('unprivileged', True), + allow_coral=repository in ('ghcr.io/blakeblackshear/frigate', 'codeproject/ai-server', 'docker.io/codeproject/ai-server')) + elif setting == 'environment': + edit_environment(deployment, ui) + elif setting == 'catalog': + refresh_template(candidate, ui) + else: + raise ValueError(setting) + if setting in ('acceleration', 'detector', 'devices'): + from .extra_devices import device_permissions + reference = candidate.get('template', {}).get('container_contract', {}).get('image', {}).get('reference', '') + permissions = device_permissions(reference, deployment.get('devices', []), deployment.get('device_permissions')) + if permissions is not None or 'device_permissions' in deployment: + deployment['device_permissions'] = permissions + + +def edit_section(candidate, ui, setting): + """A cancelled or invalid section cannot leave a half-edited draft.""" + wizard = BacktrackUI(ui) + try: + while True: + edited = copy.deepcopy(candidate) + try: + edit_setting(edited, wizard, setting) + return edited + except RestartWizard: + wizard.restart() + except UserCancelled: + return candidate + except ValueError as error: + if not wizard.retry_last(error): + ui.message(str(error), translate('Modify OCI')) + return candidate + finally: + wizard.close() + + +def edit_recreation(record, ui): + """Keep a draft across independently editable settings; apply it once.""" + from .cli import _deployment_summary_text + candidate = copy.deepcopy(record) + selected = 'cores' + while True: + pending = candidate != record + status = translate('Unapplied changes') if pending else translate('No pending changes') + text = (f"CT {record.get('vmid', '-')}: {candidate['deployment'].get('hostname', '')}\n" + f"{status}\n\n" + + translate('Choose a setting. Changes are applied only after the final review.')) + selected = ui.choose(text, modification_options(candidate), selected, + title=translate('Modify OCI'), show_tags=False) + if selected is None or selected == 'discard': + if not pending or ui.confirm(translate('Discard unapplied changes?'), False): + return None + selected = 'apply' + elif selected == 'apply': + if not pending: + ui.message(translate('Nothing was changed.'), translate('Modify OCI')) + return None + if ui.review(_deployment_summary_text(candidate['template'], candidate['deployment']), + translate('Modify OCI'), question=translate('Apply these changes?'), default=True): + proposal = {'operation': 'recreate', 'candidate': candidate} + if record.get('observed', {}).get('config_sha256'): + proposal['base_config_sha256'] = record['observed']['config_sha256'] + return proposal + else: + candidate = edit_section(candidate, ui, selected) def refresh_template(candidate, ui): diff --git a/oci/src/proxmenux_oci/registry_accounts.py b/oci/src/proxmenux_oci/registry_accounts.py index f500520c..2ad9791d 100644 --- a/oci/src/proxmenux_oci/registry_accounts.py +++ b/oci/src/proxmenux_oci/registry_accounts.py @@ -166,7 +166,7 @@ def registry_accounts_menu(ui, engine=ENGINE_ROOT, auth=None): return try: options = [('add', translate('Sign in to a registry'))] - options += [(f'account-{index}', f"{row['registry']:<28} {row['account'] or row['method']} [{translate('saved')}]") + options += [(f'account-{index}', f"{row['registry']:<28} [{translate('saved')}]") for index, row in enumerate(rows)] options += [('storage', translate('Credential storage and access'))] count = translate('{count} saved account.') if len(rows) == 1 else translate('{count} saved accounts.') diff --git a/oci/src/proxmenux_oci/ui.py b/oci/src/proxmenux_oci/ui.py index 8238d3c2..6ff5955f 100644 --- a/oci/src/proxmenux_oci/ui.py +++ b/oci/src/proxmenux_oci/ui.py @@ -221,6 +221,10 @@ class DialogUI: def _run(self, widget: list[str], title: str | None = None) -> subprocess.CompletedProcess[str]: environment = os.environ.copy() + # ncurses otherwise waits a full second to distinguish Escape from + # the prefix of a cursor/function key. Keep a short sequence window, + # while respecting an explicit terminal-specific setting. + environment.setdefault("ESCDELAY", "150") if environment.get("TERM", "").casefold() in {"", "dumb", "unknown"}: environment["TERM"] = "xterm-256color" # dialog draws on the terminal and writes the selection to stderr. diff --git a/oci/tests/test_carried_config_cache.py b/oci/tests/test_carried_config_cache.py new file mode 100644 index 00000000..026a3598 --- /dev/null +++ b/oci/tests/test_carried_config_cache.py @@ -0,0 +1,134 @@ +"""A fast Manage entry still detects config edits and verifies lifecycle work.""" +import contextlib +import copy +import json +import os +from pathlib import Path +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'remote')) +import oci_carried_record as carried + + +class CarriedConfigCacheTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) / 'instances' + self.source = Path(self.tmp.name) / 'lxc' + self.source.mkdir() + self.config = self.source / '120.conf' + self.config.write_text('# proxmenux-instance=example\nunprivileged: 1\n') + (self.root / '120').mkdir(parents=True) + self.cache = self.root / '120' / carried.CONFIG_CACHE + self.patch = patch.object(carried, 'LOCAL_CONFIGS', self.source) + self.patch.start() + self.addCleanup(self.patch.stop) + + def response(self, text='description: proxmenux-instance=example\nunprivileged: 1\n', status=0): + return SimpleNamespace(returncode=status, stdout=text) + + def test_warm_reads_avoid_pct_and_cache_is_private(self): + with patch.object(carried, '_run', return_value=self.response()) as run: + first = carried.configuration(self.root, 120) + self.assertEqual(carried.configuration(self.root, 120), first) + run.assert_called_once_with('pct', 'config', '120') + self.assertEqual(self.cache.stat().st_mode & 0o777, 0o600) + + def test_native_edits_invalidate_even_when_size_and_mtime_are_restored(self): + with patch.object(carried, '_run', side_effect=[self.response(), self.response('new')]) as run: + carried.configuration(self.root, 120) + info = self.config.stat() + self.config.write_text(self.config.read_text().replace('example', 'changed')) + os.utime(self.config, ns=(info.st_atime_ns, info.st_mtime_ns)) + self.assertEqual(carried.configuration(self.root, 120), 'new') + self.assertEqual(run.call_count, 2) + + def test_verify_forces_a_fresh_pct_read_with_a_warm_cache(self): + with patch.object(carried, '_run', side_effect=[self.response(), self.response('verified')]) as run: + carried.configuration(self.root, 120) + self.assertEqual(carried.configuration(self.root, 120, verify=True), 'verified') + self.assertEqual(run.call_count, 2) + + def test_missing_native_source_never_uses_or_populates_a_cache(self): + self.config.unlink() + with patch.object(carried, '_run', return_value=self.response()) as run: + carried.configuration(self.root, 120) + carried.configuration(self.root, 120) + self.assertEqual(run.call_count, 2) + self.assertFalse(self.cache.exists()) + + def test_concurrent_edit_does_not_cache_output_for_the_wrong_revision(self): + def command(*args): + self.config.write_text('changed while pct was reading\n') + return self.response() + with patch.object(carried, '_run', side_effect=command): + carried.configuration(self.root, 120) + self.assertFalse(self.cache.exists()) + + def test_edit_during_cache_lookup_falls_back_to_pct(self): + with patch.object(carried, '_run', return_value=self.response()): + carried.configuration(self.root, 120) + read_text = Path.read_text + def edit_during_read(path, *args, **kwargs): + text = read_text(path, *args, **kwargs) + if path == self.cache: + self.config.write_text('changed while reading the cache\n') + return text + with patch.object(Path, 'read_text', edit_during_read), \ + patch.object(carried, '_run', return_value=self.response('fresh')) as run: + self.assertEqual(carried.configuration(self.root, 120), 'fresh') + run.assert_called_once_with('pct', 'config', '120') + + def test_corrupt_cache_and_pct_failure_are_not_accepted(self): + for text in ('not json', '[]', '{"schema_version": 999}'): + with self.subTest(cache=text): + self.cache.write_text(text) + with patch.object(carried, '_run', return_value=self.response(status=1)) as run: + self.assertIsNone(carried.configuration(self.root, 120)) + run.assert_called_once() + self.assertEqual(self.cache.read_text(), text) + + def test_symlink_cache_does_not_read_or_replace_its_target(self): + target = Path(self.tmp.name) / 'outside' + target.write_text('private original') + self.cache.symlink_to(target) + with patch.object(carried, '_run', return_value=self.response()) as run: + self.assertEqual(carried.configuration(self.root, 120), self.response().stdout) + run.assert_called_once() + self.assertTrue(self.cache.is_symlink()) + self.assertEqual(target.read_text(), 'private original') + + def test_cached_config_still_detects_a_restored_container_record(self): + installation = '488ed3cb-1145-477a-b90f-c46777d69fb2' + record = {'schema_version': 1, 'vmid': 120, 'installation_id': installation, + 'status': 'installed', 'deployment': {'hostname': 'current'}} + carried.instances.write(carried.instances.location(self.root, 120), record) + native = f'description: proxmenux-instance={installation}\n' + with patch.object(carried, '_run', return_value=self.response(native)): + carried.configuration(self.root, 120) + rootfs = Path(self.tmp.name) / 'rootfs' + rootfs.mkdir() + previous = copy.deepcopy(record) + previous['deployment']['hostname'] = 'restored-snapshot' + carried.write_copy(rootfs, {'kind': carried.KIND, 'schema_version': 1, + 'record': previous, 'generation': 'restored-generation'}, + (os.getuid(), os.getgid())) + stamp = self.root / '120' / carried.STAMP + stamp.write_text(json.dumps({'generation': 'host-generation'})) + with patch.object(carried, '_run', side_effect=AssertionError('cached config expected')), \ + patch.object(carried, 'read_cluster', return_value=None), \ + patch.object(carried, 'running_pid', return_value=123), \ + patch.object(carried, 'container_root', return_value=contextlib.nullcontext(rootfs)): + self.assertEqual(carried.carry(self.root, 120, mount_stopped=False), 'stale') + self.assertFalse(carried.instances.location(self.root, 120).exists()) + self.assertTrue((self.root / '120' / f'retired-{installation}.json').exists()) + + +if __name__ == '__main__': + unittest.main() diff --git a/oci/tests/test_dialog_escape.py b/oci/tests/test_dialog_escape.py new file mode 100644 index 00000000..f5063878 --- /dev/null +++ b/oci/tests/test_dialog_escape.py @@ -0,0 +1,34 @@ +"""Escape delay is scoped to dialog and preserves terminal-specific overrides.""" +import os +from pathlib import Path +import subprocess +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src')) +from proxmenux_oci.ui import DialogUI, UserCancelled + + +class DialogEscapeTests(unittest.TestCase): + def test_escape_uses_short_delay_without_changing_parent_environment(self): + with patch.dict(os.environ, {'TERM': 'xterm'}, clear=True), \ + patch('proxmenux_oci.ui.subprocess.run', return_value=subprocess.CompletedProcess([], 255, stderr='')) as run: + self.assertIsNone(DialogUI().choose('Menu', [('1', 'First')])) + self.assertEqual(run.call_args.kwargs['env']['ESCDELAY'], '150') + self.assertNotIn('ESCDELAY', os.environ) + + def test_explicit_delay_is_preserved(self): + with patch.dict(os.environ, {'ESCDELAY': '900'}), \ + patch('proxmenux_oci.ui.subprocess.run', return_value=subprocess.CompletedProcess([], 0, stderr='1')) as run: + self.assertEqual(DialogUI().choose('Menu', [('1', 'First')]), '1') + self.assertEqual(run.call_args.kwargs['env']['ESCDELAY'], '900') + + def test_escape_in_search_input_keeps_cancellation_behavior(self): + with patch('proxmenux_oci.ui.subprocess.run', return_value=subprocess.CompletedProcess([], 255, stderr='')): + with self.assertRaises(UserCancelled): + DialogUI().ask('Search') + + +if __name__ == '__main__': + unittest.main() diff --git a/oci/tests/test_external_tags.py b/oci/tests/test_external_tags.py new file mode 100644 index 00000000..0f09c5e3 --- /dev/null +++ b/oci/tests/test_external_tags.py @@ -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() diff --git a/oci/tests/test_guessed_endpoint_healthcheck.py b/oci/tests/test_guessed_endpoint_healthcheck.py new file mode 100644 index 00000000..8ce9003f --- /dev/null +++ b/oci/tests/test_guessed_endpoint_healthcheck.py @@ -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() diff --git a/oci/tests/test_manage_sync_notice.py b/oci/tests/test_manage_sync_notice.py new file mode 100644 index 00000000..ede909b5 --- /dev/null +++ b/oci/tests/test_manage_sync_notice.py @@ -0,0 +1,34 @@ +"""Opening Manage shows the sync notice only while some configuration is still unread.""" +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'src')) +sys.path.insert(0, str(ROOT / 'remote')) + +from proxmenux_oci import management +import oci_carried_record as carried + + +class ManageSyncNoticeTests(unittest.TestCase): + def test_notice_only_while_an_installed_application_has_no_cache(self): + rows = [{'vmid': 101, 'status': 'installed', 'pending': False}, + {'vmid': 102, 'status': 'installed', 'pending': True}, + {'vmid': 103, 'status': 'failed', 'pending': False}] + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with patch.object(carried.instances, 'ROOT', root), \ + patch.object(management, 'saved_inventory', return_value=rows): + self.assertTrue(management._records_unread(ROOT)) + cache = carried.instances.location(root, 101).parent / carried.CONFIG_CACHE + cache.parent.mkdir(parents=True) + cache.write_text('{}') + # The pending and failed applications are never cached by the sync. + self.assertFalse(management._records_unread(ROOT)) + + +if __name__ == '__main__': + unittest.main() diff --git a/oci/tests/test_menu_performance.py b/oci/tests/test_menu_performance.py new file mode 100644 index 00000000..94981ceb --- /dev/null +++ b/oci/tests/test_menu_performance.py @@ -0,0 +1,109 @@ +"""Menu speedups avoid unused probes, retaining confirmation and capacity checks.""" +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import Mock,patch +ROOT=Path(__file__).resolve().parents[1] +sys.path[:0]=[str(ROOT/'src'),str(ROOT/'remote')] +from proxmenux_oci import management as menu +import oci_instances as instances +import oci_instance_reconcile as reconcile +import oci_remove as remover +import oci_work_backup as backup + + +class UpdateEntryTests(unittest.TestCase): + def run_update(self,approved,ready): + events=[] + record={'status':'installed','vmid':110,'deployment':{},'template':{}} + row={'vmid':110,'reason':'matched','stack':False,'pending':False,'status':'installed'} + ui=Mock();ui.unattended=True + ui.review.side_effect=lambda *a,**kw:events.append('review') or approved + with patch.object(menu,'carry_records',return_value={}) as carry, \ + patch.object(menu,'check_selected',return_value=row), \ + patch.object(menu,'_selected_configuration',return_value=b'current config'), \ + patch.object(instances,'read',return_value=record),patch.object(reconcile,'propose',return_value=None), \ + patch.object(menu,'_work_backup_ready',side_effect=lambda *a:events.append('backup') or ready), \ + patch.object(menu,'_run_lifecycle',side_effect=lambda *a:events.append('lifecycle') or True): + result=menu.manage_instance(ROOT,ui,row,action='update') + carry.assert_called_once_with(ROOT,vmids=[110],verify=True) + return result,events + + def test_declining_update_does_not_probe_disks_or_storages(self): + result,events=self.run_update(False,False) + self.assertFalse(result);self.assertEqual(events,['review']) + + def test_accepted_update_checks_backup_space_before_starting(self): + result,events=self.run_update(True,True) + self.assertTrue(result);self.assertEqual(events,['review','backup','lifecycle']) + + def test_insufficient_space_still_prevents_the_update(self): + result,events=self.run_update(True,False) + self.assertFalse(result);self.assertEqual(events,['review','backup']) + + +class BackupDestinationTests(unittest.TestCase): + def status(self,local_free,saved_free=None,only_needed=True): + with tempfile.TemporaryDirectory() as directory: + root=Path(directory);saved= root/'nas';saved.mkdir() + record={'deployment':{backup.KEY:'nas'}} + with patch.object(backup,'application',return_value=(110,[110,111])), \ + patch.object(instances,'read',return_value=record), \ + patch.object(backup,'required',return_value=100) as required, \ + patch.object(backup,'available',side_effect=lambda p:saved_free if Path(p)==saved else local_free), \ + patch.object(backup,'storage_path',return_value=saved if saved_free is not None else None) as path, \ + patch.object(backup,'file_storages',return_value=[{'storage':'enough','free':200},{'storage':'small','free':50}]) as alternatives: + result=backup.status(root,110,only_needed=only_needed) + required.assert_called_once_with([110,111]) + return result,path.call_count,alternatives.call_count + + def test_local_room_avoids_saved_storage_and_alternative_queries(self): + result,saved,alternatives=self.status(200,saved_free=1000) + self.assertTrue(result['fits']);self.assertEqual((saved,alternatives),(0,0)) + + def test_saved_destination_that_fits_avoids_alternative_queries(self): + result,saved,alternatives=self.status(50,saved_free=200) + self.assertTrue(result['saved_fits']);self.assertEqual((saved,alternatives),(1,0)) + + def test_when_neither_fits_all_available_destinations_are_checked(self): + result,saved,alternatives=self.status(50,saved_free=40) + self.assertFalse(result['fits']);self.assertFalse(result['saved_fits']) + self.assertEqual(result['candidates'],[{'storage':'enough','free':200}]) + self.assertEqual(alternatives,1) + + def test_missing_saved_storage_still_offers_alternatives(self): + result,saved,alternatives=self.status(50) + self.assertEqual(result['candidates'],[{'storage':'enough','free':200}]) + self.assertEqual(alternatives,1) + + def test_complete_status_keeps_legacy_fields_and_all_candidates(self): + result,saved,alternatives=self.status(200,saved_free=300,only_needed=False) + self.assertTrue(result['fits']);self.assertTrue(result['saved_fits']) + self.assertEqual((saved,alternatives),(1,1)) + self.assertEqual(result['candidates'],[{'storage':'enough','free':200}]) + + +class RemovalPreviewTests(unittest.TestCase): + def preview(self,reader): + record={'vmid':110,'deployment':{},'template':{'catalog_ui':{'title':'Example'}}} + with patch.object(remover,'members_of',return_value=(110,record,[110])), \ + patch.object(instances,'read',return_value=record), \ + patch.object(menu,'_selected_configuration',side_effect=reader), \ + patch.object(remover,'guest_config',side_effect=AssertionError('Duplicate pct process')), \ + patch.object(remover,'host_directories',return_value=[]), \ + patch.object(remover,'private_bridge',return_value=None): + return menu._removal_summary(ROOT,110) + + def test_preview_reuses_verified_config_and_keeps_volume_and_host_path_details(self): + config=b'hostname: example\nrootfs: local:subvol-110-disk-0,size=8G\nmp0: /mnt/media,mp=/media,backup=0\n' + text=self.preview(lambda v:config) + self.assertIn('8G',text);self.assertIn('/mnt/media',text) + self.assertIn('not targeted for removal',text) + + def test_missing_guest_can_still_be_reviewed_without_a_second_probe(self): + text=self.preview(lambda v:(_ for _ in ()).throw(RuntimeError('guest missing'))) + self.assertIn('CT 110',text);self.assertIn('rootfs -',text) + + +if __name__=='__main__':unittest.main() diff --git a/oci/tests/test_modify_latency.py b/oci/tests/test_modify_latency.py new file mode 100644 index 00000000..f08359a5 --- /dev/null +++ b/oci/tests/test_modify_latency.py @@ -0,0 +1,103 @@ +"""Opening Modify avoids backup probes while preserving operation safeguards.""" +from pathlib import Path +import sys +import unittest +from unittest.mock import Mock,patch +ROOT=Path(__file__).resolve().parents[1] +sys.path[:0]=[str(ROOT/'src'),str(ROOT/'remote')] +from proxmenux_oci import management as menu,recreation +import oci_carried_record as carried +import oci_instances as instances +import oci_instance_reconcile as reconcile +from proxmenux_oci.ui import UserCancelled + + +def record(): + return {'vmid': 115, 'installation_id': 'test-installation', 'status': 'installed', + 'observed': {'config_sha256': 'native-config-fingerprint'}, + 'template': {'id': 'example-app', 'catalog_ui': {'title': 'Example'}, + 'container_contract': {'image': {'reference': 'example/app:latest'}}, + 'proxmox': {'installer_profile': {}}}, + 'deployment': {'vmid': 115, 'hostname': 'example', 'resources': {'cores': 2, 'memory_mb': 512}, + 'network': {'bridge': 'vmbr0', 'ipv4': 'dhcp', 'gateway': None}, + 'rootfs': {'storage': 'local-zfs', 'size_gb': 8}, 'mounts': [ + {'container_path': '/data', 'source': 'local-zfs', + 'type': 'managed-volume', 'size_gb': 8, 'backup': True}], + 'devices': [], 'security': {'unprivileged': True}, + 'environment': [{'name': 'PASSWORD', 'value': 'private-test-secret', + 'sensitive': True}]}} + + +class SelectedConfigTests(unittest.TestCase): + def test_valid_source_keyed_cache_avoids_another_pct_process(self): + with patch.object(carried,'configuration',create=True,return_value='description: current\n') as reader, \ + patch.object(instances,'command') as command: + self.assertEqual(menu._selected_configuration(115),b'description: current\n') + reader.assert_called_once_with(instances.ROOT,115) + command.assert_not_called() + + def test_old_or_unavailable_cache_falls_back_to_pct(self): + for reader in (None,Mock(return_value=None),Mock(return_value={}),Mock(side_effect=OSError('cache unavailable'))): + with self.subTest(reader=reader),patch.object(carried,'configuration',reader,create=True), \ + patch.object(instances,'command',return_value=b'live config') as command: + self.assertEqual(menu._selected_configuration(115),b'live config') + command.assert_called_once_with('pct','config','115') + + def test_identity_check_is_still_performed_on_the_selected_config(self): + r=record();r['installation_id']='11111111-1111-4111-8111-111111111111' + import contextlib + with patch.object(instances,'locked',return_value=contextlib.nullcontext()), \ + patch.object(instances,'read',return_value=r), \ + patch.object(menu,'_selected_configuration',return_value=b'description: another container\n'): + self.assertEqual(menu.check_selected(ROOT,{'vmid':115})['reason'],'identity-unconfirmed') + + +class EditorLatencyTests(unittest.TestCase): + def run_editor(self,proposal=None,backup_ready=True,stale=False,adoption_error=False,approved=True,declined=False): + events=[];saved=record() + row={'vmid':115,'reason':'matched','stack':False,'pending':False,'status':'installed'} + def edit(*a,**kw): + events.append('editor') + if declined:return None + if proposal is None:raise UserCancelled() + return proposal + def backup(*a,**kw):events.append('backup');return backup_ready + ui=Mock();ui.unattended=True;ui.review.return_value=approved + with patch.object(menu,'carry_records',return_value={115:'stale'} if stale else {}) as verify, \ + patch.object(menu,'check_selected',return_value=row), \ + patch.object(menu,'_selected_configuration',return_value=b'current config'), \ + patch.object(instances,'read',return_value=saved), \ + patch.object(reconcile,'propose',side_effect=ValueError('external changes') if adoption_error else None,return_value=None), \ + patch.object(recreation,'edit_recreation',side_effect=edit), \ + patch.object(menu,'_work_backup_ready',side_effect=backup), \ + patch.object(menu,'_run_lifecycle',side_effect=lambda *a:events.append('lifecycle') or True): + try:result=menu.manage_instance(ROOT,ui,row,action='modify') + except UserCancelled:result=False + verify.assert_called_once_with(ROOT,vmids=[115],verify=True) + return result,events + + def test_open_and_cancel_has_no_backup_or_storage_probes(self): + result,events=self.run_editor() + self.assertFalse(result);self.assertEqual(events,['editor']) + + def test_declining_the_review_has_no_backup_or_storage_probes(self): + # The review is part of the Modify menu: declining it and leaving the + # menu returns no proposal, so no probe or operation follows. + result,events=self.run_editor(declined=True) + self.assertFalse(result);self.assertEqual(events,['editor']) + + def test_confirmed_edit_keeps_backup_check_before_the_lifecycle(self): + result,events=self.run_editor({'operation':'recreate','candidate':record()}) + self.assertTrue(result);self.assertEqual(events,['editor','backup','lifecycle']) + + def test_missing_backup_space_still_prevents_the_lifecycle(self): + result,events=self.run_editor({'operation':'recreate','candidate':record()},backup_ready=False) + self.assertFalse(result);self.assertEqual(events,['editor','backup']) + + def test_restored_or_conflicting_instance_cannot_enter_editor(self): + for options in ({'stale':True},{'adoption_error':True}): + result,events=self.run_editor(**options) + self.assertFalse(result);self.assertEqual(events,[]) + + +if __name__=='__main__':unittest.main() diff --git a/oci/tests/test_modify_menu.py b/oci/tests/test_modify_menu.py new file mode 100644 index 00000000..601c588e --- /dev/null +++ b/oci/tests/test_modify_menu.py @@ -0,0 +1,208 @@ +"""Selective OCI edits stay in a draft until reviewed, then use one transaction.""" +import copy +import json +from pathlib import Path +import stat +import sys +import unittest +from unittest.mock import Mock, patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'src')) +sys.path.insert(0, str(ROOT / 'remote')) + +from proxmenux_oci import management, recreation +from proxmenux_oci.ui import BackRequested, UserCancelled + + +def record(): + return {'vmid': 115, 'installation_id': 'test-installation', 'status': 'installed', + 'observed': {'config_sha256': 'native-config-fingerprint'}, + 'template': {'id': 'example-app', 'catalog_ui': {'title': 'Example'}, + 'container_contract': {'image': {'reference': 'example/app:latest'}}, + 'proxmox': {'installer_profile': {}}}, + 'deployment': {'vmid': 115, 'hostname': 'example', 'resources': {'cores': 2, 'memory_mb': 512}, + 'network': {'bridge': 'vmbr0', 'ipv4': 'dhcp', 'gateway': None}, + 'rootfs': {'storage': 'local-zfs', 'size_gb': 8}, 'mounts': [ + {'container_path': '/data', 'source': 'local-zfs', + 'type': 'managed-volume', 'size_gb': 8, 'backup': True}], + 'devices': [], 'security': {'unprivileged': True}, + 'environment': [{'name': 'PASSWORD', 'value': 'private-test-secret', + 'sensitive': True}]}} + + +class MenuUI: + def __init__(self, choices, answers=(), reviews=(True,), confirms=()): + self.choices, self.answers = iter(choices), iter(answers) + self.reviews, self.confirms = iter(reviews), iter(confirms) + self.events = [] + self.back_enabled = False + + def choose(self, text, options, default=None, **kwargs): + self.events.append(('menu', text, dict(options))) + value = next(self.choices) + if value is not None: + assert value in dict(options), value + return value + + def ask(self, text, default=None, **kwargs): + self.events.append(('ask', text, default)) + value = next(self.answers) + if isinstance(value, Exception): + raise value + return value + + def confirm(self, text, default=False): + self.events.append(('confirm', text)) + return next(self.confirms) + + def review(self, text, *args, **kwargs): + self.events.append(('review', text)) + return next(self.reviews) + + def message(self, text, *args): + self.events.append(('message', text)) + + +@patch('proxmenux_oci.i18n.language', return_value='en') +class ModifyMenuTests(unittest.TestCase): + def test_ram_only_does_not_ask_about_cpu_network_paths_devices_or_catalog(self, _language): + original = record() + saved = copy.deepcopy(original) + ui = MenuUI(['memory', 'apply'], ['768']) + with patch.object(recreation, 'refresh_template') as refresh: + result = recreation.edit_recreation(original, ui) + self.assertEqual(result['candidate']['deployment']['resources'], {'cores': 2, 'memory_mb': 768}) + self.assertEqual([e[1] for e in ui.events if e[0] == 'ask'], ['RAM in MiB']) + self.assertEqual(result['base_config_sha256'], 'native-config-fingerprint') + self.assertEqual(original, saved) + refresh.assert_not_called() + self.assertFalse(ui.back_enabled) + + def test_several_sections_and_revisits_keep_the_current_draft(self, _language): + ui = MenuUI(['memory', 'cores', 'memory', 'apply'], ['768', '3', '1024']) + result = recreation.edit_recreation(record(), ui) + self.assertEqual(result['candidate']['deployment']['resources'], {'cores': 3, 'memory_mb': 1024}) + self.assertEqual([e[2] for e in ui.events if e[0] == 'ask'], ['512', '2', '768']) + self.assertEqual(sum(e[0] == 'review' for e in ui.events), 1) + + def test_declining_review_returns_to_menu_without_losing_edits(self, _language): + ui = MenuUI(['cores', 'apply', 'memory', 'apply'], ['3', '768'], reviews=[False, True]) + result = recreation.edit_recreation(record(), ui) + self.assertEqual(result['candidate']['deployment']['resources'], {'cores': 3, 'memory_mb': 768}) + self.assertEqual(sum(e[0] == 'review' for e in ui.events), 2) + + def test_unchanged_apply_does_not_propose_recreating_the_guest(self, _language): + ui = MenuUI(['memory', 'apply'], ['512']) + self.assertIsNone(recreation.edit_recreation(record(), ui)) + self.assertFalse(any(e[0] == 'review' for e in ui.events)) + self.assertIn(('message', 'Nothing was changed.'), ui.events) + + def test_discard_leaves_the_saved_record_untouched(self, _language): + original = record() + saved = copy.deepcopy(original) + ui = MenuUI(['memory', 'discard'], ['768'], confirms=[True]) + self.assertIsNone(recreation.edit_recreation(original, ui)) + self.assertEqual(original, saved) + self.assertFalse(any(e[0] == 'review' for e in ui.events)) + + def test_declining_discard_preserves_the_draft(self, _language): + ui = MenuUI(['memory', None, 'apply'], ['768'], confirms=[False]) + result = recreation.edit_recreation(record(), ui) + self.assertEqual(result['candidate']['deployment']['resources']['memory_mb'], 768) + + def test_cancel_without_changes_asks_nothing_else(self, _language): + ui = MenuUI([None]) + self.assertIsNone(recreation.edit_recreation(record(), ui)) + self.assertEqual(len(ui.events), 1) + + def test_invalid_ram_is_retried_without_restarting_other_sections(self, _language): + ui = MenuUI(['cores', 'memory', 'apply'], ['3', 'invalid', '64', '768']) + result = recreation.edit_recreation(record(), ui) + self.assertEqual(result['candidate']['deployment']['resources'], {'cores': 3, 'memory_mb': 768}) + self.assertEqual(sum(e[0] == 'message' for e in ui.events), 2) + self.assertEqual(sum(e[:2] == ('ask', 'Cores') for e in ui.events), 1) + + def test_back_from_first_field_returns_to_menu_and_keeps_other_edits(self, _language): + ui = MenuUI(['cores', 'memory', 'apply'], ['3', BackRequested()]) + result = recreation.edit_recreation(record(), ui) + self.assertEqual(result['candidate']['deployment']['resources'], {'cores': 3, 'memory_mb': 512}) + self.assertFalse(ui.back_enabled) + + def test_cancelling_a_partly_edited_section_rolls_back_only_that_section(self, _language): + real_edit = recreation.edit_setting + def edit(candidate, ui, setting): + if setting == 'environment': + candidate['deployment']['environment'][0]['value'] = 'partial-value' + raise UserCancelled() + real_edit(candidate, ui, setting) + ui = MenuUI(['cores', 'environment', 'apply'], ['3']) + with patch.object(recreation, 'edit_setting', side_effect=edit): + result = recreation.edit_recreation(record(), ui) + self.assertEqual(result['candidate']['deployment']['resources']['cores'], 3) + self.assertEqual(result['candidate']['deployment']['environment'], record()['deployment']['environment']) + + def test_menu_and_review_do_not_expose_a_saved_secret(self, _language): + ui = MenuUI(['cores', 'apply'], ['3']) + recreation.edit_recreation(record(), ui) + self.assertNotIn('private-test-secret', json.dumps(ui.events)) + + def test_extra_paths_counts_only_paths_added_by_hand(self, _language): + candidate = record() + self.assertTrue(dict(recreation.modification_options(candidate))['paths'].rstrip().endswith(' 0')) + candidate['deployment']['mounts'].append({'container_path': '/media-extra', 'type': 'host-bind', + 'source': '/mnt/media', 'custom': True}) + self.assertTrue(dict(recreation.modification_options(candidate))['paths'].rstrip().endswith(' 1')) + + def test_optional_categories_follow_the_application_profile(self, _language): + candidate = record() + options = dict(recreation.modification_options(candidate)) + self.assertTrue({'cores', 'memory', 'network', 'paths', 'devices', 'environment', 'catalog', 'apply'} <= options.keys()) + self.assertFalse({'shares', 'acceleration', 'detector'} & options.keys()) + candidate['template']['proxmox']['installer_profile'] = { + 'shared_directories': {'base_path': '/shares'}, 'object_detector': {'devices': []}, + 'hardware_acceleration': {'profiles': [{'id': 'intel', 'label': 'Intel GPU'}]}} + candidate['deployment']['hardware_profile'] = 'intel' + options = dict(recreation.modification_options(candidate)) + self.assertTrue({'shares', 'acceleration', 'detector'} <= options.keys()) + self.assertIn('Intel GPU', options['acceleration']) + + +class ModifyDispatchTests(unittest.TestCase): + def call_manager(self, proposal, lifecycle): + import oci_instances + import oci_instance_reconcile + saved = record() + row = {'vmid': 115, 'reason': 'matched', 'stack': False, 'pending': False, 'status': 'installed'} + with patch.object(management, 'carry_records', return_value={}), \ + patch.object(management, 'check_selected', return_value=row), \ + patch.object(management, '_work_backup_ready', return_value=True), \ + patch.object(oci_instances, 'read', return_value=saved), \ + patch.object(oci_instances, 'command', return_value='hostname: example'), \ + patch.object(oci_instance_reconcile, 'propose', return_value=None), \ + patch.object(recreation, 'edit_recreation', return_value=proposal), \ + patch.object(management, '_run_lifecycle', side_effect=lifecycle): + return management.manage_instance(ROOT, Mock(), row, action='modify') + + def test_cancel_does_not_start_a_lifecycle_transaction(self): + lifecycle = Mock() + self.assertFalse(self.call_manager(None, lifecycle)) + lifecycle.assert_not_called() + + def test_apply_sends_one_private_proposal_to_the_existing_backend(self): + proposal = {'operation': 'recreate', 'candidate': record(), 'base_config_sha256': 'fingerprint'} + seen = [] + def lifecycle(command, title): + path = Path(command[command.index('--proposal') + 1]) + seen.append(json.loads(path.read_text())) + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) + self.assertNotIn('private-test-secret', ' '.join(command)) + self.assertIn('oci_update_current.py', command[1]) + self.assertIn('--acknowledge-external-data', command) + return True + self.assertTrue(self.call_manager(proposal, lifecycle)) + self.assertEqual(seen, [proposal]) + + +if __name__ == '__main__': + unittest.main() diff --git a/oci/tests/test_readonly_volume_preparation.py b/oci/tests/test_readonly_volume_preparation.py new file mode 100644 index 00000000..37f67011 --- /dev/null +++ b/oci/tests/test_readonly_volume_preparation.py @@ -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() diff --git a/oci/tests/test_runtime_home_contract.py b/oci/tests/test_runtime_home_contract.py index c14e8e26..25e9ca9e 100644 --- a/oci/tests/test_runtime_home_contract.py +++ b/oci/tests/test_runtime_home_contract.py @@ -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): diff --git a/oci/tests/test_runtime_home_hardening.py b/oci/tests/test_runtime_home_hardening.py new file mode 100644 index 00000000..6418709d --- /dev/null +++ b/oci/tests/test_runtime_home_hardening.py @@ -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() diff --git a/oci/tests/test_shared_directories.py b/oci/tests/test_shared_directories.py index d8204835..55545374 100644 --- a/oci/tests/test_shared_directories.py +++ b/oci/tests/test_shared_directories.py @@ -43,15 +43,18 @@ class ScriptedUI: def password(self, text, required=True): return self._answer(text, "") - def choose(self, text, options, default=None): + def choose(self, text, options, default=None, **_kwargs): return self._answer(text, default) def confirm(self, text, default=False): return self._answer(text, default) - def message(self, text): + def message(self, text, *_args): pass + def review(self, *_args, **_kwargs): + return True + @patch("proxmenux_oci.i18n.language", return_value="en") @patch("proxmenux_oci.installer.host.storages", side_effect=storages) @@ -223,14 +226,15 @@ class SharedDirectoriesTests(unittest.TestCase): patch("proxmenux_oci.recreation.edit_acceleration"), \ patch("proxmenux_oci.recreation.edit_environment"): ui = ScriptedUI({"Add a directory to share": [True], NAME: ["photos"], + "Choose a setting. Changes are applied only after the final review.": ["shares", "apply"], "Where to store /shares/photos": ["host-bind"], "Host path for /shares/photos": ["/tank/photos"]}) proposal = edit_recreation(record, ui) mounts = [m["container_path"] for m in proposal["candidate"]["deployment"]["mounts"]] self.assertEqual(mounts, ["/config", "/shares/media", "/shares/photos"]) - untouched = edit_recreation(record, ScriptedUI()) - self.assertEqual([m["container_path"] for m in untouched["candidate"]["deployment"]["mounts"]], - ["/config", "/shares/media"]) + untouched = edit_recreation(record, ScriptedUI({ + "Choose a setting. Changes are applied only after the final review.": ["discard"]})) + self.assertIsNone(untouched) self.assertEqual([m["container_path"] for m in plan["mounts"]], ["/config", "/shares/media"]) diff --git a/scripts/global/remove-banner-pve-v3.sh b/scripts/global/remove-banner-pve-v3.sh index 4ddf7af2..92df1028 100644 --- a/scripts/global/remove-banner-pve-v3.sh +++ b/scripts/global/remove-banner-pve-v3.sh @@ -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 diff --git a/scripts/global/repository-functions.sh b/scripts/global/repository-functions.sh index d6114085..7a2f3b86 100644 --- a/scripts/global/repository-functions.sh +++ b/scripts/global/repository-functions.sh @@ -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 +} diff --git a/scripts/global/repository_policy.py b/scripts/global/repository_policy.py index 7e0dca70..95f40d68 100644 --- a/scripts/global/repository_policy.py +++ b/scripts/global/repository_policy.py @@ -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 diff --git a/scripts/global/update-pbs.sh b/scripts/global/update-pbs.sh new file mode 100644 index 00000000..54574676 --- /dev/null +++ b/scripts/global/update-pbs.sh @@ -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 diff --git a/scripts/menus/menu_post_install.sh b/scripts/menus/menu_post_install.sh index 447583e7..d4e63cda 100644 --- a/scripts/menus/menu_post_install.sh +++ b/scripts/menus/menu_post_install.sh @@ -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" ;; diff --git a/scripts/menus/pbs_menu.sh b/scripts/menus/pbs_menu.sh index 86f30ca2..99a8d76c 100644 --- a/scripts/menus/pbs_menu.sh +++ b/scripts/menus/pbs_menu.sh @@ -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 diff --git a/scripts/post_install/auto_post_install.sh b/scripts/post_install/auto_post_install.sh index 25e6289f..83f84a82 100644 --- a/scripts/post_install/auto_post_install.sh +++ b/scripts/post_install/auto_post_install.sh @@ -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 diff --git a/scripts/post_install/customizable_post_install.sh b/scripts/post_install/customizable_post_install.sh index ca60b752..39274146 100644 --- a/scripts/post_install/customizable_post_install.sh +++ b/scripts/post_install/customizable_post_install.sh @@ -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" diff --git a/scripts/post_install/uninstall-tools.sh b/scripts/post_install/uninstall-tools.sh index 63b22fa1..1724f849 100644 --- a/scripts/post_install/uninstall-tools.sh +++ b/scripts/post_install/uninstall-tools.sh @@ -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...")" diff --git a/tests/test_repository_policy_pbs.py b/tests/test_repository_policy_pbs.py new file mode 100644 index 00000000..e0a71d90 --- /dev/null +++ b/tests/test_repository_policy_pbs.py @@ -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() diff --git a/web/app/[locale]/docs/post-install/page.tsx b/web/app/[locale]/docs/post-install/page.tsx index e4bc8304..89332b87 100644 --- a/web/app/[locale]/docs/post-install/page.tsx +++ b/web/app/[locale]/docs/post-install/page.tsx @@ -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) => {chunks} const strong = (chunks: React.ReactNode) => {chunks} @@ -210,6 +212,14 @@ export default async function PostInstallPage({ {t.rich("community.body", { em, code, a: communityAnchor, link: externalRepoLink })}

+

{t("pbs.heading")}

+

{t.rich("pbs.body", { strong })}

+
    + {pbsItems.map((_, i) => ( +
  • {t.rich(`pbs.items.${i}`, { strong, em })}
  • + ))} +
+

{t("related.heading")}

    {relatedItems.map((item) => ( diff --git a/web/messages/en/docs/post-install/index.json b/web/messages/en/docs/post-install/index.json index e6e60c1f..46ab94f8 100644 --- a/web/messages/en/docs/post-install/index.json +++ b/web/messages/en/docs/post-install/index.json @@ -78,6 +78,17 @@ "heading": "Community scripts", "body": "The menu also exposes two third-party scripts from the community-scripts project: Proxmox VE Post Install and Proxmox VE Microcode. These are wrappers that wget | bash the original authors' scripts. They are not maintained by ProxMenux. See External Repositories 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 Settings post-install Proxmox in the PBS menu. Automated, Customizable, Apply Available Updates and Uninstall Optimizations work as on Proxmox VE, with these differences:", + "items": [ + "Repositories and upgrade: 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 Administration > Repositories. With an active subscription, or with a no-subscription or test repository already configured, the sources stay as they are.", + "Customizable list: 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.", + "Subscription banner: the patch applies to the Proxmox Backup Server web interface, and Uninstall Optimizations restores it.", + "Figurine: installs without the Proxmox VE aliases.", + "Community scripts: the section offers Proxmox Backup Server Post Install and Proxmox Backup Server Microcode in place of the Proxmox VE scripts." + ] + }, "related": { "heading": "Related", "items": [ diff --git a/web/messages/es/docs/post-install/index.json b/web/messages/es/docs/post-install/index.json index 05bd570f..699abbdd 100644 --- a/web/messages/es/docs/post-install/index.json +++ b/web/messages/es/docs/post-install/index.json @@ -78,6 +78,17 @@ "heading": "Community scripts", "body": "El menú también expone dos scripts de terceros del proyecto community-scripts: Proxmox VE Post Install y Proxmox VE Microcode. Son wrappers que hacen wget | bash de los scripts de los autores originales. No los mantiene ProxMenux. Mira External Repositories 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 Settings post-install Proxmox en el menú de PBS. Automatizado, Personalizable, Apply Available Updates y Uninstall Optimizations funcionan igual que en Proxmox VE, con estas diferencias:", + "items": [ + "Repositorios y actualización: 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 Administration > Repositories. Con una suscripción activa, o con un repositorio sin suscripción o de pruebas ya configurado, las fuentes se mantienen como están.", + "Lista personalizable: 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.", + "Banner de suscripción: el parche se aplica a la interfaz web de Proxmox Backup Server, y Uninstall Optimizations lo restaura.", + "Figurine: se instala sin los alias de Proxmox VE.", + "Scripts de la comunidad: la sección ofrece Proxmox Backup Server Post Install y Proxmox Backup Server Microcode en lugar de los scripts de Proxmox VE." + ] + }, "related": { "heading": "Relacionado", "items": [