23 Commits
Author SHA1 Message Date
MacRimiandGitHub 7d781d570c Merge pull request #278 from Vaso73/fix/auth-setup-blocked-after-decline
Fix auth setup after authentication is declined
2026-08-06 16:19:02 +02:00
MacRimiandGitHub e581aa0121 Merge pull request #277 from Vaso73/feature/monitor-dashboard-i18n-v2
Complete Slovak i18n for the Monitor dashboard
2026-08-06 16:18:21 +02:00
Codex ef53fa0827 fix(i18n): localize login error messages 2026-08-05 09:04:18 +02:00
Codex 86517860ff fix(auth): allow setup after authentication is declined 2026-08-04 18:14:45 +02:00
Codex d62fc29581 fix(monitor): localize dynamic labels and generated status text
Localize runtime labels for SMART/NVMe details, backup target badges, firewall interface types, settings units, and generated health status copy while keeping the English and Slovak catalogs in key parity.
2026-08-04 17:02:03 +02:00
Codex 39e7a7d2db i18n(monitor): add complete Slovak dashboard translation
Complete the Slovak Monitor dashboard catalog, mark Slovak as complete, and update the translation notes to reflect the finished AppImage UI coverage.
2026-08-04 17:01:37 +02:00
Codex 88f1d09cb8 i18n(monitor): extract English dashboard messages
Move Monitor dashboard UI copy into translation keys and expand the English source catalog across the main pages, modals, and shared AppImage components.
2026-08-04 17:01:19 +02:00
Codex 2109cf2508 feat(monitor): add dashboard i18n infrastructure
Add the Monitor dashboard i18n provider, supported language metadata, message catalogs, fallback behavior, and the initial language selector/wiring for the AppImage UI.
2026-08-04 17:01:02 +02:00
MacRimi 04a028245b update 1.2.4.1 2026-08-03 17:00:18 +02:00
ProxMenuxBot f10ff18abc chore(lang): auto-rebuild translation cache
Source: 359a5c2
Triggered by: push
2026-08-02 18:50:23 +00:00
MacRimiandGitHub 359a5c269c Merge pull request #273 from Vaso73/feature/add-slovak-translation
feat(i18n): add Slovak translation
2026-08-02 20:49:31 +02:00
Vaso73 2a23cc9079 feat(i18n): add Slovak translation 2026-08-02 15:58:29 +02:00
MacRimi 7fcbc467df update 1.2.4.1 beta - apis 2026-08-01 11:29:32 +02:00
ProxMenuxBot f2d9c9fb8e chore(lang): auto-rebuild translation cache
Source: 2c05c80
Triggered by: push
2026-07-31 22:57:26 +00:00
MacRimi 2c05c800d9 Update update-pve-safe.sh 2026-08-01 00:56:18 +02:00
MacRimiandGitHub 89fa4e73a5 Merge pull request #272 from f3rs3n/fix/uninstall-boolean-registry
Fix uninstall menu for boolean registry entries
2026-07-31 19:00:45 +02:00
MacRimi c4a574422b Update flask_server.py 2026-07-31 18:56:10 +02:00
martino e7a9e3a5b6 Fix uninstall menu for boolean registry entries 2026-07-31 13:15:02 +02:00
MacRimi cbb46cb70a Update beta_version.txt 2026-07-29 00:16:32 +02:00
MacRimi 37189ed67e update 1.2.4.1 2026-07-29 00:15:39 +02:00
MacRimi ebd41b8a46 Update security_manager.py 2026-07-28 22:40:06 +02:00
MacRimi 42ca256bac update 1.2.4.1 2026-07-28 22:03:01 +02:00
github-actions[bot] 3d3b1fc902 Update AppImage beta build (2026-07-23 22:12:37) 2026-07-23 22:12:37 +00:00
101 changed files with 20057 additions and 4639 deletions
+2 -2
View File
@@ -28,7 +28,7 @@ from urllib.parse import quote
from urllib.request import Request, urlopen
DEFAULT_LANGUAGES = ("es", "fr", "de", "it", "pt")
DEFAULT_LANGUAGES = ("es", "fr", "de", "it", "pt", "sk")
DEFAULT_CONTEXT = "Context: Technical message for Proxmox and IT. Translate:"
TRANSLATE_CALL_RE = re.compile(
r"""translate\s+(?P<quote>["'])(?P<text>(?:\\.|(?! (?P=quote) ).)*?)(?P=quote)""",
@@ -260,7 +260,7 @@ def build_arg_parser() -> argparse.ArgumentParser:
parser.add_argument(
"--languages",
default=",".join(DEFAULT_LANGUAGES),
help="Comma-separated destination languages. Default: es,fr,de,it,pt",
help="Comma-separated destination languages. Default: es,fr,de,it,pt,sk",
)
parser.add_argument(
"--provider",
BIN
View File
Binary file not shown.
@@ -0,0 +1 @@
5dcad163093e68c6d7742c6724fb7df8f3d5bdd5542462f3120d2a00ad7bf699 ProxMenux-1.2.4.AppImage
+8 -5
View File
@@ -5,6 +5,7 @@ import { GeistMono } from "geist/font/mono"
import { ThemeProvider } from "../components/theme-provider"
import { PwaRegister } from "../components/pwa-register"
import { PwaInstallPrompt } from "../components/pwa-install-prompt"
import { I18nProvider } from "../lib/i18n/provider"
import { Suspense } from "react"
import "./globals.css"
@@ -43,13 +44,15 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body className={`${GeistSans.variable} ${GeistMono.variable} antialiased bg-background text-foreground`}>
<Suspense fallback={<div>Loading...</div>}>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
{children}
</ThemeProvider>
<Suspense fallback={null}>
<I18nProvider>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
{children}
</ThemeProvider>
<PwaInstallPrompt />
</I18nProvider>
</Suspense>
<PwaRegister />
<PwaInstallPrompt />
</body>
</html>
)
+4 -2
View File
@@ -5,8 +5,10 @@ import { ProxmoxDashboard } from "../components/proxmox-dashboard"
import { Login } from "../components/login"
import { AuthSetup } from "../components/auth-setup"
import { getApiUrl } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
export default function Home() {
const t = useT()
const [authStatus, setAuthStatus] = useState<{
loading: boolean
authEnabled: boolean
@@ -113,8 +115,8 @@ export default function Home() {
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
</div>
<div className="text-sm font-medium text-foreground">Loading...</div>
<p className="text-xs text-muted-foreground">Connecting to ProxMenux Monitor</p>
<div className="text-sm font-medium text-foreground">{t("app.loading")}</div>
<p className="text-xs text-muted-foreground">{t("app.connecting")}</p>
</div>
</div>
)
+26 -26
View File
@@ -13,6 +13,7 @@ import {
} from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { APP_VERSION } from "./release-notes-modal"
import { useT } from "../lib/i18n/provider"
// Issue #191: a dedicated About tab. Centralises project metadata
// (version, license, author) and every external link the project
@@ -22,8 +23,8 @@ import { APP_VERSION } from "./release-notes-modal"
// without re-cluttering the dashboard footer.
interface LinkRow {
label: string
description: string
labelKey: string
descriptionKey: string
href: string
Icon: React.ComponentType<{ className?: string }>
accent?: keyof typeof ACCENT_CLASSES
@@ -42,29 +43,29 @@ const ACCENT_CLASSES = {
const PROJECT_LINKS: LinkRow[] = [
{
label: "GitHub repository",
description: "Source code, releases and issue tracker.",
labelKey: "about.links.repository.label",
descriptionKey: "about.links.repository.description",
href: "https://github.com/MacRimi/ProxMenux",
Icon: Github,
accent: "gray",
},
{
label: "Documentation",
description: "Full user guide for ProxMenux and the Monitor.",
labelKey: "about.links.documentation.label",
descriptionKey: "about.links.documentation.description",
href: "https://proxmenux.com",
Icon: BookOpen,
accent: "blue",
},
{
label: "Discussions",
description: "Ask questions, share custom AI prompts, swap ideas.",
labelKey: "about.links.discussions.label",
descriptionKey: "about.links.discussions.description",
href: "https://github.com/MacRimi/ProxMenux/discussions",
Icon: MessageSquare,
accent: "purple",
},
{
label: "Report a bug or request a feature",
description: "Open an issue on GitHub — bugs, ideas, regressions.",
labelKey: "about.links.issues.label",
descriptionKey: "about.links.issues.description",
href: "https://github.com/MacRimi/ProxMenux/issues",
Icon: Bug,
accent: "red",
@@ -73,8 +74,8 @@ const PROJECT_LINKS: LinkRow[] = [
const SUPPORT_LINKS: LinkRow[] = [
{
label: "Support the project on Ko-fi",
description: "ProxMenux is free and open source. Donations cover hosting and dev time.",
labelKey: "about.links.support.label",
descriptionKey: "about.links.support.description",
href: "https://ko-fi.com/macrimi",
Icon: Heart,
accent: "pink",
@@ -82,6 +83,7 @@ const SUPPORT_LINKS: LinkRow[] = [
]
function LinkCard({ row }: { row: LinkRow }) {
const t = useT()
const accentClass = ACCENT_CLASSES[row.accent ?? "blue"]
// Style mirrors the PCI Devices cards in the Hardware tab: subtle
// translucent background by default, slightly lighter on hover, no
@@ -101,16 +103,17 @@ function LinkCard({ row }: { row: LinkRow }) {
</span>
<div className="min-w-0 flex-1">
<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" />
</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>
</a>
)
}
export function About() {
const t = useT()
return (
<div className="space-y-4 md:space-y-6">
{/* 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">
<Image
src="/images/proxmenux-logo.png"
alt="ProxMenux logo"
alt={t("about.logoAlt")}
fill
priority
className="object-contain"
@@ -131,9 +134,7 @@ export function About() {
ProxMenux Monitor
</h2>
<p className="text-sm text-muted-foreground mt-1">
A web dashboard and management layer for Proxmox VE health monitoring,
notifications, terminal, optimization tracker and more, packaged as a single
AppImage.
{t("about.heroDescription")}
</p>
<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">
@@ -151,7 +152,7 @@ export function About() {
const href = isPrerelease
? "https://github.com/MacRimi/ProxMenux/releases"
: "https://proxmenux.com/en/changelog"
const label = isPrerelease ? "Release notes" : "Changelog"
const label = isPrerelease ? t("about.releaseNotes") : t("about.changelog")
return (
<a
href={href}
@@ -175,9 +176,9 @@ export function About() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Github className="h-4 w-4 text-muted-foreground" />
Project
{t("about.project.title")}
</CardTitle>
<CardDescription>Repository, documentation and community channels.</CardDescription>
<CardDescription>{t("about.project.description")}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
@@ -195,11 +196,10 @@ export function About() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Heart className="h-4 w-4 text-pink-500" />
Support &amp; License
{t("about.support.title")}
</CardTitle>
<CardDescription>
ProxMenux is free and open source under the GPL-3.0 license. If it&apos;s useful to
you, a one-off contribution helps keep it that way.
{t("about.support.description")}
</CardDescription>
</CardHeader>
<CardContent>
@@ -218,11 +218,11 @@ export function About() {
</span>
<div className="min-w-0 flex-1">
<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" />
</div>
<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>
</div>
</a>
+33 -31
View File
@@ -7,12 +7,14 @@ import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { Shield, Lock, User, AlertCircle, Eye, EyeOff, Upload, Trash2 } from "lucide-react"
import { getApiUrl } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface AuthSetupProps {
onComplete: () => void
}
export function AuthSetup({ onComplete }: AuthSetupProps) {
const t = useT()
const [open, setOpen] = useState(false)
const [step, setStep] = useState<"choice" | "setup">("choice")
const [username, setUsername] = useState("")
@@ -74,7 +76,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || "Failed to skip authentication")
throw new Error(data.error || t("authSetup.skipFailed"))
}
if (data.auth_declined) {
@@ -86,7 +88,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
onComplete()
} catch (err) {
console.error("Auth skip error:", err)
setError(err instanceof Error ? err.message : "Failed to save preference")
setError(err instanceof Error ? err.message : t("authSetup.savePreferenceFailed"))
} finally {
setLoading(false)
}
@@ -108,17 +110,17 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
setError("")
if (!username || !password) {
setError("Please fill in all fields")
setError(t("authSetup.fillFields"))
return
}
if (password !== confirmPassword) {
setError("Passwords do not match")
setError(t("authSetup.passwordMismatch"))
return
}
if (password.length < 6) {
setError("Password must be at least 6 characters")
setError(t("authSetup.passwordTooShort"))
return
}
@@ -137,7 +139,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || "Failed to setup authentication")
throw new Error(data.error || t("authSetup.setupFailed"))
}
if (data.token) {
@@ -204,7 +206,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
onComplete()
} catch (err) {
console.error("Auth setup error:", err)
setError(err instanceof Error ? err.message : "Failed to setup authentication")
setError(err instanceof Error ? err.message : t("authSetup.setupFailed"))
} finally {
setLoading(false)
}
@@ -214,7 +216,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
<DialogTitle className="sr-only">
{step === "choice" ? "Setup Dashboard Protection" : "Create Password"}
{step === "choice" ? t("authSetup.choiceTitle") : t("authSetup.passwordTitle")}
</DialogTitle>
{step === "choice" ? (
<div className="space-y-6 py-2">
@@ -222,16 +224,16 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
<Shield className="h-8 w-8 text-blue-500" />
</div>
<h2 className="text-2xl font-bold">Protect Your Dashboard?</h2>
<h2 className="text-2xl font-bold">{t("authSetup.protectTitle")}</h2>
<p className="text-muted-foreground text-sm">
Add an extra layer of security to protect your Proxmox data when accessing from non-private networks.
{t("authSetup.protectDescription")}
</p>
</div>
<div className="space-y-3">
<Button onClick={() => setStep("setup")} className="w-full bg-blue-500 hover:bg-blue-600" size="lg">
<Lock className="h-4 w-4 mr-2" />
Yes, Setup Password
{t("authSetup.setupPassword")}
</Button>
<Button
onClick={handleSkipAuth}
@@ -240,11 +242,11 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
size="lg"
disabled={loading}
>
No, Continue Without Protection
{t("authSetup.skipProtection")}
</Button>
</div>
<p className="text-xs text-center text-muted-foreground">You can always enable this later in Settings</p>
<p className="text-xs text-center text-muted-foreground">{t("authSetup.enableLater")}</p>
</div>
) : (
<div className="space-y-6 py-2">
@@ -252,8 +254,8 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
<Lock className="h-8 w-8 text-blue-500" />
</div>
<h2 className="text-2xl font-bold">Setup Authentication</h2>
<p className="text-muted-foreground text-sm">Create a username and password to protect your dashboard</p>
<h2 className="text-2xl font-bold">{t("authSetup.setupTitle")}</h2>
<p className="text-muted-foreground text-sm">{t("authSetup.setupDescription")}</p>
</div>
{error && (
@@ -266,14 +268,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username" className="text-sm">
Username
{t("authSetup.username")}
</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="username"
type="text"
placeholder="Enter username"
placeholder={t("authSetup.usernamePlaceholder")}
value={username}
onChange={(e) => setUsername(e.target.value)}
className="pl-10 text-base"
@@ -285,14 +287,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="space-y-2">
<Label htmlFor="password" className="text-sm">
Password
{t("authSetup.password")}
</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="Enter password"
placeholder={t("authSetup.passwordPlaceholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10 text-base"
@@ -312,14 +314,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="space-y-2">
<Label htmlFor="confirm-password" className="text-sm">
Confirm Password
{t("authSetup.confirmPassword")}
</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="confirm-password"
type={showConfirmPassword ? "text" : "password"}
placeholder="Confirm password"
placeholder={t("authSetup.confirmPasswordPlaceholder")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="pl-10 text-base"
@@ -345,19 +347,19 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
setup endpoint returns the JWT. */}
<div className="pt-3 border-t border-border/60 space-y-4">
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Profile · optional
{t("authSetup.profileOptional")}
</p>
<div className="space-y-2">
<Label htmlFor="display-name" className="text-sm">
Display name
{t("authSetup.displayName")}
</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="display-name"
type="text"
placeholder="Shown above the username in the menu"
placeholder={t("authSetup.displayNamePlaceholder")}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
maxLength={64}
@@ -366,12 +368,12 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
/>
</div>
<p className="text-[11px] text-muted-foreground">
Leave empty to render the username itself. Up to 64 characters.
{t("authSetup.displayNameHint")}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm">Avatar</Label>
<Label className="text-sm">{t("authSetup.avatar")}</Label>
<div className="flex items-center gap-3">
{avatarPreviewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
@@ -407,7 +409,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
className="h-7 text-xs"
>
<Upload className="h-3 w-3 mr-1.5" />
{avatarFile ? "Change" : "Choose image"}
{avatarFile ? t("authSetup.change") : t("authSetup.chooseImage")}
</Button>
{avatarFile && (
<Button
@@ -419,12 +421,12 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
className="h-7 text-xs text-red-500 hover:text-red-500 hover:bg-red-500/10"
>
<Trash2 className="h-3 w-3 mr-1.5" />
Clear
{t("authSetup.clear")}
</Button>
)}
</div>
<p className="text-[11px] text-muted-foreground">
PNG, JPEG, WebP or GIF · up to 2 MB · pre-crop square for best results.
{t("authSetup.avatarHint")}
</p>
</div>
</div>
@@ -434,10 +436,10 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="space-y-2">
<Button onClick={handleSetupAuth} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Setting up..." : "Setup Authentication"}
{loading ? t("authSetup.settingUp") : t("authSetup.setupAuthentication")}
</Button>
<Button onClick={() => setStep("choice")} variant="ghost" className="w-full" disabled={loading}>
Back
{t("authSetup.back")}
</Button>
</div>
</div>
+8 -5
View File
@@ -11,6 +11,7 @@ import {
DropdownMenuTrigger,
} from "./ui/dropdown-menu"
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface AuthStatus {
auth_enabled?: boolean
@@ -57,6 +58,8 @@ interface AvatarMenuProps {
* proper /api/auth/logout that revokes the JWT server-side too.
*/
export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: AvatarMenuProps) {
const t = useT()
// IMPORTANT — all hooks must run unconditionally on every render. The
// previous version short-circuited with `if (!auth_enabled) return null`
// BEFORE the avatar blob hooks, so the hook count changed between
@@ -201,7 +204,7 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
<DropdownMenuTrigger asChild>
<button
className="rounded-full hover:ring-2 hover:ring-cyan-500/30 transition-all relative z-50 focus:outline-none focus-visible:outline-none active:outline-none data-[state=open]:outline-none data-[state=open]:ring-0 select-none"
aria-label="Open user menu"
aria-label={t("actions.openUserMenu")}
// WebKit ignores `outline` for the tap-highlight overlay
// shown on iOS / Android Chrome after a touch. That overlay
// was the white border that lingered on the avatar after
@@ -248,7 +251,7 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
<div className="text-xs text-muted-foreground truncate">{username}</div>
)}
{!profile?.display_name && (
<div className="text-xs text-muted-foreground truncate">Signed in</div>
<div className="text-xs text-muted-foreground truncate">{t("account.signedIn")}</div>
)}
</div>
</div>
@@ -257,13 +260,13 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
{onOpenProfile && (
<DropdownMenuItem onClick={onOpenProfile}>
<User className="h-4 w-4 mr-2" />
View profile
{t("account.viewProfile")}
</DropdownMenuItem>
)}
{onOpenSecurity && (
<DropdownMenuItem onClick={onOpenSecurity}>
<Shield className="h-4 w-4 mr-2" />
Security
{t("account.security")}
</DropdownMenuItem>
)}
{(onOpenProfile || onOpenSecurity) && <DropdownMenuSeparator />}
@@ -272,7 +275,7 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
>
<LogOut className="h-4 w-4 mr-2" />
Sign out
{t("account.signOut")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
+12 -10
View File
@@ -6,6 +6,7 @@ import { Badge } from "./ui/badge"
import { AreaChart, Area, ResponsiveContainer, Tooltip, YAxis } from "recharts"
import { fetchApi } from "@/lib/api-config"
import { useDiskTempThresholds } from "@/lib/health-thresholds"
import { useT } from "@/lib/i18n/provider"
interface TempPoint {
timestamp: number
@@ -24,11 +25,11 @@ interface DiskTemperatureCardProps {
// Disk-temperature thresholds come from the user-configurable backend
// (lib/health-thresholds.ts). The classifier here takes the resolved
// pair so the consumer can read it from the hook once per render.
function statusFor(temp: number, t: { warn: number; hot: number }) {
if (temp <= 0) return { label: "N/A", className: "bg-gray-500/10 text-gray-500 border-gray-500/20", color: "#6b7280" }
if (temp >= t.hot) return { label: "Hot", className: "bg-red-500/10 text-red-500 border-red-500/20", color: "#ef4444" }
if (temp >= t.warn) return { label: "Warm", className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", color: "#f59e0b" }
return { label: "Normal", className: "bg-green-500/10 text-green-500 border-green-500/20", color: "#22c55e" }
function statusFor(temp: number, thresholds: { warn: number; hot: number }) {
if (temp <= 0) return { labelKey: "common.notAvailable", className: "bg-gray-500/10 text-gray-500 border-gray-500/20", color: "#6b7280" }
if (temp >= thresholds.hot) return { labelKey: "details.temperature.status.hot", className: "bg-red-500/10 text-red-500 border-red-500/20", color: "#ef4444" }
if (temp >= thresholds.warn) return { labelKey: "details.temperature.status.warm", className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", color: "#f59e0b" }
return { labelKey: "details.temperature.status.normal", className: "bg-green-500/10 text-green-500 border-green-500/20", color: "#22c55e" }
}
const MiniTooltip = ({ active, payload }: any) => {
@@ -55,6 +56,7 @@ export function DiskTemperatureCard({
diskType,
onOpenDetail,
}: DiskTemperatureCardProps) {
const t = useT()
const [data, setData] = useState<TempPoint[]>([])
const [loading, setLoading] = useState(true)
const cancelled = useRef(false)
@@ -98,7 +100,7 @@ export function DiskTemperatureCard({
})()
const status = statusFor(liveTemperature, dt)
const lineColor = status.color
const tempDisplay = liveTemperature > 0 ? `${liveTemperature}°C` : "N/A"
const tempDisplay = liveTemperature > 0 ? `${liveTemperature}°C` : t("common.notAvailable")
const samples = data.length
const interactive = !!onOpenDetail
@@ -112,11 +114,11 @@ export function DiskTemperatureCard({
"w-full text-left border border-white/10 rounded-lg p-3 bg-white/[0.02]",
interactive ? "cursor-pointer hover:bg-white/[0.04] transition-colors focus:outline-none focus:ring-1 focus:ring-white/20" : "",
].join(" ")}
title={interactive ? "Open temperature history" : undefined}
title={interactive ? t("details.temperature.openHistory") : undefined}
>
<div className="flex items-start justify-between gap-3 mb-1.5">
<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 }}>
{tempDisplay}
</p>
@@ -124,7 +126,7 @@ export function DiskTemperatureCard({
<div className="flex flex-col items-end gap-1 flex-shrink-0">
<Thermometer className="h-3.5 w-3.5" style={{ color: lineColor }} />
<Badge variant="outline" className={`${status.className} text-[10px] px-2 py-0`}>
{status.label}
{t(status.labelKey)}
</Badge>
</div>
</div>
@@ -134,7 +136,7 @@ export function DiskTemperatureCard({
<div className="h-full w-full animate-pulse bg-white/[0.03] rounded" />
) : samples < 2 ? (
<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>
) : (
<ResponsiveContainer width="100%" height="100%">
@@ -8,12 +8,13 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
import { useIsMobile } from "../hooks/use-mobile"
import { fetchApi } from "@/lib/api-config"
import { useDiskTempThresholds, type DiskTempThreshold } from "@/lib/health-thresholds"
import { useT } from "@/lib/i18n/provider"
const TIMEFRAME_OPTIONS = [
{ value: "hour", label: "1 Hour" },
{ value: "day", label: "24 Hours" },
{ value: "week", label: "7 Days" },
{ value: "month", label: "30 Days" },
{ value: "hour", labelKey: "details.temperature.timeframes.hour" },
{ value: "day", labelKey: "details.temperature.timeframes.day" },
{ value: "week", labelKey: "details.temperature.timeframes.week" },
{ value: "month", labelKey: "details.temperature.timeframes.month" },
]
interface TempHistoryPoint {
@@ -69,10 +70,10 @@ function colorFor(temp: number, t: DiskTempThreshold): string {
}
function statusInfoFor(temp: number, t: DiskTempThreshold) {
if (temp <= 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
if (temp >= t.hot) return { status: "Hot", color: "bg-red-500/10 text-red-500 border-red-500/20" }
if (temp >= t.warn) return { status: "Warm", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" }
if (temp <= 0) return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
if (temp >= t.hot) return { color: "bg-red-500/10 text-red-500 border-red-500/20" }
if (temp >= t.warn) return { color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
return { color: "bg-green-500/10 text-green-500 border-green-500/20" }
}
export function DiskTemperatureDetailModal({
@@ -83,6 +84,7 @@ export function DiskTemperatureDetailModal({
liveTemperature,
diskType,
}: DiskTemperatureDetailModalProps) {
const t = useT()
const [timeframe, setTimeframe] = useState("day")
const [data, setData] = useState<TempHistoryPoint[]>([])
const [stats, setStats] = useState<TempStats>({ min: 0, max: 0, avg: 0, current: 0 })
@@ -168,7 +170,7 @@ export function DiskTemperatureDetailModal({
<SelectContent>
{TIMEFRAME_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
{t(opt.labelKey)}
</SelectItem>
))}
</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={`rounded-lg p-3 text-center border ${currentStatus.color}`}>
<div className="text-xs opacity-80 mb-1">Current</div>
<div className="text-lg font-bold">{currentTemp > 0 ? `${currentTemp}°C` : "N/A"}</div>
<div className="text-xs opacity-80 mb-1">{t("details.temperature.current")}</div>
<div className="text-lg font-bold">{currentTemp > 0 ? `${currentTemp}°C` : t("common.notAvailable")}</div>
</div>
<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">
<TrendingDown className="h-3 w-3" /> Min
<TrendingDown className="h-3 w-3" /> {t("details.temperature.min")}
</div>
<div className="text-lg font-bold text-green-500">{stats.min}°C</div>
</div>
<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">
<Minus className="h-3 w-3" /> Avg
<Minus className="h-3 w-3" /> {t("details.temperature.avg")}
</div>
<div className="text-lg font-bold text-foreground">{stats.avg}°C</div>
</div>
<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">
<TrendingUp className="h-3 w-3" /> Max
<TrendingUp className="h-3 w-3" /> {t("details.temperature.max")}
</div>
<div className="text-lg font-bold text-red-500">{stats.max}°C</div>
</div>
@@ -216,8 +218,8 @@ export function DiskTemperatureDetailModal({
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No temperature data yet for this disk</p>
<p className="text-sm mt-1">Samples are collected every 60 seconds</p>
<p>{t("details.temperature.noData")}</p>
<p className="text-sm mt-1">{t("details.temperature.sampleInterval")}</p>
</div>
</div>
) : (
@@ -250,7 +252,7 @@ export function DiskTemperatureDetailModal({
<Area
type="monotone"
dataKey="value"
name="Temperature"
name={t("details.temperature.seriesName")}
stroke={chartColor}
strokeWidth={2}
fill={`url(#diskTempGradient-${diskName})`}
@@ -1,6 +1,7 @@
"use client"
import { cn } from "@/lib/utils"
import { useT } from "../lib/i18n/provider"
interface SriovInfo {
role: "vf" | "pf-active" | "pf-idle"
@@ -26,6 +27,8 @@ export function GpuSwitchModeIndicator({
className,
sriovInfo,
}: GpuSwitchModeIndicatorProps) {
const t = useT()
// SR-IOV is a non-editable hardware state. Pending toggles don't apply here.
const displayMode = mode === "sriov" ? "sriov" : (pendingMode ?? mode)
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.
const sriovBadgeText = (() => {
if (!isSriovActive) return ""
if (sriovInfo?.role === "vf") return "SR-IOV VF"
if (sriovInfo?.vfCount && sriovInfo.vfCount > 0) return `SR-IOV ×${sriovInfo.vfCount}`
return "SR-IOV"
if (sriovInfo?.role === "vf") return t("hardware.gpuSwitch.sriovVf")
if (sriovInfo?.vfCount && sriovInfo.vfCount > 0) {
return t("hardware.gpuSwitch.sriovCount", { count: sriovInfo.vfCount })
}
return t("hardware.gpuSwitch.sriov")
})()
return (
@@ -124,7 +129,7 @@ export function GpuSwitchModeIndicator({
className="text-[14px] font-bold transition-all duration-300"
style={{ fontFamily: 'system-ui, sans-serif' }}
>
GPU
{t("hardware.gpuSwitch.gpu")}
</text>
</g>
@@ -268,7 +273,7 @@ export function GpuSwitchModeIndicator({
)}
style={{ fontFamily: 'system-ui, sans-serif' }}
>
LXC
{t("hardware.gpuSwitch.lxc")}
</text>
)}
{isSriovActive && (
@@ -279,7 +284,7 @@ export function GpuSwitchModeIndicator({
className="text-[9px] font-medium"
style={{ fontFamily: 'system-ui, sans-serif' }}
>
LXC
{t("hardware.gpuSwitch.lxc")}
</text>
)}
@@ -332,7 +337,7 @@ export function GpuSwitchModeIndicator({
)}
style={{ fontFamily: 'system-ui, sans-serif' }}
>
VM
{t("hardware.gpuSwitch.vm")}
</text>
)}
{isSriovActive && (
@@ -343,7 +348,7 @@ export function GpuSwitchModeIndicator({
className="text-[9px] font-medium"
style={{ fontFamily: 'system-ui, sans-serif' }}
>
VM
{t("hardware.gpuSwitch.vm")}
</text>
)}
</svg>
@@ -363,34 +368,47 @@ export function GpuSwitchModeIndicator({
)}
>
{isSriovActive
? "SR-IOV active"
? t("hardware.gpuSwitch.sriovActive")
: isLxcActive
? "Ready for LXC containers"
? t("hardware.gpuSwitch.readyForLxc")
: isVmActive
? "Ready for VM passthrough"
: "Mode unknown"}
? t("hardware.gpuSwitch.readyForVm")
: t("hardware.gpuSwitch.modeUnknown")}
</span>
<span className="text-sm text-muted-foreground">
{isSriovActive
? "Virtual Functions managed externally"
? t("hardware.gpuSwitch.virtualFunctionsExternal")
: isLxcActive
? "Native driver active"
? t("hardware.gpuSwitch.nativeDriverActive")
: isVmActive
? "VFIO-PCI driver active"
: "No driver detected"}
? t("hardware.gpuSwitch.vfioDriverActive")
: t("hardware.gpuSwitch.noDriverDetected")}
</span>
{isSriovActive && sriovInfo && (
<span className="text-xs font-mono text-teal-600/80 dark:text-teal-400/80">
{sriovInfo.role === "vf"
? `Virtual Function${sriovInfo.physfn ? ` · parent PF ${sriovInfo.physfn}` : ""}`
? t(
sriovInfo.physfn
? "hardware.gpuSwitch.virtualFunctionWithParent"
: "hardware.gpuSwitch.virtualFunction",
{ parent: sriovInfo.physfn || "" },
)
: sriovInfo.vfCount !== undefined
? `1 PF + ${sriovInfo.vfCount} VF${sriovInfo.vfCount === 1 ? "" : "s"}${sriovInfo.totalvfs ? ` / ${sriovInfo.totalvfs} max` : ""}`
? t(
sriovInfo.totalvfs
? "hardware.gpuSwitch.physicalFunctionWithMax"
: "hardware.gpuSwitch.physicalFunction",
{
count: sriovInfo.vfCount,
max: sriovInfo.totalvfs || "",
},
)
: null}
</span>
)}
{hasChanged && (
<span className="text-sm text-amber-500 font-medium animate-pulse">
Change pending...
{t("hardware.gpuSwitch.changePending")}
</span>
)}
</div>
File diff suppressed because it is too large Load Diff
+148 -122
View File
@@ -41,6 +41,7 @@ import {
HelpCircle,
} from "lucide-react"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { useT } from "@/lib/i18n/provider"
interface CategoryCheck {
status: string
@@ -104,19 +105,20 @@ interface HealthStatusModalProps {
}
const CATEGORIES = [
{ key: "cpu", category: "temperature", label: "CPU Usage & Temperature", Icon: Cpu },
{ key: "memory", category: "memory", label: "Memory & Swap", Icon: MemoryStick },
{ key: "storage", category: "storage", label: "Storage Mounts & Space", Icon: HardDrive },
{ key: "disks", category: "disks", label: "Disk I/O & Errors", Icon: Disc },
{ key: "network", category: "network", label: "Network Interfaces", Icon: Network },
{ key: "vms", category: "vms", label: "VMs & Containers", Icon: Box },
{ key: "services", category: "pve_services", label: "PVE Services", Icon: Settings },
{ key: "logs", category: "logs", label: "System Logs", Icon: FileText },
{ key: "updates", category: "updates", label: "System Updates", Icon: RefreshCw },
{ key: "security", category: "security", label: "Security & Certificates", Icon: Shield },
{ key: "cpu", category: "temperature", Icon: Cpu },
{ key: "memory", category: "memory", Icon: MemoryStick },
{ key: "storage", category: "storage", Icon: HardDrive },
{ key: "disks", category: "disks", Icon: Disc },
{ key: "network", category: "network", Icon: Network },
{ key: "vms", category: "vms", Icon: Box },
{ key: "services", category: "pve_services", Icon: Settings },
{ key: "logs", category: "logs", Icon: FileText },
{ key: "updates", category: "updates", Icon: RefreshCw },
{ key: "security", category: "security", Icon: Shield },
]
export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
const t = useT()
const [loading, setLoading] = useState(true)
const [healthData, setHealthData] = useState<HealthDetails | null>(null)
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
@@ -146,7 +148,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
if (!response.ok) {
// Fallback to legacy endpoint
const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders })
if (!legacyResponse.ok) throw new Error("Failed to fetch health details")
if (!legacyResponse.ok) throw new Error(t("healthStatus.errors.fetchFailed"))
const data = await legacyResponse.json()
setHealthData(data)
setDismissedItems([])
@@ -203,11 +205,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
})
window.dispatchEvent(event)
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error")
setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown"))
} finally {
setLoading(false)
}
}, [getApiUrl])
}, [getApiUrl, t])
// Tick counter to force re-render every 30s so "X minutes ago" stays current
const [, setTick] = useState(0)
@@ -280,20 +282,90 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
const statusUpper = status?.toUpperCase()
switch (statusUpper) {
case "OK":
return <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":
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":
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":
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":
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:
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)
const getCategoriesWithDismissed = () => {
const customCats = new Set(customSuppressions.map(cs => cs.category))
@@ -444,11 +516,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
const now = new Date()
const diffMs = now.getTime() - checkTime.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return "just now"
if (diffMin === 1) return "1 minute ago"
if (diffMin < 60) return `${diffMin} minutes ago`
if (diffMin < 1) return t("healthStatus.time.justNow")
if (diffMin === 1) return t("healthStatus.time.oneMinuteAgo")
if (diffMin < 60) return t("healthStatus.time.minutesAgo", { count: diffMin })
const diffHours = Math.floor(diffMin / 60)
return `${diffHours}h ${diffMin % 60}m ago`
return t("healthStatus.time.hoursMinutesAgo", { hours: diffHours, minutes: diffMin % 60 })
}
const getCategoryRowStyle = (status: string) => {
@@ -471,49 +543,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
}
const formatCheckLabel = (key: string): string => {
const labels: Record<string, string> = {
// CPU
cpu_usage: "CPU Usage",
cpu_temperature: "Temperature",
// Memory
ram_usage: "RAM Usage",
swap_usage: "Swap Usage",
// Disk I/O
root_filesystem: "Root Filesystem",
smart_health: "SMART Health",
io_errors: "I/O Errors",
zfs_pools: "ZFS Pools",
lvm_volumes: "LVM Volumes",
lvm_check: "LVM Status",
// Network
connectivity: "Connectivity",
// VMs & CTs
qmp_communication: "QMP Communication",
container_startup: "Container Startup",
vm_startup: "VM Startup",
oom_killer: "OOM Killer",
// Services
cluster_mode: "Cluster Mode",
// Logs (prefixed with log_)
log_error_cascade: "Error Cascade",
log_error_spike: "Error Spike",
log_persistent_errors: "Persistent Errors",
log_critical_errors: "Critical Errors",
// Updates
pve_version: "Proxmox VE Version",
security_updates: "Security Updates",
system_age: "System Age",
pending_updates: "Pending Updates",
kernel_pve: "Kernel / PVE",
// Security
uptime: "Uptime",
certificates: "Certificates",
login_attempts: "Login Attempts",
fail2ban: "Fail2Ban",
// Storage (Proxmox)
proxmox_storages: "Proxmox Storages",
}
if (labels[key]) return labels[key]
const knownKeys = new Set([
"cpu_usage", "cpu_temperature", "ram_usage", "swap_usage", "root_filesystem",
"smart_health", "io_errors", "zfs_pools", "lvm_volumes", "lvm_check", "connectivity",
"qmp_communication", "container_startup", "vm_startup", "oom_killer", "cluster_mode",
"log_error_cascade", "log_error_spike", "log_persistent_errors", "log_critical_errors",
"pve_version", "security_updates", "system_age", "pending_updates", "kernel_pve", "uptime",
"certificates", "login_attempts", "fail2ban", "proxmox_storages",
])
if (knownKeys.has(key)) return t(`healthStatus.checks.${key}`)
// Convert snake_case or camelCase to Title Case
return key
.replace(/_/g, " ")
@@ -543,15 +581,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
<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="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.permanent ? (
<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 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>
)
)}
@@ -563,6 +601,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
handleAcknowledge(checkData.error_key || checkKey, hours)
}
busy={dismissingKey === (checkData.error_key || checkKey)}
t={t}
/>
)}
</div>
@@ -582,12 +621,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
<div className="flex items-center justify-between gap-3">
<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" />
<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>}
</DialogTitle>
</div>
<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() && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
@@ -605,7 +644,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{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">
<p className="font-medium">Error loading health status</p>
<p className="font-medium">{t("healthStatus.errors.loading")}</p>
<p className="text-sm">{error}</p>
</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="text-center">
<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 className="text-center">
<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>
{stats.info > 0 && (
<div className="text-center">
<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 className="text-center">
<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 className="text-center">
<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>
{stats.unknown > 0 && (
<div className="text-center">
<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>
{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">
<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>
)}
{/* Category List */}
<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 originalStatus = categoryData?.status || "UNKNOWN"
const status = getEffectiveStatus(key, originalStatus)
const reason = categoryData?.reason
const reason = translateHealthText(categoryData?.reason)
const checks = categoryData?.checks
const isExpanded = expandedCategories.has(key)
const hasChecks = checks && Object.keys(checks).length > 0
@@ -677,7 +716,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
</div>
<div className="flex-1 min-w-0 overflow-hidden">
<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 && (
<span className="text-[10px] text-muted-foreground shrink-0">
({Object.values(checks).filter(c => c.installed !== false).length})
@@ -690,7 +729,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
</div>
<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)}`}>
{status}
{formatStatus(status)}
</Badge>
<ChevronRight
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)
}
busy={dismissingKey === `category_${key}_unknown`}
t={t}
/>
)}
</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">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
No issues detected
{t("healthStatus.noIssues")}
</div>
)}
{/* 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"
>
<Download className="h-4 w-4 mr-1.5" />
Update Now
{t("healthStatus.updateNow")}
</Button>
</div>
)}
@@ -758,12 +798,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
<div className="space-y-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" />
Dismissed Items ({filteredDismissed.length})
{t("healthStatus.dismissedItems", { count: filteredDismissed.length })}
</div>
{filteredDismissed.map((item) => {
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
const CatIcon = catMeta?.Icon || BellOff
const catLabel = catMeta?.label || item.category
const catLabel = catMeta ? t(`healthStatus.categories.${catMeta.key}`) : item.category
const isPermanent = item.permanent || item.suppression_remaining_hours === -1
return (
@@ -778,34 +818,28 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
<div className="flex items-start justify-between gap-2 mb-1">
<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="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 className="flex items-center gap-1.5 shrink-0">
{isPermanent ? (
<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 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 variant="outline" className={`text-[9px] sm:text-xs whitespace-nowrap ${getOutlineBadgeStyle(item.severity)}`}>
was {item.severity}
{t("healthStatus.wasStatus", { status: formatStatus(item.severity) })}
</Badge>
</div>
</div>
<p className="text-[10px] sm:text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{isPermanent
? "Permanently suppressed"
: `Suppressed for ${
item.suppression_remaining_hours < 24
? `${Math.round(item.suppression_remaining_hours)}h`
: item.suppression_remaining_hours < 720
? `${Math.round(item.suppression_remaining_hours / 24)} days`
: `${Math.round(item.suppression_remaining_hours / 720)} month(s)`
} more`
? t("healthStatus.permanentlySuppressed")
: t("healthStatus.suppressedForMore", { duration: formatDuration(item.suppression_remaining_hours) })
}
</p>
</div>
@@ -821,30 +855,20 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
<div className="space-y-2 pt-2">
<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" />
Custom Suppression Settings
{t("healthStatus.customSuppressionSettings")}
</div>
<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">
{customSuppressions.map((cs) => {
const catMeta = CATEGORIES.find(c => c.category === cs.category || c.key === cs.category || c.label === cs.label)
const catMeta = CATEGORIES.find(c => c.category === cs.category || c.key === cs.category)
const CatIcon = catMeta?.Icon || Settings2
const durationLabel = cs.hours === -1
? "Permanent"
: cs.hours >= 8760
? `${Math.floor(cs.hours / 8760)} year(s)`
: cs.hours >= 720
? `${Math.floor(cs.hours / 720)} month(s)`
: cs.hours >= 168
? `${Math.floor(cs.hours / 168)} week(s)`
: cs.hours >= 72
? `${Math.floor(cs.hours / 24)} days`
: `${cs.hours}h`
const durationLabel = formatDuration(cs.hours)
return (
<div key={cs.key} className="flex items-center justify-between gap-2">
<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" />
<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>
<Badge variant="outline" className="text-[9px] sm:text-[10px] border-blue-500/30 text-blue-400/80 bg-transparent shrink-0">
{durationLabel}
@@ -854,7 +878,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
})}
</div>
<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>
</div>
</div>
@@ -862,7 +886,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{healthData.timestamp && (
<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>
@@ -882,8 +906,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
params={{
EXECUTION_MODE: "web",
}}
title="Proxmox System Update"
description="Runs apt-get update + dist-upgrade and post-update cleanup on the host."
title={t("healthStatus.updateTerminalTitle")}
description={t("healthStatus.updateTerminalDescription")}
/>
</Dialog>
)
@@ -896,9 +920,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
function DismissDropdown({
onSelect,
busy,
t,
}: {
onSelect: (suppressionHours: number) => void
busy: boolean
t: ReturnType<typeof useT>
}) {
return (
<DropdownMenu>
@@ -915,27 +941,27 @@ function DismissDropdown({
) : (
<>
<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>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44" onClick={(e) => e.stopPropagation()}>
<DropdownMenuLabel className="text-[10px] uppercase tracking-wide text-muted-foreground">
Silence this alert for
{t("healthStatus.silenceFor")}
</DropdownMenuLabel>
<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 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>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => onSelect(-1)}
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>
</DropdownMenuContent>
</DropdownMenu>
+41 -36
View File
@@ -20,6 +20,7 @@ import {
Waves,
} from "lucide-react"
import { getApiUrl, getAuthToken } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
// Local fetch wrapper that *preserves* the JSON body on non-2xx
// responses so we can surface backend validation messages
@@ -282,6 +283,11 @@ function computeVisualRange(
// ─── Component ───────────────────────────────────────────────────────────────
export function HealthThresholds() {
const t = useT()
const tFallback = (key: string, fallback: string) => {
const translated = t(key)
return translated === key ? fallback : translated
}
const [tree, setTree] = useState<ThresholdsTree | null>(null)
const [loading, setLoading] = useState(true)
const [editMode, setEditMode] = useState(false)
@@ -299,7 +305,7 @@ export function HealthThresholds() {
)
if (res?.success && res.thresholds) setTree(res.thresholds)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load thresholds")
setError(err instanceof Error ? err.message : t("settings.healthThresholds.loadFailed"))
} finally {
setLoading(false)
}
@@ -322,7 +328,7 @@ export function HealthThresholds() {
if (trimmed === "") continue
const num = Number(trimmed)
if (!isFinite(num)) {
setError(`Invalid value for ${key}: must be a number`)
setError(t("settings.healthThresholds.invalidValue", { key }))
return null
}
// Walk into payload mirroring the path
@@ -362,7 +368,7 @@ export function HealthThresholds() {
{ method: "PUT", body: JSON.stringify(payload) },
)
if (!data.success || !data.thresholds) {
setError(data.message || "Save failed")
setError(data.message || t("status.saveFailed"))
return
}
setTree(data.thresholds)
@@ -371,14 +377,16 @@ export function HealthThresholds() {
setSavedFlash(true)
setTimeout(() => setSavedFlash(false), 2000)
} catch (err) {
setError(err instanceof Error ? err.message : "Network error while saving")
setError(err instanceof Error ? err.message : t("status.networkErrorWhileSaving"))
} finally {
setSaving(false)
}
}
const handleResetSection = async (sectionId: string) => {
if (!confirm(`Reset all "${SECTIONS.find((s) => s.id === sectionId)?.title}" thresholds to recommended values?`))
const section = SECTIONS.find((s) => s.id === sectionId)
const sectionTitle = section ? tFallback(`settings.healthThresholds.sections.${section.id}.title`, section.title) : sectionId
if (!confirm(t("settings.healthThresholds.resetSectionConfirm", { section: sectionTitle })))
return
try {
const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>(
@@ -386,7 +394,7 @@ export function HealthThresholds() {
{ method: "POST" },
)
if (!data.success || !data.thresholds) {
setError(data.message || "Reset failed")
setError(data.message || t("settings.healthThresholds.resetFailed"))
return
}
setTree(data.thresholds)
@@ -400,25 +408,25 @@ export function HealthThresholds() {
return next
})
} catch (err) {
setError(err instanceof Error ? err.message : "Network error while resetting")
setError(err instanceof Error ? err.message : t("settings.healthThresholds.networkErrorWhileResetting"))
}
}
const handleResetAll = async () => {
if (!confirm("Reset ALL thresholds to recommended values? This affects every section.")) return
if (!confirm(t("settings.healthThresholds.resetAllConfirm"))) return
try {
const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>(
"/api/health/thresholds/reset",
{ method: "POST" },
)
if (!data.success || !data.thresholds) {
setError(data.message || "Reset failed")
setError(data.message || t("settings.healthThresholds.resetFailed"))
return
}
setTree(data.thresholds)
setPending({})
} catch (err) {
setError(err instanceof Error ? err.message : "Network error while resetting")
setError(err instanceof Error ? err.message : t("settings.healthThresholds.networkErrorWhileResetting"))
}
}
@@ -441,7 +449,7 @@ export function HealthThresholds() {
const isCustomised = leaf.customised && !(key in pending)
const customisedClass = "border-blue-500 bg-blue-500/10 focus-visible:border-blue-500"
const fieldClass = isCustomised ? customisedClass : severityClass
const recommendedTooltip = `Recommended: ${leaf.recommended}${leaf.unit}`
const recommendedTooltip = `${t("settings.healthThresholds.recommended")}: ${leaf.recommended}${leaf.unit}`
return (
<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">
@@ -524,12 +532,12 @@ export function HealthThresholds() {
value={val}
onChange={(e) => setPending((p) => ({ ...p, [key]: e.target.value }))}
className={`absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background ${handleClass}`}
title={`Recommended: ${leaf.recommended}${unit}`}
title={`${t("settings.healthThresholds.recommended")}: ${leaf.recommended}${unit}`}
/>
</div>
<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 className="text-right">{severity === "critical" ? "CRIT" : "WARN"} &gt; {val}{unit}</span>
<span>{t("settings.healthThresholds.ok")} &lt; {val}{unit}</span>
<span className="text-right">{severity === "critical" ? t("settings.healthThresholds.crit") : t("settings.healthThresholds.warn")} &gt; {val}{unit}</span>
</div>
</div>
)
@@ -641,7 +649,7 @@ export function HealthThresholds() {
value={wVal}
onChange={(e) => setVal(wKey, Number(e.target.value), cVal, true)}
className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-amber-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-amber-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background"
title={`Warning (recommended: ${wLeaf.recommended}${unit})`}
title={`${t("settings.healthThresholds.warning")} (${t("settings.healthThresholds.recommended").toLowerCase()}: ${wLeaf.recommended}${unit})`}
/>
<input
type="range"
@@ -652,7 +660,7 @@ export function HealthThresholds() {
value={cVal}
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"
title={`Critical (recommended: ${cLeaf.recommended}${unit})`}
title={`${t("settings.healthThresholds.critical")} (${t("settings.healthThresholds.recommended").toLowerCase()}: ${cLeaf.recommended}${unit})`}
/>
</div>
@@ -660,9 +668,9 @@ export function HealthThresholds() {
"warn" starts and ends without having to read the handles. */}
{!options?.hideLabels && (
<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 className="text-center">WARN {wVal}{cVal}{unit}</span>
<span className="text-right">CRIT &gt; {cVal}{unit}</span>
<span>{t("settings.healthThresholds.ok")} &lt; {wVal}{unit}</span>
<span className="text-center">{t("settings.healthThresholds.warn")} {wVal}{cVal}{unit}</span>
<span className="text-right">{t("settings.healthThresholds.crit")} &gt; {cVal}{unit}</span>
</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 gap-2 min-w-0">
<SlidersHorizontal className="h-5 w-5 text-amber-500" />
<CardTitle>Health Monitor Thresholds</CardTitle>
<CardTitle>{t("settings.healthThresholds.title")}</CardTitle>
</div>
{!loading && (
<div className="flex items-center gap-2">
{savedFlash && (
<span className="flex items-center gap-1 text-xs text-green-500">
<Check className="h-3.5 w-3.5" />
Saved
{t("status.saved")}
</span>
)}
{editMode ? (
@@ -692,7 +700,7 @@ export function HealthThresholds() {
onClick={handleCancel}
disabled={saving}
>
Cancel
{t("actions.cancel")}
</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"
@@ -704,7 +712,7 @@ export function HealthThresholds() {
) : (
<Check className="h-3 w-3" />
)}
Save
{t("actions.save")}
</button>
</>
) : (
@@ -712,17 +720,17 @@ export function HealthThresholds() {
<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"
onClick={handleResetAll}
title="Reset every threshold to its recommended value"
title={t("settings.healthThresholds.resetAllTitle")}
>
<RotateCcw className="h-3 w-3" />
Reset all
{t("actions.resetAll")}
</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"
onClick={handleEdit}
>
<Settings2 className="h-3 w-3" />
Edit
{t("actions.edit")}
</button>
</>
)}
@@ -730,10 +738,7 @@ export function HealthThresholds() {
)}
</div>
<CardDescription>
The Health Monitor and notifications fire when these thresholds are crossed.
Drag the amber handle to set the warning level and the red handle to set the
critical level. Values that differ from the recommended default appear in blue
hover a handle to see the recommendation, or use Reset to restore it.
{t("settings.healthThresholds.description")}
</CardDescription>
</CardHeader>
<CardContent>
@@ -742,7 +747,7 @@ export function HealthThresholds() {
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : !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>
{error && (
@@ -767,13 +772,13 @@ export function HealthThresholds() {
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2 min-w-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>
{editMode && (
<button
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)}
title="Reset this section to recommended"
title={t("settings.healthThresholds.resetSectionTitle")}
>
<RotateCcw className="h-3 w-3" />
</button>
@@ -781,7 +786,7 @@ export function HealthThresholds() {
</div>
{section.description && (
<p className="text-[11px] text-muted-foreground mb-1.5 leading-snug">
{section.description}
{tFallback(`settings.healthThresholds.sections.${section.id}.description`, section.description)}
</p>
)}
<div>
@@ -806,12 +811,12 @@ export function HealthThresholds() {
// visual language end to end.
<>
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1">
RAM
{t("settings.healthThresholds.ram")}
</div>
{renderThresholdRange(["memory"])}
<div className="border-t border-border/40">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1 pt-1.5">
Swap (critical only)
{t("settings.healthThresholds.swapCriticalOnly")}
</div>
{renderSingleThresholdSlider(["memory", "swap_critical"], "critical")}
</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 { useIsMobile } from "../hooks/use-mobile"
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 = [
{ value: "hour", label: "1 Hour" },
{ value: "6hour", label: "6 Hours" },
{ value: "day", label: "24 Hours" },
{ value: "3day", label: "3 Days" },
{ value: "week", label: "7 Days" },
{ value: "hour", labelKey: "network.latency.timeframes.hour" },
{ value: "6hour", labelKey: "network.latency.timeframes.sixHours" },
{ value: "day", labelKey: "network.latency.timeframes.day" },
{ value: "3day", labelKey: "network.latency.timeframes.threeDays" },
{ value: "week", labelKey: "network.latency.timeframes.week" },
]
const TARGET_OPTIONS = [
{ value: "gateway", label: "Gateway (Router)", shortLabel: "Gateway", realtime: false },
{ value: "cloudflare", label: "Cloudflare (1.1.1.1)", shortLabel: "Cloudflare", realtime: true },
{ value: "google", label: "Google DNS (8.8.8.8)", shortLabel: "Google DNS", realtime: true },
{ value: "gateway", labelKey: "network.latency.targets.gateway", shortLabelKey: "network.latency.targets.gatewayShort", realtime: false },
{ value: "cloudflare", labelKey: "network.latency.targets.cloudflare", shortLabelKey: "network.latency.targets.cloudflareShort", realtime: true },
{ value: "google", labelKey: "network.latency.targets.google", shortLabelKey: "network.latency.targets.googleShort", realtime: true },
]
// Realtime test configuration
@@ -60,7 +63,22 @@ interface LatencyDetailModalProps {
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) {
const entry = payload[0]
const data = entry?.payload
@@ -76,17 +94,17 @@ const CustomTooltip = ({ active, payload, label }: any) => {
<>
<div className="flex items-center gap-2">
<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>
</div>
<div className="flex items-center gap-2">
<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>
</div>
<div className="flex items-center gap-2">
<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>
</div>
</>
@@ -94,14 +112,14 @@ const CustomTooltip = ({ active, payload, label }: any) => {
// Simple latency display for single data points
<div className="flex items-center gap-2">
<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>
</div>
)}
{packetLoss !== undefined && packetLoss > 0 && (
<div className="flex items-center gap-2">
<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>
</div>
)}
@@ -118,20 +136,38 @@ const getStatusColor = (latency: number) => {
return "#22c55e"
}
const getStatusInfo = (latency: number | null) => {
if (latency === null || latency === 0) return { status: "N/A", 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 < 100) return { 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" }
return { status: "Poor", color: "bg-red-500/10 text-red-500 border-red-500/20" }
const getStatusInfo = (latency: number | null, t: TFunction) => {
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: t("network.latency.status.excellent"), 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: t("network.latency.status.fair"), color: "bg-yellow-500/10 text-yellow-500 border-yellow-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 => {
if (latency === null || latency === 0) return "N/A"
if (latency < 50) return "Excellent"
if (latency < 100) return "Good"
if (latency < 200) return "Fair"
return "Poor"
const getStatusKey = (latency: number | null): "na" | "excellent" | "good" | "fair" | "poor" => {
if (latency === null || latency === 0) return "na"
if (latency < 50) return "excellent"
if (latency < 100) return "good"
if (latency < 200) return "fair"
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 {
@@ -145,9 +181,10 @@ interface ReportData {
testDuration?: number
}
const generateLatencyReport = (report: ReportData) => {
const generateLatencyReport = (report: ReportData, t: TFunction) => {
const now = new Date().toLocaleString()
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
const validRealtimeValues = report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!)
@@ -160,29 +197,57 @@ const generateLatencyReport = (report: ReportData) => {
} : null
const statusText = report.isRealtime
? getStatusText(realtimeStats?.current ?? null)
: getStatusText(report.stats.current)
? getStatusText(realtimeStats?.current ?? null, t)
: getStatusText(report.stats.current, t)
// Colors matching Lynis report
const statusColorMap: Record<string, string> = {
"Excellent": "#16a34a",
"Good": "#16a34a",
"Fair": "#ca8a04",
"Poor": "#dc2626",
"N/A": "#64748b"
excellent: "#16a34a",
good: "#16a34a",
fair: "#ca8a04",
poor: "#dc2626",
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
const realtimeTableRows = report.realtimeResults.map((r, i) => `
<tr${r.packet_loss > 0 ? ' class="warn"' : ''}>
<td>${i + 1}</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><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>
`).join('')
@@ -199,9 +264,9 @@ const generateLatencyReport = (report: ReportData) => {
<tr${d.packet_loss && d.packet_loss > 0 ? ' class="warn"' : ''}>
<td>${i + 1}</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><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>
`).join('')
@@ -210,7 +275,7 @@ const generateLatencyReport = (report: ReportData) => {
? report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!)
: 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) {
const rawMin = Math.min(...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>
<polygon points="${areaPoints}" fill="url(#areaGrad)"/>
<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>
`
}
const html = `<!DOCTYPE html>
<html lang="en">
<html lang="${htmlLang}">
<head>
<meta charset="UTF-8">
<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>
* { 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; }
@@ -463,11 +528,11 @@ const generateLatencyReport = (report: ReportData) => {
<div class="top-bar no-print">
<div class="top-bar-left">
<div>
<div class="top-bar-title">ProxMenux Network Latency Report</div>
<div class="top-bar-subtitle">Review the report, then print or save as PDF</div>
<div class="top-bar-title">${t("network.latency.report.topBarTitle")}</div>
<div class="top-bar-subtitle">${t("network.latency.report.topBarSubtitle")}</div>
</div>
</div>
<button onclick="window.print()">Print / Save as PDF</button>
<button onclick="window.print()">${t("network.latency.report.printSavePdf")}</button>
</div>
<!-- Header -->
@@ -475,21 +540,21 @@ const generateLatencyReport = (report: ReportData) => {
<div class="rpt-header-left">
<img src="${logoUrl}" alt="ProxMenux" onerror="this.style.display='none'" />
<div>
<h1>Network Latency Report</h1>
<p>ProxMenux Monitor - Network Performance Analysis</p>
<h1>${t("network.latency.report.title")}</h1>
<p>${t("network.latency.report.subtitle")}</p>
</div>
</div>
<div class="rpt-header-right">
<div><strong>Date:</strong> ${now}</div>
<div><strong>Target:</strong> ${report.targetLabel}</div>
<div><strong>Mode:</strong> ${report.isRealtime ? 'Real-time Test' : 'Historical Analysis'}</div>
<div class="rid">ID: PMXL-${Date.now().toString(36).toUpperCase()}</div>
<div><strong>${t("network.latency.report.date")}:</strong> ${now}</div>
<div><strong>${t("network.latency.report.target")}:</strong> ${report.targetLabel}</div>
<div><strong>${t("network.latency.report.mode")}:</strong> ${modeLabel}</div>
<div class="rid">ID: ${reportId}</div>
</div>
</div>
<!-- 1. Executive Summary -->
<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="latency-gauge">
<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>
</svg>
<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>
</div>
<div class="gauge-status" style="color:${statusColor};">${statusText}</div>
</div>
<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>
${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'}.
Average latency: <strong style="color:${statusColor}">${realtimeStats?.avg?.toFixed(1) ?? 'N/A'} ms</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>'}`
: `Historical latency analysis to <strong>Gateway</strong> over <strong>${timeframeLabel.toLowerCase()}</strong>.
<strong>${report.data.length} samples</strong> analyzed.
Average latency: <strong style="color:${statusColor}">${report.stats.avg} ms</strong>.`
? `${t("network.latency.report.realtimeSummary", {
target: `<strong>${report.targetLabel}</strong>`,
count: `<strong>${report.realtimeResults.length}</strong>`,
duration: realtimeDurationText,
avg: `<strong style="color:${statusColor}">${realtimeStats?.avg?.toFixed(1) ?? notAvailable} 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>
<div class="latency-range">
<div class="range-item">
<span class="range-label">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-label">${t("network.labels.minimum")}</span>
<span class="range-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? notAvailable) : report.stats.min} ms</span>
</div>
<div class="range-item">
<span class="range-label">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-label">${t("network.labels.average")}</span>
<span class="range-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? notAvailable) : report.stats.avg} ms</span>
</div>
<div class="range-item">
<span class="range-label">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-label">${t("network.labels.maximum")}</span>
<span class="range-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? notAvailable) : report.stats.max} ms</span>
</div>
</div>
</div>
@@ -545,42 +616,40 @@ const generateLatencyReport = (report: ReportData) => {
<!-- 2. Statistics -->
<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="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-label">Current</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">${t("network.labels.current")}</div>
</div>
<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-label">Minimum</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">${t("network.labels.minimum")}</div>
</div>
<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-label">Average</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">${t("network.labels.average")}</div>
</div>
<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-label">Maximum</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">${t("network.labels.maximum")}</div>
</div>
</div>
<div class="grid-3">
<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>
<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'};">
${report.isRealtime ? (realtimeStats?.avgPacketLoss?.toFixed(1) ?? '0') : (historyStats?.avgPacketLoss ?? '0')}%
</div>
</div>
<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;">
${report.isRealtime
? (report.testDuration ? Math.round(report.testDuration / 60) + ' min' : 'Real-time')
: timeframeLabel}
${testPeriodValue}
</div>
</div>
</div>
@@ -588,7 +657,7 @@ const generateLatencyReport = (report: ReportData) => {
<!-- 3. Latency Graph (always section 3) -->
<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;">
${chartSvg}
</div>
@@ -596,37 +665,37 @@ const generateLatencyReport = (report: ReportData) => {
<!-- 4. Performance Thresholds (always section 4) -->
<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-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 class="threshold-item">
<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 class="threshold-item">
<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 class="threshold-item">
<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>
${report.isRealtime && report.realtimeResults.length > 0 ? `
<!-- 5. Detailed Test Results (for Cloudflare / Google DNS) -->
<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">
<thead>
<tr>
<th>#</th>
<th>Time</th>
<th>Latency</th>
<th>Packet Loss</th>
<th>Status</th>
<th>${t("network.labels.time")}</th>
<th>${t("network.labels.latency")}</th>
<th>${t("network.labels.packetLoss")}</th>
<th>${t("network.labels.status")}</th>
</tr>
</thead>
<tbody>
@@ -639,15 +708,15 @@ const generateLatencyReport = (report: ReportData) => {
${!report.isRealtime && report.data.length > 0 ? `
<!-- 5. Detailed History (for Gateway) -->
<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">
<thead>
<tr>
<th>#</th>
<th>Time</th>
<th>Latency</th>
<th>Packet Loss</th>
<th>Status</th>
<th>${t("network.labels.time")}</th>
<th>${t("network.labels.latency")}</th>
<th>${t("network.labels.packetLoss")}</th>
<th>${t("network.labels.status")}</th>
</tr>
</thead>
<tbody>
@@ -659,41 +728,41 @@ ${!report.isRealtime && report.data.length > 0 ? `
<!-- Methodology -->
<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="card">
<div class="card-label">Test Method</div>
<div class="card-value" style="font-size:12px;">ICMP Echo Request (Ping)</div>
<div class="card-label">${t("network.latency.report.testMethod")}</div>
<div class="card-value" style="font-size:12px;">${t("network.latency.report.icmpEchoRequest")}</div>
</div>
<div class="card">
<div class="card-label">Samples per Test</div>
<div class="card-value" style="font-size:12px;">3 consecutive pings</div>
<div class="card-label">${t("network.latency.report.samplesPerTest")}</div>
<div class="card-value" style="font-size:12px;">${t("network.latency.report.threeConsecutivePings")}</div>
</div>
<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>
<div class="card">
<div class="card-label">Target IP</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-label">${t("network.latency.report.targetIp")}</div>
<div class="card-value" style="font-size:12px;">${targetIpLabel}</div>
</div>
</div>
<div class="info-box">
<h4>Performance Assessment</h4>
<h4>${t("network.latency.report.performanceAssessment")}</h4>
<p>${
statusText === 'Excellent' ? 'Network latency is excellent. No action required.' :
statusText === 'Good' ? 'Network latency is within acceptable parameters.' :
statusText === 'Fair' ? 'Network latency is elevated. Consider investigating network congestion or routing issues.' :
statusText === 'Poor' ? 'Network latency is critically high. Immediate investigation recommended.' :
'Unable to determine network status.'
statusKey === 'excellent' ? t("network.latency.report.assessmentExcellent") :
statusKey === 'good' ? t("network.latency.report.assessmentGood") :
statusKey === 'fair' ? t("network.latency.report.assessmentFair") :
statusKey === 'poor' ? t("network.latency.report.assessmentPoor") :
t("network.latency.report.assessmentUnknown")
}</p>
</div>
</div>
<!-- Footer -->
<div class="rpt-footer">
<div>ProxMenux Monitor - Network Performance Report</div>
<div>Generated: ${now} | Report ID: PMXL-${Date.now().toString(36).toUpperCase()}</div>
<div>${t("network.latency.report.footerTitle")}</div>
<div>${t("network.latency.report.generated")}: ${now} | ${t("network.latency.report.reportId")}: ${reportId}</div>
</div>
</body>
@@ -706,6 +775,7 @@ ${!report.isRealtime && report.data.length > 0 ? `
}
export function LatencyDetailModal({ open, onOpenChange, currentLatency }: LatencyDetailModalProps) {
const t = useT()
const [timeframe, setTimeframe] = useState("hour")
const [target, setTarget] = useState("gateway")
const [data, setData] = useState<LatencyHistoryPoint[]>([])
@@ -882,7 +952,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
avg: Math.round((realtimeStats?.avg ?? 0) * 10) / 10,
} : stats
const statusInfo = getStatusInfo(displayStats.current)
const statusInfo = getStatusInfo(displayStats.current, t)
// Calculate test duration for report based on first and last result timestamps
const testDuration = realtimeResults.length >= 2
@@ -897,20 +967,20 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-foreground">
<Wifi className="h-5 w-5 text-blue-500" />
Network Latency
{t("network.cards.latency")}
</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-2 mt-1 flex-nowrap">
<Select value={target} onValueChange={setTarget}>
<SelectTrigger className="w-[140px] sm:w-[180px] h-8 text-xs shrink-0">
<span className="truncate">
{TARGET_OPTIONS.find(t => t.value === target)?.shortLabel || target}
{getLatencyTargetShortLabel(target, t)}
</span>
</SelectTrigger>
<SelectContent>
{TARGET_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
{t(opt.labelKey)}
</SelectItem>
))}
</SelectContent>
@@ -923,7 +993,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
<SelectContent>
{TIMEFRAME_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
{t(opt.labelKey)}
</SelectItem>
))}
</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"
>
<Square className="h-3 w-3 fill-current" />
Stop
{t("network.latency.actions.stop")}
</Button>
) : (
<Button
@@ -948,7 +1018,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
className="gap-1.5 shrink-0 h-8 px-3"
>
<RefreshCw className="h-3 w-3" />
Test Again
{t("network.latency.actions.testAgain")}
</Button>
)
)}
@@ -957,19 +1027,19 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
size="sm"
onClick={() => generateLatencyReport({
target,
targetLabel: TARGET_OPTIONS.find(t => t.value === target)?.label || target,
targetLabel: getLatencyTargetLabel(target, t),
isRealtime,
stats,
realtimeResults,
data,
timeframe,
testDuration: isRealtime ? testDuration : undefined,
})}
}, t)}
disabled={isRealtime ? realtimeResults.length === 0 : data.length === 0}
className="gap-1.5 shrink-0 h-8 px-3"
>
<FileText className="h-3.5 w-3.5" />
Report
{t("network.latency.actions.report")}
</Button>
</div>
@@ -977,8 +1047,8 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
{isRealtime && realtimeTesting && (
<div className="mb-4">
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
<span>Testing... {Math.round(testProgress)}%</span>
<span>{Math.round((REALTIME_TEST_DURATION * (1 - testProgress / 100)))}s remaining</span>
<span>{t("network.latency.testingProgress", { percent: Math.round(testProgress) })}</span>
<span>{t("network.latency.secondsRemaining", { seconds: Math.round((REALTIME_TEST_DURATION * (1 - testProgress / 100))) })}</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
@@ -992,7 +1062,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
{/* 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 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) }}>
{displayStats.current || '-'}
</span>
@@ -1000,19 +1070,19 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
</div>
<div className="flex items-center gap-1 min-w-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-[10px] text-muted-foreground">ms</span>
</div>
<div className="flex items-center gap-1 min-w-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-[10px] text-muted-foreground">ms</span>
</div>
<div className="flex items-center gap-1 min-w-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-[10px] text-muted-foreground">ms</span>
</div>
@@ -1025,8 +1095,8 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
</Badge>
{isRealtime && (
<span className="text-xs text-muted-foreground">
{realtimeResults.length} sample{realtimeResults.length !== 1 ? 's' : ''} collected
{realtimeStats?.packetLoss ? ` | ${realtimeStats.packetLoss}% packet loss` : ''}
{t("network.latency.samplesCollected", { count: realtimeResults.length })}
{realtimeStats?.packetLoss ? ` | ${t("network.latency.packetLossValue", { value: realtimeStats.packetLoss })}` : ""}
</span>
)}
</div>
@@ -1058,7 +1128,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
domain={['dataMin - 1', 'dataMax + 2']}
tickFormatter={(v) => `${Number(v).toFixed(1)}ms`}
/>
<Tooltip content={<CustomTooltip />} />
<Tooltip content={<CustomTooltip t={t} />} />
<Area
type="monotone"
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">
<Activity className="h-12 w-12 mb-3 opacity-30" />
<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>
</div>
)
@@ -1107,7 +1177,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
domain={['dataMin - 1', 'dataMax + 2']}
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 1 hour view, show avg values since there's no downsampling */}
<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">
<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-xs mt-1">Data is collected every 60 seconds</p>
<p className="text-sm">{t("network.latency.noDataForPeriod")}</p>
<p className="text-xs mt-1">{t("network.latency.collectionInterval")}</p>
</div>
)}
</div>
@@ -1133,8 +1203,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
{isRealtime && (
<div className="mt-4 p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<p className="text-xs text-blue-400">
<strong>Real-time Mode:</strong> Tests run for 2 minutes with readings every 5 seconds.
Click "Test Again" to add more samples. All data is included in the report.
<strong>{t("network.latency.realTimeMode")}:</strong> {t("network.latency.realTimeModeDescription")}
</p>
</div>
)}
+27 -19
View File
@@ -9,6 +9,7 @@ import { Label } from "./ui/label"
import { Checkbox } from "./ui/checkbox"
import { Lock, User, AlertCircle, Server, Shield, Eye, EyeOff } from "lucide-react"
import { getApiUrl } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
import Image from "next/image"
interface LoginProps {
@@ -16,6 +17,7 @@ interface LoginProps {
}
export function Login({ onLogin }: LoginProps) {
const t = useT()
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [totpCode, setTotpCode] = useState("")
@@ -56,12 +58,12 @@ export function Login({ onLogin }: LoginProps) {
setError("")
if (!username || !password) {
setError("Please enter username and password")
setError(t("login.missingCredentials"))
return
}
if (requiresTotp && !totpCode) {
setError("Please enter your 2FA code")
setError(t("login.missingTotp"))
return
}
@@ -80,14 +82,20 @@ export function Login({ onLogin }: LoginProps) {
const data = await response.json()
if (data.requires_totp) {
if (response.ok && data.requires_totp) {
setRequiresTotp(true)
setLoading(false)
return
}
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)
@@ -107,7 +115,7 @@ export function Login({ onLogin }: LoginProps) {
onLogin()
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed")
setError(err instanceof Error ? err.message : t("login.loginFailed"))
} finally {
setLoading(false)
}
@@ -139,8 +147,8 @@ export function Login({ onLogin }: LoginProps) {
</div>
</div>
<div>
<h1 className="text-3xl font-bold">ProxMenux Monitor</h1>
<p className="text-muted-foreground mt-2">Sign in to access your dashboard</p>
<h1 className="text-3xl font-bold">{t("app.title")}</h1>
<p className="text-muted-foreground mt-2">{t("login.subtitle")}</p>
</div>
</div>
@@ -157,14 +165,14 @@ export function Login({ onLogin }: LoginProps) {
<>
<div className="space-y-2">
<Label htmlFor="login-username" className="text-sm">
Username
{t("login.username")}
</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="login-username"
type="text"
placeholder="Enter your username"
placeholder={t("login.usernamePlaceholder")}
value={username}
onChange={(e) => setUsername(e.target.value)}
className="pl-10 text-base"
@@ -176,14 +184,14 @@ export function Login({ onLogin }: LoginProps) {
<div className="space-y-2">
<Label htmlFor="login-password" className="text-sm">
Password
{t("login.password")}
</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="login-password"
type={showPassword ? "text" : "password"}
placeholder="Enter your password"
placeholder={t("login.passwordPlaceholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10 pr-10 text-base"
@@ -214,7 +222,7 @@ export function Login({ onLogin }: LoginProps) {
disabled={loading}
/>
<Label htmlFor="remember-me" className="text-sm font-normal cursor-pointer select-none">
Remember me
{t("login.rememberMe")}
</Label>
</div>
</>
@@ -223,14 +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">
<Shield className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-blue-500">Two-Factor Authentication</p>
<p className="text-xs text-blue-500 mt-1">Enter the 6-digit code from your authentication app</p>
<p className="text-sm font-medium text-blue-500">{t("login.twoFactorTitle")}</p>
<p className="text-xs text-blue-500 mt-1">{t("login.twoFactorDescription")}</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="totp-code" className="text-sm">
Authentication Code
{t("login.authenticationCode")}
</Label>
<Input
id="totp-code"
@@ -245,7 +253,7 @@ export function Login({ onLogin }: LoginProps) {
autoFocus
/>
<p className="text-xs text-muted-foreground text-center">
You can also use a backup code (format: XXXX-XXXX)
{t("login.backupCodeHint")}
</p>
</div>
@@ -260,18 +268,18 @@ export function Login({ onLogin }: LoginProps) {
}}
className="w-full"
>
Back to login
{t("login.backToLogin")}
</Button>
</div>
)}
<Button type="submit" className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Signing in..." : requiresTotp ? "Verify Code" : "Sign In"}
{loading ? t("login.signingIn") : requiresTotp ? t("login.verifyCode") : t("login.signIn")}
</Button>
</form>
</div>
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.2.4</p>
<p className="text-center text-sm text-muted-foreground">{t("login.version")}</p>
</div>
</div>
)
+77 -67
View File
@@ -1,7 +1,7 @@
"use client"
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 { Button } from "@/components/ui/button"
import {
@@ -37,6 +37,7 @@ import { Dialog as SearchDialog, DialogContent as SearchDialogContent, DialogTit
import "xterm/css/xterm.css"
import { API_PORT, fetchApi } from "@/lib/api-config"
import { getTicketedWsUrl } from "@/lib/terminal-ws"
import { useT } from "@/lib/i18n/provider"
interface LxcTerminalModalProps {
open: boolean
@@ -51,33 +52,35 @@ interface CheatSheetResult {
examples: string[]
}
const proxmoxCommands = [
{ cmd: "ls -la", desc: "List all files with details" },
{ cmd: "cd /path/to/dir", desc: "Change directory" },
{ 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: "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: "systemctl status service", desc: "Check service status" },
{ cmd: "systemctl restart service", desc: "Restart a service" },
{ cmd: "apt update && apt upgrade", desc: "Update packages" },
{ cmd: "apt install package", desc: "Install package" },
{ cmd: "tail -f /var/log/syslog", desc: "Follow log file" },
{ 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: "docker ps", desc: "List running containers" },
{ cmd: "docker images", desc: "List Docker images" },
{ cmd: "ip addr show", desc: "Show IP addresses" },
{ cmd: "ping host", desc: "Test network connectivity" },
{ cmd: "curl -I url", desc: "Get HTTP headers" },
{ cmd: "history", desc: "Show command history" },
{ cmd: "clear", desc: "Clear terminal screen" },
]
const LXC_COMMANDS = [
{ cmd: "ls -la", descKey: "listFiles" },
{ cmd: "cd /path/to/dir", descKey: "changeDirectory" },
{ cmd: "cat filename", descKey: "displayFile" },
{ cmd: "grep 'pattern' file", descKey: "searchPattern" },
{ cmd: "find . -name 'file'", descKey: "findFiles" },
{ cmd: "df -h", descKey: "diskUsage" },
{ cmd: "du -sh *", descKey: "directorySizes" },
{ cmd: "free -h", descKey: "memoryUsage" },
{ cmd: "top", descKey: "runningProcesses" },
{ cmd: "ps aux | grep process", descKey: "findProcess" },
{ cmd: "systemctl status service", descKey: "serviceStatus" },
{ cmd: "systemctl restart service", descKey: "restartService" },
{ cmd: "apt update && apt upgrade", descKey: "updatePackages" },
{ cmd: "apt install package", descKey: "installPackage" },
{ cmd: "tail -f /var/log/syslog", descKey: "followLog" },
{ cmd: "chmod 755 file", descKey: "changePermissions" },
{ cmd: "chown user:group file", descKey: "changeOwner" },
{ cmd: "tar -xzf file.tar.gz", descKey: "extractArchive" },
{ cmd: "docker ps", descKey: "listContainers" },
{ cmd: "docker images", descKey: "listImages" },
{ cmd: "ip addr show", descKey: "showIpAddresses" },
{ cmd: "ping host", descKey: "testConnectivity" },
{ cmd: "curl -I url", descKey: "httpHeaders" },
{ cmd: "history", descKey: "commandHistory" },
{ cmd: "clear", descKey: "clearScreen" },
] as const
type LocalCommand = { cmd: string; desc: string }
function getWebSocketUrl(): string {
if (typeof window === "undefined") {
@@ -101,6 +104,7 @@ export function LxcTerminalModal({
vmid,
vmName,
}: LxcTerminalModalProps) {
const t = useT()
const termRef = useRef<any>(null)
const wsRef = useRef<WebSocket | null>(null)
const fitAddonRef = useRef<any>(null)
@@ -121,12 +125,18 @@ export function LxcTerminalModal({
// Search state
const [searchModalOpen, setSearchModalOpen] = useState(false)
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 [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
const [useOnline, setUseOnline] = useState(true)
useEffect(() => {
setFilteredCommands(localCommands)
}, [localCommands])
// Detect mobile/tablet
useEffect(() => {
@@ -278,7 +288,7 @@ export function LxcTerminalModal({
// through Number without losing fidelity.
const id = Number(vmid)
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
}
ws.send(`pct enter ${id}\r`)
@@ -287,7 +297,7 @@ export function LxcTerminalModal({
ws.onerror = () => {
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 = () => {
@@ -295,7 +305,7 @@ export function LxcTerminalModal({
if (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) => {
@@ -395,7 +405,7 @@ export function LxcTerminalModal({
termRef.current.dispose()
}
}
}, [isOpen, vmid])
}, [isOpen, vmid, t])
// Resize handling
useEffect(() => {
@@ -478,7 +488,7 @@ export function LxcTerminalModal({
const searchCheatSh = async (query: string) => {
if (!query.trim()) {
setSearchResults([])
setFilteredCommands(proxmoxCommands)
setFilteredCommands(localCommands)
return
}
@@ -491,7 +501,7 @@ export function LxcTerminalModal({
})
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) => ({
@@ -503,7 +513,7 @@ export function LxcTerminalModal({
setUseOnline(true)
setSearchResults(formattedResults)
} catch (error) {
const filtered = proxmoxCommands.filter(
const filtered = localCommands.filter(
(item) =>
item.cmd.toLowerCase().includes(query.toLowerCase()) ||
item.desc.toLowerCase().includes(query.toLowerCase()),
@@ -521,12 +531,12 @@ export function LxcTerminalModal({
searchCheatSh(searchQuery)
} else {
setSearchResults([])
setFilteredCommands(proxmoxCommands)
setFilteredCommands(localCommands)
}
}, 800)
return () => clearTimeout(debounce)
}, [searchQuery])
}, [searchQuery, localCommands, t])
const handleClear = useCallback(() => {
if (termRef.current) {
@@ -565,7 +575,7 @@ export function LxcTerminalModal({
{/* Header */}
<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">
Terminal: {vmName} (ID: {vmid})
{t("lxcTerminal.title", { name: vmName, id: vmid })}
</DialogTitle>
<div className="flex gap-2">
<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"
>
<Search className="h-4 w-4" />
<span className="hidden sm:inline">Search</span>
<span className="hidden sm:inline">{t("terminal.search")}</span>
</Button>
<Button
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"
>
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">Clear</span>
<span className="hidden sm:inline">{t("terminal.clear")}</span>
</Button>
</div>
</div>
@@ -673,29 +683,29 @@ export function LxcTerminalModal({
</Button>
</DropdownMenuTrigger>
<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 />
<DropdownMenuItem onSelect={() => sendKey("\x03")}>
<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 onSelect={() => sendKey("\x18")}>
<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 onSelect={() => sendKey("\x12")}>
<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>
<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() }}>
<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 onSelect={() => { void handlePaste() }}>
<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>
</DropdownMenuContent>
</DropdownMenu>
@@ -716,7 +726,7 @@ export function LxcTerminalModal({
: "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>
<Button
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"
>
<X className="h-4 w-4" />
<span className="hidden sm:inline">Close</span>
<span className="hidden sm:inline">{t("actions.close")}</span>
</Button>
</div>
</DialogContent>
@@ -734,22 +744,22 @@ export function LxcTerminalModal({
<SearchDialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
<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">
<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={`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>
</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="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<Input
placeholder="Search commands... (e.g., tar, docker, systemctl)"
placeholder={t("terminal.searchPlaceholder")}
value={searchQuery}
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"
@@ -763,7 +773,7 @@ export function LxcTerminalModal({
{isSearching && (
<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" />
<p className="text-sm">Searching cheat.sh...</p>
<p className="text-sm">{t("terminal.searchingCheatSh")}</p>
</div>
)}
@@ -790,7 +800,7 @@ export function LxcTerminalModal({
<div className="text-center py-2">
<p className="text-xs text-zinc-500">
<Lightbulb className="inline-block w-3 h-3 mr-1" />
Powered by cheat.sh
{t("terminal.poweredByCheatSh")}
</p>
</div>
</>
@@ -816,13 +826,13 @@ export function LxcTerminalModal({
className="shrink-0 h-7 px-2 text-xs"
>
<Send className="h-3 w-3 mr-1" />
Send
{t("terminal.send")}
</Button>
</div>
</div>
))
) : !isSearching && !searchQuery && !useOnline ? (
proxmoxCommands.map((item, index) => (
localCommands.map((item, index) => (
<div
key={index}
onClick={() => sendToTerminal(item.cmd)}
@@ -843,7 +853,7 @@ export function LxcTerminalModal({
className="shrink-0 h-7 px-2 text-xs"
>
<Send className="h-3 w-3 mr-1" />
Send
{t("terminal.send")}
</Button>
</div>
</div>
@@ -854,17 +864,17 @@ export function LxcTerminalModal({
<>
<Search className="w-12 h-12 text-zinc-600 mx-auto" />
<div>
<p className="text-zinc-400 font-medium">{"No results found for \""}{searchQuery}{"\""}</p>
<p className="text-xs text-zinc-500 mt-1">Try a different command or check your spelling</p>
<p className="text-zinc-400 font-medium">{t("terminal.noResults", { query: searchQuery })}</p>
<p className="text-xs text-zinc-500 mt-1">{t("terminal.tryDifferentSearch")}</p>
</div>
</>
) : (
<>
<Terminal className="w-12 h-12 text-zinc-600 mx-auto" />
<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">
<p>Try searching for:</p>
<p>{t("terminal.trySearchingFor")}</p>
<div className="flex flex-wrap justify-center gap-2 mt-2">
{["tar", "grep", "docker", "systemctl", "curl"].map((cmd) => (
<code
@@ -881,7 +891,7 @@ export function LxcTerminalModal({
{useOnline && (
<div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4">
<Lightbulb className="w-3 h-3" />
<span>Powered by cheat.sh</span>
<span>{t("terminal.poweredByCheatSh")}</span>
</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="flex items-center gap-2">
<Lightbulb className="w-3 h-3" />
<span>Tip: Search for any Linux command</span>
<span>{t("terminal.searchTip")}</span>
</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>
</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 { Badge } from "./ui/badge"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface DetectionResponse {
success: boolean
@@ -14,6 +15,7 @@ interface DetectionResponse {
}
export function LxcUpdateDetection() {
const t = useT()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [enabled, setEnabled] = useState<boolean>(true)
@@ -32,11 +34,11 @@ export function LxcUpdateDetection() {
setEnabled(data.enabled)
setPending(data.enabled)
} else {
setError(data.message || "Failed to load setting")
setError(data.message || t("settings.lxcUpdateDetection.loadFailed"))
}
})
.catch(e => {
if (!cancelled) setError(String(e))
if (!cancelled) setError(t("settings.lxcUpdateDetection.loadFailed"))
})
.finally(() => {
if (!cancelled) setLoading(false)
@@ -77,7 +79,7 @@ export function LxcUpdateDetection() {
body: JSON.stringify({ enabled: pending }),
})
if (!data.success) {
setError(data.message || "Failed to save setting")
setError(data.message || t("settings.lxcUpdateDetection.saveFailed"))
return
}
setEnabled(pending)
@@ -95,7 +97,7 @@ export function LxcUpdateDetection() {
)
}
} catch (e) {
setError(String(e))
setError(t("settings.lxcUpdateDetection.saveFailed"))
} finally {
setSaving(false)
}
@@ -111,14 +113,14 @@ export function LxcUpdateDetection() {
breakpoint thanks to `items-center` + leading-tight title. */}
<div className="flex items-center gap-2 flex-wrap min-w-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 ? (
<Badge variant="outline" className="text-[10px] border-green-500/30 text-green-500">
Active
{t("status.active")}
</Badge>
) : (
<Badge variant="outline" className="text-[10px] border-muted-foreground/30 text-muted-foreground">
Disabled
{t("status.disabled")}
</Badge>
)}
</div>
@@ -126,7 +128,7 @@ export function LxcUpdateDetection() {
{saved && (
<span className="flex items-center gap-1 text-xs text-green-500">
<CheckCircle2 className="h-3.5 w-3.5" />
Saved
{t("status.saved")}
</span>
)}
{error && !editMode && (
@@ -134,7 +136,7 @@ export function LxcUpdateDetection() {
className="flex items-center gap-1 text-xs text-red-500 max-w-[40ch] truncate"
title={error}
>
Save failed: {error}
{t("status.saveFailed")}: {error}
</span>
)}
{editMode ? (
@@ -144,7 +146,7 @@ export function LxcUpdateDetection() {
onClick={handleCancel}
disabled={saving}
>
Cancel
{t("actions.cancel")}
</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"
@@ -152,7 +154,7 @@ export function LxcUpdateDetection() {
disabled={saving || !hasChanges}
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
Save
{t("actions.save")}
</button>
</>
) : (
@@ -162,16 +164,15 @@ export function LxcUpdateDetection() {
disabled={loading}
>
<Settings2 className="h-3 w-3" />
Edit
{t("actions.edit")}
</button>
)}
</div>
</div>
<CardDescription>
Periodically check running Debian/Ubuntu/Alpine LXC containers for pending package updates
(<code>apt list --upgradable</code> / <code>apk list -u</code>) and surface them on the dashboard. The
corresponding notification toggle in <strong>Notifications Services</strong> appears only while detection
is enabled.
{t("settings.lxcUpdateDetection.descriptionStart")}{" "}
(<code>apt list --upgradable</code> / <code>apk list -u</code>)
{t("settings.lxcUpdateDetection.descriptionEnd")}
</CardDescription>
</CardHeader>
@@ -185,7 +186,7 @@ export function LxcUpdateDetection() {
<Boxes
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>
<button
className={`relative w-10 h-5 rounded-full transition-colors shrink-0 ${
@@ -195,7 +196,7 @@ export function LxcUpdateDetection() {
disabled={!editMode || saving}
role="switch"
aria-checked={pending}
aria-label="Enable LXC update detection"
aria-label={t("settings.lxcUpdateDetection.enableLabel")}
>
<span
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">
<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">
{lastPurged} LXC entries removed from the registry. Re-enabling detection will repopulate them on the
next scan cycle.
{t("settings.lxcUpdateDetection.purgedMessage", { count: lastPurged })}
</p>
</div>
)}
+29 -27
View File
@@ -6,6 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { ArrowLeft, Loader2 } from "lucide-react"
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
import { fetchApi } from "@/lib/api-config"
import { useI18n } from "@/lib/i18n/provider"
interface MetricsViewProps {
vmid: number
@@ -15,12 +16,12 @@ interface MetricsViewProps {
}
const TIMEFRAME_OPTIONS = [
{ value: "hour", label: "1 Hour" },
{ value: "day", label: "24 Hours" },
{ value: "week", label: "7 Days" },
{ value: "month", label: "30 Days" },
{ value: "year", label: "1 Year" },
]
{ value: "hour", labelKey: "vmMetrics.timeframes.hour" },
{ value: "day", labelKey: "vmMetrics.timeframes.day" },
{ value: "week", labelKey: "vmMetrics.timeframes.week" },
{ value: "month", labelKey: "vmMetrics.timeframes.month" },
{ value: "year", labelKey: "vmMetrics.timeframes.year" },
] as const
const CustomCPUTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
@@ -103,6 +104,7 @@ const CustomNetworkTooltip = ({ active, payload, label }: any) => {
}
export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps) {
const { language, t } = useI18n()
const [timeframe, setTimeframe] = useState("week")
const [data, setData] = useState<any[]>([])
const [loading, setLoading] = useState(false)
@@ -112,7 +114,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
useEffect(() => {
fetchMetrics()
}, [vmid, timeframe])
}, [vmid, timeframe, language])
const fetchMetrics = async () => {
setLoading(true)
@@ -126,19 +128,19 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
let timeLabel = ""
if (timeframe === "hour") {
timeLabel = date.toLocaleString("en-US", {
timeLabel = date.toLocaleString(language, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "day") {
timeLabel = date.toLocaleString("en-US", {
timeLabel = date.toLocaleString(language, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "week") {
timeLabel = date.toLocaleString("en-US", {
timeLabel = date.toLocaleString(language, {
month: "short",
day: "numeric",
hour: "2-digit",
@@ -146,12 +148,12 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
hour12: false,
})
} else if (timeframe === "month") {
timeLabel = date.toLocaleString("en-US", {
timeLabel = date.toLocaleString(language, {
month: "short",
day: "numeric",
})
} else {
timeLabel = date.toLocaleString("en-US", {
timeLabel = date.toLocaleString(language, {
month: "short",
year: "numeric",
})
@@ -173,7 +175,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
setData(transformedData)
} catch (err: any) {
setError(err.message || "Error loading metrics")
setError(err.message || t("vmMetrics.errors.loading"))
} finally {
setLoading(false)
}
@@ -203,7 +205,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
if (data.length === 0) {
return (
<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>
)
}
@@ -214,7 +216,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
<div className="space-y-8">
{/* CPU Chart */}
<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}>
<AreaChart data={data} margin={{ bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
@@ -244,7 +246,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
strokeWidth={2}
fill="#3b82f6"
fillOpacity={0.3}
name="CPU %"
name={t("vmMetrics.series.cpu")}
/>
</AreaChart>
</ResponsiveContainer>
@@ -252,7 +254,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
{/* Memory Chart */}
<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}>
<AreaChart data={data} margin={{ bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
@@ -282,7 +284,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
fill="#10b981"
fillOpacity={0.3}
strokeWidth={2}
name="Memory GB"
name={t("vmMetrics.series.memoryGb")}
/>
</AreaChart>
</ResponsiveContainer>
@@ -290,7 +292,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
{/* Disk I/O Chart */}
<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}>
<AreaChart data={data} margin={{ bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
@@ -321,7 +323,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
fill="#10b981"
fillOpacity={0.3}
strokeWidth={2}
name="Read"
name={t("vmMetrics.series.read")}
hide={hiddenDiskLines.includes("diskread")}
/>
<Area
@@ -331,7 +333,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
fill="#3b82f6"
fillOpacity={0.3}
strokeWidth={2}
name="Write"
name={t("vmMetrics.series.write")}
hide={hiddenDiskLines.includes("diskwrite")}
/>
</AreaChart>
@@ -340,7 +342,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
{/* Network I/O Chart */}
<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}>
<AreaChart data={data} margin={{ bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
@@ -371,7 +373,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
fill="#10b981"
fillOpacity={0.3}
strokeWidth={2}
name="Download"
name={t("vmMetrics.series.download")}
hide={hiddenNetworkLines.includes("netin")}
/>
<Area
@@ -381,7 +383,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
fill="#3b82f6"
fillOpacity={0.3}
strokeWidth={2}
name="Upload"
name={t("vmMetrics.series.upload")}
hide={hiddenNetworkLines.includes("netout")}
/>
</AreaChart>
@@ -461,9 +463,9 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
<ArrowLeft className="h-5 w-5" />
</Button>
<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">
VMID: {vmid} Type: {vmType.toUpperCase()}
VMID: {vmid} {t("vmMetrics.type")}: {vmType.toUpperCase()}
</p>
</div>
</div>
@@ -474,7 +476,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
<SelectContent>
{TIMEFRAME_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
{t(option.labelKey)}
</SelectItem>
))}
</SelectContent>
+37 -26
View File
@@ -6,6 +6,9 @@ import { Wifi, Zap } from 'lucide-react'
import { useState, useEffect } from "react"
import { fetchApi } from "../lib/api-config"
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_: {
@@ -32,43 +35,51 @@ interface NetworkCardProps {
onClick?: () => void
}
const getInterfaceTypeBadge = (type: string) => {
const getInterfaceTypeBadge = (type: string, t: TFunction) => {
switch (type) {
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":
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":
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":
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":
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":
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:
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") {
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "LXC" }
} else if (vmType === "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 => {
if (speed === 0) return "N/A"
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 formatSpeed = (speed: number, unavailable = "N/A"): string => {
if (speed === 0) return unavailable
if (speed >= 1000) return `${(speed / 1000).toFixed(1)} Gbps`
return `${speed} Mbps`
}
export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps) {
const typeBadge = getInterfaceTypeBadge(interface_.type)
const vmTypeBadge = interface_.vm_type ? getVMTypeBadge(interface_.vm_type) : null
const t = useT()
const typeBadge = getInterfaceTypeBadge(interface_.type, t)
const vmTypeBadge = interface_.vm_type ? getVMTypeBadge(interface_.vm_type, t) : null
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(getNetworkUnit())
@@ -125,17 +136,17 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps
const getTimeframeLabel = () => {
switch (timeframe) {
case "hour":
return "Last Hour"
return t("network.timeframes.last.hour")
case "day":
return "Last 24 Hours"
return t("network.timeframes.last.day")
case "week":
return "Last 7 Days"
return t("network.timeframes.last.week")
case "month":
return "Last 30 Days"
return t("network.timeframes.last.month")
case "year":
return "Last Year"
return t("network.timeframes.last.year")
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"
}
>
{interface_.status.toUpperCase()}
{formatInterfaceStatus(interface_.status, t)}
</Badge>
</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>
<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 className="font-medium text-foreground font-mono text-sm truncate">
{interface_.type === "vm_lxc"
? (interface_.vmid ?? "N/A")
? (interface_.vmid ?? t("common.notAvailable"))
: interface_.addresses.length > 0
? interface_.addresses[0].ip
: "N/A"}
: t("common.notAvailable")}
</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">
<Zap className="h-3 w-3" />
{formatSpeed(interface_.speed)}
{formatSpeed(interface_.speed, t("common.notAvailable"))}
</div>
</div>
+29 -12
View File
@@ -7,6 +7,7 @@
import { useEffect, useMemo, useRef, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Activity } from "lucide-react"
import { useT } from "../lib/i18n/provider"
// One animated comet-trail pulse. Returned as DATA from the layout
// renderers instead of an SVG string so the parent component can
@@ -25,6 +26,12 @@ type PulseData = {
rate?: number
}
type FlowLabels = {
down: string
active: string
standby: string
}
// ─── Public types — match the /api/network shape ────────────
type NIC = {
id: string
@@ -126,14 +133,15 @@ function resolveBonds(data: NetworkFlowData): {
// 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
// mode all slaves transmit, so the link speed stays.
function nicSubLabel(n: NIC): string {
if (n.status === "down") return "down"
function nicSubLabel(n: NIC, labels: FlowLabels): string {
if (n.status === "down") return labels.down
const role = n.role === "standby" || n.role === "active" ? n.role : ""
if (!role) return n.link
// A NIC that doesn't report a negotiated speed renders its link as
// "—"; pairing that with the role would read as "— · active".
if (!n.link || n.link === "—") return role
return `${n.link} · ${role}`
const roleLabel = role === "active" ? labels.active : labels.standby
if (!n.link || n.link === "—") return roleLabel
return `${n.link} · ${roleLabel}`
}
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 ──
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 bridges = visibleBridges(data.bridges, top)
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}" />
${svgIcon("nic", nicX, y, 18, stroke)}
<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>`)
})
@@ -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
// an arc (some above, some below the bridge.cy). All elbows use Q
// 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.
// All four x-columns evenly spaced so curve→target distances are
// 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}" />
${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-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>`)
})
@@ -936,6 +944,15 @@ export function NetworkFlow({
// opens the per-interface details modal.
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 [width, setWidth] = useState(1320)
const [mode, setMode] = useState<"desktop" | "tablet" | "mobile">("desktop")
@@ -985,21 +1002,21 @@ export function NetworkFlow({
const { svgContent, pulses, viewBox, height } = useMemo(() => {
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 }
}
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 }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [memoKey])
}, [memoKey, labels])
return (
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-foreground flex items-center text-base">
<Activity className="h-5 w-5 mr-2" />
Network Flow (PoC)
{t("network.flow.title")}
</CardTitle>
</CardHeader>
<CardContent>
+231 -173
View File
@@ -13,6 +13,9 @@ import { fetchApi } from "../lib/api-config"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { LatencyDetailModal } from "./latency-detail-modal"
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 {
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
// type chip, but mapped to the actual interface class.
function getInterfaceTypeChip(type: string) {
function getInterfaceTypeLabel(type: string, t: TFunction) {
switch ((type || "").toLowerCase()) {
case "physical":
return { className: "bg-blue-500/10 text-blue-400 border-blue-500/20", label: "Physical" }
return t("network.interfaceTypes.physical")
case "bridge":
return { className: "bg-green-500/10 text-green-400 border-green-500/20", label: "Bridge" }
return t("network.interfaceTypes.bridge")
case "bond":
return { className: "bg-purple-500/10 text-purple-400 border-purple-500/20", label: "Bond" }
return t("network.interfaceTypes.bond")
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 "virtual":
return { className: "bg-orange-500/10 text-orange-400 border-orange-500/20", label: "Virtual" }
return t("network.interfaceTypes.virtual")
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"
// pattern: 2-line header (identity / live state), horizontal divider,
// vertical key→value stat block, footer with serial + arrow CTA.
@@ -166,18 +202,19 @@ function getInterfaceTypeChip(type: string) {
function renderPhysicalInterfaceCardV2(
iface: NetworkInterface,
onOpen: (iface: NetworkInterface) => void,
t: TFunction,
) {
const Icon = getInterfaceIcon(iface)
const chip = getInterfaceTypeChip(iface.type)
const chip = getInterfaceTypeChip(iface.type, t)
const isUp = (iface.status || "").toLowerCase() === "up"
const firstAddr = iface.addresses?.[0]?.ip || ""
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
// from the negotiated speed (avoids "1 Gbps (max 1 Gbps)" noise).
const maxSpeedStr =
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 errIn = iface.errors_in ?? 0
@@ -206,7 +243,7 @@ function renderPhysicalInterfaceCardV2(
}`}
>
<NetStatusDot tone={isUp ? "ok" : "fail"} />
{iface.status || "?"}
{formatInterfaceStatus(iface.status, t)}
</span>
</div>
@@ -217,11 +254,11 @@ function renderPhysicalInterfaceCardV2(
{speedStr}
{maxSpeedStr && (
<span className="text-[11px] text-muted-foreground/70">
· max {maxSpeedStr}
· {t("network.labels.maxSpeed", { speed: maxSpeedStr })}
</span>
)}
</span>
<span className="capitalize">{iface.duplex || "—"}</span>
<span>{formatDuplex(iface.duplex, t)}</span>
</div>
{/* Separator. */}
@@ -246,7 +283,7 @@ function renderPhysicalInterfaceCardV2(
{bridgesUsing.length > 0 && (
<div className="flex items-baseline justify-between gap-3">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground shrink-0">
Bridge
{t("network.interfaceTypes.bridge")}
</span>
<span className="font-medium text-right truncate font-mono text-xs text-cyan-400">
{bridgesUsing.map((b) => `${b}`).join(" ")}
@@ -260,7 +297,7 @@ function renderPhysicalInterfaceCardV2(
has no previous sample to compute against. */}
<div className="flex items-baseline justify-between gap-3">
<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 className="font-medium text-green-500 tabular-nums">
{iface.rx_Bps !== undefined ? formatRate(iface.rx_Bps) : "—"}
@@ -268,7 +305,7 @@ function renderPhysicalInterfaceCardV2(
</div>
<div className="flex items-baseline justify-between gap-3">
<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 className="font-medium text-blue-400 tabular-nums">
{iface.tx_Bps !== undefined ? formatRate(iface.tx_Bps) : "—"}
@@ -278,7 +315,7 @@ function renderPhysicalInterfaceCardV2(
<>
{totalErrors > 0 && (
<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
className={`font-medium flex items-center gap-1.5 ${
netCounterTone(totalErrors) === "ok"
@@ -295,7 +332,7 @@ function renderPhysicalInterfaceCardV2(
)}
{totalDrops > 0 && (
<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
className={`font-medium flex items-center gap-1.5 ${
netCounterTone(totalDrops) === "ok"
@@ -325,7 +362,7 @@ function renderPhysicalInterfaceCardV2(
)}
<span
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>
@@ -335,32 +372,32 @@ function renderPhysicalInterfaceCardV2(
}
const getInterfaceTypeBadge = (type: string) => {
const getInterfaceTypeBadge = (type: string, t: TFunction) => {
switch (type) {
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":
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":
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":
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":
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":
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:
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") {
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "LXC" }
} else if (vmType === "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.
@@ -396,8 +433,8 @@ const formatStorage = (bytes: number): string => {
return `${value.toFixed(decimals)} ${sizes[i]}`
}
const formatSpeed = (speed: number): string => {
if (speed === 0) return "N/A"
const formatSpeed = (speed: number, unavailable = "N/A"): string => {
if (speed === 0) return unavailable
if (speed >= 1000) return `${(speed / 1000).toFixed(1)} Gbps`
return `${speed} Mbps`
}
@@ -408,6 +445,7 @@ const fetcher = async (url: string): Promise<NetworkData> => {
export function NetworkMetrics() {
const t = useT()
const {
data: networkData,
error,
@@ -469,8 +507,8 @@ export function NetworkMetrics() {
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
</div>
<div className="text-sm font-medium text-foreground">Loading network data...</div>
<p className="text-xs text-muted-foreground">Scanning interfaces, bridges and traffic</p>
<div className="text-sm font-medium text-foreground">{t("network.loading.title")}</div>
<p className="text-xs text-muted-foreground">{t("network.loading.description")}</p>
</div>
)
}
@@ -483,10 +521,10 @@ export function NetworkMetrics() {
<div className="flex items-center gap-3 text-red-600">
<AlertCircle className="h-6 w-6" />
<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">
{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>
@@ -514,14 +552,14 @@ export function NetworkMetrics() {
const avgPacketLoss = ((packetLossIn + packetLossOut) / 2).toFixed(2)
// Determine health status
let healthStatus = "Healthy"
let healthStatusKey = "network.status.healthy"
let healthColor = "bg-green-500/10 text-green-500 border-green-500/20"
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"
} 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"
}
@@ -545,24 +583,24 @@ export function NetworkMetrics() {
const topTraffic = (top.bytes_recv || 0) + (top.bytes_sent || 0)
return ifaceTraffic > topTraffic ? iface : top
}, 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 getTimeframeLabel = () => {
switch (timeframe) {
case "hour":
return "1 Hour"
return t("network.timeframes.hour")
case "day":
return "24 Hours"
return t("network.timeframes.day")
case "week":
return "7 Days"
return t("network.timeframes.week")
case "month":
return "30 Days"
return t("network.timeframes.month")
case "year":
return "1 Year"
return t("network.timeframes.year")
default:
return "24 Hours"
return t("network.timeframes.day")
}
}
@@ -571,25 +609,42 @@ export function NetworkMetrics() {
const getTimeframeShortLabel = () => {
switch (timeframe) {
case "hour":
return "Past 1 h"
return t("network.timeframes.short.hour")
case "day":
return "Past 24 h"
return t("network.timeframes.short.day")
case "week":
return "Past 7 d"
return t("network.timeframes.short.week")
case "month":
return "Past 30 d"
return t("network.timeframes.short.month")
case "year":
return "Past 1 y"
return t("network.timeframes.short.year")
default:
return "Past 24 h"
return t("network.timeframes.short.day")
}
}
const hostname = networkData.hostname || "N/A"
const domain = networkData.domain || "N/A"
const getLastTimeframeLabel = (value: "hour" | "day" | "week" | "month" | "year") => {
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 primaryDNS = dnsServers[0] || "N/A"
const secondaryDNS = dnsServers[1] || "N/A"
const primaryDNS = dnsServers[0] || t("common.notAvailable")
const secondaryDNS = dnsServers[1] || t("common.notAvailable")
return (
<div className="space-y-6">
@@ -606,7 +661,7 @@ export function NetworkMetrics() {
<Card className="bg-card border-border">
<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">
<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>
</div>
<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>
<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 className="text-xl lg:text-2xl font-bold leading-tight text-green-500">{trafficInFormatted}</div>
</div>
<div>
<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 className="text-xl lg:text-2xl font-bold leading-tight text-blue-500">{trafficOutFormatted}</div>
</div>
@@ -631,8 +686,8 @@ export function NetworkMetrics() {
<div style={{ width: `${upPct}%`, background: '#3b82f6' }}></div>
</div>
<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-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-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>{t("network.labels.up")} {Math.round(upPct)}%</span>
</div>
</CardContent>
</Card>
@@ -642,7 +697,7 @@ export function NetworkMetrics() {
{/* ── Active Interfaces (preview restyle v2: revertido al original con title uppercase) ── */}
<Card className="bg-card border-border">
<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" />
</CardHeader>
<CardContent>
@@ -651,14 +706,16 @@ export function NetworkMetrics() {
</div>
<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">
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 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>
</div>
<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>
</CardContent>
</Card>
@@ -666,8 +723,8 @@ export function NetworkMetrics() {
{/* ── Network Status (preview restyle: packet-loss highlight + 2x2 grid) ── */}
<Card className="bg-card border-border">
<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>
<Badge variant="outline" className={`${healthColor}`}>{healthStatus === 'Healthy' ? '✓ ' : ''}{healthStatus}</Badge>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("network.cards.status")}</CardTitle>
<Badge variant="outline" className={`${healthColor}`}>{healthStatusKey === "network.status.healthy" ? "✓ " : ""}{t(healthStatusKey)}</Badge>
</CardHeader>
<CardContent>
{(() => {
@@ -680,13 +737,13 @@ export function NetworkMetrics() {
return (
<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>
<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 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="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>
<div className="min-w-0">
@@ -694,12 +751,12 @@ export function NetworkMetrics() {
<div className="font-medium font-mono truncate">{primaryDNS}</div>
</div>
<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>
<div className="min-w-0">
<div className="text-muted-foreground">Domain:</div>
<div className="font-medium font-mono truncate">{networkData.domain || '—'}</div>
<div className="text-muted-foreground">{t("network.labels.domain")}:</div>
<div className="font-medium font-mono truncate">{domain}</div>
</div>
</div>
</CardContent>
@@ -711,7 +768,7 @@ export function NetworkMetrics() {
onClick={() => setLatencyModalOpen(true)}
>
<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">
<Timer className="h-4 w-4" />
<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"
}
>
{(latencyData?.stats?.current ?? 0) < 50 ? "Excellent" :
(latencyData?.stats?.current ?? 0) < 100 ? "Good" :
(latencyData?.stats?.current ?? 0) < 200 ? "Fair" : "Poor"}
{(latencyData?.stats?.current ?? 0) < 50 ? t("network.latency.status.excellent") :
(latencyData?.stats?.current ?? 0) < 100 ? t("network.latency.status.good") :
(latencyData?.stats?.current ?? 0) < 200 ? t("network.latency.status.fair") : t("network.latency.status.poor")}
</Badge>
</div>
{/* Sparkline */}
@@ -765,7 +822,7 @@ export function NetworkMetrics() {
</div>
)}
<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>
</CardContent>
</Card>
@@ -778,11 +835,11 @@ export function NetworkMetrics() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hour">1 Hour</SelectItem>
<SelectItem value="day">24 Hours</SelectItem>
<SelectItem value="week">7 Days</SelectItem>
<SelectItem value="month">30 Days</SelectItem>
<SelectItem value="year">1 Year</SelectItem>
<SelectItem value="hour">{t("network.timeframes.hour")}</SelectItem>
<SelectItem value="day">{t("network.timeframes.day")}</SelectItem>
<SelectItem value="week">{t("network.timeframes.week")}</SelectItem>
<SelectItem value="month">{t("network.timeframes.month")}</SelectItem>
<SelectItem value="year">{t("network.timeframes.year")}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -792,7 +849,7 @@ export function NetworkMetrics() {
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-foreground flex items-center">
<Activity className="h-5 w-5 mr-2" />
Network Traffic
{t("network.cards.traffic")}
</CardTitle>
</CardHeader>
<CardContent>
@@ -904,9 +961,12 @@ export function NetworkMetrics() {
<CardHeader>
<CardTitle className="text-foreground flex items-center">
<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">
{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>
</CardTitle>
</CardHeader>
@@ -916,7 +976,7 @@ export function NetworkMetrics() {
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">
{networkData.physical_interfaces.map((iface) =>
renderPhysicalInterfaceCardV2(iface, setSelectedInterface),
renderPhysicalInterfaceCardV2(iface, setSelectedInterface, t),
)}
</div>
</CardContent>
@@ -927,16 +987,19 @@ export function NetworkMetrics() {
<CardHeader>
<CardTitle className="text-foreground flex items-center">
<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">
{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>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{networkData.bridge_interfaces.map((interface_, index) => {
const typeBadge = getInterfaceTypeBadge(interface_.type)
const typeBadge = getInterfaceTypeBadge(interface_.type, t)
return (
<div
@@ -971,30 +1034,30 @@ export function NetworkMetrics() {
: "bg-red-500/10 text-red-500 border-red-500/20"
}
>
{interface_.status.toUpperCase()}
{formatInterfaceStatus(interface_.status, t)}
</Badge>
</div>
{/* Second row: Details - Responsive layout */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<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">
{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 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">
<Zap className="h-3 w-3" />
{formatSpeed(interface_.speed)}
{formatSpeed(interface_.speed, t("common.notAvailable"))}
</div>
</div>
<div>
<div className="text-muted-foreground text-xs">Duplex</div>
<div className="font-medium text-foreground text-xs capitalize">{interface_.duplex}</div>
<div className="text-muted-foreground text-xs">{t("network.labels.duplex")}</div>
<div className="font-medium text-foreground text-xs">{formatDuplex(interface_.duplex, t)}</div>
</div>
<div>
@@ -1025,16 +1088,19 @@ export function NetworkMetrics() {
<CardHeader>
<CardTitle className="text-foreground flex items-center">
<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">
{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>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{vmLxcInterfaces.map((interface_, index) => {
const vmTypeBadge = getVMTypeBadge(interface_.vm_type)
const vmTypeBadge = getVMTypeBadge(interface_.vm_type, t)
return (
<div
@@ -1062,7 +1128,7 @@ export function NetworkMetrics() {
: "bg-red-500/10 text-red-500 border-red-500/20"
}
>
{interface_.status.toUpperCase()}
{formatInterfaceStatus(interface_.status, t)}
</Badge>
</div>
@@ -1070,20 +1136,20 @@ export function NetworkMetrics() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<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 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">
<Zap className="h-3 w-3" />
{formatSpeed(interface_.speed)}
{formatSpeed(interface_.speed, t("common.notAvailable"))}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Duplex</div>
<div className="font-medium text-foreground text-xs capitalize">{interface_.duplex}</div>
<div className="text-sm text-muted-foreground">{t("network.labels.duplex")}</div>
<div className="font-medium text-foreground text-xs">{formatDuplex(interface_.duplex, t)}</div>
</div>
<div>
@@ -1114,10 +1180,10 @@ export function NetworkMetrics() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Router className="h-5 w-5" />
{selectedInterface?.name} - Interface Details
{selectedInterface?.name} - {t("network.interfaceDetails.title")}
</DialogTitle>
<DialogDescription>
View detailed information and network traffic statistics for this interface
{t("network.interfaceDetails.description")}
</DialogDescription>
{selectedInterface?.status.toLowerCase() === "up" && selectedInterface?.vm_type !== "vm" && (
<div className="flex justify-end pt-2">
@@ -1126,11 +1192,11 @@ export function NetworkMetrics() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hour">1 Hour</SelectItem>
<SelectItem value="day">24 Hours</SelectItem>
<SelectItem value="week">7 Days</SelectItem>
<SelectItem value="month">30 Days</SelectItem>
<SelectItem value="year">1 Year</SelectItem>
<SelectItem value="hour">{t("network.timeframes.hour")}</SelectItem>
<SelectItem value="day">{t("network.timeframes.day")}</SelectItem>
<SelectItem value="week">{t("network.timeframes.week")}</SelectItem>
<SelectItem value="month">{t("network.timeframes.month")}</SelectItem>
<SelectItem value="year">{t("network.timeframes.year")}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -1156,21 +1222,21 @@ export function NetworkMetrics() {
<>
{/* Basic Information */}
<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>
<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>
<div>
<div className="text-sm text-muted-foreground">Type</div>
<Badge variant="outline" className={getInterfaceTypeBadge(displayInterface.type).color}>
{getInterfaceTypeBadge(displayInterface.type).label}
<div className="text-sm text-muted-foreground">{t("network.labels.type")}</div>
<Badge variant="outline" className={getInterfaceTypeBadge(displayInterface.type, t).color}>
{getInterfaceTypeBadge(displayInterface.type, t).label}
</Badge>
</div>
{displayInterface.type === "bridge" && displayInterface.bridge_physical_interface && (
<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">
{displayInterface.bridge_physical_interface}
</div>
@@ -1180,7 +1246,7 @@ export function NetworkMetrics() {
there never matched. */}
{displayInterface.bridge_bond_slaves && displayInterface.bridge_bond_slaves.length > 0 && (
<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">
{displayInterface.bridge_bond_slaves.map((slave, idx) => (
<Badge
@@ -1198,19 +1264,19 @@ export function NetworkMetrics() {
)}
{displayInterface.type === "vm_lxc" && displayInterface.vm_name && (
<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">
{displayInterface.vm_name}
{displayInterface.vm_type && (
<Badge variant="outline" className={getVMTypeBadge(displayInterface.vm_type).color}>
{getVMTypeBadge(displayInterface.vm_type).label}
<Badge variant="outline" className={getVMTypeBadge(displayInterface.vm_type, t).color}>
{getVMTypeBadge(displayInterface.vm_type, t).label}
</Badge>
)}
</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
variant="outline"
className={
@@ -1219,16 +1285,16 @@ export function NetworkMetrics() {
: "bg-red-500/10 text-red-500 border-red-500/20"
}
>
{displayInterface.status.toUpperCase()}
{formatInterfaceStatus(displayInterface.status, t)}
</Badge>
</div>
<div>
<div className="text-sm text-muted-foreground">Speed</div>
<div className="font-medium">{formatSpeed(displayInterface.speed)}</div>
<div className="text-sm text-muted-foreground">{t("network.labels.speed")}</div>
<div className="font-medium">{formatSpeed(displayInterface.speed, t("common.notAvailable"))}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Duplex</div>
<div className="font-medium capitalize">{displayInterface.duplex}</div>
<div className="text-sm text-muted-foreground">{t("network.labels.duplex")}</div>
<div className="font-medium">{formatDuplex(displayInterface.duplex, t)}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">MTU</div>
@@ -1236,7 +1302,7 @@ export function NetworkMetrics() {
</div>
{displayInterface.mac_address && (
<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>
)}
@@ -1246,13 +1312,13 @@ export function NetworkMetrics() {
{/* IP Addresses */}
{displayInterface.addresses.length > 0 && (
<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">
{displayInterface.addresses.map((addr, idx) => (
<div key={idx} className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<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>
))}
@@ -1264,23 +1330,15 @@ export function NetworkMetrics() {
{displayInterface.status.toLowerCase() === "up" && displayInterface.vm_type !== "vm" ? (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-4">
Network Traffic Statistics (
{modalTimeframe === "hour"
? "Last Hour"
: modalTimeframe === "day"
? "Last 24 Hours"
: modalTimeframe === "week"
? "Last 7 Days"
: modalTimeframe === "month"
? "Last 30 Days"
: "Last Year"}
)
{t("network.interfaceDetails.trafficStatistics", {
timeframe: getLastTimeframeLabel(modalTimeframe),
})}
</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<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 className="font-medium text-green-500 text-lg">
{formatNetworkTraffic(
@@ -1292,7 +1350,7 @@ export function NetworkMetrics() {
</div>
<div>
<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 className="font-medium text-blue-500 text-lg">
{formatNetworkTraffic(
@@ -1316,31 +1374,31 @@ export function NetworkMetrics() {
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-border">
<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">
{displayInterface.packets_recv?.toLocaleString() || "N/A"}
{displayInterface.packets_recv?.toLocaleString() || t("common.notAvailable")}
</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">
{displayInterface.packets_sent?.toLocaleString() || "N/A"}
{displayInterface.packets_sent?.toLocaleString() || t("common.notAvailable")}
</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>
<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>
<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>
<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>
</div>
@@ -1348,11 +1406,11 @@ export function NetworkMetrics() {
</div>
) : displayInterface.status.toLowerCase() === "up" && displayInterface.vm_type === "vm" ? (
<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>
<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 className="font-medium text-green-500 text-lg">
{formatNetworkTraffic(displayInterface.bytes_recv || 0, networkUnit)}
@@ -1360,38 +1418,38 @@ export function NetworkMetrics() {
</div>
<div>
<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 className="font-medium text-blue-500 text-lg">
{formatNetworkTraffic(displayInterface.bytes_sent || 0, networkUnit)}
</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">
{displayInterface.packets_recv?.toLocaleString() || "N/A"}
{displayInterface.packets_recv?.toLocaleString() || t("common.notAvailable")}
</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">
{displayInterface.packets_sent?.toLocaleString() || "N/A"}
{displayInterface.packets_sent?.toLocaleString() || t("common.notAvailable")}
</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>
<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>
<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>
<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>
</div>
@@ -1399,9 +1457,9 @@ export function NetworkMetrics() {
) : (
<div className="bg-muted/30 rounded-lg p-6 text-center">
<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">
This interface is currently down. Network traffic statistics are not available.
{t("network.interfaceDetails.inactiveDescription")}
</p>
</div>
)}
@@ -1409,12 +1467,12 @@ export function NetworkMetrics() {
{/* Bond Information */}
{displayInterface.type === "bond" && displayInterface.bond_slaves && (
<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>
<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">
{displayInterface.bond_mode || "Unknown"}
{displayInterface.bond_mode || t("common.unknown")}
{displayInterface.bond_mode_detail &&
displayInterface.bond_mode_detail !== displayInterface.bond_mode && (
<span className="text-muted-foreground font-normal">
@@ -1427,13 +1485,13 @@ export function NetworkMetrics() {
{displayInterface.bond_active_slave && (
<div>
<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 className="font-medium">{displayInterface.bond_active_slave}</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">
{displayInterface.bond_slaves.map((slave, idx) => {
// Only active-backup has a real standby. In every
@@ -1456,7 +1514,7 @@ export function NetworkMetrics() {
return (
<Badge key={idx} variant="outline" className={tone}>
{slave}
{role && <span className="ml-1 opacity-70">· {role}</span>}
{role && <span className="ml-1 opacity-70">· {t(`network.roles.${role}`)}</span>}
</Badge>
)
})}
@@ -1469,9 +1527,9 @@ export function NetworkMetrics() {
{/* Bridge Information */}
{displayInterface.type === "bridge" && displayInterface.bridge_members && (
<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 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">
{displayInterface.bridge_members.length > 0 ? (
displayInterface.bridge_members
@@ -1494,7 +1552,7 @@ export function NetworkMetrics() {
</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>
@@ -5,6 +5,7 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
import { Loader2 } from 'lucide-react'
import { fetchApi } from "../lib/api-config"
import { getNetworkUnit } from "../lib/format-network"
import { useT } from "../lib/i18n/provider"
interface NetworkMetricsData {
time: string
@@ -50,6 +51,7 @@ export function NetworkTrafficChart({
refreshInterval = 60000,
networkUnit: networkUnitProp, // Rename prop to avoid conflict
}: NetworkTrafficChartProps) {
const t = useT()
const [data, setData] = useState<NetworkMetricsData[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -114,7 +116,7 @@ export function NetworkTrafficChart({
const result = await fetchApi<any>(apiPath)
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) {
@@ -207,7 +209,7 @@ export function NetworkTrafficChart({
}
} catch (err: any) {
console.error("Error fetching network metrics:", err)
setError(err.message || "Error loading metrics")
setError(err.message || t("network.chart.loadError"))
} finally {
setLoading(false)
}
@@ -255,7 +257,7 @@ export function NetworkTrafficChart({
if (error) {
return (
<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>
</div>
)
@@ -264,7 +266,7 @@ export function NetworkTrafficChart({
if (data.length === 0) {
return (
<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>
)
}
@@ -295,7 +297,7 @@ export function NetworkTrafficChart({
}}
domain={[0, "auto"]}
/>
<Tooltip content={<CustomNetworkTooltip networkUnit={networkUnit} />} /> // Pass networkUnit to tooltip
<Tooltip content={<CustomNetworkTooltip networkUnit={networkUnit} />} />
<Legend verticalAlign="top" height={36} content={renderLegend} />
<Area
type="monotone"
@@ -304,7 +306,7 @@ export function NetworkTrafficChart({
strokeWidth={2}
fill="#10b981"
fillOpacity={0.3}
name="Received"
name={t("overview.receivedShort")}
hide={!visibleLines.netIn}
isAnimationActive={true}
animationDuration={300}
@@ -317,7 +319,7 @@ export function NetworkTrafficChart({
strokeWidth={2}
fill="#3b82f6"
fillOpacity={0.3}
name="Sent"
name={t("overview.sentShort")}
hide={!visibleLines.netOut}
isAnimationActive={true}
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 { useIsMobile } from "../hooks/use-mobile"
import { fetchApi } from "@/lib/api-config"
import { useI18n } from "../lib/i18n/provider"
const TIMEFRAME_OPTIONS = [
{ value: "hour", label: "1 Hour" },
{ value: "day", label: "24 Hours" },
{ value: "week", label: "7 Days" },
{ value: "month", label: "30 Days" },
{ value: "hour", labelKey: "overview.timeframes.hour" },
{ value: "day", labelKey: "overview.timeframes.day" },
{ value: "week", labelKey: "overview.timeframes.week" },
{ value: "month", labelKey: "overview.timeframes.month" },
]
interface NodeMetricsData {
@@ -90,9 +91,11 @@ type PeriodStat = { avg: number; max: number; min: number } | null
function ChartStatsHeader({
stats,
suffix = "",
labels,
}: {
stats: PeriodStat
suffix?: string
labels: { avg: string; max: string; min: string }
}) {
if (!stats) return null
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">
<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 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 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>
</div>
)
@@ -116,6 +119,7 @@ function ChartStatsHeader({
export function NodeMetricsCharts() {
const { language, t } = useI18n()
const [timeframe, setTimeframe] = useState("day")
const [data, setData] = useState<NodeMetricsData[]>([])
// period_stats from the backend — computed over the raw RRD points
@@ -141,7 +145,7 @@ export function NodeMetricsCharts() {
useEffect(() => {
fetchMetrics()
}, [timeframe])
}, [timeframe, language])
const fetchMetrics = async () => {
setLoading(true)
@@ -153,7 +157,7 @@ export function NodeMetricsCharts() {
if (!result.data || !Array.isArray(result.data)) {
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) {
@@ -171,26 +175,26 @@ export function NodeMetricsCharts() {
let timeLabel = ""
if (timeframe === "hour") {
timeLabel = date.toLocaleString("en-US", {
timeLabel = date.toLocaleString(language, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "day") {
timeLabel = date.toLocaleString("en-US", {
timeLabel = date.toLocaleString(language, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "week") {
timeLabel = date.toLocaleString("en-US", {
timeLabel = date.toLocaleString(language, {
month: "short",
day: "numeric",
hour: "2-digit",
hour12: false,
})
} else {
timeLabel = date.toLocaleString("en-US", {
timeLabel = date.toLocaleString(language, {
month: "short",
day: "numeric",
})
@@ -224,7 +228,7 @@ export function NodeMetricsCharts() {
// the user sees actionable text instead of a bare "503".
const body = err?.body
setError({
headline: body?.error || err?.message || "Error loading metrics",
headline: body?.error || err?.message || t("overview.metricsLoadError"),
details: body?.details,
suggestion: body?.suggestion,
})
@@ -311,7 +315,7 @@ export function NodeMetricsCharts() {
{error.suggestion && (
<div className="w-full mt-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
Suggested fix on the Proxmox host
{t("overview.suggestedFix")}
</p>
<code className="block text-xs bg-background/60 border border-border rounded px-2 py-1.5 font-mono break-all">
{error.suggestion}
@@ -336,14 +340,14 @@ export function NodeMetricsCharts() {
<Card className="bg-card border-border">
<CardContent className="p-6">
<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>
</CardContent>
</Card>
<Card className="bg-card border-border">
<CardContent className="p-6">
<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>
</CardContent>
</Card>
@@ -363,7 +367,7 @@ export function NodeMetricsCharts() {
<SelectContent>
{TIMEFRAME_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
{t(option.labelKey)}
</SelectItem>
))}
</SelectContent>
@@ -378,9 +382,17 @@ export function NodeMetricsCharts() {
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<CardTitle className="text-foreground flex items-center">
<TrendingUp className="h-5 w-5 mr-2" />
CPU Usage & Load Average
{t("overview.cpuUsageLoadAverage")}
</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>
</CardHeader>
<CardContent className="px-0 md:px-6">
@@ -414,7 +426,7 @@ export function NodeMetricsCharts() {
className="text-foreground"
tick={{ fill: "currentColor", fontSize: 12 }}
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"]}
/>
@@ -428,7 +440,7 @@ export function NodeMetricsCharts() {
strokeWidth={2}
fill="#3b82f6"
fillOpacity={0.3}
name="CPU %"
name={t("overview.cpuPercent")}
hide={!visibleLines.cpu.cpu}
/>
<Area
@@ -439,7 +451,7 @@ export function NodeMetricsCharts() {
strokeWidth={2}
fill="#10b981"
fillOpacity={0.3}
name="Load Avg"
name={t("overview.loadAverage")}
hide={!visibleLines.cpu.load}
/>
</AreaChart>
@@ -453,9 +465,17 @@ export function NodeMetricsCharts() {
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<CardTitle className="text-foreground flex items-center">
<MemoryStick className="h-5 w-5 mr-2" />
Memory Usage
{t("overview.memoryUsage")}
</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>
</CardHeader>
<CardContent className="px-0 pr-2 md:px-6">
@@ -490,7 +510,7 @@ export function NodeMetricsCharts() {
strokeWidth={2}
fill="#3b82f6"
fillOpacity={0.1}
name="Total"
name={t("overview.total")}
hide={!visibleLines.memory.memoryTotal}
/>
<Area
@@ -500,7 +520,7 @@ export function NodeMetricsCharts() {
strokeWidth={2}
fill="#10b981"
fillOpacity={0.3}
name="Used"
name={t("overview.used")}
hide={!visibleLines.memory.memoryUsed}
/>
{/* Only show ZFS ARC if there's data */}
@@ -525,7 +545,7 @@ export function NodeMetricsCharts() {
strokeWidth={2}
fill="#06b6d4"
fillOpacity={0.3}
name="Free"
name={t("overview.free")}
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"
import Image from "next/image"
import { Checkbox } from "./ui/checkbox"
import { useT } from "../lib/i18n/provider"
interface OnboardingSlide {
id: number
title: string
description: string
titleKey: string
descriptionKey: string
image?: string
icon: React.ReactNode
gradient: string
@@ -33,77 +34,70 @@ interface OnboardingSlide {
const slides: OnboardingSlide[] = [
{
id: 0,
title: "Welcome to ProxMenux Monitor!",
description:
"Your new monitoring tool for Proxmox. Discover all the features that will help you manage and supervise your infrastructure efficiently.",
titleKey: "onboarding.slides.welcome.title",
descriptionKey: "onboarding.slides.welcome.description",
icon: <Sparkles className="h-16 w-16" />,
gradient: "from-blue-500 via-purple-500 to-pink-500",
},
{
id: 1,
title: "System 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.",
titleKey: "onboarding.slides.overview.title",
descriptionKey: "onboarding.slides.overview.description",
image: "/images/onboarding/imagen1.png",
icon: <LayoutDashboard className="h-12 w-12" />,
gradient: "from-blue-500 to-cyan-500",
},
{
id: 2,
title: "Storage Management",
description:
"Visualize the status of all your disks and volumes. Detailed information on capacity, usage, SMART health, temperature and performance of each storage device.",
titleKey: "onboarding.slides.storage.title",
descriptionKey: "onboarding.slides.storage.description",
image: "/images/onboarding/imagen2.png",
icon: <HardDrive className="h-12 w-12" />,
gradient: "from-cyan-500 to-teal-500",
},
{
id: 3,
title: "Network Metrics",
description:
"Monitor network traffic in real-time. Bandwidth statistics, active interfaces, transfer speeds and historical usage graphs.",
titleKey: "onboarding.slides.network.title",
descriptionKey: "onboarding.slides.network.description",
image: "/images/onboarding/imagen3.png",
icon: <Network className="h-12 w-12" />,
gradient: "from-teal-500 to-green-500",
},
{
id: 4,
title: "Virtual Machines & Containers",
description:
"Manage all your VMs and LXC containers from one place. Status, allocated resources, current usage and quick controls for each virtual machine.",
titleKey: "onboarding.slides.virtualMachines.title",
descriptionKey: "onboarding.slides.virtualMachines.description",
image: "/images/onboarding/imagen4.png",
icon: <Box className="h-12 w-12" />,
gradient: "from-green-500 to-emerald-500",
},
{
id: 5,
title: "Hardware Information",
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.",
titleKey: "onboarding.slides.hardware.title",
descriptionKey: "onboarding.slides.hardware.description",
image: "/images/onboarding/imagen5.png",
icon: <Cpu className="h-12 w-12" />,
gradient: "from-emerald-500 to-blue-500",
},
{
id: 6,
title: "System 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.",
titleKey: "onboarding.slides.logs.title",
descriptionKey: "onboarding.slides.logs.description",
image: "/images/onboarding/imagen6.png",
icon: <FileText className="h-12 w-12" />,
gradient: "from-blue-500 to-indigo-500",
},
{
id: 7,
title: "Ready for the 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.",
titleKey: "onboarding.slides.future.title",
descriptionKey: "onboarding.slides.future.description",
icon: <Rocket className="h-16 w-16" />,
gradient: "from-indigo-500 via-purple-500 to-pink-500",
},
]
export function OnboardingCarousel() {
const t = useT()
const [open, setOpen] = useState(false)
const [currentSlide, setCurrentSlide] = useState(0)
const [direction, setDirection] = useState<"next" | "prev">("next")
@@ -155,11 +149,13 @@ export function OnboardingCarousel() {
}
const slide = slides[currentSlide]
const slideTitle = t(slide.titleKey)
const slideDescription = t(slide.descriptionKey)
return (
<Dialog open={open} onOpenChange={handleClose}>
<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">
<Button
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">
<Image
src={slide.image || "/placeholder.svg"}
alt={slide.title}
alt={slideTitle}
width={600}
height={400}
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="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">
{slide.description}
{slideDescription}
</p>
</div>
@@ -223,7 +219,7 @@ export function OnboardingCarousel() {
? "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"
}`}
aria-label={`Go to slide ${index + 1}`}
aria-label={t("onboarding.goToSlide", { number: index + 1 })}
/>
))}
</div>
@@ -236,7 +232,7 @@ export function OnboardingCarousel() {
className="gap-2 w-full sm:w-auto text-sm"
>
<ChevronLeft className="h-4 w-4" />
Previous
{t("onboarding.previous")}
</Button>
<div className="flex gap-2 w-full sm:w-auto">
@@ -247,13 +243,13 @@ export function OnboardingCarousel() {
onClick={handleSkip}
className="flex-1 sm:flex-none bg-transparent text-sm"
>
Skip
{t("onboarding.skip")}
</Button>
<Button
onClick={handleNext}
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" />
</Button>
</>
@@ -262,7 +258,7 @@ export function OnboardingCarousel() {
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"
>
Get Started!
{t("onboarding.getStarted")}
<Sparkles className="h-4 w-4" />
</Button>
)}
@@ -279,7 +275,7 @@ export function OnboardingCarousel() {
htmlFor="dont-show-again"
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>
</div>
</div>
+26 -16
View File
@@ -7,6 +7,7 @@ import { ScrollArea } from "./ui/scroll-area"
import { Cpu, MemoryStick, Search } from "lucide-react"
import { fetchApi } from "@/lib/api-config"
import { ProcessInfoModal } from "./process-info-modal"
import { useT } from "@/lib/i18n/provider"
interface ProcessInfo {
pid: number
@@ -61,6 +62,7 @@ const formatRss = (kb: number): string => {
}
export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailModalProps) {
const t = useT()
const [data, setData] = useState<ProcessesResponse | null>(null)
const [error, setError] = useState<string | null>(null)
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}`)
setData(res)
} catch (e: any) {
setError(e?.message || "Failed to fetch processes")
setError(e?.message || t("details.processes.loadFailed"))
} finally {
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 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 =
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."
: "Current resident memory per process. Refreshes every 3 s while open."
? t("details.processes.cpuDescription")
: t("details.processes.memoryDescription")
// Accent palette matched to the Overview cards: CPU Usage donut uses
// 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">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Filter by command line, user or PID..."
placeholder={t("details.processes.filterPlaceholder")}
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="pl-8 h-8 text-sm"
@@ -176,16 +178,18 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
<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}`}
>
<div className="hidden sm:block">PID</div>
<div className="hidden sm:block truncate">User</div>
<div>Command</div>
<div className={`text-right ${sort === "cpu" ? accent.text : ""}`}>CPU %</div>
<div className={`text-right ${sort === "mem" ? accent.text : ""}`}>{sort === "mem" ? "Memory" : "Mem %"}</div>
<div className="hidden sm:block">{t("details.processes.pid")}</div>
<div className="hidden sm:block truncate">{t("details.processes.user")}</div>
<div>{t("details.processes.command")}</div>
<div className={`text-right ${sort === "cpu" ? accent.text : ""}`}>{t("details.processes.cpuPercent")}</div>
<div className={`text-right ${sort === "mem" ? accent.text : ""}`}>
{sort === "mem" ? t("details.processes.memory") : t("details.processes.memPercent")}
</div>
</div>
{filtered.length === 0 && !loading ? (
<div className="text-center py-8 text-sm text-muted-foreground">
No processes match the filter
{t("details.processes.noMatches")}
</div>
) : (
filtered.map((p) => {
@@ -228,8 +232,8 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
where avg and now match within sampler
noise. */}
{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">
avg {p.cpu_avg.toFixed(1)}
<span className="font-mono text-[10px] text-amber-400" title={t("details.processes.lifetimeAverageTitle")}>
{t("details.processes.averageShort")} {p.cpu_avg.toFixed(1)}
</span>
)}
<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 && (
<div className="text-[10px] text-muted-foreground text-right mt-1">
Captured {new Date(data.captured_at * 1000).toLocaleTimeString()} · {filter
? `${allMatches.length} match${allMatches.length === 1 ? '' : 'es'} of ${data.processes.length} processes`
: `Top ${filtered.length} of ${data.processes.length} processes`}
{t("details.processes.captured", { time: new Date(data.captured_at * 1000).toLocaleTimeString() })} · {filter
? t(allMatches.length === 1 ? "details.processes.matchCount" : "details.processes.matchesCount", {
count: allMatches.length,
total: data.processes.length,
})
: t("details.processes.topCount", {
shown: filtered.length,
total: data.processes.length,
})}
</div>
)}
</DialogContent>
+47 -45
View File
@@ -5,6 +5,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
import { ScrollArea } from "./ui/scroll-area"
import { Activity, FileText, HardDrive, Clock, Info } from "lucide-react"
import { fetchApi } from "@/lib/api-config"
import { useI18n } from "../lib/i18n/provider"
interface ProcessDetail {
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:`
// is the canonical letter — the rest of the field is a human label like
// "(running)". We expand the bare letter to something readable.
const stateLabel = (state: string): string => {
const letter = (state || "").trim().charAt(0).toUpperCase()
const stateLabel = (state: string, t: (key: string) => string): string => {
const rawLetter = (state || "").trim().charAt(0)
const letter = rawLetter.toUpperCase()
const map: Record<string, string> = {
R: "Running",
S: "Sleeping",
D: "Disk wait",
Z: "Zombie",
T: "Stopped",
t: "Tracing stop",
X: "Dead",
I: "Idle",
R: "running",
S: "sleeping",
D: "diskWait",
Z: "zombie",
T: rawLetter === "t" ? "tracingStop" : "stopped",
X: "dead",
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) {
const { language, t } = useI18n()
const [data, setData] = useState<ProcessDetail | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
@@ -105,7 +108,7 @@ export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps
setExited(true)
stopPolling()
} else {
setError(e?.message || "Failed to fetch process")
setError(t("details.processInfo.fetchFailed"))
}
} finally {
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"
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>
</DialogTitle>
<DialogDescription className="text-xs">
{exited ? (
<>Last snapshot from <span className="font-mono">/proc/{pid}</span> before the process finished.</>
) : (
<>Live snapshot from <span className="font-mono">/proc/{pid}</span>. Auto-refreshes every {REFRESH_MS / 1000} s while open.</>
)}
{exited
? t("details.processInfo.descriptionExited", { pid: pid ?? "" })
: t("details.processInfo.descriptionLive", { pid: pid ?? "", seconds: REFRESH_MS / 1000 })}
</DialogDescription>
</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">
<Info className="h-4 w-4 flex-shrink-0 mt-0.5" />
<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">
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>
@@ -166,44 +167,44 @@ export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps
<div className="text-sm text-red-500 py-4">{error}</div>
) : !data ? (
<div className="text-sm text-muted-foreground py-8 text-center">
{loading ? "Loading" : "—"}
{loading ? t("details.processInfo.loading") : "—"}
</div>
) : (
<ScrollArea className={`max-h-[480px] pr-2 ${exited ? "opacity-75" : ""}`}>
<div className="space-y-4">
{/* Overview */}
<Section icon={<Activity className="h-4 w-4 text-blue-400" />} title="Overview">
<Row label="State" value={exited ? "Exited" : stateLabel(data.state)} />
<Row label="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="Open FDs" value={data.fd_count != null ? String(data.fd_count) : "—"} mono />
<Row label="User" value={`${data.user} (${data.uid})`} mono />
<Row label="Group" value={`${data.group} (${data.gid})`} mono />
<Section icon={<Activity className="h-4 w-4 text-blue-400" />} title={t("details.processInfo.sections.overview")}>
<Row label={t("details.processInfo.labels.state")} value={exited ? t("details.processInfo.states.exited") : stateLabel(data.state, t)} />
<Row label={t("details.processInfo.labels.parent")} value={data.parent_name ? `${data.parent_name} (PID ${data.ppid})` : `PID ${data.ppid}`} mono />
<Row label={t("details.processInfo.labels.threads")} value={String(data.threads)} mono />
<Row label={t("details.processInfo.labels.openFds")} value={data.fd_count != null ? String(data.fd_count) : "—"} mono />
<Row label={t("details.processInfo.labels.user")} value={`${data.user} (${data.uid})`} mono />
<Row label={t("details.processInfo.labels.group")} value={`${data.group} (${data.gid})`} mono />
</Section>
{/* Resources */}
<Section icon={<HardDrive className="h-4 w-4 text-amber-400" />} title="Resources">
<Row label="CPU" value={`${data.cpu.toFixed(1)} %`} mono valueClass={accent.text} />
<Row label="Memory" value={`${data.mem.toFixed(1)} %`} mono valueClass={accent.text} />
<Row label="Resident (RSS)" value={formatKb(data.vm_rss_kb)} mono />
<Row label="Virtual size" value={formatKb(data.vm_size_kb)} mono />
<Row label="Swap" value={formatKb(data.vm_swap_kb)} mono />
<Row label="I/O read" value={formatBytes(data.io_read_bytes)} mono />
<Row label="I/O write" value={formatBytes(data.io_write_bytes)} mono />
<Section icon={<HardDrive className="h-4 w-4 text-amber-400" />} title={t("details.processInfo.sections.resources")}>
<Row label={t("details.processInfo.labels.cpu")} value={`${data.cpu.toFixed(1)} %`} mono valueClass={accent.text} />
<Row label={t("details.processInfo.labels.memory")} value={`${data.mem.toFixed(1)} %`} mono valueClass={accent.text} />
<Row label={t("details.processInfo.labels.residentRss")} value={formatKb(data.vm_rss_kb)} mono />
<Row label={t("details.processInfo.labels.virtualSize")} value={formatKb(data.vm_size_kb)} mono />
<Row label={t("details.processInfo.labels.swap")} value={formatKb(data.vm_swap_kb)} mono />
<Row label={t("details.processInfo.labels.ioRead")} value={formatBytes(data.io_read_bytes)} mono />
<Row label={t("details.processInfo.labels.ioWrite")} value={formatBytes(data.io_write_bytes)} mono />
</Section>
{/* Command */}
<Section icon={<FileText className="h-4 w-4 text-purple-400" />} title="Command">
<Row label="Name" value={data.comm} mono />
<Row label="Command line" value={data.cmdline || data.comm} mono wrap />
<Row label="Executable" value={data.exe || "—"} mono wrap />
<Row label="Working dir" value={data.cwd || "—"} mono wrap />
<Section icon={<FileText className="h-4 w-4 text-purple-400" />} title={t("details.processInfo.sections.command")}>
<Row label={t("details.processInfo.labels.name")} value={data.comm} mono />
<Row label={t("details.processInfo.labels.commandLine")} value={data.cmdline || data.comm} mono wrap />
<Row label={t("details.processInfo.labels.executable")} value={data.exe || "—"} mono wrap />
<Row label={t("details.processInfo.labels.workingDir")} value={data.cwd || "—"} mono wrap />
</Section>
{/* Times */}
<Section icon={<Clock className="h-4 w-4 text-emerald-400" />} title="Lifetime">
<Row label="Started" value={data.start_time || "—"} mono />
<Row label="Running for" value={data.elapsed || "—"} mono />
<Section icon={<Clock className="h-4 w-4 text-emerald-400" />} title={t("details.processInfo.sections.lifetime")}>
<Row label={t("details.processInfo.labels.started")} value={data.start_time || "—"} mono />
<Row label={t("details.processInfo.labels.runningFor")} value={data.elapsed || "—"} mono />
</Section>
</div>
</ScrollArea>
@@ -211,7 +212,8 @@ export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps
{data?.captured_at && (
<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}` : ""}
</div>
)}
+26 -28
View File
@@ -19,6 +19,7 @@ import { Button } from "./ui/button"
import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface ProfileData {
success: boolean
@@ -51,6 +52,7 @@ interface ProfileProps {
* the operator hits Edit to start typing.
*/
export function Profile({ onOpenSecurity }: ProfileProps) {
const t = useT()
const [profile, setProfile] = useState<ProfileData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -146,7 +148,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
body: JSON.stringify({ display_name: displayDraft }),
})
if (!data.success) {
setError(data.message || "Failed to save display name")
setError(data.message || t("profilePage.errors.saveDisplayNameFailed"))
return
}
setProfile(data)
@@ -182,7 +184,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
})
const data: ProfileData = await r.json().catch(() => ({ success: false }))
if (!r.ok || !data.success) {
setAvatarError(data.message || `Upload failed (${r.status})`)
setAvatarError(data.message || t("profilePage.errors.uploadFailed", { status: r.status }))
return
}
setProfile(data)
@@ -212,7 +214,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
})
const data: ProfileData = await r.json().catch(() => ({ success: false }))
if (!r.ok || !data.success) {
setAvatarError(data.message || `Delete failed (${r.status})`)
setAvatarError(data.message || t("profilePage.errors.deleteFailed", { status: r.status }))
return
}
setProfile(data)
@@ -232,7 +234,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
<Card>
<CardContent className="p-8 flex items-center justify-center text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Loading profile
{t("profilePage.loading")}
</CardContent>
</Card>
</div>
@@ -247,7 +249,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
<div className="flex items-start gap-2 text-red-500">
<AlertCircle className="h-5 w-5 shrink-0 mt-0.5" />
<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>
</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 gap-2">
<UserIcon className="h-5 w-5 text-cyan-500" />
<CardTitle>User Profile</CardTitle>
<CardTitle>{t("profilePage.title")}</CardTitle>
</div>
<div className="flex items-center gap-2">
{savedDisplay && (
<span className="flex items-center gap-1 text-xs text-green-500">
<Check className="h-3.5 w-3.5" />
Saved
{t("status.saved")}
</span>
)}
{displayEditMode ? (
@@ -286,7 +288,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
disabled={savingDisplay}
className="h-7 text-xs"
>
Cancel
{t("actions.cancel")}
</Button>
<Button
size="sm"
@@ -299,7 +301,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
) : (
<CheckCircle2 className="h-3 w-3 mr-1.5" />
)}
Save
{t("actions.save")}
</Button>
</>
) : (
@@ -310,14 +312,13 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
className="h-7 text-xs"
>
<Settings2 className="h-3 w-3 mr-1.5" />
Edit
{t("actions.edit")}
</Button>
)}
</div>
</div>
<CardDescription>
Personal details rendered in the header avatar menu. None of this is required
the username already covers identity. Display name and avatar are decorative.
{t("profilePage.description")}
</CardDescription>
</CardHeader>
@@ -327,7 +328,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
image they uploaded. `object-cover` keeps the aspect
ratio and crops to fit the circle. */}
<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="relative shrink-0">
{avatarBlobUrl ? (
@@ -367,7 +368,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
className="justify-start"
>
<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>
{profile?.has_avatar && (
<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"
>
<Trash2 className="h-3.5 w-3.5 mr-2" />
Remove avatar
{t("profilePage.avatar.remove")}
</Button>
)}
<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
render it square or pre-crop for best results in the header.
{t("profilePage.avatar.hint")}
</p>
</div>
</div>
@@ -397,7 +397,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
{/* ─── Username (read-only) ─── */}
<div>
<Label className="text-sm" htmlFor="profile-username">Username</Label>
<Label className="text-sm" htmlFor="profile-username">{t("profilePage.username.label")}</Label>
<Input
id="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"
/>
<p className="text-[11px] text-muted-foreground mt-1">
The login name. To change it, disable authentication and reconfigure from
Security.
{t("profilePage.username.help")}
</p>
</div>
{/* ─── Display name (Edit controls live in the card header) ─── */}
<div>
<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>
<Input
id="profile-display"
value={displayDraft}
onChange={(e) => setDisplayDraft(e.target.value)}
placeholder={profile?.username || "Display name"}
placeholder={profile?.username || t("profilePage.displayName.placeholder")}
maxLength={64}
disabled={!displayEditMode || savingDisplay}
className="mt-2 max-w-sm disabled:opacity-100 disabled:cursor-default"
/>
<p className="text-[11px] text-muted-foreground mt-1">
Shown above the username inside the avatar menu. Leave empty to show the
username itself. Up to 64 characters.
{t("profilePage.displayName.help")}
</p>
{error && displayEditMode && (
<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>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-orange-500" />
<CardTitle>Account security</CardTitle>
<CardTitle>{t("profilePage.accountSecurity.title")}</CardTitle>
</div>
<CardDescription>
Password, two-factor authentication and API tokens live in the Security panel.
{t("profilePage.accountSecurity.description")}
</CardDescription>
</CardHeader>
<CardContent>
{onOpenSecurity ? (
<Button variant="outline" onClick={onOpenSecurity}>
<Lock className="h-4 w-4 mr-2" />
Open Security settings
{t("profilePage.accountSecurity.openSecurity")}
</Button>
) : (
<p className="text-xs text-muted-foreground">
Open the Security tab from the navigation.
{t("profilePage.accountSecurity.fallback")}
</p>
)}
</CardContent>
+66 -62
View File
@@ -51,6 +51,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "./ui/dropdown-menu"
import { useT } from "../lib/i18n/provider"
interface SystemStatus {
status: "healthy" | "warning" | "critical"
@@ -80,6 +81,7 @@ interface FlaskSystemInfo {
}
export function ProxmoxDashboard() {
const t = useT()
const [systemStatus, setSystemStatus] = useState<SystemStatus>({
status: "healthy",
uptime: "Loading...",
@@ -98,6 +100,8 @@ export function ProxmoxDashboard() {
const [lastScrollY, setLastScrollY] = useState(0)
const [showHealthModal, setShowHealthModal] = useState(false)
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
const HEALTH_CATEGORY_KEYS = [
@@ -168,7 +172,7 @@ export function ProxmoxDashboard() {
const data: FlaskSystemInfo = await fetchApi("/api/system-info")
const uptimeValue =
data.uptime && typeof data.uptime === "string" && data.uptime.trim() !== "" ? data.uptime : "N/A"
data.uptime && typeof data.uptime === "string" && data.uptime.trim() !== "" ? data.uptime : t("app.notAvailable")
const backendStatus = data.health?.status?.toUpperCase() || "OK"
let healthStatus: "healthy" | "warning" | "critical"
@@ -185,8 +189,8 @@ export function ProxmoxDashboard() {
status: healthStatus,
uptime: uptimeValue,
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
serverName: data.hostname || "Unknown",
nodeId: data.node_id || "Unknown",
serverName: data.hostname || t("app.unknown"),
nodeId: data.node_id || t("app.unknown"),
})
setIsServerConnected(true)
} catch (error) {
@@ -196,13 +200,13 @@ export function ProxmoxDashboard() {
setSystemStatus((prev) => ({
...prev,
status: "critical",
serverName: "Server Offline",
nodeId: "Server Offline",
uptime: "N/A",
serverName: t("app.serverOffline"),
nodeId: t("app.serverOffline"),
uptime: t("app.notAvailable"),
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
}))
}
}, [])
}, [t])
useEffect(() => {
// Siempre fetch inicial
@@ -294,13 +298,13 @@ export function ProxmoxDashboard() {
if (
systemStatus.serverName &&
systemStatus.serverName !== "Loading..." &&
systemStatus.serverName !== "Server Offline"
systemStatus.serverName !== t("app.serverOffline")
) {
document.title = `${systemStatus.serverName} - ProxMenux Monitor`
} else {
document.title = "ProxMenux Monitor"
}
}, [systemStatus.serverName])
}, [systemStatus.serverName, t])
useEffect(() => {
let hideTimeout: ReturnType<typeof setTimeout> | null = null
@@ -362,19 +366,19 @@ export function ProxmoxDashboard() {
const getActiveTabLabel = () => {
switch (activeTab) {
case "overview": return "Overview"
case "vms": return "VMs & LXCs"
case "storage": return "Storage"
case "network": return "Network"
case "hardware": return "Hardware"
case "backup": return "Backup"
case "terminal": return "Terminal"
case "logs": return "System Logs"
case "security": return "Security"
case "settings": return "Settings"
case "about": return "About"
case "profile": return "Profile"
default: return "Navigation Menu"
case "overview": return t("navigation.overview")
case "vms": return t("navigation.virtualMachines")
case "storage": return t("navigation.storage")
case "network": return t("navigation.network")
case "hardware": return t("navigation.hardware")
case "backup": return t("navigation.backup")
case "terminal": return t("navigation.terminal")
case "logs": return t("navigation.systemLogs")
case "security": return t("navigation.security")
case "settings": return t("navigation.settings")
case "about": return t("navigation.about")
case "profile": return t("navigation.profile")
default: return t("navigation.menu")
}
}
@@ -388,13 +392,13 @@ export function ProxmoxDashboard() {
<div className="container mx-auto">
<div className="flex items-center space-x-2 text-red-500 mb-2">
<XCircle className="h-5 w-5" />
<span className="font-medium">ProxMenux Server Connection Failed</span>
<span className="font-medium">{t("status.connectionFailed")}</span>
</div>
<div className="text-sm text-red-500/80 space-y-1 ml-7">
<p> Check that the monitor.service is running correctly.</p>
<p> The ProxMenux server should start automatically on port 8008</p>
<p>&bull; {t("status.checkService")}</p>
<p>&bull; {t("status.serverPort")}</p>
<p>
Try accessing:{" "}
&bull; {t("status.tryAccessing")}{" "}
<a href={getApiUrl("/api/health")} target="_blank" rel="noopener noreferrer" className="underline">
{getApiUrl("/api/health")}
</a>
@@ -433,11 +437,11 @@ export function ProxmoxDashboard() {
<Server className="h-8 w-8 md:h-6 md:w-6 text-primary absolute fallback-icon hidden" />
</div>
<div className="min-w-0">
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">ProxMenux Monitor</h1>
<p className="text-xs md:text-sm text-muted-foreground">Proxmox System Dashboard</p>
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">{t("app.title")}</h1>
<p className="text-xs md:text-sm text-muted-foreground">{t("app.description")}</p>
<div className="lg:hidden flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
<Server className="h-3 w-3" />
<span className="truncate">Node: {systemStatus.serverName}</span>
<span className="truncate">{t("status.node", { node: displayServerName })}</span>
</div>
</div>
</div>
@@ -447,14 +451,14 @@ export function ProxmoxDashboard() {
<div className="flex items-center space-x-2">
<Server className="h-4 w-4 text-muted-foreground" />
<div className="text-sm">
<div className="font-medium text-foreground">Node: {systemStatus.serverName}</div>
<div className="font-medium text-foreground">{t("status.node", { node: displayServerName })}</div>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className={statusColor}>
{statusIcon}
<span className="ml-1 capitalize">{systemStatus.status}</span>
<span className="ml-1">{t(`status.${systemStatus.status}`)}</span>
</Badge>
{systemStatus.status === "healthy" && infoCount > 0 && (
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">
@@ -465,7 +469,7 @@ export function ProxmoxDashboard() {
</div>
<div className="text-sm text-muted-foreground whitespace-nowrap">
Uptime: {systemStatus.uptime || "N/A"}
{t("status.uptime", { uptime: displayUptime })}
</div>
<Button
@@ -479,7 +483,7 @@ export function ProxmoxDashboard() {
className="border-border/50 bg-transparent hover:bg-secondary"
>
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
Refresh
{t("actions.refresh")}
</Button>
<div onClick={(e) => e.stopPropagation()}>
@@ -513,7 +517,7 @@ export function ProxmoxDashboard() {
}}
disabled={isRefreshing}
className="h-8 w-8 p-0 border-border/50 bg-transparent hover:bg-secondary"
aria-label="Refresh"
aria-label={t("actions.refresh")}
>
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
</Button>
@@ -541,7 +545,7 @@ export function ProxmoxDashboard() {
<div className="flex items-center gap-1.5">
<Badge variant="outline" className={`${statusColor} text-xs px-2`}>
{statusIcon}
<span className="ml-1 capitalize">{systemStatus.status}</span>
<span className="ml-1">{t(`status.${systemStatus.status}`)}</span>
</Badge>
{systemStatus.status === "healthy" && infoCount > 0 && (
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20 text-xs px-2">
@@ -551,7 +555,7 @@ export function ProxmoxDashboard() {
)}
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
Uptime: {systemStatus.uptime || "N/A"}
{t("status.uptime", { uptime: displayUptime })}
</span>
</div>
</div>
@@ -583,15 +587,15 @@ export function ProxmoxDashboard() {
// crumb shows where you are, the chevron tells you the
// siblings are one click away.
const NODE_ITEMS = [
{ value: "storage", label: "Storage", Icon: HardDrive, default: false },
{ value: "network", label: "Network", Icon: NetworkIcon, default: false },
{ value: "hardware", label: "Hardware", Icon: Cpu, default: false },
{ value: "storage", label: t("navigation.storage"), Icon: HardDrive, default: false },
{ value: "network", label: t("navigation.network"), Icon: NetworkIcon, default: false },
{ value: "hardware", label: t("navigation.hardware"), Icon: Cpu, default: false },
]
const ADMIN_ITEMS = [
{ value: "logs", label: "System Logs", Icon: ScrollText, default: false },
{ value: "security", label: "Security", Icon: ShieldCheck, default: false },
{ value: "settings", label: "Settings", Icon: SettingsIcon, default: false },
{ value: "about", label: "About", Icon: Info, default: false },
{ value: "logs", label: t("navigation.systemLogs"), Icon: ScrollText, default: false },
{ value: "security", label: t("navigation.security"), Icon: ShieldCheck, default: false },
{ value: "settings", label: t("navigation.settings"), Icon: SettingsIcon, default: false },
{ value: "about", label: t("navigation.about"), Icon: Info, default: false },
]
const activeNodeItem = NODE_ITEMS.find(i => i.value === activeTab)
const activeAdminItem = ADMIN_ITEMS.find(i => i.value === activeTab)
@@ -600,9 +604,9 @@ export function ProxmoxDashboard() {
// The trigger label + icon shown on the bar. When a child
// is active we surface IT; otherwise the group default.
const NodeTriggerIcon = activeNodeItem ? activeNodeItem.Icon : Server
const NodeTriggerLabel = activeNodeItem ? activeNodeItem.label : "Node"
const NodeTriggerLabel = activeNodeItem ? activeNodeItem.label : t("navigation.node")
const AdminTriggerIcon = activeAdminItem ? activeAdminItem.Icon : Settings2
const AdminTriggerLabel = activeAdminItem ? activeAdminItem.label : "Admin"
const AdminTriggerLabel = activeAdminItem ? activeAdminItem.label : t("navigation.admin")
// Dropdown trigger styling: parity with TabsTrigger so the
// parent visibly carries the "I'm the selected section"
// signal when any of its children is the active tab —
@@ -621,14 +625,14 @@ export function ProxmoxDashboard() {
{/* Direct: Overview */}
<TabsTrigger value="overview" className={triggerActiveClass}>
<LayoutDashboard className="mr-2 h-4 w-4" />
Overview
{t("navigation.overview")}
</TabsTrigger>
{/* Direct: VMs & LXCs — first-class because Proxmox IS
a hypervisor; workloads belong at top level. */}
<TabsTrigger value="vms" className={triggerActiveClass}>
<Boxes className="mr-2 h-4 w-4" />
VMs &amp; LXCs
{t("navigation.virtualMachines")}
</TabsTrigger>
{/* Dropdown: Node (Storage / Network / Hardware) */}
@@ -656,13 +660,13 @@ export function ProxmoxDashboard() {
backup ships this becomes a dropdown. */}
<TabsTrigger value="backup" className={triggerActiveClass}>
<DatabaseBackup className="mr-2 h-4 w-4" />
Backup
{t("navigation.backup")}
</TabsTrigger>
{/* Direct: Terminal */}
<TabsTrigger value="terminal" className={triggerActiveClass}>
<Terminal className="mr-2 h-4 w-4" />
Terminal
{t("navigation.terminal")}
</TabsTrigger>
{/* Dropdown: Admin (System Logs / Security / Settings / About) */}
@@ -727,47 +731,47 @@ export function ProxmoxDashboard() {
<div className="flex flex-col gap-1 mt-4">
<Button variant="ghost" onClick={() => select("overview")} className={itemClass(activeTab === "overview")}>
<LayoutDashboard className="h-5 w-5" />
<span>Overview</span>
<span>{t("navigation.overview")}</span>
</Button>
<Button variant="ghost" onClick={() => select("vms")} className={itemClass(activeTab === "vms")}>
<Boxes className="h-5 w-5" />
<span>VMs &amp; LXCs</span>
<span>{t("navigation.virtualMachines")}</span>
</Button>
<Button variant="ghost" onClick={() => select("storage")} className={itemClass(activeTab === "storage")}>
<HardDrive className="h-5 w-5" />
<span>Storage</span>
<span>{t("navigation.storage")}</span>
</Button>
<Button variant="ghost" onClick={() => select("network")} className={itemClass(activeTab === "network")}>
<NetworkIcon className="h-5 w-5" />
<span>Network</span>
<span>{t("navigation.network")}</span>
</Button>
<Button variant="ghost" onClick={() => select("hardware")} className={itemClass(activeTab === "hardware")}>
<Cpu className="h-5 w-5" />
<span>Hardware</span>
<span>{t("navigation.hardware")}</span>
</Button>
<Button variant="ghost" onClick={() => select("backup")} className={itemClass(activeTab === "backup")}>
<DatabaseBackup className="h-5 w-5" />
<span>Backup</span>
<span>{t("navigation.backup")}</span>
</Button>
<Button variant="ghost" onClick={() => select("terminal")} className={itemClass(activeTab === "terminal")}>
<Terminal className="h-5 w-5" />
<span>Terminal</span>
<span>{t("navigation.terminal")}</span>
</Button>
<Button variant="ghost" onClick={() => select("logs")} className={itemClass(activeTab === "logs")}>
<ScrollText className="h-5 w-5" />
<span>System Logs</span>
<span>{t("navigation.systemLogs")}</span>
</Button>
<Button variant="ghost" onClick={() => select("security")} className={itemClass(activeTab === "security")}>
<ShieldCheck className="h-5 w-5" />
<span>Security</span>
<span>{t("navigation.security")}</span>
</Button>
<Button variant="ghost" onClick={() => select("settings")} className={itemClass(activeTab === "settings")}>
<SettingsIcon className="h-5 w-5" />
<span>Settings</span>
<span>{t("navigation.settings")}</span>
</Button>
<Button variant="ghost" onClick={() => select("about")} className={itemClass(activeTab === "about")}>
<Info className="h-5 w-5" />
<span>About</span>
<span>{t("navigation.about")}</span>
</Button>
</div>
)
@@ -836,7 +840,7 @@ export function ProxmoxDashboard() {
</Tabs>
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground">
<p className="font-medium mb-2">ProxMenux Monitor v1.2.4</p>
<p className="font-medium mb-2">ProxMenux Monitor v1.2.4.1-beta</p>
<p>
<a
href="https://ko-fi.com/macrimi"
@@ -844,7 +848,7 @@ export function ProxmoxDashboard() {
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 hover:underline transition-colors"
>
Support and contribute to the project
{t("app.supportProject")}
</a>
</p>
</footer>
+17 -15
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from "react"
import { Plus, Share, X } from "lucide-react"
import { useT } from "../lib/i18n/provider"
// ==========================================================
// PwaInstallPrompt
@@ -58,6 +59,7 @@ function isIOS(): boolean {
}
export function PwaInstallPrompt() {
const t = useT()
const [open, setOpen] = useState(false)
const [platform, setPlatform] = useState<"ios" | "android" | null>(null)
@@ -131,7 +133,7 @@ export function PwaInstallPrompt() {
<button
type="button"
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"
>
<X className="h-4 w-4" />
@@ -143,12 +145,12 @@ export function PwaInstallPrompt() {
</div>
<div className="flex-1 min-w-0">
<h3 id="pwa-install-title" className="text-[17px] font-bold leading-tight tracking-tight text-foreground">
Install ProxMenux Monitor
{t("pwaInstall.title")}
</h3>
<p className="mt-1 text-[13px] leading-snug text-muted-foreground">
{platform === "ios"
? "Add the Monitor to your home screen for quick access."
: "Add the Monitor as an app to launch it like a native application."}
? t("pwaInstall.iosDescription")
: t("pwaInstall.androidDescription")}
</p>
</div>
</div>
@@ -160,12 +162,12 @@ export function PwaInstallPrompt() {
1
</span>
<span>
Tap the{" "}
{t("pwaInstall.ios.stepShareBefore")}{" "}
<span className="inline-flex items-center gap-1 font-semibold text-primary">
<Share className="h-4 w-4" aria-hidden="true" />
Share
{t("pwaInstall.ios.share")}
</span>{" "}
button in the bottom bar
{t("pwaInstall.ios.stepShareAfter")}
</span>
</li>
<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
</span>
<span>
Choose{" "}
{t("pwaInstall.ios.stepChooseBefore")}{" "}
<span className="inline-flex items-center gap-1 font-semibold text-primary">
<Plus className="h-4 w-4" aria-hidden="true" />
Add to Home Screen
{t("pwaInstall.addToHomeScreen")}
</span>
</span>
</li>
@@ -185,15 +187,15 @@ export function PwaInstallPrompt() {
3
</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>
</li>
</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">
Open the browser menu <b className="text-foreground"></b> {" "}
<b className="text-foreground">Add to Home Screen</b> confirm by tapping{" "}
<b className="text-foreground">Install</b>.
{t("pwaInstall.android.stepOpenMenu")} <b className="text-foreground"></b> {" "}
<b className="text-foreground">{t("pwaInstall.addToHomeScreen")}</b> {t("pwaInstall.android.stepConfirm")}{" "}
<b className="text-foreground">{t("pwaInstall.android.install")}</b>.
</div>
)}
@@ -203,14 +205,14 @@ export function PwaInstallPrompt() {
onClick={handleNotNow}
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
type="button"
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"
>
Don&apos;t show again
{t("pwaInstall.neverAgain")}
</button>
</div>
</div>
+11 -7
View File
@@ -5,8 +5,9 @@ import { Button } from "./ui/button"
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 { Checkbox } from "./ui/checkbox"
import { useT } from "../lib/i18n/provider"
const APP_VERSION = "1.2.4" // Sync with AppImage/package.json
const APP_VERSION = "1.2.4.1-beta" // Sync with AppImage/package.json
interface ReleaseNote {
date: string
@@ -247,6 +248,7 @@ interface ReleaseNotesModalProps {
}
export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
const t = useT()
const [dontShowAgain, setDontShowAgain] = useState(false)
const handleClose = () => {
@@ -259,7 +261,7 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
return (
<Dialog open={open} onOpenChange={handleClose}>
<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]">
<Button
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="space-y-2">
<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>
<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>
</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"
>
<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>
@@ -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"
>
<Sparkles className="h-4 w-4 mr-2" />
Got it!
{t("releaseNotes.gotIt")}
</Button>
<div className="flex items-center justify-center gap-2">
@@ -325,7 +329,7 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
htmlFor="dont-show-version-again"
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>
</div>
</div>
+77 -67
View File
@@ -40,6 +40,7 @@ import {
Filter,
} from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
// ── 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 {
const then = new Date(iso).getTime()
const now = Date.now()
const diff = Math.max(0, Math.round((now - then) / 1000))
if (diff < 60) return `${diff}s ago`
if (diff < 3600) return `${Math.round(diff / 60)}m ago`
if (diff < 86400) return `${Math.round(diff / 3600)}h ago`
return `${Math.round(diff / 86400)}d ago`
if (diff < 60) return t("restoreProgress.time.secondsAgo", { count: diff })
if (diff < 3600) return t("restoreProgress.time.minutesAgo", { count: Math.round(diff / 60) })
if (diff < 86400) return t("restoreProgress.time.hoursAgo", { count: Math.round(diff / 3600) })
return t("restoreProgress.time.daysAgo", { count: Math.round(diff / 86400) })
} catch {
return iso
}
@@ -140,40 +143,41 @@ const formatRelative = (iso: string) => {
// "estimating time…". After the run is terminal, "—". The output is
// a full phrase so the caller doesn't have to add suffix words that
// 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.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 perStep = elapsedSec / state.steps_done
const remaining = Math.max(0, state.steps_total - state.steps_done)
const eta = Math.round(perStep * remaining)
if (eta < 60) return `~${eta}s left`
if (eta < 3600) return `~${Math.round(eta / 60)}m left`
return `~${Math.round(eta / 3600)}h left`
if (eta < 60) return t("restoreProgress.time.secondsLeft", { count: eta })
if (eta < 3600) return t("restoreProgress.time.minutesLeft", { count: Math.round(eta / 60) })
return t("restoreProgress.time.hoursLeft", { count: Math.round(eta / 3600) })
}
// ── Small building blocks ─────────────────────────────────────
const StatusBadge: React.FC<{ status: string }> = ({ status }) => {
const t = useT()
if (status === "running")
return (
<Badge className="bg-blue-500/10 border-blue-500/40 text-blue-300 gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
Restore in progress
{t("restoreProgress.status.running")}
</Badge>
)
if (status === "complete")
return (
<Badge className="bg-emerald-500/10 border-emerald-500/40 text-emerald-400 gap-1">
<CheckCircle2 className="h-3 w-3" />
Restore complete
{t("restoreProgress.status.complete")}
</Badge>
)
if (status === "failed")
return (
<Badge className="bg-red-500/10 border-red-500/40 text-red-400 gap-1">
<XCircle className="h-3 w-3" />
Restore failed
{t("restoreProgress.status.failed")}
</Badge>
)
return <Badge variant="outline">{status}</Badge>
@@ -190,6 +194,7 @@ const ComponentStatusIcon: React.FC<{ status: string }> = ({ status }) => {
// ── Log viewer ────────────────────────────────────────────────
const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ path, historyOnly }) => {
const t = useT()
const [filter, setFilter] = useState<"all" | "issues">("all")
const swrKey = 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 gap-1 text-muted-foreground">
<FileText className="h-3.5 w-3.5" />
{path ?? "no log yet"}
{path ?? t("restoreProgress.log.noLog")}
</div>
<div className="flex items-center gap-1">
<Button
@@ -215,7 +220,7 @@ const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ p
onClick={() => setFilter("all")}
>
<ArrowDownAZ className="h-3 w-3 mr-1" />
Full
{t("restoreProgress.log.full")}
</Button>
<Button
size="sm"
@@ -224,13 +229,13 @@ const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ p
onClick={() => setFilter("issues")}
>
<Filter className="h-3 w-3 mr-1" />
Issues only
{t("restoreProgress.log.issuesOnly")}
</Button>
</div>
</div>
<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">
{isLoading ? "Loading" : (data?.lines?.join("\n") || "(no output)")}
{isLoading ? t("app.loading") : (data?.lines?.join("\n") || t("restoreProgress.log.noOutput"))}
</pre>
</ScrollArea>
</div>
@@ -240,13 +245,14 @@ const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ p
// ── Rollback delta widget ─────────────────────────────────────
const RollbackDelta: React.FC<{ delta: RestoreRollback | undefined }> = ({ delta }) => {
const t = useT()
const vms = delta?.vms_to_remove ?? []
const lxcs = delta?.lxcs_to_remove ?? []
const comps = delta?.components_to_uninstall ?? []
if (!vms.length && !lxcs.length && !comps.length) {
return (
<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>
)
}
@@ -264,7 +270,7 @@ const RollbackDelta: React.FC<{ delta: RestoreRollback | undefined }> = ({ delta
{items.length > 0 && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
Show manual cleanup commands
{t("restoreProgress.rollback.showCleanup")}
</summary>
<pre className="mt-1 p-2 rounded-md bg-black/40 text-xs text-muted-foreground font-mono">
{items.map(cmd).join("\n")}
@@ -277,22 +283,22 @@ const RollbackDelta: React.FC<{ delta: RestoreRollback | undefined }> = ({ delta
return (
<div className="space-y-3">
<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>
<Row
label="VMs created after the backup"
label={t("restoreProgress.rollback.vms")}
items={vms}
cmd={(id) => `qm stop ${id} 2>/dev/null; qm destroy ${id} --purge`}
/>
<Row
label="LXCs created after the backup"
label={t("restoreProgress.rollback.lxcs")}
items={lxcs}
cmd={(id) => `pct stop ${id} 2>/dev/null; pct destroy ${id} --purge`}
/>
<Row
label="Components installed after the backup"
label={t("restoreProgress.rollback.components")}
items={comps}
cmd={(name) => `# uninstall ${name} manually via ProxMenux → Hardware & GPU`}
cmd={(name) => t("restoreProgress.rollback.uninstallComponentCommand", { name })}
/>
</div>
)
@@ -306,6 +312,7 @@ const RestoreDetailModal: React.FC<{
state: RestoreState
historyMode?: boolean
}> = ({ open, onClose, state, historyMode }) => {
const t = useT()
const progressPct = state.steps_total > 0 ? Math.round((state.steps_done / state.steps_total) * 100) : 0
return (
@@ -314,12 +321,12 @@ const RestoreDetailModal: React.FC<{
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RotateCcw className="h-5 w-5 text-blue-500" />
Post-restore progress
{t("restoreProgress.title")}
<StatusBadge status={state.status} />
</DialogTitle>
<DialogDescription>
Started {formatIso(state.started_at)}
{state.finished_at ? ` · finished ${formatIso(state.finished_at)}` : ""}
{t("restoreProgress.startedAt", { time: formatIso(state.started_at) })}
{state.finished_at ? ` · ${t("restoreProgress.finishedAt", { time: formatIso(state.finished_at) })}` : ""}
{state.summary?.duration ? ` · ${state.summary.duration}` : ""}
</DialogDescription>
</DialogHeader>
@@ -329,8 +336,8 @@ const RestoreDetailModal: React.FC<{
<div className="flex justify-between text-xs text-muted-foreground">
<span>{state.current_step || "—"}</span>
<span>
{state.steps_done}/{state.steps_total} steps
{state.status === "running" && ` · ${computeEta(state)}`}
{t("restoreProgress.steps", { done: state.steps_done, total: state.steps_total })}
{state.status === "running" && ` · ${computeEta(state, t)}`}
</span>
</div>
<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="text-sm font-medium flex items-center gap-2">
<Cpu className="h-4 w-4" />
Components
{t("restoreProgress.sections.components")}
</div>
<div className="space-y-1.5">
{state.components.map((c) => (
@@ -358,8 +365,8 @@ const RestoreDetailModal: React.FC<{
<div className="flex items-center gap-2">
<ComponentStatusIcon status={c.status} />
<span className="font-medium">{formatComponent(c.name)}</span>
<span className="text-muted-foreground">{c.status}</span>
{c.exit_code && <span className="text-red-400">exit {c.exit_code}</span>}
<span className="text-muted-foreground">{t(`restoreProgress.componentStatus.${c.status}`)}</span>
{c.exit_code && <span className="text-red-400">{t("restoreProgress.exitCode", { code: c.exit_code })}</span>}
</div>
{c.log && <span className="text-muted-foreground font-mono">{c.log}</span>}
</div>
@@ -372,7 +379,7 @@ const RestoreDetailModal: React.FC<{
<div className="space-y-2">
<div className="text-sm font-medium flex items-center gap-2 text-amber-400">
<AlertTriangle className="h-4 w-4" />
Boot sanity warnings
{t("restoreProgress.sections.bootWarnings")}
</div>
<ul className="list-disc list-inside text-xs text-muted-foreground space-y-1">
{state.sanity_warnings.map((w) => (
@@ -385,19 +392,19 @@ const RestoreDetailModal: React.FC<{
{state.data_pools_import && <DataPoolsBlock section={state.data_pools_import} />}
<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} />
</div>
<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} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
{t("actions.close")}
</Button>
</DialogFooter>
</DialogContent>
@@ -408,6 +415,7 @@ const RestoreDetailModal: React.FC<{
// Rendered inside RestoreDetailModal — one row per outcome category
// (imported / forced / partial skip / missing skip / failed).
const DataPoolsBlock: React.FC<{ section: DataPoolsImport }> = ({ section }) => {
const t = useT()
const total =
section.ok.length +
section.forced.length +
@@ -448,40 +456,40 @@ const DataPoolsBlock: React.FC<{ section: DataPoolsImport }> = ({ section }) =>
}
return (
<div className="space-y-2">
<div className="space-y-2">
<div className="text-sm font-medium flex items-center gap-2">
<Cpu className="h-4 w-4" />
ZFS data pools auto-import
{t("restoreProgress.dataPools.title")}
</div>
<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
label="Imported (forced, foreign hostid)"
label={t("restoreProgress.dataPools.importedForced")}
tone="info"
items={section.forced}
help="New hostid grabbed onto the pool label — next boot imports clean."
help={t("restoreProgress.dataPools.importedForcedHelp")}
/>
<Row
label="Skipped (some disks missing)"
label={t("restoreProgress.dataPools.skippedPartial")}
tone="warn"
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
label="Skipped (no disks present)"
label={t("restoreProgress.dataPools.skippedMissing")}
tone="warn"
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
label="Import failed"
label={t("restoreProgress.dataPools.importFailed")}
tone="error"
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>
{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>
)
@@ -490,6 +498,7 @@ const DataPoolsBlock: React.FC<{ section: DataPoolsImport }> = ({ section }) =>
// ── History browser modal ─────────────────────────────────────
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 [detailFile, setDetailFile] = useState<string | null>(null)
const { data: detailResp } = useSWR<{ state: RestoreState }>(
@@ -504,17 +513,17 @@ const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<History className="h-5 w-5" />
Past restores
{t("restoreProgress.history.title")}
</DialogTitle>
<DialogDescription>
Restores archived by the post-boot dispatcher. The latest 20 are kept.
{t("restoreProgress.history.description")}
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-96">
<div className="space-y-1.5">
{(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) => (
<button
@@ -538,7 +547,7 @@ const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
{t("actions.close")}
</Button>
</DialogFooter>
</DialogContent>
@@ -559,6 +568,7 @@ const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({
// ── Main inline card ──────────────────────────────────────────
export const RestoreProgressCard: React.FC = () => {
const t = useT()
const { data, mutate } = useSWR<{ state: RestoreState | null }>(
"/api/host-backups/restore/status",
fetcher,
@@ -597,7 +607,7 @@ export const RestoreProgressCard: React.FC = () => {
<div className="flex justify-end">
<Button variant="ghost" size="sm" onClick={() => setHistoryOpen(true)}>
<History className="h-3.5 w-3.5 mr-1" />
Past restores
{t("restoreProgress.history.title")}
</Button>
<RestoreHistoryModal open={historyOpen} onClose={() => setHistoryOpen(false)} />
</div>
@@ -625,12 +635,12 @@ export const RestoreProgressCard: React.FC = () => {
<RotateCcw
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} />
{hasWarnings && (
<Badge variant="outline" className="text-amber-400 border-amber-500/40 bg-amber-500/10 gap-1">
<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>
)}
{poolCount > 0 && (
@@ -643,22 +653,22 @@ export const RestoreProgressCard: React.FC = () => {
}
>
<Cpu className="h-3 w-3" />
{poolCount} ZFS pool{poolCount === 1 ? "" : "s"}
{poolWarnings > 0 && ` · ${poolWarnings} need attention`}
{t("restoreProgress.badges.zfsPools", { count: poolCount })}
{poolWarnings > 0 && ` · ${t("restoreProgress.badges.needAttention", { count: poolWarnings })}`}
</Badge>
)}
</CardTitle>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setDetailOpen(true)}>
Details
{t("restoreProgress.actions.details")}
</Button>
<Button size="sm" variant="ghost" onClick={() => setHistoryOpen(true)}>
<History className="h-3.5 w-3.5 mr-1" />
History
{t("restoreProgress.actions.history")}
</Button>
{state.status !== "running" && (
<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>
)}
</div>
@@ -668,11 +678,11 @@ export const RestoreProgressCard: React.FC = () => {
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<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>
{state.steps_done}/{state.steps_total} steps
{state.status === "running" && ` · ${computeEta(state)}`}
{t("restoreProgress.steps", { done: state.steps_done, total: state.steps_total })}
{state.status === "running" && ` · ${computeEta(state, t)}`}
{state.summary?.duration && state.status !== "running" && ` · ${state.summary.duration}`}
</span>
</div>
@@ -684,19 +694,19 @@ export const RestoreProgressCard: React.FC = () => {
{state.summary && (
<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="text-muted-foreground">Guests</div>
<div className="text-muted-foreground">{t("restoreProgress.summary.guests")}</div>
<div className="font-medium">{state.summary.guests}</div>
</div>
<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>
<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>
<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>
</div>
+27 -25
View File
@@ -31,6 +31,7 @@ import {
import "xterm/css/xterm.css"
import { API_PORT } from "@/lib/api-config"
import { getTicketedWsUrl } from "@/lib/terminal-ws"
import { useT } from "../lib/i18n/provider"
interface WebInteraction {
type: "yesno" | "menu" | "msgbox" | "input" | "inputbox"
@@ -66,6 +67,7 @@ export function ScriptTerminalModal({
params = { EXECUTION_MODE: "web" },
onComplete,
}: ScriptTerminalModalProps) {
const t = useT()
const termRef = useRef<any>(null)
const wsRef = useRef<WebSocket | null>(null)
// Mirrors `isOpen` for use inside async closures (initializeTerminal)
@@ -388,12 +390,12 @@ const initMessage = {
ws.onerror = (error) => {
setConnectionStatus("offline")
term.writeln("\x1b[31mWebSocket error occurred\x1b[0m")
term.writeln(`\x1b[31m${t("scriptTerminal.websocketError")}\x1b[0m`)
}
ws.onclose = (event) => {
setConnectionStatus("offline")
term.writeln("\x1b[33mConnection closed\x1b[0m")
term.writeln(`\x1b[33m${t("scriptTerminal.connectionClosed")}\x1b[0m`)
if (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="flex flex-col items-center gap-3">
<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>
)}
@@ -835,29 +837,29 @@ const initMessage = {
</Button>
</DropdownMenuTrigger>
<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 />
<DropdownMenuItem onSelect={() => sendCommand("\x03")}>
<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 onSelect={() => sendCommand("\x18")}>
<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 onSelect={() => sendCommand("\x12")}>
<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>
<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() }}>
<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 onSelect={() => { void handlePaste() }}>
<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>
</DropdownMenuContent>
</DropdownMenu>
@@ -877,18 +879,18 @@ const initMessage = {
}`}
title={
connectionStatus === "online"
? "Connected"
? t("scriptTerminal.connected")
: connectionStatus === "connecting"
? "Connecting"
: "Disconnected"
? t("scriptTerminal.connecting")
: t("scriptTerminal.disconnected")
}
></div>
<span className="text-xs text-muted-foreground">
{connectionStatus === "online"
? "Online"
? t("scriptTerminal.online")
: connectionStatus === "connecting"
? "Connecting..."
: "Offline"}
? t("scriptTerminal.connectingStatus")
: t("scriptTerminal.offline")}
</span>
</div>
@@ -897,7 +899,7 @@ const initMessage = {
variant="outline"
className="bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
>
Close
{t("actions.close")}
</Button>
</div>
</DialogContent>
@@ -933,14 +935,14 @@ const initMessage = {
onClick={() => handleInteractionResponse("yes")}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white transition-all duration-150"
>
Yes
{t("scriptTerminal.yes")}
</Button>
<Button
onClick={() => handleInteractionResponse("cancel")}
variant="outline"
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
>
Cancel
{t("actions.cancel")}
</Button>
</div>
)}
@@ -963,14 +965,14 @@ const initMessage = {
variant="outline"
className="w-full hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
>
Cancel
{t("actions.cancel")}
</Button>
</div>
)}
{(currentInteraction.type === "input" || currentInteraction.type === "inputbox") && (
<div className="space-y-2">
<Label>Your input:</Label>
<Label>{t("scriptTerminal.yourInput")}</Label>
<Input
value={interactionInput}
onChange={(e) => setInteractionInput(e.target.value)}
@@ -987,14 +989,14 @@ const initMessage = {
onClick={() => handleInteractionResponse(interactionInput)}
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
>
Submit
{t("scriptTerminal.submit")}
</Button>
<Button
onClick={() => handleInteractionResponse("cancel")}
variant="outline"
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
>
Cancel
{t("actions.cancel")}
</Button>
</div>
</div>
@@ -1006,14 +1008,14 @@ const initMessage = {
onClick={() => handleInteractionResponse("ok")}
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
>
OK
{t("scriptTerminal.ok")}
</Button>
<Button
onClick={() => handleInteractionResponse("cancel")}
variant="outline"
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
>
Cancel
{t("actions.cancel")}
</Button>
</div>
)}
+152 -133
View File
@@ -20,6 +20,7 @@ import {
ArrowUpCircle,
} from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface NetworkInfo {
interface: string
@@ -77,6 +78,20 @@ interface WizardStep {
}
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
const [loading, setLoading] = useState(true)
const [runtimeAvailable, setRuntimeAvailable] = useState(false)
@@ -207,7 +222,7 @@ export function SecureGatewaySetup() {
}
} catch (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 {
setLoading(false)
}
@@ -265,7 +280,7 @@ export function SecureGatewaySetup() {
method: "POST",
})
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
// updates available" immediately, bypassing the 24h server
// 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.
await loadStatus()
} else {
setUpdateError(res?.message || "Update failed")
setUpdateError(res?.message || sg("errors.updateFailed"))
}
} catch (err) {
setUpdateError(err instanceof Error ? err.message : "Network error during update")
setUpdateError(err instanceof Error ? err.message : sg("errors.networkUpdateFailed"))
} finally {
setUpdateApplying(false)
}
@@ -293,7 +308,7 @@ export function SecureGatewaySetup() {
if (deploying) return
setDeploying(true)
setDeployError("")
setDeployProgress("Preparing deployment...")
setDeployProgress(sg("messages.preparingDeployment"))
try {
// Validate required fields
@@ -302,7 +317,7 @@ export function SecureGatewaySetup() {
for (const fieldName of step.fields) {
const field = configSchema?.[fieldName]
if (field?.required && !config[fieldName]) {
setDeployError(`${field.label} is required`)
setDeployError(sg("errors.fieldRequired", { field: fieldText(fieldName, "label", field.label) }))
setDeploying(false)
return
}
@@ -326,7 +341,7 @@ export function SecureGatewaySetup() {
}
// For "custom", the user has already selected networks manually
setDeployProgress("Creating LXC container...")
setDeployProgress(sg("messages.creatingLxc"))
const result = await fetchApi("/api/oci/deploy", {
method: "POST",
@@ -338,16 +353,16 @@ export function SecureGatewaySetup() {
if (!result.success) {
// 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")) {
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)
setDeploying(false)
return
}
setDeployProgress("Gateway deployed successfully!")
setDeployProgress(sg("messages.gatewayDeployed"))
// Wipe the Tailscale auth_key from React state so it's no longer
// reachable from a future XSS / state-inspection. The key only needs
@@ -376,7 +391,7 @@ export function SecureGatewaySetup() {
}, 2000)
} catch (err: any) {
setDeployError(err.message || "Deployment failed")
setDeployError(err.message || sg("errors.deploymentFailed"))
setDeploying(false)
}
}
@@ -400,7 +415,7 @@ export function SecureGatewaySetup() {
const handleUpdateAuthKey = async () => {
if (!newAuthKey.trim()) {
setUpdateAuthKeyError("Auth Key is required")
setUpdateAuthKeyError(sg("errors.authKeyRequired"))
return
}
@@ -417,7 +432,7 @@ export function SecureGatewaySetup() {
})
if (!result.success) {
setUpdateAuthKeyError(result.message || "Failed to update auth key")
setUpdateAuthKeyError(result.message || sg("errors.updateAuthKeyFailed"))
setUpdateAuthKeyLoading(false)
return
}
@@ -427,7 +442,7 @@ export function SecureGatewaySetup() {
setNewAuthKey("")
await loadStatus()
} catch (err: any) {
setUpdateAuthKeyError(err.message || "Failed to update auth key")
setUpdateAuthKeyError(err.message || sg("errors.updateAuthKeyFailed"))
} finally {
setUpdateAuthKeyLoading(false)
}
@@ -456,10 +471,10 @@ export function SecureGatewaySetup() {
try {
const result = await fetchApi("/api/oci/installed/secure-gateway/logs?lines=100")
if (result.success) {
setLogs(result.logs || "No logs available")
setLogs(result.logs || sg("logs.empty"))
}
} catch (err) {
setLogs("Failed to load logs")
setLogs(sg("logs.failed"))
} finally {
setLogsLoading(false)
}
@@ -476,16 +491,16 @@ export function SecureGatewaySetup() {
// 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.
const formatLastChecked = (iso?: string): string => {
if (!iso) return "never"
if (!iso) return sg("values.never")
const d = new Date(iso)
if (isNaN(d.getTime())) return "unknown"
if (isNaN(d.getTime())) return t("common.unknown")
const now = Date.now()
const ageMs = now - d.getTime()
const sameDay = new Date(now).toDateString() === d.toDateString()
const yesterday = new Date(now - 86_400_000).toDateString() === d.toDateString()
const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
if (sameDay) return time
if (yesterday) return `yesterday ${time}`
if (yesterday) return sg("values.yesterdayAt", { time })
if (ageMs < 7 * 86_400_000) {
return d.toLocaleDateString([], { weekday: "short" }) + " " + time
}
@@ -495,6 +510,11 @@ export function SecureGatewaySetup() {
const renderField = (fieldName: string) => {
const field = configSchema?.[fieldName]
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
if (field.depends_on) {
@@ -511,7 +531,7 @@ export function SecureGatewaySetup() {
return (
<div key={fieldName} className="space-y-2">
<Label htmlFor={fieldName} className="text-sm font-medium">
{field.label}
{translatedLabel}
{field.required && <span className="text-red-500 ml-1">*</span>}
</Label>
<div className="relative">
@@ -520,7 +540,7 @@ export function SecureGatewaySetup() {
type={isVisible ? "text" : "password"}
value={config[fieldName] || ""}
onChange={(e) => setConfig({ ...config, [fieldName]: e.target.value })}
placeholder={field.placeholder}
placeholder={translatedPlaceholder}
className="pr-10 bg-background border-border"
/>
<button
@@ -536,7 +556,7 @@ export function SecureGatewaySetup() {
{isVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<p className="text-xs text-muted-foreground">{field.description}</p>
<p className="text-xs text-muted-foreground">{translatedDescription}</p>
{field.help_url && (
<a
href={field.help_url}
@@ -544,7 +564,7 @@ export function SecureGatewaySetup() {
rel="noopener noreferrer"
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>
)}
</div>
@@ -554,7 +574,7 @@ export function SecureGatewaySetup() {
return (
<div key={fieldName} className="space-y-2">
<Label htmlFor={fieldName} className="text-sm font-medium">
{field.label}
{translatedLabel}
{field.required && <span className="text-red-500 ml-1">*</span>}
</Label>
<Input
@@ -562,10 +582,10 @@ export function SecureGatewaySetup() {
type="text"
value={config[fieldName] || ""}
onChange={(e) => setConfig({ ...config, [fieldName]: e.target.value })}
placeholder={field.placeholder}
placeholder={translatedPlaceholder}
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>
)
@@ -596,7 +616,7 @@ export function SecureGatewaySetup() {
return (
<div key={fieldName} className="space-y-3">
<Label className="text-sm font-medium">
{field.label}
{translatedLabel}
{field.required && <span className="text-red-500 ml-1">*</span>}
</Label>
<div className="space-y-2">
@@ -619,15 +639,15 @@ export function SecureGatewaySetup() {
)}
</div>
<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 && (
<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 */}
{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">
<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>
)}
</div>
@@ -642,13 +662,13 @@ export function SecureGatewaySetup() {
return (
<div key={fieldName} className="space-y-3">
<Label className="text-sm font-medium">
{field.label}
{translatedLabel}
</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">
{networks.length === 0 ? (
<p className="text-sm text-muted-foreground p-3 bg-muted/30 rounded">
No networks detected
{sg("noNetworksDetected")}
</p>
) : (
networks.map((net) => {
@@ -676,7 +696,7 @@ export function SecureGatewaySetup() {
<span className="font-mono text-sm">{net.subnet}</span>
{net.recommended && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-500">
Recommended
{sg("recommended")}
</span>
)}
</div>
@@ -705,12 +725,12 @@ export function SecureGatewaySetup() {
>
<Checkbox checked={config[fieldName] || false} className="pointer-events-none mt-0.5" />
<div>
<p className="font-medium text-sm">{field.label}</p>
<p className="text-xs text-muted-foreground">{field.description}</p>
<p className="font-medium text-sm">{translatedLabel}</p>
<p className="text-xs text-muted-foreground">{translatedDescription}</p>
{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">
<Info className="h-3 w-3 mt-0.5 flex-shrink-0" />
{field.warning}
{translatedWarning}
</p>
)}
</div>
@@ -736,40 +756,40 @@ export function SecureGatewaySetup() {
</div>
</div>
<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">
Deploy a VPN gateway using Tailscale for secure, zero-trust access to your Proxmox infrastructure without opening ports.
{sg("wizard.introDescription")}
</p>
</div>
<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">
<li className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
Access ProxMenux Monitor from anywhere
{sg("wizard.benefitMonitorAnywhere")}
</li>
<li className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
Secure access to Proxmox web UI
{sg("wizard.benefitProxmoxUi")}
</li>
<li className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
Optionally expose VMs and LXC containers
{sg("wizard.benefitVmLxc")}
</li>
<li className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
End-to-end encryption
{sg("wizard.benefitEncryption")}
</li>
<li className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
No port forwarding required
{sg("wizard.benefitNoPorts")}
</li>
</ul>
</div>
<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">
<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">
tailscale.com
</a>
@@ -783,17 +803,17 @@ export function SecureGatewaySetup() {
return (
<div className="space-y-6">
<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">
Review your configuration before deploying the gateway.
{sg("wizard.reviewDescription")}
</p>
</div>
{/* Storage selector */}
{storages.length > 1 && (
<div className="space-y-3">
<Label className="text-sm font-medium">Storage Location</Label>
<p className="text-xs text-muted-foreground">Select where to create the container disk.</p>
<Label className="text-sm font-medium">{sg("wizard.storageLocation")}</Label>
<p className="text-xs text-muted-foreground">{sg("wizard.storageDescription")}</p>
<div className="space-y-2">
{storages.filter(s => s.active && s.enabled).map((storage) => (
<div
@@ -819,12 +839,12 @@ export function SecureGatewaySetup() {
<span className="text-xs text-muted-foreground">({storage.type})</span>
{storage.recommended && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-500">
Recommended
{sg("recommended")}
</span>
)}
</div>
<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>
</div>
</div>
@@ -835,41 +855,41 @@ export function SecureGatewaySetup() {
)}
<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="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>
</div>
{storages.length > 1 && (
<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>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Access Mode:</span>
<span>{config.access_mode === "host_only" ? "Host Only" : config.access_mode === "proxmox_network" ? "Proxmox Network" : "Custom Networks"}</span>
<span className="text-muted-foreground">{sg("wizard.accessMode")}:</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>
{config.access_mode === "host_only" && hostIp && (
<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>
</div>
)}
{(config.access_mode === "proxmox_network" || config.access_mode === "custom") && config.advertise_routes?.length > 0 && (
<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>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Exit Node:</span>
<span>{config.exit_node ? "Yes" : "No"}</span>
<span className="text-muted-foreground">{sg("wizard.exitNode")}:</span>
<span>{config.exit_node ? sg("values.yes") : sg("values.no")}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Accept Routes:</span>
<span>{config.accept_routes ? "Yes" : "No"}</span>
<span className="text-muted-foreground">{sg("wizard.acceptRoutes")}:</span>
<span>{config.accept_routes ? sg("values.yes") : sg("values.no")}</span>
</div>
</div>
</div>
@@ -880,12 +900,12 @@ export function SecureGatewaySetup() {
<p className="text-xs text-cyan-400 flex items-start gap-2">
<Info className="h-4 w-4 flex-shrink-0 mt-0.5" />
<span>
<strong>Important:</strong> After deployment, you must approve the subnet route in Tailscale Admin for remote access to work.
{config.exit_node && <span> You{"'"}ll also need to approve the exit node.</span>}
<strong>{sg("wizard.important")}:</strong> {sg("wizard.approvalRequired")}
{config.exit_node && <span> {sg("wizard.exitNodeApprovalRequired")}</span>}
</span>
</p>
<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>
</div>
)}
@@ -915,8 +935,8 @@ export function SecureGatewaySetup() {
return (
<div className="space-y-6">
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">{step.title}</h3>
<p className="text-sm text-muted-foreground">{step.description}</p>
<h3 className="text-lg font-semibold">{stepText(step, "title")}</h3>
<p className="text-sm text-muted-foreground">{stepText(step, "description")}</p>
</div>
<div className="space-y-4">
{step.fields?.map((fieldName) => renderField(fieldName))}
@@ -932,7 +952,7 @@ export function SecureGatewaySetup() {
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-cyan-500" />
<CardTitle className="text-base">Secure Gateway</CardTitle>
<CardTitle className="text-base">{sg("title")}</CardTitle>
</div>
</CardHeader>
<CardContent>
@@ -953,14 +973,14 @@ export function SecureGatewaySetup() {
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-cyan-500" />
<CardTitle className="text-base">Secure Gateway</CardTitle>
<CardTitle className="text-base">{sg("title")}</CardTitle>
</div>
</CardHeader>
<CardContent>
<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()}>
Retry
{sg("retry")}
</Button>
</div>
</CardContent>
@@ -981,7 +1001,7 @@ export function SecureGatewaySetup() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<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 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" :
@@ -991,16 +1011,16 @@ export function SecureGatewaySetup() {
{isRunning ? <Wifi className="h-3 w-3" /> :
isStopped ? <Square 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>
<CardDescription>Tailscale VPN Gateway</CardDescription>
<CardDescription>{sg("installed.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Status info */}
{isRunning && appStatus.uptime_seconds > 0 && (
<div className="text-xs text-muted-foreground">
Uptime: {formatUptime(appStatus.uptime_seconds)}
{sg("installed.uptime")}: {formatUptime(appStatus.uptime_seconds)}
</div>
)}
@@ -1018,7 +1038,7 @@ export function SecureGatewaySetup() {
) : (
<Play className="h-4 w-4 mr-1" />
)}
Start
{sg("actions.start")}
</Button>
)}
{isRunning && (
@@ -1034,7 +1054,7 @@ export function SecureGatewaySetup() {
) : (
<Square className="h-4 w-4 mr-1" />
)}
Stop
{sg("actions.stop")}
</Button>
<Button
size="sm"
@@ -1047,7 +1067,7 @@ export function SecureGatewaySetup() {
) : (
<RotateCw className="h-4 w-4 mr-1" />
)}
Restart
{sg("actions.restart")}
</Button>
</>
)}
@@ -1060,7 +1080,7 @@ export function SecureGatewaySetup() {
}}
>
<FileText className="h-4 w-4 mr-1" />
Logs
{sg("actions.logs")}
</Button>
<Button
size="sm"
@@ -1070,7 +1090,7 @@ export function SecureGatewaySetup() {
disabled={actionLoading !== null}
>
<Trash2 className="h-4 w-4 mr-1" />
Remove
{sg("actions.remove")}
</Button>
</div>
@@ -1083,9 +1103,9 @@ export function SecureGatewaySetup() {
<>
<div className="flex items-center justify-between gap-2">
<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">
Tailscale v{updateInfo.latest_version} available
{sg("updates.tailscaleAvailable", { version: updateInfo.latest_version || "" })}
</span>
</div>
</div>
@@ -1101,24 +1121,23 @@ export function SecureGatewaySetup() {
<ArrowUpCircle className="h-4 w-4 mr-1.5" />
)}
{updateApplying
? "Updating"
: `Update to v${updateInfo.latest_version}`}
? sg("updates.updating")
: sg("updates.updateToVersion", { version: updateInfo.latest_version || "" })}
</Button>
{updateInfo.packages && updateInfo.packages.length > 1 && (
<div className="text-[11px] text-muted-foreground">
+{updateInfo.packages.length - 1} other package
{updateInfo.packages.length > 2 ? "s" : ""} pending in the container
{sg("updates.otherPackagesPending", { count: updateInfo.packages.length - 1 })}
</div>
)}
</>
) : (
<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
? ` · 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>
)}
{updateError && (
@@ -1146,7 +1165,7 @@ export function SecureGatewaySetup() {
className="text-xs h-7 px-2"
>
<Key className="h-3 w-3 mr-1" />
Update Auth Key
{sg("authKey.update")}
</Button>
<a
href="https://login.tailscale.com/admin/machines"
@@ -1154,7 +1173,7 @@ export function SecureGatewaySetup() {
rel="noopener noreferrer"
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>
</div>
</CardContent>
@@ -1164,8 +1183,8 @@ export function SecureGatewaySetup() {
<Dialog open={showLogs} onOpenChange={setShowLogs}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Secure Gateway Logs</DialogTitle>
<DialogDescription>Recent container logs</DialogDescription>
<DialogTitle>{sg("logs.title")}</DialogTitle>
<DialogDescription>{sg("logs.description")}</DialogDescription>
</DialogHeader>
<div className="bg-black/50 rounded-lg p-4 max-h-96 overflow-auto">
{logsLoading ? (
@@ -1174,14 +1193,14 @@ export function SecureGatewaySetup() {
</div>
) : (
<pre className="text-xs font-mono text-green-400 whitespace-pre-wrap">
{logs || "No logs available"}
{logs || sg("logs.empty")}
</pre>
)}
</div>
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={loadLogs}>
<RotateCw className="h-4 w-4 mr-1" />
Refresh
{t("actions.refresh")}
</Button>
</div>
</DialogContent>
@@ -1191,14 +1210,14 @@ export function SecureGatewaySetup() {
<Dialog open={showRemoveConfirm} onOpenChange={setShowRemoveConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove Secure Gateway?</DialogTitle>
<DialogTitle>{sg("remove.title")}</DialogTitle>
<DialogDescription>
This will stop and remove the gateway container. Your Tailscale state will be preserved for re-deployment.
{sg("remove.description")}
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setShowRemoveConfirm(false)}>
Cancel
{t("actions.cancel")}
</Button>
<Button
variant="destructive"
@@ -1210,7 +1229,7 @@ export function SecureGatewaySetup() {
) : (
<Trash2 className="h-4 w-4 mr-1" />
)}
Remove
{sg("actions.remove")}
</Button>
</div>
</DialogContent>
@@ -1228,16 +1247,16 @@ export function SecureGatewaySetup() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Key className="h-5 w-5 text-cyan-500" />
Update Auth Key
{sg("authKey.update")}
</DialogTitle>
<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>
</DialogHeader>
<div className="space-y-4">
<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
type="password"
value={newAuthKey}
@@ -1246,14 +1265,14 @@ export function SecureGatewaySetup() {
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Generate a new key at{" "}
{sg("authKey.generateAt")}{" "}
<a
href="https://login.tailscale.com/admin/settings/keys"
target="_blank"
rel="noopener noreferrer"
className="text-cyan-500 hover:text-cyan-400 underline"
>
Tailscale Admin &gt; Settings &gt; Keys
{sg("authKey.adminKeys")}
</a>
</p>
</div>
@@ -1267,7 +1286,7 @@ export function SecureGatewaySetup() {
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setShowUpdateAuthKey(false)}>
Cancel
{t("actions.cancel")}
</Button>
<Button
onClick={handleUpdateAuthKey}
@@ -1279,7 +1298,7 @@ export function SecureGatewaySetup() {
) : (
<Key className="h-4 w-4 mr-2" />
)}
Update Key
{sg("authKey.updateKey")}
</Button>
</div>
</DialogContent>
@@ -1291,10 +1310,10 @@ export function SecureGatewaySetup() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
Gateway Deployed Successfully
{sg("postDeploy.title")}
</DialogTitle>
<DialogDescription>
One more step to complete the setup
{sg("postDeploy.description")}
</DialogDescription>
</DialogHeader>
@@ -1302,17 +1321,17 @@ export function SecureGatewaySetup() {
<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">
<Info className="h-4 w-4" />
Next Step: Approve in Tailscale Admin
{sg("postDeploy.nextStep")}
</p>
<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>
<ul className="space-y-2 text-sm">
{deployedConfig.advertise_routes?.length > 0 && (
<li className="flex items-start gap-2">
<Network className="h-4 w-4 text-cyan-500 mt-0.5 flex-shrink-0" />
<div>
<span className="font-medium">Subnet Routes:</span>
<span className="font-medium">{sg("postDeploy.subnetRoutes")}:</span>
<span className="text-muted-foreground ml-1">
{deployedConfig.advertise_routes.join(", ")}
</span>
@@ -1323,9 +1342,9 @@ export function SecureGatewaySetup() {
<li className="flex items-start gap-2">
<Globe className="h-4 w-4 text-cyan-500 mt-0.5 flex-shrink-0" />
<div>
<span className="font-medium">Exit Node:</span>
<span className="font-medium">{sg("postDeploy.exitNode")}:</span>
<span className="text-muted-foreground ml-1">
Route all internet traffic
{sg("postDeploy.routeAllTraffic")}
</span>
</div>
</li>
@@ -1334,30 +1353,30 @@ export function SecureGatewaySetup() {
</div>
<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">
<li>Click the button below to open Tailscale Admin</li>
<li>Find <span className="font-mono text-cyan-400">{deployedConfig.hostname || "proxmox-gateway"}</span> in the machines list</li>
<li>Click on it to open machine details</li>
<li>In the <strong>Subnets</strong> section, click <strong>Edit</strong> and enable the route</li>
<li>{sg("postDeploy.stepOpenAdmin")}</li>
<li>{sg("postDeploy.stepFindBefore")} <span className="font-mono text-cyan-400">{deployedConfig.hostname || "proxmox-gateway"}</span> {sg("postDeploy.stepFindAfter")}</li>
<li>{sg("postDeploy.stepOpenDetails")}</li>
<li>{sg("postDeploy.stepSubnetsBefore")} <strong>Subnets</strong> {sg("postDeploy.stepSubnetsMiddle")} <strong>Edit</strong> {sg("postDeploy.stepSubnetsAfter")}</li>
{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>
</div>
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-3">
<p className="text-xs text-green-400">
Once approved, you can access your Proxmox host at{" "}
<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}:8008</span> (ProxMenux Monitor) from any device with Tailscale.
{sg("postDeploy.accessAfterApproval")}{" "}
<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) {sg("postDeploy.fromAnyDevice")}
</p>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setShowPostDeployInfo(false)}>
I{"'"}ll do it later
{sg("postDeploy.doLater")}
</Button>
<Button
onClick={() => {
@@ -1366,7 +1385,7 @@ export function SecureGatewaySetup() {
}}
className="bg-cyan-600 hover:bg-cyan-700"
>
Open Tailscale Admin
{sg("tailscale.openAdmin")}
<ExternalLink className="h-4 w-4 ml-2" />
</Button>
</div>
@@ -1383,13 +1402,13 @@ export function SecureGatewaySetup() {
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-cyan-500" />
<CardTitle className="text-base">Secure Gateway</CardTitle>
<CardTitle className="text-base">{sg("title")}</CardTitle>
</div>
<CardDescription>VPN access without opening ports</CardDescription>
<CardDescription>{sg("notInstalled.subtitle")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<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>
<Button
@@ -1397,7 +1416,7 @@ export function SecureGatewaySetup() {
className="bg-cyan-600 hover:bg-cyan-700"
>
<ShieldCheck className="h-4 w-4 mr-2" />
Deploy Secure Gateway
{sg("notInstalled.deploy")}
</Button>
</CardContent>
</Card>
@@ -1418,7 +1437,7 @@ export function SecureGatewaySetup() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-cyan-500" />
Secure Gateway Setup
{sg("wizard.setupTitle")}
</DialogTitle>
</DialogHeader>
@@ -1465,7 +1484,7 @@ export function SecureGatewaySetup() {
}}
disabled={currentStep === 0 || deploying}
>
Back
{sg("actions.back")}
</Button>
{currentStep < wizardSteps.length - 1 ? (
@@ -1480,7 +1499,7 @@ export function SecureGatewaySetup() {
}}
className="bg-cyan-600 hover:bg-cyan-700"
>
Continue
{sg("actions.continue")}
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
@@ -1492,12 +1511,12 @@ export function SecureGatewaySetup() {
{deploying ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Deploying...
{sg("actions.deploying")}
</>
) : (
<>
<Play className="h-4 w-4 mr-2" />
Deploy Gateway
{sg("actions.deployGateway")}
</>
)}
</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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle, BellOff } from "lucide-react"
import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle, BellOff, Globe2 } from "lucide-react"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
import { NotificationSettings } from "./notification-settings"
@@ -14,6 +14,8 @@ import { Switch } from "./ui/switch"
import { Input } from "./ui/input"
import { getNetworkUnit } from "../lib/format-network"
import { fetchApi } from "../lib/api-config"
import { SUPPORTED_LANGUAGES, useI18n } from "../lib/i18n/provider"
import type { LanguageCode, LanguageStatus } from "../lib/i18n/languages"
// GitHub Dark color palette for bash syntax highlighting
const BASH_KEYWORDS = new Set([
@@ -167,13 +169,13 @@ interface SuppressionCategory {
}
const SUPPRESSION_OPTIONS = [
{ value: "24", label: "24 hours" },
{ value: "72", label: "3 days" },
{ value: "168", label: "1 week" },
{ value: "720", label: "1 month" },
{ value: "8760", label: "1 year" },
{ value: "custom", label: "Custom" },
{ value: "-1", label: "Permanent" },
{ value: "24", labelKey: "settings.healthMonitor.options.24h" },
{ value: "72", labelKey: "settings.healthMonitor.options.3d" },
{ value: "168", labelKey: "settings.healthMonitor.options.1w" },
{ value: "720", labelKey: "settings.healthMonitor.options.1m" },
{ value: "8760", labelKey: "settings.healthMonitor.options.1y" },
{ value: "custom", labelKey: "settings.healthMonitor.options.custom" },
{ value: "-1", labelKey: "settings.healthMonitor.options.permanent" },
]
const CATEGORY_ICONS: Record<string, React.ElementType> = {
@@ -246,6 +248,15 @@ function normalizeErrorKey(key: string): string {
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 {
key: string
name: string
@@ -297,6 +308,13 @@ interface NetworkInterface {
}
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 [updatesAvailableCount, setUpdatesAvailableCount] = useState(0)
const [loadingTools, setLoadingTools] = useState(true)
@@ -469,11 +487,11 @@ export function Settings() {
if (entries.length === 0) return
const batch = entries.map(e => `${e.source}:${e.function}:${e.key}`).join("\n")
const title = entries.length === 1
? `Update: ${entries[0].name}`
: `Update ${entries.length} optimizations`
? t("settings.optimizations.updateOneTitle", { name: entries[0].name })
: t("settings.optimizations.updateManyTitle", { count: entries.length })
const description = entries.length === 1
? `Re-running ${entries[0].function} from the ${entries[0].source} flow.`
: `Re-running ${entries.length} post-install functions in sequence.`
? t("settings.optimizations.updateOneDescription", { functionName: entries[0].function, source: entries[0].source })
: t("settings.optimizations.updateManyDescription", { count: entries.length })
setUpdateTerminal({
open: true,
title,
@@ -899,21 +917,82 @@ export function Settings() {
k => pendingChanges[k] !== -2
)
const getLanguageStatusLabel = (status: LanguageStatus) => {
switch (status) {
case "complete":
return t("settings.interfaceLanguage.statusComplete")
case "partial":
return t("settings.interfaceLanguage.statusPartial")
case "needs-translation":
return t("settings.interfaceLanguage.statusNeedsTranslation")
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Settings</h1>
<p className="text-muted-foreground mt-2">Manage your dashboard preferences</p>
<h1 className="text-3xl font-bold">{t("settings.title")}</h1>
<p className="text-muted-foreground mt-2">{t("settings.description")}</p>
</div>
{/* Interface Language Settings */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Globe2 className="h-5 w-5 text-blue-500" />
<CardTitle>{t("settings.interfaceLanguage.title")}</CardTitle>
</div>
<CardDescription>{t("settings.interfaceLanguage.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-sm font-medium text-foreground">{t("settings.interfaceLanguage.label")}</div>
<p className="text-xs text-muted-foreground mt-1">{t("settings.interfaceLanguage.fallbackNote")}</p>
</div>
<Select value={language} onValueChange={(value) => setLanguage(value as LanguageCode)}>
<SelectTrigger className="w-full sm:w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUPPORTED_LANGUAGES.map((item) => (
<SelectItem key={item.code} value={item.code}>
{item.nativeName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{SUPPORTED_LANGUAGES.map((item) => (
<div
key={item.code}
className={`rounded-md border px-3 py-2 text-sm ${
item.code === language ? "border-blue-500 bg-blue-500/10" : "border-border bg-muted/20"
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{item.nativeName}</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{item.code}
</Badge>
</div>
<div className="text-xs text-muted-foreground mt-1">{getLanguageStatusLabel(item.status)}</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Network Units Settings */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Ruler className="h-5 w-5 text-green-500" />
<CardTitle>Network Units</CardTitle>
<CardTitle>{t("settings.networkUnits.title")}</CardTitle>
</div>
<CardDescription>Change how network traffic is displayed</CardDescription>
<CardDescription>{t("settings.networkUnits.description")}</CardDescription>
</CardHeader>
<CardContent>
{loadingUnitSettings ? (
@@ -922,14 +1001,14 @@ export function Settings() {
</div>
) : (
<div className="text-foreground flex items-center justify-between">
<div className="flex items-center">Network Unit Display</div>
<div className="flex items-center">{t("settings.networkUnits.label")}</div>
<Select value={networkUnitSettings} onValueChange={changeNetworkUnit}>
<SelectTrigger className="w-28 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Bytes">Bytes</SelectItem>
<SelectItem value="Bits">Bits</SelectItem>
<SelectItem value="Bytes">{t("settings.networkUnits.bytes")}</SelectItem>
<SelectItem value="Bits">{t("settings.networkUnits.bits")}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -943,14 +1022,14 @@ export function Settings() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<HeartPulse className="h-5 w-5 text-red-500" />
<CardTitle>Health Monitor</CardTitle>
<CardTitle>{t("settings.healthMonitor.title")}</CardTitle>
</div>
{!loadingHealth && (
<div className="flex items-center gap-2">
{savedAllHealth && (
<span className="flex items-center gap-1 text-xs text-green-500">
<Check className="h-3.5 w-3.5" />
Saved
{t("status.saved")}
</span>
)}
{healthEditMode ? (
@@ -960,7 +1039,7 @@ export function Settings() {
onClick={handleCancelEdit}
disabled={savingAllHealth}
>
Cancel
{t("actions.cancel")}
</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"
@@ -972,7 +1051,7 @@ export function Settings() {
) : (
<Check className="h-3 w-3" />
)}
Save
{t("actions.save")}
</button>
</>
) : (
@@ -981,15 +1060,14 @@ export function Settings() {
onClick={() => setHealthEditMode(true)}
>
<Settings2 className="h-3 w-3" />
Edit
{t("actions.edit")}
</button>
)}
</div>
)}
</div>
<CardDescription>
Configure how long dismissed alerts stay suppressed for each category.
Changes apply immediately to both existing and future dismissed alerts.
{t("settings.healthMonitor.description")}
</CardDescription>
</CardHeader>
<CardContent>
@@ -1001,8 +1079,8 @@ export function Settings() {
<div className="space-y-0">
{/* Header */}
<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">Suppression Duration</span>
<span className="text-xs font-medium text-muted-foreground">{t("settings.healthMonitor.category")}</span>
<span className="text-xs font-medium text-muted-foreground">{t("settings.healthMonitor.suppressionDuration")}</span>
</div>
{/* Per-category rows */}
@@ -1015,13 +1093,14 @@ export function Settings() {
const isLong = effectiveHours >= 720 && effectiveHours !== -1 && effectiveHours !== -2
const hasChanged = cat.key in pendingChanges && pendingChanges[cat.key] !== cat.hours
const selectVal = isCustomMode ? "custom" : getSelectValue(effectiveHours, cat.key)
const catLabel = tFallback(`settings.healthMonitor.categories.${healthCategoryKey(cat)}`, cat.label)
return (
<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 gap-2 min-w-0 flex-1">
<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 && (
<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"
value={customValues[cat.key] || ""}
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>
<button
@@ -1074,7 +1153,7 @@ export function Settings() {
<SelectContent>
{SUPPRESSION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
{t(opt.labelKey)}
</SelectItem>
))}
</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">
<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">
Alerts for <span className="font-semibold">{cat.label}</span> will be permanently suppressed when dismissed.
{t("settings.healthMonitor.permanentNotice", { category: catLabel })}
{cat.category === "temperature" && (
<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>
)}
</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">
<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">
Long suppression period. Dismissed alerts for this category will not reappear for an extended time.
{t("settings.healthMonitor.longSuppressionNotice")}
</p>
</div>
)}
@@ -1116,8 +1195,7 @@ export function Settings() {
<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" />
<p className="text-[11px] text-muted-foreground leading-relaxed">
These settings apply when you dismiss a warning from the Health Monitor.
Critical CPU temperature alerts always trigger regardless of settings to protect your hardware.
{t("settings.healthMonitor.footerNote")}
</p>
</div>
@@ -1133,11 +1211,11 @@ export function Settings() {
<div className="pt-8">
<div className="flex items-center gap-2 mb-1.5">
<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>
<p className="text-sm text-muted-foreground mb-4 leading-relaxed">
Alerts you have silenced from the Health Monitor. Permanent dismisses can only be
reverted here. Editing requires the Health Monitor <span className="font-mono text-xs">Edit</span> mode at the top of this card.
{t("settings.healthMonitor.activeSuppressionsDescription")}{" "}
<span className="font-mono text-xs">{t("actions.edit")}</span>.
</p>
{loadingSuppressions ? (
<div className="flex items-center justify-center py-4">
@@ -1145,19 +1223,19 @@ export function Settings() {
</div>
) : activeSuppressions.length === 0 ? (
<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 className="space-y-2">
{activeSuppressions.map((s) => {
const remaining = s.suppression_remaining_hours
const remainingLabel = s.permanent
? "Permanent"
? t("settings.healthMonitor.permanent")
: remaining === undefined || remaining === null
? "Active"
? t("status.active")
: remaining >= 24
? `${Math.round(remaining / 24)}d remaining`
: `${Math.max(0, Math.round(remaining))}h remaining`
? t("settings.healthMonitor.daysRemaining", { count: Math.round(remaining / 24) })
: t("settings.healthMonitor.hoursRemaining", { count: Math.max(0, Math.round(remaining)) })
const dismissedAtLabel = s.acknowledged_at
? 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" : ""}`}>
{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">
Permanent
{t("settings.healthMonitor.permanent")}
</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">
@@ -1183,12 +1261,12 @@ export function Settings() {
)}
<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}>
{normalizeErrorKey(s.error_key)}
{tFallback(`settings.healthMonitor.errorNames.${s.error_key}`, normalizeErrorKey(s.error_key))}
</div>
<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>
{s.severity && <span>severity: <span className="font-medium text-foreground/80">{s.severity}</span></span>}
{dismissedAtLabel && <span>dismissed: {dismissedAtLabel}</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>{t("settings.healthMonitor.labels.severity")}: <span className="font-medium text-foreground/80">{tFallback(`status.${s.severity.toLowerCase()}`, s.severity)}</span></span>}
{dismissedAtLabel && <span>{t("settings.healthMonitor.labels.dismissed")}: {dismissedAtLabel}</span>}
</div>
</div>
</div>
@@ -1204,13 +1282,13 @@ export function Settings() {
onClick={() => handleReEnable(s.error_key)}
title={
!healthEditMode
? "Enable Health Monitor Edit mode to re-enable"
? t("settings.healthMonitor.reEnableDisabledTitle")
: isQueued
? "Cancel re-enable (will not be applied on Save)"
: "Queue this alert for re-enable on Save"
? t("settings.healthMonitor.reEnableQueuedTitle")
: t("settings.healthMonitor.reEnableTitle")
}
>
{isQueued ? "Undo" : "Re-enable"}
{isQueued ? t("actions.undo") : t("settings.healthMonitor.reEnable")}
</Button>
</div>
)
@@ -1228,11 +1306,10 @@ export function Settings() {
<CardHeader>
<div className="flex items-center gap-2">
<Database className="h-5 w-5 text-purple-500" />
<CardTitle>Remote Storage Exclusions</CardTitle>
<CardTitle>{t("settings.remoteStorage.title")}</CardTitle>
</div>
<CardDescription>
Exclude remote storages (PBS, NFS, CIFS, etc.) from health monitoring and notifications.
Use this for storages that are intentionally offline or have limited API access.
{t("settings.remoteStorage.description")}
</CardDescription>
</CardHeader>
<CardContent>
@@ -1243,18 +1320,18 @@ export function Settings() {
) : remoteStorages.length === 0 ? (
<div className="text-center py-8">
<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">
PBS, NFS, CIFS, and other remote storages will appear here when configured
{t("settings.remoteStorage.emptyDescription")}
</p>
</div>
) : (
<div className="space-y-0">
{/* Header */}
<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 text-center w-20">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">{t("settings.remoteStorage.storage")}</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">{t("settings.common.alerts")}</span>
</div>
{/* Storage rows - scrollable container */}
@@ -1279,10 +1356,10 @@ export function Settings() {
</Badge>
</div>
{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 && (
<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>
@@ -1333,9 +1410,9 @@ export function Settings() {
<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" />
<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 />
<strong>Alerts:</strong> When OFF, no notifications will be sent for this storage.
<strong>{t("settings.common.alerts")}:</strong> {t("settings.remoteStorage.alertsHelp")}
</p>
</div>
</div>
@@ -1348,11 +1425,10 @@ export function Settings() {
<CardHeader>
<div className="flex items-center gap-2">
<Network className="h-5 w-5 text-blue-500" />
<CardTitle>Network Interface Exclusions</CardTitle>
<CardTitle>{t("settings.networkInterfaces.title")}</CardTitle>
</div>
<CardDescription>
Exclude network interfaces (bridges, bonds, physical NICs) from health monitoring and notifications.
Use this for interfaces that are intentionally disabled or unused.
{t("settings.networkInterfaces.description")}
</CardDescription>
</CardHeader>
<CardContent>
@@ -1363,15 +1439,15 @@ export function Settings() {
) : networkInterfaces.length === 0 ? (
<div className="text-center py-8">
<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 className="space-y-0">
{/* Header */}
<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 text-center w-20">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">{t("settings.networkInterfaces.interface")}</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">{t("settings.common.alerts")}</span>
</div>
{/* Interface rows - scrollable container */}
@@ -1393,21 +1469,21 @@ export function Settings() {
{iface.name}
</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{iface.type}
{interfaceTypeLabel(iface.type)}
</Badge>
{isDown && !isExcluded && (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">
DOWN
{t("settings.networkInterfaces.down")}
</Badge>
)}
{isExcluded && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-blue-500/10 text-blue-400">
Excluded
{t("settings.networkInterfaces.excluded")}
</Badge>
)}
</div>
<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>
</div>
</div>
@@ -1460,9 +1536,9 @@ export function Settings() {
<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" />
<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 />
<strong>Alerts:</strong> When OFF, no notifications will be sent for this interface.
<strong>{t("settings.common.alerts")}:</strong> {t("settings.networkInterfaces.alertsHelp")}
</p>
</div>
</div>
@@ -1492,27 +1568,26 @@ export function Settings() {
<CardHeader>
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-cyan-500" />
<CardTitle>Snippets storage</CardTitle>
<CardTitle>{t("settings.snippets.title")}</CardTitle>
</div>
<CardDescription>
Where ProxMenux installs hookscripts (e.g. the GPU passthrough guard for VMs/LXCs).
Pick a shared storage in cluster setups so VMs and LXCs migrate cleanly between nodes
{t("settings.snippets.description")}{" "}
<code className="mx-1">local</code>
is node-specific and breaks migration.
{" "}{t("settings.snippets.localNote")}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col md:flex-row md:items-center gap-3">
<Select value={snippetsStorage || ""} onValueChange={saveSnippetsStorage} disabled={snippetsSaving}>
<SelectTrigger className="w-full md:w-72">
<SelectValue placeholder="Pick a storage…" />
<SelectValue placeholder={t("settings.snippets.placeholder")} />
</SelectTrigger>
<SelectContent>
{snippetsCandidates.map(c => (
<SelectItem key={c.name} value={c.name} disabled={!c.active}>
{c.name}
<span className="ml-2 text-xs text-muted-foreground">
{c.type}{!c.active && " · inactive"}
{c.type}{!c.active && ` · ${t("status.inactive")}`}
</span>
</SelectItem>
))}
@@ -1521,14 +1596,12 @@ export function Settings() {
{snippetsSaving && (
<span className="text-xs text-muted-foreground inline-flex items-center gap-1.5">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Saving
{t("status.saving")}
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-3">
Existing VMs/LXCs already configured with the previous storage keep working.
Only new GPU passthrough operations (or running &quot;sync hookscripts&quot; on the host)
will use the new selection.
{t("settings.snippets.footer")}
</p>
</CardContent>
</Card>
@@ -1539,9 +1612,9 @@ export function Settings() {
<CardHeader>
<div className="flex items-center gap-2">
<Wrench className="h-5 w-5 text-orange-500" />
<CardTitle>ProxMenux Optimizations</CardTitle>
<CardTitle>{t("settings.optimizations.title")}</CardTitle>
</div>
<CardDescription>System optimizations and utilities installed via ProxMenux</CardDescription>
<CardDescription>{t("settings.optimizations.description")}</CardDescription>
</CardHeader>
<CardContent>
{loadingTools ? (
@@ -1551,15 +1624,15 @@ export function Settings() {
) : proxmenuxTools.length === 0 ? (
<div className="text-center py-8">
<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-sm text-muted-foreground mt-1">Run ProxMenux to configure system optimizations</p>
<p className="text-muted-foreground">{t("settings.optimizations.emptyTitle")}</p>
<p className="text-sm text-muted-foreground mt-1">{t("settings.optimizations.emptyDescription")}</p>
</div>
) : (
<div className="space-y-2">
<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">
<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
for the multi-select update modal. Only shown when
at least one tool has an available update. */}
@@ -1578,10 +1651,10 @@ export function Settings() {
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"
title="View available updates"
title={t("settings.optimizations.viewUpdates")}
>
<Sparkles className="h-3.5 w-3.5" />
{updatesAvailableCount} {updatesAvailableCount === 1 ? 'update' : 'updates'}
{t("settings.optimizations.updateCount", { count: updatesAvailableCount })}
</button>
)}
</div>
@@ -1605,7 +1678,7 @@ export function Settings() {
key={tool.key}
onClick={clickable ? () => viewToolSource(tool) : undefined}
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={`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>
{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">
legacy
{t("settings.optimizations.legacy")}
</span>
)}
</div>
@@ -1627,7 +1700,7 @@ export function Settings() {
<button
onClick={(e) => { e.stopPropagation(); handleSingleToolUpdate(tool) }}
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" />
</button>
@@ -1662,7 +1735,7 @@ export function Settings() {
<h3 className="text-sm font-semibold truncate">{codeModal.toolName}</h3>
{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">
legacy
{t("settings.optimizations.legacy")}
</span>
)}
</div>
@@ -1688,10 +1761,10 @@ export function Settings() {
<button
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"
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 ? 'Copied' : 'Copy'}
{codeCopied ? t("actions.copied") : t("actions.copy")}
</button>
)}
<button
@@ -1740,9 +1813,9 @@ export function Settings() {
<div className="flex items-center gap-3">
<Sparkles className="h-5 w-5 text-purple-400" />
<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">
{updatesAvailableCount} {updatesAvailableCount === 1 ? 'optimization' : 'optimizations'} can be updated to a newer version.
{t("settings.optimizations.availableUpdatesDescription", { count: updatesAvailableCount })}
</p>
</div>
</div>
@@ -1799,14 +1872,14 @@ export function Settings() {
<div className="flex items-center justify-between p-4 border-t border-border">
<span className="text-xs text-muted-foreground">
{selectedUpdates.size} of {updatesAvailableCount} selected
{t("settings.optimizations.selectedCount", { selected: selectedUpdates.size, total: updatesAvailableCount })}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setUpdateModalOpen(false)}
className="px-4 py-1.5 text-xs rounded-md bg-muted hover:bg-muted/80 transition-colors"
>
Cancel
{t("actions.cancel")}
</button>
<button
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"
>
<ArrowUpCircle className="h-3.5 w-3.5" />
Update selected
{t("settings.optimizations.updateSelected")}
</button>
</div>
</div>
+11 -8
View File
@@ -1,6 +1,7 @@
"use client"
import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText, SettingsIcon, Terminal } from "lucide-react"
import { useT } from "../lib/i18n/provider"
const menuItems = [
{ name: "Overview", href: "/", icon: LayoutDashboard },
@@ -14,6 +15,8 @@ const menuItems = [
]
const Sidebar = ({ currentPath, setOpen }) => {
const t = useT()
const handleNavigation = (tabName: string) => {
// Dispatch custom event to change tab in dashboard
const event = new CustomEvent("changeTab", { detail: { tab: tabName } })
@@ -32,7 +35,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<LayoutDashboard className="h-5 w-5" />
<span>Overview</span>
<span>{t("navigation.overview")}</span>
</button>
<button
@@ -44,7 +47,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<HardDrive className="h-5 w-5" />
<span>Storage</span>
<span>{t("navigation.storage")}</span>
</button>
<button
@@ -56,7 +59,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<Network className="h-5 w-5" />
<span>Network</span>
<span>{t("navigation.network")}</span>
</button>
<button
@@ -68,7 +71,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<Server className="h-5 w-5" />
<span>VMs & LXCs</span>
<span>{t("navigation.virtualMachines")}</span>
</button>
<button
@@ -80,7 +83,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<Cpu className="h-5 w-5" />
<span>Hardware</span>
<span>{t("navigation.hardware")}</span>
</button>
<button
@@ -92,7 +95,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<FileText className="h-5 w-5" />
<span>System Logs</span>
<span>{t("navigation.systemLogs")}</span>
</button>
<button
@@ -104,7 +107,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<Terminal className="h-5 w-5" />
<span>Terminal</span>
<span>{t("navigation.terminal")}</span>
</button>
<button
@@ -116,7 +119,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<SettingsIcon className="h-5 w-5" />
<span>Settings</span>
<span>{t("navigation.settings")}</span>
</button>
</div>
)
File diff suppressed because it is too large Load Diff
+130 -122
View File
@@ -29,6 +29,7 @@ import {
} from "lucide-react"
import { useState, useEffect, useMemo } from "react"
import { API_PORT, fetchApi, getApiUrl, getAuthToken } from "@/lib/api-config"
import { useT } from "@/lib/i18n/provider"
interface Backup {
volid: string
@@ -88,6 +89,7 @@ interface CombinedLogEntry {
}
export function SystemLogs() {
const t = useT()
const [logs, setLogs] = useState<SystemLog[]>([])
const [backups, setBackups] = useState<Backup[]>([])
const [events, setEvents] = useState<Event[]>([])
@@ -150,7 +152,7 @@ export function SystemLogs() {
setLogsCounts(countsRes)
} catch (err) {
if (cancelled) return
setError("Failed to connect to server")
setError(t("systemLogs.errors.connectFailed"))
} finally {
if (!cancelled) setLoading(false)
}
@@ -177,7 +179,7 @@ export function SystemLogs() {
const data = await fetchApi<{ logs?: SystemLog[] } | SystemLog[]>(apiUrl)
return Array.isArray(data) ? data : data.logs || []
} catch {
setError("Failed to load logs. Please try again.")
setError(t("systemLogs.errors.loadFailed"))
return []
}
}
@@ -203,26 +205,26 @@ export function SystemLogs() {
// Generate log content
const logContent = [
`Proxmox System Logs & Events Export`,
`Generated: ${new Date().toISOString()}`,
`Total Entries: ${filteredCombinedLogs.length.toLocaleString()}`,
t("systemLogs.export.title"),
`${t("systemLogs.fields.generated")}: ${new Date().toISOString()}`,
`${t("systemLogs.cards.totalEntries")}: ${filteredCombinedLogs.length.toLocaleString()}`,
``,
`Filters Applied:`,
`- Date Range: ${dateFilter === "custom" ? `${customDays} days ago` : `${dateFilter} day(s) ago`}`,
`- Level: ${levelFilter === "all" ? "All Levels" : levelFilter}`,
`- Service: ${serviceFilter === "all" ? "All Services" : serviceFilter}`,
`- Search: ${searchTerm || "None"}`,
`${t("systemLogs.export.filtersApplied")}:`,
`- ${t("systemLogs.filters.dateRange")}: ${t("systemLogs.filters.daysAgo", { count: dateFilter === "custom" ? customDays : dateFilter })}`,
`- ${t("systemLogs.fields.level")}: ${levelFilter === "all" ? t("systemLogs.filters.allLevels") : levelLabel(levelFilter)}`,
`- ${t("systemLogs.fields.service")}: ${serviceFilter === "all" ? t("systemLogs.filters.allServices") : serviceFilter}`,
`- ${t("systemLogs.fields.search")}: ${searchTerm || t("systemLogs.fields.none")}`,
``,
`${"=".repeat(80)}`,
``,
...filteredCombinedLogs.map((log) => {
const lines = [
`[${log.timestamp}] ${log.level.toUpperCase()} - ${log.service}${log.isEvent ? " [EVENT]" : ""}`,
`Message: ${log.message}`,
`Source: ${log.source}`,
`[${log.timestamp}] ${levelLabel(log.level)} - ${log.service}${log.isEvent ? ` [${t("systemLogs.badges.event")}]` : ""}`,
`${t("systemLogs.fields.message")}: ${log.message}`,
`${t("systemLogs.fields.source")}: ${log.source}`,
]
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)}`)
return lines.join("\n")
}),
@@ -273,13 +275,13 @@ export function SystemLogs() {
// Download the complete task log
const blob = new Blob(
[
`Proxmox Task Log\n`,
`${t("systemLogs.download.taskLog")}\n`,
`================\n\n`,
`UPID: ${upid}\n`,
`Timestamp: ${notification.timestamp}\n`,
`Service: ${notification.service}\n`,
`Source: ${notification.source}\n\n`,
`Complete Task Log:\n`,
`${t("systemLogs.fields.timestamp")}: ${notification.timestamp}\n`,
`${t("systemLogs.fields.service")}: ${notification.service}\n`,
`${t("systemLogs.fields.source")}: ${notification.source}\n\n`,
`${t("systemLogs.download.completeTaskLog")}:\n`,
`${"-".repeat(80)}\n`,
`${taskLog}\n`,
],
@@ -303,13 +305,13 @@ export function SystemLogs() {
// If no UPID or failed to fetch task log, download the notification message
const blob = new Blob(
[
`Notification Details\n`,
`${t("systemLogs.modals.notificationTitle")}\n`,
`==================\n\n`,
`Timestamp: ${notification.timestamp}\n`,
`Type: ${notification.type}\n`,
`Service: ${notification.service}\n`,
`Source: ${notification.source}\n\n`,
`Complete Message:\n`,
`${t("systemLogs.fields.timestamp")}: ${notification.timestamp}\n`,
`${t("systemLogs.fields.type")}: ${notification.type}\n`,
`${t("systemLogs.fields.service")}: ${notification.service}\n`,
`${t("systemLogs.fields.source")}: ${notification.source}\n\n`,
`${t("systemLogs.download.completeMessage")}:\n`,
`${notification.message}\n`,
],
{ type: "text/plain" },
@@ -342,7 +344,7 @@ export function SystemLogs() {
level: event.level,
service: event.type,
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,
eventData: event,
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) => {
switch (level) {
case "error":
@@ -551,15 +559,15 @@ export function SystemLogs() {
const getSectionLabel = (section: string) => {
switch (section) {
case "logs":
return "Logs"
return t("systemLogs.tabs.logs")
case "events":
return "Events"
return t("systemLogs.tabs.events")
case "backups":
return "Backups"
return t("systemLogs.tabs.backups")
case "notifications":
return "Notifications"
return t("systemLogs.tabs.notifications")
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="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
</div>
<div className="text-sm font-medium text-foreground">Loading logs...</div>
<p className="text-xs text-muted-foreground">Fetching system logs and events</p>
<div className="text-sm font-medium text-foreground">{t("systemLogs.loading.title")}</div>
<p className="text-xs text-muted-foreground">{t("systemLogs.loading.description")}</p>
</div>
)
}
@@ -585,7 +593,7 @@ export function SystemLogs() {
<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>
<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>
)}
@@ -594,42 +602,42 @@ export function SystemLogs() {
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4 xl:gap-6">
<Card className="bg-card border-border">
<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" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-foreground">
{(logsCounts?.total ?? 0).toLocaleString("fr-FR")}
</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>
</Card>
<Card className="bg-card border-border">
<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" />
</CardHeader>
<CardContent>
<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>
</Card>
<Card className="bg-card border-border">
<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" />
</CardHeader>
<CardContent>
<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>
</Card>
<Card className="bg-card border-border">
<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" />
</CardHeader>
<CardContent>
@@ -645,11 +653,11 @@ export function SystemLogs() {
<div className="flex items-center justify-between">
<CardTitle className="text-foreground flex items-center">
<Activity className="h-5 w-5 mr-2" />
System Logs & Events
{t("systemLogs.title")}
</CardTitle>
<Button variant="outline" size="sm" onClick={refreshData} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />
Refresh
{t("actions.refresh")}
</Button>
</div>
</CardHeader>
@@ -658,18 +666,18 @@ export function SystemLogs() {
<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">
<Terminal className="h-4 w-4 mr-2" />
Logs
{t("systemLogs.tabs.logs")}
</TabsTrigger>
<TabsTrigger value="backups" className="data-[state=active]:bg-blue-500 data-[state=active]:text-white">
<Database className="h-4 w-4 mr-2" />
Backups
{t("systemLogs.tabs.backups")}
</TabsTrigger>
<TabsTrigger
value="notifications"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white"
>
<Bell className="h-4 w-4 mr-2" />
Notifications
{t("systemLogs.tabs.notifications")}
</TabsTrigger>
</TabsList>
@@ -691,7 +699,7 @@ export function SystemLogs() {
</SheetTrigger>
<SheetContent side="left" className="w-[280px]">
<SheetHeader>
<SheetTitle>Sections</SheetTitle>
<SheetTitle>{t("systemLogs.sections")}</SheetTitle>
</SheetHeader>
<div className="mt-6 space-y-2">
<Button
@@ -707,7 +715,7 @@ export function SystemLogs() {
}}
>
<Terminal className="h-4 w-4" />
Logs
{t("systemLogs.tabs.logs")}
</Button>
<Button
variant="ghost"
@@ -722,7 +730,7 @@ export function SystemLogs() {
}}
>
<Database className="h-4 w-4" />
Backups
{t("systemLogs.tabs.backups")}
</Button>
<Button
variant="ghost"
@@ -737,7 +745,7 @@ export function SystemLogs() {
}}
>
<Bell className="h-4 w-4" />
Notifications
{t("systemLogs.tabs.notifications")}
</Button>
</div>
</SheetContent>
@@ -751,7 +759,7 @@ export function SystemLogs() {
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search logs & events..."
placeholder={t("systemLogs.filters.searchPlaceholder")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 bg-background border-border"
@@ -761,22 +769,22 @@ export function SystemLogs() {
<Select value={dateFilter} onValueChange={setDateFilter}>
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
<SelectValue placeholder="Time range" />
<SelectValue placeholder={t("systemLogs.filters.timeRange")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 day ago</SelectItem>
<SelectItem value="3">3 days ago</SelectItem>
<SelectItem value="7">1 week ago</SelectItem>
<SelectItem value="14">2 weeks ago</SelectItem>
<SelectItem value="30">1 month ago</SelectItem>
<SelectItem value="custom">Custom days</SelectItem>
<SelectItem value="1">{t("systemLogs.filters.oneDay")}</SelectItem>
<SelectItem value="3">{t("systemLogs.filters.threeDays")}</SelectItem>
<SelectItem value="7">{t("systemLogs.filters.oneWeek")}</SelectItem>
<SelectItem value="14">{t("systemLogs.filters.twoWeeks")}</SelectItem>
<SelectItem value="30">{t("systemLogs.filters.oneMonth")}</SelectItem>
<SelectItem value="custom">{t("systemLogs.filters.customDays")}</SelectItem>
</SelectContent>
</Select>
{dateFilter === "custom" && (
<Input
type="number"
placeholder="Days ago"
placeholder={t("systemLogs.filters.daysAgoPlaceholder")}
value={customDays}
onChange={(e) => setCustomDays(e.target.value)}
className="w-full sm:w-[120px] bg-background border-border"
@@ -786,23 +794,23 @@ export function SystemLogs() {
<Select value={levelFilter} onValueChange={setLevelFilter}>
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
<SelectValue placeholder="Filter by level" />
<SelectValue placeholder={t("systemLogs.filters.byLevel")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Levels</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="warning">Warning</SelectItem>
<SelectItem value="info">Info</SelectItem>
<SelectItem value="all">{t("systemLogs.filters.allLevels")}</SelectItem>
<SelectItem value="error">{t("systemLogs.levels.error")}</SelectItem>
<SelectItem value="warning">{t("systemLogs.levels.warning")}</SelectItem>
<SelectItem value="info">{t("systemLogs.levels.info")}</SelectItem>
</SelectContent>
</Select>
<Select value={serviceFilter} onValueChange={setServiceFilter}>
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
<SelectValue placeholder="Filter by service" />
<SelectValue placeholder={t("systemLogs.filters.byService")} />
</SelectTrigger>
<SelectContent>
<SelectItem key="service-all" value="all">
All Services
{t("systemLogs.filters.allServices")}
</SelectItem>
{uniqueServices.map((service) => (
<SelectItem key={`service-${service}`} value={service}>
@@ -814,7 +822,7 @@ export function SystemLogs() {
<Button variant="outline" className="border-border bg-transparent" onClick={handleDownloadLogs}>
<Download className="h-4 w-4 mr-2" />
Export Logs
{t("systemLogs.export.button")}
</Button>
</div>
@@ -844,12 +852,12 @@ export function SystemLogs() {
<div className="flex-shrink-0 flex gap-2 flex-wrap">
<Badge variant="outline" className={getLevelColor(log.level)}>
{getLevelIcon(log.level)}
{log.level.toUpperCase()}
{levelLabel(log.level)}
</Badge>
{log.eventData && (
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
<Activity className="h-3 w-3 mr-1" />
EVENT
{t("systemLogs.badges.event")}
</Badge>
)}
</div>
@@ -866,9 +874,9 @@ export function SystemLogs() {
</div>
<div className="text-xs text-muted-foreground truncate overflow-hidden">
{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.hostname && `Host: ${log.hostname}`}
{log.hostname && `${t("systemLogs.fields.host")}: ${log.hostname}`}
</div>
</div>
</div>
@@ -878,7 +886,7 @@ export function SystemLogs() {
{displayedLogs.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<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>
)}
@@ -890,7 +898,7 @@ export function SystemLogs() {
className="border-border"
>
<RefreshCw className="h-4 w-4 mr-2" />
Load More ({filteredCombinedLogs.length - displayedLogsCount} remaining)
{t("systemLogs.loadMore", { count: filteredCombinedLogs.length - displayedLogsCount })}
</Button>
</div>
)}
@@ -906,19 +914,19 @@ export function SystemLogs() {
<Card className="bg-card/50 border-border">
<CardContent className="pt-6">
<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>
</Card>
<Card className="bg-card/50 border-border">
<CardContent className="pt-6">
<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>
</Card>
<Card className="bg-card/50 border-border hidden md:block">
<CardContent className="pt-6">
<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>
</Card>
</div>
@@ -927,7 +935,7 @@ export function SystemLogs() {
<Card className="bg-card/50 border-border md:hidden">
<CardContent className="pt-6">
<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>
</Card>
</div>
@@ -967,7 +975,7 @@ export function SystemLogs() {
{backup.size_human}
</Badge>
</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">
<Calendar className="h-3 w-3 mr-1 flex-shrink-0" />
<span className="truncate">{backup.created}</span>
@@ -980,7 +988,7 @@ export function SystemLogs() {
{backups.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<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>
@@ -1006,12 +1014,12 @@ export function SystemLogs() {
>
<div className="flex-shrink-0 flex gap-2 flex-wrap">
<Badge variant="outline" className={getNotificationTypeColor(notification.type)}>
{(notification.type || "unknown").toUpperCase()}
{notification.type ? levelLabel(notification.type) : t("app.unknown")}
</Badge>
<Badge variant="outline" className={getNotificationSourceColor(notification.source)}>
{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 || "unknown").toUpperCase()}
{notification.source ? notification.source.toUpperCase() : t("app.unknown")}
</Badge>
</div>
@@ -1026,7 +1034,7 @@ export function SystemLogs() {
{notification.message}
</div>
<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>
@@ -1036,7 +1044,7 @@ export function SystemLogs() {
{notifications.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<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>
@@ -1051,55 +1059,55 @@ export function SystemLogs() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Log Details
{t("systemLogs.modals.logTitle")}
</DialogTitle>
<DialogDescription>Complete information about this log entry</DialogDescription>
<DialogDescription>{t("systemLogs.modals.logDescription")}</DialogDescription>
</DialogHeader>
{selectedLog && (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<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)}>
{getLevelIcon(selectedLog.level)}
{selectedLog.level.toUpperCase()}
{levelLabel(selectedLog.level)}
</Badge>
</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>
<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">
{selectedLog.timestamp}
</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>
{selectedLog.unit && (
<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>
)}
{selectedLog.pid && (
<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>
)}
{selectedLog.hostname && (
<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>
)}
</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">
<pre className="text-sm text-foreground whitespace-pre-wrap break-all overflow-hidden">
{selectedLog.message}
@@ -1116,37 +1124,37 @@ export function SystemLogs() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
Event Details
{t("systemLogs.modals.eventTitle")}
</DialogTitle>
<DialogDescription>Complete information about this event</DialogDescription>
<DialogDescription>{t("systemLogs.modals.eventDescription")}</DialogDescription>
</DialogHeader>
{selectedEvent && (
<div className="space-y-4">
<div className="flex gap-2">
<Badge variant="outline" className={getLevelColor(selectedEvent.level)}>
{getLevelIcon(selectedEvent.level)}
{selectedEvent.level.toUpperCase()}
{levelLabel(selectedEvent.level)}
</Badge>
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
<Activity className="h-3 w-3 mr-1" />
EVENT
{t("systemLogs.badges.event")}
</Badge>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<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>
<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>
<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>
<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>
{selectedEvent.vmid && (
@@ -1156,15 +1164,15 @@ export function SystemLogs() {
</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>
<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>
<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>
</div>
@@ -1186,31 +1194,31 @@ export function SystemLogs() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Backup Details
{t("systemLogs.modals.backupTitle")}
</DialogTitle>
<DialogDescription>Complete information about this backup</DialogDescription>
<DialogDescription>{t("systemLogs.modals.backupDescription")}</DialogDescription>
</DialogHeader>
{selectedBackup && (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<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)}>
{getBackupTypeLabel(selectedBackup.volid)}
</Badge>
</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)}>
{getBackupStorageLabel(selectedBackup.volid)}
</Badge>
</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>
<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">
{selectedBackup.size_human}
</Badge>
@@ -1222,12 +1230,12 @@ export function SystemLogs() {
</div>
)}
<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>
</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">
<pre className="text-sm text-foreground font-mono whitespace-pre-wrap break-all">
{selectedBackup.volid}
@@ -1244,38 +1252,38 @@ export function SystemLogs() {
<DialogHeader>
<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" />
<span className="truncate">Notification Details</span>
<span className="truncate">{t("systemLogs.modals.notificationTitle")}</span>
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
Complete information about this notification
{t("systemLogs.modals.notificationDescription")}
</DialogDescription>
</DialogHeader>
{selectedNotification && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
<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`}>
{(selectedNotification.type || "unknown").toUpperCase()}
{selectedNotification.type ? levelLabel(selectedNotification.type) : t("app.unknown")}
</Badge>
</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">
{selectedNotification.timestamp}
</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>
<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>
</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">
<pre className="text-xs sm:text-sm text-foreground whitespace-pre-wrap break-all font-mono">
{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"
>
<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>
</div>
</div>
+91 -96
View File
@@ -13,6 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
import { fetchApi } from "../lib/api-config"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { formatStorage } from "../lib/utils"
import { useT } from "../lib/i18n/provider"
import { Area, AreaChart, ResponsiveContainer } from "recharts"
interface TempDataPoint {
@@ -171,6 +172,7 @@ const getUnitsSettings = (): "Bytes" | "Bits" => {
}
export function SystemOverview() {
const t = useT()
const [systemData, setSystemData] = useState<SystemData | null>(null)
const [vmData, setVmData] = useState<VMData[]>([])
const [storageData, setStorageData] = useState<StorageData | null>(null)
@@ -205,7 +207,7 @@ export function SystemOverview() {
setHasAttemptedLoad(true)
if (!systemResult) {
setError("Flask server not available. Please ensure the server is running.")
setError(t("overview.errors.serverUnavailableDescription"))
return
}
@@ -261,7 +263,7 @@ export function SystemOverview() {
clearInterval(networkInterval)
window.removeEventListener("networkUnitChanged" as any, handleUnitChange)
}
}, [])
}, [t])
if (!hasAttemptedLoad || loadingStates.system) {
return (
@@ -270,8 +272,8 @@ export function SystemOverview() {
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
</div>
<div className="text-sm font-medium text-foreground">Loading system overview...</div>
<p className="text-xs text-muted-foreground">Fetching system status and metrics</p>
<div className="text-sm font-medium text-foreground">{t("overview.loadingTitle")}</div>
<p className="text-xs text-muted-foreground">{t("overview.loadingDescription")}</p>
</div>
)
}
@@ -284,9 +286,9 @@ export function SystemOverview() {
<div className="flex items-center gap-3 text-red-600">
<AlertCircle className="h-6 w-6" />
<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">
{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>
@@ -305,14 +307,14 @@ export function SystemOverview() {
}
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 < 60) return { 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" }
return { status: "Hot", color: "bg-red-500/10 text-red-500 border-red-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: t("status.normal"), color: "bg-green-500/10 text-green-500 border-green-500/20" }
if (temp < 75) return { status: t("status.warm"), color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
return { status: t("status.hot"), color: "bg-red-500/10 text-red-500 border-red-500/20" }
}
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 hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
@@ -322,6 +324,19 @@ export function SystemOverview() {
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) => {
return (bytes / 1024 ** 3).toFixed(2)
}
@@ -346,40 +361,14 @@ export function SystemOverview() {
const getLoadStatus = (load: number, cores: number) => {
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) {
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 {
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 getTimeframeLabel = (timeframe: string): string => {
@@ -406,10 +395,10 @@ export function SystemOverview() {
<Card
className="bg-card border-border cursor-pointer hover:bg-white/5 transition-colors"
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">
<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">
<Cpu className="h-4 w-4" />
<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>
<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>
</div>
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
@@ -436,7 +425,7 @@ export function SystemOverview() {
</div>
<div>
<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>
</div>
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
@@ -444,7 +433,7 @@ export function SystemOverview() {
</div>
</div>
<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>
</div>
</div>
@@ -456,10 +445,10 @@ export function SystemOverview() {
<Card
className="bg-card border-border cursor-pointer hover:bg-white/5 transition-colors"
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">
<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">
<MemoryStick className="h-4 w-4" />
<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>
<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>
</div>
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
@@ -486,7 +475,7 @@ export function SystemOverview() {
</div>
<div>
<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>
</div>
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
@@ -494,7 +483,7 @@ export function SystemOverview() {
</div>
</div>
<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>
</div>
</div>
@@ -505,7 +494,7 @@ export function SystemOverview() {
{/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */}
<Card className="bg-card border-border">
<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" />
</CardHeader>
<CardContent>
@@ -521,13 +510,19 @@ export function SystemOverview() {
<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>
</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 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>
{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>
</>
@@ -540,7 +535,7 @@ export function SystemOverview() {
onClick={() => systemData.temperature > 0 && setTempModalOpen(true)}
>
<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">
<Thermometer className="h-4 w-4" />
{systemData.temperature > 0 && (
@@ -551,7 +546,7 @@ export function SystemOverview() {
<CardContent>
<div className="flex items-center justify-between">
<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>
<Badge variant="outline" className={`${tempStatus.color}`}>
{tempStatus.status}
@@ -581,7 +576,7 @@ export function SystemOverview() {
</div>
) : (
<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>
)}
</CardContent>
@@ -613,7 +608,7 @@ export function SystemOverview() {
<CardHeader>
<CardTitle className="text-foreground flex items-center">
<HardDrive className="h-5 w-5 mr-2" />
Storage Overview
{t("overview.storageOverview")}
</CardTitle>
</CardHeader>
<CardContent>
@@ -634,7 +629,7 @@ export function SystemOverview() {
return totalCapacity > 0 ? (
<div className="space-y-2 pb-4 border-b-2 border-border">
<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">
{formatStorage(totalCapacity)}
</span>
@@ -646,13 +641,13 @@ export function SystemOverview() {
<div className="flex justify-between items-center mt-1">
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">
Used:{" "}
{t("overview.used")}:{" "}
<span className="font-semibold text-foreground">
{formatStorage(totalUsed)}
</span>
</span>
<span className="text-xs text-muted-foreground">
Free:{" "}
{t("overview.free")}:{" "}
<span className="font-semibold text-green-500">
{formatStorage(totalAvailable)}
</span>
@@ -666,28 +661,28 @@ export function SystemOverview() {
<div className="space-y-2 pb-3 border-b border-border">
<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>
</div>
<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">
{storageData.disk_count} disk{storageData.disk_count !== 1 ? "s" : ""}
{storageData.disk_count} {storageData.disk_count === 1 ? t("overview.diskSingular") : t("overview.diskPlural")}
</span>
</div>
</div>
{vmLxcStorages && vmLxcStorages.length > 0 ? (
<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">
<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">
{formatStorage(vmLxcStorageUsed)}
</span>
</div>
<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">
{formatStorage(vmLxcStorageAvailable)}
</span>
@@ -702,28 +697,28 @@ export function SystemOverview() {
</div>
{vmLxcStorages.length > 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 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-center py-4 text-muted-foreground text-sm">No VM/LXC storage configured</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">{t("overview.noVmLxcStorage")}</div>
</div>
)}
{localStorage && (
<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">
<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">
{formatStorage(localStorage.used)}
</span>
</div>
<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">
{formatStorage(localStorage.available)}
</span>
@@ -740,7 +735,7 @@ export function SystemOverview() {
)}
</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>
</Card>
@@ -750,18 +745,18 @@ export function SystemOverview() {
<CardTitle className="text-foreground flex items-center justify-between">
<div className="flex items-center">
<Network className="h-5 w-5 mr-2" />
Network Overview
{t("overview.networkOverview")}
</div>
<Select value={networkTimeframe} onValueChange={setNetworkTimeframe}>
<SelectTrigger className="w-28 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hour">1 Hour</SelectItem>
<SelectItem value="day">24 Hours</SelectItem>
<SelectItem value="week">7 Days</SelectItem>
<SelectItem value="month">30 Days</SelectItem>
<SelectItem value="year">1 Year</SelectItem>
<SelectItem value="hour">{t("overview.timeframes.hour")}</SelectItem>
<SelectItem value="day">{t("overview.timeframes.day")}</SelectItem>
<SelectItem value="week">{t("overview.timeframes.week")}</SelectItem>
<SelectItem value="month">{t("overview.timeframes.month")}</SelectItem>
<SelectItem value="year">{t("overview.timeframes.year")}</SelectItem>
</SelectContent>
</Select>
</CardTitle>
@@ -776,7 +771,7 @@ export function SystemOverview() {
) : networkData ? (
<div className="space-y-4">
<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">
{(networkData.physical_active_count || 0) + (networkData.bridge_active_count || 0)}
</span>
@@ -818,7 +813,7 @@ export function SystemOverview() {
<div className="pt-2 border-t border-border space-y-2">
<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">
{" "}
{networkUnit === "Bytes"
@@ -828,7 +823,7 @@ export function SystemOverview() {
</span>
</div>
<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">
{" "}
{networkUnit === "Bytes"
@@ -848,7 +843,7 @@ export function SystemOverview() {
</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>
</Card>
@@ -859,27 +854,27 @@ export function SystemOverview() {
<CardHeader>
<CardTitle className="text-foreground flex items-center">
<Server className="h-5 w-5 mr-2" />
System Information
{t("overview.systemInformation")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between">
<span className="text-muted-foreground">Uptime:</span>
<span className="text-foreground">{systemData.uptime}</span>
<span className="text-muted-foreground">{t("overview.uptime")}</span>
<span className="text-foreground">{formatSystemUptime(systemData.uptime)}</span>
</div>
<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>
</div>
<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>
</div>
{systemData.available_updates !== undefined && systemData.available_updates > 0 && (
<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">
{systemData.available_updates} packages
{systemData.available_updates} {t("overview.packages")}
</Badge>
</div>
)}
@@ -890,13 +885,13 @@ export function SystemOverview() {
<CardHeader>
<CardTitle className="text-foreground flex items-center">
<Zap className="h-5 w-5 mr-2" />
System Overview
{t("overview.systemOverview")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between items-center pb-3 border-b border-border">
<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 className="flex items-center gap-2">
<span className="text-lg font-semibold text-foreground font-mono">
@@ -909,17 +904,17 @@ export function SystemOverview() {
</div>
<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>
</div>
<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>
</div>
<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">
{networkData?.physical_total_count || networkData?.physical_interfaces?.length || "N/A"}
</span>
@@ -8,12 +8,13 @@ import { Thermometer, TrendingDown, TrendingUp, Minus } from "lucide-react"
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
import { useIsMobile } from "../hooks/use-mobile"
import { fetchApi } from "@/lib/api-config"
import { useT } from "@/lib/i18n/provider"
const TIMEFRAME_OPTIONS = [
{ value: "hour", label: "1 Hour" },
{ value: "day", label: "24 Hours" },
{ value: "week", label: "7 Days" },
{ value: "month", label: "30 Days" },
{ value: "hour", labelKey: "overview.timeframes.hour" },
{ value: "day", labelKey: "overview.timeframes.day" },
{ value: "week", labelKey: "overview.timeframes.week" },
{ value: "month", labelKey: "overview.timeframes.month" },
]
interface TempHistoryPoint {
@@ -70,6 +71,7 @@ const getStatusInfo = (temp: number) => {
}
export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }: TemperatureDetailModalProps) {
const t = useT()
// Default to 24 h — matches the disk temperature modal and is the
// useful timeframe for spotting trends; the 1-h view rarely tells
// 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">
<DialogTitle className="text-foreground flex items-center gap-2">
<Thermometer className="h-5 w-5" />
CPU Temperature
{t("details.temperature.title")}
</DialogTitle>
<Select value={timeframe} onValueChange={setTimeframe}>
<SelectTrigger className="w-[130px] bg-card border-border">
@@ -147,7 +149,7 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
<SelectContent>
{TIMEFRAME_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
{t(opt.labelKey)}
</SelectItem>
))}
</SelectContent>
@@ -158,24 +160,24 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
{/* Stats bar */}
<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="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>
<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">
<TrendingDown className="h-3 w-3" /> Min
<TrendingDown className="h-3 w-3" /> {t("details.temperature.min")}
</div>
<div className="text-lg font-bold text-green-500">{stats.min}°C</div>
</div>
<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">
<Minus className="h-3 w-3" /> Avg
<Minus className="h-3 w-3" /> {t("details.temperature.avg")}
</div>
<div className="text-lg font-bold text-foreground">{stats.avg}°C</div>
</div>
<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">
<TrendingUp className="h-3 w-3" /> Max
<TrendingUp className="h-3 w-3" /> {t("details.temperature.max")}
</div>
<div className="text-lg font-bold text-red-500">{stats.max}°C</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="text-center">
<Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No temperature data available for this period</p>
<p className="text-sm mt-1">Data is collected every 60 seconds</p>
<p>{t("details.temperature.noData")}</p>
<p className="text-sm mt-1">{t("details.temperature.collectionHint")}</p>
</div>
</div>
) : (
@@ -228,7 +230,7 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
<Area
type="monotone"
dataKey="value"
name="Temperature"
name={t("details.temperature.seriesName")}
stroke={chartColor}
strokeWidth={2}
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 { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import type { CheatSheetResult } from "@/lib/cheat-sheet-result" // Declare CheatSheetResult here
import { useT } from "@/lib/i18n/provider"
type TerminalPanelProps = {
websocketUrl?: string
@@ -78,74 +79,30 @@ function getApiUrl(endpoint?: string): string {
}
const proxmoxCommands = [
{ cmd: "pvesh get /nodes", desc: "List all Proxmox nodes" },
{ cmd: "pvesh get /nodes/{node}/qemu", desc: "List VMs on a node" },
{ cmd: "pvesh get /nodes/{node}/lxc", desc: "List LXC containers on a node" },
{ cmd: "pvesh get /nodes/{node}/storage", desc: "List storage on a node" },
{ cmd: "pvesh get /nodes/{node}/network", desc: "List network interfaces" },
{ cmd: "qm list", desc: "List all QEMU/KVM virtual machines" },
{ cmd: "qm start <vmid>", desc: "Start a virtual machine" },
{ cmd: "qm stop <vmid>", desc: "Stop a virtual machine" },
{ cmd: "qm shutdown <vmid>", desc: "Shutdown a virtual machine gracefully" },
{ cmd: "qm status <vmid>", desc: "Show VM status" },
{ cmd: "qm config <vmid>", desc: "Show VM configuration" },
{ cmd: "qm snapshot <vmid> <snapname>", desc: "Create VM snapshot" },
{ cmd: "pct list", desc: "List all LXC containers" },
{ cmd: "pct start <vmid>", desc: "Start LXC container" },
{ cmd: "pct stop <vmid>", desc: "Stop LXC container" },
{ cmd: "pct enter <vmid>", desc: "Enter LXC container console" },
{ 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" },
"pvesh get /nodes", "pvesh get /nodes/{node}/qemu", "pvesh get /nodes/{node}/lxc",
"pvesh get /nodes/{node}/storage", "pvesh get /nodes/{node}/network", "qm list",
"qm start <vmid>", "qm stop <vmid>", "qm shutdown <vmid>", "qm status <vmid>",
"qm config <vmid>", "qm snapshot <vmid> <snapname>", "pct list", "pct start <vmid>",
"pct stop <vmid>", "pct enter <vmid>", "pct config <vmid>", "pvesm status",
"pvesm list <storage>", "pveperf", "pveversion", "systemctl status pve-cluster",
"pvecm status", "pvecm nodes", "zpool status", "zpool list", "zfs list", "ls -la",
"cd /path/to/dir", "mkdir dirname", "rm -rf dirname", "cp source dest", "mv source dest",
"cat filename", "grep 'pattern' file", "find . -name 'file'", "chmod 755 file",
"chown user:group file", "tar -xzf file.tar.gz", "tar -czf archive.tar.gz dir/", "df -h",
"du -sh *", "free -h", "top", "ps aux | grep process", "kill -9 PID",
"systemctl status service", "systemctl start service", "systemctl stop service",
"systemctl restart service", "apt update && apt upgrade", "apt install package",
"apt remove package", "docker ps", "docker images", "docker exec -it container bash",
"ip addr show", "ping host", "curl -I url", "wget url", "ssh user@host",
"scp file user@host:/path", "tail -f /var/log/syslog", "history", "clear",
]
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 [activeTerminalId, setActiveTerminalId] = useState<string>("")
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 [searchModalOpen, setSearchModalOpen] = useState(false)
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 [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
const [useOnline, setUseOnline] = useState(true)
@@ -272,7 +229,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
const searchCheatSh = async (query: string) => {
if (!query.trim()) {
setSearchResults([])
setFilteredCommands(proxmoxCommands)
setFilteredCommands(localizedCommands)
return
}
@@ -287,7 +244,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
})
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)
setSearchResults(formattedResults)
} catch (error) {
const filtered = proxmoxCommands.filter(
const filtered = localizedCommands.filter(
(item) =>
item.cmd.toLowerCase().includes(query.toLowerCase()) ||
item.desc.toLowerCase().includes(query.toLowerCase()),
@@ -318,12 +275,12 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
searchCheatSh(searchQuery)
} else {
setSearchResults([])
setFilteredCommands(proxmoxCommands)
setFilteredCommands(localizedCommands)
}
}, 800)
return () => clearTimeout(debounce)
}, [searchQuery])
}, [searchQuery, t])
// Function to reconnect a terminal when connection is lost
// 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
// 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()
// 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) =>
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
if (terminal.fitAddon) {
@@ -384,7 +341,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
}
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 = () => {
@@ -397,7 +354,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
}
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) => {
@@ -415,7 +372,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
...prev,
{
id: newId,
title: `Terminal ${prev.length + 1}`,
title: t("terminal.terminalTitle", { number: prev.length + 1 }),
term: null,
ws: null,
isConnected: false,
@@ -570,8 +527,8 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
if (ws.readyState !== WebSocket.OPEN) {
connectionTimedOut = true
ws.close()
term.writeln('\x1b[31m[ERROR] Connection timeout. Please check your network and try again.\x1b[0m')
term.writeln('\x1b[33m[TIP] If using VPN, ensure the connection is stable.\x1b[0m')
term.writeln(`\x1b[31m[ERROR] ${t("terminal.connectionTimeout")}\x1b[0m`)
term.writeln(`\x1b[33m[TIP] ${t("terminal.vpnTip")}\x1b[0m`)
}
}, connectionTimeout)
@@ -636,7 +593,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
}))
// Only show error if not already shown by timeout
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
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" />
<div
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>
<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 className="flex gap-2">
@@ -829,7 +786,7 @@ const handleClose = () => {
variant="outline"
size="sm"
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" />
</Button>
@@ -838,7 +795,7 @@ const handleClose = () => {
variant="outline"
size="sm"
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" />
</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"
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">New</span>
<span className="hidden sm:inline">{t("terminal.new")}</span>
</Button>
<Button
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"
>
<Search className="h-4 w-4" />
<span className="hidden sm:inline">Search</span>
<span className="hidden sm:inline">{t("terminal.search")}</span>
</Button>
<Button
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"
>
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">Clear</span>
<span className="hidden sm:inline">{t("terminal.clear")}</span>
</Button>
<Button
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"
>
<X className="h-4 w-4" />
<span className="hidden sm:inline">Close</span>
<span className="hidden sm:inline">{t("actions.close")}</span>
</Button>
</div>
</div>
@@ -1075,29 +1032,29 @@ const handleClose = () => {
</Button>
</DropdownMenuTrigger>
<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 />
<DropdownMenuItem onSelect={() => sendSequence("\x03")}>
<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 onSelect={() => sendSequence("\x18")}>
<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 onSelect={() => sendSequence("\x12")}>
<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>
<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() }}>
<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 onSelect={() => { void handlePaste() }}>
<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>
</DropdownMenuContent>
</DropdownMenu>
@@ -1107,22 +1064,22 @@ const handleClose = () => {
<Dialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
<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">
<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={`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>
</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="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<Input
placeholder="Search commands... (e.g., tar, docker, qm, systemctl)"
placeholder={t("terminal.searchPlaceholder")}
value={searchQuery}
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"
@@ -1136,7 +1093,7 @@ const handleClose = () => {
{isSearching && (
<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" />
<p className="text-sm">Searching cheat.sh...</p>
<p className="text-sm">{t("terminal.searchingCheatSh")}</p>
</div>
)}
@@ -1164,7 +1121,7 @@ const handleClose = () => {
<div className="text-center py-2">
<p className="text-xs text-zinc-500">
<Lightbulb className="inline-block w-3 h-3 mr-1" />
Powered by cheat.sh
{t("terminal.poweredByCheatSh")}
</p>
</div>
</>
@@ -1190,13 +1147,13 @@ const handleClose = () => {
className="shrink-0 h-7 px-2 text-xs"
>
<Send className="h-3 w-3 mr-1" />
Send
{t("terminal.send")}
</Button>
</div>
</div>
))
) : !isSearching && !searchQuery && !useOnline ? (
proxmoxCommands.map((item, index) => (
localizedCommands.map((item, index) => (
<div
key={index}
onClick={() => sendToActiveTerminal(item.cmd)}
@@ -1217,7 +1174,7 @@ const handleClose = () => {
className="shrink-0 h-7 px-2 text-xs"
>
<Send className="h-3 w-3 mr-1" />
Send
{t("terminal.send")}
</Button>
</div>
</div>
@@ -1228,17 +1185,17 @@ const handleClose = () => {
<>
<Search className="w-12 h-12 text-zinc-600 mx-auto" />
<div>
<p className="text-zinc-400 font-medium">No results found for "{searchQuery}"</p>
<p className="text-xs text-zinc-500 mt-1">Try a different command or check your spelling</p>
<p className="text-zinc-400 font-medium">{t("terminal.noResults", { query: searchQuery })}</p>
<p className="text-xs text-zinc-500 mt-1">{t("terminal.tryDifferentSearch")}</p>
</div>
</>
) : (
<>
<Terminal className="w-12 h-12 text-zinc-600 mx-auto" />
<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">
<p>Try searching for:</p>
<p>{t("terminal.trySearchingFor")}</p>
<div className="flex flex-wrap justify-center gap-2 mt-2">
{["tar", "grep", "docker", "qm", "systemctl"].map((cmd) => (
<code
@@ -1255,7 +1212,7 @@ const handleClose = () => {
{useOnline && (
<div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4">
<Lightbulb className="w-3 h-3" />
<span>Powered by cheat.sh</span>
<span>{t("terminal.poweredByCheatSh")}</span>
</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="flex items-center gap-2">
<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>
{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>
</DialogContent>
+4 -2
View File
@@ -4,8 +4,10 @@ import { useTheme } from "next-themes"
import { useEffect, useState } from "react"
import { Button } from "./ui/button"
import { useT } from "../lib/i18n/provider"
export function ThemeToggle() {
const t = useT()
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
@@ -22,7 +24,7 @@ export function ThemeToggle() {
return (
<Button variant="outline" size="sm" className="border-border bg-transparent w-9 h-9">
<Sun className="h-4 w-4" />
<span className="sr-only">Toggle theme</span>
<span className="sr-only">{t("actions.toggleTheme")}</span>
</Button>
)
}
@@ -31,7 +33,7 @@ export function ThemeToggle() {
<Button variant="outline" size="sm" onClick={handleThemeToggle} className="border-border bg-transparent w-9 h-9">
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
<span className="sr-only">{t("actions.toggleTheme")}</span>
</Button>
)
}
+33 -32
View File
@@ -6,6 +6,7 @@ import { Input } from "./ui/input"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
import { AlertCircle, CheckCircle, Copy, Shield, Check } from "lucide-react"
import { getApiUrl } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface TwoFactorSetupProps {
open: boolean
@@ -14,6 +15,8 @@ interface 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 [qrCode, setQrCode] = useState("")
const [secret, setSecret] = useState("")
@@ -41,7 +44,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || "Failed to setup 2FA")
throw new Error(data.message || tf("setupFailed"))
}
setQrCode(data.qr_code)
@@ -49,7 +52,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
setBackupCodes(data.backup_codes)
setStep(2)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to setup 2FA")
setError(err instanceof Error ? err.message : tf("setupFailed"))
} finally {
setLoading(false)
}
@@ -57,7 +60,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
const handleVerify = async () => {
if (!verificationCode || verificationCode.length !== 6) {
setError("Please enter a 6-digit code")
setError(tf("enterSixDigitCode"))
return
}
@@ -78,12 +81,12 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || "Invalid verification code")
throw new Error(data.message || tf("invalidCode"))
}
setStep(3)
} catch (err) {
setError(err instanceof Error ? err.message : "Verification failed")
setError(err instanceof Error ? err.message : tf("verificationFailed"))
} finally {
setLoading(false)
}
@@ -141,7 +144,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
// both the Clipboard API and execCommand may be locked down.
if (!ok) {
try {
window.prompt("Copy this value:", text)
window.prompt(tf("copyPrompt"), text)
ok = true
} catch {
// ignore
@@ -183,9 +186,9 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-blue-500" />
Setup Two-Factor Authentication
{tf("title")}
</DialogTitle>
<DialogDescription>Add an extra layer of security to your account</DialogDescription>
<DialogDescription>{tf("description")}</DialogDescription>
</DialogHeader>
{error && (
@@ -199,22 +202,21 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
<div className="space-y-4">
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
<p className="text-sm text-blue-500">
Two-factor authentication (2FA) adds an extra layer of security by requiring a code from your
authentication app in addition to your password.
{tf("intro")}
</p>
</div>
<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">
<li>An authentication app (Google Authenticator, Authy, etc.)</li>
<li>Scan a QR code or enter a key manually</li>
<li>Store backup codes securely</li>
<li>{tf("needApp")}</li>
<li>{tf("needQrOrKey")}</li>
<li>{tf("needBackupCodes")}</li>
</ul>
</div>
<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>
</div>
)}
@@ -222,24 +224,24 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
{step === 2 && (
<div className="space-y-4">
<div className="space-y-2">
<h4 className="font-medium">1. Scan the QR code</h4>
<p className="text-sm text-muted-foreground">Open your authentication app and scan this QR code</p>
<h4 className="font-medium">{tf("scanTitle")}</h4>
<p className="text-sm text-muted-foreground">{tf("scanDescription")}</p>
{qrCode && (
<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 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">
<Input value={secret} readOnly className="font-mono text-sm" />
<Button
variant="outline"
size="icon"
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" />}
</Button>
@@ -247,8 +249,8 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
</div>
<div className="space-y-2">
<h4 className="font-medium">2. Enter the verification code</h4>
<p className="text-sm text-muted-foreground">Enter the 6-digit code that appears in your app</p>
<h4 className="font-medium">{tf("verifyTitle")}</h4>
<p className="text-sm text-muted-foreground">{tf("verifyDescription")}</p>
<Input
type="text"
placeholder="000000"
@@ -262,10 +264,10 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
<div className="flex gap-2">
<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 onClick={handleClose} variant="outline" className="flex-1 bg-transparent" disabled={loading}>
Cancel
{t("actions.cancel")}
</Button>
</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">
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
<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">
Your account is now protected with two-factor authentication
{tf("enabledDescription")}
</p>
</div>
</div>
<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">
These codes will allow you to access your account if you lose access to your authentication app. Store
them in a safe place.
{tf("saveCodesDescription")}
</p>
<div className="bg-muted/50 rounded-lg p-4 space-y-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")}>
{copiedCodes ? (
<Check className="h-4 w-4 text-green-500 mr-2" />
) : (
<Copy className="h-4 w-4 mr-2" />
)}
Copy All
{tf("copyAll")}
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
@@ -313,7 +314,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
</div>
<Button onClick={handleFinish} className="w-full bg-blue-500 hover:bg-blue-600">
Finish
{tf("finish")}
</Button>
</div>
)}
+27 -22
View File
@@ -4,6 +4,7 @@ import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { useT } from "@/lib/i18n/provider"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
@@ -34,28 +35,32 @@ const DialogContent = React.forwardRef<
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
hideClose?: boolean
}
>(({ className, children, hideClose, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"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",
className,
)}
aria-describedby={props["aria-describedby"] || undefined}
{...props}
>
{children}
{!hideClose && (
<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" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
))
>(({ className, children, hideClose, ...props }, ref) => {
const t = useT()
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"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",
className,
)}
aria-describedby={props["aria-describedby"] || undefined}
{...props}
>
{children}
{!hideClose && (
<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" />
<span className="sr-only">{t("actions.close")}</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
})
DialogContent.displayName = DialogPrimitive.Content.displayName
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 { X } from "lucide-react"
import { useT } from "@/lib/i18n/provider"
import { cn } from "@/lib/utils"
const Sheet = DialogPrimitive.Root
@@ -54,18 +55,22 @@ interface SheetContentProps
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Content>, SheetContentProps>(
({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{children}
<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">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</SheetPortal>
),
({ side = "right", className, children, ...props }, ref) => {
const t = useT()
return (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{children}
<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">
<X className="h-4 w-4" />
<span className="sr-only">{t("actions.close")}</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</SheetPortal>
)
},
)
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
@@ -1,6 +1,6 @@
{
"name": "ProxMenux-Monitor",
"version": "1.2.4",
"version": "1.2.4.1-beta",
"description": "Proxmox System Monitoring Dashboard",
"private": true,
"scripts": {
+8 -7
View File
@@ -653,12 +653,13 @@ def setup_auth(username, password):
Set up authentication with username and password
Returns (success: bool, message: str)
"""
# Refuse if auth has already been configured. Without this guard an
# Refuse if real credentials already exist. Without this guard an
# unauthenticated POST to /api/auth/setup would let an attacker overwrite
# the existing admin credentials and take over the account. See audit
# Tier 1 #4.
# the existing admin credentials and take over the account. A declined
# setup is marked configured but deliberately has no credentials, so it
# must remain possible to finish setup later. See audit Tier 1 #4.
existing = load_auth_config()
if existing.get("configured", False):
if existing.get("username") and existing.get("password_hash"):
return False, "Authentication is already configured"
if not username or not password:
@@ -668,7 +669,7 @@ def setup_auth(username, password):
if pw_err:
return False, pw_err
config = {
existing.update({
"enabled": True,
"username": username,
"password_hash": hash_password(password),
@@ -677,9 +678,9 @@ def setup_auth(username, password):
"totp_enabled": False,
"totp_secret": None,
"backup_codes": []
}
})
if save_auth_config(config):
if save_auth_config(existing):
return True, "Authentication configured successfully"
else:
return False, "Failed to save authentication configuration"
+631 -2
View File
@@ -79,7 +79,7 @@ from flask_notification_routes import notification_bp # noqa: E402
from flask_oci_routes import oci_bp # noqa: E402
from notification_manager import notification_manager # noqa: E402
import post_install_versions # noqa: E402 — Sprint 12A: detect post-install function updates
from jwt_middleware import require_auth, require_auth_or_ticket # noqa: E402
from jwt_middleware import require_auth, require_auth_or_ticket, require_admin_scope # noqa: E402
import auth_manager # noqa: E402
# -------------------------------------------------------------------
@@ -1477,6 +1477,479 @@ _hardware_cache = {
}
_HARDWARE_CACHE_TTL = 300 # 5 minutes - hardware doesn't change
# VM guest-agent disk usage — refreshed by a daemon thread, read by
# /api/vms and by the health monitor. Keeping the subprocess OUT of the
# hot path is what makes this whole feature affordable: /api/vms is
# polled every few seconds by the dashboard, and blocking it on a
# per-VM `qm guest cmd` (200 ms 3 s per VM depending on agent state)
# would silently regress a previously instant endpoint. The refresher
# absorbs that cost off-request and every reader gets a dict lookup.
#
# Cache stores the ALREADY-COMPUTED (used_bytes, total_bytes) tuple —
# not the raw fsinfo — so readers never parse or filter, and the
# expensive dedup / filesystem-type filtering runs at most once per
# refresh cycle per VM.
_vm_disk_cache = {} # vmid -> (fetched_at, (used, total) or None)
_VM_DISK_REFRESH_PERIOD = 60 # seconds between full refresh cycles
_VM_DISK_STALE_AFTER = 300 # readers ignore entries older than this
_VM_DISK_GA_TIMEOUT = 3 # per-VM `qm guest cmd` timeout (seconds)
_VM_DISK_REFRESH_WORKERS = 6 # parallelism cap for the fsinfo pass
# Filesystem types that never count towards VM disk usage:
# read-only image / CD formats, ram-backed pseudo-fs, kernel virtual
# filesystems, and union / container overlays. Anything not on this
# list is treated as real, persistent disk space.
_VM_FS_SKIP_TYPES = frozenset({
# Read-only image / CD formats
'erofs', 'squashfs', 'iso9660', 'udf', 'cdfs', 'romfs',
# Ram-backed
'tmpfs', 'devtmpfs', 'ramfs', 'zram',
# Kernel virtual
'proc', 'sysfs', 'cgroup', 'cgroup2', 'nsfs', 'fusectl',
'binfmt_misc', 'bpf', 'hugetlbfs', 'mqueue', 'pstore',
'tracefs', 'debugfs', 'securityfs', 'configfs', 'efivarfs',
'rpc_pipefs', 'selinuxfs',
# Overlay / union
'overlay', 'overlayfs', 'aufs', 'autofs',
})
def _compute_vm_disk_from_fsinfo(fsinfo):
"""Aggregate real filesystem usage from a QEMU guest agent fsinfo list.
Returns (used_bytes, total_bytes) when at least one persistent
filesystem is found, otherwise None. Filters non-persistent
filesystems and deduplicates bind-mounts by their backing block
device signature (bus, target, unit, dev) a Home Assistant OS
layout with a dozen bind-mounts of /mnt/data is counted once,
Windows System-Reserved partitions of size 0 are dropped so C:\\
carries the value.
"""
if not fsinfo:
return None
seen_disks = set()
total_used = 0
total_size = 0
for fs in fsinfo:
if not isinstance(fs, dict):
continue
fstype = (fs.get('type') or '').lower()
if not fstype or fstype in _VM_FS_SKIP_TYPES or fstype.startswith('fuse.'):
continue
# Must be backed by a real block device — bind-mounts to pseudo
# filesystems have an empty disk[] and are already accounted
# for through the mount that owns the underlying device.
disks = fs.get('disk') or []
if not disks or not isinstance(disks[0], dict):
continue
try:
total = int(fs.get('total-bytes') or 0)
used = int(fs.get('used-bytes') or 0)
except (TypeError, ValueError):
continue
if total <= 0:
continue
# Dedup by backing block device AFTER confirming the mount has
# a usable size. Windows exposes several partitions of the same
# `\\.\PhysicalDrive0` with identical disk[0] info; the
# System-Reserved partitions of size 0 often appear before the
# real C:\ mount, and marking their signature eagerly would
# suppress C:\ and yield None for the entire VM (bug reported
# for a Windows 11 VM at 166 GB used / 1965 GB total).
d = disks[0]
sig = (d.get('bus'), d.get('target'), d.get('unit'), d.get('dev'))
if sig in seen_disks:
continue
seen_disks.add(sig)
total_size += total
total_used += used
if total_size <= 0:
return None
return total_used, total_size
def _fetch_vm_guest_fsinfo(vmid):
"""One-shot `qm guest cmd <vmid> get-fsinfo` — invoked only by the
background refresher, never on a request path. Returns the parsed
JSON list or None on any error (timeout, absent agent, malformed
response). Callers must NOT invoke this from an endpoint handler:
it blocks for up to _VM_DISK_GA_TIMEOUT seconds per call, which is
exactly what the refresher is designed to shield /api/vms from.
"""
try:
result = subprocess.run(
['qm', 'guest', 'cmd', str(vmid), 'get-fsinfo'],
capture_output=True, text=True, timeout=_VM_DISK_GA_TIMEOUT,
)
if result.returncode != 0 or not result.stdout.strip():
return None
try:
parsed = json.loads(result.stdout)
return parsed if isinstance(parsed, list) else None
except (ValueError, TypeError):
return None
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return None
def get_cached_vm_disk(vmid):
"""Read the pre-computed VM disk usage from cache — the ONLY function
request handlers should call. Returns (used, total) or None when
the VM isn't in the cache yet (fresh VM, refresher hasn't run yet)
or its data has aged past the staleness window. Never blocks and
never spawns subprocesses.
"""
entry = _vm_disk_cache.get(vmid)
if entry is None:
return None
fetched_at, value = entry
if time.time() - fetched_at > _VM_DISK_STALE_AFTER:
return None
return value
def _vm_disk_refresh_one(vmid):
"""Refresh a single VMID's cache entry. Runs in a worker thread of
the refresher pool never on the request path."""
fsinfo = _fetch_vm_guest_fsinfo(vmid)
computed = _compute_vm_disk_from_fsinfo(fsinfo)
_vm_disk_cache[vmid] = (time.time(), computed)
def _vm_disk_refresher_loop():
"""Daemon-thread body that keeps `_vm_disk_cache` warm.
Every ``_VM_DISK_REFRESH_PERIOD`` seconds, walks the running QEMU
VMs from the (already-cached) cluster resources snapshot and fires
a parallel `qm guest cmd get-fsinfo` per VM through a small thread
pool. Absorbs every subprocess cost off-request so /api/vms is
always a dict lookup. Also drops entries for VMs that no longer
exist so the cache doesn't leak.
Robust to transient exceptions in the resources probe a single
bad cycle just delays the next one, it never crashes the thread.
"""
# Small stagger so we don't compete with the request that started
# us for the very first cluster-resources fetch.
time.sleep(2)
while True:
cycle_started = time.time()
try:
resources = get_cached_pvesh_cluster_resources_vm() or []
live_vmids = set()
targets = []
for r in resources:
if r.get('type') not in ('qemu', 'vm'):
continue
if r.get('status') != 'running':
continue
vmid = r.get('vmid')
if vmid is None:
continue
live_vmids.add(vmid)
targets.append(vmid)
if targets:
# Bounded parallelism — fsinfo calls per VM are IO-bound
# (waiting on the guest agent), so a small pool wins big
# over serial execution but doesn't stampede the host.
try:
from concurrent.futures import ThreadPoolExecutor, wait
with ThreadPoolExecutor(max_workers=_VM_DISK_REFRESH_WORKERS) as ex:
futures = [ex.submit(_vm_disk_refresh_one, v) for v in targets]
wait(futures, timeout=_VM_DISK_GA_TIMEOUT * 2)
except Exception:
# Fallback to serial if the executor itself fails
for v in targets:
try:
_vm_disk_refresh_one(v)
except Exception:
pass
# Evict entries for VMs no longer running / removed. Iterate
# over a snapshot so the concurrent updates from the workers
# above (already finished at this point) don't fight us.
for v in list(_vm_disk_cache.keys()):
if v not in live_vmids:
_vm_disk_cache.pop(v, None)
except Exception:
pass
# Sleep the remainder of the cycle — never less than 5s even if
# the refresh took longer, to avoid a busy loop when everything
# times out at once.
elapsed = time.time() - cycle_started
remaining = _VM_DISK_REFRESH_PERIOD - elapsed
time.sleep(max(remaining, 5.0))
_VM_DISK_REFRESHER_STARTED = False
def _ensure_vm_disk_refresher():
global _VM_DISK_REFRESHER_STARTED
if _VM_DISK_REFRESHER_STARTED:
return
_VM_DISK_REFRESHER_STARTED = True
import threading
t = threading.Thread(target=_vm_disk_refresher_loop, daemon=True, name='vm-disk-refresher')
t.start()
# ─── Actions API helpers ─────────────────────────────────────────────────
#
# Backing store for POST /api/system/... and POST /api/proxmenux/... —
# transient systemd units. Doing it this way instead of `subprocess.Popen`
# gives us four properties for free:
#
# 1. Persistence: the unit outlives the Flask process, so the
# self-update path (which restarts proxmenux-monitor
# mid-install) doesn't lose track of the run.
# 2. Concurrency: systemd rejects `systemd-run --unit <name>` if that
# unit is already active, so we return 409 Conflict
# without needing our own flock.
# 3. Cancellable: `systemctl stop <unit>` from CLI or DELETE from API.
# 4. State + log: `systemctl show ... --property=...` gives us the
# ActiveState / ExecMainStatus / timestamps, and the
# unit's stdout/stderr lands in the journal.
#
# Every action gets a stable unit name (one per action, not per run) so
# the GET .../status endpoint knows where to look without needing the
# client to remember an opaque run-id.
# Snapshot of the last terminal state for each action unit. systemd
# reclaims transient units the instant they finish (or are stopped
# explicitly); without this cache, `.../status` would jump back to
# `idle` seconds after a run ends and callers would never see the
# exit code / result they polled for. Every time `_action_state`
# observes a terminal state we snapshot it here; when a later call
# finds the unit gone from systemd, we serve the snapshot instead.
_action_last_state = {} # unit_name -> full state dict
_ACTION_TERMINAL = ('success', 'failed', 'cancelled')
def _action_run(unit_name, cmd_argv, description=None, extra_properties=None):
"""Launch ``cmd_argv`` as a transient systemd oneshot unit.
Returns ``(True, {})`` on success or ``(False, {"error": ..., "status": <http>})``
on failure. The caller is responsible for mapping the error dict to
a jsonify() + status code.
"""
if not unit_name.endswith('.service'):
unit_name = f'{unit_name}.service'
state = _action_state(unit_name)
if state.get('state') == 'running':
return False, {
'error': 'Action already in progress',
'unit': unit_name,
'state': state,
'status': 409,
}
# Starting a new run — clear any previous terminal snapshot so we
# don't serve stale exit-code data on the first `.../status` call
# after the new run completes.
_action_last_state.pop(unit_name, None)
# Any previous run may have left the unit in "active (exited)" via
# RemainAfterExit — reset it so systemd will accept the fresh start.
# Silent: reset-failed no-ops when the unit isn't in a failed state,
# and stop no-ops when it isn't loaded.
try:
subprocess.run(['systemctl', 'reset-failed', unit_name],
capture_output=True, text=True, timeout=5)
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
pass
# Type=simple: systemd treats the first process as the main one and
# follows its lifecycle — correct for long-running scripts like
# `proxmox_update.sh` (minutes) or the self-update installer.
# RemainAfterExit=yes: after the main process exits, systemd keeps
# the unit in the "active (exited)" state so `.../status` remains
# informative (exit code, timestamps, result) instead of returning
# to `idle` the instant the run finishes. Without this, transient
# units disappear on termination and callers lose the last-run info.
run_argv = [
'systemd-run',
'--unit', unit_name,
'--property', 'Type=simple',
'--property', 'RemainAfterExit=yes',
]
if description:
run_argv.extend(['--description', description])
for prop in (extra_properties or []):
run_argv.extend(['--property', prop])
run_argv.extend(cmd_argv)
try:
proc = subprocess.run(run_argv, capture_output=True, text=True, timeout=10)
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
return False, {'error': f'systemd-run failed to launch: {e}', 'status': 500}
if proc.returncode != 0:
return False, {
'error': 'systemd-run refused to start the unit',
'stderr': (proc.stderr or '').strip(),
'status': 500,
}
return True, {}
def _action_state(unit_name):
"""Read the state of a transient action unit.
Returns a uniform dict ALWAYS the same keys regardless of whether
the unit ever existed. Callers can jsonify() it directly.
``state`` values:
``idle`` never run (or fully cleaned up)
``running`` currently executing
``success`` last run finished with exit 0
``failed`` last run finished with non-zero exit
``cancelled`` last run was stopped via DELETE / systemctl stop
``unknown`` systemctl responded with a shape we don't expect
"""
if not unit_name.endswith('.service'):
unit_name = f'{unit_name}.service'
empty = {
'unit': unit_name,
'state': 'idle',
'started_at': None,
'finished_at': None,
'exit_code': None,
'result': None,
}
try:
proc = subprocess.run(
['systemctl', 'show', unit_name,
'--property=LoadState,ActiveState,SubState,Result,ExecMainStatus,ExecMainStartTimestamp,ExecMainExitTimestamp'],
capture_output=True, text=True, timeout=5,
)
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return empty
if proc.returncode != 0:
return empty
props = {}
for line in proc.stdout.splitlines():
if '=' in line:
k, _, v = line.partition('=')
props[k] = v
if props.get('LoadState') == 'not-found':
# systemd already reclaimed the transient unit. If we have a
# snapshot of its terminal state, serve that so the caller sees
# the exit code / result / timestamps of the last run.
snap = _action_last_state.get(unit_name)
return snap if snap else empty
active = props.get('ActiveState', '')
sub = props.get('SubState', '')
result = props.get('Result', '') or None
exit_code = props.get('ExecMainStatus')
try:
exit_code = int(exit_code) if exit_code not in (None, '') else None
except (TypeError, ValueError):
exit_code = None
# Type=simple + RemainAfterExit=yes semantics:
# active/running → process still executing
# active/exited → process finished cleanly (unit persists post-exit)
# failed → process died with non-zero or was killed by signal
if active == 'active' and sub == 'running':
state = 'running'
elif active == 'active' and sub == 'exited' and (result in (None, '', 'success')):
state = 'success'
elif active == 'inactive' and result == 'success':
# No RemainAfterExit (older units) — terminated cleanly then vanished.
state = 'success'
elif result == 'canceled':
state = 'cancelled'
# SIGTERM (exit 15) is the signal systemd sends when we DELETE the
# unit or when the operator runs `systemctl stop` — treat it as
# cancelled rather than failed, so the API surfaces intent, not
# only the signal that carried it. A hard SIGKILL (137) or any
# other signal still surfaces as `failed`.
elif result == 'signal' and exit_code == 15:
state = 'cancelled'
elif active == 'failed' or result in ('exit-code', 'signal', 'core-dump', 'timeout', 'oom-kill'):
state = 'failed'
elif active in ('activating', 'deactivating') or sub == 'start':
state = 'running'
else:
state = 'unknown'
def _ts(s):
# systemd emits either an empty string, "0" or a formatted date;
# we return it as-is (empty → None) so the frontend / client
# doesn't have to parse an ad-hoc format.
return s.strip() if s and s.strip() and s.strip() != '0' else None
result_dict = {
'unit': unit_name,
'state': state,
'started_at': _ts(props.get('ExecMainStartTimestamp', '')),
'finished_at': _ts(props.get('ExecMainExitTimestamp', '')),
'exit_code': exit_code,
'result': result,
}
# Cache terminal states so we can serve them after systemd garbage-
# collects the transient unit. Running / idle states are transient
# by definition and don't need snapshotting.
if state in _ACTION_TERMINAL:
_action_last_state[unit_name] = result_dict
return result_dict
def _action_cancel(unit_name):
"""Stop a running transient action unit. Idempotent — succeeds on
units that are not running (or don't exist).
Preserves the started-at timestamp of the run being cancelled and
manufactures a `cancelled` snapshot into ``_action_last_state`` so
that callers polling `.../status` after the cancel see the
cancellation intent, not the empty `idle` state that systemd
exposes once transient units get reclaimed. We snapshot BEFORE
calling `systemctl stop` because systemd releases the unit the
moment the stop returns reading state afterwards would see
`not-found`.
"""
if not unit_name.endswith('.service'):
unit_name = f'{unit_name}.service'
pre = _action_state(unit_name)
was_active = pre.get('state') in ('running',)
try:
subprocess.run(['systemctl', 'stop', unit_name],
capture_output=True, text=True, timeout=10)
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return False
if was_active:
import datetime
_action_last_state[unit_name] = {
'unit': unit_name,
'state': 'cancelled',
'started_at': pre.get('started_at'),
'finished_at': datetime.datetime.now().astimezone().strftime('%a %Y-%m-%d %H:%M:%S %Z'),
'exit_code': 15,
'result': 'signal',
}
return True
def get_cached_pvesh_cluster_resources_vm():
"""Get cluster VM resources with a per-process cache.
@@ -4649,6 +5122,17 @@ try:
except Exception:
pass
# Same pattern for the VM guest-agent fsinfo refresher — kicks off a
# daemon thread that keeps _vm_disk_cache warm so /api/vms and the
# health check only ever do dict lookups. First cycle runs ~2s after
# module load; running QEMU VMs report accurate disk usage as soon
# as their first refresh completes, and fall back to the PVE value
# in the meantime.
try:
_ensure_vm_disk_refresher()
except Exception:
pass
# ─── Per-NIC live rate (moving window) ──────────────────────────
# Keep the last ~30s of byte counters and derive the rate from the
@@ -5447,6 +5931,20 @@ def get_proxmox_vms():
upd = lxc_updates_map.get(str(resource.get('vmid')))
if upd is not None:
vm_data['update_check'] = upd
# PVE's cluster resources API reports disk=0 for most
# QEMU VMs — it can't see inside the guest filesystem
# for the common storage backends. For running QEMU
# VMs we override with the guest-agent-derived value
# produced by the background refresher; readers only
# do a dict lookup, no subprocess ever runs on the
# request path. VMs not yet in the cache (fresh
# boot, refresher hasn't run) keep the PVE value.
if vm_type == 'qemu' and vm_data['status'] == 'running':
computed = get_cached_vm_disk(vm_data['vmid'])
if computed is not None:
vm_data['disk'], vm_data['maxdisk'] = computed
all_vms.append(vm_data)
return all_vms
@@ -10048,7 +10546,12 @@ def _update_smart_cron():
# scheduled tests in history" with no obvious clue why.
cmd = f'/usr/local/share/proxmenux/scripts/storage/smart-scheduled-test.sh --schedule-id {schedule_id} --test-type {test_type} --retention {retention}'
if disks != ['all']:
cmd += f" --disks '{','.join(disks)}'"
# Prepend /dev/ when the UI stored the disk as a basename
# (sdb, nvme0n1, …). The runner's `[[ -b "$disk" ]]` check
# only accepts full block-device paths, so basenames were
# silently skipped and no test ever ran.
disk_args = [d if d.startswith('/') else f'/dev/{d}' for d in disks]
cmd += f" --disks '{','.join(disk_args)}'"
cron_lines.append(f'{cron_time} root {cmd} >> /var/log/proxmenux/smart-schedule.log 2>&1')
@@ -18628,6 +19131,132 @@ def api_host_backups_archive_log(archive_id):
})
# ─── Actions API ─────────────────────────────────────────────────────────
#
# External-integration friendly endpoints (Home Assistant, Homepage,
# Ansible, custom dashboards) to trigger the same actions the operator
# would run from the Monitor UI or the shell menu. All mutating routes
# require a token with `full_admin` scope; the read-only `.../status`
# routes accept any authenticated caller. Every trigger returns 202
# with the state at the moment of the call; clients poll `.../status`
# to observe progress. See `_action_run` / `_action_state` for the
# systemd-run backing that makes each of these persistent, cancellable
# and single-instance without extra plumbing.
# System power — reboot / shutdown of the Proxmox host
@app.route('/api/system/power/reboot', methods=['POST'])
@require_admin_scope
def api_system_power_reboot():
"""Reboot the Proxmox host. Fire-and-forget: the host will drop the
HTTP connection when the shutdown sequence starts."""
ok, err = _action_run(
'proxmenux-action-system-power-reboot',
['/bin/systemctl', 'reboot'],
description='ProxMenux action: reboot host',
)
if not ok:
return jsonify({k: v for k, v in err.items() if k != 'status'}), err.get('status', 500)
return jsonify(_action_state('proxmenux-action-system-power-reboot')), 202
@app.route('/api/system/power/reboot/status', methods=['GET'])
@require_auth
def api_system_power_reboot_status():
return jsonify(_action_state('proxmenux-action-system-power-reboot'))
@app.route('/api/system/power/shutdown', methods=['POST'])
@require_admin_scope
def api_system_power_shutdown():
"""Power off the Proxmox host."""
ok, err = _action_run(
'proxmenux-action-system-power-shutdown',
['/bin/systemctl', 'poweroff'],
description='ProxMenux action: shutdown host',
)
if not ok:
return jsonify({k: v for k, v in err.items() if k != 'status'}), err.get('status', 500)
return jsonify(_action_state('proxmenux-action-system-power-shutdown')), 202
@app.route('/api/system/power/shutdown/status', methods=['GET'])
@require_auth
def api_system_power_shutdown_status():
return jsonify(_action_state('proxmenux-action-system-power-shutdown'))
# Proxmox VE update — same script the Update Now dashboard button runs
_PVE_UPDATE_SCRIPT = '/usr/local/share/proxmenux/scripts/utilities/proxmox_update.sh'
@app.route('/api/system/pve-update/run', methods=['POST'])
@require_admin_scope
def api_system_pve_update_run():
"""Trigger the same safe PVE update flow the Health Monitor's
Update Now button invokes (delegates to `update-pve-safe.sh`)."""
if not os.path.exists(_PVE_UPDATE_SCRIPT):
return jsonify({'error': 'PVE update script not installed',
'path': _PVE_UPDATE_SCRIPT}), 500
ok, err = _action_run(
'proxmenux-action-pve-update',
['/bin/bash', _PVE_UPDATE_SCRIPT],
description='ProxMenux action: Proxmox VE update',
)
if not ok:
return jsonify({k: v for k, v in err.items() if k != 'status'}), err.get('status', 500)
return jsonify(_action_state('proxmenux-action-pve-update')), 202
@app.route('/api/system/pve-update/status', methods=['GET'])
@require_auth
def api_system_pve_update_status():
return jsonify(_action_state('proxmenux-action-pve-update'))
@app.route('/api/system/pve-update', methods=['DELETE'])
@require_admin_scope
def api_system_pve_update_cancel():
"""Cancel a PVE update run in progress. No-op if no run is active."""
_action_cancel('proxmenux-action-pve-update')
return jsonify(_action_state('proxmenux-action-pve-update'))
# ProxMenux self-update — pipes the canonical one-line installer.
# Runs inside a systemd unit so it survives the proxmenux-monitor
# service restart the installer performs mid-way.
_PROXMENUX_INSTALLER_URL = 'https://raw.githubusercontent.com/MacRimi/ProxMenux/main/install_proxmenux.sh'
@app.route('/api/proxmenux/self-update/run', methods=['POST'])
@require_admin_scope
def api_proxmenux_self_update_run():
"""Update ProxMenux itself by piping the canonical installer.
The installer restarts `proxmenux-monitor.service` mid-way; because
the run lives in its own transient systemd unit (not a subprocess
of this Flask worker), the update completes even though the Monitor
process that accepted the call dies during the restart. Clients
can poll `.../status` to observe completion.
"""
ok, err = _action_run(
'proxmenux-action-self-update',
['/bin/bash', '-c', f'wget -qLO - {_PROXMENUX_INSTALLER_URL} | bash'],
description='ProxMenux action: self-update',
)
if not ok:
return jsonify({k: v for k, v in err.items() if k != 'status'}), err.get('status', 500)
return jsonify(_action_state('proxmenux-action-self-update')), 202
@app.route('/api/proxmenux/self-update/status', methods=['GET'])
@require_auth
def api_proxmenux_self_update_status():
return jsonify(_action_state('proxmenux-action-self-update'))
if __name__ == '__main__':
import sys
import logging
+157
View File
@@ -964,6 +964,20 @@ class HealthMonitor:
elif lxc_disk_result.get('status') == 'WARNING':
warning_issues.append(lxc_disk_result.get('reason', 'LXC rootfs filling up'))
# QEMU VM filesystem usage via guest agent — mirrors the LXC
# rootfs check but reads from the guest agent because pvesh
# reports disk=0 for most QEMU storage backends. VMs without
# a responsive agent are silently skipped (no signal ≠ OK).
_t = time.time()
vm_disk_result = self._check_vm_disk_usage()
_perf_log("vm_disk_usage", (time.time() - _t) * 1000)
if vm_disk_result:
details['vm_disk'] = vm_disk_result
if vm_disk_result.get('status') == 'CRITICAL':
critical_issues.append(vm_disk_result.get('reason', 'VM filesystems near full'))
elif vm_disk_result.get('status') == 'WARNING':
warning_issues.append(vm_disk_result.get('reason', 'VM filesystems filling up'))
# Phase 3 capacity checks added on top of the existing storage
# ones. Each is independently configurable via Settings →
# Health Thresholds; defaults are 85/95 to align with the host
@@ -6262,6 +6276,149 @@ class HealthMonitor:
'checks': checks,
}
def _check_vm_disk_usage(self) -> Optional[Dict[str, Any]]:
"""QEMU VM filesystem usage via the guest agent.
Sibling of ``_check_lxc_disk_usage`` that closes the analogous
gap for VMs: ``pvesh cluster resources`` reports ``disk=0`` for
most QEMU storage backends (PVE can't see inside the guest),
so this check asks the guest agent directly for every running
QEMU VM and emits WARNING at 85% / CRITICAL at 95% same
defaults as the LXC counterpart. The aggregated total includes
every persistent filesystem the guest reports as backed by a
block device, PCI-passthrough drives included: the metric is
"how full is the guest", not "how full is the virtual disk
PVE knows about", which is intentionally more useful for
appliances like TrueNAS or a Synology VM.
VMs whose agent is absent, unreachable, times out, or reports
no usable data are skipped no false OK, no false alert.
Reads the pre-computed cache maintained by the daemon refresher
in ``flask_server``; never spawns a subprocess on the check
path, so a slow / dead guest agent can't stretch the health
cycle.
"""
try:
import flask_server # deferred — avoids circular import
resources = flask_server.get_cached_pvesh_cluster_resources_vm() or []
except Exception as e:
print(f"[HealthMonitor] VM disk check failed: {e}")
return None
# Cheap short-circuit: no running QEMU VMs on this node.
if not any(
r.get('type') in ('qemu', 'vm') and r.get('status') == 'running'
for r in resources
):
return None
WARN_PCT, CRIT_PCT = self._read_capacity_thresholds('vm_disk', fb_warn=85, fb_crit=95)
checks: Dict[str, Dict[str, Any]] = {}
critical_vms: list[str] = []
warning_vms: list[str] = []
emitted_keys: set[str] = set()
for r in resources:
if r.get('type') not in ('qemu', 'vm'):
continue
if r.get('status') != 'running':
continue
vmid = r.get('vmid')
if vmid is None:
continue
try:
computed = flask_server.get_cached_vm_disk(vmid)
except Exception:
computed = None
if computed is None:
continue
used, total = computed
if total <= 0:
continue
pct = (used / total) * 100
vmid_str = str(vmid)
name = r.get('name', '') or ''
label = f'VM {vmid_str}' + (f' ({name})' if name else '')
entry: Dict[str, Any] = {
'detail': f'guest filesystems {pct:.1f}% used ({used // (1024**2)} MB / {total // (1024**2)} MB)',
'usage_percent': round(pct, 1),
'disk_bytes': used,
'maxdisk_bytes': total,
'vmid': vmid_str,
'name': name,
}
error_key = f'vm_disk_{vmid_str}'
if pct >= CRIT_PCT:
entry['status'] = 'CRITICAL'
entry['error_key'] = error_key
entry['dismissable'] = True
checks[label] = entry
critical_vms.append(label)
emitted_keys.add(error_key)
health_persistence.record_error(
error_key=error_key,
category='storage',
severity='CRITICAL',
reason=f'{label} filesystems at {pct:.1f}% ({used // (1024**2)} MB / {total // (1024**2)} MB)',
details=entry,
)
elif pct >= WARN_PCT:
entry['status'] = 'WARNING'
entry['error_key'] = error_key
entry['dismissable'] = True
checks[label] = entry
warning_vms.append(label)
emitted_keys.add(error_key)
health_persistence.record_error(
error_key=error_key,
category='storage',
severity='WARNING',
reason=f'{label} filesystems at {pct:.1f}% ({used // (1024**2)} MB / {total // (1024**2)} MB)',
details=entry,
)
else:
entry['status'] = 'OK'
checks[label] = entry
# Clear stale VM disk errors (VM stopped, agent lost, freed up).
for err in (health_persistence.get_active_errors() or []):
ek = err.get('error_key', '')
if not ek.startswith('vm_disk_'):
continue
if ek not in emitted_keys:
health_persistence.clear_error(ek)
if not checks:
return None
if critical_vms:
entity, _ = _fmt_entity_and_summary(critical_vms, 'x', 'x')
return {
'status': 'CRITICAL',
'reason': f'{len(critical_vms)} VM(s) at >{CRIT_PCT}% filesystems: {_fmt_name_list(critical_vms)}',
'entity': entity,
'checks': checks,
}
if warning_vms:
entity, _ = _fmt_entity_and_summary(warning_vms, 'x', 'x')
return {
'status': 'WARNING',
'reason': f'{len(warning_vms)} VM(s) at >{WARN_PCT}% filesystems: {_fmt_name_list(warning_vms)}',
'entity': entity,
'checks': checks,
}
return {
'status': 'OK',
'reason': f'{len(checks)} running VM(s) within safe filesystem usage',
'checks': checks,
}
# ─── Phase 3 capacity checks ─────────────────────────────────────────────
#
# Three sibling methods that all share the same shape:
+6
View File
@@ -924,6 +924,12 @@ class HealthPersistence:
for cat, prefix in [('updates', 'security_updates'), ('updates', 'system_age'),
('updates', 'pending_updates'), ('updates', 'kernel_pve'),
('security', 'security_'),
# `vm_disk_<vmid>` is a storage-category key that WOULD otherwise
# match the `vm_` prefix below and end up mis-tagged under `vms`;
# putting the storage-specific override first keeps the Dismiss
# flow honest (invalidates the storage cache, groups with the
# other capacity events).
('storage', 'vm_disk_'),
('pve_services', 'pve_service_'), ('vms', 'vmct_'), ('vms', 'vm_'), ('vms', 'ct_'),
# ── Storage keys — HealthMonitor emits these under `storage` category
# but they used to fall through to 'general' here because no prefix
+10
View File
@@ -75,6 +75,16 @@ DEFAULTS: dict[str, Any] = {
"warning": {"value": 85, "unit": "%", "min": 1, "max": 100, "step": 1},
"critical": {"value": 95, "unit": "%", "min": 1, "max": 100, "step": 1},
},
"vm_disk": {
# Aggregate guest-filesystem usage for running QEMU VMs, read
# from the guest agent (mirrors `lxc_rootfs` for VMs). Includes
# every persistent filesystem the guest reports on a block
# device, so PCI-passthrough drives and add-on storage count
# towards the threshold — the metric is "how full is the
# guest", not "how full is the disk PVE knows about".
"warning": {"value": 85, "unit": "%", "min": 1, "max": 100, "step": 1},
"critical": {"value": 95, "unit": "%", "min": 1, "max": 100, "step": 1},
},
"cpu_temperature": {
"warning": {"value": 80, "unit": "°C", "min": 30, "max": 120, "step": 1},
"critical": {"value": 90, "unit": "°C", "min": 30, "max": 120, "step": 1},
+2
View File
@@ -2772,6 +2772,8 @@ class PollingCollector:
if category == 'storage':
if error_key.startswith('lxc_disk_'):
event_type = 'lxc_disk_low'
elif error_key.startswith('vm_disk_'):
event_type = 'vm_disk_low'
elif error_key.startswith('lxc_mount_'):
event_type = 'lxc_mount_low'
elif error_key.startswith('pve_storage_full_'):
+1 -1
View File
@@ -2789,7 +2789,7 @@ class NotificationManager:
# injection lands in the system prompt verbatim. Audit Tier 3.2 #4.
_ALLOWED_DETAIL_LEVELS = ('brief', 'standard', 'detailed')
_ALLOWED_AI_LANGUAGES = (
'en', 'es', 'fr', 'de', 'it', 'pt', 'ru',
'en', 'sk', 'es', 'fr', 'de', 'it', 'pt', 'ru',
'ja', 'zh', 'ko', 'pl', 'nl', 'tr', 'ar',
)
if short_key.endswith('.ai_detail_level') or short_key == 'ai_detail_level':
@@ -1157,6 +1157,27 @@ TEMPLATES = {
'default_enabled': True,
},
# Aggregate filesystem usage reported by the QEMU guest agent for a
# running VM. Fires when the guest is filling up regardless of
# whether the storage is a virtual disk or a PCI-passthrough drive
# (TrueNAS-style appliances included) — the metric is "how full is
# the guest", not "how full is the disk PVE knows about".
'vm_disk_low': {
'title': '{hostname}: VM {vmid} filesystems at {usage_percent}%',
'body': (
'VM {vmid} ({name}) guest filesystems are at {usage_percent}% '
'({disk_bytes_human} / {maxdisk_bytes_human}).\n\n'
'Reported by the QEMU guest agent. Includes every persistent '
'filesystem the guest mounts on a block device — virtual disks '
'and PCI-passthrough drives alike. Free up space inside the '
'guest or expand the affected storage before writes start to '
'fail.'
),
'label': 'VM filesystems near full',
'group': 'storage',
'default_enabled': True,
},
# ── Phase 3 capacity events (Sprint 14.5) ─────────────────────────
# Three new events that complete the storage-monitoring picture.
# Each fires at the user-configured warning/critical thresholds
@@ -1716,6 +1737,7 @@ EVENT_EMOJI = {
'mount_stale': '\U0001F517', # link (broken connection feel)
'mount_readonly': '\U0001F512', # lock
'lxc_disk_low': '\U0001F4BE', # floppy disk (near-full)
'vm_disk_low': '\U0001F4BE', # floppy disk — same shape as LXC counterpart
'lxc_mount_low': '\U0001F4C2', # 📂 folder near-full
'pve_storage_full': '\U0001F4E6', # 📦 package (running out)
'zfs_pool_full': '\U0001F30A', # 🌊 wave (pool is full)
@@ -1954,6 +1976,7 @@ def enrich_with_emojis(event_type: str, title: str, body: str,
# Supported languages for AI translation
AI_LANGUAGES = {
'en': 'English',
'sk': 'Slovak',
'es': 'Spanish',
'fr': 'French',
'de': 'German',
+91 -15
View File
@@ -137,6 +137,25 @@ def _run_cmd(cmd, timeout=10):
return -1, "", str(e)
def _pve_firewall_apply():
"""
Recompile and apply pending firewall changes.
pve-firewall 6.x (shipped with PVE 9) dropped the `reload`
subcommand only `restart` recompiles the ruleset and re-applies
it to iptables/nftables. Older releases accepted `reload`. Try
`reload` first for the cheap path; fall back to `restart` when
the subcommand is missing or returns an error. Without this
fallback, every mutating firewall call in this module was silently
a no-op on PVE 9 (the config file was updated but the kernel
ruleset never picked up the change until an unrelated restart).
"""
rc, out, err = _run_cmd(["pve-firewall", "reload"])
if rc == 0:
return rc, out, err
return _run_cmd(["pve-firewall", "restart"])
def get_firewall_status():
"""
Get the overall Proxmox firewall status.
@@ -392,7 +411,7 @@ def add_firewall_rule(direction="IN", action="ACCEPT", protocol="tcp", dport="",
with open(fw_file, 'w') as f:
f.write(content)
_run_cmd(["pve-firewall", "reload"])
_pve_firewall_apply()
return True, f"Firewall rule added: {direction} {action} {protocol}{':' + dport if dport else ''}"
except PermissionError:
@@ -509,7 +528,7 @@ def edit_firewall_rule(rule_index, level="host", direction="IN", action="ACCEPT"
with open(fw_file, 'w') as f:
f.write("\n".join(new_lines) + "\n")
_run_cmd(["pve-firewall", "reload"])
_pve_firewall_apply()
return True, f"Firewall rule updated: {direction} {action} {protocol}{':' + dport if dport else ''}"
except PermissionError:
@@ -571,7 +590,7 @@ def delete_firewall_rule(rule_index, level="host"):
with open(fw_file, 'w') as f:
f.write("\n".join(new_lines) + "\n")
_run_cmd(["pve-firewall", "reload"])
_pve_firewall_apply()
return True, f"Firewall rule deleted: {removed_rule}"
except PermissionError:
@@ -625,7 +644,7 @@ def add_monitor_port_rule():
f.write(content)
# Reload firewall
_run_cmd(["pve-firewall", "reload"])
_pve_firewall_apply()
return True, "Firewall rule added: port 8008 (TCP) allowed for ProxMenux Monitor"
except PermissionError:
@@ -662,7 +681,7 @@ def remove_monitor_port_rule():
with open(host_fw, 'w') as f:
f.writelines(new_lines)
_run_cmd(["pve-firewall", "reload"])
_pve_firewall_apply()
return True, "ProxMenux Monitor firewall rule removed"
except Exception as e:
@@ -673,9 +692,25 @@ def enable_firewall(level="host"):
"""
Enable the Proxmox firewall at host or cluster level.
Returns (success, message)
Safety net: whoever is calling this endpoint is reaching the Monitor
through port 8008. Enabling the firewall without an explicit ACCEPT
rule for that port drops the caller's own connection the instant
`pve-firewall reload` runs, and they lose the UI they need to fix it.
Ensure the rule is in host.fw first; if we can't place it, refuse
to enable rather than risk a lock-out.
"""
if level not in _FIREWALL_LEVELS:
return False, f"Invalid level: {level}. Must be one of {_FIREWALL_LEVELS}"
rule_ok, rule_msg = add_monitor_port_rule()
if not rule_ok:
return False, (
f"Refused to enable firewall: could not ensure port 8008 "
f"(ProxMenux Monitor) rule in host.fw — {rule_msg}. "
f"Add the rule manually and try again."
)
if level == "cluster":
return _set_firewall_enabled(CLUSTER_FW, True)
else:
@@ -750,7 +785,7 @@ def _set_firewall_enabled(fw_file, enabled):
_run_cmd(["systemctl", "enable", "pve-firewall"])
_run_cmd(["systemctl", "start", "pve-firewall"])
_run_cmd(["pve-firewall", "reload"])
_pve_firewall_apply()
state = "enabled" if enabled else "disabled"
level = "cluster" if fw_file == CLUSTER_FW else "host"
@@ -1425,10 +1460,13 @@ def run_lynis_audit():
global _lynis_audit_running, _lynis_audit_progress
try:
_lynis_audit_progress = "running"
# Remove old report so lynis creates a fresh one
report_file = "/var/log/lynis-report.dat"
if os.path.isfile(report_file):
os.remove(report_file)
# Remove old generated files so a failed or interrupted run does
# not get mixed with data from an earlier audit. Keep
# /var/log/lynis.log: Lynis owns that file and can use it as a
# 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
# formatted output ([+] sections) when stdout is not a tty.
@@ -1538,6 +1576,7 @@ def parse_lynis_report():
"""
report_file = "/var/log/lynis-report.dat"
output_file = "/var/log/lynis-output.log"
lynis_log_file = "/var/log/lynis.log"
# Need at least one data source
if not os.path.isfile(report_file) and not os.path.isfile(output_file):
return None
@@ -1559,6 +1598,8 @@ def parse_lynis_report():
"kernel_version": "",
"firewall_active": False,
"malware_scanner": False,
"is_complete": False,
"parse_issue": "",
}
# Collect all raw key-value pairs first for flexible matching
@@ -1685,9 +1726,30 @@ def parse_lynis_report():
# archivo entero a memoria 2 veces.
report["sections"] = []
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 = []
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:
with open(log_file, 'r') as f:
_log_lines = f.readlines()
@@ -1764,7 +1826,7 @@ def parse_lynis_report():
# Format: "Key: value" or "Key : value"
if ":" 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:
report["hardening_index"] = int(m.group(1))
elif report["tests_performed"] == 0 and "Tests performed" in stripped:
@@ -1927,6 +1989,15 @@ def parse_lynis_report():
if "malware" in sw_name and sw_status == "V":
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]"
if in_warnings and sstripped.startswith('!'):
wm = re.match(r'^!\s+(.+?)\s+\[([A-Z0-9_-]+)\]', sstripped)
@@ -2175,10 +2246,12 @@ def parse_lynis_report():
# Calculate Proxmox-adjusted score
# Lynis score is based on total tests and findings.
# 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"])
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
# not penalize. We estimate the boost proportionally.
penalty_per_finding = (100 - raw_score) / max(total_findings, 1)
@@ -2191,6 +2264,9 @@ def parse_lynis_report():
report["proxmox_expected_warnings"] = pve_expected_warnings
report["proxmox_expected_suggestions"] = pve_expected_suggestions
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
@@ -0,0 +1,113 @@
import importlib.util
import json
import tempfile
import unittest
from pathlib import Path
from unittest import mock
MODULE_PATH = Path(__file__).resolve().parents[1] / "auth_manager.py"
SPEC = importlib.util.spec_from_file_location("auth_manager_under_test", MODULE_PATH)
auth_manager = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(auth_manager)
class SetupAuthTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
config_dir = Path(self.temp_dir.name)
self.config_patch = mock.patch.multiple(
auth_manager,
CONFIG_DIR=config_dir,
AUTH_CONFIG_FILE=config_dir / "auth.json",
)
self.config_patch.start()
self.addCleanup(self.config_patch.stop)
self.hash_patch = mock.patch.object(
auth_manager, "hash_password", return_value="test-password-hash"
)
self.hash_patch.start()
self.addCleanup(self.hash_patch.stop)
def read_config(self):
return json.loads(auth_manager.AUTH_CONFIG_FILE.read_text())
def write_config(self, config):
auth_manager.AUTH_CONFIG_FILE.write_text(json.dumps(config))
def test_fresh_setup_succeeds(self):
success, message = auth_manager.setup_auth("admin", "StrongPass1!")
self.assertTrue(success, message)
config = self.read_config()
self.assertTrue(config["enabled"])
self.assertTrue(config["configured"])
self.assertFalse(config["declined"])
self.assertEqual(config["username"], "admin")
self.assertEqual(config["password_hash"], "test-password-hash")
def test_setup_succeeds_after_decline(self):
success, message = auth_manager.decline_auth()
self.assertTrue(success, message)
success, message = auth_manager.setup_auth("admin", "StrongPass1!")
self.assertTrue(success, message)
config = self.read_config()
self.assertTrue(config["enabled"])
self.assertFalse(config["declined"])
self.assertEqual(config["username"], "admin")
self.assertEqual(config["password_hash"], "test-password-hash")
def test_existing_credentials_cannot_be_overwritten(self):
self.write_config({
"enabled": True,
"configured": True,
"declined": False,
"username": "existing-admin",
"password_hash": "existing-password-hash",
})
success, message = auth_manager.setup_auth("attacker", "StrongPass1!")
self.assertFalse(success)
self.assertEqual(message, "Authentication is already configured")
config = self.read_config()
self.assertEqual(config["username"], "existing-admin")
self.assertEqual(config["password_hash"], "existing-password-hash")
def test_weak_password_is_rejected_without_writing_config(self):
success, message = auth_manager.setup_auth("admin", "weak")
self.assertFalse(success)
self.assertEqual(message, "Password must be at least 10 characters")
self.assertFalse(auth_manager.AUTH_CONFIG_FILE.exists())
def test_unrelated_fields_are_preserved(self):
preserved = {
"jwt_secret": "s" * 48,
"api_tokens": [{"id": "token-1"}],
"revoked_tokens": ["revoked-token-hash"],
"display_name": "Server Owner",
"custom_future_field": {"keep": True},
}
self.write_config({
"enabled": False,
"configured": True,
"declined": True,
"username": None,
"password_hash": None,
**preserved,
})
success, message = auth_manager.setup_auth("admin", "StrongPass1!")
self.assertTrue(success, message)
config = self.read_config()
for key, value in preserved.items():
self.assertEqual(config[key], value)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -1 +1 @@
1.2.4.0
1.2.4.0
+2 -1
View File
@@ -360,7 +360,8 @@ select_language() {
"fr" "French" \
"de" "German" \
"it" "Italian" \
"pt" "Portuguese" 3>&1 1>&2 2>&3)
"pt" "Portuguese" \
"sk" "Slovenčina" 3>&1 1>&2 2>&3)
if [ -z "$LANGUAGE" ]; then
msg_error "No language selected. Exiting."
+2 -1
View File
@@ -558,7 +558,8 @@ select_language() {
"fr" "French" \
"de" "German" \
"it" "Italian" \
"pt" "Portuguese" 3>&1 1>&2 2>&3)
"pt" "Portuguese" \
"sk" "Slovenčina" 3>&1 1>&2 2>&3)
if [ -z "$LANGUAGE" ]; then
msg_error "No language selected. Exiting."
+8 -1
View File
@@ -452,6 +452,7 @@
"Cannot proceed with invalid export path.": "Mit ungültigem Exportpfad kann nicht fortgefahren werden.",
"Cannot proceed with invalid share name.": "Mit ungültigem Freigabenamen kann nicht fortgefahren werden.",
"Cannot reach Proxmox repositories": "Proxmox-Repositorys können nicht erreicht werden",
"Cannot reach https://download.proxmox.com (HTTPS 443). Check network, proxy or DNS.": "https://download.proxmox.com (HTTPS 443) kann nicht erreicht werden.Überprüfen Sie Netzwerk, Proxy oder DNS.",
"Cannot reach portal:": "Portal kann nicht erreicht werden:",
"Cannot reach server": "Server ist nicht erreichbar",
"Cannot validate credentials - no shares available for testing.": "Anmeldeinformationen können nicht validiert werden keine Freigaben zum Testen verfügbar.",
@@ -925,7 +926,6 @@
"Could not unmount — disk may be busy. Removing fstab entry anyway.": "Die Bereitstellung konnte nicht aufgehoben werden die Festplatte ist möglicherweise ausgelastet. Fstab-Eintrag trotzdem entfernen.",
"Could not update config file.": "Die Konfigurationsdatei konnte nicht aktualisiert werden.",
"Could not write to:": "Es konnte nicht geschrieben werden an:",
"Create": "Erstellen",
"Create Directory": "Verzeichnis erstellen",
"Create GPT and one partition:": "Erstellen Sie GPT und eine Partition:",
"Create GPT partition": "Erstellen Sie eine GPT-Partition",
@@ -936,6 +936,11 @@
"Create Shared Directory": "Erstellen Sie ein freigegebenes Verzeichnis",
"Create Shared Directory on Host": "Erstellen Sie ein freigegebenes Verzeichnis auf dem Host",
"Create Universal NFS Export": "Erstellen Sie einen universellen NFS-Export",
"Create VM System Linux": "Linux-VM erstellen",
"Create VM System NAS": "VM für NAS erstellen",
"Create VM System Others (based Linux)": "VM für ein anderes Linux-basiertes System erstellen",
"Create VM System Windows": "Windows-VM erstellen",
"Create VM System macOS (OSX-PROXMOX)": "macOS-VM erstellen (OSX-PROXMOX)",
"Create VM from template or script": "Erstellen Sie eine VM aus einer Vorlage oder einem Skript",
"Create a VM first (machine type q35 + UEFI BIOS), then run this option again.": "Erstellen Sie zuerst eine VM (Maschinentyp q35 + UEFI-BIOS) und führen Sie diese Option dann erneut aus.",
"Create a backup of the container configuration:": "Erstellen Sie ein Backup der Containerkonfiguration:",
@@ -4200,6 +4205,7 @@
"Swap partition detected": "Swap-Partition erkannt",
"Swappiness configuration created successfully": "Swappiness-Konfiguration erfolgreich erstellt",
"Switch GPU Mode (VM <-> LXC)": "GPU-Modus wechseln (VM <-> LXC)",
"Switch Mode": "Modus wechseln",
"Switch Script Not Found": "Switch-Skript nicht gefunden",
"Switch to GPU -> LXC (native driver mode)": "Wechseln Sie zu GPU -> LXC (nativer Treibermodus)",
"Switch to GPU -> VM (VFIO passthrough mode)": "Wechseln Sie zu GPU -> VM (VFIO-Passthrough-Modus)",
@@ -4671,6 +4677,7 @@
"Using advanced configuration": "Verwenden der erweiterten Konfiguration",
"Using default Proxmox logo...": "Standardmäßiges Proxmox-Logo wird verwendet...",
"Using existing encryption key:": "Verwendung des vorhandenen Verschlüsselungsschlüssels:",
"Utilities": "Dienstprogramme",
"Utilities Installation Menu": "Installationsmenü für Dienstprogramme",
"Utilities Menu": "Menü „Dienstprogramme“.",
"Utilities Verification": "Überprüfung der Dienstprogramme",
+8 -1
View File
@@ -452,6 +452,7 @@
"Cannot proceed with invalid export path.": "No se puede continuar con una ruta de exportación no válida.",
"Cannot proceed with invalid share name.": "No se puede continuar con un nombre compartido no válido.",
"Cannot reach Proxmox repositories": "No se puede acceder a los repositorios de Proxmox",
"Cannot reach https://download.proxmox.com (HTTPS 443). Check network, proxy or DNS.": "No se puede acceder a https://download.proxmox.com (HTTPS 443).Verifique la red, proxy o DNS.",
"Cannot reach portal:": "No se puede acceder al portal:",
"Cannot reach server": "No puede alcanzar el servidor",
"Cannot validate credentials - no shares available for testing.": "No se pueden validar las credenciales: no hay recursos compartidos disponibles para realizar pruebas.",
@@ -925,7 +926,6 @@
"Could not unmount — disk may be busy. Removing fstab entry anyway.": "No se pudo desmontar: es posible que el disco esté ocupado. Eliminando la entrada fstab de todos modos.",
"Could not update config file.": "No se pudo actualizar el archivo de configuración.",
"Could not write to:": "No se pudo escribir a:",
"Create": "Crear",
"Create Directory": "Crear directorio",
"Create GPT and one partition:": "Crea GPT y una partición:",
"Create GPT partition": "Crear partición GPT",
@@ -936,6 +936,11 @@
"Create Shared Directory": "Crear directorio compartido",
"Create Shared Directory on Host": "Crear directorio compartido en el host",
"Create Universal NFS Export": "Crear exportación NFS universal",
"Create VM System Linux": "Crear VM con Linux",
"Create VM System NAS": "Crear VM para NAS",
"Create VM System Others (based Linux)": "Crear VM para otro sistema basado en Linux",
"Create VM System Windows": "Crear VM con Windows",
"Create VM System macOS (OSX-PROXMOX)": "Crear VM con macOS (OSX-PROXMOX)",
"Create VM from template or script": "Crear VM a partir de una plantilla o script",
"Create a VM first (machine type q35 + UEFI BIOS), then run this option again.": "Primero cree una máquina virtual (tipo de máquina q35 + UEFI BIOS), luego ejecute esta opción nuevamente.",
"Create a backup of the container configuration:": "Cree una copia de seguridad de la configuración del contenedor:",
@@ -4200,6 +4205,7 @@
"Swap partition detected": "Intercambiar partición detectada",
"Swappiness configuration created successfully": "Configuración de intercambio creada exitosamente",
"Switch GPU Mode (VM <-> LXC)": "Cambiar el modo GPU (VM <-> LXC)",
"Switch Mode": "Switch Mode",
"Switch Script Not Found": "Script de cambio no encontrado",
"Switch to GPU -> LXC (native driver mode)": "Cambie a GPU -> LXC (modo de controlador nativo)",
"Switch to GPU -> VM (VFIO passthrough mode)": "Cambie a GPU -> VM (modo de paso VFIO)",
@@ -4671,6 +4677,7 @@
"Using advanced configuration": "Usando configuración avanzada",
"Using default Proxmox logo...": "Usando el logotipo predeterminado de Proxmox...",
"Using existing encryption key:": "Usando la clave de cifrado existente:",
"Utilities": "Utilidades",
"Utilities Installation Menu": "Menú de instalación de utilidades",
"Utilities Menu": "Menú de utilidades",
"Utilities Verification": "Comprobación de utilidades",
+8 -1
View File
@@ -452,6 +452,7 @@
"Cannot proceed with invalid export path.": "Impossible de poursuivre avec un chemin d'exportation non valide.",
"Cannot proceed with invalid share name.": "Impossible de continuer avec un nom de partage invalide.",
"Cannot reach Proxmox repositories": "Impossible d'accéder aux référentiels Proxmox",
"Cannot reach https://download.proxmox.com (HTTPS 443). Check network, proxy or DNS.": "Impossible d'accéder à https://download.proxmox.com (HTTPS 443).Vérifiez le réseau, le proxy ou le DNS.",
"Cannot reach portal:": "Impossible d'accéder au portail :",
"Cannot reach server": "Ne peut pas atteindre le serveur",
"Cannot validate credentials - no shares available for testing.": "Impossible de valider les informations d'identification - aucun partage disponible pour les tests.",
@@ -925,7 +926,6 @@
"Could not unmount — disk may be busy. Removing fstab entry anyway.": "Impossible de démonter : le disque est peut-être occupé. Suppression de l'entrée fstab de toute façon.",
"Could not update config file.": "Impossible de mettre à jour le fichier de configuration.",
"Could not write to:": "impossible d'écrire vers :",
"Create": "Créer",
"Create Directory": "Créer un répertoire",
"Create GPT and one partition:": "Créez GPT et une partition :",
"Create GPT partition": "Créer une partition GPT",
@@ -936,6 +936,11 @@
"Create Shared Directory": "Créer un répertoire partagé",
"Create Shared Directory on Host": "Créer un répertoire partagé sur l'hôte",
"Create Universal NFS Export": "Créer une exportation NFS universelle",
"Create VM System Linux": "Créer une VM Linux",
"Create VM System NAS": "Créer une VM pour NAS",
"Create VM System Others (based Linux)": "Créer une VM pour un autre système basé sur Linux",
"Create VM System Windows": "Créer une VM Windows",
"Create VM System macOS (OSX-PROXMOX)": "Créer une VM macOS (OSX-PROXMOX)",
"Create VM from template or script": "Créer une VM à partir d'un modèle ou d'un script",
"Create a VM first (machine type q35 + UEFI BIOS), then run this option again.": "Créez dabord une VM (type de machine q35 + BIOS UEFI), puis réexécutez cette option.",
"Create a backup of the container configuration:": "Créez une sauvegarde de la configuration du conteneur :",
@@ -4200,6 +4205,7 @@
"Swap partition detected": "Partition d'échange détectée",
"Swappiness configuration created successfully": "Configuration Swapiness créée avec succès",
"Switch GPU Mode (VM <-> LXC)": "Changer de mode GPU (VM <-> LXC)",
"Switch Mode": "Changer de mode",
"Switch Script Not Found": "Script de commutation introuvable",
"Switch to GPU -> LXC (native driver mode)": "Passer au GPU -> LXC (mode pilote natif)",
"Switch to GPU -> VM (VFIO passthrough mode)": "Passer au GPU -> VM (mode passthrough VFIO)",
@@ -4671,6 +4677,7 @@
"Using advanced configuration": "Utilisation de la configuration avancée",
"Using default Proxmox logo...": "Utilisation du logo Proxmox par défaut...",
"Using existing encryption key:": "Utilisation de la clé de chiffrement existante :",
"Utilities": "Utilitaires",
"Utilities Installation Menu": "Menu d'installation des utilitaires",
"Utilities Menu": "Menu Utilitaires",
"Utilities Verification": "Vérification des utilitaires",
+8 -1
View File
@@ -452,6 +452,7 @@
"Cannot proceed with invalid export path.": "Impossibile procedere con un percorso di esportazione non valido.",
"Cannot proceed with invalid share name.": "Impossibile procedere con un nome di condivisione non valido.",
"Cannot reach Proxmox repositories": "Impossibile raggiungere i repository Proxmox",
"Cannot reach https://download.proxmox.com (HTTPS 443). Check network, proxy or DNS.": "impossibile raggiungere https://download.proxmox.com (HTTPS 443).Controlla rete, proxy o DNS.",
"Cannot reach portal:": "Impossibile raggiungere il portale:",
"Cannot reach server": "Impossibile raggiungere il server",
"Cannot validate credentials - no shares available for testing.": "Impossibile convalidare le credenziali: nessuna condivisione disponibile per il test.",
@@ -925,7 +926,6 @@
"Could not unmount — disk may be busy. Removing fstab entry anyway.": "Impossibile smontare: il disco potrebbe essere occupato. Rimozione comunque della voce fstab.",
"Could not update config file.": "Impossibile aggiornare il file di configurazione.",
"Could not write to:": "Impossibile scrivere a:",
"Create": "Creare",
"Create Directory": "Crea directory",
"Create GPT and one partition:": "Crea GPT e una partizione:",
"Create GPT partition": "Crea partizione GPT",
@@ -936,6 +936,11 @@
"Create Shared Directory": "Crea directory condivisa",
"Create Shared Directory on Host": "Crea directory condivisa sull'host",
"Create Universal NFS Export": "Crea esportazione NFS universale",
"Create VM System Linux": "Crea VM con Linux",
"Create VM System NAS": "Crea VM per NAS",
"Create VM System Others (based Linux)": "Crea VM per un altro sistema basato su Linux",
"Create VM System Windows": "Crea VM con Windows",
"Create VM System macOS (OSX-PROXMOX)": "Crea VM con macOS (OSX-PROXMOX)",
"Create VM from template or script": "Crea VM da modello o script",
"Create a VM first (machine type q35 + UEFI BIOS), then run this option again.": "Crea prima una VM (tipo di macchina q35 + UEFI BIOS), quindi esegui nuovamente questa opzione.",
"Create a backup of the container configuration:": "Crea un backup della configurazione del contenitore:",
@@ -4200,6 +4205,7 @@
"Swap partition detected": "Partizione di swap rilevata",
"Swappiness configuration created successfully": "Configurazione Swappiness creata con successo",
"Switch GPU Mode (VM <-> LXC)": "Cambia modalità GPU (VM <-> LXC)",
"Switch Mode": "Cambia modalità",
"Switch Script Not Found": "Script del cambio non trovato",
"Switch to GPU -> LXC (native driver mode)": "Passa a GPU -> LXC (modalità driver nativo)",
"Switch to GPU -> VM (VFIO passthrough mode)": "Passa a GPU -> VM (modalità passthrough VFIO)",
@@ -4671,6 +4677,7 @@
"Using advanced configuration": "Utilizzando la configurazione avanzata",
"Using default Proxmox logo...": "Utilizzo del logo Proxmox predefinito...",
"Using existing encryption key:": "Utilizzando la chiave di crittografia esistente:",
"Utilities": "Utilità",
"Utilities Installation Menu": "Menu di installazione delle utilità",
"Utilities Menu": "Menù Utilità",
"Utilities Verification": "Verifica delle utenze",
+8 -1
View File
@@ -452,6 +452,7 @@
"Cannot proceed with invalid export path.": "Não é possível prosseguir com caminho de exportação inválido.",
"Cannot proceed with invalid share name.": "Não é possível continuar com um nome de compartilhamento inválido.",
"Cannot reach Proxmox repositories": "Não é possível acessar os repositórios Proxmox",
"Cannot reach https://download.proxmox.com (HTTPS 443). Check network, proxy or DNS.": "Não é possível acessar https://download.proxmox.com (HTTPS 443).Verifique a rede, proxy ou DNS.",
"Cannot reach portal:": "Não é possível acessar o portal:",
"Cannot reach server": "Sem contato com o servidor",
"Cannot validate credentials - no shares available for testing.": "Não é possível validar credenciais não há compartilhamentos disponíveis para teste.",
@@ -925,7 +926,6 @@
"Could not unmount — disk may be busy. Removing fstab entry anyway.": "Não foi possível desmontar — o disco pode estar ocupado. Removendo a entrada fstab de qualquer maneira.",
"Could not update config file.": "Não foi possível atualizar o arquivo de configuração.",
"Could not write to:": "Não foi possível escrever para:",
"Create": "Criar",
"Create Directory": "Criar diretório",
"Create GPT and one partition:": "Crie GPT e uma partição:",
"Create GPT partition": "Criar partição GPT",
@@ -936,6 +936,11 @@
"Create Shared Directory": "Criar diretório compartilhado",
"Create Shared Directory on Host": "Crie um diretório compartilhado no host",
"Create Universal NFS Export": "Criar exportação NFS universal",
"Create VM System Linux": "Criar VM com Linux",
"Create VM System NAS": "Criar VM para NAS",
"Create VM System Others (based Linux)": "Criar VM para outro sistema baseado em Linux",
"Create VM System Windows": "Criar VM com Windows",
"Create VM System macOS (OSX-PROXMOX)": "Criar VM com macOS (OSX-PROXMOX)",
"Create VM from template or script": "Crie VM a partir de modelo ou script",
"Create a VM first (machine type q35 + UEFI BIOS), then run this option again.": "Crie primeiro uma VM (tipo de máquina q35 + UEFI BIOS) e, em seguida, execute esta opção novamente.",
"Create a backup of the container configuration:": "Crie um backup da configuração do contêiner:",
@@ -4200,6 +4205,7 @@
"Swap partition detected": "Partição de troca detectada",
"Swappiness configuration created successfully": "Configuração de troca criada com sucesso",
"Switch GPU Mode (VM <-> LXC)": "Alternar modo GPU (VM <-> LXC)",
"Switch Mode": "Alterar modo",
"Switch Script Not Found": "Alternar script não encontrado",
"Switch to GPU -> LXC (native driver mode)": "Mude para GPU -> LXC (modo de driver nativo)",
"Switch to GPU -> VM (VFIO passthrough mode)": "Mude para GPU -> VM (modo de passagem VFIO)",
@@ -4671,6 +4677,7 @@
"Using advanced configuration": "Usando configuração avançada",
"Using default Proxmox logo...": "Usando o logotipo padrão do Proxmox...",
"Using existing encryption key:": "Usando a chave de criptografia existente:",
"Utilities": "Utilitários",
"Utilities Installation Menu": "Menu de instalação de utilitários",
"Utilities Menu": "Menu Utilitários",
"Utilities Verification": "Verificação de utilitários",
+5274
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -4077,7 +4077,7 @@ main_menu() {
4 "$(_bk_format_menu_item "$(translate "Scheduled backups and retention policies")" "")" \
5 "$(_bk_format_menu_item "$(translate "Configure backup destinations (PBS, Borg, local)")" "")" \
"" " " \
"" "\Z4───────────────────── Community Scripts ─────────────────────\Zn" \
"" "\Z4───────────────────── $(translate "Community Scripts") ─────────────────────\Zn" \
6 "$(_bk_format_menu_item "PVE Host Backup" "Helper-Scripts")" \
7 "$(_bk_format_menu_item "proxmox_toolbox" "Tontonjo")" \
8 "$(_bk_format_menu_item "proxsave" "tis24dev")" \
@@ -4101,4 +4101,4 @@ main_menu() {
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main_menu
fi
fi
+97 -11
View File
@@ -532,6 +532,58 @@ _sb_hydrate_attached_retention() {
done < <(hb_pve_prune_to_keep_env "$prune")
}
# Safe replacement for `source <env>`. The job .env is DATA (credentials
# + parameters), not code. Sourcing it as bash produces two failure modes
# reported from the field:
# - a value with spaces (e.g. `ON_CALENDAR=*-*-* 01:00:00`) is parsed
# as "assign first token, then run the rest as a command" — the
# scheduled runner dies with `01:00:00: command not found` before
# doing any work.
# - a value containing a bare `$word` under `set -u` triggers an
# unbound-variable expansion during sourcing and aborts.
# It also opens a code-execution vector (backticks / `$(...)` in a
# password would run as root at source time).
# The API (shlex.quote) and CLI (printf %q) both quote on write, so
# jobs created by current code are safe under source. Legacy jobs on
# disk are not — hence this parser.
_sb_load_env_file() {
local file="$1"
[[ -f "$file" ]] || return 1
local line key value_raw prev_u prev_f
case $- in *u*) prev_u=1 ;; *) prev_u=0 ;; esac
case $- in *f*) prev_f=1 ;; *) prev_f=0 ;; esac
while IFS= read -r line || [[ -n "$line" ]]; do
[[ -z "$line" || "$line" == \#* ]] && continue
[[ "$line" == *=* ]] || continue
key="${line%%=*}"
[[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
value_raw="${line#*=}"
unset "$key"
# Refuse command substitution — no legitimate reason for `$(...)`
# or backticks in a data file; treating them as literal is safer
# than evaluating them as root.
# shellcheck disable=SC2016 # matching literal `$(` and backtick, not expanding
if [[ "$value_raw" == *'$('* || "$value_raw" == *'`'* ]]; then
declare -gx "$key=$value_raw"
continue
fi
# Try a shell-quoted parse (handles printf %q backslash escapes and
# shlex.quote surrounding quotes). Guards: set +u so `$FOO` in an
# unquoted value doesn't abort; set -f so `*` doesn't glob-expand
# against files on disk. If eval fails (legacy unquoted value with
# spaces, unbalanced quotes, etc.), fall through to a raw literal
# assignment.
set +u
set -f
if ! eval "declare -gx $key=$value_raw" 2>/dev/null; then
unset "$key"
declare -gx "$key=$value_raw"
fi
(( prev_u )) && set -u
(( prev_f )) || set +f
done <"$file"
}
main() {
local job_id="${1:-}"
[[ -z "$job_id" ]] && { echo "Usage: $0 <job_id>" >&2; exit 1; }
@@ -539,27 +591,61 @@ main() {
local job_file="${JOBS_DIR}/${job_id}.env"
[[ -f "$job_file" ]] || { echo "Job not found: $job_id" >&2; exit 1; }
# shellcheck source=/dev/null
source "$job_file"
_sb_load_env_file "$job_file"
# Attached jobs: re-read retention from the PVE parent live (see
# _sb_hydrate_attached_retention above for the why). Standalone
# jobs keep whatever KEEP_* the .env has.
_sb_hydrate_attached_retention
local lock_file="${LOCK_DIR}/proxmenux-backup-${job_id}.lock"
if command -v flock >/dev/null 2>&1; then
exec 9>"$lock_file" || exit 1
if ! flock -n 9; then
echo "Another run is active for job ${job_id}" >&2
exit 1
fi
fi
# Create log_file and summary_file BEFORE the flock so any early
# failure (lock contention, missing tools, unreadable env) surfaces
# in the runner log the Monitor polls, instead of being lost to
# stderr/DEVNULL and leaving the UI stuck on "Waiting for runner
# to start…" forever.
local ts log_file stage_root summary_file
ts="$(date +%Y%m%d_%H%M%S)"
log_file="${LOG_DIR}/${job_id}-${ts}.log"
summary_file="${LOG_DIR}/${job_id}-last.status"
local lock_file="${LOCK_DIR}/proxmenux-backup-${job_id}.lock"
if command -v flock >/dev/null 2>&1; then
if ! exec 9>"$lock_file"; then
{
echo "=== Scheduled backup job ${job_id} aborted at $(date -Iseconds) ==="
echo "Cannot open lock file: $lock_file"
echo "Check that ${LOCK_DIR} exists and is writable by root."
} >"$log_file"
{
echo "JOB_ID=${job_id}"
echo "RUN_AT=$(date -Iseconds)"
echo "RESULT=failed"
echo "REASON=lock_open_failed"
} >"$summary_file"
exit 1
fi
if ! flock -n 9; then
{
echo "=== Scheduled backup job ${job_id} aborted at $(date -Iseconds) ==="
echo "Another run is already active for this job (lock held: $lock_file)."
echo ""
echo "Possible causes:"
echo " - A previous run is still in progress (check with: ps auxf | grep run_scheduled_backup)."
echo " - A previous run hung and left the lock behind. Kill the stale"
echo " process (if any) and remove the lock: rm $lock_file"
echo ""
echo "This run did NOT execute a backup."
} >"$log_file"
{
echo "JOB_ID=${job_id}"
echo "RUN_AT=$(date -Iseconds)"
echo "RESULT=failed"
echo "REASON=another_run_active"
} >"$summary_file"
exit 1
fi
fi
stage_root="$(mktemp -d /tmp/proxmenux-sched-stage.XXXXXX)"
{
+20 -2
View File
@@ -104,8 +104,26 @@ update_pve_safe() {
return 1
fi
if ! ping -c 1 download.proxmox.com >/dev/null 2>&1; then
msg_error "$(translate "Cannot reach Proxmox repositories")"
# Reachability check: HEAD https://download.proxmox.com over the same
# transport apt-get update will use (HTTPS 443). Previously a single
# ICMP ping — hosts behind firewalls that filter ICMP but allow 443
# (typical corporate / cloud-provider setups) hit a false negative
# and the update aborted even though the repository was reachable.
# Two attempts with a short pause absorb transient network glitches
# without adding perceptible latency when the network is healthy.
_repo_reachable() {
local url="https://download.proxmox.com/"
local attempt
for attempt in 1 2; do
if curl -sfI --connect-timeout 5 --max-time 10 -o /dev/null "$url"; then
return 0
fi
[[ $attempt -eq 1 ]] && sleep 1
done
return 1
}
if ! _repo_reachable; then
msg_error "$(translate "Cannot reach https://download.proxmox.com (HTTPS 443). Check network, proxy or DNS.")"
echo -e
msg_success "$(translate "Press Enter to return to menu...")"
read -r
+19 -17
View File
@@ -24,7 +24,7 @@
# - Change Language
# Only on the Translation install type (venv +
# config.json.language present). Languages: en / es / fr /
# de / it / pt.
# de / it / pt / sk.
#
# - Show Version Information
# Always shown. Reports installed components, files,
@@ -64,6 +64,7 @@ fi
load_language
initialize_cache
BACKTITLE="$(translate "$BACKTITLE")"
# ==========================================================
@@ -377,29 +378,29 @@ toggle_monitor_service() {
local status=$(check_monitor_status)
if [ "$status" = "not_installed" ]; then
dialog --clear --backtitle "ProxMenux Configuration" \
dialog --clear --backtitle "$BACKTITLE" \
--title "$(translate "ProxMenux Monitor")" \
--msgbox "\n\n$(translate "ProxMenux Monitor is not installed.")" 10 50
return
fi
if [ "$status" = "active" ]; then
if dialog --clear --backtitle "ProxMenux Configuration" \
if dialog --clear --backtitle "$BACKTITLE" \
--title "$(translate "Deactivate Monitor")" \
--yesno "\n$(translate "Do you want to deactivate ProxMenux Monitor?")" 8 60; then
systemctl stop "$MONITOR_SERVICE" 2>/dev/null
systemctl disable "$MONITOR_SERVICE" 2>/dev/null
dialog --clear --backtitle "ProxMenux Configuration" \
dialog --clear --backtitle "$BACKTITLE" \
--title "$(translate "Monitor Deactivated")" \
--msgbox "\n\n$(translate "ProxMenux Monitor has been deactivated.")" 10 50
fi
else
if dialog --clear --backtitle "ProxMenux Configuration" \
if dialog --clear --backtitle "$BACKTITLE" \
--title "$(translate "Activate Monitor")" \
--yesno "\n$(translate "Do you want to activate ProxMenux Monitor?")" 8 60; then
systemctl enable "$MONITOR_SERVICE" 2>/dev/null
systemctl start "$MONITOR_SERVICE" 2>/dev/null
dialog --clear --backtitle "ProxMenux Configuration" \
dialog --clear --backtitle "$BACKTITLE" \
--title "$(translate "Monitor Activated")" \
--msgbox "\n\n$(translate "ProxMenux Monitor has been activated.")" 10 50
fi
@@ -597,7 +598,7 @@ show_config_menu() {
option_actions[$option_num]="return_main"
# Show menu
OPTION=$(dialog --clear --backtitle "ProxMenux Configuration" \
OPTION=$(dialog --clear --backtitle "$BACKTITLE" \
--title "$(translate "Configuration Menu")" \
--menu "$(translate "Select an option:")" 20 70 10 \
"${menu_options[@]}" 3>&1 1>&2 2>&3)
@@ -635,18 +636,19 @@ show_config_menu() {
# ==========================================================
change_language() {
local new_language
new_language=$(dialog --clear --backtitle "ProxMenux Configuration" \
new_language=$(dialog --clear --backtitle "$BACKTITLE" \
--title "$(translate "Change Language")" \
--menu "$(translate "Select a new language for the menu:")" 20 60 6 \
--menu "$(translate "Select a new language for the menu:")" 21 60 7 \
"en" "$(translate "English")" \
"es" "$(translate "Spanish")" \
"fr" "$(translate "French")" \
"de" "$(translate "German")" \
"it" "$(translate "Italian")" \
"pt" "$(translate "Portuguese")" 3>&1 1>&2 2>&3)
"pt" "$(translate "Portuguese")" \
"sk" "Slovenčina" 3>&1 1>&2 2>&3)
if [ -z "$new_language" ]; then
dialog --clear --backtitle "ProxMenux Configuration" \
dialog --clear --backtitle "$BACKTITLE" \
--title "$(translate "Language Change")" \
--msgbox "\n\n$(translate "No language selected.")" 10 50
return
@@ -660,7 +662,7 @@ change_language() {
echo "{\"language\": \"$new_language\"}" > "$CONFIG_FILE"
fi
dialog --clear --backtitle "ProxMenux Configuration" \
dialog --clear --backtitle "$BACKTITLE" \
--title "$(translate "Language Change")" \
--msgbox "\n\n$(translate "Language changed to") $new_language" 10 50
@@ -738,7 +740,7 @@ show_version_info() {
# Display information in a scrollable text box
tmpfile=$(mktemp)
echo -e "$info_message" > "$tmpfile"
dialog --clear --backtitle "ProxMenux Configuration" \
dialog --clear --backtitle "$BACKTITLE" \
--title "$(translate "ProxMenux Information")" \
--textbox "$tmpfile" 25 80
rm -f "$tmpfile"
@@ -746,7 +748,7 @@ show_version_info() {
# ==========================================================
uninstall_proxmenu() {
if ! dialog --clear --backtitle "ProxMenux Configuration" \
if ! dialog --clear --backtitle "$BACKTITLE" \
--title "Uninstall ProxMenux" \
--yesno "\n$(translate "Are you sure you want to uninstall ProxMenux?")" 8 60; then
return
@@ -754,7 +756,7 @@ uninstall_proxmenu() {
local deps_to_remove=""
deps_to_remove=$(dialog --clear --backtitle "ProxMenux Configuration" \
deps_to_remove=$(dialog --clear --backtitle "$BACKTITLE" \
--title "Remove Dependencies" \
--checklist "Select dependencies to remove:" 12 60 2 \
"dialog" "Interactive dialog boxes" OFF \
@@ -803,7 +805,7 @@ uninstall_proxmenu() {
echo "100" ; echo "Uninstallation complete!"
sleep 1
) | dialog --clear --backtitle "ProxMenux Configuration" \
) | dialog --clear --backtitle "$BACKTITLE" \
--title "Uninstalling ProxMenux" \
--gauge "Starting uninstallation..." 10 60 0
@@ -814,7 +816,7 @@ uninstall_proxmenu() {
fi
final_message+="Thank you for using ProxMenux!"
dialog --clear --backtitle "ProxMenux Configuration" \
dialog --clear --backtitle "$BACKTITLE" \
--title "Uninstallation Complete" \
--msgbox "$final_message" 12 60
clear
+6 -6
View File
@@ -138,14 +138,14 @@ while true; do
OS_TYPE=$(dialog --colors --backtitle "ProxMenux" \
--title "$(translate "Select System Type")" \
--menu "\n$(translate "Choose the type of virtual system to install:")" 20 70 10 \
1 "$(translate "Create") VM System NAS" \
2 "$(translate "Create") VM System Windows" \
3 "$(translate "Create") VM System Linux" \
1 "$(translate "Create VM System NAS")" \
2 "$(translate "Create VM System Windows")" \
3 "$(translate "Create VM System Linux")" \
"" "" \
"" "\Z4───────────────── Community Scripts ─────────────────\Zn" \
"" "\Z4───────────────── $(translate "Community Scripts") ─────────────────\Zn" \
"" "" \
4 "$(translate "Create") VM System macOS (OSX-PROXMOX)" \
5 "$(translate "Create") VM System Others (based Linux)" \
4 "$(translate "Create VM System macOS (OSX-PROXMOX)")" \
5 "$(translate "Create VM System Others (based Linux)")" \
"" "" \
6 "$(translate "Return to Main Menu")" \
3>&1 1>&2 2>&3)
+4 -4
View File
@@ -32,16 +32,16 @@ while true; do
"1" "$(translate "Install/Update NVIDIA Drivers (Host + LXC)")" \
"2" "$(translate "Install/Update Coral TPU on Host")" \
"" "\Zb\Z4 LXC\Zn" \
"3" "$(translate "Add GPU to LXC (Intel | AMD | NVIDIA)") \Zb\Z4Switch Mode\Zn" \
"3" "$(translate "Add GPU to LXC (Intel | AMD | NVIDIA)") \Zb\Z4$(translate "Switch Mode")\Zn" \
"4" "$(translate "Add Coral TPU to LXC")" \
"" "\Zb\Z4 VM\Zn" \
"5" "$(translate "Add GPU to VM (Intel | AMD | NVIDIA)") \Zb\Z4Switch Mode\Zn" \
"5" "$(translate "Add GPU to VM (Intel | AMD | NVIDIA)") \Zb\Z4$(translate "Switch Mode")\Zn" \
"" "" \
"" "\Z4──────────────────── SWICHT MODE ───────────────────────\Zn" \
"" "\Z4──────────────────── $(translate "Switch Mode") ───────────────────────\Zn" \
"" "" \
"6" "$(translate "Switch GPU Mode (VM <-> LXC)")" \
"" "" \
"" "\Z4───────────────────── Utilities ────────────────────────\Zn" \
"" "\Z4───────────────────── $(translate "Utilities") ────────────────────────\Zn" \
"7" "$(translate "Manual CLI Guide (GPU/TPU)")" \
"0" "$(translate "Return to Main Menu")" \
2>&1 >/dev/tty
+3 -4
View File
@@ -387,7 +387,7 @@ search_and_filter_scripts() {
local selected
selected=$(dialog --colors --backtitle "ProxMenux" \
--title "$title" \
--menu "$(translate "Select a script or action:"):" \
--menu "$(translate "Select a script or action:")" \
22 75 15 "${menu_items[@]}" 3>&1 1>&2 2>&3)
[[ $? -ne 0 ]] && return
@@ -402,7 +402,7 @@ search_and_filter_scripts() {
count=$(echo "$filtered_json" | jq 'group_by(.slug) | length')
continue
;;
"back"|"")
"")
return
;;
*)
@@ -439,10 +439,9 @@ while true; do
MENU_ITEMS+=("$local_idx" "$padded_name $padded_count")
((local_idx++))
done
SELECTED_IDX=$(dialog --backtitle "ProxMenux" \
--title "Proxmox VE Helper-Scripts" \
--menu "$(translate "Select a category or search for scripts:"):" \
--menu "$(translate "Select a category or search for scripts:")" \
22 75 15 "${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) || {
dialog --clear --title "ProxMenux" \
--msgbox "\n\n$(translate "Visit the website to discover more scripts, stay updated with the latest updates, and support the project:")\n\nhttps://community-scripts.github.io/ProxmoxVE" 15 70
+2 -2
View File
@@ -322,7 +322,7 @@ show_menu() {
menu_items+=("" "")
menu_items+=("" "\Z4───────────────────── Community Scripts ──────────────────────\Zn")
menu_items+=("" "\Z4───────────────────── $(translate "Community Scripts") ──────────────────────\Zn")
for script in "${COMMUNITY_SCRIPTS[@]}"; do
IFS='|' read -r name source command <<< "$script"
@@ -344,7 +344,7 @@ show_menu() {
script_selection=$(dialog --clear --colors \
--backtitle "ProxMenux" \
--title "$(translate "Post-Installation Scripts")" \
--menu "\n$(translate "Select a post-installation script:"):\n" \
--menu "\n$(translate "Select a post-installation script:")\n" \
24 84 16 \
"${menu_items[@]}" 2>&1 1>&3)
exit_status=$?
+2 -2
View File
@@ -1244,7 +1244,7 @@ show_menu() {
menu_items+=("" "")
menu_items+=("" "\Z4───────────────────── Community Scripts ──────────────────────\Zn")
menu_items+=("" "\Z4───────────────────── $(translate "Community Scripts") ──────────────────────\Zn")
for script in "${COMMUNITY_SCRIPTS[@]}"; do
IFS='|' read -r name source command <<< "$script"
@@ -1266,7 +1266,7 @@ show_menu() {
script_selection=$(dialog --clear --colors \
--backtitle "ProxMenux" \
--title "$(translate "Network Management")" \
--menu "\n$(translate "Select a network management option:"):\n" \
--menu "\n$(translate "Select a network management option:")\n" \
28 82 19 \
"${menu_items[@]}" 2>&1 1>&3)
exit_status=$?
+1 -1
View File
@@ -40,7 +40,7 @@ while true; do
"" "\Zb\Z4 LXC\Zn" \
"4" "$(translate "Import Disk to LXC")" \
"" "" \
"" "\Z4────────────────────── Utilities ───────────────────────\Zn" \
"" "\Z4────────────────────── $(translate "Utilities") ───────────────────────\Zn" \
"5" "$(translate "Format / Wipe Physical Disk (Safe)")" \
"6" "$(translate "SMART Disk Health & Test")" \
"7" "$(translate "Manual CLI Guide (Disk and Storage Manager)")" \
+1 -1
View File
@@ -984,7 +984,7 @@ show_uninstall_menu() {
ensure_tools_json
migrate_installed_tools
mapfile -t tools_installed < <(jq -r 'to_entries | map(select(.value==true or .value.installed==true)) | .[].key' "$TOOLS_JSON")
mapfile -t tools_installed < <(jq -r 'to_entries | map(select(.value==true or .value.installed?==true)) | .[].key' "$TOOLS_JSON")
if [[ ${#tools_installed[@]} -eq 0 ]]; then
dialog --backtitle "ProxMenux" --title "ProxMenux" \
+7
View File
@@ -169,6 +169,13 @@ FAIL_COUNT=0
log "Found $TOTAL_DISKS disk(s) to test"
for disk in $DISK_LIST; do
# Accept both full paths and basenames. Older cron entries written
# before the Monitor prepended /dev/ passed disk names as bare
# basenames (sdb, nvme0n1), which then failed the block-device
# check below. Normalising here keeps those legacy entries working
# after the update without forcing the operator to recreate them.
[[ "$disk" != /* ]] && disk="/dev/$disk"
# Skip if disk doesn't exist
if [[ ! -b "$disk" ]]; then
log "WARNING: Disk $disk not found, skipping"
+1 -1
View File
@@ -189,7 +189,7 @@ function select_nas_iso() {
"6" "Rockstor VM (openSUSE based)"
"7" "ZimaOS VM (Proxmox-zimaos)"
"" ""
"" "\Z4───────────────── Community Scripts ─────────────────\Zn"
"" "\Z4───────────────── $(translate "Community Scripts") ─────────────────\Zn"
"" ""
"8" "Umbrel OS VM (Helper-Scripts)"
"9" "$(translate "Return to Main Menu")"
+1 -1
View File
@@ -1 +1 @@
1.2.4
1.2.4
@@ -63,6 +63,7 @@ export default async function MonitorApiPage({
auth: { rows: EndpointRow[]; items: string[] }
conventions: { items: string[] }
system: { rows: EndpointRow[] }
actions: { rows: EndpointRow[] }
health: { rows: EndpointRow[] }
storage: { rows: EndpointRow[] }
network: { rows: EndpointRow[] }
@@ -81,6 +82,7 @@ export default async function MonitorApiPage({
const authItems = api.auth.items
const conventionsItems = api.conventions.items
const systemRows = api.system.rows
const actionsRows = api.actions.rows
const healthRows = api.health.rows
const storageRows = api.storage.rows
const networkRows = api.network.rows
@@ -181,6 +183,29 @@ export default async function MonitorApiPage({
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("system.heading")}</h2>
{endpointTable(systemRows, "system.rows")}
<h2 id="actions" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("actions.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("actions.intro", { code })}</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("actions.shapeTitle")}</h3>
<p className="mb-2 text-gray-800 leading-relaxed">{t("actions.shapeIntro")}</p>
<CopyableCode code={t("actions.shapeCode")} className="my-4" />
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("actions.concurrencyTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("actions.concurrencyBody", { code })}</p>
{endpointTable(actionsRows, "actions.rows")}
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("actions.syncVsAsyncTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("actions.syncVsAsyncBody", { code })}</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("actions.curlTitle")}</h3>
<p className="mb-2 text-gray-800 leading-relaxed">{t("actions.curlBody")}</p>
<CopyableCode code={t("actions.curlCode")} className="my-4" />
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("actions.haTitle")}</h3>
<p className="mb-2 text-gray-800 leading-relaxed">{t.rich("actions.haBody", { code })}</p>
<CopyableCode code={t("actions.haCode")} className="my-4" />
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("health.heading")}</h2>
{endpointTable(healthRows, "health.rows")}
<p className="mb-4 text-gray-800 leading-relaxed">
+1
View File
@@ -29,6 +29,7 @@ export function LanguageSwitcher() {
const labels: Record<string, string> = {
en: t("en"),
es: t("es"),
sk: t("sk"),
}
return (
+1 -1
View File
@@ -19,7 +19,7 @@ import { defineRouting } from "next-intl/routing"
* `CONTRIBUTING-TRANSLATIONS.md`.
*/
export const routing = defineRouting({
locales: ["en", "es"],
locales: ["en", "es", "sk"],
defaultLocale: "en",
localePrefix: "always",
})
+3 -2
View File
@@ -27,7 +27,8 @@
"language": {
"switcher": "Language",
"en": "English",
"es": "Español"
"es": "Español",
"sk": "Slovenčina"
},
"resources": {
"documentation": {
@@ -226,4 +227,4 @@
"ogTail": "Subscribe via RSS or check back for new versions."
}
}
}
}
@@ -100,6 +100,85 @@
}
]
},
"actions": {
"heading": "System actions",
"intro": "Fire-and-forget endpoints that trigger the same operations the operator would run from the Monitor UI or the shell menu — designed for Home Assistant, Homepage, Ansible, custom dashboards and any other automation that needs to reach into the host over HTTP. Every mutating route requires a token with <code>full_admin</code> scope; read-only <code>read_only</code> tokens can still poll the <code>.../status</code> endpoints without triggering anything. Each action returns 202 with a state snapshot; poll the matching <code>.../status</code> to observe progress.",
"shapeTitle": "Common response shape",
"shapeIntro": "All action endpoints return the same JSON object — one shape to parse from any client:",
"shapeCode": "{\n \"unit\": \"proxmenux-action-pve-update.service\",\n \"state\": \"idle | running | success | failed | cancelled\",\n \"started_at\": \"Sat 2026-08-01 11:01:32 CEST\",\n \"finished_at\": null,\n \"exit_code\": null,\n \"result\": null\n}",
"concurrencyTitle": "Concurrency and cancellation",
"concurrencyBody": "A second POST while a run is in flight returns 409 with the current running state. Cancel an in-flight run with a DELETE to the action's base URL; the state moves to <code>cancelled</code> and timestamps are preserved so the caller can still see when it was triggered and stopped. The last terminal state (success / failed / cancelled) is remembered until the next run replaces it.",
"rows": [
{
"endpoint": "/api/system/power/reboot",
"method": "POST",
"use": "Reboot the Proxmox host. Fire-and-forget — the HTTP connection drops when systemd starts the shutdown sequence."
},
{
"endpoint": "/api/system/power/reboot/status",
"method": "GET",
"use": "State of the last / current reboot action."
},
{
"endpoint": "/api/system/power/shutdown",
"method": "POST",
"use": "Power off the Proxmox host. Useful as an effect wired to a UPS shutdown signal."
},
{
"endpoint": "/api/system/power/shutdown/status",
"method": "GET",
"use": "State of the last / current shutdown action."
},
{
"endpoint": "/api/system/pve-update/run",
"method": "POST",
"use": "Trigger the safe PVE update flow (the same one the Health Monitor's Update Now button and the shell menu run — delegates to update-pve-safe.sh)."
},
{
"endpoint": "/api/system/pve-update/status",
"method": "GET",
"use": "State of the last / current PVE update run."
},
{
"endpoint": "/api/system/pve-update",
"method": "DELETE",
"use": "Cancel a PVE update in progress (SIGTERM to the running script)."
},
{
"endpoint": "/api/proxmenux/self-update/run",
"method": "POST",
"use": "Update ProxMenux itself by piping the canonical stable installer. Runs in its own systemd unit so the update completes across the proxmenux-monitor restart the installer performs."
},
{
"endpoint": "/api/proxmenux/self-update/status",
"method": "GET",
"use": "State of the last / current ProxMenux self-update run."
},
{
"endpoint": "/api/vms/<vmid>/control",
"method": "POST",
"use": "Start / stop / shutdown / reboot a VM or LXC container (the same operations the Monitor's VM & LXC modal exposes). Body: {\"action\": \"start|stop|shutdown|reboot\"}. Synchronous — returns the outcome directly with no polling needed."
},
{
"endpoint": "/api/vms/<vmid>/backup",
"method": "POST",
"use": "Create a vzdump backup of a VM or LXC. Body (all optional except when the defaults don't match your storage layout): {\"storage\": \"<pve-storage>\", \"mode\": \"snapshot|suspend|stop\", \"compress\": \"zstd|lzo|gz|none\", \"protected\": true, \"notes\": \"…\", \"notification\": \"auto|always|failure|never\", \"pbs_change_detection\": \"default|legacy|data\"}. Returns the PVE task UPID."
},
{
"endpoint": "/api/vms/<vmid>/backups",
"method": "GET",
"use": "List previous backups for a VM or LXC across all reachable storages."
}
],
"syncVsAsyncTitle": "Two flavours of action",
"syncVsAsyncBody": "System-level actions (host power, PVE update, ProxMenux self-update) can take minutes — they run in their own transient systemd unit and expose a <code>.../status</code> endpoint plus a <code>DELETE</code> to cancel. VM / LXC actions (start, stop, shutdown, reboot, backup) are fast and use the fire-and-return style the Monitor's UI already exposes: the POST returns the outcome directly. Both flavours share the same authentication model (JWT or long-lived API token).",
"curlTitle": "curl example",
"curlBody": "Fire a PVE update and poll for completion — the exact same sequence a Home Assistant automation or an Ansible playbook would run:",
"curlCode": "TOKEN=<your full_admin token>\n\ncurl -sSf -X POST \\\n -H \"Authorization: Bearer $TOKEN\" \\\n https://<host>:8008/api/system/pve-update/run\n\nwhile true; do\n state=$(curl -sSf -H \"Authorization: Bearer $TOKEN\" \\\n https://<host>:8008/api/system/pve-update/status | jq -r .state)\n [ \"$state\" != \"running\" ] && break\n sleep 30\ndone\necho \"final state: $state\"",
"haTitle": "Home Assistant integration snippet",
"haBody": "A <code>rest</code> sensor polls the state and a <code>rest_command</code> triggers the update. Wire both to a dashboard button and to any automation you like:",
"haCode": "# configuration.yaml\nsensor:\n - platform: rest\n resource: https://pve.local:8008/api/system/pve-update/status\n name: pve_update_state\n value_template: \"{{ value_json.state }}\"\n scan_interval: 60\n headers:\n Authorization: \"Bearer !secret proxmenux_token\"\n\nrest_command:\n pve_update:\n url: https://pve.local:8008/api/system/pve-update/run\n method: POST\n headers:\n Authorization: \"Bearer !secret proxmenux_token\"\n\n # Start VM 100 (works for LXC too — same endpoint)\n vm_100_start:\n url: https://pve.local:8008/api/vms/100/control\n method: POST\n content_type: 'application/json'\n payload: '{\"action\": \"start\"}'\n headers:\n Authorization: \"Bearer !secret proxmenux_token\"\n\n # Backup VM 100 to the 'pbs-main' storage\n vm_100_backup:\n url: https://pve.local:8008/api/vms/100/backup\n method: POST\n content_type: 'application/json'\n payload: '{\"storage\": \"pbs-main\", \"mode\": \"snapshot\", \"compress\": \"zstd\"}'\n headers:\n Authorization: \"Bearer !secret proxmenux_token\""
},
"health": {
"heading": "Health Monitor",
"rows": [
+3 -2
View File
@@ -27,7 +27,8 @@
"language": {
"switcher": "Idioma",
"en": "English",
"es": "Español"
"es": "Español",
"sk": "Slovenčina"
},
"resources": {
"documentation": {
@@ -226,4 +227,4 @@
"ogTail": "Suscríbete vía RSS o vuelve a comprobarlo para nuevas versiones."
}
}
}
}
@@ -100,6 +100,85 @@
}
]
},
"actions": {
"heading": "Acciones del sistema",
"intro": "Endpoints fire-and-forget que disparan las mismas operaciones que el usuario ejecutaría desde la interfaz del Monitor o desde el menú shell — pensados para Home Assistant, Homepage, Ansible, paneles personalizados y cualquier otra automatización que necesite alcanzar al host por HTTP. Toda ruta que muta el estado requiere un token con scope <code>full_admin</code>; los tokens <code>read_only</code> pueden consultar los endpoints <code>.../status</code> sin disparar nada. Cada acción devuelve 202 con una instantánea del estado; el cliente hace polling al <code>.../status</code> correspondiente para observar el progreso.",
"shapeTitle": "Formato de respuesta común",
"shapeIntro": "Todos los endpoints de acción devuelven el mismo objeto JSON — una sola forma que parsear desde cualquier cliente:",
"shapeCode": "{\n \"unit\": \"proxmenux-action-pve-update.service\",\n \"state\": \"idle | running | success | failed | cancelled\",\n \"started_at\": \"Sat 2026-08-01 11:01:32 CEST\",\n \"finished_at\": null,\n \"exit_code\": null,\n \"result\": null\n}",
"concurrencyTitle": "Concurrencia y cancelación",
"concurrencyBody": "Un segundo POST mientras una ejecución está en curso devuelve 409 con el estado actual. Cancela una ejecución en curso con un DELETE a la URL base de la acción; el estado pasa a <code>cancelled</code> y los timestamps se conservan para que el cliente vea cuándo se disparó y se detuvo. El último estado terminal (success / failed / cancelled) se recuerda hasta que la siguiente ejecución lo reemplace.",
"rows": [
{
"endpoint": "/api/system/power/reboot",
"method": "POST",
"use": "Reiniciar el host Proxmox. Fire-and-forget — la conexión HTTP cae cuando systemd inicia la secuencia de apagado."
},
{
"endpoint": "/api/system/power/reboot/status",
"method": "GET",
"use": "Estado de la última / actual acción de reinicio."
},
{
"endpoint": "/api/system/power/shutdown",
"method": "POST",
"use": "Apagar el host Proxmox. Útil como efecto conectado a una señal de apagado del SAI."
},
{
"endpoint": "/api/system/power/shutdown/status",
"method": "GET",
"use": "Estado de la última / actual acción de apagado."
},
{
"endpoint": "/api/system/pve-update/run",
"method": "POST",
"use": "Disparar el flujo seguro de actualización de PVE (el mismo que ejecuta el botón Update Now del Health Monitor y el menú shell — delega en update-pve-safe.sh)."
},
{
"endpoint": "/api/system/pve-update/status",
"method": "GET",
"use": "Estado de la última / actual ejecución de actualización de PVE."
},
{
"endpoint": "/api/system/pve-update",
"method": "DELETE",
"use": "Cancelar una actualización de PVE en curso (SIGTERM al script en ejecución)."
},
{
"endpoint": "/api/proxmenux/self-update/run",
"method": "POST",
"use": "Actualizar el propio ProxMenux canalizando el instalador estable canónico. Corre en su propia unit systemd para que la actualización complete a pesar del reinicio de proxmenux-monitor que el instalador realiza."
},
{
"endpoint": "/api/proxmenux/self-update/status",
"method": "GET",
"use": "Estado de la última / actual ejecución de self-update de ProxMenux."
},
{
"endpoint": "/api/vms/<vmid>/control",
"method": "POST",
"use": "Encender / apagar / apagar limpio / reiniciar una VM o contenedor LXC (las mismas operaciones que expone la modal VM & LXC del Monitor). Body: {\"action\": \"start|stop|shutdown|reboot\"}. Síncrono — devuelve el resultado directo sin necesidad de polling."
},
{
"endpoint": "/api/vms/<vmid>/backup",
"method": "POST",
"use": "Crear un backup vzdump de una VM o LXC. Body (todo opcional excepto cuando los defaults no encajan con tu layout de storage): {\"storage\": \"<pve-storage>\", \"mode\": \"snapshot|suspend|stop\", \"compress\": \"zstd|lzo|gz|none\", \"protected\": true, \"notes\": \"…\", \"notification\": \"auto|always|failure|never\", \"pbs_change_detection\": \"default|legacy|data\"}. Devuelve el UPID de la tarea PVE."
},
{
"endpoint": "/api/vms/<vmid>/backups",
"method": "GET",
"use": "Listar backups anteriores de una VM o LXC en todos los storages accesibles."
}
],
"syncVsAsyncTitle": "Dos estilos de acción",
"syncVsAsyncBody": "Las acciones a nivel de sistema (power del host, PVE update, self-update de ProxMenux) pueden tardar minutos — corren en su propia unit systemd transitoria y exponen un endpoint <code>.../status</code> más un <code>DELETE</code> para cancelar. Las acciones VM / LXC (start, stop, shutdown, reboot, backup) son rápidas y usan el estilo fire-and-return que la UI del Monitor ya expone: el POST devuelve el resultado directo. Ambos estilos comparten el mismo modelo de autenticación (JWT o token de API de larga duración).",
"curlTitle": "Ejemplo con curl",
"curlBody": "Disparar una actualización de PVE y hacer polling hasta que termine — exactamente la misma secuencia que ejecutaría una automatización de Home Assistant o un playbook de Ansible:",
"curlCode": "TOKEN=<tu token full_admin>\n\ncurl -sSf -X POST \\\n -H \"Authorization: Bearer $TOKEN\" \\\n https://<host>:8008/api/system/pve-update/run\n\nwhile true; do\n state=$(curl -sSf -H \"Authorization: Bearer $TOKEN\" \\\n https://<host>:8008/api/system/pve-update/status | jq -r .state)\n [ \"$state\" != \"running\" ] && break\n sleep 30\ndone\necho \"estado final: $state\"",
"haTitle": "Snippet de integración con Home Assistant",
"haBody": "Un sensor <code>rest</code> hace polling del estado y un <code>rest_command</code> dispara la actualización. Conecta ambos a un botón del dashboard y a las automatizaciones que quieras:",
"haCode": "# configuration.yaml\nsensor:\n - platform: rest\n resource: https://pve.local:8008/api/system/pve-update/status\n name: pve_update_state\n value_template: \"{{ value_json.state }}\"\n scan_interval: 60\n headers:\n Authorization: \"Bearer !secret proxmenux_token\"\n\nrest_command:\n pve_update:\n url: https://pve.local:8008/api/system/pve-update/run\n method: POST\n headers:\n Authorization: \"Bearer !secret proxmenux_token\"\n\n # Encender VM 100 (funciona igual para LXC — mismo endpoint)\n vm_100_start:\n url: https://pve.local:8008/api/vms/100/control\n method: POST\n content_type: 'application/json'\n payload: '{\"action\": \"start\"}'\n headers:\n Authorization: \"Bearer !secret proxmenux_token\"\n\n # Backup de la VM 100 al storage 'pbs-main'\n vm_100_backup:\n url: https://pve.local:8008/api/vms/100/backup\n method: POST\n content_type: 'application/json'\n payload: '{\"storage\": \"pbs-main\", \"mode\": \"snapshot\", \"compress\": \"zstd\"}'\n headers:\n Authorization: \"Bearer !secret proxmenux_token\""
},
"health": {
"heading": "Monitor de salud",
"rows": [

Some files were not shown because too many files have changed in this diff Show More