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 { ThemeProvider } from "../components/theme-provider"
import { PwaRegister } from "../components/pwa-register" import { PwaRegister } from "../components/pwa-register"
import { PwaInstallPrompt } from "../components/pwa-install-prompt" import { PwaInstallPrompt } from "../components/pwa-install-prompt"
import { I18nProvider } from "../lib/i18n/provider"
import { Suspense } from "react" import { Suspense } from "react"
import "./globals.css" import "./globals.css"
@@ -44,9 +45,11 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className={`${GeistSans.variable} ${GeistMono.variable} antialiased bg-background text-foreground`}> <body className={`${GeistSans.variable} ${GeistMono.variable} antialiased bg-background text-foreground`}>
<Suspense fallback={<div>Loading...</div>}> <Suspense fallback={<div>Loading...</div>}>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange> <I18nProvider>
{children} <ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
</ThemeProvider> {children}
</ThemeProvider>
</I18nProvider>
</Suspense> </Suspense>
<PwaRegister /> <PwaRegister />
<PwaInstallPrompt /> <PwaInstallPrompt />
+4 -2
View File
@@ -5,8 +5,10 @@ import { ProxmoxDashboard } from "../components/proxmox-dashboard"
import { Login } from "../components/login" import { Login } from "../components/login"
import { AuthSetup } from "../components/auth-setup" import { AuthSetup } from "../components/auth-setup"
import { getApiUrl } from "../lib/api-config" import { getApiUrl } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
export default function Home() { export default function Home() {
const t = useT()
const [authStatus, setAuthStatus] = useState<{ const [authStatus, setAuthStatus] = useState<{
loading: boolean loading: boolean
authEnabled: 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="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 className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
</div> </div>
<div className="text-sm font-medium text-foreground">Loading...</div> <div className="text-sm font-medium text-foreground">{t("app.loading")}</div>
<p className="text-xs text-muted-foreground">Connecting to ProxMenux Monitor</p> <p className="text-xs text-muted-foreground">{t("app.connecting")}</p>
</div> </div>
</div> </div>
) )
+3 -1
View File
@@ -13,6 +13,7 @@ import {
} from "lucide-react" } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { APP_VERSION } from "./release-notes-modal" import { APP_VERSION } from "./release-notes-modal"
import { useT } from "../lib/i18n/provider"
// Issue #191: a dedicated About tab. Centralises project metadata // Issue #191: a dedicated About tab. Centralises project metadata
// (version, license, author) and every external link the project // (version, license, author) and every external link the project
@@ -111,6 +112,7 @@ function LinkCard({ row }: { row: LinkRow }) {
} }
export function About() { export function About() {
const t = useT()
return ( return (
<div className="space-y-4 md:space-y-6"> <div className="space-y-4 md:space-y-6">
{/* Hero — logo, name, version, one-line description. */} {/* Hero — logo, name, version, one-line description. */}
@@ -151,7 +153,7 @@ export function About() {
const href = isPrerelease const href = isPrerelease
? "https://github.com/MacRimi/ProxMenux/releases" ? "https://github.com/MacRimi/ProxMenux/releases"
: "https://proxmenux.com/en/changelog" : "https://proxmenux.com/en/changelog"
const label = isPrerelease ? "Release notes" : "Changelog" const label = isPrerelease ? t("about.releaseNotes") : t("about.changelog")
return ( return (
<a <a
href={href} href={href}
+33 -31
View File
@@ -7,12 +7,14 @@ import { Input } from "./ui/input"
import { Label } from "./ui/label" import { Label } from "./ui/label"
import { Shield, Lock, User, AlertCircle, Eye, EyeOff, Upload, Trash2 } from "lucide-react" import { Shield, Lock, User, AlertCircle, Eye, EyeOff, Upload, Trash2 } from "lucide-react"
import { getApiUrl } from "../lib/api-config" import { getApiUrl } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface AuthSetupProps { interface AuthSetupProps {
onComplete: () => void onComplete: () => void
} }
export function AuthSetup({ onComplete }: AuthSetupProps) { export function AuthSetup({ onComplete }: AuthSetupProps) {
const t = useT()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [step, setStep] = useState<"choice" | "setup">("choice") const [step, setStep] = useState<"choice" | "setup">("choice")
const [username, setUsername] = useState("") const [username, setUsername] = useState("")
@@ -74,7 +76,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
const data = await response.json() const data = await response.json()
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || "Failed to skip authentication") throw new Error(data.error || t("authSetup.skipFailed"))
} }
if (data.auth_declined) { if (data.auth_declined) {
@@ -86,7 +88,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
onComplete() onComplete()
} catch (err) { } catch (err) {
console.error("Auth skip error:", 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 { } finally {
setLoading(false) setLoading(false)
} }
@@ -108,17 +110,17 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
setError("") setError("")
if (!username || !password) { if (!username || !password) {
setError("Please fill in all fields") setError(t("authSetup.fillFields"))
return return
} }
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError("Passwords do not match") setError(t("authSetup.passwordMismatch"))
return return
} }
if (password.length < 6) { if (password.length < 6) {
setError("Password must be at least 6 characters") setError(t("authSetup.passwordTooShort"))
return return
} }
@@ -137,7 +139,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
const data = await response.json() const data = await response.json()
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || "Failed to setup authentication") throw new Error(data.error || t("authSetup.setupFailed"))
} }
if (data.token) { if (data.token) {
@@ -204,7 +206,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
onComplete() onComplete()
} catch (err) { } catch (err) {
console.error("Auth setup error:", 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 { } finally {
setLoading(false) setLoading(false)
} }
@@ -214,7 +216,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
<DialogTitle className="sr-only"> <DialogTitle className="sr-only">
{step === "choice" ? "Setup Dashboard Protection" : "Create Password"} {step === "choice" ? t("authSetup.choiceTitle") : t("authSetup.passwordTitle")}
</DialogTitle> </DialogTitle>
{step === "choice" ? ( {step === "choice" ? (
<div className="space-y-6 py-2"> <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"> <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" /> <Shield className="h-8 w-8 text-blue-500" />
</div> </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"> <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> </p>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<Button onClick={() => setStep("setup")} className="w-full bg-blue-500 hover:bg-blue-600" size="lg"> <Button onClick={() => setStep("setup")} className="w-full bg-blue-500 hover:bg-blue-600" size="lg">
<Lock className="h-4 w-4 mr-2" /> <Lock className="h-4 w-4 mr-2" />
Yes, Setup Password {t("authSetup.setupPassword")}
</Button> </Button>
<Button <Button
onClick={handleSkipAuth} onClick={handleSkipAuth}
@@ -240,11 +242,11 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
size="lg" size="lg"
disabled={loading} disabled={loading}
> >
No, Continue Without Protection {t("authSetup.skipProtection")}
</Button> </Button>
</div> </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>
) : ( ) : (
<div className="space-y-6 py-2"> <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"> <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" /> <Lock className="h-8 w-8 text-blue-500" />
</div> </div>
<h2 className="text-2xl font-bold">Setup Authentication</h2> <h2 className="text-2xl font-bold">{t("authSetup.setupTitle")}</h2>
<p className="text-muted-foreground text-sm">Create a username and password to protect your dashboard</p> <p className="text-muted-foreground text-sm">{t("authSetup.setupDescription")}</p>
</div> </div>
{error && ( {error && (
@@ -266,14 +268,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="username" className="text-sm"> <Label htmlFor="username" className="text-sm">
Username {t("authSetup.username")}
</Label> </Label>
<div className="relative"> <div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
id="username" id="username"
type="text" type="text"
placeholder="Enter username" placeholder={t("authSetup.usernamePlaceholder")}
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
className="pl-10 text-base" className="pl-10 text-base"
@@ -285,14 +287,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password" className="text-sm"> <Label htmlFor="password" className="text-sm">
Password {t("authSetup.password")}
</Label> </Label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
id="password" id="password"
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
placeholder="Enter password" placeholder={t("authSetup.passwordPlaceholder")}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="pl-10 text-base" className="pl-10 text-base"
@@ -312,14 +314,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="confirm-password" className="text-sm"> <Label htmlFor="confirm-password" className="text-sm">
Confirm Password {t("authSetup.confirmPassword")}
</Label> </Label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
id="confirm-password" id="confirm-password"
type={showConfirmPassword ? "text" : "password"} type={showConfirmPassword ? "text" : "password"}
placeholder="Confirm password" placeholder={t("authSetup.confirmPasswordPlaceholder")}
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
className="pl-10 text-base" className="pl-10 text-base"
@@ -345,19 +347,19 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
setup endpoint returns the JWT. */} setup endpoint returns the JWT. */}
<div className="pt-3 border-t border-border/60 space-y-4"> <div className="pt-3 border-t border-border/60 space-y-4">
<p className="text-xs text-muted-foreground uppercase tracking-wider"> <p className="text-xs text-muted-foreground uppercase tracking-wider">
Profile · optional {t("authSetup.profileOptional")}
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="display-name" className="text-sm"> <Label htmlFor="display-name" className="text-sm">
Display name {t("authSetup.displayName")}
</Label> </Label>
<div className="relative"> <div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
id="display-name" id="display-name"
type="text" type="text"
placeholder="Shown above the username in the menu" placeholder={t("authSetup.displayNamePlaceholder")}
value={displayName} value={displayName}
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(e.target.value)}
maxLength={64} maxLength={64}
@@ -366,12 +368,12 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
/> />
</div> </div>
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
Leave empty to render the username itself. Up to 64 characters. {t("authSetup.displayNameHint")}
</p> </p>
</div> </div>
<div className="space-y-2"> <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"> <div className="flex items-center gap-3">
{avatarPreviewUrl ? ( {avatarPreviewUrl ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@@ -407,7 +409,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
className="h-7 text-xs" className="h-7 text-xs"
> >
<Upload className="h-3 w-3 mr-1.5" /> <Upload className="h-3 w-3 mr-1.5" />
{avatarFile ? "Change" : "Choose image"} {avatarFile ? t("authSetup.change") : t("authSetup.chooseImage")}
</Button> </Button>
{avatarFile && ( {avatarFile && (
<Button <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" 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" /> <Trash2 className="h-3 w-3 mr-1.5" />
Clear {t("authSetup.clear")}
</Button> </Button>
)} )}
</div> </div>
<p className="text-[11px] text-muted-foreground"> <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> </p>
</div> </div>
</div> </div>
@@ -434,10 +436,10 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="space-y-2"> <div className="space-y-2">
<Button onClick={handleSetupAuth} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}> <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>
<Button onClick={() => setStep("choice")} variant="ghost" className="w-full" disabled={loading}> <Button onClick={() => setStep("choice")} variant="ghost" className="w-full" disabled={loading}>
Back {t("authSetup.back")}
</Button> </Button>
</div> </div>
</div> </div>
+8 -5
View File
@@ -11,6 +11,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "./ui/dropdown-menu" } from "./ui/dropdown-menu"
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config" import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface AuthStatus { interface AuthStatus {
auth_enabled?: boolean auth_enabled?: boolean
@@ -57,6 +58,8 @@ interface AvatarMenuProps {
* proper /api/auth/logout that revokes the JWT server-side too. * proper /api/auth/logout that revokes the JWT server-side too.
*/ */
export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: AvatarMenuProps) { export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: AvatarMenuProps) {
const t = useT()
// IMPORTANT — all hooks must run unconditionally on every render. The // IMPORTANT — all hooks must run unconditionally on every render. The
// previous version short-circuited with `if (!auth_enabled) return null` // previous version short-circuited with `if (!auth_enabled) return null`
// BEFORE the avatar blob hooks, so the hook count changed between // 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> <DropdownMenuTrigger asChild>
<button <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" 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 // WebKit ignores `outline` for the tap-highlight overlay
// shown on iOS / Android Chrome after a touch. That overlay // shown on iOS / Android Chrome after a touch. That overlay
// was the white border that lingered on the avatar after // 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> <div className="text-xs text-muted-foreground truncate">{username}</div>
)} )}
{!profile?.display_name && ( {!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>
</div> </div>
@@ -257,13 +260,13 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
{onOpenProfile && ( {onOpenProfile && (
<DropdownMenuItem onClick={onOpenProfile}> <DropdownMenuItem onClick={onOpenProfile}>
<User className="h-4 w-4 mr-2" /> <User className="h-4 w-4 mr-2" />
View profile {t("account.viewProfile")}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{onOpenSecurity && ( {onOpenSecurity && (
<DropdownMenuItem onClick={onOpenSecurity}> <DropdownMenuItem onClick={onOpenSecurity}>
<Shield className="h-4 w-4 mr-2" /> <Shield className="h-4 w-4 mr-2" />
Security {t("account.security")}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{(onOpenProfile || onOpenSecurity) && <DropdownMenuSeparator />} {(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" 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" /> <LogOut className="h-4 w-4 mr-2" />
Sign out {t("account.signOut")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
+20 -18
View File
@@ -9,6 +9,7 @@ import { Label } from "./ui/label"
import { Checkbox } from "./ui/checkbox" import { Checkbox } from "./ui/checkbox"
import { Lock, User, AlertCircle, Server, Shield, Eye, EyeOff } from "lucide-react" import { Lock, User, AlertCircle, Server, Shield, Eye, EyeOff } from "lucide-react"
import { getApiUrl } from "../lib/api-config" import { getApiUrl } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
import Image from "next/image" import Image from "next/image"
interface LoginProps { interface LoginProps {
@@ -16,6 +17,7 @@ interface LoginProps {
} }
export function Login({ onLogin }: LoginProps) { export function Login({ onLogin }: LoginProps) {
const t = useT()
const [username, setUsername] = useState("") const [username, setUsername] = useState("")
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const [totpCode, setTotpCode] = useState("") const [totpCode, setTotpCode] = useState("")
@@ -56,12 +58,12 @@ export function Login({ onLogin }: LoginProps) {
setError("") setError("")
if (!username || !password) { if (!username || !password) {
setError("Please enter username and password") setError(t("login.missingCredentials"))
return return
} }
if (requiresTotp && !totpCode) { if (requiresTotp && !totpCode) {
setError("Please enter your 2FA code") setError(t("login.missingTotp"))
return return
} }
@@ -87,7 +89,7 @@ export function Login({ onLogin }: LoginProps) {
} }
if (!response.ok) { 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) localStorage.setItem("proxmenux-auth-token", data.token)
@@ -107,7 +109,7 @@ export function Login({ onLogin }: LoginProps) {
onLogin() onLogin()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Login failed") setError(err instanceof Error ? err.message : t("login.loginFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -139,8 +141,8 @@ export function Login({ onLogin }: LoginProps) {
</div> </div>
</div> </div>
<div> <div>
<h1 className="text-3xl font-bold">ProxMenux Monitor</h1> <h1 className="text-3xl font-bold">{t("app.title")}</h1>
<p className="text-muted-foreground mt-2">Sign in to access your dashboard</p> <p className="text-muted-foreground mt-2">{t("login.subtitle")}</p>
</div> </div>
</div> </div>
@@ -157,14 +159,14 @@ export function Login({ onLogin }: LoginProps) {
<> <>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="login-username" className="text-sm"> <Label htmlFor="login-username" className="text-sm">
Username {t("login.username")}
</Label> </Label>
<div className="relative"> <div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
id="login-username" id="login-username"
type="text" type="text"
placeholder="Enter your username" placeholder={t("login.usernamePlaceholder")}
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
className="pl-10 text-base" className="pl-10 text-base"
@@ -176,14 +178,14 @@ export function Login({ onLogin }: LoginProps) {
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="login-password" className="text-sm"> <Label htmlFor="login-password" className="text-sm">
Password {t("login.password")}
</Label> </Label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
id="login-password" id="login-password"
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
placeholder="Enter your password" placeholder={t("login.passwordPlaceholder")}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="pl-10 pr-10 text-base" className="pl-10 pr-10 text-base"
@@ -214,7 +216,7 @@ export function Login({ onLogin }: LoginProps) {
disabled={loading} disabled={loading}
/> />
<Label htmlFor="remember-me" className="text-sm font-normal cursor-pointer select-none"> <Label htmlFor="remember-me" className="text-sm font-normal cursor-pointer select-none">
Remember me {t("login.rememberMe")}
</Label> </Label>
</div> </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"> <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" /> <Shield className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
<div> <div>
<p className="text-sm font-medium text-blue-500">Two-Factor Authentication</p> <p className="text-sm font-medium text-blue-500">{t("login.twoFactorTitle")}</p>
<p className="text-xs text-blue-500 mt-1">Enter the 6-digit code from your authentication app</p> <p className="text-xs text-blue-500 mt-1">{t("login.twoFactorDescription")}</p>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="totp-code" className="text-sm"> <Label htmlFor="totp-code" className="text-sm">
Authentication Code {t("login.authenticationCode")}
</Label> </Label>
<Input <Input
id="totp-code" id="totp-code"
@@ -245,7 +247,7 @@ export function Login({ onLogin }: LoginProps) {
autoFocus autoFocus
/> />
<p className="text-xs text-muted-foreground text-center"> <p className="text-xs text-muted-foreground text-center">
You can also use a backup code (format: XXXX-XXXX) {t("login.backupCodeHint")}
</p> </p>
</div> </div>
@@ -260,18 +262,18 @@ export function Login({ onLogin }: LoginProps) {
}} }}
className="w-full" className="w-full"
> >
Back to login {t("login.backToLogin")}
</Button> </Button>
</div> </div>
)} )}
<Button type="submit" className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}> <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> </Button>
</form> </form>
</div> </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>
</div> </div>
) )
@@ -212,6 +212,7 @@ const AI_PROVIDERS = [
const AI_LANGUAGES = [ const AI_LANGUAGES = [
{ value: "en", label: "English" }, { value: "en", label: "English" },
{ value: "sk", label: "Slovenčina" },
{ value: "es", label: "Espanol" }, { value: "es", label: "Espanol" },
{ value: "fr", label: "Francais" }, { value: "fr", label: "Francais" },
{ value: "de", label: "Deutsch" }, { value: "de", label: "Deutsch" },
+61 -59
View File
@@ -51,6 +51,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "./ui/dropdown-menu" } from "./ui/dropdown-menu"
import { useT } from "../lib/i18n/provider"
interface SystemStatus { interface SystemStatus {
status: "healthy" | "warning" | "critical" status: "healthy" | "warning" | "critical"
@@ -80,6 +81,7 @@ interface FlaskSystemInfo {
} }
export function ProxmoxDashboard() { export function ProxmoxDashboard() {
const t = useT()
const [systemStatus, setSystemStatus] = useState<SystemStatus>({ const [systemStatus, setSystemStatus] = useState<SystemStatus>({
status: "healthy", status: "healthy",
uptime: "Loading...", uptime: "Loading...",
@@ -168,7 +170,7 @@ export function ProxmoxDashboard() {
const data: FlaskSystemInfo = await fetchApi("/api/system-info") const data: FlaskSystemInfo = await fetchApi("/api/system-info")
const uptimeValue = 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" const backendStatus = data.health?.status?.toUpperCase() || "OK"
let healthStatus: "healthy" | "warning" | "critical" let healthStatus: "healthy" | "warning" | "critical"
@@ -185,8 +187,8 @@ export function ProxmoxDashboard() {
status: healthStatus, status: healthStatus,
uptime: uptimeValue, uptime: uptimeValue,
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }), lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
serverName: data.hostname || "Unknown", serverName: data.hostname || t("app.unknown"),
nodeId: data.node_id || "Unknown", nodeId: data.node_id || t("app.unknown"),
}) })
setIsServerConnected(true) setIsServerConnected(true)
} catch (error) { } catch (error) {
@@ -196,13 +198,13 @@ export function ProxmoxDashboard() {
setSystemStatus((prev) => ({ setSystemStatus((prev) => ({
...prev, ...prev,
status: "critical", status: "critical",
serverName: "Server Offline", serverName: t("app.serverOffline"),
nodeId: "Server Offline", nodeId: t("app.serverOffline"),
uptime: "N/A", uptime: t("app.notAvailable"),
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }), lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
})) }))
} }
}, []) }, [t])
useEffect(() => { useEffect(() => {
// Siempre fetch inicial // Siempre fetch inicial
@@ -362,19 +364,19 @@ export function ProxmoxDashboard() {
const getActiveTabLabel = () => { const getActiveTabLabel = () => {
switch (activeTab) { switch (activeTab) {
case "overview": return "Overview" case "overview": return t("navigation.overview")
case "vms": return "VMs & LXCs" case "vms": return t("navigation.virtualMachines")
case "storage": return "Storage" case "storage": return t("navigation.storage")
case "network": return "Network" case "network": return t("navigation.network")
case "hardware": return "Hardware" case "hardware": return t("navigation.hardware")
case "backup": return "Backup" case "backup": return t("navigation.backup")
case "terminal": return "Terminal" case "terminal": return t("navigation.terminal")
case "logs": return "System Logs" case "logs": return t("navigation.systemLogs")
case "security": return "Security" case "security": return t("navigation.security")
case "settings": return "Settings" case "settings": return t("navigation.settings")
case "about": return "About" case "about": return t("navigation.about")
case "profile": return "Profile" case "profile": return t("navigation.profile")
default: return "Navigation Menu" default: return t("navigation.menu")
} }
} }
@@ -388,13 +390,13 @@ export function ProxmoxDashboard() {
<div className="container mx-auto"> <div className="container mx-auto">
<div className="flex items-center space-x-2 text-red-500 mb-2"> <div className="flex items-center space-x-2 text-red-500 mb-2">
<XCircle className="h-5 w-5" /> <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>
<div className="text-sm text-red-500/80 space-y-1 ml-7"> <div className="text-sm text-red-500/80 space-y-1 ml-7">
<p> Check that the monitor.service is running correctly.</p> <p>&bull; {t("status.checkService")}</p>
<p> The ProxMenux server should start automatically on port 8008</p> <p>&bull; {t("status.serverPort")}</p>
<p> <p>
Try accessing:{" "} &bull; {t("status.tryAccessing")}{" "}
<a href={getApiUrl("/api/health")} target="_blank" rel="noopener noreferrer" className="underline"> <a href={getApiUrl("/api/health")} target="_blank" rel="noopener noreferrer" className="underline">
{getApiUrl("/api/health")} {getApiUrl("/api/health")}
</a> </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" /> <Server className="h-8 w-8 md:h-6 md:w-6 text-primary absolute fallback-icon hidden" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">ProxMenux Monitor</h1> <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">Proxmox System Dashboard</p> <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"> <div className="lg:hidden flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
<Server className="h-3 w-3" /> <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> </div>
</div> </div>
@@ -447,14 +449,14 @@ export function ProxmoxDashboard() {
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Server className="h-4 w-4 text-muted-foreground" /> <Server className="h-4 w-4 text-muted-foreground" />
<div className="text-sm"> <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> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="outline" className={statusColor}> <Badge variant="outline" className={statusColor}>
{statusIcon} {statusIcon}
<span className="ml-1 capitalize">{systemStatus.status}</span> <span className="ml-1">{t(`status.${systemStatus.status}`)}</span>
</Badge> </Badge>
{systemStatus.status === "healthy" && infoCount > 0 && ( {systemStatus.status === "healthy" && infoCount > 0 && (
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20"> <Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">
@@ -465,7 +467,7 @@ export function ProxmoxDashboard() {
</div> </div>
<div className="text-sm text-muted-foreground whitespace-nowrap"> <div className="text-sm text-muted-foreground whitespace-nowrap">
Uptime: {systemStatus.uptime || "N/A"} {t("status.uptime", { uptime: systemStatus.uptime || t("app.notAvailable") })}
</div> </div>
<Button <Button
@@ -479,7 +481,7 @@ export function ProxmoxDashboard() {
className="border-border/50 bg-transparent hover:bg-secondary" className="border-border/50 bg-transparent hover:bg-secondary"
> >
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} /> <RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
Refresh {t("actions.refresh")}
</Button> </Button>
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
@@ -513,7 +515,7 @@ export function ProxmoxDashboard() {
}} }}
disabled={isRefreshing} disabled={isRefreshing}
className="h-8 w-8 p-0 border-border/50 bg-transparent hover:bg-secondary" 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" : ""}`} /> <RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
</Button> </Button>
@@ -541,7 +543,7 @@ export function ProxmoxDashboard() {
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Badge variant="outline" className={`${statusColor} text-xs px-2`}> <Badge variant="outline" className={`${statusColor} text-xs px-2`}>
{statusIcon} {statusIcon}
<span className="ml-1 capitalize">{systemStatus.status}</span> <span className="ml-1">{t(`status.${systemStatus.status}`)}</span>
</Badge> </Badge>
{systemStatus.status === "healthy" && infoCount > 0 && ( {systemStatus.status === "healthy" && infoCount > 0 && (
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20 text-xs px-2"> <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> </div>
<span className="text-xs text-muted-foreground whitespace-nowrap"> <span className="text-xs text-muted-foreground whitespace-nowrap">
Uptime: {systemStatus.uptime || "N/A"} {t("status.uptime", { uptime: systemStatus.uptime || t("app.notAvailable") })}
</span> </span>
</div> </div>
</div> </div>
@@ -583,15 +585,15 @@ export function ProxmoxDashboard() {
// crumb shows where you are, the chevron tells you the // crumb shows where you are, the chevron tells you the
// siblings are one click away. // siblings are one click away.
const NODE_ITEMS = [ const NODE_ITEMS = [
{ value: "storage", label: "Storage", Icon: HardDrive, default: false }, { value: "storage", label: t("navigation.storage"), Icon: HardDrive, default: false },
{ value: "network", label: "Network", Icon: NetworkIcon, default: false }, { value: "network", label: t("navigation.network"), Icon: NetworkIcon, default: false },
{ value: "hardware", label: "Hardware", Icon: Cpu, default: false }, { value: "hardware", label: t("navigation.hardware"), Icon: Cpu, default: false },
] ]
const ADMIN_ITEMS = [ const ADMIN_ITEMS = [
{ value: "logs", label: "System Logs", Icon: ScrollText, default: false }, { value: "logs", label: t("navigation.systemLogs"), Icon: ScrollText, default: false },
{ value: "security", label: "Security", Icon: ShieldCheck, default: false }, { value: "security", label: t("navigation.security"), Icon: ShieldCheck, default: false },
{ value: "settings", label: "Settings", Icon: SettingsIcon, default: false }, { value: "settings", label: t("navigation.settings"), Icon: SettingsIcon, default: false },
{ value: "about", label: "About", Icon: Info, default: false }, { value: "about", label: t("navigation.about"), Icon: Info, default: false },
] ]
const activeNodeItem = NODE_ITEMS.find(i => i.value === activeTab) const activeNodeItem = NODE_ITEMS.find(i => i.value === activeTab)
const activeAdminItem = ADMIN_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 // The trigger label + icon shown on the bar. When a child
// is active we surface IT; otherwise the group default. // is active we surface IT; otherwise the group default.
const NodeTriggerIcon = activeNodeItem ? activeNodeItem.Icon : Server 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 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 // Dropdown trigger styling: parity with TabsTrigger so the
// parent visibly carries the "I'm the selected section" // parent visibly carries the "I'm the selected section"
// signal when any of its children is the active tab — // signal when any of its children is the active tab —
@@ -621,14 +623,14 @@ export function ProxmoxDashboard() {
{/* Direct: Overview */} {/* Direct: Overview */}
<TabsTrigger value="overview" className={triggerActiveClass}> <TabsTrigger value="overview" className={triggerActiveClass}>
<LayoutDashboard className="mr-2 h-4 w-4" /> <LayoutDashboard className="mr-2 h-4 w-4" />
Overview {t("navigation.overview")}
</TabsTrigger> </TabsTrigger>
{/* Direct: VMs & LXCs — first-class because Proxmox IS {/* Direct: VMs & LXCs — first-class because Proxmox IS
a hypervisor; workloads belong at top level. */} a hypervisor; workloads belong at top level. */}
<TabsTrigger value="vms" className={triggerActiveClass}> <TabsTrigger value="vms" className={triggerActiveClass}>
<Boxes className="mr-2 h-4 w-4" /> <Boxes className="mr-2 h-4 w-4" />
VMs &amp; LXCs {t("navigation.virtualMachines")}
</TabsTrigger> </TabsTrigger>
{/* Dropdown: Node (Storage / Network / Hardware) */} {/* Dropdown: Node (Storage / Network / Hardware) */}
@@ -656,13 +658,13 @@ export function ProxmoxDashboard() {
backup ships this becomes a dropdown. */} backup ships this becomes a dropdown. */}
<TabsTrigger value="backup" className={triggerActiveClass}> <TabsTrigger value="backup" className={triggerActiveClass}>
<DatabaseBackup className="mr-2 h-4 w-4" /> <DatabaseBackup className="mr-2 h-4 w-4" />
Backup {t("navigation.backup")}
</TabsTrigger> </TabsTrigger>
{/* Direct: Terminal */} {/* Direct: Terminal */}
<TabsTrigger value="terminal" className={triggerActiveClass}> <TabsTrigger value="terminal" className={triggerActiveClass}>
<Terminal className="mr-2 h-4 w-4" /> <Terminal className="mr-2 h-4 w-4" />
Terminal {t("navigation.terminal")}
</TabsTrigger> </TabsTrigger>
{/* Dropdown: Admin (System Logs / Security / Settings / About) */} {/* Dropdown: Admin (System Logs / Security / Settings / About) */}
@@ -727,47 +729,47 @@ export function ProxmoxDashboard() {
<div className="flex flex-col gap-1 mt-4"> <div className="flex flex-col gap-1 mt-4">
<Button variant="ghost" onClick={() => select("overview")} className={itemClass(activeTab === "overview")}> <Button variant="ghost" onClick={() => select("overview")} className={itemClass(activeTab === "overview")}>
<LayoutDashboard className="h-5 w-5" /> <LayoutDashboard className="h-5 w-5" />
<span>Overview</span> <span>{t("navigation.overview")}</span>
</Button> </Button>
<Button variant="ghost" onClick={() => select("vms")} className={itemClass(activeTab === "vms")}> <Button variant="ghost" onClick={() => select("vms")} className={itemClass(activeTab === "vms")}>
<Boxes className="h-5 w-5" /> <Boxes className="h-5 w-5" />
<span>VMs &amp; LXCs</span> <span>{t("navigation.virtualMachines")}</span>
</Button> </Button>
<Button variant="ghost" onClick={() => select("storage")} className={itemClass(activeTab === "storage")}> <Button variant="ghost" onClick={() => select("storage")} className={itemClass(activeTab === "storage")}>
<HardDrive className="h-5 w-5" /> <HardDrive className="h-5 w-5" />
<span>Storage</span> <span>{t("navigation.storage")}</span>
</Button> </Button>
<Button variant="ghost" onClick={() => select("network")} className={itemClass(activeTab === "network")}> <Button variant="ghost" onClick={() => select("network")} className={itemClass(activeTab === "network")}>
<NetworkIcon className="h-5 w-5" /> <NetworkIcon className="h-5 w-5" />
<span>Network</span> <span>{t("navigation.network")}</span>
</Button> </Button>
<Button variant="ghost" onClick={() => select("hardware")} className={itemClass(activeTab === "hardware")}> <Button variant="ghost" onClick={() => select("hardware")} className={itemClass(activeTab === "hardware")}>
<Cpu className="h-5 w-5" /> <Cpu className="h-5 w-5" />
<span>Hardware</span> <span>{t("navigation.hardware")}</span>
</Button> </Button>
<Button variant="ghost" onClick={() => select("backup")} className={itemClass(activeTab === "backup")}> <Button variant="ghost" onClick={() => select("backup")} className={itemClass(activeTab === "backup")}>
<DatabaseBackup className="h-5 w-5" /> <DatabaseBackup className="h-5 w-5" />
<span>Backup</span> <span>{t("navigation.backup")}</span>
</Button> </Button>
<Button variant="ghost" onClick={() => select("terminal")} className={itemClass(activeTab === "terminal")}> <Button variant="ghost" onClick={() => select("terminal")} className={itemClass(activeTab === "terminal")}>
<Terminal className="h-5 w-5" /> <Terminal className="h-5 w-5" />
<span>Terminal</span> <span>{t("navigation.terminal")}</span>
</Button> </Button>
<Button variant="ghost" onClick={() => select("logs")} className={itemClass(activeTab === "logs")}> <Button variant="ghost" onClick={() => select("logs")} className={itemClass(activeTab === "logs")}>
<ScrollText className="h-5 w-5" /> <ScrollText className="h-5 w-5" />
<span>System Logs</span> <span>{t("navigation.systemLogs")}</span>
</Button> </Button>
<Button variant="ghost" onClick={() => select("security")} className={itemClass(activeTab === "security")}> <Button variant="ghost" onClick={() => select("security")} className={itemClass(activeTab === "security")}>
<ShieldCheck className="h-5 w-5" /> <ShieldCheck className="h-5 w-5" />
<span>Security</span> <span>{t("navigation.security")}</span>
</Button> </Button>
<Button variant="ghost" onClick={() => select("settings")} className={itemClass(activeTab === "settings")}> <Button variant="ghost" onClick={() => select("settings")} className={itemClass(activeTab === "settings")}>
<SettingsIcon className="h-5 w-5" /> <SettingsIcon className="h-5 w-5" />
<span>Settings</span> <span>{t("navigation.settings")}</span>
</Button> </Button>
<Button variant="ghost" onClick={() => select("about")} className={itemClass(activeTab === "about")}> <Button variant="ghost" onClick={() => select("about")} className={itemClass(activeTab === "about")}>
<Info className="h-5 w-5" /> <Info className="h-5 w-5" />
<span>About</span> <span>{t("navigation.about")}</span>
</Button> </Button>
</div> </div>
) )
@@ -844,7 +846,7 @@ export function ProxmoxDashboard() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 hover:underline transition-colors" className="text-blue-500 hover:text-blue-600 hover:underline transition-colors"
> >
Support and contribute to the project {t("app.supportProject")}
</a> </a>
</p> </p>
</footer> </footer>
+70 -6
View File
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" 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 { Badge } from "./ui/badge"
import { Button } from "./ui/button" import { Button } from "./ui/button"
import { NotificationSettings } from "./notification-settings" import { NotificationSettings } from "./notification-settings"
@@ -14,6 +14,8 @@ import { Switch } from "./ui/switch"
import { Input } from "./ui/input" import { Input } from "./ui/input"
import { getNetworkUnit } from "../lib/format-network" import { getNetworkUnit } from "../lib/format-network"
import { fetchApi } from "../lib/api-config" 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 // GitHub Dark color palette for bash syntax highlighting
const BASH_KEYWORDS = new Set([ const BASH_KEYWORDS = new Set([
@@ -297,6 +299,7 @@ interface NetworkInterface {
} }
export function Settings() { export function Settings() {
const { language, setLanguage, t } = useI18n()
const [proxmenuxTools, setProxmenuxTools] = useState<ProxMenuxTool[]>([]) const [proxmenuxTools, setProxmenuxTools] = useState<ProxMenuxTool[]>([])
const [updatesAvailableCount, setUpdatesAvailableCount] = useState(0) const [updatesAvailableCount, setUpdatesAvailableCount] = useState(0)
const [loadingTools, setLoadingTools] = useState(true) const [loadingTools, setLoadingTools] = useState(true)
@@ -899,21 +902,82 @@ export function Settings() {
k => pendingChanges[k] !== -2 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-3xl font-bold">Settings</h1> <h1 className="text-3xl font-bold">{t("settings.title")}</h1>
<p className="text-muted-foreground mt-2">Manage your dashboard preferences</p> <p className="text-muted-foreground mt-2">{t("settings.description")}</p>
</div> </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 */} {/* Network Units Settings */}
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Ruler className="h-5 w-5 text-green-500" /> <Ruler className="h-5 w-5 text-green-500" />
<CardTitle>Network Units</CardTitle> <CardTitle>{t("settings.networkUnits.title")}</CardTitle>
</div> </div>
<CardDescription>Change how network traffic is displayed</CardDescription> <CardDescription>{t("settings.networkUnits.description")}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loadingUnitSettings ? ( {loadingUnitSettings ? (
@@ -922,7 +986,7 @@ export function Settings() {
</div> </div>
) : ( ) : (
<div className="text-foreground flex items-center justify-between"> <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}> <Select value={networkUnitSettings} onValueChange={changeNetworkUnit}>
<SelectTrigger className="w-28 h-8 text-xs"> <SelectTrigger className="w-28 h-8 text-xs">
<SelectValue /> <SelectValue />
+11 -8
View File
@@ -1,6 +1,7 @@
"use client" "use client"
import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText, SettingsIcon, Terminal } from "lucide-react" import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText, SettingsIcon, Terminal } from "lucide-react"
import { useT } from "../lib/i18n/provider"
const menuItems = [ const menuItems = [
{ name: "Overview", href: "/", icon: LayoutDashboard }, { name: "Overview", href: "/", icon: LayoutDashboard },
@@ -14,6 +15,8 @@ const menuItems = [
] ]
const Sidebar = ({ currentPath, setOpen }) => { const Sidebar = ({ currentPath, setOpen }) => {
const t = useT()
const handleNavigation = (tabName: string) => { const handleNavigation = (tabName: string) => {
// Dispatch custom event to change tab in dashboard // Dispatch custom event to change tab in dashboard
const event = new CustomEvent("changeTab", { detail: { tab: tabName } }) const event = new CustomEvent("changeTab", { detail: { tab: tabName } })
@@ -32,7 +35,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`} }`}
> >
<LayoutDashboard className="h-5 w-5" /> <LayoutDashboard className="h-5 w-5" />
<span>Overview</span> <span>{t("navigation.overview")}</span>
</button> </button>
<button <button
@@ -44,7 +47,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`} }`}
> >
<HardDrive className="h-5 w-5" /> <HardDrive className="h-5 w-5" />
<span>Storage</span> <span>{t("navigation.storage")}</span>
</button> </button>
<button <button
@@ -56,7 +59,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`} }`}
> >
<Network className="h-5 w-5" /> <Network className="h-5 w-5" />
<span>Network</span> <span>{t("navigation.network")}</span>
</button> </button>
<button <button
@@ -68,7 +71,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`} }`}
> >
<Server className="h-5 w-5" /> <Server className="h-5 w-5" />
<span>VMs & LXCs</span> <span>{t("navigation.virtualMachines")}</span>
</button> </button>
<button <button
@@ -80,7 +83,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`} }`}
> >
<Cpu className="h-5 w-5" /> <Cpu className="h-5 w-5" />
<span>Hardware</span> <span>{t("navigation.hardware")}</span>
</button> </button>
<button <button
@@ -92,7 +95,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`} }`}
> >
<FileText className="h-5 w-5" /> <FileText className="h-5 w-5" />
<span>System Logs</span> <span>{t("navigation.systemLogs")}</span>
</button> </button>
<button <button
@@ -104,7 +107,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`} }`}
> >
<Terminal className="h-5 w-5" /> <Terminal className="h-5 w-5" />
<span>Terminal</span> <span>{t("navigation.terminal")}</span>
</button> </button>
<button <button
@@ -116,7 +119,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`} }`}
> >
<SettingsIcon className="h-5 w-5" /> <SettingsIcon className="h-5 w-5" />
<span>Settings</span> <span>{t("navigation.settings")}</span>
</button> </button>
</div> </div>
) )
+4 -2
View File
@@ -4,8 +4,10 @@ import { useTheme } from "next-themes"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Button } from "./ui/button" import { Button } from "./ui/button"
import { useT } from "../lib/i18n/provider"
export function ThemeToggle() { export function ThemeToggle() {
const t = useT()
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
@@ -22,7 +24,7 @@ export function ThemeToggle() {
return ( return (
<Button variant="outline" size="sm" className="border-border bg-transparent w-9 h-9"> <Button variant="outline" size="sm" className="border-border bg-transparent w-9 h-9">
<Sun className="h-4 w-4" /> <Sun className="h-4 w-4" />
<span className="sr-only">Toggle theme</span> <span className="sr-only">{t("actions.toggleTheme")}</span>
</Button> </Button>
) )
} }
@@ -31,7 +33,7 @@ export function ThemeToggle() {
<Button variant="outline" size="sm" onClick={handleThemeToggle} className="border-border bg-transparent w-9 h-9"> <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" /> <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" /> <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> </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. # injection lands in the system prompt verbatim. Audit Tier 3.2 #4.
_ALLOWED_DETAIL_LEVELS = ('brief', 'standard', 'detailed') _ALLOWED_DETAIL_LEVELS = ('brief', 'standard', 'detailed')
_ALLOWED_AI_LANGUAGES = ( _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', 'ja', 'zh', 'ko', 'pl', 'nl', 'tr', 'ar',
) )
if short_key.endswith('.ai_detail_level') or short_key == 'ai_detail_level': 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 # Supported languages for AI translation
AI_LANGUAGES = { AI_LANGUAGES = {
'en': 'English', 'en': 'English',
'sk': 'Slovak',
'es': 'Spanish', 'es': 'Spanish',
'fr': 'French', 'fr': 'French',
'de': 'German', 'de': 'German',