From 2109cf250857b2a1adc862b5fbf7ee52475a1c28 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 4 Aug 2026 17:01:02 +0200 Subject: [PATCH 1/5] feat(monitor): add dashboard i18n infrastructure Add the Monitor dashboard i18n provider, supported language metadata, message catalogs, fallback behavior, and the initial language selector/wiring for the AppImage UI. --- AppImage/app/layout.tsx | 9 +- AppImage/app/page.tsx | 6 +- AppImage/components/about.tsx | 4 +- AppImage/components/auth-setup.tsx | 64 +++++---- AppImage/components/avatar-menu.tsx | 13 +- AppImage/components/login.tsx | 38 ++--- AppImage/components/notification-settings.tsx | 1 + AppImage/components/proxmox-dashboard.tsx | 120 ++++++++-------- AppImage/components/settings.tsx | 76 +++++++++- AppImage/components/sidebar.tsx | 19 +-- AppImage/components/theme-toggle.tsx | 6 +- AppImage/lib/i18n/languages.ts | 39 +++++ AppImage/lib/i18n/provider.tsx | 133 ++++++++++++++++++ AppImage/messages/README.md | 12 ++ AppImage/messages/de/common.json | 3 + AppImage/messages/en/common.json | 132 +++++++++++++++++ AppImage/messages/es/common.json | 3 + AppImage/messages/fr/common.json | 3 + AppImage/messages/it/common.json | 3 + AppImage/messages/pt/common.json | 3 + AppImage/messages/sk/common.json | 132 +++++++++++++++++ AppImage/scripts/notification_manager.py | 2 +- AppImage/scripts/notification_templates.py | 1 + 23 files changed, 686 insertions(+), 136 deletions(-) create mode 100644 AppImage/lib/i18n/languages.ts create mode 100644 AppImage/lib/i18n/provider.tsx create mode 100644 AppImage/messages/README.md create mode 100644 AppImage/messages/de/common.json create mode 100644 AppImage/messages/en/common.json create mode 100644 AppImage/messages/es/common.json create mode 100644 AppImage/messages/fr/common.json create mode 100644 AppImage/messages/it/common.json create mode 100644 AppImage/messages/pt/common.json create mode 100644 AppImage/messages/sk/common.json diff --git a/AppImage/app/layout.tsx b/AppImage/app/layout.tsx index 3b664966..dd48ca8c 100644 --- a/AppImage/app/layout.tsx +++ b/AppImage/app/layout.tsx @@ -5,6 +5,7 @@ import { GeistMono } from "geist/font/mono" import { ThemeProvider } from "../components/theme-provider" import { PwaRegister } from "../components/pwa-register" import { PwaInstallPrompt } from "../components/pwa-install-prompt" +import { I18nProvider } from "../lib/i18n/provider" import { Suspense } from "react" import "./globals.css" @@ -44,9 +45,11 @@ export default function RootLayout({ Loading...}> - - {children} - + + + {children} + + diff --git a/AppImage/app/page.tsx b/AppImage/app/page.tsx index 810f2766..c52df589 100644 --- a/AppImage/app/page.tsx +++ b/AppImage/app/page.tsx @@ -5,8 +5,10 @@ import { ProxmoxDashboard } from "../components/proxmox-dashboard" import { Login } from "../components/login" import { AuthSetup } from "../components/auth-setup" import { getApiUrl } from "../lib/api-config" +import { useT } from "../lib/i18n/provider" export default function Home() { + const t = useT() const [authStatus, setAuthStatus] = useState<{ loading: boolean authEnabled: boolean @@ -113,8 +115,8 @@ export default function Home() {
-
Loading...
-

Connecting to ProxMenux Monitor

+
{t("app.loading")}
+

{t("app.connecting")}

) diff --git a/AppImage/components/about.tsx b/AppImage/components/about.tsx index 86f91ef3..aa6bc4c9 100644 --- a/AppImage/components/about.tsx +++ b/AppImage/components/about.tsx @@ -13,6 +13,7 @@ import { } from "lucide-react" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" import { APP_VERSION } from "./release-notes-modal" +import { useT } from "../lib/i18n/provider" // Issue #191: a dedicated About tab. Centralises project metadata // (version, license, author) and every external link the project @@ -111,6 +112,7 @@ function LinkCard({ row }: { row: LinkRow }) { } export function About() { + const t = useT() return (
{/* Hero — logo, name, version, one-line description. */} @@ -151,7 +153,7 @@ export function About() { const href = isPrerelease ? "https://github.com/MacRimi/ProxMenux/releases" : "https://proxmenux.com/en/changelog" - const label = isPrerelease ? "Release notes" : "Changelog" + const label = isPrerelease ? t("about.releaseNotes") : t("about.changelog") return ( void } export function AuthSetup({ onComplete }: AuthSetupProps) { + const t = useT() const [open, setOpen] = useState(false) const [step, setStep] = useState<"choice" | "setup">("choice") const [username, setUsername] = useState("") @@ -74,7 +76,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { const data = await response.json() if (!response.ok) { - throw new Error(data.error || "Failed to skip authentication") + throw new Error(data.error || t("authSetup.skipFailed")) } if (data.auth_declined) { @@ -86,7 +88,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { onComplete() } catch (err) { console.error("Auth skip error:", err) - setError(err instanceof Error ? err.message : "Failed to save preference") + setError(err instanceof Error ? err.message : t("authSetup.savePreferenceFailed")) } finally { setLoading(false) } @@ -108,17 +110,17 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { setError("") if (!username || !password) { - setError("Please fill in all fields") + setError(t("authSetup.fillFields")) return } if (password !== confirmPassword) { - setError("Passwords do not match") + setError(t("authSetup.passwordMismatch")) return } if (password.length < 6) { - setError("Password must be at least 6 characters") + setError(t("authSetup.passwordTooShort")) return } @@ -137,7 +139,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { const data = await response.json() if (!response.ok) { - throw new Error(data.error || "Failed to setup authentication") + throw new Error(data.error || t("authSetup.setupFailed")) } if (data.token) { @@ -204,7 +206,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { onComplete() } catch (err) { console.error("Auth setup error:", err) - setError(err instanceof Error ? err.message : "Failed to setup authentication") + setError(err instanceof Error ? err.message : t("authSetup.setupFailed")) } finally { setLoading(false) } @@ -214,7 +216,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { - {step === "choice" ? "Setup Dashboard Protection" : "Create Password"} + {step === "choice" ? t("authSetup.choiceTitle") : t("authSetup.passwordTitle")} {step === "choice" ? (
@@ -222,16 +224,16 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
-

Protect Your Dashboard?

+

{t("authSetup.protectTitle")}

- Add an extra layer of security to protect your Proxmox data when accessing from non-private networks. + {t("authSetup.protectDescription")}

-

You can always enable this later in Settings

+

{t("authSetup.enableLater")}

) : (
@@ -252,8 +254,8 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
-

Setup Authentication

-

Create a username and password to protect your dashboard

+

{t("authSetup.setupTitle")}

+

{t("authSetup.setupDescription")}

{error && ( @@ -266,14 +268,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
setUsername(e.target.value)} className="pl-10 text-base" @@ -285,14 +287,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
setPassword(e.target.value)} className="pl-10 text-base" @@ -312,14 +314,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
setConfirmPassword(e.target.value)} className="pl-10 text-base" @@ -345,19 +347,19 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { setup endpoint returns the JWT. */}

- Profile · optional + {t("authSetup.profileOptional")}

setDisplayName(e.target.value)} maxLength={64} @@ -366,12 +368,12 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { />

- Leave empty to render the username itself. Up to 64 characters. + {t("authSetup.displayNameHint")}

- +
{avatarPreviewUrl ? ( // eslint-disable-next-line @next/next/no-img-element @@ -407,7 +409,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { className="h-7 text-xs" > - {avatarFile ? "Change" : "Choose image"} + {avatarFile ? t("authSetup.change") : t("authSetup.chooseImage")} {avatarFile && ( )}

- PNG, JPEG, WebP or GIF · up to 2 MB · pre-crop square for best results. + {t("authSetup.avatarHint")}

@@ -434,10 +436,10 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
diff --git a/AppImage/components/avatar-menu.tsx b/AppImage/components/avatar-menu.tsx index 7efdffe5..76f0574d 100644 --- a/AppImage/components/avatar-menu.tsx +++ b/AppImage/components/avatar-menu.tsx @@ -11,6 +11,7 @@ import { DropdownMenuTrigger, } from "./ui/dropdown-menu" import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config" +import { useT } from "../lib/i18n/provider" interface AuthStatus { auth_enabled?: boolean @@ -57,6 +58,8 @@ interface AvatarMenuProps { * proper /api/auth/logout that revokes the JWT server-side too. */ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: AvatarMenuProps) { + const t = useT() + // IMPORTANT — all hooks must run unconditionally on every render. The // previous version short-circuited with `if (!auth_enabled) return null` // BEFORE the avatar blob hooks, so the hook count changed between @@ -201,7 +204,7 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
)} {!profile?.display_name && ( -
Signed in
+
{t("account.signedIn")}
)}
@@ -257,13 +260,13 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata {onOpenProfile && ( - View profile + {t("account.viewProfile")} )} {onOpenSecurity && ( - Security + {t("account.security")} )} {(onOpenProfile || onOpenSecurity) && } @@ -272,7 +275,7 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400" > - Sign out + {t("account.signOut")} diff --git a/AppImage/components/login.tsx b/AppImage/components/login.tsx index 74941a46..6bc745ba 100644 --- a/AppImage/components/login.tsx +++ b/AppImage/components/login.tsx @@ -9,6 +9,7 @@ import { Label } from "./ui/label" import { Checkbox } from "./ui/checkbox" import { Lock, User, AlertCircle, Server, Shield, Eye, EyeOff } from "lucide-react" import { getApiUrl } from "../lib/api-config" +import { useT } from "../lib/i18n/provider" import Image from "next/image" interface LoginProps { @@ -16,6 +17,7 @@ interface LoginProps { } export function Login({ onLogin }: LoginProps) { + const t = useT() const [username, setUsername] = useState("") const [password, setPassword] = useState("") const [totpCode, setTotpCode] = useState("") @@ -56,12 +58,12 @@ export function Login({ onLogin }: LoginProps) { setError("") if (!username || !password) { - setError("Please enter username and password") + setError(t("login.missingCredentials")) return } if (requiresTotp && !totpCode) { - setError("Please enter your 2FA code") + setError(t("login.missingTotp")) return } @@ -87,7 +89,7 @@ export function Login({ onLogin }: LoginProps) { } if (!response.ok) { - throw new Error(data.message || "Login failed") + throw new Error(data.message || t("login.loginFailed")) } localStorage.setItem("proxmenux-auth-token", data.token) @@ -107,7 +109,7 @@ export function Login({ onLogin }: LoginProps) { onLogin() } catch (err) { - setError(err instanceof Error ? err.message : "Login failed") + setError(err instanceof Error ? err.message : t("login.loginFailed")) } finally { setLoading(false) } @@ -139,8 +141,8 @@ export function Login({ onLogin }: LoginProps) {
-

ProxMenux Monitor

-

Sign in to access your dashboard

+

{t("app.title")}

+

{t("login.subtitle")}

@@ -157,14 +159,14 @@ export function Login({ onLogin }: LoginProps) { <>
setUsername(e.target.value)} className="pl-10 text-base" @@ -176,14 +178,14 @@ export function Login({ onLogin }: LoginProps) {
setPassword(e.target.value)} className="pl-10 pr-10 text-base" @@ -214,7 +216,7 @@ export function Login({ onLogin }: LoginProps) { disabled={loading} />
@@ -223,14 +225,14 @@ export function Login({ onLogin }: LoginProps) {
-

Two-Factor Authentication

-

Enter the 6-digit code from your authentication app

+

{t("login.twoFactorTitle")}

+

{t("login.twoFactorDescription")}

- You can also use a backup code (format: XXXX-XXXX) + {t("login.backupCodeHint")}

@@ -260,18 +262,18 @@ export function Login({ onLogin }: LoginProps) { }} className="w-full" > - Back to login + {t("login.backToLogin")}
)}
-

ProxMenux Monitor v1.2.4.1-beta

+

{t("login.version")}

) diff --git a/AppImage/components/notification-settings.tsx b/AppImage/components/notification-settings.tsx index 68cfbc14..94a48aa9 100644 --- a/AppImage/components/notification-settings.tsx +++ b/AppImage/components/notification-settings.tsx @@ -212,6 +212,7 @@ const AI_PROVIDERS = [ const AI_LANGUAGES = [ { value: "en", label: "English" }, + { value: "sk", label: "Slovenčina" }, { value: "es", label: "Espanol" }, { value: "fr", label: "Francais" }, { value: "de", label: "Deutsch" }, diff --git a/AppImage/components/proxmox-dashboard.tsx b/AppImage/components/proxmox-dashboard.tsx index 1959bc58..034da150 100644 --- a/AppImage/components/proxmox-dashboard.tsx +++ b/AppImage/components/proxmox-dashboard.tsx @@ -51,6 +51,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "./ui/dropdown-menu" +import { useT } from "../lib/i18n/provider" interface SystemStatus { status: "healthy" | "warning" | "critical" @@ -80,6 +81,7 @@ interface FlaskSystemInfo { } export function ProxmoxDashboard() { + const t = useT() const [systemStatus, setSystemStatus] = useState({ status: "healthy", uptime: "Loading...", @@ -168,7 +170,7 @@ export function ProxmoxDashboard() { const data: FlaskSystemInfo = await fetchApi("/api/system-info") const uptimeValue = - data.uptime && typeof data.uptime === "string" && data.uptime.trim() !== "" ? data.uptime : "N/A" + data.uptime && typeof data.uptime === "string" && data.uptime.trim() !== "" ? data.uptime : t("app.notAvailable") const backendStatus = data.health?.status?.toUpperCase() || "OK" let healthStatus: "healthy" | "warning" | "critical" @@ -185,8 +187,8 @@ export function ProxmoxDashboard() { status: healthStatus, uptime: uptimeValue, lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }), - serverName: data.hostname || "Unknown", - nodeId: data.node_id || "Unknown", + serverName: data.hostname || t("app.unknown"), + nodeId: data.node_id || t("app.unknown"), }) setIsServerConnected(true) } catch (error) { @@ -196,13 +198,13 @@ export function ProxmoxDashboard() { setSystemStatus((prev) => ({ ...prev, status: "critical", - serverName: "Server Offline", - nodeId: "Server Offline", - uptime: "N/A", + serverName: t("app.serverOffline"), + nodeId: t("app.serverOffline"), + uptime: t("app.notAvailable"), lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }), })) } - }, []) + }, [t]) useEffect(() => { // Siempre fetch inicial @@ -362,19 +364,19 @@ export function ProxmoxDashboard() { const getActiveTabLabel = () => { switch (activeTab) { - case "overview": return "Overview" - case "vms": return "VMs & LXCs" - case "storage": return "Storage" - case "network": return "Network" - case "hardware": return "Hardware" - case "backup": return "Backup" - case "terminal": return "Terminal" - case "logs": return "System Logs" - case "security": return "Security" - case "settings": return "Settings" - case "about": return "About" - case "profile": return "Profile" - default: return "Navigation Menu" + case "overview": return t("navigation.overview") + case "vms": return t("navigation.virtualMachines") + case "storage": return t("navigation.storage") + case "network": return t("navigation.network") + case "hardware": return t("navigation.hardware") + case "backup": return t("navigation.backup") + case "terminal": return t("navigation.terminal") + case "logs": return t("navigation.systemLogs") + case "security": return t("navigation.security") + case "settings": return t("navigation.settings") + case "about": return t("navigation.about") + case "profile": return t("navigation.profile") + default: return t("navigation.menu") } } @@ -388,13 +390,13 @@ export function ProxmoxDashboard() {
- ProxMenux Server Connection Failed + {t("status.connectionFailed")}
-

• Check that the monitor.service is running correctly.

-

• The ProxMenux server should start automatically on port 8008

+

• {t("status.checkService")}

+

• {t("status.serverPort")}

- • Try accessing:{" "} + • {t("status.tryAccessing")}{" "} {getApiUrl("/api/health")} @@ -433,11 +435,11 @@ export function ProxmoxDashboard() {

-

ProxMenux Monitor

-

Proxmox System Dashboard

+

{t("app.title")}

+

{t("app.description")}

- Node: {systemStatus.serverName} + {t("status.node", { node: systemStatus.serverName })}
@@ -447,14 +449,14 @@ export function ProxmoxDashboard() {
-
Node: {systemStatus.serverName}
+
{t("status.node", { node: systemStatus.serverName })}
{statusIcon} - {systemStatus.status} + {t(`status.${systemStatus.status}`)} {systemStatus.status === "healthy" && infoCount > 0 && ( @@ -465,7 +467,7 @@ export function ProxmoxDashboard() {
- Uptime: {systemStatus.uptime || "N/A"} + {t("status.uptime", { uptime: systemStatus.uptime || t("app.notAvailable") })}
e.stopPropagation()}> @@ -513,7 +515,7 @@ export function ProxmoxDashboard() { }} disabled={isRefreshing} className="h-8 w-8 p-0 border-border/50 bg-transparent hover:bg-secondary" - aria-label="Refresh" + aria-label={t("actions.refresh")} > @@ -541,7 +543,7 @@ export function ProxmoxDashboard() {
{statusIcon} - {systemStatus.status} + {t(`status.${systemStatus.status}`)} {systemStatus.status === "healthy" && infoCount > 0 && ( @@ -551,7 +553,7 @@ export function ProxmoxDashboard() { )}
- Uptime: {systemStatus.uptime || "N/A"} + {t("status.uptime", { uptime: systemStatus.uptime || t("app.notAvailable") })}
@@ -583,15 +585,15 @@ export function ProxmoxDashboard() { // crumb shows where you are, the chevron tells you the // siblings are one click away. const NODE_ITEMS = [ - { value: "storage", label: "Storage", Icon: HardDrive, default: false }, - { value: "network", label: "Network", Icon: NetworkIcon, default: false }, - { value: "hardware", label: "Hardware", Icon: Cpu, default: false }, + { value: "storage", label: t("navigation.storage"), Icon: HardDrive, default: false }, + { value: "network", label: t("navigation.network"), Icon: NetworkIcon, default: false }, + { value: "hardware", label: t("navigation.hardware"), Icon: Cpu, default: false }, ] const ADMIN_ITEMS = [ - { value: "logs", label: "System Logs", Icon: ScrollText, default: false }, - { value: "security", label: "Security", Icon: ShieldCheck, default: false }, - { value: "settings", label: "Settings", Icon: SettingsIcon, default: false }, - { value: "about", label: "About", Icon: Info, default: false }, + { value: "logs", label: t("navigation.systemLogs"), Icon: ScrollText, default: false }, + { value: "security", label: t("navigation.security"), Icon: ShieldCheck, default: false }, + { value: "settings", label: t("navigation.settings"), Icon: SettingsIcon, default: false }, + { value: "about", label: t("navigation.about"), Icon: Info, default: false }, ] const activeNodeItem = NODE_ITEMS.find(i => i.value === activeTab) const activeAdminItem = ADMIN_ITEMS.find(i => i.value === activeTab) @@ -600,9 +602,9 @@ export function ProxmoxDashboard() { // The trigger label + icon shown on the bar. When a child // is active we surface IT; otherwise the group default. const NodeTriggerIcon = activeNodeItem ? activeNodeItem.Icon : Server - const NodeTriggerLabel = activeNodeItem ? activeNodeItem.label : "Node" + const NodeTriggerLabel = activeNodeItem ? activeNodeItem.label : t("navigation.node") const AdminTriggerIcon = activeAdminItem ? activeAdminItem.Icon : Settings2 - const AdminTriggerLabel = activeAdminItem ? activeAdminItem.label : "Admin" + const AdminTriggerLabel = activeAdminItem ? activeAdminItem.label : t("navigation.admin") // Dropdown trigger styling: parity with TabsTrigger so the // parent visibly carries the "I'm the selected section" // signal when any of its children is the active tab — @@ -621,14 +623,14 @@ export function ProxmoxDashboard() { {/* Direct: Overview */} - Overview + {t("navigation.overview")} {/* Direct: VMs & LXCs — first-class because Proxmox IS a hypervisor; workloads belong at top level. */} - VMs & LXCs + {t("navigation.virtualMachines")} {/* Dropdown: Node (Storage / Network / Hardware) */} @@ -656,13 +658,13 @@ export function ProxmoxDashboard() { backup ships this becomes a dropdown. */} - Backup + {t("navigation.backup")} {/* Direct: Terminal */} - Terminal + {t("navigation.terminal")} {/* Dropdown: Admin (System Logs / Security / Settings / About) */} @@ -727,47 +729,47 @@ export function ProxmoxDashboard() {
) @@ -844,7 +846,7 @@ export function ProxmoxDashboard() { rel="noopener noreferrer" className="text-blue-500 hover:text-blue-600 hover:underline transition-colors" > - Support and contribute to the project + {t("app.supportProject")}

diff --git a/AppImage/components/settings.tsx b/AppImage/components/settings.tsx index 9da31e8b..1f5ddb1b 100644 --- a/AppImage/components/settings.tsx +++ b/AppImage/components/settings.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" -import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle, BellOff } from "lucide-react" +import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle, BellOff, Globe2 } from "lucide-react" import { Badge } from "./ui/badge" import { Button } from "./ui/button" import { NotificationSettings } from "./notification-settings" @@ -14,6 +14,8 @@ import { Switch } from "./ui/switch" 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 type { LanguageCode, LanguageStatus } from "../lib/i18n/languages" // GitHub Dark color palette for bash syntax highlighting const BASH_KEYWORDS = new Set([ @@ -297,6 +299,7 @@ interface NetworkInterface { } export function Settings() { + const { language, setLanguage, t } = useI18n() const [proxmenuxTools, setProxmenuxTools] = useState([]) const [updatesAvailableCount, setUpdatesAvailableCount] = useState(0) const [loadingTools, setLoadingTools] = useState(true) @@ -899,21 +902,82 @@ export function Settings() { k => pendingChanges[k] !== -2 ) + const getLanguageStatusLabel = (status: LanguageStatus) => { + switch (status) { + case "complete": + return t("settings.interfaceLanguage.statusComplete") + case "partial": + return t("settings.interfaceLanguage.statusPartial") + case "needs-translation": + return t("settings.interfaceLanguage.statusNeedsTranslation") + } + } + return (
-

Settings

-

Manage your dashboard preferences

+

{t("settings.title")}

+

{t("settings.description")}

+ {/* Interface Language Settings */} + + +
+ + {t("settings.interfaceLanguage.title")} +
+ {t("settings.interfaceLanguage.description")} +
+ +
+
+
{t("settings.interfaceLanguage.label")}
+

{t("settings.interfaceLanguage.fallbackNote")}

+
+ +
+ +
+ {SUPPORTED_LANGUAGES.map((item) => ( +
+
+ {item.nativeName} + + {item.code} + +
+
{getLanguageStatusLabel(item.status)}
+
+ ))} +
+
+
+ {/* Network Units Settings */}
- Network Units + {t("settings.networkUnits.title")}
- Change how network traffic is displayed + {t("settings.networkUnits.description")}
{loadingUnitSettings ? ( @@ -922,7 +986,7 @@ export function Settings() {
) : (
-
Network Unit Display
+
{t("settings.networkUnits.label")}
setVal(cKey, Number(e.target.value), wVal, false)} className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-red-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-red-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background" - title={`Critical (recommended: ${cLeaf.recommended}${unit})`} + title={`${t("settings.healthThresholds.critical")} (${t("settings.healthThresholds.recommended").toLowerCase()}: ${cLeaf.recommended}${unit})`} />
@@ -660,9 +668,9 @@ export function HealthThresholds() { "warn" starts and ends without having to read the handles. */} {!options?.hideLabels && (
- OK < {wVal}{unit} - WARN {wVal}–{cVal}{unit} - CRIT > {cVal}{unit} + {t("settings.healthThresholds.ok")} < {wVal}{unit} + {t("settings.healthThresholds.warn")} {wVal}–{cVal}{unit} + {t("settings.healthThresholds.crit")} > {cVal}{unit}
)} @@ -675,14 +683,14 @@ export function HealthThresholds() {
- Health Monitor Thresholds + {t("settings.healthThresholds.title")}
{!loading && (
{savedFlash && ( - Saved + {t("status.saved")} )} {editMode ? ( @@ -692,7 +700,7 @@ export function HealthThresholds() { onClick={handleCancel} disabled={saving} > - Cancel + {t("actions.cancel")} ) : ( @@ -712,17 +720,17 @@ export function HealthThresholds() { )} @@ -730,10 +738,7 @@ export function HealthThresholds() { )}
- The Health Monitor and notifications fire when these thresholds are crossed. - Drag the amber handle to set the warning level and the red handle to set the - critical level. Values that differ from the recommended default appear in blue — - hover a handle to see the recommendation, or use Reset to restore it. + {t("settings.healthThresholds.description")} @@ -742,7 +747,7 @@ export function HealthThresholds() {
) : !tree ? ( -
Failed to load thresholds.
+
{t("settings.healthThresholds.loadFailed")}
) : (
{error && ( @@ -767,13 +772,13 @@ export function HealthThresholds() {
-

{section.title}

+

{tFallback(`settings.healthThresholds.sections.${section.id}.title`, section.title)}

{editMode && ( @@ -781,7 +786,7 @@ export function HealthThresholds() {
{section.description && (

- {section.description} + {tFallback(`settings.healthThresholds.sections.${section.id}.description`, section.description)}

)}
@@ -806,12 +811,12 @@ export function HealthThresholds() { // visual language end to end. <>
- RAM + {t("settings.healthThresholds.ram")}
{renderThresholdRange(["memory"])}
- Swap (critical only) + {t("settings.healthThresholds.swapCriticalOnly")}
{renderSingleThresholdSlider(["memory", "swap_critical"], "critical")}
diff --git a/AppImage/components/host-backup.tsx b/AppImage/components/host-backup.tsx index 2759a837..3f5e5eae 100644 --- a/AppImage/components/host-backup.tsx +++ b/AppImage/components/host-backup.tsx @@ -51,6 +51,9 @@ import { fetchApi, getApiUrl } from "../lib/api-config" import { fetchTerminalTicket } from "../lib/terminal-ws" import { formatStorage, formatBytes } from "../lib/utils" import { getStorageUsageColor } from "../lib/storage-usage-color" +import { useT } from "../lib/i18n/provider" + +type TFunction = (key: string, params?: Record) => string // ── Shape contracts with the backend (flask_server.py: api_host_backups_*) ── @@ -234,36 +237,38 @@ const parseKeyfileError = (raw: string): KeyfileError | null => { } } -const KeyfileErrorBlock: React.FC<{ err: KeyfileError; className?: string }> = ({ err, className }) => ( -
-
- -
-
Encrypted backup — wrong keyfile on this host
-
- The keyfile installed here does not match the one used to create this backup, so PBS refuses to open it. -
- {err.manifestFp && ( -
-
Required (manifest)
-
{err.manifestFp}
+const KeyfileErrorBlock: React.FC<{ err: KeyfileError; className?: string }> = ({ err, className }) => { + const t = useT() + + return ( +
+
+ +
+
{t("backup.keyfileError.title")}
+
{t("backup.keyfileError.description")}
+ {err.manifestFp && ( +
+
{t("backup.keyfileError.requiredManifest")}
+
{err.manifestFp}
+
+ )} + {err.providedFp && ( +
+
{t("backup.keyfileError.currentlyInstalled")}
+
{err.providedFp}
+
+ )} +
+ {t("backup.keyfileError.importFrom")}{" "} + {t("backup.keyfileError.importPath")} + {" "}{t("backup.keyfileError.retryHint")}
- )} - {err.providedFp && ( -
-
Currently installed
-
{err.providedFp}
-
- )} -
- Import the correct keyfile from{" "} - Backup configuration → Destinations → PBS row → Upload - {" "}(you may need to Delete the current one first) and retry.
-
-) + ) +} const formatMtime = (mtime: number) => new Date(mtime * 1000).toLocaleString(undefined, { @@ -288,30 +293,30 @@ const formatNext = (iso: string | null) => { // operator at least knows what kind of string they're looking at. // Handles the patterns the host-backup wizard can emit ("hourly", // "daily", "weekly", "monthly", "*-*-* HH:MM:SS", "Mon..Sun *-*-* …"). -const humanizeOnCalendar = (raw: string | null | undefined): string => { +const humanizeOnCalendar = (raw: string | null | undefined, t: TFunction): string => { if (!raw) return "—" const s = raw.trim() if (!s) return "—" const lower = s.toLowerCase() - if (lower === "hourly") return "Every hour (at minute 0)" - if (lower === "daily") return "Every day at 00:00" - if (lower === "weekly") return "Every Monday at 00:00" - if (lower === "monthly") return "On the 1st of every month at 00:00" - if (lower === "yearly" || lower === "annually") return "On Jan 1st at 00:00" - if (lower === "minutely") return "Every minute" + if (lower === "hourly") return t("backup.schedule.everyHourAtMinuteZero") + if (lower === "daily") return t("backup.schedule.everyDayAtMidnight") + if (lower === "weekly") return t("backup.schedule.everyMondayAtMidnight") + if (lower === "monthly") return t("backup.schedule.firstDayMonthly") + if (lower === "yearly" || lower === "annually") return t("backup.schedule.janFirst") + if (lower === "minutely") return t("backup.schedule.everyMinute") // *-*-* HH:MM[:SS] → "Every day at HH:MM" let m = s.match(/^\*-\*-\*\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/) - if (m) return `Every day at ${m[1].padStart(2, "0")}:${m[2]}` + if (m) return t("backup.schedule.everyDayAt", { time: `${m[1].padStart(2, "0")}:${m[2]}` }) // *-*-* *:MM:SS → "Every hour at minute MM" m = s.match(/^\*-\*-\*\s+\*:(\d{2})(?::(\d{2}))?$/) - if (m) return `Every hour at minute ${m[1]}` + if (m) return t("backup.schedule.everyHourAtMinute", { minute: m[1] }) // Mon,Tue *-*-* HH:MM:SS → " at HH:MM" m = s.match(/^([A-Za-z,.\s]+)\s+\*-\*-\*\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/) if (m) { const expandWeekdays = (chunk: string): string => { const days: Record = { - mon: "Monday", tue: "Tuesday", wed: "Wednesday", - thu: "Thursday", fri: "Friday", sat: "Saturday", sun: "Sunday", + mon: t("backup.weekdays.monday"), tue: t("backup.weekdays.tuesday"), wed: t("backup.weekdays.wednesday"), + thu: t("backup.weekdays.thursday"), fri: t("backup.weekdays.friday"), sat: t("backup.weekdays.saturday"), sun: t("backup.weekdays.sunday"), } const rangeMatch = chunk.match(/^([A-Za-z]+)\.\.([A-Za-z]+)$/) if (rangeMatch) { @@ -327,9 +332,9 @@ const humanizeOnCalendar = (raw: string | null | undefined): string => { .map((d) => days[d.trim().slice(0, 3).toLowerCase()] || d.trim()) .join(", ") } - return `${expandWeekdays(m[1])} at ${m[2].padStart(2, "0")}:${m[3]}` + return t("backup.schedule.weekdaysAt", { days: expandWeekdays(m[1]), time: `${m[2].padStart(2, "0")}:${m[3]}` }) } - return `${s} (systemd OnCalendar)` + return t("backup.schedule.rawOnCalendar", { value: s }) } // A job is "running" when its .status file has RUN_AT (runner started) @@ -414,6 +419,7 @@ const formatRunAt = (iso: string | null) => { // the fuller management surface. // ────────────────────────────────────────────────────────────── function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) { + const t = useT() const { data: info, mutate: mutateInfo } = useSWR<{ installed: boolean fingerprint?: string @@ -470,7 +476,7 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) { const runUpload = async () => { if (!importFile && !importPath.trim()) { - setErr("Pick a keyfile file or enter an absolute path on this host.") + setErr(t("backup.errors.pickKeyfile")) return } setBusy(true) @@ -523,17 +529,17 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) {
- Encryption keyfile:{" "} + {t("backup.keyfileActions.keyfileLabel")}{" "} {installed ? ( - installed + {t("backup.keyfileActions.installed")} ) : ( - not installed on this host + {t("backup.keyfileActions.notInstalled")} )}
@@ -545,10 +551,10 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) { variant="outline" className="h-7 text-[11px] !text-emerald-400 border-emerald-500/40 hover:bg-emerald-500/10" onClick={download} - title="Download the keyfile as pbs-key.conf" + title={t("backup.keyfileActions.downloadTitle")} > - Download + {t("backup.actions.download")} )} @@ -570,10 +576,10 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) { variant="outline" className="h-7 text-[11px] !text-blue-400 border-blue-500/40 hover:bg-blue-500/10" onClick={() => setUploadOpen(true)} - title="Import a keyfile you already have" + title={t("backup.keyfileActions.uploadTitle")} > - Upload + {t("backup.actions.upload")} )}
@@ -582,9 +588,9 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) { !v && closeUpload()}> - Upload PBS keyfile + {t("backup.keyfileActions.uploadDialogTitle")} - Import a keyfile you already have. It lands at /usr/local/share/proxmenux/pbs-key.conf and every subsequent encrypted backup reuses it. Recovery escrow stays off — use the setup wizard if you want to enable it. + {t("backup.keyfileActions.uploadDialogDescriptionBefore")} /usr/local/share/proxmenux/pbs-key.conf {t("backup.keyfileActions.uploadDialogDescriptionAfter")}
@@ -593,10 +599,10 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) {
-
PVE-managed keyfile detected for this PBS
+
{t("backup.keyfileActions.pveKeyDetected")}
- Proxmox already stores an encryption key for storage {pveMatch.name} at{" "} - {pveMatch.path}. Import it in one click. + {t("backup.keyfileActions.pveKeyDescriptionBefore")} {pveMatch.name} {t("backup.keyfileActions.pveKeyDescriptionMiddle")}{" "} + {pveMatch.path}. {t("backup.keyfileActions.pveKeyDescriptionAfter")}
@@ -610,14 +616,14 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) { disabled={busy} className="!bg-emerald-600 hover:!bg-emerald-700 !text-white h-7 text-[11px]" > - Use this key + {t("backup.keyfileActions.useThisKey")}
)} - {pveMatch &&
— or upload your own —
} + {pveMatch &&
{t("backup.keyfileActions.orUploadOwn")}
}
- +
-
— or —
+
{t("backup.common.or")}
- + setImportPath(e.target.value)} disabled={busy || !!importFile} - placeholder="e.g. /etc/pve/priv/storage/.enc or /root/my-pbs-key" + placeholder={t("backup.placeholders.keyfilePath")} className="h-9 mt-1 font-mono text-xs" />
@@ -645,13 +651,13 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) { )}
- + @@ -662,24 +668,24 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) { - Delete keyfile + {t("backup.keyfileActions.deleteDialogTitle")} - Backups already stored on PBS were encrypted with the current keyfile. After this action: + {t("backup.keyfileActions.deleteDialogDescription")}
    -
  • New backups will use no encryption on this host until a new keyfile is set up.
  • -
  • Downloading pre-existing encrypted backups from this host will fail unless you kept a copy of the current key.
  • -
  • Existing recovery blobs on PBS stay intact — they still recover the old key with its original passphrase.
  • +
  • {t("backup.keyfileActions.deleteWarningNewBackups")}
  • +
  • {t("backup.keyfileActions.deleteWarningDownloadsBefore")} {t("backup.keyfileActions.willFail")} {t("backup.keyfileActions.deleteWarningDownloadsAfter")}
  • +
  • {t("backup.keyfileActions.deleteWarningRecoveryBlobs")}
{err && (
{err}
)} - + @@ -695,6 +701,7 @@ function KeyfileActionsBar({ mutateStatus: () => Promise escrowMode?: "none" | "local" | "full" }) { + const t = useT() const [busy, setBusy] = useState(false) const [err, setErr] = useState(null) const [pass1, setPass1] = useState("") @@ -709,11 +716,11 @@ function KeyfileActionsBar({ // Yes → passphrase required + match. No → passphrase ignored. if (pendingMode === "full") { if (!pass1) { - setErr("Recovery passphrase is required.") + setErr(t("backup.errors.recoveryPassphraseRequired")) return } if (pass1 !== pass2) { - setErr("Passphrases do not match.") + setErr(t("backup.errors.passphrasesDoNotMatch")) return } } @@ -743,12 +750,12 @@ function KeyfileActionsBar({ // Yes → Yes (new pw) → "Update passphrase" (rewraps envelope) const applyLabel = pendingMode === "full" && !currentIsFull - ? "Start uploading" + ? t("backup.keyfileManagement.startUploading") : pendingMode === "none" && currentIsFull - ? "Stop uploading" + ? t("backup.keyfileManagement.stopUploading") : pendingMode === "full" && currentIsFull - ? "Update passphrase" - : "Apply" + ? t("backup.keyfileManagement.updatePassphrase") + : t("backup.actions.apply") // Apply is enabled when there is a real change to commit. const canApply = ( @@ -767,20 +774,20 @@ function KeyfileActionsBar({ return (
-
Manage installed keyfile
+
{t("backup.keyfileManagement.title")}
{/* Current status — icon + colour by state, no truncated fp. */} {escrowMode !== undefined && (
- Upload to PBS: + {t("backup.keyfileManagement.uploadToPbs")} {currentIsFull ? ( - Yes — envelope uploaded on every backup + {t("backup.keyfileManagement.uploadYes")} ) : ( - No — kept only on this host + {t("backup.keyfileManagement.uploadNo")} )}
@@ -791,7 +798,7 @@ function KeyfileActionsBar({ intent is Yes (both for a first-time upload and for a passphrase rotation while already in Yes). */}
-
Upload key to PBS?
+
{t("backup.keyfileManagement.uploadQuestion")}
@@ -824,29 +831,29 @@ function KeyfileActionsBar({
setPass1(e.target.value)} - placeholder={currentIsFull ? "Type a new passphrase to rotate" : "Long random string — write it down somewhere safe"} + placeholder={currentIsFull ? t("backup.placeholders.newRecoveryPassphrase") : t("backup.placeholders.recoveryPassphrase")} className="font-mono mt-1 h-8 text-xs" />
- + setPass2(e.target.value)} - placeholder="Type it again" + placeholder={t("backup.placeholders.typeItAgain")} className="font-mono mt-1 h-8 text-xs" /> {pass1 && pass2 && pass1 !== pass2 && ( -

Passphrases don't match.

+

{t("backup.errors.passphrasesDontMatch")}

)}
@@ -876,7 +883,7 @@ function KeyfileActionsBar({ onClick={download} > - Download keyfile + {t("backup.keyfileManagement.downloadKeyfile")}
@@ -887,6 +894,7 @@ function KeyfileActionsBar({ } export function HostBackup() { + const t = useT() const { data: jobsResp, error: jobsErr, mutate: mutateJobs } = useSWR<{ jobs: BackupJob[] }>( "/api/host-backups/jobs", fetcher, @@ -921,7 +929,7 @@ export function HostBackup() { mutateJobs() setJobToDelete(null) } catch (e) { - setActionError(`Failed to delete "${id}": ${e instanceof Error ? e.message : String(e)}`) + setActionError(t("backup.errors.deleteFailed", { error: `"${id}": ${e instanceof Error ? e.message : String(e)}` })) } finally { setBusyJobId(null) } @@ -1009,7 +1017,7 @@ export function HostBackup() {
- Scheduled Backup Jobs + {t("backup.jobs.scheduledTitle")} {jobsResp?.jobs?.filter((j) => !j.manual).length ?? 0} @@ -1020,16 +1028,16 @@ export function HostBackup() { onClick={() => setCreatingJob(true)} > - Create job + {t("backup.jobs.createJob")} {jobsErr ? ( -
Failed to load jobs
+
{t("backup.jobs.loadFailed")}
) : !jobsResp ? (
- Loading... + {t("backup.common.loading")}
) : jobsResp.jobs.filter((j) => !j.manual).length === 0 ? null : (
@@ -1046,7 +1054,7 @@ export function HostBackup() { // the row re-opens JobDetailModal which auto-detects // the in-progress state and resumes streaming. const statusBadge = running - ? { label: "running", cls: "bg-blue-500/10 border-blue-500/40 text-blue-300" } + ? { label: t("backup.status.running"), cls: "bg-blue-500/10 border-blue-500/40 text-blue-300" } : status?.result === "ok" ? { label: "ok", cls: "bg-emerald-500/10 border-emerald-500/40 text-emerald-400" } : status?.result @@ -1059,7 +1067,7 @@ export function HostBackup() { type="button" onClick={() => setViewingJobId(j.id)} className="w-full text-left flex items-start gap-3 p-3 rounded-md border border-border bg-card hover:bg-white/5 transition-colors group" - title="Click to open this job" + title={t("backup.jobs.openJobTitle")} >
{/* Title row */} @@ -1070,19 +1078,19 @@ export function HostBackup() { {j.manual && ( - manual + {t("backup.status.manual")} )} {j.attached && ( - attached + {t("backup.status.attached")} )} {j.encrypted && ( @@ -1101,15 +1109,15 @@ export function HostBackup() { }`} title={ j.profile_mode === "custom" - ? "Custom path list — only the paths the operator picked" - : "Default path list — ProxMenux's recommended host config set" + ? t("backup.jobs.customProfileTitle") + : t("backup.jobs.defaultProfileTitle") } > - {j.profile_mode === "custom" ? "custom" : "default"} + {j.profile_mode === "custom" ? t("backup.profile.custom") : t("backup.profile.default")} {!j.enabled && !j.manual && ( - disabled + {t("status.disabled")} )}
@@ -1124,10 +1132,10 @@ export function HostBackup() {
- {humanizeOnCalendar(j.on_calendar)} + {humanizeOnCalendar(j.on_calendar, t)} {j.retention && ( - + {(() => { // Backend returns retention as "last=7, daily=7, …". @@ -1157,15 +1165,15 @@ export function HostBackup() { )} {!j.attached && j.next_run && ( - + - next: {formatNext(j.next_run)} + {t("backup.jobs.nextRun", { time: formatNext(j.next_run) })} )} {(statusBadge || lastRunWhen) && ( - last: + {t("backup.jobs.lastRunLabel")} {statusBadge && ( {running && } @@ -1178,7 +1186,7 @@ export function HostBackup() { {!status && ( - never run + {t("backup.jobs.neverRun")} )}
@@ -1204,19 +1212,19 @@ export function HostBackup() {
- Manual backups + {t("backup.manual.title")}

- Manual backups run once and stop — no schedule. + {t("backup.manual.description")}

{/* In-progress manual jobs. If the operator closed the ManualBackupDialog before the runner finished, this @@ -1231,13 +1239,13 @@ export function HostBackup() { type="button" onClick={() => setWatchingManualId(j.id)} className="w-full flex items-center gap-2 px-3 py-2 rounded-md border border-blue-500/40 bg-blue-500/5 hover:bg-blue-500/10 transition-colors text-left" - title="Click to re-open the live log" + title={t("backup.manual.reopenLogTitle")} > - Manual backup in progress — {j.id} + {t("backup.manual.inProgress")} — {j.id} - View progress + {t("backup.manual.viewProgress")} ))}
@@ -1248,17 +1256,17 @@ export function HostBackup() {
- Available Archives + {t("backup.archives.title")}
{unifiedArchives.length}

- All backups visible from this host — local .tar.zst files (PVE default dump dir, configured local target, USB mountpoints, scheduled jobs' destinations) and PBS backups from every configured datastore. Click an entry to inspect, restore or download it — downloads of PBS backups are extracted on-demand only when you request them. + {t("backup.archives.descriptionBefore")} .tar.zst {t("backup.archives.descriptionAfter")}

{remoteArchivesResp?.errors && remoteArchivesResp.errors.length > 0 && (
-
Some remote backends couldn't be queried:
+
{t("backup.archives.remoteQueryWarning")}
{remoteArchivesResp.errors.map((e, i) => (
{e.backend}/{e.repo_name}: {e.error} @@ -1267,15 +1275,15 @@ export function HostBackup() {
)} {archivesErr && remoteArchivesErr ? ( -
Failed to load archives
+
{t("backup.archives.loadFailed")}
) : !archivesResp && !remoteArchivesResp ? (
- Loading... + {t("backup.common.loading")}
) : unifiedArchives.length === 0 ? (
- No backups found yet. Use Run manual backup above, configure a scheduled job, or check that the configured PBS / Borg destinations have backups. + {t("backup.archives.emptyBefore")} {t("backup.manual.run")} {t("backup.archives.emptyAfter")}
) : (
@@ -1298,7 +1306,7 @@ export function HostBackup() { type="button" onClick={() => setInspectingArchive(u)} className="w-full text-left flex items-center justify-between gap-3 p-3 rounded-md border border-border bg-background/40 hover:bg-white/5 hover:border-blue-500/40 transition-colors group" - title="Click to inspect, restore or download this backup" + title={t("backup.archives.inspectTitle")} >
@@ -1311,7 +1319,7 @@ export function HostBackup() { {u.remote?.encrypted && ( @@ -1325,24 +1333,24 @@ export function HostBackup() { {formatBytes(u.size_bytes)} - at: {u.source_label} + {t("backup.archives.at")} {u.source_label} {u.source === "local" && localKind === "scheduled" && localJobId ? ( - job: {localJobId} + {t("backup.archives.job")} {localJobId} ) : u.source === "local" && localKind === "legacy" ? ( - legacy + {t("backup.status.legacy")} ) : u.source === "local" && localKind === "manual" ? ( - manual + {t("backup.status.manual")} ) : null} {(u.source === "pbs" || u.source === "borg") && u.remote?.backup_id && ( - {u.source === "pbs" ? "group" : "archive"}: {u.remote.backup_id} + {u.source === "pbs" ? t("backup.archives.group") : t("backup.archives.archive")}: {u.remote.backup_id} )} {u.source === "local" && localHost && ( - host: {localHost} + {t("backup.archives.host")} {localHost} )}
@@ -1454,33 +1462,31 @@ export function HostBackup() { - Delete backup job? + {t("backup.deleteJob.title")} - This action cannot be undone. + {t("backup.deleteJob.description")} {jobToDelete && (
-
Job ID
+
{t("backup.fields.jobId")}
{jobToDelete.id}
{jobToDelete.attached && jobToDelete.pve_storage && ( <> -
Type
-
attached to PVE storage {jobToDelete.pve_storage}
+
{t("backup.fields.type")}
+
{t("backup.deleteJob.attachedToStorage")} {jobToDelete.pve_storage}
)}
{jobToDelete.attached ? (

- Only the ProxMenux host backup hook is removed. - PVE vzdump jobs targeting this storage stay intact and keep running. + {t("backup.deleteJob.attachedWarning")}

) : (

- The systemd timer and service for this job will be stopped, disabled and removed. - Existing backup archives on disk are NOT deleted. + {t("backup.deleteJob.timerWarning")}

)}
@@ -1491,7 +1497,7 @@ export function HostBackup() { onClick={() => setJobToDelete(null)} disabled={busyJobId === jobToDelete?.id} > - Cancel + {t("actions.cancel")}
@@ -1526,13 +1532,14 @@ function InspectModal({ onClose: () => void onDeleted?: () => void }) { + const t = useT() const open = archive !== null // Aliases to the source-specific payloads — saves on `.local!` / // `.remote!` repetition later. PBS and Borg share the same shape. const localArc = archive?.source === "local" ? archive.local : undefined const remoteArc = archive && archive.source !== "local" ? archive.remote : undefined const isRemote = archive?.source === "pbs" || archive?.source === "borg" - const backendLabel = archive?.source === "pbs" ? "PBS" : archive?.source === "borg" ? "Borg" : "Local" + const backendLabel = archive?.source === "pbs" ? "PBS" : archive?.source === "borg" ? "Borg" : t("backup.backends.local") const [mode, setMode] = useState("full") const [report, setReport] = useState(null) const [running, setRunning] = useState(false) @@ -1647,7 +1654,7 @@ function InspectModal({ // both land at /usr/local/share/proxmenux/pbs-key.conf with // escrow_mode='none'. When both are provided the file wins. if (!importFile && !importPath.trim()) { - setImportError("Pick a keyfile file or enter an absolute path on this host.") + setImportError(t("backup.errors.pickKeyfile")) return } setImporting(true) @@ -1685,13 +1692,13 @@ function InspectModal({ setRestorePreparing(true) const body: Record = { source: archive.source } if (archive.source === "local") { - if (!localArc?.path) { setRestoreError("Local archive path missing"); setRestorePreparing(false); return } + if (!localArc?.path) { setRestoreError(t("backup.errors.localArchivePathMissing")); setRestorePreparing(false); return } body.path = localArc.path } else if (remoteArc) { body.repo_name = remoteArc.repo_name body.snapshot = remoteArc.snapshot } else { - setRestoreError("Snapshot info missing") + setRestoreError(t("backup.errors.snapshotInfoMissing")) setRestorePreparing(false) return } @@ -1701,7 +1708,7 @@ function InspectModal({ headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) - if (!r?.staging_path) throw new Error("backend did not return a staging path") + if (!r?.staging_path) throw new Error(t("backup.errors.backendNoStagingPath")) const ck = r.cross_kernel || {} const hyd = r.hydration || {} setRestoreOptions({ @@ -1753,7 +1760,7 @@ function InspectModal({ a.click() document.body.removeChild(a) } catch (e) { - setError(`Download failed: ${e instanceof Error ? e.message : String(e)}`) + setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) })) } finally { // The download itself runs in the browser's network stack; // we just initiated it. Clear the spinner immediately. @@ -1777,7 +1784,7 @@ function InspectModal({ repo_name: remoteArc.repo_name, snapshot: remoteArc.snapshot, state: "queued", - message: "Starting export…", + message: t("backup.archives.startingExport"), size_bytes: 0, output_path: null, error: null, @@ -1808,7 +1815,7 @@ function InspectModal({ if (task.state === "completed" || task.state === "failed") break } if (!task || task.state !== "completed") { - throw new Error(task?.error || "export did not complete") + throw new Error(task?.error || t("backup.errors.exportDidNotComplete")) } // Stream the resulting .tar.zst with a ticketed URL + . // Same rationale as downloadLocalArchive: bypass fetch+blob to @@ -1826,7 +1833,7 @@ function InspectModal({ document.body.removeChild(a) setExportTask(null) } catch (e) { - setError(`Download failed: ${e instanceof Error ? e.message : String(e)}`) + setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) })) } finally { setDownloading(false) } @@ -1874,7 +1881,7 @@ function InspectModal({ setShowDeleteArchiveConfirm(false) if (onDeleted) onDeleted(); else onClose() } catch (e) { - setError(`Failed to delete: ${e instanceof Error ? e.message : String(e)}`) + setError(t("backup.errors.deleteFailed", { error: e instanceof Error ? e.message : String(e) })) } finally { setDeletingArchive(false) } @@ -1933,7 +1940,7 @@ function InspectModal({ ) setReport(res) } catch (e: any) { - setError(e?.message || "Preflight failed") + setError(e?.message || t("backup.errors.preflightFailed")) } finally { setRunning(false) } @@ -1964,7 +1971,7 @@ function InspectModal({ where they used to overlap the close button). */}
-
Backup
+
{t("backup.archives.backup")}
{archive && ( @@ -1992,33 +1999,33 @@ function InspectModal({ {/* Time + size — present for every backend. */} {archive && (archive.source === "pbs" || archive.source === "borg") && remoteArc ? ( <> -
Backup time: {formatMtime(remoteArc.backup_time)}
- {remoteArc.size_bytes > 0 &&
Size: {formatBytes(remoteArc.size_bytes)}
} -
Repository: {remoteArc.repo_repository}
-
Repo name: {remoteArc.repo_name}
+
{t("backup.archives.backupTimeLabel")} {formatMtime(remoteArc.backup_time)}
+ {remoteArc.size_bytes > 0 &&
{t("backup.fields.sizeLabel")} {formatBytes(remoteArc.size_bytes)}
} +
{t("backup.fields.repositoryLabel")} {remoteArc.repo_repository}
+
{t("backup.fields.repoNameLabel")} {remoteArc.repo_name}
- {remoteArc.backend === "pbs" ? "Backup group:" : "Archive name:"}{" "} + {remoteArc.backend === "pbs" ? t("backup.fields.backupGroupLabel") : t("backup.fields.archiveNameLabel")}{" "} {remoteArc.backend === "pbs" ? `${remoteArc.backup_type}/${remoteArc.backup_id}` : remoteArc.backup_id}
- {remoteArc.owner &&
Owner: {remoteArc.owner}
} - {remoteArc.borg_id &&
Borg id: {remoteArc.borg_id}
} + {remoteArc.owner &&
{t("backup.fields.ownerLabel")} {remoteArc.owner}
} + {remoteArc.borg_id &&
{t("backup.fields.borgIdLabel")} {remoteArc.borg_id}
} ) : localArc ? ( <> -
Created: {formatMtime(localArc.mtime)}
-
Size: {formatBytes(localArc.size_bytes)}
-
Path: {localArc.path}
- {localArc.job_id &&
Job id: {localArc.job_id}
} - {localArc.profile &&
Profile: {localArc.profile}
} - {localArc.source_hostname &&
Source host: {localArc.source_hostname}
} -
Detected via: {localArc.detected_via}
+
{t("backup.fields.createdLabel")} {formatMtime(localArc.mtime)}
+
{t("backup.fields.sizeLabel")} {formatBytes(localArc.size_bytes)}
+
{t("backup.fields.pathLabel")} {localArc.path}
+ {localArc.job_id &&
{t("backup.fields.jobIdLabel")} {localArc.job_id}
} + {localArc.profile &&
{t("backup.fields.profileLabel")} {localArc.profile}
} + {localArc.source_hostname &&
{t("backup.fields.sourceHostLabel")} {localArc.source_hostname}
} +
{t("backup.fields.detectedViaLabel")} {localArc.detected_via}
) : null}
{/* PBS pxar files list — only PBS exposes this. */} {remoteArc?.files && remoteArc.files.length > 0 && (
-
Files in this backup
+
{t("backup.archives.filesInBackup")}
    {remoteArc.files.map((f) => (
  • @@ -2035,7 +2042,7 @@ function InspectModal({ {archive?.source === "local" && archiveLog && archiveLog.log_path && archiveLog.tail.length > 0 && (

    - Run log + {t("backup.logs.runLog")}

    @@ -2043,12 +2050,12 @@ function InspectModal({
                       
    - tail · {formatBytes(archiveLog.size)} + {t("backup.logs.tail")} · {formatBytes(archiveLog.size)} {archiveLog.log_path}
    @@ -2060,14 +2067,14 @@ function InspectModal({
    - {exportTask.state} + {t(`backup.taskStates.${exportTask.state}`)} — {exportTask.message}
    {exportTask.state === "failed" && exportTask.error && (
    {exportTask.error}
    )} {exportTask.state === "completed" && exportTask.size_bytes > 0 && ( -
    Packed size: {formatBytes(exportTask.size_bytes)}
    +
    {t("backup.archives.packedSizeLabel")} {formatBytes(exportTask.size_bytes)}
    )}
    )} @@ -2092,11 +2099,11 @@ function InspectModal({
    -
    Encrypted backup — keyfile required
    +
    {t("backup.keyfileGate.title")}
    - This snapshot is encrypted but no local keyfile is installed at + {t("backup.keyfileGate.descriptionBefore")} {" "}/usr/local/share/proxmenux/pbs-key.conf. - Import the keyfile that was used at backup time to continue. + {" "}{t("backup.keyfileGate.descriptionAfter")}
    @@ -2105,10 +2112,10 @@ function InspectModal({
    -
    PVE-managed keyfile detected for this PBS
    +
    {t("backup.keyfileActions.pveKeyDetected")}
    - Proxmox stores an encryption key for storage {pveMatchInspect.name} at{" "} - {pveMatchInspect.path}. Import it in one click. + {t("backup.keyfileActions.pveKeyDescriptionBefore")} {pveMatchInspect.name} {t("backup.keyfileActions.pveKeyDescriptionMiddle")}{" "} + {pveMatchInspect.path}. {t("backup.keyfileActions.pveKeyDescriptionAfter")}
    @@ -2122,14 +2129,14 @@ function InspectModal({ disabled={importing} className="!bg-emerald-600 hover:!bg-emerald-700 !text-white h-6 text-[10.5px] px-2" > - Use this key + {t("backup.keyfileActions.useThisKey")}
)}
-
— or —
+
{t("backup.common.or")}
- + setImportPath(e.target.value)} disabled={importing || !!importFile} - placeholder="e.g. /etc/pve/priv/storage/.enc or /root/my-pbs-key" + placeholder={t("backup.placeholders.keyfilePath")} className="h-8 text-[11px] font-mono" />
@@ -2171,7 +2178,7 @@ function InspectModal({ ) : ( )} - Import keyfile + {t("backup.actions.importKeyfile")}
@@ -2190,38 +2197,38 @@ function InspectModal({ onClick={beginRestore} disabled={restorePreparing || needsKeyfile} className="bg-green-600 hover:bg-green-700 text-white disabled:opacity-50" - title={needsKeyfile ? "Import the encryption keyfile above to enable Restore" : "Restore this snapshot to the current host (Complete or Custom by paths)"} + title={needsKeyfile ? t("backup.archives.importKeyToRestoreTitle") : t("backup.archives.restoreTitle")} > {restorePreparing ? ( ) : ( )} - Restore + {t("backup.actions.restore")}
@@ -2268,7 +2275,7 @@ function InspectModal({ - {kf ? "Restore blocked — encrypted backup" : "Restore preparation failed"} + {kf ? t("backup.restore.blockedEncryptedTitle") : t("backup.restore.preparationFailedTitle")} {!kf && ( @@ -2278,7 +2285,7 @@ function InspectModal({ {kf && }
- +
@@ -2346,11 +2353,11 @@ function InspectModal({ }} scriptPath="/usr/local/share/proxmenux/scripts/backup_restore/restore/monitor_apply.sh" scriptName="monitor_apply" - title={`Restore — ${restoreTerminal.mode === "full" ? "Complete" : "Custom by paths"}`} + title={t("backup.restore.terminalTitle", { mode: restoreTerminal.mode === "full" ? t("backup.restore.complete") : t("backup.restore.customByPaths") })} description={ restoreTerminal.mode === "custom" - ? `${restoreTerminal.paths.length} path(s) selected` - : "Complete restore — applies the whole backup" + ? t("backup.restore.pathsSelected", { count: restoreTerminal.paths.length }) + : t("backup.restore.completeDescription") } params={{ EXECUTION_MODE: "web", @@ -2370,14 +2377,14 @@ function InspectModal({ - Delete {backendLabel} backup + {t("backup.archives.deleteBackendTitle", { backend: backendLabel })} {archive?.source === "local" - ? "Removes the archive, its sidecar JSON and the matching run log. The action is permanent — restore needs an off-host copy." + ? t("backup.archives.deleteLocalDescription") : archive?.source === "pbs" - ? `Forgets this snapshot from the PBS repository "${remoteArc?.repo_name ?? ""}". The action is permanent — PBS GC may reclaim the underlying chunks at the next garbage-collection run.` - : `Deletes this archive from the Borg repository "${remoteArc?.repo_name ?? ""}". The action is permanent — Borg compacts the freed space at the next prune.`} + ? t("backup.archives.deletePbsDescription", { repo: remoteArc?.repo_name ?? "" }) + : t("backup.archives.deleteBorgDescription", { repo: remoteArc?.repo_name ?? "" })}
@@ -2385,7 +2392,7 @@ function InspectModal({
@@ -2405,7 +2412,7 @@ function InspectModal({ - Run log + {t("backup.logs.runLog")} {archiveLog?.log_path} @@ -2415,7 +2422,7 @@ function InspectModal({ {archiveLog?.content ?? ""}
- +
@@ -2434,25 +2441,26 @@ function ManifestSummary({ storage_inventory?: { zfs_pools?: unknown[]; lvm?: { vgs?: unknown[] } } } }) { + const t = useT() const sh = manifest.source_host const zfsCount = manifest.storage_inventory?.zfs_pools?.length ?? 0 const lvmCount = manifest.storage_inventory?.lvm?.vgs?.length ?? 0 return (
- } label="Source host" value={sh.hostname} /> - - - - - - - - + } label={t("backup.fields.sourceHost")} value={sh.hostname} /> + + + + + + + +
{manifest.proxmenux_installed_components.length > 0 && (
-
ProxMenux components at backup time:
+
{t("backup.manifest.componentsAtBackup")}
{manifest.proxmenux_installed_components.map((c) => ( @@ -2486,13 +2494,14 @@ function Field({ icon, label, value, mono, labelClassName }: { icon?: React.Reac // `key=val, key=val…` string which read like a config file. This view // drops zero-valued entries and presents what survives as ordered chips. function RetentionDisplay({ retention }: { retention: Record }) { + const t = useT() const order: Array<[string, string]> = [ - ["keep_last", "last"], - ["keep_hourly", "hourly"], - ["keep_daily", "daily"], - ["keep_weekly", "weekly"], - ["keep_monthly", "monthly"], - ["keep_yearly", "yearly"], + ["keep_last", "backup.retention.last"], + ["keep_hourly", "backup.retention.hourly"], + ["keep_daily", "backup.retention.daily"], + ["keep_weekly", "backup.retention.weekly"], + ["keep_monthly", "backup.retention.monthly"], + ["keep_yearly", "backup.retention.yearly"], ] const items = order .map(([k, lbl]) => { @@ -2506,10 +2515,10 @@ function RetentionDisplay({ retention }: { retention: Record
- retention + {t("backup.retention.title")}
{items.length === 0 ? ( -
No retention rules — backups will accumulate.
+
{t("backup.retention.noneAccumulate")}
) : (
{items.map((it) => ( @@ -2517,7 +2526,7 @@ function RetentionDisplay({ retention }: { retention: Record - {it.label} + {t(it.label)} {it.value} ))} @@ -2532,10 +2541,11 @@ function RetentionDisplay({ retention }: { retention: Record
- paths + {t("backup.paths.title")} ({paths.length})
@@ -2555,6 +2565,7 @@ function PathsDisplay({ paths }: { paths: string[] }) { // ── Preflight report view ──────────────────────────────────── function PreflightReportView({ report }: { report: PreflightReport }) { + const t = useT() const { summary, checks } = report.preflight const passColor = "text-emerald-500" const warnColor = "text-amber-500" @@ -2566,19 +2577,19 @@ function PreflightReportView({ report }: { report: PreflightReport }) {
- {summary.pass} pass + {t("backup.preflight.passCount", { count: summary.pass })} - {summary.warn} warn + {t("backup.preflight.warnCount", { count: summary.warn })} - {summary.fail} fail + {t("backup.preflight.failCount", { count: summary.fail })} {summary.fail > 0 && ( - --apply would be refused + {t("backup.preflight.applyWouldBeRefused")} )}
@@ -2609,20 +2620,20 @@ function PreflightReportView({ report }: { report: PreflightReport }) { {/* Storage / network counts */}
-
Storage [in mode: {String(report.storage.in_selected_mode)}]
+
{t("backup.preflight.storageInMode", { mode: String(report.storage.in_selected_mode) })}
- {report.storage.zfs.length} ZFS pool(s) · - {" "}{report.storage.lvm.length} LVM VG(s) · - {" "}{report.storage.pve_storage.length} PVE storage(s) + {t("backup.preflight.zfsPoolsCount", { count: report.storage.zfs.length })} · + {" "}{t("backup.preflight.lvmVgsCount", { count: report.storage.lvm.length })} · + {" "}{t("backup.preflight.pveStorageCount", { count: report.storage.pve_storage.length })}
-
Network [in mode: {String(report.network.in_selected_mode)}]
+
{t("backup.preflight.networkInMode", { mode: String(report.network.in_selected_mode) })}
- {report.network.keep.length} keep · - {" "}{report.network.remap.length} remap · - {" "}{report.network.orphan.length} orphan · - {" "}{report.network.new.length} new + {t("backup.preflight.keepCount", { count: report.network.keep.length })} · + {" "}{t("backup.preflight.remapCount", { count: report.network.remap.length })} · + {" "}{t("backup.preflight.orphanCount", { count: report.network.orphan.length })} · + {" "}{t("backup.preflight.newCount", { count: report.network.new.length })}
@@ -2630,7 +2641,7 @@ function PreflightReportView({ report }: { report: PreflightReport }) { {/* Driver plan */} {report.driver_reinstall.plan.length > 0 && (
-
Driver reinstall plan ({report.driver_reinstall.plan.length})
+
{t("backup.preflight.driverReinstallPlan", { count: report.driver_reinstall.plan.length })}
{report.driver_reinstall.plan.map((p) => (
@@ -2768,6 +2779,7 @@ function CreateJobDialog({ onCreated: () => void editingJobId?: string | null }) { + const t = useT() const isEdit = !!editingJobId const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1) const [jobId, setJobId] = useState("") @@ -3168,12 +3180,12 @@ function CreateJobDialog({ // or a typed absolute path — mirrors the shell wizard. // Existing + PVE auto-detect uses source=pve-storage. if (pbsEncryptMode === "existing" && !pbsPveMatch && !pbsImportFile && !pbsImportPath.trim()) { - setError("Pick a keyfile file or enter an absolute path on this host.") + setError(t("backup.errors.pickKeyfile")) setSubmitting(false) return } if (pbsUploadToPbs && pbsRecoveryPass !== pbsRecoveryPass2) { - setError("Recovery passphrases don't match.") + setError(t("backup.errors.passphrasesDoNotMatch")) setSubmitting(false) return } @@ -3213,9 +3225,9 @@ function CreateJobDialog({ } catch (e) { const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } } const detail = err.body?.tool_output - ? `${err.message}\n\nproxmox-backup-client output:\n${err.body.tool_output}` + ? `${err.message}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}` : err.message - setError(`Encryption setup failed: ${detail || String(e)}`) + setError(t("backup.errors.encryptionSetupFailed", { detail: detail || String(e) })) setPbsImportBusy(false) setSubmitting(false) return @@ -3297,10 +3309,10 @@ function CreateJobDialog({ ) : ( )} - {isEdit ? "Edit scheduled backup job" : "Create scheduled backup job"} + {isEdit ? t("backup.jobs.editScheduledJob") : t("backup.jobs.createScheduledJob")} - Step {step} of 5 · {mode === "attach" ? "Attached to PVE vzdump" : "Standalone scheduled job"} + {t("backup.jobs.stepOf", { step, total: 5 })} · {mode === "attach" ? t("backup.jobs.attachedToPveVzdump") : t("backup.jobs.standaloneScheduledJob")} @@ -3319,40 +3331,40 @@ function CreateJobDialog({ {step === 1 && (
- + setJobId(e.target.value)} disabled={isEdit} className="font-mono mt-1" - placeholder="my-host-backup" + placeholder="moja-zaloha-servera" />

{isEdit - ? "The job name can't be changed. Delete and recreate the job if you want to rename it." - : <>A short name to identify this job in the list, logs, and shell menu. Letters, digits, _ and - only (no spaces or accents).} + ? t("backup.jobs.jobNameLocked") + : <>{t("backup.jobs.jobNameHelpBefore")} _ {t("backup.jobs.jobNameHelpAnd")} - {t("backup.jobs.jobNameHelpAfter")}}

{!idValid && jobId.length > 0 && !isEdit && ( -

Invalid characters. Use letters, digits, _ or -.

+

{t("backup.jobs.invalidJobName")}

)}
- + {isEdit && (

- You can change where the backup is sent. The destination of the new option is set on Step 5. + {t("backup.jobs.backendEditHelp")}

)}
{(["pbs", "local", "borg"] as const).map((b) => { const Icon = b === "pbs" ? Server : b === "local" ? HardDrive : Archive const desc = b === "pbs" - ? "Proxmox Backup Server. Incremental, encrypted, dedup." + ? t("backup.backends.pbsDescription") : b === "local" - ? "tar.zst archive into a local directory or mounted disk." - : "Borg repo over SSH or on a local/USB disk (timer only)." + ? t("backup.backends.localDescription") + : t("backup.backends.borgDescriptionTimerOnly") return ( @@ -3379,11 +3391,11 @@ function CreateJobDialog({ {step === 2 && (
- +

{backend === "borg" - ? "Borg backups only run on their own timer — they're not produced by PVE vzdump." - : "Either run on a schedule you define here, or hook into an existing PVE vzdump job and inherit its schedule + retention."} + ? t("backup.schedule.borgTimerOnly") + : t("backup.schedule.modeHelp")}

@@ -3394,10 +3406,10 @@ function CreateJobDialog({ >
- New scheduled job + {t("backup.jobs.newScheduledJob")}
- Own systemd timer with the OnCalendar and retention policy you pick on the next steps. + {t("backup.jobs.newScheduledJobDescription")}
@@ -3431,19 +3443,19 @@ function CreateJobDialog({ {step === 3 && mode === "attach" && (
- +

- The host config backup will fire on every job-end of this job. + {t("backup.jobs.parentPveJobHelpBefore")} job-end {t("backup.jobs.parentPveJobHelpAfter")}

{compatibleJobs.length === 0 ? (
- No compatible PVE vzdump job + {t("backup.jobs.noCompatiblePveJob")}

- No PVE vzdump job currently uses a {backend} storage. Create one in Datacenter → Backup first, then come back here to attach. + {t("backup.jobs.noCompatiblePveJobDescriptionBefore")} {backend === "pbs" ? "PBS" : backend === "borg" ? "Borg" : t("backup.backends.local")} {t("backup.jobs.noCompatiblePveJobDescriptionMiddle")} Datacenter → Backup {t("backup.jobs.noCompatiblePveJobDescriptionAfter")}

) : ( @@ -3459,14 +3471,14 @@ function CreateJobDialog({ {j.id} {!j.enabled && ( - disabled + {t("status.disabled")} )}
- storage: {j.storage} - schedule: {j.schedule || "—"} - retention: {j.prune || "—"} + {t("backup.fields.storageLabel")} {j.storage} + {t("backup.fields.scheduleLabel")} {j.schedule || "—"} + {t("backup.fields.retentionLabel")} {j.prune || "—"}
))} @@ -3478,9 +3490,9 @@ function CreateJobDialog({ {step === 3 && mode === "new" && (
- +

- Pick how often this backup runs. The expression is built and validated for you. + {t("backup.schedule.pickFrequency")}

{scheduleType === "daily" && (
- + - +

- The job fires every hour at this minute. 0 = on the hour, 30 = half past, etc. + {t("backup.schedule.hourlyHelpBefore")} 0 {t("backup.schedule.hourlyHelpMiddle")} 30 {t("backup.schedule.hourlyHelpAfter")}

)} @@ -3533,7 +3545,7 @@ function CreateJobDialog({ {scheduleType === "weekly" && (
- +
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((d) => { const active = scheduleWeekdays.has(d) @@ -3555,17 +3567,17 @@ function CreateJobDialog({ : "border-border bg-background/40 text-muted-foreground hover:bg-white/5" }`} > - {d} + {t(`backup.weekdays.short.${d.toLowerCase()}`)} ) })}
{scheduleWeekdays.size === 0 && ( -

Pick at least one day.

+

{t("backup.schedule.pickAtLeastOneDay")}

)}
- +
- +

- If the chosen day doesn't exist in a given month (e.g. 31 in February), systemd skips that month. + {t("backup.schedule.monthlySkipHelp")}

- + - + setScheduleAdvanced(e.target.value)} className="font-mono mt-1" - placeholder="*-*-* 02:00, Mon..Fri *-*-* 04:00, daily, ..." + placeholder={t("backup.placeholders.onCalendar")} />

- Any expression accepted by systemd-analyze calendar. See man systemd.time for the full grammar. + {t("backup.schedule.advancedHelpBefore")} systemd-analyze calendar. {t("backup.schedule.advancedHelpMiddle")} man systemd.time {t("backup.schedule.advancedHelpAfter")}

)} {/* Live preview from the backend */}
-
Preview
+
{t("backup.schedule.preview")}
- Expression: + {t("backup.fields.expressionLabel")} {onCalendar}
{calendarPreview ? ( @@ -3635,13 +3647,13 @@ function CreateJobDialog({ <> {calendarPreview.normalized && calendarPreview.normalized !== onCalendar && (
- Normalized: + {t("backup.fields.normalizedLabel")} {calendarPreview.normalized}
)} {calendarPreview.next_elapse && (
- Next run: + {t("backup.jobs.nextRunLabel")} {calendarPreview.next_elapse} {calendarPreview.from_now && ( ({calendarPreview.from_now}) @@ -3651,25 +3663,25 @@ function CreateJobDialog({ ) : (
- Invalid: {calendarPreview.error} + {t("backup.validation.invalidLabel")} {calendarPreview.error}
) ) : ( -
checking…
+
{t("backup.common.checking")}
)}
- -

Zero disables that bucket.

+ +

{t("backup.retention.zeroDisables")}

{[ - { id: "keep-last", lbl: "keep-last", v: keepLast, set: setKeepLast }, - { id: "keep-hourly", lbl: "keep-hourly", v: keepHourly, set: setKeepHourly }, - { id: "keep-daily", lbl: "keep-daily", v: keepDaily, set: setKeepDaily }, - { id: "keep-weekly", lbl: "keep-weekly", v: keepWeekly, set: setKeepWeekly }, - { id: "keep-monthly", lbl: "keep-monthly", v: keepMonthly, set: setKeepMonthly }, - { id: "keep-yearly", lbl: "keep-yearly", v: keepYearly, set: setKeepYearly }, + { id: "keep-last", lbl: t("backup.retention.keepLast"), v: keepLast, set: setKeepLast }, + { id: "keep-hourly", lbl: t("backup.retention.keepHourly"), v: keepHourly, set: setKeepHourly }, + { id: "keep-daily", lbl: t("backup.retention.keepDaily"), v: keepDaily, set: setKeepDaily }, + { id: "keep-weekly", lbl: t("backup.retention.keepWeekly"), v: keepWeekly, set: setKeepWeekly }, + { id: "keep-monthly", lbl: t("backup.retention.keepMonthly"), v: keepMonthly, set: setKeepMonthly }, + { id: "keep-yearly", lbl: t("backup.retention.keepYearly"), v: keepYearly, set: setKeepYearly }, ].map((row) => (
@@ -3692,16 +3704,16 @@ function CreateJobDialog({ {step === 4 && (
- +
{profileMode === "custom" && (
- + {defaultPaths.map((p) => (