mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-07 16:26:23 +00:00
Merge pull request #277 from Vaso73/feature/monitor-dashboard-i18n-v2
Complete Slovak i18n for the Monitor dashboard
This commit is contained in:
@@ -5,6 +5,7 @@ import { GeistMono } from "geist/font/mono"
|
||||
import { ThemeProvider } from "../components/theme-provider"
|
||||
import { PwaRegister } from "../components/pwa-register"
|
||||
import { PwaInstallPrompt } from "../components/pwa-install-prompt"
|
||||
import { I18nProvider } from "../lib/i18n/provider"
|
||||
import { Suspense } from "react"
|
||||
import "./globals.css"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -5,8 +5,10 @@ import { ProxmoxDashboard } from "../components/proxmox-dashboard"
|
||||
import { Login } from "../components/login"
|
||||
import { AuthSetup } from "../components/auth-setup"
|
||||
import { getApiUrl } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
export default function Home() {
|
||||
const t = useT()
|
||||
const [authStatus, setAuthStatus] = useState<{
|
||||
loading: boolean
|
||||
authEnabled: boolean
|
||||
@@ -113,8 +115,8 @@ export default function Home() {
|
||||
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
||||
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">Loading...</div>
|
||||
<p className="text-xs text-muted-foreground">Connecting to ProxMenux Monitor</p>
|
||||
<div className="text-sm font-medium text-foreground">{t("app.loading")}</div>
|
||||
<p className="text-xs text-muted-foreground">{t("app.connecting")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
import { APP_VERSION } from "./release-notes-modal"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
// Issue #191: a dedicated About tab. Centralises project metadata
|
||||
// (version, license, author) and every external link the project
|
||||
@@ -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 & License
|
||||
{t("about.support.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
ProxMenux is free and open source under the GPL-3.0 license. If it'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>
|
||||
|
||||
@@ -7,12 +7,14 @@ import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Shield, Lock, User, AlertCircle, Eye, EyeOff, Upload, Trash2 } from "lucide-react"
|
||||
import { getApiUrl } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface AuthSetupProps {
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
const t = useT()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [step, setStep] = useState<"choice" | "setup">("choice")
|
||||
const [username, setUsername] = useState("")
|
||||
@@ -74,7 +76,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Failed to skip authentication")
|
||||
throw new Error(data.error || t("authSetup.skipFailed"))
|
||||
}
|
||||
|
||||
if (data.auth_declined) {
|
||||
@@ -86,7 +88,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
onComplete()
|
||||
} catch (err) {
|
||||
console.error("Auth skip error:", err)
|
||||
setError(err instanceof Error ? err.message : "Failed to save preference")
|
||||
setError(err instanceof Error ? err.message : t("authSetup.savePreferenceFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -108,17 +110,17 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
setError("")
|
||||
|
||||
if (!username || !password) {
|
||||
setError("Please fill in all fields")
|
||||
setError(t("authSetup.fillFields"))
|
||||
return
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match")
|
||||
setError(t("authSetup.passwordMismatch"))
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError("Password must be at least 6 characters")
|
||||
setError(t("authSetup.passwordTooShort"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -137,7 +139,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Failed to setup authentication")
|
||||
throw new Error(data.error || t("authSetup.setupFailed"))
|
||||
}
|
||||
|
||||
if (data.token) {
|
||||
@@ -204,7 +206,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
onComplete()
|
||||
} catch (err) {
|
||||
console.error("Auth setup error:", err)
|
||||
setError(err instanceof Error ? err.message : "Failed to setup authentication")
|
||||
setError(err instanceof Error ? err.message : t("authSetup.setupFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -214,7 +216,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<DialogTitle className="sr-only">
|
||||
{step === "choice" ? "Setup Dashboard Protection" : "Create Password"}
|
||||
{step === "choice" ? t("authSetup.choiceTitle") : t("authSetup.passwordTitle")}
|
||||
</DialogTitle>
|
||||
{step === "choice" ? (
|
||||
<div className="space-y-6 py-2">
|
||||
@@ -222,16 +224,16 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
|
||||
<Shield className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold">Protect Your Dashboard?</h2>
|
||||
<h2 className="text-2xl font-bold">{t("authSetup.protectTitle")}</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Add an extra layer of security to protect your Proxmox data when accessing from non-private networks.
|
||||
{t("authSetup.protectDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button onClick={() => setStep("setup")} className="w-full bg-blue-500 hover:bg-blue-600" size="lg">
|
||||
<Lock className="h-4 w-4 mr-2" />
|
||||
Yes, Setup Password
|
||||
{t("authSetup.setupPassword")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSkipAuth}
|
||||
@@ -240,11 +242,11 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
>
|
||||
No, Continue Without Protection
|
||||
{t("authSetup.skipProtection")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-center text-muted-foreground">You can always enable this later in Settings</p>
|
||||
<p className="text-xs text-center text-muted-foreground">{t("authSetup.enableLater")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6 py-2">
|
||||
@@ -252,8 +254,8 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
|
||||
<Lock className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold">Setup Authentication</h2>
|
||||
<p className="text-muted-foreground text-sm">Create a username and password to protect your dashboard</p>
|
||||
<h2 className="text-2xl font-bold">{t("authSetup.setupTitle")}</h2>
|
||||
<p className="text-muted-foreground text-sm">{t("authSetup.setupDescription")}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -266,14 +268,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" className="text-sm">
|
||||
Username
|
||||
{t("authSetup.username")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="Enter username"
|
||||
placeholder={t("authSetup.usernamePlaceholder")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
@@ -285,14 +287,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-sm">
|
||||
Password
|
||||
{t("authSetup.password")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Enter password"
|
||||
placeholder={t("authSetup.passwordPlaceholder")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
@@ -312,14 +314,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password" className="text-sm">
|
||||
Confirm Password
|
||||
{t("authSetup.confirmPassword")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="Confirm password"
|
||||
placeholder={t("authSetup.confirmPasswordPlaceholder")}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
@@ -345,19 +347,19 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
setup endpoint returns the JWT. */}
|
||||
<div className="pt-3 border-t border-border/60 space-y-4">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||
Profile · optional
|
||||
{t("authSetup.profileOptional")}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="display-name" className="text-sm">
|
||||
Display name
|
||||
{t("authSetup.displayName")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="display-name"
|
||||
type="text"
|
||||
placeholder="Shown above the username in the menu"
|
||||
placeholder={t("authSetup.displayNamePlaceholder")}
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
maxLength={64}
|
||||
@@ -366,12 +368,12 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Leave empty to render the username itself. Up to 64 characters.
|
||||
{t("authSetup.displayNameHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Avatar</Label>
|
||||
<Label className="text-sm">{t("authSetup.avatar")}</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
{avatarPreviewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@@ -407,7 +409,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Upload className="h-3 w-3 mr-1.5" />
|
||||
{avatarFile ? "Change" : "Choose image"}
|
||||
{avatarFile ? t("authSetup.change") : t("authSetup.chooseImage")}
|
||||
</Button>
|
||||
{avatarFile && (
|
||||
<Button
|
||||
@@ -419,12 +421,12 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
className="h-7 text-xs text-red-500 hover:text-red-500 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-1.5" />
|
||||
Clear
|
||||
{t("authSetup.clear")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
PNG, JPEG, WebP or GIF · up to 2 MB · pre-crop square for best results.
|
||||
{t("authSetup.avatarHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -434,10 +436,10 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button onClick={handleSetupAuth} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
||||
{loading ? "Setting up..." : "Setup Authentication"}
|
||||
{loading ? t("authSetup.settingUp") : t("authSetup.setupAuthentication")}
|
||||
</Button>
|
||||
<Button onClick={() => setStep("choice")} variant="ghost" className="w-full" disabled={loading}>
|
||||
Back
|
||||
{t("authSetup.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu"
|
||||
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface AuthStatus {
|
||||
auth_enabled?: boolean
|
||||
@@ -57,6 +58,8 @@ interface AvatarMenuProps {
|
||||
* proper /api/auth/logout that revokes the JWT server-side too.
|
||||
*/
|
||||
export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: AvatarMenuProps) {
|
||||
const t = useT()
|
||||
|
||||
// IMPORTANT — all hooks must run unconditionally on every render. The
|
||||
// previous version short-circuited with `if (!auth_enabled) return null`
|
||||
// BEFORE the avatar blob hooks, so the hook count changed between
|
||||
@@ -201,7 +204,7 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="rounded-full hover:ring-2 hover:ring-cyan-500/30 transition-all relative z-50 focus:outline-none focus-visible:outline-none active:outline-none data-[state=open]:outline-none data-[state=open]:ring-0 select-none"
|
||||
aria-label="Open user menu"
|
||||
aria-label={t("actions.openUserMenu")}
|
||||
// WebKit ignores `outline` for the tap-highlight overlay
|
||||
// shown on iOS / Android Chrome after a touch. That overlay
|
||||
// was the white border that lingered on the avatar after
|
||||
@@ -248,7 +251,7 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
|
||||
<div className="text-xs text-muted-foreground truncate">{username}</div>
|
||||
)}
|
||||
{!profile?.display_name && (
|
||||
<div className="text-xs text-muted-foreground truncate">Signed in</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{t("account.signedIn")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -257,13 +260,13 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
|
||||
{onOpenProfile && (
|
||||
<DropdownMenuItem onClick={onOpenProfile}>
|
||||
<User className="h-4 w-4 mr-2" />
|
||||
View profile
|
||||
{t("account.viewProfile")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onOpenSecurity && (
|
||||
<DropdownMenuItem onClick={onOpenSecurity}>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
Security
|
||||
{t("account.security")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(onOpenProfile || onOpenSecurity) && <DropdownMenuSeparator />}
|
||||
@@ -272,7 +275,7 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
|
||||
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Sign out
|
||||
{t("account.signOut")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+326
-263
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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 < {val}{unit}</span>
|
||||
<span className="text-right">{severity === "critical" ? "CRIT" : "WARN"} > {val}{unit}</span>
|
||||
<span>{t("settings.healthThresholds.ok")} < {val}{unit}</span>
|
||||
<span className="text-right">{severity === "critical" ? t("settings.healthThresholds.crit") : t("settings.healthThresholds.warn")} > {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 < {wVal}{unit}</span>
|
||||
<span className="text-center">WARN {wVal}–{cVal}{unit}</span>
|
||||
<span className="text-right">CRIT > {cVal}{unit}</span>
|
||||
<span>{t("settings.healthThresholds.ok")} < {wVal}{unit}</span>
|
||||
<span className="text-center">{t("settings.healthThresholds.warn")} {wVal}–{cVal}{unit}</span>
|
||||
<span className="text-right">{t("settings.healthThresholds.crit")} > {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>
|
||||
|
||||
+820
-757
File diff suppressed because it is too large
Load Diff
@@ -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 (< 50ms):</strong> Optimal for real-time applications, gaming, and video calls.</p>
|
||||
<p><strong>${t("network.latency.status.excellent")} (< 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 (> 200ms):</strong> Significant latency. Investigation recommended.</p>
|
||||
<p><strong>${t("network.latency.status.poor")} (> 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>
|
||||
)}
|
||||
|
||||
@@ -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.1-beta</p>
|
||||
<p className="text-center text-sm text-muted-foreground">{t("login.version")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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>• {t("status.checkService")}</p>
|
||||
<p>• {t("status.serverPort")}</p>
|
||||
<p>
|
||||
• Try accessing:{" "}
|
||||
• {t("status.tryAccessing")}{" "}
|
||||
<a href={getApiUrl("/api/health")} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
{getApiUrl("/api/health")}
|
||||
</a>
|
||||
@@ -433,11 +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 & 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 & LXCs</span>
|
||||
<span>{t("navigation.virtualMachines")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("storage")} className={itemClass(activeTab === "storage")}>
|
||||
<HardDrive className="h-5 w-5" />
|
||||
<span>Storage</span>
|
||||
<span>{t("navigation.storage")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("network")} className={itemClass(activeTab === "network")}>
|
||||
<NetworkIcon className="h-5 w-5" />
|
||||
<span>Network</span>
|
||||
<span>{t("navigation.network")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("hardware")} className={itemClass(activeTab === "hardware")}>
|
||||
<Cpu className="h-5 w-5" />
|
||||
<span>Hardware</span>
|
||||
<span>{t("navigation.hardware")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("backup")} className={itemClass(activeTab === "backup")}>
|
||||
<DatabaseBackup className="h-5 w-5" />
|
||||
<span>Backup</span>
|
||||
<span>{t("navigation.backup")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("terminal")} className={itemClass(activeTab === "terminal")}>
|
||||
<Terminal className="h-5 w-5" />
|
||||
<span>Terminal</span>
|
||||
<span>{t("navigation.terminal")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("logs")} className={itemClass(activeTab === "logs")}>
|
||||
<ScrollText className="h-5 w-5" />
|
||||
<span>System Logs</span>
|
||||
<span>{t("navigation.systemLogs")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("security")} className={itemClass(activeTab === "security")}>
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
<span>Security</span>
|
||||
<span>{t("navigation.security")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("settings")} className={itemClass(activeTab === "settings")}>
|
||||
<SettingsIcon className="h-5 w-5" />
|
||||
<span>Settings</span>
|
||||
<span>{t("navigation.settings")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("about")} className={itemClass(activeTab === "about")}>
|
||||
<Info className="h-5 w-5" />
|
||||
<span>About</span>
|
||||
<span>{t("navigation.about")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -844,7 +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>
|
||||
|
||||
@@ -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't show again
|
||||
{t("pwaInstall.neverAgain")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ 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.1-beta" // Sync with AppImage/package.json
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 > Settings > 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>
|
||||
|
||||
+663
-556
File diff suppressed because it is too large
Load Diff
+180
-107
@@ -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 "sync hookscripts" 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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 & 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)"
|
||||
|
||||
@@ -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,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>) => (
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client"
|
||||
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"
|
||||
import enMessages from "../../messages/en/common.json"
|
||||
import skMessages from "../../messages/sk/common.json"
|
||||
import esMessages from "../../messages/es/common.json"
|
||||
import frMessages from "../../messages/fr/common.json"
|
||||
import deMessages from "../../messages/de/common.json"
|
||||
import itMessages from "../../messages/it/common.json"
|
||||
import ptMessages from "../../messages/pt/common.json"
|
||||
import {
|
||||
DEFAULT_LANGUAGE,
|
||||
LANGUAGE_STORAGE_KEY,
|
||||
type LanguageCode,
|
||||
SUPPORTED_LANGUAGES,
|
||||
detectBrowserLanguage,
|
||||
isSupportedLanguage,
|
||||
} from "./languages"
|
||||
|
||||
type MessageTree = Record<string, unknown>
|
||||
type TranslationParams = Record<string, string | number>
|
||||
|
||||
const MESSAGE_CATALOG: Record<LanguageCode, MessageTree> = {
|
||||
en: enMessages as MessageTree,
|
||||
sk: skMessages as MessageTree,
|
||||
es: esMessages as MessageTree,
|
||||
fr: frMessages as MessageTree,
|
||||
de: deMessages as MessageTree,
|
||||
it: itMessages as MessageTree,
|
||||
pt: ptMessages as MessageTree,
|
||||
}
|
||||
|
||||
interface I18nContextValue {
|
||||
language: LanguageCode
|
||||
setLanguage: (language: LanguageCode) => void
|
||||
t: (key: string, params?: TranslationParams) => string
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null)
|
||||
|
||||
function getInitialLanguage(): LanguageCode {
|
||||
if (typeof window === "undefined") return DEFAULT_LANGUAGE
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(LANGUAGE_STORAGE_KEY)
|
||||
if (isSupportedLanguage(stored)) return stored
|
||||
} catch {
|
||||
// localStorage may be unavailable in private browsing.
|
||||
}
|
||||
|
||||
return detectBrowserLanguage()
|
||||
}
|
||||
|
||||
function getMessage(messages: MessageTree, key: string): string | undefined {
|
||||
const value = key.split(".").reduce<unknown>((cursor, segment) => {
|
||||
if (!cursor || typeof cursor !== "object") return undefined
|
||||
return (cursor as Record<string, unknown>)[segment]
|
||||
}, messages)
|
||||
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
function interpolate(template: string, params?: TranslationParams): string {
|
||||
if (!params) return template
|
||||
|
||||
return template.replace(/\{(\w+)\}/g, (match, name) => {
|
||||
const value = params[name]
|
||||
return value === undefined ? match : String(value)
|
||||
})
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [language, setLanguageState] = useState<LanguageCode>(DEFAULT_LANGUAGE)
|
||||
const [isHydrated, setIsHydrated] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setLanguageState(getInitialLanguage())
|
||||
setIsHydrated(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHydrated) return
|
||||
|
||||
document.documentElement.lang = language
|
||||
try {
|
||||
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language)
|
||||
} catch {
|
||||
// Best-effort; the in-memory language still works for this session.
|
||||
}
|
||||
}, [isHydrated, language])
|
||||
|
||||
useEffect(() => {
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === LANGUAGE_STORAGE_KEY && isSupportedLanguage(event.newValue)) {
|
||||
setLanguageState(event.newValue)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("storage", onStorage)
|
||||
return () => window.removeEventListener("storage", onStorage)
|
||||
}, [])
|
||||
|
||||
const setLanguage = useCallback((nextLanguage: LanguageCode) => {
|
||||
setLanguageState(nextLanguage)
|
||||
}, [])
|
||||
|
||||
const t = useCallback(
|
||||
(key: string, params?: TranslationParams) => {
|
||||
const localized = getMessage(MESSAGE_CATALOG[language], key)
|
||||
const fallback = getMessage(MESSAGE_CATALOG.en, key)
|
||||
return interpolate(localized ?? fallback ?? key, params)
|
||||
},
|
||||
[language],
|
||||
)
|
||||
|
||||
const value = useMemo<I18nContextValue>(() => ({ language, setLanguage, t }), [language, setLanguage, t])
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
const context = useContext(I18nContext)
|
||||
if (!context) {
|
||||
throw new Error("useI18n must be used within I18nProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function useT() {
|
||||
return useI18n().t
|
||||
}
|
||||
|
||||
export { SUPPORTED_LANGUAGES }
|
||||
@@ -0,0 +1,12 @@
|
||||
# Monitor dashboard translations
|
||||
|
||||
The ProxMenux Monitor dashboard uses a small client-side i18n layer.
|
||||
|
||||
- English (`en`) is the source language and the fallback.
|
||||
- Slovak (`sk`) is 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.
|
||||
@@ -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
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values."
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values."
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values."
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values."
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2789,7 +2789,7 @@ class NotificationManager:
|
||||
# injection lands in the system prompt verbatim. Audit Tier 3.2 #4.
|
||||
_ALLOWED_DETAIL_LEVELS = ('brief', 'standard', 'detailed')
|
||||
_ALLOWED_AI_LANGUAGES = (
|
||||
'en', 'es', 'fr', 'de', 'it', 'pt', 'ru',
|
||||
'en', 'sk', 'es', 'fr', 'de', 'it', 'pt', 'ru',
|
||||
'ja', 'zh', 'ko', 'pl', 'nl', 'tr', 'ar',
|
||||
)
|
||||
if short_key.endswith('.ai_detail_level') or short_key == 'ai_detail_level':
|
||||
|
||||
@@ -1976,6 +1976,7 @@ def enrich_with_emojis(event_type: str, title: str, body: str,
|
||||
# Supported languages for AI translation
|
||||
AI_LANGUAGES = {
|
||||
'en': 'English',
|
||||
'sk': 'Slovak',
|
||||
'es': 'Spanish',
|
||||
'fr': 'French',
|
||||
'de': 'German',
|
||||
|
||||
@@ -1460,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.
|
||||
@@ -1573,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
|
||||
@@ -1594,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
|
||||
@@ -1720,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()
|
||||
@@ -1799,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:
|
||||
@@ -1962,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)
|
||||
@@ -2210,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)
|
||||
@@ -2226,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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user