feat(monitor): add dashboard i18n infrastructure

Add the Monitor dashboard i18n provider, supported language metadata, message catalogs, fallback behavior, and the initial language selector/wiring for the AppImage UI.
This commit is contained in:
Codex
2026-08-04 17:01:02 +02:00
parent 04a028245b
commit 2109cf2508
23 changed files with 686 additions and 136 deletions
+6 -3
View File
@@ -5,6 +5,7 @@ import { GeistMono } from "geist/font/mono"
import { ThemeProvider } from "../components/theme-provider"
import { PwaRegister } from "../components/pwa-register"
import { PwaInstallPrompt } from "../components/pwa-install-prompt"
import { I18nProvider } from "../lib/i18n/provider"
import { Suspense } from "react"
import "./globals.css"
@@ -44,9 +45,11 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning>
<body className={`${GeistSans.variable} ${GeistMono.variable} antialiased bg-background text-foreground`}>
<Suspense fallback={<div>Loading...</div>}>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
{children}
</ThemeProvider>
<I18nProvider>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
{children}
</ThemeProvider>
</I18nProvider>
</Suspense>
<PwaRegister />
<PwaInstallPrompt />
+4 -2
View File
@@ -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() {
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
</div>
<div className="text-sm font-medium text-foreground">Loading...</div>
<p className="text-xs text-muted-foreground">Connecting to ProxMenux Monitor</p>
<div className="text-sm font-medium text-foreground">{t("app.loading")}</div>
<p className="text-xs text-muted-foreground">{t("app.connecting")}</p>
</div>
</div>
)
+3 -1
View File
@@ -13,6 +13,7 @@ import {
} from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { APP_VERSION } from "./release-notes-modal"
import { useT } from "../lib/i18n/provider"
// Issue #191: a dedicated About tab. Centralises project metadata
// (version, license, author) and every external link the project
@@ -111,6 +112,7 @@ function LinkCard({ row }: { row: LinkRow }) {
}
export function About() {
const t = useT()
return (
<div className="space-y-4 md:space-y-6">
{/* Hero — logo, name, version, one-line description. */}
@@ -151,7 +153,7 @@ export function About() {
const href = isPrerelease
? "https://github.com/MacRimi/ProxMenux/releases"
: "https://proxmenux.com/en/changelog"
const label = isPrerelease ? "Release notes" : "Changelog"
const label = isPrerelease ? t("about.releaseNotes") : t("about.changelog")
return (
<a
href={href}
+33 -31
View File
@@ -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) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
<DialogTitle className="sr-only">
{step === "choice" ? "Setup Dashboard Protection" : "Create Password"}
{step === "choice" ? t("authSetup.choiceTitle") : t("authSetup.passwordTitle")}
</DialogTitle>
{step === "choice" ? (
<div className="space-y-6 py-2">
@@ -222,16 +224,16 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
<Shield className="h-8 w-8 text-blue-500" />
</div>
<h2 className="text-2xl font-bold">Protect Your Dashboard?</h2>
<h2 className="text-2xl font-bold">{t("authSetup.protectTitle")}</h2>
<p className="text-muted-foreground text-sm">
Add an extra layer of security to protect your Proxmox data when accessing from non-private networks.
{t("authSetup.protectDescription")}
</p>
</div>
<div className="space-y-3">
<Button onClick={() => setStep("setup")} className="w-full bg-blue-500 hover:bg-blue-600" size="lg">
<Lock className="h-4 w-4 mr-2" />
Yes, Setup Password
{t("authSetup.setupPassword")}
</Button>
<Button
onClick={handleSkipAuth}
@@ -240,11 +242,11 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
size="lg"
disabled={loading}
>
No, Continue Without Protection
{t("authSetup.skipProtection")}
</Button>
</div>
<p className="text-xs text-center text-muted-foreground">You can always enable this later in Settings</p>
<p className="text-xs text-center text-muted-foreground">{t("authSetup.enableLater")}</p>
</div>
) : (
<div className="space-y-6 py-2">
@@ -252,8 +254,8 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
<Lock className="h-8 w-8 text-blue-500" />
</div>
<h2 className="text-2xl font-bold">Setup Authentication</h2>
<p className="text-muted-foreground text-sm">Create a username and password to protect your dashboard</p>
<h2 className="text-2xl font-bold">{t("authSetup.setupTitle")}</h2>
<p className="text-muted-foreground text-sm">{t("authSetup.setupDescription")}</p>
</div>
{error && (
@@ -266,14 +268,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username" className="text-sm">
Username
{t("authSetup.username")}
</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="username"
type="text"
placeholder="Enter username"
placeholder={t("authSetup.usernamePlaceholder")}
value={username}
onChange={(e) => setUsername(e.target.value)}
className="pl-10 text-base"
@@ -285,14 +287,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="space-y-2">
<Label htmlFor="password" className="text-sm">
Password
{t("authSetup.password")}
</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="Enter password"
placeholder={t("authSetup.passwordPlaceholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10 text-base"
@@ -312,14 +314,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="space-y-2">
<Label htmlFor="confirm-password" className="text-sm">
Confirm Password
{t("authSetup.confirmPassword")}
</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="confirm-password"
type={showConfirmPassword ? "text" : "password"}
placeholder="Confirm password"
placeholder={t("authSetup.confirmPasswordPlaceholder")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="pl-10 text-base"
@@ -345,19 +347,19 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
setup endpoint returns the JWT. */}
<div className="pt-3 border-t border-border/60 space-y-4">
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Profile · optional
{t("authSetup.profileOptional")}
</p>
<div className="space-y-2">
<Label htmlFor="display-name" className="text-sm">
Display name
{t("authSetup.displayName")}
</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="display-name"
type="text"
placeholder="Shown above the username in the menu"
placeholder={t("authSetup.displayNamePlaceholder")}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
maxLength={64}
@@ -366,12 +368,12 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
/>
</div>
<p className="text-[11px] text-muted-foreground">
Leave empty to render the username itself. Up to 64 characters.
{t("authSetup.displayNameHint")}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm">Avatar</Label>
<Label className="text-sm">{t("authSetup.avatar")}</Label>
<div className="flex items-center gap-3">
{avatarPreviewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
@@ -407,7 +409,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
className="h-7 text-xs"
>
<Upload className="h-3 w-3 mr-1.5" />
{avatarFile ? "Change" : "Choose image"}
{avatarFile ? t("authSetup.change") : t("authSetup.chooseImage")}
</Button>
{avatarFile && (
<Button
@@ -419,12 +421,12 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
className="h-7 text-xs text-red-500 hover:text-red-500 hover:bg-red-500/10"
>
<Trash2 className="h-3 w-3 mr-1.5" />
Clear
{t("authSetup.clear")}
</Button>
)}
</div>
<p className="text-[11px] text-muted-foreground">
PNG, JPEG, WebP or GIF · up to 2 MB · pre-crop square for best results.
{t("authSetup.avatarHint")}
</p>
</div>
</div>
@@ -434,10 +436,10 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="space-y-2">
<Button onClick={handleSetupAuth} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Setting up..." : "Setup Authentication"}
{loading ? t("authSetup.settingUp") : t("authSetup.setupAuthentication")}
</Button>
<Button onClick={() => setStep("choice")} variant="ghost" className="w-full" disabled={loading}>
Back
{t("authSetup.back")}
</Button>
</div>
</div>
+8 -5
View File
@@ -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
<DropdownMenuTrigger asChild>
<button
className="rounded-full hover:ring-2 hover:ring-cyan-500/30 transition-all relative z-50 focus:outline-none focus-visible:outline-none active:outline-none data-[state=open]:outline-none data-[state=open]:ring-0 select-none"
aria-label="Open user menu"
aria-label={t("actions.openUserMenu")}
// WebKit ignores `outline` for the tap-highlight overlay
// shown on iOS / Android Chrome after a touch. That overlay
// was the white border that lingered on the avatar after
@@ -248,7 +251,7 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
<div className="text-xs text-muted-foreground truncate">{username}</div>
)}
{!profile?.display_name && (
<div className="text-xs text-muted-foreground truncate">Signed in</div>
<div className="text-xs text-muted-foreground truncate">{t("account.signedIn")}</div>
)}
</div>
</div>
@@ -257,13 +260,13 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
{onOpenProfile && (
<DropdownMenuItem onClick={onOpenProfile}>
<User className="h-4 w-4 mr-2" />
View profile
{t("account.viewProfile")}
</DropdownMenuItem>
)}
{onOpenSecurity && (
<DropdownMenuItem onClick={onOpenSecurity}>
<Shield className="h-4 w-4 mr-2" />
Security
{t("account.security")}
</DropdownMenuItem>
)}
{(onOpenProfile || onOpenSecurity) && <DropdownMenuSeparator />}
@@ -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"
>
<LogOut className="h-4 w-4 mr-2" />
Sign out
{t("account.signOut")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
+20 -18
View File
@@ -9,6 +9,7 @@ import { Label } from "./ui/label"
import { Checkbox } from "./ui/checkbox"
import { Lock, User, AlertCircle, Server, Shield, Eye, EyeOff } from "lucide-react"
import { getApiUrl } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
import Image from "next/image"
interface LoginProps {
@@ -16,6 +17,7 @@ interface LoginProps {
}
export function Login({ onLogin }: LoginProps) {
const t = useT()
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [totpCode, setTotpCode] = useState("")
@@ -56,12 +58,12 @@ export function Login({ onLogin }: LoginProps) {
setError("")
if (!username || !password) {
setError("Please enter username and password")
setError(t("login.missingCredentials"))
return
}
if (requiresTotp && !totpCode) {
setError("Please enter your 2FA code")
setError(t("login.missingTotp"))
return
}
@@ -87,7 +89,7 @@ export function Login({ onLogin }: LoginProps) {
}
if (!response.ok) {
throw new Error(data.message || "Login failed")
throw new Error(data.message || t("login.loginFailed"))
}
localStorage.setItem("proxmenux-auth-token", data.token)
@@ -107,7 +109,7 @@ export function Login({ onLogin }: LoginProps) {
onLogin()
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed")
setError(err instanceof Error ? err.message : t("login.loginFailed"))
} finally {
setLoading(false)
}
@@ -139,8 +141,8 @@ export function Login({ onLogin }: LoginProps) {
</div>
</div>
<div>
<h1 className="text-3xl font-bold">ProxMenux Monitor</h1>
<p className="text-muted-foreground mt-2">Sign in to access your dashboard</p>
<h1 className="text-3xl font-bold">{t("app.title")}</h1>
<p className="text-muted-foreground mt-2">{t("login.subtitle")}</p>
</div>
</div>
@@ -157,14 +159,14 @@ export function Login({ onLogin }: LoginProps) {
<>
<div className="space-y-2">
<Label htmlFor="login-username" className="text-sm">
Username
{t("login.username")}
</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="login-username"
type="text"
placeholder="Enter your username"
placeholder={t("login.usernamePlaceholder")}
value={username}
onChange={(e) => setUsername(e.target.value)}
className="pl-10 text-base"
@@ -176,14 +178,14 @@ export function Login({ onLogin }: LoginProps) {
<div className="space-y-2">
<Label htmlFor="login-password" className="text-sm">
Password
{t("login.password")}
</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="login-password"
type={showPassword ? "text" : "password"}
placeholder="Enter your password"
placeholder={t("login.passwordPlaceholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10 pr-10 text-base"
@@ -214,7 +216,7 @@ export function Login({ onLogin }: LoginProps) {
disabled={loading}
/>
<Label htmlFor="remember-me" className="text-sm font-normal cursor-pointer select-none">
Remember me
{t("login.rememberMe")}
</Label>
</div>
</>
@@ -223,14 +225,14 @@ export function Login({ onLogin }: LoginProps) {
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 flex items-start gap-2">
<Shield className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-blue-500">Two-Factor Authentication</p>
<p className="text-xs text-blue-500 mt-1">Enter the 6-digit code from your authentication app</p>
<p className="text-sm font-medium text-blue-500">{t("login.twoFactorTitle")}</p>
<p className="text-xs text-blue-500 mt-1">{t("login.twoFactorDescription")}</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="totp-code" className="text-sm">
Authentication Code
{t("login.authenticationCode")}
</Label>
<Input
id="totp-code"
@@ -245,7 +247,7 @@ export function Login({ onLogin }: LoginProps) {
autoFocus
/>
<p className="text-xs text-muted-foreground text-center">
You can also use a backup code (format: XXXX-XXXX)
{t("login.backupCodeHint")}
</p>
</div>
@@ -260,18 +262,18 @@ export function Login({ onLogin }: LoginProps) {
}}
className="w-full"
>
Back to login
{t("login.backToLogin")}
</Button>
</div>
)}
<Button type="submit" className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Signing in..." : requiresTotp ? "Verify Code" : "Sign In"}
{loading ? t("login.signingIn") : requiresTotp ? t("login.verifyCode") : t("login.signIn")}
</Button>
</form>
</div>
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.2.4.1-beta</p>
<p className="text-center text-sm text-muted-foreground">{t("login.version")}</p>
</div>
</div>
)
@@ -212,6 +212,7 @@ const AI_PROVIDERS = [
const AI_LANGUAGES = [
{ value: "en", label: "English" },
{ value: "sk", label: "Slovenčina" },
{ value: "es", label: "Espanol" },
{ value: "fr", label: "Francais" },
{ value: "de", label: "Deutsch" },
+61 -59
View File
@@ -51,6 +51,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "./ui/dropdown-menu"
import { useT } from "../lib/i18n/provider"
interface SystemStatus {
status: "healthy" | "warning" | "critical"
@@ -80,6 +81,7 @@ interface FlaskSystemInfo {
}
export function ProxmoxDashboard() {
const t = useT()
const [systemStatus, setSystemStatus] = useState<SystemStatus>({
status: "healthy",
uptime: "Loading...",
@@ -168,7 +170,7 @@ export function ProxmoxDashboard() {
const data: FlaskSystemInfo = await fetchApi("/api/system-info")
const uptimeValue =
data.uptime && typeof data.uptime === "string" && data.uptime.trim() !== "" ? data.uptime : "N/A"
data.uptime && typeof data.uptime === "string" && data.uptime.trim() !== "" ? data.uptime : t("app.notAvailable")
const backendStatus = data.health?.status?.toUpperCase() || "OK"
let healthStatus: "healthy" | "warning" | "critical"
@@ -185,8 +187,8 @@ export function ProxmoxDashboard() {
status: healthStatus,
uptime: uptimeValue,
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
serverName: data.hostname || "Unknown",
nodeId: data.node_id || "Unknown",
serverName: data.hostname || t("app.unknown"),
nodeId: data.node_id || t("app.unknown"),
})
setIsServerConnected(true)
} catch (error) {
@@ -196,13 +198,13 @@ export function ProxmoxDashboard() {
setSystemStatus((prev) => ({
...prev,
status: "critical",
serverName: "Server Offline",
nodeId: "Server Offline",
uptime: "N/A",
serverName: t("app.serverOffline"),
nodeId: t("app.serverOffline"),
uptime: t("app.notAvailable"),
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
}))
}
}, [])
}, [t])
useEffect(() => {
// Siempre fetch inicial
@@ -362,19 +364,19 @@ export function ProxmoxDashboard() {
const getActiveTabLabel = () => {
switch (activeTab) {
case "overview": return "Overview"
case "vms": return "VMs & LXCs"
case "storage": return "Storage"
case "network": return "Network"
case "hardware": return "Hardware"
case "backup": return "Backup"
case "terminal": return "Terminal"
case "logs": return "System Logs"
case "security": return "Security"
case "settings": return "Settings"
case "about": return "About"
case "profile": return "Profile"
default: return "Navigation Menu"
case "overview": return t("navigation.overview")
case "vms": return t("navigation.virtualMachines")
case "storage": return t("navigation.storage")
case "network": return t("navigation.network")
case "hardware": return t("navigation.hardware")
case "backup": return t("navigation.backup")
case "terminal": return t("navigation.terminal")
case "logs": return t("navigation.systemLogs")
case "security": return t("navigation.security")
case "settings": return t("navigation.settings")
case "about": return t("navigation.about")
case "profile": return t("navigation.profile")
default: return t("navigation.menu")
}
}
@@ -388,13 +390,13 @@ export function ProxmoxDashboard() {
<div className="container mx-auto">
<div className="flex items-center space-x-2 text-red-500 mb-2">
<XCircle className="h-5 w-5" />
<span className="font-medium">ProxMenux Server Connection Failed</span>
<span className="font-medium">{t("status.connectionFailed")}</span>
</div>
<div className="text-sm text-red-500/80 space-y-1 ml-7">
<p> Check that the monitor.service is running correctly.</p>
<p> The ProxMenux server should start automatically on port 8008</p>
<p>&bull; {t("status.checkService")}</p>
<p>&bull; {t("status.serverPort")}</p>
<p>
Try accessing:{" "}
&bull; {t("status.tryAccessing")}{" "}
<a href={getApiUrl("/api/health")} target="_blank" rel="noopener noreferrer" className="underline">
{getApiUrl("/api/health")}
</a>
@@ -433,11 +435,11 @@ export function ProxmoxDashboard() {
<Server className="h-8 w-8 md:h-6 md:w-6 text-primary absolute fallback-icon hidden" />
</div>
<div className="min-w-0">
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">ProxMenux Monitor</h1>
<p className="text-xs md:text-sm text-muted-foreground">Proxmox System Dashboard</p>
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">{t("app.title")}</h1>
<p className="text-xs md:text-sm text-muted-foreground">{t("app.description")}</p>
<div className="lg:hidden flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
<Server className="h-3 w-3" />
<span className="truncate">Node: {systemStatus.serverName}</span>
<span className="truncate">{t("status.node", { node: systemStatus.serverName })}</span>
</div>
</div>
</div>
@@ -447,14 +449,14 @@ export function ProxmoxDashboard() {
<div className="flex items-center space-x-2">
<Server className="h-4 w-4 text-muted-foreground" />
<div className="text-sm">
<div className="font-medium text-foreground">Node: {systemStatus.serverName}</div>
<div className="font-medium text-foreground">{t("status.node", { node: systemStatus.serverName })}</div>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className={statusColor}>
{statusIcon}
<span className="ml-1 capitalize">{systemStatus.status}</span>
<span className="ml-1">{t(`status.${systemStatus.status}`)}</span>
</Badge>
{systemStatus.status === "healthy" && infoCount > 0 && (
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">
@@ -465,7 +467,7 @@ export function ProxmoxDashboard() {
</div>
<div className="text-sm text-muted-foreground whitespace-nowrap">
Uptime: {systemStatus.uptime || "N/A"}
{t("status.uptime", { uptime: systemStatus.uptime || t("app.notAvailable") })}
</div>
<Button
@@ -479,7 +481,7 @@ export function ProxmoxDashboard() {
className="border-border/50 bg-transparent hover:bg-secondary"
>
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
Refresh
{t("actions.refresh")}
</Button>
<div onClick={(e) => e.stopPropagation()}>
@@ -513,7 +515,7 @@ export function ProxmoxDashboard() {
}}
disabled={isRefreshing}
className="h-8 w-8 p-0 border-border/50 bg-transparent hover:bg-secondary"
aria-label="Refresh"
aria-label={t("actions.refresh")}
>
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
</Button>
@@ -541,7 +543,7 @@ export function ProxmoxDashboard() {
<div className="flex items-center gap-1.5">
<Badge variant="outline" className={`${statusColor} text-xs px-2`}>
{statusIcon}
<span className="ml-1 capitalize">{systemStatus.status}</span>
<span className="ml-1">{t(`status.${systemStatus.status}`)}</span>
</Badge>
{systemStatus.status === "healthy" && infoCount > 0 && (
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20 text-xs px-2">
@@ -551,7 +553,7 @@ export function ProxmoxDashboard() {
)}
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
Uptime: {systemStatus.uptime || "N/A"}
{t("status.uptime", { uptime: systemStatus.uptime || t("app.notAvailable") })}
</span>
</div>
</div>
@@ -583,15 +585,15 @@ export function ProxmoxDashboard() {
// crumb shows where you are, the chevron tells you the
// siblings are one click away.
const NODE_ITEMS = [
{ value: "storage", label: "Storage", Icon: HardDrive, default: false },
{ value: "network", label: "Network", Icon: NetworkIcon, default: false },
{ value: "hardware", label: "Hardware", Icon: Cpu, default: false },
{ value: "storage", label: t("navigation.storage"), Icon: HardDrive, default: false },
{ value: "network", label: t("navigation.network"), Icon: NetworkIcon, default: false },
{ value: "hardware", label: t("navigation.hardware"), Icon: Cpu, default: false },
]
const ADMIN_ITEMS = [
{ value: "logs", label: "System Logs", Icon: ScrollText, default: false },
{ value: "security", label: "Security", Icon: ShieldCheck, default: false },
{ value: "settings", label: "Settings", Icon: SettingsIcon, default: false },
{ value: "about", label: "About", Icon: Info, default: false },
{ value: "logs", label: t("navigation.systemLogs"), Icon: ScrollText, default: false },
{ value: "security", label: t("navigation.security"), Icon: ShieldCheck, default: false },
{ value: "settings", label: t("navigation.settings"), Icon: SettingsIcon, default: false },
{ value: "about", label: t("navigation.about"), Icon: Info, default: false },
]
const activeNodeItem = NODE_ITEMS.find(i => i.value === activeTab)
const activeAdminItem = ADMIN_ITEMS.find(i => i.value === activeTab)
@@ -600,9 +602,9 @@ export function ProxmoxDashboard() {
// The trigger label + icon shown on the bar. When a child
// is active we surface IT; otherwise the group default.
const NodeTriggerIcon = activeNodeItem ? activeNodeItem.Icon : Server
const NodeTriggerLabel = activeNodeItem ? activeNodeItem.label : "Node"
const NodeTriggerLabel = activeNodeItem ? activeNodeItem.label : t("navigation.node")
const AdminTriggerIcon = activeAdminItem ? activeAdminItem.Icon : Settings2
const AdminTriggerLabel = activeAdminItem ? activeAdminItem.label : "Admin"
const AdminTriggerLabel = activeAdminItem ? activeAdminItem.label : t("navigation.admin")
// Dropdown trigger styling: parity with TabsTrigger so the
// parent visibly carries the "I'm the selected section"
// signal when any of its children is the active tab —
@@ -621,14 +623,14 @@ export function ProxmoxDashboard() {
{/* Direct: Overview */}
<TabsTrigger value="overview" className={triggerActiveClass}>
<LayoutDashboard className="mr-2 h-4 w-4" />
Overview
{t("navigation.overview")}
</TabsTrigger>
{/* Direct: VMs & LXCs — first-class because Proxmox IS
a hypervisor; workloads belong at top level. */}
<TabsTrigger value="vms" className={triggerActiveClass}>
<Boxes className="mr-2 h-4 w-4" />
VMs &amp; LXCs
{t("navigation.virtualMachines")}
</TabsTrigger>
{/* Dropdown: Node (Storage / Network / Hardware) */}
@@ -656,13 +658,13 @@ export function ProxmoxDashboard() {
backup ships this becomes a dropdown. */}
<TabsTrigger value="backup" className={triggerActiveClass}>
<DatabaseBackup className="mr-2 h-4 w-4" />
Backup
{t("navigation.backup")}
</TabsTrigger>
{/* Direct: Terminal */}
<TabsTrigger value="terminal" className={triggerActiveClass}>
<Terminal className="mr-2 h-4 w-4" />
Terminal
{t("navigation.terminal")}
</TabsTrigger>
{/* Dropdown: Admin (System Logs / Security / Settings / About) */}
@@ -727,47 +729,47 @@ export function ProxmoxDashboard() {
<div className="flex flex-col gap-1 mt-4">
<Button variant="ghost" onClick={() => select("overview")} className={itemClass(activeTab === "overview")}>
<LayoutDashboard className="h-5 w-5" />
<span>Overview</span>
<span>{t("navigation.overview")}</span>
</Button>
<Button variant="ghost" onClick={() => select("vms")} className={itemClass(activeTab === "vms")}>
<Boxes className="h-5 w-5" />
<span>VMs &amp; LXCs</span>
<span>{t("navigation.virtualMachines")}</span>
</Button>
<Button variant="ghost" onClick={() => select("storage")} className={itemClass(activeTab === "storage")}>
<HardDrive className="h-5 w-5" />
<span>Storage</span>
<span>{t("navigation.storage")}</span>
</Button>
<Button variant="ghost" onClick={() => select("network")} className={itemClass(activeTab === "network")}>
<NetworkIcon className="h-5 w-5" />
<span>Network</span>
<span>{t("navigation.network")}</span>
</Button>
<Button variant="ghost" onClick={() => select("hardware")} className={itemClass(activeTab === "hardware")}>
<Cpu className="h-5 w-5" />
<span>Hardware</span>
<span>{t("navigation.hardware")}</span>
</Button>
<Button variant="ghost" onClick={() => select("backup")} className={itemClass(activeTab === "backup")}>
<DatabaseBackup className="h-5 w-5" />
<span>Backup</span>
<span>{t("navigation.backup")}</span>
</Button>
<Button variant="ghost" onClick={() => select("terminal")} className={itemClass(activeTab === "terminal")}>
<Terminal className="h-5 w-5" />
<span>Terminal</span>
<span>{t("navigation.terminal")}</span>
</Button>
<Button variant="ghost" onClick={() => select("logs")} className={itemClass(activeTab === "logs")}>
<ScrollText className="h-5 w-5" />
<span>System Logs</span>
<span>{t("navigation.systemLogs")}</span>
</Button>
<Button variant="ghost" onClick={() => select("security")} className={itemClass(activeTab === "security")}>
<ShieldCheck className="h-5 w-5" />
<span>Security</span>
<span>{t("navigation.security")}</span>
</Button>
<Button variant="ghost" onClick={() => select("settings")} className={itemClass(activeTab === "settings")}>
<SettingsIcon className="h-5 w-5" />
<span>Settings</span>
<span>{t("navigation.settings")}</span>
</Button>
<Button variant="ghost" onClick={() => select("about")} className={itemClass(activeTab === "about")}>
<Info className="h-5 w-5" />
<span>About</span>
<span>{t("navigation.about")}</span>
</Button>
</div>
)
@@ -844,7 +846,7 @@ export function ProxmoxDashboard() {
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 hover:underline transition-colors"
>
Support and contribute to the project
{t("app.supportProject")}
</a>
</p>
</footer>
+70 -6
View File
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle, BellOff } from "lucide-react"
import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle, BellOff, Globe2 } from "lucide-react"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
import { NotificationSettings } from "./notification-settings"
@@ -14,6 +14,8 @@ import { Switch } from "./ui/switch"
import { Input } from "./ui/input"
import { getNetworkUnit } from "../lib/format-network"
import { fetchApi } from "../lib/api-config"
import { SUPPORTED_LANGUAGES, useI18n } from "../lib/i18n/provider"
import type { LanguageCode, LanguageStatus } from "../lib/i18n/languages"
// GitHub Dark color palette for bash syntax highlighting
const BASH_KEYWORDS = new Set([
@@ -297,6 +299,7 @@ interface NetworkInterface {
}
export function Settings() {
const { language, setLanguage, t } = useI18n()
const [proxmenuxTools, setProxmenuxTools] = useState<ProxMenuxTool[]>([])
const [updatesAvailableCount, setUpdatesAvailableCount] = useState(0)
const [loadingTools, setLoadingTools] = useState(true)
@@ -899,21 +902,82 @@ export function Settings() {
k => pendingChanges[k] !== -2
)
const getLanguageStatusLabel = (status: LanguageStatus) => {
switch (status) {
case "complete":
return t("settings.interfaceLanguage.statusComplete")
case "partial":
return t("settings.interfaceLanguage.statusPartial")
case "needs-translation":
return t("settings.interfaceLanguage.statusNeedsTranslation")
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Settings</h1>
<p className="text-muted-foreground mt-2">Manage your dashboard preferences</p>
<h1 className="text-3xl font-bold">{t("settings.title")}</h1>
<p className="text-muted-foreground mt-2">{t("settings.description")}</p>
</div>
{/* Interface Language Settings */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Globe2 className="h-5 w-5 text-blue-500" />
<CardTitle>{t("settings.interfaceLanguage.title")}</CardTitle>
</div>
<CardDescription>{t("settings.interfaceLanguage.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-sm font-medium text-foreground">{t("settings.interfaceLanguage.label")}</div>
<p className="text-xs text-muted-foreground mt-1">{t("settings.interfaceLanguage.fallbackNote")}</p>
</div>
<Select value={language} onValueChange={(value) => setLanguage(value as LanguageCode)}>
<SelectTrigger className="w-full sm:w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUPPORTED_LANGUAGES.map((item) => (
<SelectItem key={item.code} value={item.code}>
{item.nativeName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{SUPPORTED_LANGUAGES.map((item) => (
<div
key={item.code}
className={`rounded-md border px-3 py-2 text-sm ${
item.code === language ? "border-blue-500 bg-blue-500/10" : "border-border bg-muted/20"
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{item.nativeName}</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{item.code}
</Badge>
</div>
<div className="text-xs text-muted-foreground mt-1">{getLanguageStatusLabel(item.status)}</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Network Units Settings */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Ruler className="h-5 w-5 text-green-500" />
<CardTitle>Network Units</CardTitle>
<CardTitle>{t("settings.networkUnits.title")}</CardTitle>
</div>
<CardDescription>Change how network traffic is displayed</CardDescription>
<CardDescription>{t("settings.networkUnits.description")}</CardDescription>
</CardHeader>
<CardContent>
{loadingUnitSettings ? (
@@ -922,7 +986,7 @@ export function Settings() {
</div>
) : (
<div className="text-foreground flex items-center justify-between">
<div className="flex items-center">Network Unit Display</div>
<div className="flex items-center">{t("settings.networkUnits.label")}</div>
<Select value={networkUnitSettings} onValueChange={changeNetworkUnit}>
<SelectTrigger className="w-28 h-8 text-xs">
<SelectValue />
+11 -8
View File
@@ -1,6 +1,7 @@
"use client"
import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText, SettingsIcon, Terminal } from "lucide-react"
import { useT } from "../lib/i18n/provider"
const menuItems = [
{ name: "Overview", href: "/", icon: LayoutDashboard },
@@ -14,6 +15,8 @@ const menuItems = [
]
const Sidebar = ({ currentPath, setOpen }) => {
const t = useT()
const handleNavigation = (tabName: string) => {
// Dispatch custom event to change tab in dashboard
const event = new CustomEvent("changeTab", { detail: { tab: tabName } })
@@ -32,7 +35,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<LayoutDashboard className="h-5 w-5" />
<span>Overview</span>
<span>{t("navigation.overview")}</span>
</button>
<button
@@ -44,7 +47,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<HardDrive className="h-5 w-5" />
<span>Storage</span>
<span>{t("navigation.storage")}</span>
</button>
<button
@@ -56,7 +59,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<Network className="h-5 w-5" />
<span>Network</span>
<span>{t("navigation.network")}</span>
</button>
<button
@@ -68,7 +71,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<Server className="h-5 w-5" />
<span>VMs & LXCs</span>
<span>{t("navigation.virtualMachines")}</span>
</button>
<button
@@ -80,7 +83,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<Cpu className="h-5 w-5" />
<span>Hardware</span>
<span>{t("navigation.hardware")}</span>
</button>
<button
@@ -92,7 +95,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<FileText className="h-5 w-5" />
<span>System Logs</span>
<span>{t("navigation.systemLogs")}</span>
</button>
<button
@@ -104,7 +107,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<Terminal className="h-5 w-5" />
<span>Terminal</span>
<span>{t("navigation.terminal")}</span>
</button>
<button
@@ -116,7 +119,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<SettingsIcon className="h-5 w-5" />
<span>Settings</span>
<span>{t("navigation.settings")}</span>
</button>
</div>
)
+4 -2
View File
@@ -4,8 +4,10 @@ import { useTheme } from "next-themes"
import { useEffect, useState } from "react"
import { Button } from "./ui/button"
import { useT } from "../lib/i18n/provider"
export function ThemeToggle() {
const t = useT()
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
@@ -22,7 +24,7 @@ export function ThemeToggle() {
return (
<Button variant="outline" size="sm" className="border-border bg-transparent w-9 h-9">
<Sun className="h-4 w-4" />
<span className="sr-only">Toggle theme</span>
<span className="sr-only">{t("actions.toggleTheme")}</span>
</Button>
)
}
@@ -31,7 +33,7 @@ export function ThemeToggle() {
<Button variant="outline" size="sm" onClick={handleThemeToggle} className="border-border bg-transparent w-9 h-9">
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
<span className="sr-only">{t("actions.toggleTheme")}</span>
</Button>
)
}
+39
View File
@@ -0,0 +1,39 @@
export const LANGUAGE_STORAGE_KEY = "proxmenux-ui-language"
export const DEFAULT_LANGUAGE = "en"
export type LanguageCode = "en" | "es" | "fr" | "de" | "it" | "pt" | "sk"
export type LanguageStatus = "complete" | "partial" | "needs-translation"
export interface SupportedLanguage {
code: LanguageCode
englishName: string
nativeName: string
status: LanguageStatus
}
export const SUPPORTED_LANGUAGES: SupportedLanguage[] = [
{ code: "en", englishName: "English", nativeName: "English", status: "complete" },
{ code: "sk", englishName: "Slovak", nativeName: "Slovenčina", status: "partial" },
{ code: "es", englishName: "Spanish", nativeName: "Español", status: "needs-translation" },
{ code: "fr", englishName: "French", nativeName: "Français", status: "needs-translation" },
{ code: "de", englishName: "German", nativeName: "Deutsch", status: "needs-translation" },
{ code: "it", englishName: "Italian", nativeName: "Italiano", status: "needs-translation" },
{ code: "pt", englishName: "Portuguese", nativeName: "Português", status: "needs-translation" },
]
export function isSupportedLanguage(value: string | null | undefined): value is LanguageCode {
return SUPPORTED_LANGUAGES.some((language) => language.code === value)
}
export function detectBrowserLanguage(): LanguageCode {
if (typeof navigator === "undefined") return DEFAULT_LANGUAGE
const candidates = [navigator.language, ...(navigator.languages || [])]
for (const candidate of candidates) {
const code = candidate?.split("-")[0]?.toLowerCase()
if (isSupportedLanguage(code)) return code
}
return DEFAULT_LANGUAGE
}
+133
View File
@@ -0,0 +1,133 @@
"use client"
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"
import enMessages from "../../messages/en/common.json"
import skMessages from "../../messages/sk/common.json"
import esMessages from "../../messages/es/common.json"
import frMessages from "../../messages/fr/common.json"
import deMessages from "../../messages/de/common.json"
import itMessages from "../../messages/it/common.json"
import ptMessages from "../../messages/pt/common.json"
import {
DEFAULT_LANGUAGE,
LANGUAGE_STORAGE_KEY,
type LanguageCode,
SUPPORTED_LANGUAGES,
detectBrowserLanguage,
isSupportedLanguage,
} from "./languages"
type MessageTree = Record<string, unknown>
type TranslationParams = Record<string, string | number>
const MESSAGE_CATALOG: Record<LanguageCode, MessageTree> = {
en: enMessages as MessageTree,
sk: skMessages as MessageTree,
es: esMessages as MessageTree,
fr: frMessages as MessageTree,
de: deMessages as MessageTree,
it: itMessages as MessageTree,
pt: ptMessages as MessageTree,
}
interface I18nContextValue {
language: LanguageCode
setLanguage: (language: LanguageCode) => void
t: (key: string, params?: TranslationParams) => string
}
const I18nContext = createContext<I18nContextValue | null>(null)
function getInitialLanguage(): LanguageCode {
if (typeof window === "undefined") return DEFAULT_LANGUAGE
try {
const stored = window.localStorage.getItem(LANGUAGE_STORAGE_KEY)
if (isSupportedLanguage(stored)) return stored
} catch {
// localStorage may be unavailable in private browsing.
}
return detectBrowserLanguage()
}
function getMessage(messages: MessageTree, key: string): string | undefined {
const value = key.split(".").reduce<unknown>((cursor, segment) => {
if (!cursor || typeof cursor !== "object") return undefined
return (cursor as Record<string, unknown>)[segment]
}, messages)
return typeof value === "string" ? value : undefined
}
function interpolate(template: string, params?: TranslationParams): string {
if (!params) return template
return template.replace(/\{(\w+)\}/g, (match, name) => {
const value = params[name]
return value === undefined ? match : String(value)
})
}
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguageState] = useState<LanguageCode>(DEFAULT_LANGUAGE)
const [isHydrated, setIsHydrated] = useState(false)
useEffect(() => {
setLanguageState(getInitialLanguage())
setIsHydrated(true)
}, [])
useEffect(() => {
if (!isHydrated) return
document.documentElement.lang = language
try {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language)
} catch {
// Best-effort; the in-memory language still works for this session.
}
}, [isHydrated, language])
useEffect(() => {
const onStorage = (event: StorageEvent) => {
if (event.key === LANGUAGE_STORAGE_KEY && isSupportedLanguage(event.newValue)) {
setLanguageState(event.newValue)
}
}
window.addEventListener("storage", onStorage)
return () => window.removeEventListener("storage", onStorage)
}, [])
const setLanguage = useCallback((nextLanguage: LanguageCode) => {
setLanguageState(nextLanguage)
}, [])
const t = useCallback(
(key: string, params?: TranslationParams) => {
const localized = getMessage(MESSAGE_CATALOG[language], key)
const fallback = getMessage(MESSAGE_CATALOG.en, key)
return interpolate(localized ?? fallback ?? key, params)
},
[language],
)
const value = useMemo<I18nContextValue>(() => ({ language, setLanguage, t }), [language, setLanguage, t])
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
}
export function useI18n() {
const context = useContext(I18nContext)
if (!context) {
throw new Error("useI18n must be used within I18nProvider")
}
return context
}
export function useT() {
return useI18n().t
}
export { SUPPORTED_LANGUAGES }
+12
View File
@@ -0,0 +1,12 @@
# Monitor dashboard translations
The ProxMenux Monitor dashboard uses a small client-side i18n layer.
- English (`en`) is the source language and the fallback.
- Slovak (`sk`) is partially translated.
- Spanish, French, German, Italian and Portuguese are registered as
community translation targets and currently fall back to English.
To add or improve a translation, copy the matching keys from
`messages/en/common.json` into your locale's `common.json` file and
translate only the values. Keep placeholders such as `{uptime}` unchanged.
+3
View File
@@ -0,0 +1,3 @@
{
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values."
}
+132
View File
@@ -0,0 +1,132 @@
{
"app": {
"title": "ProxMenux Monitor",
"description": "Proxmox System Dashboard",
"loading": "Loading...",
"connecting": "Connecting to ProxMenux Monitor",
"unknown": "Unknown",
"notAvailable": "N/A",
"serverOffline": "Server Offline",
"supportProject": "Support and contribute to the project"
},
"actions": {
"refresh": "Refresh",
"toggleTheme": "Toggle theme",
"openUserMenu": "Open user menu",
"cancel": "Cancel",
"save": "Save",
"edit": "Edit",
"close": "Close"
},
"navigation": {
"overview": "Overview",
"storage": "Storage",
"network": "Network",
"virtualMachines": "VMs & LXCs",
"hardware": "Hardware",
"backup": "Backup",
"terminal": "Terminal",
"systemLogs": "System Logs",
"security": "Security",
"settings": "Settings",
"about": "About",
"profile": "Profile",
"node": "Node",
"admin": "Admin",
"menu": "Navigation Menu"
},
"status": {
"healthy": "Healthy",
"warning": "Warning",
"critical": "Critical",
"uptime": "Uptime: {uptime}",
"node": "Node: {node}",
"connectionFailed": "ProxMenux Server Connection Failed",
"checkService": "Check that the monitor.service is running correctly.",
"serverPort": "The ProxMenux server should start automatically on port 8008",
"tryAccessing": "Try accessing:"
},
"settings": {
"title": "Settings",
"description": "Manage your dashboard preferences",
"interfaceLanguage": {
"title": "Interface language",
"description": "Choose the language used by the Monitor dashboard. Missing translations fall back to English.",
"label": "Dashboard language",
"fallbackNote": "Untranslated text is shown in English until the community fills it in.",
"statusComplete": "complete",
"statusPartial": "partial",
"statusNeedsTranslation": "community translation needed"
},
"networkUnits": {
"title": "Network Units",
"description": "Change how network traffic is displayed",
"label": "Network Unit Display"
}
},
"login": {
"subtitle": "Sign in to access your dashboard",
"username": "Username",
"password": "Password",
"usernamePlaceholder": "Enter your username",
"passwordPlaceholder": "Enter your password",
"rememberMe": "Remember me",
"missingCredentials": "Please enter username and password",
"missingTotp": "Please enter your 2FA code",
"loginFailed": "Login failed",
"signingIn": "Signing in...",
"signIn": "Sign In",
"twoFactorTitle": "Two-Factor Authentication",
"twoFactorDescription": "Enter the 6-digit code from your authentication app",
"authenticationCode": "Authentication Code",
"backupCodeHint": "You can also use a backup code (format: XXXX-XXXX)",
"backToLogin": "Back to login",
"verifyCode": "Verify Code",
"version": "ProxMenux Monitor v1.2.4.1-beta"
},
"account": {
"signedIn": "Signed in",
"viewProfile": "View profile",
"security": "Security",
"signOut": "Sign out"
},
"authSetup": {
"choiceTitle": "Setup Dashboard Protection",
"passwordTitle": "Create Password",
"protectTitle": "Protect Your Dashboard?",
"protectDescription": "Add an extra layer of security to protect your Proxmox data when accessing from non-private networks.",
"setupPassword": "Yes, Setup Password",
"skipProtection": "No, Continue Without Protection",
"enableLater": "You can always enable this later in Settings",
"setupTitle": "Setup Authentication",
"setupDescription": "Create a username and password to protect your dashboard",
"fillFields": "Please fill in all fields",
"passwordMismatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 6 characters",
"skipFailed": "Failed to skip authentication",
"savePreferenceFailed": "Failed to save preference",
"setupFailed": "Failed to setup authentication",
"username": "Username",
"usernamePlaceholder": "Enter username",
"password": "Password",
"passwordPlaceholder": "Enter password",
"confirmPassword": "Confirm Password",
"confirmPasswordPlaceholder": "Confirm password",
"profileOptional": "Profile · optional",
"displayName": "Display name",
"displayNamePlaceholder": "Shown above the username in the menu",
"displayNameHint": "Leave empty to render the username itself. Up to 64 characters.",
"avatar": "Avatar",
"change": "Change",
"chooseImage": "Choose image",
"clear": "Clear",
"avatarHint": "PNG, JPEG, WebP or GIF · up to 2 MB · pre-crop square for best results.",
"settingUp": "Setting up...",
"setupAuthentication": "Setup Authentication",
"back": "Back"
},
"about": {
"releaseNotes": "Release notes",
"changelog": "Changelog"
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values."
}
+3
View File
@@ -0,0 +1,3 @@
{
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values."
}
+3
View File
@@ -0,0 +1,3 @@
{
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values."
}
+3
View File
@@ -0,0 +1,3 @@
{
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values."
}
+132
View File
@@ -0,0 +1,132 @@
{
"app": {
"title": "ProxMenux Monitor",
"description": "Systémový prehľad Proxmoxu",
"loading": "Načítava sa...",
"connecting": "Pripájam sa k ProxMenux Monitoru",
"unknown": "Neznáme",
"notAvailable": "Nedostupné",
"serverOffline": "Server je offline",
"supportProject": "Podporte projekt alebo prispejte vlastnými úpravami"
},
"actions": {
"refresh": "Obnoviť",
"toggleTheme": "Prepnúť vzhľad",
"openUserMenu": "Otvoriť používateľské menu",
"cancel": "Zrušiť",
"save": "Uložiť",
"edit": "Upraviť",
"close": "Zavrieť"
},
"navigation": {
"overview": "Prehľad",
"storage": "Úložiská",
"network": "Sieť",
"virtualMachines": "VM a LXC",
"hardware": "Hardvér",
"backup": "Záloha",
"terminal": "Terminál",
"systemLogs": "Systémové logy",
"security": "Bezpečnosť",
"settings": "Nastavenia",
"about": "O projekte",
"profile": "Profil",
"node": "Server",
"admin": "Správa",
"menu": "Navigačné menu"
},
"status": {
"healthy": "V poriadku",
"warning": "Upozornenie",
"critical": "Problém",
"uptime": "Beží: {uptime}",
"node": "Server: {node}",
"connectionFailed": "Nepodarilo sa pripojiť k ProxMenux serveru",
"checkService": "Skontrolujte, či služba monitor.service beží správne.",
"serverPort": "ProxMenux server by sa mal spustiť automaticky na porte 8008",
"tryAccessing": "Skúste otvoriť:"
},
"settings": {
"title": "Nastavenia",
"description": "Spravujte správanie dashboardu",
"interfaceLanguage": {
"title": "Jazyk rozhrania",
"description": "Vyberte jazyk, ktorý bude používať Monitor dashboard. Texty bez prekladu sa zobrazia po anglicky.",
"label": "Jazyk dashboardu",
"fallbackNote": "Nepreložené texty zostanú po anglicky, kým ich komunita nedoplní.",
"statusComplete": "hotové",
"statusPartial": "čiastočne preložené",
"statusNeedsTranslation": "čaká na komunitný preklad"
},
"networkUnits": {
"title": "Jednotky siete",
"description": "Zmeňte, ako sa zobrazuje sieťová prevádzka",
"label": "Zobrazovanie sieťových jednotiek"
}
},
"login": {
"subtitle": "Prihláste sa do dashboardu",
"username": "Používateľské meno",
"password": "Heslo",
"usernamePlaceholder": "Zadajte používateľské meno",
"passwordPlaceholder": "Zadajte heslo",
"rememberMe": "Zapamätať si ma",
"missingCredentials": "Zadajte používateľské meno aj heslo",
"missingTotp": "Zadajte 2FA kód",
"loginFailed": "Prihlásenie zlyhalo",
"signingIn": "Prihlasujem...",
"signIn": "Prihlásiť sa",
"twoFactorTitle": "Dvojfaktorové overenie",
"twoFactorDescription": "Zadajte 6-miestny kód z overovacej aplikácie",
"authenticationCode": "Overovací kód",
"backupCodeHint": "Môžete použiť aj záložný kód vo formáte XXXX-XXXX",
"backToLogin": "Späť na prihlásenie",
"verifyCode": "Overiť kód",
"version": "ProxMenux Monitor v1.2.4.1-beta"
},
"account": {
"signedIn": "Prihlásený",
"viewProfile": "Zobraziť profil",
"security": "Bezpečnosť",
"signOut": "Odhlásiť sa"
},
"authSetup": {
"choiceTitle": "Nastavenie ochrany dashboardu",
"passwordTitle": "Vytvoriť heslo",
"protectTitle": "Chcete chrániť dashboard?",
"protectDescription": "Pridajte ďalšiu vrstvu ochrany pre svoje Proxmox dáta, hlavne pri prístupe mimo súkromnej siete.",
"setupPassword": "Áno, nastaviť heslo",
"skipProtection": "Nie, pokračovať bez ochrany",
"enableLater": "Ochranu môžete zapnúť aj neskôr v Nastaveniach",
"setupTitle": "Nastavenie prihlásenia",
"setupDescription": "Vytvorte používateľské meno a heslo na ochranu dashboardu",
"fillFields": "Vyplňte všetky polia",
"passwordMismatch": "Heslá sa nezhodujú",
"passwordTooShort": "Heslo musí mať aspoň 6 znakov",
"skipFailed": "Nepodarilo sa preskočiť prihlásenie",
"savePreferenceFailed": "Nepodarilo sa uložiť nastavenie",
"setupFailed": "Nepodarilo sa nastaviť prihlásenie",
"username": "Používateľské meno",
"usernamePlaceholder": "Zadajte používateľské meno",
"password": "Heslo",
"passwordPlaceholder": "Zadajte heslo",
"confirmPassword": "Potvrdiť heslo",
"confirmPasswordPlaceholder": "Zadajte heslo znova",
"profileOptional": "Profil · voliteľné",
"displayName": "Zobrazované meno",
"displayNamePlaceholder": "Zobrazí sa nad používateľským menom v menu",
"displayNameHint": "Ak pole necháte prázdne, zobrazí sa samotné používateľské meno. Najviac 64 znakov.",
"avatar": "Avatar",
"change": "Zmeniť",
"chooseImage": "Vybrať obrázok",
"clear": "Vymazať",
"avatarHint": "PNG, JPEG, WebP alebo GIF · najviac 2 MB · najlepšie funguje štvorcový obrázok.",
"settingUp": "Nastavujem...",
"setupAuthentication": "Nastaviť prihlásenie",
"back": "Späť"
},
"about": {
"releaseNotes": "Poznámky k vydaniu",
"changelog": "Zoznam zmien"
}
}
+1 -1
View File
@@ -2789,7 +2789,7 @@ class NotificationManager:
# injection lands in the system prompt verbatim. Audit Tier 3.2 #4.
_ALLOWED_DETAIL_LEVELS = ('brief', 'standard', 'detailed')
_ALLOWED_AI_LANGUAGES = (
'en', 'es', 'fr', 'de', 'it', 'pt', 'ru',
'en', 'sk', 'es', 'fr', 'de', 'it', 'pt', 'ru',
'ja', 'zh', 'ko', 'pl', 'nl', 'tr', 'ar',
)
if short_key.endswith('.ai_detail_level') or short_key == 'ai_detail_level':
@@ -1976,6 +1976,7 @@ def enrich_with_emojis(event_type: str, title: str, body: str,
# Supported languages for AI translation
AI_LANGUAGES = {
'en': 'English',
'sk': 'Slovak',
'es': 'Spanish',
'fr': 'French',
'de': 'German',