mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-06 15:56:23 +00:00
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:
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>• {t("status.checkService")}</p>
|
||||
<p>• {t("status.serverPort")}</p>
|
||||
<p>
|
||||
• Try accessing:{" "}
|
||||
• {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 & 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 & 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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values."
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values."
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values."
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values."
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values."
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user