Merge pull request #277 from Vaso73/feature/monitor-dashboard-i18n-v2

Complete Slovak i18n for the Monitor dashboard
This commit is contained in:
MacRimi
2026-08-06 16:18:21 +02:00
committed by GitHub
59 changed files with 13154 additions and 4551 deletions
+8 -5
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"
@@ -43,13 +44,15 @@ export default function RootLayout({
return ( return (
<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={null}>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange> <I18nProvider>
{children} <ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
</ThemeProvider> {children}
</ThemeProvider>
<PwaInstallPrompt />
</I18nProvider>
</Suspense> </Suspense>
<PwaRegister /> <PwaRegister />
<PwaInstallPrompt />
</body> </body>
</html> </html>
) )
+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>
) )
+26 -26
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
@@ -22,8 +23,8 @@ import { APP_VERSION } from "./release-notes-modal"
// without re-cluttering the dashboard footer. // without re-cluttering the dashboard footer.
interface LinkRow { interface LinkRow {
label: string labelKey: string
description: string descriptionKey: string
href: string href: string
Icon: React.ComponentType<{ className?: string }> Icon: React.ComponentType<{ className?: string }>
accent?: keyof typeof ACCENT_CLASSES accent?: keyof typeof ACCENT_CLASSES
@@ -42,29 +43,29 @@ const ACCENT_CLASSES = {
const PROJECT_LINKS: LinkRow[] = [ const PROJECT_LINKS: LinkRow[] = [
{ {
label: "GitHub repository", labelKey: "about.links.repository.label",
description: "Source code, releases and issue tracker.", descriptionKey: "about.links.repository.description",
href: "https://github.com/MacRimi/ProxMenux", href: "https://github.com/MacRimi/ProxMenux",
Icon: Github, Icon: Github,
accent: "gray", accent: "gray",
}, },
{ {
label: "Documentation", labelKey: "about.links.documentation.label",
description: "Full user guide for ProxMenux and the Monitor.", descriptionKey: "about.links.documentation.description",
href: "https://proxmenux.com", href: "https://proxmenux.com",
Icon: BookOpen, Icon: BookOpen,
accent: "blue", accent: "blue",
}, },
{ {
label: "Discussions", labelKey: "about.links.discussions.label",
description: "Ask questions, share custom AI prompts, swap ideas.", descriptionKey: "about.links.discussions.description",
href: "https://github.com/MacRimi/ProxMenux/discussions", href: "https://github.com/MacRimi/ProxMenux/discussions",
Icon: MessageSquare, Icon: MessageSquare,
accent: "purple", accent: "purple",
}, },
{ {
label: "Report a bug or request a feature", labelKey: "about.links.issues.label",
description: "Open an issue on GitHub — bugs, ideas, regressions.", descriptionKey: "about.links.issues.description",
href: "https://github.com/MacRimi/ProxMenux/issues", href: "https://github.com/MacRimi/ProxMenux/issues",
Icon: Bug, Icon: Bug,
accent: "red", accent: "red",
@@ -73,8 +74,8 @@ const PROJECT_LINKS: LinkRow[] = [
const SUPPORT_LINKS: LinkRow[] = [ const SUPPORT_LINKS: LinkRow[] = [
{ {
label: "Support the project on Ko-fi", labelKey: "about.links.support.label",
description: "ProxMenux is free and open source. Donations cover hosting and dev time.", descriptionKey: "about.links.support.description",
href: "https://ko-fi.com/macrimi", href: "https://ko-fi.com/macrimi",
Icon: Heart, Icon: Heart,
accent: "pink", accent: "pink",
@@ -82,6 +83,7 @@ const SUPPORT_LINKS: LinkRow[] = [
] ]
function LinkCard({ row }: { row: LinkRow }) { function LinkCard({ row }: { row: LinkRow }) {
const t = useT()
const accentClass = ACCENT_CLASSES[row.accent ?? "blue"] const accentClass = ACCENT_CLASSES[row.accent ?? "blue"]
// Style mirrors the PCI Devices cards in the Hardware tab: subtle // Style mirrors the PCI Devices cards in the Hardware tab: subtle
// translucent background by default, slightly lighter on hover, no // translucent background by default, slightly lighter on hover, no
@@ -101,16 +103,17 @@ function LinkCard({ row }: { row: LinkRow }) {
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground"> <div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
{row.label} {t(row.labelKey)}
<ExternalLink className="h-3 w-3 text-muted-foreground" /> <ExternalLink className="h-3 w-3 text-muted-foreground" />
</div> </div>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">{row.description}</p> <p className="text-xs text-muted-foreground mt-0.5 leading-snug">{t(row.descriptionKey)}</p>
</div> </div>
</a> </a>
) )
} }
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. */}
@@ -120,7 +123,7 @@ export function About() {
<div className="relative w-24 h-24 md:w-28 md:h-28 flex-shrink-0"> <div className="relative w-24 h-24 md:w-28 md:h-28 flex-shrink-0">
<Image <Image
src="/images/proxmenux-logo.png" src="/images/proxmenux-logo.png"
alt="ProxMenux logo" alt={t("about.logoAlt")}
fill fill
priority priority
className="object-contain" className="object-contain"
@@ -131,9 +134,7 @@ export function About() {
ProxMenux Monitor ProxMenux Monitor
</h2> </h2>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
A web dashboard and management layer for Proxmox VE health monitoring, {t("about.heroDescription")}
notifications, terminal, optimization tracker and more, packaged as a single
AppImage.
</p> </p>
<div className="flex flex-wrap items-center justify-center md:justify-start gap-2 mt-3"> <div className="flex flex-wrap items-center justify-center md:justify-start gap-2 mt-3">
<span className="inline-flex items-center gap-1.5 rounded-md bg-blue-500/10 text-blue-500 border border-blue-500/30 px-2.5 py-1 text-xs font-mono"> <span className="inline-flex items-center gap-1.5 rounded-md bg-blue-500/10 text-blue-500 border border-blue-500/30 px-2.5 py-1 text-xs font-mono">
@@ -151,7 +152,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}
@@ -175,9 +176,9 @@ export function About() {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Github className="h-4 w-4 text-muted-foreground" /> <Github className="h-4 w-4 text-muted-foreground" />
Project {t("about.project.title")}
</CardTitle> </CardTitle>
<CardDescription>Repository, documentation and community channels.</CardDescription> <CardDescription>{t("about.project.description")}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
@@ -195,11 +196,10 @@ export function About() {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Heart className="h-4 w-4 text-pink-500" /> <Heart className="h-4 w-4 text-pink-500" />
Support &amp; License {t("about.support.title")}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
ProxMenux is free and open source under the GPL-3.0 license. If it&apos;s useful to {t("about.support.description")}
you, a one-off contribution helps keep it that way.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -218,11 +218,11 @@ export function About() {
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground"> <div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
GPL-3.0 license {t("about.license.label")}
<ExternalLink className="h-3 w-3 text-muted-foreground" /> <ExternalLink className="h-3 w-3 text-muted-foreground" />
</div> </div>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug"> <p className="text-xs text-muted-foreground mt-0.5 leading-snug">
Free software see the LICENSE file for the full text. {t("about.license.description")}
</p> </p>
</div> </div>
</a> </a>
+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>
+12 -10
View File
@@ -6,6 +6,7 @@ import { Badge } from "./ui/badge"
import { AreaChart, Area, ResponsiveContainer, Tooltip, YAxis } from "recharts" import { AreaChart, Area, ResponsiveContainer, Tooltip, YAxis } from "recharts"
import { fetchApi } from "@/lib/api-config" import { fetchApi } from "@/lib/api-config"
import { useDiskTempThresholds } from "@/lib/health-thresholds" import { useDiskTempThresholds } from "@/lib/health-thresholds"
import { useT } from "@/lib/i18n/provider"
interface TempPoint { interface TempPoint {
timestamp: number timestamp: number
@@ -24,11 +25,11 @@ interface DiskTemperatureCardProps {
// Disk-temperature thresholds come from the user-configurable backend // Disk-temperature thresholds come from the user-configurable backend
// (lib/health-thresholds.ts). The classifier here takes the resolved // (lib/health-thresholds.ts). The classifier here takes the resolved
// pair so the consumer can read it from the hook once per render. // pair so the consumer can read it from the hook once per render.
function statusFor(temp: number, t: { warn: number; hot: number }) { function statusFor(temp: number, thresholds: { warn: number; hot: number }) {
if (temp <= 0) return { label: "N/A", className: "bg-gray-500/10 text-gray-500 border-gray-500/20", color: "#6b7280" } if (temp <= 0) return { labelKey: "common.notAvailable", className: "bg-gray-500/10 text-gray-500 border-gray-500/20", color: "#6b7280" }
if (temp >= t.hot) return { label: "Hot", className: "bg-red-500/10 text-red-500 border-red-500/20", color: "#ef4444" } if (temp >= thresholds.hot) return { labelKey: "details.temperature.status.hot", className: "bg-red-500/10 text-red-500 border-red-500/20", color: "#ef4444" }
if (temp >= t.warn) return { label: "Warm", className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", color: "#f59e0b" } if (temp >= thresholds.warn) return { labelKey: "details.temperature.status.warm", className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", color: "#f59e0b" }
return { label: "Normal", className: "bg-green-500/10 text-green-500 border-green-500/20", color: "#22c55e" } return { labelKey: "details.temperature.status.normal", className: "bg-green-500/10 text-green-500 border-green-500/20", color: "#22c55e" }
} }
const MiniTooltip = ({ active, payload }: any) => { const MiniTooltip = ({ active, payload }: any) => {
@@ -55,6 +56,7 @@ export function DiskTemperatureCard({
diskType, diskType,
onOpenDetail, onOpenDetail,
}: DiskTemperatureCardProps) { }: DiskTemperatureCardProps) {
const t = useT()
const [data, setData] = useState<TempPoint[]>([]) const [data, setData] = useState<TempPoint[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const cancelled = useRef(false) const cancelled = useRef(false)
@@ -98,7 +100,7 @@ export function DiskTemperatureCard({
})() })()
const status = statusFor(liveTemperature, dt) const status = statusFor(liveTemperature, dt)
const lineColor = status.color const lineColor = status.color
const tempDisplay = liveTemperature > 0 ? `${liveTemperature}°C` : "N/A" const tempDisplay = liveTemperature > 0 ? `${liveTemperature}°C` : t("common.notAvailable")
const samples = data.length const samples = data.length
const interactive = !!onOpenDetail const interactive = !!onOpenDetail
@@ -112,11 +114,11 @@ export function DiskTemperatureCard({
"w-full text-left border border-white/10 rounded-lg p-3 bg-white/[0.02]", "w-full text-left border border-white/10 rounded-lg p-3 bg-white/[0.02]",
interactive ? "cursor-pointer hover:bg-white/[0.04] transition-colors focus:outline-none focus:ring-1 focus:ring-white/20" : "", interactive ? "cursor-pointer hover:bg-white/[0.04] transition-colors focus:outline-none focus:ring-1 focus:ring-white/20" : "",
].join(" ")} ].join(" ")}
title={interactive ? "Open temperature history" : undefined} title={interactive ? t("details.temperature.openHistory") : undefined}
> >
<div className="flex items-start justify-between gap-3 mb-1.5"> <div className="flex items-start justify-between gap-3 mb-1.5">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Temperature</p> <p className="text-[11px] uppercase tracking-wider text-muted-foreground">{t("details.temperature.diskTitle")}</p>
<p className="text-xl font-bold leading-tight mt-0.5" style={{ color: lineColor }}> <p className="text-xl font-bold leading-tight mt-0.5" style={{ color: lineColor }}>
{tempDisplay} {tempDisplay}
</p> </p>
@@ -124,7 +126,7 @@ export function DiskTemperatureCard({
<div className="flex flex-col items-end gap-1 flex-shrink-0"> <div className="flex flex-col items-end gap-1 flex-shrink-0">
<Thermometer className="h-3.5 w-3.5" style={{ color: lineColor }} /> <Thermometer className="h-3.5 w-3.5" style={{ color: lineColor }} />
<Badge variant="outline" className={`${status.className} text-[10px] px-2 py-0`}> <Badge variant="outline" className={`${status.className} text-[10px] px-2 py-0`}>
{status.label} {t(status.labelKey)}
</Badge> </Badge>
</div> </div>
</div> </div>
@@ -134,7 +136,7 @@ export function DiskTemperatureCard({
<div className="h-full w-full animate-pulse bg-white/[0.03] rounded" /> <div className="h-full w-full animate-pulse bg-white/[0.03] rounded" />
) : samples < 2 ? ( ) : samples < 2 ? (
<div className="h-full flex items-center justify-center text-[10px] text-muted-foreground"> <div className="h-full flex items-center justify-center text-[10px] text-muted-foreground">
Collecting samples chart populates after ~2 minutes {t("details.temperature.collectingSamples")}
</div> </div>
) : ( ) : (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
@@ -8,12 +8,13 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
import { useIsMobile } from "../hooks/use-mobile" import { useIsMobile } from "../hooks/use-mobile"
import { fetchApi } from "@/lib/api-config" import { fetchApi } from "@/lib/api-config"
import { useDiskTempThresholds, type DiskTempThreshold } from "@/lib/health-thresholds" import { useDiskTempThresholds, type DiskTempThreshold } from "@/lib/health-thresholds"
import { useT } from "@/lib/i18n/provider"
const TIMEFRAME_OPTIONS = [ const TIMEFRAME_OPTIONS = [
{ value: "hour", label: "1 Hour" }, { value: "hour", labelKey: "details.temperature.timeframes.hour" },
{ value: "day", label: "24 Hours" }, { value: "day", labelKey: "details.temperature.timeframes.day" },
{ value: "week", label: "7 Days" }, { value: "week", labelKey: "details.temperature.timeframes.week" },
{ value: "month", label: "30 Days" }, { value: "month", labelKey: "details.temperature.timeframes.month" },
] ]
interface TempHistoryPoint { interface TempHistoryPoint {
@@ -69,10 +70,10 @@ function colorFor(temp: number, t: DiskTempThreshold): string {
} }
function statusInfoFor(temp: number, t: DiskTempThreshold) { function statusInfoFor(temp: number, t: DiskTempThreshold) {
if (temp <= 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" } if (temp <= 0) return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
if (temp >= t.hot) return { status: "Hot", color: "bg-red-500/10 text-red-500 border-red-500/20" } if (temp >= t.hot) return { color: "bg-red-500/10 text-red-500 border-red-500/20" }
if (temp >= t.warn) return { status: "Warm", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" } if (temp >= t.warn) return { color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" } return { color: "bg-green-500/10 text-green-500 border-green-500/20" }
} }
export function DiskTemperatureDetailModal({ export function DiskTemperatureDetailModal({
@@ -83,6 +84,7 @@ export function DiskTemperatureDetailModal({
liveTemperature, liveTemperature,
diskType, diskType,
}: DiskTemperatureDetailModalProps) { }: DiskTemperatureDetailModalProps) {
const t = useT()
const [timeframe, setTimeframe] = useState("day") const [timeframe, setTimeframe] = useState("day")
const [data, setData] = useState<TempHistoryPoint[]>([]) const [data, setData] = useState<TempHistoryPoint[]>([])
const [stats, setStats] = useState<TempStats>({ min: 0, max: 0, avg: 0, current: 0 }) const [stats, setStats] = useState<TempStats>({ min: 0, max: 0, avg: 0, current: 0 })
@@ -168,7 +170,7 @@ export function DiskTemperatureDetailModal({
<SelectContent> <SelectContent>
{TIMEFRAME_OPTIONS.map((opt) => ( {TIMEFRAME_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}> <SelectItem key={opt.value} value={opt.value}>
{opt.label} {t(opt.labelKey)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -181,24 +183,24 @@ export function DiskTemperatureDetailModal({
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
<div className={`rounded-lg p-3 text-center border ${currentStatus.color}`}> <div className={`rounded-lg p-3 text-center border ${currentStatus.color}`}>
<div className="text-xs opacity-80 mb-1">Current</div> <div className="text-xs opacity-80 mb-1">{t("details.temperature.current")}</div>
<div className="text-lg font-bold">{currentTemp > 0 ? `${currentTemp}°C` : "N/A"}</div> <div className="text-lg font-bold">{currentTemp > 0 ? `${currentTemp}°C` : t("common.notAvailable")}</div>
</div> </div>
<div className="bg-muted/50 rounded-lg p-3 text-center"> <div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1"> <div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<TrendingDown className="h-3 w-3" /> Min <TrendingDown className="h-3 w-3" /> {t("details.temperature.min")}
</div> </div>
<div className="text-lg font-bold text-green-500">{stats.min}°C</div> <div className="text-lg font-bold text-green-500">{stats.min}°C</div>
</div> </div>
<div className="bg-muted/50 rounded-lg p-3 text-center"> <div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1"> <div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<Minus className="h-3 w-3" /> Avg <Minus className="h-3 w-3" /> {t("details.temperature.avg")}
</div> </div>
<div className="text-lg font-bold text-foreground">{stats.avg}°C</div> <div className="text-lg font-bold text-foreground">{stats.avg}°C</div>
</div> </div>
<div className="bg-muted/50 rounded-lg p-3 text-center"> <div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1"> <div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<TrendingUp className="h-3 w-3" /> Max <TrendingUp className="h-3 w-3" /> {t("details.temperature.max")}
</div> </div>
<div className="text-lg font-bold text-red-500">{stats.max}°C</div> <div className="text-lg font-bold text-red-500">{stats.max}°C</div>
</div> </div>
@@ -216,8 +218,8 @@ export function DiskTemperatureDetailModal({
<div className="h-full flex items-center justify-center text-muted-foreground"> <div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center"> <div className="text-center">
<Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" /> <Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No temperature data yet for this disk</p> <p>{t("details.temperature.noData")}</p>
<p className="text-sm mt-1">Samples are collected every 60 seconds</p> <p className="text-sm mt-1">{t("details.temperature.sampleInterval")}</p>
</div> </div>
</div> </div>
) : ( ) : (
@@ -250,7 +252,7 @@ export function DiskTemperatureDetailModal({
<Area <Area
type="monotone" type="monotone"
dataKey="value" dataKey="value"
name="Temperature" name={t("details.temperature.seriesName")}
stroke={chartColor} stroke={chartColor}
strokeWidth={2} strokeWidth={2}
fill={`url(#diskTempGradient-${diskName})`} fill={`url(#diskTempGradient-${diskName})`}
@@ -1,6 +1,7 @@
"use client" "use client"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useT } from "../lib/i18n/provider"
interface SriovInfo { interface SriovInfo {
role: "vf" | "pf-active" | "pf-idle" role: "vf" | "pf-active" | "pf-idle"
@@ -26,6 +27,8 @@ export function GpuSwitchModeIndicator({
className, className,
sriovInfo, sriovInfo,
}: GpuSwitchModeIndicatorProps) { }: GpuSwitchModeIndicatorProps) {
const t = useT()
// SR-IOV is a non-editable hardware state. Pending toggles don't apply here. // SR-IOV is a non-editable hardware state. Pending toggles don't apply here.
const displayMode = mode === "sriov" ? "sriov" : (pendingMode ?? mode) const displayMode = mode === "sriov" ? "sriov" : (pendingMode ?? mode)
const isLxcActive = displayMode === "lxc" const isLxcActive = displayMode === "lxc"
@@ -69,9 +72,11 @@ export function GpuSwitchModeIndicator({
// exactly how many VFs are active; for a VF we show its parent PF. // exactly how many VFs are active; for a VF we show its parent PF.
const sriovBadgeText = (() => { const sriovBadgeText = (() => {
if (!isSriovActive) return "" if (!isSriovActive) return ""
if (sriovInfo?.role === "vf") return "SR-IOV VF" if (sriovInfo?.role === "vf") return t("hardware.gpuSwitch.sriovVf")
if (sriovInfo?.vfCount && sriovInfo.vfCount > 0) return `SR-IOV ×${sriovInfo.vfCount}` if (sriovInfo?.vfCount && sriovInfo.vfCount > 0) {
return "SR-IOV" return t("hardware.gpuSwitch.sriovCount", { count: sriovInfo.vfCount })
}
return t("hardware.gpuSwitch.sriov")
})() })()
return ( return (
@@ -124,7 +129,7 @@ export function GpuSwitchModeIndicator({
className="text-[14px] font-bold transition-all duration-300" className="text-[14px] font-bold transition-all duration-300"
style={{ fontFamily: 'system-ui, sans-serif' }} style={{ fontFamily: 'system-ui, sans-serif' }}
> >
GPU {t("hardware.gpuSwitch.gpu")}
</text> </text>
</g> </g>
@@ -268,7 +273,7 @@ export function GpuSwitchModeIndicator({
)} )}
style={{ fontFamily: 'system-ui, sans-serif' }} style={{ fontFamily: 'system-ui, sans-serif' }}
> >
LXC {t("hardware.gpuSwitch.lxc")}
</text> </text>
)} )}
{isSriovActive && ( {isSriovActive && (
@@ -279,7 +284,7 @@ export function GpuSwitchModeIndicator({
className="text-[9px] font-medium" className="text-[9px] font-medium"
style={{ fontFamily: 'system-ui, sans-serif' }} style={{ fontFamily: 'system-ui, sans-serif' }}
> >
LXC {t("hardware.gpuSwitch.lxc")}
</text> </text>
)} )}
@@ -332,7 +337,7 @@ export function GpuSwitchModeIndicator({
)} )}
style={{ fontFamily: 'system-ui, sans-serif' }} style={{ fontFamily: 'system-ui, sans-serif' }}
> >
VM {t("hardware.gpuSwitch.vm")}
</text> </text>
)} )}
{isSriovActive && ( {isSriovActive && (
@@ -343,7 +348,7 @@ export function GpuSwitchModeIndicator({
className="text-[9px] font-medium" className="text-[9px] font-medium"
style={{ fontFamily: 'system-ui, sans-serif' }} style={{ fontFamily: 'system-ui, sans-serif' }}
> >
VM {t("hardware.gpuSwitch.vm")}
</text> </text>
)} )}
</svg> </svg>
@@ -363,34 +368,47 @@ export function GpuSwitchModeIndicator({
)} )}
> >
{isSriovActive {isSriovActive
? "SR-IOV active" ? t("hardware.gpuSwitch.sriovActive")
: isLxcActive : isLxcActive
? "Ready for LXC containers" ? t("hardware.gpuSwitch.readyForLxc")
: isVmActive : isVmActive
? "Ready for VM passthrough" ? t("hardware.gpuSwitch.readyForVm")
: "Mode unknown"} : t("hardware.gpuSwitch.modeUnknown")}
</span> </span>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{isSriovActive {isSriovActive
? "Virtual Functions managed externally" ? t("hardware.gpuSwitch.virtualFunctionsExternal")
: isLxcActive : isLxcActive
? "Native driver active" ? t("hardware.gpuSwitch.nativeDriverActive")
: isVmActive : isVmActive
? "VFIO-PCI driver active" ? t("hardware.gpuSwitch.vfioDriverActive")
: "No driver detected"} : t("hardware.gpuSwitch.noDriverDetected")}
</span> </span>
{isSriovActive && sriovInfo && ( {isSriovActive && sriovInfo && (
<span className="text-xs font-mono text-teal-600/80 dark:text-teal-400/80"> <span className="text-xs font-mono text-teal-600/80 dark:text-teal-400/80">
{sriovInfo.role === "vf" {sriovInfo.role === "vf"
? `Virtual Function${sriovInfo.physfn ? ` · parent PF ${sriovInfo.physfn}` : ""}` ? t(
sriovInfo.physfn
? "hardware.gpuSwitch.virtualFunctionWithParent"
: "hardware.gpuSwitch.virtualFunction",
{ parent: sriovInfo.physfn || "" },
)
: sriovInfo.vfCount !== undefined : sriovInfo.vfCount !== undefined
? `1 PF + ${sriovInfo.vfCount} VF${sriovInfo.vfCount === 1 ? "" : "s"}${sriovInfo.totalvfs ? ` / ${sriovInfo.totalvfs} max` : ""}` ? t(
sriovInfo.totalvfs
? "hardware.gpuSwitch.physicalFunctionWithMax"
: "hardware.gpuSwitch.physicalFunction",
{
count: sriovInfo.vfCount,
max: sriovInfo.totalvfs || "",
},
)
: null} : null}
</span> </span>
)} )}
{hasChanged && ( {hasChanged && (
<span className="text-sm text-amber-500 font-medium animate-pulse"> <span className="text-sm text-amber-500 font-medium animate-pulse">
Change pending... {t("hardware.gpuSwitch.changePending")}
</span> </span>
)} )}
</div> </div>
File diff suppressed because it is too large Load Diff
+148 -122
View File
@@ -41,6 +41,7 @@ import {
HelpCircle, HelpCircle,
} from "lucide-react" } from "lucide-react"
import { ScriptTerminalModal } from "./script-terminal-modal" import { ScriptTerminalModal } from "./script-terminal-modal"
import { useT } from "@/lib/i18n/provider"
interface CategoryCheck { interface CategoryCheck {
status: string status: string
@@ -104,19 +105,20 @@ interface HealthStatusModalProps {
} }
const CATEGORIES = [ const CATEGORIES = [
{ key: "cpu", category: "temperature", label: "CPU Usage & Temperature", Icon: Cpu }, { key: "cpu", category: "temperature", Icon: Cpu },
{ key: "memory", category: "memory", label: "Memory & Swap", Icon: MemoryStick }, { key: "memory", category: "memory", Icon: MemoryStick },
{ key: "storage", category: "storage", label: "Storage Mounts & Space", Icon: HardDrive }, { key: "storage", category: "storage", Icon: HardDrive },
{ key: "disks", category: "disks", label: "Disk I/O & Errors", Icon: Disc }, { key: "disks", category: "disks", Icon: Disc },
{ key: "network", category: "network", label: "Network Interfaces", Icon: Network }, { key: "network", category: "network", Icon: Network },
{ key: "vms", category: "vms", label: "VMs & Containers", Icon: Box }, { key: "vms", category: "vms", Icon: Box },
{ key: "services", category: "pve_services", label: "PVE Services", Icon: Settings }, { key: "services", category: "pve_services", Icon: Settings },
{ key: "logs", category: "logs", label: "System Logs", Icon: FileText }, { key: "logs", category: "logs", Icon: FileText },
{ key: "updates", category: "updates", label: "System Updates", Icon: RefreshCw }, { key: "updates", category: "updates", Icon: RefreshCw },
{ key: "security", category: "security", label: "Security & Certificates", Icon: Shield }, { key: "security", category: "security", Icon: Shield },
] ]
export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) { export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
const t = useT()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [healthData, setHealthData] = useState<HealthDetails | null>(null) const [healthData, setHealthData] = useState<HealthDetails | null>(null)
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([]) const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
@@ -146,7 +148,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
if (!response.ok) { if (!response.ok) {
// Fallback to legacy endpoint // Fallback to legacy endpoint
const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders }) const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders })
if (!legacyResponse.ok) throw new Error("Failed to fetch health details") if (!legacyResponse.ok) throw new Error(t("healthStatus.errors.fetchFailed"))
const data = await legacyResponse.json() const data = await legacyResponse.json()
setHealthData(data) setHealthData(data)
setDismissedItems([]) setDismissedItems([])
@@ -203,11 +205,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
}) })
window.dispatchEvent(event) window.dispatchEvent(event)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Unknown error") setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [getApiUrl]) }, [getApiUrl, t])
// Tick counter to force re-render every 30s so "X minutes ago" stays current // Tick counter to force re-render every 30s so "X minutes ago" stays current
const [, setTick] = useState(0) const [, setTick] = useState(0)
@@ -280,20 +282,90 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
const statusUpper = status?.toUpperCase() const statusUpper = status?.toUpperCase()
switch (statusUpper) { switch (statusUpper) {
case "OK": case "OK":
return <Badge className="bg-green-500 text-white hover:bg-green-500">OK</Badge> return <Badge className="bg-green-500 text-white hover:bg-green-500">{t("healthStatus.status.ok")}</Badge>
case "INFO": case "INFO":
return <Badge className="bg-blue-500 text-white hover:bg-blue-500">Info</Badge> return <Badge className="bg-blue-500 text-white hover:bg-blue-500">{t("healthStatus.status.info")}</Badge>
case "WARNING": case "WARNING":
return <Badge className="bg-yellow-500 text-white hover:bg-yellow-500">Warning</Badge> return <Badge className="bg-yellow-500 text-white hover:bg-yellow-500">{t("healthStatus.status.warning")}</Badge>
case "CRITICAL": case "CRITICAL":
return <Badge className="bg-red-500 text-white hover:bg-red-500">Critical</Badge> return <Badge className="bg-red-500 text-white hover:bg-red-500">{t("healthStatus.status.critical")}</Badge>
case "UNKNOWN": case "UNKNOWN":
return <Badge className="bg-amber-500 text-white hover:bg-amber-500">UNKNOWN</Badge> return <Badge className="bg-amber-500 text-white hover:bg-amber-500">{t("healthStatus.status.unknown")}</Badge>
default: default:
return <Badge>Unknown</Badge> return <Badge>{t("healthStatus.status.unknown")}</Badge>
} }
} }
const formatStatus = (status: string) => {
const key = status?.toLowerCase()
return ["ok", "info", "warning", "critical", "unknown"].includes(key)
? t(`healthStatus.status.${key}`)
: status
}
const translateHealthText = (value?: string): string => {
if (!value) return ""
const exact: Record<string, string> = {
"All systems operational": t("healthStatus.details.allOperational"),
"Normal": t("healthStatus.details.normal"),
"No I/O errors in dmesg": t("healthStatus.details.noIoErrors"),
"Mounted read-write, space OK": t("healthStatus.details.rootFilesystemOk"),
"No SMART warnings in journal": t("healthStatus.details.noSmartWarnings"),
"No critical errors": t("healthStatus.details.noCriticalErrors"),
"No cascading errors": t("healthStatus.details.noCascadingErrors"),
"No error spikes": t("healthStatus.details.noErrorSpikes"),
"No persistent patterns": t("healthStatus.details.noPersistentPatterns"),
"Certificate valid": t("healthStatus.details.certificateValid"),
"Cluster detected (corosync.conf present)": t("healthStatus.details.clusterDetected"),
"Active": t("healthStatus.details.active"),
"UP": t("healthStatus.details.up"),
"Kernel/PVE up to date": t("healthStatus.details.kernelUpToDate"),
"Proxmox VE is up to date": t("healthStatus.details.proxmoxUpToDate"),
"No security updates pending": t("healthStatus.details.noSecurityUpdates"),
"No container startup errors": t("healthStatus.details.noContainerErrors"),
"No OOM events detected": t("healthStatus.details.noOomEvents"),
"No QMP timeouts detected": t("healthStatus.details.noQmpTimeouts"),
"No VM startup failures": t("healthStatus.details.noVmFailures"),
"Dismissed by user": t("healthStatus.details.dismissedByUser"),
}
if (exact[value]) return exact[value]
let match = value.match(/^Latency ([\d.]+)ms to gateway$/)
if (match) return t("healthStatus.details.gatewayLatency", { latency: match[1] })
match = value.match(/^(\d+) failed login attempts in 24h$/)
if (match) return t("healthStatus.details.failedLogins", { count: match[1] })
match = value.match(/^(\d+) IP\(s\) currently banned by Fail2Ban \(jails: (.+)\)$/)
if (match) return t("healthStatus.details.fail2banBannedIps", { count: match[1], jails: match[2] })
match = value.match(/^Uptime (\d+) days?$/)
if (match) return t("healthStatus.details.uptimeDays", { count: match[1] })
match = value.match(/^(\d+) package\(s\) pending$/)
if (match) return t("healthStatus.details.pendingPackages", { count: match[1] })
match = value.match(/^Last updated (\d+) day\(s\) ago$/)
if (match) return t("healthStatus.details.updatedDaysAgo", { count: match[1] })
match = value.match(/^(.+) storage available$/)
if (match) return t("healthStatus.details.storageAvailable", { type: match[1] })
match = value.match(/^(.+) mount reachable$/)
if (match) return t("healthStatus.details.mountReachable", { type: match[1] })
match = value.match(/^rootfs ([\d.]+)% used \((.+)\)$/)
if (match) return t("healthStatus.details.rootfsUsed", { percent: match[1], size: match[2] })
match = value.match(/^(\d+) running CT\(s\) within safe rootfs usage$/)
if (match) return t("healthStatus.details.runningCtsSafe", { count: match[1] })
match = value.match(/^(\d+) PVE block storage\(s\) within safe usage$/)
if (match) return t("healthStatus.details.pveStorageSafe", { count: match[1] })
match = value.match(/^(\d+) remote mount\(s\) healthy$/)
if (match) return t("healthStatus.details.remoteMountsHealthy", { count: match[1] })
return value
}
const formatDuration = (hours: number) => {
if (hours === -1) return t("healthStatus.permanent")
if (hours >= 8760) return t("healthStatus.duration.years", { count: Math.floor(hours / 8760) })
if (hours >= 720) return t("healthStatus.duration.months", { count: Math.floor(hours / 720) })
if (hours >= 168) return t("healthStatus.duration.weeks", { count: Math.floor(hours / 168) })
if (hours >= 24) return t("healthStatus.duration.days", { count: Math.floor(hours / 24) })
return t("healthStatus.duration.hours", { count: Math.round(hours) })
}
// Get categories that have dismissed items (to show as INFO) // Get categories that have dismissed items (to show as INFO)
const getCategoriesWithDismissed = () => { const getCategoriesWithDismissed = () => {
const customCats = new Set(customSuppressions.map(cs => cs.category)) const customCats = new Set(customSuppressions.map(cs => cs.category))
@@ -444,11 +516,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
const now = new Date() const now = new Date()
const diffMs = now.getTime() - checkTime.getTime() const diffMs = now.getTime() - checkTime.getTime()
const diffMin = Math.floor(diffMs / 60000) const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return "just now" if (diffMin < 1) return t("healthStatus.time.justNow")
if (diffMin === 1) return "1 minute ago" if (diffMin === 1) return t("healthStatus.time.oneMinuteAgo")
if (diffMin < 60) return `${diffMin} minutes ago` if (diffMin < 60) return t("healthStatus.time.minutesAgo", { count: diffMin })
const diffHours = Math.floor(diffMin / 60) const diffHours = Math.floor(diffMin / 60)
return `${diffHours}h ${diffMin % 60}m ago` return t("healthStatus.time.hoursMinutesAgo", { hours: diffHours, minutes: diffMin % 60 })
} }
const getCategoryRowStyle = (status: string) => { const getCategoryRowStyle = (status: string) => {
@@ -471,49 +543,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
} }
const formatCheckLabel = (key: string): string => { const formatCheckLabel = (key: string): string => {
const labels: Record<string, string> = { const knownKeys = new Set([
// CPU "cpu_usage", "cpu_temperature", "ram_usage", "swap_usage", "root_filesystem",
cpu_usage: "CPU Usage", "smart_health", "io_errors", "zfs_pools", "lvm_volumes", "lvm_check", "connectivity",
cpu_temperature: "Temperature", "qmp_communication", "container_startup", "vm_startup", "oom_killer", "cluster_mode",
// Memory "log_error_cascade", "log_error_spike", "log_persistent_errors", "log_critical_errors",
ram_usage: "RAM Usage", "pve_version", "security_updates", "system_age", "pending_updates", "kernel_pve", "uptime",
swap_usage: "Swap Usage", "certificates", "login_attempts", "fail2ban", "proxmox_storages",
// Disk I/O ])
root_filesystem: "Root Filesystem", if (knownKeys.has(key)) return t(`healthStatus.checks.${key}`)
smart_health: "SMART Health",
io_errors: "I/O Errors",
zfs_pools: "ZFS Pools",
lvm_volumes: "LVM Volumes",
lvm_check: "LVM Status",
// Network
connectivity: "Connectivity",
// VMs & CTs
qmp_communication: "QMP Communication",
container_startup: "Container Startup",
vm_startup: "VM Startup",
oom_killer: "OOM Killer",
// Services
cluster_mode: "Cluster Mode",
// Logs (prefixed with log_)
log_error_cascade: "Error Cascade",
log_error_spike: "Error Spike",
log_persistent_errors: "Persistent Errors",
log_critical_errors: "Critical Errors",
// Updates
pve_version: "Proxmox VE Version",
security_updates: "Security Updates",
system_age: "System Age",
pending_updates: "Pending Updates",
kernel_pve: "Kernel / PVE",
// Security
uptime: "Uptime",
certificates: "Certificates",
login_attempts: "Login Attempts",
fail2ban: "Fail2Ban",
// Storage (Proxmox)
proxmox_storages: "Proxmox Storages",
}
if (labels[key]) return labels[key]
// Convert snake_case or camelCase to Title Case // Convert snake_case or camelCase to Title Case
return key return key
.replace(/_/g, " ") .replace(/_/g, " ")
@@ -543,15 +581,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
<div className="flex items-start gap-1.5 sm:gap-2 min-w-0 flex-1"> <div className="flex items-start gap-1.5 sm:gap-2 min-w-0 flex-1">
<span className="mt-0.5 shrink-0">{getStatusIcon(checkData.dismissed ? "INFO" : checkData.status, "sm")}</span> <span className="mt-0.5 shrink-0">{getStatusIcon(checkData.dismissed ? "INFO" : checkData.status, "sm")}</span>
<span className="font-medium shrink-0">{formatCheckLabel(checkKey)}</span> <span className="font-medium shrink-0">{formatCheckLabel(checkKey)}</span>
<span className="text-muted-foreground break-words whitespace-pre-wrap min-w-0">{checkData.detail}</span> <span className="text-muted-foreground break-words whitespace-pre-wrap min-w-0">{translateHealthText(checkData.detail)}</span>
{checkData.dismissed && ( {checkData.dismissed && (
checkData.permanent ? ( checkData.permanent ? (
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-amber-400 border-amber-400/40"> <Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-amber-400 border-amber-400/40">
Permanent {t("healthStatus.permanent")}
</Badge> </Badge>
) : ( ) : (
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-blue-400 border-blue-400/30"> <Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-blue-400 border-blue-400/30">
Dismissed {t("healthStatus.dismissed")}
</Badge> </Badge>
) )
)} )}
@@ -563,6 +601,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
handleAcknowledge(checkData.error_key || checkKey, hours) handleAcknowledge(checkData.error_key || checkKey, hours)
} }
busy={dismissingKey === (checkData.error_key || checkKey)} busy={dismissingKey === (checkData.error_key || checkKey)}
t={t}
/> />
)} )}
</div> </div>
@@ -582,12 +621,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<DialogTitle className="flex items-center gap-2 flex-1 min-w-0"> <DialogTitle className="flex items-center gap-2 flex-1 min-w-0">
<Activity className="h-5 w-5 sm:h-6 sm:w-6 shrink-0" /> <Activity className="h-5 w-5 sm:h-6 sm:w-6 shrink-0" />
<span className="truncate text-base sm:text-lg">System Health Status</span> <span className="truncate text-base sm:text-lg">{t("healthStatus.title")}</span>
{healthData && <div className="shrink-0">{getStatusBadge(healthData.overall)}</div>} {healthData && <div className="shrink-0">{getStatusBadge(healthData.overall)}</div>}
</DialogTitle> </DialogTitle>
</div> </div>
<DialogDescription className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs sm:text-sm"> <DialogDescription className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs sm:text-sm">
<span>Detailed health checks for all system components</span> <span>{t("healthStatus.description")}</span>
{getTimeSinceCheck() && ( {getTimeSinceCheck() && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground"> <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
@@ -605,7 +644,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{error && ( {error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800 dark:bg-red-950 dark:border-red-800 dark:text-red-200"> <div className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800 dark:bg-red-950 dark:border-red-800 dark:text-red-200">
<p className="font-medium">Error loading health status</p> <p className="font-medium">{t("healthStatus.errors.loading")}</p>
<p className="text-sm">{error}</p> <p className="text-sm">{error}</p>
</div> </div>
)} )}
@@ -616,47 +655,47 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
<div className={`grid gap-2 sm:gap-3 p-3 sm:p-4 rounded-lg bg-muted/30 border ${stats.info > 0 ? "grid-cols-5" : "grid-cols-4"}`}> <div className={`grid gap-2 sm:gap-3 p-3 sm:p-4 rounded-lg bg-muted/30 border ${stats.info > 0 ? "grid-cols-5" : "grid-cols-4"}`}>
<div className="text-center"> <div className="text-center">
<div className="text-lg sm:text-2xl font-bold">{stats.total}</div> <div className="text-lg sm:text-2xl font-bold">{stats.total}</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Total</div> <div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.total")}</div>
</div> </div>
<div className="text-center"> <div className="text-center">
<div className="text-lg sm:text-2xl font-bold text-green-500">{stats.healthy}</div> <div className="text-lg sm:text-2xl font-bold text-green-500">{stats.healthy}</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Healthy</div> <div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.healthy")}</div>
</div> </div>
{stats.info > 0 && ( {stats.info > 0 && (
<div className="text-center"> <div className="text-center">
<div className="text-lg sm:text-2xl font-bold text-blue-500">{stats.info}</div> <div className="text-lg sm:text-2xl font-bold text-blue-500">{stats.info}</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Info</div> <div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.info")}</div>
</div> </div>
)} )}
<div className="text-center"> <div className="text-center">
<div className="text-lg sm:text-2xl font-bold text-yellow-500">{stats.warnings}</div> <div className="text-lg sm:text-2xl font-bold text-yellow-500">{stats.warnings}</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Warn</div> <div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.warning")}</div>
</div> </div>
<div className="text-center"> <div className="text-center">
<div className="text-lg sm:text-2xl font-bold text-red-500">{stats.critical}</div> <div className="text-lg sm:text-2xl font-bold text-red-500">{stats.critical}</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Critical</div> <div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.critical")}</div>
</div> </div>
{stats.unknown > 0 && ( {stats.unknown > 0 && (
<div className="text-center"> <div className="text-center">
<div className="text-lg sm:text-2xl font-bold text-amber-400">{stats.unknown}</div> <div className="text-lg sm:text-2xl font-bold text-amber-400">{stats.unknown}</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Unknown</div> <div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.unknown")}</div>
</div> </div>
)} )}
</div> </div>
{healthData.summary && healthData.summary !== "All systems operational" && ( {healthData.summary && healthData.summary !== "All systems operational" && (
<div className="text-xs sm:text-sm p-3 rounded-lg bg-muted/20 border overflow-hidden max-w-full"> <div className="text-xs sm:text-sm p-3 rounded-lg bg-muted/20 border overflow-hidden max-w-full">
<p className="font-medium text-foreground break-words whitespace-pre-wrap">{healthData.summary}</p> <p className="font-medium text-foreground break-words whitespace-pre-wrap">{translateHealthText(healthData.summary)}</p>
</div> </div>
)} )}
{/* Category List */} {/* Category List */}
<div className="space-y-2"> <div className="space-y-2">
{CATEGORIES.map(({ key, label, Icon }) => { {CATEGORIES.map(({ key, Icon }) => {
const categoryData = healthData.details[key as keyof typeof healthData.details] const categoryData = healthData.details[key as keyof typeof healthData.details]
const originalStatus = categoryData?.status || "UNKNOWN" const originalStatus = categoryData?.status || "UNKNOWN"
const status = getEffectiveStatus(key, originalStatus) const status = getEffectiveStatus(key, originalStatus)
const reason = categoryData?.reason const reason = translateHealthText(categoryData?.reason)
const checks = categoryData?.checks const checks = categoryData?.checks
const isExpanded = expandedCategories.has(key) const isExpanded = expandedCategories.has(key)
const hasChecks = checks && Object.keys(checks).length > 0 const hasChecks = checks && Object.keys(checks).length > 0
@@ -677,7 +716,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
</div> </div>
<div className="flex-1 min-w-0 overflow-hidden"> <div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center gap-1.5 sm:gap-2"> <div className="flex items-center gap-1.5 sm:gap-2">
<p className="font-medium text-xs sm:text-sm truncate">{label}</p> <p className="font-medium text-xs sm:text-sm truncate">{t(`healthStatus.categories.${key}`)}</p>
{hasChecks && ( {hasChecks && (
<span className="text-[10px] text-muted-foreground shrink-0"> <span className="text-[10px] text-muted-foreground shrink-0">
({Object.values(checks).filter(c => c.installed !== false).length}) ({Object.values(checks).filter(c => c.installed !== false).length})
@@ -690,7 +729,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
</div> </div>
<div className="flex items-center gap-1 sm:gap-2 shrink-0"> <div className="flex items-center gap-1 sm:gap-2 shrink-0">
<Badge variant="outline" className={`text-[10px] sm:text-xs px-1.5 sm:px-2.5 ${getOutlineBadgeStyle(status)}`}> <Badge variant="outline" className={`text-[10px] sm:text-xs px-1.5 sm:px-2.5 ${getOutlineBadgeStyle(status)}`}>
{status} {formatStatus(status)}
</Badge> </Badge>
<ChevronRight <ChevronRight
className={`h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground transition-transform duration-200 ${ className={`h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground transition-transform duration-200 ${
@@ -713,6 +752,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
handleAcknowledge(`category_${key}_unknown`, hours) handleAcknowledge(`category_${key}_unknown`, hours)
} }
busy={dismissingKey === `category_${key}_unknown`} busy={dismissingKey === `category_${key}_unknown`}
t={t}
/> />
)} )}
</div> </div>
@@ -722,7 +762,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
) : ( ) : (
<div className="flex items-center gap-2 text-xs text-muted-foreground px-3 py-2"> <div className="flex items-center gap-2 text-xs text-muted-foreground px-3 py-2">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" /> <CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
No issues detected {t("healthStatus.noIssues")}
</div> </div>
)} )}
{/* Only offer "Update Now" when the category is not {/* Only offer "Update Now" when the category is not
@@ -738,7 +778,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
className="bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200" className="bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
> >
<Download className="h-4 w-4 mr-1.5" /> <Download className="h-4 w-4 mr-1.5" />
Update Now {t("healthStatus.updateNow")}
</Button> </Button>
</div> </div>
)} )}
@@ -758,12 +798,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground pt-2"> <div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground pt-2">
<BellOff className="h-3.5 w-3.5 sm:h-4 sm:w-4" /> <BellOff className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
Dismissed Items ({filteredDismissed.length}) {t("healthStatus.dismissedItems", { count: filteredDismissed.length })}
</div> </div>
{filteredDismissed.map((item) => { {filteredDismissed.map((item) => {
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category) const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
const CatIcon = catMeta?.Icon || BellOff const CatIcon = catMeta?.Icon || BellOff
const catLabel = catMeta?.label || item.category const catLabel = catMeta ? t(`healthStatus.categories.${catMeta.key}`) : item.category
const isPermanent = item.permanent || item.suppression_remaining_hours === -1 const isPermanent = item.permanent || item.suppression_remaining_hours === -1
return ( return (
@@ -778,34 +818,28 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
<div className="flex items-start justify-between gap-2 mb-1"> <div className="flex items-start justify-between gap-2 mb-1">
<div className="min-w-0 flex-1 overflow-hidden"> <div className="min-w-0 flex-1 overflow-hidden">
<p className="font-medium text-xs sm:text-sm text-muted-foreground truncate">{catLabel}</p> <p className="font-medium text-xs sm:text-sm text-muted-foreground truncate">{catLabel}</p>
<p className="text-[10px] sm:text-xs text-muted-foreground/70 break-words line-clamp-2">{item.reason}</p> <p className="text-[10px] sm:text-xs text-muted-foreground/70 break-words line-clamp-2">{translateHealthText(item.reason)}</p>
</div> </div>
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center gap-1.5 shrink-0">
{isPermanent ? ( {isPermanent ? (
<Badge variant="outline" className="text-[9px] sm:text-xs border-amber-500/50 text-amber-500/70 bg-transparent whitespace-nowrap"> <Badge variant="outline" className="text-[9px] sm:text-xs border-amber-500/50 text-amber-500/70 bg-transparent whitespace-nowrap">
Permanent {t("healthStatus.permanent")}
</Badge> </Badge>
) : ( ) : (
<Badge variant="outline" className="text-[9px] sm:text-xs border-blue-500/50 text-blue-500/70 bg-transparent whitespace-nowrap"> <Badge variant="outline" className="text-[9px] sm:text-xs border-blue-500/50 text-blue-500/70 bg-transparent whitespace-nowrap">
Dismissed {t("healthStatus.dismissed")}
</Badge> </Badge>
)} )}
<Badge variant="outline" className={`text-[9px] sm:text-xs whitespace-nowrap ${getOutlineBadgeStyle(item.severity)}`}> <Badge variant="outline" className={`text-[9px] sm:text-xs whitespace-nowrap ${getOutlineBadgeStyle(item.severity)}`}>
was {item.severity} {t("healthStatus.wasStatus", { status: formatStatus(item.severity) })}
</Badge> </Badge>
</div> </div>
</div> </div>
<p className="text-[10px] sm:text-xs text-muted-foreground flex items-center gap-1"> <p className="text-[10px] sm:text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
{isPermanent {isPermanent
? "Permanently suppressed" ? t("healthStatus.permanentlySuppressed")
: `Suppressed for ${ : t("healthStatus.suppressedForMore", { duration: formatDuration(item.suppression_remaining_hours) })
item.suppression_remaining_hours < 24
? `${Math.round(item.suppression_remaining_hours)}h`
: item.suppression_remaining_hours < 720
? `${Math.round(item.suppression_remaining_hours / 24)} days`
: `${Math.round(item.suppression_remaining_hours / 720)} month(s)`
} more`
} }
</p> </p>
</div> </div>
@@ -821,30 +855,20 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
<div className="space-y-2 pt-2"> <div className="space-y-2 pt-2">
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground"> <div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground">
<Settings2 className="h-3.5 w-3.5 sm:h-4 sm:w-4" /> <Settings2 className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
Custom Suppression Settings {t("healthStatus.customSuppressionSettings")}
</div> </div>
<div className="rounded-lg border border-blue-500/20 bg-blue-500/5 p-2.5 sm:p-3"> <div className="rounded-lg border border-blue-500/20 bg-blue-500/5 p-2.5 sm:p-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
{customSuppressions.map((cs) => { {customSuppressions.map((cs) => {
const catMeta = CATEGORIES.find(c => c.category === cs.category || c.key === cs.category || c.label === cs.label) const catMeta = CATEGORIES.find(c => c.category === cs.category || c.key === cs.category)
const CatIcon = catMeta?.Icon || Settings2 const CatIcon = catMeta?.Icon || Settings2
const durationLabel = cs.hours === -1 const durationLabel = formatDuration(cs.hours)
? "Permanent"
: cs.hours >= 8760
? `${Math.floor(cs.hours / 8760)} year(s)`
: cs.hours >= 720
? `${Math.floor(cs.hours / 720)} month(s)`
: cs.hours >= 168
? `${Math.floor(cs.hours / 168)} week(s)`
: cs.hours >= 72
? `${Math.floor(cs.hours / 24)} days`
: `${cs.hours}h`
return ( return (
<div key={cs.key} className="flex items-center justify-between gap-2"> <div key={cs.key} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<CatIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5 text-blue-400/70 shrink-0" /> <CatIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5 text-blue-400/70 shrink-0" />
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{cs.label}</span> <span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{catMeta ? t(`healthStatus.categories.${catMeta.key}`) : cs.label}</span>
</div> </div>
<Badge variant="outline" className="text-[9px] sm:text-[10px] border-blue-500/30 text-blue-400/80 bg-transparent shrink-0"> <Badge variant="outline" className="text-[9px] sm:text-[10px] border-blue-500/30 text-blue-400/80 bg-transparent shrink-0">
{durationLabel} {durationLabel}
@@ -854,7 +878,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
})} })}
</div> </div>
<p className="text-[10px] text-muted-foreground/60 mt-2 pt-1.5 border-t border-blue-500/10"> <p className="text-[10px] text-muted-foreground/60 mt-2 pt-1.5 border-t border-blue-500/10">
Alerts in these categories are auto-suppressed when detected. {t("healthStatus.autoSuppressedHint")}
</p> </p>
</div> </div>
</div> </div>
@@ -862,7 +886,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{healthData.timestamp && ( {healthData.timestamp && (
<div className="text-xs text-muted-foreground text-center pt-2"> <div className="text-xs text-muted-foreground text-center pt-2">
Last updated: {new Date(healthData.timestamp).toLocaleString()} {t("healthStatus.lastUpdated", { date: new Date(healthData.timestamp).toLocaleString(document.documentElement.lang) })}
</div> </div>
)} )}
</div> </div>
@@ -882,8 +906,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
params={{ params={{
EXECUTION_MODE: "web", EXECUTION_MODE: "web",
}} }}
title="Proxmox System Update" title={t("healthStatus.updateTerminalTitle")}
description="Runs apt-get update + dist-upgrade and post-update cleanup on the host." description={t("healthStatus.updateTerminalDescription")}
/> />
</Dialog> </Dialog>
) )
@@ -896,9 +920,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
function DismissDropdown({ function DismissDropdown({
onSelect, onSelect,
busy, busy,
t,
}: { }: {
onSelect: (suppressionHours: number) => void onSelect: (suppressionHours: number) => void
busy: boolean busy: boolean
t: ReturnType<typeof useT>
}) { }) {
return ( return (
<DropdownMenu> <DropdownMenu>
@@ -915,27 +941,27 @@ function DismissDropdown({
) : ( ) : (
<> <>
<X className="h-3 w-3 sm:mr-0.5" /> <X className="h-3 w-3 sm:mr-0.5" />
<span className="hidden sm:inline">Dismiss</span> <span className="hidden sm:inline">{t("healthStatus.dismiss")}</span>
</> </>
)} )}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44" onClick={(e) => e.stopPropagation()}> <DropdownMenuContent align="end" className="w-44" onClick={(e) => e.stopPropagation()}>
<DropdownMenuLabel className="text-[10px] uppercase tracking-wide text-muted-foreground"> <DropdownMenuLabel className="text-[10px] uppercase tracking-wide text-muted-foreground">
Silence this alert for {t("healthStatus.silenceFor")}
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuItem onSelect={() => onSelect(24)} className="text-xs"> <DropdownMenuItem onSelect={() => onSelect(24)} className="text-xs">
<Clock className="h-3 w-3 mr-2 text-muted-foreground" /> 24 hours <Clock className="h-3 w-3 mr-2 text-muted-foreground" /> {t("healthStatus.duration.24hours")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSelect(168)} className="text-xs"> <DropdownMenuItem onSelect={() => onSelect(168)} className="text-xs">
<Clock className="h-3 w-3 mr-2 text-muted-foreground" /> 7 days <Clock className="h-3 w-3 mr-2 text-muted-foreground" /> {t("healthStatus.duration.7days")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onSelect={() => onSelect(-1)} onSelect={() => onSelect(-1)}
className="text-xs text-red-500 focus:text-red-500 focus:bg-red-500/10" className="text-xs text-red-500 focus:text-red-500 focus:bg-red-500/10"
> >
<BellOff className="h-3 w-3 mr-2" /> Permanently <BellOff className="h-3 w-3 mr-2" /> {t("healthStatus.permanently")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
+41 -36
View File
@@ -20,6 +20,7 @@ import {
Waves, Waves,
} from "lucide-react" } from "lucide-react"
import { getApiUrl, getAuthToken } from "../lib/api-config" import { getApiUrl, getAuthToken } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
// Local fetch wrapper that *preserves* the JSON body on non-2xx // Local fetch wrapper that *preserves* the JSON body on non-2xx
// responses so we can surface backend validation messages // responses so we can surface backend validation messages
@@ -282,6 +283,11 @@ function computeVisualRange(
// ─── Component ─────────────────────────────────────────────────────────────── // ─── Component ───────────────────────────────────────────────────────────────
export function HealthThresholds() { export function HealthThresholds() {
const t = useT()
const tFallback = (key: string, fallback: string) => {
const translated = t(key)
return translated === key ? fallback : translated
}
const [tree, setTree] = useState<ThresholdsTree | null>(null) const [tree, setTree] = useState<ThresholdsTree | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [editMode, setEditMode] = useState(false) const [editMode, setEditMode] = useState(false)
@@ -299,7 +305,7 @@ export function HealthThresholds() {
) )
if (res?.success && res.thresholds) setTree(res.thresholds) if (res?.success && res.thresholds) setTree(res.thresholds)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to load thresholds") setError(err instanceof Error ? err.message : t("settings.healthThresholds.loadFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -322,7 +328,7 @@ export function HealthThresholds() {
if (trimmed === "") continue if (trimmed === "") continue
const num = Number(trimmed) const num = Number(trimmed)
if (!isFinite(num)) { if (!isFinite(num)) {
setError(`Invalid value for ${key}: must be a number`) setError(t("settings.healthThresholds.invalidValue", { key }))
return null return null
} }
// Walk into payload mirroring the path // Walk into payload mirroring the path
@@ -362,7 +368,7 @@ export function HealthThresholds() {
{ method: "PUT", body: JSON.stringify(payload) }, { method: "PUT", body: JSON.stringify(payload) },
) )
if (!data.success || !data.thresholds) { if (!data.success || !data.thresholds) {
setError(data.message || "Save failed") setError(data.message || t("status.saveFailed"))
return return
} }
setTree(data.thresholds) setTree(data.thresholds)
@@ -371,14 +377,16 @@ export function HealthThresholds() {
setSavedFlash(true) setSavedFlash(true)
setTimeout(() => setSavedFlash(false), 2000) setTimeout(() => setSavedFlash(false), 2000)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Network error while saving") setError(err instanceof Error ? err.message : t("status.networkErrorWhileSaving"))
} finally { } finally {
setSaving(false) setSaving(false)
} }
} }
const handleResetSection = async (sectionId: string) => { const handleResetSection = async (sectionId: string) => {
if (!confirm(`Reset all "${SECTIONS.find((s) => s.id === sectionId)?.title}" thresholds to recommended values?`)) const section = SECTIONS.find((s) => s.id === sectionId)
const sectionTitle = section ? tFallback(`settings.healthThresholds.sections.${section.id}.title`, section.title) : sectionId
if (!confirm(t("settings.healthThresholds.resetSectionConfirm", { section: sectionTitle })))
return return
try { try {
const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>( const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>(
@@ -386,7 +394,7 @@ export function HealthThresholds() {
{ method: "POST" }, { method: "POST" },
) )
if (!data.success || !data.thresholds) { if (!data.success || !data.thresholds) {
setError(data.message || "Reset failed") setError(data.message || t("settings.healthThresholds.resetFailed"))
return return
} }
setTree(data.thresholds) setTree(data.thresholds)
@@ -400,25 +408,25 @@ export function HealthThresholds() {
return next return next
}) })
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Network error while resetting") setError(err instanceof Error ? err.message : t("settings.healthThresholds.networkErrorWhileResetting"))
} }
} }
const handleResetAll = async () => { const handleResetAll = async () => {
if (!confirm("Reset ALL thresholds to recommended values? This affects every section.")) return if (!confirm(t("settings.healthThresholds.resetAllConfirm"))) return
try { try {
const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>( const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>(
"/api/health/thresholds/reset", "/api/health/thresholds/reset",
{ method: "POST" }, { method: "POST" },
) )
if (!data.success || !data.thresholds) { if (!data.success || !data.thresholds) {
setError(data.message || "Reset failed") setError(data.message || t("settings.healthThresholds.resetFailed"))
return return
} }
setTree(data.thresholds) setTree(data.thresholds)
setPending({}) setPending({})
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Network error while resetting") setError(err instanceof Error ? err.message : t("settings.healthThresholds.networkErrorWhileResetting"))
} }
} }
@@ -441,7 +449,7 @@ export function HealthThresholds() {
const isCustomised = leaf.customised && !(key in pending) const isCustomised = leaf.customised && !(key in pending)
const customisedClass = "border-blue-500 bg-blue-500/10 focus-visible:border-blue-500" const customisedClass = "border-blue-500 bg-blue-500/10 focus-visible:border-blue-500"
const fieldClass = isCustomised ? customisedClass : severityClass const fieldClass = isCustomised ? customisedClass : severityClass
const recommendedTooltip = `Recommended: ${leaf.recommended}${leaf.unit}` const recommendedTooltip = `${t("settings.healthThresholds.recommended")}: ${leaf.recommended}${leaf.unit}`
return ( return (
<div key={key} className="flex items-center justify-between gap-2 py-1.5 px-1"> <div key={key} className="flex items-center justify-between gap-2 py-1.5 px-1">
<span className="text-xs sm:text-sm text-foreground/90 min-w-0"> <span className="text-xs sm:text-sm text-foreground/90 min-w-0">
@@ -524,12 +532,12 @@ export function HealthThresholds() {
value={val} value={val}
onChange={(e) => setPending((p) => ({ ...p, [key]: e.target.value }))} onChange={(e) => setPending((p) => ({ ...p, [key]: e.target.value }))}
className={`absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background ${handleClass}`} className={`absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background ${handleClass}`}
title={`Recommended: ${leaf.recommended}${unit}`} title={`${t("settings.healthThresholds.recommended")}: ${leaf.recommended}${unit}`}
/> />
</div> </div>
<div className="grid grid-cols-2 gap-2 mt-1.5 text-[10px] uppercase tracking-wider text-muted-foreground"> <div className="grid grid-cols-2 gap-2 mt-1.5 text-[10px] uppercase tracking-wider text-muted-foreground">
<span>OK &lt; {val}{unit}</span> <span>{t("settings.healthThresholds.ok")} &lt; {val}{unit}</span>
<span className="text-right">{severity === "critical" ? "CRIT" : "WARN"} &gt; {val}{unit}</span> <span className="text-right">{severity === "critical" ? t("settings.healthThresholds.crit") : t("settings.healthThresholds.warn")} &gt; {val}{unit}</span>
</div> </div>
</div> </div>
) )
@@ -641,7 +649,7 @@ export function HealthThresholds() {
value={wVal} value={wVal}
onChange={(e) => setVal(wKey, Number(e.target.value), cVal, true)} onChange={(e) => setVal(wKey, Number(e.target.value), cVal, true)}
className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-amber-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-amber-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background" className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-amber-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-amber-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background"
title={`Warning (recommended: ${wLeaf.recommended}${unit})`} title={`${t("settings.healthThresholds.warning")} (${t("settings.healthThresholds.recommended").toLowerCase()}: ${wLeaf.recommended}${unit})`}
/> />
<input <input
type="range" type="range"
@@ -652,7 +660,7 @@ export function HealthThresholds() {
value={cVal} value={cVal}
onChange={(e) => setVal(cKey, Number(e.target.value), wVal, false)} onChange={(e) => setVal(cKey, Number(e.target.value), wVal, false)}
className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-red-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-red-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background" className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-red-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-red-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background"
title={`Critical (recommended: ${cLeaf.recommended}${unit})`} title={`${t("settings.healthThresholds.critical")} (${t("settings.healthThresholds.recommended").toLowerCase()}: ${cLeaf.recommended}${unit})`}
/> />
</div> </div>
@@ -660,9 +668,9 @@ export function HealthThresholds() {
"warn" starts and ends without having to read the handles. */} "warn" starts and ends without having to read the handles. */}
{!options?.hideLabels && ( {!options?.hideLabels && (
<div className="grid grid-cols-3 gap-2 mt-1.5 text-[10px] uppercase tracking-wider text-muted-foreground"> <div className="grid grid-cols-3 gap-2 mt-1.5 text-[10px] uppercase tracking-wider text-muted-foreground">
<span>OK &lt; {wVal}{unit}</span> <span>{t("settings.healthThresholds.ok")} &lt; {wVal}{unit}</span>
<span className="text-center">WARN {wVal}{cVal}{unit}</span> <span className="text-center">{t("settings.healthThresholds.warn")} {wVal}{cVal}{unit}</span>
<span className="text-right">CRIT &gt; {cVal}{unit}</span> <span className="text-right">{t("settings.healthThresholds.crit")} &gt; {cVal}{unit}</span>
</div> </div>
)} )}
</div> </div>
@@ -675,14 +683,14 @@ export function HealthThresholds() {
<div className="flex items-center justify-between gap-2 flex-wrap"> <div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<SlidersHorizontal className="h-5 w-5 text-amber-500" /> <SlidersHorizontal className="h-5 w-5 text-amber-500" />
<CardTitle>Health Monitor Thresholds</CardTitle> <CardTitle>{t("settings.healthThresholds.title")}</CardTitle>
</div> </div>
{!loading && ( {!loading && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{savedFlash && ( {savedFlash && (
<span className="flex items-center gap-1 text-xs text-green-500"> <span className="flex items-center gap-1 text-xs text-green-500">
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5" />
Saved {t("status.saved")}
</span> </span>
)} )}
{editMode ? ( {editMode ? (
@@ -692,7 +700,7 @@ export function HealthThresholds() {
onClick={handleCancel} onClick={handleCancel}
disabled={saving} disabled={saving}
> >
Cancel {t("actions.cancel")}
</button> </button>
<button <button
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5" className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
@@ -704,7 +712,7 @@ export function HealthThresholds() {
) : ( ) : (
<Check className="h-3 w-3" /> <Check className="h-3 w-3" />
)} )}
Save {t("actions.save")}
</button> </button>
</> </>
) : ( ) : (
@@ -712,17 +720,17 @@ export function HealthThresholds() {
<button <button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground flex items-center gap-1.5" className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground flex items-center gap-1.5"
onClick={handleResetAll} onClick={handleResetAll}
title="Reset every threshold to its recommended value" title={t("settings.healthThresholds.resetAllTitle")}
> >
<RotateCcw className="h-3 w-3" /> <RotateCcw className="h-3 w-3" />
Reset all {t("actions.resetAll")}
</button> </button>
<button <button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5" className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
onClick={handleEdit} onClick={handleEdit}
> >
<Settings2 className="h-3 w-3" /> <Settings2 className="h-3 w-3" />
Edit {t("actions.edit")}
</button> </button>
</> </>
)} )}
@@ -730,10 +738,7 @@ export function HealthThresholds() {
)} )}
</div> </div>
<CardDescription> <CardDescription>
The Health Monitor and notifications fire when these thresholds are crossed. {t("settings.healthThresholds.description")}
Drag the amber handle to set the warning level and the red handle to set the
critical level. Values that differ from the recommended default appear in blue
hover a handle to see the recommendation, or use Reset to restore it.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -742,7 +747,7 @@ export function HealthThresholds() {
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /> <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div> </div>
) : !tree ? ( ) : !tree ? (
<div className="text-sm text-muted-foreground">Failed to load thresholds.</div> <div className="text-sm text-muted-foreground">{t("settings.healthThresholds.loadFailed")}</div>
) : ( ) : (
<div> <div>
{error && ( {error && (
@@ -767,13 +772,13 @@ export function HealthThresholds() {
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" /> <Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<h4 className="text-sm font-medium">{section.title}</h4> <h4 className="text-sm font-medium">{tFallback(`settings.healthThresholds.sections.${section.id}.title`, section.title)}</h4>
</div> </div>
{editMode && ( {editMode && (
<button <button
className="h-6 w-6 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors flex items-center justify-center" className="h-6 w-6 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors flex items-center justify-center"
onClick={() => handleResetSection(section.id)} onClick={() => handleResetSection(section.id)}
title="Reset this section to recommended" title={t("settings.healthThresholds.resetSectionTitle")}
> >
<RotateCcw className="h-3 w-3" /> <RotateCcw className="h-3 w-3" />
</button> </button>
@@ -781,7 +786,7 @@ export function HealthThresholds() {
</div> </div>
{section.description && ( {section.description && (
<p className="text-[11px] text-muted-foreground mb-1.5 leading-snug"> <p className="text-[11px] text-muted-foreground mb-1.5 leading-snug">
{section.description} {tFallback(`settings.healthThresholds.sections.${section.id}.description`, section.description)}
</p> </p>
)} )}
<div> <div>
@@ -806,12 +811,12 @@ export function HealthThresholds() {
// visual language end to end. // visual language end to end.
<> <>
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1"> <div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1">
RAM {t("settings.healthThresholds.ram")}
</div> </div>
{renderThresholdRange(["memory"])} {renderThresholdRange(["memory"])}
<div className="border-t border-border/40"> <div className="border-t border-border/40">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1 pt-1.5"> <div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1 pt-1.5">
Swap (critical only) {t("settings.healthThresholds.swapCriticalOnly")}
</div> </div>
{renderSingleThresholdSlider(["memory", "swap_critical"], "critical")} {renderSingleThresholdSlider(["memory", "swap_critical"], "critical")}
</div> </div>
File diff suppressed because it is too large Load Diff
+209 -140
View File
@@ -9,19 +9,22 @@ import { Activity, TrendingDown, TrendingUp, Minus, RefreshCw, Wifi, FileText, S
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line } from "recharts" import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line } from "recharts"
import { useIsMobile } from "../hooks/use-mobile" import { useIsMobile } from "../hooks/use-mobile"
import { fetchApi } from "@/lib/api-config" import { fetchApi } from "@/lib/api-config"
import { useT } from "../lib/i18n/provider"
type TFunction = (key: string, params?: Record<string, string | number>) => string
const TIMEFRAME_OPTIONS = [ const TIMEFRAME_OPTIONS = [
{ value: "hour", label: "1 Hour" }, { value: "hour", labelKey: "network.latency.timeframes.hour" },
{ value: "6hour", label: "6 Hours" }, { value: "6hour", labelKey: "network.latency.timeframes.sixHours" },
{ value: "day", label: "24 Hours" }, { value: "day", labelKey: "network.latency.timeframes.day" },
{ value: "3day", label: "3 Days" }, { value: "3day", labelKey: "network.latency.timeframes.threeDays" },
{ value: "week", label: "7 Days" }, { value: "week", labelKey: "network.latency.timeframes.week" },
] ]
const TARGET_OPTIONS = [ const TARGET_OPTIONS = [
{ value: "gateway", label: "Gateway (Router)", shortLabel: "Gateway", realtime: false }, { value: "gateway", labelKey: "network.latency.targets.gateway", shortLabelKey: "network.latency.targets.gatewayShort", realtime: false },
{ value: "cloudflare", label: "Cloudflare (1.1.1.1)", shortLabel: "Cloudflare", realtime: true }, { value: "cloudflare", labelKey: "network.latency.targets.cloudflare", shortLabelKey: "network.latency.targets.cloudflareShort", realtime: true },
{ value: "google", label: "Google DNS (8.8.8.8)", shortLabel: "Google DNS", realtime: true }, { value: "google", labelKey: "network.latency.targets.google", shortLabelKey: "network.latency.targets.googleShort", realtime: true },
] ]
// Realtime test configuration // Realtime test configuration
@@ -60,7 +63,22 @@ interface LatencyDetailModalProps {
currentLatency?: number currentLatency?: number
} }
const CustomTooltip = ({ active, payload, label }: any) => { const getLatencyTimeframeLabel = (value: string, t: TFunction): string =>
TIMEFRAME_OPTIONS.find((option) => option.value === value)
? t(TIMEFRAME_OPTIONS.find((option) => option.value === value)!.labelKey)
: value
const getLatencyTargetLabel = (value: string, t: TFunction): string =>
TARGET_OPTIONS.find((option) => option.value === value)
? t(TARGET_OPTIONS.find((option) => option.value === value)!.labelKey)
: value
const getLatencyTargetShortLabel = (value: string, t: TFunction): string =>
TARGET_OPTIONS.find((option) => option.value === value)
? t(TARGET_OPTIONS.find((option) => option.value === value)!.shortLabelKey)
: value
const CustomTooltip = ({ active, payload, label, t }: any) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
const entry = payload[0] const entry = payload[0]
const data = entry?.payload const data = entry?.payload
@@ -76,17 +94,17 @@ const CustomTooltip = ({ active, payload, label }: any) => {
<> <>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-green-500" /> <div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-green-500" />
<span className="text-xs text-gray-300 min-w-[60px]">Min:</span> <span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.min")}:</span>
<span className="text-sm font-semibold text-green-400">{data.min} ms</span> <span className="text-sm font-semibold text-green-400">{data.min} ms</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-blue-500" /> <div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-blue-500" />
<span className="text-xs text-gray-300 min-w-[60px]">Avg:</span> <span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.avg")}:</span>
<span className="text-sm font-semibold text-white">{data.value} ms</span> <span className="text-sm font-semibold text-white">{data.value} ms</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-red-500" /> <div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-red-500" />
<span className="text-xs text-gray-300 min-w-[60px]">Max:</span> <span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.max")}:</span>
<span className="text-sm font-semibold text-red-400">{data.max} ms</span> <span className="text-sm font-semibold text-red-400">{data.max} ms</span>
</div> </div>
</> </>
@@ -94,14 +112,14 @@ const CustomTooltip = ({ active, payload, label }: any) => {
// Simple latency display for single data points // Simple latency display for single data points
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-blue-500" /> <div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-blue-500" />
<span className="text-xs text-gray-300 min-w-[60px]">Latency:</span> <span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.latency")}:</span>
<span className="text-sm font-semibold text-white">{entry.value} ms</span> <span className="text-sm font-semibold text-white">{entry.value} ms</span>
</div> </div>
)} )}
{packetLoss !== undefined && packetLoss > 0 && ( {packetLoss !== undefined && packetLoss > 0 && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-orange-500" /> <div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-orange-500" />
<span className="text-xs text-gray-300 min-w-[60px]">Pkt Loss:</span> <span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.packetLossShort")}:</span>
<span className="text-sm font-semibold text-orange-400">{packetLoss}%</span> <span className="text-sm font-semibold text-orange-400">{packetLoss}%</span>
</div> </div>
)} )}
@@ -118,20 +136,38 @@ const getStatusColor = (latency: number) => {
return "#22c55e" return "#22c55e"
} }
const getStatusInfo = (latency: number | null) => { const getStatusInfo = (latency: number | null, t: TFunction) => {
if (latency === null || latency === 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" } if (latency === null || latency === 0) return { status: t("common.notAvailable"), color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
if (latency < 50) return { status: "Excellent", color: "bg-green-500/10 text-green-500 border-green-500/20" } if (latency < 50) return { status: t("network.latency.status.excellent"), color: "bg-green-500/10 text-green-500 border-green-500/20" }
if (latency < 100) return { status: "Good", color: "bg-green-500/10 text-green-500 border-green-500/20" } if (latency < 100) return { status: t("network.latency.status.good"), color: "bg-green-500/10 text-green-500 border-green-500/20" }
if (latency < 200) return { status: "Fair", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" } if (latency < 200) return { status: t("network.latency.status.fair"), color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
return { status: "Poor", color: "bg-red-500/10 text-red-500 border-red-500/20" } return { status: t("network.latency.status.poor"), color: "bg-red-500/10 text-red-500 border-red-500/20" }
} }
const getStatusText = (latency: number | null): string => { const getStatusKey = (latency: number | null): "na" | "excellent" | "good" | "fair" | "poor" => {
if (latency === null || latency === 0) return "N/A" if (latency === null || latency === 0) return "na"
if (latency < 50) return "Excellent" if (latency < 50) return "excellent"
if (latency < 100) return "Good" if (latency < 100) return "good"
if (latency < 200) return "Fair" if (latency < 200) return "fair"
return "Poor" return "poor"
}
const getStatusText = (latency: number | null, t: TFunction): string => {
const key = getStatusKey(latency)
return key === "na" ? t("common.notAvailable") : t(`network.latency.status.${key}`)
}
const formatReportDuration = (seconds: number | undefined, t: TFunction, compact = false): string => {
if (!seconds || seconds <= 0) {
return compact ? t("network.latency.report.realTime") : t("network.latency.report.testPeriod")
}
if (seconds < 60) {
return t(compact ? "network.latency.report.secondsShort" : "network.latency.report.seconds", { count: seconds })
}
const minutes = Math.max(1, Math.round(seconds / 60))
return t(compact ? "network.latency.report.minutesShort" : "network.latency.report.minutes", { count: minutes })
} }
interface ReportData { interface ReportData {
@@ -145,9 +181,10 @@ interface ReportData {
testDuration?: number testDuration?: number
} }
const generateLatencyReport = (report: ReportData) => { const generateLatencyReport = (report: ReportData, t: TFunction) => {
const now = new Date().toLocaleString() const now = new Date().toLocaleString()
const logoUrl = `${window.location.origin}/images/proxmenux-logo.png` const logoUrl = `${window.location.origin}/images/proxmenux-logo.png`
const htmlLang = document.documentElement.lang || "en"
// Calculate stats for realtime results - all values are individual ping measurements in latency_avg // Calculate stats for realtime results - all values are individual ping measurements in latency_avg
const validRealtimeValues = report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!) const validRealtimeValues = report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!)
@@ -160,29 +197,57 @@ const generateLatencyReport = (report: ReportData) => {
} : null } : null
const statusText = report.isRealtime const statusText = report.isRealtime
? getStatusText(realtimeStats?.current ?? null) ? getStatusText(realtimeStats?.current ?? null, t)
: getStatusText(report.stats.current) : getStatusText(report.stats.current, t)
// Colors matching Lynis report // Colors matching Lynis report
const statusColorMap: Record<string, string> = { const statusColorMap: Record<string, string> = {
"Excellent": "#16a34a", excellent: "#16a34a",
"Good": "#16a34a", good: "#16a34a",
"Fair": "#ca8a04", fair: "#ca8a04",
"Poor": "#dc2626", poor: "#dc2626",
"N/A": "#64748b" na: "#64748b",
} }
const statusColor = statusColorMap[statusText] || "#64748b" const statusKey = report.isRealtime
? getStatusKey(realtimeStats?.current ?? null)
: getStatusKey(report.stats.current)
const statusColor = statusColorMap[statusKey] || "#64748b"
const timeframeLabel = TIMEFRAME_OPTIONS.find(t => t.value === report.timeframe)?.label || report.timeframe const timeframeLabel = getLatencyTimeframeLabel(report.timeframe, t)
const reportId = `PMXL-${Date.now().toString(36).toUpperCase()}`
const notAvailable = t("common.notAvailable")
const modeLabel = report.isRealtime
? t("network.latency.report.realTimeTest")
: t("network.latency.report.historicalAnalysis")
const realtimeDurationText = formatReportDuration(report.testDuration, t)
const realtimePacketLossText =
realtimeStats && realtimeStats.avgPacketLoss > 0
? `<span style="color:#dc2626">${t("network.latency.report.averagePacketLoss", {
value: realtimeStats.avgPacketLoss.toFixed(1),
})}</span>`
: `<span style="color:#16a34a">${t("network.latency.report.noPacketLoss")}</span>`
const testPeriodValue = report.isRealtime
? formatReportDuration(report.testDuration, t, true)
: timeframeLabel
const targetIpLabel =
report.target === "gateway"
? t("network.latency.report.defaultGateway")
: report.target === "cloudflare"
? "1.1.1.1"
: "8.8.8.8"
const detailSectionNumber =
(report.isRealtime && report.realtimeResults.length > 0) || (!report.isRealtime && report.data.length > 0)
? "6"
: "5"
// Build test results table for realtime mode - each row is now an individual ping measurement // Build test results table for realtime mode - each row is now an individual ping measurement
const realtimeTableRows = report.realtimeResults.map((r, i) => ` const realtimeTableRows = report.realtimeResults.map((r, i) => `
<tr${r.packet_loss > 0 ? ' class="warn"' : ''}> <tr${r.packet_loss > 0 ? ' class="warn"' : ''}>
<td>${i + 1}</td> <td>${i + 1}</td>
<td>${new Date(r.timestamp || Date.now()).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</td> <td>${new Date(r.timestamp || Date.now()).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</td>
<td style="font-weight:600;color:${statusColorMap[getStatusText(r.latency_avg)] || '#64748b'}">${r.latency_avg !== null ? r.latency_avg.toFixed(1) + ' ms' : 'Failed'}</td> <td style="font-weight:600;color:${statusColorMap[getStatusKey(r.latency_avg)] || '#64748b'}">${r.latency_avg !== null ? r.latency_avg.toFixed(1) + ' ms' : t("network.latency.report.failed")}</td>
<td${r.packet_loss > 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${r.packet_loss}%</td> <td${r.packet_loss > 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${r.packet_loss}%</td>
<td><span class="f-tag" style="background:${statusColorMap[getStatusText(r.latency_avg)] || '#64748b'}15;color:${statusColorMap[getStatusText(r.latency_avg)] || '#64748b'}">${getStatusText(r.latency_avg)}</span></td> <td><span class="f-tag" style="background:${statusColorMap[getStatusKey(r.latency_avg)] || '#64748b'}15;color:${statusColorMap[getStatusKey(r.latency_avg)] || '#64748b'}">${getStatusText(r.latency_avg, t)}</span></td>
</tr> </tr>
`).join('') `).join('')
@@ -199,9 +264,9 @@ const generateLatencyReport = (report: ReportData) => {
<tr${d.packet_loss && d.packet_loss > 0 ? ' class="warn"' : ''}> <tr${d.packet_loss && d.packet_loss > 0 ? ' class="warn"' : ''}>
<td>${i + 1}</td> <td>${i + 1}</td>
<td>${new Date(d.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</td> <td>${new Date(d.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</td>
<td style="font-weight:600;color:${statusColorMap[getStatusText(d.value)] || '#64748b'}">${d.value !== null ? d.value.toFixed(1) + ' ms' : 'Failed'}</td> <td style="font-weight:600;color:${statusColorMap[getStatusKey(d.value)] || '#64748b'}">${d.value !== null ? d.value.toFixed(1) + ' ms' : t("network.latency.report.failed")}</td>
<td${d.packet_loss && d.packet_loss > 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${d.packet_loss?.toFixed(1) ?? 0}%</td> <td${d.packet_loss && d.packet_loss > 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${d.packet_loss?.toFixed(1) ?? 0}%</td>
<td><span class="f-tag" style="background:${statusColorMap[getStatusText(d.value)] || '#64748b'}15;color:${statusColorMap[getStatusText(d.value)] || '#64748b'}">${getStatusText(d.value)}</span></td> <td><span class="f-tag" style="background:${statusColorMap[getStatusKey(d.value)] || '#64748b'}15;color:${statusColorMap[getStatusKey(d.value)] || '#64748b'}">${getStatusText(d.value, t)}</span></td>
</tr> </tr>
`).join('') `).join('')
@@ -210,7 +275,7 @@ const generateLatencyReport = (report: ReportData) => {
? report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!) ? report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!)
: report.data.map(d => d.value || 0) : report.data.map(d => d.value || 0)
let chartSvg = '<p style="text-align:center;color:#64748b;padding:20px;">Not enough data points for chart</p>' let chartSvg = `<p style="text-align:center;color:#64748b;padding:20px;">${t("network.latency.report.notEnoughData")}</p>`
if (chartData.length >= 2) { if (chartData.length >= 2) {
const rawMin = Math.min(...chartData) const rawMin = Math.min(...chartData)
const rawMax = Math.max(...chartData) const rawMax = Math.max(...chartData)
@@ -253,17 +318,17 @@ const generateLatencyReport = (report: ReportData) => {
<text x="${padding - 5}" y="${height - padding + 4}" font-size="9" fill="#64748b" text-anchor="end">${Math.round(minVal)}ms</text> <text x="${padding - 5}" y="${height - padding + 4}" font-size="9" fill="#64748b" text-anchor="end">${Math.round(minVal)}ms</text>
<polygon points="${areaPoints}" fill="url(#areaGrad)"/> <polygon points="${areaPoints}" fill="url(#areaGrad)"/>
<polyline points="${points}" fill="none" stroke="#3b82f6" stroke-width="2"/> <polyline points="${points}" fill="none" stroke="#3b82f6" stroke-width="2"/>
<text x="${width / 2}" y="${height - 5}" font-size="9" fill="#64748b" text-anchor="middle">${chartData.length} samples</text> <text x="${width / 2}" y="${height - 5}" font-size="9" fill="#64748b" text-anchor="middle">${t("network.latency.report.samples", { count: chartData.length })}</text>
</svg> </svg>
` `
} }
const html = `<!DOCTYPE html> const html = `<!DOCTYPE html>
<html lang="en"> <html lang="${htmlLang}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Network Latency Report - ${report.targetLabel}</title> <title>${t("network.latency.report.title")} - ${report.targetLabel}</title>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a2e; background: #fff; font-size: 13px; line-height: 1.5; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a2e; background: #fff; font-size: 13px; line-height: 1.5; }
@@ -463,11 +528,11 @@ const generateLatencyReport = (report: ReportData) => {
<div class="top-bar no-print"> <div class="top-bar no-print">
<div class="top-bar-left"> <div class="top-bar-left">
<div> <div>
<div class="top-bar-title">ProxMenux Network Latency Report</div> <div class="top-bar-title">${t("network.latency.report.topBarTitle")}</div>
<div class="top-bar-subtitle">Review the report, then print or save as PDF</div> <div class="top-bar-subtitle">${t("network.latency.report.topBarSubtitle")}</div>
</div> </div>
</div> </div>
<button onclick="window.print()">Print / Save as PDF</button> <button onclick="window.print()">${t("network.latency.report.printSavePdf")}</button>
</div> </div>
<!-- Header --> <!-- Header -->
@@ -475,21 +540,21 @@ const generateLatencyReport = (report: ReportData) => {
<div class="rpt-header-left"> <div class="rpt-header-left">
<img src="${logoUrl}" alt="ProxMenux" onerror="this.style.display='none'" /> <img src="${logoUrl}" alt="ProxMenux" onerror="this.style.display='none'" />
<div> <div>
<h1>Network Latency Report</h1> <h1>${t("network.latency.report.title")}</h1>
<p>ProxMenux Monitor - Network Performance Analysis</p> <p>${t("network.latency.report.subtitle")}</p>
</div> </div>
</div> </div>
<div class="rpt-header-right"> <div class="rpt-header-right">
<div><strong>Date:</strong> ${now}</div> <div><strong>${t("network.latency.report.date")}:</strong> ${now}</div>
<div><strong>Target:</strong> ${report.targetLabel}</div> <div><strong>${t("network.latency.report.target")}:</strong> ${report.targetLabel}</div>
<div><strong>Mode:</strong> ${report.isRealtime ? 'Real-time Test' : 'Historical Analysis'}</div> <div><strong>${t("network.latency.report.mode")}:</strong> ${modeLabel}</div>
<div class="rid">ID: PMXL-${Date.now().toString(36).toUpperCase()}</div> <div class="rid">ID: ${reportId}</div>
</div> </div>
</div> </div>
<!-- 1. Executive Summary --> <!-- 1. Executive Summary -->
<div class="section"> <div class="section">
<div class="section-title">1. Executive Summary</div> <div class="section-title">1. ${t("network.latency.report.executiveSummary")}</div>
<div class="exec-box"> <div class="exec-box">
<div class="latency-gauge"> <div class="latency-gauge">
<svg viewBox="0 0 120 90" width="160" height="120"> <svg viewBox="0 0 120 90" width="160" height="120">
@@ -508,35 +573,41 @@ const generateLatencyReport = (report: ReportData) => {
<text x="98" y="87" font-size="7" fill="#64748b">300+</text> <text x="98" y="87" font-size="7" fill="#64748b">300+</text>
</svg> </svg>
<div class="gauge-value" style="color:${statusColor};"> <div class="gauge-value" style="color:${statusColor};">
<span class="gauge-num">${report.isRealtime ? (realtimeStats?.avg?.toFixed(0) ?? 'N/A') : report.stats.avg}</span> <span class="gauge-num">${report.isRealtime ? (realtimeStats?.avg?.toFixed(0) ?? notAvailable) : report.stats.avg}</span>
<span class="gauge-unit">ms</span> <span class="gauge-unit">ms</span>
</div> </div>
<div class="gauge-status" style="color:${statusColor};">${statusText}</div> <div class="gauge-status" style="color:${statusColor};">${statusText}</div>
</div> </div>
<div class="exec-text"> <div class="exec-text">
<h3>Network Latency Assessment${report.isRealtime ? ' (Real-time)' : ''}</h3> <h3>${t("network.latency.report.assessmentTitle")}${report.isRealtime ? ` (${t("network.latency.report.realTime")})` : ""}</h3>
<p> <p>
${report.isRealtime ${report.isRealtime
? `Real-time latency test to <strong>${report.targetLabel}</strong> with <strong>${report.realtimeResults.length} samples</strong> collected over ${report.testDuration ? Math.round(report.testDuration / 60) + ' minute(s)' : 'the test period'}. ? `${t("network.latency.report.realtimeSummary", {
Average latency: <strong style="color:${statusColor}">${realtimeStats?.avg?.toFixed(1) ?? 'N/A'} ms</strong>. target: `<strong>${report.targetLabel}</strong>`,
${realtimeStats && realtimeStats.avgPacketLoss > 0 ? `<span style="color:#dc2626">Average packet loss: ${realtimeStats.avgPacketLoss.toFixed(1)}%.</span>` : '<span style="color:#16a34a">No packet loss detected.</span>'}` count: `<strong>${report.realtimeResults.length}</strong>`,
: `Historical latency analysis to <strong>Gateway</strong> over <strong>${timeframeLabel.toLowerCase()}</strong>. duration: realtimeDurationText,
<strong>${report.data.length} samples</strong> analyzed. avg: `<strong style="color:${statusColor}">${realtimeStats?.avg?.toFixed(1) ?? notAvailable} ms</strong>`,
Average latency: <strong style="color:${statusColor}">${report.stats.avg} ms</strong>.` })} ${realtimePacketLossText}`
: `${t("network.latency.report.historicalSummary", {
target: t("network.latency.targets.gatewayShort"),
timeframe: timeframeLabel.toLowerCase(),
count: report.data.length,
avg: report.stats.avg,
})}`
} }
</p> </p>
<div class="latency-range"> <div class="latency-range">
<div class="range-item"> <div class="range-item">
<span class="range-label">Minimum</span> <span class="range-label">${t("network.labels.minimum")}</span>
<span class="range-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? 'N/A') : report.stats.min} ms</span> <span class="range-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? notAvailable) : report.stats.min} ms</span>
</div> </div>
<div class="range-item"> <div class="range-item">
<span class="range-label">Average</span> <span class="range-label">${t("network.labels.average")}</span>
<span class="range-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? 'N/A') : report.stats.avg} ms</span> <span class="range-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? notAvailable) : report.stats.avg} ms</span>
</div> </div>
<div class="range-item"> <div class="range-item">
<span class="range-label">Maximum</span> <span class="range-label">${t("network.labels.maximum")}</span>
<span class="range-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? 'N/A') : report.stats.max} ms</span> <span class="range-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? notAvailable) : report.stats.max} ms</span>
</div> </div>
</div> </div>
</div> </div>
@@ -545,42 +616,40 @@ const generateLatencyReport = (report: ReportData) => {
<!-- 2. Statistics --> <!-- 2. Statistics -->
<div class="section"> <div class="section">
<div class="section-title">2. Latency Statistics</div> <div class="section-title">2. ${t("network.latency.report.latencyStatistics")}</div>
<div class="grid-4"> <div class="grid-4">
<div class="card card-c"> <div class="card card-c">
<div class="card-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.current?.toFixed(1) ?? 'N/A') : report.stats.current}<span style="font-size:10px;color:#64748b;"> ms</span></div> <div class="card-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.current?.toFixed(1) ?? notAvailable) : report.stats.current}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">Current</div> <div class="card-label">${t("network.labels.current")}</div>
</div> </div>
<div class="card card-c"> <div class="card card-c">
<div class="card-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? 'N/A') : report.stats.min}<span style="font-size:10px;color:#64748b;"> ms</span></div> <div class="card-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? notAvailable) : report.stats.min}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">Minimum</div> <div class="card-label">${t("network.labels.minimum")}</div>
</div> </div>
<div class="card card-c"> <div class="card card-c">
<div class="card-value">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? 'N/A') : report.stats.avg}<span style="font-size:10px;color:#64748b;"> ms</span></div> <div class="card-value">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? notAvailable) : report.stats.avg}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">Average</div> <div class="card-label">${t("network.labels.average")}</div>
</div> </div>
<div class="card card-c"> <div class="card card-c">
<div class="card-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? 'N/A') : report.stats.max}<span style="font-size:10px;color:#64748b;"> ms</span></div> <div class="card-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? notAvailable) : report.stats.max}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">Maximum</div> <div class="card-label">${t("network.labels.maximum")}</div>
</div> </div>
</div> </div>
<div class="grid-3"> <div class="grid-3">
<div class="card"> <div class="card">
<div class="card-label">Sample Count</div> <div class="card-label">${t("network.latency.report.sampleCount")}</div>
<div class="card-value">${report.isRealtime ? report.realtimeResults.length : report.data.length}</div> <div class="card-value">${report.isRealtime ? report.realtimeResults.length : report.data.length}</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-label">Packet Loss (Avg)</div> <div class="card-label">${t("network.latency.report.packetLossAvg")}</div>
<div class="card-value" style="color:${(report.isRealtime ? (realtimeStats?.avgPacketLoss ?? 0) : parseFloat(historyStats?.avgPacketLoss ?? '0')) > 0 ? '#dc2626' : '#16a34a'};"> <div class="card-value" style="color:${(report.isRealtime ? (realtimeStats?.avgPacketLoss ?? 0) : parseFloat(historyStats?.avgPacketLoss ?? '0')) > 0 ? '#dc2626' : '#16a34a'};">
${report.isRealtime ? (realtimeStats?.avgPacketLoss?.toFixed(1) ?? '0') : (historyStats?.avgPacketLoss ?? '0')}% ${report.isRealtime ? (realtimeStats?.avgPacketLoss?.toFixed(1) ?? '0') : (historyStats?.avgPacketLoss ?? '0')}%
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-label">Test Period</div> <div class="card-label">${t("network.latency.report.testPeriodLabel")}</div>
<div class="card-value" style="font-size:11px;"> <div class="card-value" style="font-size:11px;">
${report.isRealtime ${testPeriodValue}
? (report.testDuration ? Math.round(report.testDuration / 60) + ' min' : 'Real-time')
: timeframeLabel}
</div> </div>
</div> </div>
</div> </div>
@@ -588,7 +657,7 @@ const generateLatencyReport = (report: ReportData) => {
<!-- 3. Latency Graph (always section 3) --> <!-- 3. Latency Graph (always section 3) -->
<div class="section"> <div class="section">
<div class="section-title">3. Latency Graph</div> <div class="section-title">3. ${t("network.latency.report.latencyGraph")}</div>
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:16px;"> <div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:16px;">
${chartSvg} ${chartSvg}
</div> </div>
@@ -596,37 +665,37 @@ const generateLatencyReport = (report: ReportData) => {
<!-- 4. Performance Thresholds (always section 4) --> <!-- 4. Performance Thresholds (always section 4) -->
<div class="section"> <div class="section">
<div class="section-title">4. Performance Thresholds</div> <div class="section-title">4. ${t("network.latency.report.performanceThresholds")}</div>
<div class="threshold-item"> <div class="threshold-item">
<div class="threshold-dot" style="background:#16a34a;"></div> <div class="threshold-dot" style="background:#16a34a;"></div>
<p><strong>Excellent (&lt; 50ms):</strong> Optimal for real-time applications, gaming, and video calls.</p> <p><strong>${t("network.latency.status.excellent")} (&lt; 50ms):</strong> ${t("network.latency.report.thresholdExcellent")}</p>
</div> </div>
<div class="threshold-item"> <div class="threshold-item">
<div class="threshold-dot" style="background:#16a34a;"></div> <div class="threshold-dot" style="background:#16a34a;"></div>
<p><strong>Good (50-100ms):</strong> Acceptable for most applications with minimal impact.</p> <p><strong>${t("network.latency.status.good")} (50-100ms):</strong> ${t("network.latency.report.thresholdGood")}</p>
</div> </div>
<div class="threshold-item"> <div class="threshold-item">
<div class="threshold-dot" style="background:#ca8a04;"></div> <div class="threshold-dot" style="background:#ca8a04;"></div>
<p><strong>Fair (100-200ms):</strong> Noticeable delay. May affect VoIP and interactive applications.</p> <p><strong>${t("network.latency.status.fair")} (100-200ms):</strong> ${t("network.latency.report.thresholdFair")}</p>
</div> </div>
<div class="threshold-item"> <div class="threshold-item">
<div class="threshold-dot" style="background:#dc2626;"></div> <div class="threshold-dot" style="background:#dc2626;"></div>
<p><strong>Poor (&gt; 200ms):</strong> Significant latency. Investigation recommended.</p> <p><strong>${t("network.latency.status.poor")} (&gt; 200ms):</strong> ${t("network.latency.report.thresholdPoor")}</p>
</div> </div>
</div> </div>
${report.isRealtime && report.realtimeResults.length > 0 ? ` ${report.isRealtime && report.realtimeResults.length > 0 ? `
<!-- 5. Detailed Test Results (for Cloudflare / Google DNS) --> <!-- 5. Detailed Test Results (for Cloudflare / Google DNS) -->
<div class="section"> <div class="section">
<div class="section-title">5. Detailed Test Results</div> <div class="section-title">5. ${t("network.latency.report.detailedTestResults")}</div>
<table class="chk-tbl"> <table class="chk-tbl">
<thead> <thead>
<tr> <tr>
<th>#</th> <th>#</th>
<th>Time</th> <th>${t("network.labels.time")}</th>
<th>Latency</th> <th>${t("network.labels.latency")}</th>
<th>Packet Loss</th> <th>${t("network.labels.packetLoss")}</th>
<th>Status</th> <th>${t("network.labels.status")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -639,15 +708,15 @@ const generateLatencyReport = (report: ReportData) => {
${!report.isRealtime && report.data.length > 0 ? ` ${!report.isRealtime && report.data.length > 0 ? `
<!-- 5. Detailed History (for Gateway) --> <!-- 5. Detailed History (for Gateway) -->
<div class="section"> <div class="section">
<div class="section-title">5. Latency History (Last ${Math.min(20, report.data.length)} Records)</div> <div class="section-title">5. ${t("network.latency.report.latencyHistory", { count: Math.min(20, report.data.length) })}</div>
<table class="chk-tbl"> <table class="chk-tbl">
<thead> <thead>
<tr> <tr>
<th>#</th> <th>#</th>
<th>Time</th> <th>${t("network.labels.time")}</th>
<th>Latency</th> <th>${t("network.labels.latency")}</th>
<th>Packet Loss</th> <th>${t("network.labels.packetLoss")}</th>
<th>Status</th> <th>${t("network.labels.status")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -659,41 +728,41 @@ ${!report.isRealtime && report.data.length > 0 ? `
<!-- Methodology --> <!-- Methodology -->
<div class="section"> <div class="section">
<div class="section-title">${(report.isRealtime && report.realtimeResults.length > 0) || (!report.isRealtime && report.data.length > 0) ? '6' : '5'}. Methodology</div> <div class="section-title">${detailSectionNumber}. ${t("network.latency.report.methodology")}</div>
<div class="grid-2"> <div class="grid-2">
<div class="card"> <div class="card">
<div class="card-label">Test Method</div> <div class="card-label">${t("network.latency.report.testMethod")}</div>
<div class="card-value" style="font-size:12px;">ICMP Echo Request (Ping)</div> <div class="card-value" style="font-size:12px;">${t("network.latency.report.icmpEchoRequest")}</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-label">Samples per Test</div> <div class="card-label">${t("network.latency.report.samplesPerTest")}</div>
<div class="card-value" style="font-size:12px;">3 consecutive pings</div> <div class="card-value" style="font-size:12px;">${t("network.latency.report.threeConsecutivePings")}</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-label">Target</div> <div class="card-label">${t("network.latency.report.target")}</div>
<div class="card-value" style="font-size:12px;">${report.targetLabel}</div> <div class="card-value" style="font-size:12px;">${report.targetLabel}</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-label">Target IP</div> <div class="card-label">${t("network.latency.report.targetIp")}</div>
<div class="card-value" style="font-size:12px;">${report.target === 'gateway' ? 'Default Gateway' : report.target === 'cloudflare' ? '1.1.1.1' : '8.8.8.8'}</div> <div class="card-value" style="font-size:12px;">${targetIpLabel}</div>
</div> </div>
</div> </div>
<div class="info-box"> <div class="info-box">
<h4>Performance Assessment</h4> <h4>${t("network.latency.report.performanceAssessment")}</h4>
<p>${ <p>${
statusText === 'Excellent' ? 'Network latency is excellent. No action required.' : statusKey === 'excellent' ? t("network.latency.report.assessmentExcellent") :
statusText === 'Good' ? 'Network latency is within acceptable parameters.' : statusKey === 'good' ? t("network.latency.report.assessmentGood") :
statusText === 'Fair' ? 'Network latency is elevated. Consider investigating network congestion or routing issues.' : statusKey === 'fair' ? t("network.latency.report.assessmentFair") :
statusText === 'Poor' ? 'Network latency is critically high. Immediate investigation recommended.' : statusKey === 'poor' ? t("network.latency.report.assessmentPoor") :
'Unable to determine network status.' t("network.latency.report.assessmentUnknown")
}</p> }</p>
</div> </div>
</div> </div>
<!-- Footer --> <!-- Footer -->
<div class="rpt-footer"> <div class="rpt-footer">
<div>ProxMenux Monitor - Network Performance Report</div> <div>${t("network.latency.report.footerTitle")}</div>
<div>Generated: ${now} | Report ID: PMXL-${Date.now().toString(36).toUpperCase()}</div> <div>${t("network.latency.report.generated")}: ${now} | ${t("network.latency.report.reportId")}: ${reportId}</div>
</div> </div>
</body> </body>
@@ -706,6 +775,7 @@ ${!report.isRealtime && report.data.length > 0 ? `
} }
export function LatencyDetailModal({ open, onOpenChange, currentLatency }: LatencyDetailModalProps) { export function LatencyDetailModal({ open, onOpenChange, currentLatency }: LatencyDetailModalProps) {
const t = useT()
const [timeframe, setTimeframe] = useState("hour") const [timeframe, setTimeframe] = useState("hour")
const [target, setTarget] = useState("gateway") const [target, setTarget] = useState("gateway")
const [data, setData] = useState<LatencyHistoryPoint[]>([]) const [data, setData] = useState<LatencyHistoryPoint[]>([])
@@ -882,7 +952,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
avg: Math.round((realtimeStats?.avg ?? 0) * 10) / 10, avg: Math.round((realtimeStats?.avg ?? 0) * 10) / 10,
} : stats } : stats
const statusInfo = getStatusInfo(displayStats.current) const statusInfo = getStatusInfo(displayStats.current, t)
// Calculate test duration for report based on first and last result timestamps // Calculate test duration for report based on first and last result timestamps
const testDuration = realtimeResults.length >= 2 const testDuration = realtimeResults.length >= 2
@@ -897,20 +967,20 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 text-foreground"> <DialogTitle className="flex items-center gap-2 text-foreground">
<Wifi className="h-5 w-5 text-blue-500" /> <Wifi className="h-5 w-5 text-blue-500" />
Network Latency {t("network.cards.latency")}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="flex items-center gap-2 mt-1 flex-nowrap"> <div className="flex items-center gap-2 mt-1 flex-nowrap">
<Select value={target} onValueChange={setTarget}> <Select value={target} onValueChange={setTarget}>
<SelectTrigger className="w-[140px] sm:w-[180px] h-8 text-xs shrink-0"> <SelectTrigger className="w-[140px] sm:w-[180px] h-8 text-xs shrink-0">
<span className="truncate"> <span className="truncate">
{TARGET_OPTIONS.find(t => t.value === target)?.shortLabel || target} {getLatencyTargetShortLabel(target, t)}
</span> </span>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{TARGET_OPTIONS.map(opt => ( {TARGET_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value} className="text-xs"> <SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label} {t(opt.labelKey)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -923,7 +993,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
<SelectContent> <SelectContent>
{TIMEFRAME_OPTIONS.map(opt => ( {TIMEFRAME_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value} className="text-xs"> <SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label} {t(opt.labelKey)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -938,7 +1008,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
className="gap-1.5 text-red-500 border-red-500/30 hover:bg-red-500/10 shrink-0 h-8 px-3" className="gap-1.5 text-red-500 border-red-500/30 hover:bg-red-500/10 shrink-0 h-8 px-3"
> >
<Square className="h-3 w-3 fill-current" /> <Square className="h-3 w-3 fill-current" />
Stop {t("network.latency.actions.stop")}
</Button> </Button>
) : ( ) : (
<Button <Button
@@ -948,7 +1018,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
className="gap-1.5 shrink-0 h-8 px-3" className="gap-1.5 shrink-0 h-8 px-3"
> >
<RefreshCw className="h-3 w-3" /> <RefreshCw className="h-3 w-3" />
Test Again {t("network.latency.actions.testAgain")}
</Button> </Button>
) )
)} )}
@@ -957,19 +1027,19 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
size="sm" size="sm"
onClick={() => generateLatencyReport({ onClick={() => generateLatencyReport({
target, target,
targetLabel: TARGET_OPTIONS.find(t => t.value === target)?.label || target, targetLabel: getLatencyTargetLabel(target, t),
isRealtime, isRealtime,
stats, stats,
realtimeResults, realtimeResults,
data, data,
timeframe, timeframe,
testDuration: isRealtime ? testDuration : undefined, testDuration: isRealtime ? testDuration : undefined,
})} }, t)}
disabled={isRealtime ? realtimeResults.length === 0 : data.length === 0} disabled={isRealtime ? realtimeResults.length === 0 : data.length === 0}
className="gap-1.5 shrink-0 h-8 px-3" className="gap-1.5 shrink-0 h-8 px-3"
> >
<FileText className="h-3.5 w-3.5" /> <FileText className="h-3.5 w-3.5" />
Report {t("network.latency.actions.report")}
</Button> </Button>
</div> </div>
@@ -977,8 +1047,8 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
{isRealtime && realtimeTesting && ( {isRealtime && realtimeTesting && (
<div className="mb-4"> <div className="mb-4">
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1"> <div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
<span>Testing... {Math.round(testProgress)}%</span> <span>{t("network.latency.testingProgress", { percent: Math.round(testProgress) })}</span>
<span>{Math.round((REALTIME_TEST_DURATION * (1 - testProgress / 100)))}s remaining</span> <span>{t("network.latency.secondsRemaining", { seconds: Math.round((REALTIME_TEST_DURATION * (1 - testProgress / 100))) })}</span>
</div> </div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden"> <div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div <div
@@ -992,7 +1062,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
{/* Stats Cards - Compact single row */} {/* Stats Cards - Compact single row */}
<div className="flex items-center justify-between gap-1 mb-2 py-2 px-1 bg-muted/20 rounded-lg"> <div className="flex items-center justify-between gap-1 mb-2 py-2 px-1 bg-muted/20 rounded-lg">
<div className="flex items-center gap-1 min-w-0"> <div className="flex items-center gap-1 min-w-0">
<span className="text-[10px] text-muted-foreground">Current</span> <span className="text-[10px] text-muted-foreground">{t("network.labels.current")}</span>
<span className="text-base font-bold" style={{ color: getStatusColor(displayStats.current || 0) }}> <span className="text-base font-bold" style={{ color: getStatusColor(displayStats.current || 0) }}>
{displayStats.current || '-'} {displayStats.current || '-'}
</span> </span>
@@ -1000,19 +1070,19 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
</div> </div>
<div className="flex items-center gap-1 min-w-0"> <div className="flex items-center gap-1 min-w-0">
<TrendingDown className="h-3 w-3 text-green-500 shrink-0" /> <TrendingDown className="h-3 w-3 text-green-500 shrink-0" />
<span className="text-[10px] text-muted-foreground">Min</span> <span className="text-[10px] text-muted-foreground">{t("network.labels.min")}</span>
<span className="text-base font-bold text-green-500">{displayStats.min || '-'}</span> <span className="text-base font-bold text-green-500">{displayStats.min || '-'}</span>
<span className="text-[10px] text-muted-foreground">ms</span> <span className="text-[10px] text-muted-foreground">ms</span>
</div> </div>
<div className="flex items-center gap-1 min-w-0"> <div className="flex items-center gap-1 min-w-0">
<Minus className="h-3 w-3 shrink-0" /> <Minus className="h-3 w-3 shrink-0" />
<span className="text-[10px] text-muted-foreground">Avg</span> <span className="text-[10px] text-muted-foreground">{t("network.labels.avg")}</span>
<span className="text-base font-bold">{displayStats.avg || '-'}</span> <span className="text-base font-bold">{displayStats.avg || '-'}</span>
<span className="text-[10px] text-muted-foreground">ms</span> <span className="text-[10px] text-muted-foreground">ms</span>
</div> </div>
<div className="flex items-center gap-1 min-w-0"> <div className="flex items-center gap-1 min-w-0">
<TrendingUp className="h-3 w-3 text-red-500 shrink-0" /> <TrendingUp className="h-3 w-3 text-red-500 shrink-0" />
<span className="text-[10px] text-muted-foreground">Max</span> <span className="text-[10px] text-muted-foreground">{t("network.labels.max")}</span>
<span className="text-base font-bold text-red-500">{displayStats.max || '-'}</span> <span className="text-base font-bold text-red-500">{displayStats.max || '-'}</span>
<span className="text-[10px] text-muted-foreground">ms</span> <span className="text-[10px] text-muted-foreground">ms</span>
</div> </div>
@@ -1025,8 +1095,8 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
</Badge> </Badge>
{isRealtime && ( {isRealtime && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{realtimeResults.length} sample{realtimeResults.length !== 1 ? 's' : ''} collected {t("network.latency.samplesCollected", { count: realtimeResults.length })}
{realtimeStats?.packetLoss ? ` | ${realtimeStats.packetLoss}% packet loss` : ''} {realtimeStats?.packetLoss ? ` | ${t("network.latency.packetLossValue", { value: realtimeStats.packetLoss })}` : ""}
</span> </span>
)} )}
</div> </div>
@@ -1058,7 +1128,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
domain={['dataMin - 1', 'dataMax + 2']} domain={['dataMin - 1', 'dataMax + 2']}
tickFormatter={(v) => `${Number(v).toFixed(1)}ms`} tickFormatter={(v) => `${Number(v).toFixed(1)}ms`}
/> />
<Tooltip content={<CustomTooltip />} /> <Tooltip content={<CustomTooltip t={t} />} />
<Area <Area
type="monotone" type="monotone"
dataKey="value" dataKey="value"
@@ -1075,7 +1145,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
<div className="h-full flex flex-col items-center justify-center text-muted-foreground"> <div className="h-full flex flex-col items-center justify-center text-muted-foreground">
<Activity className="h-12 w-12 mb-3 opacity-30" /> <Activity className="h-12 w-12 mb-3 opacity-30" />
<p className="text-sm"> <p className="text-sm">
{realtimeTesting ? 'Collecting data...' : 'No data yet. Click "Test Again" to start.'} {realtimeTesting ? t("network.latency.collectingData") : t("network.latency.noRealtimeData")}
</p> </p>
</div> </div>
) )
@@ -1107,7 +1177,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
domain={['dataMin - 1', 'dataMax + 2']} domain={['dataMin - 1', 'dataMax + 2']}
tickFormatter={(v) => `${Number(v).toFixed(1)}ms`} tickFormatter={(v) => `${Number(v).toFixed(1)}ms`}
/> />
<Tooltip content={<CustomTooltip />} /> <Tooltip content={<CustomTooltip t={t} />} />
{/* For longer timeframes (6h+), show max values to preserve spikes. {/* For longer timeframes (6h+), show max values to preserve spikes.
For 1 hour view, show avg values since there's no downsampling */} For 1 hour view, show avg values since there's no downsampling */}
<Area <Area
@@ -1123,8 +1193,8 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
) : ( ) : (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground"> <div className="h-full flex flex-col items-center justify-center text-muted-foreground">
<Activity className="h-12 w-12 mb-3 opacity-30" /> <Activity className="h-12 w-12 mb-3 opacity-30" />
<p className="text-sm">No latency data available for this period</p> <p className="text-sm">{t("network.latency.noDataForPeriod")}</p>
<p className="text-xs mt-1">Data is collected every 60 seconds</p> <p className="text-xs mt-1">{t("network.latency.collectionInterval")}</p>
</div> </div>
)} )}
</div> </div>
@@ -1133,8 +1203,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
{isRealtime && ( {isRealtime && (
<div className="mt-4 p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg"> <div className="mt-4 p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<p className="text-xs text-blue-400"> <p className="text-xs text-blue-400">
<strong>Real-time Mode:</strong> Tests run for 2 minutes with readings every 5 seconds. <strong>{t("network.latency.realTimeMode")}:</strong> {t("network.latency.realTimeModeDescription")}
Click "Test Again" to add more samples. All data is included in the report.
</p> </p>
</div> </div>
)} )}
+27 -19
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
} }
@@ -80,14 +82,20 @@ export function Login({ onLogin }: LoginProps) {
const data = await response.json() const data = await response.json()
if (data.requires_totp) { if (response.ok && data.requires_totp) {
setRequiresTotp(true) setRequiresTotp(true)
setLoading(false) setLoading(false)
return return
} }
if (!response.ok) { if (!response.ok) {
throw new Error(data.message || "Login failed") if (response.status === 429) {
throw new Error(t("login.tooManyAttempts"))
}
if (response.status === 401) {
throw new Error(data.requires_totp ? t("login.invalidTotp") : t("login.invalidCredentials"))
}
throw new Error(t("login.loginFailed"))
} }
localStorage.setItem("proxmenux-auth-token", data.token) localStorage.setItem("proxmenux-auth-token", data.token)
@@ -107,7 +115,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 +147,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 +165,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 +184,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 +222,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 +231,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 +253,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 +268,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>
) )
+77 -67
View File
@@ -1,7 +1,7 @@
"use client" "use client"
import type React from "react" import type React from "react"
import { useState, useEffect, useRef, useCallback } from "react" import { useState, useEffect, useRef, useCallback, useMemo } from "react"
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
@@ -37,6 +37,7 @@ import { Dialog as SearchDialog, DialogContent as SearchDialogContent, DialogTit
import "xterm/css/xterm.css" import "xterm/css/xterm.css"
import { API_PORT, fetchApi } from "@/lib/api-config" import { API_PORT, fetchApi } from "@/lib/api-config"
import { getTicketedWsUrl } from "@/lib/terminal-ws" import { getTicketedWsUrl } from "@/lib/terminal-ws"
import { useT } from "@/lib/i18n/provider"
interface LxcTerminalModalProps { interface LxcTerminalModalProps {
open: boolean open: boolean
@@ -51,33 +52,35 @@ interface CheatSheetResult {
examples: string[] examples: string[]
} }
const proxmoxCommands = [ const LXC_COMMANDS = [
{ cmd: "ls -la", desc: "List all files with details" }, { cmd: "ls -la", descKey: "listFiles" },
{ cmd: "cd /path/to/dir", desc: "Change directory" }, { cmd: "cd /path/to/dir", descKey: "changeDirectory" },
{ cmd: "cat filename", desc: "Display file contents" }, { cmd: "cat filename", descKey: "displayFile" },
{ cmd: "grep 'pattern' file", desc: "Search for pattern in file" }, { cmd: "grep 'pattern' file", descKey: "searchPattern" },
{ cmd: "find . -name 'file'", desc: "Find files by name" }, { cmd: "find . -name 'file'", descKey: "findFiles" },
{ cmd: "df -h", desc: "Show disk usage" }, { cmd: "df -h", descKey: "diskUsage" },
{ cmd: "du -sh *", desc: "Show directory sizes" }, { cmd: "du -sh *", descKey: "directorySizes" },
{ cmd: "free -h", desc: "Show memory usage" }, { cmd: "free -h", descKey: "memoryUsage" },
{ cmd: "top", desc: "Show running processes" }, { cmd: "top", descKey: "runningProcesses" },
{ cmd: "ps aux | grep process", desc: "Find running process" }, { cmd: "ps aux | grep process", descKey: "findProcess" },
{ cmd: "systemctl status service", desc: "Check service status" }, { cmd: "systemctl status service", descKey: "serviceStatus" },
{ cmd: "systemctl restart service", desc: "Restart a service" }, { cmd: "systemctl restart service", descKey: "restartService" },
{ cmd: "apt update && apt upgrade", desc: "Update packages" }, { cmd: "apt update && apt upgrade", descKey: "updatePackages" },
{ cmd: "apt install package", desc: "Install package" }, { cmd: "apt install package", descKey: "installPackage" },
{ cmd: "tail -f /var/log/syslog", desc: "Follow log file" }, { cmd: "tail -f /var/log/syslog", descKey: "followLog" },
{ cmd: "chmod 755 file", desc: "Change file permissions" }, { cmd: "chmod 755 file", descKey: "changePermissions" },
{ cmd: "chown user:group file", desc: "Change file owner" }, { cmd: "chown user:group file", descKey: "changeOwner" },
{ cmd: "tar -xzf file.tar.gz", desc: "Extract tar.gz archive" }, { cmd: "tar -xzf file.tar.gz", descKey: "extractArchive" },
{ cmd: "docker ps", desc: "List running containers" }, { cmd: "docker ps", descKey: "listContainers" },
{ cmd: "docker images", desc: "List Docker images" }, { cmd: "docker images", descKey: "listImages" },
{ cmd: "ip addr show", desc: "Show IP addresses" }, { cmd: "ip addr show", descKey: "showIpAddresses" },
{ cmd: "ping host", desc: "Test network connectivity" }, { cmd: "ping host", descKey: "testConnectivity" },
{ cmd: "curl -I url", desc: "Get HTTP headers" }, { cmd: "curl -I url", descKey: "httpHeaders" },
{ cmd: "history", desc: "Show command history" }, { cmd: "history", descKey: "commandHistory" },
{ cmd: "clear", desc: "Clear terminal screen" }, { cmd: "clear", descKey: "clearScreen" },
] ] as const
type LocalCommand = { cmd: string; desc: string }
function getWebSocketUrl(): string { function getWebSocketUrl(): string {
if (typeof window === "undefined") { if (typeof window === "undefined") {
@@ -101,6 +104,7 @@ export function LxcTerminalModal({
vmid, vmid,
vmName, vmName,
}: LxcTerminalModalProps) { }: LxcTerminalModalProps) {
const t = useT()
const termRef = useRef<any>(null) const termRef = useRef<any>(null)
const wsRef = useRef<WebSocket | null>(null) const wsRef = useRef<WebSocket | null>(null)
const fitAddonRef = useRef<any>(null) const fitAddonRef = useRef<any>(null)
@@ -121,12 +125,18 @@ export function LxcTerminalModal({
// Search state // Search state
const [searchModalOpen, setSearchModalOpen] = useState(false) const [searchModalOpen, setSearchModalOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")
const [filteredCommands, setFilteredCommands] = useState<Array<{ cmd: string; desc: string }>>(proxmoxCommands) const localCommands = useMemo<LocalCommand[]>(
() => LXC_COMMANDS.map((item) => ({ cmd: item.cmd, desc: t(`lxcTerminal.commands.${item.descKey}`) })),
[t],
)
const [filteredCommands, setFilteredCommands] = useState<LocalCommand[]>([])
const [isSearching, setIsSearching] = useState(false) const [isSearching, setIsSearching] = useState(false)
const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([]) const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
const [useOnline, setUseOnline] = useState(true) const [useOnline, setUseOnline] = useState(true)
useEffect(() => {
setFilteredCommands(localCommands)
}, [localCommands])
// Detect mobile/tablet // Detect mobile/tablet
useEffect(() => { useEffect(() => {
@@ -278,7 +288,7 @@ export function LxcTerminalModal({
// through Number without losing fidelity. // through Number without losing fidelity.
const id = Number(vmid) const id = Number(vmid)
if (!Number.isInteger(id) || id <= 0 || id >= 1_000_000) { if (!Number.isInteger(id) || id <= 0 || id >= 1_000_000) {
term.writeln('\r\n\x1b[31m[ERROR] Invalid VMID — refusing to execute pct enter\x1b[0m') term.writeln(`\r\n\x1b[31m[ERROR] ${t("lxcTerminal.errors.invalidVmid")}\x1b[0m`)
return return
} }
ws.send(`pct enter ${id}\r`) ws.send(`pct enter ${id}\r`)
@@ -287,7 +297,7 @@ export function LxcTerminalModal({
ws.onerror = () => { ws.onerror = () => {
setConnectionStatus("offline") setConnectionStatus("offline")
term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m") term.writeln(`\r\n\x1b[31m[ERROR] ${t("terminal.websocketError")}\x1b[0m`)
} }
ws.onclose = () => { ws.onclose = () => {
@@ -295,7 +305,7 @@ export function LxcTerminalModal({
if (pingIntervalRef.current) { if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current) clearInterval(pingIntervalRef.current)
} }
term.writeln("\r\n\x1b[33m[INFO] Connection closed\x1b[0m") term.writeln(`\r\n\x1b[33m[INFO] ${t("terminal.connectionClosed")}\x1b[0m`)
} }
term.onData((data) => { term.onData((data) => {
@@ -395,7 +405,7 @@ export function LxcTerminalModal({
termRef.current.dispose() termRef.current.dispose()
} }
} }
}, [isOpen, vmid]) }, [isOpen, vmid, t])
// Resize handling // Resize handling
useEffect(() => { useEffect(() => {
@@ -478,7 +488,7 @@ export function LxcTerminalModal({
const searchCheatSh = async (query: string) => { const searchCheatSh = async (query: string) => {
if (!query.trim()) { if (!query.trim()) {
setSearchResults([]) setSearchResults([])
setFilteredCommands(proxmoxCommands) setFilteredCommands(localCommands)
return return
} }
@@ -491,7 +501,7 @@ export function LxcTerminalModal({
}) })
if (!data.success || !data.examples || data.examples.length === 0) { if (!data.success || !data.examples || data.examples.length === 0) {
throw new Error("No examples found") throw new Error(t("terminal.noExamplesFound"))
} }
const formattedResults: CheatSheetResult[] = data.examples.map((example: any) => ({ const formattedResults: CheatSheetResult[] = data.examples.map((example: any) => ({
@@ -503,7 +513,7 @@ export function LxcTerminalModal({
setUseOnline(true) setUseOnline(true)
setSearchResults(formattedResults) setSearchResults(formattedResults)
} catch (error) { } catch (error) {
const filtered = proxmoxCommands.filter( const filtered = localCommands.filter(
(item) => (item) =>
item.cmd.toLowerCase().includes(query.toLowerCase()) || item.cmd.toLowerCase().includes(query.toLowerCase()) ||
item.desc.toLowerCase().includes(query.toLowerCase()), item.desc.toLowerCase().includes(query.toLowerCase()),
@@ -521,12 +531,12 @@ export function LxcTerminalModal({
searchCheatSh(searchQuery) searchCheatSh(searchQuery)
} else { } else {
setSearchResults([]) setSearchResults([])
setFilteredCommands(proxmoxCommands) setFilteredCommands(localCommands)
} }
}, 800) }, 800)
return () => clearTimeout(debounce) return () => clearTimeout(debounce)
}, [searchQuery]) }, [searchQuery, localCommands, t])
const handleClear = useCallback(() => { const handleClear = useCallback(() => {
if (termRef.current) { if (termRef.current) {
@@ -565,7 +575,7 @@ export function LxcTerminalModal({
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-b border-zinc-800"> <div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-b border-zinc-800">
<DialogTitle className="text-sm font-medium text-white"> <DialogTitle className="text-sm font-medium text-white">
Terminal: {vmName} (ID: {vmid}) {t("lxcTerminal.title", { name: vmName, id: vmid })}
</DialogTitle> </DialogTitle>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@@ -576,7 +586,7 @@ export function LxcTerminalModal({
className="h-8 gap-2 bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400 disabled:opacity-50" className="h-8 gap-2 bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400 disabled:opacity-50"
> >
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
<span className="hidden sm:inline">Search</span> <span className="hidden sm:inline">{t("terminal.search")}</span>
</Button> </Button>
<Button <Button
onClick={handleClear} onClick={handleClear}
@@ -586,7 +596,7 @@ export function LxcTerminalModal({
className="h-8 gap-2 bg-yellow-600/20 hover:bg-yellow-600/30 border-yellow-600/50 text-yellow-400 disabled:opacity-50" className="h-8 gap-2 bg-yellow-600/20 hover:bg-yellow-600/30 border-yellow-600/50 text-yellow-400 disabled:opacity-50"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">Clear</span> <span className="hidden sm:inline">{t("terminal.clear")}</span>
</Button> </Button>
</div> </div>
</div> </div>
@@ -673,29 +683,29 @@ export function LxcTerminalModal({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56"> <DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel> <DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.controlSequences")}</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => sendKey("\x03")}> <DropdownMenuItem onSelect={() => sendKey("\x03")}>
<span className="font-mono text-xs mr-2">Ctrl+C</span> <span className="font-mono text-xs mr-2">Ctrl+C</span>
<span className="text-muted-foreground text-xs">Cancel/Interrupt</span> <span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendKey("\x18")}> <DropdownMenuItem onSelect={() => sendKey("\x18")}>
<span className="font-mono text-xs mr-2">Ctrl+X</span> <span className="font-mono text-xs mr-2">Ctrl+X</span>
<span className="text-muted-foreground text-xs">Exit (nano)</span> <span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendKey("\x12")}> <DropdownMenuItem onSelect={() => sendKey("\x12")}>
<span className="font-mono text-xs mr-2">Ctrl+R</span> <span className="font-mono text-xs mr-2">Ctrl+R</span>
<span className="text-muted-foreground text-xs">Search history</span> <span className="text-muted-foreground text-xs">{t("scriptTerminal.searchHistory")}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">Clipboard</DropdownMenuLabel> <DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.clipboard")}</DropdownMenuLabel>
<DropdownMenuItem onSelect={() => { void handleCopy() }}> <DropdownMenuItem onSelect={() => { void handleCopy() }}>
<Copy className="h-3.5 w-3.5 mr-2" /> <Copy className="h-3.5 w-3.5 mr-2" />
<span className="text-xs">Copy selection</span> <span className="text-xs">{t("scriptTerminal.copySelection")}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => { void handlePaste() }}> <DropdownMenuItem onSelect={() => { void handlePaste() }}>
<Clipboard className="h-3.5 w-3.5 mr-2" /> <Clipboard className="h-3.5 w-3.5 mr-2" />
<span className="text-xs">Paste</span> <span className="text-xs">{t("scriptTerminal.paste")}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -716,7 +726,7 @@ export function LxcTerminalModal({
: "bg-red-500" : "bg-red-500"
}`} }`}
/> />
<span className="text-xs text-zinc-400 capitalize">{connectionStatus}</span> <span className="text-xs text-zinc-400">{t(`scriptTerminal.${connectionStatus}`)}</span>
</div> </div>
<Button <Button
onClick={onClose} onClick={onClose}
@@ -725,7 +735,7 @@ export function LxcTerminalModal({
className="h-8 gap-2 bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400" className="h-8 gap-2 bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="hidden sm:inline">Close</span> <span className="hidden sm:inline">{t("actions.close")}</span>
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
@@ -734,22 +744,22 @@ export function LxcTerminalModal({
<SearchDialog open={searchModalOpen} onOpenChange={setSearchModalOpen}> <SearchDialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
<SearchDialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col"> <SearchDialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-zinc-800"> <DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-zinc-800">
<SearchDialogTitle className="text-xl font-semibold">Search Commands</SearchDialogTitle> <SearchDialogTitle className="text-xl font-semibold">{t("terminal.searchCommands")}</SearchDialogTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
className={`w-2 h-2 rounded-full ${useOnline ? "bg-green-500" : "bg-red-500"}`} className={`w-2 h-2 rounded-full ${useOnline ? "bg-green-500" : "bg-red-500"}`}
title={useOnline ? "Online - Using cheat.sh API" : "Offline - Using local commands"} title={useOnline ? t("terminal.onlineSource") : t("terminal.offlineSource")}
/> />
</div> </div>
</DialogHeader> </DialogHeader>
<DialogDescription className="sr-only">Search for Linux commands</DialogDescription> <DialogDescription className="sr-only">{t("terminal.searchDescription")}</DialogDescription>
<div className="space-y-4"> <div className="space-y-4">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<Input <Input
placeholder="Search commands... (e.g., tar, docker, systemctl)" placeholder={t("terminal.searchPlaceholder")}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-zinc-900 border-zinc-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-base" className="pl-10 bg-zinc-900 border-zinc-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-base"
@@ -763,7 +773,7 @@ export function LxcTerminalModal({
{isSearching && ( {isSearching && (
<div className="text-center py-4 text-zinc-400"> <div className="text-center py-4 text-zinc-400">
<div className="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mb-2" /> <div className="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mb-2" />
<p className="text-sm">Searching cheat.sh...</p> <p className="text-sm">{t("terminal.searchingCheatSh")}</p>
</div> </div>
)} )}
@@ -790,7 +800,7 @@ export function LxcTerminalModal({
<div className="text-center py-2"> <div className="text-center py-2">
<p className="text-xs text-zinc-500"> <p className="text-xs text-zinc-500">
<Lightbulb className="inline-block w-3 h-3 mr-1" /> <Lightbulb className="inline-block w-3 h-3 mr-1" />
Powered by cheat.sh {t("terminal.poweredByCheatSh")}
</p> </p>
</div> </div>
</> </>
@@ -816,13 +826,13 @@ export function LxcTerminalModal({
className="shrink-0 h-7 px-2 text-xs" className="shrink-0 h-7 px-2 text-xs"
> >
<Send className="h-3 w-3 mr-1" /> <Send className="h-3 w-3 mr-1" />
Send {t("terminal.send")}
</Button> </Button>
</div> </div>
</div> </div>
)) ))
) : !isSearching && !searchQuery && !useOnline ? ( ) : !isSearching && !searchQuery && !useOnline ? (
proxmoxCommands.map((item, index) => ( localCommands.map((item, index) => (
<div <div
key={index} key={index}
onClick={() => sendToTerminal(item.cmd)} onClick={() => sendToTerminal(item.cmd)}
@@ -843,7 +853,7 @@ export function LxcTerminalModal({
className="shrink-0 h-7 px-2 text-xs" className="shrink-0 h-7 px-2 text-xs"
> >
<Send className="h-3 w-3 mr-1" /> <Send className="h-3 w-3 mr-1" />
Send {t("terminal.send")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -854,17 +864,17 @@ export function LxcTerminalModal({
<> <>
<Search className="w-12 h-12 text-zinc-600 mx-auto" /> <Search className="w-12 h-12 text-zinc-600 mx-auto" />
<div> <div>
<p className="text-zinc-400 font-medium">{"No results found for \""}{searchQuery}{"\""}</p> <p className="text-zinc-400 font-medium">{t("terminal.noResults", { query: searchQuery })}</p>
<p className="text-xs text-zinc-500 mt-1">Try a different command or check your spelling</p> <p className="text-xs text-zinc-500 mt-1">{t("terminal.tryDifferentSearch")}</p>
</div> </div>
</> </>
) : ( ) : (
<> <>
<Terminal className="w-12 h-12 text-zinc-600 mx-auto" /> <Terminal className="w-12 h-12 text-zinc-600 mx-auto" />
<div> <div>
<p className="text-zinc-400 font-medium mb-2">Search for any command</p> <p className="text-zinc-400 font-medium mb-2">{t("terminal.searchAnyCommand")}</p>
<div className="text-sm text-zinc-500 space-y-1"> <div className="text-sm text-zinc-500 space-y-1">
<p>Try searching for:</p> <p>{t("terminal.trySearchingFor")}</p>
<div className="flex flex-wrap justify-center gap-2 mt-2"> <div className="flex flex-wrap justify-center gap-2 mt-2">
{["tar", "grep", "docker", "systemctl", "curl"].map((cmd) => ( {["tar", "grep", "docker", "systemctl", "curl"].map((cmd) => (
<code <code
@@ -881,7 +891,7 @@ export function LxcTerminalModal({
{useOnline && ( {useOnline && (
<div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4"> <div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4">
<Lightbulb className="w-3 h-3" /> <Lightbulb className="w-3 h-3" />
<span>Powered by cheat.sh</span> <span>{t("terminal.poweredByCheatSh")}</span>
</div> </div>
)} )}
</> </>
@@ -893,9 +903,9 @@ export function LxcTerminalModal({
<div className="pt-2 border-t border-zinc-800 flex items-center justify-between text-xs text-zinc-500"> <div className="pt-2 border-t border-zinc-800 flex items-center justify-between text-xs text-zinc-500">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Lightbulb className="w-3 h-3" /> <Lightbulb className="w-3 h-3" />
<span>Tip: Search for any Linux command</span> <span>{t("terminal.searchTip")}</span>
</div> </div>
{useOnline && searchResults.length > 0 && <span className="text-zinc-600">Powered by cheat.sh</span>} {useOnline && searchResults.length > 0 && <span className="text-zinc-600">{t("terminal.poweredByCheatSh")}</span>}
</div> </div>
</div> </div>
</SearchDialogContent> </SearchDialogContent>
+20 -20
View File
@@ -5,6 +5,7 @@ import { Boxes, Info, Loader2, Settings2, CheckCircle2 } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge" import { Badge } from "./ui/badge"
import { fetchApi } from "../lib/api-config" import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface DetectionResponse { interface DetectionResponse {
success: boolean success: boolean
@@ -14,6 +15,7 @@ interface DetectionResponse {
} }
export function LxcUpdateDetection() { export function LxcUpdateDetection() {
const t = useT()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [enabled, setEnabled] = useState<boolean>(true) const [enabled, setEnabled] = useState<boolean>(true)
@@ -32,11 +34,11 @@ export function LxcUpdateDetection() {
setEnabled(data.enabled) setEnabled(data.enabled)
setPending(data.enabled) setPending(data.enabled)
} else { } else {
setError(data.message || "Failed to load setting") setError(data.message || t("settings.lxcUpdateDetection.loadFailed"))
} }
}) })
.catch(e => { .catch(e => {
if (!cancelled) setError(String(e)) if (!cancelled) setError(t("settings.lxcUpdateDetection.loadFailed"))
}) })
.finally(() => { .finally(() => {
if (!cancelled) setLoading(false) if (!cancelled) setLoading(false)
@@ -77,7 +79,7 @@ export function LxcUpdateDetection() {
body: JSON.stringify({ enabled: pending }), body: JSON.stringify({ enabled: pending }),
}) })
if (!data.success) { if (!data.success) {
setError(data.message || "Failed to save setting") setError(data.message || t("settings.lxcUpdateDetection.saveFailed"))
return return
} }
setEnabled(pending) setEnabled(pending)
@@ -95,7 +97,7 @@ export function LxcUpdateDetection() {
) )
} }
} catch (e) { } catch (e) {
setError(String(e)) setError(t("settings.lxcUpdateDetection.saveFailed"))
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -111,14 +113,14 @@ export function LxcUpdateDetection() {
breakpoint thanks to `items-center` + leading-tight title. */} breakpoint thanks to `items-center` + leading-tight title. */}
<div className="flex items-center gap-2 flex-wrap min-w-0"> <div className="flex items-center gap-2 flex-wrap min-w-0">
<Boxes className="h-5 w-5 text-purple-500 shrink-0" /> <Boxes className="h-5 w-5 text-purple-500 shrink-0" />
<CardTitle className="leading-tight">LXC Update Detection</CardTitle> <CardTitle className="leading-tight">{t("settings.lxcUpdateDetection.title")}</CardTitle>
{enabled ? ( {enabled ? (
<Badge variant="outline" className="text-[10px] border-green-500/30 text-green-500"> <Badge variant="outline" className="text-[10px] border-green-500/30 text-green-500">
Active {t("status.active")}
</Badge> </Badge>
) : ( ) : (
<Badge variant="outline" className="text-[10px] border-muted-foreground/30 text-muted-foreground"> <Badge variant="outline" className="text-[10px] border-muted-foreground/30 text-muted-foreground">
Disabled {t("status.disabled")}
</Badge> </Badge>
)} )}
</div> </div>
@@ -126,7 +128,7 @@ export function LxcUpdateDetection() {
{saved && ( {saved && (
<span className="flex items-center gap-1 text-xs text-green-500"> <span className="flex items-center gap-1 text-xs text-green-500">
<CheckCircle2 className="h-3.5 w-3.5" /> <CheckCircle2 className="h-3.5 w-3.5" />
Saved {t("status.saved")}
</span> </span>
)} )}
{error && !editMode && ( {error && !editMode && (
@@ -134,7 +136,7 @@ export function LxcUpdateDetection() {
className="flex items-center gap-1 text-xs text-red-500 max-w-[40ch] truncate" className="flex items-center gap-1 text-xs text-red-500 max-w-[40ch] truncate"
title={error} title={error}
> >
Save failed: {error} {t("status.saveFailed")}: {error}
</span> </span>
)} )}
{editMode ? ( {editMode ? (
@@ -144,7 +146,7 @@ export function LxcUpdateDetection() {
onClick={handleCancel} onClick={handleCancel}
disabled={saving} disabled={saving}
> >
Cancel {t("actions.cancel")}
</button> </button>
<button <button
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5" className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
@@ -152,7 +154,7 @@ export function LxcUpdateDetection() {
disabled={saving || !hasChanges} disabled={saving || !hasChanges}
> >
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />} {saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
Save {t("actions.save")}
</button> </button>
</> </>
) : ( ) : (
@@ -162,16 +164,15 @@ export function LxcUpdateDetection() {
disabled={loading} disabled={loading}
> >
<Settings2 className="h-3 w-3" /> <Settings2 className="h-3 w-3" />
Edit {t("actions.edit")}
</button> </button>
)} )}
</div> </div>
</div> </div>
<CardDescription> <CardDescription>
Periodically check running Debian/Ubuntu/Alpine LXC containers for pending package updates {t("settings.lxcUpdateDetection.descriptionStart")}{" "}
(<code>apt list --upgradable</code> / <code>apk list -u</code>) and surface them on the dashboard. The (<code>apt list --upgradable</code> / <code>apk list -u</code>)
corresponding notification toggle in <strong>Notifications Services</strong> appears only while detection {t("settings.lxcUpdateDetection.descriptionEnd")}
is enabled.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@@ -185,7 +186,7 @@ export function LxcUpdateDetection() {
<Boxes <Boxes
className={`h-4 w-4 shrink-0 ${pending ? "text-purple-500" : "text-muted-foreground"}`} className={`h-4 w-4 shrink-0 ${pending ? "text-purple-500" : "text-muted-foreground"}`}
/> />
<span className="text-sm font-medium truncate">Enable LXC update detection</span> <span className="text-sm font-medium truncate">{t("settings.lxcUpdateDetection.enableLabel")}</span>
</div> </div>
<button <button
className={`relative w-10 h-5 rounded-full transition-colors shrink-0 ${ className={`relative w-10 h-5 rounded-full transition-colors shrink-0 ${
@@ -195,7 +196,7 @@ export function LxcUpdateDetection() {
disabled={!editMode || saving} disabled={!editMode || saving}
role="switch" role="switch"
aria-checked={pending} aria-checked={pending}
aria-label="Enable LXC update detection" aria-label={t("settings.lxcUpdateDetection.enableLabel")}
> >
<span <span
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform ${ className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform ${
@@ -209,8 +210,7 @@ export function LxcUpdateDetection() {
<div className="flex items-start gap-2 p-3 rounded-lg bg-muted/50 border border-border"> <div className="flex items-start gap-2 p-3 rounded-lg bg-muted/50 border border-border">
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" /> <Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-muted-foreground leading-relaxed"> <p className="text-[11px] text-muted-foreground leading-relaxed">
{lastPurged} LXC entries removed from the registry. Re-enabling detection will repopulate them on the {t("settings.lxcUpdateDetection.purgedMessage", { count: lastPurged })}
next scan cycle.
</p> </p>
</div> </div>
)} )}
+29 -27
View File
@@ -6,6 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { ArrowLeft, Loader2 } from "lucide-react" import { ArrowLeft, Loader2 } from "lucide-react"
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts" import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
import { fetchApi } from "@/lib/api-config" import { fetchApi } from "@/lib/api-config"
import { useI18n } from "@/lib/i18n/provider"
interface MetricsViewProps { interface MetricsViewProps {
vmid: number vmid: number
@@ -15,12 +16,12 @@ interface MetricsViewProps {
} }
const TIMEFRAME_OPTIONS = [ const TIMEFRAME_OPTIONS = [
{ value: "hour", label: "1 Hour" }, { value: "hour", labelKey: "vmMetrics.timeframes.hour" },
{ value: "day", label: "24 Hours" }, { value: "day", labelKey: "vmMetrics.timeframes.day" },
{ value: "week", label: "7 Days" }, { value: "week", labelKey: "vmMetrics.timeframes.week" },
{ value: "month", label: "30 Days" }, { value: "month", labelKey: "vmMetrics.timeframes.month" },
{ value: "year", label: "1 Year" }, { value: "year", labelKey: "vmMetrics.timeframes.year" },
] ] as const
const CustomCPUTooltip = ({ active, payload, label }: any) => { const CustomCPUTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
@@ -103,6 +104,7 @@ const CustomNetworkTooltip = ({ active, payload, label }: any) => {
} }
export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps) { export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps) {
const { language, t } = useI18n()
const [timeframe, setTimeframe] = useState("week") const [timeframe, setTimeframe] = useState("week")
const [data, setData] = useState<any[]>([]) const [data, setData] = useState<any[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -112,7 +114,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
useEffect(() => { useEffect(() => {
fetchMetrics() fetchMetrics()
}, [vmid, timeframe]) }, [vmid, timeframe, language])
const fetchMetrics = async () => { const fetchMetrics = async () => {
setLoading(true) setLoading(true)
@@ -126,19 +128,19 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
let timeLabel = "" let timeLabel = ""
if (timeframe === "hour") { if (timeframe === "hour") {
timeLabel = date.toLocaleString("en-US", { timeLabel = date.toLocaleString(language, {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
hour12: false, hour12: false,
}) })
} else if (timeframe === "day") { } else if (timeframe === "day") {
timeLabel = date.toLocaleString("en-US", { timeLabel = date.toLocaleString(language, {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
hour12: false, hour12: false,
}) })
} else if (timeframe === "week") { } else if (timeframe === "week") {
timeLabel = date.toLocaleString("en-US", { timeLabel = date.toLocaleString(language, {
month: "short", month: "short",
day: "numeric", day: "numeric",
hour: "2-digit", hour: "2-digit",
@@ -146,12 +148,12 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
hour12: false, hour12: false,
}) })
} else if (timeframe === "month") { } else if (timeframe === "month") {
timeLabel = date.toLocaleString("en-US", { timeLabel = date.toLocaleString(language, {
month: "short", month: "short",
day: "numeric", day: "numeric",
}) })
} else { } else {
timeLabel = date.toLocaleString("en-US", { timeLabel = date.toLocaleString(language, {
month: "short", month: "short",
year: "numeric", year: "numeric",
}) })
@@ -173,7 +175,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
setData(transformedData) setData(transformedData)
} catch (err: any) { } catch (err: any) {
setError(err.message || "Error loading metrics") setError(err.message || t("vmMetrics.errors.loading"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -203,7 +205,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
if (data.length === 0) { if (data.length === 0) {
return ( return (
<div className="flex items-center justify-center h-[400px]"> <div className="flex items-center justify-center h-[400px]">
<p className="text-muted-foreground">No data available</p> <p className="text-muted-foreground">{t("vmMetrics.noData")}</p>
</div> </div>
) )
} }
@@ -214,7 +216,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
<div className="space-y-8"> <div className="space-y-8">
{/* CPU Chart */} {/* CPU Chart */}
<div> <div>
<h3 className="text-lg font-semibold mb-4">CPU Usage</h3> <h3 className="text-lg font-semibold mb-4">{t("vmMetrics.charts.cpu")}</h3>
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={300}>
<AreaChart data={data} margin={{ bottom: 80 }}> <AreaChart data={data} margin={{ bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" /> <CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
@@ -244,7 +246,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
strokeWidth={2} strokeWidth={2}
fill="#3b82f6" fill="#3b82f6"
fillOpacity={0.3} fillOpacity={0.3}
name="CPU %" name={t("vmMetrics.series.cpu")}
/> />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -252,7 +254,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
{/* Memory Chart */} {/* Memory Chart */}
<div> <div>
<h3 className="text-lg font-semibold mb-4">Memory Usage</h3> <h3 className="text-lg font-semibold mb-4">{t("vmMetrics.charts.memory")}</h3>
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={300}>
<AreaChart data={data} margin={{ bottom: 80 }}> <AreaChart data={data} margin={{ bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" /> <CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
@@ -282,7 +284,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
fill="#10b981" fill="#10b981"
fillOpacity={0.3} fillOpacity={0.3}
strokeWidth={2} strokeWidth={2}
name="Memory GB" name={t("vmMetrics.series.memoryGb")}
/> />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -290,7 +292,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
{/* Disk I/O Chart */} {/* Disk I/O Chart */}
<div> <div>
<h3 className="text-lg font-semibold mb-4">Disk I/O</h3> <h3 className="text-lg font-semibold mb-4">{t("vmMetrics.charts.diskIo")}</h3>
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={300}>
<AreaChart data={data} margin={{ bottom: 80 }}> <AreaChart data={data} margin={{ bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" /> <CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
@@ -321,7 +323,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
fill="#10b981" fill="#10b981"
fillOpacity={0.3} fillOpacity={0.3}
strokeWidth={2} strokeWidth={2}
name="Read" name={t("vmMetrics.series.read")}
hide={hiddenDiskLines.includes("diskread")} hide={hiddenDiskLines.includes("diskread")}
/> />
<Area <Area
@@ -331,7 +333,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
fill="#3b82f6" fill="#3b82f6"
fillOpacity={0.3} fillOpacity={0.3}
strokeWidth={2} strokeWidth={2}
name="Write" name={t("vmMetrics.series.write")}
hide={hiddenDiskLines.includes("diskwrite")} hide={hiddenDiskLines.includes("diskwrite")}
/> />
</AreaChart> </AreaChart>
@@ -340,7 +342,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
{/* Network I/O Chart */} {/* Network I/O Chart */}
<div> <div>
<h3 className="text-lg font-semibold mb-4">Network I/O</h3> <h3 className="text-lg font-semibold mb-4">{t("vmMetrics.charts.networkIo")}</h3>
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={300}>
<AreaChart data={data} margin={{ bottom: 80 }}> <AreaChart data={data} margin={{ bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" /> <CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
@@ -371,7 +373,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
fill="#10b981" fill="#10b981"
fillOpacity={0.3} fillOpacity={0.3}
strokeWidth={2} strokeWidth={2}
name="Download" name={t("vmMetrics.series.download")}
hide={hiddenNetworkLines.includes("netin")} hide={hiddenNetworkLines.includes("netin")}
/> />
<Area <Area
@@ -381,7 +383,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
fill="#3b82f6" fill="#3b82f6"
fillOpacity={0.3} fillOpacity={0.3}
strokeWidth={2} strokeWidth={2}
name="Upload" name={t("vmMetrics.series.upload")}
hide={hiddenNetworkLines.includes("netout")} hide={hiddenNetworkLines.includes("netout")}
/> />
</AreaChart> </AreaChart>
@@ -461,9 +463,9 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
</Button> </Button>
<div> <div>
<h2 className="text-xl font-semibold">Metrics - {vmName}</h2> <h2 className="text-xl font-semibold">{t("vmMetrics.title", { name: vmName })}</h2>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
VMID: {vmid} Type: {vmType.toUpperCase()} VMID: {vmid} {t("vmMetrics.type")}: {vmType.toUpperCase()}
</p> </p>
</div> </div>
</div> </div>
@@ -474,7 +476,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
<SelectContent> <SelectContent>
{TIMEFRAME_OPTIONS.map((option) => ( {TIMEFRAME_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {t(option.labelKey)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
+37 -26
View File
@@ -6,6 +6,9 @@ import { Wifi, Zap } from 'lucide-react'
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { fetchApi } from "../lib/api-config" import { fetchApi } from "../lib/api-config"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network" import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { useT } from "../lib/i18n/provider"
type TFunction = (key: string, params?: Record<string, string | number>) => string
interface NetworkCardProps { interface NetworkCardProps {
interface_: { interface_: {
@@ -32,43 +35,51 @@ interface NetworkCardProps {
onClick?: () => void onClick?: () => void
} }
const getInterfaceTypeBadge = (type: string) => { const getInterfaceTypeBadge = (type: string, t: TFunction) => {
switch (type) { switch (type) {
case "physical": case "physical":
return { color: "bg-blue-500/10 text-blue-500 border-blue-500/20", label: "Physical" } return { color: "bg-blue-500/10 text-blue-500 border-blue-500/20", label: t("network.interfaceTypes.physical") }
case "bridge": case "bridge":
return { color: "bg-green-500/10 text-green-500 border-green-500/20", label: "Bridge" } return { color: "bg-green-500/10 text-green-500 border-green-500/20", label: t("network.interfaceTypes.bridge") }
case "bond": case "bond":
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "Bond" } return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: t("network.interfaceTypes.bond") }
case "vlan": case "vlan":
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "VLAN" } return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: t("network.interfaceTypes.vlan") }
case "vm_lxc": case "vm_lxc":
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: "Virtual" } return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: t("network.interfaceTypes.virtual") }
case "virtual": case "virtual":
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: "Virtual" } return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: t("network.interfaceTypes.virtual") }
default: default:
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: "Unknown" } return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: t("common.unknown") }
} }
} }
const getVMTypeBadge = (vmType: string | undefined) => { const getVMTypeBadge = (vmType: string | undefined, t: TFunction) => {
if (vmType === "lxc") { if (vmType === "lxc") {
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "LXC" } return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "LXC" }
} else if (vmType === "vm") { } else if (vmType === "vm") {
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "VM" } return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "VM" }
} }
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: "Unknown" } return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: t("common.unknown") }
} }
const formatSpeed = (speed: number): string => { const formatInterfaceStatus = (status: string | undefined, t: TFunction): string => {
if (speed === 0) return "N/A" const normalized = (status || "").toLowerCase()
if (normalized === "up") return t("network.status.up")
if (normalized === "down") return t("network.status.down")
return status || t("common.unknown")
}
const formatSpeed = (speed: number, unavailable = "N/A"): string => {
if (speed === 0) return unavailable
if (speed >= 1000) return `${(speed / 1000).toFixed(1)} Gbps` if (speed >= 1000) return `${(speed / 1000).toFixed(1)} Gbps`
return `${speed} Mbps` return `${speed} Mbps`
} }
export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps) { export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps) {
const typeBadge = getInterfaceTypeBadge(interface_.type) const t = useT()
const vmTypeBadge = interface_.vm_type ? getVMTypeBadge(interface_.vm_type) : null const typeBadge = getInterfaceTypeBadge(interface_.type, t)
const vmTypeBadge = interface_.vm_type ? getVMTypeBadge(interface_.vm_type, t) : null
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(getNetworkUnit()) const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(getNetworkUnit())
@@ -125,17 +136,17 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps
const getTimeframeLabel = () => { const getTimeframeLabel = () => {
switch (timeframe) { switch (timeframe) {
case "hour": case "hour":
return "Last Hour" return t("network.timeframes.last.hour")
case "day": case "day":
return "Last 24 Hours" return t("network.timeframes.last.day")
case "week": case "week":
return "Last 7 Days" return t("network.timeframes.last.week")
case "month": case "month":
return "Last 30 Days" return t("network.timeframes.last.month")
case "year": case "year":
return "Last Year" return t("network.timeframes.last.year")
default: default:
return "Last 24 Hours" return t("network.timeframes.last.day")
} }
} }
@@ -174,7 +185,7 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps
: "bg-red-500/10 text-red-500 border-red-500/20" : "bg-red-500/10 text-red-500 border-red-500/20"
} }
> >
{interface_.status.toUpperCase()} {formatInterfaceStatus(interface_.status, t)}
</Badge> </Badge>
</div> </div>
@@ -182,22 +193,22 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div> <div>
<div className="text-muted-foreground text-xs"> <div className="text-muted-foreground text-xs">
{interface_.type === "vm_lxc" ? "VMID" : "IP Address"} {interface_.type === "vm_lxc" ? "VMID" : t("network.labels.ipAddress")}
</div> </div>
<div className="font-medium text-foreground font-mono text-sm truncate"> <div className="font-medium text-foreground font-mono text-sm truncate">
{interface_.type === "vm_lxc" {interface_.type === "vm_lxc"
? (interface_.vmid ?? "N/A") ? (interface_.vmid ?? t("common.notAvailable"))
: interface_.addresses.length > 0 : interface_.addresses.length > 0
? interface_.addresses[0].ip ? interface_.addresses[0].ip
: "N/A"} : t("common.notAvailable")}
</div> </div>
</div> </div>
<div> <div>
<div className="text-muted-foreground text-xs">Speed</div> <div className="text-muted-foreground text-xs">{t("network.labels.speed")}</div>
<div className="font-medium text-foreground flex items-center gap-1 text-xs"> <div className="font-medium text-foreground flex items-center gap-1 text-xs">
<Zap className="h-3 w-3" /> <Zap className="h-3 w-3" />
{formatSpeed(interface_.speed)} {formatSpeed(interface_.speed, t("common.notAvailable"))}
</div> </div>
</div> </div>
+29 -12
View File
@@ -7,6 +7,7 @@
import { useEffect, useMemo, useRef, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Activity } from "lucide-react" import { Activity } from "lucide-react"
import { useT } from "../lib/i18n/provider"
// One animated comet-trail pulse. Returned as DATA from the layout // One animated comet-trail pulse. Returned as DATA from the layout
// renderers instead of an SVG string so the parent component can // renderers instead of an SVG string so the parent component can
@@ -25,6 +26,12 @@ type PulseData = {
rate?: number rate?: number
} }
type FlowLabels = {
down: string
active: string
standby: string
}
// ─── Public types — match the /api/network shape ──────────── // ─── Public types — match the /api/network shape ────────────
type NIC = { type NIC = {
id: string id: string
@@ -126,14 +133,15 @@ function resolveBonds(data: NetworkFlowData): {
// Sub-label under a NIC. In active-backup the role is the useful bit // Sub-label under a NIC. In active-backup the role is the useful bit
// (which cable is actually carrying traffic right now); in every other // (which cable is actually carrying traffic right now); in every other
// mode all slaves transmit, so the link speed stays. // mode all slaves transmit, so the link speed stays.
function nicSubLabel(n: NIC): string { function nicSubLabel(n: NIC, labels: FlowLabels): string {
if (n.status === "down") return "down" if (n.status === "down") return labels.down
const role = n.role === "standby" || n.role === "active" ? n.role : "" const role = n.role === "standby" || n.role === "active" ? n.role : ""
if (!role) return n.link if (!role) return n.link
// A NIC that doesn't report a negotiated speed renders its link as // A NIC that doesn't report a negotiated speed renders its link as
// "—"; pairing that with the role would read as "— · active". // "—"; pairing that with the role would read as "— · active".
if (!n.link || n.link === "—") return role const roleLabel = role === "active" ? labels.active : labels.standby
return `${n.link} · ${role}` if (!n.link || n.link === "—") return roleLabel
return `${n.link} · ${roleLabel}`
} }
function fmt(v: number): string { function fmt(v: number): string {
@@ -214,7 +222,7 @@ function curvedTap(cx: number, busY: number, targetY: number, r = 14): string {
} }
// ─── Renderer: returns full SVG markup string for a given width ── // ─── Renderer: returns full SVG markup string for a given width ──
function renderHorizontal(data: NetworkFlowData, W: number): { svg: string; pulses: PulseData[]; height: number } { function renderHorizontal(data: NetworkFlowData, W: number, labels: FlowLabels): { svg: string; pulses: PulseData[]; height: number } {
const top = activeConsumers(data.consumers) const top = activeConsumers(data.consumers)
const bridges = visibleBridges(data.bridges, top) const bridges = visibleBridges(data.bridges, top)
const host = data.consumers.find((c) => c.kind === "host") const host = data.consumers.find((c) => c.kind === "host")
@@ -343,7 +351,7 @@ function renderHorizontal(data: NetworkFlowData, W: number): { svg: string; puls
<circle class="nf-circle" cx="${nicX}" cy="${y}" r="${radNic}" stroke="${stroke}" /> <circle class="nf-circle" cx="${nicX}" cy="${y}" r="${radNic}" stroke="${stroke}" />
${svgIcon("nic", nicX, y, 18, stroke)} ${svgIcon("nic", nicX, y, 18, stroke)}
<text class="nf-label" x="${nicX}" y="${y + radNic + 14}">${n.id}</text> <text class="nf-label" x="${nicX}" y="${y + radNic + 14}">${n.id}</text>
<text class="nf-sub" x="${nicX}" y="${y + radNic + 26}">${nicSubLabel(n)}</text> <text class="nf-sub" x="${nicX}" y="${y + radNic + 26}">${nicSubLabel(n, labels)}</text>
</g>`) </g>`)
}) })
@@ -591,7 +599,7 @@ function renderHorizontal(data: NetworkFlowData, W: number): { svg: string; puls
// own sub-trunk lives at SUB_TRUNK_X and fans out to its guests in // own sub-trunk lives at SUB_TRUNK_X and fans out to its guests in
// an arc (some above, some below the bridge.cy). All elbows use Q // an arc (some above, some below the bridge.cy). All elbows use Q
// curves; no sharp 90° corners anywhere. // curves; no sharp 90° corners anywhere.
function renderVertical(data: NetworkFlowData): { svg: string; pulses: PulseData[]; height: number; viewBox: string } { function renderVertical(data: NetworkFlowData, labels: FlowLabels): { svg: string; pulses: PulseData[]; height: number; viewBox: string } {
// Smaller W → SVG scales up on the mobile screen, nodes look bigger. // Smaller W → SVG scales up on the mobile screen, nodes look bigger.
// All four x-columns evenly spaced so curve→target distances are // All four x-columns evenly spaced so curve→target distances are
// homogeneous (host→bridge, bridge→spine, spine→guest all ~60 px). // homogeneous (host→bridge, bridge→spine, spine→guest all ~60 px).
@@ -768,7 +776,7 @@ function renderVertical(data: NetworkFlowData): { svg: string; pulses: PulseData
<circle class="nf-circle" cx="${cx}" cy="${cy}" r="${r}" stroke="${color}" /> <circle class="nf-circle" cx="${cx}" cy="${cy}" r="${r}" stroke="${color}" />
${svgIcon("nic", cx, cy, 13, color)} ${svgIcon("nic", cx, cy, 13, color)}
<text class="nf-label" x="${cx}" y="${cy + r + LABEL_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${n.id}</text> <text class="nf-label" x="${cx}" y="${cy + r + LABEL_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${n.id}</text>
<text class="nf-sub" x="${cx}" y="${cy + r + SUB_OFFSET_Y}" text-anchor="middle" style="font-size:11px">${nicSubLabel(n)}</text> <text class="nf-sub" x="${cx}" y="${cy + r + SUB_OFFSET_Y}" text-anchor="middle" style="font-size:11px">${nicSubLabel(n, labels)}</text>
</g>`) </g>`)
}) })
@@ -936,6 +944,15 @@ export function NetworkFlow({
// opens the per-interface details modal. // opens the per-interface details modal.
onNodeClick?: (name: string, kind: "nic" | "host" | "bond" | "bridge" | "lxc" | "vm") => void onNodeClick?: (name: string, kind: "nic" | "host" | "bond" | "bridge" | "lxc" | "vm") => void
}) { }) {
const t = useT()
const labels = useMemo<FlowLabels>(
() => ({
down: t("network.status.down"),
active: t("network.roles.active"),
standby: t("network.roles.standby"),
}),
[t],
)
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const [width, setWidth] = useState(1320) const [width, setWidth] = useState(1320)
const [mode, setMode] = useState<"desktop" | "tablet" | "mobile">("desktop") const [mode, setMode] = useState<"desktop" | "tablet" | "mobile">("desktop")
@@ -985,21 +1002,21 @@ export function NetworkFlow({
const { svgContent, pulses, viewBox, height } = useMemo(() => { const { svgContent, pulses, viewBox, height } = useMemo(() => {
if (mode === "mobile") { if (mode === "mobile") {
const out = renderVertical(data) const out = renderVertical(data, labels)
return { svgContent: out.svg, pulses: out.pulses, viewBox: out.viewBox, height: out.height } return { svgContent: out.svg, pulses: out.pulses, viewBox: out.viewBox, height: out.height }
} }
const W = mode === "tablet" ? 1100 : 1320 const W = mode === "tablet" ? 1100 : 1320
const out = renderHorizontal(data, W) const out = renderHorizontal(data, W, labels)
return { svgContent: out.svg, pulses: out.pulses, viewBox: `0 0 ${W} ${out.height}`, height: out.height } return { svgContent: out.svg, pulses: out.pulses, viewBox: `0 0 ${W} ${out.height}`, height: out.height }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [memoKey]) }, [memoKey, labels])
return ( return (
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-foreground flex items-center text-base"> <CardTitle className="text-foreground flex items-center text-base">
<Activity className="h-5 w-5 mr-2" /> <Activity className="h-5 w-5 mr-2" />
Network Flow (PoC) {t("network.flow.title")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
+231 -173
View File
@@ -13,6 +13,9 @@ import { fetchApi } from "../lib/api-config"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network" import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { LatencyDetailModal } from "./latency-detail-modal" import { LatencyDetailModal } from "./latency-detail-modal"
import { AreaChart, Area, LineChart, Line, ResponsiveContainer, YAxis } from "recharts" import { AreaChart, Area, LineChart, Line, ResponsiveContainer, YAxis } from "recharts"
import { useT } from "../lib/i18n/provider"
type TFunction = (key: string, params?: Record<string, string | number>) => string
interface NetworkData { interface NetworkData {
interfaces: NetworkInterface[] interfaces: NetworkInterface[]
@@ -141,24 +144,57 @@ function getInterfaceIcon(iface: NetworkInterface): React.ComponentType<{ classN
// Match the dark blue badge tone the Storage card uses for the disk // Match the dark blue badge tone the Storage card uses for the disk
// type chip, but mapped to the actual interface class. // type chip, but mapped to the actual interface class.
function getInterfaceTypeChip(type: string) { function getInterfaceTypeLabel(type: string, t: TFunction) {
switch ((type || "").toLowerCase()) { switch ((type || "").toLowerCase()) {
case "physical": case "physical":
return { className: "bg-blue-500/10 text-blue-400 border-blue-500/20", label: "Physical" } return t("network.interfaceTypes.physical")
case "bridge": case "bridge":
return { className: "bg-green-500/10 text-green-400 border-green-500/20", label: "Bridge" } return t("network.interfaceTypes.bridge")
case "bond": case "bond":
return { className: "bg-purple-500/10 text-purple-400 border-purple-500/20", label: "Bond" } return t("network.interfaceTypes.bond")
case "vlan": case "vlan":
return { className: "bg-cyan-500/10 text-cyan-400 border-cyan-500/20", label: "VLAN" } return t("network.interfaceTypes.vlan")
case "vm_lxc": case "vm_lxc":
case "virtual": case "virtual":
return { className: "bg-orange-500/10 text-orange-400 border-orange-500/20", label: "Virtual" } return t("network.interfaceTypes.virtual")
default: default:
return { className: "bg-gray-500/10 text-gray-400 border-gray-500/20", label: type || "Unknown" } return type || t("common.unknown")
} }
} }
function getInterfaceTypeChip(type: string, t: TFunction) {
switch ((type || "").toLowerCase()) {
case "physical":
return { className: "bg-blue-500/10 text-blue-400 border-blue-500/20", label: getInterfaceTypeLabel(type, t) }
case "bridge":
return { className: "bg-green-500/10 text-green-400 border-green-500/20", label: getInterfaceTypeLabel(type, t) }
case "bond":
return { className: "bg-purple-500/10 text-purple-400 border-purple-500/20", label: getInterfaceTypeLabel(type, t) }
case "vlan":
return { className: "bg-cyan-500/10 text-cyan-400 border-cyan-500/20", label: getInterfaceTypeLabel(type, t) }
case "vm_lxc":
case "virtual":
return { className: "bg-orange-500/10 text-orange-400 border-orange-500/20", label: getInterfaceTypeLabel(type, t) }
default:
return { className: "bg-gray-500/10 text-gray-400 border-gray-500/20", label: type || t("common.unknown") }
}
}
const formatInterfaceStatus = (status: string | undefined, t: TFunction): string => {
const normalized = (status || "").toLowerCase()
if (normalized === "up") return t("network.status.up")
if (normalized === "down") return t("network.status.down")
return status || t("common.unknown")
}
const formatDuplex = (duplex: string | undefined, t: TFunction): string => {
const normalized = (duplex || "").toLowerCase()
if (normalized === "full") return t("network.duplex.full")
if (normalized === "half") return t("network.duplex.half")
if (!duplex || normalized === "unknown") return t("common.unknown")
return duplex
}
// Per-interface card matching the Storage page's "Physical Disks" // Per-interface card matching the Storage page's "Physical Disks"
// pattern: 2-line header (identity / live state), horizontal divider, // pattern: 2-line header (identity / live state), horizontal divider,
// vertical key→value stat block, footer with serial + arrow CTA. // vertical key→value stat block, footer with serial + arrow CTA.
@@ -166,18 +202,19 @@ function getInterfaceTypeChip(type: string) {
function renderPhysicalInterfaceCardV2( function renderPhysicalInterfaceCardV2(
iface: NetworkInterface, iface: NetworkInterface,
onOpen: (iface: NetworkInterface) => void, onOpen: (iface: NetworkInterface) => void,
t: TFunction,
) { ) {
const Icon = getInterfaceIcon(iface) const Icon = getInterfaceIcon(iface)
const chip = getInterfaceTypeChip(iface.type) const chip = getInterfaceTypeChip(iface.type, t)
const isUp = (iface.status || "").toLowerCase() === "up" const isUp = (iface.status || "").toLowerCase() === "up"
const firstAddr = iface.addresses?.[0]?.ip || "" const firstAddr = iface.addresses?.[0]?.ip || ""
const extraAddrs = Math.max(0, (iface.addresses?.length || 0) - 1) const extraAddrs = Math.max(0, (iface.addresses?.length || 0) - 1)
const speedStr = formatSpeed(iface.speed) const speedStr = formatSpeed(iface.speed, t("common.notAvailable"))
// Hardware max in Mbps from ethtool. Show only when it's different // Hardware max in Mbps from ethtool. Show only when it's different
// from the negotiated speed (avoids "1 Gbps (max 1 Gbps)" noise). // from the negotiated speed (avoids "1 Gbps (max 1 Gbps)" noise).
const maxSpeedStr = const maxSpeedStr =
iface.max_speed && iface.max_speed !== iface.speed iface.max_speed && iface.max_speed !== iface.speed
? formatSpeed(iface.max_speed) ? formatSpeed(iface.max_speed, t("common.notAvailable"))
: "" : ""
const bridgesUsing = iface.used_by_bridges || [] const bridgesUsing = iface.used_by_bridges || []
const errIn = iface.errors_in ?? 0 const errIn = iface.errors_in ?? 0
@@ -206,7 +243,7 @@ function renderPhysicalInterfaceCardV2(
}`} }`}
> >
<NetStatusDot tone={isUp ? "ok" : "fail"} /> <NetStatusDot tone={isUp ? "ok" : "fail"} />
{iface.status || "?"} {formatInterfaceStatus(iface.status, t)}
</span> </span>
</div> </div>
@@ -217,11 +254,11 @@ function renderPhysicalInterfaceCardV2(
{speedStr} {speedStr}
{maxSpeedStr && ( {maxSpeedStr && (
<span className="text-[11px] text-muted-foreground/70"> <span className="text-[11px] text-muted-foreground/70">
· max {maxSpeedStr} · {t("network.labels.maxSpeed", { speed: maxSpeedStr })}
</span> </span>
)} )}
</span> </span>
<span className="capitalize">{iface.duplex || "—"}</span> <span>{formatDuplex(iface.duplex, t)}</span>
</div> </div>
{/* Separator. */} {/* Separator. */}
@@ -246,7 +283,7 @@ function renderPhysicalInterfaceCardV2(
{bridgesUsing.length > 0 && ( {bridgesUsing.length > 0 && (
<div className="flex items-baseline justify-between gap-3"> <div className="flex items-baseline justify-between gap-3">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground shrink-0"> <span className="text-[11px] uppercase tracking-wider text-muted-foreground shrink-0">
Bridge {t("network.interfaceTypes.bridge")}
</span> </span>
<span className="font-medium text-right truncate font-mono text-xs text-cyan-400"> <span className="font-medium text-right truncate font-mono text-xs text-cyan-400">
{bridgesUsing.map((b) => `${b}`).join(" ")} {bridgesUsing.map((b) => `${b}`).join(" ")}
@@ -260,7 +297,7 @@ function renderPhysicalInterfaceCardV2(
has no previous sample to compute against. */} has no previous sample to compute against. */}
<div className="flex items-baseline justify-between gap-3"> <div className="flex items-baseline justify-between gap-3">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground flex items-center gap-1"> <span className="text-[11px] uppercase tracking-wider text-muted-foreground flex items-center gap-1">
<ArrowDown className="h-3 w-3 text-green-500" /> Received <ArrowDown className="h-3 w-3 text-green-500" /> {t("network.labels.received")}
</span> </span>
<span className="font-medium text-green-500 tabular-nums"> <span className="font-medium text-green-500 tabular-nums">
{iface.rx_Bps !== undefined ? formatRate(iface.rx_Bps) : "—"} {iface.rx_Bps !== undefined ? formatRate(iface.rx_Bps) : "—"}
@@ -268,7 +305,7 @@ function renderPhysicalInterfaceCardV2(
</div> </div>
<div className="flex items-baseline justify-between gap-3"> <div className="flex items-baseline justify-between gap-3">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground flex items-center gap-1"> <span className="text-[11px] uppercase tracking-wider text-muted-foreground flex items-center gap-1">
<ArrowUp className="h-3 w-3 text-blue-400" /> Sent <ArrowUp className="h-3 w-3 text-blue-400" /> {t("network.labels.sent")}
</span> </span>
<span className="font-medium text-blue-400 tabular-nums"> <span className="font-medium text-blue-400 tabular-nums">
{iface.tx_Bps !== undefined ? formatRate(iface.tx_Bps) : "—"} {iface.tx_Bps !== undefined ? formatRate(iface.tx_Bps) : "—"}
@@ -278,7 +315,7 @@ function renderPhysicalInterfaceCardV2(
<> <>
{totalErrors > 0 && ( {totalErrors > 0 && (
<div className="flex items-baseline justify-between gap-3"> <div className="flex items-baseline justify-between gap-3">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">Errors</span> <span className="text-[11px] uppercase tracking-wider text-muted-foreground">{t("network.labels.errors")}</span>
<span <span
className={`font-medium flex items-center gap-1.5 ${ className={`font-medium flex items-center gap-1.5 ${
netCounterTone(totalErrors) === "ok" netCounterTone(totalErrors) === "ok"
@@ -295,7 +332,7 @@ function renderPhysicalInterfaceCardV2(
)} )}
{totalDrops > 0 && ( {totalDrops > 0 && (
<div className="flex items-baseline justify-between gap-3"> <div className="flex items-baseline justify-between gap-3">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">Drops</span> <span className="text-[11px] uppercase tracking-wider text-muted-foreground">{t("network.labels.drops")}</span>
<span <span
className={`font-medium flex items-center gap-1.5 ${ className={`font-medium flex items-center gap-1.5 ${
netCounterTone(totalDrops) === "ok" netCounterTone(totalDrops) === "ok"
@@ -325,7 +362,7 @@ function renderPhysicalInterfaceCardV2(
)} )}
<span <span
className="text-blue-400 hover:text-blue-300 transition-colors text-base leading-none shrink-0" className="text-blue-400 hover:text-blue-300 transition-colors text-base leading-none shrink-0"
aria-label="View details" aria-label={t("network.actions.viewDetails")}
> >
</span> </span>
@@ -335,32 +372,32 @@ function renderPhysicalInterfaceCardV2(
} }
const getInterfaceTypeBadge = (type: string) => { const getInterfaceTypeBadge = (type: string, t: TFunction) => {
switch (type) { switch (type) {
case "physical": case "physical":
return { color: "bg-blue-500/10 text-blue-500 border-blue-500/20", label: "Physical" } return { color: "bg-blue-500/10 text-blue-500 border-blue-500/20", label: t("network.interfaceTypes.physical") }
case "bridge": case "bridge":
return { color: "bg-green-500/10 text-green-500 border-green-500/20", label: "Bridge" } return { color: "bg-green-500/10 text-green-500 border-green-500/20", label: t("network.interfaceTypes.bridge") }
case "bond": case "bond":
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "Bond" } return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: t("network.interfaceTypes.bond") }
case "vlan": case "vlan":
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "VLAN" } return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: t("network.interfaceTypes.vlan") }
case "vm_lxc": case "vm_lxc":
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: "Virtual" } return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: t("network.interfaceTypes.virtual") }
case "virtual": case "virtual":
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: "Virtual" } return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: t("network.interfaceTypes.virtual") }
default: default:
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: "Unknown" } return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: t("common.unknown") }
} }
} }
const getVMTypeBadge = (vmType: string | undefined) => { const getVMTypeBadge = (vmType: string | undefined, t: TFunction) => {
if (vmType === "lxc") { if (vmType === "lxc") {
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "LXC" } return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "LXC" }
} else if (vmType === "vm") { } else if (vmType === "vm") {
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "VM" } return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "VM" }
} }
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: "Unknown" } return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: t("common.unknown") }
} }
// Format bytes/sec into the canonical network unit ladder. // Format bytes/sec into the canonical network unit ladder.
@@ -396,8 +433,8 @@ const formatStorage = (bytes: number): string => {
return `${value.toFixed(decimals)} ${sizes[i]}` return `${value.toFixed(decimals)} ${sizes[i]}`
} }
const formatSpeed = (speed: number): string => { const formatSpeed = (speed: number, unavailable = "N/A"): string => {
if (speed === 0) return "N/A" if (speed === 0) return unavailable
if (speed >= 1000) return `${(speed / 1000).toFixed(1)} Gbps` if (speed >= 1000) return `${(speed / 1000).toFixed(1)} Gbps`
return `${speed} Mbps` return `${speed} Mbps`
} }
@@ -408,6 +445,7 @@ const fetcher = async (url: string): Promise<NetworkData> => {
export function NetworkMetrics() { export function NetworkMetrics() {
const t = useT()
const { const {
data: networkData, data: networkData,
error, error,
@@ -469,8 +507,8 @@ export function NetworkMetrics() {
<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 network data...</div> <div className="text-sm font-medium text-foreground">{t("network.loading.title")}</div>
<p className="text-xs text-muted-foreground">Scanning interfaces, bridges and traffic</p> <p className="text-xs text-muted-foreground">{t("network.loading.description")}</p>
</div> </div>
) )
} }
@@ -483,10 +521,10 @@ export function NetworkMetrics() {
<div className="flex items-center gap-3 text-red-600"> <div className="flex items-center gap-3 text-red-600">
<AlertCircle className="h-6 w-6" /> <AlertCircle className="h-6 w-6" />
<div> <div>
<div className="font-semibold text-lg mb-1">Flask Server Not Available</div> <div className="font-semibold text-lg mb-1">{t("network.errors.serverUnavailableTitle")}</div>
<div className="text-sm"> <div className="text-sm">
{error?.message || {error?.message ||
"Unable to connect to the Flask server. Please ensure the server is running and try again."} t("network.errors.serverUnavailableDescription")}
</div> </div>
</div> </div>
</div> </div>
@@ -514,14 +552,14 @@ export function NetworkMetrics() {
const avgPacketLoss = ((packetLossIn + packetLossOut) / 2).toFixed(2) const avgPacketLoss = ((packetLossIn + packetLossOut) / 2).toFixed(2)
// Determine health status // Determine health status
let healthStatus = "Healthy" let healthStatusKey = "network.status.healthy"
let healthColor = "bg-green-500/10 text-green-500 border-green-500/20" let healthColor = "bg-green-500/10 text-green-500 border-green-500/20"
if (Number.parseFloat(avgPacketLoss) > 5 || totalErrors > 1000) { if (Number.parseFloat(avgPacketLoss) > 5 || totalErrors > 1000) {
healthStatus = "Critical" healthStatusKey = "network.status.critical"
healthColor = "bg-red-500/10 text-red-500 border-red-500/20" healthColor = "bg-red-500/10 text-red-500 border-red-500/20"
} else if (Number.parseFloat(avgPacketLoss) >= 1 || totalErrors >= 100) { } else if (Number.parseFloat(avgPacketLoss) >= 1 || totalErrors >= 100) {
healthStatus = "Warning" healthStatusKey = "network.status.warning"
healthColor = "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" healthColor = "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
} }
@@ -545,24 +583,24 @@ export function NetworkMetrics() {
const topTraffic = (top.bytes_recv || 0) + (top.bytes_sent || 0) const topTraffic = (top.bytes_recv || 0) + (top.bytes_sent || 0)
return ifaceTraffic > topTraffic ? iface : top return ifaceTraffic > topTraffic ? iface : top
}, vmLxcInterfaces[0]) }, vmLxcInterfaces[0])
: { name: "No VM/LXC", type: "unknown", bytes_recv: 0, bytes_sent: 0, vm_name: "N/A" } : { name: t("network.empty.noVmLxc"), type: "unknown", bytes_recv: 0, bytes_sent: 0, vm_name: t("common.notAvailable") }
const topInterfaceTraffic = (topInterface.bytes_recv || 0) + (topInterface.bytes_sent || 0) const topInterfaceTraffic = (topInterface.bytes_recv || 0) + (topInterface.bytes_sent || 0)
const getTimeframeLabel = () => { const getTimeframeLabel = () => {
switch (timeframe) { switch (timeframe) {
case "hour": case "hour":
return "1 Hour" return t("network.timeframes.hour")
case "day": case "day":
return "24 Hours" return t("network.timeframes.day")
case "week": case "week":
return "7 Days" return t("network.timeframes.week")
case "month": case "month":
return "30 Days" return t("network.timeframes.month")
case "year": case "year":
return "1 Year" return t("network.timeframes.year")
default: default:
return "24 Hours" return t("network.timeframes.day")
} }
} }
@@ -571,25 +609,42 @@ export function NetworkMetrics() {
const getTimeframeShortLabel = () => { const getTimeframeShortLabel = () => {
switch (timeframe) { switch (timeframe) {
case "hour": case "hour":
return "Past 1 h" return t("network.timeframes.short.hour")
case "day": case "day":
return "Past 24 h" return t("network.timeframes.short.day")
case "week": case "week":
return "Past 7 d" return t("network.timeframes.short.week")
case "month": case "month":
return "Past 30 d" return t("network.timeframes.short.month")
case "year": case "year":
return "Past 1 y" return t("network.timeframes.short.year")
default: default:
return "Past 24 h" return t("network.timeframes.short.day")
} }
} }
const hostname = networkData.hostname || "N/A" const getLastTimeframeLabel = (value: "hour" | "day" | "week" | "month" | "year") => {
const domain = networkData.domain || "N/A" switch (value) {
case "hour":
return t("network.timeframes.last.hour")
case "day":
return t("network.timeframes.last.day")
case "week":
return t("network.timeframes.last.week")
case "month":
return t("network.timeframes.last.month")
case "year":
return t("network.timeframes.last.year")
default:
return t("network.timeframes.last.day")
}
}
const hostname = networkData.hostname || t("common.notAvailable")
const domain = networkData.domain || t("common.notAvailable")
const dnsServers = networkData.dns_servers || [] const dnsServers = networkData.dns_servers || []
const primaryDNS = dnsServers[0] || "N/A" const primaryDNS = dnsServers[0] || t("common.notAvailable")
const secondaryDNS = dnsServers[1] || "N/A" const secondaryDNS = dnsServers[1] || t("common.notAvailable")
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -606,7 +661,7 @@ export function NetworkMetrics() {
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex flex-col gap-0.5 min-w-0"> <div className="flex flex-col gap-0.5 min-w-0">
<CardTitle className="text-sm font-medium text-muted-foreground">Network Traffic</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">{t("network.cards.traffic")}</CardTitle>
<span className="text-[10px] text-muted-foreground/70 font-normal">{getTimeframeShortLabel()}</span> <span className="text-[10px] text-muted-foreground/70 font-normal">{getTimeframeShortLabel()}</span>
</div> </div>
<Activity className="h-4 w-4 text-muted-foreground flex-shrink-0" /> <Activity className="h-4 w-4 text-muted-foreground flex-shrink-0" />
@@ -615,13 +670,13 @@ export function NetworkMetrics() {
<div className="grid grid-cols-2 gap-3 mb-3"> <div className="grid grid-cols-2 gap-3 mb-3">
<div> <div>
<div className="text-xs font-medium text-muted-foreground mb-1"> <div className="text-xs font-medium text-muted-foreground mb-1">
<span className="text-green-500"></span> Down <span className="text-green-500"></span> {t("network.labels.down")}
</div> </div>
<div className="text-xl lg:text-2xl font-bold leading-tight text-green-500">{trafficInFormatted}</div> <div className="text-xl lg:text-2xl font-bold leading-tight text-green-500">{trafficInFormatted}</div>
</div> </div>
<div> <div>
<div className="text-xs font-medium text-muted-foreground mb-1"> <div className="text-xs font-medium text-muted-foreground mb-1">
<span className="text-blue-500"></span> Up <span className="text-blue-500"></span> {t("network.labels.up")}
</div> </div>
<div className="text-xl lg:text-2xl font-bold leading-tight text-blue-500">{trafficOutFormatted}</div> <div className="text-xl lg:text-2xl font-bold leading-tight text-blue-500">{trafficOutFormatted}</div>
</div> </div>
@@ -631,8 +686,8 @@ export function NetworkMetrics() {
<div style={{ width: `${upPct}%`, background: '#3b82f6' }}></div> <div style={{ width: `${upPct}%`, background: '#3b82f6' }}></div>
</div> </div>
<div className="mt-2 flex justify-between text-xs text-muted-foreground"> <div className="mt-2 flex justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-green-500"></span>Down {Math.round(downPct)}%</span> <span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-green-500"></span>{t("network.labels.down")} {Math.round(downPct)}%</span>
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-blue-500"></span>Up {Math.round(upPct)}%</span> <span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-blue-500"></span>{t("network.labels.up")} {Math.round(upPct)}%</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -642,7 +697,7 @@ export function NetworkMetrics() {
{/* ── Active Interfaces (preview restyle v2: revertido al original con title uppercase) ── */} {/* ── Active Interfaces (preview restyle v2: revertido al original con title uppercase) ── */}
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Active Interfaces</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">{t("network.cards.activeInterfaces")}</CardTitle>
<Network className="h-4 w-4 text-muted-foreground" /> <Network className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -651,14 +706,16 @@ export function NetworkMetrics() {
</div> </div>
<div className="flex flex-wrap items-center gap-2 mt-2"> <div className="flex flex-wrap items-center gap-2 mt-2">
<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">
Physical: {networkData.physical_active_count ?? 0}/{networkData.physical_total_count ?? 0} {t("network.interfaceTypes.physical")}: {networkData.physical_active_count ?? 0}/{networkData.physical_total_count ?? 0}
</Badge> </Badge>
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20"> <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
Bridges: {networkData.bridge_active_count ?? 0}/{networkData.bridge_total_count ?? 0} {t("network.interfaceTypes.bridges")}: {networkData.bridge_active_count ?? 0}/{networkData.bridge_total_count ?? 0}
</Badge> </Badge>
</div> </div>
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2">
{(networkData.physical_total_count ?? 0) + (networkData.bridge_total_count ?? 0)} total interfaces {t("network.summary.totalInterfaces", {
count: (networkData.physical_total_count ?? 0) + (networkData.bridge_total_count ?? 0),
})}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -666,8 +723,8 @@ export function NetworkMetrics() {
{/* ── Network Status (preview restyle: packet-loss highlight + 2x2 grid) ── */} {/* ── Network Status (preview restyle: packet-loss highlight + 2x2 grid) ── */}
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Network Status</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">{t("network.cards.status")}</CardTitle>
<Badge variant="outline" className={`${healthColor}`}>{healthStatus === 'Healthy' ? '✓ ' : ''}{healthStatus}</Badge> <Badge variant="outline" className={`${healthColor}`}>{healthStatusKey === "network.status.healthy" ? "✓ " : ""}{t(healthStatusKey)}</Badge>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{(() => { {(() => {
@@ -680,13 +737,13 @@ export function NetworkMetrics() {
return ( return (
<div className={`mb-3 text-xl lg:text-2xl font-bold ${lossColor} leading-none`}> <div className={`mb-3 text-xl lg:text-2xl font-bold ${lossColor} leading-none`}>
{avgPacketLoss}<span className="text-sm font-normal text-muted-foreground">% </span> {avgPacketLoss}<span className="text-sm font-normal text-muted-foreground">% </span>
<span className="text-sm font-normal text-muted-foreground">Packet Loss</span> <span className="text-sm font-normal text-muted-foreground">{t("network.labels.packetLoss")}</span>
</div> </div>
) )
})()} })()}
<div className="grid grid-cols-2 gap-x-3 gap-y-3 pt-3 border-t border-border/50 text-sm"> <div className="grid grid-cols-2 gap-x-3 gap-y-3 pt-3 border-t border-border/50 text-sm">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-muted-foreground">Hostname:</div> <div className="text-muted-foreground">{t("network.labels.hostname")}:</div>
<div className="font-medium font-mono truncate">{hostname}</div> <div className="font-medium font-mono truncate">{hostname}</div>
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
@@ -694,12 +751,12 @@ export function NetworkMetrics() {
<div className="font-medium font-mono truncate">{primaryDNS}</div> <div className="font-medium font-mono truncate">{primaryDNS}</div>
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="text-muted-foreground">Errors:</div> <div className="text-muted-foreground">{t("network.labels.errors")}:</div>
<div className="font-medium font-mono">{totalErrors}</div> <div className="font-medium font-mono">{totalErrors}</div>
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="text-muted-foreground">Domain:</div> <div className="text-muted-foreground">{t("network.labels.domain")}:</div>
<div className="font-medium font-mono truncate">{networkData.domain || '—'}</div> <div className="font-medium font-mono truncate">{domain}</div>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -711,7 +768,7 @@ export function NetworkMetrics() {
onClick={() => setLatencyModalOpen(true)} onClick={() => setLatencyModalOpen(true)}
> >
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Network Latency</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">{t("network.cards.latency")}</CardTitle>
<div className="flex items-center gap-1 text-muted-foreground"> <div className="flex items-center gap-1 text-muted-foreground">
<Timer className="h-4 w-4" /> <Timer className="h-4 w-4" />
<ChevronRight className="h-4 w-4 opacity-60" /> <ChevronRight className="h-4 w-4 opacity-60" />
@@ -734,9 +791,9 @@ export function NetworkMetrics() {
: "bg-red-500/10 text-red-500 border-red-500/20" : "bg-red-500/10 text-red-500 border-red-500/20"
} }
> >
{(latencyData?.stats?.current ?? 0) < 50 ? "Excellent" : {(latencyData?.stats?.current ?? 0) < 50 ? t("network.latency.status.excellent") :
(latencyData?.stats?.current ?? 0) < 100 ? "Good" : (latencyData?.stats?.current ?? 0) < 100 ? t("network.latency.status.good") :
(latencyData?.stats?.current ?? 0) < 200 ? "Fair" : "Poor"} (latencyData?.stats?.current ?? 0) < 200 ? t("network.latency.status.fair") : t("network.latency.status.poor")}
</Badge> </Badge>
</div> </div>
{/* Sparkline */} {/* Sparkline */}
@@ -765,7 +822,7 @@ export function NetworkMetrics() {
</div> </div>
)} )}
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Avg: {latencyData?.stats?.avg ?? 0}ms | Max: {latencyData?.stats?.max ?? 0}ms {t("network.labels.avg")}: {latencyData?.stats?.avg ?? 0}ms | {t("network.labels.max")}: {latencyData?.stats?.max ?? 0}ms
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -778,11 +835,11 @@ export function NetworkMetrics() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="hour">1 Hour</SelectItem> <SelectItem value="hour">{t("network.timeframes.hour")}</SelectItem>
<SelectItem value="day">24 Hours</SelectItem> <SelectItem value="day">{t("network.timeframes.day")}</SelectItem>
<SelectItem value="week">7 Days</SelectItem> <SelectItem value="week">{t("network.timeframes.week")}</SelectItem>
<SelectItem value="month">30 Days</SelectItem> <SelectItem value="month">{t("network.timeframes.month")}</SelectItem>
<SelectItem value="year">1 Year</SelectItem> <SelectItem value="year">{t("network.timeframes.year")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -792,7 +849,7 @@ export function NetworkMetrics() {
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-foreground flex items-center"> <CardTitle className="text-foreground flex items-center">
<Activity className="h-5 w-5 mr-2" /> <Activity className="h-5 w-5 mr-2" />
Network Traffic {t("network.cards.traffic")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -904,9 +961,12 @@ export function NetworkMetrics() {
<CardHeader> <CardHeader>
<CardTitle className="text-foreground flex items-center"> <CardTitle className="text-foreground flex items-center">
<Router className="h-5 w-5 mr-2" /> <Router className="h-5 w-5 mr-2" />
Physical Interfaces {t("network.sections.physicalInterfaces")}
<Badge variant="outline" className="ml-3 bg-blue-500/10 text-blue-500 border-blue-500/20"> <Badge variant="outline" className="ml-3 bg-blue-500/10 text-blue-500 border-blue-500/20">
{networkData.physical_active_count ?? 0}/{networkData.physical_total_count ?? 0} Active {t("network.summary.activeCount", {
active: networkData.physical_active_count ?? 0,
total: networkData.physical_total_count ?? 0,
})}
</Badge> </Badge>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@@ -916,7 +976,7 @@ export function NetworkMetrics() {
long interface names won't push others off-screen. */} long interface names won't push others off-screen. */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{networkData.physical_interfaces.map((iface) => {networkData.physical_interfaces.map((iface) =>
renderPhysicalInterfaceCardV2(iface, setSelectedInterface), renderPhysicalInterfaceCardV2(iface, setSelectedInterface, t),
)} )}
</div> </div>
</CardContent> </CardContent>
@@ -927,16 +987,19 @@ export function NetworkMetrics() {
<CardHeader> <CardHeader>
<CardTitle className="text-foreground flex items-center"> <CardTitle className="text-foreground flex items-center">
<Network className="h-5 w-5 mr-2" /> <Network className="h-5 w-5 mr-2" />
Bridge Interfaces {t("network.sections.bridgeInterfaces")}
<Badge variant="outline" className="ml-3 bg-green-500/10 text-green-500 border-green-500/20"> <Badge variant="outline" className="ml-3 bg-green-500/10 text-green-500 border-green-500/20">
{networkData.bridge_active_count ?? 0}/{networkData.bridge_total_count ?? 0} Active {t("network.summary.activeCount", {
active: networkData.bridge_active_count ?? 0,
total: networkData.bridge_total_count ?? 0,
})}
</Badge> </Badge>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{networkData.bridge_interfaces.map((interface_, index) => { {networkData.bridge_interfaces.map((interface_, index) => {
const typeBadge = getInterfaceTypeBadge(interface_.type) const typeBadge = getInterfaceTypeBadge(interface_.type, t)
return ( return (
<div <div
@@ -971,30 +1034,30 @@ export function NetworkMetrics() {
: "bg-red-500/10 text-red-500 border-red-500/20" : "bg-red-500/10 text-red-500 border-red-500/20"
} }
> >
{interface_.status.toUpperCase()} {formatInterfaceStatus(interface_.status, t)}
</Badge> </Badge>
</div> </div>
{/* Second row: Details - Responsive layout */} {/* Second row: Details - Responsive layout */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div> <div>
<div className="text-muted-foreground text-xs">IP Address</div> <div className="text-muted-foreground text-xs">{t("network.labels.ipAddress")}</div>
<div className="font-medium text-foreground font-mono text-sm truncate"> <div className="font-medium text-foreground font-mono text-sm truncate">
{interface_.addresses.length > 0 ? interface_.addresses[0].ip : "N/A"} {interface_.addresses.length > 0 ? interface_.addresses[0].ip : t("common.notAvailable")}
</div> </div>
</div> </div>
<div> <div>
<div className="text-muted-foreground text-xs">Speed</div> <div className="text-muted-foreground text-xs">{t("network.labels.speed")}</div>
<div className="font-medium text-foreground flex items-center gap-1"> <div className="font-medium text-foreground flex items-center gap-1">
<Zap className="h-3 w-3" /> <Zap className="h-3 w-3" />
{formatSpeed(interface_.speed)} {formatSpeed(interface_.speed, t("common.notAvailable"))}
</div> </div>
</div> </div>
<div> <div>
<div className="text-muted-foreground text-xs">Duplex</div> <div className="text-muted-foreground text-xs">{t("network.labels.duplex")}</div>
<div className="font-medium text-foreground text-xs capitalize">{interface_.duplex}</div> <div className="font-medium text-foreground text-xs">{formatDuplex(interface_.duplex, t)}</div>
</div> </div>
<div> <div>
@@ -1025,16 +1088,19 @@ export function NetworkMetrics() {
<CardHeader> <CardHeader>
<CardTitle className="text-foreground flex items-center"> <CardTitle className="text-foreground flex items-center">
<Network className="h-5 w-5 mr-2" /> <Network className="h-5 w-5 mr-2" />
VM & LXC Network Interfaces {t("network.sections.vmLxcInterfaces")}
<Badge variant="outline" className="ml-3 bg-orange-500/10 text-orange-500 border-orange-500/20"> <Badge variant="outline" className="ml-3 bg-orange-500/10 text-orange-500 border-orange-500/20">
{networkData.vm_lxc_active_count ?? 0} / {networkData.vm_lxc_total_count ?? 0} Active {t("network.summary.activeCount", {
active: networkData.vm_lxc_active_count ?? 0,
total: networkData.vm_lxc_total_count ?? 0,
})}
</Badge> </Badge>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{vmLxcInterfaces.map((interface_, index) => { {vmLxcInterfaces.map((interface_, index) => {
const vmTypeBadge = getVMTypeBadge(interface_.vm_type) const vmTypeBadge = getVMTypeBadge(interface_.vm_type, t)
return ( return (
<div <div
@@ -1062,7 +1128,7 @@ export function NetworkMetrics() {
: "bg-red-500/10 text-red-500 border-red-500/20" : "bg-red-500/10 text-red-500 border-red-500/20"
} }
> >
{interface_.status.toUpperCase()} {formatInterfaceStatus(interface_.status, t)}
</Badge> </Badge>
</div> </div>
@@ -1070,20 +1136,20 @@ export function NetworkMetrics() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div> <div>
<div className="text-sm text-muted-foreground">VMID</div> <div className="text-sm text-muted-foreground">VMID</div>
<div className="font-medium">{interface_.vmid ?? "N/A"}</div> <div className="font-medium">{interface_.vmid ?? t("common.notAvailable")}</div>
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Speed</div> <div className="text-sm text-muted-foreground">{t("network.labels.speed")}</div>
<div className="font-medium text-foreground flex items-center gap-1"> <div className="font-medium text-foreground flex items-center gap-1">
<Zap className="h-3 w-3" /> <Zap className="h-3 w-3" />
{formatSpeed(interface_.speed)} {formatSpeed(interface_.speed, t("common.notAvailable"))}
</div> </div>
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Duplex</div> <div className="text-sm text-muted-foreground">{t("network.labels.duplex")}</div>
<div className="font-medium text-foreground text-xs capitalize">{interface_.duplex}</div> <div className="font-medium text-foreground text-xs">{formatDuplex(interface_.duplex, t)}</div>
</div> </div>
<div> <div>
@@ -1114,10 +1180,10 @@ export function NetworkMetrics() {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Router className="h-5 w-5" /> <Router className="h-5 w-5" />
{selectedInterface?.name} - Interface Details {selectedInterface?.name} - {t("network.interfaceDetails.title")}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
View detailed information and network traffic statistics for this interface {t("network.interfaceDetails.description")}
</DialogDescription> </DialogDescription>
{selectedInterface?.status.toLowerCase() === "up" && selectedInterface?.vm_type !== "vm" && ( {selectedInterface?.status.toLowerCase() === "up" && selectedInterface?.vm_type !== "vm" && (
<div className="flex justify-end pt-2"> <div className="flex justify-end pt-2">
@@ -1126,11 +1192,11 @@ export function NetworkMetrics() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="hour">1 Hour</SelectItem> <SelectItem value="hour">{t("network.timeframes.hour")}</SelectItem>
<SelectItem value="day">24 Hours</SelectItem> <SelectItem value="day">{t("network.timeframes.day")}</SelectItem>
<SelectItem value="week">7 Days</SelectItem> <SelectItem value="week">{t("network.timeframes.week")}</SelectItem>
<SelectItem value="month">30 Days</SelectItem> <SelectItem value="month">{t("network.timeframes.month")}</SelectItem>
<SelectItem value="year">1 Year</SelectItem> <SelectItem value="year">{t("network.timeframes.year")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -1156,21 +1222,21 @@ export function NetworkMetrics() {
<> <>
{/* Basic Information */} {/* Basic Information */}
<div> <div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Basic Information</h3> <h3 className="text-sm font-semibold text-muted-foreground mb-3">{t("network.interfaceDetails.basicInformation")}</h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<div className="text-sm text-muted-foreground">Interface Name</div> <div className="text-sm text-muted-foreground">{t("network.labels.interfaceName")}</div>
<div className="font-medium">{displayInterface.name}</div> <div className="font-medium">{displayInterface.name}</div>
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Type</div> <div className="text-sm text-muted-foreground">{t("network.labels.type")}</div>
<Badge variant="outline" className={getInterfaceTypeBadge(displayInterface.type).color}> <Badge variant="outline" className={getInterfaceTypeBadge(displayInterface.type, t).color}>
{getInterfaceTypeBadge(displayInterface.type).label} {getInterfaceTypeBadge(displayInterface.type, t).label}
</Badge> </Badge>
</div> </div>
{displayInterface.type === "bridge" && displayInterface.bridge_physical_interface && ( {displayInterface.type === "bridge" && displayInterface.bridge_physical_interface && (
<div className="col-span-2"> <div className="col-span-2">
<div className="text-sm text-muted-foreground">Physical Interface</div> <div className="text-sm text-muted-foreground">{t("network.labels.physicalInterface")}</div>
<div className="font-medium text-blue-500 text-lg break-all"> <div className="font-medium text-blue-500 text-lg break-all">
{displayInterface.bridge_physical_interface} {displayInterface.bridge_physical_interface}
</div> </div>
@@ -1180,7 +1246,7 @@ export function NetworkMetrics() {
there never matched. */} there never matched. */}
{displayInterface.bridge_bond_slaves && displayInterface.bridge_bond_slaves.length > 0 && ( {displayInterface.bridge_bond_slaves && displayInterface.bridge_bond_slaves.length > 0 && (
<div className="mt-2"> <div className="mt-2">
<div className="text-sm text-muted-foreground mb-2">Bond Members</div> <div className="text-sm text-muted-foreground mb-2">{t("network.labels.bondMembers")}</div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{displayInterface.bridge_bond_slaves.map((slave, idx) => ( {displayInterface.bridge_bond_slaves.map((slave, idx) => (
<Badge <Badge
@@ -1198,19 +1264,19 @@ export function NetworkMetrics() {
)} )}
{displayInterface.type === "vm_lxc" && displayInterface.vm_name && ( {displayInterface.type === "vm_lxc" && displayInterface.vm_name && (
<div className="col-span-2"> <div className="col-span-2">
<div className="text-sm text-muted-foreground">VM/LXC Name</div> <div className="text-sm text-muted-foreground">{t("network.labels.vmLxcName")}</div>
<div className="font-medium text-orange-500 text-lg flex items-center gap-2"> <div className="font-medium text-orange-500 text-lg flex items-center gap-2">
{displayInterface.vm_name} {displayInterface.vm_name}
{displayInterface.vm_type && ( {displayInterface.vm_type && (
<Badge variant="outline" className={getVMTypeBadge(displayInterface.vm_type).color}> <Badge variant="outline" className={getVMTypeBadge(displayInterface.vm_type, t).color}>
{getVMTypeBadge(displayInterface.vm_type).label} {getVMTypeBadge(displayInterface.vm_type, t).label}
</Badge> </Badge>
)} )}
</div> </div>
</div> </div>
)} )}
<div> <div>
<div className="text-sm text-muted-foreground">Status</div> <div className="text-sm text-muted-foreground">{t("network.labels.status")}</div>
<Badge <Badge
variant="outline" variant="outline"
className={ className={
@@ -1219,16 +1285,16 @@ export function NetworkMetrics() {
: "bg-red-500/10 text-red-500 border-red-500/20" : "bg-red-500/10 text-red-500 border-red-500/20"
} }
> >
{displayInterface.status.toUpperCase()} {formatInterfaceStatus(displayInterface.status, t)}
</Badge> </Badge>
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Speed</div> <div className="text-sm text-muted-foreground">{t("network.labels.speed")}</div>
<div className="font-medium">{formatSpeed(displayInterface.speed)}</div> <div className="font-medium">{formatSpeed(displayInterface.speed, t("common.notAvailable"))}</div>
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Duplex</div> <div className="text-sm text-muted-foreground">{t("network.labels.duplex")}</div>
<div className="font-medium capitalize">{displayInterface.duplex}</div> <div className="font-medium">{formatDuplex(displayInterface.duplex, t)}</div>
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">MTU</div> <div className="text-sm text-muted-foreground">MTU</div>
@@ -1236,7 +1302,7 @@ export function NetworkMetrics() {
</div> </div>
{displayInterface.mac_address && ( {displayInterface.mac_address && (
<div className="col-span-2"> <div className="col-span-2">
<div className="text-sm text-muted-foreground">MAC Address</div> <div className="text-sm text-muted-foreground">{t("network.labels.macAddress")}</div>
<div className="font-medium font-mono">{displayInterface.mac_address}</div> <div className="font-medium font-mono">{displayInterface.mac_address}</div>
</div> </div>
)} )}
@@ -1246,13 +1312,13 @@ export function NetworkMetrics() {
{/* IP Addresses */} {/* IP Addresses */}
{displayInterface.addresses.length > 0 && ( {displayInterface.addresses.length > 0 && (
<div> <div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">IP Addresses</h3> <h3 className="text-sm font-semibold text-muted-foreground mb-3">{t("network.interfaceDetails.ipAddresses")}</h3>
<div className="space-y-2"> <div className="space-y-2">
{displayInterface.addresses.map((addr, idx) => ( {displayInterface.addresses.map((addr, idx) => (
<div key={idx} className="flex items-center justify-between p-3 rounded-lg bg-muted/50"> <div key={idx} className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<div> <div>
<div className="font-medium font-mono">{addr.ip}</div> <div className="font-medium font-mono">{addr.ip}</div>
<div className="text-sm text-muted-foreground">Netmask: {addr.netmask}</div> <div className="text-sm text-muted-foreground">{t("network.labels.netmask")}: {addr.netmask}</div>
</div> </div>
</div> </div>
))} ))}
@@ -1264,23 +1330,15 @@ export function NetworkMetrics() {
{displayInterface.status.toLowerCase() === "up" && displayInterface.vm_type !== "vm" ? ( {displayInterface.status.toLowerCase() === "up" && displayInterface.vm_type !== "vm" ? (
<div> <div>
<h3 className="text-sm font-semibold text-muted-foreground mb-4"> <h3 className="text-sm font-semibold text-muted-foreground mb-4">
Network Traffic Statistics ( {t("network.interfaceDetails.trafficStatistics", {
{modalTimeframe === "hour" timeframe: getLastTimeframeLabel(modalTimeframe),
? "Last Hour" })}
: modalTimeframe === "day"
? "Last 24 Hours"
: modalTimeframe === "week"
? "Last 7 Days"
: modalTimeframe === "month"
? "Last 30 Days"
: "Last Year"}
)
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{networkUnit === "Bits" ? "Bits Received" : "Bytes Received"} {networkUnit === "Bits" ? t("network.labels.bitsReceived") : t("network.labels.bytesReceived")}
</div> </div>
<div className="font-medium text-green-500 text-lg"> <div className="font-medium text-green-500 text-lg">
{formatNetworkTraffic( {formatNetworkTraffic(
@@ -1292,7 +1350,7 @@ export function NetworkMetrics() {
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{networkUnit === "Bits" ? "Bits Sent" : "Bytes Sent"} {networkUnit === "Bits" ? t("network.labels.bitsSent") : t("network.labels.bytesSent")}
</div> </div>
<div className="font-medium text-blue-500 text-lg"> <div className="font-medium text-blue-500 text-lg">
{formatNetworkTraffic( {formatNetworkTraffic(
@@ -1316,31 +1374,31 @@ export function NetworkMetrics() {
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-border"> <div className="grid grid-cols-2 gap-4 pt-4 border-t border-border">
<div> <div>
<div className="text-sm text-muted-foreground">Packets Received</div> <div className="text-sm text-muted-foreground">{t("network.labels.packetsReceived")}</div>
<div className="font-medium"> <div className="font-medium">
{displayInterface.packets_recv?.toLocaleString() || "N/A"} {displayInterface.packets_recv?.toLocaleString() || t("common.notAvailable")}
</div> </div>
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Packets Sent</div> <div className="text-sm text-muted-foreground">{t("network.labels.packetsSent")}</div>
<div className="font-medium"> <div className="font-medium">
{displayInterface.packets_sent?.toLocaleString() || "N/A"} {displayInterface.packets_sent?.toLocaleString() || t("common.notAvailable")}
</div> </div>
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Errors In</div> <div className="text-sm text-muted-foreground">{t("network.labels.errorsIn")}</div>
<div className="font-medium text-red-500">{displayInterface.errors_in || 0}</div> <div className="font-medium text-red-500">{displayInterface.errors_in || 0}</div>
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Errors Out</div> <div className="text-sm text-muted-foreground">{t("network.labels.errorsOut")}</div>
<div className="font-medium text-red-500">{displayInterface.errors_out || 0}</div> <div className="font-medium text-red-500">{displayInterface.errors_out || 0}</div>
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Drops In</div> <div className="text-sm text-muted-foreground">{t("network.labels.dropsIn")}</div>
<div className="font-medium text-yellow-500">{displayInterface.drops_in || 0}</div> <div className="font-medium text-yellow-500">{displayInterface.drops_in || 0}</div>
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Drops Out</div> <div className="text-sm text-muted-foreground">{t("network.labels.dropsOut")}</div>
<div className="font-medium text-yellow-500">{displayInterface.drops_out || 0}</div> <div className="font-medium text-yellow-500">{displayInterface.drops_out || 0}</div>
</div> </div>
</div> </div>
@@ -1348,11 +1406,11 @@ export function NetworkMetrics() {
</div> </div>
) : displayInterface.status.toLowerCase() === "up" && displayInterface.vm_type === "vm" ? ( ) : displayInterface.status.toLowerCase() === "up" && displayInterface.vm_type === "vm" ? (
<div> <div>
<h3 className="text-sm font-semibold text-muted-foreground mb-4">Traffic since last boot</h3> <h3 className="text-sm font-semibold text-muted-foreground mb-4">{t("network.interfaceDetails.trafficSinceBoot")}</h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{networkUnit === "Bits" ? "Bits Received" : "Bytes Received"} {networkUnit === "Bits" ? t("network.labels.bitsReceived") : t("network.labels.bytesReceived")}
</div> </div>
<div className="font-medium text-green-500 text-lg"> <div className="font-medium text-green-500 text-lg">
{formatNetworkTraffic(displayInterface.bytes_recv || 0, networkUnit)} {formatNetworkTraffic(displayInterface.bytes_recv || 0, networkUnit)}
@@ -1360,38 +1418,38 @@ export function NetworkMetrics() {
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{networkUnit === "Bits" ? "Bits Sent" : "Bytes Sent"} {networkUnit === "Bits" ? t("network.labels.bitsSent") : t("network.labels.bytesSent")}
</div> </div>
<div className="font-medium text-blue-500 text-lg"> <div className="font-medium text-blue-500 text-lg">
{formatNetworkTraffic(displayInterface.bytes_sent || 0, networkUnit)} {formatNetworkTraffic(displayInterface.bytes_sent || 0, networkUnit)}
</div> </div>
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Packets Received</div> <div className="text-sm text-muted-foreground">{t("network.labels.packetsReceived")}</div>
<div className="font-medium"> <div className="font-medium">
{displayInterface.packets_recv?.toLocaleString() || "N/A"} {displayInterface.packets_recv?.toLocaleString() || t("common.notAvailable")}
</div> </div>
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Packets Sent</div> <div className="text-sm text-muted-foreground">{t("network.labels.packetsSent")}</div>
<div className="font-medium"> <div className="font-medium">
{displayInterface.packets_sent?.toLocaleString() || "N/A"} {displayInterface.packets_sent?.toLocaleString() || t("common.notAvailable")}
</div> </div>
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Errors In</div> <div className="text-sm text-muted-foreground">{t("network.labels.errorsIn")}</div>
<div className="font-medium text-red-500">{displayInterface.errors_in || 0}</div> <div className="font-medium text-red-500">{displayInterface.errors_in || 0}</div>
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Errors Out</div> <div className="text-sm text-muted-foreground">{t("network.labels.errorsOut")}</div>
<div className="font-medium text-red-500">{displayInterface.errors_out || 0}</div> <div className="font-medium text-red-500">{displayInterface.errors_out || 0}</div>
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Drops In</div> <div className="text-sm text-muted-foreground">{t("network.labels.dropsIn")}</div>
<div className="font-medium text-yellow-500">{displayInterface.drops_in || 0}</div> <div className="font-medium text-yellow-500">{displayInterface.drops_in || 0}</div>
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Drops Out</div> <div className="text-sm text-muted-foreground">{t("network.labels.dropsOut")}</div>
<div className="font-medium text-yellow-500">{displayInterface.drops_out || 0}</div> <div className="font-medium text-yellow-500">{displayInterface.drops_out || 0}</div>
</div> </div>
</div> </div>
@@ -1399,9 +1457,9 @@ export function NetworkMetrics() {
) : ( ) : (
<div className="bg-muted/30 rounded-lg p-6 text-center"> <div className="bg-muted/30 rounded-lg p-6 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground mx-auto mb-3" /> <AlertCircle className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<h3 className="text-lg font-semibold text-foreground mb-2">Interface Inactive</h3> <h3 className="text-lg font-semibold text-foreground mb-2">{t("network.interfaceDetails.inactiveTitle")}</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
This interface is currently down. Network traffic statistics are not available. {t("network.interfaceDetails.inactiveDescription")}
</p> </p>
</div> </div>
)} )}
@@ -1409,12 +1467,12 @@ export function NetworkMetrics() {
{/* Bond Information */} {/* Bond Information */}
{displayInterface.type === "bond" && displayInterface.bond_slaves && ( {displayInterface.type === "bond" && displayInterface.bond_slaves && (
<div> <div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Bond Configuration</h3> <h3 className="text-sm font-semibold text-muted-foreground mb-3">{t("network.interfaceDetails.bondConfiguration")}</h3>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<div className="text-sm text-muted-foreground">Bonding Mode</div> <div className="text-sm text-muted-foreground">{t("network.labels.bondingMode")}</div>
<div className="font-medium"> <div className="font-medium">
{displayInterface.bond_mode || "Unknown"} {displayInterface.bond_mode || t("common.unknown")}
{displayInterface.bond_mode_detail && {displayInterface.bond_mode_detail &&
displayInterface.bond_mode_detail !== displayInterface.bond_mode && ( displayInterface.bond_mode_detail !== displayInterface.bond_mode && (
<span className="text-muted-foreground font-normal"> <span className="text-muted-foreground font-normal">
@@ -1427,13 +1485,13 @@ export function NetworkMetrics() {
{displayInterface.bond_active_slave && ( {displayInterface.bond_active_slave && (
<div> <div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{displayInterface.bond_supports_failover ? "Active Slave" : "Primary Slave"} {displayInterface.bond_supports_failover ? t("network.labels.activeSlave") : t("network.labels.primarySlave")}
</div> </div>
<div className="font-medium">{displayInterface.bond_active_slave}</div> <div className="font-medium">{displayInterface.bond_active_slave}</div>
</div> </div>
)} )}
<div> <div>
<div className="text-sm text-muted-foreground mb-2">Slave Interfaces</div> <div className="text-sm text-muted-foreground mb-2">{t("network.labels.slaveInterfaces")}</div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{displayInterface.bond_slaves.map((slave, idx) => { {displayInterface.bond_slaves.map((slave, idx) => {
// Only active-backup has a real standby. In every // Only active-backup has a real standby. In every
@@ -1456,7 +1514,7 @@ export function NetworkMetrics() {
return ( return (
<Badge key={idx} variant="outline" className={tone}> <Badge key={idx} variant="outline" className={tone}>
{slave} {slave}
{role && <span className="ml-1 opacity-70">· {role}</span>} {role && <span className="ml-1 opacity-70">· {t(`network.roles.${role}`)}</span>}
</Badge> </Badge>
) )
})} })}
@@ -1469,9 +1527,9 @@ export function NetworkMetrics() {
{/* Bridge Information */} {/* Bridge Information */}
{displayInterface.type === "bridge" && displayInterface.bridge_members && ( {displayInterface.type === "bridge" && displayInterface.bridge_members && (
<div> <div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Bridge Configuration</h3> <h3 className="text-sm font-semibold text-muted-foreground mb-3">{t("network.interfaceDetails.bridgeConfiguration")}</h3>
<div> <div>
<div className="text-sm text-muted-foreground mb-2">Virtual Member Interfaces</div> <div className="text-sm text-muted-foreground mb-2">{t("network.labels.virtualMemberInterfaces")}</div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{displayInterface.bridge_members.length > 0 ? ( {displayInterface.bridge_members.length > 0 ? (
displayInterface.bridge_members displayInterface.bridge_members
@@ -1494,7 +1552,7 @@ export function NetworkMetrics() {
</Badge> </Badge>
)) ))
) : ( ) : (
<div className="text-sm text-muted-foreground">No virtual members</div> <div className="text-sm text-muted-foreground">{t("network.empty.noVirtualMembers")}</div>
)} )}
</div> </div>
</div> </div>
@@ -5,6 +5,7 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { fetchApi } from "../lib/api-config" import { fetchApi } from "../lib/api-config"
import { getNetworkUnit } from "../lib/format-network" import { getNetworkUnit } from "../lib/format-network"
import { useT } from "../lib/i18n/provider"
interface NetworkMetricsData { interface NetworkMetricsData {
time: string time: string
@@ -50,6 +51,7 @@ export function NetworkTrafficChart({
refreshInterval = 60000, refreshInterval = 60000,
networkUnit: networkUnitProp, // Rename prop to avoid conflict networkUnit: networkUnitProp, // Rename prop to avoid conflict
}: NetworkTrafficChartProps) { }: NetworkTrafficChartProps) {
const t = useT()
const [data, setData] = useState<NetworkMetricsData[]>([]) const [data, setData] = useState<NetworkMetricsData[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -114,7 +116,7 @@ export function NetworkTrafficChart({
const result = await fetchApi<any>(apiPath) const result = await fetchApi<any>(apiPath)
if (!result.data || !Array.isArray(result.data)) { if (!result.data || !Array.isArray(result.data)) {
throw new Error("Invalid data format received from server") throw new Error(t("network.chart.invalidDataFormat"))
} }
if (result.data.length === 0) { if (result.data.length === 0) {
@@ -207,7 +209,7 @@ export function NetworkTrafficChart({
} }
} catch (err: any) { } catch (err: any) {
console.error("Error fetching network metrics:", err) console.error("Error fetching network metrics:", err)
setError(err.message || "Error loading metrics") setError(err.message || t("network.chart.loadError"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -255,7 +257,7 @@ export function NetworkTrafficChart({
if (error) { if (error) {
return ( return (
<div className="flex flex-col items-center justify-center h-[300px] gap-2"> <div className="flex flex-col items-center justify-center h-[300px] gap-2">
<p className="text-muted-foreground text-sm">Network metrics not available yet</p> <p className="text-muted-foreground text-sm">{t("overview.networkMetricsUnavailable")}</p>
<p className="text-xs text-red-500">{error}</p> <p className="text-xs text-red-500">{error}</p>
</div> </div>
) )
@@ -264,7 +266,7 @@ export function NetworkTrafficChart({
if (data.length === 0) { if (data.length === 0) {
return ( return (
<div className="flex items-center justify-center h-[300px]"> <div className="flex items-center justify-center h-[300px]">
<p className="text-muted-foreground text-sm">No network metrics available</p> <p className="text-muted-foreground text-sm">{t("overview.noNetworkMetrics")}</p>
</div> </div>
) )
} }
@@ -295,7 +297,7 @@ export function NetworkTrafficChart({
}} }}
domain={[0, "auto"]} domain={[0, "auto"]}
/> />
<Tooltip content={<CustomNetworkTooltip networkUnit={networkUnit} />} /> // Pass networkUnit to tooltip <Tooltip content={<CustomNetworkTooltip networkUnit={networkUnit} />} />
<Legend verticalAlign="top" height={36} content={renderLegend} /> <Legend verticalAlign="top" height={36} content={renderLegend} />
<Area <Area
type="monotone" type="monotone"
@@ -304,7 +306,7 @@ export function NetworkTrafficChart({
strokeWidth={2} strokeWidth={2}
fill="#10b981" fill="#10b981"
fillOpacity={0.3} fillOpacity={0.3}
name="Received" name={t("overview.receivedShort")}
hide={!visibleLines.netIn} hide={!visibleLines.netIn}
isAnimationActive={true} isAnimationActive={true}
animationDuration={300} animationDuration={300}
@@ -317,7 +319,7 @@ export function NetworkTrafficChart({
strokeWidth={2} strokeWidth={2}
fill="#3b82f6" fill="#3b82f6"
fillOpacity={0.3} fillOpacity={0.3}
name="Sent" name={t("overview.sentShort")}
hide={!visibleLines.netOut} hide={!visibleLines.netOut}
isAnimationActive={true} isAnimationActive={true}
animationDuration={300} animationDuration={300}
+48 -28
View File
@@ -7,12 +7,13 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
import { Loader2, TrendingUp, MemoryStick } from "lucide-react" import { Loader2, TrendingUp, MemoryStick } from "lucide-react"
import { useIsMobile } from "../hooks/use-mobile" import { useIsMobile } from "../hooks/use-mobile"
import { fetchApi } from "@/lib/api-config" import { fetchApi } from "@/lib/api-config"
import { useI18n } from "../lib/i18n/provider"
const TIMEFRAME_OPTIONS = [ const TIMEFRAME_OPTIONS = [
{ value: "hour", label: "1 Hour" }, { value: "hour", labelKey: "overview.timeframes.hour" },
{ value: "day", label: "24 Hours" }, { value: "day", labelKey: "overview.timeframes.day" },
{ value: "week", label: "7 Days" }, { value: "week", labelKey: "overview.timeframes.week" },
{ value: "month", label: "30 Days" }, { value: "month", labelKey: "overview.timeframes.month" },
] ]
interface NodeMetricsData { interface NodeMetricsData {
@@ -90,9 +91,11 @@ type PeriodStat = { avg: number; max: number; min: number } | null
function ChartStatsHeader({ function ChartStatsHeader({
stats, stats,
suffix = "", suffix = "",
labels,
}: { }: {
stats: PeriodStat stats: PeriodStat
suffix?: string suffix?: string
labels: { avg: string; max: string; min: string }
}) { }) {
if (!stats) return null if (!stats) return null
const fmt = (n: number) => (n >= 100 ? n.toFixed(0) : n.toFixed(1)) const fmt = (n: number) => (n >= 100 ? n.toFixed(0) : n.toFixed(1))
@@ -100,15 +103,15 @@ function ChartStatsHeader({
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm tabular-nums"> <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm tabular-nums">
<span> <span>
<span className="font-semibold text-foreground">{fmt(stats.avg)}{suffix}</span> <span className="font-semibold text-foreground">{fmt(stats.avg)}{suffix}</span>
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">avg</span> <span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">{labels.avg}</span>
</span> </span>
<span> <span>
<span className="font-semibold text-foreground">{fmt(stats.max)}{suffix}</span> <span className="font-semibold text-foreground">{fmt(stats.max)}{suffix}</span>
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">max</span> <span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">{labels.max}</span>
</span> </span>
<span> <span>
<span className="font-semibold text-foreground">{fmt(stats.min)}{suffix}</span> <span className="font-semibold text-foreground">{fmt(stats.min)}{suffix}</span>
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">min</span> <span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">{labels.min}</span>
</span> </span>
</div> </div>
) )
@@ -116,6 +119,7 @@ function ChartStatsHeader({
export function NodeMetricsCharts() { export function NodeMetricsCharts() {
const { language, t } = useI18n()
const [timeframe, setTimeframe] = useState("day") const [timeframe, setTimeframe] = useState("day")
const [data, setData] = useState<NodeMetricsData[]>([]) const [data, setData] = useState<NodeMetricsData[]>([])
// period_stats from the backend — computed over the raw RRD points // period_stats from the backend — computed over the raw RRD points
@@ -141,7 +145,7 @@ export function NodeMetricsCharts() {
useEffect(() => { useEffect(() => {
fetchMetrics() fetchMetrics()
}, [timeframe]) }, [timeframe, language])
const fetchMetrics = async () => { const fetchMetrics = async () => {
setLoading(true) setLoading(true)
@@ -153,7 +157,7 @@ export function NodeMetricsCharts() {
if (!result.data || !Array.isArray(result.data)) { if (!result.data || !Array.isArray(result.data)) {
console.error("Invalid data format - data is not an array:", result) console.error("Invalid data format - data is not an array:", result)
throw new Error("Invalid data format received from server") throw new Error(t("overview.invalidMetricsData"))
} }
if (result.data.length === 0) { if (result.data.length === 0) {
@@ -171,26 +175,26 @@ export function NodeMetricsCharts() {
let timeLabel = "" let timeLabel = ""
if (timeframe === "hour") { if (timeframe === "hour") {
timeLabel = date.toLocaleString("en-US", { timeLabel = date.toLocaleString(language, {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
hour12: false, hour12: false,
}) })
} else if (timeframe === "day") { } else if (timeframe === "day") {
timeLabel = date.toLocaleString("en-US", { timeLabel = date.toLocaleString(language, {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
hour12: false, hour12: false,
}) })
} else if (timeframe === "week") { } else if (timeframe === "week") {
timeLabel = date.toLocaleString("en-US", { timeLabel = date.toLocaleString(language, {
month: "short", month: "short",
day: "numeric", day: "numeric",
hour: "2-digit", hour: "2-digit",
hour12: false, hour12: false,
}) })
} else { } else {
timeLabel = date.toLocaleString("en-US", { timeLabel = date.toLocaleString(language, {
month: "short", month: "short",
day: "numeric", day: "numeric",
}) })
@@ -224,7 +228,7 @@ export function NodeMetricsCharts() {
// the user sees actionable text instead of a bare "503". // the user sees actionable text instead of a bare "503".
const body = err?.body const body = err?.body
setError({ setError({
headline: body?.error || err?.message || "Error loading metrics", headline: body?.error || err?.message || t("overview.metricsLoadError"),
details: body?.details, details: body?.details,
suggestion: body?.suggestion, suggestion: body?.suggestion,
}) })
@@ -311,7 +315,7 @@ export function NodeMetricsCharts() {
{error.suggestion && ( {error.suggestion && (
<div className="w-full mt-2"> <div className="w-full mt-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1"> <p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
Suggested fix on the Proxmox host {t("overview.suggestedFix")}
</p> </p>
<code className="block text-xs bg-background/60 border border-border rounded px-2 py-1.5 font-mono break-all"> <code className="block text-xs bg-background/60 border border-border rounded px-2 py-1.5 font-mono break-all">
{error.suggestion} {error.suggestion}
@@ -336,14 +340,14 @@ export function NodeMetricsCharts() {
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-center justify-center h-[300px]"> <div className="flex items-center justify-center h-[300px]">
<p className="text-muted-foreground text-sm">No metrics data available</p> <p className="text-muted-foreground text-sm">{t("overview.noMetricsData")}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-center justify-center h-[300px]"> <div className="flex items-center justify-center h-[300px]">
<p className="text-muted-foreground text-sm">No metrics data available</p> <p className="text-muted-foreground text-sm">{t("overview.noMetricsData")}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -363,7 +367,7 @@ export function NodeMetricsCharts() {
<SelectContent> <SelectContent>
{TIMEFRAME_OPTIONS.map((option) => ( {TIMEFRAME_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {t(option.labelKey)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -378,9 +382,17 @@ export function NodeMetricsCharts() {
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<CardTitle className="text-foreground flex items-center"> <CardTitle className="text-foreground flex items-center">
<TrendingUp className="h-5 w-5 mr-2" /> <TrendingUp className="h-5 w-5 mr-2" />
CPU Usage & Load Average {t("overview.cpuUsageLoadAverage")}
</CardTitle> </CardTitle>
<ChartStatsHeader stats={periodStats.cpu ?? null} suffix="%" /> <ChartStatsHeader
stats={periodStats.cpu ?? null}
suffix="%"
labels={{
avg: t("overview.stats.avg"),
max: t("overview.stats.max"),
min: t("overview.stats.min"),
}}
/>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="px-0 md:px-6"> <CardContent className="px-0 md:px-6">
@@ -414,7 +426,7 @@ export function NodeMetricsCharts() {
className="text-foreground" className="text-foreground"
tick={{ fill: "currentColor", fontSize: 12 }} tick={{ fill: "currentColor", fontSize: 12 }}
label={ label={
isMobile ? undefined : { value: "Load", angle: 90, position: "insideRight", fill: "currentColor" } isMobile ? undefined : { value: t("overview.loadAxis"), angle: 90, position: "insideRight", fill: "currentColor" }
} }
domain={[0, "dataMax"]} domain={[0, "dataMax"]}
/> />
@@ -428,7 +440,7 @@ export function NodeMetricsCharts() {
strokeWidth={2} strokeWidth={2}
fill="#3b82f6" fill="#3b82f6"
fillOpacity={0.3} fillOpacity={0.3}
name="CPU %" name={t("overview.cpuPercent")}
hide={!visibleLines.cpu.cpu} hide={!visibleLines.cpu.cpu}
/> />
<Area <Area
@@ -439,7 +451,7 @@ export function NodeMetricsCharts() {
strokeWidth={2} strokeWidth={2}
fill="#10b981" fill="#10b981"
fillOpacity={0.3} fillOpacity={0.3}
name="Load Avg" name={t("overview.loadAverage")}
hide={!visibleLines.cpu.load} hide={!visibleLines.cpu.load}
/> />
</AreaChart> </AreaChart>
@@ -453,9 +465,17 @@ export function NodeMetricsCharts() {
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<CardTitle className="text-foreground flex items-center"> <CardTitle className="text-foreground flex items-center">
<MemoryStick className="h-5 w-5 mr-2" /> <MemoryStick className="h-5 w-5 mr-2" />
Memory Usage {t("overview.memoryUsage")}
</CardTitle> </CardTitle>
<ChartStatsHeader stats={periodStats.memory_used ?? null} suffix=" GB" /> <ChartStatsHeader
stats={periodStats.memory_used ?? null}
suffix=" GB"
labels={{
avg: t("overview.stats.avg"),
max: t("overview.stats.max"),
min: t("overview.stats.min"),
}}
/>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="px-0 pr-2 md:px-6"> <CardContent className="px-0 pr-2 md:px-6">
@@ -490,7 +510,7 @@ export function NodeMetricsCharts() {
strokeWidth={2} strokeWidth={2}
fill="#3b82f6" fill="#3b82f6"
fillOpacity={0.1} fillOpacity={0.1}
name="Total" name={t("overview.total")}
hide={!visibleLines.memory.memoryTotal} hide={!visibleLines.memory.memoryTotal}
/> />
<Area <Area
@@ -500,7 +520,7 @@ export function NodeMetricsCharts() {
strokeWidth={2} strokeWidth={2}
fill="#10b981" fill="#10b981"
fillOpacity={0.3} fillOpacity={0.3}
name="Used" name={t("overview.used")}
hide={!visibleLines.memory.memoryUsed} hide={!visibleLines.memory.memoryUsed}
/> />
{/* Only show ZFS ARC if there's data */} {/* Only show ZFS ARC if there's data */}
@@ -525,7 +545,7 @@ export function NodeMetricsCharts() {
strokeWidth={2} strokeWidth={2}
fill="#06b6d4" fill="#06b6d4"
fillOpacity={0.3} fillOpacity={0.3}
name="Free" name={t("overview.free")}
hide={!visibleLines.memory.memoryFree} hide={!visibleLines.memory.memoryFree}
/> />
)} )}
File diff suppressed because it is too large Load Diff
+32 -36
View File
@@ -20,11 +20,12 @@ import {
} from "lucide-react" } from "lucide-react"
import Image from "next/image" import Image from "next/image"
import { Checkbox } from "./ui/checkbox" import { Checkbox } from "./ui/checkbox"
import { useT } from "../lib/i18n/provider"
interface OnboardingSlide { interface OnboardingSlide {
id: number id: number
title: string titleKey: string
description: string descriptionKey: string
image?: string image?: string
icon: React.ReactNode icon: React.ReactNode
gradient: string gradient: string
@@ -33,77 +34,70 @@ interface OnboardingSlide {
const slides: OnboardingSlide[] = [ const slides: OnboardingSlide[] = [
{ {
id: 0, id: 0,
title: "Welcome to ProxMenux Monitor!", titleKey: "onboarding.slides.welcome.title",
description: descriptionKey: "onboarding.slides.welcome.description",
"Your new monitoring tool for Proxmox. Discover all the features that will help you manage and supervise your infrastructure efficiently.",
icon: <Sparkles className="h-16 w-16" />, icon: <Sparkles className="h-16 w-16" />,
gradient: "from-blue-500 via-purple-500 to-pink-500", gradient: "from-blue-500 via-purple-500 to-pink-500",
}, },
{ {
id: 1, id: 1,
title: "System Overview", titleKey: "onboarding.slides.overview.title",
description: descriptionKey: "onboarding.slides.overview.description",
"Monitor your server's status in real-time: CPU, memory, temperature, system load and more. Everything in an intuitive and easy-to-understand dashboard.",
image: "/images/onboarding/imagen1.png", image: "/images/onboarding/imagen1.png",
icon: <LayoutDashboard className="h-12 w-12" />, icon: <LayoutDashboard className="h-12 w-12" />,
gradient: "from-blue-500 to-cyan-500", gradient: "from-blue-500 to-cyan-500",
}, },
{ {
id: 2, id: 2,
title: "Storage Management", titleKey: "onboarding.slides.storage.title",
description: descriptionKey: "onboarding.slides.storage.description",
"Visualize the status of all your disks and volumes. Detailed information on capacity, usage, SMART health, temperature and performance of each storage device.",
image: "/images/onboarding/imagen2.png", image: "/images/onboarding/imagen2.png",
icon: <HardDrive className="h-12 w-12" />, icon: <HardDrive className="h-12 w-12" />,
gradient: "from-cyan-500 to-teal-500", gradient: "from-cyan-500 to-teal-500",
}, },
{ {
id: 3, id: 3,
title: "Network Metrics", titleKey: "onboarding.slides.network.title",
description: descriptionKey: "onboarding.slides.network.description",
"Monitor network traffic in real-time. Bandwidth statistics, active interfaces, transfer speeds and historical usage graphs.",
image: "/images/onboarding/imagen3.png", image: "/images/onboarding/imagen3.png",
icon: <Network className="h-12 w-12" />, icon: <Network className="h-12 w-12" />,
gradient: "from-teal-500 to-green-500", gradient: "from-teal-500 to-green-500",
}, },
{ {
id: 4, id: 4,
title: "Virtual Machines & Containers", titleKey: "onboarding.slides.virtualMachines.title",
description: descriptionKey: "onboarding.slides.virtualMachines.description",
"Manage all your VMs and LXC containers from one place. Status, allocated resources, current usage and quick controls for each virtual machine.",
image: "/images/onboarding/imagen4.png", image: "/images/onboarding/imagen4.png",
icon: <Box className="h-12 w-12" />, icon: <Box className="h-12 w-12" />,
gradient: "from-green-500 to-emerald-500", gradient: "from-green-500 to-emerald-500",
}, },
{ {
id: 5, id: 5,
title: "Hardware Information", titleKey: "onboarding.slides.hardware.title",
description: descriptionKey: "onboarding.slides.hardware.description",
"Complete details of your server hardware: CPU, RAM, GPU, disks, network, UPS and more. Technical specifications, models, serial numbers and status of each component.",
image: "/images/onboarding/imagen5.png", image: "/images/onboarding/imagen5.png",
icon: <Cpu className="h-12 w-12" />, icon: <Cpu className="h-12 w-12" />,
gradient: "from-emerald-500 to-blue-500", gradient: "from-emerald-500 to-blue-500",
}, },
{ {
id: 6, id: 6,
title: "System Logs", titleKey: "onboarding.slides.logs.title",
description: descriptionKey: "onboarding.slides.logs.description",
"Access system logs in real-time. Filter by event type, search for specific errors and keep complete track of your server activity. Download the displayed logs for further analysis.",
image: "/images/onboarding/imagen6.png", image: "/images/onboarding/imagen6.png",
icon: <FileText className="h-12 w-12" />, icon: <FileText className="h-12 w-12" />,
gradient: "from-blue-500 to-indigo-500", gradient: "from-blue-500 to-indigo-500",
}, },
{ {
id: 7, id: 7,
title: "Ready for the Future!", titleKey: "onboarding.slides.future.title",
description: descriptionKey: "onboarding.slides.future.description",
"ProxMenux Monitor is prepared to receive updates and improvements that will be added gradually, improving the user experience and being able to execute ProxMenux functions from the web panel.",
icon: <Rocket className="h-16 w-16" />, icon: <Rocket className="h-16 w-16" />,
gradient: "from-indigo-500 via-purple-500 to-pink-500", gradient: "from-indigo-500 via-purple-500 to-pink-500",
}, },
] ]
export function OnboardingCarousel() { export function OnboardingCarousel() {
const t = useT()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [currentSlide, setCurrentSlide] = useState(0) const [currentSlide, setCurrentSlide] = useState(0)
const [direction, setDirection] = useState<"next" | "prev">("next") const [direction, setDirection] = useState<"next" | "prev">("next")
@@ -155,11 +149,13 @@ export function OnboardingCarousel() {
} }
const slide = slides[currentSlide] const slide = slides[currentSlide]
const slideTitle = t(slide.titleKey)
const slideDescription = t(slide.descriptionKey)
return ( return (
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl p-0 gap-0 overflow-hidden border-0 bg-transparent"> <DialogContent className="max-w-4xl p-0 gap-0 overflow-hidden border-0 bg-transparent">
<DialogTitle className="sr-only">ProxMenux Onboarding</DialogTitle> <DialogTitle className="sr-only">{t("onboarding.dialogTitle")}</DialogTitle>
<div className="relative bg-card rounded-lg overflow-hidden shadow-2xl"> <div className="relative bg-card rounded-lg overflow-hidden shadow-2xl">
<Button <Button
variant="ghost" variant="ghost"
@@ -181,7 +177,7 @@ export function OnboardingCarousel() {
<div className="relative w-full h-36 md:h-48 flex items-center justify-center px-4"> <div className="relative w-full h-36 md:h-48 flex items-center justify-center px-4">
<Image <Image
src={slide.image || "/placeholder.svg"} src={slide.image || "/placeholder.svg"}
alt={slide.title} alt={slideTitle}
width={600} width={600}
height={400} height={400}
className="rounded-lg shadow-2xl object-cover max-h-36 md:max-h-48" className="rounded-lg shadow-2xl object-cover max-h-36 md:max-h-48"
@@ -207,9 +203,9 @@ export function OnboardingCarousel() {
<div className="p-4 md:p-8 space-y-3 md:space-y-6 max-h-[60vh] md:max-h-none overflow-y-auto"> <div className="p-4 md:p-8 space-y-3 md:space-y-6 max-h-[60vh] md:max-h-none overflow-y-auto">
<div className="space-y-2 md:space-y-3"> <div className="space-y-2 md:space-y-3">
<h2 className="text-xl md:text-3xl font-bold text-foreground text-balance">{slide.title}</h2> <h2 className="text-xl md:text-3xl font-bold text-foreground text-balance">{slideTitle}</h2>
<p className="text-sm md:text-lg text-muted-foreground leading-relaxed text-pretty"> <p className="text-sm md:text-lg text-muted-foreground leading-relaxed text-pretty">
{slide.description} {slideDescription}
</p> </p>
</div> </div>
@@ -223,7 +219,7 @@ export function OnboardingCarousel() {
? "w-8 h-2.5 bg-blue-500 shadow-lg shadow-blue-500/50" ? "w-8 h-2.5 bg-blue-500 shadow-lg shadow-blue-500/50"
: "w-2.5 h-2.5 bg-muted-foreground/60 hover:bg-muted-foreground/80 border border-muted-foreground/40" : "w-2.5 h-2.5 bg-muted-foreground/60 hover:bg-muted-foreground/80 border border-muted-foreground/40"
}`} }`}
aria-label={`Go to slide ${index + 1}`} aria-label={t("onboarding.goToSlide", { number: index + 1 })}
/> />
))} ))}
</div> </div>
@@ -236,7 +232,7 @@ export function OnboardingCarousel() {
className="gap-2 w-full sm:w-auto text-sm" className="gap-2 w-full sm:w-auto text-sm"
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
Previous {t("onboarding.previous")}
</Button> </Button>
<div className="flex gap-2 w-full sm:w-auto"> <div className="flex gap-2 w-full sm:w-auto">
@@ -247,13 +243,13 @@ export function OnboardingCarousel() {
onClick={handleSkip} onClick={handleSkip}
className="flex-1 sm:flex-none bg-transparent text-sm" className="flex-1 sm:flex-none bg-transparent text-sm"
> >
Skip {t("onboarding.skip")}
</Button> </Button>
<Button <Button
onClick={handleNext} onClick={handleNext}
className="gap-2 bg-blue-500 hover:bg-blue-600 flex-1 sm:flex-none text-sm" className="gap-2 bg-blue-500 hover:bg-blue-600 flex-1 sm:flex-none text-sm"
> >
Next {t("onboarding.next")}
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
</> </>
@@ -262,7 +258,7 @@ export function OnboardingCarousel() {
onClick={handleNext} onClick={handleNext}
className="gap-2 bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 w-full sm:w-auto text-sm" className="gap-2 bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 w-full sm:w-auto text-sm"
> >
Get Started! {t("onboarding.getStarted")}
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
</Button> </Button>
)} )}
@@ -279,7 +275,7 @@ export function OnboardingCarousel() {
htmlFor="dont-show-again" htmlFor="dont-show-again"
className="text-xs md:text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none" className="text-xs md:text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none"
> >
Don't show this again {t("onboarding.dontShowAgain")}
</label> </label>
</div> </div>
</div> </div>
+26 -16
View File
@@ -7,6 +7,7 @@ import { ScrollArea } from "./ui/scroll-area"
import { Cpu, MemoryStick, Search } from "lucide-react" import { Cpu, MemoryStick, Search } from "lucide-react"
import { fetchApi } from "@/lib/api-config" import { fetchApi } from "@/lib/api-config"
import { ProcessInfoModal } from "./process-info-modal" import { ProcessInfoModal } from "./process-info-modal"
import { useT } from "@/lib/i18n/provider"
interface ProcessInfo { interface ProcessInfo {
pid: number pid: number
@@ -61,6 +62,7 @@ const formatRss = (kb: number): string => {
} }
export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailModalProps) { export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailModalProps) {
const t = useT()
const [data, setData] = useState<ProcessesResponse | null>(null) const [data, setData] = useState<ProcessesResponse | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -74,7 +76,7 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
const res = await fetchApi<ProcessesResponse>(`/api/processes?sort=${sort}&limit=${FETCH_LIMIT}`) const res = await fetchApi<ProcessesResponse>(`/api/processes?sort=${sort}&limit=${FETCH_LIMIT}`)
setData(res) setData(res)
} catch (e: any) { } catch (e: any) {
setError(e?.message || "Failed to fetch processes") setError(e?.message || t("details.processes.loadFailed"))
} finally { } finally {
if (!silent) setLoading(false) if (!silent) setLoading(false)
} }
@@ -110,11 +112,11 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
const filtered = filter ? allMatches : allMatches.slice(0, DISPLAY_LIMIT) const filtered = filter ? allMatches : allMatches.slice(0, DISPLAY_LIMIT)
const Icon = sort === "cpu" ? Cpu : MemoryStick const Icon = sort === "cpu" ? Cpu : MemoryStick
const title = sort === "cpu" ? "Top processes by CPU" : "Top processes by Memory" const title = sort === "cpu" ? t("details.processes.topByCpu") : t("details.processes.topByMemory")
const description = const description =
sort === "cpu" sort === "cpu"
? "Current CPU usage per process, as a fraction of the host's total CPU — same scale as the CPU Usage card above. Refreshes every 3 s while open." ? t("details.processes.cpuDescription")
: "Current resident memory per process. Refreshes every 3 s while open." : t("details.processes.memoryDescription")
// Accent palette matched to the Overview cards: CPU Usage donut uses // Accent palette matched to the Overview cards: CPU Usage donut uses
// blue (#3b82f6), Memory cached uses rgba(99,102,241,0.55) — we keep // blue (#3b82f6), Memory cached uses rgba(99,102,241,0.55) — we keep
@@ -160,7 +162,7 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
<div className="relative mb-2"> <div className="relative mb-2">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder="Filter by command line, user or PID..." placeholder={t("details.processes.filterPlaceholder")}
value={filter} value={filter}
onChange={(e) => setFilter(e.target.value)} onChange={(e) => setFilter(e.target.value)}
className="pl-8 h-8 text-sm" className="pl-8 h-8 text-sm"
@@ -176,16 +178,18 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
<div <div
className={`grid items-center gap-x-3 sm:gap-x-6 px-3 py-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground border-b border-border bg-card sticky top-0 z-10 ${gridCols}`} className={`grid items-center gap-x-3 sm:gap-x-6 px-3 py-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground border-b border-border bg-card sticky top-0 z-10 ${gridCols}`}
> >
<div className="hidden sm:block">PID</div> <div className="hidden sm:block">{t("details.processes.pid")}</div>
<div className="hidden sm:block truncate">User</div> <div className="hidden sm:block truncate">{t("details.processes.user")}</div>
<div>Command</div> <div>{t("details.processes.command")}</div>
<div className={`text-right ${sort === "cpu" ? accent.text : ""}`}>CPU %</div> <div className={`text-right ${sort === "cpu" ? accent.text : ""}`}>{t("details.processes.cpuPercent")}</div>
<div className={`text-right ${sort === "mem" ? accent.text : ""}`}>{sort === "mem" ? "Memory" : "Mem %"}</div> <div className={`text-right ${sort === "mem" ? accent.text : ""}`}>
{sort === "mem" ? t("details.processes.memory") : t("details.processes.memPercent")}
</div>
</div> </div>
{filtered.length === 0 && !loading ? ( {filtered.length === 0 && !loading ? (
<div className="text-center py-8 text-sm text-muted-foreground"> <div className="text-center py-8 text-sm text-muted-foreground">
No processes match the filter {t("details.processes.noMatches")}
</div> </div>
) : ( ) : (
filtered.map((p) => { filtered.map((p) => {
@@ -228,8 +232,8 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
where avg and now match within sampler where avg and now match within sampler
noise. */} noise. */}
{typeof p.cpu_avg === "number" && p.cpu_avg >= 0.5 && p.cpu_avg > p.cpu * 1.5 && ( {typeof p.cpu_avg === "number" && p.cpu_avg >= 0.5 && p.cpu_avg > p.cpu * 1.5 && (
<span className="font-mono text-[10px] text-amber-400" title="Average CPU% across this process's lifetime — useful for finding long-running idle baselines"> <span className="font-mono text-[10px] text-amber-400" title={t("details.processes.lifetimeAverageTitle")}>
avg {p.cpu_avg.toFixed(1)} {t("details.processes.averageShort")} {p.cpu_avg.toFixed(1)}
</span> </span>
)} )}
<div className="w-full h-1 bg-muted rounded-full overflow-hidden"> <div className="w-full h-1 bg-muted rounded-full overflow-hidden">
@@ -261,9 +265,15 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
{data?.captured_at && ( {data?.captured_at && (
<div className="text-[10px] text-muted-foreground text-right mt-1"> <div className="text-[10px] text-muted-foreground text-right mt-1">
Captured {new Date(data.captured_at * 1000).toLocaleTimeString()} · {filter {t("details.processes.captured", { time: new Date(data.captured_at * 1000).toLocaleTimeString() })} · {filter
? `${allMatches.length} match${allMatches.length === 1 ? '' : 'es'} of ${data.processes.length} processes` ? t(allMatches.length === 1 ? "details.processes.matchCount" : "details.processes.matchesCount", {
: `Top ${filtered.length} of ${data.processes.length} processes`} count: allMatches.length,
total: data.processes.length,
})
: t("details.processes.topCount", {
shown: filtered.length,
total: data.processes.length,
})}
</div> </div>
)} )}
</DialogContent> </DialogContent>
+47 -45
View File
@@ -5,6 +5,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
import { ScrollArea } from "./ui/scroll-area" import { ScrollArea } from "./ui/scroll-area"
import { Activity, FileText, HardDrive, Clock, Info } from "lucide-react" import { Activity, FileText, HardDrive, Clock, Info } from "lucide-react"
import { fetchApi } from "@/lib/api-config" import { fetchApi } from "@/lib/api-config"
import { useI18n } from "../lib/i18n/provider"
interface ProcessDetail { interface ProcessDetail {
pid: number pid: number
@@ -59,22 +60,24 @@ const formatBytes = (b: number | null | undefined): string => {
// Linux process states from /proc/<pid>/status. The first char of `State:` // Linux process states from /proc/<pid>/status. The first char of `State:`
// is the canonical letter — the rest of the field is a human label like // is the canonical letter — the rest of the field is a human label like
// "(running)". We expand the bare letter to something readable. // "(running)". We expand the bare letter to something readable.
const stateLabel = (state: string): string => { const stateLabel = (state: string, t: (key: string) => string): string => {
const letter = (state || "").trim().charAt(0).toUpperCase() const rawLetter = (state || "").trim().charAt(0)
const letter = rawLetter.toUpperCase()
const map: Record<string, string> = { const map: Record<string, string> = {
R: "Running", R: "running",
S: "Sleeping", S: "sleeping",
D: "Disk wait", D: "diskWait",
Z: "Zombie", Z: "zombie",
T: "Stopped", T: rawLetter === "t" ? "tracingStop" : "stopped",
t: "Tracing stop", X: "dead",
X: "Dead", I: "idle",
I: "Idle",
} }
return map[letter] || state || "—" const key = map[letter]
return key ? t(`details.processInfo.states.${key}`) : state || "—"
} }
export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps) { export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps) {
const { language, t } = useI18n()
const [data, setData] = useState<ProcessDetail | null>(null) const [data, setData] = useState<ProcessDetail | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -105,7 +108,7 @@ export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps
setExited(true) setExited(true)
stopPolling() stopPolling()
} else { } else {
setError(e?.message || "Failed to fetch process") setError(t("details.processInfo.fetchFailed"))
} }
} finally { } finally {
if (!silent) setLoading(false) if (!silent) setLoading(false)
@@ -136,15 +139,13 @@ export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps
className="w-2 h-2 rounded-full flex-shrink-0" className="w-2 h-2 rounded-full flex-shrink-0"
style={{ background: accent.dot }} style={{ background: accent.dot }}
/> />
<span className="truncate font-mono text-base">{data?.comm || "Process"}</span> <span className="truncate font-mono text-base">{data?.comm || t("details.processInfo.titleFallback")}</span>
<span className="text-xs text-muted-foreground font-mono flex-shrink-0">PID {pid}</span> <span className="text-xs text-muted-foreground font-mono flex-shrink-0">PID {pid}</span>
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs"> <DialogDescription className="text-xs">
{exited ? ( {exited
<>Last snapshot from <span className="font-mono">/proc/{pid}</span> before the process finished.</> ? t("details.processInfo.descriptionExited", { pid: pid ?? "" })
) : ( : t("details.processInfo.descriptionLive", { pid: pid ?? "", seconds: REFRESH_MS / 1000 })}
<>Live snapshot from <span className="font-mono">/proc/{pid}</span>. Auto-refreshes every {REFRESH_MS / 1000} s while open.</>
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -154,9 +155,9 @@ export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps
<div className="flex items-start gap-2 px-3 py-2 rounded-md border border-amber-500/30 bg-amber-500/10 text-xs text-amber-300"> <div className="flex items-start gap-2 px-3 py-2 rounded-md border border-amber-500/30 bg-amber-500/10 text-xs text-amber-300">
<Info className="h-4 w-4 flex-shrink-0 mt-0.5" /> <Info className="h-4 w-4 flex-shrink-0 mt-0.5" />
<div> <div>
<div className="font-medium text-amber-200">This process has finished</div> <div className="font-medium text-amber-200">{t("details.processInfo.finishedTitle")}</div>
<div className="text-amber-300/80 mt-0.5"> <div className="text-amber-300/80 mt-0.5">
It was likely a short-lived helper (a script, a <span className="font-mono">pct exec</span>, or a one-shot command) that completed while the modal was open. The data below is the last snapshot captured before it exited not a stale or broken read. {t("details.processInfo.finishedDescription")}
</div> </div>
</div> </div>
</div> </div>
@@ -166,44 +167,44 @@ export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps
<div className="text-sm text-red-500 py-4">{error}</div> <div className="text-sm text-red-500 py-4">{error}</div>
) : !data ? ( ) : !data ? (
<div className="text-sm text-muted-foreground py-8 text-center"> <div className="text-sm text-muted-foreground py-8 text-center">
{loading ? "Loading" : "—"} {loading ? t("details.processInfo.loading") : "—"}
</div> </div>
) : ( ) : (
<ScrollArea className={`max-h-[480px] pr-2 ${exited ? "opacity-75" : ""}`}> <ScrollArea className={`max-h-[480px] pr-2 ${exited ? "opacity-75" : ""}`}>
<div className="space-y-4"> <div className="space-y-4">
{/* Overview */} {/* Overview */}
<Section icon={<Activity className="h-4 w-4 text-blue-400" />} title="Overview"> <Section icon={<Activity className="h-4 w-4 text-blue-400" />} title={t("details.processInfo.sections.overview")}>
<Row label="State" value={exited ? "Exited" : stateLabel(data.state)} /> <Row label={t("details.processInfo.labels.state")} value={exited ? t("details.processInfo.states.exited") : stateLabel(data.state, t)} />
<Row label="Parent" value={data.parent_name ? `${data.parent_name} (PID ${data.ppid})` : `PID ${data.ppid}`} mono /> <Row label={t("details.processInfo.labels.parent")} value={data.parent_name ? `${data.parent_name} (PID ${data.ppid})` : `PID ${data.ppid}`} mono />
<Row label="Threads" value={String(data.threads)} mono /> <Row label={t("details.processInfo.labels.threads")} value={String(data.threads)} mono />
<Row label="Open FDs" value={data.fd_count != null ? String(data.fd_count) : "—"} mono /> <Row label={t("details.processInfo.labels.openFds")} value={data.fd_count != null ? String(data.fd_count) : "—"} mono />
<Row label="User" value={`${data.user} (${data.uid})`} mono /> <Row label={t("details.processInfo.labels.user")} value={`${data.user} (${data.uid})`} mono />
<Row label="Group" value={`${data.group} (${data.gid})`} mono /> <Row label={t("details.processInfo.labels.group")} value={`${data.group} (${data.gid})`} mono />
</Section> </Section>
{/* Resources */} {/* Resources */}
<Section icon={<HardDrive className="h-4 w-4 text-amber-400" />} title="Resources"> <Section icon={<HardDrive className="h-4 w-4 text-amber-400" />} title={t("details.processInfo.sections.resources")}>
<Row label="CPU" value={`${data.cpu.toFixed(1)} %`} mono valueClass={accent.text} /> <Row label={t("details.processInfo.labels.cpu")} value={`${data.cpu.toFixed(1)} %`} mono valueClass={accent.text} />
<Row label="Memory" value={`${data.mem.toFixed(1)} %`} mono valueClass={accent.text} /> <Row label={t("details.processInfo.labels.memory")} value={`${data.mem.toFixed(1)} %`} mono valueClass={accent.text} />
<Row label="Resident (RSS)" value={formatKb(data.vm_rss_kb)} mono /> <Row label={t("details.processInfo.labels.residentRss")} value={formatKb(data.vm_rss_kb)} mono />
<Row label="Virtual size" value={formatKb(data.vm_size_kb)} mono /> <Row label={t("details.processInfo.labels.virtualSize")} value={formatKb(data.vm_size_kb)} mono />
<Row label="Swap" value={formatKb(data.vm_swap_kb)} mono /> <Row label={t("details.processInfo.labels.swap")} value={formatKb(data.vm_swap_kb)} mono />
<Row label="I/O read" value={formatBytes(data.io_read_bytes)} mono /> <Row label={t("details.processInfo.labels.ioRead")} value={formatBytes(data.io_read_bytes)} mono />
<Row label="I/O write" value={formatBytes(data.io_write_bytes)} mono /> <Row label={t("details.processInfo.labels.ioWrite")} value={formatBytes(data.io_write_bytes)} mono />
</Section> </Section>
{/* Command */} {/* Command */}
<Section icon={<FileText className="h-4 w-4 text-purple-400" />} title="Command"> <Section icon={<FileText className="h-4 w-4 text-purple-400" />} title={t("details.processInfo.sections.command")}>
<Row label="Name" value={data.comm} mono /> <Row label={t("details.processInfo.labels.name")} value={data.comm} mono />
<Row label="Command line" value={data.cmdline || data.comm} mono wrap /> <Row label={t("details.processInfo.labels.commandLine")} value={data.cmdline || data.comm} mono wrap />
<Row label="Executable" value={data.exe || "—"} mono wrap /> <Row label={t("details.processInfo.labels.executable")} value={data.exe || "—"} mono wrap />
<Row label="Working dir" value={data.cwd || "—"} mono wrap /> <Row label={t("details.processInfo.labels.workingDir")} value={data.cwd || "—"} mono wrap />
</Section> </Section>
{/* Times */} {/* Times */}
<Section icon={<Clock className="h-4 w-4 text-emerald-400" />} title="Lifetime"> <Section icon={<Clock className="h-4 w-4 text-emerald-400" />} title={t("details.processInfo.sections.lifetime")}>
<Row label="Started" value={data.start_time || "—"} mono /> <Row label={t("details.processInfo.labels.started")} value={data.start_time || "—"} mono />
<Row label="Running for" value={data.elapsed || "—"} mono /> <Row label={t("details.processInfo.labels.runningFor")} value={data.elapsed || "—"} mono />
</Section> </Section>
</div> </div>
</ScrollArea> </ScrollArea>
@@ -211,7 +212,8 @@ export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps
{data?.captured_at && ( {data?.captured_at && (
<div className="text-[10px] text-muted-foreground text-right mt-1"> <div className="text-[10px] text-muted-foreground text-right mt-1">
{exited ? "Last seen" : "Captured"} {new Date(data.captured_at * 1000).toLocaleTimeString()} {exited ? t("details.processInfo.lastSeen") : t("details.processInfo.captured")}{" "}
{new Date(data.captured_at * 1000).toLocaleTimeString(language)}
{error ? ` · ${error}` : ""} {error ? ` · ${error}` : ""}
</div> </div>
)} )}
+26 -28
View File
@@ -19,6 +19,7 @@ import { Button } from "./ui/button"
import { Input } from "./ui/input" import { Input } from "./ui/input"
import { Label } from "./ui/label" import { Label } from "./ui/label"
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config" import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface ProfileData { interface ProfileData {
success: boolean success: boolean
@@ -51,6 +52,7 @@ interface ProfileProps {
* the operator hits Edit to start typing. * the operator hits Edit to start typing.
*/ */
export function Profile({ onOpenSecurity }: ProfileProps) { export function Profile({ onOpenSecurity }: ProfileProps) {
const t = useT()
const [profile, setProfile] = useState<ProfileData | null>(null) const [profile, setProfile] = useState<ProfileData | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -146,7 +148,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
body: JSON.stringify({ display_name: displayDraft }), body: JSON.stringify({ display_name: displayDraft }),
}) })
if (!data.success) { if (!data.success) {
setError(data.message || "Failed to save display name") setError(data.message || t("profilePage.errors.saveDisplayNameFailed"))
return return
} }
setProfile(data) setProfile(data)
@@ -182,7 +184,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
}) })
const data: ProfileData = await r.json().catch(() => ({ success: false })) const data: ProfileData = await r.json().catch(() => ({ success: false }))
if (!r.ok || !data.success) { if (!r.ok || !data.success) {
setAvatarError(data.message || `Upload failed (${r.status})`) setAvatarError(data.message || t("profilePage.errors.uploadFailed", { status: r.status }))
return return
} }
setProfile(data) setProfile(data)
@@ -212,7 +214,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
}) })
const data: ProfileData = await r.json().catch(() => ({ success: false })) const data: ProfileData = await r.json().catch(() => ({ success: false }))
if (!r.ok || !data.success) { if (!r.ok || !data.success) {
setAvatarError(data.message || `Delete failed (${r.status})`) setAvatarError(data.message || t("profilePage.errors.deleteFailed", { status: r.status }))
return return
} }
setProfile(data) setProfile(data)
@@ -232,7 +234,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
<Card> <Card>
<CardContent className="p-8 flex items-center justify-center text-muted-foreground"> <CardContent className="p-8 flex items-center justify-center text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mr-2" /> <Loader2 className="h-4 w-4 animate-spin mr-2" />
Loading profile {t("profilePage.loading")}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -247,7 +249,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
<div className="flex items-start gap-2 text-red-500"> <div className="flex items-start gap-2 text-red-500">
<AlertCircle className="h-5 w-5 shrink-0 mt-0.5" /> <AlertCircle className="h-5 w-5 shrink-0 mt-0.5" />
<div> <div>
<div className="font-medium">Failed to load profile</div> <div className="font-medium">{t("profilePage.loadFailed")}</div>
<div className="text-xs text-muted-foreground mt-1 break-all">{error}</div> <div className="text-xs text-muted-foreground mt-1 break-all">{error}</div>
</div> </div>
</div> </div>
@@ -268,13 +270,13 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
<div className="flex items-center justify-between gap-2 flex-wrap"> <div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<UserIcon className="h-5 w-5 text-cyan-500" /> <UserIcon className="h-5 w-5 text-cyan-500" />
<CardTitle>User Profile</CardTitle> <CardTitle>{t("profilePage.title")}</CardTitle>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{savedDisplay && ( {savedDisplay && (
<span className="flex items-center gap-1 text-xs text-green-500"> <span className="flex items-center gap-1 text-xs text-green-500">
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5" />
Saved {t("status.saved")}
</span> </span>
)} )}
{displayEditMode ? ( {displayEditMode ? (
@@ -286,7 +288,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
disabled={savingDisplay} disabled={savingDisplay}
className="h-7 text-xs" className="h-7 text-xs"
> >
Cancel {t("actions.cancel")}
</Button> </Button>
<Button <Button
size="sm" size="sm"
@@ -299,7 +301,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
) : ( ) : (
<CheckCircle2 className="h-3 w-3 mr-1.5" /> <CheckCircle2 className="h-3 w-3 mr-1.5" />
)} )}
Save {t("actions.save")}
</Button> </Button>
</> </>
) : ( ) : (
@@ -310,14 +312,13 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
className="h-7 text-xs" className="h-7 text-xs"
> >
<Settings2 className="h-3 w-3 mr-1.5" /> <Settings2 className="h-3 w-3 mr-1.5" />
Edit {t("actions.edit")}
</Button> </Button>
)} )}
</div> </div>
</div> </div>
<CardDescription> <CardDescription>
Personal details rendered in the header avatar menu. None of this is required {t("profilePage.description")}
the username already covers identity. Display name and avatar are decorative.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@@ -327,7 +328,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
image they uploaded. `object-cover` keeps the aspect image they uploaded. `object-cover` keeps the aspect
ratio and crops to fit the circle. */} ratio and crops to fit the circle. */}
<div> <div>
<Label className="text-sm">Avatar</Label> <Label className="text-sm">{t("profilePage.avatar.label")}</Label>
<div className="flex flex-col sm:flex-row items-start gap-6 mt-3"> <div className="flex flex-col sm:flex-row items-start gap-6 mt-3">
<div className="relative shrink-0"> <div className="relative shrink-0">
{avatarBlobUrl ? ( {avatarBlobUrl ? (
@@ -367,7 +368,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
className="justify-start" className="justify-start"
> >
<Upload className="h-3.5 w-3.5 mr-2" /> <Upload className="h-3.5 w-3.5 mr-2" />
{profile?.has_avatar ? "Replace avatar" : "Upload avatar"} {profile?.has_avatar ? t("profilePage.avatar.replace") : t("profilePage.avatar.upload")}
</Button> </Button>
{profile?.has_avatar && ( {profile?.has_avatar && (
<Button <Button
@@ -378,12 +379,11 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
className="justify-start text-red-500 hover:text-red-500 hover:bg-red-500/10" className="justify-start text-red-500 hover:text-red-500 hover:bg-red-500/10"
> >
<Trash2 className="h-3.5 w-3.5 mr-2" /> <Trash2 className="h-3.5 w-3.5 mr-2" />
Remove avatar {t("profilePage.avatar.remove")}
</Button> </Button>
)} )}
<p className="text-[11px] text-muted-foreground leading-relaxed max-w-xs"> <p className="text-[11px] text-muted-foreground leading-relaxed max-w-xs">
PNG, JPEG, WebP or GIF. Up to 2 MB. The image isn&apos;t resized {t("profilePage.avatar.hint")}
render it square or pre-crop for best results in the header.
</p> </p>
</div> </div>
</div> </div>
@@ -397,7 +397,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
{/* ─── Username (read-only) ─── */} {/* ─── Username (read-only) ─── */}
<div> <div>
<Label className="text-sm" htmlFor="profile-username">Username</Label> <Label className="text-sm" htmlFor="profile-username">{t("profilePage.username.label")}</Label>
<Input <Input
id="profile-username" id="profile-username"
value={profile?.username || ""} value={profile?.username || ""}
@@ -405,28 +405,26 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
className="mt-2 max-w-sm disabled:opacity-100 disabled:cursor-default" className="mt-2 max-w-sm disabled:opacity-100 disabled:cursor-default"
/> />
<p className="text-[11px] text-muted-foreground mt-1"> <p className="text-[11px] text-muted-foreground mt-1">
The login name. To change it, disable authentication and reconfigure from {t("profilePage.username.help")}
Security.
</p> </p>
</div> </div>
{/* ─── Display name (Edit controls live in the card header) ─── */} {/* ─── Display name (Edit controls live in the card header) ─── */}
<div> <div>
<Label className="text-sm" htmlFor="profile-display"> <Label className="text-sm" htmlFor="profile-display">
Display name <span className="text-muted-foreground font-normal">(optional)</span> {t("profilePage.displayName.label")} <span className="text-muted-foreground font-normal">{t("profilePage.displayName.optional")}</span>
</Label> </Label>
<Input <Input
id="profile-display" id="profile-display"
value={displayDraft} value={displayDraft}
onChange={(e) => setDisplayDraft(e.target.value)} onChange={(e) => setDisplayDraft(e.target.value)}
placeholder={profile?.username || "Display name"} placeholder={profile?.username || t("profilePage.displayName.placeholder")}
maxLength={64} maxLength={64}
disabled={!displayEditMode || savingDisplay} disabled={!displayEditMode || savingDisplay}
className="mt-2 max-w-sm disabled:opacity-100 disabled:cursor-default" className="mt-2 max-w-sm disabled:opacity-100 disabled:cursor-default"
/> />
<p className="text-[11px] text-muted-foreground mt-1"> <p className="text-[11px] text-muted-foreground mt-1">
Shown above the username inside the avatar menu. Leave empty to show the {t("profilePage.displayName.help")}
username itself. Up to 64 characters.
</p> </p>
{error && displayEditMode && ( {error && displayEditMode && (
<div className="mt-2 text-xs text-red-500 flex items-start gap-1.5"> <div className="mt-2 text-xs text-red-500 flex items-start gap-1.5">
@@ -443,21 +441,21 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-orange-500" /> <Shield className="h-5 w-5 text-orange-500" />
<CardTitle>Account security</CardTitle> <CardTitle>{t("profilePage.accountSecurity.title")}</CardTitle>
</div> </div>
<CardDescription> <CardDescription>
Password, two-factor authentication and API tokens live in the Security panel. {t("profilePage.accountSecurity.description")}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{onOpenSecurity ? ( {onOpenSecurity ? (
<Button variant="outline" onClick={onOpenSecurity}> <Button variant="outline" onClick={onOpenSecurity}>
<Lock className="h-4 w-4 mr-2" /> <Lock className="h-4 w-4 mr-2" />
Open Security settings {t("profilePage.accountSecurity.openSecurity")}
</Button> </Button>
) : ( ) : (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Open the Security tab from the navigation. {t("profilePage.accountSecurity.fallback")}
</p> </p>
)} )}
</CardContent> </CardContent>
+65 -61
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...",
@@ -98,6 +100,8 @@ export function ProxmoxDashboard() {
const [lastScrollY, setLastScrollY] = useState(0) const [lastScrollY, setLastScrollY] = useState(0)
const [showHealthModal, setShowHealthModal] = useState(false) const [showHealthModal, setShowHealthModal] = useState(false)
const { showReleaseNotes, setShowReleaseNotes } = useVersionCheck() const { showReleaseNotes, setShowReleaseNotes } = useVersionCheck()
const displayServerName = systemStatus.serverName === "Loading..." ? t("app.loading") : systemStatus.serverName
const displayUptime = systemStatus.uptime === "Loading..." ? t("app.loading") : systemStatus.uptime || t("app.notAvailable")
// Category keys for health info count calculation // Category keys for health info count calculation
const HEALTH_CATEGORY_KEYS = [ const HEALTH_CATEGORY_KEYS = [
@@ -168,7 +172,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 +189,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 +200,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
@@ -294,13 +298,13 @@ export function ProxmoxDashboard() {
if ( if (
systemStatus.serverName && systemStatus.serverName &&
systemStatus.serverName !== "Loading..." && systemStatus.serverName !== "Loading..." &&
systemStatus.serverName !== "Server Offline" systemStatus.serverName !== t("app.serverOffline")
) { ) {
document.title = `${systemStatus.serverName} - ProxMenux Monitor` document.title = `${systemStatus.serverName} - ProxMenux Monitor`
} else { } else {
document.title = "ProxMenux Monitor" document.title = "ProxMenux Monitor"
} }
}, [systemStatus.serverName]) }, [systemStatus.serverName, t])
useEffect(() => { useEffect(() => {
let hideTimeout: ReturnType<typeof setTimeout> | null = null let hideTimeout: ReturnType<typeof setTimeout> | null = null
@@ -362,19 +366,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 +392,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 +437,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: displayServerName })}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -447,14 +451,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: displayServerName })}</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 +469,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: displayUptime })}
</div> </div>
<Button <Button
@@ -479,7 +483,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 +517,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 +545,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 +555,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: displayUptime })}
</span> </span>
</div> </div>
</div> </div>
@@ -583,15 +587,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 +604,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 +625,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 +660,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 +731,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 +848,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>
+17 -15
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { Plus, Share, X } from "lucide-react" import { Plus, Share, X } from "lucide-react"
import { useT } from "../lib/i18n/provider"
// ========================================================== // ==========================================================
// PwaInstallPrompt // PwaInstallPrompt
@@ -58,6 +59,7 @@ function isIOS(): boolean {
} }
export function PwaInstallPrompt() { export function PwaInstallPrompt() {
const t = useT()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [platform, setPlatform] = useState<"ios" | "android" | null>(null) const [platform, setPlatform] = useState<"ios" | "android" | null>(null)
@@ -131,7 +133,7 @@ export function PwaInstallPrompt() {
<button <button
type="button" type="button"
onClick={handleClose} onClick={handleClose}
aria-label="Close" aria-label={t("actions.close")}
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground hover:bg-muted transition-colors" className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground hover:bg-muted transition-colors"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@@ -143,12 +145,12 @@ export function PwaInstallPrompt() {
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 id="pwa-install-title" className="text-[17px] font-bold leading-tight tracking-tight text-foreground"> <h3 id="pwa-install-title" className="text-[17px] font-bold leading-tight tracking-tight text-foreground">
Install ProxMenux Monitor {t("pwaInstall.title")}
</h3> </h3>
<p className="mt-1 text-[13px] leading-snug text-muted-foreground"> <p className="mt-1 text-[13px] leading-snug text-muted-foreground">
{platform === "ios" {platform === "ios"
? "Add the Monitor to your home screen for quick access." ? t("pwaInstall.iosDescription")
: "Add the Monitor as an app to launch it like a native application."} : t("pwaInstall.androidDescription")}
</p> </p>
</div> </div>
</div> </div>
@@ -160,12 +162,12 @@ export function PwaInstallPrompt() {
1 1
</span> </span>
<span> <span>
Tap the{" "} {t("pwaInstall.ios.stepShareBefore")}{" "}
<span className="inline-flex items-center gap-1 font-semibold text-primary"> <span className="inline-flex items-center gap-1 font-semibold text-primary">
<Share className="h-4 w-4" aria-hidden="true" /> <Share className="h-4 w-4" aria-hidden="true" />
Share {t("pwaInstall.ios.share")}
</span>{" "} </span>{" "}
button in the bottom bar {t("pwaInstall.ios.stepShareAfter")}
</span> </span>
</li> </li>
<li className="flex items-center gap-3 rounded-xl bg-primary/10 px-3.5 py-3 text-[13.5px] leading-tight"> <li className="flex items-center gap-3 rounded-xl bg-primary/10 px-3.5 py-3 text-[13.5px] leading-tight">
@@ -173,10 +175,10 @@ export function PwaInstallPrompt() {
2 2
</span> </span>
<span> <span>
Choose{" "} {t("pwaInstall.ios.stepChooseBefore")}{" "}
<span className="inline-flex items-center gap-1 font-semibold text-primary"> <span className="inline-flex items-center gap-1 font-semibold text-primary">
<Plus className="h-4 w-4" aria-hidden="true" /> <Plus className="h-4 w-4" aria-hidden="true" />
Add to Home Screen {t("pwaInstall.addToHomeScreen")}
</span> </span>
</span> </span>
</li> </li>
@@ -185,15 +187,15 @@ export function PwaInstallPrompt() {
3 3
</span> </span>
<span> <span>
Confirm by tapping <b>Add</b> in the top-right {t("pwaInstall.ios.stepConfirmBefore")} <b>{t("pwaInstall.ios.add")}</b> {t("pwaInstall.ios.stepConfirmAfter")}
</span> </span>
</li> </li>
</ol> </ol>
) : ( ) : (
<div className="mb-4 rounded-lg border border-border bg-muted/50 px-3.5 py-3 text-[13px] leading-relaxed text-muted-foreground"> <div className="mb-4 rounded-lg border border-border bg-muted/50 px-3.5 py-3 text-[13px] leading-relaxed text-muted-foreground">
Open the browser menu <b className="text-foreground"></b> {" "} {t("pwaInstall.android.stepOpenMenu")} <b className="text-foreground"></b> {" "}
<b className="text-foreground">Add to Home Screen</b> confirm by tapping{" "} <b className="text-foreground">{t("pwaInstall.addToHomeScreen")}</b> {t("pwaInstall.android.stepConfirm")}{" "}
<b className="text-foreground">Install</b>. <b className="text-foreground">{t("pwaInstall.android.install")}</b>.
</div> </div>
)} )}
@@ -203,14 +205,14 @@ export function PwaInstallPrompt() {
onClick={handleNotNow} onClick={handleNotNow}
className="rounded-lg py-2.5 text-center text-[13.5px] font-semibold text-muted-foreground hover:bg-muted transition-colors" className="rounded-lg py-2.5 text-center text-[13.5px] font-semibold text-muted-foreground hover:bg-muted transition-colors"
> >
Not now {t("pwaInstall.notNow")}
</button> </button>
<button <button
type="button" type="button"
onClick={handleNeverAgain} onClick={handleNeverAgain}
className="rounded-lg py-2.5 text-center text-[13.5px] font-semibold text-amber-700 dark:text-amber-500 hover:bg-muted transition-colors" className="rounded-lg py-2.5 text-center text-[13.5px] font-semibold text-amber-700 dark:text-amber-500 hover:bg-muted transition-colors"
> >
Don&apos;t show again {t("pwaInstall.neverAgain")}
</button> </button>
</div> </div>
</div> </div>
+10 -6
View File
@@ -5,6 +5,7 @@ import { Button } from "./ui/button"
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog" import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup } from "lucide-react" import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup } from "lucide-react"
import { Checkbox } from "./ui/checkbox" import { Checkbox } from "./ui/checkbox"
import { useT } from "../lib/i18n/provider"
const APP_VERSION = "1.2.4.1-beta" // Sync with AppImage/package.json const APP_VERSION = "1.2.4.1-beta" // Sync with AppImage/package.json
@@ -247,6 +248,7 @@ interface ReleaseNotesModalProps {
} }
export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) { export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
const t = useT()
const [dontShowAgain, setDontShowAgain] = useState(false) const [dontShowAgain, setDontShowAgain] = useState(false)
const handleClose = () => { const handleClose = () => {
@@ -259,7 +261,7 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
return ( return (
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[85vh] p-0 gap-0 border-0 bg-transparent"> <DialogContent className="max-w-2xl max-h-[85vh] p-0 gap-0 border-0 bg-transparent">
<DialogTitle className="sr-only">Release Notes - Version {APP_VERSION}</DialogTitle> <DialogTitle className="sr-only">{t("releaseNotes.dialogTitle", { version: APP_VERSION })}</DialogTitle>
<div className="relative bg-card rounded-lg shadow-2xl h-full flex flex-col max-h-[85vh]"> <div className="relative bg-card rounded-lg shadow-2xl h-full flex flex-col max-h-[85vh]">
<Button <Button
variant="ghost" variant="ghost"
@@ -285,10 +287,10 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
<div className="flex-1 overflow-y-auto p-6 md:p-8 space-y-4 md:space-y-6 min-h-0"> <div className="flex-1 overflow-y-auto p-6 md:p-8 space-y-4 md:space-y-6 min-h-0">
<div className="space-y-2"> <div className="space-y-2">
<h2 className="text-xl md:text-2xl font-bold text-foreground text-balance"> <h2 className="text-xl md:text-2xl font-bold text-foreground text-balance">
What's New in Version {APP_VERSION} {t("releaseNotes.title", { version: APP_VERSION })}
</h2> </h2>
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
We've added exciting new features and improvements to make ProxMenux Monitor even better! {t("releaseNotes.intro")}
</p> </p>
</div> </div>
@@ -299,7 +301,9 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
className="flex items-start gap-2 md:gap-3 p-3 rounded-lg bg-muted/50 border border-border/50 hover:bg-muted/70 transition-colors" className="flex items-start gap-2 md:gap-3 p-3 rounded-lg bg-muted/50 border border-border/50 hover:bg-muted/70 transition-colors"
> >
<div className="text-orange-500 mt-0.5 flex-shrink-0">{feature.icon}</div> <div className="text-orange-500 mt-0.5 flex-shrink-0">{feature.icon}</div>
<p className="text-xs md:text-sm text-foreground leading-relaxed">{feature.text}</p> <p className="text-xs md:text-sm text-foreground leading-relaxed">
{t(index === 0 ? "releaseNotes.currentFeatures.hostUpdate" : "releaseNotes.currentFeatures.mobileInstall")}
</p>
</div> </div>
))} ))}
</div> </div>
@@ -312,7 +316,7 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600" className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600"
> >
<Sparkles className="h-4 w-4 mr-2" /> <Sparkles className="h-4 w-4 mr-2" />
Got it! {t("releaseNotes.gotIt")}
</Button> </Button>
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
@@ -325,7 +329,7 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
htmlFor="dont-show-version-again" htmlFor="dont-show-version-again"
className="text-xs md:text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none" className="text-xs md:text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none"
> >
Don't show again for this version {t("releaseNotes.dontShowAgain")}
</label> </label>
</div> </div>
</div> </div>
+77 -67
View File
@@ -40,6 +40,7 @@ import {
Filter, Filter,
} from "lucide-react" } from "lucide-react"
import { fetchApi } from "../lib/api-config" import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
// ── Shape contracts with the backend ────────────────────────── // ── Shape contracts with the backend ──────────────────────────
@@ -121,15 +122,17 @@ const formatIso = (iso: string | null | undefined) => {
} }
} }
const formatRelative = (iso: string) => { type Translator = ReturnType<typeof useT>
const formatRelative = (iso: string, t: Translator) => {
try { try {
const then = new Date(iso).getTime() const then = new Date(iso).getTime()
const now = Date.now() const now = Date.now()
const diff = Math.max(0, Math.round((now - then) / 1000)) const diff = Math.max(0, Math.round((now - then) / 1000))
if (diff < 60) return `${diff}s ago` if (diff < 60) return t("restoreProgress.time.secondsAgo", { count: diff })
if (diff < 3600) return `${Math.round(diff / 60)}m ago` if (diff < 3600) return t("restoreProgress.time.minutesAgo", { count: Math.round(diff / 60) })
if (diff < 86400) return `${Math.round(diff / 3600)}h ago` if (diff < 86400) return t("restoreProgress.time.hoursAgo", { count: Math.round(diff / 3600) })
return `${Math.round(diff / 86400)}d ago` return t("restoreProgress.time.daysAgo", { count: Math.round(diff / 86400) })
} catch { } catch {
return iso return iso
} }
@@ -140,40 +143,41 @@ const formatRelative = (iso: string) => {
// "estimating time…". After the run is terminal, "—". The output is // "estimating time…". After the run is terminal, "—". The output is
// a full phrase so the caller doesn't have to add suffix words that // a full phrase so the caller doesn't have to add suffix words that
// only make sense on some branches. // only make sense on some branches.
const computeEta = (state: RestoreState): string => { const computeEta = (state: RestoreState, t: Translator): string => {
if (state.status !== "running") return "—" if (state.status !== "running") return "—"
if (!state.steps_done || state.steps_done <= 0) return "estimating time…" if (!state.steps_done || state.steps_done <= 0) return t("restoreProgress.time.estimating")
const elapsedSec = Math.max(1, Math.round((Date.now() - new Date(state.started_at).getTime()) / 1000)) const elapsedSec = Math.max(1, Math.round((Date.now() - new Date(state.started_at).getTime()) / 1000))
const perStep = elapsedSec / state.steps_done const perStep = elapsedSec / state.steps_done
const remaining = Math.max(0, state.steps_total - state.steps_done) const remaining = Math.max(0, state.steps_total - state.steps_done)
const eta = Math.round(perStep * remaining) const eta = Math.round(perStep * remaining)
if (eta < 60) return `~${eta}s left` if (eta < 60) return t("restoreProgress.time.secondsLeft", { count: eta })
if (eta < 3600) return `~${Math.round(eta / 60)}m left` if (eta < 3600) return t("restoreProgress.time.minutesLeft", { count: Math.round(eta / 60) })
return `~${Math.round(eta / 3600)}h left` return t("restoreProgress.time.hoursLeft", { count: Math.round(eta / 3600) })
} }
// ── Small building blocks ───────────────────────────────────── // ── Small building blocks ─────────────────────────────────────
const StatusBadge: React.FC<{ status: string }> = ({ status }) => { const StatusBadge: React.FC<{ status: string }> = ({ status }) => {
const t = useT()
if (status === "running") if (status === "running")
return ( return (
<Badge className="bg-blue-500/10 border-blue-500/40 text-blue-300 gap-1"> <Badge className="bg-blue-500/10 border-blue-500/40 text-blue-300 gap-1">
<Loader2 className="h-3 w-3 animate-spin" /> <Loader2 className="h-3 w-3 animate-spin" />
Restore in progress {t("restoreProgress.status.running")}
</Badge> </Badge>
) )
if (status === "complete") if (status === "complete")
return ( return (
<Badge className="bg-emerald-500/10 border-emerald-500/40 text-emerald-400 gap-1"> <Badge className="bg-emerald-500/10 border-emerald-500/40 text-emerald-400 gap-1">
<CheckCircle2 className="h-3 w-3" /> <CheckCircle2 className="h-3 w-3" />
Restore complete {t("restoreProgress.status.complete")}
</Badge> </Badge>
) )
if (status === "failed") if (status === "failed")
return ( return (
<Badge className="bg-red-500/10 border-red-500/40 text-red-400 gap-1"> <Badge className="bg-red-500/10 border-red-500/40 text-red-400 gap-1">
<XCircle className="h-3 w-3" /> <XCircle className="h-3 w-3" />
Restore failed {t("restoreProgress.status.failed")}
</Badge> </Badge>
) )
return <Badge variant="outline">{status}</Badge> return <Badge variant="outline">{status}</Badge>
@@ -190,6 +194,7 @@ const ComponentStatusIcon: React.FC<{ status: string }> = ({ status }) => {
// ── Log viewer ──────────────────────────────────────────────── // ── Log viewer ────────────────────────────────────────────────
const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ path, historyOnly }) => { const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ path, historyOnly }) => {
const t = useT()
const [filter, setFilter] = useState<"all" | "issues">("all") const [filter, setFilter] = useState<"all" | "issues">("all")
const swrKey = path const swrKey = path
? `/api/host-backups/restore/log?filter=${filter}&tail=600${historyOnly ? `&path=${encodeURIComponent(path)}` : ""}` ? `/api/host-backups/restore/log?filter=${filter}&tail=600${historyOnly ? `&path=${encodeURIComponent(path)}` : ""}`
@@ -205,7 +210,7 @@ const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ p
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1 text-muted-foreground"> <div className="flex items-center gap-1 text-muted-foreground">
<FileText className="h-3.5 w-3.5" /> <FileText className="h-3.5 w-3.5" />
{path ?? "no log yet"} {path ?? t("restoreProgress.log.noLog")}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
@@ -215,7 +220,7 @@ const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ p
onClick={() => setFilter("all")} onClick={() => setFilter("all")}
> >
<ArrowDownAZ className="h-3 w-3 mr-1" /> <ArrowDownAZ className="h-3 w-3 mr-1" />
Full {t("restoreProgress.log.full")}
</Button> </Button>
<Button <Button
size="sm" size="sm"
@@ -224,13 +229,13 @@ const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ p
onClick={() => setFilter("issues")} onClick={() => setFilter("issues")}
> >
<Filter className="h-3 w-3 mr-1" /> <Filter className="h-3 w-3 mr-1" />
Issues only {t("restoreProgress.log.issuesOnly")}
</Button> </Button>
</div> </div>
</div> </div>
<ScrollArea className="h-72 rounded-md border border-border bg-black/40"> <ScrollArea className="h-72 rounded-md border border-border bg-black/40">
<pre className="p-3 text-xs text-muted-foreground whitespace-pre-wrap font-mono leading-relaxed"> <pre className="p-3 text-xs text-muted-foreground whitespace-pre-wrap font-mono leading-relaxed">
{isLoading ? "Loading" : (data?.lines?.join("\n") || "(no output)")} {isLoading ? t("app.loading") : (data?.lines?.join("\n") || t("restoreProgress.log.noOutput"))}
</pre> </pre>
</ScrollArea> </ScrollArea>
</div> </div>
@@ -240,13 +245,14 @@ const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ p
// ── Rollback delta widget ───────────────────────────────────── // ── Rollback delta widget ─────────────────────────────────────
const RollbackDelta: React.FC<{ delta: RestoreRollback | undefined }> = ({ delta }) => { const RollbackDelta: React.FC<{ delta: RestoreRollback | undefined }> = ({ delta }) => {
const t = useT()
const vms = delta?.vms_to_remove ?? [] const vms = delta?.vms_to_remove ?? []
const lxcs = delta?.lxcs_to_remove ?? [] const lxcs = delta?.lxcs_to_remove ?? []
const comps = delta?.components_to_uninstall ?? [] const comps = delta?.components_to_uninstall ?? []
if (!vms.length && !lxcs.length && !comps.length) { if (!vms.length && !lxcs.length && !comps.length) {
return ( return (
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
No entries exist on this host that weren't in the restored backup. {t("restoreProgress.rollback.empty")}
</div> </div>
) )
} }
@@ -264,7 +270,7 @@ const RollbackDelta: React.FC<{ delta: RestoreRollback | undefined }> = ({ delta
{items.length > 0 && ( {items.length > 0 && (
<details className="text-xs"> <details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground"> <summary className="cursor-pointer text-muted-foreground hover:text-foreground">
Show manual cleanup commands {t("restoreProgress.rollback.showCleanup")}
</summary> </summary>
<pre className="mt-1 p-2 rounded-md bg-black/40 text-xs text-muted-foreground font-mono"> <pre className="mt-1 p-2 rounded-md bg-black/40 text-xs text-muted-foreground font-mono">
{items.map(cmd).join("\n")} {items.map(cmd).join("\n")}
@@ -277,22 +283,22 @@ const RollbackDelta: React.FC<{ delta: RestoreRollback | undefined }> = ({ delta
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
These entries exist on this host but were NOT in the restored backup. Review before removing. {t("restoreProgress.rollback.description")}
</div> </div>
<Row <Row
label="VMs created after the backup" label={t("restoreProgress.rollback.vms")}
items={vms} items={vms}
cmd={(id) => `qm stop ${id} 2>/dev/null; qm destroy ${id} --purge`} cmd={(id) => `qm stop ${id} 2>/dev/null; qm destroy ${id} --purge`}
/> />
<Row <Row
label="LXCs created after the backup" label={t("restoreProgress.rollback.lxcs")}
items={lxcs} items={lxcs}
cmd={(id) => `pct stop ${id} 2>/dev/null; pct destroy ${id} --purge`} cmd={(id) => `pct stop ${id} 2>/dev/null; pct destroy ${id} --purge`}
/> />
<Row <Row
label="Components installed after the backup" label={t("restoreProgress.rollback.components")}
items={comps} items={comps}
cmd={(name) => `# uninstall ${name} manually via ProxMenux → Hardware & GPU`} cmd={(name) => t("restoreProgress.rollback.uninstallComponentCommand", { name })}
/> />
</div> </div>
) )
@@ -306,6 +312,7 @@ const RestoreDetailModal: React.FC<{
state: RestoreState state: RestoreState
historyMode?: boolean historyMode?: boolean
}> = ({ open, onClose, state, historyMode }) => { }> = ({ open, onClose, state, historyMode }) => {
const t = useT()
const progressPct = state.steps_total > 0 ? Math.round((state.steps_done / state.steps_total) * 100) : 0 const progressPct = state.steps_total > 0 ? Math.round((state.steps_done / state.steps_total) * 100) : 0
return ( return (
@@ -314,12 +321,12 @@ const RestoreDetailModal: React.FC<{
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<RotateCcw className="h-5 w-5 text-blue-500" /> <RotateCcw className="h-5 w-5 text-blue-500" />
Post-restore progress {t("restoreProgress.title")}
<StatusBadge status={state.status} /> <StatusBadge status={state.status} />
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Started {formatIso(state.started_at)} {t("restoreProgress.startedAt", { time: formatIso(state.started_at) })}
{state.finished_at ? ` · finished ${formatIso(state.finished_at)}` : ""} {state.finished_at ? ` · ${t("restoreProgress.finishedAt", { time: formatIso(state.finished_at) })}` : ""}
{state.summary?.duration ? ` · ${state.summary.duration}` : ""} {state.summary?.duration ? ` · ${state.summary.duration}` : ""}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -329,8 +336,8 @@ const RestoreDetailModal: React.FC<{
<div className="flex justify-between text-xs text-muted-foreground"> <div className="flex justify-between text-xs text-muted-foreground">
<span>{state.current_step || "—"}</span> <span>{state.current_step || "—"}</span>
<span> <span>
{state.steps_done}/{state.steps_total} steps {t("restoreProgress.steps", { done: state.steps_done, total: state.steps_total })}
{state.status === "running" && ` · ${computeEta(state)}`} {state.status === "running" && ` · ${computeEta(state, t)}`}
</span> </span>
</div> </div>
<div className="h-2 rounded-full bg-muted overflow-hidden"> <div className="h-2 rounded-full bg-muted overflow-hidden">
@@ -347,7 +354,7 @@ const RestoreDetailModal: React.FC<{
<div className="space-y-2"> <div className="space-y-2">
<div className="text-sm font-medium flex items-center gap-2"> <div className="text-sm font-medium flex items-center gap-2">
<Cpu className="h-4 w-4" /> <Cpu className="h-4 w-4" />
Components {t("restoreProgress.sections.components")}
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
{state.components.map((c) => ( {state.components.map((c) => (
@@ -358,8 +365,8 @@ const RestoreDetailModal: React.FC<{
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ComponentStatusIcon status={c.status} /> <ComponentStatusIcon status={c.status} />
<span className="font-medium">{formatComponent(c.name)}</span> <span className="font-medium">{formatComponent(c.name)}</span>
<span className="text-muted-foreground">{c.status}</span> <span className="text-muted-foreground">{t(`restoreProgress.componentStatus.${c.status}`)}</span>
{c.exit_code && <span className="text-red-400">exit {c.exit_code}</span>} {c.exit_code && <span className="text-red-400">{t("restoreProgress.exitCode", { code: c.exit_code })}</span>}
</div> </div>
{c.log && <span className="text-muted-foreground font-mono">{c.log}</span>} {c.log && <span className="text-muted-foreground font-mono">{c.log}</span>}
</div> </div>
@@ -372,7 +379,7 @@ const RestoreDetailModal: React.FC<{
<div className="space-y-2"> <div className="space-y-2">
<div className="text-sm font-medium flex items-center gap-2 text-amber-400"> <div className="text-sm font-medium flex items-center gap-2 text-amber-400">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
Boot sanity warnings {t("restoreProgress.sections.bootWarnings")}
</div> </div>
<ul className="list-disc list-inside text-xs text-muted-foreground space-y-1"> <ul className="list-disc list-inside text-xs text-muted-foreground space-y-1">
{state.sanity_warnings.map((w) => ( {state.sanity_warnings.map((w) => (
@@ -385,19 +392,19 @@ const RestoreDetailModal: React.FC<{
{state.data_pools_import && <DataPoolsBlock section={state.data_pools_import} />} {state.data_pools_import && <DataPoolsBlock section={state.data_pools_import} />}
<div className="space-y-2"> <div className="space-y-2">
<div className="text-sm font-medium">Rollback delta</div> <div className="text-sm font-medium">{t("restoreProgress.sections.rollbackDelta")}</div>
<RollbackDelta delta={state.rollback_delta} /> <RollbackDelta delta={state.rollback_delta} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="text-sm font-medium">Log</div> <div className="text-sm font-medium">{t("restoreProgress.sections.log")}</div>
<LogViewer path={state.log_path} historyOnly={historyMode} /> <LogViewer path={state.log_path} historyOnly={historyMode} />
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose}> <Button variant="outline" onClick={onClose}>
Close {t("actions.close")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -408,6 +415,7 @@ const RestoreDetailModal: React.FC<{
// Rendered inside RestoreDetailModal — one row per outcome category // Rendered inside RestoreDetailModal — one row per outcome category
// (imported / forced / partial skip / missing skip / failed). // (imported / forced / partial skip / missing skip / failed).
const DataPoolsBlock: React.FC<{ section: DataPoolsImport }> = ({ section }) => { const DataPoolsBlock: React.FC<{ section: DataPoolsImport }> = ({ section }) => {
const t = useT()
const total = const total =
section.ok.length + section.ok.length +
section.forced.length + section.forced.length +
@@ -448,40 +456,40 @@ const DataPoolsBlock: React.FC<{ section: DataPoolsImport }> = ({ section }) =>
} }
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="text-sm font-medium flex items-center gap-2"> <div className="text-sm font-medium flex items-center gap-2">
<Cpu className="h-4 w-4" /> <Cpu className="h-4 w-4" />
ZFS data pools auto-import {t("restoreProgress.dataPools.title")}
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Row label="Imported" tone="ok" items={section.ok} /> <Row label={t("restoreProgress.dataPools.imported")} tone="ok" items={section.ok} />
<Row <Row
label="Imported (forced, foreign hostid)" label={t("restoreProgress.dataPools.importedForced")}
tone="info" tone="info"
items={section.forced} items={section.forced}
help="New hostid grabbed onto the pool label — next boot imports clean." help={t("restoreProgress.dataPools.importedForcedHelp")}
/> />
<Row <Row
label="Skipped (some disks missing)" label={t("restoreProgress.dataPools.skippedPartial")}
tone="warn" tone="warn"
items={section.partial} items={section.partial}
help="Some vdev disks weren't found by /dev/disk/by-id. Pool NOT imported to avoid a degraded auto-import. Fix the disks or import manually with zpool import." help={t("restoreProgress.dataPools.skippedPartialHelp")}
/> />
<Row <Row
label="Skipped (no disks present)" label={t("restoreProgress.dataPools.skippedMissing")}
tone="warn" tone="warn"
items={section.missing} items={section.missing}
help="None of the pool's disks are on this host. Move the disks over or import from a different host." help={t("restoreProgress.dataPools.skippedMissingHelp")}
/> />
<Row <Row
label="Import failed" label={t("restoreProgress.dataPools.importFailed")}
tone="error" tone="error"
items={section.failed} items={section.failed}
help="ZFS rejected the import even with -f. Inspect with `zpool import` and the log below." help={t("restoreProgress.dataPools.importFailedHelp")}
/> />
</div> </div>
{section.log_path && ( {section.log_path && (
<div className="text-xs text-muted-foreground font-mono">Log: {section.log_path}</div> <div className="text-xs text-muted-foreground font-mono">{t("restoreProgress.dataPools.logPath", { path: section.log_path })}</div>
)} )}
</div> </div>
) )
@@ -490,6 +498,7 @@ const DataPoolsBlock: React.FC<{ section: DataPoolsImport }> = ({ section }) =>
// ── History browser modal ───────────────────────────────────── // ── History browser modal ─────────────────────────────────────
const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => { const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
const t = useT()
const { data } = useSWR<{ entries: HistoryEntry[] }>(open ? "/api/host-backups/restore/history" : null, fetcher) const { data } = useSWR<{ entries: HistoryEntry[] }>(open ? "/api/host-backups/restore/history" : null, fetcher)
const [detailFile, setDetailFile] = useState<string | null>(null) const [detailFile, setDetailFile] = useState<string | null>(null)
const { data: detailResp } = useSWR<{ state: RestoreState }>( const { data: detailResp } = useSWR<{ state: RestoreState }>(
@@ -504,17 +513,17 @@ const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<History className="h-5 w-5" /> <History className="h-5 w-5" />
Past restores {t("restoreProgress.history.title")}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Restores archived by the post-boot dispatcher. The latest 20 are kept. {t("restoreProgress.history.description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<ScrollArea className="h-96"> <ScrollArea className="h-96">
<div className="space-y-1.5"> <div className="space-y-1.5">
{(data?.entries ?? []).length === 0 ? ( {(data?.entries ?? []).length === 0 ? (
<div className="text-sm text-muted-foreground py-6 text-center">No past restores recorded.</div> <div className="text-sm text-muted-foreground py-6 text-center">{t("restoreProgress.history.empty")}</div>
) : ( ) : (
(data?.entries ?? []).map((e) => ( (data?.entries ?? []).map((e) => (
<button <button
@@ -538,7 +547,7 @@ const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose}> <Button variant="outline" onClick={onClose}>
Close {t("actions.close")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -559,6 +568,7 @@ const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({
// ── Main inline card ────────────────────────────────────────── // ── Main inline card ──────────────────────────────────────────
export const RestoreProgressCard: React.FC = () => { export const RestoreProgressCard: React.FC = () => {
const t = useT()
const { data, mutate } = useSWR<{ state: RestoreState | null }>( const { data, mutate } = useSWR<{ state: RestoreState | null }>(
"/api/host-backups/restore/status", "/api/host-backups/restore/status",
fetcher, fetcher,
@@ -597,7 +607,7 @@ export const RestoreProgressCard: React.FC = () => {
<div className="flex justify-end"> <div className="flex justify-end">
<Button variant="ghost" size="sm" onClick={() => setHistoryOpen(true)}> <Button variant="ghost" size="sm" onClick={() => setHistoryOpen(true)}>
<History className="h-3.5 w-3.5 mr-1" /> <History className="h-3.5 w-3.5 mr-1" />
Past restores {t("restoreProgress.history.title")}
</Button> </Button>
<RestoreHistoryModal open={historyOpen} onClose={() => setHistoryOpen(false)} /> <RestoreHistoryModal open={historyOpen} onClose={() => setHistoryOpen(false)} />
</div> </div>
@@ -625,12 +635,12 @@ export const RestoreProgressCard: React.FC = () => {
<RotateCcw <RotateCcw
className={`h-5 w-5 ${state.status === "running" ? "text-blue-500 animate-spin" : "text-blue-500"}`} className={`h-5 w-5 ${state.status === "running" ? "text-blue-500 animate-spin" : "text-blue-500"}`}
/> />
Post-restore progress {t("restoreProgress.title")}
<StatusBadge status={state.status} /> <StatusBadge status={state.status} />
{hasWarnings && ( {hasWarnings && (
<Badge variant="outline" className="text-amber-400 border-amber-500/40 bg-amber-500/10 gap-1"> <Badge variant="outline" className="text-amber-400 border-amber-500/40 bg-amber-500/10 gap-1">
<AlertTriangle className="h-3 w-3" /> <AlertTriangle className="h-3 w-3" />
{state.sanity_warnings.length} boot warning{state.sanity_warnings.length === 1 ? "" : "s"} {t("restoreProgress.badges.bootWarnings", { count: state.sanity_warnings.length })}
</Badge> </Badge>
)} )}
{poolCount > 0 && ( {poolCount > 0 && (
@@ -643,22 +653,22 @@ export const RestoreProgressCard: React.FC = () => {
} }
> >
<Cpu className="h-3 w-3" /> <Cpu className="h-3 w-3" />
{poolCount} ZFS pool{poolCount === 1 ? "" : "s"} {t("restoreProgress.badges.zfsPools", { count: poolCount })}
{poolWarnings > 0 && ` · ${poolWarnings} need attention`} {poolWarnings > 0 && ` · ${t("restoreProgress.badges.needAttention", { count: poolWarnings })}`}
</Badge> </Badge>
)} )}
</CardTitle> </CardTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setDetailOpen(true)}> <Button size="sm" variant="outline" onClick={() => setDetailOpen(true)}>
Details {t("restoreProgress.actions.details")}
</Button> </Button>
<Button size="sm" variant="ghost" onClick={() => setHistoryOpen(true)}> <Button size="sm" variant="ghost" onClick={() => setHistoryOpen(true)}>
<History className="h-3.5 w-3.5 mr-1" /> <History className="h-3.5 w-3.5 mr-1" />
History {t("restoreProgress.actions.history")}
</Button> </Button>
{state.status !== "running" && ( {state.status !== "running" && (
<Button size="sm" onClick={dismiss} disabled={dismissing}> <Button size="sm" onClick={dismiss} disabled={dismissing}>
{dismissing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Dismiss"} {dismissing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : t("restoreProgress.actions.dismiss")}
</Button> </Button>
)} )}
</div> </div>
@@ -668,11 +678,11 @@ export const RestoreProgressCard: React.FC = () => {
<div className="space-y-1"> <div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground"> <div className="flex justify-between text-xs text-muted-foreground">
<span className="truncate"> <span className="truncate">
{state.current_step || "—"} · started {formatRelative(state.started_at)} {state.current_step || "—"} · {t("restoreProgress.startedRelative", { time: formatRelative(state.started_at, t) })}
</span> </span>
<span> <span>
{state.steps_done}/{state.steps_total} steps {t("restoreProgress.steps", { done: state.steps_done, total: state.steps_total })}
{state.status === "running" && ` · ${computeEta(state)}`} {state.status === "running" && ` · ${computeEta(state, t)}`}
{state.summary?.duration && state.status !== "running" && ` · ${state.summary.duration}`} {state.summary?.duration && state.status !== "running" && ` · ${state.summary.duration}`}
</span> </span>
</div> </div>
@@ -684,19 +694,19 @@ export const RestoreProgressCard: React.FC = () => {
{state.summary && ( {state.summary && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs"> <div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5"> <div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
<div className="text-muted-foreground">Guests</div> <div className="text-muted-foreground">{t("restoreProgress.summary.guests")}</div>
<div className="font-medium">{state.summary.guests}</div> <div className="font-medium">{state.summary.guests}</div>
</div> </div>
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5"> <div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
<div className="text-muted-foreground">Bind-mount stubs</div> <div className="text-muted-foreground">{t("restoreProgress.summary.bindMountStubs")}</div>
<div className="font-medium">{state.summary.stubs}</div> <div className="font-medium">{state.summary.stubs}</div>
</div> </div>
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5"> <div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
<div className="text-muted-foreground">Stale nodes cleaned</div> <div className="text-muted-foreground">{t("restoreProgress.summary.staleNodesCleaned")}</div>
<div className="font-medium">{state.summary.stale_nodes}</div> <div className="font-medium">{state.summary.stale_nodes}</div>
</div> </div>
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5"> <div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
<div className="text-muted-foreground">Components</div> <div className="text-muted-foreground">{t("restoreProgress.summary.components")}</div>
<div className="font-medium">{state.summary.components}</div> <div className="font-medium">{state.summary.components}</div>
</div> </div>
</div> </div>
+27 -25
View File
@@ -31,6 +31,7 @@ import {
import "xterm/css/xterm.css" import "xterm/css/xterm.css"
import { API_PORT } from "@/lib/api-config" import { API_PORT } from "@/lib/api-config"
import { getTicketedWsUrl } from "@/lib/terminal-ws" import { getTicketedWsUrl } from "@/lib/terminal-ws"
import { useT } from "../lib/i18n/provider"
interface WebInteraction { interface WebInteraction {
type: "yesno" | "menu" | "msgbox" | "input" | "inputbox" type: "yesno" | "menu" | "msgbox" | "input" | "inputbox"
@@ -66,6 +67,7 @@ export function ScriptTerminalModal({
params = { EXECUTION_MODE: "web" }, params = { EXECUTION_MODE: "web" },
onComplete, onComplete,
}: ScriptTerminalModalProps) { }: ScriptTerminalModalProps) {
const t = useT()
const termRef = useRef<any>(null) const termRef = useRef<any>(null)
const wsRef = useRef<WebSocket | null>(null) const wsRef = useRef<WebSocket | null>(null)
// Mirrors `isOpen` for use inside async closures (initializeTerminal) // Mirrors `isOpen` for use inside async closures (initializeTerminal)
@@ -388,12 +390,12 @@ const initMessage = {
ws.onerror = (error) => { ws.onerror = (error) => {
setConnectionStatus("offline") setConnectionStatus("offline")
term.writeln("\x1b[31mWebSocket error occurred\x1b[0m") term.writeln(`\x1b[31m${t("scriptTerminal.websocketError")}\x1b[0m`)
} }
ws.onclose = (event) => { ws.onclose = (event) => {
setConnectionStatus("offline") setConnectionStatus("offline")
term.writeln("\x1b[33mConnection closed\x1b[0m") term.writeln(`\x1b[33m${t("scriptTerminal.connectionClosed")}\x1b[0m`)
if (keepAliveIntervalRef.current) { if (keepAliveIntervalRef.current) {
clearInterval(keepAliveIntervalRef.current) clearInterval(keepAliveIntervalRef.current)
@@ -712,7 +714,7 @@ const initMessage = {
<div className="absolute inset-0 flex items-center justify-center bg-black/50 backdrop-blur-sm"> <div className="absolute inset-0 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" /> <Loader2 className="h-8 w-8 animate-spin text-blue-500" />
<p className="text-sm text-muted-foreground">Processing...</p> <p className="text-sm text-muted-foreground">{t("scriptTerminal.processing")}</p>
</div> </div>
</div> </div>
)} )}
@@ -835,29 +837,29 @@ const initMessage = {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56"> <DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel> <DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.controlSequences")}</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => sendCommand("\x03")}> <DropdownMenuItem onSelect={() => sendCommand("\x03")}>
<span className="font-mono text-xs mr-2">Ctrl+C</span> <span className="font-mono text-xs mr-2">Ctrl+C</span>
<span className="text-muted-foreground text-xs">Cancel/Interrupt</span> <span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendCommand("\x18")}> <DropdownMenuItem onSelect={() => sendCommand("\x18")}>
<span className="font-mono text-xs mr-2">Ctrl+X</span> <span className="font-mono text-xs mr-2">Ctrl+X</span>
<span className="text-muted-foreground text-xs">Exit (nano)</span> <span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendCommand("\x12")}> <DropdownMenuItem onSelect={() => sendCommand("\x12")}>
<span className="font-mono text-xs mr-2">Ctrl+R</span> <span className="font-mono text-xs mr-2">Ctrl+R</span>
<span className="text-muted-foreground text-xs">Search history</span> <span className="text-muted-foreground text-xs">{t("scriptTerminal.searchHistory")}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">Clipboard</DropdownMenuLabel> <DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.clipboard")}</DropdownMenuLabel>
<DropdownMenuItem onSelect={() => { void handleCopy() }}> <DropdownMenuItem onSelect={() => { void handleCopy() }}>
<Copy className="h-3.5 w-3.5 mr-2" /> <Copy className="h-3.5 w-3.5 mr-2" />
<span className="text-xs">Copy selection</span> <span className="text-xs">{t("scriptTerminal.copySelection")}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => { void handlePaste() }}> <DropdownMenuItem onSelect={() => { void handlePaste() }}>
<Clipboard className="h-3.5 w-3.5 mr-2" /> <Clipboard className="h-3.5 w-3.5 mr-2" />
<span className="text-xs">Paste</span> <span className="text-xs">{t("scriptTerminal.paste")}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -877,18 +879,18 @@ const initMessage = {
}`} }`}
title={ title={
connectionStatus === "online" connectionStatus === "online"
? "Connected" ? t("scriptTerminal.connected")
: connectionStatus === "connecting" : connectionStatus === "connecting"
? "Connecting" ? t("scriptTerminal.connecting")
: "Disconnected" : t("scriptTerminal.disconnected")
} }
></div> ></div>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{connectionStatus === "online" {connectionStatus === "online"
? "Online" ? t("scriptTerminal.online")
: connectionStatus === "connecting" : connectionStatus === "connecting"
? "Connecting..." ? t("scriptTerminal.connectingStatus")
: "Offline"} : t("scriptTerminal.offline")}
</span> </span>
</div> </div>
@@ -897,7 +899,7 @@ const initMessage = {
variant="outline" variant="outline"
className="bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400" className="bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
> >
Close {t("actions.close")}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
@@ -933,14 +935,14 @@ const initMessage = {
onClick={() => handleInteractionResponse("yes")} onClick={() => handleInteractionResponse("yes")}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white transition-all duration-150" className="flex-1 bg-blue-600 hover:bg-blue-700 text-white transition-all duration-150"
> >
Yes {t("scriptTerminal.yes")}
</Button> </Button>
<Button <Button
onClick={() => handleInteractionResponse("cancel")} onClick={() => handleInteractionResponse("cancel")}
variant="outline" variant="outline"
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150" className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
> >
Cancel {t("actions.cancel")}
</Button> </Button>
</div> </div>
)} )}
@@ -963,14 +965,14 @@ const initMessage = {
variant="outline" variant="outline"
className="w-full hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150" className="w-full hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
> >
Cancel {t("actions.cancel")}
</Button> </Button>
</div> </div>
)} )}
{(currentInteraction.type === "input" || currentInteraction.type === "inputbox") && ( {(currentInteraction.type === "input" || currentInteraction.type === "inputbox") && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Your input:</Label> <Label>{t("scriptTerminal.yourInput")}</Label>
<Input <Input
value={interactionInput} value={interactionInput}
onChange={(e) => setInteractionInput(e.target.value)} onChange={(e) => setInteractionInput(e.target.value)}
@@ -987,14 +989,14 @@ const initMessage = {
onClick={() => handleInteractionResponse(interactionInput)} onClick={() => handleInteractionResponse(interactionInput)}
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150" className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
> >
Submit {t("scriptTerminal.submit")}
</Button> </Button>
<Button <Button
onClick={() => handleInteractionResponse("cancel")} onClick={() => handleInteractionResponse("cancel")}
variant="outline" variant="outline"
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150" className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
> >
Cancel {t("actions.cancel")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -1006,14 +1008,14 @@ const initMessage = {
onClick={() => handleInteractionResponse("ok")} onClick={() => handleInteractionResponse("ok")}
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150" className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
> >
OK {t("scriptTerminal.ok")}
</Button> </Button>
<Button <Button
onClick={() => handleInteractionResponse("cancel")} onClick={() => handleInteractionResponse("cancel")}
variant="outline" variant="outline"
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150" className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
> >
Cancel {t("actions.cancel")}
</Button> </Button>
</div> </div>
)} )}
+152 -133
View File
@@ -20,6 +20,7 @@ import {
ArrowUpCircle, ArrowUpCircle,
} from "lucide-react" } from "lucide-react"
import { fetchApi } from "../lib/api-config" import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface NetworkInfo { interface NetworkInfo {
interface: string interface: string
@@ -77,6 +78,20 @@ interface WizardStep {
} }
export function SecureGatewaySetup() { export function SecureGatewaySetup() {
const t = useT()
const sg = (key: string, params?: Record<string, string | number>) => t(`securityPage.secureGateway.${key}`, params)
const maybeSg = (key: string, fallback?: string) => {
const fullKey = `securityPage.secureGateway.${key}`
const value = t(fullKey)
return value === fullKey ? fallback || "" : value
}
const fieldText = (fieldName: string, part: string, fallback?: string) =>
maybeSg(`schema.${fieldName}.${part}`, fallback)
const optionText = (fieldName: string, value: string, part: string, fallback?: string) =>
maybeSg(`schema.${fieldName}.options.${value}.${part}`, fallback)
const stepText = (step: WizardStep, part: "title" | "description") =>
maybeSg(`steps.${step.id}.${part}`, step[part])
// State // State
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [runtimeAvailable, setRuntimeAvailable] = useState(false) const [runtimeAvailable, setRuntimeAvailable] = useState(false)
@@ -207,7 +222,7 @@ export function SecureGatewaySetup() {
} }
} catch (err) { } catch (err) {
console.error("Failed to load data:", err) console.error("Failed to load data:", err)
setLoadError(err instanceof Error ? err.message : "Failed to load wizard data") setLoadError(err instanceof Error ? err.message : sg("errors.loadWizardFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -265,7 +280,7 @@ export function SecureGatewaySetup() {
method: "POST", method: "POST",
}) })
if (res?.success) { if (res?.success) {
setUpdateResultMsg(res.message || "Update applied") setUpdateResultMsg(res.message || sg("messages.updateApplied"))
// Re-probe with force=true so the panel flips back to "No // Re-probe with force=true so the panel flips back to "No
// updates available" immediately, bypassing the 24h server // updates available" immediately, bypassing the 24h server
// cache which may still hold the pre-apply "available" entry. // cache which may still hold the pre-apply "available" entry.
@@ -274,10 +289,10 @@ export function SecureGatewaySetup() {
// refresh that too so the action buttons render the right state. // refresh that too so the action buttons render the right state.
await loadStatus() await loadStatus()
} else { } else {
setUpdateError(res?.message || "Update failed") setUpdateError(res?.message || sg("errors.updateFailed"))
} }
} catch (err) { } catch (err) {
setUpdateError(err instanceof Error ? err.message : "Network error during update") setUpdateError(err instanceof Error ? err.message : sg("errors.networkUpdateFailed"))
} finally { } finally {
setUpdateApplying(false) setUpdateApplying(false)
} }
@@ -293,7 +308,7 @@ export function SecureGatewaySetup() {
if (deploying) return if (deploying) return
setDeploying(true) setDeploying(true)
setDeployError("") setDeployError("")
setDeployProgress("Preparing deployment...") setDeployProgress(sg("messages.preparingDeployment"))
try { try {
// Validate required fields // Validate required fields
@@ -302,7 +317,7 @@ export function SecureGatewaySetup() {
for (const fieldName of step.fields) { for (const fieldName of step.fields) {
const field = configSchema?.[fieldName] const field = configSchema?.[fieldName]
if (field?.required && !config[fieldName]) { if (field?.required && !config[fieldName]) {
setDeployError(`${field.label} is required`) setDeployError(sg("errors.fieldRequired", { field: fieldText(fieldName, "label", field.label) }))
setDeploying(false) setDeploying(false)
return return
} }
@@ -326,7 +341,7 @@ export function SecureGatewaySetup() {
} }
// For "custom", the user has already selected networks manually // For "custom", the user has already selected networks manually
setDeployProgress("Creating LXC container...") setDeployProgress(sg("messages.creatingLxc"))
const result = await fetchApi("/api/oci/deploy", { const result = await fetchApi("/api/oci/deploy", {
method: "POST", method: "POST",
@@ -338,16 +353,16 @@ export function SecureGatewaySetup() {
if (!result.success) { if (!result.success) {
// Make runtime errors more user-friendly // Make runtime errors more user-friendly
let errorMsg = result.message || "Deployment failed" let errorMsg = result.message || sg("errors.deploymentFailed")
if (errorMsg.includes("9.1") || errorMsg.includes("OCI") || errorMsg.includes("not supported")) { if (errorMsg.includes("9.1") || errorMsg.includes("OCI") || errorMsg.includes("not supported")) {
errorMsg = "OCI containers require Proxmox VE 9.1 or later. Please upgrade your Proxmox installation to use this feature." errorMsg = sg("errors.ociRequiresPve")
} }
setDeployError(errorMsg) setDeployError(errorMsg)
setDeploying(false) setDeploying(false)
return return
} }
setDeployProgress("Gateway deployed successfully!") setDeployProgress(sg("messages.gatewayDeployed"))
// Wipe the Tailscale auth_key from React state so it's no longer // Wipe the Tailscale auth_key from React state so it's no longer
// reachable from a future XSS / state-inspection. The key only needs // reachable from a future XSS / state-inspection. The key only needs
@@ -376,7 +391,7 @@ export function SecureGatewaySetup() {
}, 2000) }, 2000)
} catch (err: any) { } catch (err: any) {
setDeployError(err.message || "Deployment failed") setDeployError(err.message || sg("errors.deploymentFailed"))
setDeploying(false) setDeploying(false)
} }
} }
@@ -400,7 +415,7 @@ export function SecureGatewaySetup() {
const handleUpdateAuthKey = async () => { const handleUpdateAuthKey = async () => {
if (!newAuthKey.trim()) { if (!newAuthKey.trim()) {
setUpdateAuthKeyError("Auth Key is required") setUpdateAuthKeyError(sg("errors.authKeyRequired"))
return return
} }
@@ -417,7 +432,7 @@ export function SecureGatewaySetup() {
}) })
if (!result.success) { if (!result.success) {
setUpdateAuthKeyError(result.message || "Failed to update auth key") setUpdateAuthKeyError(result.message || sg("errors.updateAuthKeyFailed"))
setUpdateAuthKeyLoading(false) setUpdateAuthKeyLoading(false)
return return
} }
@@ -427,7 +442,7 @@ export function SecureGatewaySetup() {
setNewAuthKey("") setNewAuthKey("")
await loadStatus() await loadStatus()
} catch (err: any) { } catch (err: any) {
setUpdateAuthKeyError(err.message || "Failed to update auth key") setUpdateAuthKeyError(err.message || sg("errors.updateAuthKeyFailed"))
} finally { } finally {
setUpdateAuthKeyLoading(false) setUpdateAuthKeyLoading(false)
} }
@@ -456,10 +471,10 @@ export function SecureGatewaySetup() {
try { try {
const result = await fetchApi("/api/oci/installed/secure-gateway/logs?lines=100") const result = await fetchApi("/api/oci/installed/secure-gateway/logs?lines=100")
if (result.success) { if (result.success) {
setLogs(result.logs || "No logs available") setLogs(result.logs || sg("logs.empty"))
} }
} catch (err) { } catch (err) {
setLogs("Failed to load logs") setLogs(sg("logs.failed"))
} finally { } finally {
setLogsLoading(false) setLogsLoading(false)
} }
@@ -476,16 +491,16 @@ export function SecureGatewaySetup() {
// date-only string. Used in the Updates panel — the user wants to know // date-only string. Used in the Updates panel — the user wants to know
// "how stale is this number" without seeing the raw 2026-05-09T10:23Z. // "how stale is this number" without seeing the raw 2026-05-09T10:23Z.
const formatLastChecked = (iso?: string): string => { const formatLastChecked = (iso?: string): string => {
if (!iso) return "never" if (!iso) return sg("values.never")
const d = new Date(iso) const d = new Date(iso)
if (isNaN(d.getTime())) return "unknown" if (isNaN(d.getTime())) return t("common.unknown")
const now = Date.now() const now = Date.now()
const ageMs = now - d.getTime() const ageMs = now - d.getTime()
const sameDay = new Date(now).toDateString() === d.toDateString() const sameDay = new Date(now).toDateString() === d.toDateString()
const yesterday = new Date(now - 86_400_000).toDateString() === d.toDateString() const yesterday = new Date(now - 86_400_000).toDateString() === d.toDateString()
const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
if (sameDay) return time if (sameDay) return time
if (yesterday) return `yesterday ${time}` if (yesterday) return sg("values.yesterdayAt", { time })
if (ageMs < 7 * 86_400_000) { if (ageMs < 7 * 86_400_000) {
return d.toLocaleDateString([], { weekday: "short" }) + " " + time return d.toLocaleDateString([], { weekday: "short" }) + " " + time
} }
@@ -495,6 +510,11 @@ export function SecureGatewaySetup() {
const renderField = (fieldName: string) => { const renderField = (fieldName: string) => {
const field = configSchema?.[fieldName] const field = configSchema?.[fieldName]
if (!field) return null if (!field) return null
const translatedLabel = fieldText(fieldName, "label", field.label)
const translatedDescription = fieldText(fieldName, "description", field.description)
const translatedPlaceholder = fieldText(fieldName, "placeholder", field.placeholder)
const translatedWarning = fieldText(fieldName, "warning", field.warning)
const translatedHelpText = fieldText(fieldName, "helpText", field.help_text)
// Check depends_on // Check depends_on
if (field.depends_on) { if (field.depends_on) {
@@ -511,7 +531,7 @@ export function SecureGatewaySetup() {
return ( return (
<div key={fieldName} className="space-y-2"> <div key={fieldName} className="space-y-2">
<Label htmlFor={fieldName} className="text-sm font-medium"> <Label htmlFor={fieldName} className="text-sm font-medium">
{field.label} {translatedLabel}
{field.required && <span className="text-red-500 ml-1">*</span>} {field.required && <span className="text-red-500 ml-1">*</span>}
</Label> </Label>
<div className="relative"> <div className="relative">
@@ -520,7 +540,7 @@ export function SecureGatewaySetup() {
type={isVisible ? "text" : "password"} type={isVisible ? "text" : "password"}
value={config[fieldName] || ""} value={config[fieldName] || ""}
onChange={(e) => setConfig({ ...config, [fieldName]: e.target.value })} onChange={(e) => setConfig({ ...config, [fieldName]: e.target.value })}
placeholder={field.placeholder} placeholder={translatedPlaceholder}
className="pr-10 bg-background border-border" className="pr-10 bg-background border-border"
/> />
<button <button
@@ -536,7 +556,7 @@ export function SecureGatewaySetup() {
{isVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} {isVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button> </button>
</div> </div>
<p className="text-xs text-muted-foreground">{field.description}</p> <p className="text-xs text-muted-foreground">{translatedDescription}</p>
{field.help_url && ( {field.help_url && (
<a <a
href={field.help_url} href={field.help_url}
@@ -544,7 +564,7 @@ export function SecureGatewaySetup() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-xs text-cyan-500 hover:text-cyan-400 inline-flex items-center gap-1" className="text-xs text-cyan-500 hover:text-cyan-400 inline-flex items-center gap-1"
> >
{field.help_text || "Learn more"} <ExternalLink className="h-3 w-3" /> {translatedHelpText || sg("learnMore")} <ExternalLink className="h-3 w-3" />
</a> </a>
)} )}
</div> </div>
@@ -554,7 +574,7 @@ export function SecureGatewaySetup() {
return ( return (
<div key={fieldName} className="space-y-2"> <div key={fieldName} className="space-y-2">
<Label htmlFor={fieldName} className="text-sm font-medium"> <Label htmlFor={fieldName} className="text-sm font-medium">
{field.label} {translatedLabel}
{field.required && <span className="text-red-500 ml-1">*</span>} {field.required && <span className="text-red-500 ml-1">*</span>}
</Label> </Label>
<Input <Input
@@ -562,10 +582,10 @@ export function SecureGatewaySetup() {
type="text" type="text"
value={config[fieldName] || ""} value={config[fieldName] || ""}
onChange={(e) => setConfig({ ...config, [fieldName]: e.target.value })} onChange={(e) => setConfig({ ...config, [fieldName]: e.target.value })}
placeholder={field.placeholder} placeholder={translatedPlaceholder}
className="bg-background border-border" className="bg-background border-border"
/> />
<p className="text-xs text-muted-foreground">{field.description}</p> <p className="text-xs text-muted-foreground">{translatedDescription}</p>
</div> </div>
) )
@@ -596,7 +616,7 @@ export function SecureGatewaySetup() {
return ( return (
<div key={fieldName} className="space-y-3"> <div key={fieldName} className="space-y-3">
<Label className="text-sm font-medium"> <Label className="text-sm font-medium">
{field.label} {translatedLabel}
{field.required && <span className="text-red-500 ml-1">*</span>} {field.required && <span className="text-red-500 ml-1">*</span>}
</Label> </Label>
<div className="space-y-2"> <div className="space-y-2">
@@ -619,15 +639,15 @@ export function SecureGatewaySetup() {
)} )}
</div> </div>
<div className="flex-1"> <div className="flex-1">
<p className="font-medium text-sm">{opt.label}</p> <p className="font-medium text-sm">{optionText(fieldName, opt.value, "label", opt.label)}</p>
{opt.description && ( {opt.description && (
<p className="text-xs text-muted-foreground">{opt.description}</p> <p className="text-xs text-muted-foreground">{optionText(fieldName, opt.value, "description", opt.description)}</p>
)} )}
{/* Show selected network for proxmox_network */} {/* Show selected network for proxmox_network */}
{fieldName === "access_mode" && opt.value === "proxmox_network" && config[fieldName] === "proxmox_network" && ( {fieldName === "access_mode" && opt.value === "proxmox_network" && config[fieldName] === "proxmox_network" && (
<p className="text-xs text-cyan-400 mt-1 flex items-center gap-1"> <p className="text-xs text-cyan-400 mt-1 flex items-center gap-1">
<Network className="h-3 w-3" /> <Network className="h-3 w-3" />
{networks.find((n) => n.recommended)?.subnet || networks[0]?.subnet || "No network detected"} {networks.find((n) => n.recommended)?.subnet || networks[0]?.subnet || sg("noNetworkDetected")}
</p> </p>
)} )}
</div> </div>
@@ -642,13 +662,13 @@ export function SecureGatewaySetup() {
return ( return (
<div key={fieldName} className="space-y-3"> <div key={fieldName} className="space-y-3">
<Label className="text-sm font-medium"> <Label className="text-sm font-medium">
{field.label} {translatedLabel}
</Label> </Label>
<p className="text-xs text-muted-foreground">{field.description}</p> <p className="text-xs text-muted-foreground">{translatedDescription}</p>
<div className="space-y-2 max-h-48 overflow-y-auto"> <div className="space-y-2 max-h-48 overflow-y-auto">
{networks.length === 0 ? ( {networks.length === 0 ? (
<p className="text-sm text-muted-foreground p-3 bg-muted/30 rounded"> <p className="text-sm text-muted-foreground p-3 bg-muted/30 rounded">
No networks detected {sg("noNetworksDetected")}
</p> </p>
) : ( ) : (
networks.map((net) => { networks.map((net) => {
@@ -676,7 +696,7 @@ export function SecureGatewaySetup() {
<span className="font-mono text-sm">{net.subnet}</span> <span className="font-mono text-sm">{net.subnet}</span>
{net.recommended && ( {net.recommended && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-500"> <span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-500">
Recommended {sg("recommended")}
</span> </span>
)} )}
</div> </div>
@@ -705,12 +725,12 @@ export function SecureGatewaySetup() {
> >
<Checkbox checked={config[fieldName] || false} className="pointer-events-none mt-0.5" /> <Checkbox checked={config[fieldName] || false} className="pointer-events-none mt-0.5" />
<div> <div>
<p className="font-medium text-sm">{field.label}</p> <p className="font-medium text-sm">{translatedLabel}</p>
<p className="text-xs text-muted-foreground">{field.description}</p> <p className="text-xs text-muted-foreground">{translatedDescription}</p>
{field.warning && config[fieldName] && ( {field.warning && config[fieldName] && (
<p className="text-xs text-cyan-400 mt-2 flex items-start gap-1.5 bg-cyan-500/10 p-2 rounded"> <p className="text-xs text-cyan-400 mt-2 flex items-start gap-1.5 bg-cyan-500/10 p-2 rounded">
<Info className="h-3 w-3 mt-0.5 flex-shrink-0" /> <Info className="h-3 w-3 mt-0.5 flex-shrink-0" />
{field.warning} {translatedWarning}
</p> </p>
)} )}
</div> </div>
@@ -736,40 +756,40 @@ export function SecureGatewaySetup() {
</div> </div>
</div> </div>
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<h3 className="text-lg font-semibold">Secure Remote Access</h3> <h3 className="text-lg font-semibold">{sg("wizard.introTitle")}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto"> <p className="text-sm text-muted-foreground max-w-md mx-auto">
Deploy a VPN gateway using Tailscale for secure, zero-trust access to your Proxmox infrastructure without opening ports. {sg("wizard.introDescription")}
</p> </p>
</div> </div>
<div className="bg-muted/30 rounded-lg p-4 space-y-3"> <div className="bg-muted/30 rounded-lg p-4 space-y-3">
<h4 className="text-sm font-medium">What you{"'"}ll get:</h4> <h4 className="text-sm font-medium">{sg("wizard.whatYouGet")}</h4>
<ul className="space-y-2 text-sm text-muted-foreground"> <ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-center gap-2"> <li className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" /> <CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
Access ProxMenux Monitor from anywhere {sg("wizard.benefitMonitorAnywhere")}
</li> </li>
<li className="flex items-center gap-2"> <li className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" /> <CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
Secure access to Proxmox web UI {sg("wizard.benefitProxmoxUi")}
</li> </li>
<li className="flex items-center gap-2"> <li className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" /> <CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
Optionally expose VMs and LXC containers {sg("wizard.benefitVmLxc")}
</li> </li>
<li className="flex items-center gap-2"> <li className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" /> <CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
End-to-end encryption {sg("wizard.benefitEncryption")}
</li> </li>
<li className="flex items-center gap-2"> <li className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" /> <CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
No port forwarding required {sg("wizard.benefitNoPorts")}
</li> </li>
</ul> </ul>
</div> </div>
<div className="bg-cyan-500/10 border border-cyan-500/20 rounded-lg p-3"> <div className="bg-cyan-500/10 border border-cyan-500/20 rounded-lg p-3">
<p className="text-xs text-cyan-400 flex items-start gap-2"> <p className="text-xs text-cyan-400 flex items-start gap-2">
<Info className="h-4 w-4 flex-shrink-0 mt-0.5" /> <Info className="h-4 w-4 flex-shrink-0 mt-0.5" />
You{"'"}ll need a free Tailscale account. If you don{"'"}t have one, you can create it at{" "} {sg("wizard.tailscaleAccountBefore")}{" "}
<a href="https://tailscale.com" target="_blank" rel="noopener noreferrer" className="underline hover:text-cyan-300"> <a href="https://tailscale.com" target="_blank" rel="noopener noreferrer" className="underline hover:text-cyan-300">
tailscale.com tailscale.com
</a> </a>
@@ -783,17 +803,17 @@ export function SecureGatewaySetup() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<h3 className="text-lg font-semibold">Review & Deploy</h3> <h3 className="text-lg font-semibold">{sg("wizard.reviewDeploy")}</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Review your configuration before deploying the gateway. {sg("wizard.reviewDescription")}
</p> </p>
</div> </div>
{/* Storage selector */} {/* Storage selector */}
{storages.length > 1 && ( {storages.length > 1 && (
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-sm font-medium">Storage Location</Label> <Label className="text-sm font-medium">{sg("wizard.storageLocation")}</Label>
<p className="text-xs text-muted-foreground">Select where to create the container disk.</p> <p className="text-xs text-muted-foreground">{sg("wizard.storageDescription")}</p>
<div className="space-y-2"> <div className="space-y-2">
{storages.filter(s => s.active && s.enabled).map((storage) => ( {storages.filter(s => s.active && s.enabled).map((storage) => (
<div <div
@@ -819,12 +839,12 @@ export function SecureGatewaySetup() {
<span className="text-xs text-muted-foreground">({storage.type})</span> <span className="text-xs text-muted-foreground">({storage.type})</span>
{storage.recommended && ( {storage.recommended && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-500"> <span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-500">
Recommended {sg("recommended")}
</span> </span>
)} )}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{(storage.avail / 1024 / 1024 / 1024).toFixed(1)} GB available {sg("wizard.gbAvailable", { amount: (storage.avail / 1024 / 1024 / 1024).toFixed(1) })}
</p> </p>
</div> </div>
</div> </div>
@@ -835,41 +855,41 @@ export function SecureGatewaySetup() {
)} )}
<div className="bg-muted/30 rounded-lg p-4 space-y-3"> <div className="bg-muted/30 rounded-lg p-4 space-y-3">
<h4 className="text-sm font-medium">Configuration Summary</h4> <h4 className="text-sm font-medium">{sg("wizard.configurationSummary")}</h4>
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Hostname:</span> <span className="text-muted-foreground">{sg("wizard.hostname")}:</span>
<span className="font-mono">{config.hostname || "proxmox-gateway"}</span> <span className="font-mono">{config.hostname || "proxmox-gateway"}</span>
</div> </div>
{storages.length > 1 && ( {storages.length > 1 && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Storage:</span> <span className="text-muted-foreground">{sg("wizard.storage")}:</span>
<span className="font-mono">{config.storage || storages[0]?.name}</span> <span className="font-mono">{config.storage || storages[0]?.name}</span>
</div> </div>
)} )}
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Access Mode:</span> <span className="text-muted-foreground">{sg("wizard.accessMode")}:</span>
<span>{config.access_mode === "host_only" ? "Host Only" : config.access_mode === "proxmox_network" ? "Proxmox Network" : "Custom Networks"}</span> <span>{config.access_mode === "host_only" ? sg("wizard.accessModes.hostOnly") : config.access_mode === "proxmox_network" ? sg("wizard.accessModes.proxmoxNetwork") : sg("wizard.accessModes.customNetworks")}</span>
</div> </div>
{config.access_mode === "host_only" && hostIp && ( {config.access_mode === "host_only" && hostIp && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Host Access:</span> <span className="text-muted-foreground">{sg("wizard.hostAccess")}:</span>
<span className="text-right font-mono text-xs">{hostIp}/32</span> <span className="text-right font-mono text-xs">{hostIp}/32</span>
</div> </div>
)} )}
{(config.access_mode === "proxmox_network" || config.access_mode === "custom") && config.advertise_routes?.length > 0 && ( {(config.access_mode === "proxmox_network" || config.access_mode === "custom") && config.advertise_routes?.length > 0 && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Networks:</span> <span className="text-muted-foreground">{sg("wizard.networks")}:</span>
<span className="text-right font-mono text-xs">{config.advertise_routes.join(", ")}</span> <span className="text-right font-mono text-xs">{config.advertise_routes.join(", ")}</span>
</div> </div>
)} )}
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Exit Node:</span> <span className="text-muted-foreground">{sg("wizard.exitNode")}:</span>
<span>{config.exit_node ? "Yes" : "No"}</span> <span>{config.exit_node ? sg("values.yes") : sg("values.no")}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Accept Routes:</span> <span className="text-muted-foreground">{sg("wizard.acceptRoutes")}:</span>
<span>{config.accept_routes ? "Yes" : "No"}</span> <span>{config.accept_routes ? sg("values.yes") : sg("values.no")}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -880,12 +900,12 @@ export function SecureGatewaySetup() {
<p className="text-xs text-cyan-400 flex items-start gap-2"> <p className="text-xs text-cyan-400 flex items-start gap-2">
<Info className="h-4 w-4 flex-shrink-0 mt-0.5" /> <Info className="h-4 w-4 flex-shrink-0 mt-0.5" />
<span> <span>
<strong>Important:</strong> After deployment, you must approve the subnet route in Tailscale Admin for remote access to work. <strong>{sg("wizard.important")}:</strong> {sg("wizard.approvalRequired")}
{config.exit_node && <span> You{"'"}ll also need to approve the exit node.</span>} {config.exit_node && <span> {sg("wizard.exitNodeApprovalRequired")}</span>}
</span> </span>
</p> </p>
<p className="text-xs text-muted-foreground ml-6"> <p className="text-xs text-muted-foreground ml-6">
We{"'"}ll show you exactly what to do after the gateway is deployed. {sg("wizard.showAfterDeploy")}
</p> </p>
</div> </div>
)} )}
@@ -915,8 +935,8 @@ export function SecureGatewaySetup() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<h3 className="text-lg font-semibold">{step.title}</h3> <h3 className="text-lg font-semibold">{stepText(step, "title")}</h3>
<p className="text-sm text-muted-foreground">{step.description}</p> <p className="text-sm text-muted-foreground">{stepText(step, "description")}</p>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{step.fields?.map((fieldName) => renderField(fieldName))} {step.fields?.map((fieldName) => renderField(fieldName))}
@@ -932,7 +952,7 @@ export function SecureGatewaySetup() {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-cyan-500" /> <ShieldCheck className="h-5 w-5 text-cyan-500" />
<CardTitle className="text-base">Secure Gateway</CardTitle> <CardTitle className="text-base">{sg("title")}</CardTitle>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -953,14 +973,14 @@ export function SecureGatewaySetup() {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-cyan-500" /> <ShieldCheck className="h-5 w-5 text-cyan-500" />
<CardTitle className="text-base">Secure Gateway</CardTitle> <CardTitle className="text-base">{sg("title")}</CardTitle>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-3 py-2"> <div className="space-y-3 py-2">
<p className="text-sm text-red-500">Could not load setup data: {loadError}</p> <p className="text-sm text-red-500">{sg("errors.couldNotLoadSetupData")} {loadError}</p>
<Button size="sm" variant="outline" onClick={() => loadInitialData()}> <Button size="sm" variant="outline" onClick={() => loadInitialData()}>
Retry {sg("retry")}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -981,7 +1001,7 @@ export function SecureGatewaySetup() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-cyan-500" /> <ShieldCheck className="h-5 w-5 text-cyan-500" />
<CardTitle className="text-base">Secure Gateway</CardTitle> <CardTitle className="text-base">{sg("title")}</CardTitle>
</div> </div>
<div className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${ <div className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${
isRunning ? "bg-green-500/10 text-green-500" : isRunning ? "bg-green-500/10 text-green-500" :
@@ -991,16 +1011,16 @@ export function SecureGatewaySetup() {
{isRunning ? <Wifi className="h-3 w-3" /> : {isRunning ? <Wifi className="h-3 w-3" /> :
isStopped ? <Square className="h-3 w-3" /> : isStopped ? <Square className="h-3 w-3" /> :
<XCircle className="h-3 w-3" />} <XCircle className="h-3 w-3" />}
{isRunning ? "Connected" : isStopped ? "Stopped" : "Error"} {isRunning ? sg("status.connected") : isStopped ? sg("status.stopped") : sg("status.error")}
</div> </div>
</div> </div>
<CardDescription>Tailscale VPN Gateway</CardDescription> <CardDescription>{sg("installed.description")}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Status info */} {/* Status info */}
{isRunning && appStatus.uptime_seconds > 0 && ( {isRunning && appStatus.uptime_seconds > 0 && (
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Uptime: {formatUptime(appStatus.uptime_seconds)} {sg("installed.uptime")}: {formatUptime(appStatus.uptime_seconds)}
</div> </div>
)} )}
@@ -1018,7 +1038,7 @@ export function SecureGatewaySetup() {
) : ( ) : (
<Play className="h-4 w-4 mr-1" /> <Play className="h-4 w-4 mr-1" />
)} )}
Start {sg("actions.start")}
</Button> </Button>
)} )}
{isRunning && ( {isRunning && (
@@ -1034,7 +1054,7 @@ export function SecureGatewaySetup() {
) : ( ) : (
<Square className="h-4 w-4 mr-1" /> <Square className="h-4 w-4 mr-1" />
)} )}
Stop {sg("actions.stop")}
</Button> </Button>
<Button <Button
size="sm" size="sm"
@@ -1047,7 +1067,7 @@ export function SecureGatewaySetup() {
) : ( ) : (
<RotateCw className="h-4 w-4 mr-1" /> <RotateCw className="h-4 w-4 mr-1" />
)} )}
Restart {sg("actions.restart")}
</Button> </Button>
</> </>
)} )}
@@ -1060,7 +1080,7 @@ export function SecureGatewaySetup() {
}} }}
> >
<FileText className="h-4 w-4 mr-1" /> <FileText className="h-4 w-4 mr-1" />
Logs {sg("actions.logs")}
</Button> </Button>
<Button <Button
size="sm" size="sm"
@@ -1070,7 +1090,7 @@ export function SecureGatewaySetup() {
disabled={actionLoading !== null} disabled={actionLoading !== null}
> >
<Trash2 className="h-4 w-4 mr-1" /> <Trash2 className="h-4 w-4 mr-1" />
Remove {sg("actions.remove")}
</Button> </Button>
</div> </div>
@@ -1083,9 +1103,9 @@ export function SecureGatewaySetup() {
<> <>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Last checked: {formatLastChecked(updateInfo.last_checked_iso)} ·{" "} {sg("updates.lastChecked")}: {formatLastChecked(updateInfo.last_checked_iso)} ·{" "}
<span className="text-purple-400 font-medium"> <span className="text-purple-400 font-medium">
Tailscale v{updateInfo.latest_version} available {sg("updates.tailscaleAvailable", { version: updateInfo.latest_version || "" })}
</span> </span>
</div> </div>
</div> </div>
@@ -1101,24 +1121,23 @@ export function SecureGatewaySetup() {
<ArrowUpCircle className="h-4 w-4 mr-1.5" /> <ArrowUpCircle className="h-4 w-4 mr-1.5" />
)} )}
{updateApplying {updateApplying
? "Updating" ? sg("updates.updating")
: `Update to v${updateInfo.latest_version}`} : sg("updates.updateToVersion", { version: updateInfo.latest_version || "" })}
</Button> </Button>
{updateInfo.packages && updateInfo.packages.length > 1 && ( {updateInfo.packages && updateInfo.packages.length > 1 && (
<div className="text-[11px] text-muted-foreground"> <div className="text-[11px] text-muted-foreground">
+{updateInfo.packages.length - 1} other package {sg("updates.otherPackagesPending", { count: updateInfo.packages.length - 1 })}
{updateInfo.packages.length > 2 ? "s" : ""} pending in the container
</div> </div>
)} )}
</> </>
) : ( ) : (
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Last checked: {formatLastChecked(updateInfo.last_checked_iso)} {sg("updates.lastChecked")}: {formatLastChecked(updateInfo.last_checked_iso)}
{updateInfo.current_version {updateInfo.current_version
? ` · Tailscale v${updateInfo.current_version}` ? ` · Tailscale v${updateInfo.current_version}`
: ""} : ""}
{" · "} {" · "}
<span className="text-green-500/80">No updates available</span> <span className="text-green-500/80">{sg("updates.noneAvailable")}</span>
</div> </div>
)} )}
{updateError && ( {updateError && (
@@ -1146,7 +1165,7 @@ export function SecureGatewaySetup() {
className="text-xs h-7 px-2" className="text-xs h-7 px-2"
> >
<Key className="h-3 w-3 mr-1" /> <Key className="h-3 w-3 mr-1" />
Update Auth Key {sg("authKey.update")}
</Button> </Button>
<a <a
href="https://login.tailscale.com/admin/machines" href="https://login.tailscale.com/admin/machines"
@@ -1154,7 +1173,7 @@ export function SecureGatewaySetup() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-xs text-cyan-500 hover:text-cyan-400 inline-flex items-center gap-1" className="text-xs text-cyan-500 hover:text-cyan-400 inline-flex items-center gap-1"
> >
Open Tailscale Admin <ExternalLink className="h-3 w-3" /> {sg("tailscale.openAdmin")} <ExternalLink className="h-3 w-3" />
</a> </a>
</div> </div>
</CardContent> </CardContent>
@@ -1164,8 +1183,8 @@ export function SecureGatewaySetup() {
<Dialog open={showLogs} onOpenChange={setShowLogs}> <Dialog open={showLogs} onOpenChange={setShowLogs}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Secure Gateway Logs</DialogTitle> <DialogTitle>{sg("logs.title")}</DialogTitle>
<DialogDescription>Recent container logs</DialogDescription> <DialogDescription>{sg("logs.description")}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="bg-black/50 rounded-lg p-4 max-h-96 overflow-auto"> <div className="bg-black/50 rounded-lg p-4 max-h-96 overflow-auto">
{logsLoading ? ( {logsLoading ? (
@@ -1174,14 +1193,14 @@ export function SecureGatewaySetup() {
</div> </div>
) : ( ) : (
<pre className="text-xs font-mono text-green-400 whitespace-pre-wrap"> <pre className="text-xs font-mono text-green-400 whitespace-pre-wrap">
{logs || "No logs available"} {logs || sg("logs.empty")}
</pre> </pre>
)} )}
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button variant="outline" size="sm" onClick={loadLogs}> <Button variant="outline" size="sm" onClick={loadLogs}>
<RotateCw className="h-4 w-4 mr-1" /> <RotateCw className="h-4 w-4 mr-1" />
Refresh {t("actions.refresh")}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
@@ -1191,14 +1210,14 @@ export function SecureGatewaySetup() {
<Dialog open={showRemoveConfirm} onOpenChange={setShowRemoveConfirm}> <Dialog open={showRemoveConfirm} onOpenChange={setShowRemoveConfirm}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Remove Secure Gateway?</DialogTitle> <DialogTitle>{sg("remove.title")}</DialogTitle>
<DialogDescription> <DialogDescription>
This will stop and remove the gateway container. Your Tailscale state will be preserved for re-deployment. {sg("remove.description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setShowRemoveConfirm(false)}> <Button variant="outline" onClick={() => setShowRemoveConfirm(false)}>
Cancel {t("actions.cancel")}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
@@ -1210,7 +1229,7 @@ export function SecureGatewaySetup() {
) : ( ) : (
<Trash2 className="h-4 w-4 mr-1" /> <Trash2 className="h-4 w-4 mr-1" />
)} )}
Remove {sg("actions.remove")}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
@@ -1228,16 +1247,16 @@ export function SecureGatewaySetup() {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Key className="h-5 w-5 text-cyan-500" /> <Key className="h-5 w-5 text-cyan-500" />
Update Auth Key {sg("authKey.update")}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Enter a new Tailscale auth key to re-authenticate the gateway. This is useful if your previous key has expired. {sg("authKey.description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">New Auth Key</label> <label className="text-sm font-medium">{sg("authKey.newKey")}</label>
<Input <Input
type="password" type="password"
value={newAuthKey} value={newAuthKey}
@@ -1246,14 +1265,14 @@ export function SecureGatewaySetup() {
className="font-mono text-sm" className="font-mono text-sm"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Generate a new key at{" "} {sg("authKey.generateAt")}{" "}
<a <a
href="https://login.tailscale.com/admin/settings/keys" href="https://login.tailscale.com/admin/settings/keys"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-cyan-500 hover:text-cyan-400 underline" className="text-cyan-500 hover:text-cyan-400 underline"
> >
Tailscale Admin &gt; Settings &gt; Keys {sg("authKey.adminKeys")}
</a> </a>
</p> </p>
</div> </div>
@@ -1267,7 +1286,7 @@ export function SecureGatewaySetup() {
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setShowUpdateAuthKey(false)}> <Button variant="outline" onClick={() => setShowUpdateAuthKey(false)}>
Cancel {t("actions.cancel")}
</Button> </Button>
<Button <Button
onClick={handleUpdateAuthKey} onClick={handleUpdateAuthKey}
@@ -1279,7 +1298,7 @@ export function SecureGatewaySetup() {
) : ( ) : (
<Key className="h-4 w-4 mr-2" /> <Key className="h-4 w-4 mr-2" />
)} )}
Update Key {sg("authKey.updateKey")}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
@@ -1291,10 +1310,10 @@ export function SecureGatewaySetup() {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" /> <CheckCircle className="h-5 w-5 text-green-500" />
Gateway Deployed Successfully {sg("postDeploy.title")}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
One more step to complete the setup {sg("postDeploy.description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -1302,17 +1321,17 @@ export function SecureGatewaySetup() {
<div className="bg-cyan-500/10 border border-cyan-500/20 rounded-lg p-4"> <div className="bg-cyan-500/10 border border-cyan-500/20 rounded-lg p-4">
<p className="text-sm font-medium text-cyan-400 flex items-center gap-2 mb-2"> <p className="text-sm font-medium text-cyan-400 flex items-center gap-2 mb-2">
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
Next Step: Approve in Tailscale Admin {sg("postDeploy.nextStep")}
</p> </p>
<p className="text-sm text-muted-foreground mb-3"> <p className="text-sm text-muted-foreground mb-3">
You need to approve the following settings in your Tailscale admin console for them to take effect: {sg("postDeploy.approveDescription")}
</p> </p>
<ul className="space-y-2 text-sm"> <ul className="space-y-2 text-sm">
{deployedConfig.advertise_routes?.length > 0 && ( {deployedConfig.advertise_routes?.length > 0 && (
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<Network className="h-4 w-4 text-cyan-500 mt-0.5 flex-shrink-0" /> <Network className="h-4 w-4 text-cyan-500 mt-0.5 flex-shrink-0" />
<div> <div>
<span className="font-medium">Subnet Routes:</span> <span className="font-medium">{sg("postDeploy.subnetRoutes")}:</span>
<span className="text-muted-foreground ml-1"> <span className="text-muted-foreground ml-1">
{deployedConfig.advertise_routes.join(", ")} {deployedConfig.advertise_routes.join(", ")}
</span> </span>
@@ -1323,9 +1342,9 @@ export function SecureGatewaySetup() {
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<Globe className="h-4 w-4 text-cyan-500 mt-0.5 flex-shrink-0" /> <Globe className="h-4 w-4 text-cyan-500 mt-0.5 flex-shrink-0" />
<div> <div>
<span className="font-medium">Exit Node:</span> <span className="font-medium">{sg("postDeploy.exitNode")}:</span>
<span className="text-muted-foreground ml-1"> <span className="text-muted-foreground ml-1">
Route all internet traffic {sg("postDeploy.routeAllTraffic")}
</span> </span>
</div> </div>
</li> </li>
@@ -1334,30 +1353,30 @@ export function SecureGatewaySetup() {
</div> </div>
<div className="bg-muted/30 rounded-lg p-4 space-y-2"> <div className="bg-muted/30 rounded-lg p-4 space-y-2">
<p className="text-sm font-medium">How to approve:</p> <p className="text-sm font-medium">{sg("postDeploy.howToApprove")}</p>
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside"> <ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
<li>Click the button below to open Tailscale Admin</li> <li>{sg("postDeploy.stepOpenAdmin")}</li>
<li>Find <span className="font-mono text-cyan-400">{deployedConfig.hostname || "proxmox-gateway"}</span> in the machines list</li> <li>{sg("postDeploy.stepFindBefore")} <span className="font-mono text-cyan-400">{deployedConfig.hostname || "proxmox-gateway"}</span> {sg("postDeploy.stepFindAfter")}</li>
<li>Click on it to open machine details</li> <li>{sg("postDeploy.stepOpenDetails")}</li>
<li>In the <strong>Subnets</strong> section, click <strong>Edit</strong> and enable the route</li> <li>{sg("postDeploy.stepSubnetsBefore")} <strong>Subnets</strong> {sg("postDeploy.stepSubnetsMiddle")} <strong>Edit</strong> {sg("postDeploy.stepSubnetsAfter")}</li>
{deployedConfig.exit_node && ( {deployedConfig.exit_node && (
<li>In <strong>Routing Settings</strong>, enable <strong>Exit Node</strong></li> <li>{sg("postDeploy.stepRoutingBefore")} <strong>Routing Settings</strong>, {sg("postDeploy.stepRoutingMiddle")} <strong>Exit Node</strong></li>
)} )}
</ol> </ol>
</div> </div>
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-3"> <div className="bg-green-500/10 border border-green-500/20 rounded-lg p-3">
<p className="text-xs text-green-400"> <p className="text-xs text-green-400">
Once approved, you can access your Proxmox host at{" "} {sg("postDeploy.accessAfterApproval")}{" "}
<span className="font-mono">{deployedConfig.advertise_routes?.[0]?.replace("/32", "") || hostIp}:8006</span> (Proxmox UI) or{" "} <span className="font-mono">{deployedConfig.advertise_routes?.[0]?.replace("/32", "") || hostIp}:8006</span> (Proxmox UI) {sg("postDeploy.or")}{" "}
<span className="font-mono">{deployedConfig.advertise_routes?.[0]?.replace("/32", "") || hostIp}:8008</span> (ProxMenux Monitor) from any device with Tailscale. <span className="font-mono">{deployedConfig.advertise_routes?.[0]?.replace("/32", "") || hostIp}:8008</span> (ProxMenux Monitor) {sg("postDeploy.fromAnyDevice")}
</p> </p>
</div> </div>
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setShowPostDeployInfo(false)}> <Button variant="outline" onClick={() => setShowPostDeployInfo(false)}>
I{"'"}ll do it later {sg("postDeploy.doLater")}
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
@@ -1366,7 +1385,7 @@ export function SecureGatewaySetup() {
}} }}
className="bg-cyan-600 hover:bg-cyan-700" className="bg-cyan-600 hover:bg-cyan-700"
> >
Open Tailscale Admin {sg("tailscale.openAdmin")}
<ExternalLink className="h-4 w-4 ml-2" /> <ExternalLink className="h-4 w-4 ml-2" />
</Button> </Button>
</div> </div>
@@ -1383,13 +1402,13 @@ export function SecureGatewaySetup() {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-cyan-500" /> <ShieldCheck className="h-5 w-5 text-cyan-500" />
<CardTitle className="text-base">Secure Gateway</CardTitle> <CardTitle className="text-base">{sg("title")}</CardTitle>
</div> </div>
<CardDescription>VPN access without opening ports</CardDescription> <CardDescription>{sg("notInstalled.subtitle")}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Deploy a Tailscale VPN gateway for secure remote access to your Proxmox infrastructure. No port forwarding required. {sg("notInstalled.description")}
</p> </p>
<Button <Button
@@ -1397,7 +1416,7 @@ export function SecureGatewaySetup() {
className="bg-cyan-600 hover:bg-cyan-700" className="bg-cyan-600 hover:bg-cyan-700"
> >
<ShieldCheck className="h-4 w-4 mr-2" /> <ShieldCheck className="h-4 w-4 mr-2" />
Deploy Secure Gateway {sg("notInstalled.deploy")}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -1418,7 +1437,7 @@ export function SecureGatewaySetup() {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-cyan-500" /> <ShieldCheck className="h-5 w-5 text-cyan-500" />
Secure Gateway Setup {sg("wizard.setupTitle")}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
@@ -1465,7 +1484,7 @@ export function SecureGatewaySetup() {
}} }}
disabled={currentStep === 0 || deploying} disabled={currentStep === 0 || deploying}
> >
Back {sg("actions.back")}
</Button> </Button>
{currentStep < wizardSteps.length - 1 ? ( {currentStep < wizardSteps.length - 1 ? (
@@ -1480,7 +1499,7 @@ export function SecureGatewaySetup() {
}} }}
className="bg-cyan-600 hover:bg-cyan-700" className="bg-cyan-600 hover:bg-cyan-700"
> >
Continue {sg("actions.continue")}
<ChevronRight className="h-4 w-4 ml-1" /> <ChevronRight className="h-4 w-4 ml-1" />
</Button> </Button>
) : ( ) : (
@@ -1492,12 +1511,12 @@ export function SecureGatewaySetup() {
{deploying ? ( {deploying ? (
<> <>
<Loader2 className="h-4 w-4 animate-spin mr-2" /> <Loader2 className="h-4 w-4 animate-spin mr-2" />
Deploying... {sg("actions.deploying")}
</> </>
) : ( ) : (
<> <>
<Play className="h-4 w-4 mr-2" /> <Play className="h-4 w-4 mr-2" />
Deploy Gateway {sg("actions.deployGateway")}
</> </>
)} )}
</Button> </Button>
File diff suppressed because it is too large Load Diff
+180 -107
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([
@@ -167,13 +169,13 @@ interface SuppressionCategory {
} }
const SUPPRESSION_OPTIONS = [ const SUPPRESSION_OPTIONS = [
{ value: "24", label: "24 hours" }, { value: "24", labelKey: "settings.healthMonitor.options.24h" },
{ value: "72", label: "3 days" }, { value: "72", labelKey: "settings.healthMonitor.options.3d" },
{ value: "168", label: "1 week" }, { value: "168", labelKey: "settings.healthMonitor.options.1w" },
{ value: "720", label: "1 month" }, { value: "720", labelKey: "settings.healthMonitor.options.1m" },
{ value: "8760", label: "1 year" }, { value: "8760", labelKey: "settings.healthMonitor.options.1y" },
{ value: "custom", label: "Custom" }, { value: "custom", labelKey: "settings.healthMonitor.options.custom" },
{ value: "-1", label: "Permanent" }, { value: "-1", labelKey: "settings.healthMonitor.options.permanent" },
] ]
const CATEGORY_ICONS: Record<string, React.ElementType> = { const CATEGORY_ICONS: Record<string, React.ElementType> = {
@@ -246,6 +248,15 @@ function normalizeErrorKey(key: string): string {
return `${desc}: ${resourceParts.join("_")}` return `${desc}: ${resourceParts.join("_")}`
} }
function healthCategoryKey(category: SuppressionCategory): string {
const raw = category.category || category.key.replace(/^suppress_/, "")
const aliases: Record<string, string> = {
disks: "disk",
pve_services: "services",
}
return aliases[raw] || raw
}
interface ProxMenuxTool { interface ProxMenuxTool {
key: string key: string
name: string name: string
@@ -297,6 +308,13 @@ interface NetworkInterface {
} }
export function Settings() { export function Settings() {
const { language, setLanguage, t } = useI18n()
const tFallback = (key: string, fallback: string) => {
const translated = t(key)
return translated === key ? fallback : translated
}
const interfaceTypeLabel = (type: string) =>
tFallback(`network.interfaceTypes.${type.toLowerCase()}`, type)
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)
@@ -469,11 +487,11 @@ export function Settings() {
if (entries.length === 0) return if (entries.length === 0) return
const batch = entries.map(e => `${e.source}:${e.function}:${e.key}`).join("\n") const batch = entries.map(e => `${e.source}:${e.function}:${e.key}`).join("\n")
const title = entries.length === 1 const title = entries.length === 1
? `Update: ${entries[0].name}` ? t("settings.optimizations.updateOneTitle", { name: entries[0].name })
: `Update ${entries.length} optimizations` : t("settings.optimizations.updateManyTitle", { count: entries.length })
const description = entries.length === 1 const description = entries.length === 1
? `Re-running ${entries[0].function} from the ${entries[0].source} flow.` ? t("settings.optimizations.updateOneDescription", { functionName: entries[0].function, source: entries[0].source })
: `Re-running ${entries.length} post-install functions in sequence.` : t("settings.optimizations.updateManyDescription", { count: entries.length })
setUpdateTerminal({ setUpdateTerminal({
open: true, open: true,
title, title,
@@ -899,21 +917,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,14 +1001,14 @@ 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 />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="Bytes">Bytes</SelectItem> <SelectItem value="Bytes">{t("settings.networkUnits.bytes")}</SelectItem>
<SelectItem value="Bits">Bits</SelectItem> <SelectItem value="Bits">{t("settings.networkUnits.bits")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -943,14 +1022,14 @@ export function Settings() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<HeartPulse className="h-5 w-5 text-red-500" /> <HeartPulse className="h-5 w-5 text-red-500" />
<CardTitle>Health Monitor</CardTitle> <CardTitle>{t("settings.healthMonitor.title")}</CardTitle>
</div> </div>
{!loadingHealth && ( {!loadingHealth && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{savedAllHealth && ( {savedAllHealth && (
<span className="flex items-center gap-1 text-xs text-green-500"> <span className="flex items-center gap-1 text-xs text-green-500">
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5" />
Saved {t("status.saved")}
</span> </span>
)} )}
{healthEditMode ? ( {healthEditMode ? (
@@ -960,7 +1039,7 @@ export function Settings() {
onClick={handleCancelEdit} onClick={handleCancelEdit}
disabled={savingAllHealth} disabled={savingAllHealth}
> >
Cancel {t("actions.cancel")}
</button> </button>
<button <button
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5" className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
@@ -972,7 +1051,7 @@ export function Settings() {
) : ( ) : (
<Check className="h-3 w-3" /> <Check className="h-3 w-3" />
)} )}
Save {t("actions.save")}
</button> </button>
</> </>
) : ( ) : (
@@ -981,15 +1060,14 @@ export function Settings() {
onClick={() => setHealthEditMode(true)} onClick={() => setHealthEditMode(true)}
> >
<Settings2 className="h-3 w-3" /> <Settings2 className="h-3 w-3" />
Edit {t("actions.edit")}
</button> </button>
)} )}
</div> </div>
)} )}
</div> </div>
<CardDescription> <CardDescription>
Configure how long dismissed alerts stay suppressed for each category. {t("settings.healthMonitor.description")}
Changes apply immediately to both existing and future dismissed alerts.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -1001,8 +1079,8 @@ export function Settings() {
<div className="space-y-0"> <div className="space-y-0">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between pb-2 mb-1 border-b border-border"> <div className="flex items-center justify-between pb-2 mb-1 border-b border-border">
<span className="text-xs font-medium text-muted-foreground">Category</span> <span className="text-xs font-medium text-muted-foreground">{t("settings.healthMonitor.category")}</span>
<span className="text-xs font-medium text-muted-foreground">Suppression Duration</span> <span className="text-xs font-medium text-muted-foreground">{t("settings.healthMonitor.suppressionDuration")}</span>
</div> </div>
{/* Per-category rows */} {/* Per-category rows */}
@@ -1015,13 +1093,14 @@ export function Settings() {
const isLong = effectiveHours >= 720 && effectiveHours !== -1 && effectiveHours !== -2 const isLong = effectiveHours >= 720 && effectiveHours !== -1 && effectiveHours !== -2
const hasChanged = cat.key in pendingChanges && pendingChanges[cat.key] !== cat.hours const hasChanged = cat.key in pendingChanges && pendingChanges[cat.key] !== cat.hours
const selectVal = isCustomMode ? "custom" : getSelectValue(effectiveHours, cat.key) const selectVal = isCustomMode ? "custom" : getSelectValue(effectiveHours, cat.key)
const catLabel = tFallback(`settings.healthMonitor.categories.${healthCategoryKey(cat)}`, cat.label)
return ( return (
<div key={cat.key}> <div key={cat.key}>
<div className="flex items-center justify-between gap-2 py-2 sm:py-2.5 px-1 sm:px-2"> <div className="flex items-center justify-between gap-2 py-2 sm:py-2.5 px-1 sm:px-2">
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center gap-2 min-w-0 flex-1">
<IconComp className="h-4 w-4 text-muted-foreground shrink-0" /> <IconComp className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-xs sm:text-sm font-medium">{cat.label}</span> <span className="text-xs sm:text-sm font-medium">{catLabel}</span>
{hasChanged && healthEditMode && ( {hasChanged && healthEditMode && (
<span className="h-1.5 w-1.5 rounded-full bg-blue-500 shrink-0" /> <span className="h-1.5 w-1.5 rounded-full bg-blue-500 shrink-0" />
)} )}
@@ -1035,7 +1114,7 @@ export function Settings() {
className="w-16 sm:w-20 h-7 text-xs" className="w-16 sm:w-20 h-7 text-xs"
value={customValues[cat.key] || ""} value={customValues[cat.key] || ""}
onChange={(e) => setCustomValues(prev => ({ ...prev, [cat.key]: e.target.value }))} onChange={(e) => setCustomValues(prev => ({ ...prev, [cat.key]: e.target.value }))}
placeholder="Hours" placeholder={t("settings.healthMonitor.hours")}
/> />
<span className="text-xs text-muted-foreground">h</span> <span className="text-xs text-muted-foreground">h</span>
<button <button
@@ -1074,7 +1153,7 @@ export function Settings() {
<SelectContent> <SelectContent>
{SUPPRESSION_OPTIONS.map((opt) => ( {SUPPRESSION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}> <SelectItem key={opt.value} value={opt.value}>
{opt.label} {t(opt.labelKey)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -1088,10 +1167,10 @@ export function Settings() {
<div className="flex items-start gap-2 ml-6 sm:ml-8 mr-1 mb-2 p-2 rounded-md bg-blue-500/10 border border-blue-500/20"> <div className="flex items-start gap-2 ml-6 sm:ml-8 mr-1 mb-2 p-2 rounded-md bg-blue-500/10 border border-blue-500/20">
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" /> <Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-blue-400/90 leading-relaxed"> <p className="text-[11px] text-blue-400/90 leading-relaxed">
Alerts for <span className="font-semibold">{cat.label}</span> will be permanently suppressed when dismissed. {t("settings.healthMonitor.permanentNotice", { category: catLabel })}
{cat.category === "temperature" && ( {cat.category === "temperature" && (
<span className="block mt-0.5 text-blue-300/80"> <span className="block mt-0.5 text-blue-300/80">
Critical CPU temperature alerts will still trigger for hardware safety. {t("settings.healthMonitor.temperatureSafetyNotice")}
</span> </span>
)} )}
</p> </p>
@@ -1103,7 +1182,7 @@ export function Settings() {
<div className="flex items-start gap-2 ml-6 sm:ml-8 mr-1 mb-2 p-2 rounded-md bg-blue-500/10 border border-blue-500/20"> <div className="flex items-start gap-2 ml-6 sm:ml-8 mr-1 mb-2 p-2 rounded-md bg-blue-500/10 border border-blue-500/20">
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" /> <Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-blue-400/90 leading-relaxed"> <p className="text-[11px] text-blue-400/90 leading-relaxed">
Long suppression period. Dismissed alerts for this category will not reappear for an extended time. {t("settings.healthMonitor.longSuppressionNotice")}
</p> </p>
</div> </div>
)} )}
@@ -1116,8 +1195,7 @@ export function Settings() {
<div className="flex items-start gap-2 mt-3 pt-3 border-t border-border"> <div className="flex items-start gap-2 mt-3 pt-3 border-t border-border">
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" /> <Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-muted-foreground leading-relaxed"> <p className="text-[11px] text-muted-foreground leading-relaxed">
These settings apply when you dismiss a warning from the Health Monitor. {t("settings.healthMonitor.footerNote")}
Critical CPU temperature alerts always trigger regardless of settings to protect your hardware.
</p> </p>
</div> </div>
@@ -1133,11 +1211,11 @@ export function Settings() {
<div className="pt-8"> <div className="pt-8">
<div className="flex items-center gap-2 mb-1.5"> <div className="flex items-center gap-2 mb-1.5">
<BellOff className="h-4 w-4 text-amber-500" /> <BellOff className="h-4 w-4 text-amber-500" />
<span className="text-sm font-medium">Active Suppressions</span> <span className="text-sm font-medium">{t("settings.healthMonitor.activeSuppressions")}</span>
</div> </div>
<p className="text-sm text-muted-foreground mb-4 leading-relaxed"> <p className="text-sm text-muted-foreground mb-4 leading-relaxed">
Alerts you have silenced from the Health Monitor. Permanent dismisses can only be {t("settings.healthMonitor.activeSuppressionsDescription")}{" "}
reverted here. Editing requires the Health Monitor <span className="font-mono text-xs">Edit</span> mode at the top of this card. <span className="font-mono text-xs">{t("actions.edit")}</span>.
</p> </p>
{loadingSuppressions ? ( {loadingSuppressions ? (
<div className="flex items-center justify-center py-4"> <div className="flex items-center justify-center py-4">
@@ -1145,19 +1223,19 @@ export function Settings() {
</div> </div>
) : activeSuppressions.length === 0 ? ( ) : activeSuppressions.length === 0 ? (
<div className="text-center py-4 text-sm text-muted-foreground"> <div className="text-center py-4 text-sm text-muted-foreground">
No active suppressions. Dismissed alerts from the Health Monitor will appear here. {t("settings.healthMonitor.noActiveSuppressions")}
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{activeSuppressions.map((s) => { {activeSuppressions.map((s) => {
const remaining = s.suppression_remaining_hours const remaining = s.suppression_remaining_hours
const remainingLabel = s.permanent const remainingLabel = s.permanent
? "Permanent" ? t("settings.healthMonitor.permanent")
: remaining === undefined || remaining === null : remaining === undefined || remaining === null
? "Active" ? t("status.active")
: remaining >= 24 : remaining >= 24
? `${Math.round(remaining / 24)}d remaining` ? t("settings.healthMonitor.daysRemaining", { count: Math.round(remaining / 24) })
: `${Math.max(0, Math.round(remaining))}h remaining` : t("settings.healthMonitor.hoursRemaining", { count: Math.max(0, Math.round(remaining)) })
const dismissedAtLabel = s.acknowledged_at const dismissedAtLabel = s.acknowledged_at
? new Date(s.acknowledged_at).toLocaleString() ? new Date(s.acknowledged_at).toLocaleString()
: "" : ""
@@ -1174,7 +1252,7 @@ export function Settings() {
<div className={`flex items-start gap-2 min-w-0 flex-1 ${isQueued ? "opacity-60" : ""}`}> <div className={`flex items-start gap-2 min-w-0 flex-1 ${isQueued ? "opacity-60" : ""}`}>
{s.permanent ? ( {s.permanent ? (
<Badge variant="outline" className="text-sm px-2 py-0.5 shrink-0 text-amber-400 border-amber-400/40 mt-0.5 font-normal"> <Badge variant="outline" className="text-sm px-2 py-0.5 shrink-0 text-amber-400 border-amber-400/40 mt-0.5 font-normal">
Permanent {t("settings.healthMonitor.permanent")}
</Badge> </Badge>
) : ( ) : (
<Badge variant="outline" className="text-sm px-2 py-0.5 shrink-0 text-blue-400 border-blue-400/30 mt-0.5 font-normal"> <Badge variant="outline" className="text-sm px-2 py-0.5 shrink-0 text-blue-400 border-blue-400/30 mt-0.5 font-normal">
@@ -1183,12 +1261,12 @@ export function Settings() {
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className={`text-xs sm:text-sm font-medium text-foreground truncate ${isQueued ? "line-through" : ""}`} title={s.error_key}> <div className={`text-xs sm:text-sm font-medium text-foreground truncate ${isQueued ? "line-through" : ""}`} title={s.error_key}>
{normalizeErrorKey(s.error_key)} {tFallback(`settings.healthMonitor.errorNames.${s.error_key}`, normalizeErrorKey(s.error_key))}
</div> </div>
<div className="text-sm text-muted-foreground flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5"> <div className="text-sm text-muted-foreground flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5">
<span>category: <span className="font-medium text-foreground/80">{s.category || "—"}</span></span> <span>{t("settings.healthMonitor.labels.category")}: <span className="font-medium text-foreground/80">{s.category ? tFallback(`settings.healthMonitor.categories.${s.category}`, s.category) : "—"}</span></span>
{s.severity && <span>severity: <span className="font-medium text-foreground/80">{s.severity}</span></span>} {s.severity && <span>{t("settings.healthMonitor.labels.severity")}: <span className="font-medium text-foreground/80">{tFallback(`status.${s.severity.toLowerCase()}`, s.severity)}</span></span>}
{dismissedAtLabel && <span>dismissed: {dismissedAtLabel}</span>} {dismissedAtLabel && <span>{t("settings.healthMonitor.labels.dismissed")}: {dismissedAtLabel}</span>}
</div> </div>
</div> </div>
</div> </div>
@@ -1204,13 +1282,13 @@ export function Settings() {
onClick={() => handleReEnable(s.error_key)} onClick={() => handleReEnable(s.error_key)}
title={ title={
!healthEditMode !healthEditMode
? "Enable Health Monitor Edit mode to re-enable" ? t("settings.healthMonitor.reEnableDisabledTitle")
: isQueued : isQueued
? "Cancel re-enable (will not be applied on Save)" ? t("settings.healthMonitor.reEnableQueuedTitle")
: "Queue this alert for re-enable on Save" : t("settings.healthMonitor.reEnableTitle")
} }
> >
{isQueued ? "Undo" : "Re-enable"} {isQueued ? t("actions.undo") : t("settings.healthMonitor.reEnable")}
</Button> </Button>
</div> </div>
) )
@@ -1228,11 +1306,10 @@ export function Settings() {
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Database className="h-5 w-5 text-purple-500" /> <Database className="h-5 w-5 text-purple-500" />
<CardTitle>Remote Storage Exclusions</CardTitle> <CardTitle>{t("settings.remoteStorage.title")}</CardTitle>
</div> </div>
<CardDescription> <CardDescription>
Exclude remote storages (PBS, NFS, CIFS, etc.) from health monitoring and notifications. {t("settings.remoteStorage.description")}
Use this for storages that are intentionally offline or have limited API access.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -1243,18 +1320,18 @@ export function Settings() {
) : remoteStorages.length === 0 ? ( ) : remoteStorages.length === 0 ? (
<div className="text-center py-8"> <div className="text-center py-8">
<CloudOff className="h-12 w-12 text-muted-foreground mx-auto mb-3 opacity-50" /> <CloudOff className="h-12 w-12 text-muted-foreground mx-auto mb-3 opacity-50" />
<p className="text-muted-foreground">No remote storages detected</p> <p className="text-muted-foreground">{t("settings.remoteStorage.emptyTitle")}</p>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
PBS, NFS, CIFS, and other remote storages will appear here when configured {t("settings.remoteStorage.emptyDescription")}
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-0"> <div className="space-y-0">
{/* Header */} {/* Header */}
<div className="grid grid-cols-[1fr_auto_auto] gap-4 pb-2 mb-1 border-b border-border"> <div className="grid grid-cols-[1fr_auto_auto] gap-4 pb-2 mb-1 border-b border-border">
<span className="text-xs font-medium text-muted-foreground">Storage</span> <span className="text-xs font-medium text-muted-foreground">{t("settings.remoteStorage.storage")}</span>
<span className="text-xs font-medium text-muted-foreground text-center w-20">Health</span> <span className="text-xs font-medium text-muted-foreground text-center w-20">{t("settings.common.health")}</span>
<span className="text-xs font-medium text-muted-foreground text-center w-20">Alerts</span> <span className="text-xs font-medium text-muted-foreground text-center w-20">{t("settings.common.alerts")}</span>
</div> </div>
{/* Storage rows - scrollable container */} {/* Storage rows - scrollable container */}
@@ -1279,10 +1356,10 @@ export function Settings() {
</Badge> </Badge>
</div> </div>
{isOffline && ( {isOffline && (
<p className="text-[11px] text-red-400 mt-0.5">Offline or unavailable</p> <p className="text-[11px] text-red-400 mt-0.5">{t("settings.remoteStorage.offline")}</p>
)} )}
{isNamespaceRestricted && ( {isNamespaceRestricted && (
<p className="text-[11px] text-blue-400 mt-0.5">Reachable; datastore size hidden by ACL</p> <p className="text-[11px] text-blue-400 mt-0.5">{t("settings.remoteStorage.namespaceRestricted")}</p>
)} )}
</div> </div>
</div> </div>
@@ -1333,9 +1410,9 @@ export function Settings() {
<div className="flex items-start gap-2 mt-3 pt-3 border-t border-border"> <div className="flex items-start gap-2 mt-3 pt-3 border-t border-border">
<Info className="h-3.5 w-3.5 text-purple-400 shrink-0 mt-0.5" /> <Info className="h-3.5 w-3.5 text-purple-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-muted-foreground leading-relaxed"> <p className="text-[11px] text-muted-foreground leading-relaxed">
<strong>Health:</strong> When OFF, the storage won't trigger warnings/critical alerts in the Health Monitor. <strong>{t("settings.common.health")}:</strong> {t("settings.remoteStorage.healthHelp")}
<br /> <br />
<strong>Alerts:</strong> When OFF, no notifications will be sent for this storage. <strong>{t("settings.common.alerts")}:</strong> {t("settings.remoteStorage.alertsHelp")}
</p> </p>
</div> </div>
</div> </div>
@@ -1348,11 +1425,10 @@ export function Settings() {
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Network className="h-5 w-5 text-blue-500" /> <Network className="h-5 w-5 text-blue-500" />
<CardTitle>Network Interface Exclusions</CardTitle> <CardTitle>{t("settings.networkInterfaces.title")}</CardTitle>
</div> </div>
<CardDescription> <CardDescription>
Exclude network interfaces (bridges, bonds, physical NICs) from health monitoring and notifications. {t("settings.networkInterfaces.description")}
Use this for interfaces that are intentionally disabled or unused.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -1363,15 +1439,15 @@ export function Settings() {
) : networkInterfaces.length === 0 ? ( ) : networkInterfaces.length === 0 ? (
<div className="text-center py-8"> <div className="text-center py-8">
<Network className="h-12 w-12 text-muted-foreground mx-auto mb-3 opacity-50" /> <Network className="h-12 w-12 text-muted-foreground mx-auto mb-3 opacity-50" />
<p className="text-muted-foreground">No network interfaces detected</p> <p className="text-muted-foreground">{t("settings.networkInterfaces.emptyTitle")}</p>
</div> </div>
) : ( ) : (
<div className="space-y-0"> <div className="space-y-0">
{/* Header */} {/* Header */}
<div className="grid grid-cols-[1fr_auto_auto] gap-4 pb-2 mb-1 border-b border-border"> <div className="grid grid-cols-[1fr_auto_auto] gap-4 pb-2 mb-1 border-b border-border">
<span className="text-xs font-medium text-muted-foreground">Interface</span> <span className="text-xs font-medium text-muted-foreground">{t("settings.networkInterfaces.interface")}</span>
<span className="text-xs font-medium text-muted-foreground text-center w-20">Health</span> <span className="text-xs font-medium text-muted-foreground text-center w-20">{t("settings.common.health")}</span>
<span className="text-xs font-medium text-muted-foreground text-center w-20">Alerts</span> <span className="text-xs font-medium text-muted-foreground text-center w-20">{t("settings.common.alerts")}</span>
</div> </div>
{/* Interface rows - scrollable container */} {/* Interface rows - scrollable container */}
@@ -1393,21 +1469,21 @@ export function Settings() {
{iface.name} {iface.name}
</span> </span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0"> <Badge variant="outline" className="text-[10px] px-1.5 py-0">
{iface.type} {interfaceTypeLabel(iface.type)}
</Badge> </Badge>
{isDown && !isExcluded && ( {isDown && !isExcluded && (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0"> <Badge variant="destructive" className="text-[10px] px-1.5 py-0">
DOWN {t("settings.networkInterfaces.down")}
</Badge> </Badge>
)} )}
{isExcluded && ( {isExcluded && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-blue-500/10 text-blue-400"> <Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-blue-500/10 text-blue-400">
Excluded {t("settings.networkInterfaces.excluded")}
</Badge> </Badge>
)} )}
</div> </div>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{iface.ip_address || 'No IP'} {iface.speed > 0 ? `- ${iface.speed} Mbps` : ''} {iface.ip_address || t("settings.networkInterfaces.noIp")} {iface.speed > 0 ? `- ${iface.speed} Mbps` : ''}
</span> </span>
</div> </div>
</div> </div>
@@ -1460,9 +1536,9 @@ export function Settings() {
<div className="flex items-start gap-2 mt-3 pt-3 border-t border-border"> <div className="flex items-start gap-2 mt-3 pt-3 border-t border-border">
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" /> <Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-muted-foreground leading-relaxed"> <p className="text-[11px] text-muted-foreground leading-relaxed">
<strong>Health:</strong> When OFF, the interface won't trigger warnings/critical alerts in the Health Monitor. <strong>{t("settings.common.health")}:</strong> {t("settings.networkInterfaces.healthHelp")}
<br /> <br />
<strong>Alerts:</strong> When OFF, no notifications will be sent for this interface. <strong>{t("settings.common.alerts")}:</strong> {t("settings.networkInterfaces.alertsHelp")}
</p> </p>
</div> </div>
</div> </div>
@@ -1492,27 +1568,26 @@ export function Settings() {
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-cyan-500" /> <FileText className="h-5 w-5 text-cyan-500" />
<CardTitle>Snippets storage</CardTitle> <CardTitle>{t("settings.snippets.title")}</CardTitle>
</div> </div>
<CardDescription> <CardDescription>
Where ProxMenux installs hookscripts (e.g. the GPU passthrough guard for VMs/LXCs). {t("settings.snippets.description")}{" "}
Pick a shared storage in cluster setups so VMs and LXCs migrate cleanly between nodes
<code className="mx-1">local</code> <code className="mx-1">local</code>
is node-specific and breaks migration. {" "}{t("settings.snippets.localNote")}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-col md:flex-row md:items-center gap-3"> <div className="flex flex-col md:flex-row md:items-center gap-3">
<Select value={snippetsStorage || ""} onValueChange={saveSnippetsStorage} disabled={snippetsSaving}> <Select value={snippetsStorage || ""} onValueChange={saveSnippetsStorage} disabled={snippetsSaving}>
<SelectTrigger className="w-full md:w-72"> <SelectTrigger className="w-full md:w-72">
<SelectValue placeholder="Pick a storage…" /> <SelectValue placeholder={t("settings.snippets.placeholder")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{snippetsCandidates.map(c => ( {snippetsCandidates.map(c => (
<SelectItem key={c.name} value={c.name} disabled={!c.active}> <SelectItem key={c.name} value={c.name} disabled={!c.active}>
{c.name} {c.name}
<span className="ml-2 text-xs text-muted-foreground"> <span className="ml-2 text-xs text-muted-foreground">
{c.type}{!c.active && " · inactive"} {c.type}{!c.active && ` · ${t("status.inactive")}`}
</span> </span>
</SelectItem> </SelectItem>
))} ))}
@@ -1521,14 +1596,12 @@ export function Settings() {
{snippetsSaving && ( {snippetsSaving && (
<span className="text-xs text-muted-foreground inline-flex items-center gap-1.5"> <span className="text-xs text-muted-foreground inline-flex items-center gap-1.5">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
Saving {t("status.saving")}
</span> </span>
)} )}
</div> </div>
<p className="text-xs text-muted-foreground mt-3"> <p className="text-xs text-muted-foreground mt-3">
Existing VMs/LXCs already configured with the previous storage keep working. {t("settings.snippets.footer")}
Only new GPU passthrough operations (or running &quot;sync hookscripts&quot; on the host)
will use the new selection.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -1539,9 +1612,9 @@ export function Settings() {
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Wrench className="h-5 w-5 text-orange-500" /> <Wrench className="h-5 w-5 text-orange-500" />
<CardTitle>ProxMenux Optimizations</CardTitle> <CardTitle>{t("settings.optimizations.title")}</CardTitle>
</div> </div>
<CardDescription>System optimizations and utilities installed via ProxMenux</CardDescription> <CardDescription>{t("settings.optimizations.description")}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loadingTools ? ( {loadingTools ? (
@@ -1551,15 +1624,15 @@ export function Settings() {
) : proxmenuxTools.length === 0 ? ( ) : proxmenuxTools.length === 0 ? (
<div className="text-center py-8"> <div className="text-center py-8">
<Package className="h-12 w-12 text-muted-foreground mx-auto mb-3 opacity-50" /> <Package className="h-12 w-12 text-muted-foreground mx-auto mb-3 opacity-50" />
<p className="text-muted-foreground">No ProxMenux optimizations installed yet</p> <p className="text-muted-foreground">{t("settings.optimizations.emptyTitle")}</p>
<p className="text-sm text-muted-foreground mt-1">Run ProxMenux to configure system optimizations</p> <p className="text-sm text-muted-foreground mt-1">{t("settings.optimizations.emptyDescription")}</p>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between mb-4 pb-2 border-b border-border"> <div className="flex items-center justify-between mb-4 pb-2 border-b border-border">
<span className="text-sm font-medium text-muted-foreground">Installed Tools</span> <span className="text-sm font-medium text-muted-foreground">{t("settings.optimizations.installedTools")}</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-semibold text-orange-500">{proxmenuxTools.length} active</span> <span className="text-sm font-semibold text-orange-500">{t("settings.optimizations.activeCount", { count: proxmenuxTools.length })}</span>
{/* Sprint 12B: count badge that doubles as the trigger {/* Sprint 12B: count badge that doubles as the trigger
for the multi-select update modal. Only shown when for the multi-select update modal. Only shown when
at least one tool has an available update. */} at least one tool has an available update. */}
@@ -1578,10 +1651,10 @@ export function Settings() {
setUpdateModalOpen(true) setUpdateModalOpen(true)
}} }}
className="flex items-center gap-1.5 text-xs font-semibold text-purple-300 bg-purple-500/15 border border-purple-500/40 hover:bg-purple-500/25 transition-colors rounded-full px-3 py-1" className="flex items-center gap-1.5 text-xs font-semibold text-purple-300 bg-purple-500/15 border border-purple-500/40 hover:bg-purple-500/25 transition-colors rounded-full px-3 py-1"
title="View available updates" title={t("settings.optimizations.viewUpdates")}
> >
<Sparkles className="h-3.5 w-3.5" /> <Sparkles className="h-3.5 w-3.5" />
{updatesAvailableCount} {updatesAvailableCount === 1 ? 'update' : 'updates'} {t("settings.optimizations.updateCount", { count: updatesAvailableCount })}
</button> </button>
)} )}
</div> </div>
@@ -1605,7 +1678,7 @@ export function Settings() {
key={tool.key} key={tool.key}
onClick={clickable ? () => viewToolSource(tool) : undefined} onClick={clickable ? () => viewToolSource(tool) : undefined}
className={`flex items-center justify-between gap-2 p-3 rounded-lg border transition-colors ${baseClasses} ${clickable ? 'cursor-pointer' : ''}`} className={`flex items-center justify-between gap-2 p-3 rounded-lg border transition-colors ${baseClasses} ${clickable ? 'cursor-pointer' : ''}`}
title={clickable ? (isDeprecated ? 'Legacy optimization — click to view source' : 'Click to view source code') : undefined} title={clickable ? (isDeprecated ? t("settings.optimizations.legacySourceTitle") : t("settings.optimizations.sourceTitle")) : undefined}
> >
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${ <div className={`w-2 h-2 rounded-full flex-shrink-0 ${
@@ -1614,7 +1687,7 @@ export function Settings() {
<span className="text-sm font-medium truncate">{tool.name}</span> <span className="text-sm font-medium truncate">{tool.name}</span>
{isDeprecated && ( {isDeprecated && (
<span className="text-[9px] uppercase tracking-wider text-amber-500 bg-amber-500/10 border border-amber-500/30 px-1.5 py-0.5 rounded flex-shrink-0"> <span className="text-[9px] uppercase tracking-wider text-amber-500 bg-amber-500/10 border border-amber-500/30 px-1.5 py-0.5 rounded flex-shrink-0">
legacy {t("settings.optimizations.legacy")}
</span> </span>
)} )}
</div> </div>
@@ -1627,7 +1700,7 @@ export function Settings() {
<button <button
onClick={(e) => { e.stopPropagation(); handleSingleToolUpdate(tool) }} onClick={(e) => { e.stopPropagation(); handleSingleToolUpdate(tool) }}
className="text-purple-300 hover:text-purple-200 transition-colors" className="text-purple-300 hover:text-purple-200 transition-colors"
title={`Update ${tool.name} to v${tool.available_version}`} title={t("settings.optimizations.updateToolTitle", { name: tool.name, version: tool.available_version || "?" })}
> >
<ArrowUpCircle className="h-4 w-4" /> <ArrowUpCircle className="h-4 w-4" />
</button> </button>
@@ -1662,7 +1735,7 @@ export function Settings() {
<h3 className="text-sm font-semibold truncate">{codeModal.toolName}</h3> <h3 className="text-sm font-semibold truncate">{codeModal.toolName}</h3>
{codeModal.deprecated && ( {codeModal.deprecated && (
<span className="text-[9px] uppercase tracking-wider text-amber-500 bg-amber-500/10 border border-amber-500/30 px-1.5 py-0.5 rounded flex-shrink-0"> <span className="text-[9px] uppercase tracking-wider text-amber-500 bg-amber-500/10 border border-amber-500/30 px-1.5 py-0.5 rounded flex-shrink-0">
legacy {t("settings.optimizations.legacy")}
</span> </span>
)} )}
</div> </div>
@@ -1688,10 +1761,10 @@ export function Settings() {
<button <button
onClick={copySourceCode} onClick={copySourceCode}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-muted hover:bg-muted/80 transition-colors" className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-muted hover:bg-muted/80 transition-colors"
title="Copy to clipboard" title={t("actions.copyToClipboard")}
> >
{codeCopied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />} {codeCopied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
{codeCopied ? 'Copied' : 'Copy'} {codeCopied ? t("actions.copied") : t("actions.copy")}
</button> </button>
)} )}
<button <button
@@ -1740,9 +1813,9 @@ export function Settings() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Sparkles className="h-5 w-5 text-purple-400" /> <Sparkles className="h-5 w-5 text-purple-400" />
<div> <div>
<h3 className="text-sm font-semibold">Available updates</h3> <h3 className="text-sm font-semibold">{t("settings.optimizations.availableUpdates")}</h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{updatesAvailableCount} {updatesAvailableCount === 1 ? 'optimization' : 'optimizations'} can be updated to a newer version. {t("settings.optimizations.availableUpdatesDescription", { count: updatesAvailableCount })}
</p> </p>
</div> </div>
</div> </div>
@@ -1799,14 +1872,14 @@ export function Settings() {
<div className="flex items-center justify-between p-4 border-t border-border"> <div className="flex items-center justify-between p-4 border-t border-border">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{selectedUpdates.size} of {updatesAvailableCount} selected {t("settings.optimizations.selectedCount", { selected: selectedUpdates.size, total: updatesAvailableCount })}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => setUpdateModalOpen(false)} onClick={() => setUpdateModalOpen(false)}
className="px-4 py-1.5 text-xs rounded-md bg-muted hover:bg-muted/80 transition-colors" className="px-4 py-1.5 text-xs rounded-md bg-muted hover:bg-muted/80 transition-colors"
> >
Cancel {t("actions.cancel")}
</button> </button>
<button <button
disabled={selectedUpdates.size === 0} disabled={selectedUpdates.size === 0}
@@ -1830,7 +1903,7 @@ export function Settings() {
className="flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium rounded-md bg-purple-500 hover:bg-purple-600 text-white transition-colors disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed" className="flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium rounded-md bg-purple-500 hover:bg-purple-600 text-white transition-colors disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed"
> >
<ArrowUpCircle className="h-3.5 w-3.5" /> <ArrowUpCircle className="h-3.5 w-3.5" />
Update selected {t("settings.optimizations.updateSelected")}
</button> </button>
</div> </div>
</div> </div>
+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>
) )
File diff suppressed because it is too large Load Diff
+130 -122
View File
@@ -29,6 +29,7 @@ import {
} from "lucide-react" } from "lucide-react"
import { useState, useEffect, useMemo } from "react" import { useState, useEffect, useMemo } from "react"
import { API_PORT, fetchApi, getApiUrl, getAuthToken } from "@/lib/api-config" import { API_PORT, fetchApi, getApiUrl, getAuthToken } from "@/lib/api-config"
import { useT } from "@/lib/i18n/provider"
interface Backup { interface Backup {
volid: string volid: string
@@ -88,6 +89,7 @@ interface CombinedLogEntry {
} }
export function SystemLogs() { export function SystemLogs() {
const t = useT()
const [logs, setLogs] = useState<SystemLog[]>([]) const [logs, setLogs] = useState<SystemLog[]>([])
const [backups, setBackups] = useState<Backup[]>([]) const [backups, setBackups] = useState<Backup[]>([])
const [events, setEvents] = useState<Event[]>([]) const [events, setEvents] = useState<Event[]>([])
@@ -150,7 +152,7 @@ export function SystemLogs() {
setLogsCounts(countsRes) setLogsCounts(countsRes)
} catch (err) { } catch (err) {
if (cancelled) return if (cancelled) return
setError("Failed to connect to server") setError(t("systemLogs.errors.connectFailed"))
} finally { } finally {
if (!cancelled) setLoading(false) if (!cancelled) setLoading(false)
} }
@@ -177,7 +179,7 @@ export function SystemLogs() {
const data = await fetchApi<{ logs?: SystemLog[] } | SystemLog[]>(apiUrl) const data = await fetchApi<{ logs?: SystemLog[] } | SystemLog[]>(apiUrl)
return Array.isArray(data) ? data : data.logs || [] return Array.isArray(data) ? data : data.logs || []
} catch { } catch {
setError("Failed to load logs. Please try again.") setError(t("systemLogs.errors.loadFailed"))
return [] return []
} }
} }
@@ -203,26 +205,26 @@ export function SystemLogs() {
// Generate log content // Generate log content
const logContent = [ const logContent = [
`Proxmox System Logs & Events Export`, t("systemLogs.export.title"),
`Generated: ${new Date().toISOString()}`, `${t("systemLogs.fields.generated")}: ${new Date().toISOString()}`,
`Total Entries: ${filteredCombinedLogs.length.toLocaleString()}`, `${t("systemLogs.cards.totalEntries")}: ${filteredCombinedLogs.length.toLocaleString()}`,
``, ``,
`Filters Applied:`, `${t("systemLogs.export.filtersApplied")}:`,
`- Date Range: ${dateFilter === "custom" ? `${customDays} days ago` : `${dateFilter} day(s) ago`}`, `- ${t("systemLogs.filters.dateRange")}: ${t("systemLogs.filters.daysAgo", { count: dateFilter === "custom" ? customDays : dateFilter })}`,
`- Level: ${levelFilter === "all" ? "All Levels" : levelFilter}`, `- ${t("systemLogs.fields.level")}: ${levelFilter === "all" ? t("systemLogs.filters.allLevels") : levelLabel(levelFilter)}`,
`- Service: ${serviceFilter === "all" ? "All Services" : serviceFilter}`, `- ${t("systemLogs.fields.service")}: ${serviceFilter === "all" ? t("systemLogs.filters.allServices") : serviceFilter}`,
`- Search: ${searchTerm || "None"}`, `- ${t("systemLogs.fields.search")}: ${searchTerm || t("systemLogs.fields.none")}`,
``, ``,
`${"=".repeat(80)}`, `${"=".repeat(80)}`,
``, ``,
...filteredCombinedLogs.map((log) => { ...filteredCombinedLogs.map((log) => {
const lines = [ const lines = [
`[${log.timestamp}] ${log.level.toUpperCase()} - ${log.service}${log.isEvent ? " [EVENT]" : ""}`, `[${log.timestamp}] ${levelLabel(log.level)} - ${log.service}${log.isEvent ? ` [${t("systemLogs.badges.event")}]` : ""}`,
`Message: ${log.message}`, `${t("systemLogs.fields.message")}: ${log.message}`,
`Source: ${log.source}`, `${t("systemLogs.fields.source")}: ${log.source}`,
] ]
if (log.pid) lines.push(`PID: ${log.pid}`) if (log.pid) lines.push(`PID: ${log.pid}`)
if (log.hostname) lines.push(`Hostname: ${log.hostname}`) if (log.hostname) lines.push(`${t("systemLogs.fields.hostname")}: ${log.hostname}`)
lines.push(`${"-".repeat(80)}`) lines.push(`${"-".repeat(80)}`)
return lines.join("\n") return lines.join("\n")
}), }),
@@ -273,13 +275,13 @@ export function SystemLogs() {
// Download the complete task log // Download the complete task log
const blob = new Blob( const blob = new Blob(
[ [
`Proxmox Task Log\n`, `${t("systemLogs.download.taskLog")}\n`,
`================\n\n`, `================\n\n`,
`UPID: ${upid}\n`, `UPID: ${upid}\n`,
`Timestamp: ${notification.timestamp}\n`, `${t("systemLogs.fields.timestamp")}: ${notification.timestamp}\n`,
`Service: ${notification.service}\n`, `${t("systemLogs.fields.service")}: ${notification.service}\n`,
`Source: ${notification.source}\n\n`, `${t("systemLogs.fields.source")}: ${notification.source}\n\n`,
`Complete Task Log:\n`, `${t("systemLogs.download.completeTaskLog")}:\n`,
`${"-".repeat(80)}\n`, `${"-".repeat(80)}\n`,
`${taskLog}\n`, `${taskLog}\n`,
], ],
@@ -303,13 +305,13 @@ export function SystemLogs() {
// If no UPID or failed to fetch task log, download the notification message // If no UPID or failed to fetch task log, download the notification message
const blob = new Blob( const blob = new Blob(
[ [
`Notification Details\n`, `${t("systemLogs.modals.notificationTitle")}\n`,
`==================\n\n`, `==================\n\n`,
`Timestamp: ${notification.timestamp}\n`, `${t("systemLogs.fields.timestamp")}: ${notification.timestamp}\n`,
`Type: ${notification.type}\n`, `${t("systemLogs.fields.type")}: ${notification.type}\n`,
`Service: ${notification.service}\n`, `${t("systemLogs.fields.service")}: ${notification.service}\n`,
`Source: ${notification.source}\n\n`, `${t("systemLogs.fields.source")}: ${notification.source}\n\n`,
`Complete Message:\n`, `${t("systemLogs.download.completeMessage")}:\n`,
`${notification.message}\n`, `${notification.message}\n`,
], ],
{ type: "text/plain" }, { type: "text/plain" },
@@ -342,7 +344,7 @@ export function SystemLogs() {
level: event.level, level: event.level,
service: event.type, service: event.type,
message: `${event.type}${event.vmid ? ` (VM/CT ${event.vmid})` : ""} - ${event.status}`, message: `${event.type}${event.vmid ? ` (VM/CT ${event.vmid})` : ""} - ${event.status}`,
source: `Node: ${event.node} • User: ${event.user}`, source: `${t("systemLogs.fields.node")}: ${event.node}${t("systemLogs.fields.user")}: ${event.user}`,
isEvent: true, isEvent: true,
eventData: event, eventData: event,
sortTimestamp: new Date(event.starttime).getTime(), sortTimestamp: new Date(event.starttime).getTime(),
@@ -392,6 +394,12 @@ export function SystemLogs() {
} }
} }
const levelLabel = (level: string) => {
const key = `systemLogs.levels.${safeToLowerCase(level)}`
const translated = t(key)
return translated === key ? String(level).toUpperCase() : translated
}
const getLevelIcon = (level: string) => { const getLevelIcon = (level: string) => {
switch (level) { switch (level) {
case "error": case "error":
@@ -551,15 +559,15 @@ export function SystemLogs() {
const getSectionLabel = (section: string) => { const getSectionLabel = (section: string) => {
switch (section) { switch (section) {
case "logs": case "logs":
return "Logs" return t("systemLogs.tabs.logs")
case "events": case "events":
return "Events" return t("systemLogs.tabs.events")
case "backups": case "backups":
return "Backups" return t("systemLogs.tabs.backups")
case "notifications": case "notifications":
return "Notifications" return t("systemLogs.tabs.notifications")
default: default:
return "Logs" return t("systemLogs.tabs.logs")
} }
} }
@@ -570,8 +578,8 @@ export function SystemLogs() {
<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 logs...</div> <div className="text-sm font-medium text-foreground">{t("systemLogs.loading.title")}</div>
<p className="text-xs text-muted-foreground">Fetching system logs and events</p> <p className="text-xs text-muted-foreground">{t("systemLogs.loading.description")}</p>
</div> </div>
) )
} }
@@ -585,7 +593,7 @@ export function SystemLogs() {
<div className="h-10 w-10 rounded-full border-2 border-muted"></div> <div className="h-10 w-10 rounded-full border-2 border-muted"></div>
<div className="absolute inset-0 h-10 w-10 rounded-full border-2 border-transparent border-t-primary animate-spin"></div> <div className="absolute inset-0 h-10 w-10 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
</div> </div>
<div className="text-sm font-medium text-foreground">Loading logs...</div> <div className="text-sm font-medium text-foreground">{t("systemLogs.loading.title")}</div>
</div> </div>
</div> </div>
)} )}
@@ -594,42 +602,42 @@ export function SystemLogs() {
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4 xl:gap-6"> <div className="grid grid-cols-2 xl:grid-cols-4 gap-4 xl:gap-6">
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Total Entries</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">{t("systemLogs.cards.totalEntries")}</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" /> <FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold text-foreground"> <div className="text-2xl font-bold text-foreground">
{(logsCounts?.total ?? 0).toLocaleString("fr-FR")} {(logsCounts?.total ?? 0).toLocaleString("fr-FR")}
</div> </div>
<p className="text-xs text-muted-foreground mt-2">In selected range</p> <p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.selectedRange")}</p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Errors</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">{t("systemLogs.cards.errors")}</CardTitle>
<XCircle className="h-4 w-4 text-red-500" /> <XCircle className="h-4 w-4 text-red-500" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold text-red-500">{(logsCounts?.errors ?? 0).toLocaleString("fr-FR")}</div> <div className="text-2xl font-bold text-red-500">{(logsCounts?.errors ?? 0).toLocaleString("fr-FR")}</div>
<p className="text-xs text-muted-foreground mt-2">Requires attention</p> <p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.requiresAttention")}</p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Warnings</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">{t("systemLogs.cards.warnings")}</CardTitle>
<AlertTriangle className="h-4 w-4 text-yellow-500" /> <AlertTriangle className="h-4 w-4 text-yellow-500" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold text-yellow-500">{(logsCounts?.warnings ?? 0).toLocaleString("fr-FR")}</div> <div className="text-2xl font-bold text-yellow-500">{(logsCounts?.warnings ?? 0).toLocaleString("fr-FR")}</div>
<p className="text-xs text-muted-foreground mt-2">Monitor closely</p> <p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.monitorClosely")}</p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Backups</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">{t("systemLogs.cards.backups")}</CardTitle>
<Database className="h-4 w-4 text-blue-500" /> <Database className="h-4 w-4 text-blue-500" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -645,11 +653,11 @@ export function SystemLogs() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-foreground flex items-center"> <CardTitle className="text-foreground flex items-center">
<Activity className="h-5 w-5 mr-2" /> <Activity className="h-5 w-5 mr-2" />
System Logs & Events {t("systemLogs.title")}
</CardTitle> </CardTitle>
<Button variant="outline" size="sm" onClick={refreshData} disabled={loading}> <Button variant="outline" size="sm" onClick={refreshData} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} /> <RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />
Refresh {t("actions.refresh")}
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
@@ -658,18 +666,18 @@ export function SystemLogs() {
<TabsList className="hidden md:grid w-full grid-cols-3"> <TabsList className="hidden md:grid w-full grid-cols-3">
<TabsTrigger value="logs" className="data-[state=active]:bg-blue-500 data-[state=active]:text-white"> <TabsTrigger value="logs" className="data-[state=active]:bg-blue-500 data-[state=active]:text-white">
<Terminal className="h-4 w-4 mr-2" /> <Terminal className="h-4 w-4 mr-2" />
Logs {t("systemLogs.tabs.logs")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="backups" className="data-[state=active]:bg-blue-500 data-[state=active]:text-white"> <TabsTrigger value="backups" className="data-[state=active]:bg-blue-500 data-[state=active]:text-white">
<Database className="h-4 w-4 mr-2" /> <Database className="h-4 w-4 mr-2" />
Backups {t("systemLogs.tabs.backups")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="notifications" value="notifications"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white" className="data-[state=active]:bg-blue-500 data-[state=active]:text-white"
> >
<Bell className="h-4 w-4 mr-2" /> <Bell className="h-4 w-4 mr-2" />
Notifications {t("systemLogs.tabs.notifications")}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@@ -691,7 +699,7 @@ export function SystemLogs() {
</SheetTrigger> </SheetTrigger>
<SheetContent side="left" className="w-[280px]"> <SheetContent side="left" className="w-[280px]">
<SheetHeader> <SheetHeader>
<SheetTitle>Sections</SheetTitle> <SheetTitle>{t("systemLogs.sections")}</SheetTitle>
</SheetHeader> </SheetHeader>
<div className="mt-6 space-y-2"> <div className="mt-6 space-y-2">
<Button <Button
@@ -707,7 +715,7 @@ export function SystemLogs() {
}} }}
> >
<Terminal className="h-4 w-4" /> <Terminal className="h-4 w-4" />
Logs {t("systemLogs.tabs.logs")}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -722,7 +730,7 @@ export function SystemLogs() {
}} }}
> >
<Database className="h-4 w-4" /> <Database className="h-4 w-4" />
Backups {t("systemLogs.tabs.backups")}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -737,7 +745,7 @@ export function SystemLogs() {
}} }}
> >
<Bell className="h-4 w-4" /> <Bell className="h-4 w-4" />
Notifications {t("systemLogs.tabs.notifications")}
</Button> </Button>
</div> </div>
</SheetContent> </SheetContent>
@@ -751,7 +759,7 @@ export function SystemLogs() {
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder="Search logs & events..." placeholder={t("systemLogs.filters.searchPlaceholder")}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 bg-background border-border" className="pl-10 bg-background border-border"
@@ -761,22 +769,22 @@ export function SystemLogs() {
<Select value={dateFilter} onValueChange={setDateFilter}> <Select value={dateFilter} onValueChange={setDateFilter}>
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border"> <SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
<SelectValue placeholder="Time range" /> <SelectValue placeholder={t("systemLogs.filters.timeRange")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="1">1 day ago</SelectItem> <SelectItem value="1">{t("systemLogs.filters.oneDay")}</SelectItem>
<SelectItem value="3">3 days ago</SelectItem> <SelectItem value="3">{t("systemLogs.filters.threeDays")}</SelectItem>
<SelectItem value="7">1 week ago</SelectItem> <SelectItem value="7">{t("systemLogs.filters.oneWeek")}</SelectItem>
<SelectItem value="14">2 weeks ago</SelectItem> <SelectItem value="14">{t("systemLogs.filters.twoWeeks")}</SelectItem>
<SelectItem value="30">1 month ago</SelectItem> <SelectItem value="30">{t("systemLogs.filters.oneMonth")}</SelectItem>
<SelectItem value="custom">Custom days</SelectItem> <SelectItem value="custom">{t("systemLogs.filters.customDays")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{dateFilter === "custom" && ( {dateFilter === "custom" && (
<Input <Input
type="number" type="number"
placeholder="Days ago" placeholder={t("systemLogs.filters.daysAgoPlaceholder")}
value={customDays} value={customDays}
onChange={(e) => setCustomDays(e.target.value)} onChange={(e) => setCustomDays(e.target.value)}
className="w-full sm:w-[120px] bg-background border-border" className="w-full sm:w-[120px] bg-background border-border"
@@ -786,23 +794,23 @@ export function SystemLogs() {
<Select value={levelFilter} onValueChange={setLevelFilter}> <Select value={levelFilter} onValueChange={setLevelFilter}>
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border"> <SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
<SelectValue placeholder="Filter by level" /> <SelectValue placeholder={t("systemLogs.filters.byLevel")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All Levels</SelectItem> <SelectItem value="all">{t("systemLogs.filters.allLevels")}</SelectItem>
<SelectItem value="error">Error</SelectItem> <SelectItem value="error">{t("systemLogs.levels.error")}</SelectItem>
<SelectItem value="warning">Warning</SelectItem> <SelectItem value="warning">{t("systemLogs.levels.warning")}</SelectItem>
<SelectItem value="info">Info</SelectItem> <SelectItem value="info">{t("systemLogs.levels.info")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={serviceFilter} onValueChange={setServiceFilter}> <Select value={serviceFilter} onValueChange={setServiceFilter}>
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border"> <SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
<SelectValue placeholder="Filter by service" /> <SelectValue placeholder={t("systemLogs.filters.byService")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem key="service-all" value="all"> <SelectItem key="service-all" value="all">
All Services {t("systemLogs.filters.allServices")}
</SelectItem> </SelectItem>
{uniqueServices.map((service) => ( {uniqueServices.map((service) => (
<SelectItem key={`service-${service}`} value={service}> <SelectItem key={`service-${service}`} value={service}>
@@ -814,7 +822,7 @@ export function SystemLogs() {
<Button variant="outline" className="border-border bg-transparent" onClick={handleDownloadLogs}> <Button variant="outline" className="border-border bg-transparent" onClick={handleDownloadLogs}>
<Download className="h-4 w-4 mr-2" /> <Download className="h-4 w-4 mr-2" />
Export Logs {t("systemLogs.export.button")}
</Button> </Button>
</div> </div>
@@ -844,12 +852,12 @@ export function SystemLogs() {
<div className="flex-shrink-0 flex gap-2 flex-wrap"> <div className="flex-shrink-0 flex gap-2 flex-wrap">
<Badge variant="outline" className={getLevelColor(log.level)}> <Badge variant="outline" className={getLevelColor(log.level)}>
{getLevelIcon(log.level)} {getLevelIcon(log.level)}
{log.level.toUpperCase()} {levelLabel(log.level)}
</Badge> </Badge>
{log.eventData && ( {log.eventData && (
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20"> <Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
<Activity className="h-3 w-3 mr-1" /> <Activity className="h-3 w-3 mr-1" />
EVENT {t("systemLogs.badges.event")}
</Badge> </Badge>
)} )}
</div> </div>
@@ -866,9 +874,9 @@ export function SystemLogs() {
</div> </div>
<div className="text-xs text-muted-foreground truncate overflow-hidden"> <div className="text-xs text-muted-foreground truncate overflow-hidden">
{log.source} {log.source}
{log.unit && log.unit !== log.service && `Unit: ${log.unit}`} {log.unit && log.unit !== log.service && `${t("systemLogs.fields.unit")}: ${log.unit}`}
{log.pid && ` • PID: ${log.pid}`} {log.pid && ` • PID: ${log.pid}`}
{log.hostname && `Host: ${log.hostname}`} {log.hostname && `${t("systemLogs.fields.host")}: ${log.hostname}`}
</div> </div>
</div> </div>
</div> </div>
@@ -878,7 +886,7 @@ export function SystemLogs() {
{displayedLogs.length === 0 && ( {displayedLogs.length === 0 && (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-4 opacity-50" /> <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No logs found matching your criteria</p> <p>{t("systemLogs.empty.logs")}</p>
</div> </div>
)} )}
@@ -890,7 +898,7 @@ export function SystemLogs() {
className="border-border" className="border-border"
> >
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
Load More ({filteredCombinedLogs.length - displayedLogsCount} remaining) {t("systemLogs.loadMore", { count: filteredCombinedLogs.length - displayedLogsCount })}
</Button> </Button>
</div> </div>
)} )}
@@ -906,19 +914,19 @@ export function SystemLogs() {
<Card className="bg-card/50 border-border"> <Card className="bg-card/50 border-border">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="text-2xl font-bold text-cyan-500">{backupStats.qemu}</div> <div className="text-2xl font-bold text-cyan-500">{backupStats.qemu}</div>
<p className="text-xs text-muted-foreground mt-1">VM Backups</p> <p className="text-xs text-muted-foreground mt-1">{t("systemLogs.backups.vm")}</p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-card/50 border-border"> <Card className="bg-card/50 border-border">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="text-2xl font-bold text-orange-500">{backupStats.lxc}</div> <div className="text-2xl font-bold text-orange-500">{backupStats.lxc}</div>
<p className="text-xs text-muted-foreground mt-1">LXC Backups</p> <p className="text-xs text-muted-foreground mt-1">{t("systemLogs.backups.lxc")}</p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-card/50 border-border hidden md:block"> <Card className="bg-card/50 border-border hidden md:block">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="text-2xl font-bold text-foreground">{formatBytes(backupStats.totalSize)}</div> <div className="text-2xl font-bold text-foreground">{formatBytes(backupStats.totalSize)}</div>
<p className="text-xs text-muted-foreground mt-1">Total Size</p> <p className="text-xs text-muted-foreground mt-1">{t("systemLogs.backups.totalSize")}</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -927,7 +935,7 @@ export function SystemLogs() {
<Card className="bg-card/50 border-border md:hidden"> <Card className="bg-card/50 border-border md:hidden">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="text-2xl font-bold text-foreground">{formatBytes(backupStats.totalSize)}</div> <div className="text-2xl font-bold text-foreground">{formatBytes(backupStats.totalSize)}</div>
<p className="text-xs text-muted-foreground mt-1">Total Size</p> <p className="text-xs text-muted-foreground mt-1">{t("systemLogs.backups.totalSize")}</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -967,7 +975,7 @@ export function SystemLogs() {
{backup.size_human} {backup.size_human}
</Badge> </Badge>
</div> </div>
<div className="text-xs text-muted-foreground mb-1 truncate">Storage: {backup.storage}</div> <div className="text-xs text-muted-foreground mb-1 truncate">{t("systemLogs.fields.storage")}: {backup.storage}</div>
<div className="text-xs text-muted-foreground flex items-center"> <div className="text-xs text-muted-foreground flex items-center">
<Calendar className="h-3 w-3 mr-1 flex-shrink-0" /> <Calendar className="h-3 w-3 mr-1 flex-shrink-0" />
<span className="truncate">{backup.created}</span> <span className="truncate">{backup.created}</span>
@@ -980,7 +988,7 @@ export function SystemLogs() {
{backups.length === 0 && ( {backups.length === 0 && (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">
<Database className="h-12 w-12 mx-auto mb-4 opacity-50" /> <Database className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No backups found</p> <p>{t("systemLogs.empty.backups")}</p>
</div> </div>
)} )}
</div> </div>
@@ -1006,12 +1014,12 @@ export function SystemLogs() {
> >
<div className="flex-shrink-0 flex gap-2 flex-wrap"> <div className="flex-shrink-0 flex gap-2 flex-wrap">
<Badge variant="outline" className={getNotificationTypeColor(notification.type)}> <Badge variant="outline" className={getNotificationTypeColor(notification.type)}>
{(notification.type || "unknown").toUpperCase()} {notification.type ? levelLabel(notification.type) : t("app.unknown")}
</Badge> </Badge>
<Badge variant="outline" className={getNotificationSourceColor(notification.source)}> <Badge variant="outline" className={getNotificationSourceColor(notification.source)}>
{notification.source === "task-log" && <Activity className="h-3 w-3 mr-1" />} {notification.source === "task-log" && <Activity className="h-3 w-3 mr-1" />}
{notification.source === "journal" && <FileText className="h-3 w-3 mr-1" />} {notification.source === "journal" && <FileText className="h-3 w-3 mr-1" />}
{(notification.source || "unknown").toUpperCase()} {notification.source ? notification.source.toUpperCase() : t("app.unknown")}
</Badge> </Badge>
</div> </div>
@@ -1026,7 +1034,7 @@ export function SystemLogs() {
{notification.message} {notification.message}
</div> </div>
<div className="text-xs text-muted-foreground break-words overflow-hidden"> <div className="text-xs text-muted-foreground break-words overflow-hidden">
Service: {notification.service} Source: {notification.source} {t("systemLogs.fields.service")}: {notification.service} {t("systemLogs.fields.source")}: {notification.source}
</div> </div>
</div> </div>
</div> </div>
@@ -1036,7 +1044,7 @@ export function SystemLogs() {
{notifications.length === 0 && ( {notifications.length === 0 && (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">
<Bell className="h-12 w-12 mx-auto mb-4 opacity-50" /> <Bell className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No notifications found</p> <p>{t("systemLogs.empty.notifications")}</p>
</div> </div>
)} )}
</div> </div>
@@ -1051,55 +1059,55 @@ export function SystemLogs() {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" /> <FileText className="h-5 w-5" />
Log Details {t("systemLogs.modals.logTitle")}
</DialogTitle> </DialogTitle>
<DialogDescription>Complete information about this log entry</DialogDescription> <DialogDescription>{t("systemLogs.modals.logDescription")}</DialogDescription>
</DialogHeader> </DialogHeader>
{selectedLog && ( {selectedLog && (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<div className="text-sm font-medium text-muted-foreground mb-1">Level</div> <div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.level")}</div>
<Badge variant="outline" className={getLevelColor(selectedLog.level)}> <Badge variant="outline" className={getLevelColor(selectedLog.level)}>
{getLevelIcon(selectedLog.level)} {getLevelIcon(selectedLog.level)}
{selectedLog.level.toUpperCase()} {levelLabel(selectedLog.level)}
</Badge> </Badge>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-muted-foreground mb-1">Service</div> <div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.service")}</div>
<div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.service}</div> <div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.service}</div>
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<div className="text-sm font-medium text-muted-foreground mb-1">Timestamp</div> <div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.timestamp")}</div>
<div className="text-sm text-foreground font-mono break-all overflow-hidden"> <div className="text-sm text-foreground font-mono break-all overflow-hidden">
{selectedLog.timestamp} {selectedLog.timestamp}
</div> </div>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-muted-foreground mb-1">Source</div> <div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.source")}</div>
<div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.source}</div> <div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.source}</div>
</div> </div>
{selectedLog.unit && ( {selectedLog.unit && (
<div> <div>
<div className="text-sm font-medium text-muted-foreground mb-1">Systemd Unit</div> <div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.systemdUnit")}</div>
<div className="text-sm text-foreground font-mono break-all overflow-hidden">{selectedLog.unit}</div> <div className="text-sm text-foreground font-mono break-all overflow-hidden">{selectedLog.unit}</div>
</div> </div>
)} )}
{selectedLog.pid && ( {selectedLog.pid && (
<div> <div>
<div className="text-sm font-medium text-muted-foreground mb-1">Process ID</div> <div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.processId")}</div>
<div className="text-sm text-foreground font-mono">{selectedLog.pid}</div> <div className="text-sm text-foreground font-mono">{selectedLog.pid}</div>
</div> </div>
)} )}
{selectedLog.hostname && ( {selectedLog.hostname && (
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<div className="text-sm font-medium text-muted-foreground mb-1">Hostname</div> <div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.hostname")}</div>
<div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.hostname}</div> <div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.hostname}</div>
</div> </div>
)} )}
</div> </div>
<div> <div>
<div className="text-sm font-medium text-muted-foreground mb-2">Message</div> <div className="text-sm font-medium text-muted-foreground mb-2">{t("systemLogs.fields.message")}</div>
<div className="p-4 rounded-lg bg-muted/50 border border-border overflow-hidden"> <div className="p-4 rounded-lg bg-muted/50 border border-border overflow-hidden">
<pre className="text-sm text-foreground whitespace-pre-wrap break-all overflow-hidden"> <pre className="text-sm text-foreground whitespace-pre-wrap break-all overflow-hidden">
{selectedLog.message} {selectedLog.message}
@@ -1116,37 +1124,37 @@ export function SystemLogs() {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" /> <Activity className="h-5 w-5" />
Event Details {t("systemLogs.modals.eventTitle")}
</DialogTitle> </DialogTitle>
<DialogDescription>Complete information about this event</DialogDescription> <DialogDescription>{t("systemLogs.modals.eventDescription")}</DialogDescription>
</DialogHeader> </DialogHeader>
{selectedEvent && ( {selectedEvent && (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex gap-2"> <div className="flex gap-2">
<Badge variant="outline" className={getLevelColor(selectedEvent.level)}> <Badge variant="outline" className={getLevelColor(selectedEvent.level)}>
{getLevelIcon(selectedEvent.level)} {getLevelIcon(selectedEvent.level)}
{selectedEvent.level.toUpperCase()} {levelLabel(selectedEvent.level)}
</Badge> </Badge>
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20"> <Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
<Activity className="h-3 w-3 mr-1" /> <Activity className="h-3 w-3 mr-1" />
EVENT {t("systemLogs.badges.event")}
</Badge> </Badge>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<div className="text-sm font-medium text-muted-foreground mb-1">Message</div> <div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.message")}</div>
<div className="text-sm text-foreground break-words">{selectedEvent.status}</div> <div className="text-sm text-foreground break-words">{selectedEvent.status}</div>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-muted-foreground mb-1">Type</div> <div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.type")}</div>
<div className="text-sm text-foreground break-words">{selectedEvent.type}</div> <div className="text-sm text-foreground break-words">{selectedEvent.type}</div>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-muted-foreground mb-1">Node</div> <div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.node")}</div>
<div className="text-sm text-foreground">{selectedEvent.node}</div> <div className="text-sm text-foreground">{selectedEvent.node}</div>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-muted-foreground mb-1">User</div> <div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.user")}</div>
<div className="text-sm text-foreground break-words">{selectedEvent.user}</div> <div className="text-sm text-foreground break-words">{selectedEvent.user}</div>
</div> </div>
{selectedEvent.vmid && ( {selectedEvent.vmid && (
@@ -1156,15 +1164,15 @@ export function SystemLogs() {
</div> </div>
)} )}
<div> <div>
<div className="text-sm font-medium text-muted-foreground mb-1">Duration</div> <div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.duration")}</div>
<div className="text-sm text-foreground">{selectedEvent.duration}</div> <div className="text-sm text-foreground">{selectedEvent.duration}</div>
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<div className="text-sm font-medium text-muted-foreground mb-1">Start Time</div> <div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.startTime")}</div>
<div className="text-sm text-foreground break-words">{selectedEvent.starttime}</div> <div className="text-sm text-foreground break-words">{selectedEvent.starttime}</div>
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<div className="text-sm font-medium text-muted-foreground mb-1">End Time</div> <div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.endTime")}</div>
<div className="text-sm text-foreground break-words">{selectedEvent.endtime}</div> <div className="text-sm text-foreground break-words">{selectedEvent.endtime}</div>
</div> </div>
</div> </div>
@@ -1186,31 +1194,31 @@ export function SystemLogs() {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Database className="h-5 w-5" /> <Database className="h-5 w-5" />
Backup Details {t("systemLogs.modals.backupTitle")}
</DialogTitle> </DialogTitle>
<DialogDescription>Complete information about this backup</DialogDescription> <DialogDescription>{t("systemLogs.modals.backupDescription")}</DialogDescription>
</DialogHeader> </DialogHeader>
{selectedBackup && ( {selectedBackup && (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<div className="text-sm font-medium text-muted-foreground mb-1">Type</div> <div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.type")}</div>
<Badge variant="outline" className={getBackupTypeColor(selectedBackup.volid)}> <Badge variant="outline" className={getBackupTypeColor(selectedBackup.volid)}>
{getBackupTypeLabel(selectedBackup.volid)} {getBackupTypeLabel(selectedBackup.volid)}
</Badge> </Badge>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-muted-foreground mb-1">Storage Type</div> <div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.storageType")}</div>
<Badge variant="outline" className={getBackupStorageColor(selectedBackup.volid)}> <Badge variant="outline" className={getBackupStorageColor(selectedBackup.volid)}>
{getBackupStorageLabel(selectedBackup.volid)} {getBackupStorageLabel(selectedBackup.volid)}
</Badge> </Badge>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-muted-foreground mb-1">Storage</div> <div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.storage")}</div>
<div className="text-sm text-foreground break-words">{selectedBackup.storage}</div> <div className="text-sm text-foreground break-words">{selectedBackup.storage}</div>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-muted-foreground mb-1">Size</div> <div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.size")}</div>
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20"> <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
{selectedBackup.size_human} {selectedBackup.size_human}
</Badge> </Badge>
@@ -1222,12 +1230,12 @@ export function SystemLogs() {
</div> </div>
)} )}
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<div className="text-sm font-medium text-muted-foreground mb-1">Created</div> <div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.created")}</div>
<div className="text-sm text-foreground break-words">{selectedBackup.created}</div> <div className="text-sm text-foreground break-words">{selectedBackup.created}</div>
</div> </div>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-muted-foreground mb-2">Volume ID</div> <div className="text-sm font-medium text-muted-foreground mb-2">{t("systemLogs.fields.volumeId")}</div>
<div className="p-4 rounded-lg bg-muted/50 border border-border"> <div className="p-4 rounded-lg bg-muted/50 border border-border">
<pre className="text-sm text-foreground font-mono whitespace-pre-wrap break-all"> <pre className="text-sm text-foreground font-mono whitespace-pre-wrap break-all">
{selectedBackup.volid} {selectedBackup.volid}
@@ -1244,38 +1252,38 @@ export function SystemLogs() {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg pr-8"> <DialogTitle className="flex items-center gap-2 text-base sm:text-lg pr-8">
<Bell className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" /> <Bell className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" />
<span className="truncate">Notification Details</span> <span className="truncate">{t("systemLogs.modals.notificationTitle")}</span>
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm"> <DialogDescription className="text-xs sm:text-sm">
Complete information about this notification {t("systemLogs.modals.notificationDescription")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{selectedNotification && ( {selectedNotification && (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
<div> <div>
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">Type</div> <div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">{t("systemLogs.fields.type")}</div>
<Badge variant="outline" className={`${getNotificationTypeColor(selectedNotification.type)} text-xs`}> <Badge variant="outline" className={`${getNotificationTypeColor(selectedNotification.type)} text-xs`}>
{(selectedNotification.type || "unknown").toUpperCase()} {selectedNotification.type ? levelLabel(selectedNotification.type) : t("app.unknown")}
</Badge> </Badge>
</div> </div>
<div> <div>
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">Timestamp</div> <div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">{t("systemLogs.fields.timestamp")}</div>
<div className="text-xs sm:text-sm text-foreground font-mono break-all"> <div className="text-xs sm:text-sm text-foreground font-mono break-all">
{selectedNotification.timestamp} {selectedNotification.timestamp}
</div> </div>
</div> </div>
<div> <div>
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">Service</div> <div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">{t("systemLogs.fields.service")}</div>
<div className="text-xs sm:text-sm text-foreground break-words">{selectedNotification.service}</div> <div className="text-xs sm:text-sm text-foreground break-words">{selectedNotification.service}</div>
</div> </div>
<div> <div>
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">Source</div> <div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">{t("systemLogs.fields.source")}</div>
<div className="text-xs sm:text-sm text-foreground break-words">{selectedNotification.source}</div> <div className="text-xs sm:text-sm text-foreground break-words">{selectedNotification.source}</div>
</div> </div>
</div> </div>
<div> <div>
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-2">Message</div> <div className="text-xs sm:text-sm font-medium text-muted-foreground mb-2">{t("systemLogs.fields.message")}</div>
<div className="p-3 sm:p-4 rounded-lg bg-muted/50 border border-border max-h-[180px] sm:max-h-[300px] overflow-y-auto"> <div className="p-3 sm:p-4 rounded-lg bg-muted/50 border border-border max-h-[180px] sm:max-h-[300px] overflow-y-auto">
<pre className="text-xs sm:text-sm text-foreground whitespace-pre-wrap break-all font-mono"> <pre className="text-xs sm:text-sm text-foreground whitespace-pre-wrap break-all font-mono">
{selectedNotification.message} {selectedNotification.message}
@@ -1289,7 +1297,7 @@ export function SystemLogs() {
className="border-border w-full sm:w-auto text-xs sm:text-sm h-9 sm:h-10" className="border-border w-full sm:w-auto text-xs sm:text-sm h-9 sm:h-10"
> >
<Download className="h-3 w-3 sm:h-4 sm:w-4 mr-2" /> <Download className="h-3 w-3 sm:h-4 sm:w-4 mr-2" />
<span className="truncate">Download Complete Message</span> <span className="truncate">{t("systemLogs.download.completeMessageButton")}</span>
</Button> </Button>
</div> </div>
</div> </div>
+91 -96
View File
@@ -13,6 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
import { fetchApi } from "../lib/api-config" import { fetchApi } from "../lib/api-config"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network" import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { formatStorage } from "../lib/utils" import { formatStorage } from "../lib/utils"
import { useT } from "../lib/i18n/provider"
import { Area, AreaChart, ResponsiveContainer } from "recharts" import { Area, AreaChart, ResponsiveContainer } from "recharts"
interface TempDataPoint { interface TempDataPoint {
@@ -171,6 +172,7 @@ const getUnitsSettings = (): "Bytes" | "Bits" => {
} }
export function SystemOverview() { export function SystemOverview() {
const t = useT()
const [systemData, setSystemData] = useState<SystemData | null>(null) const [systemData, setSystemData] = useState<SystemData | null>(null)
const [vmData, setVmData] = useState<VMData[]>([]) const [vmData, setVmData] = useState<VMData[]>([])
const [storageData, setStorageData] = useState<StorageData | null>(null) const [storageData, setStorageData] = useState<StorageData | null>(null)
@@ -205,7 +207,7 @@ export function SystemOverview() {
setHasAttemptedLoad(true) setHasAttemptedLoad(true)
if (!systemResult) { if (!systemResult) {
setError("Flask server not available. Please ensure the server is running.") setError(t("overview.errors.serverUnavailableDescription"))
return return
} }
@@ -261,7 +263,7 @@ export function SystemOverview() {
clearInterval(networkInterval) clearInterval(networkInterval)
window.removeEventListener("networkUnitChanged" as any, handleUnitChange) window.removeEventListener("networkUnitChanged" as any, handleUnitChange)
} }
}, []) }, [t])
if (!hasAttemptedLoad || loadingStates.system) { if (!hasAttemptedLoad || loadingStates.system) {
return ( return (
@@ -270,8 +272,8 @@ export function SystemOverview() {
<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 system overview...</div> <div className="text-sm font-medium text-foreground">{t("overview.loadingTitle")}</div>
<p className="text-xs text-muted-foreground">Fetching system status and metrics</p> <p className="text-xs text-muted-foreground">{t("overview.loadingDescription")}</p>
</div> </div>
) )
} }
@@ -284,9 +286,9 @@ export function SystemOverview() {
<div className="flex items-center gap-3 text-red-600"> <div className="flex items-center gap-3 text-red-600">
<AlertCircle className="h-6 w-6" /> <AlertCircle className="h-6 w-6" />
<div> <div>
<div className="font-semibold text-lg mb-1">Flask Server Not Available</div> <div className="font-semibold text-lg mb-1">{t("overview.errors.serverUnavailableTitle")}</div>
<div className="text-sm"> <div className="text-sm">
{error || "Unable to connect to the Flask server. Please ensure the server is running and try again."} {error || t("overview.errors.serverUnavailableDescription")}
</div> </div>
</div> </div>
</div> </div>
@@ -305,14 +307,14 @@ export function SystemOverview() {
} }
const getTemperatureStatus = (temp: number) => { const getTemperatureStatus = (temp: number) => {
if (temp === 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" } if (temp === 0) return { status: t("app.notAvailable"), color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
if (temp < 60) return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" } if (temp < 60) return { status: t("status.normal"), color: "bg-green-500/10 text-green-500 border-green-500/20" }
if (temp < 75) return { status: "Warm", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" } if (temp < 75) return { status: t("status.warm"), color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
return { status: "Hot", color: "bg-red-500/10 text-red-500 border-red-500/20" } return { status: t("status.hot"), color: "bg-red-500/10 text-red-500 border-red-500/20" }
} }
const formatUptime = (seconds: number) => { const formatUptime = (seconds: number) => {
if (!seconds || seconds === 0) return "Stopped" if (!seconds || seconds === 0) return t("status.stopped")
const days = Math.floor(seconds / 86400) const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600) const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60) const minutes = Math.floor((seconds % 3600) / 60)
@@ -322,6 +324,19 @@ export function SystemOverview() {
return `${minutes}m` return `${minutes}m`
} }
const formatSystemUptime = (uptime: string) => {
const trimmed = uptime?.trim()
if (!trimmed) return t("app.unknown")
const dayMatch = trimmed.match(/^(\d+)\s+days?,\s*(.+)$/)
if (!dayMatch) return trimmed
const days = Number(dayMatch[1])
const dayKey = days === 1 ? "dayOne" : days >= 2 && days <= 4 ? "dayFew" : "dayMany"
return `${t(`overview.uptimeDuration.${dayKey}`, { count: days })}, ${dayMatch[2]}`
}
const formatBytes = (bytes: number) => { const formatBytes = (bytes: number) => {
return (bytes / 1024 ** 3).toFixed(2) return (bytes / 1024 ** 3).toFixed(2)
} }
@@ -346,40 +361,14 @@ export function SystemOverview() {
const getLoadStatus = (load: number, cores: number) => { const getLoadStatus = (load: number, cores: number) => {
if (load < cores) { if (load < cores) {
return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" } return { status: t("status.normal"), color: "bg-green-500/10 text-green-500 border-green-500/20" }
} else if (load < cores * 1.5) { } else if (load < cores * 1.5) {
return { status: "Moderate", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" } return { status: t("status.moderate"), color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
} else { } else {
return { status: "High", color: "bg-red-500/10 text-red-500 border-red-500/20" } return { status: t("status.high"), color: "bg-red-500/10 text-red-500 border-red-500/20" }
} }
} }
const systemAlerts = []
if (systemData.available_updates && systemData.available_updates > 0) {
systemAlerts.push({
type: "warning",
message: `${systemData.available_updates} updates available`,
})
}
if (vmStats.stopped > 0) {
systemAlerts.push({
type: "info",
message: `${vmStats.stopped} VM${vmStats.stopped > 1 ? "s" : ""} stopped`,
})
}
if (systemData.temperature > 75) {
systemAlerts.push({
type: "warning",
message: "High temperature detected",
})
}
if (localStorage && localStorage.percent > 90) {
systemAlerts.push({
type: "warning",
message: "System storage almost full",
})
}
const loadStatus = getLoadStatus(systemData.load_average[0], systemData.cpu_cores || 8) const loadStatus = getLoadStatus(systemData.load_average[0], systemData.cpu_cores || 8)
const getTimeframeLabel = (timeframe: string): string => { const getTimeframeLabel = (timeframe: string): string => {
@@ -406,10 +395,10 @@ export function SystemOverview() {
<Card <Card
className="bg-card border-border cursor-pointer hover:bg-white/5 transition-colors" className="bg-card border-border cursor-pointer hover:bg-white/5 transition-colors"
onClick={() => setCpuProcModalOpen(true)} onClick={() => setCpuProcModalOpen(true)}
title="View top processes by CPU" title={t("overview.topProcessesCpu")}
> >
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">CPU Usage</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.cpuUsage")}</CardTitle>
<div className="flex items-center gap-1 text-muted-foreground"> <div className="flex items-center gap-1 text-muted-foreground">
<Cpu className="h-4 w-4" /> <Cpu className="h-4 w-4" />
<ChevronRight className="h-4 w-4 opacity-60" /> <ChevronRight className="h-4 w-4 opacity-60" />
@@ -427,7 +416,7 @@ export function SystemOverview() {
<div className="flex-1 space-y-2 min-w-0"> <div className="flex-1 space-y-2 min-w-0">
<div> <div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">User</span> <span className="text-muted-foreground">{t("overview.user")}</span>
<span className="font-medium font-mono whitespace-nowrap">{systemData.cpu_user !== undefined ? `${Math.round(systemData.cpu_user)}%` : '—'}</span> <span className="font-medium font-mono whitespace-nowrap">{systemData.cpu_user !== undefined ? `${Math.round(systemData.cpu_user)}%` : '—'}</span>
</div> </div>
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden"> <div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
@@ -436,7 +425,7 @@ export function SystemOverview() {
</div> </div>
<div> <div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">System</span> <span className="text-muted-foreground">{t("overview.system")}</span>
<span className="font-medium font-mono whitespace-nowrap">{systemData.cpu_system !== undefined ? `${Math.round(systemData.cpu_system)}%` : '—'}</span> <span className="font-medium font-mono whitespace-nowrap">{systemData.cpu_system !== undefined ? `${Math.round(systemData.cpu_system)}%` : '—'}</span>
</div> </div>
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden"> <div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
@@ -444,7 +433,7 @@ export function SystemOverview() {
</div> </div>
</div> </div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Cores</span> <span className="text-muted-foreground">{t("overview.cores")}</span>
<span className="font-medium font-mono whitespace-nowrap">{systemData.cpu_cores ?? '—'}{systemData.cpu_threads ? `/${systemData.cpu_threads}` : ''}</span> <span className="font-medium font-mono whitespace-nowrap">{systemData.cpu_cores ?? '—'}{systemData.cpu_threads ? `/${systemData.cpu_threads}` : ''}</span>
</div> </div>
</div> </div>
@@ -456,10 +445,10 @@ export function SystemOverview() {
<Card <Card
className="bg-card border-border cursor-pointer hover:bg-white/5 transition-colors" className="bg-card border-border cursor-pointer hover:bg-white/5 transition-colors"
onClick={() => setMemProcModalOpen(true)} onClick={() => setMemProcModalOpen(true)}
title="View top processes by memory" title={t("overview.topProcessesMemory")}
> >
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Memory</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.memory")}</CardTitle>
<div className="flex items-center gap-1 text-muted-foreground"> <div className="flex items-center gap-1 text-muted-foreground">
<MemoryStick className="h-4 w-4" /> <MemoryStick className="h-4 w-4" />
<ChevronRight className="h-4 w-4 opacity-60" /> <ChevronRight className="h-4 w-4 opacity-60" />
@@ -477,7 +466,7 @@ export function SystemOverview() {
<div className="flex-1 space-y-2 min-w-0"> <div className="flex-1 space-y-2 min-w-0">
<div> <div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Used</span> <span className="text-muted-foreground">{t("overview.used")}</span>
<span className="font-medium font-mono whitespace-nowrap">{systemData.memory_used.toFixed(1)}</span> <span className="font-medium font-mono whitespace-nowrap">{systemData.memory_used.toFixed(1)}</span>
</div> </div>
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden"> <div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
@@ -486,7 +475,7 @@ export function SystemOverview() {
</div> </div>
<div> <div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Cached</span> <span className="text-muted-foreground">{t("overview.cached")}</span>
<span className="font-medium font-mono whitespace-nowrap">{systemData.memory_cached !== undefined ? systemData.memory_cached.toFixed(1) : '—'}</span> <span className="font-medium font-mono whitespace-nowrap">{systemData.memory_cached !== undefined ? systemData.memory_cached.toFixed(1) : '—'}</span>
</div> </div>
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden"> <div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
@@ -494,7 +483,7 @@ export function SystemOverview() {
</div> </div>
</div> </div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Total</span> <span className="text-muted-foreground">{t("overview.total")}</span>
<span className="font-medium font-mono whitespace-nowrap">{systemData.memory_total.toFixed(0)} GB</span> <span className="font-medium font-mono whitespace-nowrap">{systemData.memory_total.toFixed(0)} GB</span>
</div> </div>
</div> </div>
@@ -505,7 +494,7 @@ export function SystemOverview() {
{/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */} {/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */}
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Active VM &amp; LXC</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.activeVmLxc")}</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" /> <Server className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -521,13 +510,19 @@ export function SystemOverview() {
<span className="text-4xl font-bold leading-none text-foreground">{vmStats.running}</span> <span className="text-4xl font-bold leading-none text-foreground">{vmStats.running}</span>
<span className="text-lg font-medium ml-1 text-muted-foreground">/ {vmStats.vms + vmStats.lxc}</span> <span className="text-lg font-medium ml-1 text-muted-foreground">/ {vmStats.vms + vmStats.lxc}</span>
</div> </div>
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">{vmStats.running} running</Badge> <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
{t("overview.runningCount", { count: vmStats.running })}
</Badge>
</div> </div>
<div className="mt-3 flex gap-1 flex-wrap"> <div className="mt-3 flex gap-1 flex-wrap">
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">{vmStats.vms} VMs</Badge> <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
{t("overview.vmsCount", { count: vmStats.vms })}
</Badge>
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">{vmStats.lxc} LXC</Badge> <Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">{vmStats.lxc} LXC</Badge>
{vmStats.stopped > 0 && ( {vmStats.stopped > 0 && (
<Badge variant="outline" className="bg-muted text-muted-foreground border-border">{vmStats.stopped} stopped</Badge> <Badge variant="outline" className="bg-muted text-muted-foreground border-border">
{t("overview.stoppedCount", { count: vmStats.stopped })}
</Badge>
)} )}
</div> </div>
</> </>
@@ -540,7 +535,7 @@ export function SystemOverview() {
onClick={() => systemData.temperature > 0 && setTempModalOpen(true)} onClick={() => systemData.temperature > 0 && setTempModalOpen(true)}
> >
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Temperature</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.temperature")}</CardTitle>
<div className="flex items-center gap-1 text-muted-foreground"> <div className="flex items-center gap-1 text-muted-foreground">
<Thermometer className="h-4 w-4" /> <Thermometer className="h-4 w-4" />
{systemData.temperature > 0 && ( {systemData.temperature > 0 && (
@@ -551,7 +546,7 @@ export function SystemOverview() {
<CardContent> <CardContent>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xl lg:text-2xl font-bold text-foreground"> <span className="text-xl lg:text-2xl font-bold text-foreground">
{systemData.temperature === 0 ? "N/A" : `${Math.round(systemData.temperature * 10) / 10}°C`} {systemData.temperature === 0 ? t("app.notAvailable") : `${Math.round(systemData.temperature * 10) / 10}°C`}
</span> </span>
<Badge variant="outline" className={`${tempStatus.color}`}> <Badge variant="outline" className={`${tempStatus.color}`}>
{tempStatus.status} {tempStatus.status}
@@ -581,7 +576,7 @@ export function SystemOverview() {
</div> </div>
) : ( ) : (
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2">
{systemData.temperature === 0 ? "No sensor available" : "Collecting data..."} {systemData.temperature === 0 ? t("overview.noSensorAvailable") : t("overview.collectingData")}
</p> </p>
)} )}
</CardContent> </CardContent>
@@ -613,7 +608,7 @@ export function SystemOverview() {
<CardHeader> <CardHeader>
<CardTitle className="text-foreground flex items-center"> <CardTitle className="text-foreground flex items-center">
<HardDrive className="h-5 w-5 mr-2" /> <HardDrive className="h-5 w-5 mr-2" />
Storage Overview {t("overview.storageOverview")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -634,7 +629,7 @@ export function SystemOverview() {
return totalCapacity > 0 ? ( return totalCapacity > 0 ? (
<div className="space-y-2 pb-4 border-b-2 border-border"> <div className="space-y-2 pb-4 border-b-2 border-border">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground">Total Node Capacity:</span> <span className="text-sm font-medium text-foreground">{t("overview.totalNodeCapacity")}</span>
<span className="text-lg font-bold text-foreground"> <span className="text-lg font-bold text-foreground">
{formatStorage(totalCapacity)} {formatStorage(totalCapacity)}
</span> </span>
@@ -646,13 +641,13 @@ export function SystemOverview() {
<div className="flex justify-between items-center mt-1"> <div className="flex justify-between items-center mt-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Used:{" "} {t("overview.used")}:{" "}
<span className="font-semibold text-foreground"> <span className="font-semibold text-foreground">
{formatStorage(totalUsed)} {formatStorage(totalUsed)}
</span> </span>
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Free:{" "} {t("overview.free")}:{" "}
<span className="font-semibold text-green-500"> <span className="font-semibold text-green-500">
{formatStorage(totalAvailable)} {formatStorage(totalAvailable)}
</span> </span>
@@ -666,28 +661,28 @@ export function SystemOverview() {
<div className="space-y-2 pb-3 border-b border-border"> <div className="space-y-2 pb-3 border-b border-border">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Total Capacity:</span> <span className="text-sm text-muted-foreground">{t("overview.totalCapacity")}</span>
<span className="text-lg font-semibold text-foreground">{storageData.total} TB</span> <span className="text-lg font-semibold text-foreground">{storageData.total} TB</span>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Physical Disks:</span> <span className="text-sm text-muted-foreground">{t("overview.physicalDisks")}</span>
<span className="text-sm font-semibold text-foreground"> <span className="text-sm font-semibold text-foreground">
{storageData.disk_count} disk{storageData.disk_count !== 1 ? "s" : ""} {storageData.disk_count} {storageData.disk_count === 1 ? t("overview.diskSingular") : t("overview.diskPlural")}
</span> </span>
</div> </div>
</div> </div>
{vmLxcStorages && vmLxcStorages.length > 0 ? ( {vmLxcStorages && vmLxcStorages.length > 0 ? (
<div className="space-y-2 pb-3 border-b border-border"> <div className="space-y-2 pb-3 border-b border-border">
<div className="text-xs font-medium text-muted-foreground mb-2">VM/LXC Storage</div> <div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.vmLxcStorage")}</div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Used:</span> <span className="text-xs text-muted-foreground">{t("overview.used")}:</span>
<span className="text-sm font-semibold text-foreground"> <span className="text-sm font-semibold text-foreground">
{formatStorage(vmLxcStorageUsed)} {formatStorage(vmLxcStorageUsed)}
</span> </span>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Available:</span> <span className="text-xs text-muted-foreground">{t("overview.available")}:</span>
<span className="text-sm font-semibold text-green-500"> <span className="text-sm font-semibold text-green-500">
{formatStorage(vmLxcStorageAvailable)} {formatStorage(vmLxcStorageAvailable)}
</span> </span>
@@ -702,28 +697,28 @@ export function SystemOverview() {
</div> </div>
{vmLxcStorages.length > 1 && ( {vmLxcStorages.length > 1 && (
<div className="text-xs text-muted-foreground mt-1"> <div className="text-xs text-muted-foreground mt-1">
{vmLxcStorages.length} storage volume{vmLxcStorages.length > 1 ? "s" : ""} {vmLxcStorages.length} {vmLxcStorages.length === 1 ? t("overview.storageVolumeSingular") : t("overview.storageVolumePlural")}
</div> </div>
)} )}
</div> </div>
) : ( ) : (
<div className="space-y-2 pb-3 border-b border-border"> <div className="space-y-2 pb-3 border-b border-border">
<div className="text-xs font-medium text-muted-foreground mb-2">VM/LXC Storage</div> <div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.vmLxcStorage")}</div>
<div className="text-center py-4 text-muted-foreground text-sm">No VM/LXC storage configured</div> <div className="text-center py-4 text-muted-foreground text-sm">{t("overview.noVmLxcStorage")}</div>
</div> </div>
)} )}
{localStorage && ( {localStorage && (
<div className="space-y-2"> <div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground mb-2">Local Storage (System)</div> <div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.localStorageSystem")}</div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Used:</span> <span className="text-xs text-muted-foreground">{t("overview.used")}:</span>
<span className="text-sm font-semibold text-foreground"> <span className="text-sm font-semibold text-foreground">
{formatStorage(localStorage.used)} {formatStorage(localStorage.used)}
</span> </span>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Available:</span> <span className="text-xs text-muted-foreground">{t("overview.available")}:</span>
<span className="text-sm font-semibold text-green-500"> <span className="text-sm font-semibold text-green-500">
{formatStorage(localStorage.available)} {formatStorage(localStorage.available)}
</span> </span>
@@ -740,7 +735,7 @@ export function SystemOverview() {
)} )}
</div> </div>
) : ( ) : (
<div className="text-center py-8 text-muted-foreground">Storage data not available</div> <div className="text-center py-8 text-muted-foreground">{t("overview.storageDataUnavailable")}</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -750,18 +745,18 @@ export function SystemOverview() {
<CardTitle className="text-foreground flex items-center justify-between"> <CardTitle className="text-foreground flex items-center justify-between">
<div className="flex items-center"> <div className="flex items-center">
<Network className="h-5 w-5 mr-2" /> <Network className="h-5 w-5 mr-2" />
Network Overview {t("overview.networkOverview")}
</div> </div>
<Select value={networkTimeframe} onValueChange={setNetworkTimeframe}> <Select value={networkTimeframe} onValueChange={setNetworkTimeframe}>
<SelectTrigger className="w-28 h-8 text-xs"> <SelectTrigger className="w-28 h-8 text-xs">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="hour">1 Hour</SelectItem> <SelectItem value="hour">{t("overview.timeframes.hour")}</SelectItem>
<SelectItem value="day">24 Hours</SelectItem> <SelectItem value="day">{t("overview.timeframes.day")}</SelectItem>
<SelectItem value="week">7 Days</SelectItem> <SelectItem value="week">{t("overview.timeframes.week")}</SelectItem>
<SelectItem value="month">30 Days</SelectItem> <SelectItem value="month">{t("overview.timeframes.month")}</SelectItem>
<SelectItem value="year">1 Year</SelectItem> <SelectItem value="year">{t("overview.timeframes.year")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</CardTitle> </CardTitle>
@@ -776,7 +771,7 @@ export function SystemOverview() {
) : networkData ? ( ) : networkData ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-between items-center pb-3 border-b border-border"> <div className="flex justify-between items-center pb-3 border-b border-border">
<span className="text-sm text-muted-foreground">Active Interfaces:</span> <span className="text-sm text-muted-foreground">{t("overview.activeInterfaces")}</span>
<span className="text-lg font-semibold text-foreground"> <span className="text-lg font-semibold text-foreground">
{(networkData.physical_active_count || 0) + (networkData.bridge_active_count || 0)} {(networkData.physical_active_count || 0) + (networkData.bridge_active_count || 0)}
</span> </span>
@@ -818,7 +813,7 @@ export function SystemOverview() {
<div className="pt-2 border-t border-border space-y-2"> <div className="pt-2 border-t border-border space-y-2">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Received:</span> <span className="text-sm text-muted-foreground">{t("overview.received")}</span>
<span className="text-lg font-semibold text-green-500 flex items-center gap-1"> <span className="text-lg font-semibold text-green-500 flex items-center gap-1">
{" "} {" "}
{networkUnit === "Bytes" {networkUnit === "Bytes"
@@ -828,7 +823,7 @@ export function SystemOverview() {
</span> </span>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Sent:</span> <span className="text-sm text-muted-foreground">{t("overview.sent")}</span>
<span className="text-lg font-semibold text-blue-500 flex items-center gap-1"> <span className="text-lg font-semibold text-blue-500 flex items-center gap-1">
{" "} {" "}
{networkUnit === "Bytes" {networkUnit === "Bytes"
@@ -848,7 +843,7 @@ export function SystemOverview() {
</div> </div>
</div> </div>
) : ( ) : (
<div className="text-center py-8 text-muted-foreground">Network data not available</div> <div className="text-center py-8 text-muted-foreground">{t("overview.networkDataUnavailable")}</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -859,27 +854,27 @@ export function SystemOverview() {
<CardHeader> <CardHeader>
<CardTitle className="text-foreground flex items-center"> <CardTitle className="text-foreground flex items-center">
<Server className="h-5 w-5 mr-2" /> <Server className="h-5 w-5 mr-2" />
System Information {t("overview.systemInformation")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Uptime:</span> <span className="text-muted-foreground">{t("overview.uptime")}</span>
<span className="text-foreground">{systemData.uptime}</span> <span className="text-foreground">{formatSystemUptime(systemData.uptime)}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Proxmox Version:</span> <span className="text-muted-foreground">{t("overview.proxmoxVersion")}</span>
<span className="text-foreground">{systemData.proxmox_version || "N/A"}</span> <span className="text-foreground">{systemData.proxmox_version || "N/A"}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Kernel:</span> <span className="text-muted-foreground">{t("overview.kernel")}</span>
<span className="text-foreground font-mono text-sm">{systemData.kernel_version || "Linux"}</span> <span className="text-foreground font-mono text-sm">{systemData.kernel_version || "Linux"}</span>
</div> </div>
{systemData.available_updates !== undefined && systemData.available_updates > 0 && ( {systemData.available_updates !== undefined && systemData.available_updates > 0 && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Available Updates:</span> <span className="text-muted-foreground">{t("overview.availableUpdates")}</span>
<Badge variant="outline" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20"> <Badge variant="outline" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">
{systemData.available_updates} packages {systemData.available_updates} {t("overview.packages")}
</Badge> </Badge>
</div> </div>
)} )}
@@ -890,13 +885,13 @@ export function SystemOverview() {
<CardHeader> <CardHeader>
<CardTitle className="text-foreground flex items-center"> <CardTitle className="text-foreground flex items-center">
<Zap className="h-5 w-5 mr-2" /> <Zap className="h-5 w-5 mr-2" />
System Overview {t("overview.systemOverview")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex justify-between items-center pb-3 border-b border-border"> <div className="flex justify-between items-center pb-3 border-b border-border">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-sm text-muted-foreground">Load Average (1m):</span> <span className="text-sm text-muted-foreground">{t("overview.loadAverage1m")}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-lg font-semibold text-foreground font-mono"> <span className="text-lg font-semibold text-foreground font-mono">
@@ -909,17 +904,17 @@ export function SystemOverview() {
</div> </div>
<div className="flex justify-between items-center pb-3 border-b border-border"> <div className="flex justify-between items-center pb-3 border-b border-border">
<span className="text-sm text-muted-foreground">CPU Threads:</span> <span className="text-sm text-muted-foreground">{t("overview.cpuThreads")}</span>
<span className="text-lg font-semibold text-foreground">{systemData.cpu_threads || "N/A"}</span> <span className="text-lg font-semibold text-foreground">{systemData.cpu_threads || "N/A"}</span>
</div> </div>
<div className="flex justify-between items-center pb-3 border-b border-border"> <div className="flex justify-between items-center pb-3 border-b border-border">
<span className="text-sm text-muted-foreground">Physical Disks:</span> <span className="text-sm text-muted-foreground">{t("overview.physicalDisks")}</span>
<span className="text-lg font-semibold text-foreground">{storageData?.disk_count || "N/A"}</span> <span className="text-lg font-semibold text-foreground">{storageData?.disk_count || "N/A"}</span>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Network Interfaces:</span> <span className="text-sm text-muted-foreground">{t("overview.networkInterfaces")}</span>
<span className="text-lg font-semibold text-foreground"> <span className="text-lg font-semibold text-foreground">
{networkData?.physical_total_count || networkData?.physical_interfaces?.length || "N/A"} {networkData?.physical_total_count || networkData?.physical_interfaces?.length || "N/A"}
</span> </span>
@@ -8,12 +8,13 @@ import { Thermometer, TrendingDown, TrendingUp, Minus } from "lucide-react"
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts" import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
import { useIsMobile } from "../hooks/use-mobile" import { useIsMobile } from "../hooks/use-mobile"
import { fetchApi } from "@/lib/api-config" import { fetchApi } from "@/lib/api-config"
import { useT } from "@/lib/i18n/provider"
const TIMEFRAME_OPTIONS = [ const TIMEFRAME_OPTIONS = [
{ value: "hour", label: "1 Hour" }, { value: "hour", labelKey: "overview.timeframes.hour" },
{ value: "day", label: "24 Hours" }, { value: "day", labelKey: "overview.timeframes.day" },
{ value: "week", label: "7 Days" }, { value: "week", labelKey: "overview.timeframes.week" },
{ value: "month", label: "30 Days" }, { value: "month", labelKey: "overview.timeframes.month" },
] ]
interface TempHistoryPoint { interface TempHistoryPoint {
@@ -70,6 +71,7 @@ const getStatusInfo = (temp: number) => {
} }
export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }: TemperatureDetailModalProps) { export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }: TemperatureDetailModalProps) {
const t = useT()
// Default to 24 h — matches the disk temperature modal and is the // Default to 24 h — matches the disk temperature modal and is the
// useful timeframe for spotting trends; the 1-h view rarely tells // useful timeframe for spotting trends; the 1-h view rarely tells
// you anything that the live reading doesn't already show. // you anything that the live reading doesn't already show.
@@ -138,7 +140,7 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
<div className="flex items-center justify-between pr-6"> <div className="flex items-center justify-between pr-6">
<DialogTitle className="text-foreground flex items-center gap-2"> <DialogTitle className="text-foreground flex items-center gap-2">
<Thermometer className="h-5 w-5" /> <Thermometer className="h-5 w-5" />
CPU Temperature {t("details.temperature.title")}
</DialogTitle> </DialogTitle>
<Select value={timeframe} onValueChange={setTimeframe}> <Select value={timeframe} onValueChange={setTimeframe}>
<SelectTrigger className="w-[130px] bg-card border-border"> <SelectTrigger className="w-[130px] bg-card border-border">
@@ -147,7 +149,7 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
<SelectContent> <SelectContent>
{TIMEFRAME_OPTIONS.map((opt) => ( {TIMEFRAME_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}> <SelectItem key={opt.value} value={opt.value}>
{opt.label} {t(opt.labelKey)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -158,24 +160,24 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
{/* Stats bar */} {/* Stats bar */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
<div className={`rounded-lg p-3 text-center ${currentStatus.color}`}> <div className={`rounded-lg p-3 text-center ${currentStatus.color}`}>
<div className="text-xs opacity-80 mb-1">Current</div> <div className="text-xs opacity-80 mb-1">{t("details.temperature.current")}</div>
<div className="text-lg font-bold">{currentTemp}°C</div> <div className="text-lg font-bold">{currentTemp}°C</div>
</div> </div>
<div className="bg-muted/50 rounded-lg p-3 text-center"> <div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1"> <div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<TrendingDown className="h-3 w-3" /> Min <TrendingDown className="h-3 w-3" /> {t("details.temperature.min")}
</div> </div>
<div className="text-lg font-bold text-green-500">{stats.min}°C</div> <div className="text-lg font-bold text-green-500">{stats.min}°C</div>
</div> </div>
<div className="bg-muted/50 rounded-lg p-3 text-center"> <div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1"> <div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<Minus className="h-3 w-3" /> Avg <Minus className="h-3 w-3" /> {t("details.temperature.avg")}
</div> </div>
<div className="text-lg font-bold text-foreground">{stats.avg}°C</div> <div className="text-lg font-bold text-foreground">{stats.avg}°C</div>
</div> </div>
<div className="bg-muted/50 rounded-lg p-3 text-center"> <div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1"> <div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<TrendingUp className="h-3 w-3" /> Max <TrendingUp className="h-3 w-3" /> {t("details.temperature.max")}
</div> </div>
<div className="text-lg font-bold text-red-500">{stats.max}°C</div> <div className="text-lg font-bold text-red-500">{stats.max}°C</div>
</div> </div>
@@ -194,8 +196,8 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
<div className="h-full flex items-center justify-center text-muted-foreground"> <div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center"> <div className="text-center">
<Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" /> <Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No temperature data available for this period</p> <p>{t("details.temperature.noData")}</p>
<p className="text-sm mt-1">Data is collected every 60 seconds</p> <p className="text-sm mt-1">{t("details.temperature.collectionHint")}</p>
</div> </div>
</div> </div>
) : ( ) : (
@@ -228,7 +230,7 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
<Area <Area
type="monotone" type="monotone"
dataKey="value" dataKey="value"
name="Temperature" name={t("details.temperature.seriesName")}
stroke={chartColor} stroke={chartColor}
strokeWidth={2} strokeWidth={2}
fill="url(#tempGradient)" fill="url(#tempGradient)"
+68 -111
View File
@@ -34,6 +34,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import type { CheatSheetResult } from "@/lib/cheat-sheet-result" // Declare CheatSheetResult here import type { CheatSheetResult } from "@/lib/cheat-sheet-result" // Declare CheatSheetResult here
import { useT } from "@/lib/i18n/provider"
type TerminalPanelProps = { type TerminalPanelProps = {
websocketUrl?: string websocketUrl?: string
@@ -78,74 +79,30 @@ function getApiUrl(endpoint?: string): string {
} }
const proxmoxCommands = [ const proxmoxCommands = [
{ cmd: "pvesh get /nodes", desc: "List all Proxmox nodes" }, "pvesh get /nodes", "pvesh get /nodes/{node}/qemu", "pvesh get /nodes/{node}/lxc",
{ cmd: "pvesh get /nodes/{node}/qemu", desc: "List VMs on a node" }, "pvesh get /nodes/{node}/storage", "pvesh get /nodes/{node}/network", "qm list",
{ cmd: "pvesh get /nodes/{node}/lxc", desc: "List LXC containers on a node" }, "qm start <vmid>", "qm stop <vmid>", "qm shutdown <vmid>", "qm status <vmid>",
{ cmd: "pvesh get /nodes/{node}/storage", desc: "List storage on a node" }, "qm config <vmid>", "qm snapshot <vmid> <snapname>", "pct list", "pct start <vmid>",
{ cmd: "pvesh get /nodes/{node}/network", desc: "List network interfaces" }, "pct stop <vmid>", "pct enter <vmid>", "pct config <vmid>", "pvesm status",
{ cmd: "qm list", desc: "List all QEMU/KVM virtual machines" }, "pvesm list <storage>", "pveperf", "pveversion", "systemctl status pve-cluster",
{ cmd: "qm start <vmid>", desc: "Start a virtual machine" }, "pvecm status", "pvecm nodes", "zpool status", "zpool list", "zfs list", "ls -la",
{ cmd: "qm stop <vmid>", desc: "Stop a virtual machine" }, "cd /path/to/dir", "mkdir dirname", "rm -rf dirname", "cp source dest", "mv source dest",
{ cmd: "qm shutdown <vmid>", desc: "Shutdown a virtual machine gracefully" }, "cat filename", "grep 'pattern' file", "find . -name 'file'", "chmod 755 file",
{ cmd: "qm status <vmid>", desc: "Show VM status" }, "chown user:group file", "tar -xzf file.tar.gz", "tar -czf archive.tar.gz dir/", "df -h",
{ cmd: "qm config <vmid>", desc: "Show VM configuration" }, "du -sh *", "free -h", "top", "ps aux | grep process", "kill -9 PID",
{ cmd: "qm snapshot <vmid> <snapname>", desc: "Create VM snapshot" }, "systemctl status service", "systemctl start service", "systemctl stop service",
{ cmd: "pct list", desc: "List all LXC containers" }, "systemctl restart service", "apt update && apt upgrade", "apt install package",
{ cmd: "pct start <vmid>", desc: "Start LXC container" }, "apt remove package", "docker ps", "docker images", "docker exec -it container bash",
{ cmd: "pct stop <vmid>", desc: "Stop LXC container" }, "ip addr show", "ping host", "curl -I url", "wget url", "ssh user@host",
{ cmd: "pct enter <vmid>", desc: "Enter LXC container console" }, "scp file user@host:/path", "tail -f /var/log/syslog", "history", "clear",
{ cmd: "pct config <vmid>", desc: "Show container configuration" },
{ cmd: "pvesm status", desc: "Show storage status" },
{ cmd: "pvesm list <storage>", desc: "List storage content" },
{ cmd: "pveperf", desc: "Test Proxmox system performance" },
{ cmd: "pveversion", desc: "Show Proxmox VE version" },
{ cmd: "systemctl status pve-cluster", desc: "Check cluster status" },
{ cmd: "pvecm status", desc: "Show cluster status" },
{ cmd: "pvecm nodes", desc: "List cluster nodes" },
{ cmd: "zpool status", desc: "Show ZFS pool status" },
{ cmd: "zpool list", desc: "List all ZFS pools" },
{ cmd: "zfs list", desc: "List all ZFS datasets" },
{ cmd: "ls -la", desc: "List all files with details" },
{ cmd: "cd /path/to/dir", desc: "Change directory" },
{ cmd: "mkdir dirname", desc: "Create new directory" },
{ cmd: "rm -rf dirname", desc: "Remove directory recursively" },
{ cmd: "cp source dest", desc: "Copy files or directories" },
{ cmd: "mv source dest", desc: "Move or rename files" },
{ cmd: "cat filename", desc: "Display file contents" },
{ cmd: "grep 'pattern' file", desc: "Search for pattern in file" },
{ cmd: "find . -name 'file'", desc: "Find files by name" },
{ cmd: "chmod 755 file", desc: "Change file permissions" },
{ cmd: "chown user:group file", desc: "Change file owner" },
{ cmd: "tar -xzf file.tar.gz", desc: "Extract tar.gz archive" },
{ cmd: "tar -czf archive.tar.gz dir/", desc: "Create tar.gz archive" },
{ cmd: "df -h", desc: "Show disk usage" },
{ cmd: "du -sh *", desc: "Show directory sizes" },
{ cmd: "free -h", desc: "Show memory usage" },
{ cmd: "top", desc: "Show running processes" },
{ cmd: "ps aux | grep process", desc: "Find running process" },
{ cmd: "kill -9 PID", desc: "Force kill process" },
{ cmd: "systemctl status service", desc: "Check service status" },
{ cmd: "systemctl start service", desc: "Start a service" },
{ cmd: "systemctl stop service", desc: "Stop a service" },
{ cmd: "systemctl restart service", desc: "Restart a service" },
{ cmd: "apt update && apt upgrade", desc: "Update Debian/Ubuntu packages" },
{ cmd: "apt install package", desc: "Install package on Debian/Ubuntu" },
{ cmd: "apt remove package", desc: "Remove package" },
{ cmd: "docker ps", desc: "List running containers" },
{ cmd: "docker images", desc: "List Docker images" },
{ cmd: "docker exec -it container bash", desc: "Enter container shell" },
{ cmd: "ip addr show", desc: "Show IP addresses" },
{ cmd: "ping host", desc: "Test network connectivity" },
{ cmd: "curl -I url", desc: "Get HTTP headers" },
{ cmd: "wget url", desc: "Download file from URL" },
{ cmd: "ssh user@host", desc: "Connect via SSH" },
{ cmd: "scp file user@host:/path", desc: "Copy file via SSH" },
{ cmd: "tail -f /var/log/syslog", desc: "Follow log file in real-time" },
{ cmd: "history", desc: "Show command history" },
{ cmd: "clear", desc: "Clear terminal screen" },
] ]
export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onClose }) => { export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onClose }) => {
const t = useT()
const localizedCommands = proxmoxCommands.map((cmd, index) => ({
cmd,
desc: t(`terminal.commandDescriptions.${index}`),
}))
const [terminals, setTerminals] = useState<TerminalInstance[]>([]) const [terminals, setTerminals] = useState<TerminalInstance[]>([])
const [activeTerminalId, setActiveTerminalId] = useState<string>("") const [activeTerminalId, setActiveTerminalId] = useState<string>("")
const [layout, setLayout] = useState<"single" | "grid">("grid") const [layout, setLayout] = useState<"single" | "grid">("grid")
@@ -154,7 +111,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
const [terminalHeight, setTerminalHeight] = useState<number>(500) // altura por defecto en px const [terminalHeight, setTerminalHeight] = useState<number>(500) // altura por defecto en px
const [searchModalOpen, setSearchModalOpen] = useState(false) const [searchModalOpen, setSearchModalOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")
const [filteredCommands, setFilteredCommands] = useState<Array<{ cmd: string; desc: string }>>(proxmoxCommands) const [filteredCommands, setFilteredCommands] = useState<Array<{ cmd: string; desc: string }>>(localizedCommands)
const [isSearching, setIsSearching] = useState(false) const [isSearching, setIsSearching] = useState(false)
const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([]) const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
const [useOnline, setUseOnline] = useState(true) const [useOnline, setUseOnline] = useState(true)
@@ -272,7 +229,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
const searchCheatSh = async (query: string) => { const searchCheatSh = async (query: string) => {
if (!query.trim()) { if (!query.trim()) {
setSearchResults([]) setSearchResults([])
setFilteredCommands(proxmoxCommands) setFilteredCommands(localizedCommands)
return return
} }
@@ -287,7 +244,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
}) })
if (!data.success || !data.examples || data.examples.length === 0) { if (!data.success || !data.examples || data.examples.length === 0) {
throw new Error("No examples found") throw new Error(t("terminal.noExamplesFound"))
} }
@@ -300,7 +257,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
setUseOnline(true) setUseOnline(true)
setSearchResults(formattedResults) setSearchResults(formattedResults)
} catch (error) { } catch (error) {
const filtered = proxmoxCommands.filter( const filtered = localizedCommands.filter(
(item) => (item) =>
item.cmd.toLowerCase().includes(query.toLowerCase()) || item.cmd.toLowerCase().includes(query.toLowerCase()) ||
item.desc.toLowerCase().includes(query.toLowerCase()), item.desc.toLowerCase().includes(query.toLowerCase()),
@@ -318,12 +275,12 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
searchCheatSh(searchQuery) searchCheatSh(searchQuery)
} else { } else {
setSearchResults([]) setSearchResults([])
setFilteredCommands(proxmoxCommands) setFilteredCommands(localizedCommands)
} }
}, 800) }, 800)
return () => clearTimeout(debounce) return () => clearTimeout(debounce)
}, [searchQuery]) }, [searchQuery, t])
// Function to reconnect a terminal when connection is lost // Function to reconnect a terminal when connection is lost
// This is called when page visibility changes (user returns from another app) // This is called when page visibility changes (user returns from another app)
@@ -332,7 +289,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
if (!terminal || !terminal.term) return if (!terminal || !terminal.term) return
// Show reconnecting message // Show reconnecting message
terminal.term.writeln('\r\n\x1b[33m[INFO] Reconnecting...\x1b[0m') terminal.term.writeln(`\r\n\x1b[33m[INFO] ${t("terminal.reconnecting")}\x1b[0m`)
const wsUrl = websocketUrl || getWebSocketUrl() const wsUrl = websocketUrl || getWebSocketUrl()
// Append the single-use auth ticket so the backend handshake can validate. // Append the single-use auth ticket so the backend handshake can validate.
@@ -358,7 +315,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
setTerminals((prev) => setTerminals((prev) =>
prev.map((t) => (t.id === terminalId ? { ...t, isConnected: true, ws, pingInterval } : t)) prev.map((t) => (t.id === terminalId ? { ...t, isConnected: true, ws, pingInterval } : t))
) )
terminal.term.writeln('\r\n\x1b[32m[INFO] Reconnected successfully\x1b[0m') terminal.term.writeln(`\r\n\x1b[32m[INFO] ${t("terminal.reconnected")}\x1b[0m`)
// Sync terminal size // Sync terminal size
if (terminal.fitAddon) { if (terminal.fitAddon) {
@@ -384,7 +341,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
} }
ws.onerror = () => { ws.onerror = () => {
terminal.term.writeln('\r\n\x1b[31m[ERROR] Reconnection failed\x1b[0m') terminal.term.writeln(`\r\n\x1b[31m[ERROR] ${t("terminal.reconnectionFailed")}\x1b[0m`)
} }
ws.onclose = () => { ws.onclose = () => {
@@ -397,7 +354,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
} }
return t return t
})) }))
terminal.term.writeln('\r\n\x1b[33m[INFO] Connection closed\x1b[0m') terminal.term.writeln(`\r\n\x1b[33m[INFO] ${t("terminal.connectionClosed")}\x1b[0m`)
} }
terminal.term.onData((data: string) => { terminal.term.onData((data: string) => {
@@ -415,7 +372,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
...prev, ...prev,
{ {
id: newId, id: newId,
title: `Terminal ${prev.length + 1}`, title: t("terminal.terminalTitle", { number: prev.length + 1 }),
term: null, term: null,
ws: null, ws: null,
isConnected: false, isConnected: false,
@@ -570,8 +527,8 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
if (ws.readyState !== WebSocket.OPEN) { if (ws.readyState !== WebSocket.OPEN) {
connectionTimedOut = true connectionTimedOut = true
ws.close() ws.close()
term.writeln('\x1b[31m[ERROR] Connection timeout. Please check your network and try again.\x1b[0m') term.writeln(`\x1b[31m[ERROR] ${t("terminal.connectionTimeout")}\x1b[0m`)
term.writeln('\x1b[33m[TIP] If using VPN, ensure the connection is stable.\x1b[0m') term.writeln(`\x1b[33m[TIP] ${t("terminal.vpnTip")}\x1b[0m`)
} }
}, connectionTimeout) }, connectionTimeout)
@@ -636,7 +593,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
})) }))
// Only show error if not already shown by timeout // Only show error if not already shown by timeout
if (!connectionTimedOut) { if (!connectionTimedOut) {
term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m") term.writeln(`\r\n\x1b[31m[ERROR] ${t("terminal.websocketError")}\x1b[0m`)
} }
} }
@@ -653,7 +610,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
})) }))
// Only show close message if not already shown by timeout // Only show close message if not already shown by timeout
if (!connectionTimedOut) { if (!connectionTimedOut) {
term.writeln("\r\n\x1b[33m[INFO] Connection closed\x1b[0m") term.writeln(`\r\n\x1b[33m[INFO] ${t("terminal.connectionClosed")}\x1b[0m`)
} }
} }
@@ -816,9 +773,9 @@ const handleClose = () => {
<Activity className="h-5 w-5 text-blue-500" /> <Activity className="h-5 w-5 text-blue-500" />
<div <div
className={`w-2 h-2 rounded-full ${activeTerminal?.isConnected ? "bg-green-500" : "bg-red-500"}`} className={`w-2 h-2 rounded-full ${activeTerminal?.isConnected ? "bg-green-500" : "bg-red-500"}`}
title={activeTerminal?.isConnected ? "Connected" : "Disconnected"} title={activeTerminal?.isConnected ? t("terminal.connected") : t("terminal.disconnected")}
></div> ></div>
<span className="text-xs text-zinc-500">{terminals.length} / 4 terminals</span> <span className="text-xs text-zinc-500">{t("terminal.terminalCount", { count: terminals.length })}</span>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -829,7 +786,7 @@ const handleClose = () => {
variant="outline" variant="outline"
size="sm" size="sm"
className={`h-8 px-2 ${layout === "single" ? "bg-blue-500/20 border-blue-500" : ""}`} className={`h-8 px-2 ${layout === "single" ? "bg-blue-500/20 border-blue-500" : ""}`}
title="Vista apilada (filas)" title={t("terminal.stackedLayout")}
> >
<AlignJustify className="h-4 w-4" /> <AlignJustify className="h-4 w-4" />
</Button> </Button>
@@ -838,7 +795,7 @@ const handleClose = () => {
variant="outline" variant="outline"
size="sm" size="sm"
className={`h-8 px-2 ${layout === "grid" ? "bg-blue-500/20 border-blue-500" : ""}`} className={`h-8 px-2 ${layout === "grid" ? "bg-blue-500/20 border-blue-500" : ""}`}
title="Vista cuadrícula 2x2" title={t("terminal.gridLayout")}
> >
<Grid2X2 className="h-4 w-4" /> <Grid2X2 className="h-4 w-4" />
</Button> </Button>
@@ -852,7 +809,7 @@ const handleClose = () => {
className="h-8 gap-2 bg-green-600/20 hover:bg-green-600/30 border-green-600/50 text-green-400 disabled:opacity-50" className="h-8 gap-2 bg-green-600/20 hover:bg-green-600/30 border-green-600/50 text-green-400 disabled:opacity-50"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
<span className="hidden sm:inline">New</span> <span className="hidden sm:inline">{t("terminal.new")}</span>
</Button> </Button>
<Button <Button
onClick={() => setSearchModalOpen(true)} onClick={() => setSearchModalOpen(true)}
@@ -862,7 +819,7 @@ const handleClose = () => {
className="h-8 gap-2 bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400 disabled:opacity-50" className="h-8 gap-2 bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400 disabled:opacity-50"
> >
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
<span className="hidden sm:inline">Search</span> <span className="hidden sm:inline">{t("terminal.search")}</span>
</Button> </Button>
<Button <Button
onClick={handleClear} onClick={handleClear}
@@ -872,7 +829,7 @@ const handleClose = () => {
className="h-8 gap-2 bg-yellow-600/20 hover:bg-yellow-600/30 border-yellow-600/50 text-yellow-400 disabled:opacity-50" className="h-8 gap-2 bg-yellow-600/20 hover:bg-yellow-600/30 border-yellow-600/50 text-yellow-400 disabled:opacity-50"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">Clear</span> <span className="hidden sm:inline">{t("terminal.clear")}</span>
</Button> </Button>
<Button <Button
onClick={handleClose} onClick={handleClose}
@@ -881,7 +838,7 @@ const handleClose = () => {
className="h-8 gap-2 bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400" className="h-8 gap-2 bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="hidden sm:inline">Close</span> <span className="hidden sm:inline">{t("actions.close")}</span>
</Button> </Button>
</div> </div>
</div> </div>
@@ -1075,29 +1032,29 @@ const handleClose = () => {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56"> <DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel> <DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.controlSequences")}</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => sendSequence("\x03")}> <DropdownMenuItem onSelect={() => sendSequence("\x03")}>
<span className="font-mono text-xs mr-2">Ctrl+C</span> <span className="font-mono text-xs mr-2">Ctrl+C</span>
<span className="text-muted-foreground text-xs">Cancel/Interrupt</span> <span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendSequence("\x18")}> <DropdownMenuItem onSelect={() => sendSequence("\x18")}>
<span className="font-mono text-xs mr-2">Ctrl+X</span> <span className="font-mono text-xs mr-2">Ctrl+X</span>
<span className="text-muted-foreground text-xs">Exit (nano)</span> <span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendSequence("\x12")}> <DropdownMenuItem onSelect={() => sendSequence("\x12")}>
<span className="font-mono text-xs mr-2">Ctrl+R</span> <span className="font-mono text-xs mr-2">Ctrl+R</span>
<span className="text-muted-foreground text-xs">Search history</span> <span className="text-muted-foreground text-xs">{t("scriptTerminal.searchHistory")}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">Clipboard</DropdownMenuLabel> <DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.clipboard")}</DropdownMenuLabel>
<DropdownMenuItem onSelect={() => { void handleCopy() }}> <DropdownMenuItem onSelect={() => { void handleCopy() }}>
<Copy className="h-3.5 w-3.5 mr-2" /> <Copy className="h-3.5 w-3.5 mr-2" />
<span className="text-xs">Copy selection</span> <span className="text-xs">{t("scriptTerminal.copySelection")}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => { void handlePaste() }}> <DropdownMenuItem onSelect={() => { void handlePaste() }}>
<Clipboard className="h-3.5 w-3.5 mr-2" /> <Clipboard className="h-3.5 w-3.5 mr-2" />
<span className="text-xs">Paste</span> <span className="text-xs">{t("scriptTerminal.paste")}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -1107,22 +1064,22 @@ const handleClose = () => {
<Dialog open={searchModalOpen} onOpenChange={setSearchModalOpen}> <Dialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col"> <DialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-zinc-800"> <DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-zinc-800">
<DialogTitle className="text-xl font-semibold">Search Commands</DialogTitle> <DialogTitle className="text-xl font-semibold">{t("terminal.searchCommands")}</DialogTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
className={`w-2 h-2 rounded-full ${useOnline ? "bg-green-500" : "bg-red-500"}`} className={`w-2 h-2 rounded-full ${useOnline ? "bg-green-500" : "bg-red-500"}`}
title={useOnline ? "Online - Using cheat.sh API" : "Offline - Using local commands"} title={useOnline ? t("terminal.onlineSource") : t("terminal.offlineSource")}
/> />
</div> </div>
</DialogHeader> </DialogHeader>
<DialogDescription className="sr-only">Search for Linux and Proxmox commands</DialogDescription> <DialogDescription className="sr-only">{t("terminal.searchDescription")}</DialogDescription>
<div className="space-y-4"> <div className="space-y-4">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<Input <Input
placeholder="Search commands... (e.g., tar, docker, qm, systemctl)" placeholder={t("terminal.searchPlaceholder")}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-zinc-900 border-zinc-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-base" className="pl-10 bg-zinc-900 border-zinc-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-base"
@@ -1136,7 +1093,7 @@ const handleClose = () => {
{isSearching && ( {isSearching && (
<div className="text-center py-4 text-zinc-400"> <div className="text-center py-4 text-zinc-400">
<div className="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mb-2" /> <div className="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mb-2" />
<p className="text-sm">Searching cheat.sh...</p> <p className="text-sm">{t("terminal.searchingCheatSh")}</p>
</div> </div>
)} )}
@@ -1164,7 +1121,7 @@ const handleClose = () => {
<div className="text-center py-2"> <div className="text-center py-2">
<p className="text-xs text-zinc-500"> <p className="text-xs text-zinc-500">
<Lightbulb className="inline-block w-3 h-3 mr-1" /> <Lightbulb className="inline-block w-3 h-3 mr-1" />
Powered by cheat.sh {t("terminal.poweredByCheatSh")}
</p> </p>
</div> </div>
</> </>
@@ -1190,13 +1147,13 @@ const handleClose = () => {
className="shrink-0 h-7 px-2 text-xs" className="shrink-0 h-7 px-2 text-xs"
> >
<Send className="h-3 w-3 mr-1" /> <Send className="h-3 w-3 mr-1" />
Send {t("terminal.send")}
</Button> </Button>
</div> </div>
</div> </div>
)) ))
) : !isSearching && !searchQuery && !useOnline ? ( ) : !isSearching && !searchQuery && !useOnline ? (
proxmoxCommands.map((item, index) => ( localizedCommands.map((item, index) => (
<div <div
key={index} key={index}
onClick={() => sendToActiveTerminal(item.cmd)} onClick={() => sendToActiveTerminal(item.cmd)}
@@ -1217,7 +1174,7 @@ const handleClose = () => {
className="shrink-0 h-7 px-2 text-xs" className="shrink-0 h-7 px-2 text-xs"
> >
<Send className="h-3 w-3 mr-1" /> <Send className="h-3 w-3 mr-1" />
Send {t("terminal.send")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -1228,17 +1185,17 @@ const handleClose = () => {
<> <>
<Search className="w-12 h-12 text-zinc-600 mx-auto" /> <Search className="w-12 h-12 text-zinc-600 mx-auto" />
<div> <div>
<p className="text-zinc-400 font-medium">No results found for "{searchQuery}"</p> <p className="text-zinc-400 font-medium">{t("terminal.noResults", { query: searchQuery })}</p>
<p className="text-xs text-zinc-500 mt-1">Try a different command or check your spelling</p> <p className="text-xs text-zinc-500 mt-1">{t("terminal.tryDifferentSearch")}</p>
</div> </div>
</> </>
) : ( ) : (
<> <>
<Terminal className="w-12 h-12 text-zinc-600 mx-auto" /> <Terminal className="w-12 h-12 text-zinc-600 mx-auto" />
<div> <div>
<p className="text-zinc-400 font-medium mb-2">Search for any command</p> <p className="text-zinc-400 font-medium mb-2">{t("terminal.searchAnyCommand")}</p>
<div className="text-sm text-zinc-500 space-y-1"> <div className="text-sm text-zinc-500 space-y-1">
<p>Try searching for:</p> <p>{t("terminal.trySearchingFor")}</p>
<div className="flex flex-wrap justify-center gap-2 mt-2"> <div className="flex flex-wrap justify-center gap-2 mt-2">
{["tar", "grep", "docker", "qm", "systemctl"].map((cmd) => ( {["tar", "grep", "docker", "qm", "systemctl"].map((cmd) => (
<code <code
@@ -1255,7 +1212,7 @@ const handleClose = () => {
{useOnline && ( {useOnline && (
<div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4"> <div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4">
<Lightbulb className="w-3 h-3" /> <Lightbulb className="w-3 h-3" />
<span>Powered by cheat.sh</span> <span>{t("terminal.poweredByCheatSh")}</span>
</div> </div>
)} )}
</> </>
@@ -1267,9 +1224,9 @@ const handleClose = () => {
<div className="pt-2 border-t border-zinc-800 flex items-center justify-between text-xs text-zinc-500"> <div className="pt-2 border-t border-zinc-800 flex items-center justify-between text-xs text-zinc-500">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Lightbulb className="w-3 h-3" /> <Lightbulb className="w-3 h-3" />
<span>Tip: Search for any Linux command or Proxmox commands (qm, pct, zpool)</span> <span>{t("terminal.searchTip")}</span>
</div> </div>
{useOnline && searchResults.length > 0 && <span className="text-zinc-600">Powered by cheat.sh</span>} {useOnline && searchResults.length > 0 && <span className="text-zinc-600">{t("terminal.poweredByCheatSh")}</span>}
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
+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>
) )
} }
+33 -32
View File
@@ -6,6 +6,7 @@ import { Input } from "./ui/input"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
import { AlertCircle, CheckCircle, Copy, Shield, Check } from "lucide-react" import { AlertCircle, CheckCircle, Copy, Shield, Check } from "lucide-react"
import { getApiUrl } from "../lib/api-config" import { getApiUrl } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface TwoFactorSetupProps { interface TwoFactorSetupProps {
open: boolean open: boolean
@@ -14,6 +15,8 @@ interface TwoFactorSetupProps {
} }
export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps) { export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps) {
const t = useT()
const tf = (key: string) => t(`securityPage.twoFactorSetup.${key}`)
const [step, setStep] = useState(1) const [step, setStep] = useState(1)
const [qrCode, setQrCode] = useState("") const [qrCode, setQrCode] = useState("")
const [secret, setSecret] = useState("") const [secret, setSecret] = useState("")
@@ -41,7 +44,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
const data = await response.json() const data = await response.json()
if (!response.ok) { if (!response.ok) {
throw new Error(data.message || "Failed to setup 2FA") throw new Error(data.message || tf("setupFailed"))
} }
setQrCode(data.qr_code) setQrCode(data.qr_code)
@@ -49,7 +52,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
setBackupCodes(data.backup_codes) setBackupCodes(data.backup_codes)
setStep(2) setStep(2)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to setup 2FA") setError(err instanceof Error ? err.message : tf("setupFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -57,7 +60,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
const handleVerify = async () => { const handleVerify = async () => {
if (!verificationCode || verificationCode.length !== 6) { if (!verificationCode || verificationCode.length !== 6) {
setError("Please enter a 6-digit code") setError(tf("enterSixDigitCode"))
return return
} }
@@ -78,12 +81,12 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
const data = await response.json() const data = await response.json()
if (!response.ok) { if (!response.ok) {
throw new Error(data.message || "Invalid verification code") throw new Error(data.message || tf("invalidCode"))
} }
setStep(3) setStep(3)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Verification failed") setError(err instanceof Error ? err.message : tf("verificationFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -141,7 +144,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
// both the Clipboard API and execCommand may be locked down. // both the Clipboard API and execCommand may be locked down.
if (!ok) { if (!ok) {
try { try {
window.prompt("Copy this value:", text) window.prompt(tf("copyPrompt"), text)
ok = true ok = true
} catch { } catch {
// ignore // ignore
@@ -183,9 +186,9 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-blue-500" /> <Shield className="h-5 w-5 text-blue-500" />
Setup Two-Factor Authentication {tf("title")}
</DialogTitle> </DialogTitle>
<DialogDescription>Add an extra layer of security to your account</DialogDescription> <DialogDescription>{tf("description")}</DialogDescription>
</DialogHeader> </DialogHeader>
{error && ( {error && (
@@ -199,22 +202,21 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4"> <div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
<p className="text-sm text-blue-500"> <p className="text-sm text-blue-500">
Two-factor authentication (2FA) adds an extra layer of security by requiring a code from your {tf("intro")}
authentication app in addition to your password.
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h4 className="font-medium">You will need:</h4> <h4 className="font-medium">{tf("youWillNeed")}</h4>
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside"> <ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
<li>An authentication app (Google Authenticator, Authy, etc.)</li> <li>{tf("needApp")}</li>
<li>Scan a QR code or enter a key manually</li> <li>{tf("needQrOrKey")}</li>
<li>Store backup codes securely</li> <li>{tf("needBackupCodes")}</li>
</ul> </ul>
</div> </div>
<Button onClick={handleSetupStart} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}> <Button onClick={handleSetupStart} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Starting..." : "Start Setup"} {loading ? tf("starting") : tf("startSetup")}
</Button> </Button>
</div> </div>
)} )}
@@ -222,24 +224,24 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
{step === 2 && ( {step === 2 && (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<h4 className="font-medium">1. Scan the QR code</h4> <h4 className="font-medium">{tf("scanTitle")}</h4>
<p className="text-sm text-muted-foreground">Open your authentication app and scan this QR code</p> <p className="text-sm text-muted-foreground">{tf("scanDescription")}</p>
{qrCode && ( {qrCode && (
<div className="flex justify-center p-4 bg-white rounded-lg"> <div className="flex justify-center p-4 bg-white rounded-lg">
<img src={qrCode || "/placeholder.svg"} alt="QR Code" width={200} height={200} className="rounded" /> <img src={qrCode || "/placeholder.svg"} alt={tf("qrCodeAlt")} width={200} height={200} className="rounded" />
</div> </div>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h4 className="font-medium">Or enter the key manually:</h4> <h4 className="font-medium">{tf("manualKey")}</h4>
<div className="flex gap-2"> <div className="flex gap-2">
<Input value={secret} readOnly className="font-mono text-sm" /> <Input value={secret} readOnly className="font-mono text-sm" />
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onClick={() => copyToClipboard(secret, "secret")} onClick={() => copyToClipboard(secret, "secret")}
title="Copy key" title={tf("copyKey")}
> >
{copiedSecret ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />} {copiedSecret ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button> </Button>
@@ -247,8 +249,8 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h4 className="font-medium">2. Enter the verification code</h4> <h4 className="font-medium">{tf("verifyTitle")}</h4>
<p className="text-sm text-muted-foreground">Enter the 6-digit code that appears in your app</p> <p className="text-sm text-muted-foreground">{tf("verifyDescription")}</p>
<Input <Input
type="text" type="text"
placeholder="000000" placeholder="000000"
@@ -262,10 +264,10 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={handleVerify} className="flex-1 bg-blue-500 hover:bg-blue-600" disabled={loading}> <Button onClick={handleVerify} className="flex-1 bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Verifying..." : "Verify and Enable"} {loading ? tf("verifying") : tf("verifyAndEnable")}
</Button> </Button>
<Button onClick={handleClose} variant="outline" className="flex-1 bg-transparent" disabled={loading}> <Button onClick={handleClose} variant="outline" className="flex-1 bg-transparent" disabled={loading}>
Cancel {t("actions.cancel")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -276,30 +278,29 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-4 flex items-start gap-2"> <div className="bg-green-500/10 border border-green-500/20 rounded-lg p-4 flex items-start gap-2">
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" /> <CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
<div> <div>
<p className="font-medium text-green-500">2FA Enabled Successfully</p> <p className="font-medium text-green-500">{tf("enabledTitle")}</p>
<p className="text-sm text-green-500 mt-1"> <p className="text-sm text-green-500 mt-1">
Your account is now protected with two-factor authentication {tf("enabledDescription")}
</p> </p>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h4 className="font-medium text-orange-500">Important: Save your backup codes</h4> <h4 className="font-medium text-orange-500">{tf("saveCodesTitle")}</h4>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
These codes will allow you to access your account if you lose access to your authentication app. Store {tf("saveCodesDescription")}
them in a safe place.
</p> </p>
<div className="bg-muted/50 rounded-lg p-4 space-y-2"> <div className="bg-muted/50 rounded-lg p-4 space-y-2">
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium">Backup Codes</span> <span className="text-sm font-medium">{tf("backupCodes")}</span>
<Button variant="outline" size="sm" onClick={() => copyToClipboard(backupCodes.join("\n"), "codes")}> <Button variant="outline" size="sm" onClick={() => copyToClipboard(backupCodes.join("\n"), "codes")}>
{copiedCodes ? ( {copiedCodes ? (
<Check className="h-4 w-4 text-green-500 mr-2" /> <Check className="h-4 w-4 text-green-500 mr-2" />
) : ( ) : (
<Copy className="h-4 w-4 mr-2" /> <Copy className="h-4 w-4 mr-2" />
)} )}
Copy All {tf("copyAll")}
</Button> </Button>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@@ -313,7 +314,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
</div> </div>
<Button onClick={handleFinish} className="w-full bg-blue-500 hover:bg-blue-600"> <Button onClick={handleFinish} className="w-full bg-blue-500 hover:bg-blue-600">
Finish {tf("finish")}
</Button> </Button>
</div> </div>
)} )}
+27 -22
View File
@@ -4,6 +4,7 @@ import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react" import { X } from "lucide-react"
import { useT } from "@/lib/i18n/provider"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root const Dialog = DialogPrimitive.Root
@@ -34,28 +35,32 @@ const DialogContent = React.forwardRef<
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
hideClose?: boolean hideClose?: boolean
} }
>(({ className, children, hideClose, ...props }, ref) => ( >(({ className, children, hideClose, ...props }, ref) => {
<DialogPortal> const t = useT()
<DialogOverlay />
<DialogPrimitive.Content return (
ref={ref} <DialogPortal>
className={cn( <DialogOverlay />
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg", <DialogPrimitive.Content
className, ref={ref}
)} className={cn(
aria-describedby={props["aria-describedby"] || undefined} "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
{...props} className,
> )}
{children} aria-describedby={props["aria-describedby"] || undefined}
{!hideClose && ( {...props}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> >
<X className="h-4 w-4" /> {children}
<span className="sr-only">Close</span> {!hideClose && (
</DialogPrimitive.Close> <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
)} <X className="h-4 w-4" />
</DialogPrimitive.Content> <span className="sr-only">{t("actions.close")}</span>
</DialogPortal> </DialogPrimitive.Close>
)) )}
</DialogPrimitive.Content>
</DialogPortal>
)
})
DialogContent.displayName = DialogPrimitive.Content.displayName DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
+17 -12
View File
@@ -5,6 +5,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react" import { X } from "lucide-react"
import { useT } from "@/lib/i18n/provider"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const Sheet = DialogPrimitive.Root const Sheet = DialogPrimitive.Root
@@ -54,18 +55,22 @@ interface SheetContentProps
VariantProps<typeof sheetVariants> {} VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Content>, SheetContentProps>( const SheetContent = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Content>, SheetContentProps>(
({ side = "right", className, children, ...props }, ref) => ( ({ side = "right", className, children, ...props }, ref) => {
<SheetPortal> const t = useT()
<SheetOverlay />
<DialogPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}> return (
{children} <SheetPortal>
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> <SheetOverlay />
<X className="h-4 w-4" /> <DialogPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
<span className="sr-only">Close</span> {children}
</DialogPrimitive.Close> <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
</DialogPrimitive.Content> <X className="h-4 w-4" />
</SheetPortal> <span className="sr-only">{t("actions.close")}</span>
), </DialogPrimitive.Close>
</DialogPrimitive.Content>
</SheetPortal>
)
},
) )
SheetContent.displayName = DialogPrimitive.Content.displayName SheetContent.displayName = DialogPrimitive.Content.displayName
File diff suppressed because it is too large Load Diff
+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: "complete" },
{ 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 complete.
- 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."
}
File diff suppressed because it is too large Load Diff
+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."
}
File diff suppressed because it is too large Load Diff
+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',
+50 -9
View File
@@ -1460,10 +1460,13 @@ def run_lynis_audit():
global _lynis_audit_running, _lynis_audit_progress global _lynis_audit_running, _lynis_audit_progress
try: try:
_lynis_audit_progress = "running" _lynis_audit_progress = "running"
# Remove old report so lynis creates a fresh one # Remove old generated files so a failed or interrupted run does
report_file = "/var/log/lynis-report.dat" # not get mixed with data from an earlier audit. Keep
if os.path.isfile(report_file): # /var/log/lynis.log: Lynis owns that file and can use it as a
os.remove(report_file) # fallback source when terminal capture is unavailable.
for report_path in ["/var/log/lynis-report.dat", "/var/log/lynis-output.log"]:
if os.path.isfile(report_path):
os.remove(report_path)
# Capture full formatted output. Lynis suppresses its nice # Capture full formatted output. Lynis suppresses its nice
# formatted output ([+] sections) when stdout is not a tty. # formatted output ([+] sections) when stdout is not a tty.
@@ -1573,6 +1576,7 @@ def parse_lynis_report():
""" """
report_file = "/var/log/lynis-report.dat" report_file = "/var/log/lynis-report.dat"
output_file = "/var/log/lynis-output.log" output_file = "/var/log/lynis-output.log"
lynis_log_file = "/var/log/lynis.log"
# Need at least one data source # Need at least one data source
if not os.path.isfile(report_file) and not os.path.isfile(output_file): if not os.path.isfile(report_file) and not os.path.isfile(output_file):
return None return None
@@ -1594,6 +1598,8 @@ def parse_lynis_report():
"kernel_version": "", "kernel_version": "",
"firewall_active": False, "firewall_active": False,
"malware_scanner": False, "malware_scanner": False,
"is_complete": False,
"parse_issue": "",
} }
# Collect all raw key-value pairs first for flexible matching # Collect all raw key-value pairs first for flexible matching
@@ -1720,9 +1726,30 @@ def parse_lynis_report():
# archivo entero a memoria 2 veces. # archivo entero a memoria 2 veces.
report["sections"] = [] report["sections"] = []
output_file = "/var/log/lynis-output.log" output_file = "/var/log/lynis-output.log"
log_file = output_file if os.path.isfile(output_file) else "/var/log/lynis.log" log_file = ""
_log_lines = [] _log_lines = []
if os.path.isfile(log_file):
def _usable_lynis_log(path):
if not os.path.isfile(path):
return False
try:
if os.path.getsize(path) <= 0:
return False
# Avoid mixing a newly created sparse report with a stale log from
# an older run. A fresh Lynis log should be at least as recent as
# the current report, allowing a small clock/file-system margin.
if os.path.isfile(report_file):
return os.path.getmtime(path) >= os.path.getmtime(report_file) - 300
return True
except Exception:
return False
for candidate in [output_file, lynis_log_file]:
if _usable_lynis_log(candidate):
log_file = candidate
break
if log_file:
try: try:
with open(log_file, 'r') as f: with open(log_file, 'r') as f:
_log_lines = f.readlines() _log_lines = f.readlines()
@@ -1799,7 +1826,7 @@ def parse_lynis_report():
# Format: "Key: value" or "Key : value" # Format: "Key: value" or "Key : value"
if ":" in stripped: if ":" in stripped:
if not report["hardening_index"] and "Hardening index" in stripped: if not report["hardening_index"] and "Hardening index" in stripped:
m = re.search(r'Hardening index\s*:\s*(\d+)', stripped) m = re.search(r'Hardening index\s*:?\s*\[?(\d+)\]?', stripped)
if m: if m:
report["hardening_index"] = int(m.group(1)) report["hardening_index"] = int(m.group(1))
elif report["tests_performed"] == 0 and "Tests performed" in stripped: elif report["tests_performed"] == 0 and "Tests performed" in stripped:
@@ -1962,6 +1989,15 @@ def parse_lynis_report():
if "malware" in sw_name and sw_status == "V": if "malware" in sw_name and sw_status == "V":
report["malware_scanner"] = True report["malware_scanner"] = True
# lynis.log does not contain the formatted "Software
# components" block, but it does log the underlying result
# lines. Use those as a fallback for the quick status cards.
s_lower = sstripped.lower()
if "host based firewall or packet filter is active" in s_lower:
report["firewall_active"] = True
if "no malware scanner found" in s_lower:
report["malware_scanner"] = False
# Parse warning lines: "! Warning text [TEST-ID]" # Parse warning lines: "! Warning text [TEST-ID]"
if in_warnings and sstripped.startswith('!'): if in_warnings and sstripped.startswith('!'):
wm = re.match(r'^!\s+(.+?)\s+\[([A-Z0-9_-]+)\]', sstripped) wm = re.match(r'^!\s+(.+?)\s+\[([A-Z0-9_-]+)\]', sstripped)
@@ -2210,10 +2246,12 @@ def parse_lynis_report():
# Calculate Proxmox-adjusted score # Calculate Proxmox-adjusted score
# Lynis score is based on total tests and findings. # Lynis score is based on total tests and findings.
# We boost the score proportionally to the expected items. # We boost the score proportionally to the expected items.
raw_score = report["hardening_index"] or 0 raw_score = report["hardening_index"]
total_findings = len(report["warnings"]) + len(report["suggestions"]) total_findings = len(report["warnings"]) + len(report["suggestions"])
expected_findings = pve_expected_warnings + pve_expected_suggestions expected_findings = pve_expected_warnings + pve_expected_suggestions
if total_findings > 0 and raw_score > 0: if raw_score is None:
adjusted_score = None
elif total_findings > 0 and raw_score > 0:
# Each finding roughly reduces the score. Expected findings should # Each finding roughly reduces the score. Expected findings should
# not penalize. We estimate the boost proportionally. # not penalize. We estimate the boost proportionally.
penalty_per_finding = (100 - raw_score) / max(total_findings, 1) penalty_per_finding = (100 - raw_score) / max(total_findings, 1)
@@ -2226,6 +2264,9 @@ def parse_lynis_report():
report["proxmox_expected_warnings"] = pve_expected_warnings report["proxmox_expected_warnings"] = pve_expected_warnings
report["proxmox_expected_suggestions"] = pve_expected_suggestions report["proxmox_expected_suggestions"] = pve_expected_suggestions
report["proxmox_context_applied"] = True report["proxmox_context_applied"] = True
report["is_complete"] = report["hardening_index"] is not None and report["tests_performed"] > 0
if not report["is_complete"]:
report["parse_issue"] = "Lynis report is incomplete: hardening index or test count is missing."
return report return report