diff --git a/AppImage/app/layout.tsx b/AppImage/app/layout.tsx index 3b664966..d9c48067 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" @@ -43,13 +44,15 @@ export default function RootLayout({ return ( - 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..d23f341c 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 @@ -22,8 +23,8 @@ import { APP_VERSION } from "./release-notes-modal" // without re-cluttering the dashboard footer. interface LinkRow { - label: string - description: string + labelKey: string + descriptionKey: string href: string Icon: React.ComponentType<{ className?: string }> accent?: keyof typeof ACCENT_CLASSES @@ -42,29 +43,29 @@ const ACCENT_CLASSES = { const PROJECT_LINKS: LinkRow[] = [ { - label: "GitHub repository", - description: "Source code, releases and issue tracker.", + labelKey: "about.links.repository.label", + descriptionKey: "about.links.repository.description", href: "https://github.com/MacRimi/ProxMenux", Icon: Github, accent: "gray", }, { - label: "Documentation", - description: "Full user guide for ProxMenux and the Monitor.", + labelKey: "about.links.documentation.label", + descriptionKey: "about.links.documentation.description", href: "https://proxmenux.com", Icon: BookOpen, accent: "blue", }, { - label: "Discussions", - description: "Ask questions, share custom AI prompts, swap ideas.", + labelKey: "about.links.discussions.label", + descriptionKey: "about.links.discussions.description", href: "https://github.com/MacRimi/ProxMenux/discussions", Icon: MessageSquare, accent: "purple", }, { - label: "Report a bug or request a feature", - description: "Open an issue on GitHub — bugs, ideas, regressions.", + labelKey: "about.links.issues.label", + descriptionKey: "about.links.issues.description", href: "https://github.com/MacRimi/ProxMenux/issues", Icon: Bug, accent: "red", @@ -73,8 +74,8 @@ const PROJECT_LINKS: LinkRow[] = [ const SUPPORT_LINKS: LinkRow[] = [ { - label: "Support the project on Ko-fi", - description: "ProxMenux is free and open source. Donations cover hosting and dev time.", + labelKey: "about.links.support.label", + descriptionKey: "about.links.support.description", href: "https://ko-fi.com/macrimi", Icon: Heart, accent: "pink", @@ -82,6 +83,7 @@ const SUPPORT_LINKS: LinkRow[] = [ ] function LinkCard({ row }: { row: LinkRow }) { + const t = useT() const accentClass = ACCENT_CLASSES[row.accent ?? "blue"] // Style mirrors the PCI Devices cards in the Hardware tab: subtle // translucent background by default, slightly lighter on hover, no @@ -101,16 +103,17 @@ function LinkCard({ row }: { row: LinkRow }) {
- {row.label} + {t(row.labelKey)}
-

{row.description}

+

{t(row.descriptionKey)}

) } export function About() { + const t = useT() return (
{/* Hero — logo, name, version, one-line description. */} @@ -120,7 +123,7 @@ export function About() {
ProxMenux logo

- A web dashboard and management layer for Proxmox VE — health monitoring, - notifications, terminal, optimization tracker and more, packaged as a single - AppImage. + {t("about.heroDescription")}

@@ -151,7 +152,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 ( - Project + {t("about.project.title")} - Repository, documentation and community channels. + {t("about.project.description")}
@@ -195,11 +196,10 @@ export function About() { - Support & License + {t("about.support.title")} - ProxMenux is free and open source under the GPL-3.0 license. If it's useful to - you, a one-off contribution helps keep it that way. + {t("about.support.description")} @@ -218,11 +218,11 @@ export function About() {
- GPL-3.0 license + {t("about.license.label")}

- Free software — see the LICENSE file for the full text. + {t("about.license.description")}

diff --git a/AppImage/components/auth-setup.tsx b/AppImage/components/auth-setup.tsx index 1b137709..9c07fd6b 100644 --- a/AppImage/components/auth-setup.tsx +++ b/AppImage/components/auth-setup.tsx @@ -7,12 +7,14 @@ import { Input } from "./ui/input" import { Label } from "./ui/label" import { Shield, Lock, User, AlertCircle, Eye, EyeOff, Upload, Trash2 } from "lucide-react" import { getApiUrl } from "../lib/api-config" +import { useT } from "../lib/i18n/provider" interface AuthSetupProps { onComplete: () => 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/disk-temperature-card.tsx b/AppImage/components/disk-temperature-card.tsx index 1a9904e8..d2a34584 100644 --- a/AppImage/components/disk-temperature-card.tsx +++ b/AppImage/components/disk-temperature-card.tsx @@ -6,6 +6,7 @@ import { Badge } from "./ui/badge" import { AreaChart, Area, ResponsiveContainer, Tooltip, YAxis } from "recharts" import { fetchApi } from "@/lib/api-config" import { useDiskTempThresholds } from "@/lib/health-thresholds" +import { useT } from "@/lib/i18n/provider" interface TempPoint { timestamp: number @@ -24,11 +25,11 @@ interface DiskTemperatureCardProps { // Disk-temperature thresholds come from the user-configurable backend // (lib/health-thresholds.ts). The classifier here takes the resolved // pair so the consumer can read it from the hook once per render. -function statusFor(temp: number, t: { warn: number; hot: number }) { - if (temp <= 0) return { label: "N/A", className: "bg-gray-500/10 text-gray-500 border-gray-500/20", color: "#6b7280" } - if (temp >= t.hot) return { label: "Hot", className: "bg-red-500/10 text-red-500 border-red-500/20", color: "#ef4444" } - if (temp >= t.warn) return { label: "Warm", className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", color: "#f59e0b" } - return { label: "Normal", className: "bg-green-500/10 text-green-500 border-green-500/20", color: "#22c55e" } +function statusFor(temp: number, thresholds: { warn: number; hot: number }) { + if (temp <= 0) return { labelKey: "common.notAvailable", className: "bg-gray-500/10 text-gray-500 border-gray-500/20", color: "#6b7280" } + if (temp >= thresholds.hot) return { labelKey: "details.temperature.status.hot", className: "bg-red-500/10 text-red-500 border-red-500/20", color: "#ef4444" } + if (temp >= thresholds.warn) return { labelKey: "details.temperature.status.warm", className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", color: "#f59e0b" } + return { labelKey: "details.temperature.status.normal", className: "bg-green-500/10 text-green-500 border-green-500/20", color: "#22c55e" } } const MiniTooltip = ({ active, payload }: any) => { @@ -55,6 +56,7 @@ export function DiskTemperatureCard({ diskType, onOpenDetail, }: DiskTemperatureCardProps) { + const t = useT() const [data, setData] = useState([]) const [loading, setLoading] = useState(true) const cancelled = useRef(false) @@ -98,7 +100,7 @@ export function DiskTemperatureCard({ })() const status = statusFor(liveTemperature, dt) const lineColor = status.color - const tempDisplay = liveTemperature > 0 ? `${liveTemperature}°C` : "N/A" + const tempDisplay = liveTemperature > 0 ? `${liveTemperature}°C` : t("common.notAvailable") const samples = data.length const interactive = !!onOpenDetail @@ -112,11 +114,11 @@ export function DiskTemperatureCard({ "w-full text-left border border-white/10 rounded-lg p-3 bg-white/[0.02]", interactive ? "cursor-pointer hover:bg-white/[0.04] transition-colors focus:outline-none focus:ring-1 focus:ring-white/20" : "", ].join(" ")} - title={interactive ? "Open temperature history" : undefined} + title={interactive ? t("details.temperature.openHistory") : undefined} >
-

Temperature

+

{t("details.temperature.diskTitle")}

{tempDisplay}

@@ -124,7 +126,7 @@ export function DiskTemperatureCard({
- {status.label} + {t(status.labelKey)}
@@ -134,7 +136,7 @@ export function DiskTemperatureCard({
) : samples < 2 ? (
- Collecting samples — chart populates after ~2 minutes + {t("details.temperature.collectingSamples")}
) : ( diff --git a/AppImage/components/disk-temperature-detail-modal.tsx b/AppImage/components/disk-temperature-detail-modal.tsx index 0d2dba52..e1e36bff 100644 --- a/AppImage/components/disk-temperature-detail-modal.tsx +++ b/AppImage/components/disk-temperature-detail-modal.tsx @@ -8,12 +8,13 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai import { useIsMobile } from "../hooks/use-mobile" import { fetchApi } from "@/lib/api-config" import { useDiskTempThresholds, type DiskTempThreshold } from "@/lib/health-thresholds" +import { useT } from "@/lib/i18n/provider" const TIMEFRAME_OPTIONS = [ - { value: "hour", label: "1 Hour" }, - { value: "day", label: "24 Hours" }, - { value: "week", label: "7 Days" }, - { value: "month", label: "30 Days" }, + { value: "hour", labelKey: "details.temperature.timeframes.hour" }, + { value: "day", labelKey: "details.temperature.timeframes.day" }, + { value: "week", labelKey: "details.temperature.timeframes.week" }, + { value: "month", labelKey: "details.temperature.timeframes.month" }, ] interface TempHistoryPoint { @@ -69,10 +70,10 @@ function colorFor(temp: number, t: DiskTempThreshold): string { } function statusInfoFor(temp: number, t: DiskTempThreshold) { - if (temp <= 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" } - if (temp >= t.hot) return { status: "Hot", color: "bg-red-500/10 text-red-500 border-red-500/20" } - if (temp >= t.warn) return { status: "Warm", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" } - return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" } + if (temp <= 0) return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20" } + if (temp >= t.hot) return { color: "bg-red-500/10 text-red-500 border-red-500/20" } + if (temp >= t.warn) return { color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" } + return { color: "bg-green-500/10 text-green-500 border-green-500/20" } } export function DiskTemperatureDetailModal({ @@ -83,6 +84,7 @@ export function DiskTemperatureDetailModal({ liveTemperature, diskType, }: DiskTemperatureDetailModalProps) { + const t = useT() const [timeframe, setTimeframe] = useState("day") const [data, setData] = useState([]) const [stats, setStats] = useState({ min: 0, max: 0, avg: 0, current: 0 }) @@ -168,7 +170,7 @@ export function DiskTemperatureDetailModal({ {TIMEFRAME_OPTIONS.map((opt) => ( - {opt.label} + {t(opt.labelKey)} ))} @@ -181,24 +183,24 @@ export function DiskTemperatureDetailModal({
-
Current
-
{currentTemp > 0 ? `${currentTemp}°C` : "N/A"}
+
{t("details.temperature.current")}
+
{currentTemp > 0 ? `${currentTemp}°C` : t("common.notAvailable")}
- Min + {t("details.temperature.min")}
{stats.min}°C
- Avg + {t("details.temperature.avg")}
{stats.avg}°C
- Max + {t("details.temperature.max")}
{stats.max}°C
@@ -216,8 +218,8 @@ export function DiskTemperatureDetailModal({
-

No temperature data yet for this disk

-

Samples are collected every 60 seconds

+

{t("details.temperature.noData")}

+

{t("details.temperature.sampleInterval")}

) : ( @@ -250,7 +252,7 @@ export function DiskTemperatureDetailModal({ { if (!isSriovActive) return "" - if (sriovInfo?.role === "vf") return "SR-IOV VF" - if (sriovInfo?.vfCount && sriovInfo.vfCount > 0) return `SR-IOV ×${sriovInfo.vfCount}` - return "SR-IOV" + if (sriovInfo?.role === "vf") return t("hardware.gpuSwitch.sriovVf") + if (sriovInfo?.vfCount && sriovInfo.vfCount > 0) { + return t("hardware.gpuSwitch.sriovCount", { count: sriovInfo.vfCount }) + } + return t("hardware.gpuSwitch.sriov") })() return ( @@ -124,7 +129,7 @@ export function GpuSwitchModeIndicator({ className="text-[14px] font-bold transition-all duration-300" style={{ fontFamily: 'system-ui, sans-serif' }} > - GPU + {t("hardware.gpuSwitch.gpu")} @@ -268,7 +273,7 @@ export function GpuSwitchModeIndicator({ )} style={{ fontFamily: 'system-ui, sans-serif' }} > - LXC + {t("hardware.gpuSwitch.lxc")} )} {isSriovActive && ( @@ -279,7 +284,7 @@ export function GpuSwitchModeIndicator({ className="text-[9px] font-medium" style={{ fontFamily: 'system-ui, sans-serif' }} > - LXC + {t("hardware.gpuSwitch.lxc")} )} @@ -332,7 +337,7 @@ export function GpuSwitchModeIndicator({ )} style={{ fontFamily: 'system-ui, sans-serif' }} > - VM + {t("hardware.gpuSwitch.vm")} )} {isSriovActive && ( @@ -343,7 +348,7 @@ export function GpuSwitchModeIndicator({ className="text-[9px] font-medium" style={{ fontFamily: 'system-ui, sans-serif' }} > - VM + {t("hardware.gpuSwitch.vm")} )} @@ -363,34 +368,47 @@ export function GpuSwitchModeIndicator({ )} > {isSriovActive - ? "SR-IOV active" + ? t("hardware.gpuSwitch.sriovActive") : isLxcActive - ? "Ready for LXC containers" + ? t("hardware.gpuSwitch.readyForLxc") : isVmActive - ? "Ready for VM passthrough" - : "Mode unknown"} + ? t("hardware.gpuSwitch.readyForVm") + : t("hardware.gpuSwitch.modeUnknown")} {isSriovActive - ? "Virtual Functions managed externally" + ? t("hardware.gpuSwitch.virtualFunctionsExternal") : isLxcActive - ? "Native driver active" + ? t("hardware.gpuSwitch.nativeDriverActive") : isVmActive - ? "VFIO-PCI driver active" - : "No driver detected"} + ? t("hardware.gpuSwitch.vfioDriverActive") + : t("hardware.gpuSwitch.noDriverDetected")} {isSriovActive && sriovInfo && ( {sriovInfo.role === "vf" - ? `Virtual Function${sriovInfo.physfn ? ` · parent PF ${sriovInfo.physfn}` : ""}` + ? t( + sriovInfo.physfn + ? "hardware.gpuSwitch.virtualFunctionWithParent" + : "hardware.gpuSwitch.virtualFunction", + { parent: sriovInfo.physfn || "" }, + ) : sriovInfo.vfCount !== undefined - ? `1 PF + ${sriovInfo.vfCount} VF${sriovInfo.vfCount === 1 ? "" : "s"}${sriovInfo.totalvfs ? ` / ${sriovInfo.totalvfs} max` : ""}` + ? t( + sriovInfo.totalvfs + ? "hardware.gpuSwitch.physicalFunctionWithMax" + : "hardware.gpuSwitch.physicalFunction", + { + count: sriovInfo.vfCount, + max: sriovInfo.totalvfs || "", + }, + ) : null} )} {hasChanged && ( - Change pending... + {t("hardware.gpuSwitch.changePending")} )}
diff --git a/AppImage/components/hardware.tsx b/AppImage/components/hardware.tsx index 28487e73..ea9c3a2d 100644 --- a/AppImage/components/hardware.tsx +++ b/AppImage/components/hardware.tsx @@ -23,6 +23,10 @@ import { fetchApi } from "@/lib/api-config" import { ScriptTerminalModal } from "./script-terminal-modal" import { GpuSwitchModeIndicator } from "./gpu-switch-mode-indicator" import { Settings2, CheckCircle2 } from "lucide-react" +import { useT } from "../lib/i18n/provider" +import { cn } from "@/lib/utils" + +type TFunction = (key: string, params?: Record) => string const parseLsblkSize = (sizeStr: string | undefined): number => { if (!sizeStr) return 0 @@ -52,10 +56,10 @@ const parseLsblkSize = (sizeStr: string | undefined): number => { } } -const formatMemory = (memoryKB: number | string): string => { +const formatMemory = (memoryKB: number | string, t?: TFunction): string => { const kb = typeof memoryKB === "string" ? Number.parseFloat(memoryKB) : memoryKB - if (isNaN(kb)) return "N/A" + if (isNaN(kb)) return t ? t("common.notAvailable") : "N/A" // Convert KB to MB const mb = kb / 1024 @@ -166,19 +170,75 @@ const getDeviceTypeColor = (type: string): string => { return "bg-gray-500/10 text-gray-500 border-gray-500/20" } -const getMonitoringToolRecommendation = (vendor: string): string => { +const getMonitoringToolRecommendation = (vendor: string, t: TFunction): string => { const lowerVendor = vendor.toLowerCase() if (lowerVendor.includes("intel")) { - return "To get extended GPU monitoring information, please install intel-gpu-tools or igt-gpu-tools package." + return t("hardware.recommendations.intel") } if (lowerVendor.includes("nvidia")) { - return "For NVIDIA GPUs, real-time monitoring requires the proprietary drivers (nvidia-driver package). Install them only if your GPU is used directly by the host." + return t("hardware.recommendations.nvidia") } if (lowerVendor.includes("amd") || lowerVendor.includes("ati")) { - return "To get extended GPU monitoring information for AMD GPUs, please install amdgpu_top. You can download it from: https://github.com/Umio-Yasuno/amdgpu_top" + return t("hardware.recommendations.amd") } - return "To get extended GPU monitoring information, please install the appropriate GPU monitoring tools for your hardware." + return t("hardware.recommendations.generic") +} + +const formatHardwareValue = (value: string | null | undefined, t: TFunction): string => { + const text = value?.trim() + if (!text) return t("common.notAvailable") + + const normalized = text.toLowerCase() + if ( + normalized === "not specified" || + normalized === "not available" || + normalized === "to be filled by o.e.m." || + normalized === "to be filled by oem" || + normalized === "default string" + ) { + return t("hardware.values.notSpecified") + } + + return text +} + +const translateDeviceType = (type: string | null | undefined, t: TFunction): string => { + const text = type?.trim() + if (!text) return t("common.unknown") + + const normalized = text.toLowerCase() + const directMap: Record = { + "graphics": "hardware.deviceTypes.graphics", + "graphics card": "hardware.deviceTypes.graphicsCard", + "vga compatible controller": "hardware.deviceTypes.graphicsCard", + "3d controller": "hardware.deviceTypes.graphicsCard", + "display controller": "hardware.deviceTypes.graphicsCard", + "usb": "hardware.deviceTypes.usb", + "usb controller": "hardware.deviceTypes.usbController", + "audio": "hardware.deviceTypes.audio", + "audio device": "hardware.deviceTypes.audio", + "audio controller": "hardware.deviceTypes.audioController", + "network": "hardware.deviceTypes.network", + "network controller": "hardware.deviceTypes.networkController", + "ethernet": "hardware.deviceTypes.ethernet", + "ethernet controller": "hardware.deviceTypes.ethernet", + "wireless": "hardware.deviceTypes.wireless", + "wireless controller": "hardware.deviceTypes.wirelessController", + "wi-fi": "hardware.deviceTypes.wifi", + "wifi": "hardware.deviceTypes.wifi", + "storage": "hardware.deviceTypes.storage", + "storage controller": "hardware.deviceTypes.storageController", + "mass storage": "hardware.deviceTypes.storage", + "hid": "hardware.deviceTypes.hid", + "vendor specific": "hardware.deviceTypes.vendorSpecific", + "communications": "hardware.deviceTypes.communications", + "integrated": "hardware.deviceTypes.integrated", + "discrete": "hardware.deviceTypes.discrete", + } + + if (directMap[normalized]) return t(directMap[normalized]) + return text } const groupAndSortTemperatures = (temperatures: any[]) => { @@ -211,6 +271,8 @@ const groupAndSortTemperatures = (temperatures: any[]) => { } export default function Hardware() { + const t = useT() + // Static data - loaded once on mount. Static fields (CPU, motherboard, memory // modules, PCI, disks, GPU list) don't change at runtime, so no auto-refresh. // `mutateStatic` is triggered explicitly after GPU switch-mode changes. @@ -300,16 +362,16 @@ export default function Hardware() { const nvidiaInstall = managedInstalls.find((it) => it.type === "nvidia_xfree86") const formatLastChecked = (iso?: string | null): string => { - if (!iso) return "never" + if (!iso) return t("hardware.values.never") const d = new Date(iso) - if (isNaN(d.getTime())) return "unknown" + if (isNaN(d.getTime())) return t("common.unknown") const now = Date.now() const ageMs = now - d.getTime() const sameDay = new Date(now).toDateString() === d.toDateString() const yesterday = new Date(now - 86_400_000).toDateString() === d.toDateString() const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) if (sameDay) return time - if (yesterday) return `yesterday ${time}` + if (yesterday) return t("hardware.time.yesterdayAt", { time }) if (ageMs < 7 * 86_400_000) { return d.toLocaleDateString([], { weekday: "short" }) + " " + time } @@ -509,8 +571,8 @@ export default function Hardware() {
-
Loading hardware data...
-

Detecting CPU, GPU, storage and PCI devices

+
{t("hardware.loading.title")}
+

{t("hardware.loading.description")}

) } @@ -522,7 +584,7 @@ export default function Hardware() {
-

System Information

+

{t("hardware.sections.systemInformation")}

@@ -536,34 +598,34 @@ export default function Hardware() {
{hardwareData.cpu.model && (
- Model + {t("hardware.labels.model")} {hardwareData.cpu.model}
)} {hardwareData.cpu.cores_per_socket && hardwareData.cpu.sockets && (
- Cores + {t("hardware.labels.cores")} {hardwareData.cpu.sockets} × {hardwareData.cpu.cores_per_socket} ={" "} - {hardwareData.cpu.sockets * hardwareData.cpu.cores_per_socket} cores + {t("hardware.values.cores", { count: hardwareData.cpu.sockets * hardwareData.cpu.cores_per_socket })}
)} {hardwareData.cpu.total_threads && (
- Threads + {t("hardware.labels.threads")} {hardwareData.cpu.total_threads}
)} {hardwareData.cpu.l3_cache && (
- L3 Cache + {t("hardware.labels.l3Cache")} {hardwareData.cpu.l3_cache}
)} {hardwareData.cpu.virtualization && (
- Virtualization + {t("hardware.labels.virtualization")} {hardwareData.cpu.virtualization}
)} @@ -576,36 +638,36 @@ export default function Hardware() {
-

Motherboard

+

{t("hardware.sections.motherboard")}

{hardwareData.motherboard.manufacturer && (
- Manufacturer - {hardwareData.motherboard.manufacturer} + {t("hardware.labels.manufacturer")} + {formatHardwareValue(hardwareData.motherboard.manufacturer, t)}
)} {hardwareData.motherboard.model && (
- Model - {hardwareData.motherboard.model} + {t("hardware.labels.model")} + {formatHardwareValue(hardwareData.motherboard.model, t)}
)} {hardwareData.motherboard.bios?.vendor && (
BIOS - {hardwareData.motherboard.bios.vendor} + {formatHardwareValue(hardwareData.motherboard.bios.vendor, t)}
)} {hardwareData.motherboard.bios?.version && (
- Version - {hardwareData.motherboard.bios.version} + {t("hardware.labels.version")} + {formatHardwareValue(hardwareData.motherboard.bios.version, t)}
)} {hardwareData.motherboard.bios?.date && (
- Date + {t("hardware.labels.date")} {hardwareData.motherboard.bios.date}
)} @@ -621,9 +683,9 @@ export default function Hardware() {
-

Memory Modules

+

{t("hardware.sections.memoryModules")}

- {hardwareData.memory_modules.length} installed + {t("hardware.counts.installed", { count: hardwareData.memory_modules.length })}
@@ -634,26 +696,26 @@ export default function Hardware() {
{module.size && (
- Size - {formatMemory(module.size)} + {t("hardware.labels.size")} + {formatMemory(module.size, t)}
)} {module.type && (
- Type + {t("hardware.labels.type")} {module.type}
)} {(module.configured_speed || module.max_speed) && (
- Speed + {t("hardware.labels.speed")} {module.configured_speed && module.max_speed && module.configured_speed !== module.max_speed ? ( {module.configured_speed} - (max: {module.max_speed}) + {t("hardware.values.max", { value: module.max_speed })} ) : ( {module.configured_speed || module.max_speed} @@ -663,7 +725,7 @@ export default function Hardware() { )} {module.manufacturer && (
- Manufacturer + {t("hardware.labels.manufacturer")} {module.manufacturer}
)} @@ -679,9 +741,9 @@ export default function Hardware() {
-

Thermal Monitoring

+

{t("hardware.sections.thermalMonitoring")}

- {hardwareData.temperatures.length} sensors + {t("hardware.counts.sensors", { count: hardwareData.temperatures.length })}
@@ -873,7 +935,7 @@ export default function Hardware() { >
-

OTHER

+

{t("hardware.sections.otherSensors")}

{groupAndSortTemperatures(hardwareData.temperatures).OTHER.length} @@ -919,9 +981,9 @@ export default function Hardware() {
-

Graphics Cards

+

{t("hardware.sections.graphicsCards")}

- {hardwareData.gpus.length} GPU{hardwareData.gpus.length > 1 ? "s" : ""} + {t("hardware.counts.gpu", { count: hardwareData.gpus.length })}
@@ -952,27 +1014,27 @@ return (
- Type - {gpu.type} + {t("hardware.labels.type")} + {translateDeviceType(gpu.type, t)}
{fullSlot && (
- PCI Slot + {t("hardware.labels.pciSlot")} {fullSlot}
)} {gpu.pci_driver && (
- Driver + {t("hardware.labels.driver")} {gpu.pci_driver}
)} {gpu.pci_kernel_module && (
- Kernel Module + {t("hardware.labels.kernelModule")} {gpu.pci_kernel_module}
)} @@ -986,23 +1048,23 @@ return ( {nvidiaInstall.update_check.available ? ( <>
- Last checked: {formatLastChecked(nvidiaInstall.update_check.last_check)} ·{" "} + {t("hardware.labels.lastChecked")}: {formatLastChecked(nvidiaInstall.update_check.last_check)} ·{" "} - NVIDIA driver v{nvidiaInstall.update_check.latest} available + {t("hardware.values.nvidiaDriverAvailable", { version: nvidiaInstall.update_check.latest || "" })}
{nvidiaInstall.menu_label && (
- Reinstall via ProxMenux post-install: {nvidiaInstall.menu_label} + {t("hardware.values.reinstallViaPostInstall", { label: nvidiaInstall.menu_label })}
)} ) : (
- Last checked: {formatLastChecked(nvidiaInstall.update_check.last_check)} + {t("hardware.labels.lastChecked")}: {formatLastChecked(nvidiaInstall.update_check.last_check)} {` · NVIDIA driver v${nvidiaInstall.current_version}`} {" · "} - No updates available + {t("hardware.values.noUpdatesAvailable")}
)}
@@ -1013,7 +1075,7 @@ return (
- Switch Mode + {t("hardware.labels.switchMode")}
{getGpuSwitchMode(gpu) === "sriov" ? ( @@ -1029,7 +1091,7 @@ return ( handleSwitchModeCancel(fullSlot, e) }} > - Cancel + {t("actions.cancel")} ) : ( @@ -1051,7 +1113,7 @@ return ( }} > - Edit + {t("actions.edit")} )}
@@ -1088,32 +1150,32 @@ return ( <> {selectedGPU.name} - GPU Real-Time Monitoring + {t("hardware.gpu.monitoringTitle")}

- Basic Information + {t("hardware.sections.basicInformation")}

- Vendor + {t("hardware.labels.vendor")} {selectedGPU.vendor}
- Type - {selectedGPU.type} + {t("hardware.labels.type")} + {translateDeviceType(selectedGPU.type, t)}
- PCI Slot + {t("hardware.labels.pciSlot")} {findPCIDeviceForGPU(selectedGPU)?.slot || selectedGPU.slot}
{(findPCIDeviceForGPU(selectedGPU)?.driver || selectedGPU.pci_driver) && (
- Driver + {t("hardware.labels.driver")} {/* CHANGE: Added monitoring availability indicator */}
@@ -1129,7 +1191,7 @@ return ( )} {(findPCIDeviceForGPU(selectedGPU)?.kernel_module || selectedGPU.pci_kernel_module) && (
- Kernel Module + {t("hardware.labels.kernelModule")} {findPCIDeviceForGPU(selectedGPU)?.kernel_module || selectedGPU.pci_kernel_module} @@ -1141,7 +1203,7 @@ return ( {detailsLoading ? (
-

Loading real-time data...

+

{t("hardware.loading.realtimeData")}

) : selectedGPU.sriov_role === "vf" ? ( // SR-IOV Virtual Function: per-VF telemetry is not exposed @@ -1156,11 +1218,9 @@ return (
-

SR-IOV Virtual Function

+

{t("hardware.gpu.sriovVirtualFunction")}

- This device is a Virtual Function spawned by a Physical Function. Per-VF - telemetry (temperature, utilization, memory) is not exposed by the kernel — - open the parent PF to see aggregate GPU metrics. + {t("hardware.gpu.sriovVirtualFunctionDescription")}

@@ -1168,10 +1228,10 @@ return (

- Virtual Function Detail + {t("hardware.gpu.virtualFunctionDetail")}

- Parent Physical Function + {t("hardware.gpu.parentPhysicalFunction")} {selectedGPU.sriov_physfn ? (
- Current Driver + {t("hardware.labels.currentDriver")} - {selectedGPU.pci_driver || "none"} + {selectedGPU.pci_driver || t("hardware.values.none")}
- Consumer + {t("hardware.labels.consumer")}
{realtimeGPUData?.sriov_consumer ? ( {realtimeGPUData.sriov_consumer.type.toUpperCase()} {realtimeGPUData.sriov_consumer.id} {realtimeGPUData.sriov_consumer.name && ` · ${realtimeGPUData.sriov_consumer.name}`} - {` · ${realtimeGPUData.sriov_consumer.running ? "running" : "stopped"}`} + {` · ${realtimeGPUData.sriov_consumer.running ? t("hardware.values.running") : t("hardware.values.stopped")}`} ) : ( - unused + {t("hardware.values.unused")} )}
@@ -1228,50 +1288,47 @@ return (
- SR-IOV active + {t("hardware.gpu.sriovActive")} - Metrics below reflect the Physical Function (aggregate across - {" "} - - {realtimeGPUData?.sriov_vf_count ?? selectedGPU.sriov_vf_count ?? "N"} - - {" "}VFs). + {t("hardware.gpu.sriovMetricsDescription", { + count: realtimeGPUData?.sriov_vf_count ?? selectedGPU.sriov_vf_count ?? "N", + })}
)}
- Updating every 3 seconds + {t("hardware.gpu.updatingEverySeconds", { seconds: 3 })}

- Real-Time Metrics + {t("hardware.gpu.realTimeMetrics")}

{realtimeGPUData.clock_graphics && (
- Graphics Clock + {t("hardware.labels.graphicsClock")} {formatClock(realtimeGPUData.clock_graphics)}
)} {realtimeGPUData.clock_memory && (
- Memory Clock + {t("hardware.labels.memoryClock")} {formatClock(realtimeGPUData.clock_memory)}
)} {realtimeGPUData.power_draw && realtimeGPUData.power_draw !== "0.00 W" && (
- Power Draw + {t("hardware.labels.powerDraw")} {realtimeGPUData.power_draw}
)} {realtimeGPUData.temperature !== undefined && realtimeGPUData.temperature !== null && (
- Temperature + {t("hardware.labels.temperature")} {realtimeGPUData.temperature}°C @@ -1287,7 +1344,7 @@ return ( realtimeGPUData.engine_video_enhance !== undefined) && (

- Engine Utilization (Total) + {t("hardware.gpu.engineUtilizationTotal")}

{realtimeGPUData.engine_render !== undefined && ( @@ -1378,7 +1435,7 @@ return ( {realtimeGPUData.processes && realtimeGPUData.processes.length > 0 && (

- Active Processes ({realtimeGPUData.processes.length}) + {t("hardware.gpu.activeProcesses", { count: realtimeGPUData.processes.length })}

{realtimeGPUData.processes.map((proc: any, idx: number) => ( @@ -1396,15 +1453,15 @@ return ( className="font-mono text-xs bg-green-500/10 text-green-500 border-green-500/20" > {typeof proc.memory === "object" - ? formatMemory(proc.memory.resident / 1024) - : formatMemory(proc.memory)} + ? formatMemory(proc.memory.resident / 1024, t) + : formatMemory(proc.memory, t)} )}
{proc.engines && Object.keys(proc.engines).length > 0 && (
-

Engine Utilization:

+

{t("hardware.gpu.engineUtilization")}:

{Object.entries(proc.engines).map(([engineName, engineData]: [string, any]) => { const utilization = typeof engineData === "object" ? engineData.busy || 0 : engineData @@ -1433,7 +1490,7 @@ return ( {realtimeGPUData.processes && realtimeGPUData.processes.length === 0 && (
-

No active processes using the GPU

+

{t("hardware.gpu.noActiveProcesses")}

)} @@ -1441,25 +1498,25 @@ return ( {realtimeGPUData.memory_total && (

- Memory + {t("hardware.labels.memory")}

- Total + {t("hardware.labels.total")} {realtimeGPUData.memory_total}
- Used + {t("hardware.labels.used")} {realtimeGPUData.memory_used}
- Free + {t("hardware.labels.free")} {realtimeGPUData.memory_free}
{realtimeGPUData.utilization_memory !== undefined && (
- Memory Utilization + {t("hardware.labels.memoryUtilization")} {realtimeGPUData.utilization_memory}%

- Virtual Functions + {t("hardware.gpu.virtualFunctions")}

{realtimeGPUData.sriov_vfs.map((vf: any) => ( @@ -1504,7 +1561,7 @@ return ( : "bg-muted text-muted-foreground" )} > - {vf.driver || "unbound"} + {vf.driver || t("hardware.values.unbound")} {vf.consumer ? ( ) : ( - unused + {t("hardware.values.unused")} )}
@@ -1542,9 +1599,9 @@ return (
-

GPU in Switch Mode VM

+

{t("hardware.gpu.switchModeVmTitle")}

- This GPU is assigned to a virtual machine via VFIO passthrough. Real-time monitoring is not available from the host because the GPU is controlled by the VM. + {t("hardware.gpu.switchModeVmDescription")}

@@ -1562,9 +1619,9 @@ return (
-

Extended Monitoring Not Available

+

{t("hardware.gpu.extendedMonitoringUnavailable")}

- {getMonitoringToolRecommendation(selectedGPU.vendor)} + {getMonitoringToolRecommendation(selectedGPU.vendor, t)}

{selectedGPU.vendor.toLowerCase().includes("nvidia") && ( )} @@ -1584,7 +1641,7 @@ return ( > <> - Install AMD GPU Tools + {t("hardware.actions.installAmdGpuTools")} )} @@ -1595,7 +1652,7 @@ return ( > <> - Install Intel GPU Tools + {t("hardware.actions.installIntelGpuTools")} )} @@ -1616,9 +1673,9 @@ return (
-

Coral TPU / AI Accelerators

+

{t("hardware.sections.coralTpu")}

- {hardwareData.coral_tpus.length} device{hardwareData.coral_tpus.length > 1 ? "s" : ""} + {t("hardware.counts.devices", { count: hardwareData.coral_tpus.length })}
@@ -1660,12 +1717,12 @@ return ( {coral.drivers_ready ? ( <> - Drivers ready + {t("hardware.values.driversReady")} ) : ( <> - Drivers not installed + {t("hardware.values.driversNotInstalled")} )}
@@ -1680,9 +1737,9 @@ return (
-

Install Coral TPU drivers

+

{t("hardware.actions.installCoralDrivers")}

- One or more detected Coral devices need drivers. A server reboot is required after installation. + {t("hardware.coral.driversNeededDescription")}

@@ -1691,7 +1748,7 @@ return ( className="bg-blue-600 hover:bg-blue-700 text-white shrink-0" > - Install Drivers + {t("hardware.actions.installDrivers")}
)} @@ -1703,13 +1760,13 @@ return ( {selectedCoral?.name} - Coral TPU Device Information + {t("hardware.coral.deviceInformation")} {selectedCoral && (
- Connection + {t("hardware.labels.connection")} - Form Factor + {t("hardware.labels.formFactor")} {selectedCoral.form_factor}
)} {selectedCoral.interface_speed && (
- Link + {t("hardware.labels.link")} {selectedCoral.interface_speed}
)}
- {selectedCoral.type === "usb" ? "Bus:Device" : "PCI Slot"} + {selectedCoral.type === "usb" ? t("hardware.labels.busDevice") : t("hardware.labels.pciSlot")} {selectedCoral.type === "usb" ? selectedCoral.bus_device : selectedCoral.slot} @@ -1745,20 +1802,20 @@ return (
- Vendor / Product ID + {t("hardware.labels.vendorProductId")} {selectedCoral.vendor_id}:{selectedCoral.device_id}
- Vendor + {t("hardware.labels.vendor")} {selectedCoral.vendor}
{selectedCoral.type === "pcie" && selectedCoral.kernel_driver && (
- Kernel Driver + {t("hardware.labels.kernelDriver")} {selectedCoral.kernel_driver} @@ -1767,7 +1824,7 @@ return ( {selectedCoral.kernel_modules && (
- Kernel Modules + {t("hardware.labels.kernelModules")}
gasket {selectedCoral.kernel_modules.gasket ? "✓" : "✗"} @@ -1781,7 +1838,7 @@ return ( {selectedCoral.device_nodes && selectedCoral.device_nodes.length > 0 && (
- Device Nodes + {t("hardware.labels.deviceNodes")} {selectedCoral.device_nodes.join(", ")} @@ -1790,17 +1847,17 @@ return ( {selectedCoral.type === "usb" && (
- Runtime State + {t("hardware.labels.runtimeState")} - {selectedCoral.programmed ? "Programmed (runtime loaded)" : "Unprogrammed (runtime not loaded)"} + {selectedCoral.programmed ? t("hardware.values.programmed") : t("hardware.values.unprogrammed")}
)}
- Edge TPU Runtime + {t("hardware.labels.edgeTpuRuntime")} - {selectedCoral.edgetpu_runtime || not installed} + {selectedCoral.edgetpu_runtime || {t("hardware.values.notInstalled")}}
@@ -1825,14 +1882,14 @@ return ( : "text-green-500" return (
- Temperature + {t("hardware.labels.temperature")}
{selectedCoral.temperature.toFixed(1)} °C {trips && trips.length > 0 && (
- Thresholds: {trips.map((t) => `${t.toFixed(0)}°C`).join(" · ")} + {t("hardware.labels.thresholds")}: {trips.map((trip) => `${trip.toFixed(0)}°C`).join(" · ")}
)}
@@ -1842,7 +1899,7 @@ return ( {selectedCoral.thermal_warnings && selectedCoral.thermal_warnings.length > 0 && (
- Hardware Warnings + {t("hardware.labels.hardwareWarnings")}
{selectedCoral.thermal_warnings.map((w) => (
@@ -1858,7 +1915,7 @@ return ( : "text-muted-foreground/70" } > - {w.enabled ? "enabled" : "disabled"} + {w.enabled ? t("status.active") : t("status.disabled")}
))} @@ -1875,7 +1932,7 @@ return ( className="w-full bg-blue-600 hover:bg-blue-700 text-white" > - Install Coral TPU Drivers + {t("hardware.actions.installCoralDrivers")} )}
@@ -1888,7 +1945,7 @@ return (
-

Power Consumption

+

{t("hardware.sections.powerConsumption")}

@@ -1901,7 +1958,7 @@ return (

{hardwareData.power_meter.watts.toFixed(1)} W

-

Current Draw

+

{t("hardware.labels.currentDraw")}

@@ -1913,9 +1970,9 @@ return (
-

Power Supplies

+

{t("hardware.sections.powerSupplies")}

- {hardwareData.power_supplies.length} PSUs + {t("hardware.counts.psus", { count: hardwareData.power_supplies.length })}
@@ -1929,7 +1986,7 @@ return ( )}

{psu.watts} W

-

Current Output

+

{t("hardware.labels.currentOutput")}

))}
@@ -1941,9 +1998,9 @@ return (
-

System Fans

+

{t("hardware.sections.systemFans")}

- {hardwareData.fans.length} fans + {t("hardware.counts.fans", { count: hardwareData.fans.length })}
@@ -1957,7 +2014,7 @@ return (
{fan.name} - {isPercentage ? `${fan.speed.toFixed(0)} percent` : `${fan.speed.toFixed(0)} ${fan.unit}`} + {isPercentage ? `${fan.speed.toFixed(0)} %` : `${fan.speed.toFixed(0)} ${fan.unit}`}
@@ -1976,9 +2033,9 @@ return (
-

UPS Status

+

{t("hardware.sections.upsStatus")}

- {hardwareData.ups.length} UPS + {t("hardware.counts.ups", { count: hardwareData.ups.length })}
@@ -2007,16 +2064,16 @@ return (
{ups.model || ups.name} - {ups.is_remote && Remote: {ups.host}} + {ups.is_remote && {t("hardware.labels.remote")}: {ups.host}}
- {ups.status || "Unknown"} + {ups.status || t("common.unknown")}
{ups.battery_charge && (
- Battery Charge + {t("hardware.labels.batteryCharge")} {ups.battery_charge}
@@ -2026,7 +2083,7 @@ return ( {ups.load_percent && (
- Load + {t("hardware.labels.load")} {ups.load_percent}
@@ -2035,7 +2092,7 @@ return ( {ups.time_left && (
- Runtime + {t("hardware.labels.runtime")}
{ups.time_left}
@@ -2044,7 +2101,7 @@ return ( {ups.input_voltage && (
- Input Voltage + {t("hardware.labels.inputVoltage")}
{ups.input_voltage}
@@ -2065,8 +2122,8 @@ return ( {selectedUPS.model || selectedUPS.name} - UPS Detailed Information - {selectedUPS.is_remote && ` • Remote: ${selectedUPS.host}`} + {t("hardware.ups.detailedInformation")} + {selectedUPS.is_remote && ` • ${t("hardware.labels.remote")}: ${selectedUPS.host}`} @@ -2074,11 +2131,11 @@ return ( {/* Status Overview */}

- Status Overview + {t("hardware.sections.statusOverview")}

- Status + {t("hardware.labels.status")} - {selectedUPS.status || "Unknown"} + {selectedUPS.status || t("common.unknown")}
- Connection + {t("hardware.labels.connection")} {selectedUPS.connection_type}
{selectedUPS.host && (
- Host + {t("hardware.labels.host")} {selectedUPS.host}
)} @@ -2109,13 +2166,13 @@ return ( {/* Battery Information */}

- Battery Information + {t("hardware.sections.batteryInformation")}

{selectedUPS.battery_charge && (
- Charge Level + {t("hardware.labels.chargeLevel")} {selectedUPS.battery_charge}
- Runtime Remaining + {t("hardware.labels.runtimeRemaining")} {selectedUPS.time_left} @@ -2137,13 +2194,13 @@ return ( )} {selectedUPS.battery_voltage && (
- Battery Voltage + {t("hardware.labels.batteryVoltage")} {selectedUPS.battery_voltage}
)} {selectedUPS.battery_date && (
- Battery Date + {t("hardware.labels.batteryDate")} {selectedUPS.battery_date}
)} @@ -2153,37 +2210,37 @@ return ( {/* Input/Output Information */}

- Power Information + {t("hardware.sections.powerInformation")}

{selectedUPS.input_voltage && (
- Input Voltage + {t("hardware.labels.inputVoltage")} {selectedUPS.input_voltage}
)} {selectedUPS.output_voltage && (
- Output Voltage + {t("hardware.labels.outputVoltage")} {selectedUPS.output_voltage}
)} {selectedUPS.input_frequency && (
- Input Frequency + {t("hardware.labels.inputFrequency")} {selectedUPS.input_frequency}
)} {selectedUPS.output_frequency && (
- Output Frequency + {t("hardware.labels.outputFrequency")} {selectedUPS.output_frequency}
)} {selectedUPS.load_percent && (
- Load + {t("hardware.labels.load")} {selectedUPS.load_percent}
- Real Power + {t("hardware.labels.realPower")} {selectedUPS.real_power}
)} {selectedUPS.apparent_power && (
- Apparent Power + {t("hardware.labels.apparentPower")} {selectedUPS.apparent_power}
)} @@ -2212,36 +2269,36 @@ return ( {/* Device Information */}

- Device Information + {t("hardware.sections.deviceInformation")}

{selectedUPS.manufacturer && (
- Manufacturer + {t("hardware.labels.manufacturer")} {selectedUPS.manufacturer}
)} {selectedUPS.model && (
- Model + {t("hardware.labels.model")} {selectedUPS.model}
)} {selectedUPS.serial && (
- Serial Number + {t("hardware.labels.serialNumber")} {selectedUPS.serial}
)} {selectedUPS.firmware && (
- Firmware + {t("hardware.labels.firmware")} {selectedUPS.firmware}
)} {selectedUPS.driver && (
- Driver + {t("hardware.labels.driver")} {selectedUPS.driver}
)} @@ -2258,9 +2315,9 @@ return (
-

PCI Devices

+

{t("hardware.sections.pciDevices")}

- {hardwareData.pci_devices.length} devices + {t("hardware.counts.devices", { count: hardwareData.pci_devices.length })}
@@ -2272,13 +2329,13 @@ return ( className="cursor-pointer rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-3 transition-colors" >
- {device.type} + {translateDeviceType(device.type, t)} {device.slot}

{device.device}

{device.vendor}

{device.driver && ( -

Driver: {device.driver}

+

{t("hardware.labels.driver")}: {device.driver}

)}
))} @@ -2291,53 +2348,53 @@ return ( {selectedPCIDevice?.device} - PCI Device Information + {t("hardware.pci.deviceInformation")} {selectedPCIDevice && (
- Device Type - {selectedPCIDevice.type} + {t("hardware.labels.deviceType")} + {translateDeviceType(selectedPCIDevice.type, t)}
- PCI Slot + {t("hardware.labels.pciSlot")} {selectedPCIDevice.slot}
- Device Name + {t("hardware.labels.deviceName")} {selectedPCIDevice.device}
{selectedPCIDevice.sdevice && (
- Product Name + {t("hardware.labels.productName")} {selectedPCIDevice.sdevice}
)}
- Vendor + {t("hardware.labels.vendor")} {selectedPCIDevice.vendor}
- Class + {t("hardware.labels.class")} {selectedPCIDevice.class}
{selectedPCIDevice.driver && (
- Driver + {t("hardware.labels.driver")} {selectedPCIDevice.driver}
)} {selectedPCIDevice.kernel_module && (
- Kernel Module + {t("hardware.labels.kernelModule")} {selectedPCIDevice.kernel_module}
)} @@ -2352,9 +2409,11 @@ return (
-

Network Summary

+

{t("hardware.sections.networkSummary")}

- {hardwareData.pci_devices.filter((d) => d.type.toLowerCase().includes("network")).length} interfaces + {t("hardware.counts.interfaces", { + count: hardwareData.pci_devices.filter((d) => d.type.toLowerCase().includes("network")).length, + })}
@@ -2376,12 +2435,12 @@ return ( : "bg-blue-500/10 text-blue-500 border-blue-500/20 px-2.5 py-0.5 shrink-0" } > - {device.network_subtype || "Ethernet"} + {translateDeviceType(device.network_subtype || "Ethernet", t)}

{device.vendor}

{device.driver && ( -

Driver: {device.driver}

+

{t("hardware.labels.driver")}: {device.driver}

)}
))} @@ -2394,41 +2453,41 @@ return ( {selectedNetwork?.device} - Network Interface Information + {t("hardware.network.interfaceInformation")} {selectedNetwork && (
- Device Type - {selectedNetwork.type} + {t("hardware.labels.deviceType")} + {translateDeviceType(selectedNetwork.type, t)}
- PCI Slot + {t("hardware.labels.pciSlot")} {selectedNetwork.slot}
- Vendor + {t("hardware.labels.vendor")} {selectedNetwork.vendor}
- Class + {t("hardware.labels.class")} {selectedNetwork.class}
{selectedNetwork.driver && (
- Driver + {t("hardware.labels.driver")} {selectedNetwork.driver}
)} {selectedNetwork.kernel_module && (
- Kernel Module + {t("hardware.labels.kernelModule")} {selectedNetwork.kernel_module}
)} @@ -2442,15 +2501,14 @@ return (
-

Storage Summary

+

{t("hardware.sections.storageSummary")}

- { - hardwareData.storage_devices.filter( + {t("hardware.counts.devices", { + count: hardwareData.storage_devices.filter( (device) => device.type === "disk" && !device.name.startsWith("zd") && !device.name.startsWith("loop"), - ).length - }{" "} - devices + ).length, + })}
@@ -2549,7 +2607,7 @@ return ( {device.name} {diskBadge.label}
- {device.size &&

{formatMemory(parseLsblkSize(device.size))}

} + {device.size &&

{formatMemory(parseLsblkSize(device.size), t)}

} {device.model && (

{device.model}

)} @@ -2557,7 +2615,7 @@ return (
{linkSpeed.text} {linkSpeed.maxText && linkSpeed.isWarning && ( - (max: {linkSpeed.maxText}) + {t("hardware.values.max", { value: linkSpeed.maxText })} )}
)} @@ -2573,18 +2631,18 @@ return ( {selectedDisk?.name} - Storage Device Hardware Information + {t("hardware.storage.deviceHardwareInformation")} {selectedDisk && (
- Device Name + {t("hardware.labels.deviceName")} {selectedDisk.name}
- Type + {t("hardware.labels.type")} {(() => { const diskType = getDiskType(selectedDisk.name, selectedDisk.rotation_rate) const badgeStyles: Record = { @@ -2608,14 +2666,14 @@ return ( {selectedDisk.size && (
- Capacity - {formatMemory(parseLsblkSize(selectedDisk.size))} + {t("hardware.labels.capacity")} + {formatMemory(parseLsblkSize(selectedDisk.size), t)}
)}

- Interface Information + {t("hardware.sections.interfaceInformation")}

@@ -2625,7 +2683,7 @@ return ( {selectedDisk.pcie_gen || selectedDisk.pcie_width ? ( <>
- Current Link Speed + {t("hardware.labels.currentLinkSpeed")} {selectedDisk.pcie_max_gen && selectedDisk.pcie_max_width && (
- Maximum Link Speed + {t("hardware.labels.maximumLinkSpeed")} {selectedDisk.pcie_max_gen} {selectedDisk.pcie_max_width} @@ -2650,8 +2708,8 @@ return ( ) : (
- PCIe Link Speed - Detecting... + {t("hardware.labels.pcieLinkSpeed")} + {t("hardware.values.detecting")}
)} @@ -2660,7 +2718,7 @@ return ( {/* SATA Information */} {!selectedDisk.name.startsWith("nvme") && selectedDisk.sata_version && (
- SATA Version + {t("hardware.labels.sataVersion")} {selectedDisk.sata_version}
)} @@ -2668,13 +2726,13 @@ return ( {/* SAS Information */} {!selectedDisk.name.startsWith("nvme") && selectedDisk.sas_version && (
- SAS Version + {t("hardware.labels.sasVersion")} {selectedDisk.sas_version}
)} {!selectedDisk.name.startsWith("nvme") && selectedDisk.sas_speed && (
- SAS Speed + {t("hardware.labels.sasSpeed")} {selectedDisk.sas_speed}
)} @@ -2686,71 +2744,71 @@ return ( !selectedDisk.sata_version && !selectedDisk.sas_version && (
- Link Speed + {t("hardware.labels.linkSpeed")} {selectedDisk.link_speed}
)} {selectedDisk.model && (
- Model + {t("hardware.labels.model")} {selectedDisk.model}
)} {selectedDisk.family && (
- Family + {t("hardware.labels.family")} {selectedDisk.family}
)} {selectedDisk.serial && (
- Serial Number + {t("hardware.labels.serialNumber")} {selectedDisk.serial}
)} {selectedDisk.firmware && (
- Firmware + {t("hardware.labels.firmware")} {selectedDisk.firmware}
)} {selectedDisk.interface && (
- Interface + {t("hardware.labels.interface")} {selectedDisk.interface}
)} {selectedDisk.driver && (
- Driver + {t("hardware.labels.driver")} {selectedDisk.driver}
)} {selectedDisk.rotation_rate !== undefined && selectedDisk.rotation_rate !== null && (
- Rotation Rate + {t("hardware.labels.rotationRate")}
{typeof selectedDisk.rotation_rate === "number" && selectedDisk.rotation_rate === -1 - ? "N/A" + ? t("common.notAvailable") : typeof selectedDisk.rotation_rate === "number" && selectedDisk.rotation_rate > 0 ? `${selectedDisk.rotation_rate} rpm` : typeof selectedDisk.rotation_rate === "string" ? selectedDisk.rotation_rate - : "Solid State Device"} + : t("hardware.values.solidStateDevice")}
)} {selectedDisk.form_factor && (
- Form Factor + {t("hardware.labels.formFactor")} {selectedDisk.form_factor}
)} @@ -2766,9 +2824,9 @@ return (
-

USB Devices

+

{t("hardware.sections.usbDevices")}

- {hardwareData.usb_devices.length} device{hardwareData.usb_devices.length > 1 ? "s" : ""} + {t("hardware.counts.devices", { count: hardwareData.usb_devices.length })}
@@ -2784,7 +2842,7 @@ return ( {usb.name} - {usb.class_label} + {translateDeviceType(usb.class_label, t)}
@@ -2793,7 +2851,7 @@ return ( {usb.bus_device} · {usb.vendor_id}:{usb.product_id}
{usb.driver && ( -
Driver: {usb.driver}
+
{t("hardware.labels.driver")}: {usb.driver}
)}
@@ -2807,37 +2865,37 @@ return ( {selectedUsbDevice?.name} - USB Device Information + {t("hardware.usb.deviceInformation")} {selectedUsbDevice && (
- Class + {t("hardware.labels.class")} - {selectedUsbDevice.class_label} + {translateDeviceType(selectedUsbDevice.class_label, t)}
- Bus:Device + {t("hardware.labels.busDevice")} {selectedUsbDevice.bus_device}
- Device Name + {t("hardware.labels.deviceName")} {selectedUsbDevice.name}
{selectedUsbDevice.vendor && (
- Vendor + {t("hardware.labels.vendor")} {selectedUsbDevice.vendor}
)}
- Vendor / Product ID + {t("hardware.labels.vendorProductId")} {selectedUsbDevice.vendor_id}:{selectedUsbDevice.product_id} @@ -2845,7 +2903,7 @@ return ( {selectedUsbDevice.speed_label && (
- Speed + {t("hardware.labels.speed")} {selectedUsbDevice.speed_label} {selectedUsbDevice.speed_mbps > 0 && ( @@ -2856,20 +2914,20 @@ return ( )}
- Class Code + {t("hardware.labels.classCode")} 0x{selectedUsbDevice.class_code}
{selectedUsbDevice.driver && (
- Driver + {t("hardware.labels.driver")} {selectedUsbDevice.driver}
)} {selectedUsbDevice.serial && (
- Serial + {t("hardware.labels.serial")} {selectedUsbDevice.serial}
)} @@ -2881,8 +2939,8 @@ return ( {/* NVIDIA Installation Monitor */} {/* { setNvidiaSessionId(null) mutateStatic() @@ -2904,8 +2962,8 @@ return ( params={{ EXECUTION_MODE: "web", }} - title="NVIDIA Driver Installation" - description="Installing NVIDIA proprietary drivers for GPU monitoring..." + title={t("hardware.scripts.nvidiaTitle")} + description={t("hardware.scripts.nvidiaDescription")} /> {/* GPU Switch Mode Modal */} @@ -2961,8 +3019,13 @@ title="AMD GPU Tools Installation" EXECUTION_MODE: "web", GPU_SWITCH_PARAMS: `${switchModeParams.gpuSlot}|${switchModeParams.targetMode}`, }} - title={`GPU Switch Mode → ${switchModeParams.targetMode.toUpperCase()}`} - description={`Switching GPU ${switchModeParams.gpuSlot} to ${switchModeParams.targetMode === "vm" ? "VM (VFIO passthrough)" : "LXC (native driver)"} mode...`} + title={t("hardware.scripts.switchModeTitle", { mode: switchModeParams.targetMode.toUpperCase() })} + description={t("hardware.scripts.switchModeDescription", { + slot: switchModeParams.gpuSlot, + mode: switchModeParams.targetMode === "vm" + ? t("hardware.scripts.switchModeVm") + : t("hardware.scripts.switchModeLxc"), + })} /> )}
diff --git a/AppImage/components/health-status-modal.tsx b/AppImage/components/health-status-modal.tsx index 037ced01..014c9b85 100644 --- a/AppImage/components/health-status-modal.tsx +++ b/AppImage/components/health-status-modal.tsx @@ -41,6 +41,7 @@ import { HelpCircle, } from "lucide-react" import { ScriptTerminalModal } from "./script-terminal-modal" +import { useT } from "@/lib/i18n/provider" interface CategoryCheck { status: string @@ -104,19 +105,20 @@ interface HealthStatusModalProps { } const CATEGORIES = [ - { key: "cpu", category: "temperature", label: "CPU Usage & Temperature", Icon: Cpu }, - { key: "memory", category: "memory", label: "Memory & Swap", Icon: MemoryStick }, - { key: "storage", category: "storage", label: "Storage Mounts & Space", Icon: HardDrive }, - { key: "disks", category: "disks", label: "Disk I/O & Errors", Icon: Disc }, - { key: "network", category: "network", label: "Network Interfaces", Icon: Network }, - { key: "vms", category: "vms", label: "VMs & Containers", Icon: Box }, - { key: "services", category: "pve_services", label: "PVE Services", Icon: Settings }, - { key: "logs", category: "logs", label: "System Logs", Icon: FileText }, - { key: "updates", category: "updates", label: "System Updates", Icon: RefreshCw }, - { key: "security", category: "security", label: "Security & Certificates", Icon: Shield }, + { key: "cpu", category: "temperature", Icon: Cpu }, + { key: "memory", category: "memory", Icon: MemoryStick }, + { key: "storage", category: "storage", Icon: HardDrive }, + { key: "disks", category: "disks", Icon: Disc }, + { key: "network", category: "network", Icon: Network }, + { key: "vms", category: "vms", Icon: Box }, + { key: "services", category: "pve_services", Icon: Settings }, + { key: "logs", category: "logs", Icon: FileText }, + { key: "updates", category: "updates", Icon: RefreshCw }, + { key: "security", category: "security", Icon: Shield }, ] export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) { + const t = useT() const [loading, setLoading] = useState(true) const [healthData, setHealthData] = useState(null) const [dismissedItems, setDismissedItems] = useState([]) @@ -146,7 +148,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu if (!response.ok) { // Fallback to legacy endpoint const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders }) - if (!legacyResponse.ok) throw new Error("Failed to fetch health details") + if (!legacyResponse.ok) throw new Error(t("healthStatus.errors.fetchFailed")) const data = await legacyResponse.json() setHealthData(data) setDismissedItems([]) @@ -203,11 +205,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu }) window.dispatchEvent(event) } catch (err) { - setError(err instanceof Error ? err.message : "Unknown error") + setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown")) } finally { setLoading(false) } - }, [getApiUrl]) + }, [getApiUrl, t]) // Tick counter to force re-render every 30s so "X minutes ago" stays current const [, setTick] = useState(0) @@ -280,20 +282,90 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu const statusUpper = status?.toUpperCase() switch (statusUpper) { case "OK": - return OK + return {t("healthStatus.status.ok")} case "INFO": - return Info + return {t("healthStatus.status.info")} case "WARNING": - return Warning + return {t("healthStatus.status.warning")} case "CRITICAL": - return Critical + return {t("healthStatus.status.critical")} case "UNKNOWN": - return UNKNOWN + return {t("healthStatus.status.unknown")} default: - return Unknown + return {t("healthStatus.status.unknown")} } } + const formatStatus = (status: string) => { + const key = status?.toLowerCase() + return ["ok", "info", "warning", "critical", "unknown"].includes(key) + ? t(`healthStatus.status.${key}`) + : status + } + + const translateHealthText = (value?: string): string => { + if (!value) return "" + const exact: Record = { + "All systems operational": t("healthStatus.details.allOperational"), + "Normal": t("healthStatus.details.normal"), + "No I/O errors in dmesg": t("healthStatus.details.noIoErrors"), + "Mounted read-write, space OK": t("healthStatus.details.rootFilesystemOk"), + "No SMART warnings in journal": t("healthStatus.details.noSmartWarnings"), + "No critical errors": t("healthStatus.details.noCriticalErrors"), + "No cascading errors": t("healthStatus.details.noCascadingErrors"), + "No error spikes": t("healthStatus.details.noErrorSpikes"), + "No persistent patterns": t("healthStatus.details.noPersistentPatterns"), + "Certificate valid": t("healthStatus.details.certificateValid"), + "Cluster detected (corosync.conf present)": t("healthStatus.details.clusterDetected"), + "Active": t("healthStatus.details.active"), + "UP": t("healthStatus.details.up"), + "Kernel/PVE up to date": t("healthStatus.details.kernelUpToDate"), + "Proxmox VE is up to date": t("healthStatus.details.proxmoxUpToDate"), + "No security updates pending": t("healthStatus.details.noSecurityUpdates"), + "No container startup errors": t("healthStatus.details.noContainerErrors"), + "No OOM events detected": t("healthStatus.details.noOomEvents"), + "No QMP timeouts detected": t("healthStatus.details.noQmpTimeouts"), + "No VM startup failures": t("healthStatus.details.noVmFailures"), + "Dismissed by user": t("healthStatus.details.dismissedByUser"), + } + if (exact[value]) return exact[value] + + let match = value.match(/^Latency ([\d.]+)ms to gateway$/) + if (match) return t("healthStatus.details.gatewayLatency", { latency: match[1] }) + match = value.match(/^(\d+) failed login attempts in 24h$/) + if (match) return t("healthStatus.details.failedLogins", { count: match[1] }) + match = value.match(/^(\d+) IP\(s\) currently banned by Fail2Ban \(jails: (.+)\)$/) + if (match) return t("healthStatus.details.fail2banBannedIps", { count: match[1], jails: match[2] }) + match = value.match(/^Uptime (\d+) days?$/) + if (match) return t("healthStatus.details.uptimeDays", { count: match[1] }) + match = value.match(/^(\d+) package\(s\) pending$/) + if (match) return t("healthStatus.details.pendingPackages", { count: match[1] }) + match = value.match(/^Last updated (\d+) day\(s\) ago$/) + if (match) return t("healthStatus.details.updatedDaysAgo", { count: match[1] }) + match = value.match(/^(.+) storage available$/) + if (match) return t("healthStatus.details.storageAvailable", { type: match[1] }) + match = value.match(/^(.+) mount reachable$/) + if (match) return t("healthStatus.details.mountReachable", { type: match[1] }) + match = value.match(/^rootfs ([\d.]+)% used \((.+)\)$/) + if (match) return t("healthStatus.details.rootfsUsed", { percent: match[1], size: match[2] }) + match = value.match(/^(\d+) running CT\(s\) within safe rootfs usage$/) + if (match) return t("healthStatus.details.runningCtsSafe", { count: match[1] }) + match = value.match(/^(\d+) PVE block storage\(s\) within safe usage$/) + if (match) return t("healthStatus.details.pveStorageSafe", { count: match[1] }) + match = value.match(/^(\d+) remote mount\(s\) healthy$/) + if (match) return t("healthStatus.details.remoteMountsHealthy", { count: match[1] }) + return value + } + + const formatDuration = (hours: number) => { + if (hours === -1) return t("healthStatus.permanent") + if (hours >= 8760) return t("healthStatus.duration.years", { count: Math.floor(hours / 8760) }) + if (hours >= 720) return t("healthStatus.duration.months", { count: Math.floor(hours / 720) }) + if (hours >= 168) return t("healthStatus.duration.weeks", { count: Math.floor(hours / 168) }) + if (hours >= 24) return t("healthStatus.duration.days", { count: Math.floor(hours / 24) }) + return t("healthStatus.duration.hours", { count: Math.round(hours) }) + } + // Get categories that have dismissed items (to show as INFO) const getCategoriesWithDismissed = () => { const customCats = new Set(customSuppressions.map(cs => cs.category)) @@ -444,11 +516,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu const now = new Date() const diffMs = now.getTime() - checkTime.getTime() const diffMin = Math.floor(diffMs / 60000) - if (diffMin < 1) return "just now" - if (diffMin === 1) return "1 minute ago" - if (diffMin < 60) return `${diffMin} minutes ago` + if (diffMin < 1) return t("healthStatus.time.justNow") + if (diffMin === 1) return t("healthStatus.time.oneMinuteAgo") + if (diffMin < 60) return t("healthStatus.time.minutesAgo", { count: diffMin }) const diffHours = Math.floor(diffMin / 60) - return `${diffHours}h ${diffMin % 60}m ago` + return t("healthStatus.time.hoursMinutesAgo", { hours: diffHours, minutes: diffMin % 60 }) } const getCategoryRowStyle = (status: string) => { @@ -471,49 +543,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu } const formatCheckLabel = (key: string): string => { - const labels: Record = { - // CPU - cpu_usage: "CPU Usage", - cpu_temperature: "Temperature", - // Memory - ram_usage: "RAM Usage", - swap_usage: "Swap Usage", - // Disk I/O - root_filesystem: "Root Filesystem", - smart_health: "SMART Health", - io_errors: "I/O Errors", - zfs_pools: "ZFS Pools", - lvm_volumes: "LVM Volumes", - lvm_check: "LVM Status", - // Network - connectivity: "Connectivity", - // VMs & CTs - qmp_communication: "QMP Communication", - container_startup: "Container Startup", - vm_startup: "VM Startup", - oom_killer: "OOM Killer", - // Services - cluster_mode: "Cluster Mode", - // Logs (prefixed with log_) - log_error_cascade: "Error Cascade", - log_error_spike: "Error Spike", - log_persistent_errors: "Persistent Errors", - log_critical_errors: "Critical Errors", - // Updates - pve_version: "Proxmox VE Version", - security_updates: "Security Updates", - system_age: "System Age", - pending_updates: "Pending Updates", - kernel_pve: "Kernel / PVE", - // Security - uptime: "Uptime", - certificates: "Certificates", - login_attempts: "Login Attempts", - fail2ban: "Fail2Ban", - // Storage (Proxmox) - proxmox_storages: "Proxmox Storages", - } - if (labels[key]) return labels[key] + const knownKeys = new Set([ + "cpu_usage", "cpu_temperature", "ram_usage", "swap_usage", "root_filesystem", + "smart_health", "io_errors", "zfs_pools", "lvm_volumes", "lvm_check", "connectivity", + "qmp_communication", "container_startup", "vm_startup", "oom_killer", "cluster_mode", + "log_error_cascade", "log_error_spike", "log_persistent_errors", "log_critical_errors", + "pve_version", "security_updates", "system_age", "pending_updates", "kernel_pve", "uptime", + "certificates", "login_attempts", "fail2ban", "proxmox_storages", + ]) + if (knownKeys.has(key)) return t(`healthStatus.checks.${key}`) // Convert snake_case or camelCase to Title Case return key .replace(/_/g, " ") @@ -543,15 +581,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{getStatusIcon(checkData.dismissed ? "INFO" : checkData.status, "sm")} {formatCheckLabel(checkKey)} - {checkData.detail} + {translateHealthText(checkData.detail)} {checkData.dismissed && ( checkData.permanent ? ( - Permanent + {t("healthStatus.permanent")} ) : ( - Dismissed + {t("healthStatus.dismissed")} ) )} @@ -563,6 +601,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu handleAcknowledge(checkData.error_key || checkKey, hours) } busy={dismissingKey === (checkData.error_key || checkKey)} + t={t} /> )}
@@ -582,12 +621,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
- System Health Status + {t("healthStatus.title")} {healthData &&
{getStatusBadge(healthData.overall)}
}
- Detailed health checks for all system components + {t("healthStatus.description")} {getTimeSinceCheck() && ( @@ -605,7 +644,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu {error && (
-

Error loading health status

+

{t("healthStatus.errors.loading")}

{error}

)} @@ -616,47 +655,47 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
0 ? "grid-cols-5" : "grid-cols-4"}`}>
{stats.total}
-
Total
+
{t("healthStatus.stats.total")}
{stats.healthy}
-
Healthy
+
{t("healthStatus.stats.healthy")}
{stats.info > 0 && (
{stats.info}
-
Info
+
{t("healthStatus.stats.info")}
)}
{stats.warnings}
-
Warn
+
{t("healthStatus.stats.warning")}
{stats.critical}
-
Critical
+
{t("healthStatus.stats.critical")}
{stats.unknown > 0 && (
{stats.unknown}
-
Unknown
+
{t("healthStatus.stats.unknown")}
)}
{healthData.summary && healthData.summary !== "All systems operational" && (
-

{healthData.summary}

+

{translateHealthText(healthData.summary)}

)} {/* Category List */}
- {CATEGORIES.map(({ key, label, Icon }) => { + {CATEGORIES.map(({ key, Icon }) => { const categoryData = healthData.details[key as keyof typeof healthData.details] const originalStatus = categoryData?.status || "UNKNOWN" const status = getEffectiveStatus(key, originalStatus) - const reason = categoryData?.reason + const reason = translateHealthText(categoryData?.reason) const checks = categoryData?.checks const isExpanded = expandedCategories.has(key) const hasChecks = checks && Object.keys(checks).length > 0 @@ -677,7 +716,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
-

{label}

+

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

{hasChecks && ( ({Object.values(checks).filter(c => c.installed !== false).length}) @@ -690,7 +729,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
- {status} + {formatStatus(status)} )}
@@ -722,7 +762,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu ) : (
- No issues detected + {t("healthStatus.noIssues")}
)} {/* Only offer "Update Now" when the category is not @@ -738,7 +778,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu className="bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200" > - Update Now + {t("healthStatus.updateNow")}
)} @@ -758,12 +798,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
- Dismissed Items ({filteredDismissed.length}) + {t("healthStatus.dismissedItems", { count: filteredDismissed.length })}
{filteredDismissed.map((item) => { const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category) const CatIcon = catMeta?.Icon || BellOff - const catLabel = catMeta?.label || item.category + const catLabel = catMeta ? t(`healthStatus.categories.${catMeta.key}`) : item.category const isPermanent = item.permanent || item.suppression_remaining_hours === -1 return ( @@ -778,34 +818,28 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu

{catLabel}

-

{item.reason}

+

{translateHealthText(item.reason)}

{isPermanent ? ( - Permanent + {t("healthStatus.permanent")} ) : ( - Dismissed + {t("healthStatus.dismissed")} )} - was {item.severity} + {t("healthStatus.wasStatus", { status: formatStatus(item.severity) })}

{isPermanent - ? "Permanently suppressed" - : `Suppressed for ${ - item.suppression_remaining_hours < 24 - ? `${Math.round(item.suppression_remaining_hours)}h` - : item.suppression_remaining_hours < 720 - ? `${Math.round(item.suppression_remaining_hours / 24)} days` - : `${Math.round(item.suppression_remaining_hours / 720)} month(s)` - } more` + ? t("healthStatus.permanentlySuppressed") + : t("healthStatus.suppressedForMore", { duration: formatDuration(item.suppression_remaining_hours) }) }

@@ -821,30 +855,20 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
- Custom Suppression Settings + {t("healthStatus.customSuppressionSettings")}
{customSuppressions.map((cs) => { - const catMeta = CATEGORIES.find(c => c.category === cs.category || c.key === cs.category || c.label === cs.label) + const catMeta = CATEGORIES.find(c => c.category === cs.category || c.key === cs.category) const CatIcon = catMeta?.Icon || Settings2 - const durationLabel = cs.hours === -1 - ? "Permanent" - : cs.hours >= 8760 - ? `${Math.floor(cs.hours / 8760)} year(s)` - : cs.hours >= 720 - ? `${Math.floor(cs.hours / 720)} month(s)` - : cs.hours >= 168 - ? `${Math.floor(cs.hours / 168)} week(s)` - : cs.hours >= 72 - ? `${Math.floor(cs.hours / 24)} days` - : `${cs.hours}h` + const durationLabel = formatDuration(cs.hours) return (
- {cs.label} + {catMeta ? t(`healthStatus.categories.${catMeta.key}`) : cs.label}
{durationLabel} @@ -854,7 +878,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu })}

- Alerts in these categories are auto-suppressed when detected. + {t("healthStatus.autoSuppressedHint")}

@@ -862,7 +886,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu {healthData.timestamp && (
- Last updated: {new Date(healthData.timestamp).toLocaleString()} + {t("healthStatus.lastUpdated", { date: new Date(healthData.timestamp).toLocaleString(document.documentElement.lang) })}
)}
@@ -882,8 +906,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu params={{ EXECUTION_MODE: "web", }} - title="Proxmox System Update" - description="Runs apt-get update + dist-upgrade and post-update cleanup on the host." + title={t("healthStatus.updateTerminalTitle")} + description={t("healthStatus.updateTerminalDescription")} /> ) @@ -896,9 +920,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu function DismissDropdown({ onSelect, busy, + t, }: { onSelect: (suppressionHours: number) => void busy: boolean + t: ReturnType }) { return ( @@ -915,27 +941,27 @@ function DismissDropdown({ ) : ( <> - Dismiss + {t("healthStatus.dismiss")} )} e.stopPropagation()}> - Silence this alert for + {t("healthStatus.silenceFor")} onSelect(24)} className="text-xs"> - 24 hours + {t("healthStatus.duration.24hours")} onSelect(168)} className="text-xs"> - 7 days + {t("healthStatus.duration.7days")} onSelect(-1)} className="text-xs text-red-500 focus:text-red-500 focus:bg-red-500/10" > - Permanently + {t("healthStatus.permanently")} diff --git a/AppImage/components/health-thresholds.tsx b/AppImage/components/health-thresholds.tsx index 935cc73c..2106412d 100644 --- a/AppImage/components/health-thresholds.tsx +++ b/AppImage/components/health-thresholds.tsx @@ -20,6 +20,7 @@ import { Waves, } from "lucide-react" import { getApiUrl, getAuthToken } from "../lib/api-config" +import { useT } from "../lib/i18n/provider" // Local fetch wrapper that *preserves* the JSON body on non-2xx // responses so we can surface backend validation messages @@ -282,6 +283,11 @@ function computeVisualRange( // ─── Component ─────────────────────────────────────────────────────────────── export function HealthThresholds() { + const t = useT() + const tFallback = (key: string, fallback: string) => { + const translated = t(key) + return translated === key ? fallback : translated + } const [tree, setTree] = useState(null) const [loading, setLoading] = useState(true) const [editMode, setEditMode] = useState(false) @@ -299,7 +305,7 @@ export function HealthThresholds() { ) if (res?.success && res.thresholds) setTree(res.thresholds) } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load thresholds") + setError(err instanceof Error ? err.message : t("settings.healthThresholds.loadFailed")) } finally { setLoading(false) } @@ -322,7 +328,7 @@ export function HealthThresholds() { if (trimmed === "") continue const num = Number(trimmed) if (!isFinite(num)) { - setError(`Invalid value for ${key}: must be a number`) + setError(t("settings.healthThresholds.invalidValue", { key })) return null } // Walk into payload mirroring the path @@ -362,7 +368,7 @@ export function HealthThresholds() { { method: "PUT", body: JSON.stringify(payload) }, ) if (!data.success || !data.thresholds) { - setError(data.message || "Save failed") + setError(data.message || t("status.saveFailed")) return } setTree(data.thresholds) @@ -371,14 +377,16 @@ export function HealthThresholds() { setSavedFlash(true) setTimeout(() => setSavedFlash(false), 2000) } catch (err) { - setError(err instanceof Error ? err.message : "Network error while saving") + setError(err instanceof Error ? err.message : t("status.networkErrorWhileSaving")) } finally { setSaving(false) } } const handleResetSection = async (sectionId: string) => { - if (!confirm(`Reset all "${SECTIONS.find((s) => s.id === sectionId)?.title}" thresholds to recommended values?`)) + const section = SECTIONS.find((s) => s.id === sectionId) + const sectionTitle = section ? tFallback(`settings.healthThresholds.sections.${section.id}.title`, section.title) : sectionId + if (!confirm(t("settings.healthThresholds.resetSectionConfirm", { section: sectionTitle }))) return try { const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>( @@ -386,7 +394,7 @@ export function HealthThresholds() { { method: "POST" }, ) if (!data.success || !data.thresholds) { - setError(data.message || "Reset failed") + setError(data.message || t("settings.healthThresholds.resetFailed")) return } setTree(data.thresholds) @@ -400,25 +408,25 @@ export function HealthThresholds() { return next }) } catch (err) { - setError(err instanceof Error ? err.message : "Network error while resetting") + setError(err instanceof Error ? err.message : t("settings.healthThresholds.networkErrorWhileResetting")) } } const handleResetAll = async () => { - if (!confirm("Reset ALL thresholds to recommended values? This affects every section.")) return + if (!confirm(t("settings.healthThresholds.resetAllConfirm"))) return try { const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>( "/api/health/thresholds/reset", { method: "POST" }, ) if (!data.success || !data.thresholds) { - setError(data.message || "Reset failed") + setError(data.message || t("settings.healthThresholds.resetFailed")) return } setTree(data.thresholds) setPending({}) } catch (err) { - setError(err instanceof Error ? err.message : "Network error while resetting") + setError(err instanceof Error ? err.message : t("settings.healthThresholds.networkErrorWhileResetting")) } } @@ -441,7 +449,7 @@ export function HealthThresholds() { const isCustomised = leaf.customised && !(key in pending) const customisedClass = "border-blue-500 bg-blue-500/10 focus-visible:border-blue-500" const fieldClass = isCustomised ? customisedClass : severityClass - const recommendedTooltip = `Recommended: ${leaf.recommended}${leaf.unit}` + const recommendedTooltip = `${t("settings.healthThresholds.recommended")}: ${leaf.recommended}${leaf.unit}` return (
@@ -524,12 +532,12 @@ export function HealthThresholds() { value={val} onChange={(e) => setPending((p) => ({ ...p, [key]: e.target.value }))} 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]: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]:border-2 [&::-moz-range-thumb]:border-background ${handleClass}`} - title={`Recommended: ${leaf.recommended}${unit}`} + title={`${t("settings.healthThresholds.recommended")}: ${leaf.recommended}${unit}`} />
- OK < {val}{unit} - {severity === "critical" ? "CRIT" : "WARN"} > {val}{unit} + {t("settings.healthThresholds.ok")} < {val}{unit} + {severity === "critical" ? t("settings.healthThresholds.crit") : t("settings.healthThresholds.warn")} > {val}{unit}
) @@ -641,7 +649,7 @@ export function HealthThresholds() { value={wVal} onChange={(e) => setVal(wKey, Number(e.target.value), cVal, true)} 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-amber-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-amber-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background" - title={`Warning (recommended: ${wLeaf.recommended}${unit})`} + title={`${t("settings.healthThresholds.warning")} (${t("settings.healthThresholds.recommended").toLowerCase()}: ${wLeaf.recommended}${unit})`} /> 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..827b30a7 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 { useI18n, 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) @@ -375,6 +380,23 @@ const methodBadgeCls = (m: string | undefined): string => { } } +const localizedBackendLabel = (source: string, t: (key: string) => string): string => + source === "pbs" ? "PBS" : source === "borg" ? "Borg" : t("backup.backends.local") + +const formatCalendarPreview = (value: string, language: string, t: (key: string) => string): string => { + if (language !== "sk") return value + const match = value.match(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(\d{4})-(\d{2})-(\d{2})\s+(.+)$/i) + if (!match) return value + const [, weekday, year, month, day, rest] = match + return `${t(`backup.weekdays.short.${weekday.toLowerCase()}`)} ${Number(day)}. ${Number(month)}. ${year} ${rest}` +} + +const formatCalendarDistance = (value: string, language: string): string => { + if (language !== "sk") return value + const distance = value.replace(/\s+left$/i, "").replace(/\bdays?\b/gi, "d") + return `zostáva ${distance}` +} + const formatRunAt = (iso: string | null) => { if (!iso) return null try { @@ -414,6 +436,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 +493,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 +546,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 +568,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 +593,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 +605,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 +616,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 +633,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 +668,13 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) { )}
- + @@ -662,24 +685,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 +718,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 +733,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 +767,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 +791,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 +815,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 +848,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 +900,7 @@ function KeyfileActionsBar({ onClick={download} > - Download keyfile + {t("backup.keyfileManagement.downloadKeyfile")}
@@ -887,6 +911,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 +946,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 +1034,7 @@ export function HostBackup() {
- Scheduled Backup Jobs + {t("backup.jobs.scheduledTitle")} {jobsResp?.jobs?.filter((j) => !j.manual).length ?? 0} @@ -1020,16 +1045,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 +1071,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 +1084,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 +1095,19 @@ export function HostBackup() { {j.manual && ( - manual + {t("backup.status.manual")} )} {j.attached && ( - attached + {t("backup.status.attached")} )} {j.encrypted && ( @@ -1101,15 +1126,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 +1149,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 +1182,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 +1203,7 @@ export function HostBackup() { {!status && ( - never run + {t("backup.jobs.neverRun")} )}
@@ -1204,19 +1229,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 +1256,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 +1273,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 +1292,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 +1323,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")} >
@@ -1306,12 +1331,12 @@ export function HostBackup() {
- {u.source} + {localizedBackendLabel(u.source, t)} {u.remote?.encrypted && ( @@ -1325,24 +1350,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 +1479,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 +1514,7 @@ export function HostBackup() { onClick={() => setJobToDelete(null)} disabled={busyJobId === jobToDelete?.id} > - Cancel + {t("actions.cancel")}
@@ -1526,13 +1549,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 +1671,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 +1709,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 +1725,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 +1777,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 +1801,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 +1832,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 +1850,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 +1898,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 +1957,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 +1988,7 @@ function InspectModal({ where they used to overlap the close button). */}
-
Backup
+
{t("backup.archives.backup")}
{archive && ( - {archive.source} + {localizedBackendLabel(archive.source, t)} )} {remoteArc?.encrypted && ( @@ -1992,33 +2016,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 === "default" ? t("backup.profile.default") : localArc.profile === "custom" ? t("backup.profile.custom") : localArc.profile}
} + {localArc.source_hostname &&
{t("backup.fields.sourceHostLabel")} {localArc.source_hostname}
} +
{t("backup.fields.detectedViaLabel")} {localArc.detected_via === "sidecar" ? t("backup.archives.companionFile") : 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 +2059,7 @@ function InspectModal({ {archive?.source === "local" && archiveLog && archiveLog.log_path && archiveLog.tail.length > 0 && (

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

    @@ -2043,12 +2067,12 @@ function InspectModal({
                       
    - tail · {formatBytes(archiveLog.size)} + {t("backup.logs.tail")} · {formatBytes(archiveLog.size)} {archiveLog.log_path}
    @@ -2060,14 +2084,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 +2116,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 +2129,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 +2146,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 +2195,7 @@ function InspectModal({ ) : ( )} - Import keyfile + {t("backup.actions.importKeyfile")}
@@ -2190,38 +2214,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 +2292,7 @@ function InspectModal({ - {kf ? "Restore blocked — encrypted backup" : "Restore preparation failed"} + {kf ? t("backup.restore.blockedEncryptedTitle") : t("backup.restore.preparationFailedTitle")} {!kf && ( @@ -2278,7 +2302,7 @@ function InspectModal({ {kf && }
- +
@@ -2346,11 +2370,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 +2394,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 +2409,7 @@ function InspectModal({
@@ -2405,7 +2429,7 @@ function InspectModal({ - Run log + {t("backup.logs.runLog")} {archiveLog?.log_path} @@ -2415,7 +2439,7 @@ function InspectModal({ {archiveLog?.content ?? ""}
- +
@@ -2434,25 +2458,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 +2511,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 +2532,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 +2543,7 @@ function RetentionDisplay({ retention }: { retention: Record - {it.label} + {t(it.label)} {it.value} ))} @@ -2532,10 +2558,11 @@ function RetentionDisplay({ retention }: { retention: Record
- paths + {t("backup.paths.title")} ({paths.length})
@@ -2555,6 +2582,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 +2594,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 +2637,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 +2658,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 +2796,7 @@ function CreateJobDialog({ onCreated: () => void editingJobId?: string | null }) { + const { t, language } = useI18n() const isEdit = !!editingJobId const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1) const [jobId, setJobId] = useState("") @@ -3168,12 +3197,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 +3242,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 +3326,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 +3348,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 +3408,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 +3423,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 +3460,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 +3488,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 +3507,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 +3562,7 @@ function CreateJobDialog({ {scheduleType === "weekly" && (
- +
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((d) => { const active = scheduleWeekdays.has(d) @@ -3555,17 +3584,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,41 +3664,41 @@ function CreateJobDialog({ <> {calendarPreview.normalized && calendarPreview.normalized !== onCalendar && (
- Normalized: + {t("backup.fields.normalizedLabel")} {calendarPreview.normalized}
)} {calendarPreview.next_elapse && (
- Next run: - {calendarPreview.next_elapse} + {t("backup.jobs.nextRunLabel")} + {formatCalendarPreview(calendarPreview.next_elapse, language, t)} {calendarPreview.from_now && ( - ({calendarPreview.from_now}) + ({formatCalendarDistance(calendarPreview.from_now, language)}) )}
)} ) : (
- 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 +3721,16 @@ function CreateJobDialog({ {step === 4 && (
- +
{profileMode === "custom" && (
- + {defaultPaths.map((p) => (